#!/usr/bin/env python3
"""Real git-native `commit-msg` hook: block commits that add untested source.

Layer 2 of PR3's two-layer defense-in-depth (see .sherlock-plan.md). Layer 1
(`tests-exist-gate.py`) is a Claude Code PreToolUse hook — it only sees
commits routed through Claude Code's Bash tool. A bare-terminal `git commit`
never touches it, so this file is the structural backstop: a real git hook,
wired via `core.hooksPath`, that git itself executes for every commit
regardless of how it was invoked.

Per the plan's "independent copies, not shared import" precedent (see
`_gate_classify.py`'s module docstring, reused for Phase 2's
`tests-exist-gate.py`), this hook duplicates its own small `git diff
--cached` subprocess call and its own minimal block/allow decision — it does
NOT import from the hyphenated `tests-exist-gate.py`. It DOES reuse the
non-hyphenated `_gate_classify.py` (Classifier, is_allowlisted,
load_allowlist, repo_root) and `path_utils.py` (get_config), both of which
ship to the sibling `hooks/` directory in both the source tree
(`src/shipteam/framework/hooks/`) and the consumer-side layout
(`.claude/hooks/`, sibling to this file's `.claude/git-hooks/`).
"""
from __future__ import annotations

import os
import subprocess
import sys
from pathlib import Path

# Sibling `hooks/` dir: src/shipteam/framework/{git-hooks,hooks}/ in the
# source tree, .claude/{git-hooks,hooks}/ once synced to a consumer repo —
# same 2-parents-up-then-"hooks" arithmetic resolves correctly in both.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "hooks"))

from _gate_classify import Classifier, is_allowlisted, load_allowlist, repo_root  # noqa: E402
from path_utils import get_config  # noqa: E402

# Fire-and-forget metrics logging. 3-parents-up-then-"scripts" deliberately
# does NOT resolve from the source tree (src/shipteam/scripts doesn't
# exist) and falls through to the no-op fallback there; it resolves for
# real once synced to .claude/git-hooks/commit-msg (3 parents up = repo
# root, then /scripts = the real scripts/ dir). Same asymmetry already
# established by auto_approve_base.py.
try:
    sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "scripts"))
    from fw_event_log import append_event as _log_event
except Exception:
    def _log_event(*a, **kw): pass  # no-op fallback


def gate_enabled() -> bool:
    """True only for a case-insensitive-stripped 'true' value of
    hooks.tests_exist_gate in config/framework.yaml."""
    return get_config("hooks.tests_exist_gate", "").strip().lower() == "true"


def has_bypass_env() -> bool:
    """True iff FW_ALLOW_UNTESTED=1 is set in the process environment. This
    hook runs as a real git-invoked subprocess, so it only ever needs to
    check os.environ — no command-string parsing (that's the separate
    PreToolUse-layer hook's job)."""
    return os.environ.get("FW_ALLOW_UNTESTED") == "1"


def staged_added_files() -> list[str]:
    """Return the paths of staged, newly-added files (`git diff --cached
    --name-only --diff-filter=A`). Returns [] on any non-zero exit — but logs
    the failure first, so a git-subprocess error is distinguishable from "no
    files were added" rather than silently defeating the gate (mirrors
    scripts/testing_health.py's _git() rationale: a git failure must be
    surfaced, not folded into the same result as a legitimately empty diff)."""
    result = subprocess.run(
        ["git", "diff", "--cached", "--name-only", "--diff-filter=A"],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        _log_event(
            "tests_exist_gate_staged_files_error",
            "tests_exist_gate",
            {"returncode": result.returncode, "reason": "staged_files_error"},
        )
        return []
    return [line for line in result.stdout.splitlines() if line]


class _GateDecision:
    """Plain class, not @dataclass: this module is loaded via
    importlib.util.module_from_spec() without being registered in
    sys.modules (see test_commit_msg_hook.py's import shim), and the
    dataclass decorator's typing resolution looks up `cls.__module__` in
    sys.modules — which is None in that scenario, raising AttributeError at
    class-definition time. A plain __init__ sidesteps that lookup. Same
    rationale as tests-exist-gate.py's GateResult."""

    def __init__(self, offending: list[str], allowlisted: list[str]) -> None:
        self.offending = offending
        self.allowlisted = allowlisted


def evaluate(added: list[str], clf: Classifier, allowlist: list[str]) -> _GateDecision:
    """Commit-level "did you add ANY test coverage alongside this commit's
    new source" decision. A test file added anywhere in `added` satisfies
    the gate for ALL new source files in the same commit (not per-file stem
    pairing). Allowlisted source files are exempt regardless of test
    presence, and are recorded in `.allowlisted` (not silently skipped) so
    `main()` can log the exemption.

    Independent copy of tests-exist-gate.py's `evaluate()` — same partition
    logic, duplicated rather than imported (see module docstring's
    "independent copies, not shared import" precedent)."""
    source_files = [p for p in added if clf.classify(p) == "source"]
    has_test = any(clf.classify(p) == "test" for p in added)

    allowlisted = [p for p in source_files if is_allowlisted(p, allowlist)]
    non_allowlisted = [p for p in source_files if p not in allowlisted]

    offending = [] if has_test else non_allowlisted

    return _GateDecision(offending=offending, allowlisted=allowlisted)


def main(argv: list[str]) -> int:
    """Entry point git invokes as the `commit-msg` hook. Short-circuits on
    gate-disabled/bypass-env, then delegates the block/allow decision to
    `evaluate()`, logs any allowlist exemption, and prints an actionable
    message (naming offending files + both bypass options) on block."""
    if not gate_enabled():
        return 0
    if has_bypass_env():
        return 0

    added = staged_added_files()
    clf = Classifier()
    allowlist = load_allowlist(repo_root())
    decision = evaluate(added, clf, allowlist)

    if decision.allowlisted:
        _log_event(
            "tests_exist_gate_allowlist_exemption",
            "tests_exist_gate",
            {"reason": "allowlist_exemption", "blocked_by_count": len(decision.allowlisted)},
        )

    if decision.offending:
        offending_list = "\n".join(f"  - {f}" for f in decision.offending)
        print(
            "Blocked: the following new source file(s) have no test coverage "
            "in this commit:\n"
            f"{offending_list}\n\n"
            "To proceed, either:\n"
            "  1. Add a test alongside the source file in this commit, or\n"
            "  2. Add the file to .claude/tests-exempt-allowlist, or\n"
            "  3. Bypass this check with: FW_ALLOW_UNTESTED=1 git commit ...\n",
            file=sys.stderr,
        )
        return 1

    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
