"""Server-level CI settings (phase settings). A minimal, extensible TOML config layer for the cc-ci
server, read once per harness process. Stdlib-only (`tomllib`). Defaults are baked into this loader,
so an ABSENT file — or an absent key within a present file — behaves EXACTLY as before (this server
needs no file to behave as today). The live file is an operator-managed HOST OVERRIDE; it must carry
NO secrets (config only — secrets stay in sops). See `settings.toml.example` for the documented keys.

Layout (minimal + extensible — one table, one key now; shaped so future CI-server configs slot in
without a redesign):

    [upgrade]
    skip_canonicals_for_upgrade = false   # when true, resolve the upgrade BASE without canonicals

Path resolution: `$CCCI_SETTINGS` if set, else `/etc/cc-ci/settings.toml` (co-located with the
deployed checkout `$CCCI_REPO=/etc/cc-ci`, alongside the tracked `settings.toml.example`; both the
Drone recipe-CI runner and the nightly sweep read this same absolute host path).

Validation:
  - absent / unreadable / malformed-TOML file  → WARN + all-defaults (a bad file can NEVER crash the
    harness — a red config should degrade to today's behavior, not take down CI).
  - unknown table / unknown key                → WARN-and-ignore (forward/typo tolerant).
  - present known key of the WRONG TYPE        → raise TypeError (a typo'd value is loud, not silently
    mis-parsed) — this is the one "errors clearly" case, distinct from a malformed file.
"""

from __future__ import annotations

import os
import sys
import tomllib
from dataclasses import dataclass

DEFAULT_PATH = "/etc/cc-ci/settings.toml"

# The full schema: table -> {key: (type, default)}. SINGLE SOURCE OF TRUTH for defaults + validation.
# Add future settings here (a new key, or a new table); `Settings` field names mirror the keys.
_SCHEMA: dict[str, dict[str, tuple[type, object]]] = {
    "upgrade": {
        "skip_canonicals_for_upgrade": (bool, False),
    },
}


@dataclass(frozen=True)
class Settings:
    # [upgrade].skip_canonicals_for_upgrade — when True, resolve_upgrade_base skips the canonical
    # lookup entirely and takes the no-canonical release-tag-first fallback (phase settings §2.B/§2.C).
    skip_canonicals_for_upgrade: bool = False


def _warn(msg: str) -> None:
    print(f"[settings] WARNING: {msg}", file=sys.stderr, flush=True)


def _resolve_path() -> str:
    return os.environ.get("CCCI_SETTINGS") or DEFAULT_PATH


def _check_type(table: str, key: str, value: object, typ: type) -> object:
    # bool is a subclass of int — reject an int given for a bool key (and vice-versa) so a stray
    # `1`/`0` for a flag is a loud error, not a silent truthy coercion.
    ok = isinstance(value, typ) and (typ is bool or not isinstance(value, bool))
    if not ok:
        raise TypeError(
            f"settings: [{table}].{key} must be {typ.__name__}, "
            f"got {type(value).__name__} ({value!r})"
        )
    return value


def load(path: str | None = None) -> Settings:
    """Load settings from `path` (default: `$CCCI_SETTINGS` / DEFAULT_PATH). Returns all-defaults on an
    absent / unreadable / malformed file (WARN, never raises). Raises TypeError only on a present
    known key whose value is the wrong type."""
    p = path or _resolve_path()
    raw: dict = {}
    if os.path.exists(p):
        try:
            with open(p, "rb") as f:
                raw = tomllib.load(f)
        except (OSError, tomllib.TOMLDecodeError) as e:
            _warn(f"could not read {p} ({e}); using defaults")
            raw = {}

    values: dict[str, object] = {}
    for table, keys in _SCHEMA.items():
        section = raw.get(table, {})
        if not isinstance(section, dict):
            _warn(f"[{table}] is not a table ({type(section).__name__}); ignoring it")
            section = {}
        for key, (typ, default) in keys.items():
            if key in section:
                values[key] = _check_type(table, key, section[key], typ)
            else:
                values[key] = default

    # Forward/typo tolerant: warn on anything not in the schema, but never fail on it.
    for table, section in raw.items():
        if table not in _SCHEMA:
            _warn(f"unknown table [{table}] ignored")
            continue
        if isinstance(section, dict):
            for key in section:
                if key not in _SCHEMA[table]:
                    _warn(f"unknown key [{table}].{key} ignored")

    return Settings(**values)  # type: ignore[arg-type]


_CACHE: Settings | None = None


def get() -> Settings:
    """Process-wide cached settings (read the file once). The harness calls this; tests call `load()`
    with an explicit path (or monkeypatch this) to bypass the cache."""
    global _CACHE
    if _CACHE is None:
        _CACHE = load()
    return _CACHE
