"""Small persisted preferences: where the pet sits, how big it is, which skin.

Separate from `bridge`, which publishes *live* state for the instance guard to
read. This is the other direction — things the user set that must survive a
restart. Dragging the pet somewhere and having it jump back to the top-left
every time `dsh web` restarts is the kind of detail that makes a desk pet feel
like a debug window instead of a pet.

Reads never raise. A corrupt or absent file yields defaults, because the pet
failing to start over a preferences file would be a far worse bug than the pet
forgetting where it was.
"""

from __future__ import annotations

import json
import os
import tempfile
from dataclasses import asdict, dataclass, replace
from pathlib import Path

from .skins import DEFAULT_SKIN_ID, is_known_skin

PREFS_DIRNAME = ".dsh-macos-pet"
PREFS_FILENAME = "prefs.json"

MIN_SCALE = 0.3
MAX_SCALE = 2.0
# The pet at frame size is too big to live on a desktop; 0.7 reads as a
# companion rather than a poster.
DEFAULT_SCALE = 0.7
# Beyond this a saved position is not "on another display", it is lost.
OFFSCREEN_LIMIT = 20_000


@dataclass
class Prefs:
    skin_id: str = DEFAULT_SKIN_ID
    x: int | None = None
    y: int | None = None
    scale: float = DEFAULT_SCALE
    # Both default off, which is exactly what the app does today: it sets the
    # accessory activation policy, so it has neither a Dock icon nor a menu bar
    # item. An existing user therefore sees nothing change on upgrade. The two
    # are independent — there is deliberately no rule keeping one of them on,
    # because right-clicking the pet always reaches the menu.
    show_menu_bar: bool = False
    show_dock: bool = False

    def clamped(self) -> "Prefs":
        """Repair anything a hand-edited or stale file could get wrong.

        Returns a copy via `replace` rather than rebuilding from a fixed list of
        keyword arguments. The rebuild silently dropped every field it did not
        name, and `save` writes the clamped copy, so a preference outside that
        list could be set and would never persist.
        """

        skin = self.skin_id if is_known_skin(self.skin_id) else DEFAULT_SKIN_ID
        try:
            scale = float(self.scale)
        except (TypeError, ValueError):
            scale = DEFAULT_SCALE
        scale = max(MIN_SCALE, min(MAX_SCALE, scale))
        x = self.x if isinstance(self.x, int) else None
        y = self.y if isinstance(self.y, int) else None
        # Negative coordinates are ordinary: a display placed to the left of, or
        # above, the primary one has them. Only absurd values are rejected —
        # those strand the window somewhere it cannot be dragged back from,
        # which is the failure actually worth guarding against.
        if x is not None and not -OFFSCREEN_LIMIT <= x <= OFFSCREEN_LIMIT:
            x = None
        if y is not None and not -OFFSCREEN_LIMIT <= y <= OFFSCREEN_LIMIT:
            y = None
        # `is True` rather than `bool(...)`: a hand-edited "yes" or 1 is not a
        # boolean, and coercing it would put a truthy string on the render path
        # instead of repairing the file.
        return replace(
            self,
            skin_id=skin,
            x=x,
            y=y,
            scale=scale,
            show_menu_bar=self.show_menu_bar is True,
            show_dock=self.show_dock is True,
        )


def prefs_path(home: Path | None = None) -> Path:
    base = home if home is not None else Path.home()
    return base / PREFS_DIRNAME / PREFS_FILENAME


def load(home: Path | None = None) -> Prefs:
    try:
        payload = json.loads(prefs_path(home).read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return Prefs()
    if not isinstance(payload, dict):
        return Prefs()
    known = {field: payload.get(field) for field in Prefs().__dict__ if field in payload}
    try:
        return Prefs(**known).clamped()
    except TypeError:
        return Prefs()


def save(prefs: Prefs, home: Path | None = None) -> bool:
    """Best-effort persist. Returns False rather than raising on a bad home."""

    path = prefs_path(home)
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".prefs-", suffix=".json")
        try:
            with os.fdopen(fd, "w", encoding="utf-8") as handle:
                json.dump(asdict(prefs.clamped()), handle, separators=(",", ":"))
            os.replace(tmp, path)
        except BaseException:
            Path(tmp).unlink(missing_ok=True)
            raise
    except OSError:
        return False
    return True
