"""Pure snapshot loading logic, shared by CLI and MCP server.

No click/rich/display dependencies — raises standard exceptions on error.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any, cast

from ._debug_session import normalize_snapshot_view
from ._warnings import emit_warning
from .compact_keys import expand_keys, is_compact_snapshot
from .output import get_latest_snapshot
from .snapshot_types import SnapshotDict
from .state_db import list_snapshot_records, remove_snapshot_records, replace_snapshot_records


def _load_snapshot_file_raw(path: Path) -> SnapshotDict:
    """Load a snapshot file as raw expanded dict (no session normalization)."""
    if not path.exists():
        raise FileNotFoundError(f"File not found: {path}")

    if path.suffix == ".toon":
        try:
            from .toon_encoder import decode_snapshot_toon

            data = decode_snapshot_toon(path.read_text("utf-8"))
        except ImportError:
            raise
        except Exception as e:
            raise ValueError(f"Failed to decode TOON file {path}: {e}") from e
    else:
        data = json.loads(path.read_text("utf-8"))

    if isinstance(data, dict):
        data_dict = cast(dict[str, Any], data)
        if is_compact_snapshot(data_dict):
            expanded = expand_keys(data_dict)
            if isinstance(expanded, dict):
                cast(dict[str, Any], expanded).pop("__llmdebug_compact__", None)
                return cast(SnapshotDict, expanded)
        return cast(SnapshotDict, data_dict)
    raise ValueError(f"Expected dict in snapshot file {path}, got {type(data).__name__}")


def load_snapshot_from_file(path: Path) -> dict[str, Any]:
    """Load and auto-expand a single snapshot file.

    Raises:
        FileNotFoundError: If path doesn't exist.
        ImportError: If TOON format without toons package.
    """
    return normalize_snapshot_view(cast(dict[str, Any], _load_snapshot_file_raw(path)))


def load_snapshot_from_file_raw(path: Path) -> dict[str, Any]:
    """Load and auto-expand a single snapshot file without normalization."""
    return cast(dict[str, Any], _load_snapshot_file_raw(path))


def _validate_snapshot_path(path: Path, out_dir: str) -> None:
    """Ensure path is within the snapshot directory to prevent path traversal.

    Raises:
        ValueError: If path is outside the allowed directory.
    """
    try:
        resolved = path.resolve()
        allowed = Path(out_dir).resolve()
        resolved.relative_to(allowed)
    except ValueError:
        raise ValueError(f"Path {path} is outside snapshot directory {out_dir}") from None


def load_snapshot(path: str | None, out_dir: str, *, _trust_path: bool = False) -> dict[str, Any]:
    """Load from explicit path or fall back to latest.

    Args:
        path: Explicit path to snapshot file, or None for latest.
        out_dir: Snapshot directory.
        _trust_path: If False (default), validate that path is within out_dir.
            Set True only for trusted internal callers.

    Raises:
        FileNotFoundError: If no snapshots found.
        ValueError: If path is outside the snapshot directory.
    """
    if path is not None:
        p = Path(path)
        if not _trust_path:
            _validate_snapshot_path(p, out_dir)
        return load_snapshot_from_file(p)
    snapshot = get_latest_snapshot(out_dir)
    if snapshot is None:
        raise FileNotFoundError(f"No snapshots found in {out_dir}")
    return snapshot


def load_snapshot_raw(
    path: str | None, out_dir: str, *, _trust_path: bool = False
) -> dict[str, Any]:
    """Load a raw snapshot payload without DebugSession normalization."""
    if path is not None:
        p = Path(path)
        if not _trust_path:
            _validate_snapshot_path(p, out_dir)
        return load_snapshot_from_file_raw(p)
    snapshot = get_latest_snapshot(out_dir, normalize=False)
    if snapshot is None:
        raise FileNotFoundError(f"No snapshots found in {out_dir}")
    return snapshot


def _has_snapshot_files(out_path: Path) -> bool:
    try:
        for path in out_path.iterdir():
            if (
                path.is_file()
                and path.suffix in {".json", ".toon"}
                and not path.name.startswith("latest")
                and not path.name.endswith(".tmp")
            ):
                return True
    except OSError:
        return False
    return False


def _normalize_line_value(value: Any) -> Any:
    if isinstance(value, str):
        stripped = value.strip()
        if stripped.isdigit():
            return int(stripped)
    return value


def _is_within_out_dir(path: Path, out_dir: str) -> bool:
    try:
        path.resolve().relative_to(Path(out_dir).resolve())
    except ValueError:
        return False
    except OSError:
        return False
    return True


def _entries_from_index(
    out_dir: str,
    *,
    limit: int | None = None,
) -> tuple[list[dict[str, Any]], bool]:
    query_limit = None
    if limit is not None and limit > 0:
        query_limit = max(limit * 4, limit + 20)
    try:
        rows = list_snapshot_records(out_dir, limit=query_limit)
    except Exception:
        return [], False
    if not rows:
        return [], True

    stale_paths: list[str] = []
    entries: list[dict[str, Any]] = []
    for row in rows:
        path_value = str(row.get("path", "")).strip()
        if not path_value:
            continue
        path = Path(path_value)
        if not _is_within_out_dir(path, out_dir) or not path.exists() or not path.is_file():
            stale_paths.append(path_value)
            continue
        entries.append(
            {
                "path": path.resolve(),
                "mtime": float(row.get("mtime", 0.0)),
                "name": row.get("name", "?"),
                "exc_type": row.get("exc_type", "?"),
                "exc_message": row.get("exc_message", ""),
                "file": row.get("file", "?"),
                "line": _normalize_line_value(row.get("line", "?")),
            }
        )
        if limit is not None and limit > 0 and len(entries) >= limit:
            break
    if stale_paths:
        try:
            remove_snapshot_records(out_dir, stale_paths)
        except Exception as e:
            emit_warning(f"failed to prune stale snapshot index rows: {type(e).__name__}: {e}")
    return entries, True


def _scan_snapshot_entries(out_dir: str) -> tuple[list[dict[str, Any]], int]:
    out_path = Path(out_dir)
    candidates: list[tuple[float, Path]] = []
    try:
        for path in out_path.iterdir():
            if (
                not path.is_file()
                or path.suffix not in {".json", ".toon"}
                or path.name.startswith("latest")
                or path.name.endswith(".tmp")
            ):
                continue
            try:
                candidates.append((path.stat().st_mtime, path))
            except OSError:
                continue
    except OSError:
        return [], 0

    candidates.sort(key=lambda item: item[0], reverse=True)
    entries: list[dict[str, Any]] = []
    skipped = 0
    for mtime, path in candidates:
        try:
            data = load_snapshot_from_file(path)
            exc = cast(dict[str, Any], data.get("exception") or {})
            frames = cast(list[dict[str, Any]], data.get("frames") or [{}])
            fr0: dict[str, Any] = frames[0] if frames else {}
            entries.append(
                {
                    "path": path.resolve(),
                    "mtime": mtime,
                    "name": data.get("name", "?"),
                    "exc_type": exc.get("type", "?"),
                    "exc_message": (exc.get("message") or "")[:80],
                    "file": fr0.get("file_rel", fr0.get("file", "?")),
                    "line": fr0.get("line", "?"),
                }
            )
        except Exception:
            skipped += 1
            continue
    return entries, skipped


def find_all_snapshots(out_dir: str, *, limit: int | None = None) -> list[dict[str, Any]]:
    """Scan output dir, return snapshot metadata sorted newest-first.

    Args:
        out_dir: Snapshot output directory.
        limit: Optional max entries to return. If None, returns all.

    Returns [] if dir doesn't exist. Silently skips corrupted files.
    """
    out_path = Path(out_dir)
    if not out_path.is_dir():
        return []

    effective_limit = limit if limit is not None and limit > 0 else None

    indexed_entries, index_ok = _entries_from_index(out_dir, limit=effective_limit)
    if indexed_entries and (effective_limit is None or len(indexed_entries) >= effective_limit):
        return indexed_entries[:effective_limit] if effective_limit is not None else indexed_entries

    if index_ok and not indexed_entries and not _has_snapshot_files(out_path):
        return []

    # Fallback scan for index miss/corruption and rebuild index.
    entries, skipped = _scan_snapshot_entries(out_dir)
    try:
        replace_snapshot_records(
            out_dir,
            [
                {
                    "path": str(entry["path"].resolve()),
                    "mtime": float(entry["mtime"]),
                    "name": entry["name"],
                    "exc_type": entry["exc_type"],
                    "exc_message": entry["exc_message"],
                    "file": entry["file"],
                    "line": str(entry["line"]),
                }
                for entry in entries
            ],
        )
    except Exception as e:
        emit_warning(f"failed to refresh snapshot index: {type(e).__name__}: {e}")

    if skipped > 0:
        emit_warning(f"skipped {skipped} corrupted snapshot(s) in {out_dir}")

    if effective_limit is None:
        return entries
    if len(entries) <= effective_limit:
        return entries
    return entries[:effective_limit]


def resolve_snapshot_ref(
    ref: str | None,
    out_dir: str,
    all_snapshots: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
    """Resolve a snapshot reference to a loaded snapshot dict.

    Handles:
    - None / "latest" → most recent snapshot
    - "previous" → second most recent
    - "#N" → Nth snapshot from list (1-based)
    - file path → load directly

    Raises:
        FileNotFoundError: If the referenced snapshot doesn't exist.
        ValueError: If the reference is out of range.
    """
    if ref is None or ref == "latest":
        return load_snapshot(None, out_dir)

    if ref == "previous":
        snapshots = (
            all_snapshots if all_snapshots is not None else find_all_snapshots(out_dir, limit=2)
        )
        if len(snapshots) < 2:
            raise FileNotFoundError(
                f"Need at least 2 snapshots to use 'previous', "
                f"but only {len(snapshots)} found in {out_dir}"
            )
        return load_snapshot_from_file(snapshots[1]["path"])

    if ref.startswith("#"):
        try:
            n = int(ref[1:])
        except ValueError:
            raise ValueError(f"Invalid snapshot reference: {ref!r}") from None
        snapshots = (
            all_snapshots if all_snapshots is not None else find_all_snapshots(out_dir, limit=n)
        )
        if n < 1 or n > len(snapshots):
            raise ValueError(
                f"Snapshot #{n} not found. Only {len(snapshots)} available in {out_dir}"
            )
        return load_snapshot_from_file(snapshots[n - 1]["path"])

    # Treat as file path
    return load_snapshot(ref, out_dir)
