"""Web UI — Flask 单页应用

提供搜索、状态查看、chunk 详情、触发索引等功能。
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
import socket
import subprocess
import sys
import time
from pathlib import Path

from flask import Flask, jsonify, request

from .config import INDEX1_HOME, Config, load_config
from .db import Database
from .embed import Embedder, create_embedder

logger = logging.getLogger(__name__)


def create_app(config: Config | None = None) -> Flask:
    """创建 Flask 应用"""
    if config is None:
        config = load_config()

    app = Flask(__name__)
    app.config["INDEX1_CONFIG"] = config

    # 单例：所有请求共享同一个 db 和 embedder，避免每次请求新建连接
    _db = Database(config.db_path_resolved, config.effective_embedding_dim)
    _db.connect()
    _embedder = create_embedder(config)

    # Cognitive DB（可选 — cognitive.db 不存在时 Memory 功能不可用）
    _cog_db = None
    try:
        from .cognitive.db_cog import CogDatabase
        cog_path = config.cognitive_db_path_resolved
        if Path(cog_path).exists():
            _cog_db = CogDatabase(cog_path, config.effective_embedding_dim)
            _cog_db.connect()
    except Exception as e:
        logger.warning("Cognitive DB 不可用: %s", e)

    # 持久 event loop：Flask 请求共享同一个 loop，防止 httpx client 跨 loop 失效
    import threading
    _loop = asyncio.new_event_loop()
    _loop_thread = threading.Thread(target=_loop.run_forever, daemon=True)
    _loop_thread.start()

    def _run_async(coro):
        """在共享 event loop 上执行协程，避免 asyncio.run() 每次创建/销毁 loop"""
        import concurrent.futures
        future = asyncio.run_coroutine_threadsafe(coro, _loop)
        try:
            return future.result(timeout=120)
        except concurrent.futures.TimeoutError:
            future.cancel()
            raise TimeoutError("操作超时（120s）")

    def _get_db() -> Database:
        return _db

    def _get_embedder() -> Embedder:
        return _embedder

    # ── 页面路由 ──

    @app.route("/")
    def index():
        return _HTML_TEMPLATE

    @app.route("/favicon.ico")
    def favicon():
        svg = (
            '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
            '<ellipse cx="16" cy="16" rx="14" ry="5" stroke="#4A90D9" stroke-width="2.5" fill="none" opacity=".7"/>'
            '<ellipse cx="16" cy="16" rx="14" ry="5" stroke="#4A90D9" stroke-width="2.5" fill="none" opacity=".7" transform="rotate(60 16 16)"/>'
            '<ellipse cx="16" cy="16" rx="14" ry="5" stroke="#4A90D9" stroke-width="2.5" fill="none" opacity=".7" transform="rotate(120 16 16)"/>'
            '<circle cx="16" cy="16" r="4" fill="#4A90D9"/>'
            '</svg>'
        )
        return app.response_class(svg, mimetype="image/svg+xml")

    # ── API 路由 ──

    @app.route("/api/search")
    def api_search():
        from .search import search as do_search

        q = request.args.get("q", "").strip()
        if not q:
            return jsonify({"results": [], "total": 0, "query_ms": 0, "mode": ""})
        if len(q) > 500:
            return jsonify({"error": "query too long (max 500 chars)"}), 400

        collection = request.args.get("collection") or None
        try:
            top_k = min(int(request.args.get("top_k", 10)), 100)
        except (ValueError, TypeError):
            return jsonify({"error": "top_k must be an integer"}), 400

        db = _get_db()
        embedder = _get_embedder()
        try:
            resp = _run_async(
                do_search(
                    q, config, db, embedder,
                    collection=collection, top_k=top_k, verbose=True,
                )
            )
            results = []
            for r in resp.results:
                results.append({
                    "chunk_id": r.chunk_id,
                    "score": r.score,
                    "source": r.source,
                    "section": r.section,
                    "summary": r.summary,
                    "relevance": r.relevance,
                    "content": r.content,
                    "token_count": r.token_count,
                })
            return jsonify({
                "results": results,
                "total": resp.total,
                "query_ms": resp.query_ms,
                "mode": resp.mode,
                "hint": resp.hint,
            })
        except Exception as e:
            logger.warning("搜索失败: %s", e)
            return jsonify({"error": "search failed"}), 500

    @app.route("/api/status")
    def api_status():
        db = _get_db()
        try:
            stats = db.get_stats()
            return jsonify(stats)
        except Exception as e:
            logger.warning("获取状态失败: %s", e)
            return jsonify({"error": "failed to get status"}), 500

    @app.route("/api/doc/<int:doc_id>/chunks")
    def api_doc_chunks(doc_id):
        db = _get_db()
        try:
            chunks = db.get_doc_chunks(doc_id)
            if not chunks:
                return jsonify({"error": "document not found"}), 404
            return jsonify({
                "source": chunks[0].source if chunks else None,
                "chunks": [
                    {
                        "id": c.id,
                        "content": c.content,
                        "section": c.section,
                        "chunk_index": c.chunk_index,
                        "token_count": c.token_count,
                        "summary": c.summary,
                    }
                    for c in chunks
                ],
            })
        except Exception as e:
            logger.warning("获取文档 chunks 失败: %s", e)
            return jsonify({"error": "failed to get doc chunks"}), 500

    @app.route("/api/browse")
    def api_browse():
        collection = request.args.get("collection") or None
        if collection and ("\x00" in collection or len(collection) > 200):
            return jsonify({"error": "invalid collection"}), 400
        doc_type = request.args.get("doc_type") or None
        if doc_type and ("\x00" in doc_type or len(doc_type) > 50):
            return jsonify({"error": "invalid doc_type"}), 400
        try:
            limit = min(int(request.args.get("limit", 50)), 200)
        except (ValueError, TypeError):
            limit = 50
        try:
            offset = max(int(request.args.get("offset", 0)), 0)
        except (ValueError, TypeError):
            offset = 0
        db = _get_db()
        try:
            docs = db.browse_collection(collection, doc_type=doc_type, limit=limit, offset=offset)
            # 返回该集合实际存在的 doc_types
            doc_types = []
            if collection:
                rows = db.conn.execute(
                    "SELECT DISTINCT doc_type FROM documents WHERE collection = ? ORDER BY doc_type",
                    (collection,),
                ).fetchall()
                doc_types = [r[0] for r in rows]
            return jsonify({"docs": docs, "total": len(docs), "doc_types": doc_types})
        except Exception as e:
            logger.warning("浏览集合失败: %s", e)
            return jsonify({"error": "failed to browse"}), 500

    @app.route("/api/collections")
    def api_collections():
        db = _get_db()
        try:
            return jsonify(db.get_collection_stats())
        except Exception as e:
            logger.warning("获取集合统计失败: %s", e)
            return jsonify({"error": "failed to get collections"}), 500

    @app.route("/api/chunk/<int:chunk_id>")
    def api_chunk(chunk_id):
        db = _get_db()
        try:
            chunk = db.get_chunk(chunk_id)
            if not chunk:
                return jsonify({"error": "chunk not found"}), 404
            ctx = db.get_chunk_context(chunk_id)
            return jsonify({
                "id": chunk.id,
                "doc_id": chunk.doc_id,
                "content": chunk.content,
                "section": chunk.section,
                "parent_section": chunk.parent_section,
                "chunk_index": chunk.chunk_index,
                "tags": chunk.tags,
                "token_count": chunk.token_count,
                "summary": chunk.summary,
                "source": chunk.source,
                "context": ctx,
            })
        except Exception as e:
            logger.warning("获取 chunk 失败: %s", e)
            return jsonify({"error": "failed to get chunk"}), 500

    @app.route("/api/reindex", methods=["POST"])
    def api_reindex():
        from .indexer import index_files

        data = request.get_json(silent=True) or {}
        paths = data.get("paths", [])
        if not paths or not isinstance(paths, list):
            return jsonify({"error": "paths is required (list of strings)"}), 400
        if len(paths) > 10:
            return jsonify({"error": "too many paths (max 10)"}), 400

        # 验证路径存在且不含空字符，限制在用户目录内，阻止符号链接逃逸
        valid_paths = []
        home_dir = Path.home().resolve()
        for p in paths:
            if not isinstance(p, str) or "\x00" in p:
                return jsonify({"error": "invalid path"}), 400
            orig = Path(p).expanduser()
            if not orig.exists():
                return jsonify({"error": f"path not found: {p}"}), 400
            # 检查路径每一级是否含符号链接（防止中间路径逃逸）
            for part in [orig] + list(orig.parents):
                if part.is_symlink():
                    return jsonify({"error": f"symlink in path not allowed: {p}"}), 403
            resolved = orig.resolve()
            try:
                resolved.relative_to(home_dir)
            except ValueError:
                return jsonify({"error": f"path not allowed: {p}"}), 403
            valid_paths.append(str(resolved))

        force = bool(data.get("force", False))
        collection = data.get("collection") or None

        db = _get_db()
        embedder = _get_embedder()
        try:
            base_dir = None
            if len(valid_paths) == 1:
                p = Path(valid_paths[0]).resolve()
                base_dir = p if p.is_dir() else p.parent

            stats = _run_async(
                index_files(
                    valid_paths, config, db, embedder,
                    collection=collection, force=force, base_dir=base_dir,
                )
            )
            return jsonify(stats)
        except Exception as e:
            logger.warning("索引失败: %s", e)
            return jsonify({"error": "indexing failed"}), 500

    # ── Cognition API（Memory Tab） ──

    @app.route("/api/cognition/stats")
    def api_cognition_stats():
        if not _cog_db:
            return jsonify({"error": "cognitive db not available"}), 503
        try:
            stats = _cog_db.get_stats()
            # 按 category 统计
            by_cat: dict[str, int] = {}
            for row in _cog_db.conn.execute(
                "SELECT category, COUNT(*) as cnt FROM facts WHERE status='active' GROUP BY category"
            ).fetchall():
                by_cat[row["category"]] = row["cnt"]
            stats["facts_by_category"] = by_cat
            # 最近会话
            sessions = _cog_db.conn.execute(
                "SELECT external_id, collection, created_at FROM sessions "
                "ORDER BY created_at DESC LIMIT 20"
            ).fetchall()
            stats["recent_sessions"] = [dict(s) for s in sessions]
            return jsonify(stats)
        except Exception as e:
            logger.warning("获取 cognition stats 失败: %s", e)
            return jsonify({"error": "failed to get cognition stats"}), 500

    @app.route("/api/cognition/facts")
    def api_cognition_facts():
        if not _cog_db:
            return jsonify({"error": "cognitive db not available"}), 503
        try:
            limit = min(int(request.args.get("limit", 50)), 200)
        except (ValueError, TypeError):
            limit = 50
        try:
            offset = max(int(request.args.get("offset", 0)), 0)
        except (ValueError, TypeError):
            offset = 0
        # since_id: 增量轮询 — 只返回 id > since_id 的新 facts
        try:
            since_id = int(request.args.get("since_id", 0))
        except (ValueError, TypeError):
            since_id = 0
        category = request.args.get("category") or None
        session_id = request.args.get("session_id") or None
        source = request.args.get("source") or None
        try:
            # 排除 embedding blob — 不可 JSON 序列化且前端不需要
            sql = ("SELECT id, assertion, category, confidence, source, session_id,"
                   " collection, scope, scope_files, scope_modules, scope_entities,"
                   " context_json, layer, status, created_at, updated_at,"
                   " last_accessed, access_count, return_count, decay_rate,"
                   " invalidated_at, invalidated_reason, superseded_by,"
                   " last_validated FROM facts WHERE status='active'")
            params: list = []
            if since_id > 0:
                sql += " AND id>?"
                params.append(since_id)
            if category:
                sql += " AND category=?"
                params.append(category)
            if session_id:
                sql += " AND session_id=?"
                params.append(session_id)
            if source:
                sql += " AND source=?"
                params.append(source)
            if since_id > 0:
                # 增量模式：按 id DESC 返回新 facts（无分页）
                sql += " ORDER BY id DESC LIMIT ?"
                params.append(limit)
            else:
                sql += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
                params.extend([limit, offset])
            rows = _cog_db.conn.execute(sql, params).fetchall()

            # total count
            count_sql = "SELECT COUNT(*) FROM facts WHERE status='active'"
            count_params: list = []
            if category:
                count_sql += " AND category=?"
                count_params.append(category)
            if session_id:
                count_sql += " AND session_id=?"
                count_params.append(session_id)
            if source:
                count_sql += " AND source=?"
                count_params.append(source)
            total = _cog_db.conn.execute(count_sql, count_params).fetchone()[0]

            facts = []
            for r in rows:
                d = dict(r)
                # 安全序列化
                for key in ("scope_files", "scope_modules", "scope_entities"):
                    if isinstance(d.get(key), str):
                        try:
                            d[key] = json.loads(d[key])
                        except Exception:
                            pass
                if isinstance(d.get("context_json"), str):
                    try:
                        d["context_json"] = json.loads(d["context_json"])
                    except Exception:
                        pass
                facts.append(d)
            return jsonify({"facts": facts, "total": total})
        except Exception as e:
            logger.warning("获取 facts 失败: %s", e)
            return jsonify({"error": "failed to get facts"}), 500

    @app.route("/api/cognition/fact/<int:fact_id>")
    def api_cognition_fact(fact_id: int):
        if not _cog_db:
            return jsonify({"error": "cognitive db not available"}), 503
        try:
            fact = _cog_db.get_fact(fact_id)
            if not fact:
                return jsonify({"error": "fact not found"}), 404
            # 排除 embedding BLOB — 不可 JSON 序列化
            fact.pop("embedding", None)
            # parse JSON fields
            for key in ("scope_files", "scope_modules", "scope_entities", "context_json"):
                if isinstance(fact.get(key), str):
                    try:
                        fact[key] = json.loads(fact[key])
                    except Exception:
                        pass
            # edges
            edges = _cog_db.get_fact_edges(fact_id)
            fact["edges"] = edges
            return jsonify(fact)
        except Exception as e:
            logger.warning("获取 fact 失败: %s", e)
            return jsonify({"error": "failed to get fact"}), 500

    @app.route("/api/cognition/sessions")
    def api_cognition_sessions():
        if not _cog_db:
            return jsonify({"error": "cognitive db not available"}), 503
        try:
            rows = _cog_db.conn.execute(
                "SELECT external_id, collection, created_at, "
                "(SELECT COUNT(*) FROM facts WHERE session_id=sessions.external_id AND status='active') as fact_count "
                "FROM sessions ORDER BY created_at DESC LIMIT 50"
            ).fetchall()
            return jsonify([dict(r) for r in rows])
        except Exception as e:
            logger.warning("获取 sessions 失败: %s", e)
            return jsonify({"error": "failed to get sessions"}), 500

    # ── Events API（raw_events 实时工具调用） ──

    @app.route("/api/cognition/events")
    def api_cognition_events():
        if not _cog_db:
            return jsonify({"error": "cognitive db not available"}), 503
        try:
            limit = min(int(request.args.get("limit", 50)), 200)
        except (ValueError, TypeError):
            limit = 50
        try:
            since_id = int(request.args.get("since_id", 0))
        except (ValueError, TypeError):
            since_id = 0
        session_id = request.args.get("session_id") or None
        try:
            sql = ("SELECT event_id, session_id, timestamp, hook_event, tool_name,"
                   " collection, summary, importance, signals_json"
                   " FROM raw_events WHERE 1=1")
            params: list = []
            if since_id > 0:
                sql += " AND event_id>?"
                params.append(since_id)
            if session_id:
                sql += " AND session_id=?"
                params.append(session_id)
            sql += " ORDER BY event_id DESC LIMIT ?"
            params.append(limit)
            rows = _cog_db.conn.execute(sql, params).fetchall()
            events = []
            for r in rows:
                d = dict(r)
                if isinstance(d.get("signals_json"), str):
                    try:
                        d["signals_json"] = json.loads(d["signals_json"])
                    except Exception:
                        pass
                events.append(d)
            count_sql = "SELECT COUNT(*) FROM raw_events WHERE 1=1"
            count_params: list = []
            if session_id:
                count_sql += " AND session_id=?"
                count_params.append(session_id)
            total = _cog_db.conn.execute(count_sql, count_params).fetchone()[0]
            return jsonify({"events": events, "total": total})
        except Exception as e:
            logger.warning("获取 events 失败: %s", e)
            return jsonify({"error": "failed to get events"}), 500

    # ── Compare API ──

    @app.route("/compare")
    def compare_page():
        return _COMPARE_TEMPLATE

    @app.route("/api/compare/list")
    def api_compare_list():
        data_dir = Path(__file__).resolve().parent.parent.parent / "project-docs" / "index" / "data"
        if not data_dir.is_dir():
            return jsonify([])
        result = []
        for f in sorted(data_dir.glob("*.json")):
            if f.name == "manifest.json":
                continue
            try:
                obj = json.loads(f.read_text(encoding="utf-8"))
                result.append({
                    "id": obj.get("id", f.stem),
                    "label": obj.get("label", f.stem),
                    "date": obj.get("date", ""),
                })
            except Exception:
                continue
        return jsonify(result)

    @app.route("/api/compare/<cid>")
    def api_compare_detail(cid: str):
        if not cid.replace("-", "").replace("_", "").isalnum():
            return jsonify({"error": "invalid id"}), 400
        data_dir = Path(__file__).resolve().parent.parent.parent / "project-docs" / "index" / "data"
        fpath = data_dir / f"{cid}.json"
        if not fpath.is_file():
            return jsonify({"error": "not found"}), 404
        try:
            obj = json.loads(fpath.read_text(encoding="utf-8"))
            return jsonify(obj)
        except Exception as e:
            logger.warning("加载对比数据失败: %s", e)
            return jsonify({"error": "load failed"}), 500

    return app


# ── 内联 HTML 模板 ──

_HTML_TEMPLATE = r"""<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>index1</title>
<link rel="icon" type="image/svg+xml" href="/favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Fira+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ── Design System: Fira Code + Fira Sans, 浅蓝清新 ── */
[data-theme="light"] {
  --bg: #FAFBFD;
  --bg-card: #FFFFFF;
  --bg-hover: #F4F6FA;
  --bg-input: #F1F4F9;
  --bg-accent: #EFF4FF;
  --border: #E4E9F1;
  --border-hover: #CDD4E0;
  --text: #1A2138;
  --text-2: #4A5468;
  --text-3: #8E99AE;
  --accent: #4A90D9;
  --accent-bg: rgba(74,144,217,0.07);
  --accent-text: #3A7BC8;
  --accent-glow: rgba(74,144,217,0.1);
  --green: #2D9E6F;
  --green-bg: rgba(45,158,111,0.07);
  --yellow: #D08B2D;
  --yellow-bg: rgba(208,139,45,0.07);
  --red: #CC4444;
  --mark-bg: rgba(74,144,217,0.1);
  --mark-text: #3A7BC8;
  --shadow-sm: 0 1px 2px rgba(26,33,56,0.03);
  --shadow: 0 1px 3px rgba(26,33,56,0.05), 0 1px 2px rgba(26,33,56,0.03);
  --shadow-lg: 0 8px 32px rgba(26,33,56,0.06);
  --modal-bg: rgba(26,33,56,0.12);
}
[data-theme="dark"] {
  --bg: #0E1525;
  --bg-card: #172033;
  --bg-hover: #1E2A42;
  --bg-input: #172033;
  --bg-accent: rgba(74,144,217,0.08);
  --border: #253350;
  --border-hover: #334768;
  --text: #E2E7F0;
  --text-2: #94A0B8;
  --text-3: #5E6E8A;
  --accent: #6BADE8;
  --accent-bg: rgba(107,173,232,0.1);
  --accent-text: #8EC3F0;
  --accent-glow: rgba(107,173,232,0.12);
  --green: #4CD9A0;
  --green-bg: rgba(76,217,160,0.1);
  --yellow: #F0C050;
  --yellow-bg: rgba(240,192,80,0.1);
  --red: #F07070;
  --mark-bg: rgba(107,173,232,0.14);
  --mark-text: #8EC3F0;
  --shadow-sm: 0 1px 2px rgba(0,0,0,0.2);
  --shadow: 0 1px 3px rgba(0,0,0,0.3);
  --shadow-lg: 0 8px 32px rgba(0,0,0,0.35);
  --modal-bg: rgba(0,0,0,0.4);
}
:root {
  --r: 10px;
  --font: 'Fira Sans', -apple-system, BlinkMacSystemFont, sans-serif;
  --mono: 'Fira Code', ui-monospace, monospace;
}

*,*::before,*::after { margin: 0; padding: 0; box-sizing: border-box; }
body {
  font-family: var(--font);
  background: var(--bg);
  color: var(--text);
  min-height: 100vh;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  transition: background 0.3s ease, color 0.3s ease;
}
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
.app { max-width: 900px; margin: 0 auto; padding: 32px 24px; }

/* ── Header ── */
.header {
  display: flex; align-items: center; justify-content: space-between;
  margin-bottom: 28px; padding-bottom: 20px;
  border-bottom: 1px solid var(--border);
}
.logo {
  font-family: var(--mono); font-size: 20px; font-weight: 700;
  color: var(--text); letter-spacing: -0.5px;
  display: flex; align-items: center; gap: 10px;
}
.logo span { color: var(--accent); }
.logo-icon { flex-shrink: 0; }
.logo-icon g { animation-play-state: paused !important; }
.logo-icon.active g { animation-play-state: running !important; }
.logo-icon .core-pulse { r: 4.5; }
.logo-icon.active .core-pulse { animation: corePulse 3s ease-in-out infinite; }
@keyframes spinCW { to { transform: rotate(360deg); } }
@keyframes spinCCW { to { transform: rotate(-360deg); } }
@keyframes corePulse { 0%,100% { r: 4; } 50% { r: 5.5; } }
.header-right { display: flex; align-items: center; gap: 10px; }
.nav-tabs { display: flex; gap: 2px; background: var(--bg-input); border-radius: 10px; padding: 3px; }
.nav-tab {
  padding: 6px 16px; border-radius: 8px; border: none; background: none;
  color: var(--text-3); cursor: pointer; font: 500 13px var(--font);
  transition: all 0.2s ease;
}
.nav-tab:hover { color: var(--text-2); }
.nav-tab.active { background: var(--bg-card); color: var(--text); box-shadow: var(--shadow); }
.theme-btn {
  width: 34px; height: 34px; border-radius: 10px;
  border: 1px solid var(--border); background: var(--bg-card);
  color: var(--text-3); cursor: pointer; display: flex;
  align-items: center; justify-content: center;
  transition: all 0.2s ease; flex-shrink: 0;
}
.theme-btn:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
.theme-btn svg { width: 16px; height: 16px; }
[data-theme="dark"] .icon-sun { display: none; }
[data-theme="light"] .icon-moon { display: none; }

/* ── Search ── */
.search-row { display: flex; gap: 10px; margin-bottom: 16px; }
.search-box { position: relative; flex: 1; }
.search-box svg.s-icon {
  position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
  color: var(--text-3); width: 18px; height: 18px; pointer-events: none;
  transition: color 0.2s;
}
.search-box input:focus ~ svg.s-icon,
.search-box:focus-within svg.s-icon { color: var(--accent); }
.search-box input {
  width: 100%; padding: 12px 70px 12px 42px;
  background: var(--bg-card); border: 1px solid var(--border);
  border-radius: var(--r); color: var(--text); font: 15px var(--font);
  outline: none; transition: all 0.2s ease;
}
.search-box input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.search-box input::placeholder { color: var(--text-3); font-weight: 300; }
.s-actions {
  position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
  display: flex; align-items: center; gap: 4px;
}
.s-clear {
  display: none; background: none; border: none;
  color: var(--text-3); cursor: pointer; padding: 3px 5px;
  border-radius: 4px; font-size: 16px; line-height: 1;
}
.s-clear:hover { color: var(--text); }
.s-clear.show { display: block; }
.kbd {
  padding: 2px 7px; border-radius: 5px;
  font: 11px var(--mono); color: var(--text-3);
  background: var(--bg-input); border: 1px solid var(--border);
}
.col-select {
  padding: 0 30px 0 12px; height: 44px;
  background: var(--bg-card); border: 1px solid var(--border);
  border-radius: var(--r); color: var(--text-2); font: 13px var(--font);
  cursor: pointer; outline: none; appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%2394A3B8' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 10px center;
  transition: border-color 0.2s;
}
.col-select:focus { border-color: var(--accent); }

/* ── Meta ── */
.meta-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; font-size: 12px; color: var(--text-3); flex-wrap: wrap; }
.badge { padding: 3px 10px; border-radius: 99px; font: 600 10px var(--mono); letter-spacing: 0.4px; text-transform: uppercase; }
.badge-hybrid { background: var(--accent-bg); color: var(--accent); }
.badge-bm25 { background: var(--yellow-bg); color: var(--yellow); }

/* ── Results ── */
.results { display: flex; flex-direction: column; gap: 6px; }
.result-card {
  background: var(--bg-card); border: 1px solid var(--border);
  border-radius: var(--r); padding: 14px 16px; cursor: pointer;
  transition: all 0.15s ease; animation: fadeIn 0.2s ease both;
}
.result-card:hover { background: var(--bg-hover); border-color: var(--border-hover); transform: translateY(-1px); box-shadow: var(--shadow); }
.result-card.selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-glow); }
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.r-top { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.r-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.r-dot.high { background: var(--green); box-shadow: 0 0 6px var(--green-bg); }
.r-dot.medium { background: var(--yellow); }
.r-dot.low { background: var(--text-3); }
.r-src { font: 500 12px var(--mono); color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.r-right { margin-left: auto; display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
.r-tok { font: 11px var(--mono); color: var(--text-3); background: var(--bg-input); padding: 2px 7px; border-radius: 5px; }
.r-score { font: 11px var(--mono); color: var(--text-3); }
.r-sec { font: 11px var(--mono); color: var(--text-3); margin-bottom: 3px; padding-left: 15px; }
.r-sum { font-size: 13px; color: var(--text-2); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; padding-left: 15px; }
.r-sum mark { background: var(--mark-bg); color: var(--mark-text); border-radius: 2px; padding: 0 2px; }

/* ── Modal ── */
.modal-overlay {
  display: none; position: fixed; inset: 0;
  background: var(--modal-bg); backdrop-filter: blur(8px);
  z-index: 100; justify-content: center; align-items: flex-start;
  padding: 60px 20px; overflow-y: auto;
}
.modal-overlay.open { display: flex; animation: mfade 0.15s ease; }
@keyframes mfade { from { opacity: 0; } to { opacity: 1; } }
.modal {
  background: var(--bg-card); border: 1px solid var(--border);
  border-radius: 14px; width: 100%; max-width: 720px;
  padding: 28px; position: relative;
  box-shadow: var(--shadow-lg); animation: mslide 0.2s ease;
}
@keyframes mslide { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: none; } }
.m-bar { position: absolute; right: 14px; top: 14px; display: flex; gap: 4px; }
.m-btn {
  background: none; border: 1px solid var(--border);
  color: var(--text-3); cursor: pointer;
  padding: 6px 10px; border-radius: 7px;
  font: 12px var(--font); transition: all 0.15s;
  display: flex; align-items: center; gap: 4px;
}
.m-btn:hover { color: var(--text); background: var(--bg-hover); }
.m-btn.copied { color: var(--green); border-color: var(--green); }
.modal h2 { font: 600 15px var(--mono); margin-bottom: 16px; padding-right: 100px; color: var(--accent); word-break: break-all; }
.m-meta {
  display: flex; flex-wrap: wrap; gap: 16px;
  margin-bottom: 16px; padding: 12px 14px;
  background: var(--bg); border-radius: 8px; border: 1px solid var(--border);
}
.m-meta-item .label { font: 400 10px var(--font); color: var(--text-3); text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 2px; }
.m-meta-item .value { font: 13px var(--mono); color: var(--text); }
.m-content {
  background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
  padding: 16px; font: 13px/1.7 var(--mono);
  white-space: pre-wrap; word-wrap: break-word;
  max-height: 450px; overflow-y: auto; tab-size: 4;
}
.m-ctx { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font: 11px var(--mono); color: var(--text-3); display: flex; gap: 20px; }

/* ── Panels ── */
.panel { display: none; }
.panel.active { display: block; }

/* ── Status ── */
.status-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 18px; }
.stat-card {
  background: var(--bg-card); border: 1px solid var(--border);
  border-radius: var(--r); padding: 18px 20px;
  transition: border-color 0.2s;
}
.stat-card:hover { border-color: var(--border-hover); }
.stat-card .label { font: 500 10px var(--font); color: var(--text-3); text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 8px; }
.stat-card .value { font: 700 28px var(--mono); letter-spacing: -1px; color: var(--accent); }
.stat-card .value.small { font-size: 13px; font-weight: 500; letter-spacing: 0; }
.stat-card .value.on { color: var(--green); }
.stat-card .value.off { color: var(--text-3); }
.stat-card .sub { font: 11px var(--mono); color: var(--text-3); margin-top: 4px; }
.s-refresh {
  display: inline-flex; align-items: center; gap: 6px;
  margin-bottom: 16px; padding: 7px 14px;
  background: var(--bg-card); border: 1px solid var(--border);
  border-radius: 8px; color: var(--text-3); font: 12px var(--font);
  cursor: pointer; transition: all 0.15s;
}
.s-refresh:hover { color: var(--accent); border-color: var(--accent); }

/* ── Index ── */
.idx-form {
  background: var(--bg-card); border: 1px solid var(--border);
  border-radius: 12px; padding: 24px;
}
.idx-form label { display: block; font: 500 10px var(--font); color: var(--text-3); text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 8px; }
.idx-form input[type="text"] {
  width: 100%; padding: 10px 14px;
  background: var(--bg-input); border: 1px solid var(--border);
  border-radius: 8px; color: var(--text); font: 13px var(--mono);
  outline: none; margin-bottom: 16px; transition: border-color 0.2s;
}
.idx-form input[type="text"]:focus { border-color: var(--accent); }
.idx-opts { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; }
.chk { display: flex; align-items: center; gap: 6px; font: 13px var(--font); color: var(--text-2); cursor: pointer; }
.chk input { accent-color: var(--accent); width: 15px; height: 15px; }
.btn {
  padding: 10px 24px; border: none; border-radius: 8px;
  font: 600 13px var(--font); cursor: pointer; transition: all 0.15s;
}
.btn-p { background: var(--accent); color: #fff; }
.btn-p:hover { filter: brightness(1.1); box-shadow: 0 2px 12px var(--accent-glow); }
.btn-p:disabled { opacity: 0.4; cursor: not-allowed; filter: none; box-shadow: none; }
.idx-log {
  margin-top: 16px; background: var(--bg); border: 1px solid var(--border);
  border-radius: 8px; padding: 12px 16px; font: 12px var(--mono);
  color: var(--text-3); max-height: 220px; overflow-y: auto; display: none;
}
.idx-log.show { display: block; }
.idx-log .ok { color: var(--green); }
.idx-log .err { color: var(--red); }
.idx-log .ts { opacity: 0.4; margin-right: 6px; }

/* ── Toast ── */
.toast {
  position: fixed; bottom: 24px; left: 50%;
  transform: translateX(-50%) translateY(60px);
  background: var(--bg-card); border: 1px solid var(--border);
  border-radius: 10px; padding: 10px 20px; font: 13px var(--font);
  color: var(--text); box-shadow: var(--shadow-lg);
  z-index: 200; opacity: 0; transition: all 0.3s ease; pointer-events: none;
}
.toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }

/* ── Spinner ── */
.spinner {
  display: inline-block; width: 14px; height: 14px;
  border: 2px solid var(--border); border-top-color: var(--accent);
  border-radius: 50%; animation: spin 0.6s linear infinite;
  vertical-align: middle; margin-right: 6px;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* ── Empty ── */
.empty { text-align: center; padding: 52px 20px; color: var(--text-3); font-size: 14px; }
.empty svg { margin-bottom: 12px; opacity: 0.4; }
.empty .hint { margin-top: 12px; font: 12px var(--mono); opacity: 0.35; }

/* ── View Modes ── */
.view-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.view-bar-left { font: 13px var(--font); color: var(--text-3); }
.view-bar-left b { color: var(--text-2); font-weight: 600; }
.view-modes { display: flex; gap: 2px; background: var(--bg-input); border-radius: 8px; padding: 3px; }
.view-mode {
  width: 30px; height: 28px; border: none; border-radius: 6px;
  background: none; color: var(--text-3); cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  transition: all 0.15s;
}
.view-mode:hover { color: var(--text-2); }
.view-mode.active { background: var(--bg-card); color: var(--accent); box-shadow: var(--shadow-sm); }
.view-mode svg { width: 15px; height: 15px; }

/* ── Table View ── */
.results-table { width: 100%; border-collapse: collapse; }
.results-table th {
  text-align: left; font: 500 10px var(--font); color: var(--text-3);
  text-transform: uppercase; letter-spacing: 0.6px;
  padding: 8px 12px; border-bottom: 1px solid var(--border);
}
.results-table td {
  padding: 10px 12px; border-bottom: 1px solid var(--border);
  font: 13px var(--mono); color: var(--text-2); cursor: pointer;
  transition: background 0.1s;
}
.results-table tr:hover td { background: var(--bg-hover); }
.results-table .t-path { color: var(--accent); font-weight: 500; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.results-table .t-num { text-align: right; color: var(--text-3); font-size: 12px; }
.results-table .t-type { font-size: 11px; }

/* ── Grid View ── */
.results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }
.grid-card {
  background: var(--bg-card); border: 1px solid var(--border);
  border-radius: var(--r); padding: 14px 16px; cursor: pointer;
  transition: all 0.15s; animation: fadeIn 0.2s ease both;
}
.grid-card:hover { background: var(--bg-hover); border-color: var(--accent); transform: translateY(-1px); box-shadow: var(--shadow); }
.grid-card .g-icon { font-size: 20px; margin-bottom: 6px; }
.grid-card .g-name { font: 500 12px var(--mono); color: var(--accent); margin-bottom: 6px; word-break: break-all; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.grid-card .g-meta { font: 11px var(--font); color: var(--text-3); }

/* ── Project Cards ── */
.proj-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px; }
.proj-card {
  background: var(--bg-card); border: 1px solid var(--border);
  border-radius: var(--r); padding: 18px 20px; cursor: pointer;
  transition: all 0.15s ease; animation: fadeIn 0.2s ease both;
}
.proj-card:hover { background: var(--bg-hover); border-color: var(--accent); transform: translateY(-2px); box-shadow: var(--shadow); }
.proj-name { font: 600 15px var(--mono); color: var(--accent); margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
.proj-name svg { width: 16px; height: 16px; flex-shrink: 0; color: var(--accent); opacity: 0.7; }
.proj-stats { display: flex; gap: 16px; }
.proj-stat { font: 12px var(--font); color: var(--text-3); }
.proj-stat b { color: var(--text-2); font-weight: 600; }
.proj-overview { margin-bottom: 8px; }
.proj-overview-title { font: 500 12px var(--font); color: var(--text-3); text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 10px; }
.proj-total { text-align: center; padding: 16px; margin-bottom: 16px; font: 13px var(--font); color: var(--text-3); background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--r); }
.proj-total b { color: var(--accent); font: 700 20px var(--mono); }

/* ── Memory ── */
.mem-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
.mem-filters { display: flex; gap: 8px; flex-wrap: wrap; flex: 1; }
.mem-select { height: 38px; font-size: 12px; min-width: 120px; }
.mem-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.mem-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; margin-bottom: 16px; }
.mem-stat {
  background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px;
  transition: border-color 0.2s;
}
.mem-stat:hover { border-color: var(--border-hover); }
.mem-stat .label { font: 500 9px var(--font); color: var(--text-3); text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 4px; }
.mem-stat .value { font: 700 22px var(--mono); letter-spacing: -0.5px; color: var(--accent); }
.mem-facts { display: flex; flex-direction: column; gap: 6px; }
.fact-card {
  background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--r);
  padding: 14px 16px; cursor: pointer; transition: all 0.15s ease;
  animation: fadeIn 0.2s ease both; position: relative;
}
.fact-card:hover { background: var(--bg-hover); border-color: var(--border-hover); transform: translateY(-1px); box-shadow: var(--shadow); }
.fact-card.fact-new { animation: factSlideIn 0.4s ease both; border-color: var(--accent); }
@keyframes factSlideIn { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: none; } }
.fact-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
.cat-badge {
  padding: 2px 8px; border-radius: 99px; font: 600 10px var(--mono);
  letter-spacing: 0.3px; text-transform: uppercase; flex-shrink: 0;
}
.cat-root_cause { background: rgba(248,81,73,0.1); color: var(--red); }
.cat-decision { background: var(--accent-bg); color: var(--accent); }
.cat-preference { background: rgba(188,140,255,0.1); color: #bc8cff; }
.cat-constraint { background: rgba(240,136,62,0.1); color: #f0883e; }
.cat-tradeoff { background: var(--yellow-bg); color: var(--yellow); }
.cat-worldview { background: rgba(99,179,237,0.1); color: #63b3ed; }
.cat-lesson { background: var(--green-bg); color: var(--green); }
.cat-config { background: var(--yellow-bg); color: var(--yellow); }
.cat-code { background: var(--accent-bg); color: var(--accent); }
.cat-general { background: var(--bg-input); color: var(--text-3); }
.src-badge {
  padding: 2px 7px; border-radius: 5px; font: 10px var(--mono);
  background: var(--bg-input); color: var(--text-3);
}
.conf-bar { width: 40px; height: 4px; border-radius: 2px; background: var(--bg-input); overflow: hidden; flex-shrink: 0; }
.conf-fill { height: 100%; border-radius: 2px; }
.conf-high { background: var(--green); }
.conf-mid { background: var(--yellow); }
.conf-low { background: var(--red); }
.fact-time { font: 11px var(--mono); color: var(--text-3); margin-left: auto; flex-shrink: 0; }
.fact-text { font: 13px var(--font); color: var(--text-2); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.fact-meta { display: flex; gap: 8px; margin-top: 6px; font: 11px var(--mono); color: var(--text-3); flex-wrap: wrap; }
.fact-scope { padding: 1px 6px; border-radius: 4px; background: var(--bg-input); }
.mem-pager { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 16px; padding: 12px 0; }
.mem-pager button {
  padding: 7px 16px; border: 1px solid var(--border); border-radius: 8px;
  background: var(--bg-card); color: var(--text-2); font: 13px var(--font);
  cursor: pointer; transition: all 0.15s;
}
.mem-pager button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.mem-pager button:disabled { opacity: 0.3; cursor: not-allowed; }
.mem-pager .page-info { font: 12px var(--mono); color: var(--text-3); }

/* ── Events ── */
.evt-card {
  background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--r);
  padding: 12px 16px; transition: all 0.15s ease;
  animation: fadeIn 0.2s ease both; position: relative;
}
.evt-card:hover { background: var(--bg-hover); border-color: var(--border-hover); }
.evt-card.evt-new { animation: factSlideIn 0.4s ease both; border-color: var(--accent); }
.evt-top { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; flex-wrap: wrap; }
.evt-tool {
  padding: 2px 8px; border-radius: 5px; font: 600 11px var(--mono);
  letter-spacing: 0.3px; background: var(--accent-bg); color: var(--accent); flex-shrink: 0;
}
.evt-tool.t-bash { background: var(--green-bg); color: var(--green); }
.evt-tool.t-edit { background: var(--yellow-bg); color: var(--yellow); }
.evt-tool.t-write { background: rgba(188,140,255,0.1); color: #bc8cff; }
.evt-tool.t-user { background: rgba(56,139,253,0.15); color: #58a6ff; font-weight: 700; }
.evt-card.evt-prompt { border-left: 3px solid #58a6ff; }
.evt-tool.t-read { background: var(--bg-input); color: var(--text-3); }
.evt-tool.t-grep { background: var(--bg-input); color: var(--text-3); }
.evt-tool.t-glob { background: var(--bg-input); color: var(--text-3); }
.evt-importance {
  width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
}
.evt-importance.imp-high { background: var(--red); box-shadow: 0 0 6px rgba(248,81,73,0.3); }
.evt-importance.imp-mid { background: var(--yellow); }
.evt-importance.imp-low { background: var(--text-3); opacity: 0.4; }
.evt-time { font: 11px var(--mono); color: var(--text-3); margin-left: auto; flex-shrink: 0; }
.evt-summary { font: 13px var(--font); color: var(--text-2); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.evt-collection { font: 10px var(--mono); color: var(--text-3); background: var(--bg-input); padding: 1px 6px; border-radius: 4px; }

/* ── Scrollbar ── */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--border-hover); }

/* ── Responsive ── */
@media (max-width: 640px) {
  .app { padding: 20px 16px; }
  .header { flex-wrap: wrap; gap: 12px; }
  .search-row { flex-direction: column; }
  .col-select { min-width: auto; height: 40px; }
  .stat-card .value { font-size: 22px; }
  .modal { padding: 20px; }
  .kbd { display: none; }
}
</style>
</head>
<body>
<div class="app">
  <div class="header">
    <div class="logo">
      <svg class="logo-icon" width="56" height="56" viewBox="0 0 56 56" fill="none">
        <g style="transform-origin:28px 28px;animation:spinCW 8s linear infinite">
          <ellipse cx="28" cy="28" rx="18" ry="6" stroke="var(--accent)" stroke-width="1.5" opacity=".25"/>
          <circle cx="46" cy="28" r="2.5" fill="var(--accent)" opacity=".5"/>
          <circle cx="10" cy="28" r="2" fill="var(--accent)" opacity=".3"/>
        </g>
        <g style="transform-origin:28px 28px;animation:spinCCW 6s linear infinite">
          <ellipse cx="28" cy="28" rx="18" ry="6" stroke="var(--accent)" stroke-width="1.5" opacity=".25" transform="rotate(60 28 28)"/>
          <circle cx="28" cy="10.2" r="2.5" fill="var(--accent)" opacity=".5" transform="rotate(60 28 28)"/>
        </g>
        <g style="transform-origin:28px 28px;animation:spinCW 10s linear infinite">
          <ellipse cx="28" cy="28" rx="18" ry="6" stroke="var(--accent)" stroke-width="1.5" opacity=".25" transform="rotate(120 28 28)"/>
          <circle cx="28" cy="45.8" r="2" fill="var(--accent)" opacity=".4" transform="rotate(120 28 28)"/>
        </g>
        <circle class="core-pulse" cx="28" cy="28" r="4.5" fill="var(--accent)"/>
      </svg>
      <div><span>index</span>1</div>
    </div>
    <div class="header-right">
      <div class="nav-tabs">
        <button class="nav-tab active" data-panel="memory-panel" onclick="switchTab(this)">Memory</button>
        <button class="nav-tab" data-panel="events-panel" onclick="switchTab(this)">Events</button>
        <button class="nav-tab" data-panel="search-panel" onclick="switchTab(this)">Search</button>
        <button class="nav-tab" data-panel="status-panel" onclick="switchTab(this)">Status</button>
        <button class="nav-tab" data-panel="index-panel" onclick="switchTab(this)">Index</button>
      </div>
      <button class="theme-btn" onclick="toggleTheme()" title="Toggle theme" aria-label="Toggle theme">
        <svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72l1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
        <svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>
      </button>
    </div>
  </div>

  <!-- Search -->
  <div id="search-panel" class="panel">
    <div class="search-row">
      <div class="search-box">
        <svg class="s-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
        <input type="text" id="search-input" placeholder="Search knowledge base..." autofocus>
        <div class="s-actions">
          <button class="s-clear" id="s-clear" onclick="clearSearch()" aria-label="Clear search">&times;</button>
          <span class="kbd" id="kbd-slash">/</span>
        </div>
      </div>
      <select class="col-select" id="col-filter" onchange="doSearch()" aria-label="Filter collection">
        <option value="">All Projects</option>
      </select>
      <select class="col-select" id="type-filter" onchange="applyTypeFilter()" aria-label="Filter type">
        <option value="">All Types</option>
      </select>
    </div>
    <div id="meta" class="meta-bar" style="display:none"></div>
    <div id="proj-overview" class="proj-overview"></div>
    <div id="results" class="results"></div>
  </div>

  <!-- Memory -->
  <div id="memory-panel" class="panel active">
    <div class="mem-toolbar">
      <div class="mem-filters">
        <select class="col-select mem-select" id="mem-cat-filter" onchange="loadFacts()" aria-label="Filter category">
          <option value="">All Categories</option>
          <option value="root_cause">root_cause</option>
          <option value="decision">decision</option>
          <option value="preference">preference</option>
          <option value="constraint">constraint</option>
          <option value="tradeoff">tradeoff</option>
          <option value="worldview">worldview</option>
          <option value="lesson">lesson</option>
          <option value="config">config</option>
          <option value="code">code</option>
          <option value="general">general</option>
        </select>
        <select class="col-select mem-select" id="mem-session-filter" onchange="loadFacts()" aria-label="Filter session">
          <option value="">All Sessions</option>
        </select>
        <select class="col-select mem-select" id="mem-source-filter" onchange="loadFacts()" aria-label="Filter source">
          <option value="">All Sources</option>
          <option value="observer_l2">observer (LLM)</option>
          <option value="prompt_l0">prompt (L0)</option>
          <option value="capture">capture (rules)</option>
          <option value="rules_l0">rules (L0)</option>
          <option value="rules_l1">rules (L1)</option>
          <option value="mcp_learn">learn (MCP)</option>
          <option value="rebuild">rebuild (git)</option>
        </select>
      </div>
      <div class="mem-actions">
        <label class="chk"><input type="checkbox" id="mem-auto-refresh" checked onchange="toggleAutoRefresh()"> Auto-refresh</label>
        <button class="s-refresh" onclick="loadFacts()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg></button>
      </div>
    </div>
    <div id="mem-stats" class="mem-stats"></div>
    <div id="mem-facts" class="mem-facts"></div>
    <div id="mem-pager" class="mem-pager"></div>
  </div>

  <!-- Events -->
  <div id="events-panel" class="panel">
    <div class="mem-toolbar">
      <div class="mem-filters">
        <select class="col-select mem-select" id="evt-session-filter" onchange="loadEvents()" aria-label="Filter session">
          <option value="">All Sessions</option>
        </select>
      </div>
      <div class="mem-actions">
        <span id="evt-total" style="font:12px var(--mono);color:var(--text-3)"></span>
        <label class="chk"><input type="checkbox" id="evt-auto-refresh" checked onchange="toggleEvtAutoRefresh()"> Auto-refresh</label>
        <button class="s-refresh" onclick="loadEvents()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg></button>
      </div>
    </div>
    <div id="evt-list" class="mem-facts"></div>
  </div>

  <!-- Status -->
  <div id="status-panel" class="panel"><div id="status-content"></div></div>

  <!-- Index -->
  <div id="index-panel" class="panel">
    <div class="idx-form">
      <label>Path</label>
      <input type="text" id="idx-path" placeholder="/path/to/project">
      <div class="idx-opts">
        <label class="chk"><input type="checkbox" id="idx-force"> Force re-index</label>
        <input type="text" id="idx-col" placeholder="collection" style="width:170px;margin-bottom:0;">
      </div>
      <button class="btn btn-p" id="idx-btn" onclick="runIndex()">Index</button>
      <div id="idx-log" class="idx-log"></div>
    </div>
  </div>
</div>

<!-- Modal -->
<div id="chunk-modal" class="modal-overlay" onclick="if(event.target===this)closeModal()">
  <div class="modal" role="dialog" aria-modal="true">
    <div class="m-bar">
      <button class="m-btn" id="copy-btn" onclick="copyContent()" aria-label="Copy content">
        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
        <span>Copy</span>
      </button>
      <button class="m-btn" onclick="closeModal()" aria-label="Close">
        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
      </button>
    </div>
    <h2 id="m-title"></h2>
    <div id="m-meta" class="m-meta"></div>
    <div id="m-content" class="m-content"></div>
  </div>
</div>

<div id="toast" class="toast" role="alert"></div>

<script>
// ── Theme ──
function toggleTheme() {
  const html = document.documentElement;
  const next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
  html.setAttribute('data-theme', next);
  try { localStorage.setItem('index1-theme', next); } catch(e) {}
}
(function() {
  try {
    const saved = localStorage.getItem('index1-theme');
    if (saved) document.documentElement.setAttribute('data-theme', saved);
  } catch(e) {}
})();

// ── State ──
let selIdx = -1, curResults = [], curContent = '', curViewMode = 'list', curBrowseDocs = [];

// ── Tabs ──
function switchTab(btn) {
  document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
  document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
  btn.classList.add('active');
  document.getElementById(btn.dataset.panel).classList.add('active');
  if (btn.dataset.panel === 'status-panel') loadStatus();
  if (btn.dataset.panel === 'memory-panel') { loadMemStats(); loadFacts(); }
  if (btn.dataset.panel === 'events-panel') { loadEvents(); startEvtAutoRefresh(); } else { stopEvtAutoRefresh(); }
}

// ── Search ──
let timer = null;
const sInput = document.getElementById('search-input');
const sClear = document.getElementById('s-clear');
const sKbd = document.getElementById('kbd-slash');

sInput.addEventListener('input', () => {
  clearTimeout(timer);
  sClear.classList.toggle('show', sInput.value.length > 0);
  sKbd.style.display = sInput.value.length > 0 ? 'none' : '';
  timer = setTimeout(doSearch, 300);
});
sInput.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') {
    if (selIdx >= 0 && curResults[selIdx]) showChunk(curResults[selIdx].chunk_id);
    else { clearTimeout(timer); doSearch(); }
    return;
  }
  if (e.key === 'ArrowDown') { e.preventDefault(); moveSel(1); }
  if (e.key === 'ArrowUp') { e.preventDefault(); moveSel(-1); }
});

function clearSearch() {
  sInput.value = ''; sInput.focus();
  sClear.classList.remove('show'); sKbd.style.display = '';
  selIdx = -1; curResults = []; curBrowseDocs = []; activateLogo(false);
  document.getElementById('results').innerHTML = '';
  document.getElementById('meta').style.display = 'none';
  document.getElementById('proj-overview').style.display = '';
  document.getElementById('col-filter').value = '';
  document.getElementById('type-filter').value = '';
  // 恢复全局 type filter
  fetch('/api/status').then(r => r.json()).then(d => { if (d.doc_types) updTypes(d.doc_types); }).catch(() => {});
  loadProjectOverview();
}

function moveSel(d) {
  const c = document.querySelectorAll('.result-card');
  if (!c.length) return;
  c.forEach(x => x.classList.remove('selected'));
  selIdx = Math.max(-1, Math.min(c.length - 1, selIdx + d));
  if (selIdx >= 0) { c[selIdx].classList.add('selected'); c[selIdx].scrollIntoView({ block: 'nearest' }); }
}

function hl(text, q) {
  if (!q || !text) return esc(text);
  const terms = q.split(/\s+/).filter(t => t.length > 1);
  if (!terms.length) return esc(text);
  const e = esc(text);
  const p = terms.map(t => esc(t).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
  try { return e.replace(new RegExp('(' + p + ')', 'gi'), '<mark>$1</mark>'); } catch(x) { return e; }
}

async function doSearch() {
  const q = sInput.value.trim();
  const rEl = document.getElementById('results');
  const mEl = document.getElementById('meta');
  const col = document.getElementById('col-filter').value;
  selIdx = -1; curResults = [];

  if (!q) {
    // 搜索框为空但选了集合 → 浏览该集合
    if (col) { browseCollection(col); return; }
    clearSearch(); return;
  }

  document.getElementById('proj-overview').style.display = 'none';
  rEl.innerHTML = '<div class="empty"><span class="spinner"></span> Searching...</div>';
  mEl.style.display = 'none';

  try {
    let url = '/api/search?q=' + encodeURIComponent(q) + '&top_k=10';
    if (col) url += '&collection=' + encodeURIComponent(col);
    const resp = await fetch(url);
    const data = await resp.json();

    if (data.error) { rEl.innerHTML = '<div class="empty" style="color:var(--red)">' + esc(data.error) + '</div>'; return; }
    if (!data.results || !data.results.length) { activateLogo(false); rEl.innerHTML = '<div class="empty">No results for "' + esc(q) + '"</div>'; return; }

    curResults = data.results;
    activateLogo(data.results.length > 0);
    const mc = data.mode === 'hybrid' ? 'badge-hybrid' : 'badge-bm25';
    mEl.innerHTML = '<span>' + data.total + ' results &middot; ' + data.query_ms + 'ms</span><span class="badge ' + mc + '">' + esc(data.mode) + '</span>' + (data.hint ? '<span>' + esc(data.hint) + '</span>' : '');
    mEl.style.display = 'flex';

    rEl.innerHTML = data.results.map((r, i) =>
      '<div class="result-card" style="animation-delay:' + (i*30) + 'ms" onclick="showChunk(' + r.chunk_id + ')" onmouseenter="selIdx=' + i + '">' +
        '<div class="r-top">' +
          '<span class="r-dot ' + r.relevance + '"></span>' +
          '<span class="r-src">' + esc(r.source || '?') + '</span>' +
          '<div class="r-right">' + (r.token_count ? '<span class="r-tok">' + r.token_count + ' tok</span>' : '') +
            '<span class="r-score">' + r.score.toFixed(3) + '</span></div>' +
        '</div>' +
        (r.section ? '<div class="r-sec">' + esc(r.section) + '</div>' : '') +
        '<div class="r-sum">' + hl(r.summary ? r.summary.substring(0,200) : (r.content ? r.content.substring(0,200) : ''), q) + '</div>' +
      '</div>'
    ).join('');
  } catch (e) { rEl.innerHTML = '<div class="empty" style="color:var(--red)">' + esc(e.message) + '</div>'; }
}

// ── Chunk ──
async function showChunk(id) {
  const modal = document.getElementById('chunk-modal');
  const tEl = document.getElementById('m-title');
  const mEl = document.getElementById('m-meta');
  const cEl = document.getElementById('m-content');
  curContent = ''; tEl.textContent = 'Loading...'; mEl.innerHTML = ''; cEl.textContent = '';
  modal.classList.add('open');
  document.getElementById('copy-btn').classList.remove('copied');
  document.getElementById('copy-btn').querySelector('span').textContent = 'Copy';

  try {
    const data = await (await fetch('/api/chunk/' + id)).json();
    if (data.error) { tEl.textContent = 'Error'; cEl.textContent = data.error; return; }
    curContent = data.content || '';
    tEl.textContent = data.source || 'Chunk #' + data.id;
    mEl.innerHTML = [
      { l: 'Chunk', v: '#' + data.id }, { l: 'Section', v: data.section || '-' },
      { l: 'Index', v: data.chunk_index }, { l: 'Tokens', v: data.token_count || '-' },
      { l: 'Tags', v: data.tags || '-' },
    ].map(m => '<div class="m-meta-item"><div class="label">' + m.l + '</div><div class="value">' + esc(String(m.v)) + '</div></div>').join('');
    cEl.textContent = curContent;
    if (data.context && (data.context.prev_section || data.context.next_section)) {
      const d = document.createElement('div'); d.className = 'm-ctx';
      if (data.context.prev_section) { const s = document.createElement('span'); s.textContent = '\u2190 ' + data.context.prev_section; d.appendChild(s); }
      if (data.context.next_section) { const s = document.createElement('span'); s.textContent = data.context.next_section + ' \u2192'; d.appendChild(s); }
      cEl.appendChild(d);
    }
  } catch (e) { tEl.textContent = 'Error'; cEl.textContent = e.message; }
}

async function copyContent() {
  if (!curContent) return;
  try {
    await navigator.clipboard.writeText(curContent);
    const b = document.getElementById('copy-btn'); b.classList.add('copied');
    b.querySelector('span').textContent = 'Copied!';
    setTimeout(() => { b.classList.remove('copied'); b.querySelector('span').textContent = 'Copy'; }, 2000);
  } catch(e) { toast('Copy failed'); }
}

function closeModal() { document.getElementById('chunk-modal').classList.remove('open'); }

// ── Status ──
async function loadStatus() {
  const el = document.getElementById('status-content');
  el.innerHTML = '<div class="empty"><span class="spinner"></span></div>';
  try {
    const data = await (await fetch('/api/status')).json();
    if (data.error) { el.innerHTML = '<div class="empty" style="color:var(--red)">' + esc(data.error) + '</div>'; return; }
    const vc = data.vec_loaded ? 'on' : 'off';
    el.innerHTML =
      '<button class="s-refresh" onclick="loadStatus()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg> Refresh</button>' +
      '<div class="status-grid">' +
        sc('Documents', data.doc_count, '') + sc('Chunks', data.chunk_count, '') +
        sc('Collections', data.collections ? data.collections.length : 0, data.collections ? data.collections.join(', ') : '-') +
        sc('Vector', data.vec_loaded ? 'ON' : 'OFF', data.vec_loaded ? 'sqlite-vec loaded' : 'BM25 only', vc) +
      '</div><div class="status-grid">' +
        sc('Last Indexed', data.last_indexed || 'Never', '', 'small') +
        sc('Database', '', data.db_path || '-', 'small') + '</div>';
    updCol(data.collections || []);
    updTypes(data.doc_types || []);
  } catch(e) { el.innerHTML = '<div class="empty" style="color:var(--red)">' + esc(e.message) + '</div>'; }
}

function sc(l, v, s, c) {
  return '<div class="stat-card"><div class="label">' + l + '</div><div class="value ' + (c||'') + '">' + esc(String(v)) + '</div>' + (s ? '<div class="sub">' + esc(s) + '</div>' : '') + '</div>';
}

function updCol(cols) {
  const sel = document.getElementById('col-filter');
  const cur = sel.value;
  sel.innerHTML = '<option value="">All Projects</option>';
  cols.forEach(c => { const o = document.createElement('option'); o.value = c; o.textContent = c; if (c === cur) o.selected = true; sel.appendChild(o); });
}

const TYPE_LABELS = { markdown: 'Markdown', python: 'Python', rust: 'Rust', javascript: 'JavaScript', text: 'Text' };
function updTypes(types) {
  const sel = document.getElementById('type-filter');
  const cur = sel.value;
  sel.innerHTML = '<option value="">All Types</option>';
  (types || []).forEach(t => { const o = document.createElement('option'); o.value = t; o.textContent = TYPE_LABELS[t] || t; if (t === cur) o.selected = true; sel.appendChild(o); });
}

function applyTypeFilter() {
  const tf = document.getElementById('type-filter').value;
  const col = document.getElementById('col-filter').value;
  // 如果在浏览模式（没有搜索词），重新加载浏览结果
  if (!sInput.value.trim() && col) {
    browseCollection(col);
    return;
  }
  // 搜索模式下，客户端过滤
  if (!tf) { document.querySelectorAll('.result-card').forEach(c => c.style.display = ''); return; }
  document.querySelectorAll('.result-card').forEach((c, i) => {
    if (!curResults[i]) return;
    const src = curResults[i].source || '';
    const ext = src.split('.').pop().toLowerCase();
    const typeMap = { py: 'python', md: 'markdown', rs: 'rust', js: 'javascript', ts: 'javascript', txt: 'text', toml: 'text', cfg: 'text', ini: 'text', yml: 'text', yaml: 'text' };
    const docType = typeMap[ext] || 'text';
    c.style.display = (docType === tf) ? '' : 'none';
  });
}

// ── Index ──
async function runIndex() {
  const pi = document.getElementById('idx-path');
  const path = pi.value.trim();
  if (!path) { pi.focus(); return; }
  const force = document.getElementById('idx-force').checked;
  const col = document.getElementById('idx-col').value.trim() || undefined;
  const btn = document.getElementById('idx-btn');
  const log = document.getElementById('idx-log');
  btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Indexing...';
  log.classList.add('show');
  log.innerHTML = '<div><span class="ts">' + new Date().toLocaleTimeString() + '</span> ' + esc(path) + '</div>';
  try {
    const data = await (await fetch('/api/reindex', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths: [path], force, collection: col }) })).json();
    const t = new Date().toLocaleTimeString();
    if (data.error) log.innerHTML += '<div class="err"><span class="ts">' + t + '</span> ' + esc(data.error) + '</div>';
    else { log.innerHTML += '<div class="ok"><span class="ts">' + t + '</span> ' + data.indexed + ' indexed, ' + data.skipped + ' skipped, ' + data.failed + ' failed</div>'; toast(data.indexed + ' files indexed'); if (data.indexed > 0) activateLogo(true); }
  } catch(e) { log.innerHTML += '<div class="err">' + esc(e.message) + '</div>'; }
  finally { btn.disabled = false; btn.textContent = 'Index'; }
}

// ── Keys ──
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') { closeModal(); return; }
  if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;
  if (e.key === '/') { e.preventDefault(); document.querySelectorAll('.nav-tab')[0].click(); sInput.focus(); }
  if (e.key >= '1' && e.key <= '5') { const t = document.querySelectorAll('.nav-tab'); if (t[e.key-1]) switchTab(t[e.key-1]); }
});

// ── Util ──
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function toast(m) { const t = document.getElementById('toast'); t.textContent = m; t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 2500); }
function localTime(utcStr) {
  if (!utcStr) return '';
  const d = new Date(utcStr + (utcStr.endsWith('Z') ? '' : 'Z'));
  if (isNaN(d.getTime())) return utcStr.substring(5, 16).replace('T',' ');
  const m = String(d.getMonth()+1).padStart(2,'0');
  const day = String(d.getDate()).padStart(2,'0');
  const h = String(d.getHours()).padStart(2,'0');
  const min = String(d.getMinutes()).padStart(2,'0');
  return m + '-' + day + ' ' + h + ':' + min;
}

// ── Logo activation ──
function activateLogo(on) {
  const el = document.querySelector('.logo-icon');
  if (el) el.classList.toggle('active', !!on);
}

// ── Project Overview ──
async function loadProjectOverview() {
  const el = document.getElementById('proj-overview');
  try {
    const data = await (await fetch('/api/collections')).json();
    if (!data || !data.length) {
      el.innerHTML = '<div class="empty"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg><p>No indexed projects yet</p><div class="hint">Use the Index tab to add projects</div></div>';
      return;
    }
    const totalDocs = data.reduce((s, c) => s + c.doc_count, 0);
    const totalChunks = data.reduce((s, c) => s + c.chunk_count, 0);
    el.innerHTML =
      '<div class="proj-total"><b>' + data.length + '</b> projects &middot; <b>' + totalDocs + '</b> documents &middot; <b>' + totalChunks + '</b> chunks</div>' +
      '<div class="proj-grid">' + data.map((c, i) =>
        '<div class="proj-card" style="animation-delay:' + (i*50) + 'ms" onclick="browseCollection(\'' + esc(c.collection) + '\')">' +
          '<div class="proj-name"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>' + esc(c.collection) + '</div>' +
          '<div class="proj-stats"><span class="proj-stat"><b>' + c.doc_count + '</b> docs</span><span class="proj-stat"><b>' + c.chunk_count + '</b> chunks</span></div>' +
        '</div>'
      ).join('') + '</div>';
  } catch(e) { el.innerHTML = '<div class="empty" style="color:var(--red)">' + esc(e.message) + '</div>'; }
}

async function browseCollection(col) {
  document.getElementById('col-filter').value = col;
  document.getElementById('proj-overview').style.display = 'none';
  const rEl = document.getElementById('results');
  const mEl = document.getElementById('meta');
  rEl.innerHTML = '<div class="empty"><span class="spinner"></span> Loading...</div>';

  const tf = document.getElementById('type-filter').value;
  let url = '/api/browse?collection=' + encodeURIComponent(col) + '&limit=100';
  if (tf) url += '&doc_type=' + encodeURIComponent(tf);

  try {
    const data = await (await fetch(url)).json();
    if (data.error) { rEl.innerHTML = '<div class="empty" style="color:var(--red)">' + esc(data.error) + '</div>'; return; }

    // 动态更新 type filter，只显示该集合存在的类型
    if (data.doc_types) updTypes(data.doc_types);

    if (!data.docs || !data.docs.length) { rEl.innerHTML = '<div class="empty">No documents in ' + esc(col) + (tf ? ' (' + tf + ')' : '') + '</div>'; mEl.innerHTML = '<span>0 documents</span><button class="m-btn" style="margin-left:auto" onclick="clearSearch()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> Back</button>'; mEl.style.display = 'flex'; return; }

    curBrowseDocs = data.docs;

    mEl.innerHTML = '<span>' + data.docs.length + ' documents in <b>' + esc(col) + '</b></span>' +
      '<div class="view-modes">' +
        '<button class="view-mode' + (curViewMode==='list'?' active':'') + '" onclick="switchViewMode(\'list\')" title="List view"><svg viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="2" width="14" height="2" rx="0.5"/><rect x="1" y="7" width="14" height="2" rx="0.5"/><rect x="1" y="12" width="14" height="2" rx="0.5"/></svg></button>' +
        '<button class="view-mode' + (curViewMode==='table'?' active':'') + '" onclick="switchViewMode(\'table\')" title="Table view"><svg viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="0.5" opacity=".7"/><rect x="9" y="1" width="6" height="6" rx="0.5" opacity=".7"/><rect x="1" y="9" width="6" height="6" rx="0.5" opacity=".7"/><rect x="9" y="9" width="6" height="6" rx="0.5" opacity=".7"/></svg></button>' +
        '<button class="view-mode' + (curViewMode==='grid'?' active':'') + '" onclick="switchViewMode(\'grid\')" title="Grid view"><svg viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="1" width="4" height="4" rx="0.5"/><rect x="6" y="1" width="4" height="4" rx="0.5"/><rect x="11" y="1" width="4" height="4" rx="0.5"/><rect x="1" y="6" width="4" height="4" rx="0.5"/><rect x="6" y="6" width="4" height="4" rx="0.5"/><rect x="11" y="6" width="4" height="4" rx="0.5"/><rect x="1" y="11" width="4" height="4" rx="0.5"/><rect x="6" y="11" width="4" height="4" rx="0.5"/><rect x="11" y="11" width="4" height="4" rx="0.5"/></svg></button>' +
      '</div>' +
      '<button class="m-btn" onclick="clearSearch()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> Back</button>';
    mEl.style.display = 'flex';

    renderBrowseView(data.docs);
  } catch(e) { rEl.innerHTML = '<div class="empty" style="color:var(--red)">' + esc(e.message) + '</div>'; }
}

const TYPE_ICONS = {
  python: '\ud83d\udc0d', rust: '\u2699\ufe0f', javascript: '\ud83d\udfe1',
  markdown: '\ud83d\udcdd', text: '\ud83d\udcc4'
};

function switchViewMode(mode) {
  curViewMode = mode;
  document.querySelectorAll('.view-mode').forEach(b => b.classList.remove('active'));
  const btns = document.querySelectorAll('.view-mode');
  const idx = {list:0, table:1, grid:2}[mode] || 0;
  if (btns[idx]) btns[idx].classList.add('active');
  if (curBrowseDocs.length) renderBrowseView(curBrowseDocs);
}

function renderBrowseView(docs) {
  const rEl = document.getElementById('results');
  if (curViewMode === 'table') {
    rEl.innerHTML = '<table class="results-table"><thead><tr><th>File</th><th>Type</th><th style="text-align:right">Chunks</th><th style="text-align:right">Tokens</th></tr></thead><tbody>' +
      docs.map(d =>
        '<tr class="browse-row" data-doc-id="' + d.id + '">' +
          '<td class="t-path">' + esc(d.path) + '</td>' +
          '<td class="t-type">' + (TYPE_ICONS[d.doc_type]||'') + ' ' + (TYPE_LABELS[d.doc_type]||d.doc_type) + '</td>' +
          '<td class="t-num">' + d.chunk_count + '</td>' +
          '<td class="t-num">' + d.total_tokens + '</td>' +
        '</tr>'
      ).join('') + '</tbody></table>';
  } else if (curViewMode === 'grid') {
    rEl.innerHTML = '<div class="results-grid">' + docs.map((d, i) => {
      const fname = d.path.split('/').pop();
      return '<div class="grid-card browse-row" data-doc-id="' + d.id + '" style="animation-delay:' + (i*20) + 'ms">' +
        '<div class="g-icon">' + (TYPE_ICONS[d.doc_type]||'\ud83d\udcc4') + '</div>' +
        '<div class="g-name">' + esc(fname) + '</div>' +
        '<div class="g-meta">' + d.chunk_count + ' chunks &middot; ' + d.total_tokens + ' tok</div>' +
      '</div>';
    }).join('') + '</div>';
  } else {
    rEl.innerHTML = docs.map((d, i) =>
      '<div class="result-card browse-row" data-doc-id="' + d.id + '" style="animation-delay:' + (i*20) + 'ms">' +
        '<div class="r-top">' +
          '<span style="font-size:14px">' + (TYPE_ICONS[d.doc_type]||'\ud83d\udcc4') + '</span>' +
          '<span class="r-src">' + esc(d.path) + '</span>' +
          '<div class="r-right"><span class="r-tok">' + d.chunk_count + ' chunks</span><span class="r-tok">' + d.total_tokens + ' tok</span></div>' +
        '</div>' +
      '</div>'
    ).join('');
  }
  // 事件委托：安全绑定点击事件，避免 XSS
  rEl.querySelectorAll('.browse-row').forEach(el => {
    el.addEventListener('click', () => browseDoc(parseInt(el.dataset.docId)));
  });
}

async function browseDoc(docId) {
  const modal = document.getElementById('chunk-modal');
  const tEl = document.getElementById('m-title');
  const mEl = document.getElementById('m-meta');
  const cEl = document.getElementById('m-content');
  curContent = ''; tEl.textContent = 'Loading...'; mEl.innerHTML = ''; cEl.textContent = '';
  modal.classList.add('open');
  document.getElementById('copy-btn').classList.remove('copied');
  document.getElementById('copy-btn').querySelector('span').textContent = 'Copy';

  try {
    const data = await (await fetch('/api/doc/' + docId + '/chunks')).json();
    if (data.error) { tEl.textContent = 'Error'; cEl.textContent = data.error; return; }
    tEl.textContent = data.source || 'Document #' + docId;
    mEl.innerHTML = '<div class="m-meta-item"><div class="label">Chunks</div><div class="value">' + data.chunks.length + '</div></div>' +
      '<div class="m-meta-item"><div class="label">Tokens</div><div class="value">' + data.chunks.reduce((s,c) => s + (c.token_count||0), 0) + '</div></div>';
    curContent = data.chunks.map(c => (c.section ? '## ' + c.section + '\n\n' : '') + c.content).join('\n\n---\n\n');
    cEl.textContent = curContent;
  } catch(e) { tEl.textContent = 'Error'; cEl.textContent = e.message; }
}

// ── Memory ──
let memPage = 0, memTotal = 0, memLimit = 30;
let memFactsTimer = null, memStatsTimer = null, latestFactId = 0;

async function loadMemStats() {
  const el = document.getElementById('mem-stats');
  try {
    const data = await (await fetch('/api/cognition/stats')).json();
    if (data.error) { el.innerHTML = '<div class="empty" style="color:var(--red)">' + esc(data.error) + '</div>'; return; }
    const cats = data.facts_by_category || {};
    const topCats = Object.entries(cats).sort((a,b) => b[1]-a[1]).slice(0, 6);
    el.innerHTML =
      '<div class="mem-stat"><div class="label">Total Facts</div><div class="value">' + (data.facts_active||0) + '</div></div>' +
      '<div class="mem-stat"><div class="label">Models</div><div class="value">' + (data.mental_models||0) + '</div></div>' +
      topCats.map(([cat, cnt]) =>
        '<div class="mem-stat"><div class="label">' + esc(cat) + '</div><div class="value">' + cnt + '</div></div>'
      ).join('');
    // populate session filter
    if (data.recent_sessions) {
      const sel = document.getElementById('mem-session-filter');
      const cur = sel.value;
      sel.innerHTML = '<option value="">All Sessions</option>';
      data.recent_sessions.forEach(s => {
        const o = document.createElement('option');
        o.value = s.external_id;
        const t = localTime(s.created_at) || '?';
        o.textContent = t + ' (' + (s.collection||'?') + ')';
        if (s.external_id === cur) o.selected = true;
        sel.appendChild(o);
      });
    }
  } catch(e) { el.innerHTML = ''; }
}

async function loadFacts() {
  const el = document.getElementById('mem-facts');
  el.innerHTML = '<div class="empty"><span class="spinner"></span> Loading facts...</div>';
  const cat = document.getElementById('mem-cat-filter').value;
  const sess = document.getElementById('mem-session-filter').value;
  const src = document.getElementById('mem-source-filter').value;
  let url = '/api/cognition/facts?limit=' + memLimit + '&offset=' + (memPage * memLimit);
  if (cat) url += '&category=' + encodeURIComponent(cat);
  if (sess) url += '&session_id=' + encodeURIComponent(sess);
  if (src) url += '&source=' + encodeURIComponent(src);
  try {
    const data = await (await fetch(url)).json();
    if (data.error) { el.innerHTML = '<div class="empty" style="color:var(--red)">' + esc(data.error) + '</div>'; return; }
    memTotal = data.total || 0;
    if (!data.facts || !data.facts.length) { el.innerHTML = '<div class="empty">No facts found</div>'; latestFactId = 0; renderMemPager(); return; }
    el.innerHTML = data.facts.map((f, i) => renderFactCard(f, i)).join('');
    // track latest fact id for incremental polling
    latestFactId = Math.max(...data.facts.map(f => f.id));
    // bind click events
    el.querySelectorAll('.fact-card').forEach(c => {
      c.addEventListener('click', () => showFact(parseInt(c.dataset.factId)));
    });
    renderMemPager();
  } catch(e) { el.innerHTML = '<div class="empty" style="color:var(--red)">' + esc(e.message) + '</div>'; }
}

async function pollNewFacts() {
  if (memPage !== 0 || latestFactId === 0) return;  // 只在第一页增量
  const cat = document.getElementById('mem-cat-filter').value;
  const sess = document.getElementById('mem-session-filter').value;
  const src = document.getElementById('mem-source-filter').value;
  let url = '/api/cognition/facts?since_id=' + latestFactId + '&limit=50';
  if (cat) url += '&category=' + encodeURIComponent(cat);
  if (sess) url += '&session_id=' + encodeURIComponent(sess);
  if (src) url += '&source=' + encodeURIComponent(src);
  try {
    const data = await (await fetch(url)).json();
    if (!data.facts || !data.facts.length) return;
    const el = document.getElementById('mem-facts');
    // 按 id 升序排列，最新的最后 prepend（这样最新的在最上面）
    const sorted = data.facts.sort((a, b) => a.id - b.id);
    sorted.forEach(f => {
      const html = renderFactCard(f, 0);
      const tmp = document.createElement('div');
      tmp.innerHTML = html;
      const card = tmp.firstElementChild;
      card.classList.add('fact-new');
      card.addEventListener('click', () => showFact(f.id));
      el.prepend(card);
      // 高亮 1.5s 后移除
      setTimeout(() => card.classList.remove('fact-new'), 1500);
    });
    latestFactId = Math.max(latestFactId, ...data.facts.map(f => f.id));
    // 更新 total
    if (data.total) { memTotal = data.total; renderMemPager(); }
  } catch(e) { /* 静默失败，下次重试 */ }
}

function renderFactCard(f, i) {
  const cat = f.category || 'general';
  const conf = f.confidence || 0;
  const confCls = conf >= 0.8 ? 'conf-high' : conf >= 0.5 ? 'conf-mid' : 'conf-low';
  const src = f.source || '?';
  const t = localTime(f.created_at);
  const assertion = f.assertion || '';
  // scope info
  const scopes = [];
  if (f.scope_files && Array.isArray(f.scope_files) && f.scope_files.length) scopes.push(...f.scope_files.map(s => s.split('/').pop()));
  if (f.scope_modules && Array.isArray(f.scope_modules) && f.scope_modules.length) scopes.push(...f.scope_modules);

  return '<div class="fact-card" data-fact-id="' + f.id + '" style="animation-delay:' + (i*20) + 'ms">' +
    '<div class="fact-top">' +
      '<span class="cat-badge cat-' + esc(cat) + '">' + esc(cat) + '</span>' +
      '<span class="src-badge">' + esc(src) + '</span>' +
      '<div class="conf-bar" title="confidence: ' + conf.toFixed(2) + '"><div class="conf-fill ' + confCls + '" style="width:' + (conf*100) + '%"></div></div>' +
      '<span class="fact-time">' + esc(t) + '</span>' +
    '</div>' +
    '<div class="fact-text">' + esc(assertion) + '</div>' +
    (scopes.length ? '<div class="fact-meta">' + scopes.slice(0,3).map(s => '<span class="fact-scope">' + esc(s) + '</span>').join('') + '</div>' : '') +
  '</div>';
}

function renderMemPager() {
  const el = document.getElementById('mem-pager');
  const pages = Math.ceil(memTotal / memLimit);
  if (pages <= 1) { el.innerHTML = '<span class="page-info">' + memTotal + ' facts</span>'; return; }
  el.innerHTML =
    '<button onclick="memPage=Math.max(0,memPage-1);loadFacts()"' + (memPage===0?' disabled':'') + '>&lsaquo; Prev</button>' +
    '<span class="page-info">' + (memPage+1) + ' / ' + pages + ' (' + memTotal + ')</span>' +
    '<button onclick="memPage=Math.min(' + (pages-1) + ',memPage+1);loadFacts()"' + (memPage>=pages-1?' disabled':'') + '>Next &rsaquo;</button>';
}

async function showFact(factId) {
  const modal = document.getElementById('chunk-modal');
  const tEl = document.getElementById('m-title');
  const mEl = document.getElementById('m-meta');
  const cEl = document.getElementById('m-content');
  curContent = ''; tEl.textContent = 'Loading...'; mEl.innerHTML = ''; cEl.textContent = '';
  modal.classList.add('open');
  document.getElementById('copy-btn').classList.remove('copied');
  document.getElementById('copy-btn').querySelector('span').textContent = 'Copy';
  try {
    const f = await (await fetch('/api/cognition/fact/' + factId)).json();
    if (f.error) { tEl.textContent = 'Error'; cEl.textContent = f.error; return; }
    curContent = f.assertion || '';
    tEl.textContent = 'Fact #' + f.id + ' — ' + (f.category || '?');
    mEl.innerHTML = [
      { l: 'Category', v: f.category || '-' },
      { l: 'Confidence', v: (f.confidence || 0).toFixed(2) },
      { l: 'Source', v: f.source || '-' },
      { l: 'Layer', v: f.layer || '-' },
      { l: 'Collection', v: f.collection || '-' },
      { l: 'Access', v: f.access_count || 0 },
    ].map(m => '<div class="m-meta-item"><div class="label">' + m.l + '</div><div class="value">' + esc(String(m.v)) + '</div></div>').join('');

    let content = f.assertion || '';
    if (f.context_json && typeof f.context_json === 'object') {
      content += '\n\n--- context_json ---\n' + JSON.stringify(f.context_json, null, 2);
    }
    if (f.scope_files && f.scope_files.length) content += '\n\n--- scope_files ---\n' + f.scope_files.join('\n');
    if (f.scope_modules && f.scope_modules.length) content += '\n\n--- scope_modules ---\n' + f.scope_modules.join('\n');
    if (f.edges && f.edges.length) {
      content += '\n\n--- fact_edges ---\n' + f.edges.map(e => e.relation + ' → #' + e.target_fact_id + ' (conf: ' + e.confidence + ')').join('\n');
    }
    curContent = content;
    cEl.textContent = content;
  } catch(e) { tEl.textContent = 'Error'; cEl.textContent = e.message; }
}

function startAutoRefresh() {
  stopAutoRefresh();
  memFactsTimer = setInterval(pollNewFacts, 3000);   // 增量 facts 3s
  memStatsTimer = setInterval(loadMemStats, 10000);   // stats 10s
}
function stopAutoRefresh() {
  if (memFactsTimer) { clearInterval(memFactsTimer); memFactsTimer = null; }
  if (memStatsTimer) { clearInterval(memStatsTimer); memStatsTimer = null; }
}
function toggleAutoRefresh() {
  if (document.getElementById('mem-auto-refresh').checked) {
    startAutoRefresh();
  } else {
    stopAutoRefresh();
  }
}

// ── Events ──
let evtTimer = null, latestEvtId = 0;
const TOOL_CLASSES = {Bash:'t-bash',Edit:'t-edit',Write:'t-write',Read:'t-read',Grep:'t-grep',Glob:'t-glob'};

async function loadEvents() {
  const el = document.getElementById('evt-list');
  el.innerHTML = '<div class="empty"><span class="spinner"></span> Loading events...</div>';
  const sess = document.getElementById('evt-session-filter').value;
  let url = '/api/cognition/events?limit=50';
  if (sess) url += '&session_id=' + encodeURIComponent(sess);
  try {
    const data = await (await fetch(url)).json();
    if (data.error) { el.innerHTML = '<div class="empty" style="color:var(--red)">' + esc(data.error) + '</div>'; return; }
    document.getElementById('evt-total').textContent = (data.total||0) + ' events';
    if (!data.events || !data.events.length) { el.innerHTML = '<div class="empty">No events yet</div>'; latestEvtId = 0; return; }
    el.innerHTML = data.events.map((e, i) => renderEvtCard(e, i)).join('');
    latestEvtId = Math.max(...data.events.map(e => e.event_id));
    // populate session filter if needed
    populateEvtSessions(data.events);
  } catch(e) { el.innerHTML = '<div class="empty" style="color:var(--red)">' + esc(e.message) + '</div>'; }
}

async function pollNewEvents() {
  if (latestEvtId === 0) return;
  const sess = document.getElementById('evt-session-filter').value;
  let url = '/api/cognition/events?since_id=' + latestEvtId + '&limit=50';
  if (sess) url += '&session_id=' + encodeURIComponent(sess);
  try {
    const data = await (await fetch(url)).json();
    if (!data.events || !data.events.length) return;
    const el = document.getElementById('evt-list');
    const sorted = data.events.sort((a, b) => a.event_id - b.event_id);
    sorted.forEach(e => {
      const html = renderEvtCard(e, 0);
      const tmp = document.createElement('div');
      tmp.innerHTML = html;
      const card = tmp.firstElementChild;
      card.classList.add('evt-new');
      el.prepend(card);
      setTimeout(() => card.classList.remove('evt-new'), 1500);
    });
    latestEvtId = Math.max(latestEvtId, ...data.events.map(e => e.event_id));
    if (data.total) document.getElementById('evt-total').textContent = data.total + ' events';
  } catch(e) { /* silent */ }
}

function renderEvtCard(e, i) {
  const isPrompt = e.hook_event === 'UserPromptSubmit' && !e.tool_name;
  const tool = isPrompt ? 'User' : (e.tool_name || e.hook_event || '?');
  const toolCls = isPrompt ? 't-user' : (TOOL_CLASSES[tool] || '');
  const imp = (e.importance||0) >= 0.7 ? 'imp-high' : (e.importance||0) >= 0.3 ? 'imp-mid' : 'imp-low';
  const t = localTime(e.timestamp);
  const summary = e.summary || '';
  const col = e.collection || '';
  return '<div class="evt-card' + (isPrompt ? ' evt-prompt' : '') + '" style="animation-delay:' + (i*15) + 'ms">' +
    '<div class="evt-top">' +
      '<span class="evt-importance ' + imp + '" title="importance: ' + (e.importance||0).toFixed(2) + '"></span>' +
      '<span class="evt-tool ' + toolCls + '">' + esc(tool) + '</span>' +
      (col ? '<span class="evt-collection">' + esc(col) + '</span>' : '') +
      '<span class="evt-time">' + esc(t) + '</span>' +
    '</div>' +
    (summary ? '<div class="evt-summary">' + esc(summary) + '</div>' : '') +
  '</div>';
}

function populateEvtSessions(events) {
  const sel = document.getElementById('evt-session-filter');
  const cur = sel.value;
  const seen = new Set();
  sel.querySelectorAll('option').forEach(o => { if (o.value) seen.add(o.value); });
  events.forEach(e => {
    if (e.session_id && !seen.has(e.session_id)) {
      seen.add(e.session_id);
      const o = document.createElement('option');
      o.value = e.session_id;
      o.textContent = e.session_id.substring(0, 8) + '... (' + (e.collection||'?') + ')';
      if (e.session_id === cur) o.selected = true;
      sel.appendChild(o);
    }
  });
}

function startEvtAutoRefresh() { stopEvtAutoRefresh(); evtTimer = setInterval(pollNewEvents, 3000); }
function stopEvtAutoRefresh() { if (evtTimer) { clearInterval(evtTimer); evtTimer = null; } }
function toggleEvtAutoRefresh() {
  if (document.getElementById('evt-auto-refresh').checked) startEvtAutoRefresh();
  else stopEvtAutoRefresh();
}

// ── Init ──
fetch('/api/status').then(r => r.json()).then(d => {
  if (d.collections) updCol(d.collections);
  if (d.doc_types) updTypes(d.doc_types);
}).catch(() => {});
loadProjectOverview();
// Memory is default tab — load on startup + start auto-refresh
loadMemStats(); loadFacts();
startAutoRefresh();
</script>
</body>
</html>
"""


_COMPARE_TEMPLATE = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>index1 Compare</title>
<style>
:root {
  --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  --mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
  --bg: #0d1117; --bg-card: #161b22; --bg-hover: #1c2128;
  --border: #30363d; --text: #e6edf3; --text-2: #8b949e; --text-3: #6e7681;
  --accent: #58a6ff; --green: #3fb950; --yellow: #d29922; --red: #f85149;
  --purple: #bc8cff; --orange: #f0883e;
  --r: 10px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font: 14px var(--font); background: var(--bg); color: var(--text); padding: 32px; max-width: 1200px; margin: 0 auto; }
h1 { font: 600 22px var(--mono); margin-bottom: 8px; }
h1 span { color: var(--accent); }
.subtitle { color: var(--text-3); font-size: 12px; margin-bottom: 20px; }
.tabs { display: flex; gap: 0; margin-bottom: 28px; border-bottom: 2px solid var(--border); flex-wrap: wrap; }
.tab { padding: 10px 20px; font: 500 13px var(--font); color: var(--text-3); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s; white-space: nowrap; }
.tab:hover { color: var(--text-2); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
#content { min-height: 400px; }
.section { margin-bottom: 28px; }
.section h3 { font: 600 15px var(--font); color: var(--text); margin-bottom: 12px; }
.overview { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
.sys-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--r); padding: 20px; }
.sys-card h4 { margin: 0 0 12px; font: 600 16px var(--mono); }
.sys-card:first-child h4 { color: var(--accent); }
.sys-card:last-child h4 { color: var(--green); }
.sys-card .stat { font: 13px var(--font); color: var(--text-2); margin: 4px 0; }
.sys-card .stat b { color: var(--text); font-weight: 600; }
.stat-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--r); padding: 16px; }
.stat-card .label { font: 400 10px var(--font); color: var(--text-3); text-transform: uppercase; letter-spacing: 0.6px; }
.stat-card .value { font: 600 24px var(--mono); color: var(--text); margin-top: 4px; }
.tbl { width: 100%; border-collapse: collapse; margin-bottom: 8px; }
.tbl th { text-align: left; font: 500 10px var(--font); color: var(--text-3); text-transform: uppercase; letter-spacing: 0.6px; padding: 8px 12px; border-bottom: 2px solid var(--border); white-space: nowrap; }
.tbl td { padding: 10px 12px; border-bottom: 1px solid var(--border); font: 13px var(--font); color: var(--text-2); vertical-align: top; }
.tbl tr:hover td { background: var(--bg-hover); }
.win-a { color: var(--accent); font-weight: 600; }
.win-b { color: var(--green); font-weight: 600; }
.win-tie { color: var(--yellow); font-weight: 600; }
.tbl td.num { text-align: right; font-family: var(--mono); font-size: 12px; }
.detail-item { font: 12px var(--mono); color: var(--text-2); padding: 8px 10px; background: var(--bg); border-radius: 6px; margin-bottom: 6px; line-height: 1.5; }
.bar-wrap { display: flex; align-items: center; gap: 8px; }
.bar { height: 8px; border-radius: 4px; min-width: 4px; }
.bar.a { background: var(--accent); }
.bar.b { background: var(--green); }
.bar-label { font: 11px var(--mono); color: var(--text-3); min-width: 32px; text-align: right; }
.loading { text-align: center; padding: 60px; color: var(--text-3); }
.score-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; margin: 16px 0; }
.score-item { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 14px; }
.score-item .label { font: 400 10px var(--font); color: var(--text-3); text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 8px; }
.expandable { cursor: pointer; }
.expandable td { transition: background 0.1s; }
.detail { display: none; }
.detail.open { display: table-row; }
.detail td { padding: 16px; background: var(--bg-card); border-bottom: 2px solid var(--border); }
.detail-grid { display: flex; gap: 16px; }
.detail-col { flex: 1; min-width: 0; }
.detail-col h5 { font: 600 12px var(--font); margin-bottom: 6px; }
nav.top-nav { display: flex; gap: 0; margin-bottom: 20px; padding: 8px 0; border-bottom: 1px solid var(--border); }
nav.top-nav a { font: 500 12px var(--font); color: var(--text-3); text-decoration: none; padding: 6px 14px; border-radius: 6px; transition: all 0.2s; }
nav.top-nav a:hover { color: var(--text); background: var(--bg-card); }
nav.top-nav a.current { color: var(--accent); background: var(--bg-card); border: 1px solid var(--border); }
</style>
</head>
<body>
<nav class="top-nav">
  <a href="/">Search</a>
  <a href="/compare" class="current">Compare</a>
</nav>
<h1><span>index</span>1 Compare</h1>
<div class="subtitle">Dynamic comparison dashboard</div>
<div id="tabs" class="tabs"></div>
<div id="content"><div class="loading">Loading comparisons...</div></div>

<script>
const E = s => { const d = document.createElement('div'); d.textContent = String(s); return d.innerHTML; };
let cache = {}, activeId = null;

async function init() {
  try {
    const list = await (await fetch('/api/compare/list')).json();
    if (!list.length) { document.getElementById('tabs').innerHTML = '<div style="color:var(--text-3);padding:8px">No comparisons found. Add JSON files to project-docs/index/data/</div>'; return; }
    document.getElementById('tabs').innerHTML = list.map(c =>
      '<div class="tab" data-id="'+E(c.id)+'" onclick="loadTab(\''+c.id.replace(/'/g,"\\'")+ '\')">'+E(c.label)+'</div>'
    ).join('');
    loadTab(list[0].id);
  } catch(e) { document.getElementById('content').innerHTML = '<div class="loading" style="color:var(--red)">Failed to load: '+E(e.message)+'</div>'; }
}

async function loadTab(id) {
  activeId = id;
  document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.id === id));
  const el = document.getElementById('content');
  if (cache[id]) { el.innerHTML = render(cache[id]); return; }
  el.innerHTML = '<div class="loading">Loading...</div>';
  try {
    const data = await (await fetch('/api/compare/' + encodeURIComponent(id))).json();
    if (data.error) { el.innerHTML = '<div class="loading" style="color:var(--red)">'+E(data.error)+'</div>'; return; }
    cache[id] = data;
    el.innerHTML = render(data);
  } catch(e) { el.innerHTML = '<div class="loading" style="color:var(--red)">'+E(e.message)+'</div>'; }
}

function render(D) {
  if (D.dimension1) return rValue(D);
  let h = rMeta(D) + rSystems(D);
  if (D.write) h += rWrite(D);
  if (D.architecture) h += rArch(D);
  if (D.fairness) h += rFairness(D);
  h += rQueries(D);
  if (D.rootCause) h += rRC(D.rootCause);
  h += rScore(D);
  h += rExtra(D);
  return h;
}

function rMeta(D) {
  if (!D.meta) return '';
  const m = D.meta;
  let h = '<div class="section">';
  if (m.method) h += '<p style="color:var(--text-2);font-size:13px;margin-bottom:12px">'+E(m.method)+'</p>';
  if (m.note) h += '<p style="color:var(--yellow);font-size:12px;margin-bottom:12px">\u26a0 '+E(m.note)+'</p>';
  return h + '</div>';
}

function rSystems(D) {
  if (!D.systems) return '';
  const keys = Object.keys(D.systems);
  let h = '<div class="overview">';
  keys.forEach(k => {
    const s = D.systems[k];
    h += '<div class="sys-card"><h4>'+E(s.name||k)+'</h4>';
    Object.entries(s).forEach(([fk,fv]) => { if (fk !== 'name') h += '<div class="stat">'+E(fk)+': <b>'+E(String(fv))+'</b></div>'; });
    h += '</div>';
  });
  return h + '</div>';
}

function rWrite(D) {
  const w = D.write;
  let h = '<div class="section"><h3>Write Performance</h3><div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;margin-bottom:16px">';
  Object.entries(w).forEach(([k,v]) => { h += '<div class="stat-card"><div class="label">'+E(k)+'</div><div class="value" style="font-size:20px">'+E(String(v))+'</div></div>'; });
  h += '</div>';
  if (D.mem0_stored) { h += '<p style="color:var(--text-3);font-size:12px;margin-bottom:8px">mem0 stored memories:</p>'; D.mem0_stored.forEach(m => { h += '<div class="detail-item" style="color:var(--green)">'+E(m)+'</div>'; }); }
  return h + '</div>';
}

function rArch(D) {
  const A = D.architecture, cols = Object.keys(A[0]);
  let h = '<div class="section"><h3>Architecture Comparison</h3><table class="tbl"><thead><tr>';
  cols.forEach(c => { h += '<th>'+E(c)+'</th>'; });
  h += '</tr></thead><tbody>';
  A.forEach(row => { h += '<tr>'; cols.forEach(c => { h += '<td class="'+(c==='winner'?wc(row[c]):'')+'">'+E(String(row[c]||''))+'</td>'; }); h += '</tr>'; });
  return h + '</tbody></table></div>';
}

function rFairness(D) {
  let h = '<div class="section"><h3>Fairness Analysis</h3><table class="tbl"><thead><tr><th>Advantage</th><th>Detail</th></tr></thead><tbody>';
  D.fairness.forEach(f => { h += '<tr><td class="'+wc(f.advantage)+'">'+E(f.advantage)+'</td><td>'+E(f.item)+'</td></tr>'; });
  return h + '</tbody></table></div>';
}

function rQueries(D) {
  if (!D.queries || !D.queries.length) return '';
  const Q = D.queries;
  const skip = new Set(['id','query','dim','type','file','winner','notes']);
  const sys = [], det = [];
  Object.keys(Q[0]).forEach(k => { if (skip.has(k)) return; if (k.endsWith('_items')) { det.push(k); return; } if (typeof Q[0][k]==='object' && Q[0][k]!==null) sys.push(k); });
  const wins = {}; Q.forEach(q => { const w = q.winner||'tie'; wins[w]=(wins[w]||0)+1; });

  let h = '<div class="section"><h3>Search Results: '+Q.length+' Queries</h3>';
  h += '<div style="display:flex;gap:12px;margin-bottom:16px">';
  Object.entries(wins).forEach(([w,n]) => { h += '<div class="stat-card" style="flex:1;text-align:center"><div class="label">'+E(w)+'</div><div class="value '+wc(w)+'">'+n+'</div></div>'; });
  h += '</div><table class="tbl"><thead><tr><th>#</th><th>Query</th>';
  if (Q[0].dim!==undefined) h += '<th>Dim</th>';
  if (Q[0].type!==undefined) h += '<th>Type</th>';
  sys.forEach(k => { const nm = D.systems && D.systems[k] ? D.systems[k].name : k; h += '<th>'+E(nm)+'</th>'; });
  h += '<th>Winner</th></tr></thead><tbody>';

  const colSpan = 2 + (Q[0].dim!==undefined?1:0) + (Q[0].type!==undefined?1:0) + sys.length + 1;
  Q.forEach(q => {
    h += '<tr class="expandable" onclick="toggleDetail(\'q'+q.id+'\')">';
    h += '<td class="num">'+q.id+'</td><td style="font-family:var(--mono);font-size:12px;max-width:220px">'+E(q.query)+'</td>';
    if (q.dim!==undefined) h += '<td>'+E(q.dim||'')+'</td>';
    if (q.type!==undefined) h += '<td>'+E(q.type||'')+'</td>';
    sys.forEach(k => {
      const r = q[k];
      if (!r) { h += '<td>-</td>'; return; }
      let c = '';
      if (r.top) c = E(String(r.top)).substring(0,80)+'...';
      else if (r.results) c = E(String(r.results));
      else if (r.p5!==undefined) c = 'P@5:'+r.p5+' MRR:'+(r.mrr!==undefined?r.mrr.toFixed(3):'-');
      else if (r.depth) c = E(String(r.depth)).substring(0,80);
      else c = Object.entries(r).slice(0,3).map(([a,b])=>a+':'+b).join(' ');
      const rel = r.relevant===true?' \u2705':r.relevant===false?' \u274c':'';
      h += '<td style="font-size:11px;max-width:200px;overflow:hidden;text-overflow:ellipsis">'+c+rel+'</td>';
    });
    h += '<td class="'+wc(q.winner)+'">'+E(q.winner||'')+'</td></tr>';
    // Detail row (hidden, toggled by click)
    h += '<tr class="detail" id="d-q'+q.id+'"><td colspan="'+colSpan+'">';
    h += '<div class="detail-grid">';
    sys.forEach(k => {
      const r = q[k];
      const nm = D.systems && D.systems[k] ? D.systems[k].name : k;
      h += '<div class="detail-col"><h5 style="color:'+(wc(k)==='win-a'?'var(--accent)':'var(--green)')+'">'+E(nm)+' Top Result</h5>';
      if (r && r.top) {
        h += '<div class="detail-item"><div style="font-size:10px;color:var(--text-3);margin-bottom:4px">'+(r.count||'-')+' results | '+(r.lat||'-')+'ms | relevant: '+(r.relevant?'yes':'no')+'</div><div>'+E(String(r.top))+'</div></div>';
      } else if (r) {
        h += '<div class="detail-item">'+E(JSON.stringify(r))+'</div>';
      } else {
        h += '<div class="detail-item" style="color:var(--text-3)">No data</div>';
      }
      h += '</div>';
    });
    if (q.notes) h += '<div style="margin-top:8px;font-size:11px;color:var(--text-3)">'+E(q.notes)+'</div>';
    h += '</div></td></tr>';
  });
  return h + '</tbody></table></div>';
}

function rScore(D) {
  if (!D.queries || !D.queries.length) return '';
  const Q = D.queries, wins = {};
  Q.forEach(q => { const w = q.winner||'tie'; wins[w]=(wins[w]||0)+1; });
  const total = Q.length;
  let h = '<div class="section"><h3>Scoreboard</h3><div class="score-grid">';
  Object.entries(wins).forEach(([w,n]) => {
    const pct = Math.round(n/total*100);
    h += '<div class="score-item"><div class="label">'+E(w)+'</div><div class="bar-wrap"><div class="bar '+(wc(w).includes('win-a')?'a':'b')+'" style="width:'+pct+'%"></div><div class="bar-label">'+n+'/'+total+' ('+pct+'%)</div></div></div>';
  });
  return h + '</div></div>';
}

function rRC(text) {
  return '<div class="section"><h3>Root Cause Analysis</h3><div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--r);padding:20px"><p style="color:var(--text-2);font-size:13px;line-height:1.8;white-space:pre-wrap">'+E(text)+'</p></div></div>';
}

function rExtra(D) {
  const skip = new Set(['id','label','date','meta','systems','queries','write','architecture','fairness','rootCause','dimension1','dimension2','dimension3','dimension4','timestamp','scores','files','mem0_stored']);
  let h = '';
  Object.keys(D).forEach(k => {
    if (skip.has(k)) return;
    const v = D[k];
    if (v === null || v === undefined) return;
    const title = k.charAt(0).toUpperCase() + k.slice(1);
    if (Array.isArray(v) && v.length && typeof v[0]==='object') {
      const cols = Object.keys(v[0]);
      h += '<div class="section"><h3>'+E(title)+'</h3><table class="tbl"><thead><tr>';
      cols.forEach(c => { h += '<th>'+E(c)+'</th>'; });
      h += '</tr></thead><tbody>';
      v.forEach(row => {
        h += '<tr>';
        cols.forEach(c => {
          let val = row[c];
          if (val === true) val = '\u2705';
          else if (val === false) val = '\u274c';
          else if (val === null) val = '-';
          else if (Array.isArray(val)) { h += '<td style="font-size:11px">'+val.map(it => typeof it==='object'?'<div class="detail-item">'+E(it.src||'')+': '+E(it.txt||'')+'</div>':'<div>'+E(String(it))+'</div>').join('')+'</td>'; return; }
          const isNum = typeof val==='number';
          h += '<td'+(isNum?' class="num"':'')+'>'+E(String(val))+'</td>';
        });
        h += '</tr>';
      });
      h += '</tbody></table></div>';
    } else if (typeof v==='object' && !Array.isArray(v)) {
      const subs = Object.keys(v);
      if (subs.length && typeof v[subs[0]]==='object' && !Array.isArray(v[subs[0]])) {
        h += '<div class="section"><h3>'+E(title)+'</h3><div class="overview">';
        subs.forEach(sk => {
          h += '<div class="sys-card"><h4>'+E(sk)+'</h4>';
          Object.entries(v[sk]).forEach(([fk,fv]) => {
            let dv = fv===true?'\u2705':fv===false?'\u274c':fv===null?'-':String(fv);
            h += '<div class="stat">'+E(fk)+': <b>'+E(dv)+'</b></div>';
          });
          h += '</div>';
        });
        h += '</div></div>';
      } else {
        h += '<div class="section"><h3>'+E(title)+'</h3><div class="sys-card">';
        Object.entries(v).forEach(([fk,fv]) => {
          let dv = fv===true?'\u2705':fv===false?'\u274c':fv===null?'-':String(fv);
          h += '<div class="stat">'+E(fk)+': <b>'+E(dv)+'</b></div>';
        });
        h += '</div></div>';
      }
    }
  });
  return h;
}

function rValue(D) {
  let h = '<div class="section"><h3>Data Source Analysis</h3><p style="color:var(--text-3);font-size:12px;margin-bottom:16px">'+E(D.timestamp||'')+'</p>';
  if (D.dimension1 && D.dimension1.summary) {
    h += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px">';
    Object.values(D.dimension1.summary).forEach(s => {
      h += '<div class="stat-card"><div class="label">'+E(s.label)+'</div><div class="value" style="font-size:20px">'+s.count+'</div>';
      h += '<div style="font-size:11px;color:var(--text-3)">avg conf: '+s.avg_confidence+' | ctx fill: '+s.ctx_fill_rate+'%</div></div>';
    });
    h += '</div>';
  }
  if (D.dimension1 && D.dimension1.sources) {
    h += '<table class="tbl"><thead><tr><th>Source</th><th>Count</th><th>Avg Conf</th><th>Avg Len</th><th>Categories</th></tr></thead><tbody>';
    D.dimension1.sources.forEach(s => { h += '<tr><td>'+E(s.source)+'</td><td class="num">'+s.count+'</td><td class="num">'+s.avg_confidence.toFixed(3)+'</td><td class="num">'+s.avg_assertion_len.toFixed(1)+'</td><td class="num">'+s.category_count+'</td></tr>'; });
    h += '</tbody></table>';
  }
  h += '</div>';
  if (D.dimension3) {
    h += '<div class="section"><h3>Embedding Quality</h3>';
    Object.entries(D.dimension3).forEach(([k,emb]) => {
      h += '<div class="sys-card" style="margin-bottom:12px"><h4>'+E(emb.name||k)+'</h4>';
      h += '<div class="stat">Dim: <b>'+emb.dim+'</b> | Latency: <b>'+emb.latency_ms+'ms</b> | Avg Margin: <b>'+emb.avg_margin.toFixed(4)+'</b></div>';
      if (emb.triplets) {
        h += '<table class="tbl" style="margin-top:8px"><thead><tr><th>Query</th><th>Pos</th><th>Neg</th><th>Margin</th></tr></thead><tbody>';
        emb.triplets.forEach(t => { const mc = t.margin<0?'color:var(--red)':t.margin<0.05?'color:var(--yellow)':'color:var(--green)'; h += '<tr><td style="font-size:12px">'+E(t.q)+'</td><td class="num">'+(t.pos!==undefined?t.pos.toFixed(4):'-')+'</td><td class="num">'+(t.neg!==undefined?t.neg.toFixed(4):'-')+'</td><td class="num" style="'+mc+'">'+t.margin.toFixed(4)+'</td></tr>'; });
        h += '</tbody></table>';
      }
      h += '</div>';
    });
    h += '</div>';
  }
  return h;
}

function wc(w) {
  if (!w) return '';
  const l = w.toLowerCase();
  if (l==='tie'||l==='draw') return 'win-tie';
  if (l.includes('idx1')||l.includes('index1')||l==='index1') return 'win-a';
  return 'win-b';
}

function toggleDetail(id) {
  const el = document.getElementById('d-'+id);
  if (el) el.classList.toggle('open');
}

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


# ── Web UI 后台服务管理 ──

_WEB_PID_FILE = INDEX1_HOME / ".web_server.pid"


def _is_port_open(port: int, host: str = "127.0.0.1") -> bool:
    """检测端口是否有进程在监听"""
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.settimeout(0.5)
            return s.connect_ex((host, port)) == 0
    except OSError:
        return False


def _is_process_alive(pid: int) -> bool:
    """检测进程是否存活"""
    try:
        os.kill(pid, 0)
        return True
    except (OSError, ProcessLookupError):
        return False


def _read_pid_file() -> dict | None:
    """读取 PID 文件，返回 {pid, port, started_at} 或 None"""
    try:
        if _WEB_PID_FILE.exists():
            data = json.loads(_WEB_PID_FILE.read_text())
            if isinstance(data, dict) and "pid" in data:
                return data
    except (json.JSONDecodeError, OSError):
        pass
    return None


def _write_pid_file(pid: int, port: int) -> None:
    """写入 PID 文件"""
    try:
        INDEX1_HOME.mkdir(parents=True, exist_ok=True)
        _WEB_PID_FILE.write_text(
            json.dumps({"pid": pid, "port": port, "started_at": time.time()})
        )
    except OSError:
        pass


def _cleanup_pid_file() -> None:
    """清理 stale PID 文件"""
    try:
        _WEB_PID_FILE.unlink(missing_ok=True)
    except OSError:
        pass


def ensure_web_running(config: Config | None = None) -> bool:
    """幂等启动 Web UI 后台服务。

    已运行 → 跳过，返回 True
    新启动 → 拉起后台进程，返回 True
    失败 → 返回 False（不崩溃）
    """
    try:
        if config is None:
            config = load_config()

        port = config.web_port

        # 1. 检查 PID 文件
        pid_info = _read_pid_file()
        if pid_info:
            pid = pid_info["pid"]
            saved_port = pid_info.get("port", port)
            # 进程存活 + 端口正常 → 已在运行
            if _is_process_alive(pid) and _is_port_open(saved_port):
                return True
            # stale PID → 清理
            _cleanup_pid_file()

        # 2. 端口已被占用（可能是手动启动的 index1 web）
        if _is_port_open(port):
            return True

        # 3. 启动新进程
        proc = subprocess.Popen(
            [sys.executable, "-m", "index1.cli", "web", "-p", str(port)],
            start_new_session=True,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )

        # 4. 写 PID 文件
        _write_pid_file(proc.pid, port)
        logger.info("Web UI 后台启动: pid=%d, port=%d", proc.pid, port)
        return True

    except Exception as e:
        logger.debug("Web UI 自动启动失败: %s", e)
        return False
