"""``mgf-fed migrate <from> <to> <path>`` — regex-based codemod.

Mechanical safe renames only. The bootstrap reshape (5-call
v0.1 sequence → single ``bootstrap()``) is a behavioural
restructure that benefits from human review per case; it is
walked in :doc:`MIGRATION_0.1_TO_0.3.md` §1, NOT applied here.

Supported migrations
--------------------

  - ``0.1`` → ``0.3``:
    - ``EnvironmentError`` → ``HostEnvironmentError`` (closes
      FEEDBACK API-01) — both the import statement and every use
      site under the rewritten path.
    - ``make_settings_config`` → ``settings_config`` (closes the
      ``make_`` verb-prefix noise) — both import and call sites.

  - ``0.32`` → ``0.33`` (closes PAPER-43): same two renames as
    ``0.1 → 0.3``. Both names were `deprecated` shims in v0.3
    through v0.32 per the AP-12 deprecation cycle; v0.33 removes
    them. Consumers who ran the v0.1 → v0.3 migration earlier are
    already clean — the codemod is idempotent and finds nothing
    to do on a second pass. Consumers catching up directly from
    a pre-v0.3 vintage to v0.33+ get the renames applied.

  - ``0.38`` → ``0.39`` (closes PAPER-87): PEP-8 N818 exception
    rename. Twelve public exception classes gained the ``Error``
    suffix at v0.39.0 (``HttpBadRequest`` → ``HttpBadRequestError``,
    …, ``ServiceNotReady`` → ``ServiceNotReadyError``; full list in
    ``_RULES_0_38_TO_0_39``). The aliases were kept as deprecation
    shims through v0.40 and removed in the v0.41 pre-v1.0 cleanup
    pass — but TS-04 (``filterwarnings = ["error"]``) promotes the
    deprecation warning to a hard failure even at v0.39+, so this
    codemod is what every consumer crossing the rename needs.

The codemod:

  - Walks ``<path>`` recursively (any directory) or operates on
    ``<path>`` itself (any single ``.py`` file).
  - Each rule carries its own ``import_gate`` regex. A rule
    only applies to files that mention the corresponding
    ``mgf.common.*`` subpackage — so a file using Python's
    builtin ``EnvironmentError`` is NOT touched, while a file
    importing from ``mgf.common.exceptions`` is.
  - Reports per-file changes to stdout; nothing else is mutated.
  - Is **idempotent**: running twice is a no-op (the second
    pass finds the new names already present).
  - Honours ``--dry-run`` (preview, no writes) and ``--backup``
    (write ``<file>.bak`` next to each modified file).

Why regex (not :mod:`libcst`)? The two transformations above are
purely lexical — they rename a name; they never touch surrounding
syntax. Regex is appropriate, has no extra dep, and runs fast on
large codebases. AST-based transformation would be required for
the bootstrap reshape (which is genuinely structural) but Phase 7
deliberately leaves that to human review per the
``CLOSED_BOX_REDESIGN.md`` §9 trade-off.

Exit codes:

  - ``0`` — every transformation applied OR nothing to do.
  - ``EXIT_OPERATION_ERROR (2)`` — IO error during file walk
    or write.
  - ``EXIT_CONFIG_ERROR (1)`` — unsupported ``<from>`` /
    ``<to>`` pair.
"""

from __future__ import annotations

import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any

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

if TYPE_CHECKING:
    import io
    from collections.abc import Iterable, Iterator


# ---------------------------------------------------------------------------
# Rule definitions
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class Rule:
    """A single rename transformation.

    The ``pattern`` is compiled with :func:`re.compile`; the
    ``replacement`` follows :mod:`re` substitution syntax.
    ``description`` is shown in the per-file change report.

    The ``import_gate`` is a regex matched against the file
    BEFORE the rename is considered. Only files where the gate
    matches are eligible for the rename. This stops the codemod
    from over-applying — e.g., the ``EnvironmentError`` rename
    must NOT touch a file that uses Python's builtin
    ``EnvironmentError`` (alias for ``OSError``); we gate on the
    presence of ``mgf.common.exceptions`` in the file so only
    files that actually import from that module are touched.

    The gate is per-rule (not per-file) because two rules in the
    same migration may have different gates: ``EnvironmentError``
    is gated on ``mgf.common.exceptions`` while
    ``make_settings_config`` is gated on ``mgf.common.config``.
    A single file may match one gate but not the other, in which
    case only the matching rules apply.
    """

    pattern: re.Pattern[str]
    replacement: str
    description: str
    import_gate: re.Pattern[str]


# Word-boundary regex helpers — enforce that we match the full
# identifier, not a substring of a longer name (e.g. so we don't
# rename `EnvironmentError` inside `MyEnvironmentErrorSubclass`).
def _identifier(name: str) -> str:
    """Return the regex source matching ``name`` as a full identifier."""
    return rf"\b{re.escape(name)}\b"


# Import-gate patterns. We gate on the SUBPACKAGE name (not the
# specific import statement) to handle:
#   - `from mgf.common.exceptions import EnvironmentError`
#   - `from mgf.common.exceptions import (X, Y, EnvironmentError)`
#   - `import mgf.common.exceptions` (rare but valid)
#   - `from mgf.common.exceptions import EnvironmentError as Foo`
# The gate is conservative — it accepts any file that mentions
# ``mgf.common.exceptions`` lexically, which includes files that
# only have it in a comment / docstring; that is acceptable
# because the per-rule identifier match still requires the actual
# old name to appear AS A WHOLE WORD.
_GATE_EXCEPTIONS = re.compile(r"\bmgf\.common\.exceptions\b")
_GATE_CONFIG = re.compile(r"\bmgf\.common\.config\b")


# v0.1 → v0.3 — the canonical migration this CLI ships.
_RULES_0_1_TO_0_3: tuple[Rule, ...] = (
    # 1. EnvironmentError → HostEnvironmentError (closes API-01).
    #    Apply to imports first AND to use sites — the regex is
    #    identifier-anchored so it matches both safely.
    Rule(
        pattern=re.compile(_identifier("EnvironmentError")),
        replacement="HostEnvironmentError",
        description="EnvironmentError → HostEnvironmentError (API-01)",
        import_gate=_GATE_EXCEPTIONS,
    ),
    # 2. make_settings_config → settings_config (rename only —
    #    consumers who want the subclass-kwarg pattern do that
    #    by hand per MIGRATION_0.1_TO_0.3.md §2.2 option B).
    Rule(
        pattern=re.compile(_identifier("make_settings_config")),
        replacement="settings_config",
        description="make_settings_config → settings_config",
        import_gate=_GATE_CONFIG,
    ),
)


# v0.38 → v0.39 — PEP-8 N818 exception rename (PAPER-87, v0.41.5+).
#
# Twelve public exception classes gained the ``Error`` suffix at
# v0.39.0; pre-rename aliases survived as deprecation shims through
# v0.40 and were removed in v0.41's pre-v1.0 cleanup. Each rule is
# gated on ``mgf.common.exceptions`` so the codemod doesn't touch
# unrelated identifiers — e.g., a consumer's own ``ProcessTimeout``
# class in a different module stays untouched.
#
# Order matters only for substring-overlap safety; the
# ``_identifier`` helper anchors each match with ``\b`` on both
# sides, so substring shadowing (e.g., ``ProcessTimeout`` inside
# ``ProcessTimeoutError`` from a half-migrated file) is already
# prevented. The codemod is idempotent: a second pass finds the
# new names already present and produces no further changes.
_N818_RENAMES: tuple[tuple[str, str], ...] = (
    ("HttpBadRequest", "HttpBadRequestError"),
    ("HttpUnauthorized", "HttpUnauthorizedError"),
    ("HttpForbidden", "HttpForbiddenError"),
    ("HttpNotFound", "HttpNotFoundError"),
    ("HttpConflict", "HttpConflictError"),
    ("HttpGone", "HttpGoneError"),
    ("HttpUnprocessable", "HttpUnprocessableError"),
    ("HttpServiceUnavailable", "HttpServiceUnavailableError"),
    ("ProcessTimeout", "ProcessTimeoutError"),
    ("ProcessNonZeroExit", "ProcessNonZeroExitError"),
    ("ProcessSignaled", "ProcessSignaledError"),
    ("ServiceNotReady", "ServiceNotReadyError"),
)

_RULES_0_38_TO_0_39: tuple[Rule, ...] = tuple(
    Rule(
        pattern=re.compile(_identifier(old)),
        replacement=new,
        description=f"{old} → {new} (N818, PAPER-87)",
        import_gate=_GATE_EXCEPTIONS,
    )
    for old, new in _N818_RENAMES
)


def _parse_minor(v: str) -> tuple[int, ...]:
    """Parse a version string like ``"0.41"`` into ``(0, 41)`` for
    range comparisons. Tuples compare lexicographically, matching
    semver ordering for the dotted-integer subset the federation
    uses. Non-integer components are not handled — the federation
    never uses pre-releases / post-releases / local versions in
    migration registrations.

    PAPER-87 Approach B (v0.41.6+).
    """
    parts: list[int] = []
    for p in v.split("."):
        try:
            parts.append(int(p))
        except ValueError:
            break
    return tuple(parts)


def _resolve_migration_chain(
    from_version: str,
    to_version: str,
) -> tuple[tuple[str, str, tuple[Rule, ...]], ...]:
    """Resolve a ``(from, to)`` request to an ordered chain of
    registered migrations.

    PAPER-87 Approach B (v0.41.6+). The federation registers
    migrations as atomic per-hop entries (typically one hop per
    minor that carried a rename). Consumers naturally request
    spans (e.g., ``migrate 0.37 0.41``) where their pin was. This
    composes the chain:

      1. **Atomic match first.** If ``(from, to)`` is in
         :data:`_MIGRATIONS` verbatim, return just that — preserves
         the v0.41.4 behaviour for the explicitly-registered hops.
      2. **Range composition.** Otherwise, collect every registered
         migration ``(s, t)`` where ``from_version <= s`` AND
         ``t <= to_version``. Sort by source-version ascending.
         Apply each in sequence.

    Returns a tuple of ``(fr, to, rules)`` triples in apply-order.
    Empty tuple means "no migrations match the range" — caller
    surfaces an unsupported-migration error.

    Rules are identifier-anchored and idempotent, so concatenating
    them in sequence is safe even when an earlier hop's post-rename
    names appear in the input (a later hop's pattern targeting the
    pre-rename name simply finds nothing to match).
    """
    if (from_version, to_version) in _MIGRATIONS:
        return ((from_version, to_version, _MIGRATIONS[(from_version, to_version)]),)

    from_v = _parse_minor(from_version)
    to_v = _parse_minor(to_version)
    if not from_v or not to_v or from_v > to_v:
        return ()

    chain: list[tuple[str, str, tuple[Rule, ...]]] = []
    for (s, t), rules in _MIGRATIONS.items():
        s_v = _parse_minor(s)
        t_v = _parse_minor(t)
        if from_v <= s_v and t_v <= to_v:
            chain.append((s, t, rules))

    chain.sort(key=lambda triple: _parse_minor(triple[0]))
    return tuple(chain)


# Migration table. Keyed by ``(from, to)`` so future versions
# can add ``(0.3, 0.4)`` etc. without a version-juggle.
#
# PAPER-43 note: ``(0.32, 0.33)`` shares ``_RULES_0_1_TO_0_3``
# verbatim. The v0.33.0 AP-12 deprecation-removal release deletes
# the ``EnvironmentError`` and ``make_settings_config`` aliases
# that were already documented for removal back in the v0.3
# closed-box redesign. The rules are identifier-anchored renames;
# they don't care which release cycle is removing the alias, only
# which name is being replaced. A consumer who ran the v0.1 →
# v0.3 codemod sees a no-op on re-run for v0.32 → v0.33 (the
# codemod is idempotent — second pass finds new names already
# present). A consumer catching up directly from pre-v0.3 to
# v0.33+ gets the renames applied for the first time.
_MIGRATIONS: dict[tuple[str, str], tuple[Rule, ...]] = {
    ("0.1", "0.3"): _RULES_0_1_TO_0_3,
    ("0.32", "0.33"): _RULES_0_1_TO_0_3,
    ("0.38", "0.39"): _RULES_0_38_TO_0_39,
}


# ---------------------------------------------------------------------------
# File walker + transformer
# ---------------------------------------------------------------------------


def _python_files(root: Path) -> Iterator[Path]:
    """Yield every ``.py`` file under ``root`` (or ``root`` itself
    if it is a single ``.py`` file).

    Skips common no-op directories (``.venv``, ``__pycache__``,
    ``.git``, ``build``, ``dist``, ``node_modules``) so the
    codemod doesn't waste time scanning vendored or compiled
    artefacts.
    """
    if root.is_file():
        if root.suffix == ".py":
            yield root
        return

    skip_dirs = {
        ".venv",
        "venv",
        ".env",
        "env",
        "__pycache__",
        ".git",
        "build",
        "dist",
        ".tox",
        ".mypy_cache",
        ".pytest_cache",
        "node_modules",
    }

    for path in root.rglob("*.py"):
        if any(part in skip_dirs for part in path.parts):
            continue
        yield path


@dataclass(frozen=True)
class FileChange:
    """One file's transformation report."""

    path: Path
    """Absolute path to the file."""

    original: str
    """Pre-transformation file contents."""

    transformed: str
    """Post-transformation file contents (same as ``original``
    when no rule matched)."""

    rules_applied: tuple[str, ...]
    """Descriptions of the rules that produced at least one
    substitution."""

    @property
    def changed(self) -> bool:
        return self.original != self.transformed


def _apply_rules(text: str, rules: Iterable[Rule]) -> tuple[str, list[str]]:
    """Apply each rule to ``text`` in order; return ``(new_text, applied)``.

    For each rule, the ``import_gate`` is checked against the
    file BEFORE the rename is considered. Files that don't match
    the gate are skipped FOR THAT RULE — other rules with
    different gates may still apply.

    ``applied`` is the list of rule descriptions that produced
    at least one substitution. The list preserves insertion
    order so the per-file report is deterministic.
    """
    applied: list[str] = []
    out = text
    for rule in rules:
        if not rule.import_gate.search(out):
            continue
        new_text, count = rule.pattern.subn(rule.replacement, out)
        if count > 0:
            applied.append(rule.description)
            out = new_text
    return out, applied


def _transform_file(path: Path, rules: Iterable[Rule]) -> FileChange:
    """Read ``path``, apply ``rules``, return the change report."""
    original = path.read_text(encoding="utf-8")
    transformed, applied = _apply_rules(original, rules)
    return FileChange(
        path=path,
        original=original,
        transformed=transformed,
        rules_applied=tuple(applied),
    )


# ---------------------------------------------------------------------------
# Subcommand entry point
# ---------------------------------------------------------------------------


def migrate(
    from_version: str,
    to_version: str,
    target: Path,
    *,
    dry_run: bool = False,
    backup: bool = False,
    out: io.TextIOBase | None = None,
    err: io.TextIOBase | None = None,
) -> int:
    """Run the codemod; return the exit code.

    Parameters
    ----------
    from_version, to_version
        The migration to apply (e.g. ``"0.1"`` → ``"0.3"``).
        Unsupported pairs return :data:`EXIT_CONFIG_ERROR` with
        a list of supported migrations on stderr.
    target
        File or directory to walk. Symlinks are NOT followed.
    dry_run
        If true, no files are written; the report still prints.
        Useful for previewing what the codemod would do.
    backup
        If true, each modified file's pre-transformation
        contents are written to ``<file>.bak`` next to the
        original before the new contents are written.
    out, err
        Streams. Default to ``sys.stdout`` / ``sys.stderr``.
    """
    if out is None:
        out = sys.stdout  # type: ignore[assignment]
    if err is None:
        err = sys.stderr  # type: ignore[assignment]
    assert out is not None and err is not None

    chain = _resolve_migration_chain(from_version, to_version)
    if not chain:
        print(
            f"✗ unsupported migration: {from_version} → {to_version}",
            file=err,
        )
        print(file=err)
        print("Supported migrations:", file=err)
        for fr, to in sorted(_MIGRATIONS.keys(), key=lambda p: _parse_minor(p[0])):
            print(f"  {fr} → {to}", file=err)
        print(file=err)
        print(
            "PAPER-87 Approach B (v0.41.6+) composes intermediate "
            "hops automatically when the requested span has no "
            "atomic registration. A request landing here means no "
            "registered migrations fall within the range "
            f"[{from_version}, {to_version}].",
            file=err,
        )
        return EXIT_CONFIG_ERROR

    # PAPER-87 Approach B: concatenate rules from every link in the
    # chain, in apply-order. Idempotent per-rule + per-rule import
    # gating make the concatenated set safe for any input (a later
    # hop's pre-rename pattern simply finds nothing in
    # already-renamed source).
    rules: tuple[Rule, ...] = tuple(r for _, _, link_rules in chain for r in link_rules)

    if not target.exists():
        print(f"✗ target path does not exist: {target}", file=err)
        return EXIT_OPERATION_ERROR

    print(
        f"mgf-fed migrate {from_version} → {to_version}  "
        f"target: {target}{' (dry-run)' if dry_run else ''}",
        file=out,
    )
    # When the request is a composed chain (not a single atomic
    # registration), surface the steps so consumers see what's
    # about to run.
    is_composed = len(chain) > 1 or chain[0][:2] != (from_version, to_version)
    if is_composed:
        print("Composed migration chain:", file=out)
        for fr, to, _ in chain:
            print(f"  {fr} → {to}", file=out)
    print(file=out)

    files_scanned = 0
    files_changed = 0

    for path in _python_files(target):
        files_scanned += 1

        try:
            change = _transform_file(path, rules)
        except (OSError, UnicodeDecodeError) as e:
            print(f"  ✗ {path}: failed to read — {e}", file=err)
            return EXIT_OPERATION_ERROR

        if not change.changed:
            continue

        files_changed += 1
        rule_summary = ", ".join(change.rules_applied)
        print(f"  ✓ {path}  [{rule_summary}]", file=out)

        if dry_run:
            continue

        # SH-04: validate the codemod result parses as Python
        # BEFORE writing. A transform that produces invalid
        # source must fail loud, not land a broken file (the
        # file's original content is the consumer's working
        # source; leaving them with a half-migrated unparseable
        # version is worse than refusing). Python-source-only —
        # other future codemods (e.g. for YAML) would need their
        # own validator.
        import ast

        try:
            ast.parse(change.transformed, filename=str(path))
        except SyntaxError as e:
            print(
                f"  ✗ {path}: codemod produced invalid Python — refusing to write. {e}",
                file=err,
            )
            return EXIT_OPERATION_ERROR

        # Preserve the consumer's file permissions. A source
        # file might be 0o644 normally but 0o755 for a script,
        # or hardened to 0o600 by a security-conscious consumer
        # — ``atomic_write``'s default ``mode=0o600`` would
        # change that. Read the original mode first; fall back
        # to 0o644 on stat failure (the rename hasn't happened
        # yet, so a stat failure is unusual).
        try:
            original_mode = path.stat().st_mode & 0o777
        except OSError:
            original_mode = 0o644

        try:
            if backup:
                # The backup is a throwaway recovery aid; a
                # half-written backup is harmless. Plain
                # ``write_text`` is fine here.
                backup_path = path.with_suffix(path.suffix + ".bak")
                backup_path.write_text(change.original, encoding="utf-8")
            # SH-04: atomic write of the consumer's source. A
            # crash mid-write must NOT truncate the original —
            # ``atomic_write`` writes a temp file, fsync's it,
            # then renames over the target. Either the consumer
            # sees the old file (rename hasn't happened yet) or
            # the new file (rename completed); never a partial
            # half-written source file.
            from mgf.common.io import atomic_write

            atomic_write(path, change.transformed, mode=original_mode)
        except OSError as e:
            print(f"  ✗ {path}: failed to write — {e}", file=err)
            return EXIT_OPERATION_ERROR

    print(file=out)
    print(
        f"Scanned {files_scanned} file{'s' if files_scanned != 1 else ''}, "
        f"{'would change' if dry_run else 'changed'} {files_changed}.",
        file=out,
    )

    if files_changed == 0:
        print("Nothing to do — your code is already migrated.", file=out)

    return EXIT_SUCCESS


def add_migrate_args(subparser: Any) -> None:
    """Wire the ``migrate`` subcommand to an argparse subparser."""
    subparser.add_argument(
        "from_version",
        help="Source version (e.g. '0.1').",
    )
    subparser.add_argument(
        "to_version",
        help="Target version (e.g. '0.3').",
    )
    subparser.add_argument(
        "target",
        type=Path,
        help="File or directory to migrate. Walks recursively if "
        "a directory; symlinks are NOT followed.",
    )
    subparser.add_argument(
        "--dry-run",
        action="store_true",
        default=False,
        help="Preview changes without writing files.",
    )
    subparser.add_argument(
        "--backup",
        action="store_true",
        default=False,
        help="Write <file>.bak alongside each modified file.",
    )


def run_migrate(args: Any) -> int:
    """Argparse-callback wiring for the ``migrate`` subcommand."""
    return migrate(
        from_version=args.from_version,
        to_version=args.to_version,
        target=args.target,
        dry_run=args.dry_run,
        backup=args.backup,
    )


__all__ = [
    "FileChange",
    "Rule",
    "add_migrate_args",
    "migrate",
    "run_migrate",
]
