"""Phase 7 of the v0.3 closed-box redesign — migrate codemod tests.

Drives ``mgf-common migrate`` through the in-process ``main(argv)``
entry against ``tmp_path`` Python files. Verifies the safe
identifier-anchored renames, the per-rule import-gate semantics
(no over-application), --dry-run / --backup behaviour,
idempotency, and the unsupported-version path.

The codemod ships only mechanical safe renames; the bootstrap
reshape (§1 of MIGRATION_0.1_TO_0.3.md) is deliberately NOT
codemodded — it benefits from human review per case. Tests
assert this restriction (no transformation of bootstrap-shape
files).
"""

from __future__ import annotations

import textwrap
from typing import TYPE_CHECKING

from mgf.common.cli._exit_codes import (
    EXIT_CONFIG_ERROR,
    EXIT_OPERATION_ERROR,
    EXIT_SUCCESS,
)

from mgf.fed.__main__ import main

if TYPE_CHECKING:
    from pathlib import Path

    import pytest


# ---------------------------------------------------------------------------
# EnvironmentError → HostEnvironmentError
# ---------------------------------------------------------------------------


class TestEnvironmentErrorRename:
    """Rule 1 — closes FEEDBACK API-01."""

    def test_rename_in_import_and_use_site(
        self,
        tmp_path: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        target = tmp_path / "module.py"
        target.write_text(
            textwrap.dedent("""
            from mgf.common.exceptions import EnvironmentError

            class CpuLacksVmxError(EnvironmentError):
                pass

            def check() -> None:
                raise EnvironmentError("not supported")
        """)
        )
        rc = main(["migrate", "0.1", "0.3", str(tmp_path)])
        assert rc == EXIT_SUCCESS
        new = target.read_text()
        assert "HostEnvironmentError" in new
        assert "EnvironmentError" not in new.replace("HostEnvironmentError", "")

    def test_does_not_touch_files_without_mgf_exceptions_import(
        self,
        tmp_path: Path,
    ) -> None:
        target = tmp_path / "unrelated.py"
        # Uses Python's builtin EnvironmentError (alias for OSError).
        # No mgf.common.exceptions import → MUST NOT be renamed.
        original = textwrap.dedent("""
            import os

            EnvironmentError = OSError  # MUST stay as-is

            def is_io_problem(exc: BaseException) -> bool:
                return isinstance(exc, EnvironmentError)
        """)
        target.write_text(original)
        main(["migrate", "0.1", "0.3", str(tmp_path)])
        assert target.read_text() == original

    def test_does_not_touch_substring_of_longer_identifier(
        self,
        tmp_path: Path,
    ) -> None:
        # MyEnvironmentErrorSubclass should NOT become MyHostEnvironmentErrorSubclass
        # (the rename is identifier-anchored via word boundary).
        target = tmp_path / "module.py"
        target.write_text(
            textwrap.dedent("""
            from mgf.common.exceptions import EnvironmentError

            # Substring inside a longer identifier — MUST NOT rename.
            class MyEnvironmentErrorSubclass(EnvironmentError):
                pass

            class MyEnvironmentErrorWrapper:
                pass
        """)
        )
        main(["migrate", "0.1", "0.3", str(tmp_path)])
        new = target.read_text()
        # The exact identifier is replaced.
        assert "(HostEnvironmentError)" in new
        # Substrings inside MyEnvironmentErrorSubclass / MyEnvironmentErrorWrapper
        # MUST stay intact.
        assert "MyEnvironmentErrorSubclass" in new
        assert "MyEnvironmentErrorWrapper" in new


# ---------------------------------------------------------------------------
# make_settings_config → settings_config
# ---------------------------------------------------------------------------


class TestMakeSettingsConfigRename:
    """Rule 2 — drops the make_ verb-prefix noise."""

    def test_rename_in_import_and_call_site(
        self,
        tmp_path: Path,
    ) -> None:
        target = tmp_path / "settings.py"
        target.write_text(
            textwrap.dedent("""
            from mgf.common.config import BaseAppSettings, make_settings_config


            class MySettings(BaseAppSettings):
                model_config = make_settings_config(env_prefix="MYAPP_")
        """)
        )
        main(["migrate", "0.1", "0.3", str(tmp_path)])
        new = target.read_text()
        assert "import BaseAppSettings, settings_config" in new
        assert "model_config = settings_config(env_prefix=" in new
        # No leftover make_settings_config.
        assert "make_settings_config" not in new

    def test_does_not_touch_files_without_mgf_config_import(
        self,
        tmp_path: Path,
    ) -> None:
        target = tmp_path / "unrelated.py"
        original = textwrap.dedent("""
            # Custom helper that happens to share the name.
            def make_settings_config(**kw):
                return kw
        """)
        target.write_text(original)
        main(["migrate", "0.1", "0.3", str(tmp_path)])
        assert target.read_text() == original


# ---------------------------------------------------------------------------
# Per-rule gating — multiple rules with different gates
# ---------------------------------------------------------------------------


class TestPerRuleGating:
    """Each rule has its own import_gate; rules apply independently."""

    def test_only_config_rule_applies_when_only_config_imported(
        self,
        tmp_path: Path,
    ) -> None:
        target = tmp_path / "module.py"
        target.write_text(
            textwrap.dedent("""
            from mgf.common.config import make_settings_config

            EnvironmentError = OSError  # builtin alias — MUST stay

            class S:
                model_config = make_settings_config(env_prefix="X_")
        """)
        )
        main(["migrate", "0.1", "0.3", str(tmp_path)])
        new = target.read_text()
        # Config rule applied.
        assert "settings_config(env_prefix=" in new
        assert "make_settings_config" not in new
        # Exceptions rule did NOT apply (builtin EnvironmentError untouched).
        assert "EnvironmentError = OSError" in new
        assert "HostEnvironmentError" not in new

    def test_only_exceptions_rule_applies_when_only_exceptions_imported(
        self,
        tmp_path: Path,
    ) -> None:
        target = tmp_path / "module.py"
        target.write_text(
            textwrap.dedent("""
            from mgf.common.exceptions import EnvironmentError

            def make_settings_config(**kw):  # local helper — MUST stay
                return kw

            class CpuLacksVmxError(EnvironmentError):
                pass
        """)
        )
        main(["migrate", "0.1", "0.3", str(tmp_path)])
        new = target.read_text()
        assert "import HostEnvironmentError" in new
        assert "(HostEnvironmentError)" in new
        # Local helper preserved.
        assert "def make_settings_config(" in new


# ---------------------------------------------------------------------------
# --dry-run / --backup
# ---------------------------------------------------------------------------


class TestDryRunAndBackup:
    """The two CLI safety flags."""

    def test_dry_run_does_not_modify_files(
        self,
        tmp_path: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        target = tmp_path / "module.py"
        original = "from mgf.common.exceptions import EnvironmentError\n"
        target.write_text(original)
        rc = main(["migrate", "0.1", "0.3", str(tmp_path), "--dry-run"])
        captured = capsys.readouterr()
        assert rc == EXIT_SUCCESS
        # File unchanged.
        assert target.read_text() == original
        # Report still shows what WOULD have changed.
        assert "would change" in captured.out
        assert str(target) in captured.out
        assert "(dry-run)" in captured.out

    def test_backup_creates_bak_file(self, tmp_path: Path) -> None:
        target = tmp_path / "module.py"
        original = "from mgf.common.exceptions import EnvironmentError\n"
        target.write_text(original)
        rc = main(["migrate", "0.1", "0.3", str(tmp_path), "--backup"])
        assert rc == EXIT_SUCCESS
        # Backup carries the original.
        assert (tmp_path / "module.py.bak").read_text() == original
        # Target was rewritten.
        new = target.read_text()
        assert "HostEnvironmentError" in new

    def test_no_backup_by_default(self, tmp_path: Path) -> None:
        target = tmp_path / "module.py"
        target.write_text("from mgf.common.exceptions import EnvironmentError\n")
        main(["migrate", "0.1", "0.3", str(tmp_path)])
        # No .bak created.
        assert not (tmp_path / "module.py.bak").exists()


# ---------------------------------------------------------------------------
# Idempotency
# ---------------------------------------------------------------------------


class TestIdempotency:
    """Re-running the codemod is a no-op."""

    def test_second_run_is_noop(
        self,
        tmp_path: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        target = tmp_path / "module.py"
        target.write_text(
            textwrap.dedent("""
            from mgf.common.exceptions import EnvironmentError
            from mgf.common.config import make_settings_config
        """)
        )
        main(["migrate", "0.1", "0.3", str(tmp_path)])
        capsys.readouterr()  # discard first run output
        first = target.read_text()

        rc = main(["migrate", "0.1", "0.3", str(tmp_path)])
        captured = capsys.readouterr()
        assert rc == EXIT_SUCCESS
        # File unchanged on second run.
        assert target.read_text() == first
        assert "Nothing to do" in captured.out


# ---------------------------------------------------------------------------
# File walk + unsupported-version + missing-target
# ---------------------------------------------------------------------------


class TestFileWalk:
    """Recursion + skip directory + single-file targets."""

    def test_walks_recursively(self, tmp_path: Path) -> None:
        nested = tmp_path / "pkg" / "sub"
        nested.mkdir(parents=True)
        target = nested / "deep.py"
        target.write_text("from mgf.common.exceptions import EnvironmentError\n")
        rc = main(["migrate", "0.1", "0.3", str(tmp_path)])
        assert rc == EXIT_SUCCESS
        assert "HostEnvironmentError" in target.read_text()

    def test_skips_dot_venv_directory(self, tmp_path: Path) -> None:
        vendored = tmp_path / ".venv" / "lib" / "package"
        vendored.mkdir(parents=True)
        target = vendored / "vendored.py"
        # Vendored file MUST stay untouched even if it has the old name.
        original = "from mgf.common.exceptions import EnvironmentError\n"
        target.write_text(original)
        main(["migrate", "0.1", "0.3", str(tmp_path)])
        assert target.read_text() == original

    def test_skips_pycache_directory(self, tmp_path: Path) -> None:
        cache = tmp_path / "src" / "__pycache__"
        cache.mkdir(parents=True)
        target = cache / "stale.py"
        original = "from mgf.common.exceptions import EnvironmentError\n"
        target.write_text(original)
        main(["migrate", "0.1", "0.3", str(tmp_path)])
        assert target.read_text() == original

    def test_single_file_target(self, tmp_path: Path) -> None:
        target = tmp_path / "single.py"
        target.write_text("from mgf.common.exceptions import EnvironmentError\n")
        rc = main(["migrate", "0.1", "0.3", str(target)])
        assert rc == EXIT_SUCCESS
        assert "HostEnvironmentError" in target.read_text()

    def test_non_python_files_ignored(self, tmp_path: Path) -> None:
        target = tmp_path / "config.yaml"
        original = "EnvironmentError: should not be touched\n"
        target.write_text(original)
        main(["migrate", "0.1", "0.3", str(tmp_path)])
        assert target.read_text() == original


class TestErrorPaths:
    """Unsupported version + missing target."""

    def test_unsupported_migration_returns_config_error(
        self,
        tmp_path: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        rc = main(["migrate", "9.9", "10.0", str(tmp_path)])
        captured = capsys.readouterr()
        assert rc == EXIT_CONFIG_ERROR
        assert "unsupported migration" in captured.err
        assert "Supported migrations:" in captured.err
        assert "0.1 → 0.3" in captured.err
        # PAPER-43: v0.33 AP-12 deprecation-removal pair is also
        # registered. Listing both pairs makes the supported set
        # discoverable from a single unsupported invocation.
        assert "0.32 → 0.33" in captured.err

    def test_missing_target_returns_operation_error(
        self,
        tmp_path: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        rc = main(["migrate", "0.1", "0.3", str(tmp_path / "nonexistent")])
        captured = capsys.readouterr()
        assert rc == EXIT_OPERATION_ERROR
        assert "does not exist" in captured.err


# ---------------------------------------------------------------------------
# CLI dispatch — `migrate` is the fourth subcommand
# ---------------------------------------------------------------------------


class TestDispatch:
    """The CLI exposes migrate alongside explain/validate/doctor."""

    def test_migrate_in_subcommand_list(
        self,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        from mgf.common.cli import build_cli

        import mgf.fed.cli  # noqa: F401  (registers the moved subcommands)

        parser = build_cli()
        import argparse

        sub_action = next(a for a in parser._actions if isinstance(a, argparse._SubParsersAction))
        assert "migrate" in sub_action.choices


# ---------------------------------------------------------------------------
# SH-04 — atomic rewrite + AST validation
# ---------------------------------------------------------------------------


class TestSH04AtomicSourceRewrite:
    """SH-04: ``mgf-common migrate`` rewrites consumer Python
    source. The rewrite MUST be atomic (no truncation window on
    a crash mid-write) AND MUST refuse to land if the codemod
    produced unparseable Python (better to fail loud than leave
    the consumer with half-migrated invalid source)."""

    def test_atomic_rewrite_no_truncation_on_atomic_write_failure(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """When ``atomic_write`` raises mid-call, the ORIGINAL
        file must be unchanged — neither truncated nor partially
        written. (atomic_write writes a tempfile, fsync's, then
        renames; on failure it cleans up the tempfile.)"""
        from mgf.fed.__main__ import main

        target = tmp_path / "src.py"
        original = (
            "from mgf.common.exceptions import EnvironmentError\nraise EnvironmentError('boom')\n"
        )
        target.write_text(original)

        def bad_atomic_write(*args: object, **kwargs: object) -> None:
            raise OSError("simulated mid-write failure")

        monkeypatch.setattr(
            "mgf.common.io.atomic_write",
            bad_atomic_write,
        )

        # The codemod returns EXIT_OPERATION_ERROR but must NOT
        # truncate the source.
        rc = main(["migrate", "0.1", "0.3", str(target)])
        assert rc != 0
        # Original content is intact — nothing was partial-written.
        assert target.read_text() == original

    def test_refuses_to_write_if_transform_produces_invalid_python(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """SH-04: after computing the transform, ``ast.parse`` it.
        If the result is invalid Python, refuse to write — better
        to fail loud than land a broken file. Pinned via a
        synthetic Change whose ``transformed`` is unparseable."""
        from mgf.fed.__main__ import main
        from mgf.fed.cli import _migrate

        target = tmp_path / "src.py"
        original = "from mgf.common.exceptions import EnvironmentError\n"
        target.write_text(original)

        # Intercept _transform_file to return a Change whose
        # `.transformed` is unparseable (a stray `def foo(`).
        real_transform = _migrate._transform_file

        def bad_transform(path: Path, rules: object) -> object:
            real_change = real_transform(path, rules)

            # Replace transformed with broken Python while
            # keeping changed=True so we reach the write branch.
            class _BadChange:
                changed = True
                rules_applied = real_change.rules_applied
                original = real_change.original
                transformed = "def foo(\n"  # syntax error

            return _BadChange()

        monkeypatch.setattr(_migrate, "_transform_file", bad_transform)

        rc = main(["migrate", "0.1", "0.3", str(target)])
        captured = capsys.readouterr()
        # Non-zero exit + the AST-refused error message.
        assert rc != 0
        assert "invalid Python" in captured.err
        # Original file is untouched.
        assert target.read_text() == original

    def test_preserves_original_file_permissions(
        self,
        tmp_path: Path,
    ) -> None:
        """SH-04: the consumer might have set a non-default mode
        on their source (e.g., 0o755 for a script, or 0o600 for
        hardened files). atomic_write's default is 0o600;
        migrate stat()s the original first and passes that mode
        so the consumer's permissions are preserved."""
        from mgf.fed.__main__ import main

        target = tmp_path / "src.py"
        target.write_text("from mgf.common.exceptions import EnvironmentError\n")
        # Hardened permissions — preserved post-rewrite.
        target.chmod(0o600)

        rc = main(["migrate", "0.1", "0.3", str(target)])
        assert rc == EXIT_SUCCESS
        # Final mode is the original mode, not atomic_write's
        # default 0o600 (which happens to match here — so test
        # again with 0o755 to be sure).
        assert oct(target.stat().st_mode)[-3:] == "600"

        # And the rewrite happened (sanity).
        assert "HostEnvironmentError" in target.read_text()

        # Second case: 0o644 (typical default) survives too.
        target2 = tmp_path / "src2.py"
        target2.write_text("from mgf.common.exceptions import EnvironmentError\n")
        target2.chmod(0o644)
        main(["migrate", "0.1", "0.3", str(target2)])
        assert oct(target2.stat().st_mode)[-3:] == "644"


# ---------------------------------------------------------------------------
# PAPER-43: v0.32 → v0.33 migration registration
# ---------------------------------------------------------------------------


class TestPaper43V032ToV033Migration:
    """PAPER-43 — v0.33's AP-12 deprecation-removal release deletes the
    ``EnvironmentError`` and ``make_settings_config`` aliases. The
    v0.33 cutover guide documents ``mgf-common migrate`` as the
    codemod path; this class verifies the version pair
    ``(0.32, 0.33)`` is registered and applies the same two
    identifier-anchored renames as the v0.1 → v0.3 migration.

    Reuse is intentional: the rules are identifier-anchored renames
    that don't care which release cycle is removing the alias. A
    consumer who ran the v0.1 → v0.3 codemod earlier sees a no-op
    on re-run for v0.32 → v0.33 (idempotency); a consumer catching
    up directly from pre-v0.3 to v0.33+ gets the renames applied
    for the first time.
    """

    def test_environment_error_renamed_on_v032_to_v033(
        self,
        tmp_path: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        target = tmp_path / "module.py"
        target.write_text(
            textwrap.dedent("""
            from mgf.common.exceptions import EnvironmentError

            class HostMissingKvm(EnvironmentError):
                pass
        """).lstrip()
        )

        rc = main(["migrate", "0.32", "0.33", str(tmp_path)])
        assert rc == EXIT_SUCCESS

        rewritten = target.read_text()
        assert "HostEnvironmentError" in rewritten
        assert "EnvironmentError" not in rewritten.replace("HostEnvironmentError", "")

    def test_make_settings_config_renamed_on_v032_to_v033(
        self,
        tmp_path: Path,
    ) -> None:
        target = tmp_path / "settings.py"
        target.write_text(
            textwrap.dedent("""
            from mgf.common.config import make_settings_config

            CFG = make_settings_config(env_prefix="MYAPP_")
        """).lstrip()
        )

        rc = main(["migrate", "0.32", "0.33", str(tmp_path)])
        assert rc == EXIT_SUCCESS

        rewritten = target.read_text()
        assert "settings_config" in rewritten
        assert "make_settings_config" not in rewritten

    def test_idempotent_after_v01_to_v03(self, tmp_path: Path) -> None:
        # Consumer ran the v0.1 → v0.3 codemod historically; now they
        # bump to v0.33. The v0.32 → v0.33 invocation must be a no-op
        # because the names are already the post-rename ones.
        target = tmp_path / "module.py"
        target.write_text(
            textwrap.dedent("""
            from mgf.common.exceptions import HostEnvironmentError
            from mgf.common.config import settings_config

            CFG = settings_config(env_prefix="MYAPP_")
        """).lstrip()
        )
        original = target.read_text()

        # First run (already-migrated content).
        rc1 = main(["migrate", "0.1", "0.3", str(tmp_path)])
        assert rc1 == EXIT_SUCCESS
        assert target.read_text() == original

        # Second run on the v0.32 → v0.33 pair — same idempotent
        # behaviour, because the rules are identifier-anchored
        # renames that have nothing to do on already-renamed code.
        rc2 = main(["migrate", "0.32", "0.33", str(tmp_path)])
        assert rc2 == EXIT_SUCCESS
        assert target.read_text() == original

    def test_dry_run_on_v032_to_v033(
        self,
        tmp_path: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        # --dry-run threads through the same way it does for v0.1 →
        # v0.3 — preview without writing.
        target = tmp_path / "module.py"
        original = textwrap.dedent("""
            from mgf.common.exceptions import EnvironmentError
            raise EnvironmentError("boom")
        """).lstrip()
        target.write_text(original)

        rc = main(
            [
                "migrate",
                "0.32",
                "0.33",
                str(tmp_path),
                "--dry-run",
            ]
        )
        assert rc == EXIT_SUCCESS
        assert target.read_text() == original  # unchanged on disk

        out = capsys.readouterr().out
        assert "(dry-run)" in out
        assert str(target) in out

    def test_per_rule_gating_holds_on_v032_to_v033(
        self,
        tmp_path: Path,
    ) -> None:
        # The per-rule import_gate semantics apply identically. A file
        # that uses Python's builtin EnvironmentError (alias for
        # OSError) without importing from mgf.common.exceptions must
        # NOT be touched by the v0.32 → v0.33 codemod.
        target = tmp_path / "untouched.py"
        original = textwrap.dedent("""
            # Builtin EnvironmentError — alias for OSError.
            try:
                open("/dev/null/no")
            except EnvironmentError as exc:
                raise
        """).lstrip()
        target.write_text(original)

        rc = main(["migrate", "0.32", "0.33", str(tmp_path)])
        assert rc == EXIT_SUCCESS
        assert target.read_text() == original


# ---------------------------------------------------------------------------
# PAPER-87 — v0.38 → v0.39 PEP-8 N818 exception rename
# ---------------------------------------------------------------------------


class TestPaper87V038ToV039Migration:
    """PAPER-87 (v0.41.5+): the v0.39.0 release added the ``Error``
    suffix to twelve public exception classes. Consumers crossing
    that rename — especially with TS-04 ``filterwarnings = ["error"]``
    that promotes ``MgfDeprecationWarning`` to hard failure — need
    a mechanical codemod. This class verifies the registered
    ``(0.38, 0.39)`` migration applies all twelve renames, respects
    the per-rule import-gate, and is idempotent.
    """

    def test_all_twelve_http_and_process_renames(
        self,
        tmp_path: Path,
    ) -> None:
        # One file exercising every rename in the catalogue.
        target = tmp_path / "exceptions_demo.py"
        target.write_text(
            textwrap.dedent("""
            from mgf.common.exceptions import (
                HttpBadRequest,
                HttpUnauthorized,
                HttpForbidden,
                HttpNotFound,
                HttpConflict,
                HttpGone,
                HttpUnprocessable,
                HttpServiceUnavailable,
                ProcessTimeout,
                ProcessNonZeroExit,
                ProcessSignaled,
                ServiceNotReady,
            )

            def fire() -> None:
                raise HttpBadRequest("bad")
                raise HttpUnauthorized("no auth")
                raise HttpForbidden("nope")
                raise HttpNotFound("missing")
                raise HttpConflict("conflict")
                raise HttpGone("gone")
                raise HttpUnprocessable("unproc")
                raise HttpServiceUnavailable("unavail")
                raise ProcessTimeout("timeout")
                raise ProcessNonZeroExit("rc!=0")
                raise ProcessSignaled("signaled")
                raise ServiceNotReady("not ready")
        """).lstrip()
        )

        rc = main(["migrate", "0.38", "0.39", str(tmp_path)])
        assert rc == EXIT_SUCCESS

        rewritten = target.read_text()
        for old in (
            "HttpBadRequest",
            "HttpUnauthorized",
            "HttpForbidden",
            "HttpNotFound",
            "HttpConflict",
            "HttpGone",
            "HttpUnprocessable",
            "HttpServiceUnavailable",
            "ProcessTimeout",
            "ProcessNonZeroExit",
            "ProcessSignaled",
            "ServiceNotReady",
        ):
            # Pre-rename name MUST NOT survive as a whole word (the
            # only surviving occurrences should be substrings of the
            # post-rename `*Error` form).
            new = old + "Error"
            stripped = rewritten.replace(new, "")
            assert old not in stripped, (
                f"pre-rename name {old!r} survived the codemod: {stripped[:200]!r}"
            )

    def test_isinstance_and_subclass_get_renamed(
        self,
        tmp_path: Path,
    ) -> None:
        # The codemod is identifier-anchored — it MUST rewrite
        # isinstance checks and subclass declarations identically to
        # raises and imports.
        target = tmp_path / "handler.py"
        target.write_text(
            textwrap.dedent("""
            from mgf.common.exceptions import HttpNotFound, ProcessTimeout

            class CustomNotFound(HttpNotFound):
                pass

            def handle(exc: Exception) -> str:
                if isinstance(exc, (HttpNotFound, ProcessTimeout)):
                    return "known"
                return "other"
        """).lstrip()
        )

        rc = main(["migrate", "0.38", "0.39", str(tmp_path)])
        assert rc == EXIT_SUCCESS

        rewritten = target.read_text()
        assert "class CustomNotFound(HttpNotFoundError):" in rewritten
        assert "isinstance(exc, (HttpNotFoundError, ProcessTimeoutError))" in rewritten

    def test_import_gate_skips_unrelated_files(
        self,
        tmp_path: Path,
    ) -> None:
        # A file with a class named `ProcessTimeout` that doesn't
        # import from `mgf.common.exceptions` MUST NOT be touched —
        # that's a consumer-domain identifier collision, not the
        # federation's exception. The per-rule import_gate guards
        # against over-application identically to the v0.1 → v0.3
        # gating.
        target = tmp_path / "domain.py"
        original = textwrap.dedent("""
            # Consumer's own domain object.
            class ProcessTimeout:
                def __init__(self, ms: int) -> None:
                    self.ms = ms
        """).lstrip()
        target.write_text(original)

        rc = main(["migrate", "0.38", "0.39", str(tmp_path)])
        assert rc == EXIT_SUCCESS
        # Unchanged — no `mgf.common.exceptions` mention in the file.
        assert target.read_text() == original

    def test_idempotent_on_already_renamed_source(
        self,
        tmp_path: Path,
    ) -> None:
        # Running the codemod on already-migrated source must be a
        # no-op. The post-rename names contain the pre-rename names as
        # substrings; word-boundary anchoring is what makes idempotency
        # safe.
        target = tmp_path / "migrated.py"
        original = textwrap.dedent("""
            from mgf.common.exceptions import (
                HttpBadRequestError,
                ProcessTimeoutError,
                ServiceNotReadyError,
            )

            def fire() -> None:
                raise HttpBadRequestError("x")
                raise ProcessTimeoutError("y")
                raise ServiceNotReadyError("z")
        """).lstrip()
        target.write_text(original)

        rc = main(["migrate", "0.38", "0.39", str(tmp_path)])
        assert rc == EXIT_SUCCESS
        # Byte-identical: no spurious double-suffixing
        # (`HttpBadRequestErrorError`, etc.).
        assert target.read_text() == original

    def test_dry_run_does_not_write(
        self,
        tmp_path: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        target = tmp_path / "module.py"
        original = textwrap.dedent("""
            from mgf.common.exceptions import HttpBadRequest
            raise HttpBadRequest("boom")
        """).lstrip()
        target.write_text(original)

        rc = main(
            [
                "migrate",
                "0.38",
                "0.39",
                str(tmp_path),
                "--dry-run",
            ]
        )
        assert rc == EXIT_SUCCESS
        assert target.read_text() == original  # unchanged on disk
        out = capsys.readouterr().out
        assert "(dry-run)" in out


# ---------------------------------------------------------------------------
# PAPER-87 Approach B — chain composition across version spans
# ---------------------------------------------------------------------------


class TestPaper87ApproachBChainComposition:
    """PAPER-87 Approach B (v0.41.6+): when a requested ``(from, to)``
    pair has no atomic registration but registered migrations fall
    within its range, the codemod composes them into a chain and
    applies each in sequence. Closes the PlasmaMapper-shaped
    ``migrate 0.37 0.41`` case where the consumer's pin is multiple
    minors behind the federation's current.
    """

    def test_chain_composes_0_37_to_0_41_via_0_38_to_0_39(
        self,
        tmp_path: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        # The canonical PlasmaMapper scenario: pin was 0.37, current
        # mgf-common is 0.41. Only the 0.38 → 0.39 hop carries the
        # N818 renames; no other hops have registrations. The chain
        # resolves to a single link but the request is a span.
        target = tmp_path / "module.py"
        target.write_text(
            textwrap.dedent("""
            from mgf.common.exceptions import HttpBadRequest, ProcessTimeout

            def fire() -> None:
                raise HttpBadRequest("bad")
                raise ProcessTimeout("slow")
        """).lstrip()
        )

        rc = main(["migrate", "0.37", "0.41", str(tmp_path)])
        assert rc == EXIT_SUCCESS

        rewritten = target.read_text()
        assert "HttpBadRequestError" in rewritten
        assert "ProcessTimeoutError" in rewritten
        # Pre-rename names must not survive as whole words.
        assert 'HttpBadRequest"' not in rewritten
        assert 'ProcessTimeout"' not in rewritten

        out = capsys.readouterr().out
        # The chain narrative is surfaced.
        assert "Composed migration chain:" in out
        assert "0.38 → 0.39" in out

    def test_chain_composes_multiple_hops_when_in_range(
        self,
        tmp_path: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        # A wide span that picks up multiple registered hops. The
        # current registrations are (0.1, 0.3), (0.32, 0.33), and
        # (0.38, 0.39). A 0.1 → 0.41 request sweeps all three.
        target = tmp_path / "wide.py"
        target.write_text(
            textwrap.dedent("""
            from mgf.common.exceptions import (
                EnvironmentError,
                HttpBadRequest,
                ProcessTimeout,
            )

            def fire() -> None:
                raise EnvironmentError("env")
                raise HttpBadRequest("bad")
                raise ProcessTimeout("slow")
        """).lstrip()
        )

        rc = main(["migrate", "0.1", "0.41", str(tmp_path)])
        assert rc == EXIT_SUCCESS

        rewritten = target.read_text()
        # All three rename events applied (composition order is
        # source-version ascending).
        assert "HostEnvironmentError" in rewritten
        assert "HttpBadRequestError" in rewritten
        assert "ProcessTimeoutError" in rewritten

        out = capsys.readouterr().out
        assert "Composed migration chain:" in out
        assert "0.1 → 0.3" in out
        assert "0.32 → 0.33" in out
        assert "0.38 → 0.39" in out

    def test_unsupported_when_no_migrations_in_range(
        self,
        tmp_path: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        # A request entirely outside any registered range produces
        # the unsupported error + a hint about Approach B.
        target = tmp_path / "x.py"
        target.write_text("# nothing\n")

        rc = main(["migrate", "0.50", "0.55", str(tmp_path)])
        assert rc == EXIT_CONFIG_ERROR

        err = capsys.readouterr().err
        assert "unsupported migration: 0.50 → 0.55" in err
        assert "PAPER-87 Approach B" in err

    def test_atomic_registration_still_wins_over_composition(
        self,
        tmp_path: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        # The exact registered pair (0.38, 0.39) takes the atomic
        # path — single hop, no "Composed migration chain:" header.
        # Preserves the pre-Approach-B output shape for explicit
        # requests.
        target = tmp_path / "module.py"
        target.write_text(
            textwrap.dedent("""
            from mgf.common.exceptions import HttpBadRequest
            raise HttpBadRequest("x")
        """).lstrip()
        )

        rc = main(["migrate", "0.38", "0.39", str(tmp_path)])
        assert rc == EXIT_SUCCESS

        out = capsys.readouterr().out
        assert "Composed migration chain:" not in out

    def test_dry_run_threads_through_composition(
        self,
        tmp_path: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        target = tmp_path / "module.py"
        original = textwrap.dedent("""
            from mgf.common.exceptions import HttpBadRequest
            raise HttpBadRequest("x")
        """).lstrip()
        target.write_text(original)

        rc = main(
            [
                "migrate",
                "0.37",
                "0.41",
                str(tmp_path),
                "--dry-run",
            ]
        )
        assert rc == EXIT_SUCCESS
        # File unchanged on disk.
        assert target.read_text() == original

        out = capsys.readouterr().out
        assert "(dry-run)" in out
        assert "Composed migration chain:" in out

    def test_idempotent_compose_on_already_migrated_source(
        self,
        tmp_path: Path,
    ) -> None:
        # Already-migrated source. Composed chain is a no-op.
        target = tmp_path / "migrated.py"
        original = textwrap.dedent("""
            from mgf.common.exceptions import HttpBadRequestError
            raise HttpBadRequestError("x")
        """).lstrip()
        target.write_text(original)

        rc = main(["migrate", "0.37", "0.41", str(tmp_path)])
        assert rc == EXIT_SUCCESS
        assert target.read_text() == original
