"""
PlayState — mutable per-user state: favorites, continue list, playtime.

The catalog ships an IMMUTABLE seed at `dataset/play_state.json` so off-device
demos look populated. The on-device truth lives at `<state_dir>/play_state.json`
and is owned by this class: every favorite toggle / session record persists
immediately and atomically (tmp + rename).

Init takes a `seed` dict (typically `catalog.play_state`); its values are
COPIED, never mutated. The App's `run()` then re-wires `catalog.play_state`
to point at PlayState's `data` dict — keeping `catalog.is_favorite()` and
friends in sync with mutations without screens having to know this class
exists. Tests that construct PlayState in isolation never pollute the
shared catalog (PlayState owns its own dict; the seed is read-only).

Corruption tolerance: if `<state_dir>/play_state.json` is missing or
unparseable (zero-byte after fskit corruption), the seed is used — no
crash, no silent loss of preferences.
"""

from __future__ import annotations

import json
import os
from datetime import datetime, timezone
from typing import Optional

PLAY_STATE_FILE = "play_state.json"
SESSION_FILE = "session.json"
SESSION_STALE_AFTER_S = 24 * 3600        # 24h → presumed stale (device crashed
                                         # mid-game; better to lose tracking on a
                                         # rare event than credit a 47h ghost)


def _format_playtime(seconds: int) -> str:
    """`12h 47m` form used by the existing Home rail. Bridges new int-based
    session tracking to screens that still read the legacy string field."""
    hours = max(0, int(seconds)) // 3600
    minutes = (max(0, int(seconds)) % 3600) // 60
    return f"{hours}h {minutes:02d}m"


def _format_last_played(when: datetime, now: Optional[datetime] = None) -> str:
    """Human relative form (`just now`, `5 min ago`, `2 days ago`). Computed
    at write time — entries that aren't touched again will drift; render-time
    re-formatting is a follow-up slice."""
    now = now or datetime.now(timezone.utc)
    seconds = (now - when).total_seconds()
    if seconds < 60:
        return "just now"
    if seconds < 3600:
        m = int(seconds // 60)
        return f"{m} min ago"
    if seconds < 86400:
        h = int(seconds // 3600)
        return f"{h} hour{'s' if h != 1 else ''} ago"
    if seconds < 2 * 86400:
        return "yesterday"
    if seconds < 7 * 86400:
        return f"{int(seconds // 86400)} days ago"
    if seconds < 30 * 86400:
        w = int(seconds // (7 * 86400))
        return f"{w} week{'s' if w != 1 else ''} ago"
    if seconds < 365 * 86400:
        mo = int(seconds // (30 * 86400))
        return "last month" if mo == 1 else f"{mo} months ago"
    return "a year ago"


class PlayState:
    CONTINUE_LIMIT = 12                  # most-recent N entries kept in continue rail

    def __init__(self, state_dir: str, seed: Optional[dict] = None):
        self.state_dir = state_dir
        self.path = os.path.join(state_dir, PLAY_STATE_FILE)

        # Start from the seed — copy values, do NOT alias the seed dict.
        seed = seed or {}
        self.data: dict = {
            "favorites": list(seed.get("favorites", [])),
            "continue": list(seed.get("continue", [])),
            "cheats": dict(seed.get("cheats", {})),
            "display": dict(seed.get("display", {})),
            # notifications: which library game ids the player has already
            # "seen" (acknowledged by opening the drawer). Drives the
            # bell's unread dot + the "new on your shelf" feed item.
            "notifications": dict(seed.get("notifications", {})),
        }

        # Override with the on-disk truth if it parses; fall back silently
        # otherwise (matches the fskit zero-byte recovery model).
        try:
            with open(self.path, encoding="utf-8") as fh:
                loaded = json.load(fh)
            if isinstance(loaded, dict):
                self.data = {
                    "favorites": list(loaded.get("favorites", [])),
                    "continue": list(loaded.get("continue", [])),
                    "cheats": dict(loaded.get("cheats", {})),
                    "display": dict(loaded.get("display", {})),
                    "notifications": dict(loaded.get("notifications", {})),
                }
        except (OSError, ValueError):
            pass

    # ── reads ─────────────────────────────────────────────────────────

    def favorites(self) -> list:
        return list(self.data.get("favorites", []))

    def is_favorite(self, gid: str) -> bool:
        return gid in self.data.get("favorites", [])

    def continue_entries(self) -> list:
        return list(self.data.get("continue", []))

    def continue_entry(self, gid: str) -> Optional[dict]:
        for e in self.data.get("continue", []):
            if e.get("id") == gid:
                return e
        return None

    # ── mutations (each persists immediately + atomically) ────────────

    def toggle_favorite(self, gid: str) -> bool:
        """Returns the new state (True = now a favorite)."""
        favs = self.data["favorites"]
        if gid in favs:
            favs.remove(gid)
            new = False
        else:
            favs.append(gid)
            new = True
        self._save()
        return new

    # ── cheats: which curated cheats the player has enabled per-game ──
    # Labels (not codes) are the stable key — the curated JSON ships with
    # the dataset, so labels stay stable across catalog refreshes (codes
    # might re-emit with different normalization).

    def enabled_cheats(self, gid: str) -> list:
        """Curated cheat labels the player has toggled ON for `gid`.
        Empty list = nothing enabled = UI shows all toggles off."""
        cheats = self.data.get("cheats") or {}
        return list(cheats.get(gid, []))

    def is_cheat_enabled(self, gid: str, label: str) -> bool:
        return label in (self.data.get("cheats") or {}).get(gid, [])

    def toggle_cheat(self, gid: str, label: str) -> bool:
        """Returns the new state (True = now enabled). Persists immediately,
        atomically — same tmp+rename as favorites."""
        cheats = self.data.setdefault("cheats", {})
        per_game = list(cheats.get(gid, []))
        if label in per_game:
            per_game.remove(label)
            new = False
        else:
            per_game.append(label)
            new = True
        if per_game:
            cheats[gid] = per_game
        else:
            cheats.pop(gid, None)                     # don't litter empty lists
        self._save()
        return new

    # ── notifications: seen-set for the bell unread dot + feed ────────
    # We track which library game ids the player has acknowledged (by
    # opening the drawer). "New on your shelf" = installed ids not yet
    # seen. First run has an empty seen-set → the feed shows a one-shot
    # welcome instead of screaming "977 new", and opening the drawer
    # baselines the whole current library as seen.

    def seen_game_ids(self) -> set:
        return set((self.data.get("notifications") or {}).get("seen", []))

    def notifications_baselined(self) -> bool:
        """True once the player has opened the drawer at least once (so the
        first-run welcome doesn't reappear forever)."""
        return bool((self.data.get("notifications") or {}).get("baselined"))

    def mark_games_seen(self, ids) -> None:
        """Union `ids` into the seen-set and mark the drawer baselined.
        Persists atomically. Called when the player opens / clears the
        notifications drawer — viewing IS acknowledging."""
        notif = self.data.setdefault("notifications", {})
        seen = set(notif.get("seen", []))
        seen.update(i for i in (ids or []) if i)
        notif["seen"] = sorted(seen)
        notif["baselined"] = True
        self._save()

    # ── display look (P1.D) ──────────────────────────────────────────
    # The device has ONE screen and no video output, so there's no output
    # target to choose (the old lcd/tv axis is gone). The user picks a global
    # LOOK instead — how much "old screen" character to apply — which drives
    # the §12 preset emitted at launch (gui.library.display.cfg_for). Kept as a
    # literal here so the persistence layer doesn't depend on the display engine;
    # must stay in sync with display.LOOKS.
    _VALID_DISPLAY_LOOKS = ("crisp", "retro")

    def display_look(self) -> str:
        """The user's chosen presentation look — 'crisp' (sharp, pixel-correct)
        or 'retro' (authentic per-system filter). Default 'retro' (the manifesto
        §12 look). Drives which preset auto-applies at launch."""
        look = (self.data.get("display") or {}).get("look")
        return look if look in self._VALID_DISPLAY_LOOKS else "retro"

    def set_display_look(self, look: str) -> str:
        """Set + persist the look. Silently rejects unknown values (the UI
        shouldn't offer them, but defensive in case it ever did). Returns the
        effective look after the call."""
        if look not in self._VALID_DISPLAY_LOOKS:
            return self.display_look()                    # ignore bogus
        display = self.data.setdefault("display", {})
        display["look"] = look
        self._save()
        return look

    # ── session tracking (around launch_game → emulator → return) ─────

    @property
    def _session_path(self) -> str:
        return os.path.join(self.state_dir, SESSION_FILE)

    def start_session(self, gid: str, now: Optional[datetime] = None) -> None:
        """Mark a session as started. Called from App.launch_game BEFORE the
        shell exits to hand control to the emulator. If a stale session file
        exists (unclean prior exit) it is overwritten — we credit the most
        recent intent, not a phantom from last week."""
        now = now or datetime.now(timezone.utc)
        try:
            os.makedirs(self.state_dir, exist_ok=True)
            tmp = self._session_path + ".tmp"
            with open(tmp, "w", encoding="utf-8") as fh:
                json.dump({"id": gid, "started_at": now.isoformat()},
                          fh, ensure_ascii=False, indent=2)
            os.replace(tmp, self._session_path)
        except OSError as exc:                            # noqa: BLE001
            # Same tolerance as _save(): on a bad filesystem we lose this
            # session's playtime credit but don't brick launch_game itself.
            print(f"start_session save failed ({exc!r}) — playtime won't be "
                  f"credited for this launch", flush=True)

    def end_session(self, now: Optional[datetime] = None) -> Optional[dict]:
        """Read the pending session file, compute its duration, remove the
        file (regardless of outcome — we never leave a half-eaten session).
        Returns `{id, duration_seconds}` for a healthy session, or None when
        there's nothing to credit (missing / corrupt / stale > 24h /
        negative-duration clock skew)."""
        try:
            with open(self._session_path, encoding="utf-8") as fh:
                data = json.load(fh)
        except (OSError, ValueError):
            data = None

        # Cleanup the file in any case — broken or healthy, it's spent.
        try:
            os.remove(self._session_path)
        except OSError:
            pass

        if not isinstance(data, dict):
            return None
        gid = data.get("id")
        try:
            started = datetime.fromisoformat(data["started_at"])
        except (KeyError, ValueError, TypeError):
            return None
        if not gid:
            return None

        now = now or datetime.now(timezone.utc)
        duration = (now - started).total_seconds()
        if duration < 0 or duration > SESSION_STALE_AFTER_S:
            return None
        return {"id": gid, "duration_seconds": int(duration)}

    def record_session(self, gid: str, duration_seconds: int,
                       now: Optional[datetime] = None) -> None:
        """Credit `duration_seconds` of playtime to `gid` — accumulates into
        an existing continue entry or inserts a new one at the top. Capped at
        CONTINUE_LIMIT (most-recent wins). Writes BOTH int form (for math)
        AND legacy string form (`playtime`, `last_played`) so screens that
        still read the old shape keep rendering."""
        now = now or datetime.now(timezone.utc)
        cont = self.data.setdefault("continue", [])

        existing = None
        for e in cont:
            if e.get("id") == gid:
                existing = e
                break

        if existing:
            total_s = existing.get("playtime_seconds", 0) + int(duration_seconds)
            existing["playtime_seconds"] = total_s
            existing["last_played_at"] = now.isoformat()
            existing["playtime"] = _format_playtime(total_s)
            existing["last_played"] = _format_last_played(now, now=now)
            cont.remove(existing)
            cont.insert(0, existing)
        else:
            cont.insert(0, {
                "id": gid,
                "playtime_seconds": int(duration_seconds),
                "last_played_at": now.isoformat(),
                "playtime": _format_playtime(duration_seconds),
                "last_played": _format_last_played(now, now=now),
            })

        if len(cont) > self.CONTINUE_LIMIT:
            del cont[self.CONTINUE_LIMIT:]

        self._save()

    def consume_pending_session(self, now: Optional[datetime] = None) -> Optional[str]:
        """End-to-end: read any pending session file, credit the duration to
        its game, persist. Called once per App.run() startup. Returns the
        game id whose session was credited, or None if there was nothing."""
        pending = self.end_session(now=now)
        if pending is None:
            return None
        self.record_session(pending["id"], pending["duration_seconds"], now=now)
        return pending["id"]

    # ── persistence ───────────────────────────────────────────────────

    def _save(self) -> None:
        """Atomic: tmp + rename. Creates state_dir if missing.

        Tolerates write failure (read-only filesystem on a dev box, full SD,
        permission flap mid-write) — the in-memory mutation already happened;
        we print a warning and move on rather than crash the launcher. Same
        defaults-over-defeat philosophy as Catalog.load tolerating corrupt
        sidecars. State will retry on the next mutation; if the FS stays bad,
        the next boot just re-reads the seed instead of the lost write."""
        try:
            os.makedirs(self.state_dir, exist_ok=True)
            tmp = self.path + ".tmp"
            with open(tmp, "w", encoding="utf-8") as fh:
                json.dump(self.data, fh, ensure_ascii=False, indent=2)
            os.replace(tmp, self.path)
        except OSError as exc:                            # noqa: BLE001
            print(f"play_state save failed ({exc!r}) — keeping in-memory state, "
                  f"will retry on next mutation", flush=True)
