# File: scripts/refactor/migrate_os_getenv.py
# Purpose: Provide an assisted migration tool that rewrites os.getenv/os.environ usage to ConfigManager APIs.
# Context: Supports CONF-003 by offering a dry-run diff generator and guarded apply mode with file filtering controls.
#!/usr/bin/env python3
"""os.getenv / os.environ migration helper (CONF-003).

This script scans Python modules and proposes (or applies) replacements that
route environment variable access through ConfigManager instead of directly
touching ``os``. By default it runs in dry-run mode and prints unified diffs.
``--apply`` enables writes, but remains disabled in CI for safety.
"""

from __future__ import annotations

import argparse
import ast
import difflib
import os
import re
import sys
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field
from fnmatch import fnmatch
from pathlib import Path

TARGET_CALLS = (
    "os.getenv",
    "os.environ.get",
)

DEFAULT_HELPER_NAME = "_CONFIG_MANAGER"


@dataclass
class FilePlan:
    """Planned updates for a single file."""

    path: Path
    original: str
    updated: str
    warnings: list[str] = field(default_factory=list)

    @property
    def modified(self) -> bool:
        return self.original != self.updated


def find_call_ranges(content: str, prefix: str) -> list[tuple[int, int]]:
    """Locate call spans for the given prefix (e.g., 'os.getenv')."""

    ranges: list[tuple[int, int]] = []
    start = 0
    while True:
        idx = content.find(prefix, start)
        if idx == -1:
            break

        after = idx + len(prefix)
        while after < len(content) and content[after].isspace():
            after += 1
        if after >= len(content) or content[after] != "(":
            start = after
            continue

        depth = 0
        end = after
        while end < len(content):
            char = content[end]
            if char == "(":
                depth += 1
            elif char == ")":
                depth -= 1
                if depth == 0:
                    end += 1
                    break
            end += 1

        if depth == 0:
            ranges.append((idx, end))
            start = end
        else:
            start = after
    return ranges


def build_replacement(call_text: str, func_name: str, helper_name: str) -> tuple[str, list[str]]:
    """Return replacement text and warnings for a given call."""

    warnings: list[str] = []
    open_paren = call_text.find("(")
    args_str = call_text[open_paren + 1 : -1]

    try:
        expr = ast.parse(f"fn({args_str})", mode="eval")
        call: ast.Call = expr.body  # type: ignore[assignment]
    except SyntaxError as exc:  # pragma: no cover - edge formatting
        warnings.append(f"Unable to parse arguments for `{call_text}` ({exc})")
        return call_text, warnings

    positional = [ast.unparse(arg) for arg in call.args]  # type: ignore[attr-defined]
    keywords = [f"{kw.arg}={ast.unparse(kw.value)}" for kw in call.keywords]  # type: ignore[attr-defined]
    joined_args = ", ".join([*positional, *keywords])

    new_call = f"{helper_name}.get({joined_args})"
    if func_name == "os.environ.get" and not keywords and len(positional) < 2:
        new_call = f"{helper_name}.get({joined_args}, default=None)"

    return new_call, warnings


def replace_function_calls(content: str, func_name: str, helper_name: str) -> tuple[str, list[str]]:
    """Replace supported call patterns with ConfigManager equivalents."""

    warnings: list[str] = []
    ranges = find_call_ranges(content, func_name)
    if not ranges:
        return content, warnings

    replacements: list[tuple[int, int, str]] = []
    for start, end in ranges:
        call_text = content[start:end]
        replacement, call_warnings = build_replacement(call_text, func_name, helper_name)
        warnings.extend(call_warnings)
        if replacement != call_text:
            replacements.append((start, end, replacement))

    if not replacements:
        return content, warnings

    new_content_parts: list[str] = []
    cursor = 0
    for start, end, replacement in sorted(replacements):
        new_content_parts.append(content[cursor:start])
        new_content_parts.append(replacement)
        cursor = end
    new_content_parts.append(content[cursor:])

    return "".join(new_content_parts), warnings


def detect_helper_name(content: str) -> str:
    """Return the existing helper name, if any."""

    match = re.search(r"^(\w+)\s*=\s*get_config_manager\(\)", content, flags=re.MULTILINE)
    if match:
        return match.group(1)
    return DEFAULT_HELPER_NAME


def ensure_config_helper(content: str, helper_name: str) -> str:
    """Inject ConfigManager import and helper constant when needed."""

    lines = content.splitlines()
    insert_idx = 0
    for idx, line in enumerate(lines):
        stripped = line.strip()
        if stripped.startswith("from ") or stripped.startswith("import "):
            insert_idx = idx + 1

    if "get_config_manager" not in content:
        lines.insert(
            insert_idx,
            "from noveler.infrastructure.config.config_manager import get_config_manager",
        )
        insert_idx += 1

    helper_pattern = rf"{helper_name}\s*=\s*get_config_manager\(\)"
    if not re.search(helper_pattern, "\n".join(lines)):
        lines.insert(insert_idx, f"{helper_name} = get_config_manager()")

    updated = "\n".join(lines)
    if content.endswith("\n"):
        updated += "\n"
    return updated


def _is_os_environ_subscript(node: ast.Subscript) -> bool:
    """Return True if the node represents os.environ[...]."""

    attr = getattr(node, "value", None)
    return (
        isinstance(attr, ast.Attribute)
        and isinstance(attr.value, ast.Name)
        and attr.value.id == "os"
        and attr.attr == "environ"
    )


def _line_offsets(content: str) -> list[int]:
    """Return byte offsets for the start of each line (1-indexed)."""

    offsets = [0]
    total = 0
    for line in content.splitlines(keepends=True):
        total += len(line)
        offsets.append(total)
    return offsets


def _to_index(offsets: list[int], lineno: int, col: int) -> int:
    """Convert (lineno, col) to absolute index."""

    return offsets[lineno - 1] + col


def replace_environ_subscripts(content: str, helper_name: str) -> tuple[str, list[str]]:
    """Replace os.environ[...] access with ConfigManager helpers."""

    warnings: list[str] = []
    try:
        tree = ast.parse(content)
    except SyntaxError as exc:  # pragma: no cover - syntax errors are out-of-scope
        warnings.append(f"Failed to parse file for os.environ analysis: {exc}")
        return content, warnings

    offsets = _line_offsets(content)
    replacements: list[tuple[int, int, str]] = []

    for node in ast.walk(tree):
        if isinstance(node, ast.Assign) and len(node.targets) == 1:
            target = node.targets[0]
            if isinstance(target, ast.Subscript) and _is_os_environ_subscript(target):
                key_src = ast.get_source_segment(content, target.slice) or ast.unparse(target.slice)
                value_src = ast.get_source_segment(content, node.value) or ast.unparse(node.value)
                start = _to_index(offsets, node.lineno, node.col_offset)
                end = _to_index(offsets, node.end_lineno, node.end_col_offset)
                line_start = content.rfind("\n", 0, start) + 1
                indent = content[line_start:start]
                replacement = f"{indent}{helper_name}.set({key_src}, {value_src})"
                replacements.append((start, end, replacement))
        elif isinstance(node, ast.Subscript) and _is_os_environ_subscript(node):
            if isinstance(node.ctx, ast.Load):
                key_src = ast.get_source_segment(content, node.slice) or ast.unparse(node.slice)
                replacement = f"{helper_name}.require({key_src})"
                start = _to_index(offsets, node.lineno, node.col_offset)
                end = _to_index(offsets, node.end_lineno, node.end_col_offset)
                replacements.append((start, end, replacement))

    if not replacements:
        return content, warnings

    new_content = content
    for start, end, replacement in sorted(replacements, key=lambda item: item[0], reverse=True):
        new_content = new_content[:start] + replacement + new_content[end:]

    return new_content, warnings


def process_file(path: Path) -> FilePlan:
    """Compute the migration plan for a single file."""

    original = path.read_text(encoding="utf-8")
    updated = original
    warnings: list[str] = []
    helper_name = detect_helper_name(original)

    updated, environ_warnings = replace_environ_subscripts(updated, helper_name)
    warnings.extend(environ_warnings)

    for func in TARGET_CALLS:
        updated, call_warnings = replace_function_calls(updated, func, helper_name)
        warnings.extend(call_warnings)

    if updated != original:
        helper_name = detect_helper_name(updated)
        updated = ensure_config_helper(updated, helper_name)

    return FilePlan(path=path, original=original, updated=updated, warnings=warnings)


def iter_python_files(targets: Iterable[Path], includes: list[str], excludes: list[str]) -> list[Path]:
    """Yield Python files respecting include/exclude globs."""

    files: list[Path] = []
    for target in targets:
        if target.is_file() and target.suffix == ".py":
            files.append(target)
            continue
        if target.is_dir():
            files.extend(target.rglob("*.py"))

    def matches(path: Path) -> bool:
        rel = str(path).replace("\\", "/")
        include_hit = any(fnmatch(rel, pattern) for pattern in includes)
        exclude_hit = any(fnmatch(rel, pattern) for pattern in excludes)
        return include_hit and not exclude_hit

    return sorted({path for path in files if matches(path)})


def print_diff(plan: FilePlan) -> None:
    """Emit unified diff for a modified file."""

    diff = difflib.unified_diff(
        plan.original.splitlines(),
        plan.updated.splitlines(),
        fromfile=str(plan.path),
        tofile=f"{plan.path} (updated)",
        lineterm="",
    )
    for line in diff:
        print(line)


def parse_arguments(argv: Sequence[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Replace os.getenv/os.environ usage with ConfigManager accessors.",
    )
    parser.add_argument(
        "targets",
        nargs="*",
        default=["src/noveler"],
        help="Files or directories to scan (default: %(default)s)",
    )
    parser.add_argument(
        "--include",
        action="append",
        default=["*.py"],
        help="Glob to include (can be provided multiple times).",
    )
    parser.add_argument(
        "--exclude",
        action="append",
        default=["*/tests/*", "*/__pycache__/*"],
        help="Glob to exclude (can be provided multiple times).",
    )
    parser.add_argument(
        "--apply",
        action="store_true",
        help="Apply modifications (disabled when CI environment variable is set).",
    )
    parser.add_argument(
        "--quiet",
        action="store_true",
        help="Suppress per-file diff output (summary only).",
    )
    return parser.parse_args(argv)


def main(argv: Sequence[str] | None = None) -> int:
    args = parse_arguments(argv)

    if args.apply and os.environ.get("CI"):
        print("❌ Apply mode is disabled in CI for safety. Run in dry-run or unset CI.", file=sys.stderr)
        return 2

    targets = [Path(p).resolve() for p in args.targets]
    files = iter_python_files(targets, args.include, args.exclude)

    modified = 0
    total_warnings = 0

    for path in files:
        plan = process_file(path)
        if plan.warnings:
            total_warnings += len(plan.warnings)
            for warning in plan.warnings:
                print(f"⚠️  {path}: {warning}")

        if not plan.modified:
            continue

        modified += 1
        if args.apply:
            path.write_text(plan.updated, encoding="utf-8")
            print(f"✅ Updated: {path}")
        elif not args.quiet:
            print_diff(plan)
        else:
            print(f"⊕ Diff: {path}")

    print()
    print("Summary")
    print("-------")
    print(f"Files scanned : {len(files)}")
    print(f"Files modified: {modified}")
    print(f"Warnings      : {total_warnings}")
    if not args.apply:
        print("Mode          : DRY-RUN (no files were changed)")
        print("Run with --apply to write changes; CI runs will refuse apply mode.")
    else:
        print("Mode          : APPLY (changes written)")

    return 0


if __name__ == "__main__":
    sys.exit(main())
