"""Declarative settings UI schema — the "Property Inspector" analog.

Plugins need a settings UI (API keys, toggles, thresholds) but shouldn't have
to hand-write PyQt for it. A :class:`SettingsSchema` describes the fields
declaratively; the app renders it, and headless callers (tests, the emulator,
CI) can resolve a settings store against it without any UI at all via
:meth:`SettingsSchema.apply`.

Schema v2 adds layout/static fields (section, divider, note, link), extra
input types (slider, multiselect, file, folder, icon, hotkey, list), action
fields (button, test) and conditional visibility via ``show_when`` on any
field.
"""
from __future__ import annotations

import json
import os
from typing import Any, Dict, List, Optional, Tuple

SCHEMA_NAME = "settings.schema.json"

# Static/layout field types: no key, no stored value, purely presentational.
_STATIC_TYPES = {"section", "heading", "divider", "note", "link", "label"}

# Action field types: no stored value, the app invokes a plugin callback.
_ACTION_TYPES = {"button", "test"}

# Input field types: have a key and a stored/default value.
_INPUT_TYPES = {"text", "password", "number", "slider", "bool", "choice",
                "multiselect", "color", "file", "folder", "icon", "hotkey",
                "list"}

FIELD_TYPES = _STATIC_TYPES | _ACTION_TYPES | _INPUT_TYPES

# Field types that don't carry a stored value (no entry in defaults()).
_VALUELESS_TYPES = _STATIC_TYPES | _ACTION_TYPES

# Field types whose default (if present) must be a list.
_LIST_VALUE_TYPES = {"multiselect", "list"}


class SettingsSchema:
    """Fluent builder + (de)serializer for ``settings.schema.json``."""

    def __init__(self, fields: Optional[List[Dict[str, Any]]] = None) -> None:
        self.fields: List[Dict[str, Any]] = list(fields) if fields else []

    # ── fluent adders ────────────────────────────────────────────────────
    def _add(self, field: Dict[str, Any]) -> "SettingsSchema":
        self.fields.append({k: v for k, v in field.items() if v is not None})
        return self

    # -- inputs --------------------------------------------------------
    def text(self, key: str, label: str, default: str = "",
            help: Optional[str] = None,
            placeholder: Optional[str] = None,
            pattern: Optional[str] = None,
            min_length: Optional[int] = None,
            max_length: Optional[int] = None,
            required: Optional[bool] = None,
            show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        return self._add({"key": key, "type": "text", "label": label,
                          "default": default, "help": help,
                          "placeholder": placeholder, "pattern": pattern,
                          "min_length": min_length, "max_length": max_length,
                          "required": required, "show_when": show_when})

    def password(self, key: str, label: str, default: str = "",
                help: Optional[str] = None,
                placeholder: Optional[str] = None,
                pattern: Optional[str] = None,
                min_length: Optional[int] = None,
                max_length: Optional[int] = None,
                required: Optional[bool] = None,
                show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        return self._add({"key": key, "type": "password", "label": label,
                          "default": default, "help": help,
                          "placeholder": placeholder, "pattern": pattern,
                          "min_length": min_length, "max_length": max_length,
                          "required": required, "show_when": show_when})

    def number(self, key: str, label: str, default: float = 0,
              min: Optional[float] = None, max: Optional[float] = None,
              step: Optional[float] = None,
              help: Optional[str] = None,
              show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        return self._add({"key": key, "type": "number", "label": label,
                          "default": default, "min": min, "max": max,
                          "step": step, "help": help, "show_when": show_when})

    def slider(self, key: str, label: str, default: float = 0,
              min: float = 0, max: float = 100, step: float = 1,
              help: Optional[str] = None,
              show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        return self._add({"key": key, "type": "slider", "label": label,
                          "default": default, "min": min, "max": max,
                          "step": step, "help": help, "show_when": show_when})

    def boolean(self, key: str, label: str, default: bool = False,
               help: Optional[str] = None,
               show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        return self._add({"key": key, "type": "bool", "label": label,
                          "default": default, "help": help,
                          "show_when": show_when})

    def choice(self, key: str, label: str,
              options: Optional[List[Any]] = None,
              options_from: Optional[str] = None,
              default: Optional[Any] = None,
              help: Optional[str] = None,
              show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        if default is None and options:
            first = options[0]
            default = first.get("value") if isinstance(first, dict) else first
        return self._add({"key": key, "type": "choice", "label": label,
                          "options": options, "options_from": options_from,
                          "default": default, "help": help,
                          "show_when": show_when})

    def multiselect(self, key: str, label: str,
                    options: Optional[List[Any]] = None,
                    options_from: Optional[str] = None,
                    default: Optional[List[Any]] = None,
                    help: Optional[str] = None,
                    show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        return self._add({"key": key, "type": "multiselect", "label": label,
                          "options": options, "options_from": options_from,
                          "default": default, "help": help,
                          "show_when": show_when})

    def color(self, key: str, label: str, default: str = "#ffffff",
             help: Optional[str] = None,
             show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        return self._add({"key": key, "type": "color", "label": label,
                          "default": default, "help": help,
                          "show_when": show_when})

    def file(self, key: str, label: str, default: str = "",
            filter: Optional[str] = None,
            help: Optional[str] = None,
            show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        return self._add({"key": key, "type": "file", "label": label,
                          "default": default, "filter": filter,
                          "help": help, "show_when": show_when})

    def folder(self, key: str, label: str, default: str = "",
              help: Optional[str] = None,
              show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        return self._add({"key": key, "type": "folder", "label": label,
                          "default": default, "help": help,
                          "show_when": show_when})

    def icon(self, key: str, label: str, default: str = "",
            help: Optional[str] = None,
            show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        return self._add({"key": key, "type": "icon", "label": label,
                          "default": default, "help": help,
                          "show_when": show_when})

    def hotkey(self, key: str, label: str, default: str = "",
              help: Optional[str] = None,
              show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        return self._add({"key": key, "type": "hotkey", "label": label,
                          "default": default, "help": help,
                          "show_when": show_when})

    def list_field(self, key: str, label: str, placeholder: Optional[str] = None,
                  help: Optional[str] = None,
                  show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        """Add a ``list`` field (value is a list of strings). Named ``list_field``
        since ``list`` is a builtin."""
        return self._add({"key": key, "type": "list", "label": label,
                          "placeholder": placeholder, "help": help,
                          "show_when": show_when})

    # -- static / layout -------------------------------------------------
    def section(self, label: str, help: Optional[str] = None,
               show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        return self._add({"type": "section", "label": label, "help": help,
                          "show_when": show_when})

    def label(self, text: str, help: Optional[str] = None,
             show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        return self._add({"type": "label", "label": text, "help": help,
                          "show_when": show_when})

    def divider(self, show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        return self._add({"type": "divider", "show_when": show_when})

    def note(self, text: str, style: str = "info",
             help: Optional[str] = None,
             show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        return self._add({"type": "note", "text": text, "style": style,
                          "help": help, "show_when": show_when})

    def link(self, label: str, url: str, help: Optional[str] = None,
             show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        return self._add({"type": "link", "label": label, "url": url,
                          "help": help, "show_when": show_when})

    # -- actions -----------------------------------------------------------
    def button(self, label: str, action: Optional[str] = None,
              help: Optional[str] = None,
              show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        return self._add({"type": "button", "label": label, "action": action,
                          "help": help, "show_when": show_when})

    def test(self, label: str, action: Optional[str] = None,
            help: Optional[str] = None,
            show_when: Optional[Dict[str, Any]] = None) -> "SettingsSchema":
        """A test button: the app calls ``plugin.on_settings_test(action)``
        and expects an ``(ok: bool, message: str)`` tuple back."""
        return self._add({"type": "test", "label": label, "action": action,
                          "help": help, "show_when": show_when})

    # ── (de)serialization ────────────────────────────────────────────────
    def to_dict(self) -> Dict[str, Any]:
        return {"schema": 1, "fields": self.fields}

    def to_json(self, indent: int = 2) -> str:
        return json.dumps(self.to_dict(), indent=indent)

    def save(self, pkg_dir_or_path: str) -> str:
        """Write ``settings.schema.json``. Accepts a package dir or full path."""
        path = pkg_dir_or_path
        if os.path.isdir(path) or not path.lower().endswith(".json"):
            path = os.path.join(path, SCHEMA_NAME)
        os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
        with open(path, "w", encoding="utf-8") as f:
            f.write(self.to_json())
        return path

    @classmethod
    def from_dict(cls, d: Dict[str, Any]) -> "SettingsSchema":
        return cls(list(d.get("fields", [])))

    @classmethod
    def from_json(cls, s: str) -> "SettingsSchema":
        return cls.from_dict(json.loads(s))

    @classmethod
    def load(cls, path: str) -> "SettingsSchema":
        if os.path.isdir(path):
            path = os.path.join(path, SCHEMA_NAME)
        with open(path, encoding="utf-8") as f:
            return cls.from_dict(json.load(f))

    # ── validation ───────────────────────────────────────────────────────
    def validate(self) -> Tuple[List[str], List[str]]:
        """Check key uniqueness, valid types, choice/multiselect options,
        default types."""
        errors: List[str] = []
        warnings: List[str] = []
        seen = set()
        for f in self.fields:
            ftype = f.get("type")
            key = f.get("key", "")
            if ftype not in FIELD_TYPES:
                errors.append(f"field {key!r}: unknown type {ftype!r}")
                continue
            if ftype in _VALUELESS_TYPES:
                continue
            if not key:
                errors.append(f"field of type {ftype!r} has no key")
                continue
            if key in seen:
                errors.append(f"duplicate key {key!r}")
            seen.add(key)

            if ftype in ("choice", "multiselect"):
                if not f.get("options") and not f.get("options_from"):
                    errors.append(
                        f"field {key!r}: {ftype} type needs 'options' or "
                        "'options_from'")

            default = f.get("default")
            if ftype in ("text", "password") and default is not None \
                    and not isinstance(default, str):
                warnings.append(f"field {key!r}: default is not a string")
            elif ftype in ("number", "slider") and default is not None \
                    and not isinstance(default, (int, float)):
                warnings.append(f"field {key!r}: default is not a number")
            elif ftype == "bool" and default is not None \
                    and not isinstance(default, bool):
                warnings.append(f"field {key!r}: default is not a bool")
            elif ftype in _LIST_VALUE_TYPES and default is not None \
                    and not isinstance(default, list):
                warnings.append(f"field {key!r}: default is not a list")
        return errors, warnings

    # ── headless resolution ──────────────────────────────────────────────
    def defaults(self) -> Dict[str, Any]:
        """Map key -> default value, skipping valueless field types."""
        out: Dict[str, Any] = {}
        for f in self.fields:
            if f.get("type") in _VALUELESS_TYPES:
                continue
            key = f.get("key", "")
            if key:
                out[key] = f.get("default")
        return out

    def apply(self, store: Dict[str, Any]) -> Dict[str, Any]:
        """Return ``store`` merged over the schema defaults (fills gaps)."""
        merged = self.defaults()
        merged.update(store or {})
        return merged


def load_schema(pkg_dir: str) -> Optional[SettingsSchema]:
    """Read ``<pkg_dir>/settings.schema.json`` if present, else ``None``."""
    path = os.path.join(pkg_dir, SCHEMA_NAME)
    if not os.path.isfile(path):
        return None
    return SettingsSchema.load(path)


__all__ = ["FIELD_TYPES", "SCHEMA_NAME", "SettingsSchema", "load_schema"]
