#!/usr/bin/env python3
"""Pre-commit gate for context-guard.

Rejects commits touching more than N files when nothing shows the
PLAN -> EXECUTE -> VERIFY protocol was ever engaged for this context.
"""
import json
import os
import subprocess
import sys
from datetime import datetime

DEFAULT_FILE_THRESHOLD = 2
BYPASS_LOG = ".context-guard/bypass.log"
GUARD_DIR = ".context-guard"


def repo_root():
    return subprocess.check_output(
        ["git", "rev-parse", "--show-toplevel"], text=True
    ).strip()


def staged_files():
    out = subprocess.check_output(
        ["git", "diff", "--cached", "--name-only"], text=True
    )
    return [f for f in out.splitlines() if f.strip()]


def manifest_paths(root):
    """Every manifest in the context, newest layout first.

    Since the multi-change refactor state lives in
    .context-guard/changes/{name}/manifest.json. The flat path is still read so
    a 1.x repo that has not run `cg migrate` yet is not blocked by an upgrade
    of the hook alone.
    """
    base = os.path.join(root, ".context-guard")
    paths = []
    changes_dir = os.path.join(base, "changes")
    if os.path.isdir(changes_dir):
        for entry in sorted(os.listdir(changes_dir)):
            if entry == "archive":
                continue
            candidate = os.path.join(changes_dir, entry, "manifest.json")
            if os.path.exists(candidate):
                paths.append(candidate)
    flat = os.path.join(base, "manifest.json")
    if os.path.exists(flat):
        paths.append(flat)
    return paths


def load_manifests(root):
    """Parse every manifest, skipping the ones we cannot read.

    A corrupt manifest is a context-guard problem; letting it raise here would
    make it a git problem too and leave the user unable to commit the fix.
    """
    manifests = []
    for path in manifest_paths(root):
        try:
            with open(path) as f:
                manifests.append(json.load(f))
        except (OSError, ValueError):
            continue
    return manifests


def file_threshold(manifests):
    """How many staged files are allowed outside the protocol.

    Precedence: the environment wins, then the manifests, then the default. The
    env var is the per-invocation escape hatch, so committed configuration must
    not be able to override it.

    Across changes the strictest configured value wins. Picking "the first one"
    would resolve a repo-wide policy by directory order; the minimum is
    order-independent and errs toward asking rather than toward silence.
    """
    from_env = os.environ.get("CONTEXT_GUARD_FILE_THRESHOLD")
    if from_env is not None:
        try:
            return int(from_env)
        except ValueError:
            pass

    configured = []
    for manifest in manifests:
        value = manifest.get("hook", {}).get("file_threshold")
        try:
            configured.append(int(value))
        except (TypeError, ValueError):
            # A typo in the manifest must not silently disable the hook.
            continue
    return min(configured) if configured else DEFAULT_FILE_THRESHOLD


def out_of_scope(manifests, files):
    """Staged files that no executing change declared as its scope.

    Advisory only, per PLAN.md F4. Blocking here would put the hook in the way
    of the user's own flow on a field the agent fills in by hand, and a soft
    check that hardens into a blocker is how you end up with an agent that
    cannot commit.

    Only changes actually in EXECUTE are considered: in PLAN the scope has not
    been decided yet, so every file would be "out" of it and the warning would
    be noise from the first commit.
    """
    scopes = []
    for manifest in manifests:
        if manifest.get("lock_phase") != "EXECUTE":
            continue
        scopes.extend(manifest.get("files_in_scope") or [])
    if not scopes:
        # Not declared is not the same as "nothing is allowed" — the manifest
        # ships with files_in_scope empty.
        return []

    stray = []
    for path in files:
        # The guard's own state is written by every phase; warning about it on
        # every commit is how a soft check gets tuned out.
        if path == GUARD_DIR or path.startswith(GUARD_DIR + "/"):
            continue
        if any(path == s or path.startswith(s.rstrip("/") + "/") for s in scopes):
            continue
        stray.append(path)
    return stray


def protocol_engaged(manifests):
    """True if any change is doing the work.

    One engaged change is enough: the staged files belong to whichever change
    is active, and the hook cannot tell which without guessing — the
    alphabetical guess PLAN.md 1.3 forbids by name.
    """
    for manifest in manifests:
        txn = manifest.get("transaction", {})
        if manifest.get("completed_phases") or txn.get("txn_status") == "in_progress":
            return True
    return False


def log_bypass(root, files, reason):
    path = os.path.join(root, ".context-guard")
    os.makedirs(path, exist_ok=True)
    with open(os.path.join(path, "bypass.log"), "a") as f:
        f.write(f"{datetime.now().isoformat()}|{reason}|files={len(files)}|{','.join(files)}\n")


def main():
    root = repo_root()
    files = staged_files()
    manifests = load_manifests(root)

    # Advisory, and checked on every commit regardless of size: a one-file
    # change outside the declared scope is just as worth mentioning, and this
    # branch never affects the exit code.
    stray = out_of_scope(manifests, files)
    if stray:
        print(
            f"[context-guard] WARN: {len(stray)} staged file(s) outside "
            f"files_in_scope: {', '.join(stray)}",
            file=sys.stderr,
        )

    threshold = file_threshold(manifests)
    if len(files) <= threshold:
        sys.exit(0)

    if protocol_engaged(manifests):
        sys.exit(0)

    if os.environ.get("CONTEXT_GUARD_BYPASS") == "1":
        reason = os.environ.get("CONTEXT_GUARD_BYPASS_REASON", "unspecified")
        log_bypass(root, files, reason)
        print(f"[context-guard] BYPASS recorded in {BYPASS_LOG}", file=sys.stderr)
        sys.exit(0)

    print(
        f"[context-guard] COMMIT REJECTED: {len(files)} files staged "
        f"(threshold={threshold}) with no phase engaged.\n"
        f"  -> Start the protocol first: cg new <change> or cg begin --phase <PHASE>.\n"
        f"  -> Escape hatch: CONTEXT_GUARD_BYPASS=1 CONTEXT_GUARD_BYPASS_REASON='...' git commit ...\n",
        file=sys.stderr,
    )
    sys.exit(1)

if __name__ == "__main__":
    main()
