"""File output and latest.json management."""

from __future__ import annotations

import datetime as dt
import json
import os
import sys
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import timezone
from pathlib import Path
from typing import Any, cast

from filelock import FileLock, Timeout

from ._debug_session import normalize_snapshot_view
from .config import SnapshotConfig
from .state_db import remove_snapshot_records, upsert_snapshot_record


@contextmanager
def _directory_lock(out_dir: Path, timeout: float) -> Iterator[bool]:
    """Acquire lock for output directory operations.

    Yields True if lock acquired, False if timeout.
    """
    lock = FileLock(out_dir / ".llmdebug.lock", timeout=timeout)
    try:
        lock.acquire()
        yield True
    except Timeout:
        yield False
    finally:
        if lock.is_locked:
            lock.release()


def _timestamp() -> str:
    """Generate timestamp string."""
    return dt.datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")


def _update_latest_symlink(out_dir: Path, target_name: str, ext: str = ".json") -> None:
    """Update latest symlink/copy.

    Args:
        out_dir: Output directory
        target_name: Name of the target file
        ext: File extension for latest file (default: .json)
    """
    latest = out_dir / f"latest{ext}"
    target = out_dir / target_name

    # Clean up stale latest file from other format to avoid confusion
    opposite_ext = ".toon" if ext == ".json" else ".json"
    stale_latest = out_dir / f"latest{opposite_ext}"
    try:
        stale_latest.unlink(missing_ok=True)
    except Exception:  # nosec B110 — best-effort cleanup
        pass  # Best effort cleanup

    if sys.platform == "win32":
        # Windows: copy instead of symlink
        try:
            if latest.exists():
                latest.unlink()
            latest.write_text(target.read_text(encoding="utf-8"), encoding="utf-8")
        except Exception as e:
            sys.stderr.write(
                f"llmdebug: warning: could not update {latest.name}: {type(e).__name__}: {e}\n"
            )
    else:
        # POSIX: atomic symlink update (PID-unique temp avoids TOCTOU race)
        tmp = out_dir / f".latest{ext}.tmp.{os.getpid()}"
        try:
            tmp.symlink_to(target_name)
            tmp.rename(latest)
        except Exception as e:
            # Clean up partial temp file
            try:
                tmp.unlink(missing_ok=True)
            except Exception:  # nosec B110 — best-effort temp cleanup
                pass
            sys.stderr.write(
                f"llmdebug: warning: could not update {latest.name}: {type(e).__name__}: {e}\n"
            )


def _cleanup_old_snapshots(out_dir: Path, max_snapshots: int) -> None:
    """Remove old snapshots beyond the limit."""
    if max_snapshots <= 0:
        return  # Unlimited

    # Find all snapshot files (JSON and TOON), exclude latest.*
    snapshots = [
        p
        for p in out_dir.iterdir()
        if p.is_file()
        and p.suffix in {".json", ".toon"}
        and not p.name.startswith("latest")
        and not p.name.endswith(".tmp")
    ]

    if len(snapshots) <= max_snapshots:
        return

    # Sort by modification time (oldest first)
    snapshots.sort(key=lambda p: p.stat().st_mtime)

    # Delete oldest snapshots beyond the limit
    to_delete = snapshots[: len(snapshots) - max_snapshots]
    for snap in to_delete:
        try:
            snap.unlink()
            try:
                remove_snapshot_records(str(out_dir), [str(snap.resolve())])
            except Exception as index_error:
                sys.stderr.write(
                    "llmdebug: warning: snapshot index cleanup failed for "
                    f"{snap.name}: {type(index_error).__name__}: {index_error}\n"
                )
            # Also delete associated traceback file
            tb_file = snap.with_suffix("").with_suffix(".traceback.txt")
            if tb_file.exists():
                tb_file.unlink()
        except Exception as e:
            sys.stderr.write(
                f"llmdebug: warning: could not clean up {snap.name}: {type(e).__name__}: {e}\n"
            )


def _snapshot_index_entry(snapshot_path: Path, payload: dict[str, Any]) -> dict[str, Any]:
    """Build snapshot index metadata from an in-memory payload."""
    view = normalize_snapshot_view(payload)
    exc = cast(dict[str, Any], view.get("exception") or {})
    frames_raw = view.get("frames")
    frames: list[dict[str, Any]] = (
        cast(list[dict[str, Any]], frames_raw) if isinstance(frames_raw, list) else []
    )
    frame0: dict[str, Any] = frames[0] if frames else {}
    try:
        mtime = snapshot_path.stat().st_mtime
    except OSError:
        mtime = 0.0
    return {
        "path": str(snapshot_path.resolve()),
        "mtime": mtime,
        "name": view.get("name", "?"),
        "exc_type": exc.get("type", "?"),
        "exc_message": (exc.get("message") or "")[:80],
        "file": frame0.get("file_rel", frame0.get("file", "?")),
        "line": frame0.get("line", "?"),
    }


def write_bundle(payload: dict[str, Any], cfg: SnapshotConfig) -> Path:
    """Write snapshot bundle to disk in configured format."""
    out_dir = Path(cfg.out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    name_value = payload.get("name")
    if not isinstance(name_value, str):
        session = payload.get("session")
        if isinstance(session, dict):
            session_name = cast(dict[str, Any], session).get("name")
            name_value = session_name if isinstance(session_name, str) else "snapshot"
        else:
            name_value = "snapshot"
    name = name_value.replace(" ", "_").replace("/", "_")
    # Add a short nonce to avoid same-second name collisions.
    base = f"{_timestamp()}_{name}_{uuid.uuid4().hex[:8]}"

    # Determine content and extension based on format
    if cfg.output_format == "json":
        content = json.dumps(payload, indent=2, ensure_ascii=False, default=str)
        ext = ".json"
    elif cfg.output_format == "json_compact":
        from .compact_keys import compact_keys

        compacted = compact_keys(payload)
        if isinstance(compacted, dict):
            # Explicit marker avoids ambiguous compact-format detection on read.
            compacted["__llmdebug_compact__"] = 1
        content = json.dumps(compacted, separators=(",", ":"), ensure_ascii=False, default=str)
        ext = ".json"  # Still valid JSON, just minified with short keys
    elif cfg.output_format == "toon":
        from .toon_encoder import encode_snapshot_toon

        content = encode_snapshot_toon(payload)
        ext = ".toon"
    else:
        raise ValueError(f"Unknown output_format: {cfg.output_format}")

    snapshot_path = out_dir / f"{base}{ext}"

    # Atomic write
    tmp = snapshot_path.with_suffix(".tmp")
    tmp.write_text(content, encoding="utf-8")
    tmp.rename(snapshot_path)
    try:
        upsert_snapshot_record(str(out_dir), _snapshot_index_entry(snapshot_path, payload))
    except Exception as index_error:
        sys.stderr.write(
            "llmdebug: warning: snapshot index update failed for "
            f"{snapshot_path.name}: {type(index_error).__name__}: {index_error}\n"
        )

    # Write human-readable traceback (legacy flat payload or DebugSession envelope).
    traceback_text: str | None = None
    if isinstance(payload.get("traceback"), str):
        traceback_text = str(payload["traceback"])
    else:
        snapshot = payload.get("snapshot")
        if isinstance(snapshot, dict):
            tb_raw = cast(dict[str, Any], snapshot).get("traceback")
            if isinstance(tb_raw, str):
                traceback_text = tb_raw
    if traceback_text is not None:
        tb_path = out_dir / f"{base}.traceback.txt"
        tb_path.write_text(traceback_text, encoding="utf-8")

    # Lock for symlink update and cleanup (these can race with concurrent writers)
    with _directory_lock(out_dir, cfg.lock_timeout) as acquired:
        if acquired:
            _update_latest_symlink(out_dir, snapshot_path.name, ext)
            _cleanup_old_snapshots(out_dir, cfg.max_snapshots)
        else:
            sys.stderr.write("llmdebug: warning: lock timeout — latest symlink may be stale\n")

    return snapshot_path


def get_latest_snapshot(
    out_dir: str = ".llmdebug",
    *,
    detail: str | None = None,
    normalize: bool = True,
) -> dict[str, Any] | None:
    """Read the latest snapshot if it exists, auto-detecting format.

    If both latest.json and latest.toon exist (e.g. stale leftovers from
    a previous run), prefers the newest by mtime. Automatically expands
    compact JSON keys to full keys for compatibility.

    Args:
        out_dir: Output directory containing snapshots
        detail: Optional detail level filter ("crash", "full", "context").
                None means no filtering (returns full snapshot).
        normalize: If True (default), normalize DebugSession payloads into the
                legacy flat snapshot view. If False, return raw expanded payload.

    Returns:
        Snapshot data with full keys, or None if not found
    """
    if detail is not None and not normalize:
        raise ValueError("detail filter requires normalize=True")

    out_path = Path(out_dir)

    latest_json = out_path / "latest.json"
    latest_toon = out_path / "latest.toon"
    candidates: list[tuple[str, Path, float]] = []
    for kind, path in (("json", latest_json), ("toon", latest_toon)):
        if not path.exists():
            continue
        try:
            candidates.append((kind, path, path.stat().st_mtime))
        except Exception:
            candidates.append((kind, path, 0.0))

    if not candidates:
        return None

    candidates.sort(key=lambda t: t[2], reverse=True)

    def _read_json(path: Path) -> dict[str, Any] | None:
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
        except Exception as e:
            sys.stderr.write(
                f"llmdebug: warning: {path.name} is corrupted: {type(e).__name__}: {e}\n"
            )
            return None
        if not isinstance(data, dict):
            sys.stderr.write(
                f"llmdebug: warning: {path.name} contains {type(data).__name__}, expected dict\n"
            )
            return None
        from .compact_keys import expand_keys, is_compact_snapshot

        data_dict = cast(dict[str, Any], data)
        if is_compact_snapshot(data_dict):
            try:
                expanded = expand_keys(data_dict)
            except Exception as e:
                sys.stderr.write(
                    f"llmdebug: warning: {path.name} compact key expansion failed: "
                    f"{type(e).__name__}: {e}\n"
                )
                return None
            if isinstance(expanded, dict):
                cast(dict[str, Any], expanded).pop("__llmdebug_compact__", None)
                return (
                    normalize_snapshot_view(cast(dict[str, Any], expanded))
                    if normalize
                    else cast(dict[str, Any], expanded)
                )
            return None
        return normalize_snapshot_view(data_dict) if normalize else data_dict

    def _read_toon(path: Path) -> dict[str, Any] | None:
        try:
            from .toon_encoder import decode_snapshot_toon

            data = decode_snapshot_toon(path.read_text(encoding="utf-8"))
            if isinstance(data, dict):  # pyright: ignore[reportUnnecessaryIsInstance]
                return normalize_snapshot_view(data) if normalize else data
            return None
        except Exception as e:
            sys.stderr.write(
                f"llmdebug: warning: {path.name} decode failed: {type(e).__name__}: {e}\n"
            )
            return None

    for kind, path, _ in candidates:
        snapshot = _read_json(path) if kind == "json" else _read_toon(path)
        if snapshot is not None:
            if detail is not None:
                from .detail_filter import filter_snapshot

                snapshot = filter_snapshot(snapshot, detail)  # type: ignore[arg-type]
            return snapshot

    return None
