from __future__ import annotations

import argparse
import datetime as dt
import json
import sqlite3
import subprocess
import time
import sys
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from hashlib import sha256
from typing import Any
from urllib.parse import urlparse, parse_qs

ROOT = Path(__file__).resolve().parents[2]
SNAPSHOT_ADMIN_VERSION = "snapshot-admin-web-v6"
KIS_COLLECTION_DB = ROOT / "src" / "quant_engine" / "kis_data_collection.db"
KIS_COLLECTION_REPORT = ROOT / "Temp" / "kis_data_collection_v1.json"
WORKBOOK_JSON = ROOT / "GatherTradingData.json"
WORKBOOK_XLSX = ROOT / "GatherTradingData.xlsx"
QUALITATIVE_SELL_DB = ROOT / "outputs" / "qualitative_sell_strategy" / "qualitative_sell_strategy.db"

# WBS-7.9 부속 — 테이블별 그리드 조회(Tabler). 화이트리스트에 없는 테이블명은
# SQL에 절대 보간되지 않는다(요청 테이블명을 그대로 SELECT 문에 넣지 않고
# 아래 레지스트리 키와 정확히 일치할 때만 허용).
WORKSPACE_BROWSABLE_TABLES = (
    "settings",
    "account_snapshot",
    "workspace_change_log",
    "workspace_approval_v2",
    "workspace_lock",
    "workspace_meta",
)
COLLECTION_BROWSABLE_TABLES = (
    "collection_runs",
    "collection_snapshots",
    "collection_source_errors",
)
QUALITATIVE_SELL_BROWSABLE_TABLES = (
    "sell_strategy_results",
    "satellite_recommendations",
)

# Editable tables configurations (WBS requirement 2)
EDITABLE_TABLES = {
    "settings",
    "account_snapshot",
}


def _resolve_table_db(table: str, workspace_db_path: Path) -> Path | None:
    if table in WORKSPACE_BROWSABLE_TABLES:
        return Path(workspace_db_path)
    if table in COLLECTION_BROWSABLE_TABLES:
        return KIS_COLLECTION_DB
    if table in QUALITATIVE_SELL_BROWSABLE_TABLES:
        return QUALITATIVE_SELL_DB
    return None


def list_browsable_tables(workspace_db_path: Path) -> list[dict[str, Any]]:
    tables: list[dict[str, Any]] = []
    for table in (
        *WORKSPACE_BROWSABLE_TABLES,
        *COLLECTION_BROWSABLE_TABLES,
        *QUALITATIVE_SELL_BROWSABLE_TABLES,
    ):
        db_path = _resolve_table_db(table, workspace_db_path)
        exists = bool(db_path and db_path.exists())
        row_count = 0
        if exists:
            try:
                with sqlite3.connect(db_path) as conn:
                    row_count = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]  # noqa: S608 - table is whitelist-checked above
            except sqlite3.OperationalError:
                exists = False
        tables.append({
            "table": table,
            "db": str(db_path) if db_path else "",
            "exists": exists,
            "row_count": row_count,
            "editable": table in EDITABLE_TABLES,
        })
    tables.sort(key=lambda item: (
        0 if item["table"] == "account_snapshot" else 1 if item["table"] == "settings" else 2,
        0 if item["row_count"] else 1,
        item["table"],
    ))
    return tables


def fetch_table_rows(
    table: str,
    workspace_db_path: Path,
    *,
    limit: int = 50,
    offset: int = 0,
    filter_text: str = "",
    column_filters: dict[str, str] | None = None,
) -> dict[str, Any]:
    db_path = _resolve_table_db(table, workspace_db_path)
    if db_path is None:
        raise ValueError(f"unknown or non-browsable table: {table}")
    if not db_path.exists():
        return {"table": table, "db": str(db_path), "columns": [], "rows": [], "total": 0, "limit": limit, "offset": offset, "editable": table in EDITABLE_TABLES}
    with sqlite3.connect(db_path) as conn:
        conn.row_factory = sqlite3.Row
        cursor = conn.execute(f"SELECT rowid as _rowid, * FROM {table} ORDER BY rowid DESC",)  # noqa: S608 - whitelisted table name
        all_rows = [dict(row) for row in cursor.fetchall()]
        columns = [description[0] for description in cursor.description] if cursor.description else []
    cleaned_filter_text = str(filter_text or "").strip().lower()
    normalized_column_filters = {str(key): str(value).strip().lower() for key, value in (column_filters or {}).items() if str(value).strip()}

    def _match_row(row: dict[str, Any]) -> bool:
        display_row = {k: v for k, v in row.items() if not str(k).startswith("_")}
        haystack = json.dumps(display_row, ensure_ascii=False, default=str).lower()
        if cleaned_filter_text and cleaned_filter_text not in haystack:
            return False
        for key, needle in normalized_column_filters.items():
            cell = str(display_row.get(key, "") or "").lower()
            if needle not in cell:
                return False
        return True

    filtered_rows = [row for row in all_rows if _match_row(row)]
    total = len(filtered_rows)
    rows = filtered_rows[offset: offset + limit]
    return {"table": table, "db": str(db_path), "columns": columns, "rows": rows, "total": total, "limit": limit, "offset": offset, "editable": table in EDITABLE_TABLES, "filter_text": cleaned_filter_text, "column_filters": normalized_column_filters}


def fetch_domain_rows(domain: str, workspace_db_path: Path) -> dict[str, Any]:
    if domain == "settings":
        rows = load_settings_rows(workspace_db_path)
        return {"domain": domain, "db": str(workspace_db_path), "columns": ["ordinal", "key", "value", "note", "updated_at"], "rows": rows}
    if domain == "account_snapshot":
        rows = load_account_snapshot_rows(workspace_db_path)
        return {
            "domain": domain,
            "db": str(workspace_db_path),
            "columns": list(ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS),
            "rows": rows,
        }
    raise ValueError(f"unknown editable domain: {domain}")
SNAPSHOT_ADMIN_VERSION_FILES = (
    ROOT / "src" / "quant_engine" / "snapshot_admin_server_v1.py",
    ROOT / "src" / "quant_engine" / "snapshot_admin_store_v1.py",
    ROOT / "src" / "quant_engine" / "data_collection_store_v1.py",
    ROOT / "tools" / "run_snapshot_admin_server_v1.py",
    ROOT / "tools" / "validate_snapshot_admin_web_v1.py",
    ROOT / "tests" / "unit" / "test_snapshot_admin_web_v1.py",
    ROOT / "package.json",
)

from .snapshot_admin_store_v1 import (
    ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS,
    DEFAULT_DB,
    DEFAULT_SEED_JSON,
    export_payload,
    clear_lock,
    import_seed_json,
    is_locked,
    load_account_snapshot_rows,
    load_approval_for_domain,
    load_approval_rows,
    load_change_log_rows,
    load_locks,
    load_settings_rows,
    normalize_db_path,
    now_kst_iso,
    open_connection,
    parse_account_snapshot_tsv,
    parse_scalar,
    record_change_log,
    validate_account_snapshot_rows,
    validate_settings_rows,
    build_validation_suggestions,
    build_safe_autofix_actions,
    apply_safe_autofix_action,
    lock_conflicts_for_rows,
    set_approval,
    set_lock,
    replace_account_snapshot,
    replace_settings,
    undo_last_change,
    summarize_workspace,
)
from .data_collection_store_v1 import load_collection_dashboard_state


def _strip_internal_fields(row: dict[str, Any]) -> dict[str, Any]:
    return {key: value for key, value in row.items() if not key.startswith("_")}


def _snapshot_columns_from_rows(rows: list[dict[str, Any]]) -> list[str]:
    columns = list(ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS)
    extras = sorted(
        {
            key
            for row in rows
            for key in row.keys()
            if not key.startswith("_") and key not in ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS
        }
    )
    for key in extras:
        if key not in columns:
            columns.append(key)
    return columns


def _write_json(path: Path, payload: dict[str, Any]) -> Path:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
    return path


def _render_approval_packet_md(packet: dict[str, Any]) -> str:
    pending = packet.get("pending_targets") if isinstance(packet.get("pending_targets"), list) else []
    summary = packet.get("summary") if isinstance(packet.get("summary"), dict) else {}
    lines = [
        "# Snapshot Admin Approval Packet",
        "",
        "## Summary",
        "",
        f"- settings_changed: {summary.get('settings_changed', 0)}",
        f"- account_snapshot_changed: {summary.get('account_snapshot_changed', 0)}",
        f"- pending_target_count: {summary.get('pending_target_count', 0)}",
        "",
        "## Pending Targets",
        "",
    ]
    if pending:
        for item in pending[:100]:
            if not isinstance(item, dict):
                continue
            lines.append(f"- {item.get('domain', '')}:{item.get('target_ref', '')} ({item.get('change_type', '')})")
    else:
        lines.append("_none_")
    return "\n".join(lines)


def write_approval_packet_artifacts(packet: dict[str, Any]) -> dict[str, str]:
    json_path = ROOT / "Temp" / "snapshot_admin_approval_packet_v1.json"
    md_path = ROOT / "Temp" / "snapshot_admin_approval_packet_v1.md"
    _write_json(json_path, packet)
    md_path.parent.mkdir(parents=True, exist_ok=True)
    md_path.write_text(_render_approval_packet_md(packet), encoding="utf-8")
    return {"json_path": str(json_path), "md_path": str(md_path)}


def _git_info() -> dict[str, Any]:
    try:
        commit = subprocess.check_output(
            ["git", "rev-parse", "--short", "HEAD"],
            cwd=str(ROOT),
            text=True,
            stderr=subprocess.DEVNULL,
        ).strip()
        status = subprocess.check_output(
            ["git", "status", "--porcelain"],
            cwd=str(ROOT),
            text=True,
            stderr=subprocess.DEVNULL,
        )
        return {
            "commit": commit,
            "dirty": bool(status.strip()),
            "tree_state": "DIRTY" if status.strip() else "CLEAN",
        }
    except Exception:
        return {
            "commit": "",
            "dirty": False,
            "tree_state": "UNKNOWN",
        }


def _source_fingerprint() -> dict[str, Any]:
    digest = sha256()
    latest_mtime = 0.0
    for path in SNAPSHOT_ADMIN_VERSION_FILES:
        if not path.exists():
            continue
        try:
            data = path.read_bytes()
            digest.update(path.as_posix().encode("utf-8"))
            digest.update(b"\0")
            digest.update(data)
            latest_mtime = max(latest_mtime, path.stat().st_mtime)
        except OSError:
            continue
    latest_updated_at = ""
    if latest_mtime:
        latest_updated_at = dt.datetime.fromtimestamp(latest_mtime, tz=dt.timezone.utc).astimezone(
            dt.timezone(dt.timedelta(hours=9))
        ).isoformat()
    return {
        "fingerprint": digest.hexdigest()[:16],
        "latest_mtime": latest_mtime,
        "latest_updated_at": latest_updated_at,
    }


def _approval_entry_from_conn(conn, domain: str, target_ref: str = "*") -> dict[str, Any] | None:
    ensure_schema(conn)
    row = conn.execute(
        f"""
        SELECT domain, target_ref, status, approved_by, approved_at, note, updated_at
        FROM {APPROVAL_TABLE}
        WHERE domain = ? AND target_ref = ?
        LIMIT 1
        """,
        (domain, target_ref or "*"),
    ).fetchone()
    return dict(row) if row is not None else None


def _lock_entry_from_conn(conn, domain: str, target_ref: str = "*") -> dict[str, Any] | None:
    ensure_schema(conn)
    row = conn.execute(
        f"""
        SELECT domain, target_ref, locked_by, reason, locked_at
        FROM {LOCK_TABLE}
        WHERE domain = ? AND target_ref = ?
        LIMIT 1
        """,
        (domain, target_ref or "*"),
    ).fetchone()
    return dict(row) if row is not None else None


def build_ui_state(db_path: Path | str | None = None) -> dict[str, Any]:
    summary = summarize_workspace(db_path)
    settings_rows = load_settings_rows(db_path)
    account_rows = [_strip_internal_fields(row) for row in load_account_snapshot_rows(db_path)]
    settings_errors = validate_settings_rows(settings_rows)
    snapshot_errors = validate_account_snapshot_rows(account_rows)
    suggestions = build_validation_suggestions(settings_rows, account_rows)
    autofix_actions = build_safe_autofix_actions(settings_rows, account_rows)
    try:
        collection = load_collection_dashboard_state(KIS_COLLECTION_DB, KIS_COLLECTION_REPORT)
    except Exception:
        collection = {}
    workbook_registry = load_workbook_sheet_registry()
    return {
        "version": {
            "app": SNAPSHOT_ADMIN_VERSION,
            "git": _git_info(),
            "source": _source_fingerprint(),
        },
        "summary": summary,
        "approval_rows": load_approval_rows(db_path),
        "approval_settings": load_approval_for_domain(db_path, "settings"),
        "approval_account_snapshot": load_approval_for_domain(db_path, "account_snapshot"),
        "locks": load_locks(db_path),
        "recent_changes": load_change_log_rows(db_path, limit=12),
        "history_counts": {
            "changes": len(load_change_log_rows(db_path, limit=200)),
            "approvals": len(load_approval_rows(db_path)),
            "locks": len(load_locks(db_path)),
        },
        "settings_rows": settings_rows,
        "account_snapshot_rows": account_rows,
        "account_snapshot_columns": _snapshot_columns_from_rows(account_rows),
        "validation": {
            "settings": settings_errors,
            "account_snapshot": snapshot_errors,
            "suggestions": suggestions,
        },
        "autofix_actions": autofix_actions,
        "collection": collection,
        "workbook_registry": workbook_registry,
        "generated_at": now_kst_iso(),
    }


def load_workbook_sheet_registry() -> dict[str, Any]:
    try:
        payload = json.loads(WORKBOOK_JSON.read_text(encoding="utf-8"))
    except Exception:
        payload = {}
    metadata = payload.get("metadata") if isinstance(payload, dict) else {}
    sheets = metadata.get("sheets_included") if isinstance(metadata, dict) else []
    sheet_headers = metadata.get("sheet_headers") if isinstance(metadata, dict) else {}
    data = payload.get("data") if isinstance(payload, dict) else {}
    if not isinstance(sheets, list):
        sheets = []
    if not isinstance(data, dict):
        data = {}
    entries: list[dict[str, Any]] = []
    for sheet in sheets:
        sheet_name = str(sheet)
        source_role = "derived_report_evidence"
        if sheet_name in {"settings", "account_snapshot"}:
            destination = "snapshot_admin.db"
            kind = "workspace_db"
            purpose = "workspace_edit"
            source_role = "canonical_db"
        elif sheet_name == "data_feed":
            destination = "kis_data_collection.db"
            kind = "collector_db"
            purpose = "collector_run"
            source_role = "collector_db"
        elif sheet_name in {"sector_universe_refresh_audit", "daily_history", "event_calendar", "pa1_feedback", "alpha_history", "backdata_feature_bank", "sell_priority", "harness_context", "monthly_history", "sector_flow_history", "sector_universe", "core_satellite", "universe", "event_risk", "macro", "sector_flow"}:
            destination = "GatherTradingData.json"
            if sheet_name in {"sector_universe_refresh_audit"}:
                purpose = "refresh_audit"
            elif sheet_name in {"daily_history"}:
                purpose = "history_ledger"
            elif sheet_name in {"event_calendar", "pa1_feedback"}:
                purpose = "audit_history"
            elif sheet_name in {"alpha_history", "backdata_feature_bank", "sell_priority"}:
                purpose = "analysis_report"
            elif sheet_name in {"harness_context"}:
                purpose = "execution_context"
            elif sheet_name in {"monthly_history", "sector_flow_history"}:
                purpose = "history_ledger"
            elif sheet_name in {"sector_universe", "core_satellite", "universe"}:
                purpose = "universe_registry"
            elif sheet_name in {"event_risk", "macro"}:
                purpose = "macro_risk_context"
            elif sheet_name in {"sector_flow"}:
                purpose = "flow_leadership"
            else:
                purpose = "json_payload"
            kind = "json_payload"
        else:
            destination = "unknown"
            kind = "unmapped"
            purpose = "unmapped"
        header_meta = sheet_headers.get(sheet_name) if isinstance(sheet_headers, dict) else {}
        entries.append(
            {
                "sheet": sheet_name,
                "destination": destination,
                "kind": kind,
                "purpose": purpose,
                "source_role": source_role,
                "has_json_payload": sheet_name in data,
                "row_count": _sheet_payload_row_count(data.get(sheet_name), header_meta),
            }
        )
    explicit_target_sheets = [
        "sector_universe_refresh_audit",
        "daily_history",
        "event_calendar",
        "pa1_feedback",
        "alpha_history",
        "backdata_feature_bank",
        "sell_priority",
        "harness_context",
        "monthly_history",
        "sector_flow_history",
        "sector_universe",
        "core_satellite",
        "universe",
        "event_risk",
        "macro",
        "sector_flow",
        "data_feed",
    ]
    existing_sheets = {item["sheet"] for item in entries}
    for sheet_name in explicit_target_sheets:
        if sheet_name in existing_sheets:
            continue
        if sheet_name == "data_feed":
            destination = "kis_data_collection.db"
            kind = "collector_db"
            purpose = "collector_run"
            source_role = "collector_db"
        else:
            destination = "GatherTradingData.json"
            kind = "json_payload"
            source_role = "derived_report_evidence"
            if sheet_name == "sector_universe_refresh_audit":
                purpose = "refresh_audit"
            elif sheet_name == "daily_history":
                purpose = "history_ledger"
            elif sheet_name in {"event_calendar", "pa1_feedback"}:
                purpose = "audit_history"
            elif sheet_name in {"alpha_history", "backdata_feature_bank", "sell_priority"}:
                purpose = "analysis_report"
            elif sheet_name == "harness_context":
                purpose = "execution_context"
            elif sheet_name in {"monthly_history", "sector_flow_history"}:
                purpose = "history_ledger"
            elif sheet_name in {"sector_universe", "core_satellite", "universe"}:
                purpose = "universe_registry"
            elif sheet_name in {"event_risk", "macro"}:
                purpose = "macro_risk_context"
            elif sheet_name == "sector_flow":
                purpose = "flow_leadership"
            else:
                purpose = "json_payload"
        entries.append(
            {
                "sheet": sheet_name,
                "destination": destination,
                "kind": kind,
                "purpose": purpose,
                "source_role": source_role,
                "has_json_payload": kind == "json_payload",
                "row_count": 0,
            }
        )
    return {
        "xlsx_path": str(WORKBOOK_XLSX),
        "json_path": str(WORKBOOK_JSON),
        "json_role": "derived_report_evidence",
        "sheet_count": len(entries),
        "entries": entries,
        "unmapped_sheets": [item["sheet"] for item in entries if item["kind"] == "unmapped"],
    }


def _sheet_payload_row_count(value: Any, header_meta: Any | None = None) -> int | None:
    if isinstance(header_meta, dict) and isinstance(header_meta.get("row_count"), int):
        return int(header_meta["row_count"])
    if isinstance(value, list):
        return len(value)
    if isinstance(value, dict):
        return len(value)
    if value is None:
        return 0
    return None


def run_collection_job(
    *,
    sqlite_db: Path | None = None,
    input_json: Path | None = None,
    output_json: Path | None = None,
    allow_naver_fallback: bool = False,
    include_live_kis: bool = False,
    kis_account: str = "real",
) -> dict[str, Any]:
    sqlite_db = sqlite_db or KIS_COLLECTION_DB
    input_json = input_json or (ROOT / "GatherTradingData.json")
    output_json = output_json or KIS_COLLECTION_REPORT
    cmd = [
        sys.executable,
        str(ROOT / "tools" / "run_kis_data_collection_v1.py"),
        "--input-json",
        str(input_json),
        "--sqlite-db",
        str(sqlite_db),
        "--output-json",
        str(output_json),
        "--kis-account",
        kis_account,
    ]
    if allow_naver_fallback:
        cmd.append("--allow-naver-fallback")
    if not include_live_kis:
        cmd.append("--no-live-kis")

    started_at = now_kst_iso()
    started = time.perf_counter()
    proc = subprocess.run(
        cmd,
        cwd=str(ROOT),
        capture_output=True,
        text=True,
        encoding="utf-8",
    )
    finished_at = now_kst_iso()
    elapsed_ms = round((time.perf_counter() - started) * 1000.0, 1)
    summary = {}
    if output_json.exists():
        try:
            loaded = json.loads(output_json.read_text(encoding="utf-8"))
            summary = loaded if isinstance(loaded, dict) else {}
        except Exception:
            summary = {}
    state = {}
    try:
        state = load_collection_dashboard_state(sqlite_db, output_json)
    except Exception:
        state = {}
    return {
        "status": "PASS" if proc.returncode == 0 else "FAIL",
        "started_at": started_at,
        "finished_at": finished_at,
        "elapsed_ms": elapsed_ms,
        "command": cmd,
        "returncode": proc.returncode,
        "stdout": proc.stdout,
        "stderr": proc.stderr,
        "summary": summary,
        "state": state,
    }


def _json_response(handler: BaseHTTPRequestHandler, status: int, payload: Any) -> None:
    body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
    handler.send_response(status)
    handler.send_header("Content-Type", "application/json; charset=utf-8")
    handler.send_header("Content-Length", str(len(body)))
    handler.end_headers()
    handler.wfile.write(body)


def _text_response(handler: BaseHTTPRequestHandler, status: int, text: str, content_type: str = "text/plain; charset=utf-8") -> None:
    body = text.encode("utf-8")
    handler.send_response(status)
    handler.send_header("Content-Type", content_type)
    handler.send_header("Content-Length", str(len(body)))
    handler.end_headers()
    handler.wfile.write(body)


def _json_response(handler: BaseHTTPRequestHandler, status: int, payload: Any) -> None:
    body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
    handler.send_response(status)
    handler.send_header("Content-Type", "application/json; charset=utf-8")
    handler.send_header("Content-Length", str(len(body)))
    handler.end_headers()
    handler.wfile.write(body)


def _text_response(handler: BaseHTTPRequestHandler, status: int, text: str, content_type: str = "text/plain; charset=utf-8") -> None:
    body = text.encode("utf-8")
    handler.send_response(status)
    handler.send_header("Content-Type", content_type)
    handler.send_header("Content-Length", str(len(body)))
    handler.end_headers()
    handler.wfile.write(body)


def _read_json_body(handler: BaseHTTPRequestHandler) -> dict[str, Any]:
    length = int(handler.headers.get("Content-Length") or "0")
    raw = handler.rfile.read(length).decode("utf-8") if length else "{}"
    payload = json.loads(raw or "{}")
    if not isinstance(payload, dict):
        raise ValueError("JSON body must be an object")
    return payload


def render_home_html() -> str:
    return """<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Snapshot Admin Home</title>
  <style>
    body { margin:0; font-family: ui-sans-serif, system-ui, sans-serif; background: linear-gradient(180deg, #08101f 0%, #0b1220 100%); color:#e5e7eb; }
    .wrap { max-width: 1080px; margin: 0 auto; padding: 32px 20px 40px; }
    .hero { padding: 24px; border:1px solid rgba(255,255,255,.08); border-radius: 20px; background: rgba(17,24,39,.9); box-shadow: 0 16px 48px rgba(0,0,0,.24); }
    h1 { margin:0; font-size: 28px; }
    .sub { margin-top:8px; color:#9ca3af; line-height:1.5; }
    .grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap:12px; margin-top:16px; }
    .card { padding:16px; border-radius:16px; border:1px solid rgba(255,255,255,.08); background: rgba(3,7,18,.42); }
    .card .label { font-size:11px; letter-spacing:.05em; text-transform:uppercase; color:#9ca3af; }
    .card strong { display:block; margin-top:6px; font-size:16px; }
    .card p { margin:8px 0 0; color:#cbd5e1; font-size:13px; line-height:1.45; }
    .actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:18px; }
    a.btn { text-decoration:none; color:#e5e7eb; background:#172036; border:1px solid rgba(255,255,255,.12); padding:10px 14px; border-radius:10px; }
    a.btn.primary { background: linear-gradient(135deg, #38bdf8, #0ea5e9); color:#fff; }
    .note { margin-top:14px; color:#9ca3af; font-size:12px; }
    @media (max-width: 900px) { .grid { grid-template-columns:1fr; } }
  </style>
</head>
<body>
  <main class="wrap">
    <section class="hero">
      <h1>Snapshot Admin Home</h1>
      <div class="sub">이 화면은 운영 판단용입니다. 편집, 승인, 수집이 모두 한 곳에 섞여 있으면 목적이 흐려지므로, 먼저 해야 할 일만 보여줍니다.</div>
      <div class="grid">
        <div class="card">
          <div class="label">1. Workspace</div>
          <strong>데이터를 고치고 저장</strong>
          <p>settings와 account_snapshot 편집, TSV 적용, 검증 확인은 workspace에서 처리합니다.</p>
        </div>
        <div class="card">
          <div class="label">2. Collection</div>
          <strong>수집 실행과 결과 확인</strong>
          <p>수집 버튼, 진행상태, 결과 로그는 collection 화면에 둡니다.</p>
        </div>
        <div class="card">
          <div class="label">3. Tables</div>
          <strong>DB와 JSON 증빙을 분리 검토</strong>
          <p>DB별 조회, JSON별 조회, 시계열 이력은 tables에서 확인합니다.</p>
        </div>
      </div>
      <div class="actions">
        <a class="btn primary" href="/workspace">Open workspace</a>
        <a class="btn" href="/collection">Open collection</a>
        <a class="btn" href="/tables">Open tables</a>
      </div>
      <div class="note">테이블 브라우저는 보조 경로로 <a href="/tables" style="color:#93c5fd;">/tables</a>에 둡니다.</div>
    </section>
  </main>
</body>
</html>
"""


def render_inspector_html() -> str:
    return """<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Snapshot Inspector</title>
  <style>
    body { margin:0; font-family: ui-sans-serif, system-ui, sans-serif; background: linear-gradient(180deg, #08101f 0%, #0b1220 100%); color:#e5e7eb; }
    .wrap { max-width: 1080px; margin: 0 auto; padding: 24px; }
    .panel { padding: 18px; border:1px solid rgba(255,255,255,.08); border-radius: 18px; background: rgba(17,24,39,.94); }
    .toolbar { display:flex; flex-wrap:wrap; gap:8px; margin-top:12px; }
    input, select, button, a.btn { border:1px solid rgba(255,255,255,.12); background:#172036; color:#e5e7eb; border-radius:10px; padding:10px 12px; font-size:13px; }
    a.btn { text-decoration:none; display:inline-block; }
    button.primary, a.primary { background: linear-gradient(135deg, #38bdf8, #0ea5e9); color:#fff; }
    pre { margin:12px 0 0; white-space:pre-wrap; max-height:420px; overflow:auto; background:rgba(3,7,18,.45); border:1px solid rgba(255,255,255,.08); border-radius:12px; padding:10px; }
    .muted { color:#9ca3af; }
  </style>
</head>
<body>
  <main class="wrap">
    <section class="panel">
      <h1 style="margin:0;">Snapshot Inspector</h1>
      <div class="muted" style="margin-top:6px;">Row-level operations move here so the workspace stays focused on editing.</div>
      <div class="toolbar">
        <a class="btn" href="/workspace">Open workspace</a>
        <a class="btn" href="/">Home</a>
        <input id="domainInput" value="settings" aria-label="domain" />
        <input id="targetInput" placeholder="target_ref or key" aria-label="target_ref" />
        <button class="primary" onclick="loadInspector()">Load</button>
      </div>
      <pre id="inspectorDetail">Select a domain and target_ref, then load.</pre>
      <pre id="inspectorHistory"></pre>
    </section>
  </main>
  <script>
    async function loadInspector() {
      const domain = String(document.getElementById("domainInput").value || "").trim();
      const target = String(document.getElementById("targetInput").value || "").trim();
      const response = await fetch("/api/state");
      const payload = await response.json();
      const rows = domain === "account_snapshot" ? (payload.snapshotRows || []) : (payload.settingsRows || []);
      const row = rows.find((item) => String(item._row_ref || item.target_ref || item.key || item.ticker || "").trim() === target);
      document.getElementById("inspectorDetail").textContent = row ? JSON.stringify(row, null, 2) : "No matching row found.";
      document.getElementById("inspectorHistory").textContent = JSON.stringify({ summary: payload.summary || {}, approval_rows: payload.approval_rows || [], locks: payload.locks || [] }, null, 2);
    }
  </script>
</body>
</html>
"""


def render_index_html() -> str:
    return """<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Snapshot Admin</title>
  <style>
    @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700&display=swap');
    :root {
      color-scheme: dark;
      --bg: #090d16;
      --panel: rgba(17, 24, 39, 0.7);
      --panel-2: rgba(10, 15, 30, 0.85);
      --line: rgba(255, 255, 255, 0.08);
      --text: #f3f4f6;
      --muted: #9ca3af;
      --accent: #0ea5e9;
      --accent-hover: #38bdf8;
      --accent-2: #10b981;
      --danger: #ef4444;
      --warn: #f59e0b;
      --chip: rgba(30, 41, 59, 0.5);
      --shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
    }
    * { box-sizing: border-box; }
    body {
      margin: 0;
      font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
      background:
        radial-gradient(circle at 10% 20%, rgba(14, 165, 233, 0.15), transparent 40%),
        radial-gradient(circle at 90% 80%, rgba(16, 185, 129, 0.1), transparent 40%),
        #050811;
      color: var(--text);
      line-height: 1.5;
    }
    ::-webkit-scrollbar {
      width: 8px;
      height: 8px;
    }
    ::-webkit-scrollbar-track {
      background: rgba(0, 0, 0, 0.15);
    }
    ::-webkit-scrollbar-thumb {
      background: rgba(255, 255, 255, 0.12);
      border-radius: 4px;
    }
    ::-webkit-scrollbar-thumb:hover {
      background: rgba(255, 255, 255, 0.25);
    }
    header {
      padding: 24px;
      border-bottom: 1px solid var(--line);
      background: rgba(9, 13, 22, 0.6);
      position: sticky;
      top: 0;
      backdrop-filter: blur(16px);
      -webkit-backdrop-filter: blur(16px);
      z-index: 10;
    }
    h1 {
      margin: 0;
      font-family: 'Outfit', sans-serif;
      font-size: 24px;
      font-weight: 700;
      letter-spacing: -0.02em;
      background: linear-gradient(135deg, #fff 0%, #9ca3af 100%);
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
    }
    .subline { color: var(--muted); margin-top: 6px; font-size: 13px; }
    .wrap { padding: 24px; max-width: 1600px; margin: 0 auto; }
    .grid {
      display: grid;
      grid-template-columns: 1fr;
      gap: 20px;
    }
    .panel {
      border: 1px solid var(--line);
      background: var(--panel);
      backdrop-filter: blur(12px);
      -webkit-backdrop-filter: blur(12px);
      border-radius: 16px;
      box-shadow: var(--shadow);
      overflow: hidden;
      transition: transform 0.2s ease, box-shadow 0.2s ease;
    }
    .panel-head {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      padding: 16px 20px 14px;
      border-bottom: 1px solid var(--line);
    }
    .panel-head h2 {
      margin: 0;
      font-family: 'Outfit', sans-serif;
      font-size: 17px;
      font-weight: 600;
      letter-spacing: -0.01em;
      color: #fff;
    }
    .actions { display: flex; flex-wrap: wrap; gap: 8px; }
    button, .btn {
      border: 1px solid rgba(255, 255, 255, 0.1);
      background: var(--chip);
      color: var(--text);
      padding: 8px 16px;
      border-radius: 8px;
      cursor: pointer;
      font-size: 13px;
      font-weight: 500;
      font-family: 'Outfit', sans-serif;
      transition: all 0.2s ease;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      gap: 6px;
      text-decoration: none;
    }
    button:hover, .btn:hover {
      background: rgba(255, 255, 255, 0.08);
      border-color: rgba(255, 255, 255, 0.2);
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
      transform: translateY(-1px);
    }
    button.primary {
      background: linear-gradient(135deg, var(--accent), #0284c7);
      border: none;
      color: #fff;
    }
    button.primary:hover {
      background: linear-gradient(135deg, var(--accent-hover), #0ea5e9);
      box-shadow: 0 0 15px rgba(14, 165, 233, 0.4);
    }
    button.good {
      background: linear-gradient(135deg, var(--accent-2), #059669);
      border: none;
      color: #fff;
    }
    button.good:hover {
      box-shadow: 0 0 15px rgba(16, 185, 129, 0.4);
    }
    button.danger {
      background: linear-gradient(135deg, var(--danger), #dc2626);
      border: none;
      color: #fff;
    }
    button.danger:hover {
      box-shadow: 0 0 15px rgba(239, 68, 68, 0.4);
    }
    .status {
      padding: 12px 20px;
      color: var(--muted);
      border-bottom: 1px solid var(--line);
      font-size: 13px;
      display: flex;
      flex-wrap: wrap;
      gap: 12px;
      align-items: center;
      background: rgba(0, 0, 0, 0.2);
    }
    .version {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      margin-top: 8px;
      font-size: 12px;
      color: var(--muted);
    }
    .status strong { color: #fff; }
    .filter-row {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      margin: 12px 0 0;
    }
    .filter-row input {
      background: rgba(2, 6, 23, 0.6);
      color: var(--text);
      border: 1px solid var(--line);
      border-radius: 8px;
      padding: 8px 12px;
      font-size: 12px;
      min-width: 180px;
      outline: none;
      transition: border-color 0.2s ease, box-shadow 0.2s ease;
    }
    .filter-row input:focus {
      border-color: var(--accent);
      box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.25);
    }
    .tiny {
      font-size: 11px;
      color: var(--muted);
      text-transform: uppercase;
      letter-spacing: 0.05em;
    }
    .pane-body { padding: 20px; }
    .grid-wrap {
      overflow: auto;
      border: 1px solid var(--line);
      border-radius: 12px;
      background: rgba(2, 6, 23, 0.4);
    }
    table.sheet {
      width: 100%;
      border-collapse: collapse;
      min-width: 100%;
    }
    .sheet th, .sheet td {
      border-bottom: 1px solid var(--line);
      border-right: 1px solid rgba(255, 255, 255, 0.03);
      padding: 0;
      vertical-align: top;
      font-size: 12px;
      max-width: 220px;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    .sheet th {
      position: sticky;
      top: 0;
      background: #0f172a;
      text-align: left;
      padding: 12px 10px;
      color: #e2e8f0;
      white-space: nowrap;
      z-index: 2;
      font-family: 'Outfit', sans-serif;
      font-weight: 600;
    }
    .sheet td[contenteditable="true"] {
      min-width: 120px;
      padding: 10px 10px;
      outline: none;
      white-space: pre-wrap;
      word-break: break-word;
      transition: background 0.15s ease;
    }
    .sheet td[contenteditable="true"]:focus {
      background: rgba(14, 165, 233, 0.15);
      box-shadow: inset 0 0 0 1px rgba(14, 165, 233, 0.4);
    }
    .sheet td.rownum, .sheet th.rownum {
      min-width: 54px;
      width: 54px;
      text-align: right;
      color: var(--muted);
      padding-right: 12px;
      background: #0b0f19;
      position: sticky;
      left: 0;
      z-index: 1;
      border-right: 1px solid var(--line);
    }
    .sheet th.rownum { z-index: 3; }
    .sheet th.col-wide, .sheet td.col-wide { min-width: 180px; }
    .sheet th.col-xwide, .sheet td.col-xwide { min-width: 260px; }
    .sheet th.col-narrow, .sheet td.col-narrow { min-width: 88px; }
    .sheet th.col-micro, .sheet td.col-micro { min-width: 68px; }
    .note-box {
      margin-top: 16px;
      display: grid;
      gap: 12px;
      grid-template-columns: 1fr;
    }
    textarea {
      width: 100%;
      min-height: 120px;
      resize: vertical;
      background: rgba(2, 6, 23, 0.5);
      color: var(--text);
      border: 1px solid var(--line);
      border-radius: 12px;
      padding: 12px;
      font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
      font-size: 12px;
      outline: none;
      transition: border-color 0.2s ease;
    }
    textarea:focus {
      border-color: var(--accent);
    }
    .muted { color: var(--muted); }
    .warn { color: var(--warn); }
    .error { color: var(--danger); }
    .chip {
      display: inline-flex;
      gap: 6px;
      align-items: center;
      padding: 4px 10px;
      border-radius: 999px;
      background: rgba(255, 255, 255, 0.05);
      color: var(--muted);
      font-size: 11px;
      font-weight: 500;
      border: 1px solid rgba(255, 255, 255, 0.03);
    }
    .toolbar {
      display: flex;
      gap: 10px;
      flex-wrap: wrap;
      align-items: center;
    }
    .top-banner {
      display: grid;
      gap: 12px;
      margin-top: 14px;
      padding: 16px;
      border-radius: 16px;
      border: 1px solid rgba(14, 165, 233, 0.25);
      background: linear-gradient(135deg, rgba(14, 165, 233, 0.12), rgba(15, 23, 42, 0.9));
      box-shadow: var(--shadow);
    }
    .top-banner-grid {
      display: grid;
      grid-template-columns: repeat(4, minmax(0, 1fr));
      gap: 12px;
    }
    .top-banner .stat {
      padding: 12px;
      border-radius: 12px;
      background: rgba(2, 6, 23, 0.5);
      border: 1px solid var(--line);
    }
    .top-banner .stat .label {
      display: block;
      font-size: 10px;
      color: var(--muted);
      margin-bottom: 4px;
      text-transform: uppercase;
      letter-spacing: 0.08em;
    }
    .top-banner .stat strong {
      display: block;
      color: #fff;
      font-size: 15px;
      font-family: 'Outfit', sans-serif;
      font-weight: 600;
      line-height: 1.35;
      word-break: break-word;
    }
    .ops-hero {
      display: grid;
      gap: 12px;
      margin-top: 16px;
      padding: 16px;
      border-radius: 16px;
      border: 1px solid rgba(148, 163, 184, 0.15);
      background: linear-gradient(180deg, rgba(15, 23, 42, 0.5), rgba(3, 7, 18, 0.3));
    }
    .ops-hero-grid {
      display: grid;
      grid-template-columns: repeat(3, minmax(0, 1fr));
      gap: 12px;
    }
    .ops-card {
      padding: 14px;
      border-radius: 12px;
      border: 1px solid rgba(255, 255, 255, 0.05);
      background: rgba(255, 255, 255, 0.02);
      transition: transform 0.2s ease, background 0.2s ease;
    }
    .ops-card:hover {
      transform: translateY(-2px);
      background: rgba(255, 255, 255, 0.04);
      border-color: rgba(255, 255, 255, 0.1);
    }
    .ops-card .label {
      display: block;
      color: var(--muted);
      font-size: 10px;
      text-transform: uppercase;
      letter-spacing: 0.08em;
      margin-bottom: 6px;
    }
    .ops-card strong {
      display: block;
      font-size: 15px;
      font-family: 'Outfit', sans-serif;
      font-weight: 600;
      line-height: 1.45;
    }
    .ops-card .cta-row {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      margin-top: 12px;
    }
    .ops-card .muted {
      display: block;
      margin-top: 6px;
      font-size: 12px;
    }
    .two-col {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 20px;
    }
    .row-actions {
      display: flex;
      gap: 6px;
      flex-wrap: wrap;
    }
    .sheet tr.selected td {
      background: rgba(14, 165, 233, 0.15);
      box-shadow: inset 0 1px 0 rgba(186, 230, 253, 0.15), inset 0 -1px 0 rgba(186, 230, 253, 0.1);
    }
    .sheet tr.selected td.rownum {
      background: rgba(14, 165, 233, 0.25);
      color: #e0f2fe;
    }
    .sheet tr.selected td[contenteditable="true"] {
      background: rgba(34, 211, 238, 0.1);
      outline: 1px solid rgba(103, 232, 249, 0.25);
      outline-offset: -1px;
      font-weight: 600;
    }
    .sheet tr.selected td.selected-field {
      background: rgba(251, 191, 36, 0.1);
      outline-color: rgba(251, 191, 36, 0.35);
    }
    .sheet tr.selected td.selected-field:focus {
      background: rgba(251, 191, 36, 0.15);
      outline-color: rgba(251, 191, 36, 0.7);
      box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.15);
    }
    .sheet tr.selected td[contenteditable="true"]:focus {
      background: rgba(103, 232, 249, 0.15);
      outline: 2px solid rgba(125, 211, 252, 0.7);
      outline-offset: -2px;
      box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.15);
    }
    .table-banner {
      display: grid;
      gap: 8px;
      margin-bottom: 12px;
      padding: 12px 14px;
      border-radius: 12px;
      border: 1px solid var(--line);
      background: rgba(2, 6, 23, 0.4);
    }
    .table-banner .meta-row {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
    }
    .diff-list {
      display: grid;
      gap: 8px;
      margin-top: 8px;
    }
    .diff-item {
      padding: 12px;
      border: 1px solid var(--line);
      border-radius: 12px;
      background: rgba(2, 6, 23, 0.3);
      font-size: 12px;
    }
    .diff-item strong { color: #fff; }
    .diff-item code {
      display: block;
      white-space: pre-wrap;
      margin-top: 6px;
      color: #cbd5e1;
    }
    .history-list {
      display: grid;
      gap: 8px;
      margin-top: 10px;
      max-height: 260px;
      overflow: auto;
    }
    .history-item {
      border: 1px solid var(--line);
      border-radius: 12px;
      background: rgba(2, 6, 23, 0.3);
      padding: 12px;
      font-size: 12px;
    }
    .history-item code {
      display: block;
      margin-top: 6px;
      white-space: pre-wrap;
      color: #cbd5e1;
    }
    .inspector {
      display: grid;
      gap: 10px;
    }
    .inspector-grid {
      display: grid;
      grid-template-columns: 1.1fr .9fr;
      gap: 16px;
    }
    .inspector-pre {
      margin: 0;
      white-space: pre-wrap;
      max-height: 280px;
      overflow: auto;
      background: rgba(2, 6, 23, 0.4);
      border: 1px solid var(--line);
      border-radius: 12px;
      padding: 12px;
    }
    .view-controls {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      margin-top: 8px;
      align-items: center;
    }
    .view-controls select, .view-controls input {
      background: rgba(2, 6, 23, 0.6);
      color: var(--text);
      border: 1px solid var(--line);
      border-radius: 8px;
      padding: 8px 12px;
      font-size: 12px;
      min-width: 120px;
      outline: none;
    }
    .collection-grid {
      display: grid;
      grid-template-columns: 1.1fr .9fr;
      gap: 16px;
    }
    .collection-panel {
      display: grid;
      gap: 10px;
    }
    .collection-filter-row {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      margin-top: 8px;
      align-items: center;
    }
    .collection-filter-row input {
      background: rgba(2, 6, 23, 0.6);
      color: var(--text);
      border: 1px solid var(--line);
      border-radius: 8px;
      padding: 8px 12px;
      font-size: 12px;
      min-width: 180px;
      outline: none;
    }
    .collection-list {
      display: grid;
      gap: 6px;
      margin-top: 8px;
    }
    .collection-item {
      width: 100%;
      text-align: left;
      border: 1px solid rgba(255,255,255,.05);
      border-radius: 10px;
      padding: 10px 14px;
      background: rgba(255,255,255,.02);
      color: var(--text);
      font-size: 12px;
      cursor: pointer;
      transition: all 0.2s ease;
    }
    .collection-item:hover {
      background: rgba(255,255,255,.04);
      border-color: rgba(255,255,255,.1);
    }
    .collection-item.selected {
      border-color: var(--accent);
      box-shadow: 0 0 12px rgba(14, 165, 233, 0.25);
      background: rgba(14, 165, 233, 0.08);
    }
    .collection-item code {
      display: block;
      margin-top: 6px;
      white-space: pre-wrap;
      color: #cbd5e1;
    }
    .collection-metrics {
      display: grid;
      grid-template-columns: repeat(3, minmax(0, 1fr));
      gap: 10px;
      margin-top: 10px;
    }
    .metric-card {
      border: 1px solid var(--line);
      border-radius: 12px;
      padding: 12px;
      background: rgba(255, 255, 255, 0.02);
      font-size: 12px;
    }
    .metric-card strong {
      display: block;
      font-size: 18px;
      margin-top: 4px;
      color: #fff;
    }
    .compact-log {
      margin: 0;
      white-space: pre-wrap;
      max-height: 220px;
      overflow: auto;
      background: rgba(2, 6, 23, 0.4);
      border: 1px solid var(--line);
      border-radius: 12px;
      padding: 12px;
      font-size: 12px;
    }
    .shortcut-hint {
      color: var(--muted);
      font-size: 11px;
      margin-top: 8px;
    }
    .log-filter-row {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      margin-top: 8px;
      align-items: center;
    }
    .log-filter-row input {
      background: rgba(2, 6, 23, 0.6);
      color: var(--text);
      border: 1px solid var(--line);
      border-radius: 8px;
      padding: 8px 12px;
      font-size: 12px;
      min-width: 220px;
      outline: none;
    }
    @media (max-width: 1080px) {
      .two-col { grid-template-columns: 1fr; }
      .inspector-grid { grid-template-columns: 1fr; }
      .collection-grid { grid-template-columns: 1fr; }
      .collection-metrics { grid-template-columns: 1fr; }
      .ops-hero-grid { grid-template-columns: 1fr; }
    }
  </style>
</head>
<body>
  <header>
    <h1>Snapshot Admin</h1>
    <div class="subline">SQLite canonical editor for <code>settings</code> and <code>account_snapshot</code>. Save via API only; xlsx stays as export surface.</div>
    <div class="toolbar" style="margin-top:10px;">
      <a class="btn" href="/collection">Open collection dashboard</a>
      <a class="btn" href="/tables">Open table browser</a>
      <a class="btn" href="/">Open home</a>
    </div>
    <div class="version" id="versionSummary"></div>
    <div class="top-banner" id="opsBanner" aria-live="polite">
      <div class="top-banner-grid">
        <div class="stat">
          <span class="label">Approval</span>
          <strong id="bannerApprovalSummary">Loading...</strong>
        </div>
        <div class="stat">
          <span class="label">Lock</span>
          <strong id="bannerLockSummary">Loading...</strong>
        </div>
        <div class="stat">
          <span class="label">Selection</span>
          <strong id="bannerSelectionSummary">No row selected.</strong>
        </div>
        <div class="stat">
          <span class="label">Diff</span>
          <strong id="bannerDiffSummary">Pending diff loading...</strong>
        </div>
      </div>
      <div class="muted" id="bannerDetail">Snapshot approval state and lock state are pinned here for immediate review.</div>
    </div>
    <div class="ops-hero" aria-label="workspace summary">
      <div class="ops-hero-grid">
        <div class="ops-card">
          <span class="label">Now</span>
          <strong id="heroNow">Loading current workspace status...</strong>
          <span class="muted" id="heroNowDetail">This page summarizes the canonical SQLite workspace and collection state.</span>
        </div>
        <div class="ops-card">
          <span class="label">Next action</span>
          <strong id="heroNextAction">Loading...</strong>
          <span class="muted" id="heroNextDetail">The page will tell you whether to edit, approve, unlock, or collect.</span>
          <div class="cta-row">
            <a class="btn" id="heroPrimaryAction" href="/">Open workspace</a>
            <a class="btn" href="/collection">Open collection</a>
          </div>
        </div>
        <div class="ops-card">
          <span class="label">Collection</span>
          <strong id="heroCollection">Loading...</strong>
          <span class="muted" id="heroCollectionDetail">Latest collector run and result summary.</span>
        </div>
      </div>
    </div>
  </header>
  <main class="wrap">
    <div class="grid">
      <section class="panel snapshot-panel">
        <div class="panel-head">
          <h2>Workspace</h2>
          <div class="actions">
            <button class="primary" onclick="reloadState()">Reload</button>
            <button class="good" onclick="seedWorkspace()">Seed from GatherTradingData.json</button>
            <button onclick="downloadExport()">Download JSON</button>
            <button class="danger" onclick="undoLastChange('settings')">Undo settings</button>
            <button class="danger" onclick="undoLastChange('account_snapshot')">Undo snapshot</button>
            <button class="good" onclick="runAutofix()">Apply safe autofix</button>
          </div>
        </div>
        <div class="status" id="workspaceStatus">Loading...</div>
        <div class="pane-body">
          <div class="two-col">
            <div>
              <div class="tiny">Validation</div>
              <pre id="validationBox" style="margin:8px 0 0; white-space:pre-wrap; max-height:180px; overflow:auto; background:rgba(3,7,18,.45); border:1px solid rgba(255,255,255,.08); border-radius:12px; padding:10px;"></pre>
              <div class="tiny" style="margin-top:10px;">Suggestions</div>
              <pre id="suggestionBox" style="margin:8px 0 0; white-space:pre-wrap; max-height:180px; overflow:auto; background:rgba(3,7,18,.45); border:1px solid rgba(255,255,255,.08); border-radius:12px; padding:10px;"></pre>
            </div>
            <div>
              <div class="tiny">Diff preview</div>
              <pre id="diffPreview" style="margin:8px 0 0; white-space:pre-wrap; max-height:180px; overflow:auto; background:rgba(3,7,18,.45); border:1px solid rgba(255,255,255,.08); border-radius:12px; padding:10px;"></pre>
            </div>
          </div>
        </div>
      </section>

      <details class="panel" id="approvalPanel">
        <summary class="panel-head" style="cursor:pointer;">
          <h2>Approval & Locks</h2>
          <div class="actions">
            <span class="chip">open when you need to approve or lock rows</span>
          </div>
        </summary>
        <div class="pane-body">
          <div class="actions" style="margin-bottom:10px;">
            <button onclick="lockDomain('settings')">Lock settings</button>
            <button onclick="unlockDomain('settings')">Unlock settings</button>
            <button onclick="lockDomain('account_snapshot')">Lock snapshot</button>
            <button onclick="unlockDomain('account_snapshot')">Unlock snapshot</button>
            <button class="good" onclick="approveDomain('settings')">Approve settings</button>
            <button class="good" onclick="approveDomain('account_snapshot')">Approve snapshot</button>
          </div>
          <div class="two-col">
            <div>
              <div class="chip" id="approvalSettingsChip">settings approval</div>
              <div class="chip" id="approvalSnapshotChip">snapshot approval</div>
              <div class="muted" id="lockSummary" style="margin-top:10px;"></div>
              <div class="muted" id="approvalRowSummary" style="margin-top:8px;"></div>
              <div class="muted" id="historySummary" style="margin-top:8px;"></div>
              <div class="toolbar" style="margin-top:12px;">
                <input id="lockDomainInput" value="settings" aria-label="lock domain" />
                <input id="lockTargetInput" placeholder="target_ref (key or ticker)" aria-label="lock target" />
                <button onclick="lockTargetFromInput()">Lock target</button>
                <button onclick="unlockTargetFromInput()">Unlock target</button>
                <button class="good" onclick="approvePendingChanges()">Approve pending</button>
                <button onclick="previewDiff()">Refresh diff</button>
                <button class="primary" onclick="exportApprovalPacket()">Export approval packet</button>
              </div>
            </div>
            <div>
              <div class="muted">Recent change log</div>
              <div class="log-filter-row">
                <input id="changeLogFilter" placeholder="Filter change log" oninput="renderMeta()" />
                <button onclick="clearChangeLogFilter()">Clear filter</button>
              </div>
              <pre id="changeLog" style="margin:10px 0 0; white-space:pre-wrap; max-height:240px; overflow:auto; background:rgba(3,7,18,.45); border:1px solid rgba(255,255,255,.08); border-radius:12px; padding:10px;"></pre>
              <div class="muted" style="margin-top:10px;">Timeline</div>
              <div id="changeTimeline" class="history-list"></div>
            </div>
          </div>
        </div>
        </div>
      </details>

      <details class="panel" id="collectionPanel" open>
        <summary class="panel-head" style="cursor:pointer;">
          <h2>KIS Collection</h2>
          <div class="actions">
            <span class="chip">open when you want to run or inspect collector output</span>
          </div>
        </summary>
        <div class="pane-body collection-panel">
          <div class="actions" style="margin-bottom:10px;">
            <button class="primary" onclick="reloadState()">Refresh collection</button>
            <button onclick="copyCollectionStatus()">Copy status</button>
          </div>
          <div class="collection-grid">
            <div>
              <div class="chip" id="collectionChip">collection: loading...</div>
              <div class="muted" id="collectionSummary" style="margin-top:10px;"></div>
              <div class="collection-metrics" id="collectionMetrics"></div>
              <div class="muted" style="margin-top:10px;">Collection trend</div>
              <div id="collectionTrendSummary" class="collection-trend-summary"></div>
              <div id="collectionTrendChart" class="collection-trend-chart"></div>
              <div class="collection-filter-row">
                <input id="collectionFilter" placeholder="Filter runs / snapshots / errors" oninput="applyCollectionFilter()" />
                <button onclick="clearCollectionFilter()">Clear filter</button>
              </div>
            </div>
            <div>
              <div class="muted">Recent collector runs</div>
              <div id="collectionRuns" class="collection-list"></div>
              <div class="muted" style="margin-top:10px;">Recent collector snapshots</div>
              <div id="collectionSnapshots" class="collection-list"></div>
              <div class="muted" style="margin-top:10px;">Recent collector errors</div>
              <div id="collectionErrors" class="collection-list"></div>
              <div class="muted" style="margin-top:10px;">Collection detail</div>
              <pre id="collectionDetail" class="inspector-pre"></pre>
            </div>
          </div>
        </div>
      </details>

      <details class="panel" id="selectionPanel">
        <summary class="panel-head" style="cursor:pointer;">
          <h2>Selection Inspector</h2>
          <div class="actions">
            <span class="chip">open when you need row-level operations</span>
          </div>
        </summary>
        <div class="pane-body inspector">
          <div class="actions" style="margin-bottom:10px;">
            <button onclick="focusSelectedRow()">Focus row</button>
            <button onclick="copySelectedTarget()">Copy target</button>
            <button class="good" onclick="approveSelectedRow()">Approve selected</button>
            <button onclick="lockSelectedRow()">Lock selected</button>
            <button onclick="unlockSelectedRow()">Unlock selected</button>
          </div>
          <div class="inspector-grid">
            <div>
              <div class="muted" id="selectedRowSummary">No row selected.</div>
              <pre id="selectedRowDetail" class="inspector-pre"></pre>
              <div class="tiny" style="margin-top:10px;">Recent row history</div>
              <pre id="selectedRowHistory" class="inspector-pre"></pre>
            </div>
            <div>
              <div class="muted">Batch paste</div>
              <div class="view-controls">
                <select id="batchDomainSelect" onchange="syncBatchDomain()">
                  <option value="settings">settings</option>
                  <option value="account_snapshot">account_snapshot</option>
                </select>
                <button onclick="applyBatchPaste()">Apply TSV to selection</button>
              </div>
              <textarea id="batchPasteTsv" placeholder="Paste TSV here to apply from selected row onward."></textarea>
              <div class="tiny">Tip: clipboard paste still works directly in the grid. This panel is for multi-row batch edit against the selected row.</div>
              <div class="shortcut-hint">Shortcuts: `Ctrl+S` save current domain, `Ctrl+Enter` save current domain, `Delete` remove selected row.</div>
            </div>
          </div>
        </div>
      </details>

      <section class="panel">
        <div class="panel-head">
          <h2>Settings <span class="chip" id="settingsCountChip">0 rows</span></h2>
          <div class="actions">
            <button onclick="addSettingRow()">Add row</button>
            <button class="primary" onclick="saveSettings()">Save settings</button>
          </div>
        </div>
        <div class="pane-body">
          <div class="view-controls">
            <input id="settingsSortField" placeholder="sort field" list="settingsSortFields" oninput="applyViewPreferences('settings')" />
            <select id="settingsSortDirection" onchange="applyViewPreferences('settings')">
              <option value="asc">asc</option>
              <option value="desc">desc</option>
            </select>
            <button onclick="saveViewPreset('settings')">Save view</button>
            <select id="settingsViewPreset" onchange="loadViewPreset('settings')"></select>
            <button onclick="clearViewPreset('settings')">Delete view</button>
          </div>
          <datalist id="settingsSortFields">
            <option value="ordinal"></option>
            <option value="key"></option>
            <option value="value"></option>
            <option value="note"></option>
            <option value="updated_at"></option>
          </datalist>
          <div class="grid-wrap" id="settingsWrap"></div>
          <div class="filter-row">
            <input id="settingsFilter" placeholder="Filter settings key / note" oninput="applyFilters()" />
          </div>
        </div>
      </section>

      <section class="panel">
        <div class="panel-head">
          <h2>Account Snapshot <span class="chip" id="snapshotCountChip">0 rows</span></h2>
          <div class="actions">
            <button onclick="addSnapshotRow()">Add row</button>
            <button class="primary" onclick="saveSnapshot()">Save snapshot</button>
          </div>
        </div>
        <div class="status">
          <span class="chip">Paste TSV below and replace all rows</span>
          <span class="chip">Canonical column order follows spec/15_account_snapshot_contract.yaml</span>
        </div>
        <div class="pane-body">
          <div class="snapshot-callout">
            <strong>Account snapshot editing surface</strong>
            <div class="muted">This panel is intentionally separated from settings so row selection, field edits, and save approval stay visually dominant.</div>
          </div>
          <div class="view-controls">
            <input id="snapshotSortField" placeholder="sort field" list="snapshotSortFields" oninput="applyViewPreferences('account_snapshot')" />
            <select id="snapshotSortDirection" onchange="applyViewPreferences('account_snapshot')">
              <option value="asc">asc</option>
              <option value="desc">desc</option>
            </select>
            <button onclick="saveViewPreset('account_snapshot')">Save view</button>
            <select id="snapshotViewPreset" onchange="loadViewPreset('account_snapshot')"></select>
            <button onclick="clearViewPreset('account_snapshot')">Delete view</button>
          </div>
          <datalist id="snapshotSortFields">
            <option value="ordinal"></option>
            <option value="ticker"></option>
            <option value="name"></option>
            <option value="account"></option>
            <option value="account_type"></option>
            <option value="parse_status"></option>
            <option value="position_type"></option>
            <option value="updated_at"></option>
            <option value="market_value"></option>
          </datalist>
          <div class="filter-row">
            <input id="snapshotFilter" placeholder="Filter ticker / name / account" oninput="applyFilters()" />
          </div>
          <div class="grid-wrap" id="snapshotWrap"></div>
          <div class="note-box">
            <textarea id="snapshotTsv" placeholder="Paste TSV rows here (headerless or with header)."></textarea>
            <div class="toolbar">
              <button class="good" onclick="importSnapshotTsv()">Import TSV (replace)</button>
              <button onclick="fillSnapshotTemplate()">Insert blank template</button>
            </div>
          </div>
        </div>
      </section>
    </div>
  </main>

  <script>
    const state = {
      settingsRows: [],
      snapshotRows: [],
      initialSettingsRows: [],
      initialSnapshotRows: [],
      snapshotColumns: [],
      summary: {},
      approvalRows: [],
      approvalSettings: {},
      approvalSnapshot: {},
      locks: [],
      recentChanges: [],
      validation: { settings: [], account_snapshot: [] },
      suggestions: [],
      autofixActions: [],
      filterSettings: "",
      filterSnapshot: "",
      filterCollection: "",
      filterChangeLog: "",
      selected: { domain: "", target_ref: "" },
      viewPresets: { settings: [], account_snapshot: [] },
      viewPrefs: {
        settings: { sortField: "ordinal", sortDirection: "asc" },
        account_snapshot: { sortField: "ordinal", sortDirection: "asc" },
      },
      collection: {},
      collectionSelection: { kind: "", key: "" },
    };

    const VIEW_STORAGE_KEY = "snapshot_admin_view_presets_v1";
    const PREF_STORAGE_KEY = "snapshot_admin_view_prefs_v1";

    const settingsColumns = ["ordinal", "key", "value", "note", "updated_at"];

    function esc(value) {
      return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
    }

    function cellValue(row, column) {
      const value = row[column];
      if (value === undefined || value === null) return "";
      if (typeof value === "object") return JSON.stringify(value, null, 2);
      return String(value);
    }

    function collectTable(tableId, columns) {
      const table = document.getElementById(tableId);
      const rows = [];
      const bodyRows = table.querySelectorAll("tbody tr[data-row-index]");
      bodyRows.forEach((tr, index) => {
        const row = { ordinal: index + 1, _row_ref: tr.dataset.rowRef || "" };
        columns.forEach((col, colIndex) => {
          const cell = tr.querySelector(`td[data-col-index="${colIndex}"]`);
          row[col] = cell ? cell.innerText.trim() : "";
        });
        rows.push(row);
      });
      return rows;
    }

    function currentSettingsRows() {
      try {
        return collectTable("settingsTable", ["key", "value", "note"]).map((row, index) => ({
          ordinal: index + 1,
          _row_ref: row._row_ref || "",
          key: row.key,
          value: parseCellValue(row.value),
          note: row.note,
        }));
      } catch (err) {
        return state.settingsRows || [];
      }
    }

    function currentSnapshotRows() {
      try {
        return collectTable("snapshotTable", state.snapshotColumns).map((row, index) => {
          const normalized = { ordinal: index + 1, _row_ref: row._row_ref || "" };
          state.snapshotColumns.forEach((column) => {
            normalized[column] = parseCellValue(row[column]);
          });
          return normalized;
        });
      } catch (err) {
        return state.snapshotRows || [];
      }
    }

    function cloneRows(rows) {
      return JSON.parse(JSON.stringify(rows || []));
    }

    function loadJsonStorage(key, fallback) {
      try {
        const text = localStorage.getItem(key);
        if (!text) return fallback;
        const parsed = JSON.parse(text);
        return parsed && typeof parsed === "object" ? parsed : fallback;
      } catch (err) {
        return fallback;
      }
    }

    function saveJsonStorage(key, value) {
      try {
        localStorage.setItem(key, JSON.stringify(value));
      } catch (err) {
        return;
      }
    }

    function rowKey(domain, row) {
      if (domain === "settings") return String(row?._row_ref || row?.key || "").trim();
      if (domain === "account_snapshot") return snapshotTargetRef(row);
      return String(row?.target_ref || "").trim();
    }

    function snapshotTargetRef(row) {
      const ref = String(row?._row_ref || "").trim();
      if (ref) return ref;
      const ordinal = String(row?._ordinal ?? row?.ordinal ?? "").trim();
      if (ordinal) return `row:${ordinal}`;
      const ticker = String(row?.ticker || "").trim();
      if (ticker) return `ticker:${ticker}`;
      return "row:unknown";
    }

    function settingsTargetRef(row) {
      return String(row?._row_ref || row?.key || "").trim();
    }

    function rowDisplayLabel(domain, row) {
      if (domain === "settings") {
        return String(row?.key || "").trim() || "settings-row";
      }
      if (domain === "account_snapshot") {
        const ticker = String(row?.ticker || "").trim();
        const name = String(row?.name || "").trim();
        const ordinal = String(row?._ordinal ?? row?.ordinal ?? "").trim();
        return [ticker, name, ordinal ? `#${ordinal}` : ""].filter(Boolean).join(" ");
      }
      return rowKey(domain, row);
    }

    function collectionItemKey(kind, item, index) {
      if (kind === "run") {
        return String(item?.run_id || item?.started_at || index || "").trim();
      }
      if (kind === "snapshot") {
        return [item?.run_id, item?.dataset_name, item?.ticker, item?.created_at, index].map((value) => String(value || "").trim()).join("|");
      }
      if (kind === "error") {
        return [item?.run_id, item?.source_name, item?.ticker, item?.error_kind, item?.created_at, index].map((value) => String(value || "").trim()).join("|");
      }
      return String(item?.id || index || "").trim();
    }

    function collectionItemLabel(kind, item) {
      if (kind === "run") {
        const finished = item?.finished_at || "RUNNING";
        return `${item?.started_at || ""} ${item?.status || ""} ${item?.collector_name || "collector"} ${item?.run_id || ""} -> ${finished}`.trim();
      }
      if (kind === "snapshot") {
        return `${item?.created_at || ""} ${item?.dataset_name || ""}:${item?.ticker || ""} ${item?.source_status || ""}`.trim();
      }
      if (kind === "error") {
        return `${item?.created_at || ""} ${item?.source_name || ""}:${item?.error_kind || ""} ${item?.ticker || ""} ${item?.error_message || ""}`.trim();
      }
      return JSON.stringify(item, null, 2);
    }

    function collectionItemDetail(kind, item) {
      const base = item && typeof item === "object" ? item : {};
      return JSON.stringify({ kind, ...base }, null, 2);
    }

    function selectCollectionItem(kind, key) {
      state.collectionSelection = { kind, key };
      renderCollectionState();
    }

    function currentCollectionSelectionKey(kind, item, index) {
      return collectionItemKey(kind, item, index);
    }

    function selectedRowRef(domain) {
      return state.selected.domain === domain ? String(state.selected.target_ref || "").trim() : "";
    }

    function selectRow(domain, row) {
      state.selected = { domain, target_ref: rowKey(domain, row) };
      renderSelectionInspector();
      renderSettings();
      renderSnapshot();
    }

    function currentSelectionRow() {
      const domain = state.selected.domain;
      const ref = String(state.selected.target_ref || "").trim();
      if (!domain || !ref) return null;
      const rows = domain === "settings" ? state.settingsRows : domain === "account_snapshot" ? state.snapshotRows : [];
      return rows.find((row) => rowKey(domain, row) === ref) || null;
    }

    function currentSelectionTarget() {
      const row = currentSelectionRow();
      if (!row) return { domain: "", target_ref: "" };
      return { domain: state.selected.domain, target_ref: rowKey(state.selected.domain, row) };
    }

    function selectedDomainOrDefault() {
      if (state.selected.domain === "settings" || state.selected.domain === "account_snapshot") {
        return state.selected.domain;
      }
      const active = document.activeElement;
      if (active && active.closest) {
        if (active.closest("#snapshotWrap") || active.closest("#snapshotTsv")) return "account_snapshot";
        if (active.closest("#settingsWrap")) return "settings";
      }
      return "settings";
    }

    function normalizeViewPreset(domain, preset) {
      const fallback = domain === "settings"
        ? { sortField: "ordinal", sortDirection: "asc", filter: "" }
        : { sortField: "ordinal", sortDirection: "asc", filter: "" };
      if (!preset || typeof preset !== "object") return fallback;
      return {
        sortField: String(preset.sortField || fallback.sortField),
        sortDirection: String(preset.sortDirection || fallback.sortDirection),
        filter: String(preset.filter || ""),
      };
    }

    function loadStoredViewPrefs() {
      const prefs = loadJsonStorage(PREF_STORAGE_KEY, {});
      state.viewPrefs.settings = normalizeViewPreset("settings", prefs.settings);
      state.viewPrefs.account_snapshot = normalizeViewPreset("account_snapshot", prefs.account_snapshot);
      state.filterSettings = state.viewPrefs.settings.filter || "";
      state.filterSnapshot = state.viewPrefs.account_snapshot.filter || "";
    }

    function persistViewPrefs() {
      saveJsonStorage(PREF_STORAGE_KEY, state.viewPrefs);
    }

    function loadStoredPresets() {
      const presets = loadJsonStorage(VIEW_STORAGE_KEY, { settings: [], account_snapshot: [] });
      state.viewPresets.settings = Array.isArray(presets.settings) ? presets.settings : [];
      state.viewPresets.account_snapshot = Array.isArray(presets.account_snapshot) ? presets.account_snapshot : [];
    }

    function persistViewPresets() {
      saveJsonStorage(VIEW_STORAGE_KEY, state.viewPresets);
    }

    function updateViewControls(domain) {
      const prefs = state.viewPrefs[domain] || {};
      const filterId = domain === "settings" ? "settingsFilter" : "snapshotFilter";
      const sortFieldId = domain === "settings" ? "settingsSortField" : "snapshotSortField";
      const sortDirId = domain === "settings" ? "settingsSortDirection" : "snapshotSortDirection";
      const presetId = domain === "settings" ? "settingsViewPreset" : "snapshotViewPreset";
      document.getElementById(filterId).value = prefs.filter || "";
      document.getElementById(sortFieldId).value = prefs.sortField || "ordinal";
      document.getElementById(sortDirId).value = prefs.sortDirection || "asc";
      const select = document.getElementById(presetId);
      const presets = state.viewPresets[domain] || [];
      select.innerHTML = ['<option value="">Saved views</option>']
        .concat(presets.map((item) => `<option value="${esc(item.name)}">${esc(item.name)}</option>`))
        .join("");
    }

    function applyViewPreferences(domain) {
      const filterId = domain === "settings" ? "settingsFilter" : "snapshotFilter";
      const sortFieldId = domain === "settings" ? "settingsSortField" : "snapshotSortField";
      const sortDirId = domain === "settings" ? "settingsSortDirection" : "snapshotSortDirection";
      state.viewPrefs[domain] = {
        sortField: document.getElementById(sortFieldId).value.trim() || "ordinal",
        sortDirection: document.getElementById(sortDirId).value,
        filter: document.getElementById(filterId).value,
      };
      persistViewPrefs();
      renderSettings();
      renderSnapshot();
    }

    function saveViewPreset(domain) {
      const name = prompt("Save view preset name");
      if (!name) return;
      const filterId = domain === "settings" ? "settingsFilter" : "snapshotFilter";
      const sortFieldId = domain === "settings" ? "settingsSortField" : "snapshotSortField";
      const sortDirId = domain === "settings" ? "settingsSortDirection" : "snapshotSortDirection";
      const preset = {
        name,
        filter: document.getElementById(filterId).value,
        sortField: document.getElementById(sortFieldId).value.trim() || "ordinal",
        sortDirection: document.getElementById(sortDirId).value,
      };
      const list = state.viewPresets[domain] || [];
      const index = list.findIndex((item) => item.name === name);
      if (index >= 0) list[index] = preset;
      else list.push(preset);
      state.viewPresets[domain] = list;
      persistViewPresets();
      updateViewControls(domain);
    }

    function loadViewPreset(domain) {
      const presetId = domain === "settings" ? "settingsViewPreset" : "snapshotViewPreset";
      const name = document.getElementById(presetId).value;
      if (!name) return;
      const preset = (state.viewPresets[domain] || []).find((item) => item.name === name);
      if (!preset) return;
      const filterId = domain === "settings" ? "settingsFilter" : "snapshotFilter";
      const sortFieldId = domain === "settings" ? "settingsSortField" : "snapshotSortField";
      const sortDirId = domain === "settings" ? "settingsSortDirection" : "snapshotSortDirection";
      document.getElementById(filterId).value = preset.filter || "";
      document.getElementById(sortFieldId).value = preset.sortField || "ordinal";
      document.getElementById(sortDirId).value = preset.sortDirection || "asc";
      applyViewPreferences(domain);
    }

    function clearViewPreset(domain) {
      const presetId = domain === "settings" ? "settingsViewPreset" : "snapshotViewPreset";
      const name = document.getElementById(presetId).value;
      if (!name) return;
      state.viewPresets[domain] = (state.viewPresets[domain] || []).filter((item) => item.name !== name);
      persistViewPresets();
      updateViewControls(domain);
    }

    function sortRows(rows, domain) {
      const prefs = state.viewPrefs[domain] || {};
      const field = prefs.sortField || "ordinal";
      const direction = String(prefs.sortDirection || "asc").toLowerCase() === "desc" ? -1 : 1;
      const sorted = (rows || []).slice();
      sorted.sort((a, b) => {
        const av = a?.[field];
        const bv = b?.[field];
        const aText = av === null || av === undefined ? "" : String(av);
        const bText = bv === null || bv === undefined ? "" : String(bv);
        const aNum = Number(aText);
        const bNum = Number(bText);
        if (!Number.isNaN(aNum) && !Number.isNaN(bNum) && aText !== "" && bText !== "") {
          return (aNum - bNum) * direction;
        }
        return aText.localeCompare(bText, "ko") * direction;
      });
      return sorted;
    }

    function applyFiltersAndSort(domain, rows, columns) {
      const filterText = domain === "settings" ? state.filterSettings : state.filterSnapshot;
      const terms = String(filterText || "").toLowerCase().trim().replaceAll("\t", " ").split(" ").filter(Boolean);
      const filtered = rows.filter((row) => matchesFilter(row, terms, columns));
      return sortRows(filtered, domain);
    }

    function rowFieldDiffs(beforeRow, afterRow, columns) {
      const fields = [];
      const seen = new Set(columns || []);
      for (const column of columns || []) {
        const before = cellValue(beforeRow || {}, column);
        const after = cellValue(afterRow || {}, column);
        if (before !== after) {
          fields.push({ field: column, before, after });
        }
      }
      for (const key of Object.keys(beforeRow || {})) {
        if (key.startsWith("_") || seen.has(key)) continue;
        const before = cellValue(beforeRow || {}, key);
        const after = cellValue(afterRow || {}, key);
        if (before !== after) {
          fields.push({ field: key, before, after });
        }
      }
      for (const key of Object.keys(afterRow || {})) {
        if (key.startsWith("_") || seen.has(key) || Object.prototype.hasOwnProperty.call(beforeRow || {}, key)) continue;
        const before = "";
        const after = cellValue(afterRow || {}, key);
        if (before !== after) {
          fields.push({ field: key, before, after });
        }
      }
      return fields;
    }

    function rowDiffSummary(beforeRows, afterRows, domain, columns) {
      const beforeMap = new Map((beforeRows || []).map((row) => [rowKey(domain, row), row]));
      const afterMap = new Map((afterRows || []).map((row) => [rowKey(domain, row), row]));
      const added = [];
      const removed = [];
      const changed = [];
      for (const [key, afterRow] of afterMap.entries()) {
        if (!beforeMap.has(key)) {
          added.push({ key, row: afterRow });
          continue;
        }
        const beforeRow = beforeMap.get(key);
        const beforeJson = JSON.stringify(beforeRow);
        const afterJson = JSON.stringify(afterRow);
        if (beforeJson !== afterJson) {
          changed.push({ key, before: beforeRow, after: afterRow, cells: rowFieldDiffs(beforeRow, afterRow, columns) });
        }
      }
      for (const [key, beforeRow] of beforeMap.entries()) {
        if (!afterMap.has(key)) {
          removed.push({ key, row: beforeRow });
        }
      }
      return { added, removed, changed };
    }

    function compactRowDiff(summary, domain) {
      return {
        domain,
        added: (summary.added || []).map((item) => ({ key: item.key, change_type: "added" })),
        removed: (summary.removed || []).map((item) => ({ key: item.key, change_type: "removed" })),
        changed: (summary.changed || []).map((item) => ({
          key: item.key,
          change_type: "changed",
          cells: (item.cells || []).map((cell) => ({ field: cell.field, before: cell.before, after: cell.after })),
        })),
      };
    }

    function compressApprovalDiff(summary) {
      const compressed = compactRowDiff(summary, "account_snapshot");
      compressed.changed = (summary.changed || []).map((item) => ({
        key: item.key,
        change_type: "changed",
        changed_fields: (item.cells || []).map((cell) => cell.field),
        field_count: (item.cells || []).length,
      }));
      return compressed;
    }

    function buildDiffHtml(title, summary) {
      const sections = [
        { label: "added", items: summary.added, tone: "good" },
        { label: "removed", items: summary.removed, tone: "danger" },
        { label: "changed", items: summary.changed, tone: "warn" },
      ];
      const summaryLine = title.includes("account_snapshot")
        ? `<div class="chip">row-level preview focused on <strong>account_snapshot</strong></div>`
        : "";
      const blocks = sections.map((section) => {
        const entries = section.items.slice(0, 5).map((item) => {
          const before = item.before ? JSON.stringify(item.before, null, 2) : JSON.stringify(item.row, null, 2);
          const after = item.after ? JSON.stringify(item.after, null, 2) : JSON.stringify(item.row, null, 2);
          const cellText = item.cells && item.cells.length
            ? `<code>${esc(item.cells.map((cell) => `${cell.field}: ${cell.before} -> ${cell.after}`).join("\n"))}</code>`
            : "";
          const detail = item.after
            ? `<strong>${esc(item.key)}</strong>${cellText}<code>before:\n${esc(before)}\n\nafter:\n${esc(after)}</code>`
            : `<strong>${esc(item.key)}</strong><code>${esc(after)}</code>`;
          return `<div class="diff-item"><span class="chip">${section.label}</span>${detail}</div>`;
        }).join("");
        return `<div><div class="chip">${section.label}: ${section.items.length}</div>${entries || "<div class='muted'>none</div>"}</div>`;
      }).join("");
      return `<div class="diff-list"><div class="chip">${esc(title)}</div>${summaryLine}${blocks}</div>`;
    }

    function buildTable(containerId, tableId, columns, rows, options) {
      const container = document.getElementById(containerId);
      const domain = options?.domain || "";
      const table = document.createElement("table");
      table.className = "sheet";
      table.id = tableId;
      const thead = document.createElement("thead");
      const headRow = document.createElement("tr");
      const numHead = document.createElement("th");
      numHead.className = "rownum";
      numHead.textContent = "#";
      headRow.appendChild(numHead);
      columns.forEach((column) => {
        const th = document.createElement("th");
        th.textContent = column;
        headRow.appendChild(th);
      });
      if (options && Array.isArray(options.rowActions) && options.rowActions.length > 0) {
        const th = document.createElement("th");
        th.textContent = "actions";
        headRow.appendChild(th);
      }
      thead.appendChild(headRow);
      table.appendChild(thead);

      const tbody = document.createElement("tbody");
      rows.forEach((row, rowIndex) => {
        const tr = document.createElement("tr");
        tr.dataset.rowIndex = String(rowIndex);
        tr.dataset.rowRef = String(row?._row_ref || rowKey(domain, row) || "");
        const currentSelection = state.selected || {};
        const rowSelected = domain && currentSelection.domain === domain && currentSelection.target_ref === tr.dataset.rowRef;
        if (rowSelected) {
          tr.classList.add("selected");
          if (domain === "account_snapshot") {
            tr.classList.add("selected-account-snapshot");
          }
        }
        tr.addEventListener("click", (event) => {
          if (event.target.closest("button")) return;
          if (domain) {
            selectRow(domain, row);
          }
        });
        const rowNum = document.createElement("td");
        rowNum.className = "rownum";
        rowNum.textContent = String(rowIndex + 1);
        tr.appendChild(rowNum);
        columns.forEach((column, colIndex) => {
          const td = document.createElement("td");
          td.setAttribute("contenteditable", "true");
          td.setAttribute("spellcheck", "false");
          td.dataset.colIndex = String(colIndex);
          td.textContent = cellValue(row, column);
          if (rowSelected && domain === "account_snapshot") {
            td.classList.add("selected-field");
          }
          td.addEventListener("paste", (event) => handlePaste(event, tableId, columns));
          td.addEventListener("input", () => previewDiff());
          td.addEventListener("focus", () => {
            if (domain) selectRow(domain, row);
          });
          td.addEventListener("keydown", (event) => {
            if (event.key === "Enter" && !event.shiftKey) {
              event.preventDefault();
              td.blur();
            }
          });
          tr.appendChild(td);
        });
        if (options && Array.isArray(options.rowActions) && options.rowActions.length > 0) {
          const actionTd = document.createElement("td");
          const actions = document.createElement("div");
          actions.className = "row-actions";
          options.rowActions.forEach((action) => {
            const btn = document.createElement("button");
            btn.textContent = action.label;
            if (action.className) {
              btn.className = action.className;
            }
            btn.addEventListener("click", () => action.onClick(row, rowIndex));
            actions.appendChild(btn);
          });
          actionTd.appendChild(actions);
          tr.appendChild(actionTd);
        }
        tbody.appendChild(tr);
      });
      table.appendChild(tbody);
      container.innerHTML = "";
      container.appendChild(table);
      if (options && options.emptyMessage && rows.length === 0) {
        const empty = document.createElement("div");
        empty.className = "muted";
        empty.style.padding = "12px";
        empty.textContent = options.emptyMessage;
        container.appendChild(empty);
      }
    }

    function matchesFilter(row, terms, columns) {
      if (!terms.length) return true;
      const haystack = columns.map((column) => String(row[column] ?? "")).join(" ").toLowerCase();
      return terms.every((term) => haystack.includes(term));
    }

    function handlePaste(event, tableId, columns) {
      const text = event.clipboardData.getData("text/plain");
      if (!text.includes("\t") && !text.includes("\n")) {
        return;
      }
      event.preventDefault();
      const table = document.getElementById(tableId);
      const startCell = event.target.closest("td");
      const startRow = startCell.parentElement.rowIndex - 1;
      const startCol = Number(startCell.dataset.colIndex);
      const rows = table.querySelectorAll("tbody tr[data-row-index]");
      const pastedRows = text.replace(/\r/g, "").split("\n").filter((line) => line.trim() !== "");
      pastedRows.forEach((line, rowOffset) => {
        const targetRow = rows[startRow + rowOffset];
        if (!targetRow) return;
        const values = line.split("\t");
        values.forEach((value, colOffset) => {
          const targetCell = targetRow.querySelector(`td[data-col-index="${startCol + colOffset}"]`);
          if (targetCell) {
            targetCell.innerText = value;
          }
        });
      });
    }

    function addSettingRow() {
      state.settingsRows.push({ ordinal: state.settingsRows.length + 1, _row_ref: `settings:new:${state.settingsRows.length + 1}`, key: "", value: "", note: "", updated_at: "" });
      renderSettings();
    }

    function addSnapshotRow() {
      const row = { ordinal: state.snapshotRows.length + 1, _row_ref: `account_snapshot:new:${state.snapshotRows.length + 1}` };
      state.snapshotColumns.forEach((col) => {
        row[col] = "";
      });
      row.parse_status = "NOT_PROVIDED";
      row.user_confirmed = "N";
      state.snapshotRows.push(row);
      renderSnapshot();
    }

    function deleteSettingRow(row) {
      const index = state.settingsRows.indexOf(row);
      if (index >= 0) {
        state.settingsRows.splice(index, 1);
        state.settingsRows.forEach((item, idx) => { item.ordinal = idx + 1; });
        renderSettings();
      }
    }

    function deleteSnapshotRow(row) {
      const index = state.snapshotRows.indexOf(row);
      if (index >= 0) {
        state.snapshotRows.splice(index, 1);
        state.snapshotRows.forEach((item, idx) => { item.ordinal = idx + 1; });
        renderSnapshot();
      }
    }

    function _nextTempRowRef(domain, suffix) {
      const rows = domain === "settings" ? state.settingsRows : state.snapshotRows;
      return `${domain}:${suffix}:${rows.length + 1}:${Date.now().toString(36)}`;
    }

    function _reindexRows(rows) {
      rows.forEach((item, idx) => {
        item.ordinal = idx + 1;
      });
    }

    function insertSettingRow(referenceRow, placement = "below", duplicate = false) {
      const index = state.settingsRows.indexOf(referenceRow);
      const insertAt = index < 0 ? state.settingsRows.length : (placement === "above" ? index : index + 1);
      const template = duplicate ? referenceRow : {};
      const row = {
        ordinal: insertAt + 1,
        _row_ref: _nextTempRowRef("settings", duplicate ? "copy" : "blank"),
        key: duplicate ? "" : "",
        value: duplicate ? template.value : "",
        note: duplicate ? template.note : "",
        updated_at: "",
      };
      state.settingsRows.splice(insertAt, 0, row);
      _reindexRows(state.settingsRows);
      selectRow("settings", row);
      renderSettings();
    }

    function insertSnapshotRow(referenceRow, placement = "below", duplicate = false) {
      const index = state.snapshotRows.indexOf(referenceRow);
      const insertAt = index < 0 ? state.snapshotRows.length : (placement === "above" ? index : index + 1);
      const row = duplicate
        ? { ...cloneRows([referenceRow])[0] }
        : {};
      const nextRow = {
        ordinal: insertAt + 1,
        _row_ref: _nextTempRowRef("account_snapshot", duplicate ? "copy" : "blank"),
      };
      state.snapshotColumns.forEach((col) => {
        if (duplicate) {
          nextRow[col] = row[col] ?? "";
        } else {
          nextRow[col] = "";
        }
      });
      nextRow.parse_status = duplicate ? (row.parse_status || "CAPTURE_READ_OK") : "NOT_PROVIDED";
      nextRow.user_confirmed = duplicate ? (row.user_confirmed || "N") : "N";
      state.snapshotRows.splice(insertAt, 0, nextRow);
      _reindexRows(state.snapshotRows);
      selectRow("account_snapshot", nextRow);
      renderSnapshot();
    }

    function renderSettings() {
      updateViewControls("settings");
      const filteredRows = applyFiltersAndSort("settings", state.settingsRows, ["key", "value", "note"]);
      buildTable("settingsWrap", "settingsTable", ["key", "value", "note"], filteredRows, {
        domain: "settings",
        emptyMessage: "No settings rows yet.",
        rowActions: [
          { label: "Insert above", onClick: (row) => insertSettingRow(row, "above") },
          { label: "Insert below", onClick: (row) => insertSettingRow(row, "below") },
          { label: "Duplicate", onClick: (row) => insertSettingRow(row, "below", true) },
          { label: "Approve row", className: "good", onClick: (row) => approveTarget("settings", settingsTargetRef(row)) },
          { label: "Lock row", onClick: (row) => lockTarget("settings", settingsTargetRef(row)) },
          { label: "Unlock row", onClick: (row) => unlockTarget("settings", settingsTargetRef(row)) },
          { label: "Delete", className: "danger", onClick: deleteSettingRow },
        ],
      });
    }

    function renderSnapshot() {
      updateViewControls("account_snapshot");
      const filteredRows = applyFiltersAndSort("account_snapshot", state.snapshotRows, ["ticker", "name", "account", "account_type", "parse_status", "position_type"]);
      buildTable("snapshotWrap", "snapshotTable", state.snapshotColumns, filteredRows, {
        domain: "account_snapshot",
        emptyMessage: "No account snapshot rows yet.",
        rowActions: [
          { label: "Insert above", onClick: (row) => insertSnapshotRow(row, "above") },
          { label: "Insert below", onClick: (row) => insertSnapshotRow(row, "below") },
          { label: "Duplicate", onClick: (row) => insertSnapshotRow(row, "below", true) },
          { label: "Approve row", className: "good", onClick: (row) => approveTarget("account_snapshot", snapshotTargetRef(row)) },
          { label: "Lock row", onClick: (row) => lockTarget("account_snapshot", snapshotTargetRef(row)) },
          { label: "Unlock row", onClick: (row) => unlockTarget("account_snapshot", snapshotTargetRef(row)) },
          { label: "Delete", className: "danger", onClick: deleteSnapshotRow },
        ],
      });
    }

    function parseCellValue(value) {
      const text = value.trim();
      if (text === "") return "";
      if (text === "null" || text === "None") return null;
      if (text === "true") return true;
      if (text === "false") return false;
      const numericPattern = new RegExp("^-?(0|[1-9]\\d*)(\\.\\d+)?([eE][-+]?\\d+)?$");
      if (numericPattern.test(text)) return Number(text);
      try {
        return JSON.parse(text);
      } catch (err) {
        return text;
      }
    }

    async function reloadState() {
      const response = await fetch("/api/state");
      const payload = await response.json();
      state.summary = payload.summary || {};
      state.version = payload.version || {};
      state.approvalRows = payload.approval_rows || [];
      state.approvalSettings = payload.approval_settings || {};
      state.approvalSnapshot = payload.approval_account_snapshot || {};
      state.locks = payload.locks || [];
      state.recentChanges = payload.recent_changes || [];
      state.settingsRows = (payload.settings_rows || []).map((row) => ({ ...row, _row_ref: settingsTargetRef(row) }));
      state.snapshotRows = (payload.account_snapshot_rows || []).map((row) => ({ ...row, _row_ref: snapshotTargetRef(row) }));
      state.initialSettingsRows = cloneRows(state.settingsRows);
      state.initialSnapshotRows = cloneRows(state.snapshotRows);
      state.snapshotColumns = payload.account_snapshot_columns || [];
      state.validation = payload.validation || { settings: [], account_snapshot: [] };
      state.suggestions = (state.validation && state.validation.suggestions) || [];
      state.autofixActions = payload.autofix_actions || [];
      loadStoredViewPrefs();
      loadStoredPresets();
      renderSettings();
      renderSnapshot();
      renderMeta();
      renderCollectionState();
      renderSelectionInspector();
      renderValidation();
      const settingsCountChip = document.getElementById("settingsCountChip");
      if (settingsCountChip) settingsCountChip.textContent = `${state.settingsRows.length} rows`;
      const snapshotCountChip = document.getElementById("snapshotCountChip");
      if (snapshotCountChip) snapshotCountChip.textContent = `${state.snapshotRows.length} rows`;
      document.getElementById("workspaceStatus").innerHTML = `
        <strong>DB:</strong> ${esc(state.summary.db_path || "")}
        <strong>settings:</strong> ${esc(state.summary.settings_rows ?? 0)}
        <strong>account_snapshot:</strong> ${esc(state.summary.account_snapshot_rows ?? 0)}
        <strong>updated:</strong> ${esc(state.summary.latest_update || "")}
      `;
      previewDiff();
    }

    async function saveSettings() {
      if (String(state.filterSettings || "").trim()) {
        throw new Error("clear settings filter before saving");
      }
      const rows = currentSettingsRows();
      if (state.locks.some((lock) => lock.domain === "settings" && lock.target_ref === "*")) {
        throw new Error("settings are locked");
      }
      const targets = rows.map((row) => String(row.key || "").trim()).filter(Boolean);
      if (state.locks.some((lock) => lock.domain === "settings" && lock.target_ref !== "*" && targets.includes(String(lock.target_ref || "").trim()))) {
        throw new Error("one or more settings rows are locked");
      }
      const response = await fetch("/api/settings/save", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ rows }),
      });
      const payload = await response.json();
      if (!response.ok) throw new Error(payload.detail || "settings save failed");
      await reloadState();
      alert(`settings saved: ${payload.settings_rows}`);
    }

    async function saveSnapshot() {
      if (String(state.filterSnapshot || "").trim()) {
        throw new Error("clear snapshot filter before saving");
      }
      const columns = state.snapshotColumns;
      const rows = currentSnapshotRows();
      if (state.locks.some((lock) => lock.domain === "account_snapshot" && lock.target_ref === "*")) {
        throw new Error("account_snapshot is locked");
      }
      const targets = rows.map((row) => snapshotTargetRef(row)).filter(Boolean);
      if (state.locks.some((lock) => lock.domain === "account_snapshot" && lock.target_ref !== "*" && targets.includes(String(lock.target_ref || "").trim()))) {
        throw new Error("one or more account_snapshot rows are locked");
      }
      const response = await fetch("/api/account_snapshot/save", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ rows }),
      });
      const payload = await response.json();
      if (!response.ok) throw new Error(payload.detail || "snapshot save failed");
      await reloadState();
      alert(`account_snapshot saved: ${payload.account_snapshot_rows}`);
    }

    async function importSnapshotTsv() {
      const tsv = document.getElementById("snapshotTsv").value;
      const response = await fetch("/api/account_snapshot/import_tsv", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ tsv }),
      });
      const payload = await response.json();
      if (!response.ok) throw new Error(payload.detail || "TSV import failed");
      await reloadState();
      alert(`Imported ${payload.account_snapshot_rows} snapshot rows`);
    }

    async function approveDomain(domain) {
      const response = await fetch("/api/approve", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ domain }),
      });
      const payload = await response.json();
      if (!response.ok) throw new Error(payload.detail || "approve failed");
      await reloadState();
    }

    async function approveTarget(domain, targetRef) {
      const response = await fetch("/api/approve", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ domain, target_ref: targetRef || "*" }),
      });
      const payload = await response.json();
      if (!response.ok) throw new Error(payload.detail || "approve failed");
      await reloadState();
      return payload;
    }

    async function lockDomain(domain) {
      return lockTarget(domain, "*");
    }

    async function lockTarget(domain, targetRef) {
      const response = await fetch("/api/lock", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ domain, target_ref: targetRef || "*" }),
      });
      const payload = await response.json();
      if (!response.ok) throw new Error(payload.detail || "lock failed");
      await reloadState();
    }

    async function unlockDomain(domain) {
      return unlockTarget(domain, "*");
    }

    async function unlockTarget(domain, targetRef) {
      const response = await fetch("/api/unlock", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ domain, target_ref: targetRef || "*" }),
      });
      const payload = await response.json();
      if (!response.ok) throw new Error(payload.detail || "unlock failed");
      await reloadState();
    }

    async function lockTargetFromInput() {
      const domain = document.getElementById("lockDomainInput").value.trim();
      const targetRef = document.getElementById("lockTargetInput").value.trim();
      await lockTarget(domain, targetRef || "*");
    }

    async function unlockTargetFromInput() {
      const domain = document.getElementById("lockDomainInput").value.trim();
      const targetRef = document.getElementById("lockTargetInput").value.trim();
      await unlockTarget(domain, targetRef || "*");
    }

    async function approvePendingChanges() {
      const packet = buildApprovalPacket();
      if (!packet.pending_targets.length) {
        alert("No pending changes to approve.");
        return;
      }
      for (const target of packet.pending_targets) {
        const response = await fetch("/api/approve", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ domain: target.domain, target_ref: target.target_ref }),
        });
        const payload = await response.json();
        if (!response.ok) throw new Error(payload.detail || `approve failed: ${target.domain}:${target.target_ref}`);
      }
      await reloadState();
    }

    function previewDiff() {
      const settingsDiff = rowDiffSummary(state.initialSettingsRows, currentSettingsRows(), "settings", ["key", "value", "note"]);
      const snapshotDiff = rowDiffSummary(state.initialSnapshotRows, currentSnapshotRows(), "account_snapshot", state.snapshotColumns);
      const settingsHtml = buildDiffHtml("settings pending diff", settingsDiff);
      const snapshotHtml = buildDiffHtml("account_snapshot pending diff", snapshotDiff);
      document.getElementById("diffPreview").innerHTML = `${settingsHtml}<hr style="border:0;border-top:1px solid rgba(255,255,255,.08);margin:12px 0;">${snapshotHtml}`;
      updateBannerDiffSummary(settingsDiff, snapshotDiff);
    }

    function buildApprovalPacket() {
      const settingsDiff = rowDiffSummary(state.initialSettingsRows, currentSettingsRows(), "settings", ["key", "value", "note"]);
      const snapshotDiff = rowDiffSummary(state.initialSnapshotRows, currentSnapshotRows(), "account_snapshot", state.snapshotColumns);
      const pending_targets = [
        ...settingsDiff.added.map((item) => ({ domain: "settings", target_ref: item.key, change_type: "added" })),
        ...settingsDiff.removed.map((item) => ({ domain: "settings", target_ref: item.key, change_type: "removed" })),
        ...settingsDiff.changed.map((item) => ({ domain: "settings", target_ref: item.key, change_type: "changed" })),
        ...snapshotDiff.added.map((item) => ({ domain: "account_snapshot", target_ref: item.key, change_type: "added" })),
        ...snapshotDiff.removed.map((item) => ({ domain: "account_snapshot", target_ref: item.key, change_type: "removed" })),
        ...snapshotDiff.changed.map((item) => ({ domain: "account_snapshot", target_ref: item.key, change_type: "changed" })),
      ];
      return {
        formula_id: "SNAPSHOT_ADMIN_APPROVAL_PACKET_V1",
        generated_at: new Date().toISOString(),
        summary: {
          settings_changed: settingsDiff.added.length + settingsDiff.removed.length + settingsDiff.changed.length,
          account_snapshot_changed: snapshotDiff.added.length + snapshotDiff.removed.length + snapshotDiff.changed.length,
          pending_target_count: pending_targets.length,
        },
        pending_targets,
        diff_preview: {
          settings: settingsDiff,
          account_snapshot: compressApprovalDiff(snapshotDiff),
        },
        approvals: state.approvalRows || [],
        locks: state.locks || [],
        workspace: state.summary || {},
      };
    }

    async function exportApprovalPacket() {
      const packet = buildApprovalPacket();
      const response = await fetch("/api/approval_packet", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ packet }),
      });
      const payload = await response.json();
      if (!response.ok) throw new Error(payload.detail || "approval packet export failed");
      alert(`approval packet exported: ${payload.packet_path}`);
      await reloadState();
    }

    async function undoLastChange(domain) {
      const response = await fetch("/api/undo", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ domain }),
      });
      const payload = await response.json();
      if (!response.ok) throw new Error(payload.detail || "undo failed");
      await reloadState();
    }

    async function runAutofix() {
      const eligible = (state.autofixActions || []).filter((item) => item.action_id !== "required_total_asset_missing");
      if (!eligible.length) {
        alert("No safe autofix actions available.");
        return;
      }
      for (const action of eligible) {
        const response = await fetch("/api/autofix", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ action_id: action.action_id }),
        });
        const payload = await response.json();
        if (!response.ok) throw new Error(payload.detail || `autofix failed: ${action.action_id}`);
      }
      await reloadState();
    }

    async function seedWorkspace() {
      const response = await fetch("/api/bootstrap", { method: "POST" });
      const payload = await response.json();
      if (!response.ok) throw new Error(payload.detail || "seed failed");
      await reloadState();
      alert("Workspace seeded from GatherTradingData.json");
    }

    async function downloadExport() {
      const response = await fetch("/api/export");
      const payload = await response.text();
      const blob = new Blob([payload], { type: "application/json" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = "snapshot_admin_export.json";
      document.body.appendChild(a);
      a.click();
      a.remove();
      URL.revokeObjectURL(url);
    }

    function fillSnapshotTemplate() {
      document.getElementById("snapshotTsv").value = [
        state.snapshotColumns.join("\t"),
        state.snapshotColumns.map(() => "").join("\t"),
      ].join("\n");
    }

    function summarizeNextAction() {
      const settingsErrors = (state.validation?.settings || []).length;
      const snapshotErrors = (state.validation?.account_snapshot || []).length;
      const settingsApproval = state.approvalSettings?.status || "MISSING";
      const snapshotApproval = state.approvalSnapshot?.status || "MISSING";
      const lockCount = (state.locks || []).length;
      if (settingsErrors || snapshotErrors) {
        return {
          title: "Fix validation issues",
          detail: `${settingsErrors + snapshotErrors} validation issue(s) remain. Edit the workspace before approving or exporting.`,
        };
      }
      if (settingsApproval !== "APPROVED" || snapshotApproval !== "APPROVED") {
        return {
          title: "Approve the current workspace",
          detail: `Approval status is settings=${settingsApproval}, snapshot=${snapshotApproval}. Review and approve the rows that are ready.`,
        };
      }
      if (lockCount > 0) {
        return {
          title: "Resolve active locks",
          detail: `${lockCount} lock(s) are active. Unlock only after confirming the affected rows are stable.`,
        };
      }
      return {
        title: "Workspace is ready",
        detail: "Approvals are clear and no active locks remain. Export or continue with downstream work.",
      };
    }

    function summarizeCollectionAction(collection, latestRun, latestReport) {
      const runStatus = String(latestRun?.status || "").trim();
      const runId = String(latestRun?.run_id || "").trim();
      if (!runId) {
        return {
          title: "No collection run yet",
          detail: "Use Collect now to create the first collector run and populate the run history.",
        };
      }
      if (runStatus === "FAIL") {
        return {
          title: "Collector failed",
          detail: `Latest run ${runId} failed. Check the run log and error rows before retrying.`,
        };
      }
      return {
        title: `Latest collection ${runStatus || "PASS"}`,
        detail: `Run ${runId} finished at ${latestRun?.finished_at || "N/A"}. Report status is ${latestReport?.status || "N/A"}.`,
      };
    }

    function setPanelOpen(panelId, shouldOpen) {
      const panel = document.getElementById(panelId);
      if (panel) panel.open = Boolean(shouldOpen);
    }

    function renderMeta() {
      const settingsApproval = state.approvalSettings || {};
      const snapshotApproval = state.approvalSnapshot || {};
      const settingsErrors = state.validation?.settings || [];
      const snapshotErrors = state.validation?.account_snapshot || [];
      const lockCount = (state.locks || []).length;
      const collection = state.collection || {};
      const latestRun = collection.latest_run || {};
      const latestReport = collection.latest_report || {};
      const version = state.version || {};
      const git = version.git || {};
      const source = version.source || {};
      document.getElementById("versionSummary").textContent =
        `app=${version.app || "N/A"} | git=${git.commit || "N/A"} | tree=${git.tree_state || "N/A"} | source=${source.fingerprint || "N/A"}`;
      document.getElementById("approvalSettingsChip").textContent =
        `settings: ${settingsApproval.status || "MISSING"} / ${settingsApproval.updated_at || ""}`;
      document.getElementById("approvalSnapshotChip").textContent =
        `account_snapshot: ${snapshotApproval.status || "MISSING"} / ${snapshotApproval.updated_at || ""}`;
      const locks = state.locks || [];
      const lockText = locks.length === 0
        ? "No active locks."
        : locks.map((lock) => `${lock.domain}:${lock.target_ref} by ${lock.locked_by || "unknown"} (${lock.locked_at})`).join(" | ");
      document.getElementById("lockSummary").textContent = lockText;
      const approvalRows = state.approvalRows || [];
      const approvalText = approvalRows.length === 0
        ? "No row-level approvals."
        : approvalRows.slice(0, 8).map((row) => `${row.domain}:${row.target_ref}=${row.status}`).join(" | ");
      document.getElementById("approvalRowSummary").textContent = approvalText;
      const historyCounts = state.historyCounts || {};
      const recent = (state.recentChanges || [])[0];
      const changeLogFilter = String(document.getElementById("changeLogFilter")?.value || state.filterChangeLog || "").trim().toLowerCase();
      state.filterChangeLog = changeLogFilter;
      const visibleChanges = (state.recentChanges || []).filter((item) => {
        if (!changeLogFilter) return true;
        const haystack = [
          item?.domain,
          item?.action,
          item?.target_ref,
          item?.actor,
          item?.note,
          item?.created_at,
        ].map((value) => String(value || "").toLowerCase()).join(" ");
        return haystack.includes(changeLogFilter);
      });
      document.getElementById("historySummary").textContent =
        `change_log=${historyCounts.changes ?? 0}, approvals=${historyCounts.approvals ?? 0}, locks=${historyCounts.locks ?? 0}` +
        (changeLogFilter ? `, filtered=${visibleChanges.length}` : "") +
        (recent ? ` | latest=${recent.domain}:${recent.action}:${recent.target_ref} @ ${recent.created_at}` : "");
      const nextAction = summarizeNextAction();
      const collectionAction = summarizeCollectionAction(collection, latestRun, latestReport);
      const heroNow = document.getElementById("heroNow");
      const heroNowDetail = document.getElementById("heroNowDetail");
      const heroNextAction = document.getElementById("heroNextAction");
      const heroNextDetail = document.getElementById("heroNextDetail");
      const heroPrimaryAction = document.getElementById("heroPrimaryAction");
      const heroCollection = document.getElementById("heroCollection");
      const heroCollectionDetail = document.getElementById("heroCollectionDetail");
      if (heroNow) {
        heroNow.textContent = `Workspace rows: settings=${state.summary?.settings_rows ?? 0}, snapshot=${state.summary?.account_snapshot_rows ?? 0}`;
      }
      if (heroNowDetail) {
        heroNowDetail.textContent = `Approval: settings=${settingsApproval.status || "MISSING"}, snapshot=${snapshotApproval.status || "MISSING"} | locks=${locks.length}`;
      }
      if (heroNextAction) heroNextAction.textContent = nextAction.title;
      if (heroNextDetail) heroNextDetail.textContent = nextAction.detail;
      if (heroPrimaryAction) {
        heroPrimaryAction.textContent = nextAction.title;
        const workspaceNeedsAttention = settingsErrors.length > 0 || snapshotErrors.length > 0 || settingsApproval.status !== "APPROVED" || snapshotApproval.status !== "APPROVED" || lockCount > 0;
        heroPrimaryAction.href = workspaceNeedsAttention ? "/" : "/collection";
      }
      if (heroCollection) {
        heroCollection.textContent = collectionAction.title;
      }
      if (heroCollectionDetail) {
        heroCollectionDetail.textContent = collectionAction.detail;
      }
      const shouldOpenWorkspace = settingsErrors.length > 0 || snapshotErrors.length > 0;
      const shouldOpenApproval = !shouldOpenWorkspace && (settingsApproval.status !== "APPROVED" || snapshotApproval.status !== "APPROVED" || lockCount > 0);
      const shouldOpenCollection = !shouldOpenWorkspace && !shouldOpenApproval && (!latestRun.run_id || latestRun.status === "FAIL");
      setPanelOpen("approvalPanel", shouldOpenApproval);
      setPanelOpen("collectionPanel", shouldOpenCollection);
      setPanelOpen("selectionPanel", Boolean(state.selected?.domain) && !shouldOpenWorkspace && !shouldOpenApproval && !shouldOpenCollection);
      document.getElementById("bannerApprovalSummary").textContent =
        `settings=${settingsApproval.status || "MISSING"} | snapshot=${snapshotApproval.status || "MISSING"}`;
      document.getElementById("bannerLockSummary").textContent =
        locks.length === 0 ? "unlocked" : `${locks.length} active lock(s)`;
      document.getElementById("bannerSelectionSummary").textContent =
        state.selected?.domain ? `${state.selected.domain}:${state.selected.target_ref || "*"}` : "No row selected.";
      document.getElementById("bannerDetail").textContent = lockText;
      updateBannerDiffSummary(
        rowDiffSummary(state.initialSettingsRows, currentSettingsRows(), "settings", ["key", "value", "note"]),
        rowDiffSummary(state.initialSnapshotRows, currentSnapshotRows(), "account_snapshot", state.snapshotColumns),
      );
      document.getElementById("changeLog").textContent = visibleChanges.map((item) => {
        const beforeCount = Array.isArray(item.before_json) ? item.before_json.length : 0;
        const afterCount = Array.isArray(item.after_json) ? item.after_json.length : 0;
        return `#${item.id} ${item.domain} ${item.action} ${item.target_ref} ${item.created_at}\n${beforeCount} -> ${afterCount}\n`;
      }).join("\n");
      const timelineEl = document.getElementById("changeTimeline");
      if (timelineEl) {
        const changeSummary = visibleChanges.slice(0, 12).map((item) => ({
          ts: String(item.created_at || "").slice(5, 16),
          label: `${item.domain}:${item.action}`,
        }));
        timelineEl.innerHTML = visibleChanges.length
          ? `
              <div class="muted" style="margin-bottom:8px;">Recent change summary</div>
              <div class="collection-trend-summary" style="margin-bottom:10px;">
                ${changeSummary.map((item) => `<span class="chip">${esc(item.ts || "N/A")} · ${esc(item.label)}</span>`).join("")}
              </div>
              ${visibleChanges.map((item) => {
                const before = Array.isArray(item.before_json) ? item.before_json : [];
                const after = Array.isArray(item.after_json) ? item.after_json : [];
                return `
                  <details class="history-item">
                    <summary class="chip">${esc(item.domain)} · ${esc(item.action)} · ${esc(item.target_ref)} · ${esc(item.created_at)}</summary>
                    <code>${esc(JSON.stringify({ before, after }, null, 2))}</code>
                  </details>
                `;
              }).join("")}
            `
          : "<div class='muted'>No timeline entries.</div>";
      }
    }

    function renderValidation() {
      const settingsErrors = state.validation?.settings || [];
      const snapshotErrors = state.validation?.account_snapshot || [];
      const suggestions = state.validation?.suggestions || [];
      const settingsText = settingsErrors.length ? settingsErrors.map((item) => `- ${item}`).join("\n") : "settings OK";
      const snapshotText = snapshotErrors.length ? snapshotErrors.map((item) => `- ${item}`).join("\n") : "account_snapshot OK";
      const suggestionText = suggestions.length ? suggestions.map((item) => `- ${item}`).join("\n") : "No suggestions";
      document.getElementById("validationBox").textContent = `settings\n${settingsText}\n\naccount_snapshot\n${snapshotText}`;
      const autofixText = (state.autofixActions || []).map((item) => `- ${item.label}`).join("\n") || "No safe autofix available";
      document.getElementById("suggestionBox").textContent = `Suggestions\n${suggestionText}\n\nSafe autofix\n${autofixText}`;
      const diffSummary = `recent changes: ${(state.recentChanges || []).length}\napproval settings: ${(state.approvalSettings || {}).status || "MISSING"}\napproval snapshot: ${(state.approvalSnapshot || {}).status || "MISSING"}`;
      if (!document.getElementById("diffPreview").innerHTML.trim()) {
        document.getElementById("diffPreview").textContent = diffSummary;
      }
    }

    function renderCollectionState() {
      const collection = state.collection || {};
      const counts = collection.counts || {};
      const latestRun = collection.latest_run || {};
      const latestReport = collection.latest_report || {};
      const recentRuns = collection.runs || [];
      const recentSnapshots = collection.recent_snapshots || [];
      const recentErrors = collection.recent_errors || [];
      const chip = document.getElementById("collectionChip");
      const summary = document.getElementById("collectionSummary");
      const metrics = document.getElementById("collectionMetrics");
      const runsEl = document.getElementById("collectionRuns");
      const snapshotsEl = document.getElementById("collectionSnapshots");
      const errorsEl = document.getElementById("collectionErrors");
      const timelineEl = document.getElementById("collectionTimeline");
      const detailEl = document.getElementById("collectionDetail");
      if (!chip || !summary || !metrics || !runsEl || !snapshotsEl || !errorsEl || !timelineEl || !detailEl) return;
      const filterText = String(document.getElementById("collectionFilter")?.value || state.filterCollection || "").trim().toLowerCase();
      state.filterCollection = filterText;
      chip.textContent = `collector: ${counts.collection_runs ?? 0} runs / ${counts.collection_snapshots ?? 0} snapshots / ${counts.collection_source_errors ?? 0} errors`;
      summary.textContent = [
        collection.db_path ? `db=${collection.db_path}` : "db=N/A",
        collection.output_json_path ? `report=${collection.output_json_path}` : "report=N/A",
        latestRun.run_id ? `latest_run=${latestRun.run_id}` : "",
        latestReport.status ? `latest_status=${latestReport.status}` : "",
        latestReport.generated_at ? `generated_at=${latestReport.generated_at}` : "",
        state.version?.source?.latest_updated_at ? `final_updated_at=${state.version.source.latest_updated_at}` : "",
        filterText ? `filter=${filterText}` : "",
      ].filter(Boolean).join(" | ");
      const sourceCounts = latestReport.source_counts || {};
      const metricItems = [
        { label: "Runs", value: counts.collection_runs ?? 0 },
        { label: "Snapshots", value: counts.collection_snapshots ?? 0 },
        { label: "Errors", value: counts.collection_source_errors ?? 0 },
      ];
      if (latestReport.row_count !== undefined) {
        metricItems.push({ label: "Rows in report", value: latestReport.row_count });
      }
      if (Object.keys(sourceCounts).length) {
        metricItems.push({ label: "KIS", value: sourceCounts.kis_open_api ?? 0 });
        metricItems.push({ label: "Naver", value: sourceCounts.naver_finance ?? 0 });
        metricItems.push({ label: "Seed", value: sourceCounts.gathertradingdata_json ?? 0 });
      }
      metrics.innerHTML = metricItems.map((item) => `<div class="metric-card">${esc(item.label)}<strong>${esc(item.value)}</strong></div>`).join("");
      const selection = state.collectionSelection || {};
      const matchesFilter = (kind, item) => {
        if (!filterText) return true;
        const haystack = JSON.stringify({ kind, ...item }).toLowerCase();
        return haystack.includes(filterText);
      };
      const renderItemList = (kind, items, emptyText) => {
        const visible = items
          .map((item, index) => ({ item, index }))
          .filter(({ item }) => matchesFilter(kind, item));
        if (!visible.length) return `<div class="muted">${esc(filterText ? `No ${kind} items match filter.` : emptyText)}</div>`;
        return visible.map(({ item, index }) => {
          const key = currentCollectionSelectionKey(kind, item, index);
          const selected = selection.kind === kind && selection.key === key;
          return `
            <button class="collection-item collection-item-${kind} ${selected ? "selected" : ""}" onclick="selectCollectionItem(${JSON.stringify(kind)}, ${JSON.stringify(key)})">
              ${esc(collectionItemLabel(kind, item))}
              <code>${esc(collectionItemDetail(kind, item))}</code>
            </button>
          `;
        }).join("");
      };
      runsEl.innerHTML = renderItemList("run", recentRuns, "No collection runs yet.");
      snapshotsEl.innerHTML = renderItemList("snapshot", recentSnapshots, "No collection snapshots yet.");
      errorsEl.innerHTML = renderItemList("error", recentErrors, "No collection errors.");
      const activityRows = [
        ...recentRuns.map((item, index) => ({ kind: "run", ts: String(item.started_at || item.created_at || ""), label: `${item.status || "RUN"} ${item.collector_name || "collector"} ${item.run_id || ""}`, key: currentCollectionSelectionKey("run", item, index) })),
        ...recentSnapshots.map((item, index) => ({ kind: "snapshot", ts: String(item.created_at || item.as_of_date || ""), label: `${item.dataset_name || "snapshot"}:${item.ticker || ""} ${item.source_status || ""}`, key: currentCollectionSelectionKey("snapshot", item, index) })),
        ...recentErrors.map((item, index) => ({ kind: "error", ts: String(item.created_at || ""), label: `${item.source_name || "error"}:${item.error_kind || ""} ${item.ticker || ""}`, key: currentCollectionSelectionKey("error", item, index) })),
      ].sort((a, b) => String(a.ts).localeCompare(String(b.ts))).slice(-18);
      timelineEl.innerHTML = activityRows.length
        ? activityRows.map((item, index) => {
            const previous = activityRows[index - 1];
            const currentDate = String(item.ts || "").slice(0, 10);
            const previousDate = previous ? String(previous.ts || "").slice(0, 10) : "";
            const dateHeader = !index || currentDate !== previousDate
              ? `<div class="muted" style="margin:4px 0 2px;">${esc(currentDate || "Unknown date")}</div>`
              : "";
            const selected = selection.kind === item.kind && selection.key === item.key;
            return `${dateHeader}<button class="collection-item collection-item-${item.kind} ${selected ? "selected" : ""}" onclick="selectCollectionItem(${JSON.stringify(item.kind)}, ${JSON.stringify(item.key)})"><strong>${esc(item.ts || "N/A")}</strong><code>${esc(item.label)}</code></button>`;
          }).join("")
        : `<div class="muted">No unified activity yet.</div>`;
      const selectedItem = (() => {
        const key = String(selection.key || "").trim();
        if (!key) return null;
        const run = recentRuns.find((item, index) => currentCollectionSelectionKey("run", item, index) === key);
        if (run) return { kind: "run", item: run };
        const snapshot = recentSnapshots.find((item, index) => currentCollectionSelectionKey("snapshot", item, index) === key);
        if (snapshot) return { kind: "snapshot", item: snapshot };
        const error = recentErrors.find((item, index) => currentCollectionSelectionKey("error", item, index) === key);
        if (error) return { kind: "error", item: error };
        return null;
      })();
      detailEl.textContent = selectedItem
        ? collectionItemDetail(selectedItem.kind, selectedItem.item)
        : JSON.stringify({
            latest_run: latestRun,
            latest_report: latestReport,
          }, null, 2);
    }

    async function copyCollectionStatus() {
      const collection = state.collection || {};
      const counts = collection.counts || {};
      const latestReport = collection.latest_report || {};
      const text = [
        `db=${collection.db_path || "N/A"}`,
        `runs=${counts.collection_runs ?? 0}`,
        `snapshots=${counts.collection_snapshots ?? 0}`,
        `errors=${counts.collection_source_errors ?? 0}`,
        `status=${latestReport.status || "N/A"}`,
      ].join(" | ");
      await navigator.clipboard.writeText(text);
    }

    function applyFilters() {
      state.filterSettings = document.getElementById("settingsFilter").value;
      state.filterSnapshot = document.getElementById("snapshotFilter").value;
      state.viewPrefs.settings.filter = state.filterSettings;
      state.viewPrefs.account_snapshot.filter = state.filterSnapshot;
      persistViewPrefs();
      renderSettings();
      renderSnapshot();
    }

    function applyCollectionFilter() {
      state.filterCollection = document.getElementById("collectionFilter").value;
      renderCollectionState();
    }

    function clearCollectionFilter() {
      const input = document.getElementById("collectionFilter");
      if (input) input.value = "";
      state.filterCollection = "";
      renderCollectionState();
    }

    function clearChangeLogFilter() {
      const input = document.getElementById("changeLogFilter");
      if (input) input.value = "";
      state.filterChangeLog = "";
      renderMeta();
    }

    function renderSelectionInspector() {
      const row = currentSelectionRow();
      const summaryEl = document.getElementById("selectedRowSummary");
      const detailEl = document.getElementById("selectedRowDetail");
      const historyEl = document.getElementById("selectedRowHistory");
      const batchDomainSelect = document.getElementById("batchDomainSelect");
      if (!row) {
        summaryEl.textContent = "No row selected.";
        detailEl.textContent = "";
        historyEl.textContent = "";
        if (batchDomainSelect && !batchDomainSelect.value) {
          batchDomainSelect.value = "settings";
        }
        return;
      }
      const domain = state.selected.domain;
      const targetRef = rowKey(domain, row);
      summaryEl.textContent = `${domain}:${targetRef} | ${rowDisplayLabel(domain, row)}`;
      const bannerSelection = document.getElementById("bannerSelectionSummary");
      if (bannerSelection) bannerSelection.textContent = `${domain}:${targetRef}`;
      if (domain === "account_snapshot") {
        const editableColumns = (state.snapshotColumns || []).filter((column) => !String(column || "").startsWith("_"));
        const rowCard = `
          <div class="snapshot-detail-card">
            <div class="meta-row">
              <span class="chip">selected account_snapshot row</span>
              <span class="chip">${esc(targetRef)}</span>
            </div>
            <div class="field-grid">
              ${editableColumns.map((column) => `
                <div class="field">
                  <strong>${esc(column)}</strong>
                  ${esc(String(row?.[column] ?? ""))}
                </div>
              `).join("")}
            </div>
          </div>
        `;
        detailEl.innerHTML = rowCard;
      } else {
        detailEl.textContent = JSON.stringify(row, null, 2);
      }
      const history = (state.recentChanges || []).filter((item) => {
        if (!item || item.domain !== domain) return false;
        const ref = String(item.target_ref || "").trim();
        return ref === "*" || ref === targetRef || ref === String(row?.ticker || "").trim();
      });
      historyEl.textContent = history.length
        ? history.slice(0, 8).map((item) => `#${item.id} ${item.action} ${item.target_ref} @ ${item.created_at}\n${JSON.stringify(item.before_json, null, 2)}\n=>\n${JSON.stringify(item.after_json, null, 2)}`).join("\n\n")
        : "No recent history for selected row.";
      if (batchDomainSelect) {
        batchDomainSelect.value = domain;
      }
    }

    function updateBannerDiffSummary(settingsDiff, snapshotDiff) {
      const changeCount =
        (settingsDiff.added || []).length + (settingsDiff.removed || []).length + (settingsDiff.changed || []).length +
        (snapshotDiff.added || []).length + (snapshotDiff.removed || []).length + (snapshotDiff.changed || []).length;
      const bannerDiff = document.getElementById("bannerDiffSummary");
      if (bannerDiff) {
        bannerDiff.textContent = `${changeCount} pending change(s) | snapshot changed rows: ${(snapshotDiff.changed || []).length}`;
      }
    }

    function focusSelectedRow() {
      const domain = state.selected.domain;
      if (!domain) return;
      const selector = domain === "settings" ? "#settingsTable tbody tr" : "#snapshotTable tbody tr";
      const targetRef = String(state.selected.target_ref || "");
      const row = document.querySelector(`${selector}[data-row-ref="${CSS.escape(targetRef)}"]`);
      if (row) {
        row.scrollIntoView({ block: "center", behavior: "smooth" });
        row.classList.add("selected");
      }
    }

    async function copySelectedTarget() {
      const target = currentSelectionTarget();
      if (!target.domain) {
        alert("No row selected.");
        return;
      }
      await navigator.clipboard.writeText(`${target.domain}:${target.target_ref}`);
    }

    async function approveSelectedRow() {
      const target = currentSelectionTarget();
      if (!target.domain) {
        alert("No row selected.");
        return;
      }
      await approveTarget(target.domain, target.target_ref);
    }

    async function lockSelectedRow() {
      const target = currentSelectionTarget();
      if (!target.domain) {
        alert("No row selected.");
        return;
      }
      await lockTarget(target.domain, target.target_ref);
    }

    async function unlockSelectedRow() {
      const target = currentSelectionTarget();
      if (!target.domain) {
        alert("No row selected.");
        return;
      }
      await unlockTarget(target.domain, target.target_ref);
    }

    function syncBatchDomain() {
      const domain = document.getElementById("batchDomainSelect").value;
      const selected = currentSelectionTarget();
      if (!selected.domain) return;
      if (selected.domain !== domain) {
        state.selected = { domain, target_ref: "" };
      }
    }

    function _tsvRows(tsvText) {
      return String(tsvText || "").replace(/\\r/g, "").split("\\n").filter((line) => line.trim() !== "");
    }

    function applyBatchPaste() {
      const domain = document.getElementById("batchDomainSelect").value;
      const tsv = document.getElementById("batchPasteTsv").value;
      const lines = _tsvRows(tsv);
      if (!lines.length) {
        alert("Paste TSV first.");
        return;
      }
      const selected = currentSelectionRow();
      const rows = domain === "settings" ? state.settingsRows : state.snapshotRows;
      if (!selected || state.selected.domain !== domain) {
        alert("Select a row in the same domain first.");
        return;
      }
      const tableId = domain === "settings" ? "settingsTable" : "snapshotTable";
      const table = document.getElementById(tableId);
      const bodyRows = Array.from(table.querySelectorAll("tbody tr[data-row-index]"));
      const startIndex = bodyRows.findIndex((tr) => tr.dataset.rowRef === rowKey(domain, selected));
      if (startIndex < 0) {
        alert("Selected row no longer exists.");
        return;
      }
      const columns = domain === "settings" ? ["key", "value", "note"] : state.snapshotColumns;
      const pastedRows = lines.map((line) => line.split("\t"));
      pastedRows.forEach((values, rowOffset) => {
        const targetRow = bodyRows[startIndex + rowOffset];
        if (!targetRow) return;
        values.forEach((value, colIndex) => {
          const cell = targetRow.querySelector(`td[data-col-index="${colIndex}"]`);
          if (cell) cell.innerText = value;
        });
      });
      if (domain === "settings") renderSettings(); else renderSnapshot();
      renderSelectionInspector();
      previewDiff();
    }

    async function saveSelectedDomain() {
      const domain = selectedDomainOrDefault();
      if (domain === "account_snapshot") {
        await saveSnapshot();
      } else {
        await saveSettings();
      }
    }

    function installKeyboardShortcuts() {
      document.addEventListener("keydown", (event) => {
        if (event.key === "F5") return;
        const target = event.target;
        const isTyping = target && (
          target.tagName === "INPUT" ||
          target.tagName === "TEXTAREA" ||
          target.isContentEditable
        );
        if (event.key === "Delete" && !event.ctrlKey && !event.metaKey && !event.altKey) {
          if (isTyping) return;
          const row = currentSelectionRow();
          if (!row) return;
          const domain = state.selected.domain;
          const confirmMsg = `${domain}:${rowKey(domain, row)} delete?`;
          if (!confirm(confirmMsg)) return;
          if (domain === "settings") {
            deleteSettingRow(row);
          } else if (domain === "account_snapshot") {
            deleteSnapshotRow(row);
          }
          event.preventDefault();
          return;
        }
        if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
          event.preventDefault();
          saveSelectedDomain().catch((err) => alert(err.message));
          return;
        }
        if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
          event.preventDefault();
          saveSelectedDomain().catch((err) => alert(err.message));
          return;
        }
        if (isTyping) return;
        if (event.key === "ArrowDown" || event.key === "ArrowUp") {
          const domain = selectedDomainOrDefault();
          const rows = domain === "settings" ? state.settingsRows : state.snapshotRows;
          if (!rows.length) return;
          const current = currentSelectionRow();
          let index = current ? rows.findIndex((row) => rowKey(domain, row) === rowKey(domain, current)) : -1;
          index = event.key === "ArrowDown" ? Math.min(rows.length - 1, index + 1) : Math.max(0, index <= 0 ? 0 : index - 1);
          const next = rows[index];
          if (next) {
            selectRow(domain, next);
            focusSelectedRow();
            event.preventDefault();
          }
        }
      });
    }

    reloadState().catch((error) => {
      document.getElementById("workspaceStatus").innerHTML = `<span class="error">${esc(error.message)}</span>`;
    });
    installKeyboardShortcuts();
  </script>
</body>
</html>
"""


def render_collection_html() -> str:
    return """<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>KIS Collection Dashboard</title>
  <style>
    :root {
      color-scheme: light;
      --bg: #0f172a;
      --panel: #111827;
      --line: #243047;
      --text: #e5e7eb;
      --muted: #9ca3af;
      --accent: #38bdf8;
      --accent-2: #22c55e;
      --chip: #172036;
    }
    * { box-sizing: border-box; }
    body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: linear-gradient(180deg, #08101f 0%, #0b1220 100%); color: var(--text); }
    header { padding: 24px; border-bottom: 1px solid rgba(255,255,255,.06); position: sticky; top: 0; background: rgba(5,10,20,.6); backdrop-filter: blur(10px); z-index: 10; }
    h1 { margin: 0; font-size: 22px; }
    .subline { margin-top: 6px; color: var(--muted); font-size: 13px; }
    .wrap { max-width: 1280px; margin: 0 auto; padding: 8px 12px 20px; }
    .panel { border: 1px solid rgba(255,255,255,.08); border-radius: 12px; overflow: hidden; background: linear-gradient(180deg, rgba(17,24,39,.94), rgba(10,16,28,.95)); box-shadow: 0 8px 24px rgba(0,0,0,.24); }
    .panel-head { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:8px; padding: 10px 12px 8px; border-bottom: 1px solid rgba(255,255,255,.06); }
    .actions { display:flex; flex-wrap:wrap; gap:6px; }
    button, a.btn { border: 1px solid rgba(255,255,255,.12); background: var(--chip); color: var(--text); padding: 6px 10px; border-radius: 8px; cursor:pointer; font-size:13px; text-decoration:none; }
    button.primary { background: linear-gradient(135deg, var(--accent), #0ea5e9); color:#fff; }
    button.primary.run-live { background: linear-gradient(135deg, #22c55e, #16a34a); box-shadow: 0 0 0 1px rgba(34,197,94,.18) inset; }
    .pane { padding: 8px 12px 12px; }
    .grid { display:grid; gap:8px; grid-template-columns: 1.1fr .9fr; }
    .chip { display:inline-flex; gap:6px; align-items:center; padding:4px 8px; border-radius:999px; background: rgba(255,255,255,.06); color: var(--muted); font-size:12px; }
    .muted { color: var(--muted); }
    .metrics { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap:6px; margin-top:6px; }
    .metric { border: 1px solid rgba(255,255,255,.08); border-radius: 8px; padding: 8px; background: rgba(3,7,18,.42); font-size:12px; }
    .metric strong { display:block; font-size:16px; margin-top:4px; }
    .filter-row { display:flex; flex-wrap:wrap; gap:6px; margin-top: 6px; }
    .filter-row input { background: rgba(3,7,18,.45); color: var(--text); border: 1px solid rgba(255,255,255,.10); border-radius: 8px; padding: 6px 8px; font-size: 12px; min-width: 220px; }
    .run-form { display:flex; flex-wrap:wrap; gap:6px; margin-top: 6px; }
    .run-form input, .run-form select { background: rgba(3,7,18,.45); color: var(--text); border: 1px solid rgba(255,255,255,.10); border-radius: 8px; padding: 6px 8px; font-size: 12px; min-width: 180px; }
    .run-note { margin-top: 6px; color: var(--muted); font-size: 12px; }
    .run-banner {
      display: none;
      margin-top: 8px;
      padding: 8px 10px;
      border-radius: 8px;
      border: 1px solid rgba(255,255,255,.08);
      background: rgba(3,7,18,.45);
      color: var(--text);
      font-size: 12px;
    }
    .run-banner.success { display: block; border-color: rgba(34,197,94,.36); background: rgba(34,197,94,.12); }
    .run-banner.error { display: block; border-color: rgba(248,113,113,.36); background: rgba(248,113,113,.12); }
    .list { display:grid; gap:4px; margin-top: 6px; }
    .collection-item {
      width: 100%;
      text-align: left;
      border: 1px solid rgba(255,255,255,.08);
      border-radius: 12px;
      padding: 10px;
      background: rgba(3, 7, 18, .42);
      color: var(--text);
      font-size: 12px;
      cursor: pointer;
    }
    .collection-item.selected {
      border-color: rgba(96, 165, 250, .8);
      box-shadow: 0 0 0 1px rgba(96, 165, 250, .35) inset;
    }
    .collection-item-run { border-left: 4px solid #3b82f6 !important; background: rgba(59, 130, 246, 0.12) !important; }
    .collection-item-snapshot { border-left: 4px solid #10b981 !important; background: rgba(16, 185, 129, 0.12) !important; }
    .collection-item-error { border-left: 4px solid #ef4444 !important; background: rgba(239, 68, 68, 0.12) !important; }
    .collection-item code {
      display: block;
      margin-top: 6px;
      white-space: pre-wrap;
      color: #cbd5e1;
    }
    pre { margin:0; max-height: 320px; overflow:auto; background: rgba(3,7,18,.45); border: 1px solid rgba(255,255,255,.08); border-radius: 12px; padding:10px; font-size:12px; }
    @media (max-width: 980px) { .grid, .metrics { grid-template-columns: 1fr; } }
  </style>
</head>
<body>
  <header>
    <h1>KIS Collection Dashboard</h1>
    <div class="subline">Separate read-only view for KIS collection run, snapshots, errors, and raw JSON evidence.</div>
  </header>
  <main class="wrap">
    <section class="panel">
      <div class="panel-head">
        <div>
          <div class="chip">Execution / Status / Result</div>
          <div class="chip" id="collectionChip">collection: loading...</div>
          <div class="chip" id="collectionRunChip" style="margin-top:8px;">run: idle</div>
          <div class="chip" id="collectionProgressChip" style="margin-top:8px;">progress: idle</div>
          <div class="chip" id="collectionStageChip" style="margin-top:8px;">stage: idle</div>
          <div class="chip" id="collectionResultChip" style="margin-top:8px;">result: pending</div>
          <div class="muted" id="collectionSummary" style="margin-top:10px;"></div>
        </div>
        <div class="actions">
          <a class="btn" href="/">Back to workspace</a>
          <a class="btn" href="/tables">Open table browser</a>
          <button class="primary" onclick="reloadState()">Refresh</button>
          <button onclick="downloadRawCollection()">Download raw JSON</button>
          <button onclick="downloadCsvCollection()">Download CSV</button>
        </div>
      </div>
      <div class="pane">
        <div class="grid">
          <div>
            <div class="metrics" id="collectionMetrics"></div>
            <form class="run-form" action="/api/collection/run" method="post" onsubmit="runCollection(event); return false;">
              <select id="collectionModeInput" onchange="syncCollectionMode()">
                <option value="real" selected>Real mode</option>
              </select>
              <input id="collectionAccountInput" placeholder="kis_account" value="real" />
              <select id="collectionLiveInput">
                <option value="true" selected>Live KIS on</option>
                <option value="false">Live KIS off</option>
              </select>
              <select id="collectionFallbackInput">
                <option value="false" selected>Naver fallback off</option>
                <option value="true">Naver fallback on</option>
              </select>
              <button class="primary" id="collectionRunBtn" type="submit">Collect now</button>
              <div class="chip" id="collectionModeBadge" style="margin-left:4px;">mode: real / live</div>
            </form>
            <div class="run-note" id="collectionRunNote">Real mode is selected by default. Offline replay is available only when explicitly enabled.</div>
            <div class="chip" id="collectionLiveStatus" style="margin-top:8px;">live source: unknown</div>
            <div class="muted" style="margin-top:8px;">Auto refreshes every 15 seconds while this page is open.</div>
            <div class="run-banner" id="collectionRunBanner"></div>
            <div class="filter-row">
              <input id="collectionFilter" placeholder="Filter runs / snapshots / errors" oninput="renderCollectionState()" />
              <input id="collectionTickerFilter" placeholder="Ticker quick search" oninput="renderCollectionState()" />
              <input id="collectionDateFilter" placeholder="Date quick search" oninput="renderCollectionState()" />
              <button onclick="clearFilter()">Clear filter</button>
            </div>
            <div class="muted" style="margin-top:12px;">Recent collector runs</div>
            <div id="collectionRuns" class="list"></div>
            <div class="muted" style="margin-top:12px;">Recent collector snapshots</div>
            <div id="collectionSnapshots" class="list"></div>
            <div class="muted" style="margin-top:12px;">Recent collector errors</div>
            <div id="collectionErrors" class="list"></div>
            <div class="muted" style="margin-top:12px;">Unified activity timeline</div>
            <div id="collectionTimeline" class="list"></div>
          </div>
          <div>
            <div class="muted" id="collectionDetailAnchor">Collection detail</div>
            <pre id="collectionDetail"></pre>
            <div class="muted" id="collectionRunLogAnchor" style="margin-top:12px;">Run log</div>
            <pre id="collectionRunLog"></pre>
          </div>
        </div>
      </div>
    </section>
  </main>
  <script>
    const state = { collection: {}, version: {}, selection: { kind: "", key: "" } };
    function esc(value) { return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;"); }
    function keyFor(kind, item, index) {
      if (kind === "run") return String(item?.run_id || item?.started_at || index || "").trim();
      if (kind === "snapshot") return [item?.run_id, item?.dataset_name, item?.ticker, item?.created_at, index].join("|");
      return [item?.run_id, item?.source_name, item?.ticker, item?.error_kind, item?.created_at, index].join("|");
    }
    function labelFor(kind, item) {
      if (kind === "run") return `[RUN] ${item?.started_at || ""} ${item?.status || ""} ${item?.collector_name || "collector"} ${item?.run_id || ""} -> ${item?.finished_at || "RUNNING"}`.trim();
      if (kind === "snapshot") return `[SNAPSHOT] ${item?.created_at || ""} ${item?.dataset_name || ""}:${item?.ticker || ""} ${item?.source_status || ""}`.trim();
      return `[ERROR] ${item?.created_at || ""} ${item?.source_name || ""}:${item?.error_kind || ""} ${item?.ticker || ""} ${item?.error_message || ""}`.trim();
    }
    function detailFor(kind, item) { return JSON.stringify({ kind, ...item }, null, 2); }
    function firstLine(text) {
      return String(text || "").replace(/\\r/g, "").split("\\n").map((line) => line.trim()).find((line) => line) || "";
    }
    function scrollToCollectionPanel(anchorId) {
      const anchor = document.getElementById(anchorId);
      if (anchor && anchor.scrollIntoView) {
        anchor.scrollIntoView({ block: "start", behavior: "smooth" });
      }
    }
    function syncCollectionMode() {
      const modeInput = document.getElementById("collectionModeInput");
      const liveInput = document.getElementById("collectionLiveInput");
      const note = document.getElementById("collectionRunNote");
      const isReal = String(modeInput?.value || "real") === "real";
      if (liveInput) {
        liveInput.value = isReal ? "true" : "false";
        liveInput.disabled = isReal;
      }
      if (note) {
        note.textContent = isReal
          ? "Real mode is selected by default. Live KIS is enabled."
          : "Offline replay is enabled.";
      }
    }
    function filtered(kind, items, filterText) {
      const tickerText = String(document.getElementById("collectionTickerFilter")?.value || "").trim().toLowerCase();
      const dateText = String(document.getElementById("collectionDateFilter")?.value || "").trim().toLowerCase();
      return items.map((item, index) => ({ item, index })).filter(({ item }) => {
        const haystack = JSON.stringify({ kind, ...item }).toLowerCase();
        if (filterText && !haystack.includes(filterText)) return false;
        if (tickerText && !haystack.includes(tickerText)) return false;
        if (dateText && !haystack.includes(dateText)) return false;
        return true;
      });
    }
    async function reloadState() {
      const response = await fetch("/api/state");
      const payload = await response.json();
      state.collection = payload.collection || {};
      state.version = payload.version || {};
      renderCollectionState();
    }
    let collectionRefreshTimer = null;
    function startCollectionAutoRefresh() {
      if (collectionRefreshTimer) return;
      collectionRefreshTimer = setInterval(() => {
        if (location.pathname === "/collection") {
          reloadState().catch(() => {});
        }
      }, 15000);
    }
    async function runCollection(event) {
      if (event) event.preventDefault();
      const runBtn = document.getElementById("collectionRunBtn");
      const runChip = document.getElementById("collectionRunChip");
      const progressChip = document.getElementById("collectionProgressChip");
      const stageChip = document.getElementById("collectionStageChip");
      const resultChip = document.getElementById("collectionResultChip");
      const detail = document.getElementById("collectionDetail");
      const runLog = document.getElementById("collectionRunLog");
      const banner = document.getElementById("collectionRunBanner");
      const mode = String(document.getElementById("collectionModeInput")?.value || "real").trim() || "real";
      const kisAccount = String(document.getElementById("collectionAccountInput")?.value || "real").trim() || "real";
      const liveValue = String(document.getElementById("collectionLiveInput")?.value || "true") === "true";
      const includeLiveKis = mode === "real" ? true : liveValue;
      const allowNaverFallback = String(document.getElementById("collectionFallbackInput")?.value || "false") === "true";
      if (mode === "real" && !confirm("Run real KIS collection now?")) {
        if (runBtn) runBtn.disabled = false;
        return false;
      }
      if (runBtn) runBtn.disabled = true;
      if (runChip) runChip.textContent = "run: running...";
      if (progressChip) progressChip.textContent = "progress: running collect job";
      if (stageChip) stageChip.textContent = "stage: starting collection";
      if (banner) {
        banner.className = "run-banner";
        banner.style.display = "block";
        banner.textContent = "Collection run started...";
      }
      if (runLog) runLog.textContent = JSON.stringify({
        action: "POST /api/collection/run",
        mode: mode,
        kis_account: kisAccount,
        include_live_kis: includeLiveKis,
        allow_naver_fallback: allowNaverFallback,
      }, null, 2);
      if (detail) detail.textContent = JSON.stringify({ status: "RUNNING", message: "collection job started" }, null, 2);
      try {
        const response = await fetch("/api/collection/run", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            mode: mode,
            kis_account: kisAccount,
            include_live_kis: includeLiveKis,
            allow_naver_fallback: allowNaverFallback,
          }),
        });
        const payload = await response.json();
        if (!response.ok) {
          throw new Error(payload.detail || payload.stderr || "collection failed");
        }
        if (runChip) runChip.textContent = `run: ${payload.status || "PASS"} | ${payload.elapsed_ms ?? "?"} ms | rc=${payload.returncode ?? "?"}`;
        if (progressChip) progressChip.textContent = payload.status === "PASS"
          ? `progress: completed ${payload.summary?.row_count ?? "?"} rows`
          : `progress: ${payload.status || "FAIL"}`;
        if (stageChip) stageChip.textContent = payload.status === "PASS"
          ? "stage: completed"
          : `stage: ${payload.status || "FAIL"}`;
        if (resultChip) {
          resultChip.textContent = payload.status === "PASS"
            ? `result: rows=${payload.summary?.row_count ?? "?"} | kis=${Number(payload.summary?.source_counts?.kis_open_api || 0)} | seed=${Number(payload.summary?.source_counts?.gathertradingdata_json || 0)} | naver=${Number(payload.summary?.source_counts?.naver_finance || 0)}`
            : "result: failed";
        }
        if (banner) {
          banner.className = payload.status === "PASS" ? "run-banner success" : "run-banner error";
          banner.style.cursor = "pointer";
          banner.textContent = payload.status === "PASS"
            ? `Collection succeeded: rows=${payload.summary?.row_count ?? "?"} live=${Number(payload.summary?.source_counts?.kis_open_api || 0) > 0 ? "yes" : "no"} price=${Array.isArray(payload.summary?.rows) && payload.summary.rows.some((row) => row && row.current_price !== null && row.current_price !== undefined) ? "present" : "missing"} at=${payload.summary?.generated_at || "N/A"} rc=${payload.returncode ?? "0"}`
            : `Collection failed: ${payload.returncode ?? "?"}`;
          banner.onclick = () => scrollToCollectionPanel(payload.status === "PASS" ? "collectionDetailAnchor" : "collectionRunLogAnchor");
        }
        if (detail) detail.textContent = JSON.stringify(payload, null, 2);
        if (runLog) runLog.textContent = JSON.stringify({
          request: {
            mode: mode,
            kis_account: kisAccount,
            include_live_kis: includeLiveKis,
            allow_naver_fallback: allowNaverFallback,
          },
          response: payload,
        }, null, 2);
        await reloadState();
        if (payload.status === "PASS") {
          scrollToCollectionPanel("collectionDetailAnchor");
        }
        return payload;
      } catch (error) {
        if (runChip) runChip.textContent = `run: FAIL | ${error.message}`;
        if (progressChip) progressChip.textContent = "progress: failed";
        if (stageChip) stageChip.textContent = "stage: failed";
        if (resultChip) resultChip.textContent = "result: failed";
        if (banner) {
          banner.className = "run-banner error";
          banner.style.display = "block";
          banner.style.cursor = "pointer";
          banner.textContent = `Collection failed: ${firstLine(error.message)}`;
          banner.onclick = () => scrollToCollectionPanel("collectionRunLogAnchor");
        }
        if (detail) detail.textContent = JSON.stringify({ status: "FAIL", detail: String(error.message || error) }, null, 2);
        if (runLog) runLog.textContent = JSON.stringify({ status: "FAIL", detail: String(error.message || error) }, null, 2);
        throw error;
      } finally {
        if (runBtn) runBtn.disabled = false;
      }
      return false;
    }
    function clearFilter() {
      const input = document.getElementById("collectionFilter");
      if (input) input.value = "";
      const ticker = document.getElementById("collectionTickerFilter");
      if (ticker) ticker.value = "";
      const date = document.getElementById("collectionDateFilter");
      if (date) date.value = "";
      renderCollectionState();
    }
    function selectItem(kind, key) { state.selection = { kind, key }; renderCollectionState(); }
    function renderCollectionState() {
      const collection = state.collection || {};
      const counts = collection.counts || {};
      const latestRun = collection.latest_run || {};
      const latestReport = collection.latest_report || {};
      const runs = collection.runs || [];
      const snapshots = collection.recent_snapshots || [];
      const errors = collection.recent_errors || [];
      const filterText = String(document.getElementById("collectionFilter")?.value || "").trim().toLowerCase();
      const resultChip = document.getElementById("collectionResultChip");
      document.getElementById("collectionChip").textContent = `collector: ${counts.collection_runs ?? 0} runs / ${counts.collection_snapshots ?? 0} snapshots / ${counts.collection_source_errors ?? 0} errors`;
      const latestRunState = latestRun.status ? `run: ${latestRun.status}` : "run: idle";
      const runChip = document.getElementById("collectionRunChip");
      if (runChip) runChip.textContent = latestRun.run_id ? `${latestRunState} | ${latestRun.run_id}` : latestRunState;
      const versionText = state.version?.source?.latest_updated_at
        ? `app=${state.version.app || "N/A"} | git=${state.version.git?.commit || "N/A"} | tree=${state.version.git?.tree_state || "N/A"} | source=${state.version.source?.fingerprint || "N/A"} | final_updated_at=${state.version.source.latest_updated_at}`
        : "version=N/A";
      document.getElementById("collectionSummary").textContent = [
        collection.db_path ? `db=${collection.db_path}` : "db=N/A",
        collection.output_json_path ? `report=${collection.output_json_path}` : "report=N/A",
        latestRun.run_id ? `latest_run=${latestRun.run_id}` : "",
        latestReport.generated_at ? `generated_at=${latestReport.generated_at}` : "",
        versionText,
        filterText ? `filter=${filterText}` : "",
        String(document.getElementById("collectionTickerFilter")?.value || "").trim() ? `ticker=${String(document.getElementById("collectionTickerFilter")?.value || "").trim()}` : "",
        String(document.getElementById("collectionDateFilter")?.value || "").trim() ? `date=${String(document.getElementById("collectionDateFilter")?.value || "").trim()}` : "",
      ].filter(Boolean).join(" | ");
      const modeInput = document.getElementById("collectionModeInput");
      const liveInput = document.getElementById("collectionLiveInput");
      const runNote = document.getElementById("collectionRunNote");
      if (modeInput && liveInput && runNote) {
        syncCollectionMode();
      }
      const modeBadge = document.getElementById("collectionModeBadge");
      const runBtn = document.getElementById("collectionRunBtn");
      if (modeBadge && runBtn) {
        const mode = String(modeInput?.value || "real");
        modeBadge.textContent = mode === "real" ? "mode: real / live" : "mode: offline replay";
        runBtn.className = mode === "real" ? "primary run-live" : "primary";
      }
      const runBanner = document.getElementById("collectionRunBanner");
      if (runBanner && !runBanner.textContent.trim()) {
        runBanner.style.display = "none";
      }
      const sourceCounts = latestReport.source_counts || {};
      const liveKisCount = Number(sourceCounts.kis_open_api || 0);
      const hasLivePrice = Array.isArray(latestReport.rows) && latestReport.rows.some((row) => row && row.current_price !== null && row.current_price !== undefined);
      const liveSummary = liveKisCount > 0
        ? (hasLivePrice ? "live=active price=present" : "live=active price=missing")
        : "live=unavailable";
      const liveStatus = document.getElementById("collectionLiveStatus");
      const progressChip = document.getElementById("collectionProgressChip");
      const stageChip = document.getElementById("collectionStageChip");
      if (liveStatus) {
        liveStatus.textContent = liveKisCount > 0
          ? (hasLivePrice ? `live source: active | KIS rows=${liveKisCount} | live price: present` : `live source: active | KIS rows=${liveKisCount} | live price: missing`)
          : "live source: unavailable";
        liveStatus.className = `chip ${liveKisCount > 0 ? "success" : "warning"}`;
      }
      if (progressChip) {
        progressChip.textContent = latestRun.run_id ? `progress: ${latestRun.status || "idle"} ${latestRun.run_id}` : "progress: idle";
        progressChip.className = `chip ${latestRun.status === "PASS" ? "success" : latestRun.status === "RUNNING" ? "warning" : "info"}`;
      }
      if (stageChip) {
        stageChip.textContent = latestRun.status === "RUNNING"
          ? "stage: running collection"
          : latestRun.status === "PASS"
            ? "stage: completed"
            : latestRun.status === "FAIL"
              ? "stage: failed"
              : "stage: idle";
        stageChip.className = `chip ${latestRun.status === "PASS" ? "success" : latestRun.status === "RUNNING" ? "warning" : latestRun.status === "FAIL" ? "danger" : "info"}`;
      }
      if (resultChip) {
        const rowCount = latestReport.row_count ?? (Array.isArray(latestReport.rows) ? latestReport.rows.length : 0);
        resultChip.textContent = latestRun.run_id
          ? `result: rows=${rowCount ?? 0} | kis=${Number(sourceCounts.kis_open_api || 0)} | seed=${Number(sourceCounts.gathertradingdata_json || 0)} | naver=${Number(sourceCounts.naver_finance || 0)}`
          : "result: pending";
        resultChip.className = `chip ${latestRun.status === "PASS" ? "success" : latestRun.status === "RUNNING" ? "warning" : latestRun.status === "FAIL" ? "danger" : "info"}`;
      }
      const currentSummary = document.getElementById("collectionSummary");
      if (currentSummary && currentSummary.textContent) {
        const baseSummary = currentSummary.textContent;
        if (!baseSummary.includes("live=")) {
          currentSummary.textContent = `${baseSummary} | ${liveSummary}`;
        }
        if (versionText && !baseSummary.includes("final_updated_at=")) {
          currentSummary.textContent = `${currentSummary.textContent} | ${versionText}`;
        }
      }
      const metrics = [
        { label: "Runs", value: counts.collection_runs ?? 0 },
        { label: "Snapshots", value: counts.collection_snapshots ?? 0 },
        { label: "Errors", value: counts.collection_source_errors ?? 0 },
      ];
      if (latestReport.row_count !== undefined) metrics.push({ label: "Rows", value: latestReport.row_count });
      if (Object.keys(sourceCounts).length) {
        metrics.push({ label: "KIS", value: sourceCounts.kis_open_api ?? 0 });
        metrics.push({ label: "Naver", value: sourceCounts.naver_finance ?? 0 });
        metrics.push({ label: "Seed", value: sourceCounts.gathertradingdata_json ?? 0 });
      }
      metrics.push({ label: "Visible", value: filtered("run", runs, filterText).length + filtered("snapshot", snapshots, filterText).length + filtered("error", errors, filterText).length });
      document.getElementById("collectionMetrics").innerHTML = metrics.map((item) => `<div class="metric">${esc(item.label)}<strong>${esc(item.value)}</strong></div>`).join("");
      const renderList = (kind, items, emptyText) => {
        const visible = filtered(kind, items, filterText);
        if (!visible.length) return `<div class="muted">${esc(filterText ? `No ${kind} items match filter.` : emptyText)}</div>`;
        return visible.map(({ item, index }) => {
          const key = keyFor(kind, item, index);
      document.getElementById("collectionSnapshots").innerHTML = renderList("snapshot", snapshots, "No collection snapshots yet.");
      document.getElementById("collectionErrors").innerHTML = renderList("error", errors, "No collection errors.");
      const key = String(state.selection.key || "").trim();
      const selected = runs.find((item, index) => keyFor("run", item, index) === key)
        || snapshots.find((item, index) => keyFor("snapshot", item, index) === key)
        || errors.find((item, index) => keyFor("error", item, index) === key);
      document.getElementById("collectionDetail").textContent = selected ? JSON.stringify(selected, null, 2) : JSON.stringify({ latest_run: latestRun, latest_report: latestReport }, null, 2);
      const runLog = document.getElementById("collectionRunLog");
      if (runLog && !runLog.textContent.trim()) {
        runLog.textContent = JSON.stringify({ latest_run: latestRun, latest_report: latestReport }, null, 2);
      }
    }

    function renderCollectionTrends(runs, snapshots, errors, latestReport) {
      const summary = document.getElementById("collectionTrendSummary");
      const chart = document.getElementById("collectionTrendChart");
      if (!summary || !chart) return;
      const runSeries = Array.isArray(runs)
        ? [...runs]
            .filter((item) => item && item.started_at)
            .sort((a, b) => String(a.started_at).localeCompare(String(b.started_at)))
            .slice(-12)
        : [];
      const snapshotSeries = new Map();
      for (const item of snapshots || []) {
        const key = String(item?.run_id || "unknown");
        snapshotSeries.set(key, (snapshotSeries.get(key) || 0) + 1);
      }
      const errorSeries = new Map();
      for (const item of errors || []) {
        const key = String(item?.run_id || "unknown");
        errorSeries.set(key, (errorSeries.get(key) || 0) + 1);
      }
      const labels = runSeries.map((item) => String(item.started_at || "").slice(5, 16));
      const snapshotValues = runSeries.map((item) => snapshotSeries.get(String(item.run_id || "")) || 0);
      const errorValues = runSeries.map((item) => errorSeries.get(String(item.run_id || "")) || 0);
      const generated = String(latestReport.generated_at || "");
      summary.innerHTML = [
        `<span class="chip">run timeline: <strong>${escapeHtml(runSeries.length)}</strong></span>`,
        generated ? `<span class="chip">latest report: <strong>${escapeHtml(generated)}</strong></span>` : `<span class="chip">latest report: N/A</span>`,
        `<span class="chip">snapshots tracked: <strong>${escapeHtml(Array.isArray(snapshots) ? snapshots.length : 0)}</strong></span>`,
        `<span class="chip">errors tracked: <strong>${escapeHtml(Array.isArray(errors) ? errors.length : 0)}</strong></span>`,
        `<span class="chip">older → newer</span>`,
      ].join("");
      chart.innerHTML = renderTrendSvg([
        { label: "Snapshots / run", values: snapshotValues, stroke: "#38bdf8" },
        { label: "Errors / run", values: errorValues, stroke: "#f59e0b" },
      ], labels);
    }

    function renderTrendSvg(series, labels) {
      const width = 760;
      const height = 180;
      const padX = 36;
      const padY = 22;
      const innerW = width - padX * 2;
      const innerH = height - padY * 2 - 14;
      const maxPoints = Math.max(1, ...series.map((s) => (Array.isArray(s.values) ? s.values.length : 0)), labels.length);
      const maxValue = Math.max(1, ...series.flatMap((s) => s.values || []));
      const gridLines = [0, 0.25, 0.5, 0.75, 1];
      const linePath = (values) => values.map((value, index) => {
        const x = padX + (maxPoints === 1 ? innerW / 2 : (innerW * index) / Math.max(1, maxPoints - 1));
        const y = padY + innerH - (Number(value || 0) / maxValue) * innerH;
        return `${index === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
      }).join(" ");
      return `
        <svg viewBox="0 0 ${width} ${height}" width="100%" height="${height}" role="img" aria-label="collection trend chart">
          <rect x="0" y="0" width="${width}" height="${height}" rx="14" fill="rgba(3,7,18,.45)" stroke="rgba(255,255,255,.08)" />
          ${gridLines.map((ratio) => {
            const y = padY + innerH - innerH * ratio;
            const label = Math.round(maxValue * ratio);
            return `<g><line x1="${padX}" y1="${y.toFixed(1)}" x2="${(padX + innerW).toFixed(1)}" y2="${y.toFixed(1)}" stroke="rgba(148,163,184,.12)" /><text x="8" y="${(y + 4).toFixed(1)}" fill="#94a3b8" font-size="10">${label}</text></g>`;
          }).join("")}
          ${series.map((item) => `<path d="${linePath(item.values || [])}" fill="none" stroke="${item.stroke}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />`).join("")}
          ${labels.map((label, index) => {
            const x = padX + (maxPoints === 1 ? innerW / 2 : (innerW * index) / Math.max(1, maxPoints - 1));
            return `<text x="${x.toFixed(1)}" y="${(height - 10).toFixed(1)}" fill="#9ca3af" font-size="10" text-anchor="middle">${escapeHtml(label)}</text>`;
          }).join("")}
          ${series.map((item, seriesIndex) => `
            <text x="${(padX + 8).toFixed(1)}" y="${(18 + seriesIndex * 14).toFixed(1)}" fill="${item.stroke}" font-size="11">${escapeHtml(item.label)}</text>
          `).join("")}
        </svg>
      `;
    }
    async function downloadRawCollection() {
      const response = await fetch("/api/state");
      const payload = await response.json();
      const blob = new Blob([JSON.stringify(payload.collection || {}, null, 2)], { type: "application/json" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = "kis_collection_state.json";
      document.body.appendChild(a);
      a.click();
      a.remove();
      URL.revokeObjectURL(url);
    }
    async function downloadCsvCollection() {
      const response = await fetch("/api/state");
      const payload = await response.json();
      const collection = payload.collection || {};
      const rows = [
        ...(collection.runs || []).map((item) => ({ kind: "run", ...item })),
        ...(collection.recent_snapshots || []).map((item) => ({ kind: "snapshot", ...item })),
        ...(collection.recent_errors || []).map((item) => ({ kind: "error", ...item })),
      ];
      const headers = ["kind", "run_id", "collector_name", "started_at", "finished_at", "status", "dataset_name", "ticker", "name", "source_name", "error_kind", "error_message", "created_at"];
      const csv = [headers.join(",")].concat(rows.map((row) => headers.map((header) => {
        const value = row[header];
        const text = String(value ?? "");
        return `"${text.replaceAll('"', '""')}"`;
      }).join(","))).join("\\n");
      const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = "kis_collection_state.csv";
      document.body.appendChild(a);
      a.click();
      a.remove();
      URL.revokeObjectURL(url);
    }
    reloadState().catch((error) => { document.getElementById("collectionSummary").textContent = error.message; });
    startCollectionAutoRefresh();
  </script>
</body>
</html>
"""


def render_tables_html() -> str:
    return """<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Snapshot Admin — Table Browser</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0/dist/css/tabler.min.css">
</head>
<body>
  <div class="page">
    <header class="navbar navbar-expand-md navbar-dark d-print-none" data-bs-theme="dark">
      <div class="container-xl">
        <h1 class="navbar-brand">Snapshot Admin — Table Browser</h1>
        <div class="navbar-nav flex-row">
          <a class="btn btn-outline-light btn-sm me-2" href="/">Workspace editor</a>
          <a class="btn btn-outline-light btn-sm" href="/collection">Collection dashboard</a>
        </div>
      </div>
    </header>
    <div class="page-wrapper">
      <div class="page-body">
        <div class="container-xl">
          <div class="card mb-3">
            <div class="card-body">
              <div class="d-flex flex-wrap gap-2 align-items-center justify-content-between">
                <div>
                  <div class="badge bg-dark-lt mb-2">DB 먼저 / JSON은 증빙</div>
                  <h2 class="mb-1">DB별 수정, JSON별 검토, 수집 증빙 확인</h2>
                  <div class="text-secondary small">Workspace rows are editable only in the canonical DB. Collection and strategy views are read-only proof surfaces.</div>
                </div>
                <div class="d-flex flex-wrap gap-2">
                  <a class="btn btn-primary" href="/">Workspace editor</a>
                  <a class="btn btn-outline-primary" href="/collection">Collection dashboard</a>
                </div>
              </div>
              <div class="d-flex flex-wrap gap-2 mt-3" id="tablePurposeNavigator">
                <button type="button" class="btn btn-primary btn-sm" onclick="focusTableGroup('Workspace')">1. Workspace</button>
                <button type="button" class="btn btn-info btn-sm" onclick="focusTableGroup('Collection')">2. Collection</button>
                <button type="button" class="btn btn-warning btn-sm" onclick="focusTableGroup('Strategy')">3. Strategy</button>
                <button type="button" class="btn btn-secondary btn-sm" onclick="focusJsonEvidence()">4. JSON evidence</button>
              </div>
              <div class="d-flex flex-wrap gap-2 mt-3" id="tablePurposeCards">
                <span class="badge bg-primary-lt">DB 먼저</span>
                <span class="badge bg-info-lt">JSON은 증빙</span>
                <span class="badge bg-warning-lt">Edit only canonical rows</span>
              </div>
              <div class="row g-2 mt-3">
                <div class="col-lg-6">
                  <div class="alert alert-primary mb-0">
                    <div class="fw-semibold">DB tables</div>
                    <div class="small">Editable source of truth. Only canonical workspace rows are mutated here.</div>
                  </div>
                </div>
                <div class="col-lg-6">
                  <div class="alert alert-info mb-0">
                    <div class="fw-semibold">Workbook sheets</div>
                    <div class="small">Derived report evidence. These sheets summarize DB-backed outputs and run history.</div>
                  </div>
                </div>
              </div>
              <div class="text-secondary small mt-2" id="tablesVersionTop">version: loading...</div>
              <div class="alert alert-warning mt-3 mb-0" id="tablesCoverageWarning">
                Checking whether the table combo covers the target workbook sheets...
              </div>
              <div class="d-flex flex-wrap gap-2 align-items-center mt-3">
                <span class="badge bg-secondary-lt">Workbook sheets only</span>
                <select id="workbookSheetSelect" class="form-select form-select-sm" style="min-width:320px" onchange="onWorkbookSheetChange()"></select>
                <span class="badge bg-secondary-lt" id="workbookSheetMeta">sheet: none</span>
                <span class="badge bg-secondary-lt" id="workbookSheetSurfaceMeta">surface: none</span>
              </div>
            </div>
          </div>
          <div class="card">
              <div class="card-header d-flex flex-wrap gap-2 align-items-center justify-content-between">
                <div class="d-flex gap-2 align-items-center">
                  <label class="form-label mb-0 me-1" for="tableSelect">DB Table</label>
                  <select id="tableSelect" class="form-select" style="min-width:280px" onchange="onTableChange()"></select>
                  <span class="badge bg-secondary-lt" id="tableMeta"></span>
                  <span class="badge bg-danger-lt" id="tableEditState">read only</span>
                </div>
              <div class="d-flex gap-2">
                <input id="gridFilter" class="form-control form-control-sm" style="min-width:240px" placeholder="Filter current page" oninput="applyGridFilter()" />
                <button class="btn btn-sm" onclick="clearGridFilters()">Clear filters</button>
                <button class="btn btn-sm" onclick="prevPage()">&laquo; Prev</button>
                <span class="d-flex align-items-center px-2" id="pageInfo"></span>
                <button class="btn btn-sm" onclick="nextPage()">Next &raquo;</button>
                <button class="btn btn-sm btn-primary" onclick="reload()">Refresh</button>
                <button class="btn btn-sm btn-success" id="saveTableBtn" onclick="saveCurrentTable()">Save changes</button>
              </div>
            </div>
            <div class="muted mt-2">Save applies only to the canonical workspace DB. This selector is for DB tables only; workbook sheets are handled above.</div>
            <div class="card-body border-top">
              <div class="d-flex flex-wrap gap-2 mb-2">
                <span class="badge bg-primary-lt">Workspace</span>
                <span class="badge bg-info-lt">Collection</span>
                <span class="badge bg-warning-lt">Strategy</span>
                <span class="badge bg-secondary-lt">JSON</span>
              </div>
              <details class="mt-2">
                <summary class="chip">Registry details</summary>
                <div class="d-flex flex-wrap gap-2 mb-2" id="tableGroupSummary"></div>
                <div class="d-flex flex-wrap gap-2 mb-2" id="tableSourceSummary"></div>
                <div class="d-flex flex-wrap gap-2 mb-2" id="workbookRegistrySummary"></div>
                <div class="d-flex flex-wrap gap-2 mb-2" id="workbookPurposeFilters"></div>
                <div class="muted mt-2">Open only when you need the table/sheet mapping.</div>
                <div class="row g-2 mt-1">
                  <div class="col-lg-6">
                    <div class="card">
                      <div class="card-header">
                        <div class="card-title">DB tables</div>
                      </div>
                      <div class="card-body table-responsive">
                        <table class="table table-vcenter table-striped" id="dbRegistryTable" style="table-layout:fixed;width:100%;">
                          <thead>
                            <tr>
                              <th>Table</th>
                              <th>DB</th>
                              <th>Rows</th>
                              <th>Edit</th>
                            </tr>
                          </thead>
                          <tbody id="dbRegistryBody"></tbody>
                        </table>
                      </div>
                    </div>
                  </div>
                  <div class="col-lg-6">
                    <div class="card">
                      <div class="card-header">
                        <div class="card-title">Derived report registry</div>
                      </div>
                      <div class="card-body pt-2 pb-0">
                        <div class="alert alert-info mb-2">
                          DB가 원천이고 JSON은 DB 기반 파생 보고서 증빙이다. 이 화면은 그 관계만 보여준다.
                        </div>
                      </div>
                      <div class="card-body table-responsive">
                        <table class="table table-vcenter table-striped" id="workbookRegistryTable" style="table-layout:fixed;width:100%;">
                          <thead>
                            <tr>
                              <th>Sheet</th>
                              <th>Destination</th>
                              <th>Kind</th>
                              <th>Source Role</th>
                              <th>Purpose</th>
                              <th>Rows</th>
                              <th>Recorded surface</th>
                            </tr>
                          </thead>
                          <tbody id="workbookRegistryBody"></tbody>
                        </table>
                      </div>
                    </div>
                  </div>
                </div>
              </details>
              <details class="mt-2" open>
                <summary class="chip">History details</summary>
                <div class="muted mt-2">Recent workspace change log and collector run history.</div>
                <div class="row g-2 mt-1">
                  <div class="col-lg-6">
                    <div class="table-responsive">
                      <table class="table table-vcenter table-striped" id="historyChangeTable" style="table-layout:fixed;width:100%;">
                        <thead>
                          <tr><th>When</th><th>Domain</th><th>Action</th><th>Target</th></tr>
                        </thead>
                        <tbody id="historyChangeBody"></tbody>
                      </table>
                    </div>
                  </div>
                  <div class="col-lg-6">
                    <div class="table-responsive">
                      <table class="table table-vcenter table-striped" id="historyRunTable" style="table-layout:fixed;width:100%;">
                        <thead>
                          <tr><th>Run</th><th>Status</th><th>Started</th><th>Finished</th></tr>
                        </thead>
                        <tbody id="historyRunBody"></tbody>
                      </table>
                    </div>
                  </div>
                </div>
              </details>
              <div class="d-flex flex-wrap gap-2 mb-2">
                <span class="badge bg-info-lt">Collection purpose: monitor collector runs and snapshots.</span>
                <span class="badge bg-info-lt" id="collectionPurposeSummary">Latest run summary loads from `/api/state`.</span>
              </div>
              <div class="text-secondary small">
                DB별 / JSON별 조회 기준을 먼저 확인한 뒤 수정하세요.
                This page is intentionally a triage surface, not a generic table dump.
                Workspace tables are editable only when the table is in the canonical workspace DB.
                Collection and strategy tables are read-only by design unless the backing store explicitly supports editing.
              </div>
            </div>
            <details class="mt-3" open id="tableWorkspaceSection">
              <summary class="chip">Workspace and DB view</summary>
              <div class="muted mt-2">Purpose: edit canonical workspace rows only.</div>
              <div class="table-banner mt-2">
                <div class="meta-row">
                  <span class="chip" id="tableBannerTitle">Table browser ready</span>
                  <span class="chip" id="tableBannerCount">0 rows</span>
                  <span class="chip" id="tableBannerFilter">filter=none</span>
                  <span class="chip" id="tableBannerPage">page=0</span>
                </div>
                <div class="muted" id="tableBannerDetail">If a table shows "no rows", the current filter or selected table has no visible records.</div>
              </div>
              <div class="table-responsive mt-2">
                <table class="table table-vcenter card-table table-striped" id="gridTable" style="table-layout:fixed;width:100%;">
                  <thead>
                    <tr id="gridHead"></tr>
                    <tr id="gridFilterRow"></tr>
                  </thead>
                  <tbody id="gridBody"></tbody>
                </table>
              </div>
            </details>
            <details class="mt-3" open id="tableJsonSection">
              <summary class="chip">Derived JSON evidence view</summary>
              <div class="muted mt-2">Purpose: inspect DB-backed JSON evidence and row payloads.</div>
              <div class="card mt-2">
                <div class="card-header">
                  <div>
                    <div class="card-title">Derived JSON Evidence Preview</div>
                    <div class="text-secondary small">Latest report JSON generated after DB-backed collection from <code>Temp/kis_data_collection_v1.json</code></div>
                  </div>
                  <span class="badge bg-info-lt" id="jsonReportStatus">loading...</span>
                </div>
              <div class="card-body">
                <div class="d-flex flex-wrap gap-2 mb-2" id="jsonReportMeta"></div>
                <div class="d-flex flex-wrap gap-2 mb-2" id="jsonReportFocus"></div>
                <div class="d-flex flex-wrap gap-2 mb-2" id="jsonReportStats"></div>
                <div class="alert alert-secondary mb-2" id="workbookSheetDetail">Select a workbook sheet to inspect its registry detail.</div>
                <pre class="mb-2" id="workbookSheetPreview" style="white-space:pre-wrap; word-break:break-word; min-height:96px; background:#f8fafc; color:#0f172a; border:1px solid #cbd5e1; border-radius:10px; padding:12px;">No workbook sheet selected.</pre>
                <div class="table-responsive">
                    <table class="table table-vcenter card-table table-striped" id="jsonReportTable" style="table-layout:fixed;width:100%;">
                      <thead>
                        <tr id="jsonReportHead"></tr>
                      </thead>
                      <tbody id="jsonReportBody"></tbody>
                    </table>
                  </div>
                  <div class="muted mt-2">Click a row to inspect the derived JSON payload.</div>
                  <pre class="mt-2" id="jsonReportDetail" style="white-space:pre-wrap; word-break:break-word; min-height:120px; background:#0f172a; color:#e5e7eb; border:1px solid #243047; border-radius:10px; padding:12px;">No JSON row selected.</pre>
                  <div class="text-secondary small mt-2" id="tablesVersionBottom">version: loading...</div>
                </div>
              </div>
            </details>
          </div>
        </div>
      </div>
    </div>
  </div>
  <script>
    const state = { tables: [], current: "", limit: 50, offset: 0, total: 0, editable: false, rows: [], filter: "" };
    const jsonState = { latestReport: null, selectedIndex: -1, workbookEntries: [], selectedSheet: "" };
    let globalUiState = null;

    function escapeHtml(value) {
      if (value === null || value === undefined) return "";
      const text = typeof value === "object" ? JSON.stringify(value) : String(value);
      return text.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch]));
    }

    function isEditableDomain(domain) {
      return domain === "settings" || domain === "account_snapshot";
    }

    function normalizeCellValue(value) {
      if (value === null || value === undefined) return "";
      if (typeof value === "object") return JSON.stringify(value);
      return String(value);
    }

    function editableCell(rowIndex, column, value) {
      return `<td class="${columnClass(state.current, column)}" contenteditable="true" data-row-index="${rowIndex}" data-column="${escapeHtml(column)}">${escapeHtml(normalizeCellValue(value))}</td>`;
    }

    function columnClass(tableName, column) {
      const table = String(tableName || "").toLowerCase();
      const key = String(column || "").toLowerCase();
      if (table === "account_snapshot") {
        if (["ticker"].some((token) => key.includes(token))) return "col-micro";
        if (["account", "name"].some((token) => key.includes(token))) return "col-xwide";
        if (["note", "reason", "message"].some((token) => key.includes(token))) return "col-xwide";
        if (["parse_status", "position_type", "account_type", "status"].some((token) => key.includes(token))) return "col-wide";
        if (["holding_quantity", "average_cost", "current_price", "valuation", "pl", "ordinal", "quantity", "qty"].some((token) => key.includes(token))) return "col-micro";
        if (["captured_at", "updated_at", "created_at", "approved_at", "locked_at", "entry_date", "last_updated"].some((token) => key.includes(token))) return "col-wide";
      }
      if (table === "settings") {
        if (["key"].some((token) => key.includes(token))) return "col-xwide";
        if (["value", "note"].some((token) => key.includes(token))) return "col-xwide";
        if (["updated_at", "created_at", "approved_at", "locked_at"].some((token) => key.includes(token))) return "col-wide";
        if (["ordinal", "rowid"].some((token) => key.includes(token))) return "col-micro";
      }
      if (table === "workspace_change_log") {
        if (["before_json", "after_json"].some((token) => key.includes(token))) return "col-xwide";
        if (["domain", "action", "target_ref", "actor"].some((token) => key.includes(token))) return "col-narrow";
        if (["created_at", "updated_at"].some((token) => key.includes(token))) return "col-wide";
      }
      if (table.includes("collection")) {
        if (["run_id", "dataset_name", "source_name", "error_kind"].some((token) => key.includes(token))) return "col-xwide";
        if (["ticker", "status", "stage", "state", "kind"].some((token) => key.includes(token))) return "col-narrow";
        if (["created_at", "started_at", "finished_at", "captured_at", "updated_at", "last_updated"].some((token) => key.includes(token))) return "col-wide";
        if (["count", "rows", "ordinal", "rowid"].some((token) => key.includes(token))) return "col-micro";
      }
      if (table === "sell_strategy_results" || table === "satellite_recommendations") {
        if (["ticker", "name", "sector", "reason", "message"].some((token) => key.includes(token))) return "col-xwide";
        if (["score", "rank", "confidence", "probability"].some((token) => key.includes(token))) return "col-micro";
        if (["status", "action", "stage", "decision"].some((token) => key.includes(token))) return "col-narrow";
        if (["created_at", "updated_at", "evaluated_at"].some((token) => key.includes(token))) return "col-wide";
      }
      if (["ticker", "account", "name", "note", "reason", "message"].some((token) => key.includes(token))) return "col-xwide";
      if (["captured_at", "updated_at", "created_at", "approved_at", "locked_at", "entry_date", "last_updated"].some((token) => key.includes(token))) return "col-wide";
      if (["status", "type", "stage", "flag", "parse", "confirm", "editable"].some((token) => key.includes(token))) return "col-narrow";
      if (["qty", "quantity", "count", "ordinal", "rank", "rowid"].some((token) => key.includes(token))) return "col-micro";
      return "";
    }

    async function loadTables() {
      const [tablesRes, stateRes] = await Promise.all([fetch("/api/tables"), fetch("/api/state")]);
      const data = await tablesRes.json();
      const uiState = await stateRes.json();
      globalUiState = uiState;
      state.tables = data.tables || [];
      jsonState.latestReport = uiState?.collection?.latest_report || null;
      jsonState.workbookEntries = Array.isArray(uiState?.workbook_registry?.entries) ? uiState.workbook_registry.entries : [];
      renderTableGroupSummary();
      renderTableSourceSummary(uiState);
      renderDbRegistry(uiState);
      renderWorkbookPurposeFilters(uiState);
      renderWorkbookRegistry(uiState);
      renderHistoryTrail(uiState);
      renderJsonReportPreview();
      const versionText = uiState?.version
        ? `app=${uiState.version.app || "N/A"} | git=${uiState.version.git?.commit || "N/A"} | tree=${uiState.version.git?.tree_state || "N/A"} | source=${uiState.version.source?.fingerprint || "N/A"} | updated=${uiState.version.source?.latest_updated_at || "N/A"} | final_updated_at=${uiState.version.source?.latest_updated_at || "N/A"}`
        : "version=N/A";
      const versionTop = document.getElementById("tablesVersionTop");
      const versionBottom = document.getElementById("tablesVersionBottom");
      const coverageWarning = document.getElementById("tablesCoverageWarning");
      if (versionTop) versionTop.textContent = versionText;
      if (versionBottom) versionBottom.textContent = versionText;
      if (coverageWarning) {
        const comboTables = new Set((state.tables || []).map((item) => item.table));
        const targetSheets = jsonState.workbookEntries
          .filter((item) => item.kind === "json_payload" || item.kind === "collector_db")
          .map((item) => String(item.sheet || "").trim())
          .filter(Boolean);
        const missingSheets = targetSheets.filter((sheet) => !comboTables.has(sheet));
        coverageWarning.className = missingSheets.length ? "alert alert-warning mt-3 mb-0" : "alert alert-success mt-3 mb-0";
        coverageWarning.textContent = missingSheets.length
          ? `Combo coverage gap: ${missingSheets.length} target sheet(s) are not in the table combo -> ${missingSheets.join(", ")}`
          : "Combo coverage PASS: all target sheets are present in the table combo.";
      }
      const workbookSelect = document.getElementById("workbookSheetSelect");
      const workbookMeta = document.getElementById("workbookSheetMeta");
      const workbookSurfaceMeta = document.getElementById("workbookSheetSurfaceMeta");
      const workbookEntries = jsonState.workbookEntries.filter((item) => item.kind === "json_payload" || item.kind === "collector_db");
      if (workbookSelect) {
        workbookSelect.innerHTML = workbookEntries.map((item) => `<option value="${escapeHtml(item.sheet)}">${escapeHtml(item.sheet)} (${escapeHtml(item.kind)})</option>`).join("");
        if (!jsonState.selectedSheet && workbookEntries.length) {
          jsonState.selectedSheet = String(workbookEntries[0].sheet || "");
        }
        workbookSelect.value = jsonState.selectedSheet || "";
      }
      if (workbookMeta) {
        const selected = jsonState.workbookEntries.find((item) => String(item.sheet || "") === String(jsonState.selectedSheet || ""));
        workbookMeta.textContent = selected
          ? `sheet: ${selected.sheet} | kind: ${selected.kind} | purpose: ${selected.purpose} | destination: ${selected.destination}`
          : "sheet: none";
      }
      if (workbookSurfaceMeta) {
        const selected = jsonState.workbookEntries.find((item) => String(item.sheet || "") === String(jsonState.selectedSheet || ""));
        workbookSurfaceMeta.textContent = selected
          ? `surface: ${selected.kind} | destination: ${selected.destination}`
          : "surface: none";
      }
      const select = document.getElementById("tableSelect");
      select.innerHTML = state.tables
        .map((t) => `<option value="${t.table}" ${!t.exists ? "disabled" : ""}>${t.table} (${t.exists ? t.row_count : "no db"})${t.editable ? ' (Editable)' : ''}</option>`)
        .join("");
      if (!state.current && state.tables.length) {
        state.current = state.tables.find((t) => t.table === "settings" && t.exists && Number(t.row_count || 0) > 0)?.table
          || state.tables.find((t) => t.table === "account_snapshot" && t.exists && Number(t.row_count || 0) > 0)?.table
          || state.tables.find((t) => t.exists && Number(t.row_count || 0) > 0)?.table
          || state.tables.find((t) => t.exists)?.table
          || state.tables[0].table;
      }
      select.value = state.current;
      await loadRows();
    }

    function onTableChange() {
      state.current = document.getElementById("tableSelect").value;
      state.offset = 0;
      clearGridFilters(false);
      loadRows();
    }

    function onWorkbookSheetChange() {
      const workbookSelect = document.getElementById("workbookSheetSelect");
      jsonState.selectedSheet = String(workbookSelect?.value || "");
      const workbookMeta = document.getElementById("workbookSheetMeta");
      const workbookSurfaceMeta = document.getElementById("workbookSheetSurfaceMeta");
      const selected = jsonState.workbookEntries.find((item) => String(item.sheet || "") === String(jsonState.selectedSheet || ""));
      if (workbookMeta) {
        workbookMeta.textContent = selected
          ? `sheet: ${selected.sheet} | kind: ${selected.kind} | purpose: ${selected.purpose} | destination: ${selected.destination} | recorded surface: ${selected.kind}`
          : "sheet: none";
      }
      if (workbookSurfaceMeta) {
        workbookSurfaceMeta.textContent = selected
          ? `surface: ${selected.kind} | destination: ${selected.destination}`
          : "surface: none";
      }
      if (globalUiState) {
        renderWorkbookRegistry(globalUiState);
      }
      const section = document.getElementById("tableJsonSection");
      if (section && section.open === false) section.open = true;
      const tableSelect = document.getElementById("tableSelect");
      if (tableSelect && state.tables.some((item) => item.table === jsonState.selectedSheet)) {
        state.current = jsonState.selectedSheet;
        tableSelect.value = jsonState.selectedSheet;
        state.offset = 0;
        clearGridFilters(false);
        loadRows();
      }
    }

    async function loadRows() {
      if (!state.current) return;
      const editable = state.tables.find(t => t.table === state.current)?.editable || false;
      state.editable = editable;
      const isDomain = isEditableDomain(state.current);
      const filterText = String(document.getElementById("gridFilter")?.value || "").trim();
      const filterParams = new URLSearchParams({ table: state.current, limit: state.limit, offset: state.offset });
      if (filterText) {
        filterParams.set("filter", filterText);
      }
      for (const input of document.querySelectorAll("#gridFilterRow input[data-filter-column]")) {
        const column = String(input.getAttribute("data-filter-column") || "").trim();
        const value = String(input.value || "").trim();
        if (column && value) {
          filterParams.set(`filter_${column}`, value);
        }
      }
      const url = isDomain ? `/api/domain_rows?domain=${encodeURIComponent(state.current)}` : `/api/table_rows?${filterParams.toString()}`;
      const res = await fetch(url);
      const data = await res.json();
      state.rows = data.rows || [];
      state.total = isDomain ? state.rows.length : (data.total || 0);
      const head = document.getElementById("gridHead");
      const filterRow = document.getElementById("gridFilterRow");
      const body = document.getElementById("gridBody");
      const displayColumns = (data.columns || []).filter((c) => !String(c).startsWith("_"));
      head.innerHTML = displayColumns.map((c) => `<th class="${columnClass(state.current, c)}">${escapeHtml(c)}</th>`).join("");
      filterRow.innerHTML = displayColumns.map((c) => `<th class="${columnClass(state.current, c)}"><input class="form-control form-control-sm" data-filter-column="${escapeHtml(c)}" placeholder="Filter" oninput="applyGridFilter()" /></th>`).join("");
      body.innerHTML = state.rows.length
        ? state.rows
            .map((row, rowIndex) => {
              return `<tr data-row-index="${rowIndex}">${displayColumns.map((c) => {
                // Settings key and other primary columns can be protected or editable based on needs
                const cellVal = row[c];
                return editable ? editableCell(rowIndex, c, cellVal) : `<td>${escapeHtml(cellVal)}</td>`;
              }).join("")}</tr>`;
            })
            .join("")
        : `<tr><td colspan="99" class="text-secondary">no rows</td></tr>`;
      document.getElementById("tableMeta").textContent = `${data.db || ""}`;
      const from = state.total === 0 ? 0 : state.offset + 1;
      const to = Math.min(state.offset + state.limit, state.total);
      const currentFilter = String(document.getElementById("gridFilter")?.value || "").trim();
      const visibleCount = currentFilter
        ? state.rows.filter((row) => {
            const haystack = displayColumns.map((c) => String(row[c] ?? "")).join(" ").toLowerCase();
            return haystack.includes(currentFilter.toLowerCase());
          }).length
        : state.rows.length;
      const bannerTitle = document.getElementById("tableBannerTitle");
      const bannerCount = document.getElementById("tableBannerCount");
      const bannerFilter = document.getElementById("tableBannerFilter");
      const bannerPage = document.getElementById("tableBannerPage");
      const bannerDetail = document.getElementById("tableBannerDetail");
      const sourceSummary = document.getElementById("tableSourceSummary");
      if (bannerTitle) bannerTitle.textContent = `${state.current || "table"}${editable ? " (editable)" : ""}`;
      if (bannerCount) bannerCount.textContent = `${state.total} rows total, ${visibleCount} visible`;
      if (bannerFilter) bannerFilter.textContent = currentFilter ? `filter=${currentFilter}` : "filter=none";
      if (bannerPage) bannerPage.textContent = `${from}-${to} / ${state.total}`;
      if (bannerDetail) {
        bannerDetail.textContent = state.total === 0
          ? "This table currently has no rows in the selected database."
          : visibleCount === 0
            ? "Rows exist, but the active filter hides them."
            : editable
              ? "Rows are loaded and editable because this table belongs to the canonical workspace DB."
              : "Rows are loaded but read-only because this table belongs to collector or strategy storage.";
      }
      if (sourceSummary) {
        const currentDb = String(data.db || "").trim();
        sourceSummary.title = currentDb ? `Selected table DB: ${currentDb}` : "Selected table DB unavailable";
      }
      document.getElementById("pageInfo").textContent = `${from}-${to} / ${state.total}`;
      const saveBtn = document.getElementById("saveTableBtn");
      const tableEditState = document.getElementById("tableEditState");
      if (saveBtn) {
        saveBtn.disabled = !editable;
        saveBtn.textContent = editable ? "Save current table" : "Read only";
        saveBtn.className = editable ? "btn btn-sm btn-success" : "btn btn-sm btn-secondary";
      }
      if (tableEditState) {
        tableEditState.textContent = editable ? "editable in workspace DB" : "read only from collector/strategy DB";
        tableEditState.className = editable ? "badge bg-success-lt" : "badge bg-danger-lt";
      }
      applyGridFilter();
    }

    function renderTableGroupSummary() {
      const target = document.getElementById("tableGroupSummary");
      if (!target) return;
      const groups = [
        { label: "Workspace", match: (table) => ["settings", "account_snapshot", "workspace_change_log", "workspace_approval_v2", "workspace_lock", "workspace_meta"].includes(table.table), tone: "primary" },
        { label: "Collection", match: (table) => ["collection_runs", "collection_snapshots", "collection_source_errors"].includes(table.table), tone: "info" },
        { label: "Strategy", match: (table) => ["sell_strategy_results", "satellite_recommendations"].includes(table.table), tone: "warning" },
      ];
      target.innerHTML = groups.map((group) => {
        const items = state.tables.filter(group.match);
        const exists = items.filter((item) => item.exists).length;
        const rows = items.reduce((sum, item) => sum + Number(item.row_count || 0), 0);
        const active = items.some((item) => item.table === state.current);
        return `<button type="button" class="btn btn${active ? '' : '-outline'}-${group.tone} btn-sm" title="${group.label} tables: ${items.map((item) => item.table).join(', ')}" onclick="focusTableGroup('${group.label}')">${group.label}: ${exists}/${items.length} tables, ${rows} rows${active ? ' • current' : ''}</button>`;
      }).join("");
    }

    function renderTableSourceSummary(uiState) {
      const target = document.getElementById("tableSourceSummary");
      if (!target) return;
      const topology = uiState?.summary?.topology || {};
      const collection = uiState?.collection || {};
      const sources = [
        { label: "Workspace DB", value: topology.workspace_db || "N/A" },
        { label: "Collector DB", value: topology.collector_db || "N/A" },
        { label: "Strategy DB", value: "outputs/qualitative_sell_strategy/qualitative_sell_strategy.db" },
        { label: "Derived JSON report", value: collection.output_json_path || "N/A" },
      ];
      target.innerHTML = sources.map((item) => `<span class="chip">${escapeHtml(item.label)}: <strong>${escapeHtml(item.value)}</strong></span>`).join("");
    }

    function renderDbRegistry(uiState) {
      const body = document.getElementById("dbRegistryBody");
      if (!body) return;
      const tables = Array.isArray(state.tables) ? state.tables : [];
      const currentDb = String(uiState?.summary?.topology?.workspace_db || "").trim();
      const collectorDb = String(uiState?.summary?.topology?.collector_db || "").trim();
      const strategyDb = "outputs/qualitative_sell_strategy/qualitative_sell_strategy.db";
      body.innerHTML = tables.length
        ? tables.map((item) => {
            const dbLabel = item.table === "settings" || item.table === "account_snapshot"
              ? currentDb || "workspace_db"
              : item.table.includes("collection")
                ? collectorDb || "collector_db"
                : item.table.includes("strategy") || item.table.includes("recommendation")
                  ? strategyDb
                  : "workspace_db";
            return `<tr><td>${escapeHtml(item.table)}</td><td>${escapeHtml(dbLabel)}</td><td>${escapeHtml(item.row_count ?? "N/A")}</td><td>${escapeHtml(item.editable ? "editable" : "read-only")}</td></tr>`;
          }).join("")
        : `<tr><td colspan="4" class="text-secondary">No DB table metadata available.</td></tr>`;
    }

    function renderWorkbookPurposeFilters(uiState) {
      const target = document.getElementById("workbookPurposeFilters");
      if (!target) return;
      const entries = Array.isArray(uiState?.workbook_registry?.entries) ? uiState.workbook_registry.entries : [];
      const purposes = Array.from(new Set(entries.map((item) => String(item.purpose || "unmapped"))));
      target.innerHTML = [
        `<span class="chip">Purpose filter:</span>`,
        `<button type="button" class="btn btn-sm btn-outline-primary" onclick="filterWorkbookPurpose('all')">All</button>`,
        ...purposes.map((purpose) => `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="filterWorkbookPurpose('${escapeHtml(purpose)}')">${escapeHtml(purpose)}</button>`),
      ].join("");
    }

    function selectWorkbookSheet(sheet) {
      const workbookSelect = document.getElementById("workbookSheetSelect");
      if (workbookSelect) {
        workbookSelect.value = sheet;
        onWorkbookSheetChange();
      }
    }

    function renderWorkbookRegistry(uiState) {
      const summary = document.getElementById("workbookRegistrySummary");
      const body = document.getElementById("workbookRegistryBody");
      if (!summary || !body) return;
      const registry = uiState?.workbook_registry || {};
      const entries = Array.isArray(registry.entries) ? registry.entries : [];
      const unmapped = Array.isArray(registry.unmapped_sheets) ? registry.unmapped_sheets : [];
      summary.innerHTML = [
        `<span class="chip">Workbook sheets: <strong>${escapeHtml(registry.sheet_count ?? 0)}</strong></span>`,
        `<span class="chip">XLSX: <strong>${escapeHtml(registry.xlsx_path || "N/A")}</strong></span>`,
        `<span class="chip">JSON evidence: <strong>${escapeHtml(registry.json_path || "N/A")}</strong></span>`,
        `<span class="chip">JSON role: <strong>${escapeHtml(registry.json_role || "N/A")}</strong></span>`,
        `<span class="chip">Unmapped: <strong>${escapeHtml(unmapped.length)}</strong></span>`,
      ].join("");
      body.innerHTML = entries.length
        ? entries.map((item) => {
            const recordedSurface = item.kind === "workspace_db" || item.kind === "collector_db" || item.kind === "json_payload"
              ? item.kind
              : "unmapped";
            const rows = item.row_count === null || item.row_count === undefined ? "N/A" : String(item.row_count);
            const surfaceBadge = recordedSurface === "unmapped"
              ? '<span class="badge bg-danger-lt">unmapped</span>'
              : `<span class="badge bg-success-lt">${escapeHtml(recordedSurface)}</span>`;
            const isSelected = String(item.sheet || "") === String(jsonState.selectedSheet || "");
            const selectedClass = isSelected ? 'class="workbook-row-selected table-active"' : '';
            return `<tr ${selectedClass} style="cursor: pointer;" onclick="selectWorkbookSheet('${escapeHtml(item.sheet)}')"><td>${escapeHtml(item.sheet)}</td><td>${escapeHtml(item.destination)}</td><td>${escapeHtml(item.kind)}</td><td>${escapeHtml(item.source_role || "N/A")}</td><td>${escapeHtml(item.purpose || "N/A")}</td><td>${escapeHtml(rows)}</td><td>${surfaceBadge}</td></tr>`;
          }).join("")
        : `<tr><td colspan="7" class="text-secondary">No workbook registry data available.</td></tr>`;
    }

    function filterWorkbookPurpose(purpose) {
      const rows = Array.from(document.querySelectorAll("#workbookRegistryBody tr"));
      rows.forEach((tr) => {
        const text = tr.textContent || "";
        const match = purpose === "all" || text.includes(purpose);
        tr.style.display = match ? "" : "none";
      });
    }

    function renderHistoryTrail(uiState) {
      const changeBody = document.getElementById("historyChangeBody");
      const runBody = document.getElementById("historyRunBody");
      if (!changeBody || !runBody) return;
      const changes = Array.isArray(uiState?.recent_changes) ? uiState.recent_changes : [];
      const runs = Array.isArray(uiState?.collection?.runs) ? uiState.collection.runs : [];
      changeBody.innerHTML = changes.length
        ? changes.slice(0, 8).map((item) => `<tr><td>${escapeHtml(item.created_at || "")}</td><td>${escapeHtml(item.domain || "")}</td><td>${escapeHtml(item.action || "")}</td><td>${escapeHtml(item.target_ref || "")}</td></tr>`).join("")
        : `<tr><td colspan="4" class="text-secondary">No recent changes.</td></tr>`;
      runBody.innerHTML = runs.length
        ? runs.slice(0, 8).map((item) => `<tr class="${escapeHtml(String(item.status || "").toLowerCase())}"><td>[RUN] ${escapeHtml(item.run_id || "")}</td><td>${escapeHtml(item.status || "")}</td><td>${escapeHtml(item.started_at || "")}</td><td>${escapeHtml(item.finished_at || "")}</td></tr>`).join("")
        : `<tr><td colspan="4" class="text-secondary">No collector runs.</td></tr>`;
    }

    function renderJsonReportPreview() {
      const report = jsonState.latestReport || {};
      const status = document.getElementById("jsonReportStatus");
      const meta = document.getElementById("jsonReportMeta");
      const focus = document.getElementById("jsonReportFocus");
      const stats = document.getElementById("jsonReportStats");
      const workbookDetail = document.getElementById("workbookSheetDetail");
      const workbookPreview = document.getElementById("workbookSheetPreview");
      const head = document.getElementById("jsonReportHead");
      const body = document.getElementById("jsonReportBody");
      const detail = document.getElementById("jsonReportDetail");
      if (!status || !meta || !focus || !stats || !workbookDetail || !workbookPreview || !head || !body || !detail) return;
      const rows = Array.isArray(report.rows) ? report.rows : [];
      status.textContent = report.run_id ? `run ${report.run_id}` : "no report";
      const sourceCounts = report.source_counts || {};
      meta.innerHTML = [
        report.generated_at ? `<span class="chip">generated_at: <strong>${escapeHtml(report.generated_at)}</strong></span>` : "",
        report.row_count !== undefined ? `<span class="chip">rows: <strong>${escapeHtml(report.row_count)}</strong></span>` : "",
        `<span class="chip">kis_open_api: <strong>${escapeHtml(sourceCounts.kis_open_api ?? 0)}</strong></span>`,
        `<span class="chip">gathertradingdata_json: <strong>${escapeHtml(sourceCounts.gathertradingdata_json ?? 0)}</strong></span>`,
      ].filter(Boolean).join("");
      const previewColumns = ["ticker", "name", "sector", "current_price", "source_priority"];
      head.innerHTML = previewColumns.map((col) => `<th>${escapeHtml(col)}</th>`).join("");
      body.innerHTML = rows.length
        ? rows.slice(0, 8).map((row, index) => `<tr class="json-row ${index === jsonState.selectedIndex ? "selected" : ""}" role="button" onclick="selectJsonRow(${index})">${previewColumns.map((col) => `<td>${escapeHtml(row?.[col])}</td>`).join("")}</tr>`).join("")
        : `<tr><td colspan="5" class="text-secondary">No JSON report rows available.</td></tr>`;
      const selected = rows[jsonState.selectedIndex];
      focus.innerHTML = selected
        ? [
            `<span class="chip">ticker: <strong>${escapeHtml(selected.ticker || "N/A")}</strong></span>`,
            `<span class="chip">current_price: <strong>${escapeHtml(selected.current_price ?? "N/A")}</strong></span>`,
            `<span class="chip">source_priority: <strong>${escapeHtml(Array.isArray(selected.source_priority) ? selected.source_priority.join(" > ") : "N/A")}</strong></span>`,
          ].join("")
        : `<span class="chip">No JSON row selected</span>`;
      stats.innerHTML = selected
        ? `<span class="badge bg-success-lt">Price ${escapeHtml(selected.current_price ?? "N/A")}</span><span class="badge bg-secondary-lt">${escapeHtml(selected.name || "N/A")}</span>`
        : `<span class="badge bg-secondary-lt">Select a row to highlight price</span>`;
      detail.textContent = selected ? JSON.stringify(selected, null, 2) : "No JSON row selected.";
      const selectedSheet = jsonState.workbookEntries.find((item) => String(item.sheet || "") === String(jsonState.selectedSheet || ""));
      workbookDetail.textContent = selectedSheet
        ? `Selected sheet: ${selectedSheet.sheet} | kind=${selectedSheet.kind} | purpose=${selectedSheet.purpose} | destination=${selectedSheet.destination} | rows=${selectedSheet.row_count ?? "N/A"}`
        : "Select a workbook sheet to inspect its registry detail.";
      workbookPreview.textContent = selectedSheet
        ? JSON.stringify(selectedSheet, null, 2)
        : "No workbook sheet selected.";
    }

    function selectJsonRow(index) {
      jsonState.selectedIndex = Number(index);
      renderJsonReportPreview();
    }

    function focusTableGroup(groupLabel) {
      const groupMap = {
        Workspace: ["settings", "account_snapshot", "workspace_change_log", "workspace_approval_v2", "workspace_lock", "workspace_meta"],
        Collection: ["collection_runs", "collection_snapshots", "collection_source_errors"],
        Strategy: ["sell_strategy_results", "satellite_recommendations"],
      };
      const allowed = groupMap[groupLabel] || [];
      const next = state.tables.find((t) => allowed.includes(t.table) && t.exists && Number(t.row_count || 0) > 0)
        || state.tables.find((t) => allowed.includes(t.table) && t.exists)
        || state.tables.find((t) => allowed.includes(t.table));
      if (!next) return;
      state.current = next.table;
      document.getElementById("tableSelect").value = next.table;
      state.offset = 0;
      clearGridFilters(false);
      loadRows();
    }

    function focusJsonEvidence() {
      const section = document.getElementById("tableJsonSection");
      if (section && section.open === false) section.open = true;
      const target = document.getElementById("jsonReportDetail");
      if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
    }

    function prevPage() {
      state.offset = Math.max(0, state.offset - state.limit);
      loadRows();
    }

    function nextPage() {
      if (state.offset + state.limit < state.total) {
        state.offset += state.limit;
        loadRows();
      }
    }

    function reload() {
      loadRows();
    }

    function clearGridFilters(reloadTable = true) {
      const gridFilter = document.getElementById("gridFilter");
      if (gridFilter) gridFilter.value = "";
      document.querySelectorAll("#gridFilterRow input[data-filter-column]").forEach((input) => {
        input.value = "";
      });
      state.filter = "";
      if (reloadTable) {
        state.offset = 0;
        loadRows();
      } else {
        applyGridFilter();
      }
    }

    function applyGridFilter() {
      const text = String(document.getElementById("gridFilter")?.value || "").trim().toLowerCase();
      state.filter = text;
      const columnFilters = Array.from(document.querySelectorAll("#gridFilterRow input[data-filter-column]"))
        .map((input) => ({
          column: String(input.getAttribute("data-filter-column") || ""),
          value: String(input.value || "").trim().toLowerCase(),
        }))
        .filter((item) => item.value);
      const headers = Array.from(document.querySelectorAll("#gridHead th")).map((th) => String(th.textContent || ""));
      const rows = Array.from(document.querySelectorAll("#gridBody tr[data-row-index]"));
      rows.forEach((tr) => {
        const cells = Array.from(tr.querySelectorAll("td"));
        const haystack = tr.textContent.toLowerCase();
        const matches = columnFilters.every((filter) => {
          const idx = headers.indexOf(filter.column);
          if (idx < 0) return true;
          return String(cells[idx]?.textContent || "").toLowerCase().includes(filter.value);
        });
        tr.style.display = (!text || haystack.includes(text)) && matches ? "" : "none";
      });
      const bannerFilter = document.getElementById("tableBannerFilter");
      if (bannerFilter) bannerFilter.textContent = text ? `filter=${text}` : "filter=none";
    }

    function collectEditableRows() {
      const table = document.getElementById("gridTable");
      const columns = Array.from(table.querySelectorAll("thead th")).map((th) => th.textContent || "");
      const bodyRows = table.querySelectorAll("tbody tr[data-row-index]");
      return Array.from(bodyRows).map((tr, index) => {
        const cells = tr.querySelectorAll("td");
        const row = {};
        let cellIndex = 0;
        for (const column of columns) {
          if (String(column).startsWith("_")) {
            continue;
          }
          const cell = cells[cellIndex++];
          row[column] = cell ? cell.textContent : "";
        }
        row.ordinal = index + 1;
        return row;
      });
    }

    function parseCellValue(value) {
      const text = String(value || "").trim();
      if (text === "") return "";
      if (text === "null" || text === "None") return null;
      if (text === "true") return true;
      if (text === "false") return false;
      const numericPattern = new RegExp("^-?(0|[1-9]\\\\d*)(\\\\.\\\\d+)?([eE][-+]?\\\\d+)?$");
      if (numericPattern.test(text)) return Number(text);
      try {
        return JSON.parse(text);
      } catch (err) {
        return text;
      }
    }

    async function saveCurrentTable() {
      if (!state.editable) return;
      const isDomain = isEditableDomain(state.current);
      const endpoint = isDomain
        ? (state.current === "settings" ? "/api/settings/save" : "/api/account_snapshot/save")
        : "/api/table/save";
      
      const rows = collectEditableRows().map(row => {
        const parsedRow = {};
        for (const k of Object.keys(row)) {
          parsedRow[k] = parseCellValue(row[k]);
        }
        return parsedRow;
      });

      const payload = isDomain ? { rows } : { table: state.current, rows };
      
      const response = await fetch(endpoint, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      });
      const data = await response.json();
      if (!response.ok) throw new Error(data.detail || "save failed");
      await loadRows();
      alert(`saved: ${state.current}`);
    }

    loadTables().catch((error) => {
      document.getElementById("gridBody").innerHTML = `<tr><td class="text-danger">${escapeHtml(error.message)}</td></tr>`;
    });
  </script>
</body>
</html>
"""


class SnapshotAdminHandler(BaseHTTPRequestHandler):
    db_path: Path = DEFAULT_DB
    seed_json_path: Path = DEFAULT_SEED_JSON

    def log_message(self, format: str, *args: Any) -> None:  # noqa: A003
        return

    def _handle_exception(self, exc: Exception) -> None:
        _json_response(self, HTTPStatus.INTERNAL_SERVER_ERROR, {"detail": str(exc)})

    def do_GET(self) -> None:  # noqa: N802
        parsed = urlparse(self.path)
        if parsed.path == "/":
            _text_response(self, HTTPStatus.OK, render_home_html(), "text/html; charset=utf-8")
            return
        if parsed.path == "/workspace":
            _text_response(self, HTTPStatus.OK, render_index_html(), "text/html; charset=utf-8")
            return
        if parsed.path == "/collection":
            _text_response(self, HTTPStatus.OK, render_collection_html(), "text/html; charset=utf-8")
            return
        if parsed.path == "/tables":
            _text_response(self, HTTPStatus.OK, render_tables_html(), "text/html; charset=utf-8")
            return
        if parsed.path == "/api/tables":
            _json_response(self, HTTPStatus.OK, {"tables": list_browsable_tables(self.db_path)})
            return
        if parsed.path == "/api/table_rows":
            query = parse_qs(parsed.query)
            table = (query.get("table") or [""])[0]
            try:
                limit = int((query.get("limit") or ["50"])[0])
                offset = int((query.get("offset") or ["0"])[0])
            except ValueError:
                _json_response(self, HTTPStatus.BAD_REQUEST, {"detail": "limit/offset must be integers"})
                return
            limit = min(max(limit, 1), 500)
            offset = max(offset, 0)
            filter_text = (query.get("filter") or [""])[0]
            column_filters: dict[str, str] = {}
            for key, values in query.items():
                if key.startswith("filter_") and values:
                    column_filters[key.removeprefix("filter_")] = values[0]
            try:
                payload = fetch_table_rows(
                    table,
                    self.db_path,
                    limit=limit,
                    offset=offset,
                    filter_text=filter_text,
                    column_filters=column_filters,
                )
            except ValueError as exc:
                _json_response(self, HTTPStatus.BAD_REQUEST, {"detail": str(exc)})
                return
            _json_response(self, HTTPStatus.OK, payload)
            return
        if parsed.path == "/api/state":
            _json_response(self, HTTPStatus.OK, build_ui_state(self.db_path))
            return
        if parsed.path == "/api/collection/run":
            _json_response(self, HTTPStatus.METHOD_NOT_ALLOWED, {"detail": "POST required"})
            return
        if parsed.path == "/api/history":
            _json_response(
                self,
                HTTPStatus.OK,
                {
                    "settings": load_change_log_rows(self.db_path, limit=25),
                    "approvals": load_approval_rows(self.db_path),
                    "locks": load_locks(self.db_path),
                },
            )
            return
        if parsed.path == "/api/export":
            _text_response(
                self,
                HTTPStatus.OK,
                json.dumps(export_payload(self.db_path), ensure_ascii=False, indent=2),
                "application/json; charset=utf-8",
            )
            return
        if parsed.path == "/favicon.ico":
            _text_response(self, HTTPStatus.NO_CONTENT, "")
            return
        _json_response(self, HTTPStatus.NOT_FOUND, {"detail": "not found"})

    def do_POST(self) -> None:  # noqa: N802
        parsed = urlparse(self.path)
        try:
            if parsed.path == "/api/bootstrap":
                summary = import_seed_json(self.db_path, self.seed_json_path)
                _json_response(self, HTTPStatus.OK, summary)
                return
            if parsed.path == "/api/collection/run":
                content_type = str(self.headers.get("Content-Type") or "").lower()
                if "application/json" in content_type:
                    payload = _read_json_body(self)
                else:
                    length = int(self.headers.get("Content-Length") or 0)
                    raw = self.rfile.read(length).decode("utf-8") if length > 0 else ""
                    form_payload = {key: values[0] for key, values in parse_qs(raw).items()}
                    payload = {
                        "kis_account": form_payload.get("kis_account") or "real",
                        "include_live_kis": str(form_payload.get("include_live_kis") or "true").lower() == "true",
                        "allow_naver_fallback": str(form_payload.get("allow_naver_fallback") or "false").lower() == "true",
                    }
                result = run_collection_job(
                    sqlite_db=KIS_COLLECTION_DB,
                    input_json=ROOT / "GatherTradingData.json",
                    output_json=KIS_COLLECTION_REPORT,
                    allow_naver_fallback=bool(payload.get("allow_naver_fallback")),
                    include_live_kis=bool(payload.get("include_live_kis")),
                    kis_account=str(payload.get("kis_account") or "real"),
                )
                _json_response(self, HTTPStatus.OK, result)
                return
            payload = _read_json_body(self)
            if parsed.path == "/api/settings/save":
                if is_locked(self.db_path, "settings"):
                    raise ValueError("settings are locked")
                rows = payload.get("rows")
                if not isinstance(rows, list):
                    raise ValueError("rows must be a list")
                normalized_rows = []
                for idx, row in enumerate(rows, start=1):
                    if not isinstance(row, dict):
                        continue
                    key = str(row.get("key") or "").strip()
                    if not key:
                        continue
                    normalized_rows.append(
                        {
                            "ordinal": idx,
                            "key": key,
                            "value": row.get("value", ""),
                            "note": str(row.get("note") or ""),
                        }
                    )
                conflicts = lock_conflicts_for_rows(self.db_path, "settings", normalized_rows)
                if conflicts:
                    refs = ", ".join(sorted({str(item.get("target_ref") or "") for item in conflicts if item.get("target_ref")}))
                    raise ValueError(f"settings lock conflict: {refs}")
                with open_connection(self.db_path) as conn:
                    replace_settings(conn, normalized_rows)
                _json_response(self, HTTPStatus.OK, summarize_workspace(self.db_path))
                return
            if parsed.path == "/api/account_snapshot/save":
                if is_locked(self.db_path, "account_snapshot"):
                    raise ValueError("account_snapshot is locked")
                rows = payload.get("rows")
                if not isinstance(rows, list):
                    raise ValueError("rows must be a list")
                normalized_rows: list[dict[str, Any]] = []
                for idx, row in enumerate(rows, start=1):
                    if not isinstance(row, dict):
                        continue
                    candidate = {key: value for key, value in row.items() if not key.startswith("_")}
                    candidate["ordinal"] = idx
                    normalized_rows.append(candidate)
                conflicts = lock_conflicts_for_rows(self.db_path, "account_snapshot", normalized_rows)
                if conflicts:
                    refs = ", ".join(sorted({str(item.get("target_ref") or "") for item in conflicts if item.get("target_ref")}))
                    raise ValueError(f"account_snapshot lock conflict: {refs}")
                with open_connection(self.db_path) as conn:
                    replace_account_snapshot(conn, normalized_rows)
                _json_response(self, HTTPStatus.OK, summarize_workspace(self.db_path))
                return
            if parsed.path == "/api/account_snapshot/import_tsv":
                if is_locked(self.db_path, "account_snapshot"):
                    raise ValueError("account_snapshot is locked")
                tsv_text = str(payload.get("tsv") or "")
                rows = parse_account_snapshot_tsv(tsv_text)
                with open_connection(self.db_path) as conn:
                    replace_account_snapshot(conn, rows)
                _json_response(self, HTTPStatus.OK, summarize_workspace(self.db_path))
                return
            if parsed.path == "/api/table/save":
                table = str(payload.get("table") or "").strip()
                rows = payload.get("rows")
                if table not in EDITABLE_TABLES:
                    raise ValueError(f"table not editable: {table}")
                if not isinstance(rows, list):
                    raise ValueError("rows must be a list")
                db_path = _resolve_table_db(table, self.db_path)
                if not db_path:
                    raise ValueError(f"database not found for table: {table}")
                with open_connection(db_path) as conn:
                    conn.execute("BEGIN TRANSACTION")
                    try:
                        conn.execute(f"DELETE FROM {table}")  # noqa: S608 - Whitelisted table name
                        if rows:
                            first_row = rows[0]
                            columns = [k for k in first_row.keys() if not k.startswith("_")]
                            if "rowid" in columns:
                                columns.remove("rowid")
                            if "_rowid" in columns:
                                columns.remove("_rowid")
                            placeholders = ", ".join(["?"] * len(columns))
                            col_list = ", ".join(columns)
                            insert_sql = f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})"  # noqa: S608 - Whitelisted table name
                            for row in rows:
                                values = [row.get(col) for col in columns]
                                conn.execute(insert_sql, values)
                        conn.commit()
                    except Exception as e:
                        conn.rollback()
                        raise e
                _json_response(self, HTTPStatus.OK, {"status": "SUCCESS", "table": table, "row_count": len(rows)})
                return
            if parsed.path == "/api/approval_packet":
                packet = payload.get("packet")
                if not isinstance(packet, dict):
                    raise ValueError("packet must be an object")
                artifacts = write_approval_packet_artifacts(packet)
                response = {
                    "gate": "PASS",
                    "packet_path": artifacts["json_path"],
                    "md_path": artifacts["md_path"],
                    "formula_id": packet.get("formula_id", "SNAPSHOT_ADMIN_APPROVAL_PACKET_V1"),
                }
                _json_response(self, HTTPStatus.OK, response)
                return
            if parsed.path == "/api/approve":
                domain = str(payload.get("domain") or "")
                if domain not in {"settings", "account_snapshot"}:
                    raise ValueError("domain must be settings or account_snapshot")
                target_ref = str(payload.get("target_ref") or "*")
                with open_connection(self.db_path) as conn:
                    before = _approval_entry_from_conn(conn, domain, target_ref)
                    set_approval(conn, domain, "APPROVED", target_ref=target_ref, approved_by="ui", note="manual approval")
                    after = _approval_entry_from_conn(conn, domain, target_ref)
                    record_change_log(
                        conn,
                        domain=domain,
                        action="approve",
                        target_ref=target_ref,
                        before_json=before,
                        after_json=after,
                        actor="ui",
                        note="manual approval",
                    )
                    conn.commit()
                _json_response(self, HTTPStatus.OK, {"domain": domain, "target_ref": target_ref, "status": "APPROVED"})
                return
            if parsed.path == "/api/lock":
                domain = str(payload.get("domain") or "")
                target_ref = str(payload.get("target_ref") or "*")
                if domain not in {"settings", "account_snapshot"}:
                    raise ValueError("domain must be settings or account_snapshot")
                with open_connection(self.db_path) as conn:
                    before = _lock_entry_from_conn(conn, domain, target_ref)
                    set_lock(conn, domain, target_ref, locked_by="ui", reason="manual lock")
                    after = _lock_entry_from_conn(conn, domain, target_ref)
                    record_change_log(
                        conn,
                        domain=domain,
                        action="lock",
                        target_ref=target_ref,
                        before_json=before,
                        after_json=after,
                        actor="ui",
                        note="manual lock",
                    )
                    conn.commit()
                _json_response(self, HTTPStatus.OK, {"domain": domain, "target_ref": target_ref, "status": "LOCKED"})
                return
            if parsed.path == "/api/unlock":
                domain = str(payload.get("domain") or "")
                target_ref = str(payload.get("target_ref") or "*")
                if domain not in {"settings", "account_snapshot"}:
                    raise ValueError("domain must be settings or account_snapshot")
                with open_connection(self.db_path) as conn:
                    before = _lock_entry_from_conn(conn, domain, target_ref)
                    clear_lock(conn, domain, target_ref)
                    after = _lock_entry_from_conn(conn, domain, target_ref)
                    record_change_log(
                        conn,
                        domain=domain,
                        action="unlock",
                        target_ref=target_ref,
                        before_json=before,
                        after_json=after,
                        actor="ui",
                        note="manual unlock",
                    )
                    conn.commit()
                _json_response(self, HTTPStatus.OK, {"domain": domain, "target_ref": target_ref, "status": "UNLOCKED"})
                return
            if parsed.path == "/api/undo":
                domain = str(payload.get("domain") or "")
                if domain not in {"settings", "account_snapshot"}:
                    raise ValueError("domain must be settings or account_snapshot")
                if is_locked(self.db_path, domain):
                    raise ValueError(f"{domain} is locked")
                with open_connection(self.db_path) as conn:
                    result = undo_last_change(conn, domain, actor="ui")
                _json_response(self, HTTPStatus.OK, result if result else {"domain": domain, "status": "UNDONE"})
                return
            if parsed.path == "/api/autofix":
                action_id = str(payload.get("action_id") or "")
                if not action_id:
                    raise ValueError("action_id required")
                with open_connection(self.db_path) as conn:
                    result = apply_safe_autofix_action(conn, action_id, actor="ui")
                _json_response(self, HTTPStatus.OK, result)
                return
            _json_response(self, HTTPStatus.NOT_FOUND, {"detail": "not found"})
        except Exception as exc:  # noqa: BLE001
            self._handle_exception(exc)


def serve(host: str, port: int, db_path: Path | str | None = None, seed_json_path: Path | str | None = None, bootstrap: bool = True) -> None:
    db = normalize_db_path(db_path)
    seed = Path(seed_json_path) if seed_json_path else DEFAULT_SEED_JSON
    if bootstrap and seed.exists():
        with open_connection(db) as conn:
            from .snapshot_admin_store_v1 import ensure_schema

            ensure_schema(conn)
        if summarize_workspace(db)["settings_rows"] == 0 and summarize_workspace(db)["account_snapshot_rows"] == 0:
            import_seed_json(db, seed)
    SnapshotAdminHandler.db_path = db
    SnapshotAdminHandler.seed_json_path = seed
    server = ThreadingHTTPServer((host, port), SnapshotAdminHandler)
    print(f"Snapshot Admin listening on http://{host}:{port}")
    print(f"SQLite DB: {db}")
    print(f"Seed JSON: {seed}")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.server_close()


def main() -> int:
    parser = argparse.ArgumentParser(description="Run the snapshot admin web server.")
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=8787)
    parser.add_argument("--db", type=Path, default=DEFAULT_DB)
    parser.add_argument("--seed", type=Path, default=DEFAULT_SEED_JSON)
    parser.add_argument("--no-bootstrap", action="store_true")
    args = parser.parse_args()
    serve(args.host, args.port, args.db, args.seed, bootstrap=not args.no_bootstrap)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
