#!/usr/bin/env python3
"""Behavioral tests for checkout-health-guard.py (checkout-health-hardening §1, 14 cases).

Each case builds a temp bare origin + clone under a temp HOME so the hook's
Path.home()-anchored REPO/LEASE_FILE resolve into the sandbox, then runs the hook
as a subprocess (real git, real stdin) and asserts on HEAD movement / output.
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path

HOOK = str(Path(__file__).resolve().parent / "checkout-health-guard.py")
PASS = 0
FAIL = 0


def ok(msg):
    global PASS
    PASS += 1
    print(f"  OK: {msg}")
    return True  # MUST return truthy: assertions use `cond and ok(...) or no(...)`


def no(msg):
    global FAIL
    FAIL += 1
    print(f"  FAIL: {msg}")
    return False  # MUST return falsy so `... or no(...)` does not double-fire


def git(repo, *args, check=True):
    r = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True)
    if check and r.returncode != 0:
        raise RuntimeError(f"git {' '.join(args)} failed: {r.stderr}")
    return r


def head(repo):
    return git(repo, "rev-parse", "HEAD").stdout.strip()


def make_world():
    """Returns (home, repo, origin). repo is a clone of origin, on main, up to date."""
    home = Path(tempfile.mkdtemp(prefix="chg-home-"))
    origin = home / "origin.git"
    repo = home / "CLAUDE_PROJECTS"
    git(home, "init", "-q", "--bare", str(origin))
    seed = home / "_seed"
    git(home, "init", "-q", str(seed))
    git(seed, "config", "user.email", "t@t")
    git(seed, "config", "user.name", "t")
    (seed / "README").write_text("hi\n")
    git(seed, "add", "-A")
    git(seed, "commit", "-qm", "init")
    git(seed, "branch", "-M", "main")
    git(seed, "remote", "add", "origin", str(origin))
    git(seed, "push", "-q", "-u", "origin", "main")
    git(origin, "symbolic-ref", "HEAD", "refs/heads/main")
    git(home, "clone", "-q", str(origin), str(repo))
    git(repo, "config", "user.email", "t@t")
    git(repo, "config", "user.name", "t")
    (home / ".claude" / "state").mkdir(parents=True, exist_ok=True)
    return home, repo, origin


def advance_origin(home, origin, n=1):
    """Push n new commits to origin/main from a throwaway clone."""
    tmp = home / f"_adv-{time.time_ns()}"
    git(home, "clone", "-q", str(origin), str(tmp))
    git(tmp, "config", "user.email", "t@t")
    git(tmp, "config", "user.name", "t")
    for i in range(n):
        (tmp / f"f{time.time_ns()}-{i}").write_text("x")
        git(tmp, "add", "-A")
        git(tmp, "commit", "-qm", f"advance {i}")
    git(tmp, "push", "-q")


def run_hook(home, payload=None, bad_stdin=None, env_extra=None):
    env = dict(os.environ)
    env["HOME"] = str(home)
    # The hook resolves REPO/lease via CANONICAL_REPO / CHECKOUT_LEASE_STATE_DIR (env-overridable,
    # not hard-coded). Pin them to the sandbox so the test is independent of the ambient environment
    # AND exercises the override path (these equal the under-HOME defaults).
    env["CANONICAL_REPO"] = str(home / "CLAUDE_PROJECTS")
    env["CHECKOUT_LEASE_STATE_DIR"] = str(home / ".claude" / "state")
    if env_extra:
        env.update(env_extra)
    stdin = bad_stdin if bad_stdin is not None else json.dumps(payload or {"source": "startup"})
    return subprocess.run([sys.executable, HOOK], input=stdin, env=env,
                          capture_output=True, text=True, timeout=30)


def write_live_checkout_lease(home, session_id, transcript="", expires_epoch=None):
    """Write the EXACT schema produced by live ~/.claude/hooks/checkout-lease-gate.py._write_lease."""
    now = int(time.time())
    lease = {
        "host": "testhost",
        "session_id": session_id,
        "transcript": transcript,
        "repo": str(home / "CLAUDE_PROJECTS"),
        "heartbeat": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)),
        "expires_epoch": expires_epoch if expires_epoch is not None else now + 600,
    }
    (home / ".claude" / "state" / "checkout-lease.json").write_text(json.dumps(lease))


def cleanup(home):
    shutil.rmtree(home, ignore_errors=True)


# 1. clean main behind-only -> exits 0 and HEAD == origin/main
home, repo, origin = make_world()
advance_origin(home, origin, 2)
r = run_hook(home)
git(repo, "fetch", "-q", "origin")
target = git(repo, "rev-parse", "origin/main").stdout.strip()
(r.returncode == 0 and head(repo) == target) and ok("clean main behind-only fast-forwards") \
    or no(f"behind-only did not ff (rc={r.returncode}, head={head(repo)}, target={target})")
cleanup(home)

# 2. clean main up-to-date -> exits 0, no mutation
home, repo, origin = make_world()
before = head(repo)
r = run_hook(home)
(r.returncode == 0 and head(repo) == before) and ok("up-to-date no mutation") \
    or no("up-to-date mutated or errored")
cleanup(home)

# 3. stray branch behind main -> branch unchanged, warning names branch
home, repo, origin = make_world()
advance_origin(home, origin, 1)
git(repo, "checkout", "-q", "-b", "feature/x")
before = head(repo)
r = run_hook(home)
(r.returncode == 0 and head(repo) == before and "feature/x" in r.stdout) \
    and ok("stray branch left alone + named in warning") or no("stray branch mishandled")
cleanup(home)

# 4. dirty tracked file -> file + HEAD unchanged
home, repo, origin = make_world()
advance_origin(home, origin, 1)
(repo / "README").write_text("DIRTY\n")
before = head(repo)
r = run_hook(home)
(r.returncode == 0 and head(repo) == before and (repo / "README").read_text() == "DIRTY\n") \
    and ok("dirty tracked file preserved, no ff") or no("dirty tracked file mishandled")
cleanup(home)

# 5. untracked file -> preserved, HEAD unchanged
home, repo, origin = make_world()
advance_origin(home, origin, 1)
(repo / "scratch.tmp").write_text("keep")
before = head(repo)
r = run_hook(home)
(r.returncode == 0 and head(repo) == before and (repo / "scratch.tmp").exists()) \
    and ok("untracked file preserved, no ff") or no("untracked file mishandled")
cleanup(home)

# 6. local ahead commit -> preserved, no reset
home, repo, origin = make_world()
(repo / "local.txt").write_text("local")
git(repo, "add", "-A")
git(repo, "commit", "-qm", "local ahead")
before = head(repo)
r = run_hook(home)
(r.returncode == 0 and head(repo) == before) and ok("local-ahead preserved, no ff/reset") \
    or no("local-ahead mishandled")
cleanup(home)

# 7. divergent ahead+behind -> no merge
home, repo, origin = make_world()
advance_origin(home, origin, 1)
(repo / "local.txt").write_text("local")
git(repo, "add", "-A")
git(repo, "commit", "-qm", "local ahead")
before = head(repo)
r = run_hook(home)
(r.returncode == 0 and head(repo) == before and "DIVERGENT" in r.stdout) \
    and ok("divergent not merged") or no("divergent mishandled")
cleanup(home)

# 8. origin unreachable -> no mutation
home, repo, origin = make_world()
advance_origin(home, origin, 1)
git(repo, "remote", "set-url", "origin", str(home / "nonexistent-origin.git"))
before = head(repo)
r = run_hook(home)
(r.returncode == 0 and head(repo) == before) and ok("origin-unreachable no mutation") \
    or no("origin-unreachable mishandled")
cleanup(home)

# 9. different live checkout lease -> no merge
home, repo, origin = make_world()
advance_origin(home, origin, 1)
write_live_checkout_lease(home, "OTHER-SESSION")  # live by TTL, real lease schema
before = head(repo)
r = run_hook(home, payload={"source": "startup", "session_id": "ME"})
(r.returncode == 0 and head(repo) == before) and ok("different live holder blocks ff") \
    or no("different live holder did not block ff")
cleanup(home)

# 10. same session lease (or no lease) -> behind-only auto-ff allowed
home, repo, origin = make_world()
advance_origin(home, origin, 1)
write_live_checkout_lease(home, "ME")
r = run_hook(home, payload={"source": "startup", "session_id": "ME"})
git(repo, "fetch", "-q", "origin")
target = git(repo, "rev-parse", "origin/main").stdout.strip()
(r.returncode == 0 and head(repo) == target) and ok("own-lease still allows ff") \
    or no("own-lease blocked ff")
cleanup(home)

# 11. malformed hook stdin -> exits 0
home, repo, origin = make_world()
r = run_hook(home, bad_stdin="this is not json{{{")
(r.returncode == 0) and ok("malformed stdin exits 0") or no(f"malformed stdin rc={r.returncode}")
cleanup(home)

# 12. lease parity: fresh foreign holder in the live checkout-lease-gate.py schema blocks ff
home, repo, origin = make_world()
advance_origin(home, origin, 1)
transcript = home / "foreign-transcript.jsonl"
transcript.write_text("activity\n")
os.utime(transcript, None)
write_live_checkout_lease(home, "FOREIGN-LIVE", transcript=str(transcript), expires_epoch=int(time.time()) - 1)
before = head(repo)
r = run_hook(home, payload={"source": "startup", "session_id": "ME"})
(r.returncode == 0 and head(repo) == before) and ok("real lease schema + fresh foreign transcript blocks ff") \
    or no("real live lease schema was misclassified as safe")
cleanup(home)

# 13. stash present -> no ff even if clean/behind
home, repo, origin = make_world()
(repo / "stash.txt").write_text("stashed\n")
git(repo, "add", "-A")
git(repo, "stash", "push", "-qm", "keep-stash")
advance_origin(home, origin, 1)
before = head(repo)
r = run_hook(home)
(r.returncode == 0 and head(repo) == before and "stash present" in r.stdout) \
    and ok("stash-present checkout does not auto-ff") or no("stash-present checkout auto-ffed")
cleanup(home)

# 14. stash list failure -> fail closed, no ff while stash state is UNKNOWN
home, repo, origin = make_world()
advance_origin(home, origin, 1)
fakebin = home / "fakebin"
fakebin.mkdir()
real_git = shutil.which("git")
(fakebin / "git").write_text(f"""#!/usr/bin/env bash
if [ "$1" = "-C" ] && [ "$3" = "stash" ] && [ "$4" = "list" ]; then
  exit 42
fi
exec "{real_git}" "$@"
""")
(fakebin / "git").chmod(0o755)
before = head(repo)
r = run_hook(home, env_extra={"PATH": str(fakebin) + os.pathsep + os.environ["PATH"]})
(r.returncode == 0 and head(repo) == before and "stash state UNKNOWN" in r.stdout) \
    and ok("stash-read failure is unsafe and does not auto-ff") or no("stash-read failure allowed auto-ff")
cleanup(home)

print("--------")
print(f"PASS={PASS}  FAIL={FAIL}")
sys.exit(0 if FAIL == 0 else 1)
