"""Crash-safe state v2 persistence and the shared POSIX writer lock."""

from __future__ import annotations

import copy
import fcntl
import json
import os
import tempfile
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterator


STATE_VERSION = 2
TS_FMT = "%Y-%m-%dT%H:%MZ"
POSITIONS = ("DEL", "GND", "TWR", "DEP", "APP", "CTR")
FIRED_DISPOSITIONS = {
    "pending", "notified", "suppressed_seed", "suppressed_pause",
    "suppressed_guard", "withdrawn",
}
LEAD_DISPOSITIONS = {
    "pending", "notified", "suppressed_pause", "suppressed_guard",
}
LEAD_STATUSES = {
    "active", "cancellation_pending", "cancelled", "completed",
    "fulfilled", "replaced", "withdrawn",
}
NOTIFICATION_KINDS = {"live", "lead", "upgrade", "cancelled", "event", "health", "recovery"}


class StateError(RuntimeError):
    pass


class StateFormatError(StateError):
    pass


class LockUnavailable(StateError):
    pass


@dataclass(frozen=True)
class StateLoadResult:
    state: dict[str, Any]
    warning: str | None = None
    quarantined: Path | None = None

    def __iter__(self):
        yield self.state
        yield self.quarantined


def fresh_state() -> dict[str, Any]:
    return {
        "version": STATE_VERSION,
        "seeded": False,
        "last_attempt": None,
        "last_http_success": None,
        "last_fresh_data": None,
        "fail_count": 0,
        "first_fail_at": None,
        "next_try": None,
        "transport_error_since": None,
        "stale_since": None,
        "local_error_since": None,
        "last_outcome": "new",
        "last_error": None,
        "server_origin": None,
        "health_warning": None,
        "paused_until": None,
        "away_since": None,
        "session_cookie": None,
        "session_cookie_origin": None,
        "session_cookie_at": None,
        "unknown_icaos": [],
        "airports": {},
        "fired": {},
        "announced_leads": {},
        "pending_notifications": {},
    }


def _ts(value: Any) -> str | None:
    if not isinstance(value, str):
        return None
    try:
        datetime.strptime(value, TS_FMT)
    except ValueError:
        return None
    return value


def _positions(value: Any) -> list[str]:
    if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
        return []
    return [position for position in POSITIONS if position in value]


def _airport(value: Any) -> dict[str, Any]:
    if not isinstance(value, dict):
        raise StateFormatError("airport state must be an object")
    normalized_online = _positions(value.get("online_positions", []))
    return {
        "initialized": bool(value.get("initialized", True)),
        "status": value.get("status") if value.get("status") in {"live", "upcoming", "none", "unknown"} else "none",
        "name": value.get("name") if isinstance(value.get("name"), str) else "",
        "online_positions": normalized_online,
        "radar_seen": [item for item in ("APP", "DEP", "CTR") if item in _positions(value.get("radar_seen", []))],
        "live_since": _ts(value.get("live_since")),
        "offline_since": _ts(value.get("offline_since")),
        "last_seen": _ts(value.get("last_seen")),
        "next_start": _ts(value.get("next_start")),
        "next_end": _ts(value.get("next_end")),
    }


def _kind(key: str) -> tuple[str, str] | None:
    parts = key.split("|")
    if len(parts) < 2 or len(parts[0]) != 4 or parts[1] not in NOTIFICATION_KINDS:
        return None
    return parts[0], parts[1]


def _fired(raw: Any) -> dict[str, dict[str, Any]]:
    if not isinstance(raw, dict):
        raise StateFormatError("fired must be an object")
    result = {}
    for key, value in raw.items():
        identity = _kind(key) if isinstance(key, str) else None
        if identity is None:
            raise StateFormatError("invalid fired key")
        icao, kind = identity
        if not isinstance(value, dict) or _ts(value.get("fired_at")) is None:
            raise StateFormatError("invalid fired record")
        if value.get("disposition") not in FIRED_DISPOSITIONS:
            raise StateFormatError("invalid fired disposition")
        expiry = value.get("expires_at")
        if expiry is not None and _ts(expiry) is None:
            raise StateFormatError("invalid fired expiry")
        result[key] = {
            "kind": kind, "icao": icao, "fired_at": value["fired_at"],
            "expires_at": expiry, "disposition": value["disposition"],
        }
    return result


def _leads(raw: Any) -> dict[str, dict[str, Any]]:
    if not isinstance(raw, dict):
        raise StateFormatError("announced_leads must be an object")
    result = {}
    for key, value in raw.items():
        if not isinstance(key, str) or not isinstance(value, dict):
            raise StateFormatError("invalid announced lead")
        required = ("icao", "start", "end", "disposition", "missing_fresh_runs")
        if any(field not in value for field in required):
            raise StateFormatError("announced lead lacks exact fields")
        if (not isinstance(value["icao"], str) or len(value["icao"]) != 4
                or any(_ts(value[field]) is None for field in ("start", "end"))
                or value["disposition"] not in LEAD_DISPOSITIONS
                or not isinstance(value["missing_fresh_runs"], int)
                or isinstance(value["missing_fresh_runs"], bool)
                or value["missing_fresh_runs"] < 0):
            raise StateFormatError("invalid announced lead fields")
        status = value.get("status", "active")
        if status not in LEAD_STATUSES:
            raise StateFormatError("invalid announced lead status")
        result[key] = {
            "icao": value["icao"], "start": value["start"], "end": value["end"],
            "positions": _positions(value.get("positions", [])),
            "disposition": value["disposition"],
            "missing_fresh_runs": value["missing_fresh_runs"], "status": status,
            "created_at": _ts(value.get("created_at")) or value["start"],
            "notified_at": _ts(value.get("notified_at")),
        }
    return result


def _pending(raw: Any) -> dict[str, dict[str, Any]]:
    if not isinstance(raw, dict):
        raise StateFormatError("pending_notifications must be an object")
    result = {}
    for notification_id, value in raw.items():
        if (not isinstance(notification_id, str) or not isinstance(value, dict)
                or value.get("kind") not in NOTIFICATION_KINDS
                or not isinstance(value.get("key"), str)
                or _ts(value.get("created_at")) is None):
            raise StateFormatError("invalid pending notification")
        result[notification_id] = {
            "id": notification_id, "key": value["key"], "kind": value["kind"],
            "icao": value.get("icao") if isinstance(value.get("icao"), str) else None,
            "title": value.get("title") if isinstance(value.get("title"), str) else "atcwatch",
            "body": value.get("body") if isinstance(value.get("body"), str) else "",
            "urgent": bool(value.get("urgent")), "created_at": value["created_at"],
            "data": copy.deepcopy(value.get("data")) if isinstance(value.get("data"), dict) else {},
        }
    return result


def decode_state(raw: Any) -> dict[str, Any]:
    """Decode a state v2 document with typed defaults for missing fields.

    Any other version — including the long-gone v1 — is rejected; the caller
    quarantines the file and silently reseeds, exactly like corrupt state.
    """

    if not isinstance(raw, dict):
        raise StateFormatError("state root must be an object")
    version = raw.get("version")
    if version != STATE_VERSION:
        raise StateFormatError(f"unsupported state version {version!r}")
    state = fresh_state()
    timestamps = (
        "last_attempt", "last_http_success", "last_fresh_data", "first_fail_at",
        "next_try", "transport_error_since", "stale_since", "local_error_since",
        "paused_until", "away_since", "session_cookie_at",
    )
    for field in timestamps:
        state[field] = _ts(raw.get(field))
    state["seeded"] = bool(raw.get("seeded"))
    fail_count = raw.get("fail_count", 0)
    state["fail_count"] = fail_count if isinstance(fail_count, int) and not isinstance(fail_count, bool) and fail_count >= 0 else 0
    state["last_outcome"] = raw.get("last_outcome") if isinstance(raw.get("last_outcome"), str) else "new"
    state["last_error"] = raw.get("last_error") if isinstance(raw.get("last_error"), str) else None
    state["server_origin"] = raw.get("server_origin") if isinstance(raw.get("server_origin"), str) else None
    if isinstance(raw.get("session_cookie_origin"), str):
        state["session_cookie_origin"] = raw["session_cookie_origin"]
        state["session_cookie"] = raw.get("session_cookie") if isinstance(raw.get("session_cookie"), str) else None
    warning = raw.get("health_warning")
    if isinstance(warning, dict) and isinstance(warning.get("kind"), str) and _ts(warning.get("delivered_at")):
        state["health_warning"] = {"kind": warning["kind"], "delivered_at": warning["delivered_at"]}
    airports = raw.get("airports", {})
    if not isinstance(airports, dict):
        raise StateFormatError("airports must be an object")
    for icao, value in airports.items():
        if not isinstance(icao, str) or len(icao) != 4 or icao != icao.upper():
            raise StateFormatError("invalid airport key")
        state["airports"][icao] = _airport(value)
    unknown = raw.get("unknown_icaos", [])
    if not isinstance(unknown, list) or any(not isinstance(item, str) for item in unknown):
        raise StateFormatError("invalid unknown_icaos")
    state["unknown_icaos"] = sorted(set(unknown))
    state["fired"] = _fired(raw.get("fired", {}))
    state["announced_leads"] = _leads(raw.get("announced_leads", {}))
    state["pending_notifications"] = _pending(raw.get("pending_notifications", {}))
    return state


def _fsync_dir(path: Path) -> None:
    fd = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
    try:
        os.fsync(fd)
    finally:
        os.close(fd)


class AtomicWriteConflict(Exception):
    """The file changed underneath us between snapshot and write."""


def atomic_write_private_file(
    path: Path, content: bytes, *, expected_content: bytes | None = None,
) -> None:
    """Replace *path* with *content* atomically, owner-readable only.

    Temp file in the target directory, fsync, ``os.replace`` — a reader either
    sees the old file or the new one, never a half-written config or state.

    *expected_content* turns the write into a check-and-set: the file is
    compared once more immediately before the rename, and an intervening edit
    aborts instead of being clobbered.  This is not atomic across processes —
    no portable file CAS exists — but it closes the window that matters here,
    a user editing config.json in a text editor while the tray menu is open.
    """

    path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
    fd, temp_name = tempfile.mkstemp(
        prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
    temporary = Path(temp_name)
    try:
        os.fchmod(fd, 0o600)
        with os.fdopen(fd, "wb") as handle:
            fd = -1
            handle.write(content)
            handle.flush()
            os.fsync(handle.fileno())
        if expected_content is not None:
            try:
                current = path.read_bytes()
            except FileNotFoundError:
                current = None
            if current != expected_content:
                raise AtomicWriteConflict(f"{path} changed while it was updated")
        os.replace(temporary, path)
        os.chmod(path, 0o600)
        _fsync_dir(path.parent)
    finally:
        if fd >= 0:
            os.close(fd)
        try:
            temporary.unlink()
        except FileNotFoundError:
            pass


def _quarantine(path: Path, now: datetime) -> Path:
    stamp = now.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
    candidate = path.with_name(f"{path.name}.broken.{stamp}")
    suffix = 1
    while candidate.exists():
        candidate = path.with_name(f"{path.name}.broken.{stamp}.{suffix}")
        suffix += 1
    return candidate


def load_state(
    path: Path, *, quarantine: bool = True, now: datetime | None = None,
) -> StateLoadResult:
    if not path.exists():
        return StateLoadResult(fresh_state())
    try:
        if path.is_symlink() or not path.is_file():
            raise StateFormatError("state path is not a regular file")
        with path.open("r", encoding="utf-8") as handle:
            return StateLoadResult(decode_state(json.load(handle)))
    except (OSError, UnicodeError, json.JSONDecodeError, StateFormatError, ValueError) as exc:
        warning = f"discarding unsafe state: {exc}"
        if not quarantine:
            return StateLoadResult(fresh_state(), warning=warning)
        destination = _quarantine(path, now or datetime.now(timezone.utc))
        try:
            os.replace(path, destination)
            _fsync_dir(path.parent)
        except OSError as move_error:
            raise StateError(f"cannot quarantine invalid state {path}: {move_error}") from move_error
        return StateLoadResult(fresh_state(), warning=warning, quarantined=destination)


def save_state(path: Path, state: dict[str, Any]) -> None:
    canonical = decode_state(state)
    try:
        payload = json.dumps(
            canonical, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
        atomic_write_private_file(path, payload.encode("utf-8"))
    except (OSError, TypeError, ValueError, StateFormatError) as exc:
        raise StateError(f"cannot save state {path}: {exc}") from exc


@contextmanager
def singleton_lock(path: Path) -> Iterator[int]:
    path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
    fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
    try:
        os.fchmod(fd, 0o600)
        try:
            fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError as exc:
            raise LockUnavailable("atcwatch is already running") from exc
        yield fd
    finally:
        try:
            fcntl.flock(fd, fcntl.LOCK_UN)
        finally:
            os.close(fd)


writer_lock = singleton_lock
