"""
Helper Functions for Echo Ops

WO-ECHO-TEAMMATE-01: Utilities for receipt scanning, command validation,
TODO scanning, import checking, and git log parsing.
"""

import hashlib
import json
import os
import re
import subprocess
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple

# Project root detection
# __file__ = .../helix-cdc/helix_cdc/echo/ops/helpers.py
# parents[3] = helix-cdc (the repo root)
PROJECT_ROOT = Path(__file__).resolve().parents[3]

# ============================================================================
# Command Validation & Execution
# ============================================================================

# Allowlisted commands
ALLOWED_COMMANDS = {
    "git": ["status", "diff", "log", "branch", "show", "rev-parse"],
    "rg": None,  # All args allowed
    "find": None,  # All args allowed
    "pytest": None,
    "python": ["-m"],  # Only module execution
    "make": [
        "healthcheck", "test", "gate",
        # KAT targets
        "kat-se", "kat-regrow", "kat-fidelity",
        # Echo targets
        "echo-health", "echo-stubs", "echo-brief",
    ],
    "ls": None,
    "wc": None,
    "head": None,
    "tail": None,
    "sha256sum": None,
    "cat": None,  # Allow reading files
}

# Blocked patterns (always reject these)
BLOCKED_PATTERNS = [
    r"\brm\s",
    r"\brm$",
    r"git\s+push",
    r"git\s+reset\s+--hard",
    r"git\s+checkout\s+\.",
    r"git\s+clean",
    r"\bsudo\b",
    r"curl.*\|\s*(sh|bash)",
    r"wget.*\|\s*(sh|bash)",
    r">\s*/dev/",
    r"\beval\b",
    r"\bexec\b",
    r";\s*rm",
    r"&&\s*rm",
    r"\|\s*rm",
]


def validate_command(command: str, args: List[str], cwd: Optional[str] = None) -> Tuple[bool, str]:
    """
    Validate a command against the allowlist and blocked patterns.

    Returns:
        (is_valid, reason)
    """
    full_command = f"{command} {' '.join(args)}".strip()

    # Check blocked patterns first
    for pattern in BLOCKED_PATTERNS:
        if re.search(pattern, full_command, re.IGNORECASE):
            return False, f"Blocked pattern detected: {pattern}"

    # Check allowlist
    if command not in ALLOWED_COMMANDS:
        return False, f"Command '{command}' not in allowlist"

    allowed_subcommands = ALLOWED_COMMANDS[command]
    if allowed_subcommands is not None and args:
        # Check if first arg is an allowed subcommand
        first_arg = args[0]
        # For make, check the target
        if command == "make":
            if not any(first_arg.startswith(sub) for sub in allowed_subcommands):
                return False, f"make target '{first_arg}' not in allowlist"
        elif command == "git":
            if first_arg not in allowed_subcommands:
                return False, f"git subcommand '{first_arg}' not in allowlist"
        elif command == "python":
            if first_arg not in allowed_subcommands:
                return False, f"python flag '{first_arg}' not in allowlist (only -m allowed)"

    # Validate cwd if provided
    if cwd:
        cwd_path = Path(cwd).resolve()
        if not str(cwd_path).startswith(str(PROJECT_ROOT)):
            return False, f"cwd must be within project root: {PROJECT_ROOT}"
        if not cwd_path.exists():
            return False, f"cwd does not exist: {cwd}"

    return True, "OK"


def execute_command(
    command: str,
    args: List[str],
    timeout_seconds: int = 60,
    cwd: Optional[str] = None,
) -> Dict[str, Any]:
    """
    Execute a validated command and return results.

    Returns dict with: exit_code, stdout, stderr, elapsed_seconds
    """
    full_args = [command] + args
    work_dir = Path(cwd) if cwd else PROJECT_ROOT

    start_time = time.time()
    try:
        result = subprocess.run(
            full_args,
            capture_output=True,
            text=True,
            timeout=timeout_seconds,
            cwd=str(work_dir),
            env={**os.environ, "PYTHONPATH": str(PROJECT_ROOT)},
        )
        return {
            "exit_code": result.returncode,
            "stdout": result.stdout[:50000],  # Truncate long output
            "stderr": result.stderr[:10000],
            "elapsed_seconds": time.time() - start_time,
        }
    except subprocess.TimeoutExpired:
        return {
            "exit_code": -1,
            "stdout": "",
            "stderr": f"Command timed out after {timeout_seconds}s",
            "elapsed_seconds": timeout_seconds,
        }
    except Exception as e:
        return {
            "exit_code": -1,
            "stdout": "",
            "stderr": str(e),
            "elapsed_seconds": time.time() - start_time,
        }


# ============================================================================
# Receipt Scanning
# ============================================================================

def scan_receipts(
    days: int = 7,
    receipt_dirs: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
    """
    Scan receipts from the last N days.

    Returns list of receipt summaries with status (PASS/FAIL).
    """
    if receipt_dirs is None:
        receipt_dirs = ["receipts", "notes"]

    receipts = []
    cutoff = datetime.now(timezone.utc) - timedelta(days=days)

    for dir_name in receipt_dirs:
        receipts_path = PROJECT_ROOT / dir_name
        if not receipts_path.exists():
            continue

        # Scan JSON files
        for json_file in receipts_path.rglob("*.json"):
            try:
                stat = json_file.stat()
                mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
                if mtime < cutoff:
                    continue

                with open(json_file) as f:
                    data = json.load(f)

                # Determine status
                status = "UNKNOWN"
                if "status" in data:
                    status = data["status"]
                elif "summary" in data and isinstance(data["summary"], dict):
                    if "overall_status" in data["summary"]:
                        status = data["summary"]["overall_status"]
                    elif "passed" in data["summary"]:
                        status = "PASS" if data["summary"]["passed"] else "FAIL"
                elif "passed" in data:
                    status = "PASS" if data["passed"] else "FAIL"

                receipts.append({
                    "path": str(json_file.relative_to(PROJECT_ROOT)),
                    "type": data.get("receipt_type", data.get("type", "unknown")),
                    "status": status,
                    "timestamp": data.get("timestamp", data.get("timestamp_utc", mtime.isoformat())),
                    "work_order": data.get("work_order"),
                })
            except Exception:
                continue

    return sorted(receipts, key=lambda x: x.get("timestamp", ""), reverse=True)


def count_receipts_by_status(receipts: List[Dict[str, Any]]) -> Dict[str, int]:
    """Count receipts by status."""
    counts = {"PASS": 0, "FAIL": 0, "UNKNOWN": 0}
    for r in receipts:
        status = r.get("status", "UNKNOWN")
        if status in counts:
            counts[status] += 1
        else:
            counts["UNKNOWN"] += 1
    return counts


# ============================================================================
# Git Operations
# ============================================================================

def get_recent_commits(count: int = 10) -> List[Dict[str, str]]:
    """Get recent git commits."""
    try:
        result = subprocess.run(
            ["git", "log", f"-{count}", "--pretty=format:%H|%s|%an|%ci"],
            capture_output=True,
            text=True,
            cwd=str(PROJECT_ROOT),
            timeout=10,
        )
        if result.returncode != 0:
            return []

        commits = []
        for line in result.stdout.strip().split("\n"):
            if not line:
                continue
            parts = line.split("|", 3)
            if len(parts) >= 4:
                commits.append({
                    "hash": parts[0][:8],
                    "message": parts[1],
                    "author": parts[2],
                    "date": parts[3],
                })
        return commits
    except Exception:
        return []


# ============================================================================
# Import Analysis
# ============================================================================

def check_imports(modules: List[str]) -> Dict[str, bool]:
    """
    Check if Python modules can be imported.

    Returns dict mapping module name to importable status.
    """
    results = {}
    for module in modules:
        try:
            code = f"import {module}"
            result = subprocess.run(
                ["python3", "-c", code],
                capture_output=True,
                text=True,
                timeout=10,
                cwd=str(PROJECT_ROOT),
                env={**os.environ, "PYTHONPATH": str(PROJECT_ROOT)},
            )
            results[module] = result.returncode == 0
        except Exception:
            results[module] = False
    return results


def get_import_graph(target_path: Path, max_depth: int = 3) -> Dict[str, Set[str]]:
    """
    Build import graph for a file/directory.

    Returns dict mapping file path to set of imported modules.
    """
    graph: Dict[str, Set[str]] = {}
    import_pattern = re.compile(
        r'^(?:from\s+(\S+)\s+import|import\s+(\S+))',
        re.MULTILINE
    )

    def scan_file(py_file: Path, depth: int = 0):
        if depth > max_depth:
            return
        rel_path = str(py_file.relative_to(PROJECT_ROOT))
        if rel_path in graph:
            return

        graph[rel_path] = set()
        try:
            content = py_file.read_text(errors="ignore")
            for match in import_pattern.finditer(content):
                module = match.group(1) or match.group(2)
                if module:
                    module = module.split(".")[0]  # Get base module
                    graph[rel_path].add(module)
        except Exception:
            pass

    if target_path.is_file():
        scan_file(target_path)
    elif target_path.is_dir():
        for py_file in target_path.rglob("*.py"):
            scan_file(py_file)

    return {k: list(v) for k, v in graph.items()}


def compute_fan_in_out(
    target_path: str,
    project_root: Path = PROJECT_ROOT,
) -> Tuple[List[str], List[str]]:
    """
    Compute import fan-in (who imports this) and fan-out (what this imports).

    Returns (imports_in, imports_out)
    """
    target = Path(target_path)
    if not target.is_absolute():
        target = project_root / target_path

    # Determine module name from path
    try:
        rel_path = target.relative_to(project_root)
        module_name = str(rel_path).replace("/", ".").replace(".py", "")
    except ValueError:
        module_name = target.stem

    imports_in = []
    imports_out = []

    # Get fan-out (what this file imports)
    if target.exists() and target.is_file():
        import_pattern = re.compile(
            r'^(?:from\s+(\S+)\s+import|import\s+(\S+))',
            re.MULTILINE
        )
        try:
            content = target.read_text(errors="ignore")
            for match in import_pattern.finditer(content):
                module = match.group(1) or match.group(2)
                if module and module not in imports_out:
                    imports_out.append(module)
        except Exception:
            pass

    # Get fan-in (who imports this)
    short_name = module_name.split(".")[-1]
    import_patterns = [
        rf'from\s+{re.escape(module_name)}\s+import',
        rf'import\s+{re.escape(module_name)}',
        rf'from\s+\S*{re.escape(short_name)}\s+import',
    ]

    for py_file in project_root.rglob("*.py"):
        if py_file == target:
            continue
        try:
            content = py_file.read_text(errors="ignore")
            for pattern in import_patterns:
                if re.search(pattern, content):
                    rel = str(py_file.relative_to(project_root))
                    if rel not in imports_in:
                        imports_in.append(rel)
                    break
        except Exception:
            continue

    return imports_in, imports_out


# ============================================================================
# TODO/FIXME Scanning
# ============================================================================

TODO_PATTERN = re.compile(
    r'#\s*(TODO|FIXME|XXX|HACK)\s*:?\s*(.*)$',
    re.MULTILINE | re.IGNORECASE
)


def scan_todos(
    target_path: Optional[Path] = None,
    exclude_dirs: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
    """
    Scan for TODO/FIXME/XXX/HACK markers.

    Returns list of markers with file, line, type, and text.
    """
    if target_path is None:
        target_path = PROJECT_ROOT
    if exclude_dirs is None:
        exclude_dirs = ["__pycache__", ".git", "node_modules", "venv", ".venv", "seeds", "site-packages", "gitforest-repo"]

    todos = []

    for py_file in target_path.rglob("*.py"):
        # Skip excluded directories
        if any(excl in py_file.parts for excl in exclude_dirs):
            continue
        # Skip venv-like directories
        path_str = str(py_file)
        if ".venv" in path_str or "venv" in path_str or "site-packages" in path_str:
            continue

        try:
            content = py_file.read_text(errors="ignore")
            for i, line in enumerate(content.split("\n"), 1):
                match = TODO_PATTERN.search(line)
                if match:
                    todos.append({
                        "marker_type": match.group(1).upper(),
                        "text": match.group(2).strip(),
                        "file_path": str(py_file.relative_to(PROJECT_ROOT)),
                        "line_number": i,
                        "context": line.strip(),
                    })
        except Exception:
            continue

    return todos


def count_todos_by_file(todos: List[Dict[str, Any]]) -> Dict[str, int]:
    """Count TODOs by file."""
    counts: Dict[str, int] = {}
    for todo in todos:
        path = todo["file_path"]
        counts[path] = counts.get(path, 0) + 1
    return dict(sorted(counts.items(), key=lambda x: -x[1]))


# ============================================================================
# Orphan Module Detection
# ============================================================================

def find_orphan_modules(
    target_dir: Optional[Path] = None,
    exclude_dirs: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
    """
    Find Python modules that are never imported.

    Returns list of orphan modules with metadata.
    """
    if target_dir is None:
        target_dir = PROJECT_ROOT
    if exclude_dirs is None:
        exclude_dirs = ["__pycache__", ".git", "node_modules", "venv", ".venv", "tests", "experiments", "site-packages", "gitforest-repo"]

    # Build import index
    all_imports: Set[str] = set()
    all_modules: Dict[str, Path] = {}

    for py_file in target_dir.rglob("*.py"):
        if any(excl in py_file.parts for excl in exclude_dirs):
            continue
        # Skip venv-like directories
        path_str = str(py_file)
        if ".venv" in path_str or "venv" in path_str or "site-packages" in path_str:
            continue

        # Record module
        rel_path = str(py_file.relative_to(PROJECT_ROOT))
        module_name = rel_path.replace("/", ".").replace(".py", "")
        all_modules[module_name] = py_file

        # Record imports
        try:
            content = py_file.read_text(errors="ignore")
            import_pattern = re.compile(
                r'^(?:from\s+(\S+)\s+import|import\s+(\S+))',
                re.MULTILINE
            )
            for match in import_pattern.finditer(content):
                module = match.group(1) or match.group(2)
                if module:
                    all_imports.add(module)
                    # Also add parent modules
                    parts = module.split(".")
                    for i in range(1, len(parts)):
                        all_imports.add(".".join(parts[:i]))
        except Exception:
            continue

    # Find orphans
    orphans = []
    for module_name, py_file in all_modules.items():
        # Check if module or any of its names are imported
        short_name = module_name.split(".")[-1]
        is_imported = (
            module_name in all_imports
            or short_name in all_imports
            or any(module_name in imp for imp in all_imports)
        )

        if not is_imported:
            # Check for __main__ or if it's a test file
            try:
                content = py_file.read_text(errors="ignore")
                has_main = 'if __name__ == "__main__"' in content or "if __name__ == '__main__'" in content
                lines_of_code = len([l for l in content.split("\n") if l.strip() and not l.strip().startswith("#")])
            except Exception:
                has_main = False
                lines_of_code = 0

            orphans.append({
                "path": str(py_file.relative_to(PROJECT_ROOT)),
                "module_name": module_name,
                "lines_of_code": lines_of_code,
                "has_main": has_main,
                "has_tests": "test" in py_file.name.lower(),
            })

    return orphans


# ============================================================================
# Receipt Generation
# ============================================================================

def generate_receipt_id() -> str:
    """Generate a unique receipt ID."""
    timestamp = datetime.now(timezone.utc).isoformat()
    return hashlib.sha256(f"{timestamp}:{time.time_ns()}".encode()).hexdigest()[:16]


def save_ops_receipt(
    receipt_type: str,
    data: Dict[str, Any],
    subdir: str = "ops",
) -> str:
    """
    Save an operations receipt.

    Returns path to saved receipt.
    """
    receipts_dir = PROJECT_ROOT / "receipts" / subdir
    receipts_dir.mkdir(parents=True, exist_ok=True)

    timestamp = datetime.now(timezone.utc)
    date_str = timestamp.strftime("%Y%m%d")
    receipt_id = generate_receipt_id()

    filename = f"{receipt_type.lower()}_{date_str}_{receipt_id[:8]}.json"
    receipt_path = receipts_dir / filename

    receipt = {
        "receipt_type": receipt_type,
        "receipt_id": receipt_id,
        "timestamp": timestamp.isoformat(),
        "work_order": "WO-ECHO-TEAMMATE-01",
        **data,
    }

    with open(receipt_path, "w") as f:
        json.dump(receipt, f, indent=2, default=str)

    return str(receipt_path.relative_to(PROJECT_ROOT))


# ============================================================================
# Triage Scoring
# ============================================================================

def compute_triage_rubric(
    path: str,
    receipts: List[Dict[str, Any]],
    todos: List[Dict[str, Any]],
    imports_in: List[str],
    imports_out: List[str],
    lines_of_code: int,
) -> Dict[str, int]:
    """
    Compute triage rubric scores (0-3 each).

    Returns dict with evidence, connectivity, effort, risk scores.
    """
    # Evidence score (0-3): based on receipts mentioning this path
    related_receipts = [r for r in receipts if path in r.get("path", "")]
    if len(related_receipts) >= 3:
        evidence = 3
    elif len(related_receipts) >= 2:
        evidence = 2
    elif len(related_receipts) >= 1:
        evidence = 1
    else:
        evidence = 0

    # Connectivity score (0-3): based on import fan-in + fan-out
    total_connections = len(imports_in) + len(imports_out)
    if total_connections >= 10:
        connectivity = 3
    elif total_connections >= 5:
        connectivity = 2
    elif total_connections >= 2:
        connectivity = 1
    else:
        connectivity = 0

    # Effort score (0-3, inverted): smaller is better
    if lines_of_code <= 50:
        effort = 3
    elif lines_of_code <= 200:
        effort = 2
    elif lines_of_code <= 500:
        effort = 1
    else:
        effort = 0

    # Risk score (0-3, inverted): fewer TODOs and has receipts is better
    path_todos = [t for t in todos if path in t.get("file_path", "")]
    if len(path_todos) == 0 and len(related_receipts) > 0:
        risk = 3
    elif len(path_todos) <= 2:
        risk = 2
    elif len(path_todos) <= 5:
        risk = 1
    else:
        risk = 0

    return {
        "evidence": evidence,
        "connectivity": connectivity,
        "effort": effort,
        "risk": risk,
    }


def classify_triage_category(rubric: Dict[str, int], has_fail: bool = False) -> str:
    """
    Classify into triage category based on rubric scores.

    Categories:
    - alive: evidence >=2, connectivity >=2, not failed
    - dead: has explicit FAIL status
    - continue: connectivity >=2, effort >=2, low evidence
    - freeze: low connectivity, low evidence
    """
    if has_fail:
        return "dead"

    evidence = rubric["evidence"]
    connectivity = rubric["connectivity"]
    effort = rubric["effort"]

    if evidence >= 2 and connectivity >= 2:
        return "alive"
    elif connectivity >= 2 and effort >= 2 and evidence < 2:
        return "continue"
    else:
        return "freeze"


# ============================================================================
# Se (Symbolic Entropy) Computation for Ops
# WO-ECHO-TEAMMATE-02: Adapt Se formula for health/triage metrics
# ============================================================================

import math


def compute_ops_se(
    receipts: List[Dict[str, Any]],
    todos: List[Dict[str, Any]],
    import_results: Dict[str, bool],
) -> Dict[str, Any]:
    """
    Compute Se (Symbolic Entropy) for ops health data.

    Adapts the Se = H × C × D formula for ops metrics:
    - H: Entropy of receipt status distribution
    - C: Coherence of import graph (fraction importable)
    - D: Depth of TODO clustering (disorder metric)

    Returns dict with H, C, D, Se, regime, and classification_mode.
    """
    # H: Entropy of receipt status distribution
    status_counts = {"PASS": 0, "FAIL": 0, "UNKNOWN": 0}
    for r in receipts:
        status = r.get("status", "UNKNOWN")
        if status in status_counts:
            status_counts[status] += 1
        else:
            status_counts["UNKNOWN"] += 1

    total = sum(status_counts.values())
    if total == 0:
        H = 0.0
    else:
        # Shannon entropy normalized to 0-1
        H_bits = 0.0
        for count in status_counts.values():
            if count > 0:
                p = count / total
                H_bits -= p * math.log2(p)
        # Max entropy for 3 categories is log2(3) ≈ 1.585
        H = min(1.0, H_bits / math.log2(3))

    # C: Coherence = fraction of imports that work
    if import_results:
        importable = sum(1 for v in import_results.values() if v)
        C = importable / len(import_results)
    else:
        C = 1.0  # No imports to check = fully coherent

    # D: Depth = TODO clustering disorder
    # Higher TODO count = more disorder = higher D
    if todos:
        # Count unique files with TODOs
        files_with_todos = set(t.get("file_path", "") for t in todos)
        # Normalize: cap at 20 files for max disorder
        D = min(1.0, len(files_with_todos) / 20.0)
    else:
        D = 0.0  # No TODOs = no disorder

    # Se = H × C × D (all 0-1 scale)
    Se = H * C * D

    # Classify regime (per Se formula thresholds)
    if Se < 0.12:
        regime = "low_se_structured"
        classification_mode = "analytical"  # LEFT lobe
    elif Se < 0.18:
        regime = "mid_se_balanced"
        classification_mode = "intuitive"  # RIGHT lobe
    else:
        regime = "high_se_exploratory"
        classification_mode = "creative"  # POET lobe

    return {
        "H": round(H, 4),
        "C": round(C, 4),
        "D": round(D, 4),
        "Se": round(Se, 6),
        "regime": regime,
        "classification_mode": classification_mode,
    }


def compute_module_se(
    target_path: Path,
    receipts: List[Dict[str, Any]],
    todos: List[Dict[str, Any]],
) -> Dict[str, Any]:
    """
    Compute Se for a specific module/directory.

    Adapts Se formula for module-level metrics:
    - H: Code complexity entropy (lines of code distribution)
    - C: Import coherence (connectivity ratio)
    - D: TODO density (disorder metric)

    Se thresholds for routing:
    - Se < 0.12 → analytical (LEFT lobe)
    - Se < 0.18 → intuitive (RIGHT lobe)
    - Se >= 0.18 → creative (POET lobe)
    """
    if not target_path.exists():
        return {
            "H": 0.0,
            "C": 1.0,
            "D": 0.0,
            "Se": 0.0,
            "regime": "low_se_structured",
            "classification_mode": "analytical",
        }

    # Collect Python files
    if target_path.is_file():
        py_files = [target_path]
    else:
        py_files = list(target_path.rglob("*.py"))
        # Filter out __pycache__
        py_files = [f for f in py_files if "__pycache__" not in str(f)]

    if not py_files:
        return {
            "H": 0.0,
            "C": 1.0,
            "D": 0.0,
            "Se": 0.0,
            "regime": "low_se_structured",
            "classification_mode": "analytical",
        }

    # H: Code complexity entropy (lines of code distribution)
    loc_values = []
    for py_file in py_files[:50]:  # Cap for performance
        try:
            content = py_file.read_text(errors="ignore")
            loc = len([l for l in content.split("\n") if l.strip() and not l.strip().startswith("#")])
            loc_values.append(loc)
        except Exception:
            continue

    if loc_values:
        # Normalize LOC to bins and compute entropy
        total_loc = sum(loc_values)
        if total_loc > 0:
            # Entropy of file size distribution
            H_bits = 0.0
            for loc in loc_values:
                if loc > 0:
                    p = loc / total_loc
                    H_bits -= p * math.log2(p)
            # Normalize by log2(N) where N = number of files
            max_H = math.log2(len(loc_values)) if len(loc_values) > 1 else 1.0
            H = min(1.0, H_bits / max_H) if max_H > 0 else 0.0
        else:
            H = 0.0
    else:
        H = 0.0

    # C: Import coherence
    # Check if target module's imports resolve
    imports_in, imports_out = compute_fan_in_out(str(target_path))
    connectivity = len(imports_in) + len(imports_out)
    # Normalize: 10+ connections = highly coherent
    C = min(1.0, connectivity / 10.0)

    # D: TODO density in target
    rel_path = str(target_path.relative_to(PROJECT_ROOT)) if target_path.is_relative_to(PROJECT_ROOT) else str(target_path)
    target_todos = [t for t in todos if rel_path in t.get("file_path", "")]

    if py_files and target_todos:
        # TODOs per file
        todos_per_file = len(target_todos) / len(py_files)
        # Normalize: 3+ TODOs/file = high disorder
        D = min(1.0, todos_per_file / 3.0)
    else:
        D = 0.0

    # Se = H × C × D
    Se = H * C * D

    # Classify regime
    if Se < 0.12:
        regime = "low_se_structured"
        classification_mode = "analytical"
    elif Se < 0.18:
        regime = "mid_se_balanced"
        classification_mode = "intuitive"
    else:
        regime = "high_se_exploratory"
        classification_mode = "creative"

    return {
        "H": round(H, 4),
        "C": round(C, 4),
        "D": round(D, 4),
        "Se": round(Se, 6),
        "regime": regime,
        "classification_mode": classification_mode,
    }


# ============================================================================
# Spatial Memory Helpers
# ============================================================================

def compute_spatial_se(
    n_objects: int,
    n_queries: int,
    query_results: List[Dict[str, Any]],
) -> Dict[str, Any]:
    """
    Compute Se (Symbolic Entropy) for spatial query operations.

    GROUNDED IN CANONICAL Se FORMULA (helix_cdc/metrics/symbolic_entropy.py):
    =========================================================================

    Se = H × C × D where:
        - H: Shannon entropy (bits) - TRUE physics quantity
        - C: Contextual coherence (0-1) - HEURISTIC but empirically useful
        - D: Dimensional depth (1-72) - HEURISTIC, 72 = specialist count (practical, not math)

    PROVEN EVIDENCE:
        - 728× improvement with scaffolding (receipt: se_728_receipt.json)
        - Real measurements in artifacts/proof/se_measure.receipt.json
        - Se=37.27, H=5.34, C=0.87, D=8 on actual data

    ADAPTATION FOR SPATIAL QUERIES:
    ================================

    For spatial memory, we adapt the formula while preserving semantics:

        H: Shannon entropy of query result bytes (bits/byte, 0-8 range)
            - Serialize results to bytes, compute true Shannon entropy
            - More diverse results = higher entropy

        C: Coherence = fraction of successful queries (0-1)
            - Analogous to "reasoning markers" detection in canonical formula
            - Full success = fully coherent response

        D: Dimensional depth (1-72 scale)
            - NOTE: 72 was chosen because the swarm had 72 specialists
            - NOT a principled math constant - just a practical upper bound
            - Maps scene complexity to this legacy scale for routing consistency

    Se thresholds adapted from canonical:
        - Se < 10: low_se_structured (analytical routing)
        - Se < 50: mid_se_balanced (intuitive routing)
        - Se >= 50: high_se_exploratory (creative routing)

    Returns dict with H, C, D, Se, regime, and classification_mode.
    """
    import json

    # H: Shannon entropy of serialized query results (TRUE Shannon)
    # This matches the canonical formula: -Σ p log₂(p) on byte distribution
    if query_results:
        results_bytes = json.dumps(
            [r.get("result") for r in query_results],
            default=str
        ).encode("utf-8")

        if len(results_bytes) > 0:
            freq: Dict[int, int] = {}
            for b in results_bytes:
                freq[b] = freq.get(b, 0) + 1
            total = len(results_bytes)
            H = 0.0
            for count in freq.values():
                if count > 0:
                    p = count / total
                    H -= p * math.log2(p)
        else:
            H = 0.0
    else:
        H = 0.0

    # C: Coherence = fraction of successful queries (0-1)
    # Analogous to canonical "structure_score" for valid JSON output
    successful = sum(1 for r in query_results if r.get("error") is None)
    C = successful / len(query_results) if query_results else 1.0

    # D: Dimensional depth (1-72 scale)
    # NOTE: 72 = number of specialists in swarm (practical choice, not math constant)
    # Maps scene complexity to legacy routing scale
    if n_objects < 10:
        D = 1.0
    elif n_objects < 50:
        D = 4.0
    elif n_objects < 100:
        D = 8.0
    elif n_objects < 500:
        D = 16.0
    elif n_objects < 1000:
        D = 36.0
    elif n_objects < 5000:
        D = 48.0
    else:
        D = 72.0

    # Se = H × C × D (canonical formula)
    # Note: Se is NOT normalized to [0,1] - it can be 0 to ~576 (8 × 1 × 72)
    Se = H * C * D

    # Regime thresholds adapted for this scale
    # Canonical receipts show Se values like 37.27 for moderate complexity
    if Se < 10:
        regime = "low_se_structured"
        classification_mode = "analytical"
    elif Se < 50:
        regime = "mid_se_balanced"
        classification_mode = "intuitive"
    else:
        regime = "high_se_exploratory"
        classification_mode = "creative"

    return {
        "H": round(H, 4),
        "C": round(C, 4),
        "D": round(D, 1),
        "Se": round(Se, 4),
        "regime": regime,
        "classification_mode": classification_mode,
    }


# ============================================================================
# Geometry Intent Detection (WO-ECHO-FIBPI3D-LOOP-01)
# ============================================================================

GEOMETRY_KEYWORDS = {
    # Distance-related
    "distance", "how far", "how close", "nearest", "closest", "near", "far",
    "proximity", "range",
    # Directional
    "left of", "right of", "above", "below", "between", "inside", "outside",
    # Spatial concepts
    "radius", "spatial", "position", "coordinates", "location", "geometry",
    "objects", "neighbors", "adjacent",
    # Action words often paired with spatial
    "find", "locate", "where is", "is there",
}

# Patterns that strongly indicate geometry queries
GEOMETRY_PATTERNS = [
    r"distance\s+between",
    r"nearest\s+\d*\s*objects?",
    r"closest\s+\d*\s*objects?",
    r"k[_\-]?nearest",
    r"obj_\d+",  # Object naming convention
    r"is\s+\w+\s+(near|left of|right of|above|below|between|inside)",
    r"how\s+far\s+is",
    r"within\s+\d+(\.\d+)?\s+(units?|meters?|distance)",
]


def detect_geometry_intent(prompt: str) -> Dict[str, Any]:
    """
    Detect if a prompt requires spatial tool usage.

    WO-ECHO-FIBPI3D-LOOP-01: Keyword-based geometry detection for routing.

    Returns:
        dict with:
            - is_geometry: bool - True if spatial tool should be used
            - matched_keywords: list - Keywords that matched
            - matched_patterns: list - Regex patterns that matched
            - confidence: float (0-1) - Confidence score
            - suggested_tool: str or None - Tool to route to
    """
    prompt_lower = prompt.lower()

    # Check keywords
    matched_keywords = []
    for kw in GEOMETRY_KEYWORDS:
        if kw in prompt_lower:
            matched_keywords.append(kw)

    # Check patterns
    matched_patterns = []
    for pattern in GEOMETRY_PATTERNS:
        if re.search(pattern, prompt_lower):
            matched_patterns.append(pattern)

    # Compute confidence
    # Each keyword adds 0.15, each pattern adds 0.25, capped at 1.0
    keyword_score = len(matched_keywords) * 0.15
    pattern_score = len(matched_patterns) * 0.25
    confidence = min(1.0, keyword_score + pattern_score)

    # Determine if geometry
    # At least 1 keyword OR 1 pattern match triggers geometry detection
    is_geometry = len(matched_keywords) >= 1 or len(matched_patterns) >= 1

    return {
        "is_geometry": is_geometry,
        "matched_keywords": matched_keywords,
        "matched_patterns": matched_patterns,
        "confidence": round(confidence, 3),
        "suggested_tool": "fibpi3d_spatial_tool" if is_geometry else None,
    }


# ============================================================================
# Math/Calculator Intent Detection (WO-MODULAR-INVENTORY-01)
# ============================================================================

# Keywords that indicate math/calculation queries
# WO-TOOL-ROUTING-HARDEN-01: Removed false-positive-prone keywords:
#   - "what is" - triggers on any question
#   - "how much is" - triggers on price queries
#   - "log" - conflicts with "log file", "log in"
#   - "equals" - abstract usage in English
# Keep "logarithm" instead of "log"
MATH_KEYWORDS = {
    # Arithmetic operations
    "calculate", "compute", "sum", "add", "subtract", "multiply", "divide",
    "plus", "minus", "times", "divided by",
    # Mathematical operations
    "square root", "sqrt", "power", "exponent", "factorial", "modulo", "mod",
    "percentage", "percent", "average", "mean", "median",
    # Numeric queries (removed: "what is", "how much is", "equals")
    "result of",
    # Trigonometry
    "sin", "cos", "tan", "sine", "cosine", "tangent",
    # Other math (removed: "log" - keep "logarithm")
    "logarithm", "ln", "absolute", "round", "floor", "ceil",
}

# Patterns that strongly indicate math queries
MATH_PATTERNS = [
    r"\d+\s*[\+\-\*\/\^]\s*\d+",          # 5 + 3, 10 * 2
    r"calculate\s+.+",                     # calculate anything
    r"compute\s+.+",                       # compute anything
    r"what\s+is\s+\d+",                    # what is 5...
    r"(\d+)\s*%\s*of\s*(\d+)",             # 10% of 50
    r"sqrt\s*\(?\s*\d+",                   # sqrt 16 or sqrt(16)
    r"\d+\s*\^\s*\d+",                     # 2^10
    r"sum\s+of\s+[\d,\s]+",                # sum of 1, 2, 3
    r"average\s+of\s+[\d,\s]+",            # average of 1, 2, 3
    r"\d+\s*factorial",                    # 5 factorial
    r"\d+\s*!(?!\=)",                      # 5! (but not 5!=)
]


def detect_math_intent(prompt: str) -> Dict[str, Any]:
    """
    Detect if a prompt requires math/calculator tool usage.

    WO-MODULAR-INVENTORY-01: Keyword-based math detection for routing.
    WO-TOOL-ROUTING-HARDEN-01: Hardened to reduce false positives:
        - Patterns require digits (implicit in patterns like \\d+)
        - Keywords alone require digits present AND 3+ keyword matches
        - Single pattern match is strong evidence

    Returns:
        dict with:
            - is_math: bool - True if math tool should be used
            - matched_keywords: list - Keywords that matched
            - matched_patterns: list - Regex patterns that matched
            - confidence: float (0-1) - Confidence score
            - suggested_tool: str or None - Tool to route to
            - has_digits: bool - Whether prompt contains digits
    """
    prompt_lower = prompt.lower()

    # WO-TOOL-ROUTING-HARDEN-01: Check for digits - key heuristic
    has_digits = bool(re.search(r'\d', prompt))

    # Check keywords
    matched_keywords = []
    for kw in MATH_KEYWORDS:
        if kw in prompt_lower:
            matched_keywords.append(kw)

    # Check patterns
    matched_patterns = []
    for pattern in MATH_PATTERNS:
        if re.search(pattern, prompt_lower):
            matched_patterns.append(pattern)

    # Compute confidence
    # Patterns are stronger evidence (they typically contain digits)
    # Keywords without digits are weak evidence
    keyword_score = len(matched_keywords) * 0.10  # Reduced from 0.15
    pattern_score = len(matched_patterns) * 0.35  # Increased from 0.30
    # Bonus for having digits with keywords
    if has_digits and matched_keywords:
        keyword_score *= 1.5
    confidence = min(1.0, keyword_score + pattern_score)

    # WO-TOOL-ROUTING-HARDEN-01: Stricter math detection
    # - Pattern match alone → math (patterns require digits implicitly)
    # - Keywords require 3+ matches AND digits present
    is_math = (
        len(matched_patterns) >= 1 or
        (len(matched_keywords) >= 3 and has_digits)
    )

    return {
        "is_math": is_math,
        "matched_keywords": matched_keywords,
        "matched_patterns": matched_patterns,
        "confidence": round(confidence, 3),
        "suggested_tool": "calculator_tool" if is_math else None,
        "has_digits": has_digits,
    }


# ============================================================================
# Retrieval/Search Intent Detection (WO-MODULAR-INVENTORY-01)
# ============================================================================

# Keywords that indicate retrieval/search queries
RETRIEVAL_KEYWORDS = {
    # Search actions
    "search", "look up", "lookup", "find information", "search for",
    "retrieve", "fetch", "query", "look for",
    # Document references
    "document", "file", "article", "paper", "reference", "source",
    "documentation", "docs", "manual", "guide",
    # Information seeking
    "information about", "info on", "details about", "learn about",
    "what does", "explain", "definition of", "meaning of",
    # Database/knowledge
    "database", "knowledge base", "index", "catalog",
}

# Patterns that strongly indicate retrieval queries
RETRIEVAL_PATTERNS = [
    r"search\s+for\s+.+",                  # search for something
    r"look\s+up\s+.+",                     # look up something
    r"find\s+information\s+(about|on)",    # find information about/on
    r"what\s+does\s+\w+\s+mean",           # what does X mean
    r"definition\s+of\s+.+",               # definition of something
    r"in\s+(the\s+)?docs?",                # in the docs
    r"according\s+to\s+.+",                # according to something
    r"retrieve\s+(all|the)?\s*.+",         # retrieve the/all something
]


def detect_retrieval_intent(prompt: str) -> Dict[str, Any]:
    """
    Detect if a prompt requires retrieval/search tool usage.

    WO-MODULAR-INVENTORY-01: Keyword-based retrieval detection for routing.

    Returns:
        dict with:
            - is_retrieval: bool - True if retrieval tool should be used
            - matched_keywords: list - Keywords that matched
            - matched_patterns: list - Regex patterns that matched
            - confidence: float (0-1) - Confidence score
            - suggested_tool: str or None - Tool to route to
    """
    prompt_lower = prompt.lower()

    # Check keywords
    matched_keywords = []
    for kw in RETRIEVAL_KEYWORDS:
        if kw in prompt_lower:
            matched_keywords.append(kw)

    # Check patterns
    matched_patterns = []
    for pattern in RETRIEVAL_PATTERNS:
        if re.search(pattern, prompt_lower):
            matched_patterns.append(pattern)

    # Compute confidence
    # Each keyword adds 0.12, each pattern adds 0.25, capped at 1.0
    keyword_score = len(matched_keywords) * 0.12
    pattern_score = len(matched_patterns) * 0.25
    confidence = min(1.0, keyword_score + pattern_score)

    # Determine if retrieval
    # At least 1 pattern match OR 2 keywords triggers retrieval detection
    is_retrieval = len(matched_patterns) >= 1 or len(matched_keywords) >= 2

    return {
        "is_retrieval": is_retrieval,
        "matched_keywords": matched_keywords,
        "matched_patterns": matched_patterns,
        "confidence": round(confidence, 3),
        "suggested_tool": "retrieval_tool" if is_retrieval else None,
    }


# ============================================================================
# Code Execution Intent Detection (WO-MODULAR-INVENTORY-01)
# ============================================================================

# Keywords that indicate code execution queries
# WO-TOOL-ROUTING-HARDEN-01: Reduced broad keywords ("run" and "execute" alone are too broad)
# Now require stronger code-specific context
CODE_KEYWORDS = {
    # Actions (removed standalone "run", "execute" - too broad)
    "run code", "run script", "run this", "execute code", "execute script",
    "eval", "evaluate", "compile", "interpret",
    # Code references
    "code", "script", "program", "function", "snippet",
    # Languages (common ones)
    "python", "javascript", "bash", "shell", "sql",
    # File types
    ".py", ".js", ".sh", ".sql", ".ts",
    # Operations
    "result of running", "test this", "debug this",
}

# Patterns that strongly indicate code execution queries
CODE_PATTERNS = [
    r"run\s+(this\s+)?(code|script|function|program)",    # run this code
    r"execute\s+(this\s+)?(code|script|command)",         # execute this script
    r"```\w*\n",                                           # code block markdown
    r"what\s+(does|will)\s+this\s+(code|script)\s+(do|output)", # what does this code do
    r"\.(py|js|sh|sql|ts)\s*$",                           # file extension at end
    r"(def|function|class)\s+\w+",                         # function/class definition
    r"import\s+\w+",                                       # import statement
    r"from\s+\w+\s+import",                                # from X import
]


def detect_code_intent(prompt: str) -> Dict[str, Any]:
    """
    Detect if a prompt requires code execution tool usage.

    WO-MODULAR-INVENTORY-01: Keyword-based code detection for routing.
    WO-TOOL-ROUTING-HARDEN-01: Hardened to reduce false positives:
        - Patterns are strong evidence (code fence, import, def/class)
        - Keywords alone require 2+ matches
        - Removed overly broad keywords like standalone "run", "execute"

    Returns:
        dict with:
            - is_code: bool - True if code tool should be used
            - matched_keywords: list - Keywords that matched
            - matched_patterns: list - Regex patterns that matched
            - confidence: float (0-1) - Confidence score
            - suggested_tool: str or None - Tool to route to
    """
    prompt_lower = prompt.lower()

    # Check keywords
    matched_keywords = []
    for kw in CODE_KEYWORDS:
        if kw in prompt_lower:
            matched_keywords.append(kw)

    # Check patterns (case sensitive for code patterns)
    matched_patterns = []
    for pattern in CODE_PATTERNS:
        if re.search(pattern, prompt, re.IGNORECASE):
            matched_patterns.append(pattern)

    # Compute confidence
    # Patterns are strong evidence (code fences, imports, defs)
    # Keywords need context
    keyword_score = len(matched_keywords) * 0.15
    pattern_score = len(matched_patterns) * 0.35
    confidence = min(1.0, keyword_score + pattern_score)

    # WO-TOOL-ROUTING-HARDEN-01: Stricter code detection
    # - Pattern match alone → code (patterns like code fence, import, def are strong)
    # - Keywords require 2+ matches (but keywords are now more specific)
    is_code = len(matched_patterns) >= 1 or len(matched_keywords) >= 2

    return {
        "is_code": is_code,
        "matched_keywords": matched_keywords,
        "matched_patterns": matched_patterns,
        "confidence": round(confidence, 3),
        "suggested_tool": "code_executor_tool" if is_code else None,
    }


def compute_health_se(checks: List[Dict[str, Any]]) -> Dict[str, Any]:
    """
    Compute Se for nightly health check results.

    Used by echo_nightly_health.py for phase-lock saves.

    - H: Entropy of check result distribution (PASS/FAIL/ERROR/SKIP)
    - C: Coherence = fraction of checks that passed
    - D: Depth = ratio of critical failures
    """
    if not checks:
        return {
            "H": 0.0,
            "C": 1.0,
            "D": 0.0,
            "Se": 0.0,
            "regime": "low_se_structured",
            "classification_mode": "analytical",
        }

    # H: Entropy of check status distribution
    status_counts = {"PASS": 0, "FAIL": 0, "ERROR": 0, "SKIP": 0}
    for check in checks:
        status = check.get("status", "ERROR")
        if status in status_counts:
            status_counts[status] += 1
        else:
            status_counts["ERROR"] += 1

    total = sum(status_counts.values())
    if total == 0:
        H = 0.0
    else:
        H_bits = 0.0
        for count in status_counts.values():
            if count > 0:
                p = count / total
                H_bits -= p * math.log2(p)
        H = min(1.0, H_bits / math.log2(4))  # Max entropy for 4 categories

    # C: Coherence = fraction passed
    passed = sum(1 for c in checks if c.get("passed", False))
    C = passed / total if total > 0 else 1.0

    # D: Critical failure depth
    critical_failures = sum(1 for c in checks if c.get("critical", True) and not c.get("passed", False))
    D = min(1.0, critical_failures / 5.0)  # Cap at 5 critical failures

    Se = H * C * D

    if Se < 0.12:
        regime = "low_se_structured"
        classification_mode = "analytical"
    elif Se < 0.18:
        regime = "mid_se_balanced"
        classification_mode = "intuitive"
    else:
        regime = "high_se_exploratory"
        classification_mode = "creative"

    return {
        "H": round(H, 4),
        "C": round(C, 4),
        "D": round(D, 4),
        "Se": round(Se, 6),
        "regime": regime,
        "classification_mode": classification_mode,
    }


# ============================================================================
# Uncertainty Intent Detection (WO-ECHO-UNCERTAINTY-DISPATCH)
# ============================================================================

# Keywords that indicate the user needs external information lookup
UNCERTAINTY_KEYWORDS = {
    # Explicit lookup requests
    "look up", "search for", "find out", "check", "look for",
    # Temporal boundaries (knowledge cutoff)
    "latest", "recent", "current", "today", "2024", "2025", "2026",
    "newest", "up to date", "now",
    # External knowledge indicators
    "api", "documentation", "docs", "version", "release",
    "github", "stackoverflow", "pypi", "npm", "crates.io",
    "official", "website",
    # Codebase queries
    "in this codebase", "which file", "where is", "implementation of",
    "in the code", "in our code", "in this repo", "in this project",
}

# Patterns that strongly indicate uncertainty/lookup queries
UNCERTAINTY_PATTERNS = [
    r"what.*is.*the.*latest",
    r"what.*is.*the.*current",
    r"search.*for\s+.+",
    r"look.*up\s+.+",
    r"find.*in.*(?:docs?|code|codebase)",
    r"how.*to.*install",
    r"where.*can.*i.*find",
    r"what.*version.*of",
    r"is.*there.*a.*newer",
    r"check.*(?:if|whether)",
    r"what.*file.*(?:contains|has|defines)",
    r"which.*(?:file|module|class).*(?:has|contains|implements)",
]


def detect_uncertainty_intent(
    prompt: str,
    other_detections: Optional[Dict[str, Dict[str, Any]]] = None,
) -> Dict[str, Any]:
    """
    Detect if a query requires external information lookup.

    WO-ECHO-UNCERTAINTY-DISPATCH: Uncertainty detection for agent dispatch.

    Uncertainty is detected when:
    1. Explicit lookup keywords/patterns are present, OR
    2. All other detectors return low confidence (aggregate < 0.3)

    Args:
        prompt: The user's query
        other_detections: Optional dict of detection results from other detectors
            e.g., {"geometry": {...}, "math": {...}, ...}
            If not provided, runs all detectors internally.

    Returns:
        dict with:
            - is_uncertain: bool - True if uncertainty dispatch should be used
            - uncertainty_type: str - "temporal", "external", "codebase", or "general"
            - matched_keywords: list - Keywords that matched
            - matched_patterns: list - Regex patterns that matched
            - confidence: float (0-1) - Confidence score
            - aggregate_other_confidence: float - Max confidence from other detectors
            - suggested_agents: list - Agent types to dispatch
    """
    prompt_lower = prompt.lower()

    # Check explicit uncertainty keywords
    matched_keywords = []
    for kw in UNCERTAINTY_KEYWORDS:
        if kw in prompt_lower:
            matched_keywords.append(kw)

    # Check explicit uncertainty patterns
    matched_patterns = []
    for pattern in UNCERTAINTY_PATTERNS:
        if re.search(pattern, prompt_lower):
            matched_patterns.append(pattern)

    # Classify uncertainty type based on keywords
    uncertainty_type = "general"
    if any(kw in matched_keywords for kw in ["latest", "recent", "current", "today", "2024", "2025", "2026", "newest"]):
        uncertainty_type = "temporal"
    elif any(kw in matched_keywords for kw in ["in this codebase", "which file", "where is", "in the code", "in our code", "in this repo"]):
        uncertainty_type = "codebase"
    elif any(kw in matched_keywords for kw in ["api", "documentation", "docs", "github", "stackoverflow", "pypi", "npm"]):
        uncertainty_type = "external"

    # Check other detectors if not provided
    if other_detections is None:
        other_detections = {
            "geometry": detect_geometry_intent(prompt),
            "math": detect_math_intent(prompt),
            "retrieval": detect_retrieval_intent(prompt),
            "code": detect_code_intent(prompt),
        }

    # Get aggregate confidence from other detectors
    other_confidences = [d.get("confidence", 0.0) for d in other_detections.values()]
    aggregate_other_confidence = max(other_confidences) if other_confidences else 0.0

    # Compute uncertainty confidence
    # Explicit keywords/patterns are strong signals
    keyword_score = len(matched_keywords) * 0.15
    pattern_score = len(matched_patterns) * 0.30
    explicit_confidence = min(1.0, keyword_score + pattern_score)

    # If all other detectors are low confidence, boost uncertainty
    low_other_confidence_boost = 0.3 if aggregate_other_confidence < 0.3 else 0.0

    confidence = min(1.0, explicit_confidence + low_other_confidence_boost)

    # Determine if uncertain
    # Explicit markers trigger uncertainty, OR low confidence from all other detectors
    has_explicit_markers = len(matched_patterns) >= 1 or len(matched_keywords) >= 2
    all_others_low = aggregate_other_confidence < 0.3

    is_uncertain = has_explicit_markers or (all_others_low and len(matched_keywords) >= 1)

    # Suggest agents based on uncertainty type
    agent_mapping = {
        "codebase": ["codebase"],
        "temporal": ["search"],
        "external": ["search", "retrieval"],
        "general": ["codebase", "search"],
    }
    suggested_agents = agent_mapping.get(uncertainty_type, ["codebase"])

    return {
        "is_uncertainty": is_uncertain,  # Key matches routing expectation: is_{mode_name}
        "is_uncertain": is_uncertain,    # Alias for readability
        "uncertainty_type": uncertainty_type,
        "matched_keywords": matched_keywords,
        "matched_patterns": matched_patterns,
        "confidence": round(confidence, 3),
        "aggregate_other_confidence": round(aggregate_other_confidence, 3),
        "suggested_agents": suggested_agents,
        "suggested_tool": "uncertainty_dispatcher" if is_uncertain else None,
    }


# ============================================================================
# Test Execution Intent Detection (WO-ECHO-TEST-AGENT-01)
# ============================================================================

# Keywords that indicate test execution queries
# NOTE: Removed bare "test"/"tests" to avoid false positives like "test my understanding"
# Precision over recall - we're routing into EXECUTION
TEST_KEYWORDS = {
    # Explicit test actions (require action verb + test noun)
    "run tests", "run the tests", "run pytest", "run test", "execute tests",
    "pytest", "unit test", "unit tests", "integration test",
    # Make targets
    "make test", "make gate", "make healthcheck",
    # Verification actions (only when combined with other signals)
    "check tests", "test suite",
    # Results queries (informational, not execution)
    # "test results", "test output", "test status",  # Removed - these are queries, not execution
}

# Patterns that strongly indicate test execution queries
# These are high-confidence signals that should trigger routing
TEST_PATTERNS = [
    r"run\s+(the\s+)?tests?",                 # run tests, run the test
    r"pytest\s+[\w\/\.\-]*",                  # pytest tests/ or pytest path or just pytest
    r"^pytest$",                              # just "pytest" alone
    r"make\s+(test|gate|healthcheck|kat)",    # make test targets
    r"python3?\s+-m\s+pytest",                # python -m pytest OR python3 -m pytest
    r"execute\s+(the\s+)?tests?",             # execute tests
    r"run\s+unit\s+tests?",                   # run unit tests
    r"run\s+integration\s+tests?",            # run integration tests
]


def detect_test_intent(prompt: str) -> Dict[str, Any]:
    """
    Detect if a prompt requires test execution tool usage.

    WO-ECHO-TEST-AGENT-01: Keyword-based test detection for routing.

    PRECISION OVER RECALL: This routes into EXECUTION, so we require
    either a parseable command OR a strong pattern match. Keywords alone
    are not enough (to avoid "test my understanding" false positives).

    Returns:
        dict with:
            - is_test: bool - True if test tool should be used
            - matched_keywords: list - Keywords that matched
            - matched_patterns: list - Regex patterns that matched
            - confidence: float (0-1) - Confidence score
            - suggested_tool: str or None - Tool to route to
            - parsed_command: Optional dict with parsed command info
    """
    prompt_lower = prompt.lower()

    # Check keywords
    matched_keywords = []
    for kw in TEST_KEYWORDS:
        if kw in prompt_lower:
            matched_keywords.append(kw)

    # Check patterns
    matched_patterns = []
    for pattern in TEST_PATTERNS:
        if re.search(pattern, prompt_lower):
            matched_patterns.append(pattern)

    # Try to extract a command from the prompt
    parsed_command = _parse_test_command(prompt)

    # Compute confidence
    # Parseable command is strongest signal (we can actually execute it)
    # Patterns are also strong (they contain explicit tool names)
    # Keywords alone are weak (used for boosting, not triggering)
    pattern_score = len(matched_patterns) * 0.35
    command_bonus = 0.40 if parsed_command else 0.0
    keyword_boost = min(len(matched_keywords) * 0.08, 0.25)  # Cap keyword contribution
    confidence = min(1.0, pattern_score + command_bonus + keyword_boost)

    # STRICT: Only route to test if we have concrete evidence
    # - Parseable command → we know what to execute
    # - Pattern match → explicit tool token like "pytest", "make test"
    # Keywords alone are NOT enough (avoids "test my understanding" false positives)
    is_test = parsed_command is not None or len(matched_patterns) >= 1

    return {
        "is_test": is_test,
        "matched_keywords": matched_keywords,
        "matched_patterns": matched_patterns,
        "confidence": round(confidence, 3),
        "suggested_tool": "test_agent" if is_test else None,
        "parsed_command": parsed_command,
    }


def _parse_test_command(prompt: str) -> Optional[Dict[str, Any]]:
    """
    Parse a test command from natural language.

    Returns dict with command, args if parseable, None otherwise.

    Examples:
        "run pytest tests/ -v" -> {"command": "pytest", "args": ["tests/", "-v"]}
        "run the tests" -> {"command": "pytest", "args": ["tests/"]}
        "make test" -> {"command": "make", "args": ["test"]}
        "python3 -m pytest" -> {"command": "python", "args": ["-m", "pytest", "tests/"]}
    """
    prompt_lower = prompt.lower().strip()

    # Pattern: python3 -m pytest <args> (check FIRST, more specific)
    # Normalize python3 → python (allowlist expects "python")
    python3_pytest_match = re.search(r'python3\s+-m\s+pytest\s*([\w\/\.\-\s]*)', prompt_lower)
    if python3_pytest_match:
        args_str = python3_pytest_match.group(1).strip()
        args = ["-m", "pytest"] + (args_str.split() if args_str else ["tests/"])
        return {"command": "python", "args": args}

    # Pattern: python -m pytest <args>
    python_pytest_match = re.search(r'python\s+-m\s+pytest\s*([\w\/\.\-\s]*)', prompt_lower)
    if python_pytest_match:
        args_str = python_pytest_match.group(1).strip()
        args = ["-m", "pytest"] + (args_str.split() if args_str else ["tests/"])
        return {"command": "python", "args": args}

    # Pattern: pytest <args>
    pytest_match = re.search(r'pytest\s*([\w\/\.\-\s]*)', prompt_lower)
    if pytest_match:
        args_str = pytest_match.group(1).strip()
        args = args_str.split() if args_str else ["tests/"]
        return {"command": "pytest", "args": args}

    # Pattern: make <target>
    make_match = re.search(r'make\s+(test|gate|healthcheck|kat[\w\-]*)', prompt_lower)
    if make_match:
        target = make_match.group(1)
        return {"command": "make", "args": [target]}

    # Generic "run tests" / "run the tests"
    if re.search(r'run\s+(the\s+)?tests?', prompt_lower):
        return {"command": "pytest", "args": ["tests/"]}

    return None


# ============================================================================
# Patch Intent Detection & Validation (WO-ECHO-PATCH-AGENT-01)
# ============================================================================

# Allowed path prefixes for patching
ALLOWED_PATCH_PATHS = {
    "prefixes": ["helix_cdc/", "api/", "tools/", "tests/"],
    "excluded": [".git/", "__pycache__/", "venv/", ".venv/", "node_modules/"],
    "extensions": [".py", ".md", ".json", ".yaml", ".yml", ".txt"],
}

# Keywords that indicate patch/edit requests
PATCH_KEYWORDS = {
    # Edit actions
    "edit", "modify", "change", "update", "fix", "patch",
    "add", "remove", "delete", "replace", "insert",
    # File operations
    "in file", "in the file", "to file", "to the file",
    "the file", "this file",
    # Code modifications
    "add a comment", "add comment", "fix the bug", "fix bug",
    "change the", "update the", "modify the",
}

# Patterns that strongly indicate patch/edit queries
PATCH_PATTERNS = [
    r"edit\s+(the\s+)?file",
    r"edit\s+[\w_\/\.]+\.(py|md|json|yaml|yml|txt)",  # edit path/file.py
    r"modify\s+(the\s+)?file",
    r"modify\s+[\w_\/\.]+\.(py|md|json|yaml|yml|txt)",  # modify path/file.py
    r"fix\s+(the\s+)?(bug|error|issue)\s+in",
    r"change\s+.+\s+to\s+.+\s+in",
    r"add\s+.+\s+to\s+(the\s+)?file",
    r"remove\s+.+\s+from\s+(the\s+)?file",
    r"update\s+(the\s+)?[\w_\/\.]+\.(py|md|json|yaml|yml|txt)",
    r"in\s+[\w_\/\.]+\.(py|md|json|yaml|yml|txt)",
    r"patch\s+[\w_\/\.]+",
]


def validate_patch_path(file_path: str) -> Tuple[bool, str]:
    """
    Validate file path for patching.

    WO-ECHO-PATCH-AGENT-01: Path validation against allowlist.

    Checks:
    1. Path starts with an allowed prefix
    2. Path doesn't contain excluded patterns (e.g., .git/)
    3. File has an allowed extension

    Returns:
        (is_valid, reason)
    """
    # Normalize path
    path = file_path.strip()
    if path.startswith("/"):
        # Make relative to project root
        if path.startswith(str(PROJECT_ROOT)):
            path = path[len(str(PROJECT_ROOT)):].lstrip("/")
        else:
            return False, f"Absolute path must be within PROJECT_ROOT: {PROJECT_ROOT}"

    # Check excluded patterns
    for excluded in ALLOWED_PATCH_PATHS["excluded"]:
        if excluded in path:
            return False, f"Path contains excluded pattern: {excluded}"

    # Check allowed prefixes
    has_allowed_prefix = False
    for prefix in ALLOWED_PATCH_PATHS["prefixes"]:
        if path.startswith(prefix):
            has_allowed_prefix = True
            break

    if not has_allowed_prefix:
        return False, f"Path must start with one of: {ALLOWED_PATCH_PATHS['prefixes']}"

    # Check extension
    has_allowed_ext = False
    for ext in ALLOWED_PATCH_PATHS["extensions"]:
        if path.endswith(ext):
            has_allowed_ext = True
            break

    if not has_allowed_ext:
        return False, f"File must have one of these extensions: {ALLOWED_PATCH_PATHS['extensions']}"

    # Check path traversal
    resolved = (PROJECT_ROOT / path).resolve()
    if not str(resolved).startswith(str(PROJECT_ROOT)):
        return False, "Path traversal detected"

    return True, "OK"


def detect_patch_intent(prompt: str) -> Dict[str, Any]:
    """
    Detect if a prompt requires patch/edit tool usage.

    WO-ECHO-PATCH-AGENT-01: Keyword-based patch detection for routing.

    Returns:
        dict with:
            - is_patch: bool - True if patch tool should be used
            - matched_keywords: list - Keywords that matched
            - matched_patterns: list - Regex patterns that matched
            - confidence: float (0-1) - Confidence score
            - suggested_tool: str or None - Tool to route to
            - detected_files: list - File paths detected in prompt
    """
    prompt_lower = prompt.lower()

    # Check keywords
    matched_keywords = []
    for kw in PATCH_KEYWORDS:
        if kw in prompt_lower:
            matched_keywords.append(kw)

    # Check patterns
    matched_patterns = []
    for pattern in PATCH_PATTERNS:
        if re.search(pattern, prompt_lower):
            matched_patterns.append(pattern)

    # Try to detect file paths in the prompt
    detected_files = []
    # Match paths like helix_cdc/foo.py, api/bar.py, etc.
    path_pattern = r'(?:helix_cdc|api|tools|tests)[\w\/\.\-]+\.(?:py|md|json|yaml|yml|txt)'
    for match in re.finditer(path_pattern, prompt, re.IGNORECASE):
        detected_files.append(match.group(0))

    # Compute confidence
    keyword_score = len(matched_keywords) * 0.12
    pattern_score = len(matched_patterns) * 0.30
    file_bonus = 0.25 if detected_files else 0.0
    confidence = min(1.0, keyword_score + pattern_score + file_bonus)

    # Determine if patch
    # At least 1 pattern match OR (2+ keywords AND a detected file)
    is_patch = (
        len(matched_patterns) >= 1 or
        (len(matched_keywords) >= 2 and len(detected_files) >= 1)
    )

    return {
        "is_patch": is_patch,
        "matched_keywords": matched_keywords,
        "matched_patterns": matched_patterns,
        "confidence": round(confidence, 3),
        "suggested_tool": "patch_agent" if is_patch else None,
        "detected_files": detected_files,
    }
