#!/usr/bin/env python3
"""
FlyDocs Hook: stop-gate.py
Triggered: Stop (every time the agent finishes responding)
Purpose: Gate agent completion on workflow state — blocks finish if issue
         is still In Progress, warns if acceptance criteria are incomplete.

Exit codes:
  0 - Allow stop (optional JSON output parsed)
  2 - Block stop (stderr shown to Claude as instruction)
"""

import json
import os
import re
import sys
from pathlib import Path

DEBUG_HOOK = os.environ.get('DEBUG_HOOK', '0') == '1'
SCRIPT_DIR = Path(__file__).parent.resolve()
DEBUG_LOG = SCRIPT_DIR.parent / 'logs' / 'hook-debug.log'

# FLY-1272: the status vocabulary lives in one place. Appended, not inserted,
# so the workflow scripts directory can never shadow the standard library.
sys.path.append(
    str(SCRIPT_DIR.parent / 'skills' / 'flydocs-workflow' / 'scripts')
)
from status_vocab import GATED_STATUSES, normalize  # noqa: E402


def debug_log(message: str) -> None:
    """Write debug message to log file if DEBUG_HOOK is enabled."""
    if not DEBUG_HOOK:
        return
    try:
        DEBUG_LOG.parent.mkdir(parents=True, exist_ok=True)
        from datetime import datetime
        timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        with open(DEBUG_LOG, 'a') as f:
            f.write(f'[{timestamp}] [stop-gate] {message}\n')
    except (OSError, IOError):
        pass

ISSUE_ID_PATTERN = re.compile(r"[A-Z]+-[0-9]+")
CHECKBOX_DONE = re.compile(r"- \[x\]", re.IGNORECASE)
CHECKBOX_ALL = re.compile(r"- \[[ x]\]", re.IGNORECASE)
# FLY-1087: `- [ ] (deferred: FLY-1234) ...` — the issue ref is mandatory.
DEFERRED_CRITERION = re.compile(
    r"- \[ \]\s*\(\s*deferred:\s*([A-Z][A-Z0-9]*-\d+)\s*\)", re.IGNORECASE
)


def read_file_safe(path):
    """Read file contents, returning None on any error."""
    try:
        return Path(path).read_text(encoding="utf-8")
    except (OSError, ValueError):
        return None


def extract_issue_id(text):
    """Extract first issue ID matching PROJ-123 pattern."""
    match = ISSUE_ID_PATTERN.search(text)
    return match.group(0) if match else None


def count_criteria(description: str) -> tuple[int, int]:
    """Return (checked, total) acceptance-criteria checkboxes in a description."""
    return (
        len(CHECKBOX_DONE.findall(description)),
        len(CHECKBOX_ALL.findall(description)),
    )


def count_deferred(description: str) -> int:
    """Count criteria explicitly deferred to another issue (FLY-1087).

    Syntax: `- [ ] (deferred: FLY-1234) criterion text`

    Not every unchecked criterion is unfinished work. Some are only partly met,
    with the remainder carried to a named issue and the reasoning written into
    the review. Treating those as failures leaves two ways out — tick a box that
    is not true, or move the issue out of REVIEW — and both corrupt the state
    the gate exists to protect. A gate satisfiable only by lying to it teaches
    people to lie to it.

    The issue reference is **required**. A bare "(deferred)" does not qualify,
    because a deferral with no destination is just an unfinished criterion with
    better manners. The criterion also stays visibly unchecked, so the board
    never claims work that did not happen.
    """
    return len(DEFERRED_CRITERION.findall(description))


def read_tier() -> str:
    """Read the workspace tier from the config the WRITER will use.

    Resolution goes through `flydocs_api.find_project_root()` — the same
    resolver `issues.py` runs — rather than the hook's own `resolve_repo_dir()`.
    The two disagree exactly where it hurts: in a multi-repo workspace with no
    `active-repo` pointer (written only by the file-edit hook, so a session can
    reach REVIEW without one) `resolve_repo_dir()` can land on the WORKSPACE
    ROOT, whose `.flydocs/config.json` is absent or a pre-multi-repo leftover
    that `find_project_root` explicitly refuses to trust (FLY-723) — while the
    commands this message prints resolve to the child repo and its real tier.

    Returns "unknown" when no config can be read. Unknown must not collapse to
    "local": guessing cloud on a local workspace fails self-describingly (the
    acceptance route prints the local instruction itself), while guessing local
    on a cloud workspace prints a tokenless --file rewrite that hard-fails,
    plus two false claims — a message with no working route in it (FLY-1473).

    Best-effort — the gate never fails on this.
    """
    try:
        # Imported here, not at module load: the gate reaches this only on the
        # unmet-criteria path, and the hook must stay cheap on every other
        # Stop. `sys.path` already carries the scripts directory.
        from flydocs_api import find_project_root
        config = json.loads(
            (Path(find_project_root()) / ".flydocs" / "config.json").read_text()
        )
        tier = config.get("tier")
        return tier if isinstance(tier, str) and tier else "unknown"
    except Exception:
        return "unknown"


def render_unmet_hint(
    issue_id: str, done: int, deferred: int, total: int, tier: str
) -> str:
    """Build the REVIEW-gate message for unmet acceptance criteria.

    Tier decides which route leads, because the routes are not both available:

    - **cloud** — criteria are addressed by number through the acceptance
      route: line-surgical, carrying its own revision token, and the only
      sanctioned way to change a checkbox (AGENTS.md hard rule 4). Surfaced MCP
      first, runner second, raw script last (rule 1). The description rewrite
      stays as the fallback for wrong criterion prose.
    - **local** — `issues.py acceptance` hard-fails off cloud (its merge runs
      on the relay), so the description rewrite leads, without the
      `--expected-revision` the local backend ignores.
    - **anything else, including an unread tier** — lead with cloud and say the
      tier is unread. Cloud-on-local is a self-describing failure; local-on-
      cloud is a dead end (see `read_tier`).

    Placeholders are single tokens (`<PATH>`, `<REVISION>`, `<NAME>`) and
    criterion numbers stay abstract (`N`, `FLY-xxxx`). A printed
    `--defer 4:FLY-1234` is a *working* command that defers a criterion to
    whatever issue that ref happens to be, and `count_deferred` then counts it
    as accounted for — the gate would be publishing its own bypass.

    Split out of main() so the wording is testable without a live Stop event.
    """
    unmet = total - done - deferred
    script = "python3 .claude/skills/flydocs-workflow/scripts/issues.py"
    deferred_note = f", {deferred} deferred" if deferred else ""

    summary = (
        f"Issue {issue_id}: {unmet} of {total} acceptance criteria unmet "
        f"({done} checked{deferred_note}, read live from the issue). Verify "
        "them, or defer them to an issue that carries the remainder.\n"
    )

    if tier == "local":
        route = (
            "This workspace is on the local tier, where criteria are edited "
            "in the description:\n"
            f"  flydocs run issue.description {issue_id} --file <PATH> "
            "[--repo <NAME>]\n"
            "Last resort if the runner is unavailable:\n"
            f"  {script} description {issue_id} --file <PATH>\n"
            "<PATH> holds the FULL updated description — the write replaces "
            f"it, so re-read `issues.py get {issue_id}` and edit that text. "
            "Pass no --expected-revision: it is a relay concept, ignored "
            "here. Pass --repo <NAME> when the working directory is not "
            "inside the repo you mean — a workspace root, a worktree, a "
            "shared directory. The acceptance route (issue_acceptance_update, "
            "flydocs run issue.acceptance) is cloud-only — its criterion "
            "merge runs on the relay.\n"
        )
    else:
        route = (
            "Change the boxes by criterion number, never by rewriting the "
            "description — MCP first:\n"
            '  issue_acceptance_update — criterionId N, status "checked" (or '
            '"deferred" with deferredTo FLY-xxxx)\n'
            f"  flydocs run issue.acceptance {issue_id} --check N "
            "[--repo <NAME>]\n"
            f"  flydocs run issue.acceptance {issue_id} --defer N:FLY-xxxx "
            "[--repo <NAME>]\n"
            "Last resort if the runner is unavailable:\n"
            f"  {script} acceptance {issue_id} --check N\n"
            f"N is the criterion number from `issues.py get {issue_id}` "
            "(comma-separate for several: --check N,N); this route sends the "
            "revision token for you. Pass --repo <NAME> when the working "
            "directory is not inside the repo you mean — a workspace root, a "
            "worktree, a shared directory.\n"
            "Only if the criterion PROSE itself is wrong, rewrite the "
            "description instead:\n"
            f"  flydocs run issue.description {issue_id} --file <PATH> "
            "--expected-description-hash <HASH> [--repo <NAME>]\n"
            "  <PATH> holds the rewritten description; <HASH> is the "
            f"`descriptionHash` from that same `issues.py get {issue_id}` — on "
            "cloud a --file rewrite is refused without a guard. "
            "--expected-revision <REVISION> (the `revision` field from the "
            "same read) also satisfies the refusal, but it is a last-modified "
            "timestamp, so it also refuses after a status change that touched "
            "no prose — which is what you just did (FLY-1468, FLY-1470).\n"
        )
        if tier != "cloud":
            route += (
                "Tier unread: if this workspace is on the local tier the "
                "acceptance route will say so — edit the description with "
                f"`flydocs run issue.description {issue_id} --file <PATH>` "
                "instead.\n"
            )

    # Printed on every unmet message, deferrals in use or not: this is the
    # contract that keeps a deferral honest, not a syntax reminder that has
    # served its purpose once one marker exists (FLY-1087).
    deferral_contract = (
        "A deferral needs a destination issue that actually carries the "
        "remainder: the criterion reads `- [ ] (deferred: FLY-xxxx) ...`, the "
        "box stays unchecked, and this gate then counts it as accounted for."
    )

    return summary + route + deferral_contract


def fetch_live_description(issue_id: str, repo_dir) -> str | None:
    """Read acceptance criteria from the live issue, not a local snapshot.

    Checkboxes live in the issue description (workflow hard rule #4), which is
    the only place they are authoritative. The former local snapshot was
    written once at IMPLEMENTING and never refreshed, so criteria ticked after
    that point were invisible and the gate blocked work that was genuinely
    finished (FLY-1065). Reading live is also what keeps `issues.py acceptance`
    (FLY-1265) working here for free: it writes to the description on the
    relay, and this reads the description from the relay.

    Returns the live description, or None when the provider cannot be reached —
    callers must degrade to a warning rather than blocking on unknown state.
    The raw description is returned rather than counts so callers can also
    detect deferral markers (FLY-1087).
    """
    script = SCRIPT_DIR.parent / "skills" / "flydocs-workflow" / "scripts" / "issues.py"
    if not script.exists():
        return None
    try:
        import subprocess
        result = subprocess.run(
            [sys.executable, str(script), "get", issue_id],
            capture_output=True, text=True, timeout=20, cwd=str(repo_dir),
        )
        if result.returncode != 0:
            debug_log(f"live AC fetch failed rc={result.returncode}")
            return None
        issue = json.loads(result.stdout)
        description = issue.get("description") or ""
    except (subprocess.TimeoutExpired, subprocess.SubprocessError,
            json.JSONDecodeError, ValueError, OSError) as exc:
        debug_log(f"live AC fetch error: {type(exc).__name__}")
        return None
    return description


def main():
    # -- Parse stdin --
    try:
        input_data = json.loads(sys.stdin.read())
    except (json.JSONDecodeError, ValueError):
        input_data = {}

    # -- Loop guard: if the hook itself triggered this stop, exit cleanly --
    if input_data.get("stop_hook_active"):
        sys.exit(0)

    # -- Resolve working directory --
    cwd = input_data.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR", "")
    if not cwd:
        sys.exit(0)

    try:
        os.chdir(cwd)
    except OSError:
        sys.exit(0)

    # Resolve active repo for sibling-repos topology
    from repo_context import resolve_repo_dir, resolve_session_dir
    repo_dir = resolve_repo_dir()
    session_dir = resolve_session_dir(repo_dir)

    # -- Check for active focus --
    focus_text = read_file_safe(session_dir / "focus.md")
    if not focus_text:
        sys.exit(0)

    issue_id = extract_issue_id(focus_text)
    if not issue_id:
        sys.exit(0)

    # -- Read current status --
    #
    # FLY-1471: a focused issue with no recorded status is a real state, not
    # an empty one — the drifted-pair clear in `issues.py` leaves it, and so
    # does an unresolvable relay reply (FLY-1356). The gate genuinely has
    # nothing to judge, and every other consumer stands down on it too, so
    # the session is running with its workflow enforcement off. That is worth
    # one line. No focus.md at all is a different thing entirely — an idle
    # session, nothing claimed, nothing to say — and exits above in silence.
    status_text = read_file_safe(session_dir / "status")
    if not status_text:
        print(json.dumps({"systemMessage": (
            "FlyDocs: {} is focused but has no recorded status — the Stop "
            "gate is off. Transition {} to re-arm it."
        ).format(issue_id, issue_id)}))
        sys.exit(0)

    # `status` describes the issue named by `status-ref`, which is not
    # necessarily the focused one. Gating on a mismatched pair blocks or warns
    # on a status the focused issue never had (FLY-1064). This is the
    # highest-consequence reader of the pair, so it fails open on mismatch.
    #
    # FLY-1471: failing open used to fail silent. The only record was
    # `debug_log`, which writes nothing unless DEBUG_HOOK=1, so a session
    # could lose its Stop gate for hours with nothing to show for it.
    #
    # It is said as a `systemMessage` on stdout, the channel this file already
    # uses for its BLOCKED and unreadable-criteria paths — hook stderr is
    # shown to the agent only on exit 2, and exit 2 is precisely what must not
    # happen here: the gate has no verdict, and blocking on the absence of one
    # is the failure FLY-1064 removed.
    #
    # `systemMessage` reaches the USER, not the model, and that is the right
    # audience: at Stop, the only way to hand the agent something is to keep
    # the turn alive (`decision: "block"` with a `reason`), which is the block
    # this path exists to refuse. The person is also the one who can act — the
    # repair is a transition on their board, and they are the one whose gate
    # just stood down. The agent finds out the ordinary way, from the mirror
    # itself, on its next turn through `prompt-submit.py`.
    #
    # Not filtered by `GATED_STATUSES`, deliberately: that status belongs to
    # the OTHER issue, so it is no evidence about whether the gate would have
    # had a rule for the focused one — whose real status is exactly what is
    # unknown here. Filtering on it would go quiet precisely when the other
    # issue is closed, which is the commonest way to reach this state (a
    # review-and-close sweep) and the case the note exists for. The condition
    # is already narrow enough not to nag: it needs a focused issue AND a
    # status file, so an idle or wrapped session never reaches it.
    status_ref = read_file_safe(session_dir / "status-ref")
    if not status_ref or status_ref.strip().upper() != issue_id.upper():
        tracked = (status_ref or "").strip().upper() or "(none)"
        debug_log(
            f"status-ref {status_ref!r} does not match focus {issue_id} — "
            f"skipping gate rather than judging a mismatched pair"
        )
        print(json.dumps({"systemMessage": (
            "FlyDocs: the session status describes {} but the focused issue "
            "is {} — the Stop gate was skipped rather than judging one issue "
            "by another's status. Transition {} to resync."
        ).format(tracked, issue_id, issue_id)}))
        sys.exit(0)

    status = status_text.strip().upper()
    debug_log(f"Issue {issue_id} status={status}")

    # The gate has a rule for four statuses (status_vocab.GATED_STATUSES); for
    # anything else — a terminal state, a triage inbox, a status this install
    # has never heard of — it has nothing to say. Naming the set rather than
    # falling through four `if`s means a status added to the vocabulary is an
    # explicit decision here, not an accident (FLY-1272).
    if normalize(status) not in GATED_STATUSES:
        sys.exit(0)

    # -- READY: warn that transition to IMPLEMENTING was missed --
    if status == "READY":
        # Check if any code edits happened (heuristic: git has uncommitted changes)
        try:
            import subprocess
            # Exit code 1 means dirty; 128 (not a git repo, e.g. a multi-repo
            # workspace root) and other failures must not count as changes.
            result = subprocess.run(
                ["git", "diff", "--quiet"], capture_output=True, timeout=5,
                cwd=repo_dir,
            )
            has_changes = result.returncode == 1
        except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
            has_changes = False

        if has_changes:
            msg = (
                "Issue {} is still in READY but code changes were made. "
                "Transition to IMPLEMENTING before continuing:\n"
                "  python3 .claude/skills/flydocs-workflow/scripts/issues.py "
                "transition {} IMPLEMENTING \"Starting implementation\""
            ).format(issue_id, issue_id)
            sys.stderr.write(msg)
            sys.exit(2)

    # -- IMPLEMENTING: block only when code changes exist --
    if status == "IMPLEMENTING":
        try:
            import subprocess
            # Exit code 1 means dirty; 128 (not a git repo) must not count.
            result = subprocess.run(
                ["git", "diff", "--quiet"], capture_output=True, timeout=5,
                cwd=repo_dir,
            )
            has_changes = result.returncode == 1
            if not has_changes:
                # Also check staged changes
                result = subprocess.run(
                    ["git", "diff", "--cached", "--quiet"],
                    capture_output=True, timeout=5, cwd=repo_dir,
                )
                has_changes = result.returncode == 1
        except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
            has_changes = False

        if has_changes:
            msg = (
                "Issue {} is still IMPLEMENTING. "
                "Transition to REVIEW before finishing:\n"
                "  python3 .claude/skills/flydocs-workflow/scripts/issues.py "
                "transition {} REVIEW \"Implementation complete\""
            ).format(issue_id, issue_id)
            sys.stderr.write(msg)
            sys.exit(2)

    # -- REVIEW: block on incomplete acceptance criteria --
    if status == "REVIEW":
        live_description = fetch_live_description(issue_id, repo_dir)
        if live_description is None:
            # Unknown state is not evidence of incompleteness. Say so and let
            # the agent finish rather than blocking on a provider outage.
            print(json.dumps({"systemMessage": (
                "Could not read live acceptance criteria for {} — the gate was "
                "skipped, not passed. Verify the criteria in the issue before "
                "closing it."
            ).format(issue_id)}))
            sys.exit(0)

        done, total = count_criteria(live_description)
        # FLY-1087: a criterion deferred to a named issue is accounted for, not
        # unfinished. A plain unchecked box still blocks exactly as before.
        deferred = count_deferred(live_description or "")
        if total > 0 and (done + deferred) < total:
            # The acceptance route the hint leads with exists only on
            # cloud, so the message has to know the tier — resolved the way
            # the commands it prints will resolve it (FLY-1473).
            sys.stderr.write(
                render_unmet_hint(issue_id, done, deferred, total, read_tier())
            )
            sys.exit(2)

    # -- BLOCKED: inform that issue needs unblocking --
    if status == "BLOCKED":
        result = {
            "systemMessage": (
                "Issue {} is BLOCKED. Resolve the blocker before continuing, "
                "then transition back to IMPLEMENTING."
            ).format(issue_id)
        }
        print(json.dumps(result))

    sys.exit(0)


if __name__ == "__main__":
    main()
