from __future__ import annotations

import json
import re
import sqlite3
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from pathlib import Path
from typing import Any

import yaml


ROOT = Path(__file__).resolve().parents[2]
DEFAULT_DB = ROOT / "src" / "quant_engine" / "snapshot_admin.db"
DEFAULT_SEED_JSON = ROOT / "GatherTradingData.json"
KST = timezone(timedelta(hours=9))

SETTINGS_TABLE = "settings"
SNAPSHOT_TABLE = "account_snapshot"
CHANGE_LOG_TABLE = "workspace_change_log"
APPROVAL_TABLE = "workspace_approval_v2"
LOCK_TABLE = "workspace_lock"

ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS = [
    "captured_at",
    "account",
    "account_type",
    "ticker",
    "name",
    "holding_quantity",
    "available_quantity",
    "average_cost",
    "total_cost",
    "current_price",
    "market_value",
    "profit_loss",
    "return_pct",
    "immediate_cash",
    "settlement_cash_d2",
    "available_cash",
    "open_order_amount",
    "monthly_contribution_limit",
    "monthly_contribution_used",
    "parse_status",
    "user_confirmed",
    "stop_price",
    "highest_price_since_entry",
    "entry_date",
    "entry_stage",
    "position_type",
    "last_updated",
]

ALLOWED_PARSE_STATUS = {
    "CAPTURE_READ_OK",
    "CAPTURE_READ_FAILED",
    "CAPTURE_PROVIDED_BUT_NOT_HOLDINGS",
    "NOT_PROVIDED",
}

SETTINGS_SPEC_PATH = ROOT / "spec" / "18_settings_contract.yaml"
ACCOUNT_SNAPSHOT_SPEC_PATH = ROOT / "spec" / "15_account_snapshot_contract.yaml"


def now_kst_iso() -> str:
    return datetime.now(tz=KST).isoformat(timespec="seconds")


def parse_scalar(value: str) -> Any:
    text = value.strip()
    if text == "":
        return ""
    if text.lower() in {"null", "none"}:
        return None
    if text.lower() in {"true", "false"}:
        return text.lower() == "true"
    try:
        return json.loads(text)
    except Exception:
        return text


def _json_dump(value: Any) -> str:
    return json.dumps(value, ensure_ascii=False)


def _json_load(text: str) -> Any:
    try:
        return json.loads(text)
    except Exception:
        return text


def normalize_db_path(db_path: Path | str | None = None) -> Path:
    path = Path(db_path) if db_path else DEFAULT_DB
    path.parent.mkdir(parents=True, exist_ok=True)
    return path


def open_connection(db_path: Path | str | None = None) -> sqlite3.Connection:
    conn = sqlite3.connect(normalize_db_path(db_path))
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA foreign_keys = ON")
    conn.execute("PRAGMA journal_mode = WAL")
    return conn


def ensure_schema(conn: sqlite3.Connection) -> None:
    conn.execute(
        f"""
        CREATE TABLE IF NOT EXISTS {SETTINGS_TABLE} (
            ordinal INTEGER NOT NULL,
            key TEXT PRIMARY KEY,
            value_json TEXT NOT NULL,
            note TEXT NOT NULL DEFAULT '',
            updated_at TEXT NOT NULL
        )
        """
    )
    conn.execute(
        f"""
        CREATE TABLE IF NOT EXISTS {SNAPSHOT_TABLE} (
            ordinal INTEGER NOT NULL,
            row_json TEXT NOT NULL,
            captured_at TEXT NOT NULL DEFAULT '',
            account TEXT NOT NULL DEFAULT '',
            account_type TEXT NOT NULL DEFAULT '',
            ticker TEXT NOT NULL DEFAULT '',
            name TEXT NOT NULL DEFAULT '',
            parse_status TEXT NOT NULL DEFAULT '',
            user_confirmed TEXT NOT NULL DEFAULT '',
            updated_at TEXT NOT NULL
        )
        """
    )
    conn.execute(
        f"CREATE INDEX IF NOT EXISTS idx_{SNAPSHOT_TABLE}_captured_at ON {SNAPSHOT_TABLE}(captured_at)"
    )
    conn.execute(
        f"CREATE INDEX IF NOT EXISTS idx_{SNAPSHOT_TABLE}_ticker ON {SNAPSHOT_TABLE}(ticker)"
    )
    conn.execute(
        "CREATE TABLE IF NOT EXISTS workspace_meta (key TEXT PRIMARY KEY, value_json TEXT NOT NULL)"
    )
    conn.execute(
        f"""
        CREATE TABLE IF NOT EXISTS {CHANGE_LOG_TABLE} (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            domain TEXT NOT NULL,
            action TEXT NOT NULL,
            target_ref TEXT NOT NULL DEFAULT '',
            actor TEXT NOT NULL DEFAULT 'system',
            note TEXT NOT NULL DEFAULT '',
            before_json TEXT NOT NULL DEFAULT 'null',
            after_json TEXT NOT NULL DEFAULT 'null',
            created_at TEXT NOT NULL
        )
        """
    )
    conn.execute(
        f"""
        CREATE TABLE IF NOT EXISTS {APPROVAL_TABLE} (
            domain TEXT NOT NULL,
            target_ref TEXT NOT NULL DEFAULT '*',
            status TEXT NOT NULL,
            approved_by TEXT NOT NULL DEFAULT '',
            approved_at TEXT NOT NULL DEFAULT '',
            note TEXT NOT NULL DEFAULT '',
            updated_at TEXT NOT NULL,
            PRIMARY KEY (domain, target_ref)
        )
        """
    )
    conn.execute(
        f"""
        CREATE TABLE IF NOT EXISTS {LOCK_TABLE} (
            domain TEXT NOT NULL,
            target_ref TEXT NOT NULL DEFAULT '',
            locked_by TEXT NOT NULL DEFAULT '',
            reason TEXT NOT NULL DEFAULT '',
            locked_at TEXT NOT NULL,
            PRIMARY KEY (domain, target_ref)
        )
        """
    )
    conn.commit()


def _normalize_settings_rows(settings: Any) -> list[dict[str, Any]]:
    if isinstance(settings, list):
        rows: list[dict[str, Any]] = []
        for idx, item in enumerate(settings, start=1):
            if isinstance(item, dict) and "key" in item:
                rows.append(
                    {
                        "ordinal": int(item.get("ordinal") or idx),
                        "key": str(item.get("key") or ""),
                        "value": item.get("value", ""),
                        "note": str(item.get("note") or ""),
                    }
                )
        return rows
    if isinstance(settings, dict):
        rows = []
        for idx, (key, value) in enumerate(settings.items(), start=1):
            rows.append({"ordinal": idx, "key": str(key), "value": value, "note": ""})
        return rows
    return []


def _normalize_snapshot_rows(rows: Any) -> list[dict[str, Any]]:
    if not isinstance(rows, list):
        return []
    normalized: list[dict[str, Any]] = []
    for idx, item in enumerate(rows, start=1):
        if isinstance(item, dict):
            row = dict(item)
            row.setdefault("ordinal", idx)
            normalized.append(row)
    return normalized


def seed_payload_from_json(json_path: Path | str) -> dict[str, Any]:
    payload = json.loads(Path(json_path).read_text(encoding="utf-8"))
    data = payload.get("data") if isinstance(payload, dict) else None
    if not isinstance(data, dict):
        data = payload if isinstance(payload, dict) else {}
    settings = _normalize_settings_rows(data.get("settings"))
    account_snapshot = _normalize_snapshot_rows(data.get("account_snapshot"))
    return {
        "meta": payload.get("meta") if isinstance(payload, dict) else {},
        "settings": settings,
        "account_snapshot": account_snapshot,
    }


def replace_settings(conn: sqlite3.Connection, rows: list[dict[str, Any]]) -> None:
    ensure_schema(conn)
    errors = validate_settings_rows(rows)
    if errors:
        raise ValueError("; ".join(errors))
    old_rows = load_settings_rows_from_conn(conn)
    conn.execute(f"DELETE FROM {SETTINGS_TABLE}")
    for idx, row in enumerate(rows, start=1):
        key = str(row.get("key") or "").strip()
        if not key:
            continue
        conn.execute(
            f"""
            INSERT INTO {SETTINGS_TABLE} (ordinal, key, value_json, note, updated_at)
            VALUES (?, ?, ?, ?, ?)
            """,
            (
                int(row.get("ordinal") or idx),
                key,
                _json_dump(row.get("value", "")),
                str(row.get("note") or ""),
                now_kst_iso(),
            ),
        )
    record_change_log(
        conn,
        domain=SETTINGS_TABLE,
        action="replace",
        before_json=old_rows,
        after_json=rows,
        target_ref="*",
        note="settings replace",
    )
    set_approval(conn, SETTINGS_TABLE, "PENDING", note="settings updated")
    conn.commit()


def replace_account_snapshot(conn: sqlite3.Connection, rows: list[dict[str, Any]]) -> None:
    ensure_schema(conn)
    errors = validate_account_snapshot_rows(rows)
    if errors:
        raise ValueError("; ".join(errors))
    old_rows = load_account_snapshot_rows_from_conn(conn)
    conn.execute(f"DELETE FROM {SNAPSHOT_TABLE}")
    for idx, row in enumerate(rows, start=1):
        normalized = dict(row)
        ordinal = int(normalized.pop("ordinal", idx) or idx)
        captured_at = str(normalized.get("captured_at") or "")
        account = str(normalized.get("account") or "")
        account_type = str(normalized.get("account_type") or "")
        ticker = str(normalized.get("ticker") or "")
        name = str(normalized.get("name") or "")
        parse_status = str(normalized.get("parse_status") or "")
        user_confirmed = str(normalized.get("user_confirmed") or "")
        conn.execute(
            f"""
            INSERT INTO {SNAPSHOT_TABLE} (
                ordinal, row_json, captured_at, account, account_type, ticker, name,
                parse_status, user_confirmed, updated_at
            )
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                ordinal,
                _json_dump(normalized),
                captured_at,
                account,
                account_type,
                ticker,
                name,
                parse_status,
                user_confirmed,
                now_kst_iso(),
            ),
        )
    record_change_log(
        conn,
        domain=SNAPSHOT_TABLE,
        action="replace",
        before_json=old_rows,
        after_json=rows,
        target_ref="*",
        note="account_snapshot replace",
    )
    set_approval(conn, SNAPSHOT_TABLE, "PENDING", note="account_snapshot updated")
    conn.commit()


def import_seed_json(db_path: Path | str | None, json_path: Path | str) -> dict[str, Any]:
    payload = seed_payload_from_json(json_path)
    with open_connection(db_path) as conn:
        replace_settings(conn, payload["settings"])
        replace_account_snapshot(conn, payload["account_snapshot"])
        conn.execute(
            "INSERT OR REPLACE INTO workspace_meta(key, value_json) VALUES (?, ?)",
            ("seed_json_path", _json_dump(str(Path(json_path).resolve()))),
        )
        conn.execute(
            "INSERT OR REPLACE INTO workspace_meta(key, value_json) VALUES (?, ?)",
            ("seeded_at", _json_dump(now_kst_iso())),
        )
        conn.commit()
    return summarize_workspace(db_path)


def load_settings_rows(db_path: Path | str | None = None) -> list[dict[str, Any]]:
    with open_connection(db_path) as conn:
        return load_settings_rows_from_conn(conn)


def load_settings_rows_from_conn(conn: sqlite3.Connection) -> list[dict[str, Any]]:
    ensure_schema(conn)
    rows = conn.execute(
        f"SELECT ordinal, key, value_json, note, updated_at FROM {SETTINGS_TABLE} ORDER BY ordinal ASC, key ASC"
    ).fetchall()
    return [
        {
            "ordinal": int(row["ordinal"]),
            "key": row["key"],
            "value": _json_load(row["value_json"]),
            "note": row["note"],
            "updated_at": row["updated_at"],
        }
        for row in rows
    ]


def load_account_snapshot_rows(db_path: Path | str | None = None) -> list[dict[str, Any]]:
    with open_connection(db_path) as conn:
        return load_account_snapshot_rows_from_conn(conn)


def load_account_snapshot_rows_from_conn(conn: sqlite3.Connection) -> list[dict[str, Any]]:
    ensure_schema(conn)
    rows = conn.execute(
        f"""
        SELECT ordinal, row_json, captured_at, account, account_type, ticker, name,
               parse_status, user_confirmed, updated_at
        FROM {SNAPSHOT_TABLE}
        ORDER BY ordinal ASC
        """
    ).fetchall()
    loaded: list[dict[str, Any]] = []
    for row in rows:
        payload = _json_load(row["row_json"])
        item = payload if isinstance(payload, dict) else {}
        item.setdefault("captured_at", row["captured_at"])
        item.setdefault("account", row["account"])
        item.setdefault("account_type", row["account_type"])
        item.setdefault("ticker", row["ticker"])
        item.setdefault("name", row["name"])
        item.setdefault("parse_status", row["parse_status"])
        item.setdefault("user_confirmed", row["user_confirmed"])
        item["_ordinal"] = int(row["ordinal"])
        item["_updated_at"] = row["updated_at"]
        loaded.append(item)
    return loaded


def export_payload(db_path: Path | str | None = None) -> dict[str, Any]:
    settings_rows = load_settings_rows(db_path)
    settings = {row["key"]: row["value"] for row in settings_rows}
    account_snapshot = load_account_snapshot_rows(db_path)
    return {
        "meta": {
            "generated_at": now_kst_iso(),
            "source_db": str(normalize_db_path(db_path)),
        },
        "data": {
            "settings": settings,
            "account_snapshot": account_snapshot,
        },
    }


def write_export_json(db_path: Path | str | None, output_path: Path | str) -> Path:
    payload = export_payload(db_path)
    output = Path(output_path)
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
    return output


def load_meta(db_path: Path | str | None = None) -> dict[str, Any]:
    with open_connection(db_path) as conn:
        ensure_schema(conn)
        rows = conn.execute("SELECT key, value_json FROM workspace_meta ORDER BY key ASC").fetchall()
    return {row["key"]: _json_load(row["value_json"]) for row in rows}


def record_change_log(
    conn: sqlite3.Connection,
    *,
    domain: str,
    action: str,
    before_json: Any,
    after_json: Any,
    target_ref: str = "",
    actor: str = "ui",
    note: str = "",
) -> None:
    ensure_schema(conn)
    conn.execute(
        f"""
        INSERT INTO {CHANGE_LOG_TABLE} (
            domain, action, target_ref, actor, note, before_json, after_json, created_at
        )
        VALUES (?, ?, ?, ?, ?, ?, ?, ?)
        """,
        (
            domain,
            action,
            target_ref,
            actor,
            note,
            _json_dump(before_json),
            _json_dump(after_json),
            now_kst_iso(),
        ),
    )


def set_approval(
    conn: sqlite3.Connection,
    domain: str,
    status: str,
    *,
    target_ref: str = "*",
    approved_by: str = "",
    note: str = "",
) -> None:
    ensure_schema(conn)
    conn.execute(
        f"""
        INSERT INTO {APPROVAL_TABLE} (domain, target_ref, status, approved_by, approved_at, note, updated_at)
        VALUES (?, ?, ?, ?, ?, ?, ?)
        ON CONFLICT(domain, target_ref) DO UPDATE SET
            status=excluded.status,
            approved_by=excluded.approved_by,
            approved_at=excluded.approved_at,
            note=excluded.note,
            updated_at=excluded.updated_at
        """,
        (
            domain,
            target_ref or "*",
            status,
            approved_by,
            now_kst_iso() if status == "APPROVED" else "",
            note,
            now_kst_iso(),
        ),
    )


def load_approval_rows(db_path: Path | str | None = None) -> list[dict[str, Any]]:
    with open_connection(db_path) as conn:
        ensure_schema(conn)
        rows = conn.execute(
            f"SELECT domain, target_ref, status, approved_by, approved_at, note, updated_at FROM {APPROVAL_TABLE} ORDER BY domain ASC, target_ref ASC"
        ).fetchall()
    return [dict(row) for row in rows]


def load_approval_entry(db_path: Path | str | None, domain: str, target_ref: str = "*") -> dict[str, Any] | None:
    with open_connection(db_path) as conn:
        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 load_change_log_rows(db_path: Path | str | None = None, limit: int = 20) -> list[dict[str, Any]]:
    with open_connection(db_path) as conn:
        ensure_schema(conn)
        rows = conn.execute(
            f"""
            SELECT id, domain, action, target_ref, actor, note, before_json, after_json, created_at
            FROM {CHANGE_LOG_TABLE}
            ORDER BY id DESC
            LIMIT ?
            """,
            (int(limit),),
        ).fetchall()
    items = []
    for row in rows:
        items.append(
            {
                "id": int(row["id"]),
                "domain": row["domain"],
                "action": row["action"],
                "target_ref": row["target_ref"],
                "actor": row["actor"],
                "note": row["note"],
                "before_json": _json_load(row["before_json"]),
                "after_json": _json_load(row["after_json"]),
                "created_at": row["created_at"],
            }
        )
    return items


def load_last_change_row(conn: sqlite3.Connection, domain: str) -> dict[str, Any] | None:
    ensure_schema(conn)
    row = conn.execute(
        f"""
        SELECT id, domain, action, target_ref, actor, note, before_json, after_json, created_at
        FROM {CHANGE_LOG_TABLE}
        WHERE domain = ?
        ORDER BY id DESC
        LIMIT 1
        """,
        (domain,),
    ).fetchone()
    if row is None:
        return None
    return {
        "id": int(row["id"]),
        "domain": row["domain"],
        "action": row["action"],
        "target_ref": row["target_ref"],
        "actor": row["actor"],
        "note": row["note"],
        "before_json": _json_load(row["before_json"]),
        "after_json": _json_load(row["after_json"]),
        "created_at": row["created_at"],
    }


def set_lock(conn: sqlite3.Connection, domain: str, target_ref: str, *, locked_by: str, reason: str) -> None:
    ensure_schema(conn)
    conn.execute(
        f"""
        INSERT INTO {LOCK_TABLE} (domain, target_ref, locked_by, reason, locked_at)
        VALUES (?, ?, ?, ?, ?)
        ON CONFLICT(domain, target_ref) DO UPDATE SET
            locked_by=excluded.locked_by,
            reason=excluded.reason,
            locked_at=excluded.locked_at
        """,
        (domain, target_ref, locked_by, reason, now_kst_iso()),
    )


def clear_lock(conn: sqlite3.Connection, domain: str, target_ref: str) -> None:
    ensure_schema(conn)
    conn.execute(
        f"DELETE FROM {LOCK_TABLE} WHERE domain = ? AND target_ref = ?",
        (domain, target_ref),
    )


def load_locks(db_path: Path | str | None = None) -> list[dict[str, Any]]:
    with open_connection(db_path) as conn:
        ensure_schema(conn)
        rows = conn.execute(
            f"SELECT domain, target_ref, locked_by, reason, locked_at FROM {LOCK_TABLE} ORDER BY domain ASC, target_ref ASC"
        ).fetchall()
    return [dict(row) for row in rows]


def load_lock_entry(db_path: Path | str | None, domain: str, target_ref: str = "*") -> dict[str, Any] | None:
    with open_connection(db_path) as conn:
        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 is_locked(db_path: Path | str | None, domain: str, target_ref: str = "*") -> bool:
    with open_connection(db_path) as conn:
        ensure_schema(conn)
        row = conn.execute(
            f"SELECT 1 FROM {LOCK_TABLE} WHERE domain = ? AND target_ref IN (?, '*') LIMIT 1",
            (domain, target_ref),
        ).fetchone()
    return row is not None


def lock_conflicts_for_rows(
    db_path: Path | str | None,
    domain: str,
    rows: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    with open_connection(db_path) as conn:
        ensure_schema(conn)
        locks = conn.execute(
            f"SELECT domain, target_ref, locked_by, reason, locked_at FROM {LOCK_TABLE} WHERE domain = ? ORDER BY target_ref ASC",
            (domain,),
        ).fetchall()
    if not locks:
        return []
    row_refs: list[str] = []
    for idx, row in enumerate(rows, start=1):
        if domain == SETTINGS_TABLE:
            ref = str(row.get("_row_ref") or "").strip() or str(row.get("key") or "").strip()
        elif domain == SNAPSHOT_TABLE:
            ref = str(row.get("_row_ref") or "").strip()
            if not ref:
                ordinal = str(row.get("_ordinal") or row.get("ordinal") or idx).strip()
                ref = f"row:{ordinal}"
        else:
            ref = str(row.get("target_ref") or "").strip()
        if ref:
            row_refs.append(ref)
        if domain == SETTINGS_TABLE:
            key = str(row.get("key") or "").strip()
            if key:
                row_refs.append(key)
        if domain == SNAPSHOT_TABLE:
            ticker = str(row.get("ticker") or "").strip()
            if ticker:
                row_refs.append(ticker)
    conflicts: list[dict[str, Any]] = []
    for lock in locks:
        target_ref = str(lock["target_ref"] or "").strip()
        if target_ref == "*" or target_ref in row_refs:
            conflicts.append(dict(lock))
    return conflicts


def undo_last_change(conn: sqlite3.Connection, domain: str, *, actor: str = "ui") -> dict[str, Any]:
    ensure_schema(conn)
    last = load_last_change_row(conn, domain)
    if not last:
        raise ValueError(f"no change log for domain={domain}")
    before_json = last.get("before_json")
    if domain == SETTINGS_TABLE:
        rows = before_json if isinstance(before_json, list) else []
        replace_settings(conn, rows)
    elif domain == SNAPSHOT_TABLE:
        rows = before_json if isinstance(before_json, list) else []
        replace_account_snapshot(conn, rows)
    else:
        raise ValueError(f"unsupported domain={domain}")
    record_change_log(
        conn,
        domain=domain,
        action="undo",
        before_json=last.get("after_json"),
        after_json=before_json,
        target_ref=last.get("target_ref", "*"),
        actor=actor,
        note=f"undo change #{last['id']}",
    )
    conn.commit()
    return load_last_change_row(conn, domain) or {}


def load_approval_for_domain(db_path: Path | str | None, domain: str) -> dict[str, Any]:
    with open_connection(db_path) as conn:
        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 = '*'
            """,
            (domain,),
        ).fetchone()
    return (
        dict(row)
        if row
        else {"domain": domain, "target_ref": "*", "status": "MISSING", "approved_by": "", "approved_at": "", "note": "", "updated_at": ""}
    )


def summarize_workspace(db_path: Path | str | None = None) -> dict[str, Any]:
    with open_connection(db_path) as conn:
        ensure_schema(conn)
        settings_count = conn.execute(f"SELECT COUNT(*) FROM {SETTINGS_TABLE}").fetchone()[0]
        snapshot_count = conn.execute(f"SELECT COUNT(*) FROM {SNAPSHOT_TABLE}").fetchone()[0]
        latest_update = conn.execute(
            f"""
            SELECT MAX(latest_ts)
            FROM (
                SELECT updated_at as latest_ts FROM {SETTINGS_TABLE}
                UNION ALL
                SELECT captured_at FROM {SNAPSHOT_TABLE}
            )
            """
            ).fetchone()[0]
        table_rows = conn.execute(
            "SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?, ?, ?, ?)",
            (SETTINGS_TABLE, SNAPSHOT_TABLE, CHANGE_LOG_TABLE, APPROVAL_TABLE, LOCK_TABLE),
        ).fetchall()
    tables = sorted(row[0] for row in table_rows)
    workspace_db = str(normalize_db_path(db_path))
    return {
        "db_path": workspace_db,
        "settings_rows": int(settings_count),
        "account_snapshot_rows": int(snapshot_count),
        "latest_update": latest_update or "",
        "tables": tables,
        "topology": {
            "mode": "single_workspace_sqlite",
            "workspace_db": workspace_db,
            "collector_db": str(ROOT / "src" / "quant_engine" / "kis_data_collection.db"),
            "settings_and_snapshot_share_db": True,
            "collector_separate_db": True,
        },
        "meta": load_meta(db_path),
    }


def parse_account_snapshot_tsv(tsv_text: str) -> list[dict[str, Any]]:
    lines = [line.rstrip("\r") for line in tsv_text.splitlines() if line.strip() != ""]
    if not lines:
        return []
    rows: list[list[str]] = [line.split("\t") for line in lines]
    first_row = rows[0]
    if first_row == ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS:
        data_rows = rows[1:]
    elif set(first_row) >= {"captured_at", "account", "ticker"}:
        header = first_row
        data_rows = rows[1:]
        converted: list[dict[str, Any]] = []
        for idx, row in enumerate(data_rows, start=1):
            item: dict[str, Any] = {"ordinal": idx}
            for col_index, column in enumerate(header):
                value = row[col_index] if col_index < len(row) else ""
                item[column] = parse_scalar(value)
            converted.append(item)
        return converted
    else:
        data_rows = rows
    converted = []
    for idx, row in enumerate(data_rows, start=1):
        item: dict[str, Any] = {"ordinal": idx}
        for col_index, column in enumerate(ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS):
            value = row[col_index] if col_index < len(row) else ""
            item[column] = parse_scalar(value)
        converted.append(item)
    return converted


def settings_rows_to_dict(rows: list[dict[str, Any]]) -> dict[str, Any]:
    result: dict[str, Any] = {}
    for row in rows:
        key = str(row.get("key") or "").strip()
        if key:
            result[key] = row.get("value", "")
    return result


def _as_number(value: Any) -> float | None:
    if value is None:
        return None
    if isinstance(value, bool):
        return None
    if isinstance(value, (int, float)):
        return float(value)
    text = str(value).strip()
    if not text:
        return None
    try:
        return float(text)
    except Exception:
        return None


def _as_int(value: Any) -> int | None:
    if value is None or value == "":
        return None
    if isinstance(value, bool):
        return None
    if isinstance(value, int):
        return value
    if isinstance(value, float):
        return int(value) if value.is_integer() else None
    try:
        text = str(value).strip().replace(",", "")
        if not text:
            return None
        parsed = float(text)
        return int(parsed) if parsed.is_integer() else None
    except Exception:
        return None


@lru_cache(maxsize=1)
def _load_settings_spec() -> dict[str, Any]:
    return yaml.safe_load(SETTINGS_SPEC_PATH.read_text(encoding="utf-8")) or {}


@lru_cache(maxsize=1)
def _load_account_snapshot_spec() -> dict[str, Any]:
    return yaml.safe_load(ACCOUNT_SNAPSHOT_SPEC_PATH.read_text(encoding="utf-8")) or {}


def validate_settings_rows(rows: list[dict[str, Any]]) -> list[str]:
    errors: list[str] = []
    spec = _load_settings_spec().get("required_keys") or {}
    optional_spec = _load_settings_spec().get("optional_keys") or {}
    seen: set[str] = set()
    total_asset_found = False
    for idx, row in enumerate(rows, start=1):
        key = str(row.get("key") or "").strip()
        if not key:
            errors.append(f"settings row {idx}: missing key")
            continue
        if key in seen:
            errors.append(f"settings row {idx}: duplicate key {key}")
        seen.add(key)
        value = row.get("value", "")
        if key == "total_asset_krw":
            total_asset_found = True
            amount = _as_number(value)
            if amount is None or amount <= 0:
                errors.append("settings.total_asset_krw must be positive number")
        if key in {"weekly_target_cash_pct", "fc_budget_pct_override"}:
            pct = _as_number(value)
            if pct is None or pct < 0:
                errors.append(f"settings.{key} must be non-negative number")
        if key in spec and spec[key].get("type") == "string":
            if value is not None and not isinstance(value, str):
                errors.append(f"settings.{key} must be string")
        if key in optional_spec and optional_spec[key].get("format") == "YYYY-MM":
            text = str(value).strip()
            if text and not re.fullmatch(r"\d{4}-\d{2}(-.*)?", text):
                errors.append(f"settings.{key} must use YYYY-MM")
    if not total_asset_found:
        errors.append("settings.total_asset_krw is required")
    return errors


def validate_account_snapshot_rows(rows: list[dict[str, Any]]) -> list[str]:
    errors: list[str] = []
    spec = _load_account_snapshot_spec().get("account_snapshot_contract") or {}
    canonical = spec.get("canonical_fields") or {}
    for idx, row in enumerate(rows, start=1):
        captured_at = str(row.get("captured_at") or "").strip()
        account = str(row.get("account") or "").strip()
        ticker = str(row.get("ticker") or "").strip()
        name = str(row.get("name") or "").strip()
        account_type = str(row.get("account_type") or "").strip()
        parse_status = str(row.get("parse_status") or "").strip()
        holding_quantity = _as_int(row.get("holding_quantity"))
        available_quantity = _as_int(row.get("available_quantity"))
        average_cost = _as_number(row.get("average_cost"))
        total_cost = _as_number(row.get("total_cost"))
        current_price = _as_number(row.get("current_price"))
        market_value = _as_number(row.get("market_value"))
        profit_loss = _as_number(row.get("profit_loss"))
        return_pct = _as_number(row.get("return_pct"))
        stop_price = _as_number(row.get("stop_price"))
        entry_stage = str(row.get("entry_stage") or "").strip()
        position_type = str(row.get("position_type") or "").strip()
        user_confirmed = str(row.get("user_confirmed") or "").strip().upper()
        if not captured_at:
            errors.append(f"account_snapshot row {idx}: captured_at required")
        if not account:
            errors.append(f"account_snapshot row {idx}: account required")
        if not account_type:
            errors.append(f"account_snapshot row {idx}: account_type required")
        if account_type and canonical.get("account_type", {}).get("allowed") and account_type not in canonical["account_type"]["allowed"]:
            errors.append(f"account_snapshot row {idx}: invalid account_type {account_type!r}")
        if not ticker and name != "예수금/D+2현금":
            errors.append(f"account_snapshot row {idx}: ticker required")
        if not name:
            errors.append(f"account_snapshot row {idx}: name required")
        if parse_status not in ALLOWED_PARSE_STATUS:
            errors.append(f"account_snapshot row {idx}: invalid parse_status {parse_status!r}")
        if holding_quantity is not None and holding_quantity < 0:
            errors.append(f"account_snapshot row {idx}: holding_quantity must be >= 0")
        if available_quantity is not None and available_quantity < 0:
            errors.append(f"account_snapshot row {idx}: available_quantity must be >= 0")
        if average_cost is not None and average_cost < 0:
            errors.append(f"account_snapshot row {idx}: average_cost must be >= 0")
        if total_cost is not None and total_cost < 0:
            errors.append(f"account_snapshot row {idx}: total_cost must be >= 0")
        if current_price is not None and current_price < 0:
            errors.append(f"account_snapshot row {idx}: current_price must be >= 0")
        if market_value is not None and market_value < 0:
            errors.append(f"account_snapshot row {idx}: market_value must be >= 0")
        if profit_loss is not None and profit_loss != profit_loss:
            errors.append(f"account_snapshot row {idx}: profit_loss invalid")
        if return_pct is not None and abs(return_pct) > 1000:
            errors.append(f"account_snapshot row {idx}: return_pct out of range")
        if stop_price is not None and stop_price < 0:
            errors.append(f"account_snapshot row {idx}: stop_price must be >= 0")
        if user_confirmed and user_confirmed not in {"Y", "N"}:
            errors.append(f"account_snapshot row {idx}: user_confirmed must be Y or N")
        if parse_status == "CAPTURE_READ_OK" and user_confirmed != "Y":
            errors.append(f"account_snapshot row {idx}: CAPTURE_READ_OK rows require user_confirmed=Y")
        if entry_stage and canonical.get("entry_stage", {}).get("allowed") and entry_stage not in canonical["entry_stage"]["allowed"]:
            errors.append(f"account_snapshot row {idx}: invalid entry_stage {entry_stage!r}")
        if position_type and canonical.get("position_type", {}).get("allowed") and position_type not in canonical["position_type"]["allowed"]:
            errors.append(f"account_snapshot row {idx}: invalid position_type {position_type!r}")
    return errors


def build_validation_suggestions(settings_rows: list[dict[str, Any]], snapshot_rows: list[dict[str, Any]]) -> list[str]:
    suggestions: list[str] = []
    settings_map = settings_rows_to_dict(settings_rows)
    snapshot_count = len(snapshot_rows)
    if "total_asset_krw" not in settings_map:
        suggestions.append("settings: add total_asset_krw from current investable asset total")
    if str(settings_map.get("weekly_target_cash_pct", "")).strip() == "":
        suggestions.append("settings: weekly_target_cash_pct can stay blank unless weekly rebalance is active")
    for row in snapshot_rows:
        if str(row.get("parse_status") or "").strip() == "CAPTURE_READ_OK" and str(row.get("user_confirmed") or "").strip().upper() != "Y":
            suggestions.append(
                f"account_snapshot {row.get('ticker') or row.get('name') or 'row'}: set user_confirmed=Y for CAPTURE_READ_OK"
            )
        account_type = str(row.get("account_type") or "").strip()
        if account_type and account_type not in {"일반계좌", "ISA", "연금저축"}:
            suggestions.append(
                f"account_snapshot {row.get('ticker') or row.get('name') or 'row'}: account_type should be one of 일반계좌/ISA/연금저축"
            )
        if str(row.get("entry_stage") or "").strip() and str(row.get("position_type") or "").strip() == "":
            suggestions.append(
                f"account_snapshot {row.get('ticker') or row.get('name') or 'row'}: consider setting position_type when entry_stage is present"
            )
    if not snapshot_rows:
        suggestions.append("account_snapshot: import TSV from HTS capture before saving snapshot")
    return suggestions[:20]


def build_safe_autofix_actions(settings_rows: list[dict[str, Any]], snapshot_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    actions: list[dict[str, Any]] = []
    if any(str(row.get("parse_status") or "").strip() == "CAPTURE_READ_OK" and str(row.get("user_confirmed") or "").strip().upper() != "Y" for row in snapshot_rows):
        actions.append(
            {
                "action_id": "confirm_captured_rows",
                "domain": "account_snapshot",
                "label": "Set user_confirmed=Y for CAPTURE_READ_OK rows",
                "description": "Safe autofix using the contract default confirmation flag.",
            }
        )
    if any(str(row.get("position_type") or "").strip() == "" and str(row.get("entry_stage") or "").strip() for row in snapshot_rows):
        actions.append(
            {
                "action_id": "default_position_type_satellite",
                "domain": "account_snapshot",
                "label": "Default blank position_type to satellite",
                "description": "Uses the contract default when position_type is missing.",
            }
        )
    if not any(str(row.get("key") or "").strip() == "total_asset_krw" for row in settings_rows):
        actions.append(
            {
                "action_id": "required_total_asset_missing",
                "domain": "settings",
                "label": "Settings total_asset_krw missing",
                "description": "Manual input required. No safe autofix.",
            }
        )
    return actions


def apply_safe_autofix_action(
    conn: sqlite3.Connection,
    action_id: str,
    *,
    actor: str = "ui",
) -> dict[str, Any]:
    ensure_schema(conn)
    snapshot_rows = load_account_snapshot_rows_from_conn(conn)
    if action_id == "confirm_captured_rows":
        updated = []
        for row in snapshot_rows:
            candidate = dict(row)
            if str(candidate.get("parse_status") or "").strip() == "CAPTURE_READ_OK" and str(candidate.get("user_confirmed") or "").strip().upper() != "Y":
                candidate["user_confirmed"] = "Y"
            updated.append(candidate)
        replace_account_snapshot(conn, updated)
        return {"domain": SNAPSHOT_TABLE, "status": "AUTOFIXED", "action_id": action_id}
    if action_id == "default_position_type_satellite":
        updated = []
        for row in snapshot_rows:
            candidate = dict(row)
            if str(candidate.get("entry_stage") or "").strip() and str(candidate.get("position_type") or "").strip() == "":
                candidate["position_type"] = "satellite"
            updated.append(candidate)
        replace_account_snapshot(conn, updated)
        return {"domain": SNAPSHOT_TABLE, "status": "AUTOFIXED", "action_id": action_id}
    if action_id == "required_total_asset_missing":
        return {"domain": SETTINGS_TABLE, "status": "MANUAL_REQUIRED", "action_id": action_id}
    raise ValueError(f"unknown action_id={action_id}")
