"""One generalized preset store — named JSON overlays deep-merged over a
schema'd default, with whitelist+clamp validation.

Ported from the archive's proven reel-style machinery (deep-merge overlay
files, fail-safe fallback to the builtin, every value clamped because "a
wild value here ships in every render until someone notices"), generalized
so themes, render styles, per-game config and plugin settings all share one
implementation instead of hand-writing a validator each.

Clamp schema — a dict mirroring the defaults' shape; leaves are tuples:
    ("num", lo, hi)        float, clamped
    ("int", lo, hi)        int, clamped
    ("bool",)              coerced with bool()
    ("str", maxlen)        string, length-capped
    ("basename", maxlen)   os.path.basename'd string (file references)
    ("slug", maxlen)       lowercased [a-z0-9_-]
    ("enum", [choices])    must be one of choices
    ("strmap", kmax, vmax) open str→str map (both length-capped, keys slugged)
Keys absent from the schema are dropped; invalid values are dropped, never
"fixed" silently into something the author didn't write.
"""
import json
import os
import re
from copy import deepcopy
from pathlib import Path

from fastapi import APIRouter, HTTPException

from clipshelf.config import DATA

PRESETS_DIR = DATA / "presets"

_SLUG = re.compile(r"[^a-z0-9_-]")


def slug(name: str) -> str:
    return _SLUG.sub("", str(name or "").lower())[:64]


def deep_merge(base: dict, over: dict) -> dict:
    out = dict(base)
    for k, v in (over or {}).items():
        out[k] = (deep_merge(base[k], v)
                  if isinstance(base.get(k), dict) and isinstance(v, dict) else v)
    return out


def clamp(overlay: dict, schema: dict) -> dict:
    """Whitelist + clamp an overlay against a schema. Returns only the keys
    that exist in the schema and carry valid values."""
    out = {}
    for k, spec in schema.items():
        if not isinstance(overlay, dict) or k not in overlay:
            continue
        v = overlay[k]
        if isinstance(spec, dict):
            if isinstance(v, dict):
                sub = clamp(v, spec)
                if sub:
                    out[k] = sub
            continue
        kind = spec[0]
        if kind == "num" and isinstance(v, (int, float)) and not isinstance(v, bool):
            out[k] = max(spec[1], min(spec[2], float(v)))
        elif kind == "int" and isinstance(v, (int, float)) and not isinstance(v, bool):
            out[k] = int(max(spec[1], min(spec[2], v)))
        elif kind == "bool":
            out[k] = bool(v)
        elif kind == "str" and isinstance(v, str):
            out[k] = v[:spec[1]]
        elif kind == "basename" and isinstance(v, str) and v:
            out[k] = os.path.basename(v)[:spec[1]]
        elif kind == "slug" and isinstance(v, str):
            s = slug(v)[:spec[1]]
            if s:
                out[k] = s
        elif kind == "enum" and v in spec[1]:
            out[k] = v
        elif kind == "strmap" and isinstance(v, dict):
            m = {slug(mk)[:spec[1]]: str(mv)[:spec[2]]
                 for mk, mv in v.items() if slug(mk) and str(mv)}
            if m:
                out[k] = m
    return out


class PresetDomain:
    """A named family of presets (themes, styles, plugin settings, ...).

    `builtins` map id → overlay; they are always listed, can be duplicated,
    and can never be overwritten or deleted. User presets live one JSON file
    per id under DATA/presets/<domain>/.
    """

    def __init__(self, name: str, defaults: dict, schema: dict,
                 builtins: dict | None = None):
        self.name = slug(name)
        self.defaults = deepcopy(defaults)
        self.schema = schema
        self.builtins = {slug(k): deepcopy(v) for k, v in (builtins or {}).items()}
        self.dir = PRESETS_DIR / self.name

    def _path(self, pid: str) -> Path:
        return self.dir / f"{slug(pid)}.json"

    def raw(self, pid: str) -> dict:
        pid = slug(pid)
        if pid in self.builtins:
            return deepcopy(self.builtins[pid])
        try:
            return json.loads(self._path(pid).read_text(encoding="utf-8"))
        except Exception:
            return {}

    def get(self, pid: str) -> dict:
        """Effective values: overlay clamped + merged over defaults. A
        broken/unknown preset is exactly the defaults — it must never sink
        the feature using it."""
        merged = deep_merge(self.defaults, clamp(self.raw(pid), self.schema))
        merged["_preset"] = slug(pid) or "default"
        return merged

    def ids(self) -> list:
        out = sorted(self.builtins)
        if self.dir.is_dir():
            out += sorted(p.stem for p in self.dir.glob("*.json")
                          if p.stem not in self.builtins)
        return out

    def listing(self) -> dict:
        return {
            "domain": self.name,
            "defaults": self.defaults,
            "builtins": sorted(self.builtins),
            "presets": {pid: {"raw": self.raw(pid), "merged": self.get(pid)}
                        for pid in self.ids()},
        }

    def save(self, pid: str, overlay: dict) -> dict:
        pid = slug(pid)
        if not pid:
            raise ValueError("preset id required")
        if pid in self.builtins:
            raise ValueError(f"'{pid}' is builtin — duplicate it under a new name")
        cleaned = clamp(overlay or {}, self.schema)
        self.dir.mkdir(parents=True, exist_ok=True)
        tmp = self._path(pid).with_suffix(".json.tmp")
        tmp.write_text(json.dumps(cleaned, indent=2), encoding="utf-8")
        tmp.replace(self._path(pid))
        return cleaned

    def delete(self, pid: str):
        pid = slug(pid)
        if pid in self.builtins:
            raise ValueError(f"'{pid}' is builtin and can't be deleted")
        p = self._path(pid)
        if p.exists():
            p.unlink()


DOMAINS: dict[str, PresetDomain] = {}


def register(domain: PresetDomain) -> PresetDomain:
    DOMAINS[domain.name] = domain
    return domain


def domain(name: str) -> PresetDomain:
    d = DOMAINS.get(slug(name))
    if not d:
        raise KeyError(f"no preset domain '{name}'")
    return d


router = APIRouter(tags=["presets"])


def _dom(name: str) -> PresetDomain:
    try:
        return domain(name)
    except KeyError:
        raise HTTPException(404, f"no preset domain '{name}'")


@router.get("/api/presets/{dom}")
def presets_list(dom: str):
    return _dom(dom).listing()


@router.get("/api/presets/{dom}/{pid}")
def presets_get(dom: str, pid: str):
    d = _dom(dom)
    if slug(pid) not in d.ids():
        raise HTTPException(404, "no such preset")
    return {"raw": d.raw(pid), "merged": d.get(pid)}


@router.put("/api/presets/{dom}/{pid}")
def presets_put(dom: str, pid: str, body: dict):
    try:
        return {"saved": _dom(dom).save(pid, body)}
    except ValueError as e:
        raise HTTPException(400, str(e))


@router.delete("/api/presets/{dom}/{pid}")
def presets_delete(dom: str, pid: str):
    try:
        _dom(dom).delete(pid)
    except ValueError as e:
        raise HTTPException(400, str(e))
    return {"ok": True}
