"""
FastAPI Endpoints for Echo Ops

WO-ECHO-TEAMMATE-01: /echo/brief, /echo/act, /echo/triage endpoints
"""

from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field

from .models import (
    ActRequest,
    ActResponse,
    ActReceipt,
    AskEvidence,
    AskMode,
    AskReceipt,
    AskRequest,
    AskResponse,
    BriefResponse,
    EchoLoopReceipt,
    GeometryRoutingCDR,
    GreenItem,
    HealthLevel,
    MapResponse,
    NextAction,
    RedIssue,
    RouterDecision,
    RubricScore,
    SeMetrics,
    SpatialMemoryRequest,
    SpatialMemoryResponse,
    SpatialQuery,
    SpatialQueryResult,
    SpatialQueryType,
    SpatialSceneInfo,
    SpatialScenesResponse,
    TriageCategory,
    TriagedItem,
    TriageRequest,
    TriageResponse,
    YellowIssue,
)
from .helpers import (
    PROJECT_ROOT,
    check_imports,
    classify_triage_category,
    compute_fan_in_out,
    compute_module_se,
    compute_ops_se,
    compute_spatial_se,
    compute_triage_rubric,
    count_receipts_by_status,
    count_todos_by_file,
    detect_geometry_intent,
    execute_command,
    generate_receipt_id,
    get_recent_commits,
    save_ops_receipt,
    scan_receipts,
    scan_todos,
    validate_command,
)
from .map_collector import collect_system_map

router = APIRouter(prefix="/echo", tags=["echo-ops"])


# ============================================================================
# GET /echo/brief - Daily Standup
# ============================================================================

@router.get("/brief", response_model=BriefResponse)
async def echo_brief():
    """
    Daily standup with red/yellow/green health signals.

    WO-ECHO-TEAMMATE-01: Aggregates health data from multiple sources.

    Data Sources:
    - Last 7 days of receipts/
    - Last 10 git commits
    - Import sanity check
    - TODO/FIXME markers by file
    """
    timestamp = datetime.now(timezone.utc).isoformat()

    red_issues: List[RedIssue] = []
    yellow_issues: List[YellowIssue] = []
    green_items: List[GreenItem] = []
    next_actions: List[NextAction] = []

    data_sources: Dict[str, Any] = {}

    # 1. Scan receipts from last 7 days
    receipts = scan_receipts(days=7)
    receipt_counts = count_receipts_by_status(receipts)
    data_sources["receipts"] = {
        "total": len(receipts),
        "counts": receipt_counts,
    }

    # Check for failing receipts
    failing_receipts = [r for r in receipts if r.get("status") == "FAIL"]
    for fr in failing_receipts[:5]:  # Limit to 5
        red_issues.append(RedIssue(
            issue_type="failing_tests",
            description=f"Receipt failed: {fr.get('type', 'unknown')}",
            source=fr.get("path", "unknown"),
            details={"work_order": fr.get("work_order")},
            first_seen=fr.get("timestamp"),
        ))

    # Passing receipts are green
    passing_receipts = [r for r in receipts if r.get("status") == "PASS"]
    for pr in passing_receipts[:5]:  # Limit to 5
        green_items.append(GreenItem(
            item_type="receipt",
            description=f"Receipt passed: {pr.get('type', 'unknown')}",
            source=pr.get("path", "unknown"),
            timestamp=pr.get("timestamp"),
        ))

    # 2. Check imports
    critical_modules = [
        "helix_cdc",
        "helix_cdc.substrate.api",
        "helix_cdc.echo.tools",
        "helix_cdc.regrow",
    ]
    import_results = check_imports(critical_modules)
    data_sources["imports"] = import_results

    for module, importable in import_results.items():
        if not importable:
            red_issues.append(RedIssue(
                issue_type="broken_import",
                description=f"Cannot import: {module}",
                source=module,
            ))
        else:
            green_items.append(GreenItem(
                item_type="healthcheck",
                description=f"Import OK: {module}",
                source=module,
            ))

    # 3. Get recent commits
    commits = get_recent_commits(10)
    data_sources["commits"] = {
        "count": len(commits),
        "latest": commits[0] if commits else None,
    }

    for commit in commits[:3]:
        green_items.append(GreenItem(
            item_type="commit",
            description=f"{commit['hash']}: {commit['message'][:50]}",
            source=f"git:{commit['hash']}",
            timestamp=commit.get("date"),
        ))

    # 4. Scan TODOs
    todos = scan_todos()
    todo_counts = count_todos_by_file(todos)
    data_sources["todos"] = {
        "total": len(todos),
        "by_file": dict(list(todo_counts.items())[:10]),
    }

    # Files with many TODOs are yellow
    for file_path, count in list(todo_counts.items())[:3]:
        if count >= 5:
            yellow_issues.append(YellowIssue(
                issue_type="todo_hotspot",
                description=f"{count} TODOs in {file_path}",
                source=file_path,
                details={"count": count},
            ))

    # 5. Determine health level
    if red_issues:
        health_level = HealthLevel.RED
    elif yellow_issues:
        health_level = HealthLevel.YELLOW
    else:
        health_level = HealthLevel.GREEN

    # 6. Generate next actions
    if red_issues:
        for i, issue in enumerate(red_issues[:3], 1):
            if issue.issue_type == "failing_tests":
                next_actions.append(NextAction(
                    priority=i,
                    description=f"Fix failing receipt: {issue.source}",
                    command=f"cat {issue.source} | jq '.summary'",
                    rationale="Failing receipts indicate broken functionality",
                ))
            elif issue.issue_type == "broken_import":
                next_actions.append(NextAction(
                    priority=i,
                    description=f"Fix import: {issue.source}",
                    command=f"python3 -c 'import {issue.source}'",
                    rationale="Broken imports block other functionality",
                ))

    if yellow_issues and not red_issues:
        for i, issue in enumerate(yellow_issues[:3], 1):
            if issue.issue_type == "todo_hotspot":
                next_actions.append(NextAction(
                    priority=i + len(red_issues),
                    description=f"Address TODOs in {issue.source}",
                    command=f"rg 'TODO|FIXME' {issue.source}",
                    rationale="High TODO density indicates incomplete work",
                ))

    if not next_actions:
        next_actions.append(NextAction(
            priority=5,
            description="System healthy - consider running nightly health check",
            command="make echo-health",
            rationale="Regular health checks maintain system reliability",
        ))

    # 7. Compute Se metrics (WO-ECHO-TEAMMATE-02)
    se_data = compute_ops_se(receipts, todos, import_results)
    se_metrics = SeMetrics(
        H=se_data["H"],
        C=se_data["C"],
        D=se_data["D"],
        Se=se_data["Se"],
        regime=se_data["regime"],
        classification_mode=se_data["classification_mode"],
    )

    # 8. Save phase-lock for Se continuity
    phase_lock_id = None
    try:
        import sys
        sys.path.insert(0, str(PROJECT_ROOT))
        from echo_memory import EchoMemory
        mem = EchoMemory()
        phase_lock_id = mem.save_phase_lock(
            name="ops_health",
            Se=se_data["Se"],
            H_X=se_data["H"],
            C_X=se_data["C"],
            D_X=se_data["D"],
            metadata={
                "health_level": health_level.value,
                "receipts_pass": receipt_counts["PASS"],
                "receipts_fail": receipt_counts["FAIL"],
                "todos_total": len(todos),
            }
        )
    except Exception as e:
        # Phase-lock save is optional - don't fail the endpoint
        pass

    # 9. Generate summary
    summary_parts = [
        f"Health: {health_level.value.upper()}",
        f"Receipts: {receipt_counts['PASS']} pass, {receipt_counts['FAIL']} fail",
        f"Imports: {sum(import_results.values())}/{len(import_results)} OK",
        f"TODOs: {len(todos)} total",
        f"Se: {se_data['Se']:.4f} ({se_data['regime']})",
    ]
    summary = " | ".join(summary_parts)

    return BriefResponse(
        timestamp=timestamp,
        health_level=health_level,
        red=red_issues,
        yellow=yellow_issues,
        green=green_items,
        next_actions=next_actions,
        summary=summary,
        data_sources=data_sources,
        se_metrics=se_metrics,
        phase_lock_id=phase_lock_id,
    )


# ============================================================================
# POST /echo/act - Safe Tool Runner
# ============================================================================

@router.post("/act", response_model=ActResponse)
async def echo_act(request: ActRequest):
    """
    Safe tool runner with allowlisted commands.

    WO-ECHO-TEAMMATE-01: Execute commands safely with receipt generation.

    Allowlist:
    - git status, diff, log, branch, show
    - rg, find
    - pytest, python -m
    - make healthcheck, make kat-*, make test-*

    Blocked: rm, git push, git reset --hard, sudo, curl|sh
    """
    timestamp = datetime.now(timezone.utc)

    # Validate command
    is_valid, reason = validate_command(
        request.command,
        request.args,
        request.cwd,
    )

    if not is_valid:
        return ActResponse(
            status="blocked",
            message=f"Command blocked: {reason}",
            blocked_reason=reason,
            validated_command=f"{request.command} {' '.join(request.args)}",
        )

    full_command = f"{request.command} {' '.join(request.args)}".strip()

    # Dry run - just validate
    if request.dry_run:
        return ActResponse(
            status="dry_run",
            message="Command validated successfully (dry run)",
            validated_command=full_command,
        )

    # Execute command
    result = execute_command(
        request.command,
        request.args,
        request.timeout_seconds,
        request.cwd,
    )

    # Generate receipt
    receipt_id = generate_receipt_id()
    receipt = ActReceipt(
        receipt_id=receipt_id,
        timestamp=timestamp.isoformat(),
        command=request.command,
        args=request.args,
        exit_code=result["exit_code"],
        stdout=result["stdout"],
        stderr=result["stderr"],
        elapsed_seconds=result["elapsed_seconds"],
        receipt_path="",  # Will be filled after save
    )

    # Save receipt
    receipt_path = save_ops_receipt(
        "act",
        {
            "command": request.command,
            "args": request.args,
            "exit_code": result["exit_code"],
            "stdout": result["stdout"][:5000],  # Truncate for storage
            "stderr": result["stderr"][:2000],
            "elapsed_seconds": result["elapsed_seconds"],
            "dry_run": request.dry_run,
            "cwd": request.cwd,
        },
    )
    receipt.receipt_path = receipt_path

    # Determine status
    if result["exit_code"] == 0:
        status = "success"
        message = f"Command executed successfully in {result['elapsed_seconds']:.2f}s"
    else:
        status = "error"
        message = f"Command failed with exit code {result['exit_code']}"

    return ActResponse(
        status=status,
        message=message,
        receipt=receipt,
        validated_command=full_command,
    )


# ============================================================================
# POST /echo/triage - Idea/Stub Classification
# ============================================================================

@router.post("/triage", response_model=TriageResponse)
async def echo_triage(request: TriageRequest):
    """
    Classify ideas/stubs by evidence and effort.

    WO-ECHO-TEAMMATE-01: Categorize modules into alive/dead/continue/freeze.

    Rubric (0-3 each):
    - Evidence: receipt/benchmark count
    - Connectivity: import fan-in/fan-out
    - Effort (inverted): lines of code
    - Risk (inverted): TODO count, no receipts

    Categories:
    - Alive: evidence >=2, connectivity >=2, status PASS
    - Dead: status FAIL/DISPROVEN
    - Continue: connectivity >=2, effort >=2, low evidence
    - Freeze: low connectivity, low evidence
    """
    timestamp = datetime.now(timezone.utc).isoformat()

    alive: List[TriagedItem] = []
    dead: List[TriagedItem] = []
    stubs_continue: List[TriagedItem] = []
    stubs_freeze: List[TriagedItem] = []

    # Resolve target path
    target = request.target
    if target.startswith("WO-"):
        # Work order reference - search for related files
        target_path = PROJECT_ROOT
    else:
        target_path = Path(target)
        if not target_path.is_absolute():
            target_path = PROJECT_ROOT / target

    if not target_path.exists():
        raise HTTPException(status_code=404, detail=f"Target not found: {target}")

    # Gather data
    receipts = scan_receipts(days=30)  # Longer window for triage
    todos = scan_todos(target_path if target_path.is_dir() else None)

    # Find Python files to analyze
    if target_path.is_file():
        py_files = [target_path]
    else:
        py_files = list(target_path.rglob("*.py"))[:100]  # Limit for performance

    scan_metadata = {
        "target": str(target),
        "files_analyzed": len(py_files),
        "receipts_scanned": len(receipts),
        "todos_found": len(todos),
    }

    # Analyze each file
    for py_file in py_files:
        rel_path = str(py_file.relative_to(PROJECT_ROOT))

        # Skip __pycache__ and similar
        if "__pycache__" in rel_path or ".git" in rel_path:
            continue

        # Get metrics
        try:
            content = py_file.read_text(errors="ignore")
            lines_of_code = len([
                l for l in content.split("\n")
                if l.strip() and not l.strip().startswith("#")
            ])
        except Exception:
            lines_of_code = 0

        # Get imports
        if request.include_dependencies:
            imports_in, imports_out = compute_fan_in_out(rel_path)
        else:
            imports_in, imports_out = [], []

        # Get TODOs for this file
        file_todos = [t for t in todos if rel_path in t.get("file_path", "")]

        # Compute rubric
        rubric = compute_triage_rubric(
            rel_path,
            receipts,
            todos,
            imports_in,
            imports_out,
            lines_of_code,
        )

        # Check for explicit FAIL status in receipts
        file_receipts = [r for r in receipts if rel_path in r.get("path", "")]
        has_fail = any(r.get("status") == "FAIL" for r in file_receipts)

        # Classify
        category = classify_triage_category(rubric, has_fail)

        # Build reasons
        reasons = []
        if rubric["evidence"] >= 2:
            reasons.append(f"Good evidence ({rubric['evidence']}/3)")
        elif rubric["evidence"] == 0:
            reasons.append("No evidence/receipts")

        if rubric["connectivity"] >= 2:
            reasons.append(f"Well connected ({len(imports_in)} in, {len(imports_out)} out)")
        elif rubric["connectivity"] == 0:
            reasons.append("Isolated (low connectivity)")

        if len(file_todos) > 5:
            reasons.append(f"High TODO count ({len(file_todos)})")

        if has_fail:
            reasons.append("Has failing receipts")

        # Create triaged item
        item = TriagedItem(
            name=py_file.name,
            path=rel_path,
            category=TriageCategory(category),
            rubric=RubricScore(**rubric),
            reasons=reasons,
            receipts=[r["path"] for r in file_receipts[:5]],
            todos=len(file_todos),
            imports_in=imports_in[:10],
            imports_out=imports_out[:10],
            lines_of_code=lines_of_code,
        )

        # Add to appropriate list
        if category == "alive":
            alive.append(item)
        elif category == "dead":
            dead.append(item)
        elif category == "continue":
            stubs_continue.append(item)
        else:
            stubs_freeze.append(item)

    # Sort by total rubric score
    def rubric_total(item: TriagedItem) -> int:
        r = item.rubric
        return r.evidence + r.connectivity + r.effort + r.risk

    alive.sort(key=rubric_total, reverse=True)
    stubs_continue.sort(key=rubric_total, reverse=True)
    stubs_freeze.sort(key=rubric_total, reverse=True)

    # Compute Se for target module (WO-ECHO-TEAMMATE-02)
    se_data = compute_module_se(target_path, receipts, todos)
    se_metrics = SeMetrics(
        H=se_data["H"],
        C=se_data["C"],
        D=se_data["D"],
        Se=se_data["Se"],
        regime=se_data["regime"],
        classification_mode=se_data["classification_mode"],
    )

    # Se-based routing determines classification style
    classification_mode = se_data["classification_mode"]
    scan_metadata["se_routing"] = {
        "Se": se_data["Se"],
        "regime": se_data["regime"],
        "mode": classification_mode,
        "rationale": {
            "analytical": "Se < 0.12: Structured analysis (LEFT lobe style)",
            "intuitive": "0.12 <= Se < 0.18: Balanced intuition (RIGHT lobe style)",
            "creative": "Se >= 0.18: Creative suggestions (POET lobe style)",
        }.get(classification_mode, "Unknown"),
    }

    # Generate summary with Se routing
    summary = (
        f"Triaged {len(py_files)} files: "
        f"{len(alive)} alive, {len(dead)} dead, "
        f"{len(stubs_continue)} continue, {len(stubs_freeze)} freeze "
        f"| Se={se_data['Se']:.4f} ({classification_mode})"
    )

    return TriageResponse(
        timestamp=timestamp,
        target=str(target),
        alive=alive,
        dead=dead,
        stubs_continue=stubs_continue,
        stubs_freeze=stubs_freeze,
        summary=summary,
        scan_metadata=scan_metadata,
        se_metrics=se_metrics,
        classification_mode=classification_mode,
    )


# ============================================================================
# GET /echo/map - System Layout (WO-ONTOLOGY-HARDENING-01)
# ============================================================================

@router.get("/map", response_model=MapResponse)
async def echo_map():
    """
    System layout: what is plugged in where.

    WO-ONTOLOGY-HARDENING-01: Observability for Echo's architecture.

    This endpoint provides structured JSON showing:
    - Git state (head, branch, dirty)
    - Service port status (basin_server, helixd)
    - Backend availability (decode_only, chat, qpu_stubs)
    - Module load status
    - Tripwire configuration defaults
    - Recent receipts summary
    - Key file paths

    This is NOT self-awareness - it's observability + invariants + receipts.
    Echo becomes a system that can't lie to itself without leaving fingerprints.
    """
    return await collect_system_map()


# ============================================================================
# POST /echo/spatial - Spatial Memory Tool
# ============================================================================

@router.post("/spatial", response_model=SpatialMemoryResponse)
async def echo_spatial(request: SpatialMemoryRequest):
    """
    Spatial memory tool wrapping FibPi3D substrate.

    Enables deterministic spatial queries (near, between, k_nearest, inside,
    left_of, above) without retraining weights. The FibPi3D backend provides
    O(1) geometric predicates for most operations.

    Query Types:
    - near: Is distance(A, B) < threshold?
    - distance: Euclidean distance from A to B
    - k_nearest: K closest objects to A
    - between: Is B geometrically between A and C?
    - inside: Is A within radius of B?
    - left_of: Is A.x < B.x?
    - above: Is A.z > B.z?

    All queries are deterministic: same scene_id + seed → same results.
    """
    import time
    from .spatial_memory import get_manager

    start_time = time.perf_counter()
    timestamp = datetime.now(timezone.utc)

    mgr = get_manager()

    # Get or create scene
    scene = mgr.get_scene(request.scene_id)
    if scene is None:
        if not request.create_scene_if_missing:
            raise HTTPException(
                status_code=404,
                detail=f"Scene not found: {request.scene_id}"
            )

        # Create with config or defaults
        config = request.scene_config
        if config:
            scene = mgr.create_scene(
                scene_id=request.scene_id,
                n_objects=config.n_objects,
                distribution=config.distribution,
                bounds=(config.bounds_min, config.bounds_max),
                seed=config.seed,
            )
        else:
            scene = mgr.create_scene(request.scene_id)

    # Execute queries
    query_results = []
    for q in request.queries:
        result = mgr.query(
            scene_id=request.scene_id,
            query_type=q.query_type.value,
            a=q.a,
            b=q.b,
            c=q.c,
            k=q.k,
            threshold=q.threshold,
            tolerance=q.tolerance,
        )

        query_results.append(SpatialQueryResult(
            query_type=result.get("query_type", q.query_type.value),
            a=result.get("a", q.a),
            b=result.get("b"),
            c=result.get("c"),
            result=result.get("result"),
            latency_us=result.get("latency_us", 0.0),
            error=result.get("error"),
        ))

    # Calculate total latency
    end_time = time.perf_counter()
    total_latency_ms = (end_time - start_time) * 1000

    # Generate receipt ID
    receipt_id = generate_receipt_id()

    # Save receipt
    receipt_data = {
        "scene_id": request.scene_id,
        "scene_hash": scene.scene_hash,
        "n_objects": len(scene.objects),
        "n_queries": len(request.queries),
        "total_latency_ms": round(total_latency_ms, 3),
        "query_types": [q.query_type.value for q in request.queries],
        "results_summary": {
            "successful": sum(1 for r in query_results if r.error is None),
            "failed": sum(1 for r in query_results if r.error is not None),
        },
    }
    save_ops_receipt("spatial_query", receipt_data, subdir="spatial")

    # Compute Se metrics
    se_data = compute_spatial_se(
        n_objects=len(scene.objects),
        n_queries=len(request.queries),
        query_results=[
            {"query_type": r.query_type, "error": r.error}
            for r in query_results
        ],
    )
    se_metrics = SeMetrics(
        H=se_data["H"],
        C=se_data["C"],
        D=se_data["D"],
        Se=se_data["Se"],
        regime=se_data["regime"],
        classification_mode=se_data["classification_mode"],
    )

    return SpatialMemoryResponse(
        scene_id=request.scene_id,
        scene_hash=scene.scene_hash,
        n_objects=len(scene.objects),
        results=query_results,
        total_latency_ms=round(total_latency_ms, 3),
        receipt_id=receipt_id,
        se_metrics=se_metrics,
    )


@router.get("/spatial/scenes", response_model=SpatialScenesResponse)
async def echo_spatial_scenes():
    """
    List available spatial scenes.

    Returns metadata for all scenes currently loaded in memory.
    """
    from .spatial_memory import get_manager

    mgr = get_manager()
    scenes_info = []

    for scene_id in mgr.list_scenes():
        scene = mgr.get_scene(scene_id)
        if scene:
            scenes_info.append(SpatialSceneInfo(
                scene_id=scene.scene_id,
                n_objects=len(scene.objects),
                scene_hash=scene.scene_hash,
                distribution=scene.config.distribution,
                created_at=scene.created_at,
            ))

    return SpatialScenesResponse(
        scenes=scenes_info,
        count=len(scenes_info),
    )


# ============================================================================
# POST /echo/geometry-loop - Full Echo->Tool->Echo Loop (WO-ECHO-FIBPI3D-LOOP-01)
# ============================================================================

import hashlib
import json


class GeometryLoopRequest(BaseModel):
    """Request for geometry loop endpoint."""
    prompt: str = Field(..., description="User prompt to analyze for geometry intent")
    scene_id: str = Field(default="geometry_loop", description="Scene ID to use")
    scene_config: Optional[dict] = Field(None, description="Scene configuration")


class GeometryLoopResponse(BaseModel):
    """Response from geometry loop endpoint."""
    is_geometry: bool = Field(..., description="Whether geometry intent was detected")
    router_decision: RouterDecision
    answer: str = Field(..., description="Answer from spatial tool or fallback")
    loop_receipt: Optional[EchoLoopReceipt] = Field(None, description="Full loop receipt if geometry")
    receipt_path: Optional[str] = Field(None, description="Path where receipt was saved")


def _compute_chain_hash(prompt_sha: str, router: dict, tool_receipt_id: str, answer_sha: str) -> str:
    """Compute chain hash linking all loop steps."""
    data = f"{prompt_sha}|{json.dumps(router, sort_keys=True)}|{tool_receipt_id}|{answer_sha}"
    return hashlib.sha256(data.encode()).hexdigest()


def _sha256(text: str) -> str:
    """Compute SHA256 of text."""
    return hashlib.sha256(text.encode()).hexdigest()


def _parse_prompt_to_queries(prompt: str, router_decision: dict) -> List[dict]:
    """
    Parse a natural language prompt into spatial queries.

    This is a simple heuristic parser. For more complex prompts,
    an LLM would be used (but that's optional/gated).
    """
    prompt_lower = prompt.lower()
    queries = []

    # Try to extract object references (obj_XXXX pattern)
    import re
    obj_pattern = r'obj_(\d{4})'
    objects = re.findall(obj_pattern, prompt_lower)
    objects = [f"obj_{o}" for o in objects]

    # Determine query type based on keywords
    if "nearest" in prompt_lower or "closest" in prompt_lower:
        # k_nearest query
        k = 5  # Default
        k_match = re.search(r'(\d+)\s*(nearest|closest)', prompt_lower)
        if k_match:
            k = int(k_match.group(1))
        a = objects[0] if objects else "obj_0000"
        queries.append({"query_type": "k_nearest", "a": a, "k": k})

    elif "distance" in prompt_lower or "how far" in prompt_lower:
        # distance query
        if len(objects) >= 2:
            queries.append({"query_type": "distance", "a": objects[0], "b": objects[1]})
        elif len(objects) == 1:
            queries.append({"query_type": "distance", "a": objects[0], "b": "obj_0001"})

    elif "near" in prompt_lower:
        # near query
        threshold = 5.0
        thresh_match = re.search(r'threshold\s*(?:of\s*)?(\d+(?:\.\d+)?)', prompt_lower)
        if thresh_match:
            threshold = float(thresh_match.group(1))
        if len(objects) >= 2:
            queries.append({"query_type": "near", "a": objects[0], "b": objects[1], "threshold": threshold})

    elif "left of" in prompt_lower:
        if len(objects) >= 2:
            queries.append({"query_type": "left_of", "a": objects[0], "b": objects[1]})

    elif "above" in prompt_lower:
        if len(objects) >= 2:
            queries.append({"query_type": "above", "a": objects[0], "b": objects[1]})

    elif "between" in prompt_lower:
        if len(objects) >= 3:
            queries.append({
                "query_type": "between",
                "a": objects[0], "b": objects[1], "c": objects[2],
                "tolerance": 2.0
            })

    # Fallback: if we found objects but no specific query, use k_nearest
    if not queries and objects:
        queries.append({"query_type": "k_nearest", "a": objects[0], "k": 5})

    return queries


def _format_answer(prompt: str, results: List[dict]) -> str:
    """Format spatial query results into a natural language answer."""
    if not results:
        return "No spatial queries could be executed for this prompt."

    answers = []
    for r in results:
        qt = r.get("query_type", "unknown")
        result = r.get("result")
        error = r.get("error")

        if error:
            answers.append(f"Error: {error}")
            continue

        a = r.get("a", "unknown")
        b = r.get("b")

        if qt == "k_nearest":
            neighbors = result[:5] if isinstance(result, list) else []
            if neighbors:
                neighbor_str = ", ".join([f"{n[0]} ({n[1]:.2f})" for n in neighbors])
                answers.append(f"The nearest objects to {a} are: {neighbor_str}")
            else:
                answers.append(f"No neighbors found for {a}")

        elif qt == "distance":
            if isinstance(result, (int, float)):
                answers.append(f"The distance between {a} and {b} is {result:.4f} units")
            else:
                answers.append(f"Could not compute distance")

        elif qt == "near":
            if isinstance(result, bool):
                status = "are" if result else "are not"
                answers.append(f"{a} and {b} {status} near each other")

        elif qt == "left_of":
            if isinstance(result, bool):
                status = "is" if result else "is not"
                answers.append(f"{a} {status} to the left of {b}")

        elif qt == "above":
            if isinstance(result, bool):
                status = "is" if result else "is not"
                answers.append(f"{a} {status} above {b}")

        elif qt == "between":
            c = r.get("c")
            if isinstance(result, bool):
                status = "is" if result else "is not"
                answers.append(f"{b} {status} between {a} and {c}")

    return " ".join(answers) if answers else "Query completed."


@router.post("/geometry-loop", response_model=GeometryLoopResponse)
async def echo_geometry_loop(request: GeometryLoopRequest):
    """
    Full Echo->Tool->Echo loop for geometry prompts.

    WO-ECHO-FIBPI3D-LOOP-01: Implements the complete loop closure:
    1. Detect geometry intent in prompt
    2. Route to spatial tool if geometry detected
    3. Execute spatial queries
    4. Generate loop receipt with chain hash
    5. Return answer and receipt

    This endpoint demonstrates the full routing pattern without requiring
    an LLM - it uses heuristic prompt parsing for demonstration.
    """
    import time
    from .spatial_memory import get_manager

    start_time = time.perf_counter()
    timestamp = datetime.now(timezone.utc)
    errors = []

    # Step 1: Detect geometry intent
    router_result = detect_geometry_intent(request.prompt)
    router_decision = RouterDecision(
        is_geometry=router_result["is_geometry"],
        matched_keywords=router_result["matched_keywords"],
        matched_patterns=router_result["matched_patterns"],
        confidence=router_result["confidence"],
        suggested_tool=router_result["suggested_tool"],
    )

    # If not geometry, return early
    if not router_result["is_geometry"]:
        return GeometryLoopResponse(
            is_geometry=False,
            router_decision=router_decision,
            answer="This prompt does not appear to be a geometry/spatial query.",
            loop_receipt=None,
            receipt_path=None,
        )

    # Step 2: Parse prompt to queries
    queries = _parse_prompt_to_queries(request.prompt, router_result)
    if not queries:
        return GeometryLoopResponse(
            is_geometry=True,
            router_decision=router_decision,
            answer="Geometry intent detected but could not parse specific queries from prompt.",
            loop_receipt=None,
            receipt_path=None,
        )

    # Step 3: Execute spatial queries
    mgr = get_manager()

    # Get or create scene
    scene = mgr.get_scene(request.scene_id)
    if scene is None:
        config = request.scene_config or {}
        scene = mgr.create_scene(
            scene_id=request.scene_id,
            n_objects=config.get("n_objects", 100),
            distribution=config.get("distribution", "uniform"),
            bounds=tuple(config.get("bounds", [-10.0, 10.0])),
            seed=config.get("seed", request.scene_id),
        )

    # Execute queries
    query_results = []
    for q in queries:
        result = mgr.query(
            scene_id=request.scene_id,
            query_type=q.get("query_type", "k_nearest"),
            a=q.get("a", "obj_0000"),
            b=q.get("b"),
            c=q.get("c"),
            k=q.get("k", 5),
            threshold=q.get("threshold", 5.0),
            tolerance=q.get("tolerance", 2.0),
        )
        query_results.append(result)
        if result.get("error"):
            errors.append(result["error"])

    # Step 4: Format answer
    answer = _format_answer(request.prompt, query_results)

    # Step 5: Generate loop receipt
    loop_id = generate_receipt_id()
    tool_receipt_id = generate_receipt_id()
    prompt_sha = _sha256(request.prompt)
    answer_sha = _sha256(answer)

    # Compute chain hash
    chain_hash = _compute_chain_hash(
        prompt_sha,
        router_result,
        tool_receipt_id,
        answer_sha
    )

    # Compute Se metrics
    se_data = compute_spatial_se(
        n_objects=len(scene.objects),
        n_queries=len(queries),
        query_results=query_results,
    )
    se_metrics = SeMetrics(
        H=se_data["H"],
        C=se_data["C"],
        D=se_data["D"],
        Se=se_data["Se"],
        regime=se_data["regime"],
        classification_mode=se_data["classification_mode"],
    )

    # Build loop receipt
    loop_receipt = EchoLoopReceipt(
        loop_id=loop_id,
        timestamp_utc=timestamp.isoformat(),
        work_order="WO-ECHO-FIBPI3D-LOOP-01",
        prompt=request.prompt,
        prompt_sha256=prompt_sha,
        router_decision=router_decision,
        router_receipt_id=generate_receipt_id(),
        tool_endpoint="/echo/spatial",
        tool_request={
            "scene_id": request.scene_id,
            "queries": queries,
        },
        tool_response={
            "scene_id": request.scene_id,
            "scene_hash": scene.scene_hash,
            "n_objects": len(scene.objects),
            "results": query_results,
        },
        tool_receipt_id=tool_receipt_id,
        tool_scene_hash=scene.scene_hash,
        answer=answer,
        answer_sha256=answer_sha,
        chain_hash=chain_hash,
        se_metadata=se_metrics,
        status="PASS" if not errors else "FAIL",
        errors=errors,
    )

    # Step 6: Save loop receipt
    receipts_dir = PROJECT_ROOT / "receipts" / "echo_loops"
    receipts_dir.mkdir(parents=True, exist_ok=True)

    receipt_filename = f"loop_{timestamp.strftime('%Y%m%d_%H%M%S')}_{loop_id[:8]}.json"
    receipt_path = receipts_dir / receipt_filename

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

    return GeometryLoopResponse(
        is_geometry=True,
        router_decision=router_decision,
        answer=answer,
        loop_receipt=loop_receipt,
        receipt_path=str(receipt_path.relative_to(PROJECT_ROOT)),
    )


# ============================================================================
# POST /echo/ask - Memory Search with Evidence (WO-ECHO-ASK-02)
# ============================================================================

# Lazy-loaded MemoryIndex singleton
_memory_index = None
_memory_index_error = None


def _get_memory_index():
    """Get or create MemoryIndex singleton."""
    global _memory_index, _memory_index_error

    if _memory_index is not None:
        return _memory_index

    if _memory_index_error is not None:
        # Already failed, don't retry
        return None

    try:
        from helix_cdc.echo.tools.memory import MemoryIndex
        _memory_index = MemoryIndex()
        return _memory_index
    except Exception as e:
        _memory_index_error = str(e)
        return None


def _compute_ask_chain_hash(
    query_sha: str,
    mode: str,
    k: int,
    result_sha: str,
    evidence_sha: str,
    status: str,
) -> str:
    """Compute chain hash for /echo/ask receipt."""
    import hashlib
    data = f"{query_sha}|{mode}|{k}|{result_sha}|{evidence_sha}|{status}"
    return hashlib.sha256(data.encode()).hexdigest()


@router.post("/ask", response_model=AskResponse)
async def echo_ask(request: AskRequest):
    """
    Search memory index with evidence-grounded answers.

    WO-ECHO-ASK-02: Expose MemoryIndex over HTTP.

    Modes:
    - answer: Full evidence + synthesized answer + confidence
    - search: Just search results (chunks with sources)
    - receipt: Find specific receipt by ID/path

    Returns:
    - Evidence list with sources, snippets, and SHA256 hashes
    - Receipt path for verification
    - Chain hash for audit
    """
    import hashlib
    import json

    timestamp = datetime.now(timezone.utc)

    # Get memory index
    memory = _get_memory_index()

    if memory is None:
        # Index not available - return error response with receipt
        error_msg = _memory_index_error or "MemoryIndex not initialized"

        query_sha = hashlib.sha256(request.query.encode()).hexdigest()
        result_sha = hashlib.sha256(b"").hexdigest()
        evidence_sha = hashlib.sha256(b"[]").hexdigest()

        chain_hash = _compute_ask_chain_hash(
            query_sha, request.mode.value, request.k,
            result_sha, evidence_sha, "NO_INDEX"
        )

        receipt_id = generate_receipt_id()
        receipt = AskReceipt(
            timestamp_utc=timestamp.isoformat(),
            receipt_id=receipt_id,
            query=request.query[:500],
            query_sha256=query_sha,
            mode=request.mode.value,
            k=request.k,
            result_sha256=result_sha,
            evidence_sha256=evidence_sha,
            answer_preview=None,
            evidence_preview=[],
            status="NO_INDEX",
            error=error_msg,
            chain_hash=chain_hash,
        )

        receipt_path = _save_ask_receipt(receipt, timestamp)

        return AskResponse(
            status="NO_INDEX",
            mode=request.mode,
            query=request.query,
            answer=None,
            confidence=None,
            evidence=[],
            receipt_data=None,
            receipt_path=receipt_path,
            chain_hash=chain_hash,
            error=error_msg,
        )

    try:
        # Route based on mode
        if request.mode == AskMode.RECEIPT:
            return await _handle_ask_receipt_mode(request, memory, timestamp)
        elif request.mode == AskMode.SEARCH:
            return await _handle_ask_search_mode(request, memory, timestamp)
        else:  # ANSWER mode
            return await _handle_ask_answer_mode(request, memory, timestamp)

    except Exception as e:
        # Error - generate error receipt
        error_msg = str(e)
        query_sha = hashlib.sha256(request.query.encode()).hexdigest()
        result_sha = hashlib.sha256(b"").hexdigest()
        evidence_sha = hashlib.sha256(b"[]").hexdigest()

        chain_hash = _compute_ask_chain_hash(
            query_sha, request.mode.value, request.k,
            result_sha, evidence_sha, "ERROR"
        )

        receipt_id = generate_receipt_id()
        receipt = AskReceipt(
            timestamp_utc=timestamp.isoformat(),
            receipt_id=receipt_id,
            query=request.query[:500],
            query_sha256=query_sha,
            mode=request.mode.value,
            k=request.k,
            result_sha256=result_sha,
            evidence_sha256=evidence_sha,
            answer_preview=None,
            evidence_preview=[],
            status="ERROR",
            error=error_msg[:500],
            chain_hash=chain_hash,
        )

        receipt_path = _save_ask_receipt(receipt, timestamp)

        return AskResponse(
            status="ERROR",
            mode=request.mode,
            query=request.query,
            answer=None,
            confidence=None,
            evidence=[],
            receipt_data=None,
            receipt_path=receipt_path,
            chain_hash=chain_hash,
            error=error_msg,
        )


async def _handle_ask_answer_mode(
    request: AskRequest,
    memory,
    timestamp: datetime,
) -> AskResponse:
    """Handle mode=answer: Full evidence + synthesized answer."""
    import hashlib
    import json

    # Call memory.answer()
    result = memory.answer(request.query, k=request.k)

    # Build evidence list
    evidence_items = []
    for ev in result.evidence:
        snippet = ev.get("quote", "")[:300] if request.include_chunks else ev.get("quote", "")[:150]
        evidence_items.append(AskEvidence(
            source=ev.get("source", "unknown"),
            sha256=None,  # Could compute if needed
            snippet=snippet,
            source_type=ev.get("type"),
            score=ev.get("score"),
        ))

    # Compute hashes
    query_sha = hashlib.sha256(request.query.encode()).hexdigest()
    result_sha = hashlib.sha256(result.result.encode()).hexdigest()
    evidence_json = json.dumps([e.model_dump() for e in evidence_items], sort_keys=True)
    evidence_sha = hashlib.sha256(evidence_json.encode()).hexdigest()

    chain_hash = _compute_ask_chain_hash(
        query_sha, request.mode.value, request.k,
        result_sha, evidence_sha, "OK"
    )

    # Build receipt
    receipt_id = generate_receipt_id()
    receipt = AskReceipt(
        timestamp_utc=timestamp.isoformat(),
        receipt_id=receipt_id,
        query=request.query[:500],
        query_sha256=query_sha,
        mode=request.mode.value,
        k=request.k,
        result_sha256=result_sha,
        evidence_sha256=evidence_sha,
        answer_preview=result.result[:200] if result.result else None,
        evidence_preview=[e.source for e in evidence_items[:5]],
        status="OK",
        error=None,
        chain_hash=chain_hash,
    )

    receipt_path = _save_ask_receipt(receipt, timestamp)

    return AskResponse(
        status="OK",
        mode=request.mode,
        query=request.query,
        answer=result.result,
        confidence=result.confidence,
        evidence=evidence_items,
        receipt_data=None,
        receipt_path=receipt_path,
        chain_hash=chain_hash,
        error=None,
    )


async def _handle_ask_search_mode(
    request: AskRequest,
    memory,
    timestamp: datetime,
) -> AskResponse:
    """Handle mode=search: Just search results."""
    import hashlib
    import json

    # Call memory.search()
    chunks = memory.search(request.query, k=request.k)

    # Build evidence list
    evidence_items = []
    for chunk in chunks:
        snippet_len = 300 if request.include_chunks else 150
        snippet = chunk.text[:snippet_len] if chunk.text else ""
        evidence_items.append(AskEvidence(
            source=chunk.source_path,
            sha256=chunk.sha256 if chunk.sha256 else None,
            snippet=snippet,
            source_type=chunk.source_type,
            score=round(chunk.relevance_score, 3) if chunk.relevance_score else None,
        ))

    # Compute hashes
    query_sha = hashlib.sha256(request.query.encode()).hexdigest()
    result_sha = hashlib.sha256(f"search:{len(chunks)}".encode()).hexdigest()
    evidence_json = json.dumps([e.model_dump() for e in evidence_items], sort_keys=True)
    evidence_sha = hashlib.sha256(evidence_json.encode()).hexdigest()

    chain_hash = _compute_ask_chain_hash(
        query_sha, request.mode.value, request.k,
        result_sha, evidence_sha, "OK"
    )

    # Build receipt
    receipt_id = generate_receipt_id()
    receipt = AskReceipt(
        timestamp_utc=timestamp.isoformat(),
        receipt_id=receipt_id,
        query=request.query[:500],
        query_sha256=query_sha,
        mode=request.mode.value,
        k=request.k,
        result_sha256=result_sha,
        evidence_sha256=evidence_sha,
        answer_preview=f"Found {len(chunks)} chunks",
        evidence_preview=[e.source for e in evidence_items[:5]],
        status="OK",
        error=None,
        chain_hash=chain_hash,
    )

    receipt_path = _save_ask_receipt(receipt, timestamp)

    return AskResponse(
        status="OK",
        mode=request.mode,
        query=request.query,
        answer=f"Found {len(chunks)} relevant chunks.",
        confidence=None,
        evidence=evidence_items,
        receipt_data=None,
        receipt_path=receipt_path,
        chain_hash=chain_hash,
        error=None,
    )


async def _handle_ask_receipt_mode(
    request: AskRequest,
    memory,
    timestamp: datetime,
) -> AskResponse:
    """Handle mode=receipt: Find specific receipt."""
    import hashlib
    import json

    # Use receipt_id if provided, otherwise use query
    receipt_query = request.receipt_id or request.query

    # Call memory.get_receipt()
    receipt_data = memory.get_receipt(receipt_query)

    # Compute hashes
    query_sha = hashlib.sha256(request.query.encode()).hexdigest()

    if receipt_data:
        result_sha = hashlib.sha256(
            json.dumps(receipt_data, sort_keys=True, default=str).encode()
        ).hexdigest()
        evidence = [AskEvidence(
            source=receipt_data.get("_path", "unknown"),
            sha256=receipt_data.get("_sha256"),
            snippet=f"Receipt type: {receipt_data.get('receipt_type', 'unknown')}",
            source_type="receipt",
            score=1.0,
        )]
        status = "OK"
        answer = f"Found receipt: {receipt_data.get('_path', 'unknown')}"
    else:
        result_sha = hashlib.sha256(b"null").hexdigest()
        evidence = []
        status = "OK"  # Not an error, just no match
        answer = f"No receipt found matching: {receipt_query}"
        receipt_data = None

    evidence_json = json.dumps([e.model_dump() for e in evidence], sort_keys=True)
    evidence_sha = hashlib.sha256(evidence_json.encode()).hexdigest()

    chain_hash = _compute_ask_chain_hash(
        query_sha, request.mode.value, request.k,
        result_sha, evidence_sha, status
    )

    # Build receipt
    receipt_id = generate_receipt_id()
    receipt = AskReceipt(
        timestamp_utc=timestamp.isoformat(),
        receipt_id=receipt_id,
        query=request.query[:500],
        query_sha256=query_sha,
        mode=request.mode.value,
        k=request.k,
        result_sha256=result_sha,
        evidence_sha256=evidence_sha,
        answer_preview=answer[:200],
        evidence_preview=[e.source for e in evidence],
        status=status,
        error=None,
        chain_hash=chain_hash,
    )

    receipt_path = _save_ask_receipt(receipt, timestamp)

    return AskResponse(
        status=status,
        mode=request.mode,
        query=request.query,
        answer=answer,
        confidence=None,
        evidence=evidence,
        receipt_data=receipt_data,
        receipt_path=receipt_path,
        chain_hash=chain_hash,
        error=None,
    )


def _save_ask_receipt(receipt: AskReceipt, timestamp: datetime) -> str:
    """Save /echo/ask receipt to disk."""
    receipts_dir = PROJECT_ROOT / "receipts" / "echo_ask"
    receipts_dir.mkdir(parents=True, exist_ok=True)

    date_str = timestamp.strftime("%Y%m%d")
    filename = f"ask_{date_str}_{receipt.receipt_id[:8]}.json"
    receipt_path = receipts_dir / filename

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

    return str(receipt_path.relative_to(PROJECT_ROOT))
