"""Implementation of :class:`VersionedFileStore`.

The store handles the load + validate + migrate + atomic-save shape
for satellite config files: small typed documents (hooks, schedules,
alert specs, profiles) that live next to the main app config and
need their own schema versioning.

The design splits cleanly from :class:`mgf.common.config.BaseAppSettings`:

  - ``BaseAppSettings`` is **the** settings root — exactly one per
    process, with multi-source layering (env / dotenv / pyproject).
  - ``VersionedFileStore`` is many-per-app — each instance manages
    one satellite document on disk. No layering; the file IS the
    truth. Atomic writes; deterministic.

The atomic-write helper is intentionally a small private duplicate of
:func:`mgf.common.config._loader._write_yaml_atomic` (~15 lines). Keeping
the helpers separate lets the two modules evolve independently — the
settings loader writes ONE shape (a top-level YAML config); this store
writes either YAML or JSON. Refactoring to a single shared helper is
deferred until both call sites need the same dispatch path.
"""

from __future__ import annotations

import json
import logging
from collections.abc import Callable, Mapping
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar

if TYPE_CHECKING:
    from pathlib import Path

import yaml
from pydantic import BaseModel, ValidationError

from mgf.common._errors import add_help_note
from mgf.common.exceptions import AppConfigError, AppConfigErrorKind

_LOG = logging.getLogger("mgf.common.versioned")

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

#: The migration callback type. Receives the raw parsed payload AND
#: the version it was loaded with; returns the migrated raw payload
#: ready for schema validation. Multi-step migrations are the
#: callback's responsibility — the store calls it once per load.
MigrateFn = Callable[[Mapping[str, Any], int], Mapping[str, Any]]

_FileFormat = Literal["yaml", "json"]


class VersionedFileStore(Generic[T]):
    """Generic versioned-file store: load / validate / migrate / save.

    Captures the pattern that every satellite config file in a real
    consumer ships by hand:

      - ``version: int`` field at the document root.
      - Unknown-key rejection (pydantic ``extra='forbid'``).
      - Migration callback for older on-disk shapes.
      - Atomic write via temp file + rename.

    Wire it once per file::

        from pydantic import BaseModel, ConfigDict
        from mgf.common.versioned import VersionedFileStore

        class HooksFile(BaseModel):
            model_config = ConfigDict(extra="forbid", frozen=True)
            version: int
            hooks: list[Hook] = []

        def migrate(raw, from_version):
            if from_version == 1:
                return {**raw, "hooks": [...rename cmd → command...]}
            return raw

        store = VersionedFileStore(
            Path("~/.config/myapp/hooks.yaml").expanduser(),
            schema=HooksFile,
            current_version=2,
            migrate=migrate,
        )

        hooks = store.load()
        store.save(hooks.model_copy(update={"hooks": [...]}))

    Parameters
    ----------
    path
        Where the document lives on disk. Format is auto-detected
        from the suffix (``.yaml``/``.yml`` → YAML, ``.json`` →
        JSON); override via ``file_format=`` if the suffix is
        non-standard.
    schema
        The pydantic model class for the document root. MUST set
        ``model_config = ConfigDict(extra='forbid')`` so unknown keys
        on disk raise loudly. The constructor verifies this and
        raises :class:`AppConfigError` early if missing.
    current_version
        The version this code expects to read and write. Documents
        with a different version on disk are routed through
        ``migrate`` before validation; ``save()`` always stamps the
        document with this value.
    migrate
        Optional ``Callable[[Mapping, int], Mapping]``. Called when
        the on-disk version differs from ``current_version`` —
        receives the raw dict and the on-disk version, returns the
        migrated raw dict ready for ``schema`` validation. Multi-step
        migrations are the callback's responsibility; the store calls
        ``migrate`` once per load.
    version_field
        The name of the version-bearing field on the document root.
        Default ``"version"``. The reference consumer uses both
        ``"version"`` and ``"schema_version"`` in the wild — pick
        whichever fits the document.
    file_format
        ``"yaml"`` or ``"json"``. Default: auto-detect from
        ``path.suffix``. Pass explicitly when the path uses a
        non-standard extension.
    tolerate_missing_version
        Default ``False`` — files lacking the typed
        ``version_field`` raise :class:`AppConfigError` (the
        strict-by-default contract; closes FEEDBACK PAPER-04).
        Set to ``True`` to opt in to a grace mode for consumers
        with **pre-versioning legacy files** — files written
        before the consumer adopted CF-07's ``version:``
        discipline. When ``True``, a missing field logs one
        WARNING via the ``mgf.common.versioned`` logger and is
        treated as ``current_version`` (the migrate callback is
        NOT called — there is no version to migrate from). The
        next ``save()`` stamps the file with the current
        version, so the WARNING fires at most once per file.
        Files where the field is *present but not an int*
        always raise — that's a corruption signal, not a legacy
        file.

    Threading + concurrency
    -----------------------
    Thread-safe at the object level (no shared mutable state). NOT
    process-safe: concurrent ``save()`` calls from different
    processes against the same path can race at the rename step. If
    you need cross-process coordination, layer your own file lock on
    top — a future version of this class may add a ``lock=`` kwarg.

    See ``docs/recipes/versioned.md`` for the full recipe.
    """

    def __init__(
        self,
        path: Path,
        *,
        schema: type[T],
        current_version: int,
        migrate: MigrateFn | None = None,
        version_field: str = "version",
        file_format: _FileFormat | None = None,
        tolerate_missing_version: bool = False,
    ) -> None:
        self._path = path
        self._schema = schema
        self._current_version = current_version
        self._migrate = migrate
        self._version_field = version_field
        self._format: _FileFormat = file_format if file_format is not None else _detect_format(path)
        self._tolerate_missing_version = tolerate_missing_version

        # Verify schema rejects unknown keys at construction time so
        # the consumer sees the failure during wiring, not on a load
        # five hours later.
        if schema.model_config.get("extra") != "forbid":
            raise AppConfigError(
                f"VersionedFileStore: schema {schema.__name__!r} must set "
                f"model_config = ConfigDict(extra='forbid'). Without it "
                f"the unknown-key-rejection contract this store promises "
                f"is silently broken."
            )

        # Verify the schema actually has the version field. Reading
        # an attribute that doesn't exist on the model would silently
        # blow up at save() time on a stale model — fail loud now.
        if version_field not in schema.model_fields:
            raise AppConfigError(
                f"VersionedFileStore: schema {schema.__name__!r} has no "
                f"field {version_field!r}. Either add it (typed as "
                f"``int``) or pass version_field=... to the constructor."
            )

    @property
    def path(self) -> Path:
        """The on-disk path this store reads from and writes to."""
        return self._path

    def exists(self) -> bool:
        """Return ``True`` if the file currently exists on disk."""
        return self._path.exists()

    def load(self) -> T:
        """Load + (optionally migrate) + validate the document.

        Returns the validated :class:`T` instance.

        Raises :class:`AppConfigError` with PEP 678 notes pointing at
        the path when the file is missing, malformed, carries an
        unknown version with no migrate, or fails schema validation.
        """
        if not self._path.exists():
            exc = AppConfigError(f"versioned file not found: {self._path}")
            exc.kind = AppConfigErrorKind.FILE_NOT_FOUND
            add_help_note(
                exc,
                f"Create the file via {type(self).__name__}.save(...) "
                f"or guard your read with `if store.exists():`.",
                doc_ref="docs/recipes/versioned.md",
            )
            raise exc

        try:
            text = self._path.read_text(encoding="utf-8")
        except OSError as orig:
            exc = AppConfigError(f"versioned file at {self._path} could not be read: {orig}")
            exc.kind = AppConfigErrorKind.IO_READ_FAILED
            raise exc from orig

        try:
            raw = self._parse(text)
        except (yaml.YAMLError, json.JSONDecodeError) as orig:
            exc = AppConfigError(
                f"versioned file at {self._path} is malformed ({self._format}): {orig}"
            )
            exc.kind = (
                AppConfigErrorKind.YAML_PARSE_FAILURE
                if self._format == "yaml"
                else AppConfigErrorKind.JSON_PARSE_FAILURE
            )
            add_help_note(
                exc,
                f"The file may have been hand-edited or truncated by a "
                f"crashed write. Restore from backup or recreate via "
                f"{type(self).__name__}.save(...).",
            )
            raise exc from orig

        if not isinstance(raw, dict):
            exc = AppConfigError(
                f"versioned file at {self._path} must be a mapping at "
                f"the root; got {type(raw).__name__}"
            )
            exc.kind = AppConfigErrorKind.NON_DICT_ROOT
            raise exc

        # Pull the on-disk version BEFORE validation so we can route
        # through the migrate callback when shapes have shifted.
        # Distinguish "field absent" (legacy-file case PAPER-09 covers)
        # from "field present but wrong type" (corruption — always raise).
        version_field_present = self._version_field in raw
        on_disk_version = raw.get(self._version_field)

        if not version_field_present and self._tolerate_missing_version:
            # Grace mode: treat as current_version, skip migrate.
            # Inject the version into the raw payload so schema
            # validation (which requires the field) passes; the next
            # save() rewrites the file with the stamp.
            _LOG.warning(
                "versioned file has no version field; tolerating in grace mode",
                extra={
                    "audit": True,
                    "event": "versioned.grace_mode",
                    "path": str(self._path),
                    "version_field": self._version_field,
                    "tolerated_as_version": self._current_version,
                    "hint": "next save() will stamp the file with the current version (PAPER-09 grace mode); this warning fires once per load until the file is rewritten",
                },
            )
            raw[self._version_field] = self._current_version
            on_disk_version = self._current_version
        elif not isinstance(on_disk_version, int):
            exc = AppConfigError(
                f"versioned file at {self._path} is missing a typed "
                f"{self._version_field!r} field (expected int, got "
                f"{type(on_disk_version).__name__})"
            )
            exc.kind = AppConfigErrorKind.MISSING_VERSION
            add_help_note(
                exc,
                f"Add `{self._version_field}: {self._current_version}` "
                f"to the document root, or recreate the file via "
                f"{type(self).__name__}.save(...).",
            )
            if not self._tolerate_missing_version:
                add_help_note(
                    exc,
                    f"For pre-versioning legacy files, pass "
                    f"tolerate_missing_version=True to "
                    f"{type(self).__name__}(...) — missing version "
                    f"will be tolerated with a WARNING log.",
                )
            raise exc

        if on_disk_version != self._current_version:
            if self._migrate is None:
                exc = AppConfigError(
                    f"versioned file at {self._path} has "
                    f"{self._version_field}={on_disk_version}; this code "
                    f"expects {self._current_version} and no migrate "
                    f"callback was registered."
                )
                exc.kind = AppConfigErrorKind.UNSUPPORTED_VERSION
                add_help_note(
                    exc,
                    f"Pass migrate=... to {type(self).__name__}(...) "
                    f"with a callback that returns a "
                    f"{self._version_field}={self._current_version} "
                    f"shape, or rewrite the file at the current version.",
                )
                raise exc
            try:
                migrated = self._migrate(raw, on_disk_version)
            except Exception as orig:
                exc = AppConfigError(
                    f"migrate callback raised while upgrading "
                    f"{self._path} from {self._version_field}="
                    f"{on_disk_version} to {self._current_version}: {orig}"
                )
                exc.kind = AppConfigErrorKind.MIGRATE_FAILED
                raise exc from orig
            raw = dict(migrated)

        try:
            return self._schema(**raw)
        except ValidationError as orig:
            exc = AppConfigError(
                f"versioned file at {self._path} failed schema "
                f"validation against {self._schema.__name__}"
            )
            exc.kind = AppConfigErrorKind.SCHEMA_VALIDATION
            for err in orig.errors():
                loc = ".".join(str(p) for p in err.get("loc", ()))
                msg = err.get("msg", "validation failed")
                add_help_note(
                    exc,
                    f"Field {loc!r}: {msg}.",
                )
            raise exc from orig

    def save(self, value: T) -> None:
        """Atomically save ``value`` to disk.

        The ``version_field`` is overwritten with ``current_version``
        before serialisation so consumers can't accidentally save a
        stale version stamp.

        The write goes through a temp file + rename so a partial
        write never corrupts the destination — readers either see
        the previous version or the new one, never half a document.

        Raises :class:`AppConfigError` if the destination directory
        cannot be created or the temp-write / rename fails.
        """
        if not isinstance(value, self._schema):
            raise AppConfigError(
                f"VersionedFileStore.save: expected {self._schema.__name__}, "
                f"got {type(value).__name__}"
            )

        payload = value.model_dump(mode="json")
        # Stamp the version. The model's value is ignored — the store
        # is the single source of truth for "what version did we just
        # write".
        payload[self._version_field] = self._current_version

        try:
            self._write_atomic(payload)
        except OSError as orig:
            exc = AppConfigError(f"versioned file at {self._path} could not be saved: {orig}")
            raise exc from orig

    # ------------------------------------------------------------------
    # Internal: format dispatch + atomic write
    # ------------------------------------------------------------------

    def _parse(self, text: str) -> Any:
        if self._format == "yaml":
            return yaml.safe_load(text)
        return json.loads(text)

    def _serialize(self, payload: dict[str, Any]) -> str:
        if self._format == "yaml":
            return yaml.safe_dump(
                payload,
                sort_keys=False,
                default_flow_style=False,
                allow_unicode=True,
            )
        return json.dumps(payload, indent=2, sort_keys=False) + "\n"

    def _write_atomic(self, payload: dict[str, Any]) -> None:
        """RA-01: delegates to :func:`mgf.common._io.atomic_write`.

        Parent-directory fsync is ON — versioned configs are
        load-bearing for the consumer's recovery (a lost rename
        means the schema-migration is lost and the next load
        re-runs it; safer to make the rename durable across
        power failure).
        """
        from mgf.common._io import atomic_write

        target = self._path
        target.parent.mkdir(parents=True, exist_ok=True)
        text = self._serialize(payload)
        atomic_write(target, text, mode=0o600, fsync_dir=True)


def _detect_format(path: Path) -> _FileFormat:
    suffix = path.suffix.lower()
    if suffix in {".yaml", ".yml"}:
        return "yaml"
    if suffix == ".json":
        return "json"
    raise AppConfigError(
        f"VersionedFileStore: cannot auto-detect format from "
        f"{path.name!r}; pass file_format='yaml' or "
        f"file_format='json' explicitly."
    )


__all__ = ["MigrateFn", "VersionedFileStore"]
