"""Shared schema-migration helper for VManager YAML stores.

Pinned by OP-10 in ``docs/inprogress/OPERABILITY.md``.

Four YAML stores carry a top-level ``version: <int>`` field
(``hooks.yaml``, ``schedules.yaml``, ``alerts.yaml``,
``port-forwards.yaml``). Before OP-10 every loader rejected any
version != the constant it knew about — a release that bumped
the schema forced users to hand-edit every existing file.

This module is the load-bearing wiring for the upgrade path:

- :func:`check_and_migrate` takes the raw parsed dict, the
  current expected version, the store's display name, and an
  optional :class:`MigrationChain`. On a version that matches
  the current expected, returns the dict unchanged.
  On a LOWER version with a registered migration, applies every
  step in order and returns the upgraded dict.
  On a HIGHER version (downgrade), raises with a clear "this
  file was written by a newer VManager" message.
  On a LOWER version WITHOUT a registered migration, raises with
  the actionable "no migration registered" template.
- :class:`MigrationChain` is a tiny registry — store owners add
  one ``@chain.register(from_version=N)`` decorated function per
  bump. The decorator validates that the function targets the
  next-higher version, so a typo can't silently skip a step.

**Forward-compatible-by-default:** an empty :class:`MigrationChain`
(the bootstrap state for every store today) means "any
``version < current`` is a hard error pointing at the
``--reset / manual edit`` recovery path". The store owners
register real migrations only when they actually bump the schema
— OP-10's deliverable is the contract + the rejection-shape, not
a phantom v0→v1 migration nobody asked for.

**Why a shared helper:** keeps the four loaders consistent on the
error wording, version-comparison ordering, and the "what counts
as a downgrade" rule. A future bump of `schedules.yaml` shouldn't
need a parallel correctness review of how `hooks.yaml` does the
same thing.
"""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from mgf.vm.core.exceptions import LabSpecError

if TYPE_CHECKING:
    from pathlib import Path

# A migration step is a function that takes the v(N) raw dict
# and returns the v(N+1) raw dict. The function MUST NOT mutate
# the input (we copy defensively in :func:`check_and_migrate`)
# and MUST NOT touch disk — the loader runs migrations on the
# parsed YAML, then re-validates the result against the v(N+1)
# schema.
MigrationStep = Callable[[dict[str, object]], dict[str, object]]


@dataclass
class MigrationChain:
    """Ordered registry of schema-bump migrations for one store.

    Construct one of these at the top of every store module that
    carries a versioned YAML. Each migration is registered with
    the source version it transforms — so a ``v1 → v2`` migration
    is registered as ``from_version=1``. The chain runs every
    matching step in order; a file at v1 going to v3 runs v1→v2
    then v2→v3 back-to-back, with the v1→v2 output validated
    against the v2 schema by the migration itself.

    The bootstrap state (no migrations registered) means "any
    version below the current is a hard error". That's the
    correct shape for v1 — nothing's been bumped yet. The first
    real bump registers a v1→v2 step + bumps ``CURRENT_X_VERSION``;
    after that, ``check_and_migrate`` walks the chain transparently.
    """

    _steps: dict[int, MigrationStep] = field(default_factory=dict)

    def register(self, *, from_version: int) -> Callable[[MigrationStep], MigrationStep]:
        """Register a ``from_version → from_version + 1`` step.

        Decorator form::

            chain = MigrationChain()

            @chain.register(from_version=1)
            def _v1_to_v2(raw: dict[str, object]) -> dict[str, object]:
                ...
                return upgraded
        """

        def decorator(fn: MigrationStep) -> MigrationStep:
            if from_version in self._steps:
                msg = f"migration v{from_version} → v{from_version + 1} already registered"
                raise ValueError(msg)
            self._steps[from_version] = fn
            return fn

        return decorator

    def apply(
        self, raw: dict[str, object], *, from_version: int, to_version: int
    ) -> dict[str, object]:
        """Run every step from ``from_version`` up to ``to_version``."""
        current = dict(raw)  # defensive copy — callers may keep the original
        for v in range(from_version, to_version):
            step = self._steps.get(v)
            if step is None:
                # Should never happen — ``check_and_migrate`` already
                # validated coverage. Raise loudly if it does.
                msg = (
                    f"internal error: migration chain is missing step "
                    f"v{v} → v{v + 1}; coverage check failed"
                )
                raise RuntimeError(msg)
            current = step(current)
        return current

    def covers(self, *, from_version: int, to_version: int) -> bool:
        """``True`` iff every step from ``from_version`` (inclusive)
        through ``to_version - 1`` (inclusive) is registered."""
        return all(v in self._steps for v in range(from_version, to_version))


def check_and_migrate(
    raw: dict[str, object],
    *,
    current_version: int,
    store_name: str,
    chain: MigrationChain | None = None,
    path: Path | None = None,
) -> dict[str, object]:
    """Validate the version field + run any necessary migration.

    Parameters
    ----------
    raw:
        The parsed YAML dict, top-level.
    current_version:
        The version this VManager build supports for the store.
    store_name:
        Display name used in error messages (e.g. ``"hooks.yaml"``,
        ``"schedules.yaml"``). Goes verbatim into the
        ``LabSpecError`` text so the user can find the file.
    chain:
        Optional :class:`MigrationChain`. ``None`` is treated the
        same as an empty chain — every below-current version is a
        hard error.
    path:
        Optional path included in the error message for ergonomics.

    Returns
    -------
    dict
        The raw dict at the current schema version. May be the
        input ``raw`` unchanged (if already at the current
        version) or a new dict produced by the chain.

    Raises
    ------
    :class:`mgf.vm.exceptions.LabSpecError`
        On any of: missing ``version`` field, non-int version,
        a downgrade (file written by a newer build), or a lower
        version with no migration registered. The message names
        the store, the observed vs expected version, and the
        recovery path (re-create the file or wait for a future
        release with the migration).
    """
    where = f"{store_name}" + (f" ({path})" if path is not None else "")
    raw_version = raw.get("version")
    if raw_version is None:
        msg = f"{where}: missing required 'version' field (expected {current_version})"
        raise LabSpecError(msg)
    if not isinstance(raw_version, int) or isinstance(raw_version, bool):
        msg = (
            f"{where}: 'version' must be an int, got {type(raw_version).__name__} ({raw_version!r})"
        )
        raise LabSpecError(msg)

    if raw_version == current_version:
        return raw

    if raw_version > current_version:
        msg = (
            f"{where}: schema version {raw_version} is newer than this "
            f"VManager's supported version ({current_version}). The "
            f"file was written by a newer release; either upgrade "
            f"VManager or restore an older copy of this file."
        )
        raise LabSpecError(msg)

    # raw_version < current_version — needs migration.
    if chain is None or not chain.covers(
        from_version=raw_version,
        to_version=current_version,
    ):
        msg = (
            f"{where}: schema version {raw_version} is older than this "
            f"VManager's supported version ({current_version}) and no "
            f"migration is registered for that step. Back up the file "
            f"and recreate it via the relevant CLI (e.g. "
            f"`vmanager schedule add`, `vmanager hook add`, "
            f"`vmanager alert add`, `vmanager vm port-forward add`)."
        )
        raise LabSpecError(msg)

    return chain.apply(
        raw,
        from_version=raw_version,
        to_version=current_version,
    )


__all__ = [
    "MigrationChain",
    "MigrationStep",
    "check_and_migrate",
]
