"""
Festinger — main FastAPI application.

Routes:
  POST /api/chat                         Ollama-compatible chat (loop detection + recollection injection)
  POST /api/generate                     Ollama-compatible generate
  POST /v1/messages                      Anthropic Messages API proxy (loop detection + recollection)
  POST /v1/chat/completions              OpenAI-compatible proxy (loop detection + recollection)
  POST /chat/completions                 Alias for /v1/chat/completions (LiteLLM without /v1 prefix)
  POST /{agent_id}/v1/chat/completions   Agent-prefixed OpenAI proxy — agent identity from URL
  POST /{agent_id}/chat/completions      Agent-prefixed OpenAI proxy (no /v1 prefix variant)
  POST /{agent_id}/v1/messages           Agent-prefixed Anthropic proxy — agent identity from URL
  POST /{agent_id}/api/chat              Agent-prefixed Ollama chat — agent identity from URL
  POST /{agent_id}/api/generate          Agent-prefixed Ollama generate — agent identity from URL
  POST /iknowthat                        Manual write path (gutask iknowthat)
  POST /resolve/run                      Manually trigger nightly resolution job
  POST /reload                           Reload URD cache (called by resolution job)
  GET  /health                           Health + stats
  GET  /conflicts                        Pending and recently resolved conflicts
  GET  /admin                            Minimal admin UI
  *    /{path}                           Passthrough to upstream Ollama or Anthropic
"""
from __future__ import annotations

import asyncio
import json
import logging
import os
import re
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any

import httpx
import yaml
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse

from . import cache
from .db import (
    close_pool, get_config, get_or_create_soas,
    get_pool, init_schema, bootstrap_dimensions,
    bootstrap_english_dictionary, warm_cache, reload_urd_cache,
    flush_encounter_deltas, reset_graph, log_recollection,
)
from .loop_detector import apply_mitigations, record_and_check, session_key
from .cue_scanner import scan_cues
from .recollection import build_recollection_block, inject_recollection
from .resolution_job import run_resolution_job, last_run_timestamp
from .tokenizer import tokenize
from .llm_client import ModelConfig
from .write_queue import enqueue_cue, start_worker, stop_worker
from .urd_writer import InsertRequest, insert_urd_edge
from .wordnet import import_wordnet, CITATION as WORDNET_CITATION
from .test_scenarios import SCENARIOS, seed_scenario, reset_scenario

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s  %(levelname)-7s  %(message)s",
)
log = logging.getLogger("festinger")

CONFIG_PATH = Path(__file__).parent.parent / "config.yaml"


def load_yaml_config() -> dict:
    with open(CONFIG_PATH) as f:
        return yaml.safe_load(f)


# ---------------------------------------------------------------------------
# Lifespan — startup / shutdown
# ---------------------------------------------------------------------------

_scheduler = AsyncIOScheduler()


@asynccontextmanager
async def lifespan(app: FastAPI):
    cfg = load_yaml_config()
    dsn = os.environ.get("POSTGRES_DSN", cfg.get("postgres_dsn", ""))

    pool = await get_pool(dsn)
    app.state.pool = pool
    app.state.yaml_config = cfg

    await init_schema(pool)
    await bootstrap_dimensions(pool)
    await bootstrap_english_dictionary(pool)
    await warm_cache(pool)
    await start_worker(pool)

    # Schedule saliency flush every 30 s
    _scheduler.add_job(flush_encounter_deltas, "interval", seconds=30, args=[pool], id="saliency_flush")

    # Schedule nightly resolution job from config table
    resolution_cron = await get_config(pool, "resolution_schedule", "0 2 * * *")
    cron_parts = resolution_cron.split()
    if len(cron_parts) == 5:
        minute, hour, day, month, dow = cron_parts
        _scheduler.add_job(
            run_resolution_job, "cron",
            minute=minute, hour=hour, day=day, month=month, day_of_week=dow,
            args=[pool], id="nightly_resolution",
        )

    _scheduler.start()
    log.info("festinger ready")

    yield

    _scheduler.shutdown(wait=False)
    await stop_worker()
    await close_pool()


app = FastAPI(title="Festinger", lifespan=lifespan)


# ---------------------------------------------------------------------------
# Upstream error — preserves the real HTTP status code from upstream
# ---------------------------------------------------------------------------

class UpstreamError(Exception):
    """
    Raised when an upstream (Anthropic / Ollama / OpenAI) returns a non-2xx
    response.  Carries the original status code and body so route handlers can
    forward them verbatim instead of turning everything into a 500.
    """
    def __init__(self, status_code: int, content: bytes, content_type: str, provider: str):
        self.status_code = status_code
        self.content = content
        self.content_type = content_type or "application/json"
        self.provider = provider
        super().__init__(f"{provider} upstream returned {status_code}")


# ---------------------------------------------------------------------------
# Request / response logging middleware
# ---------------------------------------------------------------------------

@app.middleware("http")
async def log_requests(request: Request, call_next):
    """Log every inbound request and its outcome. Catches unhandled exceptions."""
    t0 = time.perf_counter()
    method = request.method
    path = request.url.path
    qs = ("?" + str(request.url.query)) if request.url.query else ""
    size = request.headers.get("content-length", "?")

    log.info("→ %s %s%s  bytes=%s", method, path, qs, size)
    try:
        response = await call_next(request)
        ms = (time.perf_counter() - t0) * 1000
        log.info("← %d %s  %.0fms", response.status_code, path, ms)
        return response
    except Exception as exc:
        ms = (time.perf_counter() - t0) * 1000
        log.exception("✗ %s %s  %.0fms  unhandled %s: %s", method, path, ms, type(exc).__name__, exc)
        return Response(
            content=json.dumps({"error": str(exc), "type": type(exc).__name__}),
            status_code=500,
            media_type="application/json",
        )


# ---------------------------------------------------------------------------
# Ollama forwarding helpers
# ---------------------------------------------------------------------------

async def call_ollama(path: str, body: dict, upstream: str) -> tuple[str, dict]:
    body = dict(body)
    body["stream"] = False
    model = body.get("model", "?")
    url = f"{upstream}{path}"
    log.info("upstream_call  provider=ollama model=%s url=%s", model, url)
    t0 = time.perf_counter()
    try:
        async with httpx.AsyncClient(timeout=300.0) as client:
            r = await client.post(url, json=body)
    except httpx.TimeoutException as exc:
        log.error("upstream_timeout  provider=ollama model=%s url=%s  after=%.0fs  %s",
                  model, url, time.perf_counter() - t0, exc)
        raise
    except httpx.RequestError as exc:
        log.error("upstream_connect_error  provider=ollama model=%s url=%s  %s: %s",
                  model, url, type(exc).__name__, exc)
        raise
    ms = (time.perf_counter() - t0) * 1000
    if not r.is_success:
        log.error("upstream_error  provider=ollama model=%s url=%s  status=%d  %.0fms  body=%.500s",
                  model, url, r.status_code, ms, r.text)
        raise UpstreamError(r.status_code, r.content, r.headers.get("content-type", ""), "ollama")
    log.info("upstream_ok  provider=ollama model=%s status=%d  %.0fms", model, r.status_code, ms)
    data = r.json()
    if path == "/api/chat":
        text = data.get("message", {}).get("content", "")
    else:
        text = data.get("response", "")
    return text, data


# ---------------------------------------------------------------------------
# Anthropic forwarding helpers
# ---------------------------------------------------------------------------

def _relay_headers(request: Request, keep: tuple[str, ...]) -> dict[str, str]:
    """Extract a safe subset of request headers to forward upstream."""
    return {
        k: v for k, v in request.headers.items()
        if k.lower() in keep
    }


ANTHROPIC_RELAY_HEADERS = (
    "x-api-key",
    "anthropic-version",
    "anthropic-beta",
    "content-type",
)

OPENAI_RELAY_HEADERS = (
    "authorization",
    "content-type",
    "openai-organization",
    "openai-project",
)


def _anthropic_to_sse(data: dict) -> bytes:
    """
    Convert a non-streaming Anthropic Messages API response into SSE bytes
    that litellm expects when it sent stream=True.

    Emits the minimal event sequence litellm's streaming parser requires:
      message_start → content_block_start → content_block_delta(s)
      → content_block_stop → message_delta → message_stop
    """
    def sse(event: str, payload: dict) -> str:
        return f"event: {event}\ndata: {json.dumps(payload)}\n\n"

    lines: list[str] = []

    # message_start
    lines.append(sse("message_start", {
        "type": "message_start",
        "message": {
            "id": data.get("id", "msg_festinger"),
            "type": "message",
            "role": "assistant",
            "content": [],
            "model": data.get("model", ""),
            "stop_reason": None,
            "stop_sequence": None,
            "usage": data.get("usage", {"input_tokens": 0, "output_tokens": 0}),
        },
    }))
    lines.append(sse("ping", {"type": "ping"}))

    content_blocks = data.get("content", [])
    for idx, block in enumerate(content_blocks):
        btype = block.get("type", "text")

        if btype == "text":
            lines.append(sse("content_block_start", {
                "type": "content_block_start",
                "index": idx,
                "content_block": {"type": "text", "text": ""},
            }))
            lines.append(sse("content_block_delta", {
                "type": "content_block_delta",
                "index": idx,
                "delta": {"type": "text_delta", "text": block.get("text", "")},
            }))

        elif btype == "tool_use":
            lines.append(sse("content_block_start", {
                "type": "content_block_start",
                "index": idx,
                "content_block": {
                    "type": "tool_use",
                    "id": block.get("id", ""),
                    "name": block.get("name", ""),
                    "input": {},
                },
            }))
            lines.append(sse("content_block_delta", {
                "type": "content_block_delta",
                "index": idx,
                "delta": {
                    "type": "input_json_delta",
                    "partial_json": json.dumps(block.get("input", {})),
                },
            }))

        lines.append(sse("content_block_stop", {
            "type": "content_block_stop",
            "index": idx,
        }))

    # message_delta + message_stop
    usage = data.get("usage", {})
    lines.append(sse("message_delta", {
        "type": "message_delta",
        "delta": {
            "stop_reason": data.get("stop_reason", "end_turn"),
            "stop_sequence": data.get("stop_sequence"),
        },
        "usage": {"output_tokens": usage.get("output_tokens", 0)},
    }))
    lines.append(sse("message_stop", {"type": "message_stop"}))

    return "".join(lines).encode()


async def call_anthropic(body: dict, upstream: str, headers: dict) -> tuple[str, dict]:
    """
    Forward a request to the Anthropic Messages API (non-streaming).
    Returns (assistant_text, raw_response_dict).
    """
    body = dict(body)
    body["stream"] = False
    if "anthropic-version" not in {k.lower() for k in headers}:
        headers = {**headers, "anthropic-version": "2023-06-01"}

    model = body.get("model", "?")
    url = f"{upstream}/v1/messages"
    log.info("upstream_call  provider=anthropic model=%s url=%s", model, url)
    t0 = time.perf_counter()
    try:
        async with httpx.AsyncClient(timeout=300.0) as client:
            r = await client.post(url, json=body, headers=headers)
    except httpx.TimeoutException as exc:
        log.error("upstream_timeout  provider=anthropic model=%s url=%s  after=%.0fs  %s",
                  model, url, time.perf_counter() - t0, exc)
        raise
    except httpx.RequestError as exc:
        log.error("upstream_connect_error  provider=anthropic model=%s url=%s  %s: %s",
                  model, url, type(exc).__name__, exc)
        raise
    ms = (time.perf_counter() - t0) * 1000
    if not r.is_success:
        log.error("upstream_error  provider=anthropic model=%s url=%s  status=%d  %.0fms  body=%.500s",
                  model, url, r.status_code, ms, r.text)
        raise UpstreamError(r.status_code, r.content, r.headers.get("content-type", ""), "anthropic")
    log.info("upstream_ok  provider=anthropic model=%s status=%d  %.0fms", model, r.status_code, ms)
    data = r.json()
    text = ""
    for block in data.get("content", []):
        if block.get("type") == "text":
            text += block.get("text", "")

    stop_reason = data.get("stop_reason", "unknown")
    usage = data.get("usage", {})
    in_tok = usage.get("input_tokens", "?")
    out_tok = usage.get("output_tokens", "?")
    preview = text[:120].replace("\n", " ") if text else "(empty)"
    log.info(
        "upstream_response  provider=anthropic model=%s stop_reason=%s in_tokens=%s out_tokens=%s  text=%.120s",
        model, stop_reason, in_tok, out_tok, preview,
    )

    if stop_reason == "max_tokens":
        # Output was cut off at the token limit.  The truncated response is
        # always identical, so Festinger's loop detector will fire immediately
        # and Agent0 will retry forever.  Convert to a 400 so litellm raises
        # ContextWindowExceededError and the agent can handle it gracefully.
        log.error(
            "upstream_max_tokens  provider=anthropic model=%s in_tokens=%s out_tokens=%s"
            " — response truncated, converting to 400 to break loop",
            model, in_tok, out_tok,
        )
        error_body = json.dumps({
            "type": "error",
            "error": {
                "type": "invalid_request_error",
                "message": (
                    f"max_tokens reached: response was truncated after {out_tok} output tokens "
                    f"({in_tok} input tokens used). "
                    "Reduce the prompt length or raise the max_tokens limit."
                ),
            },
        }).encode()
        raise UpstreamError(400, error_body, "application/json", "anthropic")

    return text, data


async def call_openai(body: dict, upstream: str, headers: dict) -> tuple[str, dict]:
    """
    Forward a request to an OpenAI-compatible chat completions endpoint (non-streaming).
    Returns (assistant_text, raw_response_dict).
    """
    body = dict(body)
    body["stream"] = False
    model = body.get("model", "?")
    url = f"{upstream}/v1/chat/completions"
    log.info("upstream_call  provider=openai model=%s url=%s", model, url)
    t0 = time.perf_counter()
    try:
        async with httpx.AsyncClient(timeout=300.0) as client:
            r = await client.post(url, json=body, headers=headers)
    except httpx.TimeoutException as exc:
        log.error("upstream_timeout  provider=openai model=%s url=%s  after=%.0fs  %s",
                  model, url, time.perf_counter() - t0, exc)
        raise
    except httpx.RequestError as exc:
        log.error("upstream_connect_error  provider=openai model=%s url=%s  %s: %s",
                  model, url, type(exc).__name__, exc)
        raise
    ms = (time.perf_counter() - t0) * 1000
    if not r.is_success:
        log.error("upstream_error  provider=openai model=%s url=%s  status=%d  %.0fms  body=%.500s",
                  model, url, r.status_code, ms, r.text)
        raise UpstreamError(r.status_code, r.content, r.headers.get("content-type", ""), "openai")
    log.info("upstream_ok  provider=openai model=%s status=%d  %.0fms", model, r.status_code, ms)
    data = r.json()
    text = data.get("choices", [{}])[0].get("message", {}).get("content", "")
    return text, data


# ---------------------------------------------------------------------------
# Text extraction helpers (unified across API formats)
# ---------------------------------------------------------------------------

def _extract_text_strings(content) -> list[str]:
    """
    Normalise any Anthropic content shape into a list of plain strings.
    Handles: bare string, list of content-block dicts, or anything unexpected.
    """
    if isinstance(content, str):
        return [content] if content else []
    if isinstance(content, list):
        out = []
        for block in content:
            if isinstance(block, dict) and block.get("type") == "text":
                text = block.get("text", "")
                if isinstance(text, str) and text:
                    out.append(text)
        return out
    return []


# Tokens that look structural/technical — kept for potential future use.
_STRUCTURAL_RE = re.compile(
    r"[/@]"
    r"|\.\w"
    r"|^\d+$"
    r"|^\d[\d.]+\d$"
    r"|^#[0-9a-f]{3,6}$"    # hex colour
    r"|^0x[0-9a-f]+$",       # hex literal
    re.IGNORECASE,
)



def extract_prompt_text(body: dict, path: str) -> str:
    """
    Extract text from the most recent turns only (last user + last assistant message).

    Scanning full conversation history on every request is noisy and redundant —
    the system prompt in particular is large and stable, so its novel words were
    already registered on the first request.
    """
    if path in ("/api/chat", "/v1/chat/completions", "/v1/messages"):
        messages = body.get("messages", [])
        last_user = next((m for m in reversed(messages) if m.get("role") == "user"), None)
        last_assistant = next(
            (m for m in reversed(messages) if m.get("role") == "assistant"), None
        )
        parts: list[str] = []
        for m in filter(None, [last_user, last_assistant]):
            parts.extend(_extract_text_strings(m.get("content", "")))
        return " ".join(parts)
    return body.get("prompt", "")


def _human_prefix(text: str, max_chars: int = 1000) -> str:
    """
    Extract the human-written prefix of a user message, stopping before any
    agent-framework context injection (EXTRAS, tool results, structured sections).

    Agent Zero (and similar frameworks) append structured context to the user
    message after a blank line followed by a section marker.  We only want the
    user's actual words — the first paragraph before any such marker.

    Markers we stop at:
      \\n\\n[          — Agent Zero [EXTRAS], [context], [solutions], …
      \\n\\n<          — XML-wrapped context blocks
      \\n---           — horizontal rule separators
      \\n# or \\n##    — markdown headings injected by the framework
    """
    # Structural section delimiters injected by agent frameworks
    for delim in ("\n\n[", "\n\n<", "\n---", "\n[EXTRAS]", "\n[context]"):
        idx = text.find(delim)
        if 0 < idx < max_chars:
            return text[:idx].strip()
    return text[:max_chars].strip()


def _is_internal_subcall(user_text: str, sys_text: str = "") -> bool:
    """
    Heuristic: returns True if this looks like an agent-internal sub-call
    (memory extraction, consolidation, keyword search) rather than a main
    user-facing turn.  We skip recollection injection for these.

    Signals checked in order:
      1. User message is a JSON/array literal  → memory operation payload
      2. System message is very short          → utility prompt, not a full agent
         personality (Agent Zero's main system prompt is typically >5 000 chars;
         memory sub-call prompts are usually <1 500 chars)
      3. System message contains memory-task keywords in the first 200 chars
    """
    # Signal 1: JSON payload
    if user_text.strip().startswith(("{", "[")):
        return True

    if sys_text:
        # Signal 2: system prompt suspiciously short for a real agent call
        if len(sys_text) < 1500:
            return True

        # Signal 3: task-oriented system message (memory ops, keyword extraction…)
        sys_head = sys_text[:200].lower()
        task_markers = (
            "extract keyword", "consolidat", "search for similar",
            "rate the similarity", "memorize", "memory content",
        )
        if any(m in sys_head for m in task_markers):
            return True

    return False


def _last_user_message_text(body: dict, path: str) -> str:
    """
    Extract only the human-written prefix of the last user message.
    Agent Zero and similar frameworks append large context sections (EXTRAS,
    solutions, tool results) after the user's actual words.  We stop at the
    first structural delimiter so that domain tokens in the injected context
    don't spuriously trigger recollection.
    """
    if path in ("/api/chat", "/v1/chat/completions", "/v1/messages"):
        messages = body.get("messages", [])
        last_user = next((m for m in reversed(messages) if m.get("role") == "user"), None)
        if last_user:
            raw = " ".join(_extract_text_strings(last_user.get("content", "")))
            return _human_prefix(raw)
        return ""
    raw = body.get("prompt", "")
    return _human_prefix(raw)


def _last_assistant_message_text(body: dict, path: str) -> str:
    """Extract the last assistant message for cue scanning."""
    if path in ("/api/chat", "/v1/chat/completions", "/v1/messages"):
        messages = body.get("messages", [])
        last_assistant = next(
            (m for m in reversed(messages) if m.get("role") == "assistant"), None
        )
        if last_assistant:
            return " ".join(_extract_text_strings(last_assistant.get("content", "")))
    return ""


def _system_message_text(body: dict, path: str) -> str:
    """
    Extract the system message text from the request body.
    - OpenAI/Ollama: first message with role='system' in the messages list.
    - Anthropic: top-level 'system' field (string or content-block list).
    """
    if path == "/v1/messages":
        sys_field = body.get("system") or ""
        if isinstance(sys_field, list):
            return " ".join(
                b.get("text", "") for b in sys_field
                if isinstance(b, dict) and b.get("type") == "text"
            )
        return sys_field

    if path in ("/api/chat", "/v1/chat/completions", "/api/generate"):
        messages = body.get("messages", [])
        sys_msg = next((m for m in messages if m.get("role") == "system"), None)
        if sys_msg:
            return " ".join(_extract_text_strings(sys_msg.get("content", "")))

    return ""


def inject_recollection_anthropic(body: dict, block: str) -> dict:
    """
    Inject a recollection block into an Anthropic Messages API request.
    Anthropic uses a top-level 'system' field — either a plain string or a list of
    content-block dicts. Normalise to a plain string before prepending the block.
    """
    body = dict(body)
    existing = body.get("system") or ""
    if isinstance(existing, list):
        # Flatten content-block list to plain text
        existing = " ".join(
            b.get("text", "") for b in existing
            if isinstance(b, dict) and b.get("type") == "text"
        )
    body["system"] = block + ("\n\n" + existing if existing else "")
    return body


# ---------------------------------------------------------------------------
# Request model mirroring
# ---------------------------------------------------------------------------

def _extract_request_model_config(
    path: str,
    body: dict,
    request_headers: dict,
    cfg: dict,
) -> ModelConfig | None:
    """
    Build a ModelConfig from the intercepted request so Festinger's utility
    LLM calls (context discovery) can use the same provider/model as Agent0 —
    no separate write_model_id configuration needed.

    Provider inference:
      /v1/messages            → anthropic
      /v1/chat/completions    → lm-studio (OpenAI-compatible; base_url from upstream_openai)
      /api/chat, /api/generate → lm-studio (Ollama's OpenAI-compat endpoint; base_url from upstream_ollama)
    """
    model_name = body.get("model", "")
    if not model_name:
        return None

    if path == "/v1/messages":
        api_key = request_headers.get("x-api-key", "")
        return ModelConfig(
            provider="claude",
            model_name=model_name,
            api_key=api_key,
        )

    if path == "/v1/chat/completions":
        auth = request_headers.get("authorization", "")
        api_key = auth[len("Bearer "):].strip() if auth.lower().startswith("bearer ") else auth
        base_url = cfg.get("upstream_openai", "")
        return ModelConfig(
            provider="lm-studio",
            model_name=model_name,
            api_key=api_key or "lm-studio",
            base_url=base_url,
        )

    if path in ("/api/chat", "/api/generate"):
        # Ollama exposes an OpenAI-compatible endpoint at the same base URL.
        base_url = cfg.get("upstream_ollama", "")
        return ModelConfig(
            provider="lm-studio",
            model_name=model_name,
            api_key="ollama",
            base_url=base_url,
        )

    return None


# ---------------------------------------------------------------------------
# Agent routing — cross-protocol dispatch
# ---------------------------------------------------------------------------

def _openai_to_anthropic_body(body: dict, model_name: str) -> dict:
    """
    Translate an OpenAI chat completions request to Anthropic Messages API format.
    - system messages are lifted to the top-level 'system' field
    - max_tokens defaults to 4096 if not specified
    - temperature/top_p forwarded if present
    """
    system_parts: list[str] = []
    claude_messages: list[dict] = []

    for m in body.get("messages", []):
        role = m.get("role", "")
        content = m.get("content", "")
        if role == "system":
            if isinstance(content, str):
                system_parts.append(content)
            elif isinstance(content, list):
                system_parts.extend(
                    b.get("text", "") for b in content
                    if isinstance(b, dict) and b.get("type") == "text"
                )
        else:
            claude_messages.append(m)

    anthropic_body: dict = {
        "model": model_name,
        "messages": claude_messages,
        "max_tokens": body.get("max_tokens") or 4096,
    }
    if system_parts:
        anthropic_body["system"] = "\n\n".join(system_parts)
    for key in ("temperature", "top_p", "stop_sequences"):
        if key in body:
            anthropic_body[key] = body[key]
    return anthropic_body


def _anthropic_to_openai_response(data: dict) -> dict:
    """Convert an Anthropic Messages API response to OpenAI chat completions format."""
    text = "".join(
        b.get("text", "") for b in data.get("content", [])
        if b.get("type") == "text"
    )
    usage = data.get("usage", {})
    stop_map = {"end_turn": "stop", "max_tokens": "length", "stop_sequence": "stop"}
    finish = stop_map.get(data.get("stop_reason", "end_turn"), "stop")
    return {
        "id": data.get("id", "chatcmpl-festinger"),
        "object": "chat.completion",
        "created": int(time.time()),
        "model": data.get("model", ""),
        "choices": [{
            "index": 0,
            "message": {"role": "assistant", "content": text},
            "finish_reason": finish,
        }],
        "usage": {
            "prompt_tokens": usage.get("input_tokens", 0),
            "completion_tokens": usage.get("output_tokens", 0),
            "total_tokens": usage.get("input_tokens", 0) + usage.get("output_tokens", 0),
        },
    }


def _openai_sse_from_response(raw: dict) -> bytes:
    """
    Synthesise a minimal OpenAI-compatible SSE stream from a complete (non-streaming)
    OpenAI-format response dict.  Used when the client sent stream=true but the
    upstream was called non-streaming.
    """
    text = raw.get("choices", [{}])[0].get("message", {}).get("content", "")
    model = raw.get("model", "")
    cid = raw.get("id", "chatcmpl-festinger")
    ts = int(time.time())

    def chunk(delta: dict, finish_reason=None) -> str:
        return "data: " + json.dumps({
            "id": cid, "object": "chat.completion.chunk",
            "created": ts, "model": model,
            "choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
        }) + "\n\n"

    parts = [
        chunk({"role": "assistant", "content": ""}),
        chunk({"content": text}),
        chunk({}, finish_reason="stop"),
        "data: [DONE]\n\n",
    ]
    return "".join(parts).encode()


# ---------------------------------------------------------------------------
# Per-upstream concurrency control for local models
# ---------------------------------------------------------------------------

# Local inference servers (LM Studio, Ollama) typically run a single model
# on one GPU and queue or crash under concurrent requests.  We serialize all
# agent-routed calls that share the same base URL through a semaphore.
# Cloud providers (claude, openai) are excluded — they handle concurrency fine.
_local_upstream_semaphores: dict[str, asyncio.Semaphore] = {}


def _get_upstream_semaphore(base_url: str) -> asyncio.Semaphore:
    """Return (creating if needed) a semaphore that limits concurrency to 1 for this upstream."""
    key = base_url.rstrip("/").lower()
    if key not in _local_upstream_semaphores:
        _local_upstream_semaphores[key] = asyncio.Semaphore(1)
    return _local_upstream_semaphores[key]



async def _get_agent_routing_model(pool, agent_name: str) -> ModelConfig | None:
    """
    Look up the agent's configured model from the agent_models table.
    agent_name is the normalised key (lowercase name or numeric ID string).
    """
    if not agent_name:
        return None
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            """
            SELECT m.provider, m.model_name, m.api_key, m.base_url
            FROM agent_models am
            JOIN models m ON m.id = am.model_id
            WHERE am.agent_name = $1
            """,
            agent_name,
        )
    if not row:
        return None
    return ModelConfig(
        provider=row["provider"],
        model_name=row["model_name"],
        api_key=row["api_key"],
        base_url=row["base_url"] or "",
    )


async def _route_agent_chat(
    body: dict,
    agent_model: ModelConfig,
    original_stream: bool,
    pool,
    cfg: dict,
    request_headers: dict,
    min_len: int,
    agent_name: str = "",
) -> Response:
    """
    Route an OpenAI-compatible chat completions request to the agent's
    configured provider, handling cross-protocol translation.

    claude   → Anthropic Messages API (translated in both directions)
    openai   → OpenAI-compatible endpoint (base_url + model swap)
    lm-studio→ same as openai, serialized through a per-upstream semaphore
    """
    is_local = agent_model.provider in ("lm-studio", "ollama")

    # Local single-GPU models can't handle concurrent requests.
    # Skip context discovery so we don't fire a second LM Studio call
    # at the same time as the main inference.
    # (Configure write_model_id or a per-agent model for a cloud/separate
    # model if you want memory building alongside local inference.)
    body = await process_prompt(
        body, "/v1/chat/completions", pool, cfg, request_headers, agent_name=agent_name,
    )

    sess = session_key(agent_model.model_name, body.get("messages", []))

    # Capture upstream config for use in the loop-detection re-run closure
    if agent_model.provider == "claude":
        anthropic_upstream = agent_model.base_url or "https://api.anthropic.com"
        anthropic_headers = {
            "x-api-key": agent_model.api_key,
            "anthropic-version": "2023-06-01",
            "content-type": "application/json",
        }
    else:
        # call_openai() appends /v1/chat/completions to whatever upstream it gets.
        # DB base_url often ends in /v1 (correct for the OpenAI SDK used by the
        # resolution job), so strip that suffix to avoid /v1/v1/chat/completions.
        raw_base = agent_model.base_url or cfg.get("upstream_openai", "https://api.openai.com")
        oai_upstream = raw_base.rstrip("/")
        if oai_upstream.endswith("/v1"):
            oai_upstream = oai_upstream[:-3]
        oai_headers = {
            "authorization": f"Bearer {agent_model.api_key or 'lm-studio'}",
            "content-type": "application/json",
        }
        # One request at a time to local inference servers.
        _local_sem = _get_upstream_semaphore(oai_upstream) if is_local else None

    async def _call(current_body: dict) -> tuple[str, dict]:
        """Call the agent's upstream; always return (text, openai_format_dict)."""
        if agent_model.provider == "claude":
            ab = _openai_to_anthropic_body(current_body, agent_model.model_name)
            text, raw_a = await call_anthropic(ab, anthropic_upstream, anthropic_headers)
            return text, _anthropic_to_openai_response(raw_a)
        else:
            b = dict(current_body)
            b["model"] = agent_model.model_name
            if _local_sem is not None:
                async with _local_sem:
                    text, raw_o = await call_openai(b, oai_upstream, oai_headers)
            else:
                text, raw_o = await call_openai(b, oai_upstream, oai_headers)
            return text, raw_o

    text, raw = await _call(body)
    count = record_and_check(sess, text, min_len)

    if count >= 2:
        log.warning(
            "loop_detected (agent routed)  agent_model=%s session=%s count=%d",
            agent_model.model_name, sess[1], count,
        )
        body, override = apply_mitigations(body, count, cfg)
        if override is not None:
            raw["choices"] = [{
                "index": 0,
                "message": {"role": "assistant", "content": override},
                "finish_reason": "stop",
            }]
            raw["loop_detected"] = True
        else:
            text, raw = await _call(body)
            record_and_check(sess, text, min_len)

    if original_stream:
        return Response(content=_openai_sse_from_response(raw), media_type="text/event-stream")
    return Response(content=json.dumps(raw), media_type="application/json")


# ---------------------------------------------------------------------------
# Saliency + recollection pipeline
# ---------------------------------------------------------------------------

def _extract_agent_name(body: dict, headers: dict) -> tuple[str, dict]:
    """
    Extract agent identity from the request body (LiteLLM extra params) or headers.
    Priority: body agent_name > body agent_id > X-Agent-Name header > X-Agent-Id header.

    Also returns a cleaned copy of the body with agent_id/agent_name stripped,
    so unknown parameters are never forwarded to the upstream LLM.
    """
    # Pull from body first — LiteLLM passes extra params as top-level JSON fields
    agent_name = str(body.get("agent_name", "")).strip().lower()
    agent_id = str(body.get("agent_id", "")).strip()

    # Fall back to headers (X-Agent-Name / X-Agent-Id)
    if not agent_name:
        agent_name = headers.get("x-agent-name", "").strip().lower()
    if not agent_id and not agent_name:
        agent_id = headers.get("x-agent-id", "").strip()

    identity = agent_name or agent_id  # name preferred; id as string fallback

    # Strip festinger-specific params so the upstream never sees them
    clean_body = {k: v for k, v in body.items() if k not in ("agent_id", "agent_name")}

    return identity, clean_body


async def process_prompt(
    body: dict,
    path: str,
    pool,
    cfg: dict,
    request_headers: dict | None = None,
    agent_name: str = "",
) -> dict:
    """
    Run the saliency + recollection pipeline over the prompt.
    Returns a (possibly modified) body dict with the recollection block injected.
    agent_name may be passed in directly (e.g. extracted from the URL path) to
    avoid re-parsing the body/headers; falls back to _extract_agent_name if empty.
    """
    read_threshold = float(await get_config(pool, "saliency_read_threshold", "0.5"))
    conf_floor = float(await get_config(pool, "recollection_confidence_floor", "0.6"))
    recency_days = int(await get_config(pool, "recollection_recency_days", "90"))

    hdrs = request_headers or {}
    if not agent_name:
        agent_name, _ = _extract_agent_name(body, hdrs)  # body already cleaned by route handler

    # Last user message — human-written prefix only (strips agent framework context).
    user_text = _last_user_message_text(body, path)
    if not user_text.strip():
        return body

    # Skip injection for internal agent sub-calls (memory extraction,
    # consolidation, keyword search).  These are detected by:
    #   - user message being a JSON payload, OR
    #   - system message being very short (utility prompt, not a full agent), OR
    #   - system message starting with memory-task keywords.
    raw_sys_text = _system_message_text(body, path)
    if _is_internal_subcall(user_text, raw_sys_text):
        return body

    # 1. Scan user message for explicit relationship cues (fast, regex-only).
    for cue in scan_cues(user_text):
        await enqueue_cue(cue)

    # 2. Also scan the last assistant message for is-a / is-part-of assertions.
    #    Agents frequently state facts in their responses ("X is a Y", "X runs on Z").
    #    No token-loop needed here — just cue extraction.
    assistant_text = _last_assistant_message_text(body, path)
    if assistant_text:
        for cue in scan_cues(assistant_text):
            await enqueue_cue(cue)

    # 3. Session boost — scan the system message for domain tokens.
    #    Concepts grounding the agent's persona or project context rank higher
    #    in the recollection block for the entire session.
    session_boost_ids: set[int] = set()
    if raw_sys_text:
        for tok in tokenize(raw_sys_text):
            row = cache.soas_by_token.get(tok)
            if row and row.saliency > 0.0:
                session_boost_ids.add(row.id)
        if session_boost_ids:
            boost_tokens = [cache.soas_by_id.get(i, str(i)) for i in session_boost_ids]
            log.debug("session_boost | agent=%s tokens=%s", agent_name or "(none)", boost_tokens)

    # 4. Token loop over user message for saliency-triggered recollection.
    tokens = tokenize(user_text)
    salient_for_read: list[int] = []

    for token in tokens:
        soas_row = cache.soas_by_token.get(token)
        if soas_row is None:
            continue
        if soas_row.saliency == 0.0:
            continue  # saliency=0 is the English-word sentinel — skip
        cache.record_encounter(soas_row.id)
        if soas_row.saliency >= read_threshold:
            salient_for_read.append(soas_row.id)

    if not salient_for_read:
        return body

    # 5. Conflict spike — warn loudly if any salient concept has a pending contradiction.
    conflicted = [
        cache.soas_by_id.get(cid, str(cid))
        for cid in salient_for_read
        if cid in cache.pending_conflicts
    ]
    if conflicted:
        log.warning(
            "recollection_conflict_spike | agent=%s conflicted=%s  "
            "(recollection block will show '?' suffix on affected dimensions)",
            agent_name or "(none)", conflicted,
        )

    # 6. Build recollection block
    block = build_recollection_block(
        salient_for_read, conf_floor, recency_days,
        session_boost_ids=session_boost_ids,
    )
    if not block:
        return body

    salient_tokens = [cache.soas_by_id.get(cid, str(cid)) for cid in salient_for_read]
    log.info(
        "recollection fired | agent=%s salient=%s\n%s",
        agent_name or "(none)", salient_tokens, block,
    )

    # 7. Inject into messages
    if path == "/api/chat" or path == "/v1/chat/completions":
        body = dict(body)
        body["messages"] = inject_recollection(body.get("messages", []), block)
        log_messages = body["messages"]
    elif path == "/v1/messages":
        body = inject_recollection_anthropic(body, block)
        # Reconstruct a messages-style list for the log viewer
        sys_content = body.get("system", "")
        log_messages = ([{"role": "system", "content": sys_content}] if sys_content else []) + body.get("messages", [])
    elif path == "/api/generate":
        body = dict(body)
        body["prompt"] = block + "\n\n" + body.get("prompt", "")
        log_messages = [{"role": "user", "content": body["prompt"]}]
    else:
        log_messages = []

    asyncio.create_task(log_recollection(pool, agent_name or "", salient_tokens, block, log_messages))

    return body


# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------

async def _handle_ollama_chat(request: Request, agent_name: str = "") -> Response:
    cfg = request.app.state.yaml_config
    pool = request.app.state.pool
    raw_body = await request.json()
    if agent_name:
        _, body = _extract_agent_name(raw_body, dict(request.headers))
    else:
        agent_name, body = _extract_agent_name(raw_body, dict(request.headers))
    model = body.get("model", "unknown")
    upstream = cfg["upstream_ollama"]
    min_len = cfg["detection"]["min_length"]
    log.info("chat  route=/api/chat model=%s agent=%s", model, agent_name or "—")
    try:
        body = await process_prompt(body, "/api/chat", pool, cfg, dict(request.headers), agent_name=agent_name)
        text, raw = await call_ollama("/api/chat", body, upstream)
        sess = session_key(model, body.get("messages", []))
        count = record_and_check(sess, text, min_len)
        if count >= 2:
            log.warning("loop_detected  model=%s session=%s count=%d", model, sess[1], count)
            body, override = apply_mitigations(body, count, cfg)
            if override is not None:
                raw["message"] = {"role": "assistant", "content": override}
                raw["loop_detected"] = True
                return Response(content=json.dumps(raw), media_type="application/json")
            text, raw = await call_ollama("/api/chat", body, upstream)
            record_and_check(sess, text, min_len)
        raw["message"] = {"role": "assistant", "content": text}
        return Response(content=json.dumps(raw), media_type="application/json")
    except UpstreamError as exc:
        log.error("chat_upstream_error  route=/api/chat model=%s  %s", model, exc)
        return Response(content=exc.content, status_code=exc.status_code, media_type=exc.content_type)
    except Exception as exc:
        log.exception("chat_error  route=/api/chat model=%s  %s: %s", model, type(exc).__name__, exc)
        raise


async def _handle_ollama_generate(request: Request, agent_name: str = "") -> Response:
    cfg = request.app.state.yaml_config
    pool = request.app.state.pool
    raw_body = await request.json()
    if agent_name:
        _, body = _extract_agent_name(raw_body, dict(request.headers))
    else:
        agent_name, body = _extract_agent_name(raw_body, dict(request.headers))
    model = body.get("model", "unknown")
    upstream = cfg["upstream_ollama"]
    min_len = cfg["detection"]["min_length"]
    log.info("chat  route=/api/generate model=%s agent=%s", model, agent_name or "—")
    try:
        body = await process_prompt(body, "/api/generate", pool, cfg, dict(request.headers), agent_name=agent_name)
        messages = [{"role": "user", "content": body.get("prompt", "")}]
        sess = session_key(model, messages)
        text, raw = await call_ollama("/api/generate", body, upstream)
        count = record_and_check(sess, text, min_len)
        if count >= 2:
            log.warning("loop_detected  model=%s session=%s count=%d", model, sess[1], count)
            body, override = apply_mitigations(body, count, cfg)
            if override is not None:
                raw["response"] = override
                raw["loop_detected"] = True
                return Response(content=json.dumps(raw), media_type="application/json")
            text, raw = await call_ollama("/api/generate", body, upstream)
            record_and_check(sess, text, min_len)
        raw["response"] = text
        return Response(content=json.dumps(raw), media_type="application/json")
    except UpstreamError as exc:
        log.error("chat_upstream_error  route=/api/generate model=%s  %s", model, exc)
        return Response(content=exc.content, status_code=exc.status_code, media_type=exc.content_type)
    except Exception as exc:
        log.exception("chat_error  route=/api/generate model=%s  %s: %s", model, type(exc).__name__, exc)
        raise


@app.post("/api/chat")
async def chat(request: Request) -> Response:
    return await _handle_ollama_chat(request)


@app.post("/api/generate")
async def generate(request: Request) -> Response:
    return await _handle_ollama_generate(request)


# ---------------------------------------------------------------------------
# Anthropic Messages API  (POST /v1/messages)
# ---------------------------------------------------------------------------

async def _handle_anthropic_messages(request: Request, agent_name: str = "") -> Response:
    cfg = request.app.state.yaml_config
    pool = request.app.state.pool
    raw_body = await request.body()
    parsed = json.loads(raw_body)
    if agent_name:
        _, body = _extract_agent_name(parsed, dict(request.headers))
    else:
        agent_name, body = _extract_agent_name(parsed, dict(request.headers))
    # Capture streaming intent BEFORE call_anthropic forces stream=False
    original_stream: bool = bool(body.get("stream", False))
    model = body.get("model", "unknown")
    upstream = cfg["upstream_anthropic"]
    min_len = cfg["detection"]["min_length"]
    log.info(
        "chat  route=/v1/messages model=%s upstream=%s agent=%s stream=%s  req_preview=%.200s",
        model, upstream, agent_name or "—", original_stream,
        raw_body[:200].decode(errors="replace").replace("\n", " "),
    )
    try:
        headers = _relay_headers(request, ANTHROPIC_RELAY_HEADERS)
        if "anthropic-version" not in {k.lower() for k in headers}:
            headers["anthropic-version"] = "2023-06-01"
        body = await process_prompt(body, "/v1/messages", pool, cfg, headers, agent_name=agent_name)
        messages = body.get("messages", [])
        sess = session_key(model, messages)
        text, raw = await call_anthropic(body, upstream, headers)
        count = record_and_check(sess, text, min_len)
        if count >= 2:
            log.warning("loop_detected  model=%s session=%s count=%d", model, sess[1], count)
            body, override = apply_mitigations(body, count, cfg)
            if override is not None:
                raw["content"] = [{"type": "text", "text": override}]
                raw["loop_detected"] = True
                if original_stream:
                    return Response(content=_anthropic_to_sse(raw), media_type="text/event-stream")
                return Response(content=json.dumps(raw), media_type="application/json")
            text, raw = await call_anthropic(body, upstream, headers)
            record_and_check(sess, text, min_len)
        if original_stream:
            sse_bytes = _anthropic_to_sse(raw)
            log.info(
                "streaming_response  provider=anthropic model=%s  converting json→sse  bytes=%d  preview=%.300s",
                model, len(sse_bytes), sse_bytes[:300].decode(errors="replace"),
            )
            return Response(content=sse_bytes, media_type="text/event-stream")
        resp_body = json.dumps(raw)
        log.info(
            "json_response  provider=anthropic model=%s  bytes=%d  preview=%.300s",
            model, len(resp_body), resp_body[:300],
        )
        return Response(content=resp_body, media_type="application/json")
    except UpstreamError as exc:
        log.error("chat_upstream_error  route=/v1/messages model=%s  %s", model, exc)
        return Response(content=exc.content, status_code=exc.status_code, media_type=exc.content_type)
    except Exception as exc:
        log.exception("chat_error  route=/v1/messages model=%s  %s: %s", model, type(exc).__name__, exc)
        raise


@app.post("/v1/messages")
async def anthropic_messages(request: Request) -> Response:
    return await _handle_anthropic_messages(request)


# ---------------------------------------------------------------------------
# OpenAI-compatible chat completions  (POST /v1/chat/completions)
# ---------------------------------------------------------------------------

async def _handle_openai_chat(request: Request, agent_name: str = "") -> Response:
    cfg = request.app.state.yaml_config
    pool = request.app.state.pool
    raw_body = await request.json()
    hdrs = dict(request.headers)
    if agent_name:
        _, body = _extract_agent_name(raw_body, hdrs)
    else:
        agent_name, body = _extract_agent_name(raw_body, hdrs)  # strips agent_id/agent_name
    model = body.get("model", "unknown")
    upstream = cfg["upstream_openai"]
    min_len = cfg["detection"]["min_length"]
    original_stream: bool = bool(body.get("stream", False))

    log.info("chat  route=/v1/chat/completions model=%s upstream=%s agent=%s stream=%s",
             model, upstream, agent_name or "—", original_stream)
    try:
        # Agent routing: if agent has a registered model, dispatch cross-protocol
        if agent_name:
            agent_model = await _get_agent_routing_model(pool, agent_name)
            if agent_model:
                log.info(
                    "agent_route  agent=%s provider=%s model=%s base_url=%s",
                    agent_name, agent_model.provider, agent_model.model_name,
                    agent_model.base_url or "(default)",
                )
                return await _route_agent_chat(
                    body, agent_model, original_stream, pool, cfg, hdrs, min_len, agent_name=agent_name,
                )

        # Standard path — forward to configured upstream unchanged
        headers = _relay_headers(request, OPENAI_RELAY_HEADERS)
        body = await process_prompt(body, "/v1/chat/completions", pool, cfg, hdrs, agent_name=agent_name)
        messages = body.get("messages", [])
        sess = session_key(model, messages)
        text, raw = await call_openai(body, upstream, headers)
        count = record_and_check(sess, text, min_len)
        if count >= 2:
            log.warning("loop_detected  model=%s session=%s count=%d", model, sess[1], count)
            body, override = apply_mitigations(body, count, cfg)
            if override is not None:
                if raw.get("choices"):
                    raw["choices"][0]["message"]["content"] = override
                raw["loop_detected"] = True
                if original_stream:
                    return Response(content=_openai_sse_from_response(raw), media_type="text/event-stream")
                return Response(content=json.dumps(raw), media_type="application/json")
            text, raw = await call_openai(body, upstream, headers)
            record_and_check(sess, text, min_len)
        if original_stream:
            return Response(content=_openai_sse_from_response(raw), media_type="text/event-stream")
        return Response(content=json.dumps(raw), media_type="application/json")
    except UpstreamError as exc:
        log.error("chat_upstream_error  route=/v1/chat/completions model=%s  %s", model, exc)
        return Response(content=exc.content, status_code=exc.status_code, media_type=exc.content_type)
    except Exception as exc:
        log.exception("chat_error  route=/v1/chat/completions model=%s  %s: %s", model, type(exc).__name__, exc)
        raise


@app.post("/v1/chat/completions")
async def openai_chat_completions(request: Request) -> Response:
    return await _handle_openai_chat(request)


# Alias: some LiteLLM provider types (custom_openai, openai_like) omit the
# /v1 prefix and post directly to /chat/completions.
@app.post("/chat/completions")
async def openai_chat_completions_no_prefix(request: Request) -> Response:
    """Alias for /v1/chat/completions — handles LiteLLM providers that omit /v1."""
    return await _handle_openai_chat(request)


# ---------------------------------------------------------------------------
# Agent-prefixed routes  /{agent_id}/...
#
# Configure each agent instance's base_url to include its name:
#   http://festinger:11434/gerhard/v1      ← hermes (OpenAI-compat)
#   http://festinger:11434/dobby/v1/messages  ← Agent0 (Anthropic)
#   http://festinger:11434/gunnar           ← Ollama clients
#
# The agent_id is extracted from the URL path and takes priority over any
# agent_name/agent_id passed in the request body or headers.
# ---------------------------------------------------------------------------

@app.post("/{agent_id}/v1/chat/completions")
async def openai_chat_with_agent_id(agent_id: str, request: Request) -> Response:
    return await _handle_openai_chat(request, agent_name=agent_id.lower())


@app.post("/{agent_id}/chat/completions")
async def openai_chat_with_agent_id_no_v1(agent_id: str, request: Request) -> Response:
    return await _handle_openai_chat(request, agent_name=agent_id.lower())


@app.post("/{agent_id}/v1/messages")
async def anthropic_messages_with_agent_id(agent_id: str, request: Request) -> Response:
    return await _handle_anthropic_messages(request, agent_name=agent_id.lower())


@app.post("/{agent_id}/api/chat")
async def ollama_chat_with_agent_id(agent_id: str, request: Request) -> Response:
    return await _handle_ollama_chat(request, agent_name=agent_id.lower())


@app.post("/{agent_id}/api/generate")
async def ollama_generate_with_agent_id(agent_id: str, request: Request) -> Response:
    return await _handle_ollama_generate(request, agent_name=agent_id.lower())


@app.get("/{agent_id}/v1/models")
async def models_with_agent_id(agent_id: str, request: Request) -> Response:
    """
    Model discovery for agent-prefixed base URLs.

    Clients (LiteLLM, OpenAI SDK) that use base_url=http://festinger/{agent_id}/v1
    will call GET /{agent_id}/v1/models to discover available models.
    Strip the agent prefix and proxy to the agent's configured upstream.
    """
    pool = request.app.state.pool
    cfg = request.app.state.yaml_config
    name = agent_id.lower()

    agent_model = await _get_agent_routing_model(pool, name)
    if agent_model and agent_model.base_url:
        raw_base = agent_model.base_url.rstrip("/")
        if raw_base.endswith("/v1"):
            raw_base = raw_base[:-3]
        upstream_url = f"{raw_base}/v1/models"
    else:
        # Fall back to configured OpenAI upstream
        raw_base = cfg.get("upstream_openai", "").rstrip("/")
        if raw_base.endswith("/v1"):
            raw_base = raw_base[:-3]
        upstream_url = f"{raw_base}/v1/models"

    log.info("models_discovery  agent=%s → %s", name, upstream_url)
    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            r = await client.get(upstream_url)
        return Response(content=r.content, status_code=r.status_code,
                        media_type=r.headers.get("content-type", "application/json"))
    except httpx.RequestError as exc:
        log.error("models_discovery_error  agent=%s url=%s  %s", name, upstream_url, exc)
        return Response(
            content='{"object":"list","data":[]}',
            status_code=200,
            media_type="application/json",
        )


# ---------------------------------------------------------------------------
# /scan — gutask integration: scan task / letter text and return recollection
# ---------------------------------------------------------------------------

# Saliency encounter weight per context type.
# Facts stated in tasks are stronger signals than chat overheard in passing.
_CONTEXT_WEIGHT: dict[str, float] = {
    "task":   2.0,
    "letter": 1.5,
    "chat":   1.0,
}


@app.post("/scan")
async def scan_text(request: Request) -> dict:
    """
    Scan a block of plain text for domain concepts and return a recollection block.

    Called by gutask when an agent reads a task or a letter, so that the agent
    sees relevant knowledge-graph context alongside the task/letter content.

    Body:
        text    (str)  — the task description, letter body, or any free text
        agent   (str)  — agent name (for logging; optional)
        context (str)  — "task" | "letter" | "chat"  (default: "task")

    Returns:
        recollection_block  (str | null)  — the <recollection>…</recollection> block,
                                            or null if nothing salient was found
        salient_tokens      (list[str])   — concepts that triggered the block
        cues_found          (int)         — number of URD cues extracted from the text
    """
    pool = request.app.state.pool
    data = await request.json()

    text: str = data.get("text", "").strip()
    agent_name: str = data.get("agent", "").strip().lower()
    context_type: str = data.get("context", "task").strip().lower()
    weight: float = _CONTEXT_WEIGHT.get(context_type, 1.0)

    if not text:
        return {"recollection_block": None, "salient_tokens": [], "cues_found": 0}

    read_threshold = float(await get_config(pool, "saliency_read_threshold", "0.5"))
    conf_floor = float(await get_config(pool, "recollection_confidence_floor", "0.6"))
    recency_days = int(await get_config(pool, "recollection_recency_days", "90"))

    # 1. Cue scanner — extract explicit relationship assertions and enqueue them.
    cues = list(scan_cues(text))
    cues_found = len(cues)
    for cue in cues:
        await enqueue_cue(cue)

    # 2. Token loop — find salient concepts and record weighted encounters.
    tokens = tokenize(text)
    salient_ids: list[int] = []

    for token in tokens:
        row = cache.soas_by_token.get(token)
        if row is None or row.saliency == 0.0:
            continue
        # Weight the encounter: task/letter mentions count more than chat.
        # We stage fractional deltas; flush_encounter_deltas rounds to int,
        # so accumulate weight as repeated single increments for simplicity.
        increments = max(1, round(weight))
        for _ in range(increments):
            cache.record_encounter(row.id)
        if row.saliency >= read_threshold:
            salient_ids.append(row.id)

    if not salient_ids:
        log.debug("scan | agent=%s context=%s cues=%d → no salient concepts",
                  agent_name or "(none)", context_type, cues_found)
        return {"recollection_block": None, "salient_tokens": [], "cues_found": cues_found}

    # 3. Build recollection block (session boost not applicable here — no system message).
    block = build_recollection_block(salient_ids, conf_floor, recency_days)

    salient_tokens = [cache.soas_by_id.get(cid, str(cid)) for cid in salient_ids]
    log.info(
        "scan | agent=%s context=%s cues=%d salient=%s\n%s",
        agent_name or "(none)", context_type, cues_found, salient_tokens,
        block or "(no block)",
    )

    return {
        "recollection_block": block,
        "salient_tokens": salient_tokens,
        "cues_found": cues_found,
    }


# ---------------------------------------------------------------------------
# /recall/{concept} — gutask recall backend
# ---------------------------------------------------------------------------

@app.get("/recall/{concept:path}")
async def recall_concept(
    concept: str,
    request: Request,
    depth: str = "brief",
) -> dict:
    """
    Return what Festinger knows about a concept, formatted for agent display.

    depth:
      brief      — URD edges only (same as recollection block, more readable)
      detailed   — edges + saliency stats + related concepts in same dimensions
      everything — detailed + full write log history
    """
    pool = request.app.state.pool
    concept = concept.lower().strip()

    row = cache.soas_by_token.get(concept)
    if row is None:
        # Try DB in case cache is stale
        async with pool.acquire() as conn:
            db_row = await conn.fetchrow(
                "SELECT id, token, saliency, novelty, encounter_count, "
                "first_seen_context, last_seen FROM soas WHERE token = $1",
                concept,
            )
        if not db_row:
            return {"concept": concept, "found": False, "text": f"No knowledge about '{concept}' in Festinger."}
        from .cache import SoasRow
        row = SoasRow(
            id=db_row["id"], token=db_row["token"],
            saliency=db_row["saliency"], novelty=db_row["novelty"],
            encounter_count=db_row["encounter_count"],
            first_seen_context=db_row["first_seen_context"] or "",
            last_seen=db_row["last_seen"],
        )

    edges = cache.urd_by_concept.get(row.id, [])
    reverse_edges = cache.urd_by_parent.get(row.id, [])

    lines = [f"── {concept} {'─' * max(0, 50 - len(concept))}"]

    if depth in ("detailed", "everything"):
        from .recollection import recency_decay, centrality_bonus, effective_score
        decay = recency_decay(row)
        score = effective_score(row.id)
        last_seen_str = (
            row.last_seen.strftime("%Y-%m-%d") if row.last_seen else "never"
        )
        lines.append(f"  saliency: {row.saliency:.2f}  encounters: {row.encounter_count}"
                     f"  score: {score:.2f}  last seen: {last_seen_str}")
        if row.first_seen_context:
            lines.append(f"  first seen: \"{row.first_seen_context[:80]}\"")
        if row.id in cache.pending_conflicts:
            lines.append("  ⚠ has pending conflict in resolution queue")
        lines.append("")

    if edges:
        lines.append("  Relationships (outgoing):")
        for e in edges:
            conf_str = f"  conf={e.confidence:.2f}" if e.confidence < 1.0 else ""
            lines.append(f"    [{e.dim_token}] → {e.parent_token}{conf_str}")
    else:
        lines.append("  No outgoing relationships stored.")

    if reverse_edges:
        lines.append("")
        lines.append("  Referenced by:")
        for e in reverse_edges[:10]:
            child_token = cache.soas_by_id.get(e.concept_id, str(e.concept_id))
            lines.append(f"    [{e.dim_token}] ← {child_token}")
        if len(reverse_edges) > 10:
            lines.append(f"    … and {len(reverse_edges) - 10} more")

    if depth in ("detailed", "everything") and edges:
        # Siblings: other concepts sharing the same parent in the same dimension
        siblings: dict[str, list[str]] = {}
        for e in edges:
            peer_edges = cache.urd_by_parent.get(e.parent_id, [])
            peers = [
                cache.soas_by_id.get(pe.concept_id, str(pe.concept_id))
                for pe in peer_edges
                if pe.concept_id != row.id and pe.dim_id == e.dim_id
            ][:5]
            if peers:
                siblings[f"{e.dim_token}/{e.parent_token}"] = peers
        if siblings:
            lines.append("")
            lines.append("  Siblings (same parent/dimension):")
            for label, peers in siblings.items():
                lines.append(f"    {label}: {', '.join(peers)}")

    if depth == "everything":
        async with pool.acquire() as conn:
            log_rows = await conn.fetch(
                """
                SELECT op, parent_token, dim_token, is_isa, source, created_at
                FROM kg_write_log WHERE concept_id = $1
                ORDER BY created_at DESC LIMIT 20
                """,
                row.id,
            )
        if log_rows:
            lines.append("")
            lines.append("  Write history:")
            for lr in log_rows:
                ts = lr["created_at"].strftime("%Y-%m-%d")
                isa = "is-a" if lr["is_isa"] else "is-part-of"
                lines.append(
                    f"    {ts}  {lr['op']:<10} [{lr['dim_token']}] {isa} {lr['parent_token']}"
                    f"  ({lr['source']})"
                )

    text = "\n".join(lines)
    return {
        "concept": concept,
        "found": True,
        "depth": depth,
        "saliency": row.saliency,
        "encounter_count": row.encounter_count,
        "edges": [
            {"dim": e.dim_token, "parent": e.parent_token,
             "is_isa": e.is_isa, "confidence": e.confidence}
            for e in edges
        ],
        "text": text,
    }


# ---------------------------------------------------------------------------
# /iknowthat — manual write path
# ---------------------------------------------------------------------------

@app.post("/iknowthat")
async def iknowthat(request: Request) -> dict:
    """
    Parse and insert a manual fact.
    Body: {"fact": "gnommoweb -isa repo in context of glitch_university"}
    """
    pool = request.app.state.pool
    data = await request.json()
    fact = data.get("fact", "").strip()
    if not fact:
        return {"error": "fact is required"}

    # Parse: "{subject} -isa|-ispart {parent} [in context of {dimension}]"
    import re
    m = re.match(
        r"^(?P<subj>\S+)\s+(?P<rel>-isa|-ispart)\s+(?P<parent>\S+)"
        r"(?:\s+in\s+context\s+of\s+(?P<dim>\S+))?$",
        fact, re.IGNORECASE,
    )
    if not m:
        return {"error": f"could not parse fact: {fact!r}. "
                         "Expected: '<subject> -isa|-ispart <parent> [in context of <dimension>]'"}

    subj = m.group("subj").lower()
    is_isa = m.group("rel").lower() == "-isa"
    parent = m.group("parent").lower()
    dim = (m.group("dim") or ("type" if is_isa else "membership")).lower()

    subj_row = await get_or_create_soas(pool, subj)
    parent_row = await get_or_create_soas(pool, parent)
    dim_row = await get_or_create_soas(pool, dim)

    req = InsertRequest(
        concept_id=subj_row.id,
        parent_id=parent_row.id,
        dim_id=dim_row.id,
        is_isa=is_isa,
        confidence=1.0,
        source="gutask",
    )
    collision = await insert_urd_edge(pool, req, priority=True)

    if collision:
        return {
            "status": "collision",
            "collision_type": collision.collision_type,
            "fact": fact,
            "message": "Conflict queued for nightly resolution (priority). "
                       "Current world model unchanged.",
        }

    return {
        "status": "inserted",
        "fact": fact,
        "subject": subj,
        "parent": parent,
        "dimension": dim,
        "is_isa": is_isa,
    }


# ---------------------------------------------------------------------------
# /concepts — browse and search domain concepts
# ---------------------------------------------------------------------------

@app.get("/concepts")
async def list_concepts(
    request: Request,
    q: str = "",
    limit: int = 50,
    offset: int = 0,
) -> dict:
    """
    Return domain concepts (saliency > 0 or novelty > 0) with their URD edges.
    Optionally filter by token prefix/substring via ?q=.
    """
    pool = request.app.state.pool
    async with pool.acquire() as conn:
        if q:
            rows = await conn.fetch(
                """
                SELECT id, token, saliency, novelty, encounter_count, first_seen_context
                FROM soas
                WHERE (saliency > 0 OR novelty > 0) AND token ILIKE $1
                ORDER BY saliency DESC, encounter_count DESC
                LIMIT $2 OFFSET $3
                """,
                f"%{q}%", limit, offset,
            )
            total = await conn.fetchval(
                "SELECT COUNT(*) FROM soas WHERE (saliency > 0 OR novelty > 0) AND token ILIKE $1",
                f"%{q}%",
            )
        else:
            rows = await conn.fetch(
                """
                SELECT id, token, saliency, novelty, encounter_count, first_seen_context
                FROM soas
                WHERE saliency > 0 OR novelty > 0
                ORDER BY saliency DESC, encounter_count DESC
                LIMIT $1 OFFSET $2
                """,
                limit, offset,
            )
            total = await conn.fetchval(
                "SELECT COUNT(*) FROM soas WHERE saliency > 0 OR novelty > 0"
            )

    result = []
    for r in rows:
        cid = r["id"]
        edges = cache.urd_by_concept.get(cid, [])
        result.append({
            "id": cid,
            "token": r["token"],
            "saliency": round(r["saliency"], 3),
            "novelty": round(r["novelty"], 3),
            "encounter_count": r["encounter_count"],
            "first_seen_context": r["first_seen_context"] or "",
            "edges": [
                {
                    "dim": e.dim_token,
                    "parent": e.parent_token,
                    "is_isa": e.is_isa,
                    "confidence": round(e.confidence, 3),
                    "source": e.source,
                }
                for e in edges
            ],
        })

    return {"total": total, "concepts": result}


@app.delete("/concepts/{token:path}")
async def delete_concept(token: str, request: Request) -> dict:
    """Remove a concept and all its URD edges from SOAS."""
    pool = request.app.state.pool
    token = token.lower().strip()
    async with pool.acquire() as conn:
        async with conn.transaction():
            await conn.execute("DELETE FROM kg_write_log WHERE concept_token = $1", token)
            soas_id = await conn.fetchval("SELECT id FROM soas WHERE token = $1", token)
            if soas_id is None:
                return {"error": f"concept {token!r} not found"}
            await conn.execute("DELETE FROM resolution_queue WHERE concept_id = $1", soas_id)
            await conn.execute("DELETE FROM urd WHERE id = $1", soas_id)
            await conn.execute("DELETE FROM soas WHERE id = $1", soas_id)

    # Evict from cache
    row = cache.soas_by_token.pop(token, None)
    if row:
        cache.soas_by_id.pop(row.id, None)
        edges = cache.urd_by_concept.pop(row.id, [])
        for e in edges:
            cache.urd_by_concept_dim.pop((row.id, e.dim_id), None)
        cache.urd_by_parent.pop(row.id, None)
        cache.pending_conflicts.discard(row.id)

    return {"status": "deleted", "token": token}


# ---------------------------------------------------------------------------
# /models — LLM model management
# ---------------------------------------------------------------------------

@app.get("/models")
async def list_models(request: Request) -> dict:
    pool = request.app.state.pool
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            "SELECT id, provider, model_name, base_url, created_at FROM models ORDER BY id"
        )
    return {"models": [
        {"id": r["id"], "provider": r["provider"], "model_name": r["model_name"],
         "base_url": r["base_url"] or "", "created_at": r["created_at"].isoformat()}
        for r in rows
    ]}


@app.post("/models")
async def create_model(request: Request) -> dict:
    pool = request.app.state.pool
    data = await request.json()
    provider = data.get("provider", "").strip()
    model_name = data.get("model_name", "").strip()
    api_key = data.get("api_key", "").strip()
    base_url = data.get("base_url", "").strip()
    if not provider or not model_name:
        return {"error": "provider and model_name are required"}
    if provider not in ("claude", "openai", "lm-studio"):
        return {"error": "provider must be 'claude', 'openai', or 'lm-studio'"}
    if provider == "claude" and not api_key:
        return {"error": "api_key is required for claude provider"}
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "INSERT INTO models (provider, model_name, api_key, base_url) VALUES ($1,$2,$3,$4) RETURNING id",
            provider, model_name, api_key, base_url,
        )
    log.info("model created  id=%d provider=%s model=%s base_url=%s", row["id"], provider, model_name, base_url)
    return {"status": "ok", "id": row["id"]}


@app.delete("/models/{model_id}")
async def delete_model(model_id: int, request: Request) -> dict:
    pool = request.app.state.pool
    async with pool.acquire() as conn:
        result = await conn.execute("DELETE FROM models WHERE id=$1", model_id)
    deleted = int(result.split()[-1]) if result else 0
    if not deleted:
        return {"error": f"model {model_id} not found"}
    log.info("model deleted  id=%d", model_id)
    return {"status": "ok", "deleted": model_id}


# ---------------------------------------------------------------------------
# /agent-models — per-agent model assignments
# ---------------------------------------------------------------------------

@app.get("/agent-models")
async def list_agent_models(request: Request) -> dict:
    pool = request.app.state.pool
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            """
            SELECT am.agent_name, am.model_id, am.created_at,
                   m.provider, m.model_name, m.base_url
            FROM agent_models am
            JOIN models m ON m.id = am.model_id
            ORDER BY am.agent_name
            """
        )
    return {"agent_models": [
        {
            "agent_name": r["agent_name"],
            "model_id": r["model_id"],
            "provider": r["provider"],
            "model_name": r["model_name"],
            "base_url": r["base_url"] or "",
            "created_at": r["created_at"].isoformat(),
        }
        for r in rows
    ]}


@app.put("/agent-models/{agent_name}")
async def set_agent_model(agent_name: str, request: Request) -> dict:
    """
    Assign a model to an agent.  agent_name is normalised to lowercase.
    Body: {"model_id": 3}
    """
    pool = request.app.state.pool
    data = await request.json()
    model_id = data.get("model_id")
    if not model_id:
        return {"error": "model_id is required"}
    name = agent_name.strip().lower()
    if not name:
        return {"error": "agent_name must not be empty"}
    async with pool.acquire() as conn:
        # Verify the model exists
        row = await conn.fetchrow("SELECT id, provider, model_name FROM models WHERE id=$1", int(model_id))
        if not row:
            return {"error": f"model {model_id} not found"}
        await conn.execute(
            """
            INSERT INTO agent_models (agent_name, model_id)
            VALUES ($1, $2)
            ON CONFLICT (agent_name) DO UPDATE SET model_id = EXCLUDED.model_id, created_at = now()
            """,
            name, int(model_id),
        )
    log.info("agent_model set  agent=%s model_id=%s provider=%s model=%s",
             name, model_id, row["provider"], row["model_name"])
    return {"status": "ok", "agent_name": name, "model_id": int(model_id)}


@app.delete("/agent-models/{agent_name}")
async def delete_agent_model(agent_name: str, request: Request) -> dict:
    pool = request.app.state.pool
    name = agent_name.strip().lower()
    async with pool.acquire() as conn:
        result = await conn.execute("DELETE FROM agent_models WHERE agent_name=$1", name)
    deleted = int(result.split()[-1]) if result else 0
    if not deleted:
        return {"error": f"no assignment found for agent '{name}'"}
    log.info("agent_model deleted  agent=%s", name)
    return {"status": "ok", "deleted": name}


@app.get("/models/discover")
async def discover_models(base_url: str = "http://host.docker.internal:1234") -> dict:
    """
    Proxy a GET to {base_url}/v1/models to discover models available in LM Studio
    (or any OpenAI-compatible server). Avoids browser CORS restrictions.
    """
    url = base_url.rstrip("/") + "/v1/models"
    log.info("discover_models  url=%s", url)
    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            r = await client.get(url)
        if not r.is_success:
            return {"error": f"server returned {r.status_code}", "models": []}
        data = r.json()
        model_ids = [m.get("id", m) for m in data.get("data", [])]
        return {"models": model_ids, "raw": data}
    except httpx.ConnectError:
        return {"error": f"could not connect to {url} — is LM Studio running?", "models": []}
    except Exception as exc:
        return {"error": str(exc), "models": []}


@app.get("/config")
async def get_all_config(request: Request) -> dict:
    pool = request.app.state.pool
    async with pool.acquire() as conn:
        rows = await conn.fetch("SELECT key, value, updated_at FROM config ORDER BY key")
    return {"config": {r["key"]: r["value"] for r in rows}}


@app.post("/config")
async def update_config(request: Request) -> dict:
    pool = request.app.state.pool
    data = await request.json()
    key = data.get("key", "").strip()
    value = str(data.get("value", "")).strip()
    if not key:
        return {"error": "key is required"}
    async with pool.acquire() as conn:
        await conn.execute(
            "UPDATE config SET value=$1, updated_at=now() WHERE key=$2",
            value, key,
        )
    log.info("config updated  key=%s value=%s", key, value)
    return {"status": "ok", "key": key, "value": value}


# ---------------------------------------------------------------------------
# /resolve/run — manually trigger resolution job
# ---------------------------------------------------------------------------

@app.post("/resolve/run")
async def resolve_run(request: Request) -> dict:
    pool = request.app.state.pool
    result = await run_resolution_job(pool)
    return result


# ---------------------------------------------------------------------------
# /wordnet/import — bulk-load WordNet lemmas into SOAS
# ---------------------------------------------------------------------------

@app.post("/wordnet/import")
async def wordnet_import(request: Request) -> dict:
    """
    Import Princeton WordNet 3.x lemmas into SOAS (saliency=0, novelty=0).
    Idempotent — already-present tokens are skipped.
    Can take 5–15 seconds for a full import (~130k lemmas).
    """
    pool = request.app.state.pool
    result = await import_wordnet(pool)
    return result


# ---------------------------------------------------------------------------
# /reload — reload URD cache (used after resolution job)
# ---------------------------------------------------------------------------

@app.post("/reload")
async def reload(request: Request) -> dict:
    pool = request.app.state.pool
    await reload_urd_cache(pool)
    return {"status": "ok", "urd_edges": len(cache.urd_by_concept_dim)}


@app.post("/reset")
async def reset(request: Request) -> dict:
    """
    Wipe all learned knowledge (URD, domain SOAS, resolution queue, write log).
    Keeps the standard-English dictionary seed intact.
    Re-bootstraps dimension roots so the graph is ready for new learning.
    """
    pool = request.app.state.pool
    counts = await reset_graph(pool)
    # Re-bootstrap dimension self-referential roots
    await bootstrap_dimensions(pool)
    # Re-warm the URD cache (should now be empty except roots)
    await reload_urd_cache(pool)
    log.info("graph reset complete")
    return {"status": "ok", **counts,
            "soas_remaining": len(cache.soas_by_token),
            "urd_edges": len(cache.urd_by_concept_dim)}


# ---------------------------------------------------------------------------
# /health
# ---------------------------------------------------------------------------

@app.get("/health")
async def health(request: Request) -> dict:
    cfg = request.app.state.yaml_config
    return {
        "status": "ok",
        "upstream": cfg["upstream_ollama"],
        "active_loop_sessions": 0,  # loop detector is stateful in-process
        "soas_tokens": len(cache.soas_by_token),
        "urd_edges": len(cache.urd_by_concept_dim),
        "pending_conflicts": len(cache.pending_conflicts),
        "last_resolution_run": last_run_timestamp(),
        "timestamp": int(time.time()),
    }


# ---------------------------------------------------------------------------
# /conflicts — expose resolution queue
# ---------------------------------------------------------------------------

@app.get("/conflicts")
async def conflicts(request: Request) -> dict:
    pool = request.app.state.pool
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            """
            SELECT id, concept_id, existing_parent_id, incoming_parent_id,
                   dim_id, collision_type, status, resolution, priority,
                   created_at, resolved_at
            FROM resolution_queue
            ORDER BY status ASC, created_at DESC
            LIMIT 100
            """
        )

    def format_row(r):
        return {
            "id": r["id"],
            "concept": cache.soas_by_id.get(r["concept_id"], str(r["concept_id"])),
            "existing_parent": cache.soas_by_id.get(r["existing_parent_id"], "?"),
            "incoming_parent": cache.soas_by_id.get(r["incoming_parent_id"], "?"),
            "dimension": cache.soas_by_id.get(r["dim_id"], "?"),
            "collision_type": r["collision_type"],
            "status": r["status"],
            "resolution": r["resolution"],
            "priority": r["priority"],
            "created_at": r["created_at"].isoformat() if r["created_at"] else None,
            "resolved_at": r["resolved_at"].isoformat() if r["resolved_at"] else None,
        }

    return {"conflicts": [format_row(r) for r in rows]}


@app.post("/conflicts/clear")
async def clear_conflicts(request: Request) -> dict:
    """
    Delete all pending conflicts from the resolution queue and clear the
    in-memory pending set.  Resolved/error rows are left untouched.
    """
    pool = request.app.state.pool
    async with pool.acquire() as conn:
        result = await conn.execute(
            "DELETE FROM resolution_queue WHERE status = 'pending'"
        )
    # result is a string like "DELETE 17"
    deleted = int(result.split()[-1]) if result else 0
    cache.pending_conflicts.clear()
    log.info("conflicts cleared  deleted=%d", deleted)
    return {"status": "ok", "deleted": deleted}


@app.get("/kg-log")
async def kg_log(request: Request, limit: int = 100, offset: int = 0, op: str = "") -> dict:
    """Return recent knowledge graph write log entries, newest first."""
    pool = request.app.state.pool
    query = """
        SELECT id, op, concept_token, parent_token, prev_parent_token,
               dim_token, is_isa, confidence, source, resolution_queue_id, created_at
        FROM kg_write_log
        {where}
        ORDER BY created_at DESC
        LIMIT $1 OFFSET $2
    """
    count_query = "SELECT COUNT(*) FROM kg_write_log {where}"

    if op:
        async with pool.acquire() as conn:
            rows = await conn.fetch(
                query.format(where="WHERE op = $3"),
                limit, offset, op,
            )
            total = await conn.fetchval(
                "SELECT COUNT(*) FROM kg_write_log WHERE op = $1",
                op,
            )
    else:
        async with pool.acquire() as conn:
            rows = await conn.fetch(query.format(where=""), limit, offset)
            total = await conn.fetchval("SELECT COUNT(*) FROM kg_write_log")

    def fmt(r):
        return {
            "id": r["id"],
            "op": r["op"],
            "concept": r["concept_token"],
            "parent": r["parent_token"],
            "prev_parent": r["prev_parent_token"],
            "dimension": r["dim_token"],
            "is_isa": r["is_isa"],
            "confidence": round(r["confidence"], 3),
            "source": r["source"],
            "resolution_queue_id": r["resolution_queue_id"],
            "created_at": r["created_at"].isoformat() if r["created_at"] else None,
        }

    return {"total": total, "offset": offset, "limit": limit, "entries": [fmt(r) for r in rows]}


# ---------------------------------------------------------------------------
# /test — scenario seeding and reset (for integration testing)
# ---------------------------------------------------------------------------

@app.get("/test/scenarios")
async def list_scenarios() -> dict:
    """List all available test scenarios."""
    return {
        "scenarios": {
            sid: {
                "name": sc.name,
                "trigger_fact": sc.trigger_fact,
                "expected_collision": sc.expected_collision,
                "expected_resolution": sc.expected_resolution,
            }
            for sid, sc in SCENARIOS.items()
        }
    }


@app.post("/test/seed/{scenario_id}")
async def test_seed(scenario_id: str, request: Request) -> dict:
    """
    Seed the database with pre-defined world-model state for a named scenario.
    After seeding, POST the trigger fact to /iknowthat to cause the collision.

    Available scenarios: A (misclassification), B (ISA+ISA decompose), C (ISPART+ISPART)
    """
    pool = request.app.state.pool
    return await seed_scenario(pool, scenario_id)


@app.post("/test/reset/{scenario_id}")
async def test_reset(scenario_id: str, request: Request) -> dict:
    """
    Remove URD edges, SOAS saliency boosts, and resolution queue rows
    introduced by a scenario seed. Use before re-running a scenario.
    """
    pool = request.app.state.pool
    return await reset_scenario(pool, scenario_id)


# ---------------------------------------------------------------------------
# /recollection-log — browse prompts where recollection was injected
# ---------------------------------------------------------------------------

RECOLLECTION_LOG_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Festinger — Recollection Log</title>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: monospace; background: #0d0d1a; color: #ccc; padding: 20px; }
  h1 { color: #fff; font-size: 1.2em; margin-bottom: 6px; }
  .subtitle { font-size: 0.75em; color: #555; margin-bottom: 20px; }
  .toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 18px; }
  .btn { padding: 6px 14px; border: none; border-radius: 3px; cursor: pointer;
         font-family: monospace; font-size: 0.82em; }
  .btn-danger { background: #6b1a1a; color: #ffaaaa; }
  .btn-danger:hover { background: #8b2a2a; }
  .btn-secondary { background: #1e1e40; color: #aaa; }
  .btn-secondary:hover { background: #2a2a55; }
  .filter-row { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; }
  .filter-row input { background: #1a1a2e; border: 1px solid #2a2a4e; color: #ddd;
    padding: 5px 10px; border-radius: 3px; font-family: monospace; font-size: 0.82em; width: 220px; }
  .count { font-size: 0.75em; color: #555; }

  .entry { background: #12122a; border: 1px solid #1e1e40; border-radius: 5px;
           margin-bottom: 14px; overflow: hidden; }
  .entry-header { padding: 10px 14px; display: flex; gap: 16px; align-items: baseline;
                  border-bottom: 1px solid #1e1e40; cursor: pointer; }
  .entry-header:hover { background: #1a1a3a; }
  .ts { color: #555; font-size: 0.78em; white-space: nowrap; }
  .agent { color: #7070ff; font-size: 0.82em; font-weight: bold; }
  .tokens { font-size: 0.78em; color: #aaa; flex: 1; }
  .tok-tag { display: inline-block; background: #1e1e50; color: #9090ff;
             border-radius: 3px; padding: 1px 6px; margin: 1px 3px 1px 0; }
  .toggle-hint { font-size: 0.7em; color: #444; margin-left: auto; white-space: nowrap; }

  .entry-body { display: none; padding: 14px; }
  .entry-body.open { display: block; }

  .section-label { font-size: 0.68em; text-transform: uppercase; letter-spacing: 0.1em;
                   color: #555; margin-bottom: 6px; margin-top: 14px; }
  .section-label:first-child { margin-top: 0; }

  .recollection-block { background: #0a1020; border-left: 3px solid #3040aa;
    padding: 10px 12px; font-size: 0.83em; white-space: pre-wrap; color: #aabbff;
    border-radius: 0 3px 3px 0; line-height: 1.5; }

  .messages-accordion { margin-top: 8px; }
  .msg-toggle { background: #1a1a3a; border: none; color: #777; font-family: monospace;
                font-size: 0.78em; padding: 5px 10px; border-radius: 3px; cursor: pointer;
                width: 100%; text-align: left; }
  .msg-toggle:hover { background: #222250; color: #aaa; }
  .messages-list { display: none; margin-top: 8px; }
  .messages-list.open { display: block; }
  .msg { border: 1px solid #1e1e40; border-radius: 3px; margin-bottom: 6px; overflow: hidden; }
  .msg-role { padding: 4px 10px; font-size: 0.72em; font-weight: bold; text-transform: uppercase;
              letter-spacing: 0.08em; }
  .role-system  { background: #1a2040; color: #6688ff; }
  .role-user    { background: #1a2a1a; color: #66bb66; }
  .role-assistant { background: #2a1a1a; color: #cc8844; }
  .role-other   { background: #1e1e2e; color: #888; }
  .msg-content  { padding: 8px 10px; font-size: 0.8em; white-space: pre-wrap;
                  line-height: 1.5; max-height: 300px; overflow-y: auto; color: #ccc; }

  .empty { color: #444; font-size: 0.85em; padding: 30px 0; text-align: center; }
  .pagination { display: flex; gap: 8px; align-items: center; margin-top: 20px; }
  .pg-btn { padding: 5px 12px; background: #1e1e40; border: none; color: #aaa;
            border-radius: 3px; cursor: pointer; font-family: monospace; font-size: 0.8em; }
  .pg-btn:hover { background: #2a2a55; }
  .pg-btn:disabled { opacity: 0.35; cursor: default; }
  .pg-info { font-size: 0.78em; color: #555; }
  a.back { color: #5555aa; text-decoration: none; font-size: 0.82em; display: inline-block; margin-bottom: 16px; }
  a.back:hover { color: #8888ff; }
</style>
</head>
<body>
<a href="/admin" class="back">← admin</a>
<h1>Recollection Log</h1>
<p class="subtitle">Every prompt where Festinger injected a &lt;recollection&gt; block.</p>

<div class="toolbar">
  <button class="btn btn-danger" onclick="clearLog()">Clear log</button>
  <button class="btn btn-secondary" onclick="load()">Refresh</button>
  <span class="count" id="count-label"></span>
</div>

<div class="filter-row">
  <input id="agent-filter" type="text" placeholder="Filter by agent…" oninput="load()" />
</div>

<div id="entries"></div>
<div class="pagination">
  <button class="pg-btn" id="btn-prev" onclick="prevPage()" disabled>← prev</button>
  <span class="pg-info" id="pg-info"></span>
  <button class="pg-btn" id="btn-next" onclick="nextPage()">next →</button>
</div>

<script>
let currentPage = 0;
const PAGE = 20;

async function load() {
  const agent = document.getElementById('agent-filter').value.trim();
  const qs = new URLSearchParams({ limit: PAGE, offset: currentPage * PAGE });
  if (agent) qs.set('agent', agent);
  const res = await fetch('/recollection-log/data?' + qs);
  const data = await res.json();
  renderEntries(data.entries);
  const total = data.total;
  document.getElementById('count-label').textContent = total + ' event' + (total === 1 ? '' : 's');
  document.getElementById('pg-info').textContent =
    'page ' + (currentPage + 1) + ' of ' + Math.max(1, Math.ceil(total / PAGE));
  document.getElementById('btn-prev').disabled = currentPage === 0;
  document.getElementById('btn-next').disabled = (currentPage + 1) * PAGE >= total;
}

function renderEntries(entries) {
  const el = document.getElementById('entries');
  if (!entries.length) {
    el.innerHTML = '<div class="empty">No recollection events yet.</div>';
    return;
  }
  el.innerHTML = entries.map((e, i) => {
    const tokHtml = (e.salient_tokens || []).map(t =>
      '<span class="tok-tag">' + esc(t) + '</span>'
    ).join('');
    const msgsHtml = (e.messages || []).map(m => {
      const role = (m.role || 'unknown').toLowerCase();
      const cls = ['system','user','assistant'].includes(role) ? 'role-' + role : 'role-other';
      const content = typeof m.content === 'string' ? m.content
        : JSON.stringify(m.content, null, 2);
      return '<div class="msg"><div class="msg-role ' + cls + '">' + esc(role) + '</div>'
           + '<div class="msg-content">' + esc(content) + '</div></div>';
    }).join('');
    return '<div class="entry">'
      + '<div class="entry-header" onclick="toggle(' + i + ')">'
      + '  <span class="ts">' + esc(e.created_at) + '</span>'
      + '  <span class="agent">' + esc(e.agent_name || '(none)') + '</span>'
      + '  <span class="tokens">' + tokHtml + '</span>'
      + '  <span class="toggle-hint">click to expand</span>'
      + '</div>'
      + '<div class="entry-body" id="entry-body-' + i + '">'
      + '  <div class="section-label">Recollection block</div>'
      + '  <pre class="recollection-block">' + esc(e.recollection_block) + '</pre>'
      + '  <div class="section-label" style="margin-top:14px">Full prompt (' + (e.messages || []).length + ' messages)</div>'
      + '  <div class="messages-accordion">'
      + '    <button class="msg-toggle" onclick="toggleMsgs(event,' + i + ')">Show messages ▾</button>'
      + '    <div class="messages-list" id="msgs-' + i + '">' + msgsHtml + '</div>'
      + '  </div>'
      + '</div>'
      + '</div>';
  }).join('');
}

function toggle(i) {
  const el = document.getElementById('entry-body-' + i);
  el.classList.toggle('open');
}

function toggleMsgs(evt, i) {
  evt.stopPropagation();
  const el = document.getElementById('msgs-' + i);
  el.classList.toggle('open');
  evt.target.textContent = el.classList.contains('open') ? 'Hide messages ▴' : 'Show messages ▾';
}

function esc(s) {
  return String(s)
    .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
    .replace(/"/g,'&quot;');
}

function prevPage() { currentPage = Math.max(0, currentPage - 1); load(); }
function nextPage() { currentPage++; load(); }

async function clearLog() {
  if (!confirm('Delete all recollection log entries?')) return;
  await fetch('/recollection-log', { method: 'DELETE' });
  currentPage = 0;
  load();
}

load();
</script>
</body>
</html>
"""


@app.get("/recollection-log", response_class=HTMLResponse)
async def recollection_log_ui() -> HTMLResponse:
    return HTMLResponse(RECOLLECTION_LOG_HTML)


@app.get("/recollection-log/data")
async def recollection_log_data(
    request: Request,
    limit: int = 20,
    offset: int = 0,
    agent: str = "",
) -> dict:
    pool = request.app.state.pool
    async with pool.acquire() as conn:
        if agent:
            rows = await conn.fetch(
                """
                SELECT id, created_at, agent_name, salient_tokens, recollection_block, messages_json
                FROM recollection_log
                WHERE agent_name ILIKE $1
                ORDER BY created_at DESC
                LIMIT $2 OFFSET $3
                """,
                f"%{agent}%", limit, offset,
            )
            total = await conn.fetchval(
                "SELECT COUNT(*) FROM recollection_log WHERE agent_name ILIKE $1",
                f"%{agent}%",
            )
        else:
            rows = await conn.fetch(
                """
                SELECT id, created_at, agent_name, salient_tokens, recollection_block, messages_json
                FROM recollection_log
                ORDER BY created_at DESC
                LIMIT $1 OFFSET $2
                """,
                limit, offset,
            )
            total = await conn.fetchval("SELECT COUNT(*) FROM recollection_log")

    entries = []
    for r in rows:
        entries.append({
            "id": r["id"],
            "created_at": r["created_at"].strftime("%Y-%m-%d %H:%M:%S UTC"),
            "agent_name": r["agent_name"],
            "salient_tokens": list(r["salient_tokens"]),
            "recollection_block": r["recollection_block"],
            "messages": r["messages_json"],
        })

    return {"total": total, "offset": offset, "limit": limit, "entries": entries}


@app.delete("/recollection-log")
async def clear_recollection_log(request: Request) -> dict:
    pool = request.app.state.pool
    async with pool.acquire() as conn:
        result = await conn.execute("DELETE FROM recollection_log")
    deleted = int(result.split()[-1]) if result else 0
    log.info("recollection_log cleared  deleted=%d", deleted)
    return {"status": "ok", "deleted": deleted}


# ---------------------------------------------------------------------------
# /graph — knowledge graph explorer
# ---------------------------------------------------------------------------

@app.get("/graph/data")
async def graph_data(
    request: Request,
    dim: str = "",
    min_saliency: float = 1.0,
    limit: int = 400,
    center: str = "",
) -> dict:
    """
    Return nodes and edges for the knowledge graph explorer.
    - dim: filter to a single dimension (empty = all)
    - min_saliency: only include concepts above this threshold
    - limit: max edges to return
    - center: if set, return the neighbourhood of this concept token
    """
    pool = request.app.state.pool
    base_query = """
        SELECT u.id AS concept_id, u.parent_id, u.dim_id,
               u.is_isa, u.confidence,
               sc.token AS concept_token,
               COALESCE(sc.saliency, 0.0) AS concept_saliency,
               sp.token AS parent_token,
               COALESCE(sp.saliency, 0.0) AS parent_saliency,
               sd.token AS dim_token
        FROM urd u
        JOIN soas sc ON sc.id = u.id
        JOIN soas sp ON sp.id = u.parent_id
        JOIN soas sd ON sd.id = u.dim_id
        WHERE u.id != u.parent_id
    """

    async with pool.acquire() as conn:
        if center:
            row = await conn.fetchrow("SELECT id FROM soas WHERE token = $1", center)
            if not row:
                return {"nodes": [], "edges": [], "dim_list": [], "total_nodes": 0, "total_edges": 0}
            center_id = row["id"]
            rows = await conn.fetch(
                base_query + " AND (u.id = $1 OR u.parent_id = $1) AND ($2 = '' OR sd.token = $2) LIMIT $3",
                center_id, dim, limit,
            )
        else:
            rows = await conn.fetch(
                base_query + " AND ($1 = '' OR sd.token = $1) AND sc.saliency >= $2 ORDER BY sc.saliency DESC LIMIT $3",
                dim, min_saliency, limit,
            )

    nodes_map: dict = {}
    edges = []

    for r in rows:
        cid, pid, dtok = r["concept_id"], r["parent_id"], r["dim_token"]

        if cid not in nodes_map:
            nodes_map[cid] = {"id": cid, "token": r["concept_token"],
                               "saliency": round(float(r["concept_saliency"]), 3), "dc": {}}
        if pid not in nodes_map:
            nodes_map[pid] = {"id": pid, "token": r["parent_token"],
                               "saliency": round(float(r["parent_saliency"]), 3), "dc": {}}

        nodes_map[cid]["dc"][dtok] = nodes_map[cid]["dc"].get(dtok, 0) + 1

        edges.append({"source": cid, "target": pid, "dim": dtok,
                       "is_isa": r["is_isa"], "confidence": round(float(r["confidence"]), 3)})

    nodes = []
    for n in nodes_map.values():
        primary = max(n["dc"], key=n["dc"].get) if n["dc"] else "other"
        nodes.append({"id": n["id"], "token": n["token"], "saliency": n["saliency"],
                       "primary_dim": primary, "dims": list(n["dc"].keys())})

    dim_list = sorted({e["dim"] for e in edges})
    return {"nodes": nodes, "edges": edges, "dim_list": dim_list,
            "total_nodes": len(nodes), "total_edges": len(edges)}


GRAPH_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Festinger — Knowledge Graph</title>
<style>
  * {{ box-sizing: border-box; margin: 0; padding: 0; }}
  body {{ font-family: monospace; display: flex; height: 100vh; overflow: hidden; background: #f0f0f0; color: #222; }}

  /* ── Sidebar ── */
  #sidebar {{
    width: 270px; flex-shrink: 0; background: #1a1a2e; color: #ccc;
    display: flex; flex-direction: column; overflow-y: auto;
  }}
  .sb-section {{ padding: 14px 16px; border-bottom: 1px solid #2a2a4e; }}
  .sb-section:last-child {{ border-bottom: none; flex: 1; }}
  #sb-title {{ font-size: 1.05em; color: #fff; font-weight: bold; }}
  #sb-subtitle {{ font-size: 0.72em; color: #555; margin-top: 2px; }}
  .back-link {{ font-size: 0.72em; color: #5555aa; text-decoration: none; }}
  .back-link:hover {{ color: #8888ff; }}
  .sec-label {{
    font-size: 0.68em; text-transform: uppercase; letter-spacing: 0.1em;
    color: #555; margin-bottom: 8px; font-weight: bold;
  }}
  .stats-row {{ display: flex; gap: 8px; }}
  .stat-box {{ flex: 1; background: #0d0d20; border-radius: 4px; padding: 7px 10px; }}
  .stat-val {{ font-size: 1.25em; font-weight: bold; color: #7070ff; }}
  .stat-lbl {{ font-size: 0.68em; color: #555; text-transform: uppercase; margin-top: 1px; }}
  input[type="text"] {{
    width: 100%; padding: 6px 8px; background: #0d0d20; border: 1px solid #2a2a4e;
    color: #ddd; border-radius: 3px; font-family: monospace; font-size: 0.83em;
  }}
  input[type="text"]:focus {{ outline: 1px solid #7070ff; border-color: #7070ff; }}
  input[type="range"] {{ width: 100%; accent-color: #7070ff; cursor: pointer; margin-top: 4px; }}
  .range-vals {{ display: flex; justify-content: space-between; font-size: 0.7em; color: #555; }}
  label.field-label {{ font-size: 0.8em; color: #999; display: flex; justify-content: space-between; }}
  label.field-label span {{ color: #aaa; }}
  .btn-row {{ display: flex; gap: 6px; margin-top: 8px; }}
  .btn {{ flex: 1; padding: 7px 10px; border: none; border-radius: 3px; cursor: pointer; font-family: monospace; font-size: 0.83em; }}
  .btn-primary {{ background: #4040cc; color: #fff; }}
  .btn-primary:hover {{ background: #5555dd; }}
  .btn-secondary {{ background: #1e1e40; color: #aaa; }}
  .btn-secondary:hover {{ background: #2a2a55; }}
  .btn-load {{ width: 100%; padding: 9px; background: #4040cc; color: #fff; border: none; border-radius: 3px; cursor: pointer; font-family: monospace; font-size: 0.87em; }}
  .btn-load:hover {{ background: #5555dd; }}
  .dim-item {{ display: flex; align-items: center; gap: 7px; padding: 3px 0; cursor: pointer; font-size: 0.82em; }}
  .dim-dot {{ width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }}
  .dim-item input {{ cursor: pointer; accent-color: #7070ff; }}

  /* Selected node */
  #node-info {{ display: none; }}
  .ni-name {{ font-size: 1.05em; color: #fff; word-break: break-all; margin-bottom: 4px; }}
  .ni-meta {{ font-size: 0.75em; color: #666; margin-bottom: 6px; }}
  .nbr-label {{ font-size: 0.68em; color: #555; text-transform: uppercase; letter-spacing: 0.07em; margin: 8px 0 4px; }}
  #nbr-list {{ list-style: none; max-height: 200px; overflow-y: auto; }}
  #nbr-list li {{
    padding: 3px 0; font-size: 0.78em; cursor: pointer;
    border-bottom: 1px solid #1a1a3e; line-height: 1.4;
  }}
  #nbr-list li:hover {{ color: #aaaaff; }}
  #nbr-list li .edge-dim {{ font-size: 0.85em; opacity: 0.7; }}
  #no-sel {{ font-size: 0.8em; color: #444; }}

  /* Graph area */
  #graph-area {{ flex: 1; position: relative; overflow: hidden; background: #fafafa; }}
  #graph-svg {{ width: 100%; height: 100%; }}

  /* Loading */
  #loading {{
    position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
    background: rgba(250,250,250,0.88); font-size: 1em; color: #888; z-index: 10;
  }}
  #loading.hidden {{ display: none; }}
  .spinner {{ display: inline-block; width: 18px; height: 18px; border: 2px solid #ccc;
    border-top-color: #7070ff; border-radius: 50%; animation: spin 0.7s linear infinite; margin-right: 8px; }}
  @keyframes spin {{ to {{ transform: rotate(360deg); }} }}
  #empty-state {{
    position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
    flex-direction: column; gap: 10px; color: #999;
  }}
  #empty-state.visible {{ display: flex; }}
  #empty-state p {{ font-size: 0.85em; }}

  /* Zoom */
  #zoom-ctrls {{ position: absolute; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 4px; }}
  #zoom-ctrls button {{
    width: 34px; height: 34px; background: #fff; border: 1px solid #ddd; border-radius: 4px;
    cursor: pointer; font-size: 1.1em; line-height: 1; box-shadow: 0 1px 3px rgba(0,0,0,0.1);
  }}
  #zoom-ctrls button:hover {{ background: #f0f0f0; }}

  /* Tooltip */
  #tip {{
    position: absolute; pointer-events: none; background: rgba(15,15,35,0.95);
    color: #eee; padding: 8px 12px; border-radius: 5px; font-size: 0.78em;
    display: none; z-index: 20; max-width: 240px; line-height: 1.6;
  }}
  #tip strong {{ color: #fff; font-size: 1.1em; }}
  #tip .tip-edge {{ color: #aaa; font-size: 0.9em; }}
  #tip .tip-dim {{ font-weight: bold; }}

  /* Legend */
  #legend {{
    position: absolute; top: 12px; left: 12px; display: flex; gap: 8px;
    flex-wrap: wrap; z-index: 5; pointer-events: none;
  }}
  .leg-item {{
    display: flex; align-items: center; gap: 5px; background: rgba(255,255,255,0.88);
    padding: 3px 10px; border-radius: 12px; font-size: 0.72em; border: 1px solid #ddd;
    cursor: pointer; pointer-events: all;
  }}
  .leg-item:hover {{ background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.12); }}
  .leg-dot {{ width: 10px; height: 10px; border-radius: 50%; }}
  .leg-item.dimmed {{ opacity: 0.35; }}

  /* View mode toggle */
  #view-toggle {{ display: flex; gap: 4px; margin-bottom: 8px; }}
  #view-toggle button {{
    flex: 1; padding: 5px 8px; border: 1px solid #2a2a4e; border-radius: 3px;
    background: #0d0d20; color: #666; cursor: pointer; font-family: monospace; font-size: 0.78em;
  }}
  #view-toggle button.active {{ background: #4040cc; color: #fff; border-color: #4040cc; }}
</style>
</head>
<body>

<div id="sidebar">
  <div class="sb-section">
    <div id="sb-title">Knowledge Graph</div>
    <div id="sb-subtitle">Festinger URD explorer</div>
    <div style="margin-top:6px"><a href="/admin" class="back-link">← admin</a></div>
  </div>

  <div class="sb-section">
    <div class="stats-row">
      <div class="stat-box"><div class="stat-val" id="s-nodes">—</div><div class="stat-lbl">Nodes</div></div>
      <div class="stat-box"><div class="stat-val" id="s-edges">—</div><div class="stat-lbl">Edges</div></div>
      <div class="stat-box"><div class="stat-val" id="s-dims">—</div><div class="stat-lbl">Dims</div></div>
    </div>
  </div>

  <div class="sb-section">
    <div class="sec-label">Search / focus</div>
    <input type="text" id="search-input" placeholder="concept name…" />
    <div class="btn-row">
      <button class="btn btn-primary" onclick="searchFocus()">Focus</button>
      <button class="btn btn-secondary" onclick="clearSearch()">Clear</button>
    </div>
  </div>

  <div class="sb-section">
    <div class="sec-label">Filters</div>
    <label class="field-label">Min saliency <span id="sal-val">0.0</span></label>
    <input type="range" id="sal-slider" min="0" max="6" step="0.5" value="0"
           oninput="document.getElementById('sal-val').textContent=parseFloat(this.value).toFixed(1)">
    <div class="range-vals"><span>0</span><span>6</span></div>
    <div style="margin-top:10px"></div>
    <label class="field-label">Max nodes <span id="limit-val">300</span></label>
    <input type="range" id="limit-slider" min="50" max="600" step="50" value="300"
           oninput="document.getElementById('limit-val').textContent=this.value">
    <div class="range-vals"><span>50</span><span>600</span></div>
    <div style="margin-top:10px"></div>
    <label class="field-label" style="align-items:center">
      <span>Show bubbles</span>
      <input type="checkbox" id="show-hulls" checked style="cursor:pointer;accent-color:#7070ff"
             onchange="renderWithFilters()">
    </label>
    <label class="field-label" style="align-items:center;margin-top:6px">
      <span>Edge labels</span>
      <input type="checkbox" id="show-edgelabels" checked style="cursor:pointer;accent-color:#7070ff"
             onchange="renderWithFilters()">
    </label>
  </div>

  <div class="sb-section">
    <div class="sec-label">Dimensions</div>
    <div id="dim-list"></div>
  </div>

  <div class="sb-section">
    <button class="btn-load" onclick="loadGraph()">Reload graph</button>
  </div>

  <div class="sb-section" style="flex:1">
    <div class="sec-label">Selected node</div>
    <div id="no-sel" style="font-size:0.8em;color:#444">Click any node to inspect it.</div>
    <div id="node-info">
      <div class="ni-name" id="ni-name"></div>
      <div class="ni-meta">saliency: <span id="ni-sal"></span></div>
      <div class="nbr-label">Facts (outgoing)</div>
      <ul id="nbr-list"></ul>
    </div>
  </div>
</div>

<div id="graph-area">
  <svg id="graph-svg"></svg>
  <div id="legend"></div>
  <div id="loading"><span class="spinner"></span>Loading graph…</div>
  <div id="empty-state">
    <p>No nodes match the current filters.</p>
    <p>Try lowering the saliency threshold or adding concepts via <a href="/admin" style="color:#7070ff">admin</a>.</p>
  </div>
  <div id="tip"></div>
  <div id="zoom-ctrls">
    <button onclick="zoomBy(1.4)" title="Zoom in">+</button>
    <button onclick="resetZoom()" title="Fit">&#8857;</button>
    <button onclick="zoomBy(0.7)" title="Zoom out">&#8722;</button>
  </div>
</div>

<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
// ── Palette ──────────────────────────────────────────────────────────────────
const PALETTE = [
  '#4e79a7','#f28e2b','#59a14f','#e15759',
  '#b07aa1','#76b7b2','#edc948','#ff9da7',
  '#9c755f','#d4a0c7','#8cd17d','#499894',
];
const dimColors = {{}};
['type','membership','runs-on','tech','owned-by','geography']
  .forEach((d, i) => {{ dimColors[d] = PALETTE[i]; }});

function dimColor(dim) {{
  if (!dimColors[dim]) {{
    const idx = Object.keys(dimColors).length % PALETTE.length;
    dimColors[dim] = PALETTE[idx];
  }}
  return dimColors[dim];
}}

function hexWithAlpha(hex, a) {{
  // Convert #rrggbb to rgba(r,g,b,a)
  const r = parseInt(hex.slice(1,3),16);
  const g = parseInt(hex.slice(3,5),16);
  const b = parseInt(hex.slice(5,7),16);
  return `rgba(${{r}},${{g}},${{b}},${{a}})`;
}}

// ── SVG setup ─────────────────────────────────────────────────────────────────
const svgEl    = document.getElementById('graph-svg');
const graphArea = document.getElementById('graph-area');
const svg      = d3.select(svgEl);
const root     = svg.append('g');
const defs     = svg.append('defs');

// Layer order: hulls → links → edge-labels → nodes → dim-labels (top)
const hullLayer     = root.append('g').attr('class', 'hulls');
const linkLayer     = root.append('g').attr('class', 'links');
const edgeLblLayer  = root.append('g').attr('class', 'edge-labels');
const nodeLayer     = root.append('g').attr('class', 'nodes');
const dimLblLayer   = root.append('g').attr('class', 'dim-labels'); // always on top

const zoom = d3.zoom().scaleExtent([0.04, 14]).on('zoom', e => root.attr('transform', e.transform));
svg.call(zoom).on('dblclick.zoom', null);

let W = graphArea.clientWidth, H = graphArea.clientHeight;
window.addEventListener('resize', () => {{
  W = graphArea.clientWidth; H = graphArea.clientHeight;
  if (sim) sim.force('center', d3.forceCenter(W/2, H/2)).alpha(0.15).restart();
}});

// ── State ─────────────────────────────────────────────────────────────────────
let graphData = null;
let sim = null;

function nodeR(d) {{ return 5 + Math.min(Math.log1p(d.saliency) * 5, 16); }}

// ── Arrow markers + metaball filter ──────────────────────────────────────────
// BLOB_R: radius of the circle drawn at each node for the metaball effect.
//   Larger = blobs reach further and merge sooner.
// BLOB_BLUR: Gaussian blur sigma. Controls how far each blob "spreads".
// BLOB_MULT / BLOB_CUT: alpha-threshold matrix. new_a = MULT*old_a - CUT.
//   Threshold fires where old_a > CUT/MULT, i.e. within ≈ blur distance.
const BLOB_R    = 44;
const BLOB_BLUR = 16;
const BLOB_MULT = 28;
const BLOB_CUT  = 11;
const BLOB_OPACITY = 0.20;  // overall transparency of the blob layer

function buildMarkers(dims) {{
  defs.selectAll('marker').remove();
  // Single shared metaball filter — applied per-dim group so colors stay separate.
  defs.selectAll('#metaball-filter').remove();
  const f = defs.append('filter')
    .attr('id', 'metaball-filter')
    .attr('x', '-60%').attr('y', '-60%')
    .attr('width', '220%').attr('height', '220%')
    .attr('color-interpolation-filters', 'sRGB');
  f.append('feGaussianBlur')
    .attr('in', 'SourceGraphic').attr('stdDeviation', BLOB_BLUR).attr('result', 'blur');
  f.append('feColorMatrix')
    .attr('in', 'blur').attr('type', 'matrix')
    .attr('values', `1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 ${{BLOB_MULT}} -${{BLOB_CUT}}`);

  dims.forEach(dim => {{
    const safe = dim.replace(/[^a-zA-Z0-9]/g, '_');
    defs.append('marker')
      .attr('id', 'arr_' + safe)
      .attr('viewBox', '0 -5 10 10').attr('refX', 20).attr('refY', 0)
      .attr('markerWidth', 6).attr('markerHeight', 6).attr('orient', 'auto')
      .append('path').attr('d', 'M0,-5L10,0L0,5').attr('fill', dimColor(dim));
  }});
}}

// ── Dim grouping ──────────────────────────────────────────────────────────────
// Group nodes by dimension (concept + parent both included).
function computeDimGroups(nd, ed) {{
  const byId = {{}};
  nd.forEach(n => byId[n.id] = n);
  const groups = {{}};
  for (const e of ed) {{
    const src = typeof e.source === 'object' ? e.source : byId[e.source];
    const tgt = typeof e.target === 'object' ? e.target : byId[e.target];
    if (!src || !tgt) continue;
    if (!groups[e.dim]) groups[e.dim] = new Set();
    groups[e.dim].add(src);
    groups[e.dim].add(tgt);
  }}
  return Object.entries(groups).map(([dim, nodeSet]) => ({{ dim, nodes: [...nodeSet] }}));
}}

// Centroid of a node set
function centroid(nodes) {{
  return [d3.mean(nodes, n => n.x), d3.mean(nodes, n => n.y)];
}}

// ── Data load ─────────────────────────────────────────────────────────────────
async function loadGraph() {{
  setLoading(true);
  clearSelection();

  const minSal = document.getElementById('sal-slider').value;
  const limit  = document.getElementById('limit-slider').value;
  const center = document.getElementById('search-input').value.trim();
  const params = new URLSearchParams({{ min_saliency: minSal, limit }});
  if (center) params.set('center', center);

  try {{
    const resp = await fetch('/graph/data?' + params);
    graphData = await resp.json();
    buildDimCheckboxes(graphData.dim_list);
    buildLegend(graphData.dim_list);
    renderWithFilters();
  }} catch(err) {{
    console.error(err);
  }} finally {{
    setLoading(false);
  }}
}}

// ── Dimension checkboxes ──────────────────────────────────────────────────────
function buildDimCheckboxes(dims) {{
  const el = document.getElementById('dim-list');
  const existing = new Set([...el.querySelectorAll('.dchk')].map(c => c.value));
  dims.forEach(dim => {{
    if (existing.has(dim)) return;
    const label = document.createElement('label');
    label.className = 'dim-item';
    label.innerHTML =
      `<input type="checkbox" class="dchk" value="${{dim}}" checked>` +
      `<span class="dim-dot" style="background:${{dimColor(dim)}}"></span>` +
      `<span>${{dim}}</span>`;
    label.querySelector('input').addEventListener('change', renderWithFilters);
    el.appendChild(label);
  }});
}}

function enabledDims() {{
  return new Set([...document.querySelectorAll('.dchk:checked')].map(c => c.value));
}}

// ── Legend ────────────────────────────────────────────────────────────────────
function buildLegend(dims) {{
  const el = document.getElementById('legend');
  el.innerHTML = dims.map(dim => {{
    const safe = dim.replace(/[^a-zA-Z0-9]/g, '_');
    return `<span class="leg-item" id="leg_${{safe}}" onclick="toggleDim('${{dim}}')">
      <span class="leg-dot" style="background:${{dimColor(dim)}}"></span>${{dim}}
    </span>`;
  }}).join('');
}}

function toggleDim(dim) {{
  const cb = document.querySelector(`.dchk[value="${{dim}}"]`);
  if (cb) {{ cb.checked = !cb.checked; renderWithFilters(); }}
  const safe = dim.replace(/[^a-zA-Z0-9]/g, '_');
  const leg = document.getElementById('leg_' + safe);
  if (leg) leg.classList.toggle('dimmed', !cb.checked);
}}

// ── Filter + render ───────────────────────────────────────────────────────────
function renderWithFilters() {{
  if (!graphData) return;
  const allowed = enabledDims();
  const edges = graphData.edges.filter(e => allowed.has(e.dim));
  const usedIds = new Set(edges.flatMap(e => [e.source, e.target]));
  const nodes = graphData.nodes.filter(n => usedIds.has(n.id));

  document.getElementById('s-nodes').textContent = nodes.length.toLocaleString();
  document.getElementById('s-edges').textContent = edges.length.toLocaleString();
  document.getElementById('s-dims').textContent = allowed.size;

  const empty = document.getElementById('empty-state');
  if (nodes.length === 0) {{ empty.classList.add('visible'); renderGraph([], []); return; }}
  empty.classList.remove('visible');
  renderGraph(nodes, edges);
}}

// ── D3 render ─────────────────────────────────────────────────────────────────
function renderGraph(nodes, edges) {{
  W = graphArea.clientWidth; H = graphArea.clientHeight;
  buildMarkers(graphData ? graphData.dim_list : []);
  if (sim) sim.stop();

  const nd = nodes.map(n => ({{ ...n }}));
  const ed = edges.map(e => ({{ ...e }}));
  const showHulls = document.getElementById('show-hulls').checked;
  const showEdgeLabels = document.getElementById('show-edgelabels').checked;

  // ── Metaball blobs ────────────────────────────────────────────────────────
  // One filtered <g> per dimension. Each contains a circle per node.
  // The blur+threshold SVG filter merges nearby circles into smooth organic
  // blobs. Nodes in multiple dimensions sit inside overlapping blobs — the
  // color mixing shows their cross-dimension membership visually.
  // Labels are rendered in a separate unfiltered layer so they stay crisp.
  function updateHulls() {{
    if (!showHulls) {{ hullLayer.selectAll('*').remove(); return; }}
    const groups = computeDimGroups(nd, ed);

    // One <g class="dim-blob"> per dimension, with the metaball filter applied.
    const blobGroups = hullLayer.selectAll('g.dim-blob')
      .data(groups, g => g.dim)
      .join('g').attr('class', 'dim-blob')
      .attr('filter', 'url(#metaball-filter)')
      .attr('opacity', BLOB_OPACITY);

    // Inside each dim group: one circle per node.
    blobGroups.each(function(g) {{
      d3.select(this).selectAll('circle.bn')
        .data(g.nodes, n => n.id)
        .join('circle').attr('class', 'bn')
        .attr('cx', n => n.x).attr('cy', n => n.y)
        .attr('r', BLOB_R)
        .attr('fill', dimColor(g.dim));
    }});

    // Dimension labels — in dimLblLayer (above nodes) so they're always crisp.
    dimLblLayer.selectAll('text.blob-label')
      .data(groups, g => g.dim)
      .join('text').attr('class', 'blob-label')
      .attr('x', g => centroid(g.nodes)[0])
      .attr('y', g => Math.min(...g.nodes.map(n => n.y)) - BLOB_R * 0.6)
      .attr('text-anchor', 'middle')
      .attr('fill', g => dimColor(g.dim))
      .attr('font-size', '12px')
      .attr('font-family', 'monospace')
      .attr('font-weight', 'bold')
      .attr('opacity', 0.9)
      .style('pointer-events', 'none')
      // White halo so label is readable over any blob color
      .style('paint-order', 'stroke fill')
      .style('stroke', 'rgba(255,255,255,0.85)')
      .style('stroke-width', '3px')
      .style('stroke-linejoin', 'round');
  }}

  // ── Links ──
  const linkKey = e => `${{typeof e.source==='object'?e.source.id:e.source}}:${{typeof e.target==='object'?e.target.id:e.target}}:${{e.dim}}`;
  const link = linkLayer.selectAll('line.lnk').data(ed, linkKey);
  link.exit().remove();
  const linkMerge = link.enter().append('line').attr('class','lnk').merge(link)
    .attr('stroke', e => dimColor(e.dim))
    .attr('stroke-opacity', 0.55)
    .attr('stroke-width', e => e.is_isa ? 2.2 : 1.5)
    .attr('marker-end', e => `url(#arr_${{e.dim.replace(/[^a-zA-Z0-9]/g,'_')}})`);

  // ── Edge labels ──
  function updateEdgeLabels() {{
    if (!showEdgeLabels) {{ edgeLblLayer.selectAll('*').remove(); return; }}
    edgeLblLayer.selectAll('text.elbl')
      .data(ed, linkKey)
      .join('text').attr('class', 'elbl')
      .attr('text-anchor', 'middle')
      .attr('font-size', '9px')
      .attr('font-family', 'monospace')
      .attr('fill', e => dimColor(e.dim))
      .attr('opacity', 0.7)
      .style('pointer-events', 'none')
      .text(e => e.dim);
  }}
  updateEdgeLabels();

  // ── Nodes ──
  const node = nodeLayer.selectAll('g.nd').data(nd, d => d.id);
  node.exit().remove();
  const nodeEnter = node.enter().append('g').attr('class', 'nd')
    .call(d3.drag()
      .on('start', (ev, d) => {{ if (!ev.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }})
      .on('drag',  (ev, d) => {{ d.fx = ev.x; d.fy = ev.y; }})
      .on('end',   (ev, d) => {{ if (!ev.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }})
    )
    .on('click', (ev, d) => {{ ev.stopPropagation(); selectNode(d, nd, ed); }})
    .on('mouseover', (ev, d) => showTip(ev, d, nd, ed))
    .on('mousemove', moveTip)
    .on('mouseout',  hideTip);

  nodeEnter.append('circle');
  nodeEnter.append('text')
    .style('pointer-events', 'none').style('user-select', 'none')
    .attr('text-anchor', 'middle').style('font-size', '10px').style('fill', '#111')
    // White halo punches through any blob color sitting beneath the text
    .style('paint-order', 'stroke fill')
    .style('stroke', 'rgba(255,255,255,0.9)')
    .style('stroke-width', '3.5px')
    .style('stroke-linejoin', 'round');

  const nodeMerge = nodeEnter.merge(node);
  nodeMerge.select('circle')
    .attr('r', nodeR)
    .attr('fill', d => dimColor(d.primary_dim))
    .attr('stroke', '#fff').attr('stroke-width', 1.5)
    .style('cursor', 'pointer');
  nodeMerge.select('text')
    .text(d => d.token)
    .attr('y', d => -(nodeR(d) + 4));

  svg.on('click', () => clearSelection());

  // ── Simulation ──
  const charge = Math.max(-700, -200 - nd.length * 1.2);
  sim = d3.forceSimulation(nd)
    .force('link',    d3.forceLink(ed).id(d => d.id).distance(90).strength(0.55))
    .force('charge',  d3.forceManyBody().strength(charge))
    .force('center',  d3.forceCenter(W/2, H/2))
    .force('collide', d3.forceCollide().radius(d => nodeR(d) + 8))
    .on('tick', () => {{
      // Move link endpoints to node surface
      linkMerge
        .attr('x1', e => e.source.x).attr('y1', e => e.source.y)
        .attr('x2', e => {{
          const dx = e.target.x - e.source.x, dy = e.target.y - e.source.y;
          const l = Math.sqrt(dx*dx+dy*dy) || 1;
          return e.target.x - dx/l*(nodeR(e.target)+2);
        }})
        .attr('y2', e => {{
          const dx = e.target.x - e.source.x, dy = e.target.y - e.source.y;
          const l = Math.sqrt(dx*dx+dy*dy) || 1;
          return e.target.y - dy/l*(nodeR(e.target)+2);
        }});

      // Edge label at midpoint
      if (showEdgeLabels) {{
        edgeLblLayer.selectAll('text.elbl')
          .attr('x', e => (e.source.x + e.target.x) / 2)
          .attr('y', e => (e.source.y + e.target.y) / 2 - 4);
      }}

      nodeMerge.attr('transform', d => `translate(${{d.x}},${{d.y}})`);
      updateHulls();
    }});
}}

// ── Selection ─────────────────────────────────────────────────────────────────
function selectNode(d, nodes, edges) {{
  document.getElementById('no-sel').style.display = 'none';
  document.getElementById('node-info').style.display = 'block';
  document.getElementById('ni-name').textContent = d.token;
  document.getElementById('ni-sal').textContent = d.saliency.toFixed(3);

  // Build neighbour list showing full triadic context
  const byId = {{}};
  nodes.forEach(n => byId[n.id] = n);

  const outEdges = edges.filter(e => {{
    const sid = typeof e.source === 'object' ? e.source.id : e.source;
    return sid === d.id;
  }});
  const inEdges = edges.filter(e => {{
    const tid = typeof e.target === 'object' ? e.target.id : e.target;
    return tid === d.id;
  }});

  let html = '';
  if (outEdges.length) {{
    html += outEdges.map(e => {{
      const tid = typeof e.target === 'object' ? e.target.id : e.target;
      const tn = byId[tid]; const tt = tn ? tn.token : '?';
      const rel = e.is_isa ? 'is-a' : 'is-part-of';
      return `<li onclick="focusNode(${{tid}})" style="color:${{dimColor(e.dim)}}">
        → ${{tt}} <span class="edge-dim">[${{e.dim}}]</span>
        <br><span style="color:#555;font-size:0.82em;padding-left:8px">(${{rel}})</span>
      </li>`;
    }}).join('');
  }}
  if (inEdges.length) {{
    html += `<li style="color:#444;cursor:default;padding-top:4px"><em>referenced by:</em></li>`;
    html += inEdges.map(e => {{
      const sid = typeof e.source === 'object' ? e.source.id : e.source;
      const sn = byId[sid]; const st = sn ? sn.token : '?';
      return `<li onclick="focusNode(${{sid}})" style="color:${{dimColor(e.dim)}}">
        ← ${{st}} <span class="edge-dim">[${{e.dim}}]</span>
      </li>`;
    }}).join('');
  }}
  if (!html) html = '<li style="color:#555;cursor:default">no connections visible</li>';
  document.getElementById('nbr-list').innerHTML = html;

  const allNeighborIds = new Set([
    ...outEdges.map(e => typeof e.target==='object' ? e.target.id : e.target),
    ...inEdges.map(e => typeof e.source==='object' ? e.source.id : e.source),
  ]);

  nodeLayer.selectAll('g.nd circle')
    .attr('opacity', n => (n.id === d.id || allNeighborIds.has(n.id)) ? 1 : 0.12);
  nodeLayer.selectAll('g.nd text')
    .attr('opacity', n => (n.id === d.id || allNeighborIds.has(n.id)) ? 1 : 0.08);
  linkLayer.selectAll('line.lnk').attr('opacity', e => {{
    const sid = typeof e.source==='object' ? e.source.id : e.source;
    const tid = typeof e.target==='object' ? e.target.id : e.target;
    return (sid===d.id || tid===d.id) ? 0.9 : 0.04;
  }});
  edgeLblLayer.selectAll('text.elbl').attr('opacity', e => {{
    const sid = typeof e.source==='object' ? e.source.id : e.source;
    const tid = typeof e.target==='object' ? e.target.id : e.target;
    return (sid===d.id || tid===d.id) ? 0.9 : 0.04;
  }});
}}

function clearSelection() {{
  document.getElementById('no-sel').style.display = 'block';
  document.getElementById('node-info').style.display = 'none';
  nodeLayer.selectAll('g.nd circle').attr('opacity', 1);
  nodeLayer.selectAll('g.nd text').attr('opacity', 1);
  linkLayer.selectAll('line.lnk').attr('opacity', 0.55);
  edgeLblLayer.selectAll('text.elbl').attr('opacity', 0.7);
}}

function focusNode(id) {{
  const n = nodeLayer.selectAll('g.nd').filter(d => d.id === id);
  if (n.empty()) return;
  const d = n.datum();
  svg.transition().duration(500).call(
    zoom.transform, d3.zoomIdentity.translate(W/2 - d.x*1.8, H/2 - d.y*1.8).scale(1.8)
  );
}}

// ── Zoom ──────────────────────────────────────────────────────────────────────
function zoomBy(f) {{ svg.transition().duration(300).call(zoom.scaleBy, f); }}
function resetZoom() {{
  svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(W/2, H/2).scale(1));
}}

// ── Tooltip ───────────────────────────────────────────────────────────────────
const tip = document.getElementById('tip');
function showTip(ev, d, nodes, edges) {{
  tip.style.display = 'block';
  const byId = {{}};
  nodes.forEach(n => byId[n.id] = n);
  // Outgoing edges for this node
  const out = edges.filter(e => {{
    const sid = typeof e.source==='object' ? e.source.id : e.source;
    return sid === d.id;
  }});
  let edgeHtml = out.map(e => {{
    const tid = typeof e.target==='object' ? e.target.id : e.target;
    const tn = byId[tid]; const tt = tn ? tn.token : '?';
    return `<div class="tip-edge">→ ${{tt}} <span class="tip-dim" style="color:${{dimColor(e.dim)}}">${{e.dim}}</span></div>`;
  }}).join('');
  if (!edgeHtml) edgeHtml = `<div class="tip-edge" style="color:#555">no outgoing edges</div>`;
  tip.innerHTML = `<strong>${{d.token}}</strong><br>saliency: ${{d.saliency}}<br>${{edgeHtml}}`;
}}
function moveTip(ev) {{ tip.style.left=(ev.pageX+14)+'px'; tip.style.top=(ev.pageY-10)+'px'; }}
function hideTip() {{ tip.style.display='none'; }}

// ── Loading ───────────────────────────────────────────────────────────────────
function setLoading(on) {{
  document.getElementById('loading').classList.toggle('hidden', !on);
}}

// ── Search ────────────────────────────────────────────────────────────────────
function searchFocus() {{ loadGraph(); }}
function clearSearch()  {{ document.getElementById('search-input').value=''; loadGraph(); }}
document.getElementById('search-input').addEventListener('keydown', e => {{
  if (e.key === 'Enter') loadGraph();
}});

loadGraph();
</script>
</body>
</html>"""


GRAPH_HTML = GRAPH_HTML.replace('{{', '{').replace('}}', '}')


@app.get("/graph", response_class=HTMLResponse)
async def graph_explorer() -> str:
    return GRAPH_HTML


# ---------------------------------------------------------------------------
# /admin — minimal HTML UI
# ---------------------------------------------------------------------------

ADMIN_HTML = """<!DOCTYPE html>
<html>
<head>
  <title>Festinger Admin</title>
  <style>
    body {{ font-family: monospace; max-width: 960px; margin: 40px auto; padding: 0 20px; color: #222; }}
    h1 {{ font-size: 1.4em; margin-bottom: 0.2em; }}
    h2 {{ font-size: 1.1em; margin-top: 2em; border-bottom: 1px solid #ddd; padding-bottom: 4px; }}
    .subtitle {{ color: #666; font-size: 0.85em; margin-bottom: 1.5em; }}
    .stats {{ display: flex; gap: 2em; flex-wrap: wrap; margin: 1em 0; }}
    .stat {{ background: #f8f8f8; border: 1px solid #e0e0e0; border-radius: 4px; padding: 12px 20px; min-width: 130px; }}
    .stat-label {{ font-size: 0.75em; color: #666; text-transform: uppercase; letter-spacing: 0.05em; }}
    .stat-value {{ font-size: 1.8em; font-weight: bold; margin-top: 2px; }}
    .actions {{ display: flex; gap: 1em; flex-wrap: wrap; margin: 1em 0; }}
    button {{ padding: 8px 18px; cursor: pointer; border: 1px solid #aaa; background: #fff; border-radius: 3px; font-family: monospace; }}
    button:hover {{ background: #f0f0f0; }}
    button.primary {{ background: #1a1a2e; color: #fff; border-color: #1a1a2e; }}
    button.primary:hover {{ background: #2a2a4e; }}
    button:disabled {{ opacity: 0.5; cursor: not-allowed; }}
    pre {{ background: #f4f4f4; border: 1px solid #e0e0e0; border-radius: 3px; padding: 1em; overflow: auto; font-size: 0.85em; max-height: 400px; }}
    .status-ok {{ color: #2a7a2a; }}
    .status-err {{ color: #b00; }}
    table {{ width: 100%; border-collapse: collapse; font-size: 0.82em; }}
    th {{ text-align: left; border-bottom: 2px solid #ddd; padding: 4px 8px; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.04em; color: #666; }}
    td {{ border-bottom: 1px solid #f0f0f0; padding: 4px 8px; vertical-align: top; }}
    tr:hover td {{ background: #fafafa; }}
    .op-insert {{ color: #2a7a2a; font-weight: bold; }}
    .op-rewrite {{ color: #c07000; font-weight: bold; }}
    .op-decompose {{ color: #5050cc; font-weight: bold; }}
    .op-reclassify {{ color: #c02060; font-weight: bold; }}
    .log-controls {{ display: flex; gap: 1em; align-items: center; margin: 0.5em 0 1em; }}
    .log-controls select, .log-controls button {{ font-family: monospace; padding: 4px 10px; }}
    .log-nav {{ margin-top: 0.5em; display: flex; gap: 1em; align-items: center; font-size: 0.85em; }}
    footer {{ margin-top: 3em; padding-top: 1em; border-top: 1px solid #ddd; font-size: 0.78em; color: #888; }}
    footer a {{ color: #888; }}
    .fact-form {{ display: flex; gap: 0.6em; flex-wrap: wrap; align-items: flex-end; margin-top: 0.6em; }}
    .fact-form input, .fact-form select {{ font-family: monospace; padding: 5px 8px; border: 1px solid #ccc; border-radius: 3px; }}
    .concept-edges {{ font-size: 0.78em; color: #555; }}
    .search-row {{ display: flex; gap: 0.6em; align-items: center; margin-bottom: 0.8em; }}
    .search-row input {{ font-family: monospace; padding: 5px 10px; border: 1px solid #ccc; border-radius: 3px; width: 220px; }}
  </style>
</head>
<body>
  <h1>Festinger</h1>
  <p class="subtitle">Ollama-compatible inference middleware — loop detection &amp; Recollections world model
    &nbsp;&mdash;&nbsp;<a href="/graph" style="color:#1a1a2e">Knowledge Graph Explorer</a>
    &nbsp;&mdash;&nbsp;<a href="/recollection-log" style="color:#1a1a2e">Recollection Log</a>
    &nbsp;&mdash;&nbsp;<a href="/models-ui" style="color:#1a1a2e">Model Manager</a>
  </p>

  <h2>World model stats</h2>
  <div class="stats" id="stats">
    <div class="stat"><div class="stat-label">SOAS tokens</div><div class="stat-value" id="s-soas">…</div></div>
    <div class="stat"><div class="stat-label">URD edges</div><div class="stat-value" id="s-urd">…</div></div>
    <div class="stat"><div class="stat-label">Pending conflicts</div><div class="stat-value" id="s-conflicts">…</div></div>
    <div class="stat"><div class="stat-label">Last resolution</div><div class="stat-value" style="font-size:0.85em" id="s-lastrun">…</div></div>
  </div>

  <h2>Resolution model</h2>
  <div id="models-section">
    <table id="models-table" style="margin-bottom:0.8em">
      <thead><tr><th>ID</th><th>Provider</th><th>Model name</th><th>resolve?</th><th>write?</th><th></th></tr></thead>
      <tbody id="models-tbody"><tr><td colspan="6">Loading…</td></tr></tbody>
    </table>
    <details style="margin-bottom:1em">
      <summary style="cursor:pointer;font-size:0.9em;color:#555">Add model…</summary>
      <div style="margin-top:0.6em;display:flex;gap:0.7em;flex-wrap:wrap;align-items:flex-end">
        <label style="font-size:0.85em">Provider
          <select id="m-provider" style="font-family:monospace;padding:4px 8px;display:block;margin-top:2px">
            <option value="claude">claude</option>
            <option value="openai">openai</option>
          </select>
        </label>
        <label style="font-size:0.85em">Model name
          <input id="m-name" type="text" value="claude-opus-4-6"
                 style="font-family:monospace;padding:5px 8px;border:1px solid #ccc;border-radius:3px;display:block;margin-top:2px;width:200px">
        </label>
        <label style="font-size:0.85em">API key
          <input id="m-key" type="password" placeholder="sk-ant-…"
                 style="font-family:monospace;padding:5px 8px;border:1px solid #ccc;border-radius:3px;display:block;margin-top:2px;width:260px">
        </label>
        <button onclick="addModel(this)" style="height:32px">Add</button>
      </div>
    </details>
  </div>

  <h2>Agent models</h2>
  <p style="font-size:0.83em;color:#666;margin-bottom:0.8em">
    Routes the main inference request to the agent's model — full cross-protocol
    (Claude ↔ LM Studio ↔ OpenAI). Agent must send <code>X-Agent-Name: GUNNAR</code>
    or <code>X-Agent-Id: 3</code> on every request. Also determines which model
    Festinger uses for memory writing (context discovery).
  </p>
  <table id="agent-models-table" style="margin-bottom:0.8em">
    <thead><tr><th>Agent name / ID</th><th>Model ID</th><th>Provider</th><th>Model name</th><th></th></tr></thead>
    <tbody id="agent-models-tbody"><tr><td colspan="5">Loading…</td></tr></tbody>
  </table>
  <details style="margin-bottom:1em">
    <summary style="cursor:pointer;font-size:0.9em;color:#555">Assign model to agent…</summary>
    <div style="margin-top:0.6em;display:flex;gap:0.7em;flex-wrap:wrap;align-items:flex-end">
      <label style="font-size:0.85em">Agent name or ID (e.g. gunnar or 3)
        <input id="am-agent" type="text" placeholder="gunnar or 3"
               style="font-family:monospace;padding:5px 8px;border:1px solid #ccc;border-radius:3px;display:block;margin-top:2px;width:160px">
      </label>
      <label style="font-size:0.85em">Model
        <select id="am-model" style="font-family:monospace;padding:5px 8px;border:1px solid #ccc;border-radius:3px;display:block;margin-top:2px">
          <option value="">— select —</option>
        </select>
      </label>
      <button onclick="assignAgentModel(this)" style="height:32px">Assign</button>
    </div>
  </details>

  <h2>Concepts</h2>
  <p style="font-size:0.83em;color:#666;margin-bottom:0.6em">
    Domain concepts with saliency above zero. Add facts manually; the agent's
    is-a / is-part-of statements are also captured automatically via cue scanning.
  </p>

  <details open style="margin-bottom:1em">
    <summary style="cursor:pointer;font-size:0.9em;color:#555;font-weight:bold">Add a fact…</summary>
    <div class="fact-form" style="margin-top:0.7em">
      <label style="font-size:0.85em">Concept
        <input id="f-concept" type="text" placeholder="gnommoweb"
               style="display:block;margin-top:2px;width:150px">
      </label>
      <label style="font-size:0.85em">Relation
        <select id="f-rel" style="display:block;margin-top:2px;padding:6px 8px">
          <option value="-isa">is-a (type)</option>
          <option value="-ispart">is-part-of (membership)</option>
        </select>
      </label>
      <label style="font-size:0.85em">Parent
        <input id="f-parent" type="text" placeholder="repository"
               style="display:block;margin-top:2px;width:150px">
      </label>
      <label style="font-size:0.85em">Dimension <span style="color:#aaa">(optional)</span>
        <input id="f-dim" type="text" placeholder="type"
               style="display:block;margin-top:2px;width:120px">
      </label>
      <button onclick="addFact(this)" style="height:32px;margin-top:0">Add fact</button>
    </div>
    <div id="fact-result" style="margin-top:0.5em;font-size:0.85em"></div>
  </details>

  <div class="search-row">
    <input id="c-search" type="text" placeholder="Search concepts…"
           oninput="debounceSearch()" onkeydown="if(event.key==='Enter')loadConcepts(0)">
    <button onclick="loadConcepts(0)">Search</button>
    <button onclick="document.getElementById('c-search').value='';loadConcepts(0)">Clear</button>
  </div>
  <div id="concepts-table-wrap"><em style="color:#aaa">Enter a search term or click Search to browse.</em></div>
  <div class="log-nav" id="concepts-nav" style="display:none">
    <button id="c-prev" onclick="conceptsPage(-1)" disabled>← prev</button>
    <span id="c-page-info"></span>
    <button id="c-next" onclick="conceptsPage(1)">next →</button>
  </div>

  <h2>Actions</h2>
  <div class="actions">
    <button class="primary" onclick="runResolution(this)">Run conflict resolution now</button>
    <button onclick="runWordnetImport(this)">Import WordNet lemmas</button>
    <button onclick="resetGraph(this)" style="color:#b00;border-color:#e0b0b0">&#9888; Reset knowledge graph</button>
  </div>
  <pre id="result" style="display:none"></pre>

  <h2>Pending conflicts</h2>
  <div class="actions" style="margin-bottom:0.5em">
    <button onclick="clearConflicts(this)">Clear pending conflicts</button>
  </div>
  <pre id="conflicts-pre">Loading…</pre>

  <h2>Knowledge graph write log</h2>
  <div class="log-controls">
    <label>Op filter:
      <select id="log-op-filter" onchange="loadLog(0)">
        <option value="">all</option>
        <option value="insert">insert</option>
        <option value="rewrite">rewrite</option>
        <option value="decompose">decompose</option>
        <option value="reclassify">reclassify</option>
      </select>
    </label>
    <button onclick="loadLog(0)">Refresh</button>
  </div>
  <div id="log-table-wrap">Loading…</div>
  <div class="log-nav" id="log-nav" style="display:none">
    <button id="log-prev" onclick="logPage(-1)" disabled>← prev</button>
    <span id="log-page-info"></span>
    <button id="log-next" onclick="logPage(1)">next →</button>
  </div>

  <footer>
    <strong>Vocabulary source:</strong>
    Princeton University &ldquo;About WordNet.&rdquo; <em>WordNet.</em> Princeton University. 2010.
    <a href="https://wordnet.princeton.edu/" target="_blank">https://wordnet.princeton.edu/</a>
    &mdash; used to pre-seed the SOAS concept vocabulary at saliency&nbsp;0 (common English baseline).
  </footer>

  <script>
    async function loadStats() {{
      const r = await fetch('/health');
      const d = await r.json();
      document.getElementById('s-soas').textContent = d.soas_tokens.toLocaleString();
      document.getElementById('s-urd').textContent = d.urd_edges.toLocaleString();
      document.getElementById('s-conflicts').textContent = d.pending_conflicts;
      document.getElementById('s-lastrun').textContent = d.last_resolution_run
        ? d.last_resolution_run.replace('T', ' ').slice(0, 19)
        : 'never';
    }}

    let _cfg = {{}};

    async function loadModels() {{
      const [mr, cr] = await Promise.all([fetch('/models'), fetch('/config')]);
      const md = await mr.json();
      _cfg = (await cr.json()).config;
      const resolveId = _cfg['resolve_model_id'] || '';
      const writeId   = _cfg['write_model_id']   || '';

      const tbody = document.getElementById('models-tbody');
      if (!md.models.length) {{
        tbody.innerHTML = '<tr><td colspan="6" style="color:#999">No models yet — add one below.</td></tr>';
        return;
      }}
      tbody.innerHTML = md.models.map(m => `
        <tr>
          <td>${{m.id}}</td>
          <td>${{m.provider}}</td>
          <td>${{m.model_name}}</td>
          <td><button onclick="setConfig('resolve_model_id','${{m.id}}')" style="padding:2px 8px;font-size:0.8em;${{resolveId==String(m.id)?'background:#2a7a2a;color:#fff;border-color:#2a7a2a':''}}">${{resolveId==String(m.id)?'✓ active':'set'}}</button></td>
          <td><button onclick="setConfig('write_model_id','${{m.id}}')" style="padding:2px 8px;font-size:0.8em;${{writeId==String(m.id)?'background:#2a7a2a;color:#fff;border-color:#2a7a2a':''}}">${{writeId==String(m.id)?'✓ active':'set'}}</button></td>
          <td><button onclick="deleteModel(${{m.id}},this)" style="padding:2px 8px;font-size:0.8em;color:#b00;border-color:#b00">✕</button></td>
        </tr>`).join('');

      // Populate model dropdown in agent-models assignment form
      const sel = document.getElementById('am-model');
      sel.innerHTML = '<option value="">— select —</option>' +
        md.models.map(m => `<option value="${{m.id}}">${{m.id}} — ${{m.provider}} / ${{m.model_name}}</option>`).join('');
    }}

    async function addModel(btn) {{
      const provider   = document.getElementById('m-provider').value;
      const model_name = document.getElementById('m-name').value.trim();
      const api_key    = document.getElementById('m-key').value.trim();
      if (!model_name || !api_key) {{ alert('Model name and API key are required.'); return; }}
      btn.disabled = true;
      try {{
        const r = await fetch('/models', {{method:'POST', headers:{{'Content-Type':'application/json'}},
          body: JSON.stringify({{provider, model_name, api_key}})}});
        const d = await r.json();
        if (d.error) {{ showResult('Error: ' + d.error, false); return; }}
        showResult('Model added (id=' + d.id + '). You can now set it as the resolve model.', true);
        document.getElementById('m-key').value = '';
        await loadModels();
      }} catch(e) {{ showResult('Error: ' + e.message, false); }}
      finally {{ btn.disabled = false; }}
    }}

    async function deleteModel(id, btn) {{
      if (!confirm('Delete model ' + id + '?')) return;
      btn.disabled = true;
      try {{
        const r = await fetch('/models/' + id, {{method:'DELETE'}});
        const d = await r.json();
        if (d.error) {{ showResult('Error: ' + d.error, false); return; }}
        await loadModels();
      }} catch(e) {{ showResult('Error: ' + e.message, false); }}
      finally {{ btn.disabled = false; }}
    }}

    async function setConfig(key, value) {{
      const r = await fetch('/config', {{method:'POST', headers:{{'Content-Type':'application/json'}},
        body: JSON.stringify({{key, value}})}});
      const d = await r.json();
      if (d.error) {{ showResult('Error: ' + d.error, false); return; }}
      showResult('Config updated: ' + key + ' = ' + value, true);
      await loadModels();
    }}

    async function loadAgentModels() {{
      const r = await fetch('/agent-models');
      const d = await r.json();
      const tbody = document.getElementById('agent-models-tbody');
      if (!d.agent_models.length) {{
        tbody.innerHTML = '<tr><td colspan="5" style="color:#999">No assignments yet.</td></tr>';
        return;
      }}
      tbody.innerHTML = d.agent_models.map(a => `
        <tr>
          <td><strong>${{a.agent_name}}</strong></td>
          <td>${{a.model_id}}</td>
          <td>${{a.provider}}</td>
          <td>${{a.model_name}}${{a.base_url ? ' <span style="color:#999;font-size:0.85em">(' + a.base_url + ')</span>' : ''}}</td>
          <td><button onclick="removeAgentModel('${{a.agent_name}}',this)" style="padding:2px 8px;font-size:0.8em;color:#b00;border-color:#b00">✕</button></td>
        </tr>`).join('');
    }}

    async function assignAgentModel(btn) {{
      const agent = document.getElementById('am-agent').value.trim().toLowerCase();
      const model_id = document.getElementById('am-model').value;
      if (!agent) {{ alert('Enter an agent name.'); return; }}
      if (!model_id) {{ alert('Select a model.'); return; }}
      btn.disabled = true;
      try {{
        const r = await fetch('/agent-models/' + encodeURIComponent(agent), {{
          method: 'PUT',
          headers: {{'Content-Type': 'application/json'}},
          body: JSON.stringify({{model_id: parseInt(model_id)}})
        }});
        const d = await r.json();
        if (d.error) {{ showResult('Error: ' + d.error, false); return; }}
        showResult('Assigned model ' + model_id + ' to agent "' + agent + '".', true);
        document.getElementById('am-agent').value = '';
        await loadAgentModels();
      }} catch(e) {{ showResult('Error: ' + e.message, false); }}
      finally {{ btn.disabled = false; }}
    }}

    async function removeAgentModel(agent, btn) {{
      if (!confirm('Remove model assignment for "' + agent + '"?')) return;
      btn.disabled = true;
      try {{
        const r = await fetch('/agent-models/' + encodeURIComponent(agent), {{method: 'DELETE'}});
        const d = await r.json();
        if (d.error) {{ showResult('Error: ' + d.error, false); return; }}
        await loadAgentModels();
      }} catch(e) {{ showResult('Error: ' + e.message, false); }}
      finally {{ btn.disabled = false; }}
    }}

    // --------------- Concepts ---------------

    const CONCEPT_PAGE_SIZE = 30;
    let conceptOffset = 0;
    let conceptTotal = 0;
    let _searchTimer = null;

    function debounceSearch() {{
      clearTimeout(_searchTimer);
      _searchTimer = setTimeout(() => loadConcepts(0), 350);
    }}

    async function loadConcepts(offset) {{
      conceptOffset = offset;
      const q = document.getElementById('c-search').value.trim();
      const params = new URLSearchParams({{limit: CONCEPT_PAGE_SIZE, offset: conceptOffset}});
      if (q) params.set('q', q);
      const r = await fetch('/concepts?' + params);
      const d = await r.json();
      conceptTotal = d.total;

      const wrap = document.getElementById('concepts-table-wrap');
      if (!d.concepts.length) {{
        wrap.innerHTML = '<em style="color:#aaa">No concepts found.</em>';
        document.getElementById('concepts-nav').style.display = 'none';
        return;
      }}

      let html = '<table><thead><tr>'
        + '<th>Concept</th><th>Saliency</th><th>Encounters</th>'
        + '<th>Edges (dim → parent)</th><th>First seen</th><th></th>'
        + '</tr></thead><tbody>';
      for (const c of d.concepts) {{
        const edgeStr = c.edges.map(e =>
          `<span style="color:#555">[${{e.dim}}]</span> ${{e.parent}}`
        ).join('&ensp;');
        const ctx = c.first_seen_context
          ? `<span title="${{c.first_seen_context.replace(/"/g,'&quot;')}}" style="color:#999;font-size:0.8em">…</span>`
          : '';
        html += `<tr>
          <td><strong>${{c.token}}</strong></td>
          <td>${{c.saliency}}</td>
          <td>${{c.encounter_count}}</td>
          <td class="concept-edges">${{edgeStr || '<span style="color:#bbb">none</span>'}}</td>
          <td>${{ctx}}</td>
          <td><button onclick="deleteConcept('${{c.token}}',this)"
              style="padding:2px 8px;font-size:0.8em;color:#b00;border-color:#b00">✕</button></td>
        </tr>`;
      }}
      html += '</tbody></table>';
      wrap.innerHTML = html;

      const nav = document.getElementById('concepts-nav');
      if (conceptTotal > CONCEPT_PAGE_SIZE) {{
        nav.style.display = 'flex';
        document.getElementById('c-prev').disabled = conceptOffset === 0;
        document.getElementById('c-next').disabled = conceptOffset + CONCEPT_PAGE_SIZE >= conceptTotal;
        document.getElementById('c-page-info').textContent =
          `${{conceptOffset + 1}}–${{Math.min(conceptOffset + CONCEPT_PAGE_SIZE, conceptTotal)}} of ${{conceptTotal}}`;
      }} else {{
        nav.style.display = 'none';
      }}
    }}

    function conceptsPage(dir) {{
      const next = conceptOffset + dir * CONCEPT_PAGE_SIZE;
      if (next < 0 || next >= conceptTotal) return;
      loadConcepts(next);
    }}

    async function addFact(btn) {{
      const concept = document.getElementById('f-concept').value.trim().toLowerCase();
      const rel     = document.getElementById('f-rel').value;
      const parent  = document.getElementById('f-parent').value.trim().toLowerCase();
      const dim     = document.getElementById('f-dim').value.trim().toLowerCase();
      if (!concept || !parent) {{ alert('Concept and parent are required.'); return; }}
      let fact = `${{concept}} ${{rel}} ${{parent}}`;
      if (dim) fact += ` in context of ${{dim}}`;
      btn.disabled = true;
      const el = document.getElementById('fact-result');
      try {{
        const r = await fetch('/iknowthat', {{
          method: 'POST',
          headers: {{'Content-Type': 'application/json'}},
          body: JSON.stringify({{fact}})
        }});
        const d = await r.json();
        if (d.error) {{
          el.style.color = '#b00'; el.textContent = 'Error: ' + d.error;
        }} else if (d.status === 'collision') {{
          el.style.color = '#c07000';
          el.textContent = `Collision (${{d.collision_type}}) — queued for resolution. Current graph unchanged.`;
        }} else {{
          el.style.color = '#2a7a2a';
          el.textContent = `Inserted: ${{d.subject}} [${{d.is_isa ? 'is-a' : 'is-part-of'}}] ${{d.parent}} (dim: ${{d.dimension}})`;
          document.getElementById('f-concept').value = '';
          document.getElementById('f-parent').value = '';
          document.getElementById('f-dim').value = '';
          await loadStats();
          loadConcepts(conceptOffset);
        }}
      }} catch(e) {{ el.style.color = '#b00'; el.textContent = 'Error: ' + e.message; }}
      finally {{ btn.disabled = false; }}
    }}

    async function deleteConcept(token, btn) {{
      if (!confirm('Delete concept "' + token + '" and all its edges?')) return;
      btn.disabled = true;
      try {{
        const r = await fetch('/concepts/' + encodeURIComponent(token), {{method: 'DELETE'}});
        const d = await r.json();
        if (d.error) {{ showResult('Error: ' + d.error, false); return; }}
        showResult('Deleted: ' + token, true);
        await loadStats();
        loadConcepts(conceptOffset);
      }} catch(e) {{ showResult('Error: ' + e.message, false); }}
      finally {{ btn.disabled = false; }}
    }}

    async function loadConflicts() {{
      const r = await fetch('/conflicts');
      const d = await r.json();
      const pending = d.conflicts.filter(c => c.status === 'pending');
      const el = document.getElementById('conflicts-pre');
      el.textContent = pending.length
        ? JSON.stringify(pending, null, 2)
        : '(none)';
    }}

    function showResult(text, ok) {{
      const el = document.getElementById('result');
      el.style.display = 'block';
      el.className = ok ? 'status-ok' : 'status-err';
      el.textContent = text;
    }}

    async function clearConflicts(btn) {{
      if (!confirm('Delete all pending conflicts from the resolution queue?')) return;
      btn.disabled = true;
      showResult('Clearing pending conflicts…', true);
      try {{
        const r = await fetch('/conflicts/clear', {{method: 'POST'}});
        const d = await r.json();
        showResult(JSON.stringify(d, null, 2), r.ok);
        await loadStats();
        await loadConflicts();
      }} catch(e) {{
        showResult('Error: ' + e.message, false);
      }} finally {{
        btn.disabled = false;
      }}
    }}

    async function runResolution(btn) {{
      btn.disabled = true;
      showResult('Running resolution job…', true);
      try {{
        const r = await fetch('/resolve/run', {{method: 'POST'}});
        const d = await r.json();
        showResult(JSON.stringify(d, null, 2), r.ok);
        await loadStats();
        await loadConflicts();
      }} catch(e) {{
        showResult('Error: ' + e.message, false);
      }} finally {{
        btn.disabled = false;
      }}
    }}

    async function runWordnetImport(btn) {{
      btn.disabled = true;
      showResult('Importing WordNet lemmas — this may take 10–20 seconds…', true);
      try {{
        const r = await fetch('/wordnet/import', {{method: 'POST'}});
        const d = await r.json();
        showResult(JSON.stringify(d, null, 2), r.ok && !d.error);
        await loadStats();
      }} catch(e) {{
        showResult('Error: ' + e.message, false);
      }} finally {{
        btn.disabled = false;
      }}
    }}

    async function resetGraph(btn) {{
      if (!confirm(
        'This will DELETE all learned URD edges, domain concepts, conflicts, and write logs.\\n\\n' +
        'The standard English dictionary seed will be kept.\\n\\n' +
        'Are you sure?'
      )) return;
      btn.disabled = true;
      showResult('Resetting knowledge graph…', true);
      try {{
        const r = await fetch('/reset', {{method: 'POST'}});
        const d = await r.json();
        showResult(JSON.stringify(d, null, 2), r.ok);
        await loadStats();
        await loadConflicts();
        await loadLog(0);
      }} catch(e) {{
        showResult('Error: ' + e.message, false);
      }} finally {{
        btn.disabled = false;
      }}
    }}

    const LOG_PAGE_SIZE = 50;
    let logOffset = 0;
    let logTotal = 0;

    async function loadLog(offset) {{
      logOffset = offset;
      const op = document.getElementById('log-op-filter').value;
      const params = new URLSearchParams({{limit: LOG_PAGE_SIZE, offset: logOffset}});
      if (op) params.set('op', op);
      const r = await fetch('/kg-log?' + params);
      const d = await r.json();
      logTotal = d.total;

      const opClass = {{insert:'op-insert', rewrite:'op-rewrite', decompose:'op-decompose', reclassify:'op-reclassify'}};

      if (!d.entries.length) {{
        document.getElementById('log-table-wrap').innerHTML = '<em>No entries yet.</em>';
        document.getElementById('log-nav').style.display = 'none';
        return;
      }}

      let html = '<table><thead><tr>'
        + '<th>#</th><th>Time</th><th>Op</th><th>Concept</th>'
        + '<th>Dimension</th><th>Parent</th><th>Prev parent</th>'
        + '<th>is-a</th><th>Conf</th><th>Source</th>'
        + '</tr></thead><tbody>';
      for (const e of d.entries) {{
        const ts = e.created_at ? e.created_at.replace('T',' ').slice(0,19) : '';
        const cls = opClass[e.op] || '';
        html += `<tr>
          <td>${{e.id}}</td>
          <td>${{ts}}</td>
          <td><span class="${{cls}}">${{e.op}}</span></td>
          <td>${{e.concept}}</td>
          <td>${{e.dimension}}</td>
          <td>${{e.parent}}</td>
          <td>${{e.prev_parent || '—'}}</td>
          <td>${{e.is_isa ? 'yes' : ''}}</td>
          <td>${{e.confidence}}</td>
          <td>${{e.source}}</td>
        </tr>`;
      }}
      html += '</tbody></table>';
      document.getElementById('log-table-wrap').innerHTML = html;

      const nav = document.getElementById('log-nav');
      nav.style.display = 'flex';
      document.getElementById('log-prev').disabled = logOffset === 0;
      document.getElementById('log-next').disabled = logOffset + LOG_PAGE_SIZE >= logTotal;
      document.getElementById('log-page-info').textContent =
        `${{logOffset + 1}}–${{Math.min(logOffset + LOG_PAGE_SIZE, logTotal)}} of ${{logTotal}}`;
    }}

    function logPage(dir) {{
      loadLog(Math.max(0, logOffset + dir * LOG_PAGE_SIZE));
    }}

    loadStats();
    loadConflicts();
    loadLog(0);
    loadModels();
    loadAgentModels();
  </script>
</body>
</html>
"""


@app.get("/admin")
async def admin() -> Response:
    return Response(
        content=ADMIN_HTML.format(),
        media_type="text/html",
        headers={"Cache-Control": "no-store"},
    )


# ---------------------------------------------------------------------------
# /models-ui — model manager page
# ---------------------------------------------------------------------------

MODELS_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Festinger — Model Manager</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: monospace; background: #f5f5f5; color: #222; min-height: 100vh; }

/* ── Top bar ── */
.topbar {
  background: #1a1a2e; color: #fff;
  padding: 0 28px; height: 52px;
  display: flex; align-items: center; gap: 20px;
}
.topbar-title { font-size: 1.1em; font-weight: bold; letter-spacing: 0.02em; }
.topbar-sub { font-size: 0.78em; color: #666; }
.topbar a { color: #7070cc; font-size: 0.8em; text-decoration: none; }
.topbar a:hover { color: #9999ff; }
.topbar-spacer { flex: 1; }

/* ── Layout ── */
.page { max-width: 900px; margin: 0 auto; padding: 28px 20px 60px; }

/* ── Cards ── */
.card {
  background: #fff; border: 1px solid #e0e0e0; border-radius: 6px;
  margin-bottom: 20px; overflow: hidden;
}
.card-header {
  background: #f8f8f8; border-bottom: 1px solid #e8e8e8;
  padding: 12px 18px; display: flex; align-items: center; gap: 10px;
}
.card-title { font-size: 0.95em; font-weight: bold; }
.card-sub { font-size: 0.75em; color: #888; margin-left: auto; }
.card-body { padding: 18px; }

/* ── Active badge ── */
.badge {
  display: inline-block; padding: 2px 9px; border-radius: 10px;
  font-size: 0.72em; font-weight: bold; letter-spacing: 0.03em;
}
.badge-resolve { background: #d4edda; color: #155724; }
.badge-write   { background: #cce5ff; color: #004085; }
.badge-both    { background: #fff3cd; color: #856404; }

/* ── Tables ── */
table { width: 100%; border-collapse: collapse; font-size: 0.84em; }
th {
  text-align: left; border-bottom: 2px solid #eee; padding: 6px 10px;
  font-size: 0.72em; text-transform: uppercase; letter-spacing: 0.06em; color: #888;
}
td { border-bottom: 1px solid #f2f2f2; padding: 7px 10px; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafafa; }

/* ── Forms ── */
.field-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 14px; }
.field { display: flex; flex-direction: column; gap: 4px; }
.field label { font-size: 0.78em; color: #666; }
input[type="text"], input[type="password"], input[type="url"], select {
  font-family: monospace; font-size: 0.85em;
  padding: 7px 10px; border: 1px solid #ccc; border-radius: 4px;
  background: #fff; color: #222;
}
input[type="text"]:focus, input[type="password"]:focus,
input[type="url"]:focus, select:focus {
  outline: none; border-color: #7070cc;
}
input[type="text"].wide { width: 280px; }
input[type="password"].wide { width: 280px; }
input[type="url"].wide { width: 300px; }

/* ── Buttons ── */
.btn {
  padding: 7px 16px; border-radius: 4px; border: 1px solid #bbb;
  cursor: pointer; font-family: monospace; font-size: 0.84em;
  background: #fff; color: #333; white-space: nowrap;
}
.btn:hover { background: #f0f0f0; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { background: #1a1a2e; color: #fff; border-color: #1a1a2e; }
.btn-primary:hover { background: #2a2a4e; }
.btn-success { background: #1e6e3e; color: #fff; border-color: #1e6e3e; }
.btn-success:hover { background: #27844b; }
.btn-sm { padding: 3px 10px; font-size: 0.78em; }
.btn-danger { color: #b00; border-color: #e0b0b0; }
.btn-danger:hover { background: #fff0f0; }
.btn-active { background: #d4edda; color: #155724; border-color: #b0d8bc; cursor: default; }

/* ── Divider ── */
.divider { height: 1px; background: #eee; margin: 16px 0; }

/* ── Discovery list ── */
#disc-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; min-height: 20px; }
.disc-item {
  background: #eef0ff; border: 1px solid #c8ccee; border-radius: 4px;
  padding: 5px 12px; font-size: 0.82em; display: flex; align-items: center; gap: 8px;
}
.disc-item button { padding: 2px 8px; font-size: 0.78em; }

/* ── Status / toast ── */
#toast {
  position: fixed; bottom: 24px; right: 24px; z-index: 100;
  background: #1a1a2e; color: #fff; padding: 10px 18px; border-radius: 6px;
  font-size: 0.85em; opacity: 0; transition: opacity 0.25s;
  pointer-events: none; max-width: 360px;
}
#toast.show { opacity: 1; }
#toast.err { background: #8b0000; }

/* ── Separator label ── */
.sep { font-size: 0.72em; color: #aaa; text-transform: uppercase; letter-spacing: 0.08em;
  margin: 18px 0 10px; }

/* ── Provider pill ── */
.pill {
  display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 0.75em;
}
.pill-claude    { background: #fce8d5; color: #6b3000; }
.pill-openai    { background: #d5f0e8; color: #00502a; }
.pill-lm-studio { background: #e8d5fc; color: #3a006b; }
</style>
</head>
<body>

<div class="topbar">
  <span class="topbar-title">Festinger</span>
  <span class="topbar-sub">Model Manager</span>
  <div class="topbar-spacer"></div>
  <a href="/admin">← admin</a>
</div>

<div class="page">

  <!-- ── Currently configured models ── -->
  <div class="card">
    <div class="card-header">
      <span class="card-title">Configured models</span>
      <span class="card-sub" id="cfg-sub">Loading…</span>
    </div>
    <div class="card-body" style="padding:0">
      <table>
        <thead><tr>
          <th>ID</th><th>Provider</th><th>Model</th><th>Endpoint</th>
          <th>Role</th><th>Actions</th>
        </tr></thead>
        <tbody id="models-tbody">
          <tr><td colspan="6" style="color:#aaa;padding:18px">Loading…</td></tr>
        </tbody>
      </table>
    </div>
  </div>

  <!-- ── LM Studio ── -->
  <div class="card">
    <div class="card-header">
      <span class="card-title">LM Studio</span>
      <span class="card-sub">OpenAI-compatible local inference</span>
    </div>
    <div class="card-body">
      <p style="font-size:0.83em;color:#666;margin-bottom:14px">
        Start LM Studio, load a model, and enable the local server
        (default port&nbsp;1234). Festinger will discover available models
        and register them for conflict resolution.
      </p>
      <div class="field-row">
        <div class="field">
          <label>LM Studio base URL</label>
          <input type="url" id="lms-url" class="wide" value="http://host.docker.internal:1234">
        </div>
        <button class="btn btn-primary" onclick="discoverModels(this)">Discover models</button>
      </div>
      <div id="disc-error" style="display:none;color:#b00;font-size:0.82em;margin-top:6px"></div>
      <div id="disc-list"></div>
    </div>
  </div>

  <!-- ── Add Claude / OpenAI ── -->
  <div class="card">
    <div class="card-header">
      <span class="card-title">Add cloud model</span>
      <span class="card-sub">Claude or OpenAI-compatible</span>
    </div>
    <div class="card-body">
      <div class="field-row">
        <div class="field">
          <label>Provider</label>
          <select id="add-provider" onchange="onProviderChange()">
            <option value="claude">Claude (Anthropic)</option>
            <option value="openai">OpenAI</option>
            <option value="lm-studio">Custom OpenAI-compatible</option>
          </select>
        </div>
        <div class="field">
          <label>Model name</label>
          <input type="text" id="add-name" class="wide" value="claude-haiku-4-5-20251001">
        </div>
        <div class="field" id="add-key-field">
          <label>API key</label>
          <input type="password" id="add-key" class="wide" placeholder="sk-ant-…">
        </div>
      </div>
      <div class="field-row" id="add-url-row" style="display:none">
        <div class="field">
          <label>Base URL</label>
          <input type="url" id="add-url" style="width:360px" placeholder="http://host.docker.internal:1234/v1">
        </div>
      </div>
      <button class="btn btn-primary" onclick="addModel(this)">Add model</button>
    </div>
  </div>

</div>

<div id="toast"></div>

<script>
// ─── State ────────────────────────────────────────────────────────────────────
let _cfg = {};
let _models = [];

// ─── Toast ────────────────────────────────────────────────────────────────────
let _toastTimer = null;
function toast(msg, err = false) {
  const el = document.getElementById('toast');
  el.textContent = msg;
  el.className = 'show' + (err ? ' err' : '');
  clearTimeout(_toastTimer);
  _toastTimer = setTimeout(() => el.className = '', 3000);
}

// ─── Load ─────────────────────────────────────────────────────────────────────
async function load() {
  const [mr, cr] = await Promise.all([fetch('/models'), fetch('/config')]);
  _models = (await mr.json()).models || [];
  _cfg = (await cr.json()).config || {};
  renderModels();
}

function roleBadge(id) {
  const sid = String(id);
  const isResolve = _cfg['resolve_model_id'] === sid;
  const isWrite   = _cfg['write_model_id']   === sid;
  if (isResolve && isWrite) return '<span class="badge badge-both">resolve + write</span>';
  if (isResolve) return '<span class="badge badge-resolve">resolve</span>';
  if (isWrite)   return '<span class="badge badge-write">write</span>';
  return '<span style="color:#bbb;font-size:0.8em">—</span>';
}

function providerPill(p) {
  const cls = 'pill-' + p;
  return `<span class="pill ${cls}">${p}</span>`;
}

function renderModels() {
  const tbody = document.getElementById('models-tbody');
  const sub   = document.getElementById('cfg-sub');

  if (!_models.length) {
    tbody.innerHTML = '<tr><td colspan="6" style="color:#aaa;padding:18px">No models configured yet.</td></tr>';
    sub.textContent = '0 models';
    return;
  }
  sub.textContent = _models.length + ' model' + (_models.length !== 1 ? 's' : '');

  const resolveId = _cfg['resolve_model_id'] || '';
  const writeId   = _cfg['write_model_id']   || '';

  tbody.innerHTML = _models.map(m => {
    const sid = String(m.id);
    const endpoint = m.base_url
      ? `<span style="color:#555;font-size:0.85em">${m.base_url}</span>`
      : '<span style="color:#ccc">—</span>';
    const rBtn = resolveId === sid
      ? `<button class="btn btn-sm btn-active" disabled>✓ resolve</button>`
      : `<button class="btn btn-sm" onclick="setRole('resolve_model_id','${sid}')">set resolve</button>`;
    const wBtn = writeId === sid
      ? `<button class="btn btn-sm btn-active" disabled>✓ write</button>`
      : `<button class="btn btn-sm" onclick="setRole('write_model_id','${sid}')">set write</button>`;
    return `<tr>
      <td style="color:#aaa">#${m.id}</td>
      <td>${providerPill(m.provider)}</td>
      <td>${m.model_name}</td>
      <td>${endpoint}</td>
      <td>${roleBadge(m.id)}</td>
      <td style="display:flex;gap:6px;padding:6px 10px">
        ${rBtn} ${wBtn}
        <button class="btn btn-sm btn-danger" onclick="deleteModel(${m.id},this)">✕</button>
      </td>
    </tr>`;
  }).join('');
}

// ─── Set role ─────────────────────────────────────────────────────────────────
async function setRole(key, value) {
  const r = await fetch('/config', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({key, value}),
  });
  const d = await r.json();
  if (d.error) { toast('Error: ' + d.error, true); return; }
  _cfg[key] = value;
  renderModels();
  toast(key.replace('_model_id', '') + ' model set to #' + value);
}

// ─── Delete ───────────────────────────────────────────────────────────────────
async function deleteModel(id, btn) {
  if (!confirm(`Delete model #${id}?`)) return;
  btn.disabled = true;
  const r = await fetch('/models/' + id, {method: 'DELETE'});
  const d = await r.json();
  btn.disabled = false;
  if (d.error) { toast('Error: ' + d.error, true); return; }
  toast('Model #' + id + ' deleted');
  await load();
}

// ─── LM Studio discover ───────────────────────────────────────────────────────
async function discoverModels(btn) {
  const base = document.getElementById('lms-url').value.trim();
  const errEl = document.getElementById('disc-error');
  const listEl = document.getElementById('disc-list');
  errEl.style.display = 'none';
  listEl.innerHTML = '<span style="color:#aaa;font-size:0.82em">Connecting…</span>';
  btn.disabled = true;

  try {
    const r = await fetch('/models/discover?base_url=' + encodeURIComponent(base));
    const d = await r.json();
    btn.disabled = false;

    if (d.error) {
      errEl.textContent = d.error;
      errEl.style.display = 'block';
      listEl.innerHTML = '';
      return;
    }

    if (!d.models.length) {
      listEl.innerHTML = '<span style="color:#aaa;font-size:0.82em">No models loaded in LM Studio.</span>';
      return;
    }

    listEl.innerHTML = d.models.map(name => `
      <div class="disc-item">
        <span>${name}</span>
        <button class="btn btn-sm btn-success"
          onclick="addLmStudioModel('${name}','${base}/v1',this)">
          + add
        </button>
      </div>
    `).join('');
  } catch(e) {
    btn.disabled = false;
    errEl.textContent = 'Request failed: ' + e.message;
    errEl.style.display = 'block';
    listEl.innerHTML = '';
  }
}

async function addLmStudioModel(modelName, baseUrl, btn) {
  btn.disabled = true;
  const r = await fetch('/models', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({provider: 'lm-studio', model_name: modelName, api_key: '', base_url: baseUrl}),
  });
  const d = await r.json();
  btn.disabled = false;
  if (d.error) { toast('Error: ' + d.error, true); return; }
  toast(`Added "${modelName}" (#${d.id}). Click "set resolve" to activate it.`);
  btn.textContent = '✓ added';
  btn.classList.add('btn-active');
  await load();
}

// ─── Add cloud model ──────────────────────────────────────────────────────────
function onProviderChange() {
  const p = document.getElementById('add-provider').value;
  document.getElementById('add-url-row').style.display = p === 'lm-studio' ? 'flex' : 'none';
  document.getElementById('add-key-field').style.display = p === 'lm-studio' ? 'none' : 'flex';

  const nameDefaults = {
    claude: 'claude-haiku-4-5-20251001',
    openai: 'gpt-4o-mini',
    'lm-studio': '',
  };
  document.getElementById('add-name').value = nameDefaults[p] || '';
}

async function addModel(btn) {
  const provider  = document.getElementById('add-provider').value;
  const modelName = document.getElementById('add-name').value.trim();
  const apiKey    = document.getElementById('add-key').value.trim();
  const baseUrl   = document.getElementById('add-url').value.trim();

  if (!modelName) { toast('Model name is required', true); return; }
  if (provider === 'claude' && !apiKey) { toast('API key required for Claude', true); return; }

  btn.disabled = true;
  const r = await fetch('/models', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({provider, model_name: modelName, api_key: apiKey, base_url: baseUrl}),
  });
  const d = await r.json();
  btn.disabled = false;
  if (d.error) { toast('Error: ' + d.error, true); return; }
  toast(`Model added (#${d.id}). Click "set resolve" to activate it.`);
  document.getElementById('add-key').value = '';
  await load();
}

// ─── Boot ─────────────────────────────────────────────────────────────────────
load();
</script>
</body>
</html>"""


@app.get("/models-ui", response_class=HTMLResponse)
async def models_ui() -> str:
    return MODELS_HTML


# ---------------------------------------------------------------------------
# Passthrough — everything else forwarded to upstream Ollama
# ---------------------------------------------------------------------------

@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "HEAD"])
async def passthrough(path: str, request: Request) -> Response:
    cfg = request.app.state.yaml_config

    # Some LiteLLM configurations build wrong paths like v1/messages/chat/completions
    # (when api_base already includes /v1/messages) or v1/chat/completions without
    # a leading slash.  Redirect all chat-completion variants to the proper handler.
    if path.endswith("chat/completions") and request.method == "POST":
        log.info("passthrough redirect  %s → /v1/chat/completions", path)
        return await openai_chat_completions(request)

    # Agent-prefixed paths: /{agent_id}/v1/... where the agent_id segment was not
    # matched by a dedicated route (e.g. GET /{agent_id}/v1/completions).
    # Strip the leading segment so the upstream receives a clean /v1/... path.
    parts = path.split("/", 1)
    if len(parts) == 2 and parts[1].startswith("v1/"):
        stripped = parts[1]
        log.info("passthrough strip_agent_prefix  %s → /%s", path, stripped)
        path = stripped

    if path.startswith("v1/"):
        upstream = cfg["upstream_anthropic"]
        relay_headers = ANTHROPIC_RELAY_HEADERS
        provider = "anthropic"
    else:
        upstream = cfg["upstream_ollama"]
        relay_headers = None
        provider = "ollama"

    url = f"{upstream}/{path}"
    log.info("passthrough  %s %s → %s", request.method, path, url)

    body = await request.body()
    if relay_headers:
        headers = _relay_headers(request, relay_headers)
    else:
        headers = {k: v for k, v in request.headers.items() if k.lower() != "host"}

    t0 = time.perf_counter()
    try:
        async with httpx.AsyncClient(timeout=120.0) as client:
            r = await client.request(request.method, url, content=body, headers=headers)
    except httpx.TimeoutException as exc:
        log.error("passthrough_timeout  provider=%s url=%s  after=%.0fs  %s",
                  provider, url, time.perf_counter() - t0, exc)
        raise
    except httpx.RequestError as exc:
        log.error("passthrough_connect_error  provider=%s url=%s  %s: %s",
                  provider, url, type(exc).__name__, exc)
        raise

    ms = (time.perf_counter() - t0) * 1000
    if not r.is_success:
        log.warning("passthrough_error  provider=%s url=%s  status=%d  %.0fms  body=%.300s",
                    provider, url, r.status_code, ms, r.text)
    else:
        log.info("passthrough_ok  provider=%s status=%d  %.0fms", provider, r.status_code, ms)

    return Response(
        content=r.content,
        status_code=r.status_code,
        media_type=r.headers.get("content-type"),
    )
