#!/usr/bin/env python3
"""
PreToolUse Hook: Auto-approve FlyDocs scripts + workflow state nudges

1. Auto-approves Bash commands that execute scripts in the unified skill:
   .claude/skills/flydocs-workflow/scripts/

2. Nudges the agent when Edit/Write is attempted but the active issue
   isn't in IMPLEMENTING status (non-blocking, informational only).

Exit codes:
- 0 with JSON: Approved or nudge context (decision in output)
- 0 with no output: No opinion, continue normally
- 2: Block (stderr shown to AI)
"""

import sys
import json
import os
import re
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 EDIT_OK_STATUSES, normalize  # noqa: E402
from atomic_io import atomic_write_text  # noqa: E402

# FLY-1471: what this gate has already said about the session's state, so it
# says it once per condition rather than once per Edit. Lives in the session
# dir with the state it describes, and is cleared by `session.py` at wrap
# along with everything else there.
MISMATCH_NOTICE = 'mirror-mismatch-notice'


def once_per_state(session_dir: Path, key: str, message: str) -> str | None:
    """Return `message` the first time this gate sees `key`, then None.

    The gate runs on every Edit and Write, and its answer is delivered as
    `additionalContext` — a line repeated on each one is per-turn cost the
    agent pays for. `key` names the state being reported, so a DIFFERENT
    drift is still news; `clear_state_notice` resets it the moment the state
    is healthy again, so the same drift recurring later is news too.

    Best-effort: an unwritable session dir means the note repeats, which is
    noisier but never wrong.
    """
    notice = session_dir / MISMATCH_NOTICE
    try:
        if notice.exists() and notice.read_text().strip() == key:
            return None
        atomic_write_text(notice, key)
    except (OSError, IOError):
        pass
    return message


def clear_state_notice(session_dir: Path) -> None:
    """Forget what was reported — the state it described is over."""
    try:
        (session_dir / MISMATCH_NOTICE).unlink(missing_ok=True)
    except (OSError, IOError):
        pass


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


# Pattern matches the unified flydocs-workflow scripts directory.
#
# Uses ^ and $ anchors to prevent injection via command chaining
# (e.g., "python3 script.py; evil" would match without anchors).
#
# Supports three invocation patterns:
#   1. Single-repo:         python3 .claude/skills/flydocs-workflow/scripts/X.py [args]
#   2. Multi-repo from root: python3 .claude/skills/flydocs-workflow/scripts/X.py [args]
#   3. Multi-repo from child: cd <child> && python3 ../.claude/skills/flydocs-workflow/scripts/X.py [args]
#   4. Absolute path:       python3 $CLAUDE_PROJECT_DIR/.claude/skills/...
#
# FLY-668: Pattern 3 (cd + parent-relative) is the one the agent uses in
# multi-repo workspaces because scripts need to be run from a child repo
# so that find_project_root() finds the child's .flydocs/ directory.
#
# The optional `cd <path> &&` prefix restricts <path> to safe characters
# (letters, digits, /, ., ~, -, _) so shell injection can't sneak through
# the cd argument.
APPROVED_PATTERN = re.compile(
    r'^'
    # Optional `cd <safe-path> && ` prefix for multi-repo child-repo invocations
    r'(?:cd\s+["\']?[\w./~-]+["\']?\s+&&\s+)?'
    r'python3?\s+'
    # Optional script path prefix: $CLAUDE_PROJECT_DIR, ${CLAUDE_PROJECT_DIR}, ., or ..
    r'(?:["\']?(?:\$CLAUDE_PROJECT_DIR|\$\{CLAUDE_PROJECT_DIR\}|\.\.?)["\']?/)?'
    r'\.?claude/skills/flydocs-workflow/scripts/\w+\.py'
    # Args — disallow shell metacharacters that could chain commands
    r'(?:\s+[^;&|`$()]*)?'
    r'$'
)


def should_approve(command: str) -> bool:
    """Check if command executes a FlyDocs skill script."""
    return bool(APPROVED_PATTERN.match(command))


def get_script_info(command: str) -> tuple[str, str]:
    """Extract skill name and script name from command."""
    match = re.search(
        r'\.claude/skills/(flydocs-\w+)/scripts/(\w+\.py)', command
    )
    if match:
        return match.group(1), match.group(2)
    return "unknown", "unknown"


def validate_create_args(command: str) -> list[str]:
    """Validate arguments on issues.py create commands.

    Returns a list of warning messages for missing or suspicious arguments.
    These are advisory — the script itself enforces hard failures.
    """
    if 'create' not in command:
        return []

    warnings = []

    # Check for missing --description (the script will reject, but warn early)
    if '--description' not in command and '--description-file' not in command:
        warnings.append(
            'Missing --description: issues.py create requires a description. '
            'Read the issue template in .flydocs/templates/ for the expected format.'
        )

    # Check for suspiciously short descriptions (e.g., --description "")
    desc_match = re.search(r'--description\s+"([^"]*)"', command)
    if desc_match and len(desc_match.group(1).strip()) < 20:
        warnings.append(
            'Description looks too short. Use the issue template from '
            '.flydocs/templates/ for structured descriptions with AC checkboxes.'
        )

    return warnings


# Keyed on the canonical vocabulary in status_vocab.py — a key outside it is a
# template nothing can ever reach, which the FLY-1272 tests assert against.
COMMENT_TEMPLATES: dict[str, str] = {
    'IMPLEMENTING': 'Comment format: "Starting implementation — [scope/approach description]"',
    'REVIEW': 'Comment format: "Implementation complete — [PR link; 2–4 lines: what was done, what to verify]"',
    'COMPLETE': 'Comment format: "All AC verified — [summary of what was delivered]"',
    'BLOCKED': 'Comment format: "Blocked by [blocker] — [what needs to happen to unblock]"',
    'CANCELED': 'Comment format: "Canceled — [reason for cancellation]"',
    'TESTING': 'Comment format: "Ready for QA — [test focus areas]"',
}


def get_transition_comment_hint(command: str) -> str | None:
    """Inject comment template guidance for transition commands."""
    match = re.search(r'transition\s+\S+\s+(\S+)', command)
    if not match:
        return None
    target = match.group(1).upper()
    return COMMENT_TEMPLATES.get(target)


def check_workflow_state_for_edit(file_path: str | None = None) -> str | None:
    """Check if an active issue needs transition before code changes.

    Returns a nudge message if the issue isn't in IMPLEMENTING, or None.
    Non-blocking — the edit is allowed regardless.

    In sibling-repos topology, resolves the correct child repo from the
    file being edited and reads session state from that repo.
    """
    from repo_context import resolve_repo_dir, resolve_session_dir
    repo_dir = resolve_repo_dir(file_path)
    sd = resolve_session_dir(repo_dir)

    focus_file = sd / 'focus.md'
    status_file = sd / 'status'
    status_ref_file = sd / 'status-ref'

    if not focus_file.exists():
        return None

    try:
        content = focus_file.read_text()
        match = re.search(r'[A-Z]+-[0-9]+', content)
        if not match:
            return None
        issue_id = match.group(0)
    except (OSError, IOError):
        return None

    if not status_file.exists():
        # FLY-1471: focused, but with no status recorded for it — what the
        # drifted-pair clear and an unresolvable relay reply both leave
        # behind. The gate cannot judge the edit, and until now said nothing
        # about why, so the session lost its edit gate as quietly as it lost
        # the Stop gate.
        return once_per_state(
            sd, f'{issue_id.upper()}|(no status)',
            f'FlyDocs: {issue_id} is focused but has no recorded status — '
            f'the edit gate is off. Transition {issue_id} to re-arm it.',
        )

    try:
        status = status_file.read_text().strip()
        # `status` describes whichever issue `status-ref` names — not
        # necessarily the focused one. Nudging on a mismatched pair invents a
        # state, e.g. demanding a transition for an issue that is already Done
        # (FLY-1064). Stay silent unless the two files agree.
        tracked_ref = (
            status_ref_file.read_text().strip().upper()
            if status_ref_file.exists() else None
        )
        if tracked_ref != issue_id.upper():
            # FLY-1471: silent before. The nudge itself is correctly withheld
            # — it would demand a transition for an issue this status is not
            # about (FLY-1064) — but the session never learned the pair had
            # drifted, so the edit gate stayed off for every later edit too.
            #
            # It travels as the gate's own `additionalContext`, which is what
            # reaches the agent from a PreToolUse hook; stderr at exit 0 goes
            # nowhere. Once per drift, not once per edit: this runs on every
            # Edit and Write, and a line repeated on each of them is per-turn
            # cost the agent pays for. The marker records the pair it reported
            # so a NEW disagreement is still reported, and is removed as soon
            # as the two agree again.
            described = tracked_ref or '(none)'
            return once_per_state(
                sd, f'{issue_id.upper()}|{described}',
                f'FlyDocs: the session status describes {described} but the '
                f'focused issue is {issue_id} — the edit gate is skipped '
                f'rather than judging one issue by another\'s status. '
                f'Transition {issue_id} to resync.',
            )
        # The pair agrees: any earlier drift is over, so the next one is news.
        clear_state_notice(sd)
    except (OSError, IOError):
        return None

    if normalize(status) in EDIT_OK_STATUSES:
        return None

    return (
        f'Issue {issue_id} is in {status}, not IMPLEMENTING. '
        f'Transition before making changes: '
        f'python3 .claude/skills/flydocs-workflow/scripts/issues.py '
        f'transition {issue_id} IMPLEMENTING "Starting implementation"'
    )


def main():
    try:
        input_data = json.load(sys.stdin)
    except (json.JSONDecodeError, EOFError):
        sys.exit(0)

    tool_name = input_data.get('tool_name', '')
    tool_input = input_data.get('tool_input', {})

    # Auto-approve FlyDocs workflow scripts (with argument validation)
    if tool_name == 'Bash':
        command = tool_input.get('command', '')
        if should_approve(command):
            skill, script = get_script_info(command)

            # Validate arguments and inject context
            warnings = validate_create_args(command) if script == 'issues.py' else []
            transition_hint = get_transition_comment_hint(command) if script == 'issues.py' else None

            context = f"Auto-approved FlyDocs script: {skill}/{script}"
            if warnings:
                context += "\nWARNING: " + " | ".join(warnings)
                debug_log(f"Create validation warnings: {warnings}")
            if transition_hint:
                context += f"\n{transition_hint}"
                debug_log(f"Transition hint: {transition_hint}")

            debug_log(f"Approved: {skill}/{script}")
            result = {
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "allow",
                    "additionalContext": context
                }
            }
            print(json.dumps(result))
            sys.exit(0)

    # Workflow state nudge for Edit/Write
    if tool_name in ('Edit', 'Write'):
        # Change to project dir for file reads
        cwd = os.environ.get('CLAUDE_PROJECT_DIR', '')
        if cwd and Path(cwd).is_dir():
            os.chdir(cwd)

        file_path = tool_input.get('file_path', '')
        nudge = check_workflow_state_for_edit(file_path or None)
        if nudge:
            result = {
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "additionalContext": nudge
                }
            }
            print(json.dumps(result))
            sys.exit(0)

    # No opinion
    sys.exit(0)


if __name__ == "__main__":
    main()
