"""Tests for :mod:`mgf.common.config._settings` + :mod:`mgf.common.config._loader`.

Uses a small concrete :class:`BaseAppSettings` subclass declared
inside this file so the tests exercise the round-trip loader,
version-check validator, and source customisation against a real
subclass — without coupling the mgf-common test suite to any
consumer-project schema.
"""

from __future__ import annotations

from pathlib import Path
from typing import ClassVar

import pytest
import yaml
from pydantic import Field, ValidationError, field_validator

from mgf.common.config import (
    BaseAppSettings,
    expand_path,
    load_settings,
    save_settings,
    settings_config,
)
from mgf.common.exceptions import AppConfigError


class _TestSettings(BaseAppSettings):
    """Minimal concrete subclass for testing.

    Uses :func:`settings_config` to set its own ``env_prefix`` —
    this is the canonical pattern documented for consumer projects,
    so exercising it across the whole loader test class doubles as
    an integration test for the helper.
    """

    model_config = settings_config(env_prefix="MGFTEST_")

    CURRENT_VERSION: ClassVar[int] = 1

    name: str = "default"
    timeout_seconds: int = Field(default=30, ge=0)
    home_dir: Path = Path("~/mgftest")

    @field_validator("home_dir", mode="before")
    @classmethod
    def _expand(cls, v: object) -> object:
        return expand_path(v)


# ---------------------------------------------------------------------------
# Defaults + direct construction
# ---------------------------------------------------------------------------


class TestDefaults:
    def test_defaults(self) -> None:
        cfg = _TestSettings()
        assert cfg.name == "default"
        assert cfg.timeout_seconds == 30
        assert cfg.version == 1

    def test_path_field_is_expanded(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("HOME", "/home/tester")
        cfg = _TestSettings()
        assert str(cfg.home_dir).startswith("/home/tester")

    def test_rejects_negative_int(self) -> None:
        # v0.5 Phase C: pydantic ValidationError is wrapped into
        # AppConfigError; the original ValidationError is preserved
        # via __cause__.
        with pytest.raises(AppConfigError, match="timeout_seconds") as excinfo:
            _TestSettings(timeout_seconds=-1)
        assert isinstance(excinfo.value.__cause__, ValidationError)

    def test_unknown_kwarg_rejected(self) -> None:
        # v0.5 Phase C: ValidationError wrapped in AppConfigError.
        with pytest.raises(AppConfigError, match=r"extra_forbidden|extra|typo_field"):
            _TestSettings(typo_field="x")  # type: ignore[call-arg]


# ---------------------------------------------------------------------------
# YAML round-trip
# ---------------------------------------------------------------------------


class TestRoundTrip:
    def test_default_round_trip(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("HOME", str(tmp_path / "home"))
        cfg = _TestSettings()
        path = tmp_path / "config.yaml"
        save_settings(cfg, path)
        loaded = load_settings(_TestSettings, path=path)
        for name in _TestSettings.model_fields:
            assert getattr(loaded, name) == getattr(cfg, name), name

    def test_custom_values_round_trip(self, tmp_path: Path) -> None:
        cfg = _TestSettings(name="custom", timeout_seconds=120)
        path = tmp_path / "config.yaml"
        save_settings(cfg, path)
        loaded = load_settings(_TestSettings, path=path)
        assert loaded.name == "custom"
        assert loaded.timeout_seconds == 120

    def test_save_writes_0600_permissions(self, tmp_path: Path) -> None:
        path = tmp_path / "config.yaml"
        save_settings(_TestSettings(), path)
        mode = path.stat().st_mode & 0o777
        assert mode == 0o600

    def test_save_creates_parent_dir(self, tmp_path: Path) -> None:
        nested = tmp_path / "a" / "b" / "c" / "config.yaml"
        save_settings(_TestSettings(), nested)
        assert nested.exists()

    def test_save_is_atomic_on_failure(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        target = tmp_path / "config.yaml"
        save_settings(_TestSettings(), target)
        original_content = target.read_text()

        def boom(*args: object, **kwargs: object) -> None:
            raise RuntimeError("disk full")

        monkeypatch.setattr("mgf.common.config._loader.yaml.safe_dump", boom)
        with pytest.raises(RuntimeError, match="disk full"):
            save_settings(_TestSettings(name="other"), target)

        # Original file preserved + no temp leftovers.
        assert target.read_text() == original_content
        leftovers = [p for p in tmp_path.iterdir() if p.name.startswith(".config.yaml.")]
        assert leftovers == []

    def test_yaml_serialises_paths_as_strings(self, tmp_path: Path) -> None:
        path = tmp_path / "config.yaml"
        save_settings(_TestSettings(), path)
        raw = yaml.safe_load(path.read_text())
        assert isinstance(raw["home_dir"], str)


# ---------------------------------------------------------------------------
# Loader corner cases
# ---------------------------------------------------------------------------


class TestLoader:
    def test_path_none_returns_defaults_plus_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("MGFTEST_NAME", "from-env")
        cfg = load_settings(_TestSettings, path=None)
        assert cfg.name == "from-env"

    def test_missing_file_returns_defaults(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.setenv("HOME", str(tmp_path / "home"))
        path = tmp_path / "does-not-exist.yaml"
        cfg = load_settings(_TestSettings, path=path)
        assert cfg == _TestSettings()

    def test_empty_file_returns_defaults(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.setenv("HOME", str(tmp_path / "home"))
        path = tmp_path / "config.yaml"
        path.write_text("")
        cfg = load_settings(_TestSettings, path=path)
        assert cfg == _TestSettings()

    def test_whitespace_only_file_returns_defaults(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.setenv("HOME", str(tmp_path / "home"))
        path = tmp_path / "config.yaml"
        path.write_text("   \n\n")
        cfg = load_settings(_TestSettings, path=path)
        assert cfg == _TestSettings()

    def test_unknown_key_is_rejected(self, tmp_path: Path) -> None:
        path = tmp_path / "config.yaml"
        path.write_text("typo_field: value\n")
        with pytest.raises(AppConfigError, match="typo_field"):
            load_settings(_TestSettings, path=path)

    def test_malformed_yaml_raises_app_config_error(self, tmp_path: Path) -> None:
        path = tmp_path / "config.yaml"
        path.write_text("{{{ not yaml")
        with pytest.raises(AppConfigError, match="could not parse"):
            load_settings(_TestSettings, path=path)

    def test_top_level_list_rejected(self, tmp_path: Path) -> None:
        path = tmp_path / "config.yaml"
        path.write_text("- 1\n- 2\n")
        with pytest.raises(AppConfigError, match="mapping"):
            load_settings(_TestSettings, path=path)

    def test_non_string_top_level_keys_rejected(self, tmp_path: Path) -> None:
        path = tmp_path / "config.yaml"
        # ``?`` in YAML is a bare key, parsed as ``None``.
        path.write_text("?\n")
        with pytest.raises(AppConfigError, match="non-string top-level keys"):
            load_settings(_TestSettings, path=path)

    def test_partial_file_merges_with_defaults(self, tmp_path: Path) -> None:
        path = tmp_path / "config.yaml"
        path.write_text("timeout_seconds: 60\n")
        cfg = load_settings(_TestSettings, path=path)
        assert cfg.timeout_seconds == 60
        assert cfg.name == "default"  # untouched

    def test_future_version_rejected(self, tmp_path: Path) -> None:
        path = tmp_path / "config.yaml"
        path.write_text("version: 999\n")
        with pytest.raises(AppConfigError, match="newer than supported"):
            load_settings(_TestSettings, path=path)

    def test_non_int_version_rejected(self, tmp_path: Path) -> None:
        path = tmp_path / "config.yaml"
        path.write_text("version: '1'\n")
        with pytest.raises(AppConfigError, match="version must be an int"):
            load_settings(_TestSettings, path=path)

    def test_loader_wraps_validation_errors(self, tmp_path: Path) -> None:
        path = tmp_path / "config.yaml"
        path.write_text("timeout_seconds: -5\n")
        with pytest.raises(AppConfigError, match="timeout_seconds"):
            load_settings(_TestSettings, path=path)


# ---------------------------------------------------------------------------
# Migration callback
# ---------------------------------------------------------------------------


class _MigratableSettings(BaseAppSettings):
    """A subclass with CURRENT_VERSION = 2 to exercise migration."""

    model_config = settings_config(env_prefix="MGFTEST_")

    CURRENT_VERSION: ClassVar[int] = 2

    name: str = "default"
    new_field: str = "post-migration-default"


class TestMigration:
    def test_migrate_callback_runs_for_older_version(self, tmp_path: Path) -> None:
        path = tmp_path / "config.yaml"
        path.write_text("version: 1\nname: pre-migration\n")

        def migrate(raw: dict[str, object], from_version: int) -> dict[str, object]:
            assert from_version == 1
            raw["new_field"] = "from-migration"
            raw["version"] = 2
            return raw

        cfg = load_settings(_MigratableSettings, path=path, migrate=migrate)
        assert cfg.name == "pre-migration"
        assert cfg.new_field == "from-migration"
        # File on disk is rewritten with version=2.
        on_disk = yaml.safe_load(path.read_text())
        assert on_disk["version"] == 2

    def test_no_migrate_callback_loads_old_version_as_is(self, tmp_path: Path) -> None:
        path = tmp_path / "config.yaml"
        path.write_text("version: 1\nname: pre-migration\n")
        cfg = load_settings(_MigratableSettings, path=path)
        # Loaded successfully; new_field gets its default.
        assert cfg.name == "pre-migration"
        assert cfg.new_field == "post-migration-default"

    def test_migrate_failure_carries_migrate_failed_kind(self, tmp_path: Path) -> None:
        """RA-13: when the consumer's migrate callback raises, the
        loader MUST wrap the failure as ``AppConfigError`` with
        ``kind == AppConfigErrorKind.MIGRATE_FAILED`` so consumer
        ``match e.kind`` patterns can branch on it without
        string-matching the message."""
        from mgf.common.exceptions import AppConfigErrorKind

        path = tmp_path / "config.yaml"
        path.write_text("version: 1\nname: pre-migration\n")

        def bad_migrate(raw: dict[str, object], from_version: int) -> dict[str, object]:
            raise RuntimeError("migrate cannot upgrade from this shape")

        with pytest.raises(AppConfigError) as exc_info:
            load_settings(_MigratableSettings, path=path, migrate=bad_migrate)

        assert exc_info.value.kind is AppConfigErrorKind.MIGRATE_FAILED
        # The original exception is chained via __cause__ so
        # consumers can introspect if needed.
        assert isinstance(exc_info.value.__cause__, RuntimeError)
        assert "migrate cannot upgrade" in str(exc_info.value.__cause__)


# ---------------------------------------------------------------------------
# Loader error-kind discrimination (RA-13)
# ---------------------------------------------------------------------------


class TestLoaderErrorKinds:
    """RA-13: every ``AppConfigError`` raised by ``load_settings``
    carries a typed ``kind`` discriminator so consumers can write
    ``match e.kind: case AppConfigErrorKind.X:`` without
    string-matching the message."""

    def test_yaml_parse_failure_kind(self, tmp_path: Path) -> None:
        from mgf.common.exceptions import AppConfigErrorKind

        path = tmp_path / "config.yaml"
        path.write_text("not: valid: yaml: at all: :::: !!\n")
        with pytest.raises(AppConfigError) as exc_info:
            load_settings(_TestSettings, path=path)
        assert exc_info.value.kind is AppConfigErrorKind.YAML_PARSE_FAILURE

    def test_non_dict_root_kind(self, tmp_path: Path) -> None:
        from mgf.common.exceptions import AppConfigErrorKind

        path = tmp_path / "config.yaml"
        path.write_text("- list\n- root\n")  # top-level YAML list
        with pytest.raises(AppConfigError) as exc_info:
            load_settings(_TestSettings, path=path)
        assert exc_info.value.kind is AppConfigErrorKind.NON_DICT_ROOT

    def test_missing_version_kind_when_non_int(self, tmp_path: Path) -> None:
        from mgf.common.exceptions import AppConfigErrorKind

        path = tmp_path / "config.yaml"
        path.write_text("version: not-an-int\nname: x\n")
        with pytest.raises(AppConfigError) as exc_info:
            load_settings(_TestSettings, path=path)
        assert exc_info.value.kind is AppConfigErrorKind.MISSING_VERSION

    def test_unsupported_version_kind(self, tmp_path: Path) -> None:
        from mgf.common.exceptions import AppConfigErrorKind

        path = tmp_path / "config.yaml"
        # _TestSettings.CURRENT_VERSION = 1; write 99 (future).
        path.write_text("version: 99\nname: x\n")
        with pytest.raises(AppConfigError) as exc_info:
            load_settings(_TestSettings, path=path)
        assert exc_info.value.kind is AppConfigErrorKind.UNSUPPORTED_VERSION


# ---------------------------------------------------------------------------
# Env-var precedence
# ---------------------------------------------------------------------------


class TestEnvVarPrecedence:
    def test_env_var_overrides_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("MGFTEST_NAME", "from-env")
        cfg = _TestSettings()
        assert cfg.name == "from-env"

    def test_construction_kwarg_wins_over_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("MGFTEST_NAME", "from-env")
        cfg = _TestSettings(name="from-kwarg")
        assert cfg.name == "from-kwarg"

    def test_env_var_overrides_yaml_file_value(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.setenv("MGFTEST_NAME", "from-env")
        path = tmp_path / "config.yaml"
        path.write_text("name: from-yaml\n")
        cfg = load_settings(_TestSettings, path=path)
        assert cfg.name == "from-env"

    def test_env_var_typed_int(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("MGFTEST_TIMEOUT_SECONDS", "90")
        cfg = _TestSettings()
        assert cfg.timeout_seconds == 90


# ---------------------------------------------------------------------------
# Subclassing patterns (rule CF-01) — guard the canonical helper-based
# pattern AND pin the broken `**spread` failure mode so a future reader
# doesn't try to "simplify" the base by exposing the model_config dict
# for spreading.
# ---------------------------------------------------------------------------


class TestSubclassPatterns:
    def test_settings_config_applies_baseline(self) -> None:
        """The helper sets every key documented in the docstring."""
        cfg = settings_config(env_prefix="X_")
        assert cfg["env_prefix"] == "X_"
        assert cfg["env_file"] == ".env"
        assert cfg["env_file_encoding"] == "utf-8"
        assert cfg["extra"] == "forbid"
        assert cfg["case_sensitive"] is False
        assert cfg["validate_default"] is True
        assert cfg["arbitrary_types_allowed"] is True

    def test_settings_config_overrides_win(self) -> None:
        """Caller overrides take precedence over the baseline defaults."""
        cfg = settings_config(extra="ignore", env_prefix="X_")
        assert cfg["extra"] == "ignore"

    def test_helper_subclass_constructs_and_reads_env(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """End-to-end: helper-built subclass picks up its env_prefix."""

        class _HelperSettings(BaseAppSettings):
            model_config = settings_config(env_prefix="HELPER_")
            name: str = "default"

        monkeypatch.setenv("HELPER_NAME", "from-helper-env")
        assert _HelperSettings().name == "from-helper-env"

    def test_no_override_subclass_inherits_base_config(self) -> None:
        """A subclass that omits ``model_config`` inherits the base's
        (with empty ``env_prefix``) — useful for trivial subclasses."""

        class _PlainSettings(BaseAppSettings):
            name: str = "default"

        # Inherits the base ``model_config``; ``env_prefix`` is the
        # base default (empty string).
        assert _PlainSettings.model_config.get("env_prefix") == ""
        assert _PlainSettings.model_config.get("extra") == "forbid"
        # Constructs cleanly — proves the inherited config is valid.
        assert _PlainSettings().name == "default"

    def test_naive_spread_pattern_is_broken(self) -> None:
        """Pin the failure mode of ``**BaseAppSettings.model_config``.

        pydantic-settings materialises ``model_config`` with a default
        for every option (``env_prefix=""`` among them), so spreading
        and re-supplying ``env_prefix`` raises TypeError. The
        :func:`settings_config` helper exists precisely to avoid
        this trap — if this test ever flips, the docstring + helper
        rationale need re-examining.
        """
        from pydantic_settings import SettingsConfigDict

        with pytest.raises(TypeError, match="multiple values"):
            SettingsConfigDict(  # type: ignore[misc]
                **BaseAppSettings.model_config,
                env_prefix="WILL_FAIL_",
            )


class TestLoadSettingsConcurrency:
    """RA-04: concurrent ``load_settings`` calls against the SAME
    subclass must not cross-contaminate via the shared
    ``_yaml_file_override`` ClassVar.

    Without the ``_LOAD_SETTINGS_LOCK`` guard, two threads can race:
    thread A sets path-A → thread B sets path-B → thread A
    constructs (sees path-B's values) → thread A clears → thread B
    constructs (sees None and falls back to defaults). Either way,
    at least one of the two instances has values from the WRONG
    file. The test pins the contract by loading two distinct YAML
    files concurrently and asserting each instance carries its own
    file's values.
    """

    def test_concurrent_loads_against_same_subclass_do_not_cross_contaminate(
        self, tmp_path: Path
    ) -> None:
        import threading

        # Two distinct YAML files; same subclass.
        path_a = tmp_path / "a.yaml"
        path_b = tmp_path / "b.yaml"
        path_a.write_text(yaml.safe_dump({"version": 1, "name": "from-a", "timeout_seconds": 11}))
        path_b.write_text(yaml.safe_dump({"version": 1, "name": "from-b", "timeout_seconds": 22}))

        results: dict[str, _TestSettings] = {}
        errors: list[BaseException] = []
        # Run many iterations so the race is reliably exposed if
        # the lock is removed. With N=50 and 2 threads, an unguarded
        # race shows up in seconds; with the lock, the test passes
        # deterministically in well under a second.
        n_iters = 50

        def load_a() -> None:
            try:
                for _ in range(n_iters):
                    results["a"] = load_settings(_TestSettings, path=path_a)
                    assert results["a"].name == "from-a", (
                        f"thread A saw cross-contamination: got name="
                        f"{results['a'].name!r} (expected 'from-a')"
                    )
                    assert results["a"].timeout_seconds == 11
            except BaseException as e:
                errors.append(e)

        def load_b() -> None:
            try:
                for _ in range(n_iters):
                    results["b"] = load_settings(_TestSettings, path=path_b)
                    assert results["b"].name == "from-b", (
                        f"thread B saw cross-contamination: got name="
                        f"{results['b'].name!r} (expected 'from-b')"
                    )
                    assert results["b"].timeout_seconds == 22
            except BaseException as e:
                errors.append(e)

        thread_a = threading.Thread(target=load_a, name="loader-A")
        thread_b = threading.Thread(target=load_b, name="loader-B")
        thread_a.start()
        thread_b.start()
        thread_a.join(timeout=10.0)
        thread_b.join(timeout=10.0)

        assert not thread_a.is_alive(), "thread A did not complete in 10 s"
        assert not thread_b.is_alive(), "thread B did not complete in 10 s"
        assert not errors, f"concurrent load race exposed: {errors!r}"

    def test_override_is_cleared_after_failure(self, tmp_path: Path) -> None:
        """If construction raises mid-load, the ClassVar override
        MUST still be cleared (the ``finally`` branch). Otherwise
        the next load on the same subclass would silently pick up
        the failed call's path."""
        bad_path = tmp_path / "bad.yaml"
        bad_path.write_text(
            yaml.safe_dump({"version": 1, "name": "x", "timeout_seconds": -5})  # ge=0 fails
        )

        prior_override = _TestSettings._yaml_file_override
        with pytest.raises(AppConfigError):
            load_settings(_TestSettings, path=bad_path)
        # The ClassVar is restored to its pre-call value.
        assert _TestSettings._yaml_file_override is prior_override
