"""Web UI — Flask 单页应用

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

from __future__ import annotations

import asyncio
import json
import logging
import os
import shutil
import socket
import subprocess
import sys
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path

from flask import Flask, jsonify, render_template, request

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

logger = logging.getLogger(__name__)


def _parse_log_ts(line: str) -> datetime | None:
    """Extract UTC timestamp from a log line (maintenance or observer_failures format).

    maintenance.log:        ``2026-02-15 21:22:06,435 INFO ...``
    observer_failures.log:  ``2026-02-15T09:45:10+00:00 claude: ...``
    """
    try:
        if len(line) >= 19 and line[4] == "-" and line[7] == "-":
            if "T" in line[:20]:
                # ISO 8601 with timezone
                return datetime.fromisoformat(line[:25]).astimezone(timezone.utc)
            else:
                # maintenance.log format: "2026-02-15 21:22:06,435" — 本地时间
                # astimezone() 对 naive datetime 自动视为本地时间，再转 UTC
                return datetime.strptime(line[:19], "%Y-%m-%d %H:%M:%S").astimezone(
                    timezone.utc
                )
    except (ValueError, IndexError):
        pass
    return None


def _time_range_cutoff(time_range: str | None) -> str | None:
    """Convert time_range (today/week/month) to ISO timestamp cutoff."""
    if not time_range:
        return None
    now = datetime.now(timezone.utc)
    if time_range == "today":
        cutoff = now.replace(hour=0, minute=0, second=0, microsecond=0)
    elif time_range == "week":
        cutoff = now - timedelta(days=7)
    elif time_range == "month":
        cutoff = now - timedelta(days=30)
    else:
        return None
    return cutoff.strftime("%Y-%m-%dT%H:%M:%S")


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()

    # 服务器启动时间：/api/logs 只返回启动后的条目（修复后重启即清屏）
    _server_start = datetime.now(timezone.utc)

    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 render_template("index.html")

    @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()
            from . import __version__
            stats["version"] = __version__

            # Hooks 存活检测：最近 raw_event 时间
            if _cog_db:
                try:
                    row = _cog_db.conn.execute(
                        "SELECT timestamp FROM raw_events ORDER BY event_id DESC LIMIT 1"
                    ).fetchone()
                    stats["last_event"] = row[0] if row else None
                    cnt = _cog_db.conn.execute(
                        "SELECT COUNT(*) FROM raw_events WHERE timestamp > datetime('now', '-1 hour')"
                    ).fetchone()
                    stats["events_last_hour"] = cnt[0] if cnt else 0
                except Exception:
                    stats["last_event"] = None
                    stats["events_last_hour"] = 0

            return jsonify(stats)
        except Exception as e:
            logger.warning("获取状态失败: %s", e)
            return jsonify({"error": "failed to get status"}), 500

    @app.route("/api/logs")
    def api_logs():
        """最近错误日志 — 只返回服务器启动后的问题条目，修复后重启即清屏"""
        result: dict[str, list[str]] = {}
        # 取服务器启动时间和 2 小时前的较晚者
        cutoff = max(_server_start, datetime.now(timezone.utc) - timedelta(hours=2))
        for name in ("observer_failures.log", "maintenance.log"):
            path = INDEX1_HOME / name
            try:
                if not path.exists() or path.stat().st_size == 0:
                    continue
                # 文件超过 2 小时未修改 → 跳过（快速路径）
                mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
                if mtime < cutoff:
                    continue
                # 只读文件尾部 8KB（避免大日志全量加载）
                fsize = path.stat().st_size
                with open(path, "rb") as f:
                    f.seek(max(0, fsize - 8192))
                    tail = f.read().decode("utf-8", errors="replace")
                lines = tail.strip().split("\n")[-50:]
                # 过滤测试产生的 mock provider 记录
                if name == "observer_failures.log":
                    lines = [l for l in lines if "failing:" not in l]
                # maintenance.log 只显示 ERROR/WARNING（成功的 INFO 不展示）
                if name == "maintenance.log":
                    lines = [l for l in lines if any(
                        lv in l for lv in ("WARNING", "ERROR", "CRITICAL")
                    )]
                # 时间过滤：只保留 cutoff 之后的行
                recent: list[str] = []
                for l in lines:
                    ts = _parse_log_ts(l)
                    if ts is None or ts >= cutoff:
                        recent.append(l)  # 无法解析时间的行保留（多行续行）
                if recent:
                    result[name] = recent[-20:]
            except Exception:
                continue
        return jsonify(result)

    # PyPI 版本缓存（进程级，30 分钟）
    _pypi_cache: dict = {"version": None, "ts": 0.0}

    @app.route("/api/version")
    def api_version():
        import time
        from . import __version__
        now = time.time()
        latest = _pypi_cache["version"]
        # 30 分钟缓存
        if latest is None or now - _pypi_cache["ts"] > 1800:
            try:
                import urllib.request
                req = urllib.request.Request(
                    "https://pypi.org/pypi/index1/json",
                    headers={"Accept": "application/json"},
                )
                with urllib.request.urlopen(req, timeout=3) as resp:
                    import json as _json
                    data = _json.loads(resp.read())
                    latest = data.get("info", {}).get("version")
                    _pypi_cache["version"] = latest
                    _pypi_cache["ts"] = now
            except Exception:
                latest = _pypi_cache["version"]  # 用上次缓存
        # 语义版本比较：只有 latest > current 才算有更新
        update_available = False
        if latest and latest != __version__:
            try:
                from packaging.version import Version
                update_available = Version(latest) > Version(__version__)
            except Exception:
                # packaging 未安装时简单字符串比较
                update_available = latest > __version__
        return jsonify({
            "current": __version__,
            "latest": latest,
            "update_available": update_available,
        })

    @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:
            collection = request.args.get("collection") or None
            stats = _cog_db.get_stats(collection=collection)
            # 按 category 统计（支持 collection 过滤）
            cat_sql = "SELECT category, COUNT(*) as cnt FROM facts WHERE status='active'"
            cat_params: list = []
            if collection:
                cat_sql += " AND collection=?"
                cat_params.append(collection)
            cat_sql += " GROUP BY category"
            by_cat: dict[str, int] = {}
            for row in _cog_db.conn.execute(cat_sql, cat_params).fetchall():
                by_cat[row["category"]] = row["cnt"]
            stats["facts_by_category"] = by_cat
            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/collections")
    def api_cognition_collections():
        """返回 facts 中的 distinct collections 及其计数"""
        if not _cog_db:
            return jsonify({"error": "cognitive db not available"}), 503
        try:
            rows = _cog_db.conn.execute(
                "SELECT collection, COUNT(*) as cnt FROM facts "
                "WHERE status='active' AND collection IS NOT NULL AND collection != '' "
                "GROUP BY collection ORDER BY cnt DESC"
            ).fetchall()
            return jsonify([{"name": r["collection"], "count": r["cnt"]} for r in rows])
        except Exception as e:
            logger.warning("获取 cognition collections 失败: %s", e)
            return jsonify({"error": "failed to get collections"}), 500

    @app.route("/api/cognition/search")
    def api_cognition_search():
        """搜索认知记忆 — BM25 + 向量混合搜索 facts"""
        if not _cog_db:
            return jsonify({"error": "cognitive db not available"}), 503
        q = request.args.get("q", "").strip()
        if not q:
            return jsonify({"facts": [], "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", 20)), 100)
        except (ValueError, TypeError):
            return jsonify({"error": "top_k must be an integer"}), 400
        try:
            from .cognitive.search_cog import recall as cog_recall

            # lazy 预热 embedder（首次搜索时加载 ONNX 模型，后续跳过）
            if _embedder and not _embedder.available:
                try:
                    _embedder._ensure_encoder()
                except Exception:
                    pass  # 预热失败→BM25-only 降级

            result = _run_async(
                cog_recall(
                    q, _cog_db, _embedder,
                    collection=collection, top_k=top_k,
                )
            )
            facts = []
            for f in result.facts:
                d = {
                    "id": f.get("id"),
                    "assertion": f.get("assertion", ""),
                    "category": f.get("category", ""),
                    "confidence": f.get("confidence", 0),
                    "source": f.get("source", ""),
                    "session_id": f.get("session_id"),
                    "collection": f.get("collection"),
                    "created_at": f.get("created_at"),
                    "score": round(f.get("_rrf_score", 0), 4),
                    "access_count": f.get("access_count", 0),
                    "context_json": None,
                }
                # 解析 context_json
                cj = f.get("context_json")
                if isinstance(cj, str):
                    try:
                        d["context_json"] = json.loads(cj)
                    except Exception:
                        pass
                elif isinstance(cj, dict):
                    d["context_json"] = cj
                facts.append(d)
            return jsonify({
                "facts": facts,
                "total": result.total,
                "mode": result.mode,
                "query_ms": round(result.query_ms, 1),
            })
        except Exception as e:
            logger.warning("认知搜索失败: %s", e)
            return jsonify({"error": "search failed"}), 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
        time_range = request.args.get("time_range") or None
        time_cutoff = _time_range_cutoff(time_range)
        source = request.args.get("source") or None
        collection = request.args.get("collection") 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 time_cutoff:
                sql += " AND created_at>=?"
                params.append(time_cutoff)
            if source:
                sql += " AND source=?"
                params.append(source)
            if collection:
                sql += " AND collection=?"
                params.append(collection)
            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 time_cutoff:
                count_sql += " AND created_at>=?"
                count_params.append(time_cutoff)
            if source:
                count_sql += " AND source=?"
                count_params.append(source)
            if collection:
                count_sql += " AND collection=?"
                count_params.append(collection)
            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:
            collection = request.args.get("collection") or None
            sql = ("SELECT external_id, collection, created_at, "
                   "(SELECT COUNT(*) FROM facts WHERE session_id=sessions.external_id AND status='active') as fact_count "
                   "FROM sessions")
            params: list = []
            if collection:
                sql += " WHERE collection=?"
                params.append(collection)
            sql += " ORDER BY created_at DESC LIMIT 50"
            rows = _cog_db.conn.execute(sql, params).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
        time_range = request.args.get("time_range") or None
        time_cutoff = _time_range_cutoff(time_range)
        hook_event = request.args.get("hook_event") or None
        collection = request.args.get("collection") or None
        include_payload = request.args.get("include_payload", "").lower() in ("1", "true", "yes")
        try:
            cols = ("event_id, session_id, timestamp, hook_event, tool_name,"
                    " collection, summary, importance, signals_json")
            if include_payload:
                cols += ", payload"
            sql = "SELECT " + cols + " FROM raw_events WHERE 1=1"
            params: list = []
            if since_id > 0:
                sql += " AND event_id>?"
                params.append(since_id)
            if time_cutoff:
                sql += " AND timestamp>=?"
                params.append(time_cutoff)
            if hook_event:
                sql += " AND hook_event=?"
                params.append(hook_event)
            if collection:
                sql += " AND collection=?"
                params.append(collection)
            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 time_cutoff:
                count_sql += " AND timestamp>=?"
                count_params.append(time_cutoff)
            if hook_event:
                count_sql += " AND hook_event=?"
                count_params.append(hook_event)
            if collection:
                count_sql += " AND collection=?"
                count_params.append(collection)
            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

    # ── Observations API (#241 — 结构化事件记录) ──

    @app.route("/api/cognition/observations")
    def api_cognition_observations():
        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
        collection = request.args.get("collection") or None
        time_range = request.args.get("time_range") or None
        time_cutoff = _time_range_cutoff(time_range)
        obs_type = request.args.get("obs_type") or None
        try:
            since_id = int(request.args.get("since_id", 0))
        except (ValueError, TypeError):
            since_id = 0
        try:
            sql = ("SELECT id, session_id, collection, obs_type, title, narrative,"
                   " concepts, files, provider, model, source, timestamp"
                   " FROM observations WHERE 1=1")
            params: list = []
            if since_id > 0:
                sql += " AND id>?"
                params.append(since_id)
            if collection:
                sql += " AND collection=?"
                params.append(collection)
            if time_cutoff:
                sql += " AND timestamp>=?"
                params.append(time_cutoff)
            if obs_type:
                sql += " AND obs_type=?"
                params.append(obs_type)
            sql += " ORDER BY id DESC LIMIT ? OFFSET ?"
            params.extend([limit, offset])
            rows = _cog_db.conn.execute(sql, params).fetchall()

            # 收集 observation_ids 用于关联 facts
            obs_list = []
            obs_ids = []
            for r in rows:
                d = dict(r)
                for key in ("concepts", "files"):
                    if isinstance(d.get(key), str):
                        try:
                            d[key] = json.loads(d[key])
                        except Exception:
                            pass
                d["facts"] = []
                obs_list.append(d)
                obs_ids.append(d["id"])

            # 批量查询关联 facts
            if obs_ids:
                placeholders = ",".join("?" * len(obs_ids))
                fact_rows = _cog_db.conn.execute(
                    f"SELECT id, assertion, category, confidence, observation_id"
                    f" FROM facts WHERE observation_id IN ({placeholders})"
                    f" AND status='active' ORDER BY id",
                    obs_ids,
                ).fetchall()
                # 按 observation_id 分组
                facts_by_obs: dict[int, list] = {}
                for fr in fact_rows:
                    fd = dict(fr)
                    oid = fd.pop("observation_id")
                    facts_by_obs.setdefault(oid, []).append(fd)
                for obs_d in obs_list:
                    obs_d["facts"] = facts_by_obs.get(obs_d["id"], [])

            # total count
            count_sql = "SELECT COUNT(*) FROM observations WHERE 1=1"
            count_params: list = []
            if collection:
                count_sql += " AND collection=?"
                count_params.append(collection)
            if time_cutoff:
                count_sql += " AND timestamp>=?"
                count_params.append(time_cutoff)
            if obs_type:
                count_sql += " AND obs_type=?"
                count_params.append(obs_type)
            total = _cog_db.conn.execute(count_sql, count_params).fetchone()[0]

            return jsonify({"observations": obs_list, "total": total})
        except Exception as e:
            logger.warning("获取 observations 失败: %s", e)
            return jsonify({"error": "failed to get observations"}), 500

    # ── Observer API (#239) ──

    def _check_provider_available(name: str, cfg: Config) -> bool:
        """轻量 Provider 可用性检测（Web 进程不持有 SessionObserver）"""
        try:
            if name == "claude":
                return shutil.which("claude") is not None
            elif name == "gemini":
                return bool(cfg.gemini_api_key or os.environ.get("GEMINI_API_KEY"))
            elif name == "openrouter":
                return bool(cfg.openrouter_api_key or os.environ.get("OPENROUTER_API_KEY"))
            elif name == "openclaw":
                return bool(cfg.openclaw_api_key or os.environ.get("OPENCLAW_API_KEY"))
            elif name == "ollama":
                from urllib.parse import urlparse
                parsed = urlparse(cfg.ollama_url)
                host = parsed.hostname or "localhost"
                port = parsed.port or 11434
                with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                    s.settimeout(0.5)
                    return s.connect_ex((host, port)) == 0
        except Exception as e:
            logger.debug("Provider %s 检测失败: %s", name, e)
        return False

    @app.route("/api/cognition/observer")
    def api_cognition_observer():
        try:
            from .cognitive.observer_health import check_observer_health
            health = check_observer_health(_cog_db)
        except Exception as e:
            logger.warning("Observer health check 失败: %s", e)
            health = {"status": "unknown", "cb_state": "unknown", "failures": 0,
                       "last_failure_age_hours": None, "recent_circuit_opens": 0,
                       "message": f"Health check failed: {type(e).__name__}: {e}"}

        # Provider 模型名映射（从 OBSERVER_MODELS 集中管理，不硬编码）
        model_map = {
            "claude": OBSERVER_MODELS.get("claude_cli", "unknown"),
            "gemini": OBSERVER_MODELS.get("gemini", "unknown"),
            "openrouter": OBSERVER_MODELS.get("openrouter", "unknown"),
            "openclaw": OBSERVER_MODELS.get("openclaw", "unknown"),
            "ollama": OBSERVER_MODELS.get("ollama", "unknown"),
        }

        # 按优先级构建 provider 列表
        providers = []
        active_provider = None
        for i, name in enumerate(config.observer_provider_priority, 1):
            available = _check_provider_available(name, config)
            model = model_map.get(name, "unknown")
            # 用户配置覆盖 claude 模型名
            if name == "claude" and config.observer_claude_model:
                model = config.observer_claude_model
            entry = {"name": name, "available": available, "model": model, "priority": i}
            providers.append(entry)
            if available and active_provider is None:
                active_provider = {"name": name, "model": model}

        # 检测 observer.html 模板是否存在
        try:
            observer_page_available = bool(app.jinja_loader and app.jinja_loader.get_source(app.jinja_env, "observer.html"))
        except Exception:
            observer_page_available = False

        return jsonify({
            "health": health,
            "active_provider": active_provider,
            "providers": providers,
            "observer_page": observer_page_available,
        })

    @app.route("/observer")
    def observer_page():
        try:
            return render_template("observer.html")
        except Exception:
            return (
                "<h3>Observer page not available</h3>"
                "<p>The <code>observer.html</code> template is not included in the repository.</p>"
                "<p>The Observer API is still available at "
                "<a href='/api/cognition/observer'>/api/cognition/observer</a>.</p>"
                "<p><a href='/'>Back to index1</a></p>"
            ), 200

    # ── Compare API ──

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

    @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


# ── 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. 启动新进程（B: entry point 替代 python -m，避免模块解析问题）
        import shutil
        import time

        index1_bin = shutil.which("index1")
        if not index1_bin:
            logger.warning("index1 不在 PATH 中，Web UI 自动启动可能失败")
            index1_bin = "index1"
        log_file = INDEX1_HOME / "web_server.log"
        stderr_fh = open(log_file, "a")  # C: stderr 写日志，不吞错误
        try:
            proc = subprocess.Popen(
                [index1_bin, "web", "-p", str(port)],
                start_new_session=True,
                stdout=subprocess.DEVNULL,
                stderr=stderr_fh,
            )

            # 4. 写 PID 文件
            _write_pid_file(proc.pid, port)

            # A: 启动后健康检查 — 等待最多 2s 确认进程存活且端口开放
            for _ in range(4):
                time.sleep(0.5)
                if proc.poll() is not None:
                    # 进程已退出，启动失败
                    logger.warning("Web UI 进程启动后立即退出 (rc=%s)，详见 %s", proc.returncode, log_file)
                    _cleanup_pid_file()
                    return False
                if _is_port_open(port):
                    logger.info("Web UI 后台启动: pid=%d, port=%d", proc.pid, port)
                    return True

            # 2s 后端口仍未开放，但进程活着 — 可能还在初始化，乐观返回
            logger.info("Web UI 后台启动(端口未确认): pid=%d, port=%d", proc.pid, port)
            return True
        finally:
            stderr_fh.close()

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