# Makefile — the one entry point for working on documate.
#
# Thin front door over two things: the tool run against its own repo (every page
# under docs/ is generated by the thing being built, so `make docs` is both the
# dogfood and the release artifact), and the dev loop around it — suite,
# coverage, lint, release build.
#
# The dev venv is a build artifact with a rule, not a setup step you have to
# remember: every target that needs it creates it first, and re-installs by
# itself whenever pyproject.toml changes. A fresh clone can run `make test` as
# its very first command.
#
#   make                          # this grouped, colourised target list
#   make test                     # the suite, in parallel
#   make docs                     # regenerate docs/ then gate them
#   make check                    # the gate alone — what CI and the hook run
#   make check BASE=main          # options go on the command line
#   make test PYTHON=python3.12   # pin the interpreter the venv is built from

.DEFAULT_GOAL := help

# Fail loudly. -e stops a recipe at the first failing command instead of running
# on with a broken state, -u turns a typo'd shell variable into an error rather
# than an empty string, and pipefail keeps a failure from being swallowed by a
# later stage of a pipe.
SHELL       := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c
MAKEFLAGS   += --no-print-directory --no-builtin-rules
.SUFFIXES:

REPO_ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))

# The interpreter the venv is built from. Any 3.10+ works (pyproject's floor);
# CI covers 3.10-3.12. Override to reproduce a version-specific failure:
#   make test PYTHON=python3.10
PYTHON   ?= python3

VENV     := $(REPO_ROOT)/.venv
PY       := $(VENV)/bin/python
DOCUMATE := $(VENV)/bin/documate
RUFF     := $(VENV)/bin/ruff
# Marks "editable install + dev tools are present". No trailing comments on any
# assignment in this file: make keeps the spaces between a value and a `#`, and
# a variable holding "path   " silently breaks every recipe that uses it.
STAMP    := $(VENV)/.dev-install

# Dev-only tools, deliberately absent from pyproject.toml: they serve this
# repo's loop, not anyone installing documate. Keeping them here means the
# published dependency list stays the four runtime packages it claims to be.
DEV_DEPS := pytest pytest-xdist coverage ruff

# ruff is pointed at documate's own modules only — the non-recursive glob stops
# at src/documate/*.py and so never descends into _engine/. That tree is
# first-party but hand-maintained against its upstream (see _engine/ORIGIN.md,
# and CONTRIBUTING's "never edit _engine"); reformatting it would churn the
# diff that provenance rests on. It is already ruff-check clean either way.
LINT_PATHS := $(REPO_ROOT)/src/documate/*.py $(REPO_ROOT)/scripts $(REPO_ROOT)/tests

# Every documate invocation puts the repo path FIRST on purpose: --ai takes an
# optional model argument, so `documate --ai .` would swallow the path as a
# model name. Path-first is immune to that.
#   BASE  — git ref the drift gate diffs against
#   HTML  — HTML=1 also renders the static site
#   MODEL — `make ai MODEL=sonnet` upgrades from the default haiku
#   ARGS  — extra flags forwarded verbatim to the underlying tool
BASE  ?=
HTML  ?=
MODEL ?=
ARGS  ?=

FLAGS := $(if $(BASE),--base $(BASE))

.PHONY: help venv hooks doctor \
        docs check site stats watch ai \
        test ci coverage \
        lint fmt fmt-check \
        build verify release \
        clean clean-graph distclean

##@ Setup
## venv: create or refresh .venv — every other target does this for you
##   Uses uv when it's on PATH (seconds), falls back to `python -m venv` + pip.
##   Installs documate editable plus the dev-only tools. Idempotent, and it
##   re-runs itself whenever pyproject.toml changes, so the venv can't drift
##   behind the dependency list.
venv: $(STAMP)

$(STAMP): $(REPO_ROOT)/pyproject.toml
	@printf '==> dev venv: %s\n' "$(VENV)"
	@if [ ! -x "$(PY)" ]; then \
	   command -v "$(PYTHON)" >/dev/null 2>&1 || { \
	     printf '    %s not found — install Python 3.10+ or pass PYTHON=/path/to/python3\n' "$(PYTHON)" >&2; \
	     exit 1; \
	   }; \
	   if command -v uv >/dev/null 2>&1; then uv venv --python "$(PYTHON)" "$(VENV)" >/dev/null; \
	   else "$(PYTHON)" -m venv "$(VENV)"; fi; \
	 fi
	@if command -v uv >/dev/null 2>&1; then \
	   uv pip install --python "$(PY)" -q -e "$(REPO_ROOT)" $(DEV_DEPS); \
	 else \
	   "$(PY)" -m pip install -q --upgrade pip; \
	   "$(PY)" -m pip install -q -e "$(REPO_ROOT)" $(DEV_DEPS); \
	 fi
	@touch "$@"
	@"$(PY)" -c 'import sys; print("    ready: python %d.%d.%d" % sys.version_info[:3])'

## hooks: point git at hooks/ so the docs gate runs before every commit
##   Writes only to this repo's .git/config, never your global gitconfig. The
##   hook is self-healing: it regenerates the generated pages and stages them,
##   then gates. Safe to re-run.
hooks:
	@git -C "$(REPO_ROOT)" config core.hooksPath hooks
	@printf '    core.hooksPath = hooks  ·  bypass one commit with --no-verify\n'

## doctor: preflight — interpreter, venv, tools and repo state on one screen
##   Read-only and never fails on a missing optional tool; it reports so you can
##   see at a glance which half of a broken command is missing.
doctor:
	@printf '\n  toolchain\n'
	@printf '    %-14s %s\n' "$(PYTHON)" "$$($(PYTHON) -V 2>&1 || echo 'MISSING — install Python 3.10+')"
	@printf '    %-14s %s\n' "uv" "$$(uv --version 2>/dev/null || echo 'absent (optional — venv falls back to pip)')"
	@printf '    %-14s %s\n' "git" "$$(git --version 2>/dev/null || echo 'MISSING')"
	@printf '    %-14s %s\n' "claude" "$$(command -v claude >/dev/null 2>&1 && echo present || echo 'absent (only --ai needs it)')"
	@printf '\n  dev venv\n'
	@if [ -x "$(PY)" ]; then \
	   printf '    %-14s %s\n' "python" "$$("$(PY)" -V 2>&1)"; \
	   printf '    %-14s %s\n' "documate" "$$("$(PY)" -c 'from importlib.metadata import version; print(version("documate"))' 2>/dev/null || echo 'not installed — run: make venv')"; \
	   printf '    %-14s %s\n' "install" "$$([ -f "$(STAMP)" ] && echo 'current' || echo 'stale — next target refreshes it')"; \
	 else \
	   printf '    %-14s %s\n' "state" "absent — run: make venv"; \
	 fi
	@printf '\n  repo\n'
	@printf '    %-14s %s\n' "branch" "$$(git -C "$(REPO_ROOT)" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')"
	@printf '    %-14s %s\n' "hooksPath" "$$(git -C "$(REPO_ROOT)" config --local core.hooksPath 2>/dev/null || echo 'unset for this repo — run: make hooks')"
	@printf '    %-14s %s\n' "worktree" "$$(git -C "$(REPO_ROOT)" status --porcelain 2>/dev/null | wc -l | tr -d ' ') file(s) dirty"
	@printf '\n'

##@ Docs  (documate run against its own repo)
## docs: the whole job — regenerate docs/, then gate the result
##   The default. HTML=1 also renders the static site into site/.
docs: $(STAMP)
	@"$(DOCUMATE)" "$(REPO_ROOT)" $(if $(HTML),--html) $(FLAGS) $(ARGS)

## check: the gate alone, writes nothing — docs fresh, anchors real, no drift
##   Exactly what CI and the pre-commit hook run. BASE=<ref> picks the ref the
##   drift half diffs against (default: the config's default_base).
check: $(STAMP)
	@"$(DOCUMATE)" "$(REPO_ROOT)" --check $(FLAGS) $(ARGS)

## site: render the static HTML site into site/ (gitignored) and print its path
site: $(STAMP)
	@"$(DOCUMATE)" "$(REPO_ROOT)" --html $(FLAGS) $(ARGS)
	@printf '    open %s\n' "$(REPO_ROOT)/site/index.html"

## stats: the dashboard — coverage bars, doc lines +/−, all-time --ai spend
stats: $(STAMP)
	@"$(DOCUMATE)" "$(REPO_ROOT)" --stats $(ARGS)

## watch: regenerate on every save — the dev loop; ctrl-c stops it
watch: $(STAMP)
	@"$(DOCUMATE)" "$(REPO_ROOT)" --watch $(ARGS)

## ai: the opt-in model layer — draft missing docstrings, then re-verify
##   MODEL=sonnet upgrades from the default haiku. Needs the claude CLI, which
##   is checked first so a missing binary fails here with a reason rather than
##   halfway through a run. Drafts land uncommitted, for you to review.
ai: $(STAMP)
	@command -v claude >/dev/null 2>&1 || { \
	  printf '    claude CLI not on PATH — the --ai layer drives it\n' >&2; exit 1; }
	@"$(DOCUMATE)" "$(REPO_ROOT)" --ai $(MODEL) $(FLAGS) $(ARGS)

##@ Test
## test: the full suite in parallel — the fast local loop
##   ARGS forwards pytest flags, e.g. `make test ARGS="-k anchors -x"`.
test: $(STAMP)
	@"$(PY)" -m pytest -n auto -q "$(REPO_ROOT)/tests/test_documate.py" $(ARGS)

## ci: everything CI runs — the stdlib unittest suite, then the gate on our docs
##   Same commands as .github/workflows/ci.yml, so green here means green there
##   (CI adds only the 3.10/3.11/3.12 matrix). Serial and verbose on purpose:
##   this is the target to reach for when the parallel run hides an ordering bug.
ci: $(STAMP)
	@printf '==> unit tests (stdlib unittest, serial — as CI runs them)\n'
	@cd "$(REPO_ROOT)" && "$(PY)" -m unittest discover -s tests -v
	@printf '\n==> dogfood: the gate on our own docs\n'
	@"$(DOCUMATE)" "$(REPO_ROOT)" --check $(FLAGS)
	@printf '\n    CI subset passed — matrix aside, this is the whole gate.\n'

## coverage: line coverage of src/documate → colored table + an HTML report
##   Runs the suite serially under coverage.py (parallel workers would fragment
##   the data), then renders scripts/coverage_report.py. The run's output is
##   captured and only replayed if it fails, so a green run stays one table.
coverage: $(STAMP)
	@mkdir -p "$(REPO_ROOT)/coverage"
	@cd "$(REPO_ROOT)" && "$(PY)" -m coverage run --source=src/documate -m unittest tests.test_documate \
	   >coverage/test.log 2>&1 || { cat coverage/test.log; exit 1; }
	@cd "$(REPO_ROOT)" && "$(PY)" -m coverage html -q -d coverage/html
	@cd "$(REPO_ROOT)" && "$(PY)" -m coverage json -q -o coverage/coverage.json
	@"$(PY)" "$(REPO_ROOT)/scripts/coverage_report.py" "$(REPO_ROOT)/coverage/coverage.json"
	@printf '    html: coverage/html/index.html\n'

##@ Quality
## lint: ruff check over documate's own modules, scripts and tests
lint: $(STAMP)
	@"$(RUFF)" check $(LINT_PATHS)

## fmt: ruff format, in place
fmt: $(STAMP)
	@"$(RUFF)" format -q $(LINT_PATHS)
	@printf '    formatted (src/documate/_engine is excluded by design)\n'

## fmt-check: fail if anything is unformatted — writes nothing
fmt-check: $(STAMP)
	@"$(RUFF)" format --check $(LINT_PATHS)

##@ Release  (see RELEASING.md — this is the mechanical half)
## build: clear dist/ and build the wheel + sdist
##   dist/ is always cleared first: a stale artifact left behind is the one way
##   `twine upload dist/*` can publish the wrong build.
build:
	@command -v uv >/dev/null 2>&1 || { printf '    uv not found — install from https://astral.sh/uv\n' >&2; exit 1; }
	@rm -rf "$(REPO_ROOT)/dist"
	@cd "$(REPO_ROOT)" && uv build

## verify: install the freshly built wheel into a throwaway env and run it
##   The last gate before an upload, because a published version can only be
##   yanked, never replaced. Insists on exactly one wheel so it can't silently
##   verify yesterday's build.
verify:
	@command -v uv >/dev/null 2>&1 || { printf '    uv not found — install from https://astral.sh/uv\n' >&2; exit 1; }
	@cd "$(REPO_ROOT)"; \
	 shopt -s nullglob; \
	 wheels=(dist/*.whl); \
	 if [ $${#wheels[@]} -ne 1 ]; then \
	   printf '    expected exactly one wheel in dist/, found %d — run: make build\n' "$${#wheels[@]}" >&2; \
	   exit 1; \
	 fi; \
	 printf '==> verifying %s\n' "$${wheels[0]}"; \
	 uv run --with "$${wheels[0]}" --no-project documate --help >/dev/null
	@printf '    the wheel installs and runs clean\n'

## release: bump → test → build → verify → publish → tag, via ./release.sh
##   usage: make release VERSION=X.Y.Z
##   The script is gitignored personal tooling and carries its own guards
##   (interactive-only, PyPI collision check, type-the-version before upload);
##   this is a front door, not a second set of guards. Without it, RELEASING.md
##   has the same steps by hand.
release:
	@test -n "$(VERSION)" || { printf '    usage: make release VERSION=X.Y.Z\n' >&2; exit 2; }
	@test -x "$(REPO_ROOT)/release.sh" || { \
	  printf '    release.sh not present (gitignored personal tooling)\n' >&2; \
	  printf '    follow the manual steps in RELEASING.md instead\n' >&2; exit 1; }
	@cd "$(REPO_ROOT)" && ./release.sh "$(VERSION)"

##@ Housekeeping
## clean: remove build, test and site artifacts — the venv and the graph stay
clean:
	@rm -rf "$(REPO_ROOT)"/dist "$(REPO_ROOT)"/build "$(REPO_ROOT)"/site \
	        "$(REPO_ROOT)"/coverage "$(REPO_ROOT)"/.coverage "$(REPO_ROOT)"/.pytest_cache \
	        "$(REPO_ROOT)"/src/*.egg-info
	@find "$(REPO_ROOT)/src" "$(REPO_ROOT)/tests" "$(REPO_ROOT)/scripts" \
	      -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true
	@printf '    removed build / test / site artifacts\n'

## clean-graph: drop the rebuildable index only — the ledgers are kept
##   .documate/graph.db is a cache; the next run re-indexes from scratch. The
##   two jsonl ledgers beside it (stats.jsonl, spend.jsonl) are append-only
##   history that nothing can regenerate, so this never touches them.
clean-graph:
	@rm -f "$(REPO_ROOT)"/.documate/graph.db*
	@printf '    dropped the index — ledgers kept; next run re-indexes in full\n'

## distclean: clean, and also delete the dev venv
distclean: clean
	@rm -rf "$(VENV)"
	@printf '    removed %s\n' "$(VENV)"

## help: this grouped, colourised target list
help:
	@if [ -t 1 ] && [ -z "$${NO_COLOR:-}" ]; then \
	  b=$$(printf '\033[1m'); c=$$(printf '\033[36m'); y=$$(printf '\033[1;33m'); d=$$(printf '\033[2m'); r=$$(printf '\033[0m'); \
	else b=; c=; y=; d=; r=; fi; \
	printf '\n  %sdocumate%s  %s·  docs tied to the code they describe, gated when either moves%s\n' "$$b" "$$r" "$$d" "$$r"; \
	printf '  %srun%s make <target>  %s·  options:%s BASE=<ref>  HTML=1  MODEL=sonnet  PYTHON=python3.12  ARGS="…"\n' \
	  "$$d" "$$r" "$$d" "$$r"; \
	awk -v c="$$c" -v y="$$y" -v d="$$d" -v r="$$r" \
	  '/^##@ / { printf "\n  %s%s%s\n", y, substr($$0,5), r; next } \
	   /^## [^ ]/ { s=substr($$0,4); i=index(s,": "); \
	     printf "    %s%-13s%s %s%s%s\n", c, substr(s,1,i-1), r, d, substr(s,i+2), r }' \
	  $(MAKEFILE_LIST); \
	printf '\n'
