"""Layer 3 — Nested sub-models + discriminated union + schema migration.

Production-shaped configuration. Demonstrates the patterns from the
CONFIGURATION standard (mgf-common standards CONFIGURATION):

  • Nested sub-models for grouped fields (LoggingSettings,
    StorageSettings) — readable, env-var-mappable via the nested
    delimiter, validated independently.

  • Discriminated unions (rule CF-12) for mutually-exclusive
    backend variants. Pydantic uses the `kind` field to pick the
    right model class; each variant carries only its own backend's
    settings; `extra="forbid"` rejects fields belonging to a
    different backend.

  • Schema versioning + migration callback (rule CF-03). Loading
    a file with `version: 1` triggers the registered migration
    that brings it up to `version: 2`; the file is rewritten with
    the migrated content (the loader handles the rewrite).

  • Field aliases for renames-without-breaks (rule CF-11). The
    legacy field name `connect_timeout_s` is still accepted; the
    canonical name is `connection_timeout_seconds`. After two
    MINOR cycles, the alias is removed in MAJOR.

  • Atomic write + 0o600 (rule CF-04). save_settings does both;
    a crash mid-write never leaves a truncated file.

Run with::

    python examples/02_config/03_advanced.py
"""

from __future__ import annotations

import os
import tempfile
from pathlib import Path
from typing import Annotated, Any, Literal, Union

import yaml
from pydantic import AliasChoices, BaseModel, ConfigDict, Field

from mgf.common.config import (
    BaseAppSettings,
    load_settings,
    settings_config,
)

# ─────────────────────────────────────────────────────────────────
# Sub-models — group related fields
# ─────────────────────────────────────────────────────────────────
#
# Each sub-model uses pydantic's BaseModel (NOT BaseAppSettings)
# because they're inner objects, not standalone settings sources.
# The outer Settings class composes them.


class LoggingSettings(BaseModel):
    """Logging-related fields."""

    model_config = ConfigDict(extra="forbid")  # rule CF-06

    level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
    format: Literal["text", "json", "auto"] = "auto"


class StorageSettings(BaseModel):
    """Storage / path settings — paths expanded lazily at use."""

    model_config = ConfigDict(extra="forbid")

    image_store: Path = Path("~/images")
    state_dir: Path = Path("~/.local/state/myapp")


# ─────────────────────────────────────────────────────────────────
# Discriminated union for vault backends (rule CF-12)
# ─────────────────────────────────────────────────────────────────
#
# Three mutually-exclusive variants. The `kind` field is the
# discriminator: Pydantic uses it to pick the right model class on
# load. Adding a new backend is a new model class + one entry in
# the Union — no change to existing variants.


class KeyringVaultConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    kind: Literal["keyring"] = "keyring"
    service: str = "myapp"


class FileVaultConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    kind: Literal["file"] = "file"
    path: Path = Path("~/.local/share/myapp/vault.enc")
    passphrase_env: str = "MYAPP_VAULT_PASSPHRASE"


class KMSVaultConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    kind: Literal["kms"] = "kms"
    endpoint: str
    key_id: str


VaultConfig = Annotated[
    Union[KeyringVaultConfig, FileVaultConfig, KMSVaultConfig],
    Field(discriminator="kind"),
]


# ─────────────────────────────────────────────────────────────────
# Top-level Settings
# ─────────────────────────────────────────────────────────────────


CURRENT_SCHEMA_VERSION = 2


class Settings(BaseAppSettings):
    """Single typed source of truth for myapp config.

    Rule CF-08: load once at the entry point and pass down by
    constructor injection — never read env vars or the config file
    from anywhere else.

    Rule CF-03: ``CURRENT_VERSION`` here MUST equal the bumped
    schema version; loading a higher version fails loud, lower
    version triggers the registered migration.
    """

    CURRENT_VERSION = CURRENT_SCHEMA_VERSION

    model_config = settings_config(env_prefix="MYAPP_")

    # Sub-models composed in via Field(default_factory=...) so each
    # instance gets a fresh sub-model (not the class default).
    logging: LoggingSettings = Field(default_factory=LoggingSettings)
    storage: StorageSettings = Field(default_factory=StorageSettings)
    vault: VaultConfig = Field(default_factory=KeyringVaultConfig)

    # Top-level fields with type-narrow validation.
    backend_uri: str = "qemu:///system"

    # Rule CF-11: renames-without-breaks. The new canonical name is
    # `connection_timeout_seconds`. Legacy on-disk configs may use
    # `connect_timeout_s`; the AliasChoices accepts both. The alias
    # is removed in v3.0 of this app's schema.
    connection_timeout_seconds: int = Field(
        default=30,
        ge=1,
        le=600,
        validation_alias=AliasChoices(
            "connection_timeout_seconds",  # canonical (new)
            "connect_timeout_s",  # legacy (still accepted)
        ),
    )


# ─────────────────────────────────────────────────────────────────
# Migration callback (rule CF-03)
# ─────────────────────────────────────────────────────────────────
#
# Walks the raw dict from a file at ``from_version`` to
# ``CURRENT_SCHEMA_VERSION``. Each step reads the current shape
# and writes the next; steps are idempotent and ordered.


def migrate(raw: dict[str, Any], from_version: int) -> dict[str, Any]:
    """Migrate the raw config dict to the current schema version.

    v1 → v2: split the legacy flat ``timeout_ms`` field into
    ``connection_timeout_seconds`` (the new typed field).
    """
    if from_version == 1:
        old_ms = raw.pop("timeout_ms", 30_000)
        raw["connection_timeout_seconds"] = max(1, old_ms // 1000)
        raw["version"] = 2
        from_version = 2
    return raw


# ─────────────────────────────────────────────────────────────────
# main() — exercises every pattern
# ─────────────────────────────────────────────────────────────────


def main() -> None:
    with tempfile.TemporaryDirectory() as tmp:
        tmp_dir = Path(tmp)
        path = tmp_dir / "config.yaml"

        # ── 1. Build from defaults ──────────────────────────
        print("─── 1. Defaults ───────────────────────────")
        s = Settings()
        print(f"  logging.level = {s.logging.level!r}")
        print(f"  storage.state_dir = {s.storage.state_dir}")
        print(f"  vault.kind = {s.vault.kind!r}  (keyring backend)")
        print(f"  connection_timeout_seconds = {s.connection_timeout_seconds}")

        # ── 2. Discriminated union — switch the backend ─────
        print("\n─── 2. Discriminated union ────────────────")
        s_file_vault = Settings(
            vault=FileVaultConfig(path=Path(tmp) / "vault.enc"),
        )
        print(f"  vault.kind = {s_file_vault.vault.kind!r}")
        print(f"  vault.path = {s_file_vault.vault.path}")
        # Pydantic picks the right model class via the `kind` discriminator;
        # passing a `path` to a KeyringVaultConfig would raise.

        # ── 3. Save (atomic + 0o600 — rule CF-04) ──────────
        #
        # NOTE: mgf.common.save_settings uses BaseAppSettings.to_yaml_dict
        # which is shallow — it does NOT recurse into nested BaseModel
        # sub-fields (LoggingSettings, the discriminated VaultConfig, etc.).
        # That's a known limitation worth filing in FEEDBACK.md. For now,
        # serialise via model_dump(mode="json") — the recommended path
        # per to_yaml_dict's own docstring — and write atomically.
        print("\n─── 3. Save (atomic + 0o600) ──────────────")
        payload = s.model_dump(mode="json")
        payload["version"] = CURRENT_SCHEMA_VERSION
        # Atomic temp + rename, mode 0o600 (rule CF-04 / SC-04).
        tmp_file = path.with_suffix(path.suffix + ".tmp")
        tmp_file.write_text(yaml.safe_dump(payload, sort_keys=False))
        tmp_file.chmod(0o600)
        tmp_file.replace(path)
        mode = oct(path.stat().st_mode)[-3:]
        print(f"  wrote {path.name}: mode {mode}")
        print(f"  contents preview:")
        for line in path.read_text().splitlines()[:10]:
            print(f"    {line}")

        # ── 4. Reload — round-trip key fields ──────────────
        # Note: full `==` equality across nested sub-models can be
        # subtle (e.g., `Path("~/...")` round-trips through yaml as
        # an unexpanded string + the discriminated union picks the
        # KeyringVaultConfig variant on load). Compare the load-bearing
        # fields explicitly so the example's contract is clear.
        print("\n─── 4. Round-trip ─────────────────────────")
        loaded = load_settings(Settings, path=path)
        assert loaded.backend_uri == s.backend_uri, "backend_uri drift"
        assert loaded.connection_timeout_seconds == s.connection_timeout_seconds
        assert loaded.vault.kind == s.vault.kind, "vault kind drift"
        assert loaded.logging.level == s.logging.level, "logging.level drift"
        print(
            f"  loaded.backend_uri = {loaded.backend_uri!r}  ✓"
            f"  loaded.vault.kind = {loaded.vault.kind!r}  ✓"
        )

        # ── 5. Schema migration (v1 → v2) ──────────────────
        print("\n─── 5. Schema migration v1 → v2 ───────────")
        legacy_path = tmp_dir / "legacy.yaml"
        # A v1-shaped on-disk config the migration can lift.
        legacy_path.write_text(
            yaml.safe_dump(
                {
                    "version": 1,
                    "backend_uri": "qemu:///session",
                    "timeout_ms": 45_000,  # v1 field
                }
            )
        )
        migrated = load_settings(Settings, path=legacy_path, migrate=migrate)
        print(f"  v1 timeout_ms=45000 → v2 connection_timeout_seconds={migrated.connection_timeout_seconds}")
        print(f"  loaded version: {migrated.version}")
        # The loader rewrites the file with the migrated content,
        # so subsequent loads see the v2 shape directly.

        # ── 6. Field alias accepts the legacy name (rule CF-11) ─
        print("\n─── 6. Legacy field-name alias ────────────")
        alias_path = tmp_dir / "alias.yaml"
        alias_path.write_text(
            yaml.safe_dump(
                {
                    "version": CURRENT_SCHEMA_VERSION,
                    "backend_uri": "qemu:///system",
                    "connect_timeout_s": 60,  # legacy name
                }
            )
        )
        with_alias = load_settings(Settings, path=alias_path)
        print(f"  legacy 'connect_timeout_s: 60' → connection_timeout_seconds={with_alias.connection_timeout_seconds}")
        # On the next save, the file would be rewritten with the
        # canonical name (the model dumps the canonical form).

        # ── 7. Env override via MYAPP_BACKEND_URI ──────────
        print("\n─── 7. Env override ───────────────────────")
        os.environ["MYAPP_BACKEND_URI"] = "qemu:///session"
        try:
            env_overridden = Settings()
            print(f"  MYAPP_BACKEND_URI=qemu:///session → backend_uri={env_overridden.backend_uri!r}")
        finally:
            del os.environ["MYAPP_BACKEND_URI"]


if __name__ == "__main__":
    main()
