# --- Makefile (PyScaffold Clickstart; pyproject-only, Ruff-only) -------------
# Provides:
#   - Auto versioned builds (setuptools_scm)
#   - Linting/formatting via Ruff
#   - Incremental pytest testing via STAMPs
#   - Release helpers (Git tag based)
#   - venv-based bootstrap (.venv by default; override with VENV=...)
# -----------------------------------------------------------------------------

.SILENT:
.ONESHELL:
SHELL := $(shell which bash)
.SHELLFLAGS := -eu -o pipefail -c
.DEFAULT_GOAL := help

# ---- Python / venv ----------------------------------------------------------
# `?=` means VENV is also read from the environment, so a machine whose in-repo
# .venv is unusable (see venv-check below) can be fixed once per shell with
#     export VENV="$HOME/.venvs/dof"
# rather than remembering VENV= on every make call. The default stays .venv so
# CI runners and other machines behave exactly as before.
VENV ?= .venv

# ---- Environment install stamp ---------------------------------------------

CONF_FILES := pyproject.toml
ENV_STAMP := $(VENV)/.installed

$(ENV_STAMP): $(CONF_FILES) | venv
	@echo "Installing project dependencies (editable)..."
	"$(PY)" -m pip install -U pip setuptools wheel
	"$(PY)" -m pip install -e ".[dev]"
	@echo "installed" > "$(ENV_STAMP)"

# Pick a system Python: use 'python' if present, else Windows launcher 'py -3'
PYTHON_SYS := $(shell command -v python >/dev/null 2>&1 && echo python || echo py -3)

# Linux friendly too
PY = $(if $(wildcard $(VENV)/Scripts/python.exe),$(VENV)/Scripts/python.exe,$(VENV)/bin/python)

# Main code package (templated). NOTE: PKG is the *import* package (src/dof, --cov
# target, `python -m dof`); DIST is the *distribution* name on PyPI. They differ here:
# `dof` is blocked on PyPI (it normalises onto the existing `pydof`), so the project
# ships as `treasure-map` but is still imported and invoked as `dof`. Anything that
# talks to pip/PyPI/importlib.metadata must use DIST, not PKG.
PKG        :=  dof
DIST       :=  treasure-map
CODE_DIRS  := src/$(PKG)
STAMPS_DIR := .stamps
UNIT_STAMP  := $(STAMPS_DIR)/unit.ok
INTEG_STAMP := $(STAMPS_DIR)/integration.ok
NO_CACHE  ?= 0

# Default release kind for `make release` (patch|minor|major)
KIND ?= patch

# Pytest flags
PYTEST           := $(PY) -m pytest
PYTEST_Q         := -q
PYTEST_WARN      := --disable-warnings
PYTEST_COV_BASE  := --cov=$(PKG)
PYTEST_COV_UNIT  := $(PYTEST_COV_BASE) --cov-report= --cov-append
PYTEST_COV_INTEG := $(PYTEST_COV_BASE) --cov-report= --cov-append
PYTEST_XDIST    ?= -n auto
PYTEST_TIMEOUT  ?= --timeout=60

# Test directories
UNIT_DIR   := tests/unit
INTEG_DIR  := tests/integration
SYSTEM_DIR := tests/system  # live/system tests (opt-in, uncached)

.PHONY: help venv venv-check bootstrap precommit docs lint format \
        test test-all test-live clean-tests \
        build upload version fetch-tags changelog changelog-md \
        release-show release release-patch release-minor release-major \
        clean run-cli check-clean

help:
	@echo "Common targets:"
	@echo "  make venv                - create virtual environment"
	@echo "  make bootstrap           - create $(VENV) and install .[dev]"
	@echo "  make precommit           - install pre-commit hook (requires bootstrap)"
	@echo "  make docs                - build Sphinx/MyST docs to docs/_build/html"
	@echo "  make lint                - run Ruff checks"
	@echo "  make format              - auto-fix via Ruff"
	@echo "  make test                - run cached unit+integration tests (not live)"
	@echo "  make test-all            - run all non-live tests (no stamps)"
	@echo "  make test-live           - run @live tests only (no cache)"
	@echo "  make build               - build wheel+sdist"
	@echo "  make upload              - upload to PyPI (via Twine)"
	@echo "  make version             - print setuptools_scm inferred version"
	@echo "  make changelog           - show changes since last Git tag"
	@echo "  make changelog-md        - write docs/CHANGELOG.md from Git history"
	@echo "  make release-show        - show scm ver, installed ver, last Git tag"
	@echo "  make release             - run tests, show changelog and tag (KIND=patch|minor|major)"
	@echo "  make clean               - remove build artifacts"
	@echo "  make run-cli             - run via python -m package (pass CLI_ARGS=...)"
	@echo ""
	@echo "Tip (Windows/OneDrive permission issues):"
	@echo '  make bootstrap VENV="$HOME/.venvs/$(PKG)"'
	@echo '  make release   VENV="$HOME/.venvs/$(PKG)" KIND=major'

$(VENV)/pyvenv.cfg:
	$(PYTHON_SYS) -m venv $(VENV)

venv: $(VENV)/pyvenv.cfg

# ---- venv health preflight --------------------------------------------------
# OneDrive intermittently leaves an in-repo .venv half-readable: the interpreter
# is still on disk, so $(ENV_STAMP) is satisfied and make happily runs ruff /
# pytest / sphinx against it — which then dies with a bare
# `OSError: [Errno 22] Invalid argument` from deep inside an import. This probe
# turns that into one actionable message. It costs a single interpreter start.
#
# Deliberately NOT changing the default VENV: the breakage is transient (the same
# .venv works again later), CI runners are not on OneDrive, and hardcoding an
# absolute path would break on Kevin's other machines.
venv-check:
	if [ ! -f "$(PY)" ]; then \
	  echo "❌ No interpreter at $(PY)."; \
	  echo "   Run: make bootstrap VENV=\"$(VENV)\""; \
	  exit 1; \
	fi
	if ! "$(PY)" -c "import sysconfig, dof" >/dev/null 2>&1; then \
	  echo "❌ The virtualenv at '$(VENV)' exists but is not usable."; \
	  echo "   ($(PY) cannot import the project from its own site-packages.)"; \
	  echo ""; \
	  echo "   If this checkout is under OneDrive, that is almost certainly file"; \
	  echo "   locking or an evicted cloud placeholder, not a real install problem."; \
	  echo ""; \
	  echo "   Rebuild it outside OneDrive:"; \
	  echo "       make bootstrap VENV=\"\$$HOME/.venvs/$(PKG)\""; \
	  echo "   then export it once so every later make call picks it up:"; \
	  echo "       export VENV=\"\$$HOME/.venvs/$(PKG)\""; \
	  exit 1; \
	fi

bootstrap: $(ENV_STAMP)
	@echo "Environment ready."

precommit: bootstrap
	"$(PY)" -m pre_commit install

# -----------------------------------------------------------------------------#
# Docs
docs: $(ENV_STAMP) venv-check
	"$(PY)" -m sphinx -b html docs docs/_build/html

# -----------------------------------------------------------------------------#
# Linting / Formatting
lint: $(ENV_STAMP) venv-check
	"$(PY)" -m ruff check .
	"$(PY)" -m ruff format --check .

format: $(ENV_STAMP) venv-check
	"$(PY)" -m ruff check --fix .
	"$(PY)" -m ruff format .

# -----------------------------------------------------------------------------#
# Incremental Testing (cache via stamps)

$(STAMPS_DIR):
	mkdir -p $(STAMPS_DIR)

UNIT_SIG    := $(STAMPS_DIR)/unit.sig
INTEG_SIG   := $(STAMPS_DIR)/integration.sig

define compute_dir_sig
{ [ -d "$(1)" ] && find $(1) -type f -not -path "*/__pycache__/*" -print0 || true; } \
| LC_ALL=C sort -z | xargs -0r sha1sum | sha1sum | awk '{print $1}'
endef

$(UNIT_STAMP): | $(STAMPS_DIR) $(ENV_STAMP) venv-check
	@tests_sig=$( $(call compute_dir_sig,$(UNIT_DIR)) ); \
	code_sig=$( $(call compute_dir_sig,$(CODE_DIRS)) ); \
	conf_sig=$( sha1sum $(CONF_FILES) 2>/dev/null | awk '{print $1}' | sha1sum | awk '{print $1}' ); \
	new_sig=$( printf "%s\n%s\n%s\n" "$tests_sig" "$code_sig" "$conf_sig" | sha1sum | awk '{print $1}' ); \
	old_sig=$(cat $(UNIT_SIG) 2>/dev/null || echo -n); \
	if [ "$(NO_CACHE)" = "1" ] || [ "$new_sig" != "$old_sig" ] || [ ! -f $@ ]; then \
	  echo "=== Running unit tests ==="; \
	  rm -f .coverage; \
	  set +e; \
	  "$(PY)" -m pytest -q $(UNIT_DIR) -m "not live" $(PYTEST_WARN) $(PYTEST_XDIST) $(PYTEST_TIMEOUT) $(PYTEST_COV_UNIT); \
	  status=$?; \
	  set -e; \
	  if [ "$status" -eq 5 ]; then \
	    echo "No unit tests collected; treating as success."; \
	  elif [ "$status" -ne 0 ]; then \
	    exit $status; \
	  fi; \
	  echo "$new_sig" > $(UNIT_SIG); \
	  touch $@; \
	else \
	  echo "No changes detected; skipping unit tests."; \
	fi

$(INTEG_STAMP): | $(STAMPS_DIR) $(ENV_STAMP) venv-check
	@tests_sig=$( $(call compute_dir_sig,$(INTEG_DIR)) ); \
	code_sig=$( $(call compute_dir_sig,$(CODE_DIRS)) ); \
	conf_sig=$( sha1sum $(CONF_FILES) 2>/dev/null | awk '{print $1}' | sha1sum | awk '{print $1}' ); \
	new_sig=$( printf "%s\n%s\n%s\n" "$tests_sig" "$code_sig" "$conf_sig" | sha1sum | awk '{print $1}' ); \
	old_sig=$(cat $(INTEG_SIG) 2>/dev/null || echo -n); \
	if [ "$(NO_CACHE)" = "1" ] || [ "$new_sig" != "$old_sig" ] || [ ! -f $@ ]; then \
	  echo "=== Running integration tests ==="; \
	  set +e; \
	  "$(PY)" -m pytest -q $(INTEG_DIR) -m "not live" $(PYTEST_WARN) $(PYTEST_XDIST) $(PYTEST_TIMEOUT) $(PYTEST_COV_INTEG); \
	  status=$?; \
	  set -e; \
	  if [ "$status" -eq 5 ]; then \
	    echo "No integration tests collected; treating as success."; \
	  elif [ "$status" -ne 0 ]; then \
	    exit $status; \
	  fi; \
	  echo "$new_sig" > $(INTEG_SIG); \
	  touch $@; \
	else \
	  echo "No changes detected; skipping integration tests."; \
	fi

test: $(UNIT_STAMP) $(INTEG_STAMP)
	@echo "=== Aggregated coverage report ==="
	"$(PY)" -m coverage report
	"$(PY)" -m coverage xml
	@echo "✅ Unit + Integration tests up-to-date (not live)"

# NOTE: the coverage gate is NOT specified here. It lives in pyproject.toml under
# [tool.coverage.report] fail_under, which pytest-cov honours automatically. Do not
# reintroduce --cov-fail-under; one number, one place.
test-all: $(ENV_STAMP) venv-check
	"$(PY)" -m pytest -v -m "not live" $(PYTEST_WARN) $(PYTEST_XDIST) $(PYTEST_TIMEOUT) --cov=$(PKG) --cov-report=term-missing --cov-report=xml

# A zero-collection run must never look like a pass: pytest exit code 5 is reported
# loudly and propagated as a failure, exactly like any other red result.
# NOTE: `$$?`/`$$status` — a single `$` would be eaten by Make before the shell sees it.
# No --cov here on purpose: the coverage gate belongs to test-all and to the single
# fail_under in pyproject.toml, and a live-only run would trip it spuriously.
test-live: $(ENV_STAMP) venv-check
	set +e; \
	SF_LIVE_TESTS=true "$(PY)" -m pytest -v -m live $(PYTEST_WARN) --timeout=180 --no-cov; \
	status=$$?; \
	set -e; \
	if [ "$$status" -eq 5 ]; then \
	  echo "*** FAIL: no tests matched -m live. The 'live' marker is registered but no test applies it."; \
	  echo "*** A green here would mean live coverage you do not actually have."; \
	  exit 5; \
	fi; \
	exit $$status

clean-tests:
	rm -rf $(STAMPS_DIR)

# -----------------------------------------------------------------------------#
# Build & Publish
build: $(ENV_STAMP) venv-check
	"$(PY)" -m pip install -U build
	"$(PY)" -m build

upload: build
	"$(PY)" -m pip install -U twine
	"$(PY)" -m twine check dist/*
	"$(PY)" -m twine upload dist/*

# -----------------------------------------------------------------------------#
# Version & Release helpers (setuptools_scm + Git tags)
# -----------------------------------------------------------------------------#
# Version & Release helpers (setuptools_scm + Git tags)

fetch-tags:
	@git fetch --tags --force --prune 2>/dev/null || true

LAST_TAG := $(strip $(shell git tag --list "v[0-9]*.[0-9]*.[0-9]*" --sort=-version:refname | head -n 1))
ifeq ($(LAST_TAG),)
LAST_TAG := v0.0.0
endif

# Robust parsing (avoid sed portability issues on Git Bash/Windows)
MAJOR := $(shell sh -lc 't="$(LAST_TAG)"; t="$${t#v}"; echo "$${t%%.*}"')
MINOR := $(shell sh -lc 't="$(LAST_TAG)"; t="$${t#v}"; t="$${t#*.}"; echo "$${t%%.*}"')
PATCH := $(shell sh -lc 't="$(LAST_TAG)"; t="$${t#v}"; t="$${t#*.*.}"; echo "$${t%%[^0-9]*}"')

ifeq ($(strip $(MAJOR)),)
MAJOR := 0
endif
ifeq ($(strip $(MINOR)),)
MINOR := 0
endif
ifeq ($(strip $(PATCH)),)
PATCH := 0
endif

version: $(ENV_STAMP) venv-check
	@"$(PY)" -m setuptools_scm || true

define CHANGELOG
$(shell \
  if git rev-parse "$(LAST_TAG)" >/dev/null 2>&1; then \
    git log "$(LAST_TAG)..HEAD" --pretty=format:"- %s (%h)" --no-merges; \
  else \
    git log HEAD --pretty=format:"- %s (%h)" --no-merges; \
  fi \
)
endef

changelog: fetch-tags
	@echo "Changes since $(LAST_TAG):"
	@echo "$(CHANGELOG)"

changelog-md: fetch-tags
	@mkdir -p docs
	@echo "Writing docs/CHANGELOG.md ..."
	@printf "# Changelog\n\n## Since %s\n\n%s\n" "$(LAST_TAG)" "$(CHANGELOG)" > docs/CHANGELOG.md
	@echo "✅ docs/CHANGELOG.md updated"

release-show: fetch-tags $(ENV_STAMP) venv-check
	@echo "python exe:"; "$(PY)" -c "import sys; print(sys.executable)"
	@echo "setuptools_scm version:"; "$(PY)" -m setuptools_scm || echo "(unavailable)"
	@echo "installed dist version:"; "$(PY)" -c "import importlib.metadata as m; print(m.version('$(DIST)'))" || echo "(package not installed)"
	@echo "Last Git tag: $(LAST_TAG)"
	@echo "Parsed: MAJOR=$(MAJOR) MINOR=$(MINOR) PATCH=$(PATCH)"

check-clean:
	@if ! git diff --quiet || ! git diff --cached --quiet; then \
	  echo "❌ Working directory not clean. Commit or stash changes before releasing."; \
	  git status -s; \
	  exit 1; \
	fi
	@if [ "$$(git rev-parse @ 2>/dev/null)" != "$$(git rev-parse @{u} 2>/dev/null)" ]; then \
	  echo "❌ Local branch not in sync with upstream (push/pull first)."; \
	  exit 1; \
	fi

NL := $(shell printf "\n")

release-patch: fetch-tags check-clean
	@NEW="v$(MAJOR).$(MINOR).$$(($(PATCH) + 1))"; \
	echo "Tagging $$NEW (from LAST_TAG=$(LAST_TAG))"; \
	TMP="$$(mktemp -t dof-tagmsg.XXXXXX)"; \
	printf 'release: %s\n\n%s\n' "$$NEW" "$(CHANGELOG)" > "$$TMP"; \
	git tag -a "$$NEW" -F "$$TMP"; \
	rm -f "$$TMP"; \
	git push origin "$$NEW"; \
	echo "Tagged $$NEW"

release-minor: fetch-tags check-clean
	@NEW="v$(MAJOR).$$(($(MINOR) + 1)).0"; \
	echo "Tagging $$NEW (from LAST_TAG=$(LAST_TAG))"; \
	TMP="$$(mktemp -t dof-tagmsg.XXXXXX)"; \
	printf 'release: %s\n\n%s\n' "$$NEW" "$(CHANGELOG)" > "$$TMP"; \
	git tag -a "$$NEW" -F "$$TMP"; \
	rm -f "$$TMP"; \
	git push origin "$$NEW"; \
	echo "Tagged $$NEW"

release-major: fetch-tags check-clean
	@NEW="v$$(($(MAJOR) + 1)).0.0"; \
	echo "Tagging $$NEW (from LAST_TAG=$(LAST_TAG))"; \
	TMP="$$(mktemp -t dof-tagmsg.XXXXXX)"; \
	printf 'release: %s\n\n%s\n' "$$NEW" "$(CHANGELOG)" > "$$TMP"; \
	git tag -a "$$NEW" -F "$$TMP"; \
	rm -f "$$TMP"; \
	git push origin "$$NEW"; \
	echo "Tagged $$NEW"

release: fetch-tags $(ENV_STAMP) venv-check
	@echo "=== Running full test suite before release ==="
	$(MAKE) test-all
	@echo "=== Changelog (from $(LAST_TAG) to HEAD) ==="
	$(MAKE) changelog
	@echo "=== Performing $(KIND) release ==="
	@if [ "$(KIND)" = "patch" ]; then \
	  $(MAKE) release-patch; \
	elif [ "$(KIND)" = "minor" ]; then \
	  $(MAKE) release-minor; \
	elif [ "$(KIND)" = "major" ]; then \
	  $(MAKE) release-major; \
	else \
	  echo "Unknown KIND=$(KIND). Use: patch | minor | major"; \
	  exit 1; \
	fi


# -----------------------------------------------------------------------------#
# CLI convenience
CLI_ARGS ?=
run-cli: $(ENV_STAMP) venv-check
	"$(PY)" -m $(PKG) $(CLI_ARGS)

# -----------------------------------------------------------------------------#
clean:
	rm -rf build dist .eggs *.egg-info .coverage htmlcov .pytest_cache coverage.xml
	find . -type d -name "__pycache__" -prune -exec rm -rf {} +
	rm -rf $(STAMPS_DIR)
