# tests/test_prevent_e2e.py
"""prevent.py end-to-end THROUGH a real git pre-commit hook — the seam every other prevent test goes AROUND.
A gate whose job is to ABORT must be proven by performing the REAL action: install the hook, run a real
`git commit`, assert it aborts (rc!=0) + HEAD unchanged on a RATCHET block, then that an un-ratcheted state
commits (rc==0, HEAD advances). Covers main() + staged_files() + exit-code propagation through git — the
load-bearing integration boundary a direct runner.run() call cannot exercise (#42 doctrine: wired, not assumed).
stdlib + python3 only (no bun/pnpm) → CI-portable."""
import importlib.util, json, os, subprocess

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PREVENT = os.path.join(ROOT, "prevent", "prevent.py")


def _load(name, path):
    spec = importlib.util.spec_from_file_location(name, path)
    mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod); return mod


install = _load("sg_install_e2e", os.path.join(ROOT, "prevent", "install.py"))

def _git(repo, *args, env):
    return subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True, env=env)

def _head(repo, env):
    return _git(repo, "rev-parse", "HEAD", env=env).stdout.strip()

def _make_warning_detector(det_root):
    """Temp per-file warning detector (imprecise → blocks ONLY via the ratchet, never via authorized-error)."""
    ddir = os.path.join(det_root, "domains", "sec", "detectors", "e2e")
    os.makedirs(ddir)
    script = os.path.join(ddir, "e2e.py")
    with open(script, "w", encoding="utf-8") as fh:
        fh.write("import json, sys\n"
                 "print(json.dumps({'detector':'e2e','status':'ok','findings':[{'ruleId':'e2e-1',"
                 "'level':'warning','class':'S11','message':'seeded','file':sys.argv[1],'line':1,"
                 "'symbol':'e2e-sym'}],'coverage':{'scanned':[sys.argv[1]],'unresolved':[]}}))\n")
    with open(os.path.join(ddir, "detector.json"), "w", encoding="utf-8") as fh:
        json.dump({"id": "e2e", "exec": ["python3", script], "scope": "per-file",
                   "scope_globs": ["**/*.ts"], "trigger_globs": [], "needs_context": "none",
                   "triggers": ["pre-commit"], "precision": "imprecise", "class": "S11"}, fh)

def test_real_commit_aborts_on_ratchet_then_succeeds_when_clean(tmp_path):
    repo = tmp_path / "repo"; repo.mkdir()
    det_root = tmp_path / "det"; det_root.mkdir()
    conf = tmp_path / "confirmed.json"
    _make_warning_detector(str(det_root))

    # isolate from any global/system git config (esp. a global core.hooksPath / gpgsign) → hermetic.
    env = dict(os.environ, GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_SYSTEM=os.devnull)
    _git(str(repo), "init", env=env)
    _git(str(repo), "config", "user.email", "t@t.t", env=env)
    _git(str(repo), "config", "user.name", "t", env=env)
    _git(str(repo), "config", "commit.gpgsign", "false", env=env)

    hooks = repo / ".git" / "hooks"; hooks.mkdir(parents=True, exist_ok=True)
    hook = hooks / "pre-commit"
    hook.write_text(f'#!/bin/sh\nexec python3 "{PREVENT}" --trigger pre-commit '
                    f'--detectors-root "{det_root}" --confirmed "{conf}"\n')
    os.chmod(hook, 0o755)
    _git(str(repo), "config", "core.hooksPath", str(hooks), env=env)

    # init commit: README.md → no .ts detector applies → gate exits 0 → commit allowed. empty ratchet.
    conf.write_text("[]", encoding="utf-8")
    (repo / "README.md").write_text("x\n")
    _git(str(repo), "add", "README.md", env=env)
    c0 = _git(str(repo), "commit", "-m", "init", env=env)
    assert c0.returncode == 0, f"init commit must pass; got {c0.stderr}"
    sha0 = _head(str(repo), env)
    assert sha0, "init commit must produce a HEAD"

    # RED: stage a .ts the detector flags + a ratchet confirming that exact (class,suffix,symbol) → BLOCK.
    (repo / "app.ts").write_text("export const x = 1\n")
    _git(str(repo), "add", "app.ts", env=env)
    conf.write_text(json.dumps([{"class": "S11", "file": "app.ts", "symbol": "e2e-sym"}]), encoding="utf-8")
    red = _git(str(repo), "commit", "-m", "should-block", env=env)
    assert red.returncode != 0, f"ratcheted finding must ABORT the real commit; got rc=0\n{red.stdout}\n{red.stderr}"
    assert _head(str(repo), env) == sha0, "blocked commit must NOT advance HEAD"

    # GREEN: same staged file, empty ratchet → finding is only a WARN → commit proceeds, HEAD advances.
    conf.write_text("[]", encoding="utf-8")
    green = _git(str(repo), "commit", "-m", "should-pass", env=env)
    assert green.returncode == 0, f"un-ratcheted warning must allow commit; got {green.stdout}\n{green.stderr}"
    assert _head(str(repo), env) != sha0, "clean commit must advance HEAD"


def test_report_only_allows_commit_despite_would_block(tmp_path):
    """report-only is MONITOR mode: a ratcheted finding that WOULD abort in enforce must NOT abort here — a real
    git commit succeeds (rc==0, HEAD advances) and the would-block finding is still surfaced. Locks the
    monitor->enforce seam (the only difference is the exit) through the REAL action, not a direct prevent.py call."""
    repo = tmp_path / "repo"; repo.mkdir()
    det_root = tmp_path / "det"; det_root.mkdir()
    conf = tmp_path / "confirmed.json"
    _make_warning_detector(str(det_root))

    env = dict(os.environ, GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_SYSTEM=os.devnull)
    _git(str(repo), "init", env=env)
    _git(str(repo), "config", "user.email", "t@t.t", env=env)
    _git(str(repo), "config", "user.name", "t", env=env)
    _git(str(repo), "config", "commit.gpgsign", "false", env=env)

    hooks = repo / ".git" / "hooks"; hooks.mkdir(parents=True, exist_ok=True)
    hook = hooks / "pre-commit"
    hook.write_text(f'#!/bin/sh\nexec python3 "{PREVENT}" --trigger pre-commit --report-only '
                    f'--detectors-root "{det_root}" --confirmed "{conf}"\n')
    os.chmod(hook, 0o755)
    _git(str(repo), "config", "core.hooksPath", str(hooks), env=env)

    conf.write_text("[]", encoding="utf-8")
    (repo / "README.md").write_text("x\n")
    _git(str(repo), "add", "README.md", env=env)
    assert _git(str(repo), "commit", "-m", "init", env=env).returncode == 0
    sha0 = _head(str(repo), env)

    # stage a flagged .ts + ratchet it → enforce mode WOULD block; report-only must ALLOW + surface it.
    (repo / "app.ts").write_text("export const x = 1\n")
    _git(str(repo), "add", "app.ts", env=env)
    conf.write_text(json.dumps([{"class": "S11", "file": "app.ts", "symbol": "e2e-sym"}]), encoding="utf-8")
    r = _git(str(repo), "commit", "-m", "monitor-allows", env=env)
    assert r.returncode == 0, f"report-only must NOT abort a would-block commit; rc={r.returncode}\n{r.stdout}\n{r.stderr}"
    assert _head(str(repo), env) != sha0, "report-only commit must advance HEAD"
    out = r.stdout + r.stderr
    assert "report-only" in out, f"monitor mode must surface the would-block finding; got {out!r}"


def _hermetic(tmp_path):
    """Init a git repo isolated from any global/system git config (hooksPath/gpgsign) → reproducible commits."""
    repo = tmp_path / "repo"; repo.mkdir()
    env = dict(os.environ, GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_SYSTEM=os.devnull)
    _git(str(repo), "init", env=env)
    _git(str(repo), "config", "user.email", "t@t.t", env=env)
    _git(str(repo), "config", "user.name", "t", env=env)
    _git(str(repo), "config", "commit.gpgsign", "false", env=env)
    return repo, env


def test_installed_block_preserves_failing_prior_hook(tmp_path):
    """The no-swallow guard EXECUTED through git on the REAL install.apply()-generated block (string-checks go
    AROUND this). Prior hook's last command FAILS without self-exiting (`echo; false` — models lint-staged
    failing on multideal/trance, which have no `|| exit`). The chained report-only block (exit 0) must NOT
    swallow it: a real commit must still ABORT (rc!=0, HEAD unborn). Verifies the BEGIN *comment* line does not
    reset $? before `_pb_rc=$?` — shell semantics that only running it can confirm."""
    repo, env = _hermetic(tmp_path)
    hook = repo / ".git" / "hooks" / "pre-commit"; hook.parent.mkdir(parents=True, exist_ok=True)
    hook.write_text("#!/usr/bin/env sh\necho prior-hook\nfalse\n", encoding="utf-8")  # last cmd fails, no self-exit
    install.apply(str(repo), report_only=True)  # the REAL generated block, chained after the failing hook
    (repo / "README.md").write_text("x\n")  # non-manifest: abort happens at the guard, before any detector runs
    _git(str(repo), "add", "README.md", env=env)
    r = _git(str(repo), "commit", "-m", "should-abort", env=env)
    assert r.returncode != 0, f"failing prior hook must STILL abort under the chained block; got rc=0\n{r.stdout}\n{r.stderr}"
    assert _git(str(repo), "rev-parse", "HEAD", env=env).returncode != 0, "no commit should exist (HEAD unborn) after abort"


def test_installed_block_passes_clean_prior_hook(tmp_path):
    """Complement: prior hook passes (rc 0) + clean non-manifest staged set → the chained block runs the
    PRODUCTION prevent (no detector applies to README) and the commit PROCEEDS (rc==0, HEAD advances). Exercises
    the generated block's invocation path end-to-end, not a hand-written stand-in. No manifest staged → deps
    never fires (no network)."""
    repo, env = _hermetic(tmp_path)
    hook = repo / ".git" / "hooks" / "pre-commit"; hook.parent.mkdir(parents=True, exist_ok=True)
    hook.write_text("#!/usr/bin/env sh\necho prior-hook-ok\n", encoding="utf-8")  # passes (rc 0)
    install.apply(str(repo), report_only=True)
    (repo / "README.md").write_text("x\n")
    _git(str(repo), "add", "README.md", env=env)
    r = _git(str(repo), "commit", "-m", "should-pass", env=env)
    assert r.returncode == 0, f"clean staged set must commit through the chained block; got rc={r.returncode}\n{r.stdout}\n{r.stderr}"
    assert _git(str(repo), "rev-parse", "HEAD", env=env).returncode == 0, "commit must produce a HEAD"


def test_installed_block_fails_open_on_absent_security_gate(tmp_path, monkeypatch):
    """fail-open EXECUTED: if security-gate is absent at the hardcoded path (another clone / CI), the `[ -f ]`
    guard makes the block a no-op — the commit must PROCEED, never hard-fail every commit. Points the generated
    block at a nonexistent PREVENT_PY and fires a real commit."""
    monkeypatch.setattr(install, "PREVENT_PY", "/no/such/security-gate/prevent/prevent.py")
    repo, env = _hermetic(tmp_path)
    install.apply(str(repo), report_only=True)  # fresh hook (shebang) + block pointing at the absent path
    (repo / "README.md").write_text("x\n")
    _git(str(repo), "add", "README.md", env=env)
    r = _git(str(repo), "commit", "-m", "fail-open", env=env)
    assert r.returncode == 0, f"absent security-gate path must NO-OP (fail-open), not block; got rc={r.returncode}\n{r.stdout}\n{r.stderr}"
    assert _git(str(repo), "rev-parse", "HEAD", env=env).returncode == 0, "fail-open commit must produce a HEAD"
