"""User-perspective tests for ``mgf.common.versioned``.

Surfaces exercised:
  - :class:`VersionedFileStore` — load + save round-trip.
  - :class:`VersionedFileStore` — single-step migration via the
    public ``MigrateFn`` callback type.
  - :class:`VersionedFileStore` — schema validation surfaces as
    :class:`AppConfigError` with PEP 678 notes.

The closed-box rule is enforced by ``test_no_private_imports.py`` —
this file uses only ``from mgf.common.versioned import ...``.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

import pytest
from pydantic import BaseModel, ConfigDict

from mgf.common.exceptions import AppConfigError
from mgf.common.versioned import MigrateFn, VersionedFileStore

if TYPE_CHECKING:
    from collections.abc import Mapping
    from pathlib import Path


class _Hook(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)
    name: str
    command: str


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


class TestUserShape:
    def test_round_trip_yaml(self, tmp_path: Path) -> None:
        store: VersionedFileStore[HooksFile] = VersionedFileStore(
            tmp_path / "hooks.yaml",
            schema=HooksFile,
            current_version=1,
        )
        original = HooksFile(version=1, hooks=[_Hook(name="x", command="y")])
        store.save(original)
        assert store.load() == original

    def test_round_trip_json(self, tmp_path: Path) -> None:
        store: VersionedFileStore[HooksFile] = VersionedFileStore(
            tmp_path / "hooks.json",
            schema=HooksFile,
            current_version=1,
        )
        original = HooksFile(version=1)
        store.save(original)
        assert store.load() == original

    def test_migration_callback_type_alias_usable(
        self,
        tmp_path: Path,
    ) -> None:
        """The ``MigrateFn`` re-export is callable-shaped + assignable."""
        path = tmp_path / "hooks.yaml"
        path.write_text("version: 1\nhooks:\n  - {name: x, cmd: y}\n")

        def migrate(raw: Mapping[str, Any], from_version: int) -> Mapping[str, Any]:
            assert from_version == 1
            return {
                **raw,
                "hooks": [{"name": h["name"], "command": h["cmd"]} for h in raw["hooks"]],
            }

        # Type-level: the function fits MigrateFn.
        callback: MigrateFn = migrate

        store: VersionedFileStore[HooksFile] = VersionedFileStore(
            path,
            schema=HooksFile,
            current_version=2,
            migrate=callback,
        )
        loaded = store.load()
        assert list(loaded.hooks) == [_Hook(name="x", command="y")]

    def test_validation_failure_raises_app_config_error_with_notes(
        self,
        tmp_path: Path,
    ) -> None:
        path = tmp_path / "hooks.yaml"
        path.write_text("version: 1\nhooks:\n  - {name: x}\n")  # missing command

        store: VersionedFileStore[HooksFile] = VersionedFileStore(
            path,
            schema=HooksFile,
            current_version=1,
        )
        with pytest.raises(AppConfigError) as info:
            store.load()
        assert "failed schema validation" in str(info.value)
        # PEP 678 notes name the offending field path.
        assert any("hooks" in n for n in info.value.__notes__)

    def test_missing_file_raises_app_config_error(
        self,
        tmp_path: Path,
    ) -> None:
        store: VersionedFileStore[HooksFile] = VersionedFileStore(
            tmp_path / "no-such.yaml",
            schema=HooksFile,
            current_version=1,
        )
        assert store.exists() is False
        with pytest.raises(AppConfigError, match="not found"):
            store.load()
