"""Generic YAML loader / saver for :class:`BaseAppSettings` subclasses.

Layered loading per ``docs/standards/CONFIGURATION.md`` rule CF-02
(top wins):

  1. CLI flags / construction kwargs
  2. Environment variables                  — prefix set by the subclass
  3. ``.env`` file in the working directory — same prefix
  4. On-disk YAML (``config.yaml``)         — see :func:`mgf.common.config.default_config_path`
  5. Typed defaults declared on the subclass

Schema versioning + migration (rule CF-03):

  - The YAML file is read in :func:`load_settings` BEFORE the model
    is built, so a version mismatch can be detected and migrated
    on disk first.
  - Loading a higher version than ``cls.CURRENT_VERSION`` fails loud.
  - Loading a lower version runs the consumer-supplied ``migrate``
    chain and writes the migrated content back atomically.

Atomic writes (rule CF-04):

  - :func:`save_settings` writes to a temp file in the same directory
    then renames, mode ``0o600``.
"""

from __future__ import annotations

import threading
from typing import TYPE_CHECKING, Any, TypeVar

import yaml
from pydantic import ValidationError

from mgf.common.config._settings import BaseAppSettings
from mgf.common.exceptions import AppConfigError, AppConfigErrorKind

if TYPE_CHECKING:
    from collections.abc import Callable, Collection
    from pathlib import Path

T = TypeVar("T", bound=BaseAppSettings)

# RA-04: serialise the set/construct/clear sequence on
# ``settings_cls._yaml_file_override``. The override is a
# ``ClassVar`` so concurrent ``load_settings`` calls against the
# same subclass race on the value — thread A sets path-A, thread B
# overwrites to path-B, thread A constructs (reads path-B), thread
# A resets, thread B constructs (reads None). The lock is
# reentrant so a Settings ``__init__`` that itself calls
# ``load_settings`` recursively doesn't deadlock.
_LOAD_SETTINGS_LOCK: threading.RLock = threading.RLock()


def load_settings(
    settings_cls: type[T],
    *,
    path: Path | None = None,
    migrate: Callable[[dict[str, Any], int], dict[str, Any]] | None = None,
) -> T:
    """Load ``config.yaml`` into a ``settings_cls`` instance.

    A missing file is **not** an error — it returns the default
    ``settings_cls()`` (with env / .env overrides applied) so first-run
    users get sensible behaviour without writing a config file. A
    malformed file raises :class:`AppConfigError`.

    Parameters
    ----------
    settings_cls
        A concrete :class:`BaseAppSettings` subclass.
    path
        Path to the YAML file. When ``None``, the caller MUST resolve
        the default themselves (typically via
        :func:`mgf.common.config.default_config_path`) — passing
        ``None`` here means "do not read any file; use defaults +
        env only".
    migrate
        Optional callback for upgrading older on-disk schema
        versions. Signature ``(raw_dict, from_version) -> new_dict``.
        When omitted, a version lower than ``settings_cls.CURRENT_VERSION``
        is loaded as-is (the in-memory model fills any new fields
        with their defaults).
    """
    yaml_for_pydantic: Path | None = None
    if path is not None and path.exists():
        # RA-13: every raise below sets ``kind`` so consumer
        # ``match e.kind`` patterns can discriminate between parse
        # failures, schema-mismatch failures, and migrate failures
        # without string-matching on the exception message.
        try:
            raw = yaml.safe_load(path.read_text(encoding="utf-8"))
        except yaml.YAMLError as e:
            exc = AppConfigError(f"could not parse {path}: {e}")
            exc.kind = AppConfigErrorKind.YAML_PARSE_FAILURE
            raise exc from e
        if raw is None:
            # Empty / whitespace-only file → treat as defaults; do
            # not pass it through pydantic-settings.
            yaml_for_pydantic = None
        elif not isinstance(raw, dict):
            exc = AppConfigError(
                f"{path} must contain a YAML mapping at the top level, got {type(raw).__name__}"
            )
            exc.kind = AppConfigErrorKind.NON_DICT_ROOT
            raise exc
        else:
            # Reject non-string keys at the top level. YAML happily
            # accepts ``None`` / ints / lists / etc. as map keys
            # (e.g., ``yaml.safe_load('?')`` returns ``{None: None}``)
            # which would later crash pydantic-settings with a bare
            # ``TypeError: keywords must be strings``. Catching it
            # here gives the user a typed AppConfigError pointing at
            # the offending key.
            bad_keys = [k for k in raw if not isinstance(k, str)]
            if bad_keys:
                exc = AppConfigError(f"{path} has non-string top-level keys: {bad_keys!r}")
                exc.kind = AppConfigErrorKind.NON_DICT_ROOT
                raise exc
            current_version = settings_cls.CURRENT_VERSION
            version = raw.get("version", current_version)
            if not isinstance(version, int):
                exc = AppConfigError(f"version must be an int, got {type(version).__name__}")
                exc.kind = AppConfigErrorKind.MISSING_VERSION
                raise exc
            if version > current_version:
                exc = AppConfigError(
                    f"config.yaml version {version} is newer than supported "
                    f"version {current_version}; upgrade the application"
                )
                exc.kind = AppConfigErrorKind.UNSUPPORTED_VERSION
                raise exc
            if version < current_version and migrate is not None:
                # Rewrite the file with the migrated content so
                # subsequent loads see the current schema.
                try:
                    migrated = migrate(raw, version)
                except Exception as orig:
                    # RA-13: wrap the consumer's migration error so
                    # callers can ``except AppConfigError`` /
                    # ``match e.kind: case MIGRATE_FAILED:`` without
                    # caring about the inner exception type.
                    exc = AppConfigError(
                        f"migrate callback raised while upgrading "
                        f"{path} from version={version} to "
                        f"{current_version}: {orig}"
                    )
                    exc.kind = AppConfigErrorKind.MIGRATE_FAILED
                    raise exc from orig
                _write_yaml_atomic(path, migrated)
            yaml_for_pydantic = path

    # Tell the model where to read YAML from for THIS call, then
    # construct. Reset afterwards so subsequent ``settings_cls()``
    # constructions (e.g., in tests) don't accidentally pick up a
    # stale path.
    #
    # RA-04: the override is a ClassVar shared across threads.
    # ``_LOAD_SETTINGS_LOCK`` serialises the set/construct/clear
    # sequence so two concurrent ``load_settings`` calls (different
    # threads, same subclass) cannot cross-contaminate the override
    # mid-construction. The lock is process-wide rather than
    # per-class because (a) ``load_settings`` is typically called
    # once at startup, so contention is rare; (b) per-class state
    # would need a ``WeakKeyDictionary`` of locks with its own
    # construction-time lock — added complexity for negligible
    # benefit.
    with _LOAD_SETTINGS_LOCK:
        prior = settings_cls._yaml_file_override
        settings_cls._yaml_file_override = yaml_for_pydantic
        try:
            return settings_cls()
        except ValidationError as e:
            # Aggregate every field error into one AppConfigError so the
            # user sees every problem at once.
            errors = "; ".join(
                f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}" for err in e.errors()
            )
            raise AppConfigError(f"invalid configuration: {errors}") from e
        finally:
            settings_cls._yaml_file_override = prior


def save_settings(
    cfg: BaseAppSettings, path: Path, *, fields: Collection[str] | None = None
) -> None:
    """Write a settings instance to ``config.yaml`` atomically.

    Writes to a temp file in the same directory then renames, so a
    crash mid-write never leaves a truncated file behind. Creates
    the parent directory if it does not exist. File mode is
    ``0o600`` — the config has no secrets today, but keeping it
    user-private is a sensible default.

    ``fields`` restricts the write to the named settings, overlaying them onto the
    file's existing content and leaving every other key **byte-for-byte as the
    operator wrote it** (FEEDBACK PAPER-104). Pass it whenever the app is
    persisting a specific user action ("remember this window size"), which is the
    normal GUI/CLI case.

    Without ``fields`` the whole model is written — and because settings are
    *layered* (file < env < CLI), that whole model is the RESOLVED view. Writing it
    back therefore bakes environment and CLI overrides into the operator's file and
    pins every unset key at its current default. That is the documented behaviour
    and is right for "write a complete config", but it is destructive for a partial
    save, so prefer ``fields`` there.
    """
    if fields is None:
        payload = cfg.to_yaml_dict()
    else:
        resolved = cfg.to_yaml_dict()
        unknown = sorted(set(fields) - set(resolved))
        if unknown:
            exc = AppConfigError(
                f"save_settings(fields=…) names setting(s) not on {type(cfg).__name__}: {unknown!r}"
            )
            exc.kind = AppConfigErrorKind.SCHEMA_VALIDATION
            raise exc
        # Start from what is ON DISK, not from the model: unknown//future keys and
        # the operator's untouched values must survive verbatim.
        payload = _read_yaml_mapping(path)
        payload.update({name: resolved[name] for name in fields})
    # Ensure version is always written, even if the caller hand-built
    # the model from pieces and forgot to set it.
    payload["version"] = type(cfg).CURRENT_VERSION
    _write_yaml_atomic(path, payload)


def _read_yaml_mapping(path: Path) -> dict[str, Any]:
    """The file's current top-level mapping, or ``{}`` when absent/empty.

    A malformed file raises rather than being silently overwritten — a partial save
    must never be the operation that destroys a config it could not read.
    """
    if not path.exists():
        return {}
    try:
        raw = yaml.safe_load(path.read_text(encoding="utf-8"))
    except yaml.YAMLError as e:
        exc = AppConfigError(f"could not parse {path} for a partial save: {e}")
        exc.kind = AppConfigErrorKind.YAML_PARSE_FAILURE
        raise exc from e
    if raw is None:
        return {}
    if not isinstance(raw, dict):
        exc = AppConfigError(
            f"{path} must contain a YAML mapping at the top level, got {type(raw).__name__}"
        )
        exc.kind = AppConfigErrorKind.NON_DICT_ROOT
        raise exc
    return dict(raw)


# ---------------------------------------------------------------------------
# Internal: atomic YAML write
# ---------------------------------------------------------------------------


def _write_yaml_atomic(target: Path, payload: dict[str, Any]) -> None:
    """Atomic temp-file + rename write of a YAML payload.

    Mode 0o600. Creates the parent directory if absent. On failure
    the temp file is cleaned up before the exception propagates.

    Used by :func:`save_settings` AND by :func:`load_settings` when a
    migration step rewrites the on-disk representation.

    RA-28: delegates to :func:`mgf.common._io.atomic_write` so the
    write body is fsync'd before the rename and the parent
    directory is fsync'd after — config files are load-bearing on
    the next boot, so we want the rename durable across power
    failure (``fsync_dir=True``), same shape as the
    versioned-store helper.
    """
    from mgf.common._io import atomic_write

    target.parent.mkdir(parents=True, exist_ok=True)
    text = yaml.safe_dump(
        payload,
        sort_keys=False,
        default_flow_style=False,
        allow_unicode=True,
    )
    atomic_write(target, text, mode=0o600, fsync_dir=True)


__all__ = ["load_settings", "save_settings"]
