# File: scripts/convert_logging_fstrings.py
# Purpose: Reformat logging f-strings into lazy `%` formatting based on Ruff G004 findings.
# Context: Supports Phase 6-3 remediation by reconstructing logging calls in the working tree.

"""Rewrite logging f-strings into lazy `%` formatting using Ruff G004 reports.

The script analyzes the working tree version of each file, builds a safe
`%`-formatted variant, and rewrites the file in-place with a
consistent multi-line layout.
"""

from __future__ import annotations

import argparse
import ast
import json
import re
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from pathlib import Path

SAFE_EXPR_TYPES: tuple[type[ast.AST], ...] = (
    ast.Attribute,
    ast.BinOp,
    ast.BoolOp,
    ast.Call,
    ast.Compare,
    ast.Constant,
    ast.IfExp,
    ast.Name,
    ast.Subscript,
    ast.UnaryOp,
)

FORMAT_SPEC_PATTERN = re.compile(r"^[#0\- +]*(?:\d+)?(?:\.\d+)?[bcdeEfFgGnosxX%]?$")


@dataclass
class Violation:
    """Ruff violation location."""

    row: int
    column: int


@dataclass
class KeywordArgument:
    """Keyword argument representation for reconstructed calls."""

    arg: str | None
    value: str


@dataclass
class Replacement:
    """Source replacement spanning a line range."""

    start_line: int  # 1-based, inclusive
    end_line: int  # 1-based, inclusive
    text: str


def parse_arguments() -> argparse.Namespace:
    """Parse CLI arguments."""

    parser = argparse.ArgumentParser(
        description="Convert logging f-strings into lazy %% formatting using Ruff reports."
    )
    parser.add_argument(
        "--from-report",
        type=Path,
        required=True,
        help="Path to Ruff JSON report generated with --select G004.",
    )
    parser.add_argument(
        "--apply",
        action="store_true",
        help="Apply modifications to files. Without this flag, a dry-run summary is printed.",
    )
    parser.add_argument(
        "--limit",
        type=int,
        default=None,
        help="Optional limit on processed violations (for spot checks).",
    )
    return parser.parse_args()


def load_report(path: Path, limit: int | None) -> dict[Path, list[Violation]]:
    """Load Ruff JSON report and bucket violations per file."""

    with path.open(encoding="utf-8") as report_file:
        entries = json.load(report_file)

    violations: dict[Path, list[Violation]] = {}
    for index, entry in enumerate(entries):
        if limit is not None and index >= limit:
            break
        file_path = Path(entry["filename"])
        location = entry["location"]
        violations.setdefault(file_path, []).append(
            Violation(row=location["row"], column=location["column"])
        )

    return violations


def build_parent_map(tree: ast.AST) -> dict[ast.AST, ast.AST]:
    """Construct a mapping from nodes to their parents."""

    parent_map: dict[ast.AST, ast.AST] = {}

    class ParentVisitor(ast.NodeVisitor):
        def generic_visit(self, node: ast.AST) -> None:
            for child in ast.iter_child_nodes(node):
                parent_map[child] = node
                self.visit(child)

    ParentVisitor().visit(tree)
    return parent_map


def has_formatted_values(node: ast.JoinedStr) -> bool:
    """Return True when the f-string contains formatted fields."""

    return any(isinstance(value, ast.FormattedValue) for value in node.values)


def find_joined_str(
    tree: ast.AST, target_line: int, target_column: int
) -> ast.JoinedStr | None:
    """Locate the f-string node in the HEAD AST associated with the Ruff violation."""

    matches: list[tuple[int, ast.JoinedStr]] = []
    for node in ast.walk(tree):
        if not isinstance(node, ast.JoinedStr):
            continue
        start_line = getattr(node, "lineno", None)
        end_line = getattr(node, "end_lineno", start_line)
        if start_line is None or end_line is None:
            continue
        if not (start_line <= target_line <= end_line):
            continue

        start_col = getattr(node, "col_offset", 0) + 1
        end_col = getattr(node, "end_col_offset", start_col - 1) + 1
        if start_col <= target_column <= end_col and has_formatted_values(node):
            return node
        matches.append((abs(target_column - start_col), node))

    filtered = [item for item in matches if has_formatted_values(item[1])]
    if filtered:
        filtered.sort(key=lambda item: item[0])
        return filtered[0][1]

    if matches:
        matches.sort(key=lambda item: item[0])
        return matches[0][1]

    return None


def is_safe_expression(expr: ast.AST) -> bool:
    """Determine whether an expression is safe for automated conversion."""

    if not isinstance(expr, SAFE_EXPR_TYPES):
        return False

    for nested in ast.walk(expr):
        if isinstance(
            nested,
            ast.Lambda | ast.Yield | ast.YieldFrom | ast.Await | ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp,
        ):
            return False
    return True


def build_placeholder(formatted: ast.FormattedValue) -> str | None:
    """Return a suitable `%` placeholder for a formatted value."""

    if not is_safe_expression(formatted.value):
        return None

    conversion = formatted.conversion
    conversion_char = chr(conversion) if conversion != -1 else None
    format_spec = formatted.format_spec

    if format_spec is None:
        spec_text = ""
    elif isinstance(format_spec, ast.Constant) and isinstance(format_spec.value, str):
        spec_text = format_spec.value
    elif isinstance(format_spec, ast.JoinedStr):
        pieces: list[str] = []
        for value in format_spec.values:
            if isinstance(value, ast.Constant) and isinstance(value.value, str):
                pieces.append(value.value)
            else:
                return None
        spec_text = "".join(pieces)
    else:
        return None

    if spec_text and not FORMAT_SPEC_PATTERN.match(spec_text):
        return None

    if conversion_char and conversion_char not in {"s", "r"}:
        return None

    if conversion_char == "r":
        if spec_text:
            return None
        return "%r"

    if spec_text:
        return f"%{spec_text}"

    return "%s"


def escape_literal(text: str) -> str:
    """Escape percent symbols in literal fragments to keep semantics."""

    return text.replace("%", "%%")


def extract_message_components(
    source: str, joined: ast.JoinedStr
) -> tuple[str, list[str]] | None:
    """Build the `%` message literal and accompanying expression texts."""

    literal_parts: list[str] = []
    expressions: list[str] = []

    for value in joined.values:
        if isinstance(value, ast.Constant):
            if not isinstance(value.value, str):
                return None
            literal_parts.append(escape_literal(value.value))
        elif isinstance(value, ast.FormattedValue):
            placeholder = build_placeholder(value)
            if placeholder is None:
                return None
            literal_parts.append(placeholder)
            expr_text = (ast.get_source_segment(source, value.value) or "").strip()
            if not expr_text:
                return None
            expressions.append(expr_text)
        else:
            return None

    message_literal = json.dumps("".join(literal_parts), ensure_ascii=False)
    return message_literal, expressions


def compose_call_text(
    indent: str,
    func_text: str,
    message_literal: str,
    inserted_args: list[str],
    additional_args: list[str],
    keywords: list[KeywordArgument],
) -> str:
    """Create a multi-line logging call with consistent formatting."""

    lines: list[str] = [f"{indent}{func_text}("]
    needs_comma = bool(inserted_args or additional_args or keywords)
    if needs_comma:
        lines.append(f"{indent}    {message_literal},")
    else:
        lines.append(f"{indent}    {message_literal}")

    lines.extend(f"{indent}    {expression}," for expression in inserted_args)
    lines.extend(f"{indent}    {expression}," for expression in additional_args)

    for keyword in keywords:
        if keyword.arg is None:
            lines.append(f"{indent}    **{keyword.value},")
        else:
            lines.append(f"{indent}    {keyword.arg}={keyword.value},")

    lines.append(f"{indent})")
    return "\n".join(lines)


def apply_replacements_to_lines(
    lines: list[str], replacements: Iterable[Replacement]
) -> list[str]:
    """Apply textual replacements to the provided list of lines."""

    updated = lines[:]
    for replacement in sorted(replacements, key=lambda item: item.start_line, reverse=True):
        start = replacement.start_line - 1
        end = replacement.end_line
        replacement_lines = replacement.text.splitlines()
        updated[start:end] = replacement_lines
    return updated


def process_file(
    path: Path, violations: Sequence[Violation], apply_changes: bool
) -> tuple[int, int, dict[str, int]]:
    """Reconstruct logging calls for a single file."""

    if not path.exists():
        return 0, len(violations), {"file_missing": len(violations)}

    current_source = path.read_text(encoding="utf-8")
    current_lines = current_source.splitlines()
    original_had_trailing_newline = current_source.endswith("\n")

    try:
        current_tree = ast.parse(current_source)
    except SyntaxError:
        skip_reasons = {"parse_error": len(violations)}
        return 0, len(violations), skip_reasons

    parent_map = build_parent_map(current_tree)
    replacements: list[Replacement] = []
    applied = 0
    skipped = 0
    skip_reasons: dict[str, int] = {}

    for violation in violations:
        joined = find_joined_str(current_tree, violation.row, violation.column)
        if joined is None:
            skipped += 1
            skip_reasons["joinedstr_not_found"] = skip_reasons.get("joinedstr_not_found", 0) + 1
            continue

        parent: ast.AST | None = joined
        call = None
        while parent is not None:
            parent = parent_map.get(parent)
            if isinstance(parent, ast.Call):
                call = parent
                break

        if call is None or not call.args:
            skipped += 1
            skip_reasons["call_not_found"] = skip_reasons.get("call_not_found", 0) + 1
            continue

        if call.args[0] is not joined:
            skipped += 1
            skip_reasons["first_arg_not_joinedstr"] = skip_reasons.get(
                "first_arg_not_joinedstr", 0
            ) + 1
            continue

        extraction = extract_message_components(current_source, joined)
        if extraction is None:
            skipped += 1
            skip_reasons["unsupported_pattern"] = skip_reasons.get(
                "unsupported_pattern", 0
            ) + 1
            continue

        message_literal, inserted_args = extraction
        additional_args = [
            (ast.get_source_segment(current_source, arg) or "").strip() for arg in call.args[1:]
        ]
        keyword_args = [
            KeywordArgument(
                arg=keyword.arg,
                value=(ast.get_source_segment(current_source, keyword.value) or "").strip(),
            )
            for keyword in call.keywords
        ]

        indent = current_lines[call.lineno - 1][: call.col_offset]
        func_text = (ast.get_source_segment(current_source, call.func) or "").strip()
        if not func_text:
            skipped += 1
            skip_reasons["func_text_missing"] = skip_reasons.get("func_text_missing", 0) + 1
            continue

        replacement_text = compose_call_text(
            indent=indent,
            func_text=func_text,
            message_literal=message_literal,
            inserted_args=inserted_args,
            additional_args=additional_args,
            keywords=keyword_args,
        )

        replacements.append(
            Replacement(
                start_line=call.lineno,
                end_line=call.end_lineno,
                text=replacement_text,
            )
        )
        applied += 1

    if replacements and apply_changes:
        updated_lines = apply_replacements_to_lines(current_lines, replacements)
        new_source = "\n".join(updated_lines)
        if original_had_trailing_newline and not new_source.endswith("\n"):
            new_source += "\n"
        path.write_text(new_source, encoding="utf-8")

    return applied, skipped, skip_reasons


def main() -> None:
    """CLI entry point."""

    args = parse_arguments()
    violations_by_file = load_report(args.from_report, args.limit)

    total = sum(len(items) for items in violations_by_file.values())
    total_applied = 0
    total_skipped = 0
    aggregate_reasons: dict[str, int] = {}

    for file_path, violations in violations_by_file.items():
        applied, skipped, reasons = process_file(file_path, violations, args.apply)
        total_applied += applied
        total_skipped += skipped
        for key, value in reasons.items():
            aggregate_reasons[key] = aggregate_reasons.get(key, 0) + value

        status = f"converted {applied} / {len(violations)}"
        if skipped:
            status += f" (skipped {skipped})"
        print(f"{file_path}: {status}")

    mode = "APPLIED" if args.apply else "DRY-RUN"
    print("\n===== Summary =====")
    print(f"Mode             : {mode}")
    print(f"Violations total : {total}")
    print(f"Converted        : {total_applied}")
    print(f"Skipped          : {total_skipped}")
    if aggregate_reasons:
        print("Skip reasons     :")
        for reason, count in aggregate_reasons.items():
            print(f"  - {reason}: {count}")


if __name__ == "__main__":
    main()
