#!/usr/bin/env python3
"""
FlyDocs Hook: post-transition-check.py
Triggered: PostToolUse (Bash)
Purpose: Validate transitions and manage session files

Fires after every Bash command. Only acts on `issues.py transition`
commands. Validates comment presence, flags unusual transitions, and
updates local session files to keep hooks in sync.

Exit codes:
  0 - Always (non-blocking)
"""

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 (  # noqa: E402
    DEFAULT_STATUS_MAPPING,
    is_unusual_transition,
    normalize,
)


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}] [post-transition] {message}\n')
    except (OSError, IOError):
        pass

TRANSITION_PATTERN = re.compile(
    r"issues\.py\s+transition\s+(\S+)\s+(\S+)(?:\s+(.+))?"
)

# The "is this edge unusual?" predicate and the fallback provider mapping are
# imported from status_vocab above. This hook used to keep its own copies, and
# its table had already fallen a state behind `issues.py` — it had no TRIAGE
# entry, so a transition out of a provider triage inbox resolved to
# "unrecognised status" and went unchecked (FLY-1272).
#
# FLY-1265: the predicate, not the raw table. Reading the table directly meant
# re-deriving "unusual" here, and the derivation had a hole — `from_status in
# VALID_TRANSITIONS` doubled as the "can work leave this state?" test, so once
# every canonical status became a key the terminal states started being judged,
# and a no-op re-run (COMPLETE -> COMPLETE, ARCHIVED -> ARCHIVED) warned about
# a transition the relay had just accepted as SAME_STATE.


def _resolve_repo_dir() -> Path:
    """Resolve the active repo directory, falling back to cwd."""
    try:
        from repo_context import resolve_repo_dir
        return Path(resolve_repo_dir())
    except Exception:
        return Path(".")


def _resolve_session_dir() -> Path:
    """Resolve the workspace-scoped session directory (FLY-674)."""
    try:
        from repo_context import resolve_repo_dir, resolve_session_dir
        repo_dir = resolve_repo_dir()
        return resolve_session_dir(repo_dir)
    except (ImportError, Exception):
        return Path(".flydocs/session/default")


def _load_status_mapping() -> dict[str, str]:
    """Load the workspace's canonical -> provider status mapping.

    The mapping in `.flydocs/config.json` is per-workspace data and stays
    there — but its keys are the vocabulary, so they are checked against
    `status_vocab` on the way in (FLY-1272). A key FlyDocs does not know is
    dropped rather than trusted: `canonical_status()` searches this mapping by
    provider name, and an unknown key would let it return a status no other
    consumer recognises.
    """
    try:
        config = json.loads(
            (_resolve_repo_dir() / ".flydocs" / "config.json").read_text()
        )
        mapping = config.get("statusMapping")
        if isinstance(mapping, dict) and mapping:
            known = {
                canonical: provider
                for canonical, provider in mapping.items()
                if normalize(str(canonical))
            }
            dropped = len(mapping) - len(known)
            if dropped:
                debug_log(
                    f"statusMapping has {dropped} key(s) outside the canonical "
                    f"vocabulary — ignoring them"
                )
            if known:
                return known
    except (OSError, json.JSONDecodeError, ValueError):
        pass
    return DEFAULT_STATUS_MAPPING


def canonical_status(status: str | None) -> str | None:
    """Map a status name to its canonical FlyDocs form.

    The relay reports provider-native names ("In Progress", "Backlog") while
    the transition table is keyed on canonical ones ("IMPLEMENTING", "BACKLOG").
    Accepts either form; returns None when the name is unrecognized so callers
    can stay silent instead of comparing against a guess.
    """
    if not status or not status.strip():
        return None
    raw = status.strip()
    known = normalize(raw)
    if known:
        return known
    for canonical, provider in _load_status_mapping().items():
        if isinstance(provider, str) and provider.strip().lower() == raw.lower():
            return canonical.upper()
    return None


def read_authoritative_statuses(
    input_data: dict,
) -> tuple[str | None, str | None, bool]:
    """Read the transition's real before/after state from its own output.

    `issues.py transition` prints the relay's response, which carries the
    authoritative previousStatus/newStatus for the issue actually transitioned
    (flydocs_api.py). Returns (previous, new, succeeded).

    NOTE: the PostToolUse payload key is `tool_response`, not `tool_result`
    (FLY-1064) — the earlier `tool_result` reads in this file never resolved,
    which is why no create audit ever reported an auto-resolved field.
    """
    response = input_data.get("tool_response")
    if isinstance(response, str):
        stdout = response
    elif isinstance(response, dict):
        stdout = response.get("stdout") or ""
    else:
        stdout = ""
    if not stdout.strip():
        return None, None, True

    # The response may be preceded by human-readable notes on stderr/stdout,
    # so scan for the JSON object rather than assuming it starts at byte 0.
    for line in reversed(stdout.splitlines()):
        line = line.strip()
        if not line.startswith("{"):
            continue
        try:
            result = json.loads(line)
        except (json.JSONDecodeError, ValueError):
            continue
        if not isinstance(result, dict) or "issue" not in result:
            continue
        succeeded = result.get("success", True) is not False
        return (
            canonical_status(result.get("previousStatus")),
            canonical_status(result.get("actualStatus") or result.get("newStatus")),
            succeeded,
        )
    return None, None, True


def read_cached_status(ref: str) -> str | None:
    """Read the mirrored status, but only when it belongs to THIS issue.

    The mirror is a single file recording the last status *anything* moved to,
    so pairing it with an unrelated ref fabricates a state that never existed
    (FLY-1064). `status-ref` records which issue the mirror describes; without
    a match the honest answer is "unknown".
    """
    session_dir = _resolve_session_dir()
    try:
        tracked_ref = (session_dir / "status-ref").read_text().strip().upper()
    except (OSError, ValueError):
        return None
    if tracked_ref != ref.strip().upper():
        return None
    try:
        return canonical_status(
            (session_dir / "status").read_text().strip()
        )
    except (OSError, ValueError):
        return None


def find_created_issue(input_data: dict) -> dict | None:
    """Return the create response if this command actually created an issue.

    Matching on the command text alone is unreliable: the words "issues.py
    create" appear in `--help` invocations, inside heredocs, and inside comment
    bodies passed to *other* subcommands. Each of those produced a bogus audit
    (FLY-1066). The output is the only trustworthy evidence a create happened —
    a create response carries both `identifier` and `url`, which `get` and
    `transition` responses do not.
    """
    response = input_data.get("tool_response")
    if isinstance(response, str):
        stdout = response
    elif isinstance(response, dict):
        stdout = response.get("stdout") or ""
    else:
        return None
    for line in reversed(stdout.splitlines()):
        line = line.strip()
        if not line.startswith("{"):
            continue
        try:
            result = json.loads(line)
        except (json.JSONDecodeError, ValueError):
            continue
        if isinstance(result, dict) and "identifier" in result and "url" in result:
            return result
    return None


def post_create_audit(command: str, input_data: dict) -> str | None:
    """Audit a newly created issue for completeness.

    Only runs when the output proves an issue was created; the command text is
    then inspected for the arguments that should have accompanied it.
    """
    warnings = []

    # Check for description presence in the command
    if "--description" not in command and "--description-file" not in command:
        if "--triage" not in command:
            warnings.append("Created without --description (script should have rejected this)")

    # Check for type
    if "--type" not in command:
        warnings.append("Created without --type")

    # Report fields the server resolved on our behalf. The create response is
    # already in hand from find_created_issue(); reading `tool_response` is
    # what makes this work at all — it previously read `tool_result`, a key the
    # payload never contains, so it never ran (FLY-1064).
    created = find_created_issue(input_data) or {}
    auto_resolved = created.get("autoResolved") or {}
    if auto_resolved:
        resolved_parts = [f"{k}={v}" for k, v in auto_resolved.items()]
        warnings.append(f"Auto-resolved: {', '.join(resolved_parts)}")

    # Track compliance score
    _update_compliance_score(has_findings=len(warnings) > 0, input_data=input_data)

    if not warnings:
        return None
    return "Post-create audit: " + " | ".join(warnings)


def _update_compliance_score(has_findings: bool, input_data: dict) -> None:
    """Track per-session compliance score in validation cache.

    Increments total_created and compliant_created counters. Resets
    when a new session is detected (different session_id).
    """
    cwd = input_data.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR", "")
    if not cwd:
        return
    cache_file = Path(cwd) / ".flydocs" / "validation-cache.json"
    try:
        cache = json.loads(cache_file.read_text()) if cache_file.exists() else {}
    except (json.JSONDecodeError, OSError):
        cache = {}

    compliance = cache.get("compliance", {"totalCreated": 0, "compliantCreated": 0})
    compliance["totalCreated"] = compliance.get("totalCreated", 0) + 1
    if not has_findings:
        compliance["compliantCreated"] = compliance.get("compliantCreated", 0) + 1
    total = compliance["totalCreated"]
    compliant = compliance["compliantCreated"]
    compliance["score"] = round(compliant / total * 100) if total > 0 else 100
    cache["compliance"] = compliance

    try:
        cache_file.parent.mkdir(parents=True, exist_ok=True)
        cache_file.write_text(json.dumps(cache, indent=2))
        debug_log(f"Compliance: {compliant}/{total} = {compliance['score']}%")
    except OSError:
        pass


def build_output(message: str) -> str:
    return json.dumps({"hookSpecificOutput": {
        "hookEventName": "PostToolUse",
        "additionalContext": message,
    }})


# FLY-1186: session-file writes (status, status-ref, focus.md, terminal-state
# cleanup) moved into `issues.py transition` itself — the script is the single
# writer. This hook used to duplicate them, but it only fired when it could
# regex the transition out of the Bash command AND re-parse the command's
# stdout; piped or truncated output silently defeated it, leaving focus.md
# stale while the script's own writes succeeded. Two writers, one fact —
# the disagreement cost a day of issue attribution. The hook keeps its
# validation role only.


def main() -> None:
    try:
        input_data = json.loads(sys.stdin.read())
    except (json.JSONDecodeError, ValueError):
        print("{}")
        sys.exit(0)

    if input_data.get("tool_name") != "Bash":
        print("{}")
        sys.exit(0)

    command = input_data.get("tool_input", {}).get("command", "")

    # Post-create audit — check if a new issue was created
    # Audit only when the output proves an issue was created. The old
    # substring test ("issues.py" and " create " anywhere in the command) fired
    # on `create --help`, on heredocs that merely contained those words, and on
    # transition commands whose *comment body* mentioned them (FLY-1066).
    if find_created_issue(input_data) is not None:
        audit_msg = post_create_audit(command, input_data)
        if audit_msg:
            print(build_output(audit_msg))
        else:
            print("{}")
        sys.exit(0)

    match = TRANSITION_PATTERN.search(command)

    if not match:
        print("{}")
        sys.exit(0)

    ref = match.group(1)
    status = match.group(2).upper()
    comment = match.group(3)

    if not comment or not comment.strip():
        msg = (
            f"Transition to {status} is missing a comment. "
            f"Every status transition requires a comment — add one now:\n"
            f'  python3 .claude/skills/flydocs-workflow/scripts/issues.py '
            f'comment {ref} "<COMMENT>"\n'
            f"<COMMENT> is one line saying what changed and why the issue is "
            f"in {status}."
        )
        print(build_output(msg))
        sys.exit(0)

    # Prefer the authoritative before/after state the transition itself
    # reported for THIS issue. Fall back to the local mirror only when it
    # provably describes the same issue; otherwise judge nothing (FLY-1064).
    prev_status, new_status, succeeded = read_authoritative_statuses(input_data)

    if not succeeded:
        # The transition failed — the mirror must not record a state the
        # provider never reached.
        print("{}")
        sys.exit(0)

    from_status = prev_status or read_cached_status(ref)
    # Record what actually happened, not what was requested — a fallback or
    # provider mapping can land somewhere else.
    effective_status = new_status or status

    if is_unusual_transition(from_status, effective_status):
        msg = (
            f"Unusual transition: {from_status} -> {effective_status}. "
            f"Verify this is intentional."
        )
        print(build_output(msg))
        sys.exit(0)

    # Session files are written by issues.py transition itself (FLY-1186) —
    # nothing to update here.
    print("{}")
    sys.exit(0)


if __name__ == "__main__":
    main()
