"""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
    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) -> 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.
    """
    payload = cfg.to_yaml_dict()
    # 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)


# ---------------------------------------------------------------------------
# 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"]
