# -----------------------------
# CONFIG
# -----------------------------
-include .env

APP_NAME      ?= pgcommon
BUILD_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)

# uv manages everything: .venv from uv.lock (the dev group carries ruff, mypy, pytest,
# pip-audit, griffe, import-linter), so every tool runs as `uv run <tool>` — no global installs.
UV ?= uv

# Everything ruff checks in CI (validate-quality.yml): src, tests and the Python CI scripts
# under .github/scripts.
LINT_PATHS := .

# Test suites (pytest markers `integration` / `e2e` are applied by each suite's conftest).
# integration and e2e start their own throwaway Postgres / PgBouncer containers with
# testcontainers, so they need Docker but not the docker-compose stack below.
TEST_UNIT_DIR := tests/unit
TEST_INT_DIR  := tests/integration
TEST_E2E_DIR  := tests/e2e
TEST_ALL_DIRS := $(TEST_UNIT_DIR) $(TEST_INT_DIR) $(TEST_E2E_DIR)

# Image names: IMAGE_NAME is the local tag of the migrate CLI image (CI sets its own
# ghcr.io name in the environment for its jobs, none of which builds through this Makefile);
# CI_IMAGE is the tag CI's smoke job builds and smoke-tests.sh runs against.
IMAGE_NAME ?= pgcommon-migrate
CI_IMAGE   ?= platform-pgcommon-py-ci-test

# Local docker-compose stack (docker-compose.yml). The host ports are overridable when
# another stack already holds 5432 / 6432 (e.g. the Go platform-pgcommon repo's); they are
# exported so `docker compose` interpolates them into docker-compose.yml.
POSTGRES_HOST_PORT  ?= 5432
PGBOUNCER_HOST_PORT ?= 6432
export POSTGRES_HOST_PORT PGBOUNCER_HOST_PORT

# Release inputs passed on the command line (release.yml / release checklist):
#   make api-compat API_NEW_VERSION=vX.Y.Z   make release-guard TAG=vX.Y.Z
# COVERAGE_THRESHOLD (optional) overrides pyproject.toml's fail_under in coverage-gate.sh; a
# command-line or environment value reaches the script's environment unchanged.
API_NEW_VERSION ?=
TAG             ?=

# -----------------------------
# SETUP
# -----------------------------

# setup: everything a fresh clone needs — the venv from uv.lock, a .env copied from
# .env-example (never overwrites an existing .env), and the git hooks.
.PHONY: setup
setup: install
	@test -f .env || cp .env-example .env
	@$(MAKE) --no-print-directory install-hooks
	@echo "Environment ready (.venv, .env, git hooks)"

# install: uv sync only (creates/updates .venv from uv.lock; dev group included by default).
.PHONY: install
install:
	$(UV) sync

# install-hooks: point git at the versioned .githooks/ directory (core.hooksPath), so the
# pre-commit hook stays in sync with the repo without re-copying. Bypass a single commit
# with `git commit --no-verify`; undo with `git config --unset core.hooksPath`.
.PHONY: install-hooks
install-hooks:
	@chmod +x .githooks/pre-commit
	@git config core.hooksPath .githooks
	@echo "Installed git hooks (core.hooksPath=.githooks)"

.PHONY: help
help:
	@echo "Available commands:"
	@echo "  make setup             - uv sync + copy .env-example to .env if missing + install git hooks"
	@echo "  make install           - uv sync only"
	@echo "  make install-hooks     - use .githooks/ as the git hooks directory (pre-commit: ruff, docs-check, lock-check)"
	@echo "  make pin-base-images   - resolve + pin the Dockerfile base images to their current @sha256 digests"
	@echo "  make docs-serve        - browse the package API docs with pydoc (http://localhost:$(PYDOC_PORT))"
	@echo "  make format            - ruff format + ruff check --fix (rewrites files; alias: fmt)"
	@echo "  make format-check      - ruff format --check (no changes; used in CI; alias: fmt-check)"
	@echo "  make typecheck         - mypy --strict"
	@echo "  make lock-check        - uv.lock up to date with pyproject.toml (uv lock --check; Go's tidy drift check)"
	@echo "  make pip-check         - installed .venv has consistent dependencies (uv pip check; Go's mod-verify)"
	@echo "  make vulncheck         - pip-audit against locked dependencies (alias: vuln-check)"
	@echo "  make api-compat        - griffe vs the last release tag (API_NEW_VERSION=vX.Y.Z: fail unless compatible or allowed to break)"
	@echo "  make release-guard     - pre-tag check, TAG=vX.Y.Z: pyproject version == tag + CHANGELOG section exists"
	@echo "  make lint              - ruff check"
	@echo "  make arch-lint         - enforce the import-linter layer contracts (pyproject.toml [tool.importlinter])"
	@echo "  make metrics-lint      - observability standard gate (metric conformance, generated inventory in sync)"
	@echo "  make metrics-doc       - regenerate docs/observability/metrics-registry.md"
	@echo "  make docs-check        - every docs/architecture/mermaid/*.mmd embedded byte-identically in ARCHITECTURE.md"
	@echo "  make ci-scripts-test   - regression tests for the CI scripts (detect-changes.sh)"
	@echo "  make lint-docker       - hadolint against the Dockerfile, CI's threshold (alias: hadolint)"
	@echo "  make test              - unit + integration + e2e in one pytest run (requires Docker; alias: test-all)"
	@echo "  make test-ci           - the CI test gate: every suite with coverage, no threshold (see cover-check)"
	@echo "  make test-unit         - unit tests only (no Docker required)"
	@echo "  make test-int          - integration tests against Postgres (requires Docker; alias: test-integration)"
	@echo "  make test-e2e          - end-to-end tests with Postgres + PgBouncer (requires Docker)"
	@echo "  make build             - wheel + sdist into dist/ (uv build), twine check, verify the migrate CLI installs"
	@echo "  make build-constraints - re-lock the build backend (pyproject [build-system]) with hashes"
	@echo "  make ci                - lock-check + format-check + lint + typecheck + arch-lint + metrics-lint + docs-check + ci-scripts-test + test-ci + coverage gate + build"
	@echo "  make cover             - test + HTML coverage report in htmlcov/"
	@echo "  make cover-func        - per-file coverage report (missing lines) from the last test run"
	@echo "  make cover-check       - test-ci + coverage gate (>= pyproject.toml fail_under, 97%)"
	@echo "  make clean             - remove venv, caches, build artifacts and coverage output"
	@echo "  make help              - this list"
	@echo ""
	@echo "Migrate CLI image (Dockerfile, requires Docker):"
	@echo "  make docker-build      - build the migrate CLI image as $(IMAGE_NAME):local"
	@echo "  make scan              - build + Trivy CVE scan (CI's 'Trivy CVE scan' gate; requires trivy)"
	@echo "  make smoke-test        - build the image + run CI's smoke-tests.sh (alias: test-smoke-image)"
	@echo ""
	@echo "Local stack (docker-compose.yml: Postgres :$(POSTGRES_HOST_PORT), PgBouncer :$(PGBOUNCER_HOST_PORT); requires Docker):"
	@echo "  make docker-up         - start Postgres and wait until it accepts connections"
	@echo "  make docker-up-all     - start Postgres + PgBouncer (transaction pooling)"
	@echo "  make pgbouncer-up      - add PgBouncer to a running stack (starts Postgres if needed)"
	@echo "  make pgbouncer-down    - stop PgBouncer only (Postgres keeps running)"
	@echo "  make docker-down       - stop and remove the stack's containers, volumes included"
	@echo "  make docker-reset      - docker-down + docker-up: a fresh, empty local Postgres"
	@echo "  make docker-logs       - tail the stack's logs"
	@echo "  make migrate-up        - apply MIGRATIONS_DIR ($(MIGRATIONS_DIR)) to the local Postgres (pgcommon-migrate up)"
	@echo "  make migrate-down      - roll back MIGRATE_DOWN_STEPS ($(MIGRATE_DOWN_STEPS)) migration(s)"
	@echo "  make migrate-status    - print the local schema version and dirty flag"
	@echo "  make rls-check         - prove row-level security is enforced (direct + PgBouncer RLS tests)"
	@echo "  make psql              - open psql in the local Postgres container"
	@echo "  make show-config       - print config_from_env() as the pool would see it (.env loaded, password redacted)"
	@echo "  make docker-ps         - list the stack's containers"

# pin-base-images: re-resolve every tagged Dockerfile base image (FROM <image>:<tag>, e.g.
# python:3.14-slim and ghcr.io/astral-sh/uv:<version>) to its current digest and rewrite the
# FROM lines as <image>:<tag>@sha256:<digest> — the format validate-quality's "Dockerfile base
# image digest check" requires. The tags are read from the Dockerfile itself, so bumping a tag
# there and running this pins the new one. Prints what changed (or that nothing did); review
# with `git diff Dockerfile`, rebuild (make smoke-test scan) and commit. Note any
# security-fix package pins in the Dockerfile may become droppable after a refresh.
# Unlike the Go repo it writes no .docker-digests file: the Dockerfile is the only record.
.PHONY: pin-base-images
pin-base-images:
	@echo "Resolving Dockerfile base-image digests (docker buildx imagetools inspect)..."
	@changed=0; \
	refs=$$(sed -nE 's/^FROM ([^ @]+:[^ @]+)(@sha256:[0-9a-f]+)?([[:space:]].*)?$$/\1/p' Dockerfile | sort -u); \
	[ -n "$$refs" ] || { echo "FAIL: no tagged FROM lines found in Dockerfile" >&2; exit 1; }; \
	for ref in $$refs; do \
	  digest=$$(docker buildx imagetools inspect "$$ref" --format '{{.Manifest.Digest}}') || exit 1; \
	  case "$$digest" in sha256:*) ;; *) echo "FAIL: no digest resolved for $$ref" >&2; exit 1 ;; esac; \
	  re=$$(printf '%s' "$$ref" | sed 's/[.]/\\./g'); \
	  stale=$$(grep -E "^FROM $$re(@sha256:[0-9a-f]+)?([[:space:]]|$$)" Dockerfile | grep -Fv "$$ref@$$digest" || true); \
	  if [ -z "$$stale" ]; then echo "  unchanged  $$ref@$$digest"; continue; fi; \
	  old=$$(printf '%s\n' "$$stale" | sed -nE 's/.*@(sha256:[0-9a-f]+).*/\1/p' | sort -u | tr '\n' ' '); \
	  echo "  updated    $$ref: $${old:-unpinned }-> $$digest"; \
	  sed -i.bak -E "s#^FROM $$re(@sha256:[0-9a-f]+)?([[:space:]]|$$)#FROM $$ref@$$digest\\2#" Dockerfile && rm -f Dockerfile.bak; \
	  changed=1; \
	done; \
	if grep -E '^FROM [^ @]+:[^ @]+( AS .+)?$$' Dockerfile; then echo "FAIL: unpinned FROM line(s) above" >&2; exit 1; fi; \
	if [ "$$changed" = 1 ]; then echo "Dockerfile updated — review (git diff Dockerfile), rebuild, and commit"; \
	else echo "Dockerfile already pinned to the current digests — no change"; fi

# docs-serve: browse the API docs locally — the counterpart of the Go repo's `make godoc`
# (pkgsite). Uses the stdlib's pydoc server against the venv (pgcommon is installed editable
# by uv sync), so it needs no extra dependency; the package docstrings are the reference.
# Navigate to the "pgcommon" entry. Ctrl-C to stop.
PYDOC_PORT ?= 8080

.PHONY: docs-serve
docs-serve:
	@echo "Starting pydoc at http://localhost:$(PYDOC_PORT)/pgcommon.html — press Ctrl-C to stop"
	$(UV) run python -m pydoc -p $(PYDOC_PORT) -b

# -----------------------------
# PYTHON BASICS (uv-managed: .venv + uv.lock)
# -----------------------------

# format: rewrite files — ruff's formatter, then ruff's autofixable lint findings.
.PHONY: format fmt
format:
	$(UV) run ruff format $(LINT_PATHS)
	$(UV) run ruff check --fix $(LINT_PATHS)
fmt: format

# format-check: verify formatting without modifying files (validate-quality's "Format check").
.PHONY: format-check fmt-check
format-check:
	$(UV) run ruff format --check $(LINT_PATHS)
fmt-check: format-check

# typecheck: mypy --strict over the pgcommon package ([tool.mypy] in pyproject.toml).
.PHONY: typecheck
typecheck:
	$(UV) run mypy

# lock-check: uv.lock records exactly what pyproject.toml asks for (validate-quality's
# "Lock check"; the Go repo's tidy drift check).
.PHONY: lock-check
lock-check:
	$(UV) lock --check

# pip-check: the installed .venv has no missing or conflicting requirements
# (validate-quality's "Verify dependency resolution"; the closest thing to Go's mod-verify —
# uv already verifies the lock's hashes when it installs).
.PHONY: pip-check
pip-check:
	$(UV) pip check

# vulncheck: pip-audit against the locked dependencies (validate-quality's "Vulnerability
# check"). Trivy also scans the image (make scan).
.PHONY: vulncheck vuln-check
vulncheck:
	$(UV) run pip-audit
vuln-check: vulncheck

# api-compat: public-API diff (griffe) against the previous stable release tag. Without
# API_NEW_VERSION it only warns (PRs); with API_NEW_VERSION=vX.Y.Z it fails on a breaking change
# unless vX.Y.Z may break (a MAJOR bump, or a MINOR bump while 0.y.z) — release.yml. griffe
# loads the tag through a temporary git worktree + `griffe-<tag>` branch it removes again.
.PHONY: api-compat
api-compat:
	bash .github/scripts/api-compat.sh $(API_NEW_VERSION)

# release-guard: run before tagging — the package version equals TAG (release_guard.py) and
# CHANGELOG.md has its section (verify-changelog-entry.sh). release.yml's verify job runs both,
# plus the git checks (tag at HEAD, on main) of verify-release-tag.sh.
.PHONY: release-guard
release-guard:
	@test -n "$(TAG)" || { echo "usage: make release-guard TAG=vX.Y.Z" >&2; exit 1; }
	$(UV) run --no-project --with 'packaging==26.2' python .github/scripts/release_guard.py $(TAG)
	RELEASE_TAG=$(TAG) bash .github/scripts/verify-changelog-entry.sh

# -----------------------------
# LINT
# -----------------------------

# lint: ruff check (validate-quality's "Lint (ruff)").
.PHONY: lint
lint:
	@echo "Running linter..."
	$(UV) run ruff check $(LINT_PATHS)

# arch-lint: the import-linter contracts in pyproject.toml (ARCHITECTURE.md's layer model) —
# the same script CI's validate-test.yml runs; the Go repo's go-arch-lint.
.PHONY: arch-lint
arch-lint:
	bash .github/scripts/arch-lint.sh

# metrics-lint: Enterprise Platform Observability Standard gate — runtime conformance of every
# registered collector against the metrics registry and the generated inventory being up to
# date (validate-quality's "Metrics standard compliance").
METRICS_LINT_TESTS := tests/unit/test_metrics_standard.py tests/unit/test_metrics_inventory.py

.PHONY: metrics-lint
metrics-lint:
	$(UV) run pytest $(METRICS_LINT_TESTS) --no-cov -q

# metrics-doc: regenerate docs/observability/metrics-registry.md from the registry
# (metrics-lint fails when it is stale).
.PHONY: metrics-doc
metrics-doc:
	PGCOMMON_UPDATE_INVENTORY=1 $(UV) run pytest tests/unit/test_metrics_inventory.py --no-cov -q

# docs-check: every docs/architecture/mermaid/*.mmd is embedded byte-identically in
# ARCHITECTURE.md (and no mermaid block lacks a source file). No Docker.
.PHONY: docs-check
docs-check:
	bash .github/scripts/docs-mermaid-sync.sh

# ci-scripts-test: regression tests for the CI shell scripts (no bats; scratch git
# repositories): detect-changes.sh's docs-only decision and log sanitising.
# validate-quality.yml runs the same target. No Docker.
.PHONY: ci-scripts-test
ci-scripts-test:
	bash .github/scripts/detect-changes_test.sh

# lint-docker: hadolint against the Dockerfile with CI's threshold (hadolint/hadolint-action
# v3.5.0, default failure threshold info, first step of ci.yml's build-image job). Uses a local
# hadolint (brew install hadolint) when installed, otherwise HADOLINT_IMAGE — keep it on the
# version hadolint-action v3.5.0 bundles (2.15.1), or local and CI results diverge.
HADOLINT_IMAGE ?= ghcr.io/hadolint/hadolint:v2.15.1-debian

.PHONY: lint-docker hadolint
lint-docker:
	@if command -v hadolint >/dev/null 2>&1; then \
	  echo "hadolint $$(hadolint --version)"; \
	  hadolint --failure-threshold info Dockerfile; \
	else \
	  echo "hadolint not installed — using $(HADOLINT_IMAGE)"; \
	  docker run --rm -i $(HADOLINT_IMAGE) hadolint --failure-threshold info - < Dockerfile; \
	fi
hadolint: lint-docker

# -----------------------------
# TESTS
# -----------------------------
# Python has no race detector, so there is no `race` target or -race flag as in the Go repo;
# concurrency is exercised by tests/integration/test_concurrency_stress.py in every run.
# pyproject.toml's pytest addopts collect coverage and enforce fail_under (97%) on every
# run, so a subset of the suite must pass --no-cov (as metrics-lint and rls-check do).

# test: every suite in one pytest run (verbose; the coverage threshold applies).
.PHONY: test test-all
test:
	$(UV) run pytest $(TEST_ALL_DIRS) -v
test-all: test

# test-ci: the CI test gate (validate-test.yml). One pass over every suite with coverage,
# writing .coverage + coverage.xml; the threshold is checked separately by
# .github/scripts/coverage-gate.sh (_coverage-gate), so a failing test and a coverage drop are
# told apart. --cov-fail-under=0 only defers the threshold; a bare `uv run pytest` still
# enforces it.
.PHONY: test-ci
test-ci:
	$(UV) run pytest $(TEST_ALL_DIRS) --cov-fail-under=0

# test-unit: unit tests only — fakes, no Docker.
.PHONY: test-unit
test-unit:
	$(UV) run pytest $(TEST_UNIT_DIR) -v

# test-int: integration tests against a real Postgres (testcontainers; requires Docker).
.PHONY: test-int test-integration
test-int:
	$(UV) run pytest $(TEST_INT_DIR) -v -m integration
test-integration: test-int

# test-e2e: Postgres + PgBouncer in transaction-pooling mode (testcontainers; requires Docker).
.PHONY: test-e2e
test-e2e:
	$(UV) run pytest $(TEST_E2E_DIR) -v -m e2e

# smoke-test: this image is a migrate CLI, not a running service, so there is no live
# endpoint to smoke-test. Builds it and runs the same image gates CI's smoke job runs
# (.github/scripts/smoke-tests.sh: size, usage, version, exit codes without a DSN /
# MIGRATIONS_DIR / migrations, non-root numeric UID).
.PHONY: smoke-test test-smoke-image
smoke-test:
	docker build -q --build-arg BUILD_VERSION=$(BUILD_VERSION) -t $(CI_IMAGE) . >/dev/null
	IMAGE=$(CI_IMAGE) bash .github/scripts/smoke-tests.sh
test-smoke-image: smoke-test

# -----------------------------
# BUILD
# -----------------------------

# The build backend and its dependencies are installed from build-constraints.txt (exact
# versions + hashes), so a wheel never builds with a backend resolved at build time.
UV_BUILD := $(UV) build --build-constraints build-constraints.txt --require-hashes

# build: wheel + sdist into dist/, twine check --strict, then install the wheel into a
# throwaway venv and run the pgcommon-migrate console script (validate-quality's "Build check").
.PHONY: build
build:
	@echo "Building wheel + sdist (version: $(BUILD_VERSION))..."
	@rm -rf dist
	$(UV_BUILD)
	@ls dist/*.whl dist/*.tar.gz
	$(UV) run --no-project --with 'twine==7.0.0' twine check --strict dist/*
	@echo "Verifying the pgcommon-migrate console script installs and runs..."
	@venv=$$(mktemp -d) && trap 'rm -rf "$$venv"' EXIT && \
		$(UV) venv --quiet --python 3.12 "$$venv/venv" && \
		VIRTUAL_ENV="$$venv/venv" $(UV) pip install --quiet dist/*.whl && \
		"$$venv/venv/bin/pgcommon-migrate" --help >/dev/null
	@echo "OK"

# build-constraints: re-lock build-constraints.txt after changing [build-system].requires in
# pyproject.toml.
.PHONY: build-constraints
build-constraints:
	$(UV) run --no-project python -c "import tomllib; print(*tomllib.load(open('pyproject.toml', 'rb'))['build-system']['requires'], sep='\n')" \
		| $(UV) pip compile - --universal --python-version 3.12 --generate-hashes \
			--custom-compile-command 'make build-constraints' -o build-constraints.txt

# -----------------------------
# DOCKER (MIGRATE CLI IMAGE)
# -----------------------------

# docker-build: the migrate CLI image, tagged $(IMAGE_NAME):local, with CI's build args
# (BUILD_VERSION, SOURCE_DATE_EPOCH = commit time).
.PHONY: docker-build
docker-build:
	@echo "Building migrate CLI image (version: $(BUILD_VERSION))..."
	docker build --build-arg BUILD_VERSION=$(BUILD_VERSION) --build-arg SOURCE_DATE_EPOCH=$$(git log -1 --format=%ct 2>/dev/null || echo 0) -t $(IMAGE_NAME):local .

# scan: requires trivy (brew install trivy). Matches CI's "Trivy CVE scan" gate: fails on
# CRITICAL/HIGH/UNKNOWN vulnerabilities that have an available fix.
.PHONY: scan
scan:
	docker build -t $(IMAGE_NAME):scan .
	trivy image --severity CRITICAL,HIGH,UNKNOWN --ignore-unfixed --exit-code 1 --scanners vuln $(IMAGE_NAME):scan

# -----------------------------
# DOCKER (LOCAL POSTGRES / PGBOUNCER)
# -----------------------------
# For trying the library, the migrate CLI and RLS by hand (README "Testing RLS locally");
# the test suites start their own containers. PgBouncer sits behind the compose profile
# `pgbouncer`, so every target that may touch it passes --profile pgbouncer.
COMPOSE := docker compose --profile pgbouncer

# docker-up: Postgres only, and wait until it accepts connections. pg_isready probes over TCP
# (-h 127.0.0.1): on a fresh volume the image first runs a temporary, socket-only server for
# initdb and then restarts, so a socket probe can report ready before that restart.
.PHONY: docker-up
docker-up:
	@echo "Starting local Postgres on :$(POSTGRES_HOST_PORT)..."
	docker compose up -d postgres
	docker compose exec postgres sh -c 'until pg_isready -h 127.0.0.1 -U test -d test; do sleep 1; done'

# docker-up-all: Postgres + PgBouncer (transaction pooling; PgBouncer waits for Postgres's
# healthcheck).
.PHONY: docker-up-all
docker-up-all:
	@echo "Starting local Postgres on :$(POSTGRES_HOST_PORT) + PgBouncer on :$(PGBOUNCER_HOST_PORT)..."
	$(COMPOSE) up -d

# pgbouncer-up / pgbouncer-down: add PgBouncer to (or take it out of) a running stack
# without touching Postgres.
.PHONY: pgbouncer-up
pgbouncer-up:
	@echo "Starting PgBouncer on :$(PGBOUNCER_HOST_PORT) (transaction pooling mode)..."
	$(COMPOSE) up -d pgbouncer

.PHONY: pgbouncer-down
pgbouncer-down:
	@echo "Stopping PgBouncer..."
	$(COMPOSE) stop pgbouncer

# docker-down: stop and remove the stack, volumes included. The compose file declares no
# named volumes — Postgres keeps its data in an anonymous volume that a later `up` would not
# reuse anyway — so removing it only avoids leaking one dangling volume per cycle. Only this
# repo's compose project (platform-pgcommon-py) is touched.
.PHONY: docker-down
docker-down:
	@echo "Stopping local Postgres and PgBouncer..."
	$(COMPOSE) down -v --remove-orphans

# docker-reset: start over with an empty database (local data is lost) — docker-down, then
# docker-up. Run migrate-up afterwards to re-apply MIGRATIONS_DIR.
.PHONY: docker-reset
docker-reset:
	@$(MAKE) --no-print-directory docker-down
	@$(MAKE) --no-print-directory docker-up

.PHONY: docker-logs
docker-logs:
	$(COMPOSE) logs -f

# -----------------------------
# MIGRATIONS
# -----------------------------
# The library ships no migrations: these targets run the pgcommon-migrate CLI (from the venv)
# with MIGRATIONS_DIR (default: the example set in migrations/) against the local
# docker-compose Postgres. The CLI reads MIGRATION_DATABASE_URL first (then DATABASE_URL /
# PG_*); it is set explicitly so .env's DATABASE_URL — or a PgBouncer DSN — is never used for
# DDL. The CLI prints a "config warning: ... sslmode=disable" line first: expected, the local
# container has no TLS. Point MIGRATION_DATABASE_URL elsewhere to migrate another database.
MIGRATIONS_DIR         ?= migrations
MIGRATION_DATABASE_URL ?= postgres://test:test@localhost:$(POSTGRES_HOST_PORT)/test?sslmode=disable
MIGRATE_DOWN_STEPS     ?= 1
MIGRATE                 = MIGRATIONS_DIR='$(MIGRATIONS_DIR)' MIGRATION_DATABASE_URL='$(MIGRATION_DATABASE_URL)' $(UV) run pgcommon-migrate

.PHONY: migrate-up
migrate-up:
	$(MIGRATE) up

.PHONY: migrate-down
migrate-down:
	$(MIGRATE) down $(MIGRATE_DOWN_STEPS)

# migrate-status: the schema version and dirty flag (exit 3: dirty; 4: version not in
# MIGRATIONS_DIR).
.PHONY: migrate-status
migrate-status:
	$(MIGRATE) status

# -----------------------------
# RLS CHECK
# -----------------------------

# rls-check: prove row-level security is enforced through pgcommon — the dedicated RLS tests,
# both modes: direct (set_config per acquire, tests/integration/test_rls_guc_injection.py) and
# PgBouncer transaction pooling (set_config(..., true) per transaction,
# tests/e2e/test_rls_pgbouncer_e2e.py). Each runs as a NOSUPERUSER role on a FORCE ROW LEVEL
# SECURITY table, so it fails if a tenant can read or write another tenant's rows. They use
# their own throwaway containers (Docker required), not the compose stack: PgBouncer's user
# list is fixed at start-up, and the compose one only knows the superuser `test`, which
# bypasses RLS. README's "Testing RLS locally" walks the same checks by hand on the stack.
RLS_TESTS := tests/integration/test_rls_guc_injection.py tests/e2e/test_rls_pgbouncer_e2e.py

.PHONY: rls-check
rls-check:
	$(UV) run pytest $(RLS_TESTS) --no-cov -v

# -----------------------------
# CI
# -----------------------------

# ci: the same gates CI enforces (validate-quality.yml + validate-test.yml), in one local
# command. The image jobs — lint-docker, scan, smoke-test — have their own targets, and
# api-compat is informational on PRs. The test gate and its coverage check are sequenced in
# the recipe so `make -j ci` can't check a .coverage before test-ci wrote it.
# Run this before pushing.
.PHONY: ci
ci: lock-check format-check lint typecheck arch-lint metrics-lint docs-check ci-scripts-test
	@$(MAKE) --no-print-directory test-ci
	@$(MAKE) --no-print-directory _coverage-gate
	@$(MAKE) --no-print-directory build

# -----------------------------
# COVERAGE
# -----------------------------
# The threshold has one source: pyproject.toml's [tool.coverage.report] fail_under (97).

# cover: every suite (the threshold applies) + an HTML report.
.PHONY: cover
cover: test
	$(UV) run coverage html
	@echo "HTML coverage report: htmlcov/index.html"

# cover-func: per-file report with missing lines from the last run's .coverage (no test run;
# the Go repo's per-function `go tool cover -func`). Never fails on the threshold.
.PHONY: cover-func
cover-func:
	@test -f .coverage || { echo "FAIL: .coverage missing — run 'make test-ci' (or any test target) first" >&2; exit 1; }
	$(UV) run coverage report -m --fail-under=0

# cover-check: run the suites with coverage, then fail below pyproject.toml's fail_under.
# Sequenced in the recipe (not as prerequisites) so `make -j` can't run the gate first.
.PHONY: cover-check
cover-check:
	@$(MAKE) --no-print-directory test-ci
	@$(MAKE) --no-print-directory _coverage-gate

# _coverage-gate: check an existing .coverage against the threshold (no test run). Used by
# cover-check and ci.
.PHONY: _coverage-gate
_coverage-gate:
	bash .github/scripts/coverage-gate.sh

# -----------------------------
# DEBUG HELPERS
# -----------------------------

# psql: an interactive psql in the local Postgres container (no local psql needed).
# Through PgBouncer instead: docker compose exec postgres psql "postgres://test:test@pgbouncer:5432/test"
.PHONY: psql
psql:
	docker compose exec postgres psql -U test -d test

# show-config: what config_from_env() resolves from the environment with .env loaded (the
# DATABASE_URL / PG_* variables), DSN password redacted, plus any config warnings — for
# checking pool settings without starting a pool.
define SHOW_CONFIG_PY
import dataclasses
from datetime import timedelta
from pgcommon._internal.dsn import mask_dsn
from pgcommon.config import config_from_env

cfg, warnings = config_from_env()
for f in dataclasses.fields(cfg):
    value = getattr(cfg, f.name)
    if f.name == "dsn":
        shown = mask_dsn(value)
    elif isinstance(value, timedelta):
        shown = str(value)
    else:
        shown = repr(value)
    print(f"{f.name:26} {shown}")
for w in warnings:
    print(f"config warning: {w.key}: {w.reason}")
endef
export SHOW_CONFIG_PY

.PHONY: show-config
show-config:
	@set -a; if [ -f .env ]; then . ./.env; fi; set +a; $(UV) run python -c "$$SHOW_CONFIG_PY"

# docker-ps: the stack's containers, PgBouncer included, with state and ports.
.PHONY: docker-ps
docker-ps:
	$(COMPOSE) ps -a

# -----------------------------
# CLEAN
# -----------------------------

.PHONY: clean
clean:
	rm -rf .venv .pytest_cache .mypy_cache .ruff_cache .import_linter_cache htmlcov .coverage coverage.xml dist build *.egg-info SHA256SUMS
