#!/usr/bin/env python3
"""
ui_handler.py — Invariant Web UI request handler.

Principles (Bisection Law):
1. Every action provides ≥1 bit of information
2. Minimal clicks to result
3. Human words, not hashes
4. Clear loading states
"""

from __future__ import annotations

import html
import json
import os
import re
import subprocess
import urllib.parse
from http.server import BaseHTTPRequestHandler
from pathlib import Path
from typing import Dict, List, Optional

try:
    from .halo import hash8_hex
    from .overlay import OverlayGraph, find_overlays
    from .physics import HaloPhysics
    from .engine import OverlayIndex, locate_files, map_file
    from .ui_pages import render_main_page, render_graph3d_page
except ImportError:
    from invariant_sdk.halo import hash8_hex
    from invariant_sdk.overlay import OverlayGraph, find_overlays
    from invariant_sdk.physics import HaloPhysics
    from invariant_sdk.engine import OverlayIndex, locate_files, map_file
    from invariant_sdk.ui_pages import render_main_page, render_graph3d_page



class UIHandler(BaseHTTPRequestHandler):
    """HTTP handler for Invariant UI."""
    
    physics: Optional[HaloPhysics] = None
    overlay: Optional[OverlayGraph] = None
    overlay_path: Optional[Path] = None
    
    _graph_cache_key: Optional[tuple] = None
    _graph_cache_value: Optional[dict] = None
    
    _degree_total_cache: Dict[str, int] = {}
    _degree_total_crystal_id: Optional[str] = None
    _overlay_index: Optional[OverlayIndex] = None
    _overlay_index_key: Optional[tuple] = None
    _docs_cache: Optional[list[dict]] = None
    
    _ANCHOR_SCAN_RADIUS = 50
    
    def log_message(self, format, *args):
        pass  # Suppress logging
    
    def send_json(self, data: dict, status: int = 200):
        body = json.dumps(data).encode()
        self.send_response(status)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', len(body))
        self.end_headers()
        self.wfile.write(body)
    
    def send_html(self, content: str):
        body = content.encode()
        self.send_response(200)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        self.send_header('Content-Length', len(body))
        self.end_headers()
        self.wfile.write(body)
    
    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        
        if parsed.path in ('/', '/index.html'):
            self.serve_page()
        elif parsed.path == '/graph3d':
            self.serve_graph3d_page(parsed.query)
        elif parsed.path == '/doc':
            self.serve_doc_page(parsed.query)
        elif parsed.path == '/api/locate':
            self.api_locate(parsed.query)
        elif parsed.path == '/api/structure':
            self.api_structure(parsed.query)
        elif parsed.path == '/api/search':
            self.api_search(parsed.query)
        elif parsed.path == '/api/suggest':
            self.api_suggest(parsed.query)
        elif parsed.path == '/api/mentions':
            self.api_mentions(parsed.query)
        elif parsed.path == '/api/graph':
            self.api_graph(parsed.query)
        elif parsed.path == '/api/docs':
            self.api_docs()
        elif parsed.path == '/api/status':
            self.api_status()
        elif parsed.path == '/api/verify':
            self.api_verify(parsed.query)
        elif parsed.path == '/api/context':
            self.api_context(parsed.query)
        elif parsed.path == '/api/open':
            self.api_open(parsed.query)
        elif parsed.path == '/api/conflicts':
            self.api_conflicts()
        elif parsed.path == '/api/bicameral':
            self.api_bicameral(parsed.query)
        elif parsed.path == '/api/analyze':
            self.api_analyze(parsed.query)
        else:
            self.send_error(404)
    
    def do_POST(self):
        if self.path == '/api/ingest':
            self.api_ingest()
        elif self.path == '/api/reindex':
            self.api_reindex()
        elif self.path.startswith('/api/delete'):
            self.api_delete()
        else:
            self.send_error(404)
    
    def serve_page(self):
        physics = UIHandler.physics
        overlay = UIHandler.overlay
        
        crystal_id = physics.crystal_id if physics else "Not connected"
        overlay_status = f"{overlay.n_edges} local edges" if overlay else "No local documents"

        self.send_html(
            render_main_page(
                crystal_id=crystal_id,
                overlay_status=overlay_status,
            )
        )

    @classmethod
    def _invalidate_overlay_caches(cls) -> None:
        cls._graph_cache_key = None
        cls._graph_cache_value = None
        cls._overlay_index = None
        cls._overlay_index_key = None
        cls._docs_cache = None

    @classmethod
    def _get_overlay_index(cls) -> Optional[OverlayIndex]:
        overlay = cls.overlay
        if not overlay:
            return None
        key = (id(overlay), overlay.n_edges, len(overlay.labels or {}))
        if cls._overlay_index is None or cls._overlay_index_key != key:
            cls._overlay_index = OverlayIndex.build(overlay)
            cls._overlay_index_key = key
            cls._docs_cache = None
        return cls._overlay_index

    def api_locate(self, query_string: str):
        """
        File-centric search (SERP): issue text -> ranked files (+ previews).

        This is the human-facing default: locate answers "which file should I open?"
        """
        params = urllib.parse.parse_qs(query_string)
        q = (params.get('q', [''])[0] or '').strip()
        doc_filter = (params.get('doc', [''])[0] or '').strip()
        try:
            max_results = int(params.get('k', ['0'])[0] or 0)
        except Exception:
            max_results = 0

        if not q:
            self.send_json({'error': 'No query'}, 400)
            return

        overlay = UIHandler.overlay
        if not overlay:
            self.send_json({'query': q, 'doc': doc_filter or None, 'files_found': 0, 'results': []})
            return

        idx = UIHandler._get_overlay_index()

        def _resolve(doc: str) -> Optional[Path]:
            p, _cands = self._resolve_doc_path(doc)
            return p

        out = locate_files(
            q,
            overlay=overlay,
            index=idx,
            physics=UIHandler.physics,  # Enable Query Lensing!
            doc_filter=doc_filter,
            max_results=max_results,
            preview_files=10,
            preview_occurrences=8,
            resolve_doc_path=_resolve,
        )
        if out.get("error"):
            self.send_json(out, 400)
            return

        out["query"] = q
        out["doc"] = doc_filter or None
        self.send_json(out)

    def api_structure(self, query_string: str):
        """Return compact outline for a single file (inv_map for humans)."""
        params = urllib.parse.parse_qs(query_string or "")
        doc = (params.get('doc', [''])[0] or '').strip()
        if not doc:
            self.send_json({'error': 'Missing doc parameter'}, 400)
            return

        doc_path, candidates = self._resolve_doc_path(doc)
        if not doc_path:
            self.send_json({'error': f'Document not found: {doc}', 'searched': [str(c) for c in candidates]}, 404)
            return

        out = map_file(doc_path)
        out["doc"] = doc
        self.send_json(out)

    def serve_doc_page(self, query_string: str = ""):
        """File page (outline + matches) and file chooser."""
        overlay = UIHandler.overlay
        physics = UIHandler.physics
        params = urllib.parse.parse_qs(query_string or "")
        doc = (params.get('doc', [''])[0] or '').strip()
        q = (params.get('q', [''])[0] or '').strip()

        ui_font_links = (
            "<link rel='preconnect' href='https://fonts.googleapis.com'/>"
            "<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin/>"
            "<link href='https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap' rel='stylesheet'/>"
        )
        ui_css = (
            ":root{--bg:#0a0a0b;--surface:#111113;--surface2:#18181b;"
            "--border:rgba(255,255,255,0.08);--border2:rgba(255,255,255,0.12);"
            "--text:#fafafa;--text2:#a1a1aa;--text3:#71717a;"
            "--accent:#3b82f6;--accentDim:rgba(59,130,246,0.15);--success:#22c55e;}"
            "body{font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;"
            "background:radial-gradient(900px circle at 15% -10%, rgba(59,130,246,0.14), transparent 55%), var(--bg);"
            "color:var(--text);margin:0;padding:28px;-webkit-font-smoothing:antialiased;}"
            "h1{margin:0 0 10px;color:var(--text);font-size:22px;letter-spacing:-0.02em;}"
            ".sub{color:var(--text2);margin:0 0 18px;font-size:13px;font-family:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;}"
            "a{color:var(--accent);text-decoration:none;}"
            "a:hover{text-decoration:underline;}"
        )
        
        if not overlay:
            self.send_html(
                "<!doctype html><html><body style='font-family:-apple-system;padding:24px;'>"
                "<h1>Docs</h1><p>No local documents yet.</p><p><a href='/'>Back</a></p>"
                "</body></html>"
            )
            return

        idx = UIHandler._get_overlay_index()
        if UIHandler._docs_cache is None and idx:
            out: list[dict] = []
            for d in idx.doc_stats.values():
                out.append({'doc': d.doc, 'edges': int(d.edges), 'nodes': int(d.nodes)})
            out.sort(key=lambda x: (-int(x.get('edges') or 0), str(x.get('doc') or '').lower()))
            UIHandler._docs_cache = out
        docs_list: list[dict] = UIHandler._docs_cache or []
        
        if not doc:
            items = []
            for d in docs_list:
                name = str(d.get("doc") or "")
                if not name:
                    continue
                items.append(
                    f"<a class='item' href='/doc?doc={urllib.parse.quote(name)}'>"
                    f"<span class='name'>{html.escape(name)}</span>"
                    f"<span class='meta'>{int(d.get('edges') or 0)} edges • {int(d.get('nodes') or 0)} nodes</span>"
                    f"</a>"
                )
            crystal = html.escape(physics.crystal_id if physics else "unknown")
            page = (
                "<!doctype html><html><head><meta charset='utf-8'/>"
                "<meta name='viewport' content='width=device-width,initial-scale=1'/>"
                + ui_font_links +
                "<title>Docs — Invariant</title>"
                "<style>"
                + ui_css +
                ".list{display:flex;flex-direction:column;gap:10px;max-width:760px;}"
                ".item{display:flex;justify-content:space-between;gap:12px;align-items:center;"
                "padding:12px 14px;border:1px solid var(--border);border-radius:12px;background:rgba(17,17,19,0.85);"
                "text-decoration:none;color:var(--text);}"
                ".item:hover{border-color:rgba(59,130,246,0.65);background:var(--accentDim);}"
                ".name{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}"
                ".meta{color:var(--text3);font-size:12px;white-space:nowrap;"
                "font-family:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;}"
                "</style></head><body>"
                f"<h1>Docs</h1><p class='sub'>Crystal: <strong>{crystal}</strong> • choose a document overlay</p>"
                "<p><a href='/'>← Back to search</a></p>"
                "<div class='list'>"
                + "".join(items)
                + "</div></body></html>"
            )
            self.send_html(page)
            return

        # File page
        doc_path, searched = self._resolve_doc_path(doc)
        if not doc_path:
            searched_html = "".join(f"<li>{html.escape(str(p))}</li>" for p in searched[:10])
            self.send_html(
                "<!doctype html><html><head><meta charset='utf-8'/>"
                "<meta name='viewport' content='width=device-width,initial-scale=1'/>"
                + ui_font_links
                + "<title>File not found — Invariant</title>"
                "<style>"
                + ui_css
                + "</style></head><body>"
                f"<h1>File not found</h1><p class='sub'>{html.escape(doc)}</p>"
                "<p><a href='/'>← Back</a></p>"
                + (f"<h3 style='margin-top:18px;'>Searched</h3><ul>{searched_html}</ul>" if searched_html else "")
                + "</body></html>"
            )
            return

        outline = map_file(doc_path)

        # Optional matches (query -> this file only).
        matches_words: list[str] = []
        occurrences: list[dict] = []
        open_line = 1
        if q and idx:
            def _resolve(_doc: str) -> Optional[Path]:
                return doc_path

            hit = locate_files(
                q,
                overlay=overlay,
                index=idx,
                physics=UIHandler.physics,  # Enable Query Lensing!
                doc_filter=doc,
                max_results=1,
                preview_files=1,
                preview_occurrences=10,
                resolve_doc_path=_resolve,
            )
            res = (hit.get("results") or [])
            if res:
                r0 = res[0]
                signal_words = list(r0.get("signal_words") or [])
                matches_words = signal_words or list(r0.get("matching_words") or [])
                occurrences = list(r0.get("occurrences") or [])
                if occurrences:
                    try:
                        open_line = int(occurrences[0].get("line") or 1)
                    except Exception:
                        open_line = 1

        def _outline_html() -> str:
            if outline.get("error"):
                return f"<div style='color:#ef4444;font-size:13px;'>{html.escape(str(outline.get('error')))}</div>"
            items = outline.get("items") or []
            if not isinstance(items, list) or not items:
                return "<div style='color:var(--text3);font-size:13px;'>No outline available.</div>"
            rows = []
            for it in items[:160]:
                if not isinstance(it, dict):
                    continue
                line = int(it.get("line") or 0)
                end = int(it.get("end_line") or line)
                name = str(it.get("name") or "").strip()
                typ = str(it.get("type") or "").strip()
                if not name or not line:
                    continue
                label = f"{typ} {name}".strip()
                loc = f"{line}" + (f"-{end}" if end and end != line else "")
                rows.append(
                    f"<div class='rowItem' data-line='{line}' title='{html.escape(label)}'>"
                    f"<div class='rowName'>{html.escape(label)}</div>"
                    f"<div class='rowLoc'>{html.escape(loc)}</div>"
                    "</div>"
                )
            return "".join(rows)

        def _matches_html() -> str:
            if not q:
                return "<div style='color:var(--text3);font-size:13px;'>No query provided.</div>"
            if not occurrences:
                return "<div style='color:var(--text3);font-size:13px;'>No matches in this file for the current query.</div>"
            rows = []
            for o in occurrences[:20]:
                try:
                    line = int(o.get("line") or 0)
                except Exception:
                    line = 0
                content = str(o.get("content") or "").rstrip()
                if not line:
                    continue
                rows.append(
                    "<div class='occItem' data-line='{ln}'>"
                    "<div class='occLoc'>{ln}</div>"
                    "<div class='occText'>{txt}</div>"
                    "</div>".format(ln=line, txt=html.escape(content))
                )
            return "".join(rows)

        doc_q = urllib.parse.quote(doc)
        back_href = "/"
        if q:
            back_href = f"/?q={urllib.parse.quote(q)}"
        graph_href = f"/graph3d?doc={doc_q}&radius=2"
        crystal = html.escape(physics.crystal_id if physics else "offline")
        matches_badges = " ".join(f"<span class='pill'>{html.escape(w)}</span>" for w in matches_words[:16]) if matches_words else ""

        page = (
            "<!doctype html><html><head><meta charset='utf-8'/>"
            "<meta name='viewport' content='width=device-width,initial-scale=1'/>"
            + ui_font_links
            + "<title>File — Invariant</title>"
            "<style>"
            + ui_css
            + ".container{max-width:1100px;margin:0 auto;}"
            + ".top{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;margin-bottom:14px;}"
            + ".actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center;}"
            + ".btn{border:1px solid rgba(255,255,255,0.10);background:rgba(255,255,255,0.03);color:var(--text);"
              "padding:8px 12px;border-radius:12px;font-size:12px;cursor:pointer;font-family:'JetBrains Mono',ui-monospace,monospace;}"
            + ".btn:hover{border-color:rgba(59,130,246,0.35);background:rgba(59,130,246,0.06);}"
            + ".card{margin-top:14px;padding:14px 16px;border:1px solid var(--border);border-radius:14px;background:rgba(17,17,19,0.85);}"
            + ".h{font-size:13px;color:var(--text2);margin:0 0 10px;font-family:'JetBrains Mono',ui-monospace,monospace;}"
            + ".rowItem{display:flex;justify-content:space-between;gap:12px;align-items:baseline;padding:8px 10px;border-radius:12px;"
              "border:1px solid transparent;cursor:pointer;}"
            + ".rowItem:hover{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}"
            + ".rowName{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;}"
            + ".rowLoc{flex-shrink:0;color:var(--text3);font-size:11px;font-family:'JetBrains Mono',ui-monospace,monospace;}"
            + ".occItem{display:flex;gap:12px;align-items:baseline;padding:8px 10px;border-radius:12px;border:1px solid rgba(255,255,255,0.06);"
              "background:rgba(0,0,0,0.18);cursor:pointer;margin-bottom:8px;}"
            + ".occItem:hover{border-color:rgba(255,255,255,0.10);background:rgba(0,0,0,0.24);}"
            + ".occLoc{min-width:56px;text-align:right;color:var(--text3);font-family:'JetBrains Mono',ui-monospace,monospace;font-size:12px;}"
            + ".occText{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);"
              "font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;}"
            + ".pill{display:inline-flex;align-items:center;padding:3px 8px;border-radius:999px;border:1px solid rgba(255,255,255,0.10);"
              "background:rgba(17,17,19,0.65);color:var(--text);font-family:'JetBrains Mono',ui-monospace,monospace;font-size:11px;margin-right:6px;}"
            + "</style></head><body><div class='container'>"
            + "<div class='top'>"
            + f"<div style='min-width:0;'><h1 style='margin-bottom:6px;'>{html.escape(doc)}</h1>"
            + f"<p class='sub'>Crystal: <strong>{crystal}</strong> • {doc_path.as_posix()}</p></div>"
            + "<div class='actions'>"
            + f"<a class='btn' href='{back_href}'>← Back</a>"
            + f"<button class='btn' data-open='vscode'>VS Code</button>"
            + f"<button class='btn' data-open='open'>Open</button>"
            + f"<button class='btn' data-open='reveal'>Reveal</button>"
            + f"<a class='btn' href='{graph_href}' target='_blank'>Graph</a>"
            + "</div></div>"
            + "<div class='card'>"
            + "<div class='h'>Outline</div>"
            + _outline_html()
            + "</div>"
            + "<div class='card'>"
            + f"<div class='h'>Matches {('for “' + html.escape(q) + '”') if q else ''}</div>"
            + (f"<div style='margin-bottom:10px;'>{matches_badges}</div>" if matches_badges else "")
            + _matches_html()
            + "</div>"
            + f"<script>const DOC={json.dumps(doc)};const OPEN_LINE={int(open_line)};"
              "document.querySelectorAll('[data-open]').forEach(btn=>{btn.onclick=async(e)=>{e.preventDefault();"
              "const mode=btn.dataset.open;try{await fetch('/api/open?mode='+encodeURIComponent(mode)"
              "+'&doc='+encodeURIComponent(DOC)+'&line='+encodeURIComponent(String(OPEN_LINE)))}catch(e){}}});"
              "document.querySelectorAll('.rowItem').forEach(el=>{el.onclick=async(e)=>{e.preventDefault();"
              "const ln=el.dataset.line||'1';try{await fetch('/api/open?mode=vscode&doc='+encodeURIComponent(DOC)"
              "+'&line='+encodeURIComponent(ln))}catch(e){}}});"
              "document.querySelectorAll('.occItem').forEach(el=>{el.onclick=async(e)=>{e.preventDefault();"
              "const ln=el.dataset.line||'1';try{await fetch('/api/open?mode=vscode&doc='+encodeURIComponent(DOC)"
              "+'&line='+encodeURIComponent(ln))}catch(e){}}});"
              "</script>"
            + "</div></body></html>"
        )
        self.send_html(page)
    
    def api_graph(self, query_string: str = ""):
        """Return (optionally filtered) local overlay graph with physics fields."""
        import math
        
        overlay = UIHandler.overlay
        physics = UIHandler.physics
        mean_mass = physics.mean_mass if physics else 0.26
        
        if not overlay:
            self.send_json({'nodes': [], 'edges': [], 'mean_mass': mean_mass})
            return
        
        params = urllib.parse.parse_qs(query_string or "")
        doc_filter = (params.get('doc', [''])[0] or '').strip()
        focus = (params.get('focus', [''])[0] or '').strip()
        try:
            radius = int(params.get('radius', ['0'])[0])
        except Exception:
            radius = 0
        try:
            max_nodes = int(params.get('max_nodes', ['0'])[0])
        except Exception:
            max_nodes = 0
        
        # Resolve focus (hash8 or label) -> hash8
        focus_id: Optional[str] = None
        if focus:
            if re.fullmatch(r'[0-9a-fA-F]{16}', focus):
                focus_id = focus.lower()
            else:
                needle = focus.strip().lower()
                for h8, label in overlay.labels.items():
                    if label and label.strip().lower() == needle:
                        focus_id = h8
                        break
        
        # Build filtered edge list (doc filter applies only to local edges).
        edge_rows: list[tuple[str, str, float]] = []
        node_set: set[str] = set()
        for src, edge_list in overlay.edges.items():
            for edge in edge_list:
                if doc_filter and edge.doc != doc_filter:
                    continue
                node_set.add(src)
                node_set.add(edge.tgt)
                edge_rows.append((src, edge.tgt, abs(edge.weight)))
        
        if not node_set:
            self.send_json({'nodes': [], 'edges': [], 'mean_mass': mean_mass, 'doc': doc_filter or None})
            return
        
        # Focused subgraph (BFS radius) to keep embedded views small.
        if focus_id and radius > 0 and focus_id in node_set:
            adj: dict[str, set[str]] = {h8: set() for h8 in node_set}
            for a, b, _w in edge_rows:
                if a in adj:
                    adj[a].add(b)
                if b in adj:
                    adj[b].add(a)
            keep = {focus_id}
            frontier = {focus_id}
            for _ in range(radius):
                nxt = set()
                for u in frontier:
                    nxt.update(adj.get(u, ()))
                nxt -= keep
                keep |= nxt
                frontier = nxt
                if not frontier:
                    break
            node_set = keep
            edge_rows = [(a, b, w) for (a, b, w) in edge_rows if a in node_set and b in node_set]
        
        # Optional hard cap (only meaningful with focus).
        if max_nodes and focus_id and len(node_set) > max_nodes:
            # Keep closest nodes by BFS order (already in keep), just trim deterministically.
            trimmed = list(node_set)
            trimmed.sort()
            node_set = set(trimmed[:max_nodes])
            if focus_id not in node_set:
                node_set.add(focus_id)
            edge_rows = [(a, b, w) for (a, b, w) in edge_rows if a in node_set and b in node_set]
        
        degree_local: dict[str, int] = {h8: 0 for h8 in node_set}
        edges: list[dict] = []
        for a, b, w in edge_rows:
            edges.append({'source': a, 'target': b, 'weight': w})
            degree_local[a] = degree_local.get(a, 0) + 1
            degree_local[b] = degree_local.get(b, 0) + 1
        
        # Degree_total cache (HALO_SPEC): required for deterministic Mass.
        if physics:
            if UIHandler._degree_total_crystal_id != physics.crystal_id:
                UIHandler._degree_total_crystal_id = physics.crystal_id
                UIHandler._degree_total_cache = {}
        
        degree_total: dict[str, int] = {}
        if physics and node_set:
            missing = [h8 for h8 in node_set if h8 not in UIHandler._degree_total_cache]
            if missing:
                try:
                    results = physics._client.get_halo_pages(missing, limit=0)
                    for h8, result in results.items():
                        meta = result.get('meta') or {}
                        try:
                            UIHandler._degree_total_cache[h8] = int(meta.get('degree_total') or 0)
                        except Exception:
                            UIHandler._degree_total_cache[h8] = 0
                except Exception:
                    pass
            for h8 in node_set:
                if h8 in UIHandler._degree_total_cache:
                    degree_total[h8] = UIHandler._degree_total_cache[h8]
        
        nodes: list[dict] = []
        for h8 in sorted(node_set):
            label = overlay.labels.get(h8) or h8[:8]
            deg_total = degree_total.get(h8, degree_local.get(h8, 0))
            try:
                mass = 1.0 / math.log(2 + max(0, int(deg_total)))
            except Exception:
                mass = 0.0
            phase = 'solid' if mass > mean_mass else 'gas'
            nodes.append({
                'id': h8,
                'label': label,
                'mass': mass,
                'phase': phase,
                'degree': degree_local.get(h8, 0),
                'degree_total': int(deg_total),
            })
        
        self.send_json({
            'nodes': nodes,
            'edges': edges,
            'mean_mass': mean_mass,
            'doc': doc_filter or None,
            'focus': focus_id,
            'radius': radius,
        })

    def serve_graph3d_page(self, query_string: str = ""):
        """3D molecule view (WebGL) with doc/focus filtering."""
        self.send_html(render_graph3d_page())
    def api_search(self, query_string: str):
        params = urllib.parse.parse_qs(query_string)
        q = params.get('q', [''])[0].strip()
        doc_filter = (params.get('doc', [''])[0] or '').strip()
        
        if not q:
            self.send_json({'error': 'No query'}, 400)
            return
        
        physics = UIHandler.physics
        overlay = UIHandler.overlay
        
        if not physics:
            self.send_json({'error': 'Not connected'}, 500)
            return
        
        try:
            # Multi-word query: try interference first (Bisection Law)
            # If interference is empty (no shared neighborhood), fallback to blend
            words = q.split()
            search_mode = 'single' if len(words) == 1 else 'interference'
            
            concept = physics.resolve(q, mode='interference')
            
            # Physics fallback: if interference empty and multi-word, try blend
            if len(words) > 1 and not concept.halo:
                concept = physics.resolve(q, mode='blend')
                search_mode = 'blend'  # Honestly report we used blend
            
            # Collect all neighbor hashes
            raw_neighbors = concept.halo[:50]
            all_hashes = [n.get('hash8', '') for n in raw_neighbors if n.get('hash8')]
            
            # Batch lookup labels from server (for global words)
            global_labels = {}
            if all_hashes:
                try:
                    global_labels = physics._client.get_labels_batch(all_hashes)
                except Exception:
                    pass
            
            neighbors = []
            for n in raw_neighbors:
                h8 = n.get('hash8', '')
                weight = n.get('weight', 0)
                doc = n.get('doc')
                ring = n.get('ring')
                is_local = (ring is not None) or (n.get('source') == 'local') or (doc is not None)
                line = n.get('line') if is_local else None
                ctx_hash = n.get('ctx_hash') if is_local else None
                
                if is_local and doc_filter and doc != doc_filter:
                    continue
                
                # Get human-readable label: LOCAL overlay first, then global server
                source = 'local' if is_local else 'global'
                ring_val = (ring or 'sigma') if is_local else 'alpha'
                phase_val = n.get('phase') if is_local else None
                label = overlay.get_label(h8) if (is_local and overlay) else None
                
                # Fallback: use global server label
                if not label:
                    label = global_labels.get(h8)
                
                # Final fallback: hash prefix
                if not label:
                    label = h8[:12] + '...'
                
                neighbor_data = {
                    'hash8': h8,
                    'label': label,
                    'weight': weight,
                    'source': source,
                    'doc': doc,
                    'ring': ring_val,
                }
                if phase_val:
                    neighbor_data['phase'] = phase_val
                # Include provenance if available
                if line is not None:
                    neighbor_data['line'] = line
                if ctx_hash:
                    neighbor_data['ctx_hash'] = ctx_hash
                
                neighbors.append(neighbor_data)
            
            # Sort: local first, then by weight
            neighbors.sort(key=lambda x: (0 if x['source'] == 'local' else 1, -abs(x['weight'])))
            
            # Response includes physics properties
            self.send_json({
                'query': q,
                'doc': doc_filter or None,
                'mode': search_mode,  # Honest: tells user what mode was used
                'phase': concept.phase,  # solid/gas
                'mass': concept.mass,  # information content
                'mean_mass': physics.mean_mass,  # phase boundary
                'atoms': concept.atoms,  # resolved hash8 atoms
                'neighbors': neighbors
            })
            
        except Exception as e:
            self.send_json({'error': str(e)}, 500)
    
    def api_suggest(self, query_string: str):
        """Autocomplete suggestions from local + global."""
        params = urllib.parse.parse_qs(query_string)
        q = params.get('q', [''])[0].strip().lower()
        
        if len(q) < 2:
            self.send_json({'suggestions': []})
            return
        
        physics = UIHandler.physics
        overlay = UIHandler.overlay
        suggestions = []
        
        # 1. Local words (from overlay labels) — highest priority
        if overlay:
            for h8, label in overlay.labels.items():
                if label and label.lower().startswith(q):
                    suggestions.append({
                        'word': label,
                        'source': 'local',
                        'hash8': h8
                    })
        
        # 2. Global suggestions via halo lookup
        if physics and len(suggestions) < 10:
            try:
                # Try to resolve the prefix and get neighbors
                h8 = hash8_hex(f"Ġ{q}")
                result = physics._client.get_halo_page(h8, limit=20)
                if result.get('exists') or result.get('neighbors'):
                    # Add the word itself
                    if not any(s['word'].lower() == q for s in suggestions):
                        suggestions.append({
                            'word': q,
                            'source': 'global',
                            'hash8': h8
                        })
                    # Add top neighbors as suggestions
                    neighbor_hashes = [n['hash8'] for n in result.get('neighbors', [])[:10]]
                    if neighbor_hashes:
                        labels = physics._client.get_labels_batch(neighbor_hashes)
                        for h8, label in labels.items():
                            if label and label.lower().startswith(q[:2]):
                                suggestions.append({
                                    'word': label,
                                    'source': 'global',
                                    'hash8': h8
                                })
            except Exception:
                pass
        
        # Dedupe and limit
        seen = set()
        unique = []
        for s in suggestions:
            key = s['word'].lower()
            if key not in seen:
                seen.add(key)
                unique.append(s)
        
        # Sort: local first, then alphabetically
        unique.sort(key=lambda x: (0 if x['source'] == 'local' else 1, x['word'].lower()))
        
        self.send_json({'suggestions': unique[:10]})

    def api_mentions(self, query_string: str):
        """
        List local document mentions (doc:line) for a single-word query.

        Theory (Invariant III / MDL):
        - Overlay already stores coordinates (doc:line) with provenance
        - No need to re-read files — lookup σ-edges by word hash
        - Returns coordinates (+ ctx_hash) so UI can fetch /api/context lazily
        """
        overlay = UIHandler.overlay
        idx = UIHandler._get_overlay_index()
        params = urllib.parse.parse_qs(query_string or "")
        q_raw = (params.get('q', [''])[0] or '').strip()
        doc_filter = (params.get('doc', [''])[0] or '').strip()

        if not q_raw:
            self.send_json({'error': 'No query'}, 400)
            return

        if not overlay or not idx:
            self.send_json({'query': q_raw, 'doc': doc_filter or None, 'total': 0, 'mentions': []})
            return

        # Hash the query word (same as ingest does)
        from invariant_sdk.halo import hash8_hex
        needle = q_raw.strip().lower()
        h8 = hash8_hex(f"Ġ{needle}")
        
        # Find all σ-edges where this word is source OR target (index-backed, O(deg)).
        mentions = []
        
        # Check edges where word is SOURCE
        for edge in overlay.edges.get(h8, []):
            if edge.ring != "sigma":
                continue
            if doc_filter and edge.doc != doc_filter:
                continue
            if edge.doc and edge.line:
                mentions.append({
                    'doc': edge.doc,
                    'line': edge.line,
                    'ctx_hash': edge.ctx_hash,
                    'related': overlay.get_label(edge.tgt) or edge.tgt[:8],
                })
        
        # Check edges where word is TARGET
        for src_hash, edge in idx.incoming.get(h8, []):
            if edge.ring != "sigma":
                continue
            if doc_filter and edge.doc != doc_filter:
                continue
            if edge.doc and edge.line:
                mentions.append(
                    {
                        'doc': edge.doc,
                        'line': edge.line,
                        'ctx_hash': edge.ctx_hash,
                        'related': overlay.get_label(src_hash) or src_hash[:8],
                    }
                )
        
        # Deduplicate by doc:line and sort
        seen = set()
        unique = []
        for m in mentions:
            key = (m['doc'], m['line'])
            if key not in seen:
                seen.add(key)
                unique.append(m)
        
        unique.sort(key=lambda x: (x['doc'], x['line']))
        
        self.send_json({
            'query': q_raw, 
            'doc': doc_filter or None, 
            'total': len(unique), 
            'mentions': unique
        })
    
    def api_ingest(self):
        """Ingest document via POST."""
        content_length = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(content_length)
        
        try:
            import math
            data = json.loads(body)
            filename = data.get('filename', 'document.txt')
            text = data.get('text', '')
            
            if not text:
                self.send_json({'error': 'No text provided'}, 400)
                return
            
            # Import uploaded doc into local storage (so it can be opened / used for context later).
            # This does not duplicate the overlay — it creates the σ-source file itself.
            safe_name = Path(str(filename)).name or "document.txt"
            stored_doc = safe_name
            try:
                uploads_dir = Path('./.invariant/uploads')
                uploads_dir.mkdir(parents=True, exist_ok=True)
                save_path = uploads_dir / safe_name
                save_path.write_text(text, encoding='utf-8')
                stored_doc = str(Path('uploads') / safe_name)
            except Exception:
                stored_doc = safe_name
            
            physics = UIHandler.physics
            overlay = UIHandler.overlay
            overlay_path = UIHandler.overlay_path
            
            if not physics:
                self.send_json({'error': 'Not connected'}, 500)
                return
            
            # Tokenize with line positions (Anchor Integrity Protocol needs stable coordinates).
            from invariant_sdk.tokenize import tokenize_with_lines
            tokens = tokenize_with_lines(text)
            
            words = [w for (w, _ln) in tokens]
            unique_words = list(dict.fromkeys(words))  # L0: Full observation, Crystal decides Solid vs Gas
            
            # Find anchors using single BATCH API call (O(1) network round-trip)
            # Server now properly supports limit=0 for meta-only checks
            word_to_hash = {w: hash8_hex(f"Ġ{w}") for w in unique_words}
            
            # Single HTTP call - meta only (no neighbors, just existence check)
            try:
                batch_results = physics._client.get_halo_pages(word_to_hash.values(), limit=0)
            except Exception as e:
                self.send_json({'error': f'Server error: {e}'}, 500)
                return
            
            # Mass filter (INVARIANTS.md): keep Solid anchors (mass > mean_mass)
            mean_mass = physics.mean_mass
            candidates: list[tuple[str, str, float, int, bool]] = []
            for word in unique_words:
                h8 = word_to_hash.get(word)
                if not h8:
                    continue
                result = batch_results.get(h8) or {}
                if not result.get('exists'):
                    # Unknown words are local anchors (σ) by definition.
                    # Treat as high-mass so they survive the phase boundary.
                    candidates.append((word, h8, 1.0, 0, False))
                    continue
                meta = result.get('meta') or {}
                try:
                    degree_total = int(meta.get('degree_total') or 0)
                except Exception:
                    degree_total = 0
                try:
                    mass = 1.0 / math.log(2 + max(0, degree_total)) if degree_total > 0 else 0.0
                except Exception:
                    mass = 0.0
                candidates.append((word, h8, mass, degree_total, True))
            
            # === LAW OF CONDENSATION (INVARIANTS V.1) ===
            # Phase = Solid iff Mass_α > μ_mass OR TF_local > TF_crit
            from collections import Counter
            
            # 1. Global Mass criterion (α-classification)
            solid_by_mass = {(w, h8) for (w, h8, m, _deg, _exists) in candidates if m > mean_mass}

            # 2. Local TF criterion (σ-observation / Condensation)
            # Define critical pressure as the mean per-type frequency in this document.
            word_counts = Counter(w for w, _ in tokens)
            tf_mean = (sum(word_counts.values()) / len(word_counts)) if word_counts else 0.0

            # Exclude LINK/hub words from condensing (Invariant: LINK if degree > √N).
            n_labels = int((physics.meta or {}).get("n_labels") or 1)
            link_degree = math.sqrt(max(1, n_labels))

            cand_by_word = {w: (h8, deg, exists) for (w, h8, _m, deg, exists) in candidates}
            solid_by_tf = {
                (w, cand_by_word[w][0])
                for w, count in word_counts.items()
                if count > tf_mean
                and w in cand_by_word
                and (
                    not cand_by_word[w][2]  # unknown words can condense
                    or float(cand_by_word[w][1]) <= link_degree
                )
            }
            
            # 3. Combined: anchor if EITHER criterion is met
            solid = solid_by_mass | solid_by_tf
            
            if len(solid) >= 2:
                anchors = list(solid)
            else:
                top = sorted(candidates, key=lambda x: x[2], reverse=True)[:64]
                top_set = {h8 for (_w, h8, _m, _deg, _exists) in top}
                anchors = [(w, h8) for (w, h8, _m, _deg, _exists) in candidates if h8 in top_set]
            
            if len(anchors) < 2:
                self.send_json({'error': 'Too few concepts found in document'}, 400)
                return
            
            anchor_words = {w for (w, _h8) in anchors}
            
            # Anchor Integrity Protocol: ctx_hash for anchor window (±2 words)
            import hashlib
            def compute_ctx_hash_at(idx: int, k: int = 2) -> str:
                start = max(0, idx - k)
                end = min(len(tokens), idx + k + 1)
                window_words = [tokens[i][0] for i in range(start, end)]
                normalized = ' '.join(w.lower() for w in window_words)
                return hashlib.sha256(normalized.encode('utf-8')).hexdigest()[:8]
            
            # Collect anchor occurrences in original order for edge construction.
            occurrences: list[tuple[str, str, int, str]] = []
            for idx, (word, line_no) in enumerate(tokens):
                if word not in anchor_words:
                    continue
                h8 = word_to_hash.get(word) or hash8_hex(f"Ġ{word}")
                occurrences.append((word, h8, line_no, compute_ctx_hash_at(idx)))
            
            if len(occurrences) < 2:
                self.send_json({'error': 'Too few anchor occurrences found'}, 400)
                return
            
            # Create edges
            if overlay is None:
                overlay = OverlayGraph()
                UIHandler.overlay = overlay
            
            edges_added = 0
            for i in range(len(occurrences) - 1):
                src_word, src_h8, _src_line, _src_ctx = occurrences[i]
                tgt_word, tgt_h8, tgt_line, tgt_ctx = occurrences[i + 1]
                
                overlay.add_edge(
                    src_h8,
                    tgt_h8,
                    weight=1.0,
                    doc=stored_doc,
                    line=tgt_line,
                    ctx_hash=tgt_ctx,
                )
                overlay.define_label(src_h8, src_word)
                overlay.define_label(tgt_h8, tgt_word)
                edges_added += 1
            
            # Save
            if overlay_path:
                overlay.save(overlay_path)
            else:
                default_path = Path('./.invariant/overlay.jsonl')
                default_path.parent.mkdir(parents=True, exist_ok=True)
                overlay.save(default_path)
                UIHandler.overlay_path = default_path
            
            # Clear caches (graph depends on overlay contents).
            UIHandler._invalidate_overlay_caches()
            
            self.send_json({
                'success': True,
                'filename': stored_doc,
                'scanned_words': len(unique_words),
                'candidates': len(candidates),
                'anchors': len(anchor_words),
                'edges': edges_added
            })
            
        except json.JSONDecodeError:
            self.send_json({'error': 'Invalid JSON'}, 400)
        except Exception as e:
            self.send_json({'error': str(e)}, 500)

    def api_reindex(self):
        """
        Re-index an existing local document (replace σ-edges for that doc).
        
        Motivation:
        - Older overlays may lack line/ctx_hash provenance.
        - Reindex rebuilds σ-edges with Anchor Integrity Protocol fields so UI can show context + open file.
        """
        content_length = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(content_length)
        
        try:
            import math
            data = json.loads(body)
            doc = (data.get('doc') or '').strip()
            if not doc:
                self.send_json({'error': 'Missing doc'}, 400)
                return
            
            physics = UIHandler.physics
            overlay = UIHandler.overlay
            overlay_path = UIHandler.overlay_path
            
            if not physics or not overlay:
                self.send_json({'error': 'No overlay loaded. Ingest documents first.'}, 400)
                return
            
            doc_path, searched = self._resolve_doc_path(doc)
            if not doc_path:
                self.send_json({'error': f'Document not found: {doc}', 'searched': [str(c) for c in searched]}, 404)
                return
            
            text = doc_path.read_text(encoding='utf-8')
            if not text.strip():
                self.send_json({'error': 'Document is empty'}, 400)
                return
            
            # Remove existing edges for this doc (replace)
            removed = 0
            for src in list(overlay.edges.keys()):
                before = len(overlay.edges[src])
                overlay.edges[src] = [e for e in overlay.edges[src] if e.doc != doc]
                removed += before - len(overlay.edges[src])
                if not overlay.edges[src]:
                    del overlay.edges[src]
            
            # Tokenize with line positions
            from invariant_sdk.tokenize import tokenize_with_lines
            tokens = tokenize_with_lines(text)
            
            words = [w for (w, _ln) in tokens]
            unique_words = list(dict.fromkeys(words))  # L0: Crystal decides via Phase Separation
            word_to_hash = {w: hash8_hex(f"Ġ{w}") for w in unique_words}
            
            try:
                batch_results = physics._client.get_halo_pages(word_to_hash.values(), limit=0)
            except Exception as e:
                self.send_json({'error': f'Server error: {e}'}, 500)
                return
            
            mean_mass = physics.mean_mass
            candidates: list[tuple[str, str, float, int, bool]] = []
            for word in unique_words:
                h8 = word_to_hash.get(word)
                if not h8:
                    continue
                result = batch_results.get(h8) or {}
                if not result.get('exists'):
                    candidates.append((word, h8, 1.0, 0, False))
                    continue
                meta = result.get('meta') or {}
                try:
                    degree_total = int(meta.get('degree_total') or 0)
                except Exception:
                    degree_total = 0
                try:
                    mass = 1.0 / math.log(2 + max(0, degree_total)) if degree_total > 0 else 0.0
                except Exception:
                    mass = 0.0
                candidates.append((word, h8, mass, degree_total, True))
            
            # === LAW OF CONDENSATION (INVARIANTS V.1) ===
            from collections import Counter
            
            solid_by_mass = {(w, h8) for (w, h8, m, _deg, _exists) in candidates if m > mean_mass}
            word_counts = Counter(w for w, _ in tokens)
            tf_mean = (sum(word_counts.values()) / len(word_counts)) if word_counts else 0.0
            
            n_labels = int((physics.meta or {}).get("n_labels") or 1)
            link_degree = math.sqrt(max(1, n_labels))
            cand_by_word = {w: (h8, deg, exists) for (w, h8, _m, deg, exists) in candidates}
            solid_by_tf = {
                (w, cand_by_word[w][0])
                for w, count in word_counts.items()
                if count > tf_mean
                and w in cand_by_word
                and (
                    not cand_by_word[w][2]
                    or float(cand_by_word[w][1]) <= link_degree
                )
            }
            solid = solid_by_mass | solid_by_tf
            
            if len(solid) >= 2:
                anchors = list(solid)
            else:
                top = sorted(candidates, key=lambda x: x[2], reverse=True)[:64]
                top_set = {h8 for (_w, h8, _m, _deg, _exists) in top}
                anchors = [(w, h8) for (w, h8, _m, _deg, _exists) in candidates if h8 in top_set]
            
            if len(anchors) < 2:
                self.send_json({'error': 'Too few concepts found in document'}, 400)
                return
            
            anchor_words = {w for (w, _h8) in anchors}
            
            import hashlib
            def compute_ctx_hash_at(idx: int, k: int = 2) -> str:
                start = max(0, idx - k)
                end = min(len(tokens), idx + k + 1)
                window_words = [tokens[i][0] for i in range(start, end)]
                normalized = ' '.join(w.lower() for w in window_words)
                return hashlib.sha256(normalized.encode('utf-8')).hexdigest()[:8]
            
            occurrences: list[tuple[str, str, int, str]] = []
            for idx, (word, line_no) in enumerate(tokens):
                if word not in anchor_words:
                    continue
                h8 = word_to_hash.get(word) or hash8_hex(f"Ġ{word}")
                occurrences.append((word, h8, line_no, compute_ctx_hash_at(idx)))
            
            if len(occurrences) < 2:
                self.send_json({'error': 'Too few anchor occurrences found'}, 400)
                return
            
            edges_added = 0
            for i in range(len(occurrences) - 1):
                src_word, src_h8, _src_line, _src_ctx = occurrences[i]
                tgt_word, tgt_h8, tgt_line, tgt_ctx = occurrences[i + 1]
                overlay.add_edge(
                    src_h8,
                    tgt_h8,
                    weight=1.0,
                    doc=doc,
                    line=tgt_line,
                    ctx_hash=tgt_ctx,
                )
                overlay.define_label(src_h8, src_word)
                overlay.define_label(tgt_h8, tgt_word)
                edges_added += 1
            
            if overlay_path:
                overlay.save(overlay_path)
            else:
                default_path = Path('./.invariant/overlay.jsonl')
                default_path.parent.mkdir(parents=True, exist_ok=True)
                overlay.save(default_path)
                UIHandler.overlay_path = default_path
            
            UIHandler._invalidate_overlay_caches()
            
            self.send_json({
                'success': True,
                'doc': doc,
                'removed_edges': removed,
                'edges': edges_added,
                'anchors': len(anchor_words),
            })
        except json.JSONDecodeError:
            self.send_json({'error': 'Invalid JSON'}, 400)
        except Exception as e:
            self.send_json({'error': str(e)}, 500)
    
    def api_delete(self):
        """Delete a document from overlay (remove all its edges)."""
        content_length = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(content_length)
        
        try:
            data = json.loads(body)
            doc = (data.get('doc') or '').strip()
            if not doc:
                self.send_json({'error': 'Missing doc'}, 400)
                return
            
            overlay = UIHandler.overlay
            overlay_path = UIHandler.overlay_path
            
            if not overlay:
                self.send_json({'error': 'No overlay loaded'}, 400)
                return
            
            # Delete all edges for this doc
            deleted = overlay.delete_doc(doc)
            
            if deleted == 0:
                self.send_json({'error': f'No edges found for doc: {doc}'}, 404)
                return
            
            # Save overlay
            if overlay_path:
                overlay.save(overlay_path)
            
            UIHandler._invalidate_overlay_caches()
            
            self.send_json({
                'success': True,
                'doc': doc,
                'deleted_edges': deleted,
                'remaining_edges': overlay.n_edges
            })
        except json.JSONDecodeError:
            self.send_json({'error': 'Invalid JSON'}, 400)
        except Exception as e:
            self.send_json({'error': str(e)}, 500)
    
    def api_status(self):
        physics = UIHandler.physics
        overlay = UIHandler.overlay
        overlay_path = UIHandler.overlay_path
        
        # Get overlay file mtime for freshness indicator
        overlay_mtime = None
        if overlay_path:
            try:
                p = Path(overlay_path)
                if p.exists():
                    overlay_mtime = p.stat().st_mtime
            except Exception:
                pass
        
        self.send_json({
            'crystal': physics.crystal_id if physics else None,
            'edges': overlay.n_edges if overlay else 0,
            'labels': len(overlay.labels) if overlay else 0,
            'overlay_mtime': overlay_mtime
        })

    def api_docs(self):
        """List ingested documents with simple stats (local overlay only)."""
        overlay = UIHandler.overlay
        idx = UIHandler._get_overlay_index()
        if not overlay or not idx:
            self.send_json({'docs': []})
            return

        if UIHandler._docs_cache is None:
            out: list[dict] = []
            for doc, st in idx.doc_stats.items():
                out.append({'doc': doc, 'edges': int(st.edges), 'nodes': int(st.nodes)})
            out.sort(key=lambda x: (-int(x.get('edges') or 0), str(x.get('doc') or '').lower()))
            UIHandler._docs_cache = out

        self.send_json({'docs': UIHandler._docs_cache})
    
    def api_conflicts(self):
        """Return detected conflicts from overlay."""
        overlay = UIHandler.overlay
        if not overlay:
            self.send_json({'conflicts': [], 'total': 0})
            return
        
        conflicts = []
        for old_edge, new_edge in overlay.conflicts:
            conflicts.append({
                'target': overlay.get_label(old_edge.tgt) or old_edge.tgt[:8],
                'old': {
                    'doc': old_edge.doc,
                    'line': old_edge.line,
                    'weight': old_edge.weight,
                },
                'new': {
                    'doc': new_edge.doc,
                    'line': new_edge.line,
                    'weight': new_edge.weight,
                },
            })
        
        self.send_json({'conflicts': conflicts, 'total': len(conflicts)})
    
    def api_verify(self, query_string: str):
        """Verify if an assertion has σ-proof (documentary evidence)."""
        physics = UIHandler.physics
        overlay = UIHandler.overlay
        
        params = urllib.parse.parse_qs(query_string or "")
        subject = (params.get('subject', [''])[0] or '').strip()
        obj = (params.get('object', [''])[0] or '').strip()
        
        if not subject or not obj:
            self.send_json({'error': 'Missing subject or object parameter'}, 400)
            return
        
        if not overlay:
            self.send_json({
                'proven': False,
                'message': 'No overlay loaded. Ingest documents first.',
                'subject': subject,
                'object': obj,
                'path': [],
                'sources': [],
                'conflicts': []
            })
            return
        
        if not physics:
            self.send_json({'error': 'Physics engine not initialized'}, 500)
            return
        
        try:
            result = physics.verify(subject, obj)

            # Enrich: resolve labels for the whole path (human UI, no hashes)
            path_targets: list[str] = []
            for edge in result.path or []:
                h8 = edge.get("hash8") if isinstance(edge, dict) else None
                if h8:
                    path_targets.append(str(h8))

            hashes: list[str] = []
            if result.subject_hash:
                hashes.append(result.subject_hash)
            hashes.extend(path_targets)
            if result.object_hash:
                hashes.append(result.object_hash)

            # de-dupe (stable)
            seen = set()
            hashes = [h for h in hashes if h and not (h in seen or seen.add(h))]

            labels: dict[str, str] = {}
            if hashes:
                try:
                    labels.update(physics._client.get_labels_batch(hashes))
                except Exception:
                    pass
            if overlay and hashes:
                for h in hashes:
                    lbl = overlay.get_label(h)
                    if lbl:
                        labels[h] = lbl

            def _lbl(h: str) -> str:
                if not h:
                    return ""
                return labels.get(h) or (h[:8] + "…")

            steps: list[dict] = []
            cur = result.subject_hash
            for edge in result.path or []:
                if not isinstance(edge, dict):
                    continue
                tgt = str(edge.get("hash8") or "")
                if not tgt:
                    continue
                steps.append({
                    "src": cur,
                    "src_label": _lbl(cur),
                    "tgt": tgt,
                    "tgt_label": _lbl(tgt),
                    "weight": edge.get("weight"),
                    "ring": edge.get("ring"),
                    "phase": edge.get("phase"),
                    "doc": edge.get("doc"),
                    "line": edge.get("line"),
                    "ctx_hash": edge.get("ctx_hash"),
                })
                cur = tgt

            self.send_json({
                'proven': result.proven,
                'message': result.message,
                'subject': subject,
                'object': obj,
                'subject_hash': result.subject_hash,
                'object_hash': result.object_hash,
                'subject_label': _lbl(result.subject_hash),
                'object_label': _lbl(result.object_hash),
                'path': result.path,
                'steps': steps,
                'labels': labels,
                'sources': result.sources,
                'conflicts': result.conflicts
            })
        except Exception as e:
            self.send_json({'error': str(e)}, 500)
    
    def api_context(self, query_string: str):
        """
        Lazy load context from source file with integrity verification.
        
        Anchor Integrity Protocol (see INVARIANTS.md):
        - Reads file on demand (MDL-compliant)
        - Verifies ctx_hash if provided (drift detection)
        - Attempts self-healing if hash not at expected line
        
        Query params:
            doc: document filename
            line: line number (1-indexed)
            ctx_hash: optional semantic checksum for verification
        
        Returns:
            status: 'fresh' | 'relocated' | 'broken' | 'unchecked'
        """
        params = urllib.parse.parse_qs(query_string or "")
        doc = (params.get('doc', [''])[0] or '').strip()
        line_str = (params.get('line', [''])[0] or '').strip()
        ctx_hash = (params.get('ctx_hash', [''])[0] or '').strip()
        max_lines_raw = (params.get('max_lines', [''])[0] or '').strip()
        
        if not doc or not line_str:
            self.send_json({'error': 'Missing doc or line parameter'}, 400)
            return
        
        try:
            target_line = int(line_str)
        except ValueError:
            self.send_json({'error': 'Invalid line number'}, 400)
            return

        # Optional: request more context for UI previews (bounded, deterministic).
        # Default stays small; UI can ask for more via `max_lines`.
        max_lines = 10
        if max_lines_raw:
            try:
                max_lines = int(max_lines_raw)
            except Exception:
                max_lines = 10
        if max_lines < 6:
            max_lines = 6
        if max_lines > 60:
            max_lines = 60
        
        doc_path, candidates = self._resolve_doc_path(doc)
        
        if not doc_path:
            self.send_json({
                'error': f'Document not found: {doc}',
                'searched': [str(c) for c in candidates],
                'status': 'broken'
            }, 404)
            return
        
        try:
            text = doc_path.read_text(encoding='utf-8')
            lines = text.split('\n')
            
            if target_line < 1 or target_line > len(lines):
                self.send_json({
                    'error': f'Line {target_line} out of range (1-{len(lines)})',
                    'status': 'broken'
                }, 400)
                return
            
            # Tokenize entire file for hash computation
            tokens = self._tokenize_file(text)
            
            status, actual_line, anchor_word = self._resolve_anchor_coordinate(
                lines=lines,
                tokens=tokens,
                requested_line=target_line,
                ctx_hash=ctx_hash or None,
            )
            
            # Extract semantic block from actual_line
            block_start, block_end, block_lines = self._extract_semantic_block(lines, actual_line, max_lines=max_lines)
            
            self.send_json({
                'doc': doc,
                'doc_path': str(doc_path.absolute()),
                'requested_line': target_line,
                'actual_line': actual_line,
                'status': status,
                'anchor_word': anchor_word,
                'block_start': block_start,
                'block_end': block_end,
                'content': '\n'.join(block_lines),
                'lines': block_lines,
                'total_lines': len(lines)
            })
            
        except Exception as e:
            self.send_json({'error': str(e), 'status': 'broken'}, 500)

    def _resolve_anchor_coordinate(
        self,
        *,
        lines: list[str],
        tokens: list[tuple[str, int]],
        requested_line: int,
        ctx_hash: Optional[str],
    ) -> tuple[str, int, Optional[str]]:
        """
        Anchor Integrity Protocol (docs/INVARIANTS.md):
        - fresh: ctx_hash matches at requested line
        - relocated: ctx_hash found within scan radius
        - broken: ctx_hash not found nearby
        - unchecked: no ctx_hash provided
        """
        status = 'unchecked'
        actual_line = requested_line
        anchor_word: Optional[str] = None

        if not ctx_hash:
            return status, actual_line, anchor_word

        for h, w in self._compute_hashes_at_line(tokens, requested_line):
            if h == ctx_hash:
                return 'fresh', requested_line, w

        scan_radius = UIHandler._ANCHOR_SCAN_RADIUS
        for offset in range(1, scan_radius + 1):
            up = requested_line - offset
            if up >= 1:
                for h, w in self._compute_hashes_at_line(tokens, up):
                    if h == ctx_hash:
                        return 'relocated', up, w

            down = requested_line + offset
            if down <= len(lines):
                for h, w in self._compute_hashes_at_line(tokens, down):
                    if h == ctx_hash:
                        return 'relocated', down, w

        return 'broken', requested_line, None

    def _project_root(self) -> Path:
        """Determine project root from overlay location when possible (robust to running from subdirs)."""
        overlay_path = UIHandler.overlay_path
        if overlay_path:
            try:
                p = Path(overlay_path).resolve()
                if p.parent.name == '.invariant':
                    return p.parent.parent
            except Exception:
                pass
        return Path('.').resolve()

    def _resolve_doc_path(self, doc: str) -> tuple[Optional[Path], list[Path]]:
        """
        Resolve a document path within project roots.

        Theory: pointer-to-reality can drift (files move). We try exact resolution first (O(1)),
        then a bounded search inside the project root (still deterministic; never guess ambiguously).
        """
        project_root = self._project_root()
        doc_rel = Path(doc)
        candidates: list[Path] = []

        # Cache (doc -> resolved path) to avoid repeated filesystem work.
        cache: dict[str, str] = getattr(UIHandler, "_doc_path_cache", {}) or {}
        cached = cache.get(doc)
        if cached:
            try:
                p = Path(cached)
                if p.exists() and p.is_file():
                    return p, [p]
            except Exception:
                cache.pop(doc, None)
                UIHandler._doc_path_cache = cache

        if doc_rel.is_absolute():
            try:
                resolved = doc_rel.resolve()
                if project_root in resolved.parents or resolved == project_root:
                    candidates.append(resolved)
            except Exception:
                pass
        else:
            for base in (
                project_root,
                project_root.parent,  # Parent of overlay dir (often project root)
                project_root.parent.parent,  # Two levels up (for nested structures)
                project_root / '.invariant',
                project_root / 'docs',
                project_root / 'data',
                project_root / 'archive',
            ):
                try:
                    resolved = (base / doc_rel).resolve()
                except Exception:
                    continue
                # Add to candidates - we'll check existence later
                candidates.append(resolved)

        for candidate in candidates:
            if candidate.exists() and candidate.is_file():
                cache[doc] = str(candidate)
                UIHandler._doc_path_cache = cache
                return candidate, candidates

        # Fallback: locate by suffix/name within project_root (path drift).
        try:
            target_suffix = doc_rel.as_posix()
            basename = doc_rel.name
            if basename:
                found: list[tuple[str, Path]] = []
                skip_dirs = {
                    '.git', '.venv', 'node_modules', 'dist', 'build',
                    '__pycache__', '.pytest_cache', '.mypy_cache',
                }
                for root, dirs, files in os.walk(project_root):
                    dirs[:] = [d for d in dirs if d not in skip_dirs and not d.startswith('.')]
                    if basename in files:
                        p = Path(root) / basename
                        if not p.is_file():
                            continue
                        try:
                            rel = p.relative_to(project_root).as_posix()
                        except Exception:
                            rel = p.as_posix()
                        found.append((rel, p))
                        if len(found) >= 30:
                            break

                if found:
                    suffix_matches = [p for (rel, p) in found if rel.endswith(target_suffix)]
                    if len(suffix_matches) == 1:
                        cache[doc] = str(suffix_matches[0])
                        UIHandler._doc_path_cache = cache
                        candidates.extend([p for (_rel, p) in found[:10]])
                        return suffix_matches[0], candidates

                    if len(found) == 1:
                        cache[doc] = str(found[0][1])
                        UIHandler._doc_path_cache = cache
                        candidates.append(found[0][1])
                        return found[0][1], candidates

                    # Ambiguous: include a few matches for debugging, but don't guess.
                    candidates.extend([p for (_rel, p) in found[:10]])
        except Exception:
            pass

        return None, candidates

    def api_open(self, query_string: str):
        """
        Open/reveal a local σ-source file at a given line (macOS).
        
        This is a UX bridge: UI -> Reality, without duplicating content (Invariant III).
        Supports Anchor Integrity Protocol via ctx_hash (self-healing line relocation).
        
        Query params:
            mode: 'open' | 'reveal' | 'vscode'
            doc: document filename/path (relative to project / .invariant / docs)
            line: line number (1-indexed)
            ctx_hash: optional semantic checksum (for relocation)
        """
        import platform
        import subprocess
        
        params = urllib.parse.parse_qs(query_string or "")
        mode = (params.get('mode', ['open'])[0] or 'open').strip().lower()
        doc = (params.get('doc', [''])[0] or '').strip()
        line_str = (params.get('line', [''])[0] or '').strip()
        ctx_hash = (params.get('ctx_hash', [''])[0] or '').strip()
        
        if not doc or not line_str:
            self.send_json({'error': 'Missing doc or line parameter'}, 400)
            return
        
        try:
            target_line = int(line_str)
        except ValueError:
            self.send_json({'error': 'Invalid line number'}, 400)
            return
        
        doc_path, candidates = self._resolve_doc_path(doc)
        
        if not doc_path:
            self.send_json({'error': f'Document not found: {doc}', 'searched': [str(c) for c in candidates]}, 404)
            return
        
        # Determine actual line via ctx_hash (self-healing)
        status = 'unchecked'
        actual_line = target_line
        try:
            text = doc_path.read_text(encoding='utf-8')
            lines = text.split('\n')
            tokens = self._tokenize_file(text)
            status, actual_line, _anchor_word = self._resolve_anchor_coordinate(
                lines=lines,
                tokens=tokens,
                requested_line=target_line,
                ctx_hash=ctx_hash or None,
            )
        except Exception:
            status = 'broken'
            actual_line = target_line
        
        # Execute open (macOS only for now)
        if platform.system().lower() != 'darwin':
            self.send_json({'error': 'Open is only implemented for macOS', 'status': status}, 501)
            return
        
        try:
            run = None
            if mode == 'reveal':
                run = subprocess.run(['open', '-R', str(doc_path)], check=False)
            elif mode == 'vscode':
                target = f"{doc_path}:{actual_line}"
                run = subprocess.run(['open', '-a', 'Visual Studio Code', '--args', '-g', target], check=False)
            else:
                run = subprocess.run(['open', str(doc_path)], check=False)
            
            self.send_json({
                'ok': (run.returncode == 0) if run else True,
                'returncode': run.returncode if run else None,
                'mode': mode,
                'doc': doc,
                'doc_path': str(doc_path),
                'requested_line': target_line,
                'actual_line': actual_line,
                'status': status,
            })
        except Exception as e:
            self.send_json({'error': str(e), 'status': status}, 500)
    
    def _tokenize_file(self, text: str) -> list:
        """Tokenize text with position info for hash computation."""
        from invariant_sdk.tokenize import tokenize_with_lines
        return tokenize_with_lines(text)
    
    def _compute_hashes_at_line(self, tokens: list, target_line: int, k: int = 2) -> list[tuple[str, str]]:
        """
        Compute all possible ctx_hashes for tokens at given line.
        
        Returns list of (ctx_hash, anchor_word) (one per token on that line).
        This is needed because we don't know which token was the anchor.
        """
        import hashlib
        
        # Find all tokens on this line
        line_tokens = [(i, t) for i, t in enumerate(tokens) if t[1] == target_line]
        
        if not line_tokens:
            return []
        
        out: list[tuple[str, str]] = []
        for anchor_idx, token in line_tokens:
            # Get window
            start = max(0, anchor_idx - k)
            end = min(len(tokens), anchor_idx + k + 1)
            
            window_words = [tokens[i][0] for i in range(start, end)]
            normalized = ' '.join(w.lower() for w in window_words)
            
            h = hashlib.sha256(normalized.encode('utf-8')).hexdigest()[:8]
            out.append((h, token[0]))
        
        return out
    
    def _extract_semantic_block(self, lines: list, target_line: int, max_lines: int = 10):
        """
        Extract semantic block around target line.
        
        Uses phase boundary detection:
        - Empty lines = paragraph boundary
        - Lines starting with # = section boundary
        - Lines with only whitespace = boundary
        
        Returns: (start_line, end_line, block_lines)
        """
        n = len(lines)
        target_idx = target_line - 1  # 0-indexed
        
        def is_boundary(line: str) -> bool:
            stripped = line.strip()
            if not stripped:  # Empty line
                return True
            if stripped.startswith('#'):  # Markdown header
                return True
            if stripped.startswith('---'):  # Horizontal rule
                return True
            return False
        
        # Find block start (go up until boundary)
        start_idx = target_idx
        while start_idx > 0 and (target_idx - start_idx) < max_lines // 2:
            if is_boundary(lines[start_idx - 1]):
                break
            start_idx -= 1
        
        # Find block end (go down until boundary)
        end_idx = target_idx
        while end_idx < n - 1 and (end_idx - target_idx) < max_lines // 2:
            if is_boundary(lines[end_idx + 1]):
                break
            end_idx += 1
        
        # Extract block
        block_lines = lines[start_idx:end_idx + 1]
        
        return start_idx + 1, end_idx + 1, block_lines  # Return 1-indexed

    # ========================================================================
    # BICAMERAL SEARCH API — Crystal (structure) + Embeddings (associations)
    # ========================================================================
    
    # Class-level Crystal cache
    _crystal: 'BinaryCrystal' = None
    _crystal_path: Optional[Path] = None
    
    def api_bicameral(self, query_string: str):
        """
        Bicameral Interference Visualization.
        
        Theory (INVARIANTS.md):
          - Crystal (1-hop): Structural neighbors
          - Embeddings (0-hop): Associative tunneling
          - Local σ: Documentary facts
          - Interference: When multiple sources confirm same neighbor
        """
        from .tokenize import tokenize_simple, dedupe_preserve_order
        
        params = urllib.parse.parse_qs(query_string or "")
        q = (params.get('q', [''])[0] or '').strip()
        
        if not q:
            self.send_json({'error': 'No query'}, 400)
            return
        
        physics = UIHandler.physics
        
        query_words = dedupe_preserve_order(tokenize_simple(q))
        if not query_words:
            self.send_json({'error': 'No words in query'}, 400)
            return
        
        try:
            if not physics:
                self.send_json({'error': 'Physics not initialized'}, 500)
                return
            
            # Get bicameral expansion (Crystal + Embeddings + Local)
            expanded = physics.expand_query(query_words)
            
            # Build visualization data
            nodes = []
            edges = []
            node_ids = {}
            
            # Query words (T=0)
            for i, word in enumerate(query_words):
                node_id = f"q{i}"
                node_ids[word] = node_id
                nodes.append({
                    "id": node_id,
                    "label": word,
                    "type": "query",
                    "weight": 100,
                })
            
            # Expanded terms
            neighbor_idx = 0
            stats = {"crystal": 0, "embedding": 0, "local": 0, "interference": 0}
            
            for h8, term in expanded.items():
                if term.get("is_direct"):
                    continue
                
                label = str(term.get("label") or h8[:8])
                weight = float(term.get("weight") or 0)
                sources = term.get("sources") or [term.get("source_word")]
                source_type = term.get("source_type") or "crystal"
                
                node_id = f"n{neighbor_idx}"
                neighbor_idx += 1
                
                # Determine node type for visualization
                is_interference = len(sources) > 1 or "+" in source_type
                is_embedding = "embedding" in source_type
                is_local = "local" in source_type
                is_crystal = "crystal" in source_type or source_type == "crystal"
                
                # Count ALL source types (not mutually exclusive)
                if is_crystal:
                    stats["crystal"] += 1
                if is_embedding:
                    stats["embedding"] += 1
                if is_local:
                    stats["local"] += 1
                if is_interference:
                    stats["interference"] += 1
                
                # Primary type for display
                if is_interference:
                    node_type = "interference"
                elif is_local:
                    node_type = "local"
                elif is_embedding:
                    node_type = "embedding"
                else:
                    node_type = "crystal"
                
                nodes.append({
                    "id": node_id,
                    "label": label,
                    "type": node_type,
                    "weight": round(weight * 100),
                    "sources": sources,
                    "source_type": source_type,  # Full type for tooltip
                })
                
                # Edges from source query words
                for src in sources:
                    if src in node_ids:
                        edges.append({
                            "source": node_ids[src],
                            "target": node_id,
                            "weight": round(weight * 100),
                        })
            
            # Get threshold from physics
            threshold = float((physics.meta or {}).get("threshold", 0.5))
            
            self.send_json({
                'query': q,
                'query_words': query_words,
                'nodes': nodes,
                'edges': edges,
                'stats': stats,
                'threshold': round(threshold * 100),
                'mode': 'bicameral',
            })
        except Exception as e:
            self.send_json({'error': f'Expansion error: {str(e)}'}, 500)

    def api_analyze(self, query_string: str):
        """
        Analyze document text for Gas/Solid token classification (MYCELIUM v2.3).
        
        Returns tokens with their phase (Gas/Solid) based on document frequency.
        Gas tokens (df > 30% of windows) are stopwords/noise.
        Solid tokens are discriminative and can be evidence for INHIB/GATE.
        
        Query params:
            doc: document name/path
        
        Returns:
            tokens: list of {word, line, phase, df, is_anchor}
            stats: {n_windows, n_tokens, gas_count, solid_count}
        """
        overlay = UIHandler.overlay
        
        params = urllib.parse.parse_qs(query_string or "")
        doc = (params.get('doc', [''])[0] or '').strip()
        
        if not doc:
            self.send_json({'error': 'Missing doc parameter'}, 400)
            return
        
        if not overlay:
            self.send_json({'error': 'No overlay loaded'}, 400)
            return
        
        doc_path, candidates = self._resolve_doc_path(doc)
        if not doc_path:
            self.send_json({'error': f'Document not found: {doc}'}, 404)
            return
        
        try:
            from invariant_sdk.tokenize import tokenize_with_lines
            from invariant_sdk.halo import hash8_hex
            from invariant_sdk.operators import build_window_stats, GAS_DF_THRESHOLD
            from collections import Counter
            
            text = doc_path.read_text(encoding='utf-8')
            tokens = tokenize_with_lines(text)
            
            # Build stats for this document's edges only
            doc_edges = {}
            for src, edges in overlay.edges.items():
                doc_edges[src] = [e for e in edges if e.doc == doc or (e.doc and e.doc.endswith(doc))]
                if not doc_edges[src]:
                    del doc_edges[src]
            
            # Calculate df from token occurrences
            word_counts = Counter(w for w, _ln in tokens)
            word_lines = {}
            for w, ln in tokens:
                if w not in word_lines:
                    word_lines[w] = []
                word_lines[w].append(ln)
            
            # Estimate windows (lines / 5)
            n_lines = len(set(ln for w, ln in tokens))
            n_windows = max(1, n_lines // 3)
            
            gas_threshold = int(n_windows * GAS_DF_THRESHOLD)
            
            # Classify tokens
            result_tokens = []
            gas_count = 0
            solid_count = 0
            
            for word in word_counts:
                count = word_counts[word]
                h8 = hash8_hex(f"Ġ{word}")
                
                # df approximation: count / avg_occurrences_per_window
                df = count
                is_gas = df > gas_threshold
                
                if is_gas:
                    gas_count += 1
                else:
                    solid_count += 1
                
                # Check if word is indexed anchor (has edges)
                is_anchor = h8 in overlay.edges or any(
                    h8 == e.tgt for edges in overlay.edges.values() for e in edges
                )
                
                result_tokens.append({
                    'word': word,
                    'lines': word_lines[word][:10],  # Limit to first 10 occurrences
                    'phase': 'gas' if is_gas else 'solid',
                    'df': df,
                    'is_anchor': is_anchor,
                    'hash8': h8,
                })
            
            # Sort: anchors first, then solid, then gas
            result_tokens.sort(key=lambda t: (
                not t['is_anchor'],
                t['phase'] == 'gas',
                -t['df']
            ))
            
            self.send_json({
                'doc': doc,
                'doc_path': str(doc_path),
                'tokens': result_tokens[:200],  # Limit response size
                'stats': {
                    'n_windows': n_windows,
                    'n_tokens': len(word_counts),
                    'gas_count': gas_count,
                    'solid_count': solid_count,
                    'gas_threshold': gas_threshold,
                    'gas_df_fraction': GAS_DF_THRESHOLD,
                }
            })
        except Exception as e:
            self.send_json({'error': str(e)}, 500)
