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

import sys
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_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_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_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-*"))



# ---------------------------------------------------------------------------
# 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


# ---------------------------------------------------------------------------
# Crash durability — the rewrite must be atomic
# ---------------------------------------------------------------------------

class TestCrashDurability:
    """apply_migration() rewrites the whole config.yaml in place.

    A bare ``open(path, "w")`` truncates the file *before* the dump runs, so an
    interruption (crash, SIGINT, ENOSPC) leaves config.yaml empty or
    half-written.  Routing through ``utils.atomic_write_text`` means the target
    is only ever swapped in via an atomic rename after the temp file is fully
    written and fsynced.
    """

    def test_config_survives_an_interrupted_write(self, trap_config: Path):
        """A failure mid-write must leave the original config.yaml untouched.

        ``--no-backup`` is a documented flag, so on that path the file being
        rewritten is the only copy in existence.
        """
        import os

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

        def boom(fd):
            raise OSError("simulated crash mid-write")

        with pytest.MonkeyPatch.context() as mp:
            mp.setattr(os, "fsync", boom)
            with pytest.raises(OSError):
                apply_migration(trap_config, issues, backup=False)

        # The original bytes must survive verbatim...
        assert trap_config.read_bytes() == original
        # ...and the aborted write must not leave a temp file behind.
        assert list(trap_config.parent.glob("*.tmp")) == []

    def test_symlinked_config_is_replaced_in_place(self, tmp_path: Path):
        """A config.yaml symlinked into a dotfiles repo must stay a symlink."""
        real_dir = tmp_path / "dotfiles"
        real_dir.mkdir()
        real = real_dir / "hermes-config.yaml"
        real.write_text(
            "principal:\n"
            "  provider: xai\n"
            "  model: grok-3\n",
            encoding="utf-8",
        )
        link = tmp_path / "config.yaml"
        link.symlink_to(real)

        issues = find_retired_xai_refs(_parse(link))
        assert issues
        apply_migration(link, issues, backup=False)

        assert link.is_symlink(), "atomic replace detached the symlink"
        assert real.read_text(encoding="utf-8") == link.read_text(encoding="utf-8")
        assert "grok-4.3" in real.read_text(encoding="utf-8")

    @pytest.mark.skipif(sys.platform == "win32", reason="POSIX permission bits")
    def test_existing_file_mode_is_preserved(self, trap_config: Path):
        """Managed (NixOS 0640) and container installs widen config.yaml
        deliberately; the migration must not silently tighten it to 0600."""
        import os
        import stat

        os.chmod(trap_config, 0o640)
        issues = find_retired_xai_refs(_parse(trap_config))
        apply_migration(trap_config, issues, backup=False)

        mode = stat.S_IMODE(trap_config.stat().st_mode)
        assert mode == 0o640, f"mode changed to {oct(mode)}"

    def test_comments_survive_the_atomic_write(self, trap_config: Path):
        """The ruamel round-trip must still run — serializing via a string
        buffer instead of the file handle must not drop comments."""
        issues = find_retired_xai_refs(_parse(trap_config))
        apply_migration(trap_config, issues, backup=False)

        text = trap_config.read_text(encoding="utf-8")
        assert "# Hermes config (sample)" in text
        assert "# the main model" in text
        assert "# not affected" in text
