"""Tests for ``hermes migrate xai`` — apply path with ruamel round-trip."""
from __future__ import annotations

from pathlib import Path

import pytest

from hermes_cli.xai_retirement import (
    RetirementIssue,
    apply_migration,
    find_retired_xai_refs,
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture
def trap_config(tmp_path: Path) -> Path:
    """A config.yaml with retired models AND comments to verify round-trip."""
    p = tmp_path / "config.yaml"
    p.write_text(
        "# Hermes config (sample)\n"
        "principal:\n"
        "  provider: xai             # the main model\n"
        "  model: grok-4-1-fast-non-reasoning  # retiring May 15\n"
        "  temperature: 0.5\n"
        "auxiliary:\n"
        "  vision:\n"
        "    provider: xai\n"
        "    model: grok-4-fast-reasoning  # retiring\n"
        "  compression:\n"
        "    provider: openai         # not affected\n"
        "    model: gpt-4o-mini\n"
        "delegation:\n"
        "  model: grok-code-fast-1    # retiring\n"
        "plugins:\n"
        "  image_gen:\n"
        "    xai:\n"
        "      model: grok-imagine-image-pro  # retiring\n",
        encoding="utf-8",
    )
    return p


@pytest.fixture
def clean_config(tmp_path: Path) -> Path:
    p = tmp_path / "config.yaml"
    p.write_text(
        "principal:\n"
        "  provider: xai\n"
        "  model: grok-4.3\n",
        encoding="utf-8",
    )
    return p


def _parse(path: Path) -> dict:
    """Load with ruamel for assertion convenience."""
    from ruamel.yaml import YAML
    yaml = YAML(typ="rt")
    with path.open("r", encoding="utf-8") as fh:
        return yaml.load(fh)


# ---------------------------------------------------------------------------
# Dry-run / no-op
# ---------------------------------------------------------------------------

class TestNoOpPaths:
    def test_clean_config_returns_unchanged_result(self, clean_config: Path):
        issues = find_retired_xai_refs(_parse(clean_config))
        assert issues == []
        result = apply_migration(clean_config, issues)
        assert result.config_changed is False
        assert result.backup_path is None
        # File untouched
        assert "grok-4.3" in clean_config.read_text(encoding="utf-8")

    def test_empty_issues_list_is_noop(self, trap_config: Path):
        original = trap_config.read_text(encoding="utf-8")
        result = apply_migration(trap_config, issues=[])
        assert result.config_changed is False
        assert trap_config.read_text(encoding="utf-8") == original

    def test_missing_file_raises(self, tmp_path: Path):
        with pytest.raises(FileNotFoundError):
            apply_migration(tmp_path / "absent.yaml", issues=[
                RetirementIssue(
                    config_path="principal.model",
                    current_model="grok-3",
                    replacement="grok-4.3",
                )
            ])


# ---------------------------------------------------------------------------
# Apply: surgical replacement
# ---------------------------------------------------------------------------

class TestApplyReplacement:
    def test_replaces_principal_model(self, trap_config: Path):
        issues = find_retired_xai_refs(_parse(trap_config))
        result = apply_migration(trap_config, issues)
        assert result.config_changed is True
        cfg = _parse(trap_config)
        assert cfg["principal"]["model"] == "grok-4.3"

    def test_adds_reasoning_effort_for_non_reasoning_variant(self, trap_config: Path):
        issues = find_retired_xai_refs(_parse(trap_config))
        apply_migration(trap_config, issues)
        cfg = _parse(trap_config)
        # Principal was grok-4-1-fast-non-reasoning → reasoning_effort: "none"
        assert cfg["principal"]["reasoning_effort"] == "none"

    def test_replaces_auxiliary_vision(self, trap_config: Path):
        issues = find_retired_xai_refs(_parse(trap_config))
        apply_migration(trap_config, issues)
        cfg = _parse(trap_config)
        assert cfg["auxiliary"]["vision"]["model"] == "grok-4.3"

    def test_replaces_delegation(self, trap_config: Path):
        issues = find_retired_xai_refs(_parse(trap_config))
        apply_migration(trap_config, issues)
        cfg = _parse(trap_config)
        assert cfg["delegation"]["model"] == "grok-4.3"

    def test_replaces_image_gen_plugin(self, trap_config: Path):
        issues = find_retired_xai_refs(_parse(trap_config))
        apply_migration(trap_config, issues)
        cfg = _parse(trap_config)
        assert cfg["plugins"]["image_gen"]["xai"]["model"] == "grok-imagine-image-quality"

    def test_does_not_touch_unrelated_slots(self, trap_config: Path):
        issues = find_retired_xai_refs(_parse(trap_config))
        apply_migration(trap_config, issues)
        cfg = _parse(trap_config)
        # auxiliary.compression was never xAI, must remain untouched
        assert cfg["auxiliary"]["compression"]["model"] == "gpt-4o-mini"
        assert cfg["auxiliary"]["compression"]["provider"] == "openai"
        # principal.temperature must survive
        assert cfg["principal"]["temperature"] == 0.5


# ---------------------------------------------------------------------------
# Round-trip preservation (the hard part)
# ---------------------------------------------------------------------------

class TestRoundTripPreservation:
    def test_preserves_top_of_file_comment(self, trap_config: Path):
        issues = find_retired_xai_refs(_parse(trap_config))
        apply_migration(trap_config, issues)
        text = trap_config.read_text(encoding="utf-8")
        assert "# Hermes config (sample)" in text

    def test_preserves_inline_comments_on_unmodified_lines(self, trap_config: Path):
        issues = find_retired_xai_refs(_parse(trap_config))
        apply_migration(trap_config, issues)
        text = trap_config.read_text(encoding="utf-8")
        assert "# the main model" in text
        assert "# not affected" in text

    def test_preserves_top_level_key_order(self, trap_config: Path):
        issues = find_retired_xai_refs(_parse(trap_config))
        apply_migration(trap_config, issues)
        text = trap_config.read_text(encoding="utf-8")
        order = [
            text.index("principal:"),
            text.index("auxiliary:"),
            text.index("delegation:"),
            text.index("plugins:"),
        ]
        assert order == sorted(order)


# ---------------------------------------------------------------------------
# Backup behaviour
# ---------------------------------------------------------------------------

class TestBackup:
    def test_backup_is_written_by_default(self, trap_config: Path):
        issues = find_retired_xai_refs(_parse(trap_config))
        original = trap_config.read_text(encoding="utf-8")
        result = apply_migration(trap_config, issues)
        assert result.backup_path is not None
        assert result.backup_path.exists()
        assert result.backup_path.read_text(encoding="utf-8") == original

    def test_backup_filename_prefixed(self, trap_config: Path):
        issues = find_retired_xai_refs(_parse(trap_config))
        result = apply_migration(trap_config, issues)
        assert result.backup_path is not None
        assert result.backup_path.name.startswith("config.yaml.bak-pre-migrate-xai-")

    def test_no_backup_when_disabled(self, trap_config: Path):
        issues = find_retired_xai_refs(_parse(trap_config))
        result = apply_migration(trap_config, issues, backup=False)
        assert result.backup_path is None
        # No bak file in the directory
        assert not list(trap_config.parent.glob("*.bak-pre-migrate-xai-*"))

    def test_no_backup_when_no_changes(self, clean_config: Path):
        issues = find_retired_xai_refs(_parse(clean_config))
        result = apply_migration(clean_config, issues, backup=True)
        assert result.backup_path is None  # nothing to back up
        assert not list(clean_config.parent.glob("*.bak-pre-migrate-xai-*"))


# ---------------------------------------------------------------------------
# Idempotence
# ---------------------------------------------------------------------------

class TestIdempotence:
    def test_apply_twice_is_safe(self, trap_config: Path):
        # First pass: replace
        issues_1 = find_retired_xai_refs(_parse(trap_config))
        apply_migration(trap_config, issues_1)
        # Second pass: nothing to do
        issues_2 = find_retired_xai_refs(_parse(trap_config))
        assert issues_2 == []
        result_2 = apply_migration(trap_config, issues_2)
        assert result_2.config_changed is False


# ---------------------------------------------------------------------------
# Fail-closed on unreadable existing config
# ---------------------------------------------------------------------------

class TestUnreadableExistingConfig:
    def test_apply_refuses_to_overwrite_unreadable_config(self, trap_config: Path):
        """apply_migration must not clobber an existing config.yaml it can't
        read. It reads the file first (which raises on an unreadable file), and
        the require_readable_config_before_write guard before the write is a
        belt-and-suspenders backstop for the read-then-write window. Either way
        the original bytes must survive."""
        import os

        issues = find_retired_xai_refs(_parse(trap_config))
        assert issues  # sanity: trap_config has retired refs
        original = trap_config.read_bytes()

        os.chmod(trap_config, 0o000)
        try:
            with pytest.raises((PermissionError, RuntimeError, OSError)):
                apply_migration(trap_config, issues, backup=False)
        finally:
            os.chmod(trap_config, 0o644)

        assert trap_config.read_bytes() == original
