"""Tests for recoil/core/config_schema.py.

Verifies that:
  1. Each of the 9 registered config files loads successfully through its
     Pydantic schema.
  2. validate_and_load() raises the right exception types on bad inputs.
  3. The `extra='forbid'` default on _BaseConfigSchema rejects typos in
     subclasses that opt in.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from recoil.core.exceptions import SchemaValidationError
from recoil.core.config_schema import (
    SCHEMAS,
    _BaseConfigSchema,
    validate_and_load,
)

CONFIG_DIR = Path(__file__).resolve().parent.parent.parent / "config"


@pytest.mark.parametrize("schema_name,filename", [
    ("pipeline_config", "pipeline_config.json"),
    ("model_profiles", "model_profiles.json"),
    ("provider_strategy", "provider_strategy.json"),
    ("pricing_rates", "pricing_rates.json"),
    ("model_roles", "model_roles.json"),
    ("prompt_constants", "prompt_constants.json"),
    ("lexicon", "lexicon.json"),
    ("ref_descriptors", "ref_descriptors.json"),
    ("prompt_bible", "PROMPT_BIBLE.yaml"),
])
def test_each_config_loads(schema_name: str, filename: str) -> None:
    """Every registered config file loads cleanly through its schema."""
    path = CONFIG_DIR / filename
    assert path.is_file(), f"Missing config: {path}"
    data = validate_and_load(path, schema_name)
    assert isinstance(data, dict)


def test_schemas_registry_has_nine_entries() -> None:
    """SCHEMAS must register exactly the 9 known config files."""
    assert len(SCHEMAS) == 9
    expected = {
        "pipeline_config",
        "model_profiles",
        "provider_strategy",
        "pricing_rates",
        "model_roles",
        "prompt_constants",
        "lexicon",
        "ref_descriptors",
        "prompt_bible",
    }
    assert set(SCHEMAS.keys()) == expected


def test_unknown_schema_name_raises() -> None:
    """validate_and_load with an unregistered schema_name raises."""
    with pytest.raises(SchemaValidationError, match="Unknown schema_name"):
        validate_and_load(
            CONFIG_DIR / "pipeline_config.json", "nonexistent_schema"
        )


def test_missing_file_raises_filenotfound() -> None:
    """validate_and_load with a missing file raises FileNotFoundError."""
    with pytest.raises(FileNotFoundError):
        validate_and_load(
            CONFIG_DIR / "DOES_NOT_EXIST.json", "pipeline_config"
        )


def test_malformed_json_raises_schema_error(tmp_path: Path) -> None:
    """Parser failures get wrapped as SchemaValidationError."""
    bad = tmp_path / "bad.json"
    bad.write_text("{not valid json")
    with pytest.raises(SchemaValidationError, match="failed to parse"):
        validate_and_load(bad, "pipeline_config")


def test_malformed_yaml_raises_schema_error(tmp_path: Path) -> None:
    """YAML parser failures also get wrapped as SchemaValidationError."""
    bad = tmp_path / "bad.yaml"
    # Unclosed quoted scalar — yaml.safe_load raises YAMLError.
    bad.write_text(":\n  - 'unterminated\n")
    with pytest.raises(SchemaValidationError, match="failed to parse"):
        validate_and_load(bad, "prompt_bible")


def test_provider_strategy_requires_schema_version(tmp_path: Path) -> None:
    """provider_strategy.json must have schema_version (int).

    Legacy `__version__` is no longer aliased — passing it instead raises
    SchemaValidationError because (a) schema_version is required and
    (b) extra='allow' lets __version__ pass but doesn't satisfy the
    required schema_version field.
    """
    sample = tmp_path / "provider_strategy.json"
    sample.write_text('{"schema_version": 1, "kling-v3": {}}')
    data = validate_and_load(sample, "provider_strategy")
    assert data["schema_version"] == 1

    legacy = tmp_path / "provider_strategy_legacy.json"
    legacy.write_text('{"__version__": "1.0", "kling-v3": {}}')
    with pytest.raises(SchemaValidationError):
        validate_and_load(legacy, "provider_strategy")


def test_pricing_rates_requires_schema_version(tmp_path: Path) -> None:
    """pricing_rates.json must have schema_version (int).

    Legacy `version` key no longer aliases — using it raises.
    """
    sample = tmp_path / "pricing_rates.json"
    sample.write_text('{"schema_version": 1, "rate_cards": []}')
    data = validate_and_load(sample, "pricing_rates")
    assert data["schema_version"] == 1
    assert data["rate_cards"] == []

    legacy = tmp_path / "pricing_rates_legacy.json"
    legacy.write_text('{"version": 1, "rate_cards": []}')
    with pytest.raises(SchemaValidationError):
        validate_and_load(legacy, "pricing_rates")


def test_pricing_rates_required_extra_field_blocked_when_typo() -> None:
    """`note` is an explicit Optional[str]; non-string types raise."""
    from recoil.core.config_schema import PricingRatesSchema
    from pydantic import ValidationError

    with pytest.raises(ValidationError):
        PricingRatesSchema.model_validate(
            {"schema_version": 1, "note": 123, "rate_cards": []}
        )


def test_base_schema_forbids_extra_when_subclass_does_not_override() -> None:
    """_BaseConfigSchema default extra='forbid' should reject unknown keys.

    Subclasses with extra='allow' opt out, but a fresh _BaseConfigSchema
    subclass (no override) should fail loud on typo'd top-level fields.
    """

    class _StrictTestSchema(_BaseConfigSchema):
        known_field: str = "default"

    # Inherits extra='forbid' from base — typo'd field should reject.
    from pydantic import ValidationError

    with pytest.raises(ValidationError):
        _StrictTestSchema.model_validate(
            {"known_field": "x", "TYPO_FIELD": "y"}
        )


def test_model_profiles_loads_dict_of_models() -> None:
    """model_profiles.json round-trips with model_id keys preserved."""
    data = validate_and_load(
        CONFIG_DIR / "model_profiles.json", "model_profiles"
    )
    assert isinstance(data, dict)
    # Sanity: at least one well-known model_id is present.
    # (extra='allow' surfaces unknown top-level keys via model_extra.)
    # We just check the dict isn't empty — actual SSOT enforcement is Phase 8.
    assert len(data) > 0
