# ─── Fluksio app Makefile ───
# Convenience targets for development, testing, linting, and deployment.

.PHONY: init env-check dev-utils dev dev-local dev-lan rebuild-frontend up down update install dev-backend dev-frontend \
        generate-client sync-example test test-backend test-checks test-stack test-frontend test-webkit soak \
        bench-startup bench-engine lint lint-backend lint-frontend format-frontend umami build docs docs-serve clean help

COMPOSE_ROOT := $(CURDIR)
# An explicit project name avoids collisions with other Compose stacks
# whose configuration also lives in a directory named "docker".
COMPOSE_PROJECT := fluksio-app
# Compose interpolation needs the project-local .env before reading compose.yml.
COMPOSE := docker compose -p $(COMPOSE_PROJECT) --env-file $(COMPOSE_ROOT)/.env
COMPOSE_PROD := $(COMPOSE) -f docker/compose.yml
# Production also runs autoheal, which restarts the backend when its deep
# health check fails. It is profile-gated because it mounts the Docker socket.
COMPOSE_PROD_RUN := $(COMPOSE_PROD) --profile autoheal
# Host port `make dev-lan` publishes the app on.
APP_PORT ?= 8080
COMPOSE_DEV := $(COMPOSE_PROD) -f docker/compose.dev.yml
# Integrated local stack: dev stack wired onto the shared `proxy` network.
COMPOSE_LOCAL := $(COMPOSE_DEV) -f docker/compose.local.yml
# Same stack, with the app also published on APP_PORT, for clients that cannot
# resolve app.$(DOMAIN).
COMPOSE_LAN := $(COMPOSE_LOCAL) -f docker/compose.lan.yml
# The frontend suite's own stack, a project of its own behind the local Traefik
# (compose.test.yml). The hostname is fixed, never read from a file or the
# command line, so the suite can only ever reach this stack.
TEST_DOMAIN := test.localhost
COMPOSE_TEST := DOMAIN=$(TEST_DOMAIN) STACK_NAME=fluksio-test TAG=test \
  docker compose -p fluksio-test --env-file $(COMPOSE_ROOT)/.env -f docker/compose.yml \
  -f docker/compose.dev.yml -f docker/compose.local.yml -f docker/compose.test.yml

help:  ## Show available targets
	@awk 'BEGIN{FS=":.*?## "} /^[a-zA-Z_-]+:.*?##/ {printf "  \033[36m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)

# ── Development (Docker) ──────────────────────────────────────────

# Hostname the local stack is served under. Never .env's DOMAIN: a checkout
# configured for a deployment carries that deployment's domain, and a local
# stack started under it answers to the same names the live instance does.
# Target-specific on purpose — an exported DOMAIN outranks --env-file in
# compose interpolation, which would put the *production* targets on localhost.
# `make dev DOMAIN=fluksio.com` still wins.
dev dev-utils dev-local dev-lan rebuild-frontend: export DOMAIN ?= localhost

# A key the template lists and the compose files default, missing from .env,
# falls back without a word. For TZ that means every cron runs on UTC. Only a
# warning, so a start never fails over it. `update` runs it after its pull.
dev dev-local dev-lan up: env-check
env-check:
	@missing=$$(grep -ohE '\$$\{[A-Z_]+:?-' docker/compose*.yml | sed 's/^..//; s/:\{0,1\}-$$//' | sort -u \
	  | while read -r k; do \
	      grep -q "^$$k=" .env.example && ! grep -q "^$$k=" .env && printf ' %s' "$$k"; \
	    done); \
	[ -z "$$missing" ] || echo "  ! .env lacks$$missing, so compose uses its defaults. See .env.example."

# First-time bootstrap. Idempotent: only secrets still set to `changethis` are
# generated, so an existing .env keeps its values.
init:  ## First-time setup (.env + secrets + docker network)
	@[ -f .env ] || { cp .env.example .env && echo "  + .env created from .env.example"; }
	@for k in SECRET_KEY POSTGRES_PASSWORD FIRST_SUPERUSER_PASSWORD; do \
	  if grep -q "^$$k=changethis$$" .env; then \
	    sed -i "s/^$$k=changethis$$/$$k=$$(openssl rand -hex 16)/" .env && echo "  + $$k generated"; \
	  fi; \
	done
	@docker network inspect proxy >/dev/null 2>&1 || docker network create proxy >/dev/null
	@echo ""
	@echo "  Admin login: $$(sed -n 's/^FIRST_SUPERUSER=//p' .env) / $$(sed -n 's/^FIRST_SUPERUSER_PASSWORD=//p' .env)"
	@echo "  Next: 'make dev-local', then open http://app.localhost"

dev-utils:  ## Start only the utility containers (proxy, mailcatcher)
	$(COMPOSE_DEV) up --build proxy mailcatcher

dev:  ## Start the full dev stack (includes a local Traefik proxy)
	$(COMPOSE_DEV) up --build

dev-local:  ## Start the local stack at http://app.localhost
	DOMAIN=$${DOMAIN:-localhost} ENVIRONMENT=$${ENVIRONMENT:-local} \
	  $(COMPOSE_LOCAL) up --build -d --remove-orphans proxy backend docs mailcatcher

# The host's LAN address is the source of its default route. Without `ip`
# (macOS) it stays empty and the instance keeps calling itself app.$(DOMAIN).
dev-lan:  ## Same, plus the app on http://<host-ip>:$(APP_PORT) (no DNS needed)
	@ip=$$(ip route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p'); \
	DOMAIN=$${DOMAIN:-localhost} ENVIRONMENT=$${ENVIRONMENT:-local} APP_PORT=$(APP_PORT) \
	  LAN_URL=$${ip:+http://$$ip:$(APP_PORT)} \
	  $(COMPOSE_LAN) up --build -d --remove-orphans proxy backend mailcatcher

# The dashboard is built into the app image, so a UI change needs a rebuild;
# only the image's frontend stage runs again. The domain and the overlay set
# come off the *running* stack rather than from a file, so the rebuilt
# container keeps the hostname and, on a LAN stack, the published port it was
# started with. A published host port is how the lan overlay is recognised,
# because it is the thing only that overlay adds. Its address is kept too,
# since it is the issuer worker and agent tokens were signed for.
rebuild-frontend:  ## Rebuild the app image and restart it (after a UI change)
	@domain=$$(docker inspect fluksio-api 2>/dev/null \
	  | grep -o 'Host(`app\.[^`]*`)' | head -1 | sed 's/Host(`app\.//;s/`)//'); \
	port=$$(docker inspect fluksio-api \
	  --format '{{range $$p, $$c := .NetworkSettings.Ports}}{{range $$c}}{{.HostPort}} {{end}}{{end}}' \
	  2>/dev/null | awk '{print $$1}'); \
	lan=$$(docker inspect fluksio-api --format '{{range .Config.Env}}{{println .}}{{end}}' \
	  2>/dev/null | sed -n 's/^FRONTEND_HOST=//p'); \
	if [ -n "$$port" ]; then \
	  echo "  lan stack detected (published on $$port) — keeping compose.lan.yml"; \
	  compose="$(COMPOSE_LAN)"; \
	else \
	  compose="$(COMPOSE_LOCAL)"; \
	fi; \
	DOMAIN=$${domain:-$$DOMAIN} ENVIRONMENT=$${ENVIRONMENT:-local} \
	  APP_PORT=$${port:-$(APP_PORT)} LAN_URL=$$lan \
	  $$compose up --build -d backend

up:  ## Start the production stack
	$(COMPOSE_PROD_RUN) up --build -d

umami:  ## Provision + start the optional Umami site-analytics service (idempotent)
	# Guard: the dashboard is internet-facing on analytics.$(DOMAIN); refuse to
	# start when its session-signing secret is unset or still the placeholder.
	@secret=$$(grep -E '^UMAMI_APP_SECRET=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); \
	if [ -z "$$secret" ] || [ "$$secret" = "changethis" ]; then \
		echo "ERROR: UMAMI_APP_SECRET is empty or 'changethis' in .env — set it in .env with 'openssl rand -hex 32' before 'make umami'." >&2; \
		exit 1; \
	fi
	# --no-recreate: reuse an already-running db instead of recreating it under
	# the prod compose set, which would blip every service that depends on it.
	$(COMPOSE_PROD) up -d --wait --no-recreate db
	# Least-privilege role owning only the umami database, so the third-party
	# image never holds the shared superuser credentials.
	@pguser=$$(grep -E '^POSTGRES_USER=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); pguser=$${pguser:-postgres}; \
	role=$$(grep -E '^UMAMI_DB_USER=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); role=$${role:-umami}; \
	pw=$$(grep -E '^UMAMI_DB_PASSWORD=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); \
	db=$$(grep -E '^UMAMI_DB=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); db=$${db:-umami}; \
	if [ -z "$$pw" ] || [ "$$pw" = "changethis" ]; then \
		echo "ERROR: UMAMI_DB_PASSWORD is empty or 'changethis' in .env — set it in .env with 'openssl rand -hex 32' before 'make umami'." >&2; \
		exit 1; \
	fi; \
	$(COMPOSE_PROD) exec -T db psql -v ON_ERROR_STOP=1 -U "$$pguser" -d postgres \
		-v role="$$role" -v pw="$$pw" -v db="$$db" < docker/provision-umami-db.sql
	# --no-deps: only (re)create umami, never restart the shared db underneath it.
	$(COMPOSE_PROD) --profile analytics up -d --no-deps umami

update:  ## Pull, rebuild using the layer cache, and recreate changed containers
	git pull
	@$(MAKE) --no-print-directory env-check
	$(COMPOSE_PROD_RUN) build
	$(COMPOSE_PROD_RUN) up -d --remove-orphans
	docker image prune -f

down:  ## Stop all running containers
	-$(COMPOSE_LOCAL) down
	-$(COMPOSE_TEST) down
	-$(COMPOSE_PROD_RUN) down

# ── Development (local, no Docker) ───────────────────────────────
# Run `make dev-backend` and `make dev-frontend` in two separate terminals.

install:  ## Install all dependencies (backend + frontend)
	# Every extra: the suite exercises the connectors that moved into
	# `fluksio[server]`, and strict mypy checks their call sites.
	cd backend && uv sync --all-extras
	cd frontend && bun install

dev-backend:  ## Start the FastAPI backend with hot-reload (local)
	cd backend && uv run --all-extras uvicorn fluksio.main:app --reload --ws-per-message-deflate false

dev-frontend:  ## Start the Vite dev server (local)
	cd frontend && bun dev

generate-client:  ## Regenerate the frontend SDK from the backend's OpenAPI schema
	bash scripts/generate-client.sh

sync-example:  ## Upload `examples/myresearch` the way a data scientist would
	# The same command the docs give, against whichever engine `fluksio login`
	# last talked to. The nodes import the package from this checkout, so the
	# engine has to be one that can see this path.
	cd backend && uv run fluksio sync ../examples/myresearch

# ── Testing ───────────────────────────────────────────────────────

test: test-backend test-checks test-frontend  ## Run all tests (backend + checks + frontend)

# The frontend has no unit-test runner, so a pure helper is checked by a
# `*.check.ts` script of plain `node:assert` that runs itself and needs no
# stack. This is the target that runs every one of them.
test-checks:  ## Run the frontend's *.check.ts scripts
	cd frontend && bun run check

test-backend:  ## Run backend tests (pytest + coverage)
	# Its own SQLite file in a temp directory (tests/__init__.py), so this needs
	# nothing running and touches no development data.
	cd backend && uv run --all-extras bash scripts/test.sh

PW_VERSION = $(shell sed -n 's/.*"@playwright\/test": "[^0-9]*\([0-9.]*\)".*/\1/p' frontend/package.json | head -1)

# The suite creates and deletes flows, dashboards and users, so it runs against
# a stack of its own, never the dev instance. `up` only recreates what changed,
# and the stack is left running for the next run (and for a look at a failure
# on http://app.test.localhost). `make down` stops it. Its volumes are
# expendable: `docker compose -p fluksio-test down -v` starts it over.
test-stack:  ## Start or update the frontend suite's own stack at http://app.test.localhost
	@docker ps -q -f name='^fluksio-app-proxy-1$$' | grep -q . \
	  || { echo "  ✗ the local Traefik is not running, 'make dev' first"; exit 1; }
	$(COMPOSE_TEST) up --build -d --wait --remove-orphans redis backend

# The Playwright container, minus its network. tests/guard.ts refuses a public
# address on top of the fixed hostname.
PW_RUN = docker run --rm --ipc=host --user $$(id -u):$$(id -g) -e HOME=/tmp \
  -v $(COMPOSE_ROOT):/app -w /app/frontend \
  -e PLAYWRIGHT_BASE_URL=http://app.$(TEST_DOMAIN) \
  -e CI=$${CI:-1} \
  -e FIRST_SUPERUSER="$$(sed -n 's/^FIRST_SUPERUSER=//p' $(COMPOSE_ROOT)/.env | head -1)" \
  -e FIRST_SUPERUSER_PASSWORD="$$(sed -n 's/^FIRST_SUPERUSER_PASSWORD=//p' $(COMPOSE_ROOT)/.env | head -1)"
PW_IMAGE = mcr.microsoft.com/playwright:v$(PW_VERSION)-noble

# The names are mapped onto the local Traefik by address, so DNS never decides.
# Twice, because Chromium pins *.localhost to loopback whatever /etc/hosts says.
test-frontend: test-stack  ## Run frontend tests (Playwright e2e) against the test stack
	@ip=$$(docker network inspect proxy \
	  --format '{{range .Containers}}{{if eq .Name "fluksio-app-proxy-1"}}{{.IPv4Address}}{{end}}{{end}}' \
	  2>/dev/null | cut -d/ -f1); \
	[ -n "$$ip" ] || { echo "  ✗ the local Traefik is not on the proxy network"; exit 1; }; \
	$(PW_RUN) --network proxy \
	  --add-host app.$(TEST_DOMAIN):$$ip \
	  -e HOST_RESOLVER_RULES="MAP app.$(TEST_DOMAIN) $$ip" \
	  $(PW_IMAGE) npx playwright test $(PLAYWRIGHT_ARGS)

# WebKit takes *.localhost to loopback through GLib and ignores /etc/hosts, so
# this run shares Traefik's network namespace, where loopback *is* Traefik.
# Only the drag specs, which is where the engines differ.
test-webkit: test-stack  ## Run the drag specs in WebKit against the test stack
	@$(PW_RUN) --network container:fluksio-app-proxy-1 -e PLAYWRIGHT_WEBKIT=1 \
	  $(PW_IMAGE) npx playwright test --project=webkit $(PLAYWRIGHT_ARGS)

# Load and chaos against a *running* stack, never part of `make test`: it
# restarts this stack's containers. `SOAK_ARGS="--dry-run"` only looks.
soak:  ## Run the soak/chaos harness (SOAK_ARGS="--minutes 30 --scenario redis --force")
	cd backend && uv run python scripts/soak.py $(SOAK_ARGS)

bench-startup:  ## Time submitting a run (BENCH_ARGS="--kedro ../some/kedro/project")
	cd backend && uv run python scripts/bench_startup.py $(BENCH_ARGS)

# In-process, so it needs no stack: what a message costs the engine in work
# and in time. `BENCH_ARGS="--redis <host>"` measures it against a real Redis,
# which is where the round trips are.
bench-engine:  ## Engine throughput and per-message cost (BENCH_ARGS="--redis localhost")
	cd backend && uv run python scripts/bench_engine.py $(BENCH_ARGS)

# ── Linting ───────────────────────────────────────────────────────

build:  ## Build the fluksio and fluksio-worker wheels into dist/
	uv build --all-packages --out-dir dist

lint: lint-backend lint-frontend  ## Run all linters

lint-backend:  ## Lint backend with ruff + mypy
	cd backend && uv run --all-extras ruff check .
	cd backend && uv run --all-extras ruff format --check .
	cd backend && uv run --all-extras mypy fluksio
	cd worker && uv run --no-project --with mypy mypy fluksio_worker

lint-frontend:  ## Lint frontend with biome
	cd frontend && bun run lint

format-frontend:  ## Apply biome's fixes to the frontend (what `lint-frontend` only reports)
	cd frontend && bun run format

# ── Documentation ─────────────────────────────────────────────────
# The public site on docs.${DOMAIN}, served by the `docs` service in
# docker/compose.yml. These targets are for local authoring: zensical runs on
# demand via uvx, so it never touches the backend environment. Pinned to the
# version docker/Dockerfile.docs and .gitea/workflows/docs.yml ship, so local
# authoring builds with what docs.fluksio.com actually gets.
ZENSICAL := zensical==0.0.46

docs:  ## Build the documentation site into ./site
	uvx $(ZENSICAL) build --clean

docs-serve:  ## Serve the documentation site locally with live reload
	uvx $(ZENSICAL) serve

# ── Cleanup ───────────────────────────────────────────────────────

clean:  ## Remove build artifacts and caches
	rm -rf frontend/dist frontend/blob-report frontend/test-results
	rm -rf backend/.pytest_cache backend/htmlcov site \
	  || echo "  ! not all removed (htmlcov/ root-owned? sudo rm -rf backend/htmlcov)" >&2
	find backend -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
