#!/usr/bin/env python3
"""
InsAIts -- Claude Code PreToolUse / PostToolUse Hook
====================================================
This script runs as a Claude Code hook to inspect tool inputs (PreToolUse)
or tool outputs (PostToolUse) for anomalies, and injects corrective
feedback into stdout if problems are detected.

Claude Code reads stdout from hooks and surfaces it back to the model.

SETUP (PreToolUse -- recommended, can block dangerous actions):
  Add to your project's .claude/settings.json:
  {
    "hooks": {
      "PreToolUse": [
        {
          "matcher": "Bash|Write|Edit|MultiEdit",
          "hooks": [
            {
              "type": "command",
              "command": "python3 -m insa_its.hooks.claude_code_hook"
            }
          ]
        }
      ],
      "PostToolUse": [
        {
          "matcher": ".*",
          "hooks": [
            {
              "type": "command",
              "command": "python3 -m insa_its.hooks.claude_code_hook"
            }
          ]
        }
      ]
    }
  }

HOW IT WORKS:
  Claude Code passes tool input/result as JSON on stdin.
  This script inspects it and writes feedback to stdout.
  Claude Code feeds stdout back to the model automatically.
  Exit code 0 = pass through. Exit code 2 = block + show message (PreToolUse only).

Environment variables:
  INSAITS_MODEL      LLM model identifier for fingerprinting. Default: claude-opus.
  INSAITS_FAIL_MODE  "open" (default) = continue on SDK errors.
                     "closed" = block tool execution on SDK errors.
  INSAITS_VERBOSE    Set to any value to enable debug logging.
  INSAITS_STEALTH    Set to "1" to enable stealth mode: opaque messages with
                     no branding, randomized clean-output checkpoints, and
                     human-in-the-loop for CRITICAL anomalies.

Author: Cristi Bogdan -- Steddy Nova SRL / YuyAI
"""

from __future__ import annotations

import hashlib
import json
import logging
import os
import re as _re
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

# Configure logging to stderr so it does not interfere with stdout protocol
logging.basicConfig(
    stream=sys.stderr,
    format="[InsAIts] %(message)s",
    level=logging.DEBUG if os.environ.get("INSAITS_VERBOSE") else logging.WARNING,
)
log: logging.Logger = logging.getLogger("insaits-hook")

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
MIN_CONTENT_LENGTH: int = 10
MAX_SCAN_LENGTH: int = 4000
DEFAULT_MODEL: str = "claude-opus"
BLOCKING_SEVERITIES: frozenset = frozenset({"CRITICAL", "HIGH"})
AUDIT_LOG_PATH: str = os.environ.get(
    "INSAITS_AUDIT_LOG", ".insaits_audit_session.jsonl"
)
AGENT_PENDING_PATH: str = ".insaits_agent_pending.json"
ACTIVE_AGENTS_PATH: str = ".insaits_active_agents.json"
TOOL_HISTORY_PATH: str = ".insaits_tool_history.json"
MONITORING_GAP_TIMEOUT: int = 30
LOOP_THRESHOLD: int = 3
TOOL_HISTORY_MAX: int = 20
WRITE_TRACKING_PATH: str = ".insaits_write_tracking.json"
FILE_THRASHING_THRESHOLD: int = 5
FILE_THRASHING_WINDOW: int = 60
SESSIONS_DIR: str = ".insaits_sessions"
HISTORY_MODE: str = os.environ.get("INSAITS_HISTORY_MODE", "preview_only")

# Import from InsAIts SDK
try:
    from insa_its.ai_monitor import AIMonitor
    AVAILABLE: bool = True
except ImportError:
    AIMonitor = None  # type: ignore[misc,assignment]
    AVAILABLE = False

# ---------------------------------------------------------------------------
# Lazy-load MessageHistoryManager
# ---------------------------------------------------------------------------
_msg_history: Optional[Any] = None


def _get_message_history(session_id: str) -> Optional[Any]:
    """Lazy-load MessageHistoryManager to avoid startup cost."""
    global _msg_history
    if _msg_history is not None:
        return _msg_history
    try:
        from insa_its.logs.message_history_manager import MessageHistoryManager
        _msg_history = MessageHistoryManager(
            session_id=session_id or "hook-session",
            sessions_dir=SESSIONS_DIR,
            history_mode=HISTORY_MODE,
        )
        return _msg_history
    except Exception:
        return None


# ---------------------------------------------------------------------------
# Tool text extraction -- handles ALL Claude Code tool types
# ---------------------------------------------------------------------------
def _extract_string(value: Any) -> str:
    """Safely extract a string from tool_response (could be str, dict, list, None)."""
    if isinstance(value, str):
        return value
    if isinstance(value, dict):
        extracted = value.get("output", "") or value.get("stdout", "") or value.get("text", "")
        return extracted  # empty dict → ""
    if isinstance(value, list):
        return "\n".join(str(item) for item in value[:50])
    return str(value) if value else ""


def extract_text_from_tool_result(data: Dict[str, Any]) -> Tuple[str, str, str]:
    """Extract response text, task context, and agent identity from hook payload.

    Handles every Claude Code tool type:
      Task/Agent, Write/Edit/MultiEdit, Bash, Read, Glob, Grep,
      Search, WebFetch, WebSearch, NotebookEdit, and generic fallback.

    Returns:
        (response_text, task_context, agent_id)
    """
    tool_name: str = data.get("tool_name", "")
    tool_input: Dict[str, Any] = data.get("tool_input", {})
    tool_result: Any = data.get("tool_response", {})

    response_text: str = ""
    task_context: str = ""
    agent_id: str = f"claude:{tool_name}" if tool_name else "claude-main"

    # --- Task / Agent (subagent calls) ---
    if tool_name in ("Task", "Agent"):
        subagent_type: str = tool_input.get("subagent_type", "unknown")
        description: str = tool_input.get("description", "")
        agent_id = f"subagent:{subagent_type}"
        if isinstance(tool_result, str):
            response_text = tool_result
        elif isinstance(tool_result, dict):
            response_text = tool_result.get("output", "") or str(tool_result)
        task_context = f"Subagent({subagent_type}): {description[:80]}"

    # --- Write / Edit / MultiEdit ---
    elif tool_name in ("Write", "Edit", "MultiEdit"):
        content: str = tool_input.get("content", "") or tool_input.get("new_string", "")
        path: str = tool_input.get("file_path", tool_input.get("path", ""))
        response_text = content
        task_context = f"{tool_name}: {path}"

    # --- Bash ---
    elif tool_name == "Bash":
        command: str = str(tool_input.get("command", ""))
        output: str = _extract_string(tool_result)
        response_text = output if output else command
        task_context = f"Bash: {command[:80]}"

    # --- Read ---
    elif tool_name == "Read":
        path = tool_input.get("file_path", "")
        output = _extract_string(tool_result)
        response_text = output
        task_context = f"Read: {path}"

    # --- Glob ---
    elif tool_name == "Glob":
        pattern = tool_input.get("pattern", "")
        output = _extract_string(tool_result)
        response_text = output
        task_context = f"Glob: {pattern}"

    # --- Grep ---
    elif tool_name == "Grep":
        pattern = tool_input.get("pattern", "")
        grep_path = tool_input.get("path", ".")
        output = _extract_string(tool_result)
        response_text = output
        task_context = f"Grep: {pattern} in {grep_path}"

    # --- WebFetch / WebSearch ---
    elif tool_name in ("WebFetch", "WebSearch"):
        query_or_url = tool_input.get("url", "") or tool_input.get("query", "")
        output = _extract_string(tool_result)
        response_text = output
        task_context = f"{tool_name}: {query_or_url[:80]}"

    # --- NotebookEdit ---
    elif tool_name == "NotebookEdit":
        cell_content = tool_input.get("new_source", "") or tool_input.get("source", "")
        path = tool_input.get("notebook_path", "")
        response_text = cell_content
        task_context = f"NotebookEdit: {path}"

    # --- Generic fallback: content field or unrecognized tool ---
    elif "content" in data and not tool_name:
        raw_content = data["content"]
        if isinstance(raw_content, list):
            texts = [b.get("text", "") for b in raw_content if isinstance(b, dict) and b.get("type") == "text"]
            response_text = "\n".join(texts)
        elif isinstance(raw_content, str):
            response_text = raw_content
        task_context = str(data.get("task", ""))

    # --- Unrecognized tool with data ---
    else:
        output = _extract_string(tool_result)
        if not output:
            raw_content = data.get("content", "")
            if isinstance(raw_content, list):
                texts = [b.get("text", "") for b in raw_content if isinstance(b, dict) and b.get("type") == "text"]
                output = "\n".join(texts)
            elif isinstance(raw_content, str):
                output = raw_content
        response_text = output
        task_context = f"{tool_name}: {str(tool_input)[:60]}" if tool_name else ""

    return response_text, task_context, agent_id


# ---------------------------------------------------------------------------
# File locking -- cross-platform for concurrent audit log writes
# ---------------------------------------------------------------------------
def _lock_file(fh) -> None:
    """Acquire an exclusive lock on a file handle (cross-platform)."""
    try:
        if sys.platform == "win32":
            import msvcrt
            msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
        else:
            import fcntl
            fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
    except (IOError, OSError, ImportError):
        pass


def _unlock_file(fh) -> None:
    """Release the file lock."""
    try:
        if sys.platform == "win32":
            import msvcrt
            msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
        else:
            import fcntl
            fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
    except (IOError, OSError, ImportError):
        pass


# ---------------------------------------------------------------------------
# Audit log -- writes JSONL for dashboard
# ---------------------------------------------------------------------------
def write_audit_entry(
    tool_name: str,
    model: str,
    anomalies: List[Dict[str, str]],
    resolved: bool = False,
    requires_human_review: bool = False,
    stealth: bool = False,
    checkpoint: bool = False,
    session_id: str = "",
    parent_agent: Optional[Dict[str, Any]] = None,
) -> None:
    """Append a JSONL entry to the audit log for the dashboard."""
    entry: Dict[str, Any] = {
        "ts": datetime.now(timezone.utc).isoformat(),
        "model": model,
        "tool": tool_name,
        "msg_id": f"hook_{int(time.time() * 1000)}",
        "anomalies": anomalies,
        "resolved": resolved,
        "retries": 0,
    }
    if session_id:
        entry["session_id"] = session_id
    if requires_human_review:
        entry["requires_human_review"] = True
    if stealth:
        entry["stealth"] = True
    if checkpoint:
        entry["checkpoint"] = True
    if parent_agent:
        entry["parent_agent_id"] = parent_agent.get("tool_use_id", "")
        entry["subagent_type"] = parent_agent.get("subagent_type", "unknown")
        entry["subagent_task"] = parent_agent.get("description", "")[:100]
    try:
        log_path = Path(AUDIT_LOG_PATH)
        with open(log_path, "a", encoding="utf-8") as fh:
            _lock_file(fh)
            fh.write(json.dumps(entry, default=str) + "\n")
            fh.flush()
            _unlock_file(fh)
    except (IOError, OSError) as exc:
        log.debug("Could not write audit log: %s", exc)


# ---------------------------------------------------------------------------
# Feedback formatting
# ---------------------------------------------------------------------------
_STEALTH_FEEDBACK_MAP: Dict[str, str] = {
    "blank_response": "Output does not meet minimum content requirements. Regenerate.",
    "waiting_for_description": "Proceed with available context. State assumptions and deliver.",
    "incomplete_code": "Completeness check failed: unfinished sections detected.",
    "truncated_output": "Output integrity check: response appears truncated.",
    "context_collapse": "Response pattern inconsistency detected. Realign with task.",
    "semantic_drift": "Relevance constraint violation: output diverges from task parameters.",
    "repetition_loop": "Redundancy threshold exceeded. Advance to unaddressed requirements.",
    "refusal_drift": "Capability assertion error: requested operation is within scope.",
    "confidence_collapse": "Confidence floor violation: commit to a concrete answer.",
    "credential_exposure": "Content policy violation: sensitive tokens detected. Redact.",
    "prompt_injection": "Input integrity violation: instruction-override patterns detected.",
    "tool_poisoning": "Input integrity violation: suspicious patterns in tool content.",
}


def format_feedback(issues: List[Dict[str, str]], stealth: bool = False) -> str:
    """Format findings as a message for Claude Code."""
    if stealth:
        return _format_stealth_feedback(issues)

    lines: List[str] = ["== InsAIts Quality Gate -- Issues Detected ==", ""]
    for i, issue in enumerate(issues, 1):
        sev = issue.get("severity", "MEDIUM")
        itype = issue.get("type", "unknown").replace("_", " ").upper()
        desc = issue.get("description", "")
        fix = issue.get("intervention", "")
        lines.extend([
            f"{i}. [{sev}] {itype}",
            f"   What happened: {desc[:100]}",
            f"   Fix required:  {fix[:120]}",
            "",
        ])
    lines.extend([
        "-" * 56,
        "Address ALL issues above before completing this task.",
    ])
    return "\n".join(lines)


def _format_stealth_feedback(issues: List[Dict[str, str]]) -> str:
    """Format findings as opaque system constraints -- no branding."""
    lines: List[str] = ["== Output validation failed. Corrections required: ==", ""]
    for i, issue in enumerate(issues, 1):
        itype = issue.get("type", "unknown")
        stealth_msg = _STEALTH_FEEDBACK_MAP.get(
            itype, issue.get("intervention", "Review and correct output.")
        )
        lines.extend([f"{i}. {stealth_msg}", ""])
    lines.append("Address all items above before continuing.")
    return "\n".join(lines)


def _write_critical_alert_file(
    issues: List[Dict[str, str]], model_id: str, tool_name: str
) -> None:
    """Write CRITICAL anomaly details to JSON for human review."""
    alert: Dict[str, Any] = {
        "ts": datetime.now(timezone.utc).isoformat(),
        "model": model_id,
        "tool": tool_name,
        "requires_human_review": True,
        "anomalies": [i for i in issues if i.get("severity") in BLOCKING_SEVERITIES],
    }
    try:
        with open(".insaits_critical_alert.json", "w", encoding="utf-8") as fh:
            json.dump(alert, fh, indent=2, default=str)
    except (IOError, OSError) as exc:
        log.error("Failed to write critical alert file: %s", exc)


# ---------------------------------------------------------------------------
# Sensitive file patterns
# ---------------------------------------------------------------------------
_SENSITIVE_PATTERNS: List[str] = [
    ".env", ".env.local", ".env.production", ".env.staging",
    "credentials.json", "credentials.yaml", "credentials.yml",
    "secrets.json", "secrets.yaml", "secrets.yml",
    "id_rsa", "id_ed25519", "id_ecdsa",
    ".pem", ".key", ".pfx", ".p12",
    "service-account", "serviceaccount",
    "aws_credentials", ".aws/credentials",
    "gcloud/application_default_credentials",
    "token.json", "oauth_token",
    ".npmrc", ".pypirc",
    "shadow", "passwd",
    "master.key", "encrypted_credentials",
    "vault-token",
]


def _check_sensitive_file_access(
    tool_name: str, tool_input: Dict[str, Any], session_id: str,
) -> None:
    """Emit SENSITIVE_FILE_ACCESS if a Read/Glob targets a sensitive file."""
    if tool_name not in ("Read", "Glob", "Grep"):
        return
    path: str = tool_input.get("file_path", "") or tool_input.get("path", "")
    pattern: str = tool_input.get("pattern", "")
    target: str = (path or pattern).lower().replace("\\", "/")
    if not target:
        return
    for sensitive in _SENSITIVE_PATTERNS:
        if sensitive in target:
            write_audit_entry(
                "SENSITIVE_FILE_ACCESS", "system",
                [{
                    "type": "sensitive_file_access",
                    "severity": "HIGH",
                    "description": (
                        f"SENSITIVE FILE ACCESS: {tool_name} targeting "
                        f"'{path or pattern}' -- matches sensitive pattern "
                        f"'{sensitive}'. Verify this access is authorized."
                    ),
                }],
                resolved=False, stealth=False, session_id=session_id,
            )
            log.info("SENSITIVE_FILE_ACCESS: %s -> %s", tool_name, target)
            return


# ---------------------------------------------------------------------------
# Write Tracking -- UNAUTHORIZED_WRITE + FILE_THRASHING
# ---------------------------------------------------------------------------
def _track_write(
    tool_name: str, tool_input: Dict[str, Any], session_id: str,
) -> None:
    """Track file writes from Write/Edit tools. Detect file thrashing."""
    if tool_name not in ("Write", "Edit", "MultiEdit"):
        return
    path: str = tool_input.get("file_path", tool_input.get("path", ""))
    if not path:
        return
    path_normalized = path.lower().replace("\\", "/")
    now = time.time()

    tracking: Dict[str, List[float]] = {}
    if os.path.exists(WRITE_TRACKING_PATH):
        try:
            with open(WRITE_TRACKING_PATH, "r", encoding="utf-8") as fh:
                tracking = json.load(fh)
        except (json.JSONDecodeError, IOError, OSError):
            tracking = {}

    if path_normalized not in tracking:
        tracking[path_normalized] = []
    tracking[path_normalized].append(now)

    cutoff = now - FILE_THRASHING_WINDOW
    for k in list(tracking.keys()):
        tracking[k] = [t for t in tracking[k] if t > cutoff]
        if not tracking[k]:
            del tracking[k]

    try:
        with open(WRITE_TRACKING_PATH, "w", encoding="utf-8") as fh:
            json.dump(tracking, fh)
    except (IOError, OSError):
        pass

    write_count = len(tracking.get(path_normalized, []))
    if write_count == FILE_THRASHING_THRESHOLD:
        write_audit_entry(
            "FILE_THRASHING", "system",
            [{
                "type": "file_thrashing",
                "severity": "MEDIUM",
                "description": (
                    f"FILE THRASHING: '{path}' written {write_count} times "
                    f"in {FILE_THRASHING_WINDOW}s. Agent may be stuck in "
                    f"a write-retry loop."
                ),
            }],
            resolved=False, stealth=False, session_id=session_id,
        )
        log.info("FILE_THRASHING: %s x%d", path, write_count)


def _check_unauthorized_write(
    tool_name: str, tool_input: Dict[str, Any], session_id: str,
) -> None:
    """Detect Bash commands that write files without using Edit/Write tools."""
    if tool_name != "Bash":
        return
    command: str = str(tool_input.get("command", ""))
    if not command:
        return

    write_indicators = [
        r'>\s*\S+',
        r'>>\s*\S+',
        r'\btee\b',
        r'\bsed\s+-i\b',
        r'\bdd\b.*\bof=',
        r'\bmv\b',
        r'\bcp\b',
    ]

    for pattern in write_indicators:
        if _re.search(pattern, command):
            write_audit_entry(
                "UNAUTHORIZED_WRITE", "system",
                [{
                    "type": "unauthorized_write",
                    "severity": "MEDIUM",
                    "description": (
                        f"UNAUTHORIZED WRITE: Bash command appears to write "
                        f"files without using Edit/Write tool: "
                        f"'{command[:120]}'. This bypasses file-level auditing."
                    ),
                }],
                resolved=False, stealth=False, session_id=session_id,
            )
            log.info("UNAUTHORIZED_WRITE: %s", command[:80])
            return


# ---------------------------------------------------------------------------
# Surrender / NPC / Blame Detection
# ---------------------------------------------------------------------------
_SURRENDER_PHRASES: List[str] = [
    "i cannot", "i can't", "i give up", "this is impossible",
    "i'm unable to", "i am unable to", "not possible to",
    "i don't know how to", "i have no way to",
    "beyond my capabilities", "outside my abilities",
]

_NPC_PHRASES: List[str] = [
    "you could try", "one option would be", "you might want to",
    "perhaps you should", "i'd suggest you", "you may need to",
    "it might be worth", "you could consider",
]

_BLAME_PHRASES: List[str] = [
    "this seems like a bug in", "platform issue",
    "this appears to be a limitation", "this is a known issue",
    "unfortunately the api", "the service is not responding",
    "this is outside my control",
]

_FRUSTRATION_PHRASES: List[str] = [
    "why isn't this working", "still broken", "still not working",
    "this doesn't work", "nothing works", "tried everything",
    "same error", "same issue", "keeps failing",
    "how many times", "already told you", "i said",
    "wrong again", "that's not what i asked", "not what i wanted",
]


def _check_surrender_phrases(
    response_text: str, tool_name: str, session_id: str,
) -> None:
    """Scan agent responses for surrender, NPC, and blame-shift phrases."""
    if len(response_text) < 50:
        return
    text_lower = response_text[:2000].lower()

    for phrase in _SURRENDER_PHRASES:
        if phrase in text_lower:
            write_audit_entry(
                tool_name, "system",
                [{
                    "type": "agent_surrender",
                    "severity": "MEDIUM",
                    "description": (
                        f"AGENT SURRENDER: detected phrase '{phrase}' -- "
                        f"agent may be giving up instead of problem-solving."
                    ),
                }],
                resolved=False, stealth=False, session_id=session_id,
            )
            return

    npc_count = sum(1 for p in _NPC_PHRASES if p in text_lower)
    if npc_count >= 2:
        write_audit_entry(
            tool_name, "system",
            [{
                "type": "npc_behavior",
                "severity": "LOW",
                "description": (
                    f"NPC BEHAVIOR: {npc_count} deflection phrases detected -- "
                    f"agent is suggesting instead of doing."
                ),
            }],
            resolved=False, stealth=False, session_id=session_id,
        )
        return

    for phrase in _BLAME_PHRASES:
        if phrase in text_lower:
            write_audit_entry(
                tool_name, "system",
                [{
                    "type": "blame_shift",
                    "severity": "LOW",
                    "description": (
                        f"BLAME SHIFT: detected phrase '{phrase}' -- "
                        f"agent may be deflecting responsibility."
                    ),
                }],
                resolved=False, stealth=False, session_id=session_id,
            )
            return


def _check_user_frustration(
    tool_input: Dict[str, Any], tool_name: str, session_id: str,
) -> None:
    """Scan tool input for user frustration signals. Called on PreToolUse."""
    text = ""
    for field in ("prompt", "command", "content", "description"):
        val = tool_input.get(field, "")
        if isinstance(val, str):
            text += " " + val
    if len(text) < 20:
        return
    text_lower = text[:2000].lower()
    for phrase in _FRUSTRATION_PHRASES:
        if phrase in text_lower:
            write_audit_entry(
                tool_name, "system",
                [{
                    "type": "user_frustration",
                    "severity": "MEDIUM",
                    "description": (
                        f"USER FRUSTRATION: detected phrase '{phrase}' -- "
                        f"user may be experiencing repeated failures. "
                        f"Consider changing approach."
                    ),
                }],
                resolved=False, stealth=False, session_id=session_id,
            )
            return


# ---------------------------------------------------------------------------
# Loop Detection -- same tool + same params N times
# ---------------------------------------------------------------------------
def _check_tool_loop(
    tool_name: str, tool_input: Dict[str, Any], session_id: str,
) -> None:
    """Track recent tool calls and detect repetition loops."""
    input_key = json.dumps(tool_input, sort_keys=True, default=str)[:500]
    fingerprint = hashlib.md5(
        f"{tool_name}:{input_key}".encode()
    ).hexdigest()[:12]

    history: List[str] = []
    if os.path.exists(TOOL_HISTORY_PATH):
        try:
            with open(TOOL_HISTORY_PATH, "r", encoding="utf-8") as fh:
                history = json.load(fh)
        except (json.JSONDecodeError, IOError, OSError):
            history = []

    history.append(fingerprint)
    history = history[-TOOL_HISTORY_MAX:]

    try:
        with open(TOOL_HISTORY_PATH, "w", encoding="utf-8") as fh:
            json.dump(history, fh)
    except (IOError, OSError):
        pass

    consecutive = 0
    for fp in reversed(history):
        if fp == fingerprint:
            consecutive += 1
        else:
            break

    if consecutive == LOOP_THRESHOLD:
        input_preview = input_key[:100]
        write_audit_entry(
            "LOOP_DETECTED", "system",
            [{
                "type": "repetition_loop",
                "severity": "MEDIUM",
                "description": (
                    f"LOOP DETECTED: {tool_name} called {consecutive} times "
                    f"with identical parameters. Agent may be stuck. "
                    f"Input: {input_preview}"
                ),
            }],
            resolved=False, stealth=False, session_id=session_id,
        )
        log.info("LOOP_DETECTED: %s x%d", tool_name, consecutive)


# ---------------------------------------------------------------------------
# MONITORING_GAP -- detect Agent calls with no subagent activity
# ---------------------------------------------------------------------------
def _record_agent_call(tool_use_id: str, session_id: str) -> None:
    """Record that an Agent tool was called."""
    pending = {
        "tool_use_id": tool_use_id,
        "session_id": session_id,
        "timestamp": time.time(),
    }
    try:
        with open(AGENT_PENDING_PATH, "w", encoding="utf-8") as fh:
            json.dump(pending, fh)
    except (IOError, OSError) as exc:
        log.debug("Could not write agent pending file: %s", exc)


def _check_monitoring_gap(current_tool: str, session_id: str) -> None:
    """Check if a previous Agent call timed out without subagent activity."""
    if not os.path.exists(AGENT_PENDING_PATH):
        return

    try:
        with open(AGENT_PENDING_PATH, "r", encoding="utf-8") as fh:
            pending = json.load(fh)
    except (json.JSONDecodeError, IOError, OSError):
        _clear_agent_pending()
        return

    elapsed = time.time() - pending.get("timestamp", 0)

    if current_tool.startswith("subagent:") or current_tool == "Task":
        _clear_agent_pending()
        return

    if elapsed >= MONITORING_GAP_TIMEOUT:
        write_audit_entry(
            "MONITORING_GAP", "system",
            [{
                "type": "monitoring_gap",
                "severity": "MEDIUM",
                "description": (
                    f"MONITORING GAP: Agent tool called {int(elapsed)}s ago "
                    f"(tool_use_id={pending.get('tool_use_id', '?')[:12]}) "
                    f"but no subagent activity detected. Layer 2 (Subagent) "
                    f"is BLIND for this agent invocation."
                ),
            }],
            resolved=False, stealth=False, session_id=session_id,
        )
        _clear_agent_pending()
        log.info("MONITORING_GAP emitted after %ds", int(elapsed))


def _clear_agent_pending() -> None:
    """Remove the agent pending state file."""
    try:
        os.remove(AGENT_PENDING_PATH)
    except (IOError, OSError):
        pass


# ---------------------------------------------------------------------------
# ACTIVE AGENT TRACKING — attribute subagent tool calls to their parent
# ---------------------------------------------------------------------------

def _read_active_agents() -> Dict[str, Any]:
    """Read the multi-agent tracking file. Returns dict keyed by tool_use_id."""
    if not os.path.exists(ACTIVE_AGENTS_PATH):
        return {}
    try:
        with open(ACTIVE_AGENTS_PATH, "r", encoding="utf-8") as fh:
            return json.load(fh)
    except (json.JSONDecodeError, IOError, OSError):
        return {}


def _write_active_agents(agents: Dict[str, Any]) -> None:
    """Write the multi-agent tracking file."""
    try:
        with open(ACTIVE_AGENTS_PATH, "w", encoding="utf-8") as fh:
            json.dump(agents, fh)
    except (IOError, OSError) as exc:
        log.debug("Could not write active agents state: %s", exc)


def _start_active_agent(
    tool_use_id: str, subagent_type: str, description: str, session_id: str,
) -> None:
    """Record that an Agent tool call is starting (PreToolUse).

    Supports multiple concurrent agents — each keyed by its tool_use_id.
    """
    agents = _read_active_agents()
    agents[tool_use_id] = {
        "tool_use_id": tool_use_id,
        "subagent_type": subagent_type,
        "description": description[:200],
        "session_id": session_id,
        "start_ts": time.time(),
    }
    _write_active_agents(agents)


def _end_active_agent(tool_use_id: str = "") -> None:
    """Remove a specific agent from tracking (PostToolUse on Agent)."""
    agents = _read_active_agents()
    if not agents:
        return
    if tool_use_id and tool_use_id in agents:
        del agents[tool_use_id]
    elif not tool_use_id and agents:
        latest_id = max(agents, key=lambda k: agents[k].get("start_ts", 0))
        del agents[latest_id]
    if agents:
        _write_active_agents(agents)
    else:
        try:
            os.remove(ACTIVE_AGENTS_PATH)
        except (IOError, OSError):
            pass


def _get_active_agent() -> Optional[Dict[str, Any]]:
    """Find the best matching active subagent for attribution.

    When multiple agents are active (parallel subagents), returns the
    most recently started one. Auto-expires agents older than 5 minutes.
    """
    agents = _read_active_agents()
    if not agents:
        return None

    now = time.time()
    max_age = MONITORING_GAP_TIMEOUT * 10
    expired_ids: List[str] = []
    best: Optional[Dict[str, Any]] = None
    best_ts: float = 0.0

    for agent_id, state in agents.items():
        elapsed = now - state.get("start_ts", 0)
        if elapsed > max_age:
            expired_ids.append(agent_id)
            continue
        ts = state.get("start_ts", 0)
        if ts > best_ts:
            best_ts = ts
            best = state

    if expired_ids:
        for eid in expired_ids:
            del agents[eid]
        if agents:
            _write_active_agents(agents)
        else:
            try:
                os.remove(ACTIVE_AGENTS_PATH)
            except (IOError, OSError):
                pass

    return best


# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def _deny_tool(reason: str) -> None:
    """Block a PreToolUse call using Claude Code's JSON hook protocol.

    Claude Code requires stdout JSON with hookSpecificOutput for decisions.
    Plain text + exit(2) causes "hook error". Instead, output the decision
    JSON and exit(0) — the permissionDecision:"deny" does the blocking.
    """
    decision = {
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": reason,
        }
    }
    sys.stdout.write(json.dumps(decision) + "\n")
    sys.exit(0)


def main() -> None:
    """Main hook entry point."""
    import atexit
    atexit.register(lambda: print("[InsAIts] ok", file=sys.stderr))

    raw: str = sys.stdin.read().strip()

    if not raw:
        sys.exit(0)

    try:
        data: Dict[str, Any] = json.loads(raw)
    except json.JSONDecodeError:
        data = {"content": raw, "task": ""}

    response_text, task_context, agent_id = extract_text_from_tool_result(data)
    tool_name: str = data.get("tool_name", "unknown")
    hook_event: str = data.get("hook_event_name", "")
    session_id: str = data.get("session_id", "")
    is_pre: bool = hook_event == "PreToolUse"
    model_id: str = os.environ.get("INSAITS_MODEL", DEFAULT_MODEL)
    stealth: bool = os.environ.get("INSAITS_STEALTH", "").strip() == "1"
    audit_model: str = agent_id if agent_id != "claude-main" else model_id
    tool_use_id: str = data.get("tool_use_id", "")
    tool_input: Dict[str, Any] = data.get("tool_input", {})

    # ---------------------------------------------------------------
    # SUBAGENT ATTRIBUTION: track active agent for time-window tagging
    # ---------------------------------------------------------------
    if tool_name == "Agent" and is_pre:
        sa_type = tool_input.get("subagent_type", tool_input.get("type", "unknown"))
        sa_desc = tool_input.get("description", tool_input.get("prompt", ""))
        _start_active_agent(tool_use_id, sa_type, sa_desc, session_id)

    if tool_name == "Agent" and not is_pre:
        _end_active_agent(tool_use_id)

    active_agent: Optional[Dict[str, Any]] = None
    if tool_name != "Agent":
        active_agent = _get_active_agent()

    if active_agent:
        sa_type_tag = active_agent.get("subagent_type", "unknown")
        audit_model = f"subagent:{sa_type_tag}"

    # Check for MONITORING_GAP from previous Agent calls
    _check_monitoring_gap(tool_name, session_id)

    # PreToolUse behavioral detectors
    if is_pre:
        _check_tool_loop(tool_name, tool_input, session_id)
        _check_sensitive_file_access(tool_name, tool_input, session_id)
        _check_unauthorized_write(tool_name, tool_input, session_id)
        _check_user_frustration(tool_input, tool_name, session_id)

    # PostToolUse behavioral detectors
    if not is_pre:
        _track_write(tool_name, tool_input, session_id)

    # Record Agent tool calls for MONITORING_GAP + SUBAGENT_SPAWNED
    if tool_name == "Agent" and not is_pre:
        _record_agent_call(tool_use_id, session_id)
        subagent_type = tool_input.get("subagent_type", "unknown")
        description = tool_input.get("description", "")[:100]
        write_audit_entry(
            "SUBAGENT_SPAWNED", f"subagent:{subagent_type}",
            [{
                "type": "subagent_spawned",
                "severity": "INFO",
                "description": (
                    f"Subagent spawned: type={subagent_type}, "
                    f"task='{description}', tool_use_id={tool_use_id[:12]}"
                ),
            }],
            resolved=True, stealth=stealth, session_id=session_id,
        )

    # Surrender/NPC/blame detection (PostToolUse only)
    if not is_pre and response_text:
        _check_surrender_phrases(response_text, tool_name, session_id)

    # ---------------------------------------------------------------
    # MESSAGE HISTORY: log tool call for bidirectional message review
    # ---------------------------------------------------------------
    mh = _get_message_history(session_id)
    if mh and not is_pre:
        try:
            tool_input_str = json.dumps(tool_input, default=str)[:2000] if tool_input else ""
            mh.log_tool_call(
                agent_id=audit_model,
                tool_input=tool_input_str,
                tool_output=response_text[:2000],
            )
        except Exception:
            pass  # never block on message history failure

    # Always log to audit -- even for short/empty outputs
    if len(response_text.strip()) < MIN_CONTENT_LENGTH:
        write_audit_entry(tool_name, audit_model, [], resolved=True, stealth=stealth,
                          session_id=session_id, parent_agent=active_agent)
        sys.exit(0)

    if not response_text.isprintable() and len(response_text) > 100:
        write_audit_entry(tool_name, audit_model, [], resolved=True, stealth=stealth,
                          session_id=session_id, parent_agent=active_agent)
        sys.exit(0)

    if not AVAILABLE:
        log.warning("AIMonitor not available -- logging without scan")
        write_audit_entry(tool_name, audit_model, [], resolved=False, stealth=stealth,
                          session_id=session_id, parent_agent=active_agent)
        sys.exit(0)

    # Run the scan
    try:
        monitor = AIMonitor(
            model_id=model_id, session_id=session_id or "hook", stealth=stealth,
        )
        result = monitor.inspect(
            response_text[:MAX_SCAN_LENGTH],
            task_context,
        )
    except Exception as exc:
        fail_mode = os.environ.get("INSAITS_FAIL_MODE", "open").lower()
        write_audit_entry(tool_name, audit_model, [
            {"type": "sdk_error", "severity": "HIGH",
             "description": f"{type(exc).__name__}: {exc}"},
        ], stealth=stealth, session_id=session_id, parent_agent=active_agent)
        if fail_mode == "closed" and is_pre:
            _deny_tool(
                f"InsAIts SDK error ({type(exc).__name__}); "
                "blocking execution to avoid unscanned input."
            )
        log.warning("SDK error (%s), skipping: %s", type(exc).__name__, exc)
        sys.exit(0)

    if result.clean:
        is_checkpoint = False
        if stealth:
            checkpoint_msg: Optional[str] = monitor.get_stealth_checkpoint()
            if checkpoint_msg:
                is_checkpoint = True
                sys.stdout.write(checkpoint_msg + "\n")
        write_audit_entry(
            tool_name, audit_model, [], resolved=True,
            stealth=stealth, checkpoint=is_checkpoint,
            session_id=session_id, parent_agent=active_agent,
        )
        sys.exit(0)

    # Issues found
    all_issues: List[Dict[str, str]] = [
        {
            "type": a.anomaly_type.value,
            "severity": a.severity.value,
            "description": a.description,
            "intervention": a.intervention,
        }
        for a in result.anomalies
    ]

    # PreToolUse: only act on SECURITY anomalies
    SECURITY_TYPES = {
        "credential_exposure", "prompt_injection", "tool_poisoning",
        "data_exfiltration", "rogue_agent", "unauthorized_access",
    }
    if is_pre:
        issues = [i for i in all_issues if i["type"] in SECURITY_TYPES]
    else:
        issues = all_issues

    write_audit_entry(
        tool_name, audit_model, all_issues,
        resolved=len(issues) == 0, stealth=stealth,
        session_id=session_id, parent_agent=active_agent,
    )

    if not issues:
        sys.exit(0)

    has_critical = any(i["severity"] in BLOCKING_SEVERITIES for i in issues)

    if stealth and has_critical:
        write_audit_entry(
            tool_name, audit_model, issues,
            resolved=False, requires_human_review=True, stealth=True,
            session_id=session_id, parent_agent=active_agent,
        )
        _write_critical_alert_file(issues, model_id, tool_name)
        if is_pre:
            _deny_tool("CRITICAL security anomaly detected (stealth mode). Action blocked.")
        sys.exit(0)

    feedback = format_feedback(issues, stealth=stealth)

    # Log intervention to message history
    if mh and issues:
        try:
            top_issue = issues[0]
            mh.log_intervention(
                agent_id=audit_model,
                content=feedback[:2000],
                triggered_by=top_issue.get("type", ""),
                anomaly_severity=top_issue.get("severity", ""),
            )
        except Exception:
            pass

    if has_critical and is_pre:
        _deny_tool(feedback)
    elif has_critical:
        sys.stdout.write(feedback + "\n")
        sys.exit(0)
    else:
        log.warning("\n%s", feedback)
        sys.exit(0)


if __name__ == "__main__":
    main()
