"""Deterministic fixers for mechanical bugs — no LLM needed.

Each fixer takes the source code and finding, returns a list of edits
(line-range operations) or None if it can't fix the issue.
"""

from __future__ import annotations

import ast
import re
from dataclasses import dataclass, field
from typing import Optional


@dataclass
class Edit:
    """A single line-range edit operation."""
    action: str  # "replace", "insert_after", "delete"
    start_line: int  # 1-indexed
    end_line: int = 0  # 1-indexed, inclusive (0 = same as start_line)
    new_code: str = ""  # replacement or insertion text


@dataclass
class FixPlan:
    """Result of a deterministic fix attempt."""
    edits: list[Edit] = field(default_factory=list)
    explanation: str = ""
    skip: bool = False  # True = finding is a false positive, don't touch
    skip_reason: str = ""


def apply_edits(source: str, edits: list[Edit]) -> str:
    """Apply a list of Edit operations to source code.

    Edits are applied from bottom to top to preserve line numbers.
    For edits at the same line, original order is preserved (stable sort).
    """
    lines = source.split("\n")

    # Sort edits bottom-to-top so earlier line numbers aren't shifted.
    # Use enumerate to preserve original order for same-line edits (stable sort).
    indexed = list(enumerate(edits))
    indexed.sort(key=lambda pair: (-pair[1].start_line, -pair[0]))

    for _orig_idx, edit in indexed:
        idx = edit.start_line - 1  # 0-indexed
        end_idx = (edit.end_line or edit.start_line) - 1

        if edit.action == "replace":
            new_lines = edit.new_code.split("\n") if edit.new_code else []
            lines[idx:end_idx + 1] = new_lines
        elif edit.action == "insert_after":
            new_lines = edit.new_code.split("\n")
            lines[idx + 1:idx + 1] = new_lines
        elif edit.action == "delete":
            lines[idx:end_idx + 1] = []

    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Import resolution table — maps common names to their import statements
# ---------------------------------------------------------------------------

_STDLIB_IMPORTS: dict[str, str] = {
    "os": "import os",
    "sys": "import sys",
    "re": "import re",
    "json": "import json",
    "logging": "import logging",
    "datetime": "import datetime",
    "time": "import time",
    "math": "import math",
    "pathlib": "from pathlib import Path",
    "Path": "from pathlib import Path",
    "typing": "import typing",
    "Optional": "from typing import Optional",
    "List": "from typing import List",
    "Dict": "from typing import Dict",
    "Any": "from typing import Any",
    "Union": "from typing import Union",
    "Tuple": "from typing import Tuple",
    "Set": "from typing import Set",
    "collections": "import collections",
    "defaultdict": "from collections import defaultdict",
    "OrderedDict": "from collections import OrderedDict",
    "Counter": "from collections import Counter",
    "dataclasses": "import dataclasses",
    "dataclass": "from dataclasses import dataclass",
    "field": "from dataclasses import field",
    "abc": "import abc",
    "ABC": "from abc import ABC",
    "abstractmethod": "from abc import abstractmethod",
    "functools": "import functools",
    "itertools": "import itertools",
    "contextlib": "import contextlib",
    "asyncio": "import asyncio",
    "uuid": "import uuid",
    "hashlib": "import hashlib",
    "base64": "import base64",
    "copy": "import copy",
    "deepcopy": "from copy import deepcopy",
    "shutil": "import shutil",
    "tempfile": "import tempfile",
    "subprocess": "import subprocess",
    "traceback": "import traceback",
    "inspect": "import inspect",
    "io": "import io",
    "BytesIO": "from io import BytesIO",
    "StringIO": "from io import StringIO",
    "textwrap": "import textwrap",
    "enum": "import enum",
    "Enum": "from enum import Enum",
    "struct": "import struct",
    "socket": "import socket",
    "http": "import http",
    "urllib": "import urllib",
    "warnings": "import warnings",
    "threading": "import threading",
    "Lock": "from threading import Lock",
    "Thread": "from threading import Thread",
    "signal": "import signal",
    "secrets": "import secrets",
    "hmac": "import hmac",
    "csv": "import csv",
    "glob": "import glob",
    "fnmatch": "import fnmatch",
    "gzip": "import gzip",
    "zipfile": "import zipfile",
    "pickle": "import pickle",
    "shelve": "import shelve",
    "sqlite3": "import sqlite3",
    "decimal": "import decimal",
    "Decimal": "from decimal import Decimal",
    "fractions": "import fractions",
    "random": "import random",
    "string": "import string",
    "pprint": "import pprint",
    "weakref": "import weakref",
}

# Common third-party imports
_THIRDPARTY_IMPORTS: dict[str, str] = {
    "requests": "import requests",
    "httpx": "import httpx",
    "pydantic": "import pydantic",
    "BaseModel": "from pydantic import BaseModel",
    "FastAPI": "from fastapi import FastAPI",
    "APIRouter": "from fastapi import APIRouter",
    "HTTPException": "from fastapi import HTTPException",
    "Depends": "from fastapi import Depends",
    "pytest": "import pytest",
    "numpy": "import numpy",
    "np": "import numpy as np",
    "pandas": "import pandas",
    "pd": "import pandas as pd",
}


def _find_last_import_line(lines: list[str]) -> int:
    """Find the 1-indexed line number of the last top-level import.

    Handles multi-line imports like:
        from foo import (
            bar,
            baz,
        )
    Returns the line of the closing paren, not the opening.
    """
    last = 0
    in_multiline = False
    for i, line in enumerate(lines):
        stripped = line.strip()
        if in_multiline:
            last = i + 1
            if ")" in stripped:
                in_multiline = False
            continue
        if (stripped.startswith("import ") or stripped.startswith("from ")) \
                and not line.startswith(" ") and not line.startswith("\t"):
            last = i + 1  # 1-indexed
            if "(" in stripped and ")" not in stripped:
                in_multiline = True
    return last


def _is_already_imported(lines: list[str], import_stmt: str) -> bool:
    """Check if an import statement (or equivalent) already exists at top level.

    Only checks top-level imports (not indented), since local imports inside
    functions don't make the name available at module scope.
    """
    import_stripped = import_stmt.strip()
    for line in lines:
        # Skip indented lines (local imports inside functions/classes)
        if line.startswith(" ") or line.startswith("\t"):
            continue
        if line.strip() == import_stripped:
            return True
    # Also check for "import X" when we want "import X" and there's "from X import ..."
    m = re.match(r'import (\w+)', import_stripped)
    if m:
        mod = m.group(1)
        for line in lines:
            if line.startswith(" ") or line.startswith("\t"):
                continue
            s = line.strip()
            if s == f"import {mod}" or s.startswith(f"from {mod} import ") or s.startswith(f"from {mod}."):
                return True
    return False


def _scan_project_imports(source: str, name: str) -> Optional[str]:
    """Try to find what module a name is imported from by scanning the file's
    existing imports for similar patterns."""
    # If the file uses `logger = logging.getLogger(...)`, we know logging is needed
    if name == "logger":
        if "logging.getLogger" in source or "get_logger" in source:
            return "import logging"
    return None


# ---------------------------------------------------------------------------
# Fixer: Undefined name / not defined
# ---------------------------------------------------------------------------

def fix_undefined_name(source: str, finding) -> Optional[FixPlan]:
    """Fix 'X is not defined' / 'Undefined name X' by adding the right import."""
    msg = finding.message
    # Extract the undefined name
    m = re.search(r'["`](\w+)["`]\s+is not defined', msg)
    if not m:
        m = re.search(r'Undefined name [`"](\w+)[`"]', msg)
    if not m:
        return None

    name = m.group(1)
    lines = source.split("\n")

    # Special case: logger — check if the file uses a custom get_logger
    if name == "logger":
        # Check if there's a get_logger import but no logger = get_logger(...)
        for line in lines:
            if "get_logger" in line and "import" in line:
                # There's a get_logger import — add logger = get_logger(__name__)
                last_import = _find_last_import_line(lines)
                # Find if there's already a logger assignment
                for ln in lines:
                    if ln.strip().startswith("logger") and "get_logger" in ln:
                        return None  # already exists
                return FixPlan(
                    edits=[Edit(
                        action="insert_after",
                        start_line=last_import,
                        new_code="\nlogger = get_logger(__name__)",
                    )],
                    explanation=f"Added `logger = get_logger(__name__)` after imports.",
                )
        # No get_logger — use stdlib logging
        import_stmt = "import logging"
        if not _is_already_imported(lines, import_stmt):
            last_import = _find_last_import_line(lines)
            return FixPlan(
                edits=[
                    Edit(action="insert_after", start_line=last_import,
                         new_code=import_stmt),
                    Edit(action="insert_after", start_line=last_import,
                         new_code="logger = logging.getLogger(__name__)"),
                ],
                explanation="Added `import logging` and `logger = logging.getLogger(__name__)`.",
            )
        else:
            # logging is imported but logger isn't defined
            last_import = _find_last_import_line(lines)
            return FixPlan(
                edits=[Edit(
                    action="insert_after",
                    start_line=last_import,
                    new_code="\nlogger = logging.getLogger(__name__)",
                )],
                explanation="Added `logger = logging.getLogger(__name__)` after imports.",
            )

    # Look up in known import tables
    import_stmt = _STDLIB_IMPORTS.get(name) or _THIRDPARTY_IMPORTS.get(name)

    # If not in table, try to infer from usage context
    if not import_stmt:
        import_stmt = _scan_project_imports(source, name)

    if not import_stmt:
        return None  # can't determine the import — fall through to LLM

    if _is_already_imported(lines, import_stmt):
        return None  # already imported, issue is something else

    last_import = _find_last_import_line(lines)
    if last_import == 0:
        last_import = 1  # no imports yet, insert at top

    return FixPlan(
        edits=[Edit(action="insert_after", start_line=last_import, new_code=import_stmt)],
        explanation=f"Added `{import_stmt}` to resolve undefined name `{name}`.",
    )


# ---------------------------------------------------------------------------
# Fixer: type(x) != type(y) → type(x) is not type(y)
# ---------------------------------------------------------------------------

# (fix_type_comparison is defined below, after fix_comparison_to_singleton)


# ---------------------------------------------------------------------------
# Fixer: Mutable default argument
# ---------------------------------------------------------------------------

def fix_mutable_default(source: str, finding) -> Optional[FixPlan]:
    """Fix B006/mutable-default-argument: def f(x=[]) → def f(x=None) + guard."""
    lines = source.split("\n")
    line_idx = finding.line - 1
    if line_idx < 0 or line_idx >= len(lines):
        return None

    line = lines[line_idx]

    # Match patterns: arg=[], arg={}, arg=set()
    # We need to find the function def and the specific default
    m_list = re.search(r'(\w+)\s*=\s*\[\]', line)
    m_dict = re.search(r'(\w+)\s*=\s*\{\}', line)

    if not m_list and not m_dict:
        return None

    if m_list:
        arg_name = m_list.group(1)
        old_default = "[]"
        new_default = "None"
        guard = f"    if {arg_name} is None:\n        {arg_name} = []"
    else:
        arg_name = m_dict.group(1)
        old_default = "{}"
        new_default = "None"
        guard = f"    if {arg_name} is None:\n        {arg_name} = {{}}"

    # Replace the default in the function signature
    new_line = line.replace(f"{arg_name}={old_default}", f"{arg_name}={new_default}")
    new_line = new_line.replace(f"{arg_name} = {old_default}", f"{arg_name}={new_default}")
    if new_line == line:
        return None

    # Find the function body start (first non-decorator, non-def line after the def)
    # Handle multi-line defs by finding the closing ):
    body_line = line_idx + 1
    if "):" not in line:
        # Multi-line def — find the closing
        for j in range(line_idx + 1, min(line_idx + 20, len(lines))):
            if "):" in lines[j] or ") ->" in lines[j]:
                body_line = j + 1
                break

    # Skip docstring if present
    if body_line < len(lines):
        stripped = lines[body_line].strip()
        if stripped.startswith('"""') or stripped.startswith("'''"):
            quote = stripped[:3]
            if stripped.count(quote) >= 2:
                body_line += 1  # single-line docstring
            else:
                for j in range(body_line + 1, min(body_line + 50, len(lines))):
                    if quote in lines[j]:
                        body_line = j + 1
                        break

    # Detect indentation from body
    if body_line < len(lines):
        body_indent = len(lines[body_line]) - len(lines[body_line].lstrip())
        indent = " " * body_indent
        guard = f"{indent}if {arg_name} is None:\n{indent}    {arg_name} = {old_default}"

    return FixPlan(
        edits=[
            Edit(action="replace", start_line=finding.line, new_code=new_line),
            Edit(action="insert_after", start_line=body_line,
                 new_code=guard),
        ],
        explanation=f"Replaced mutable default `{old_default}` with `None` and added guard clause.",
    )


# ---------------------------------------------------------------------------
# Fixer: except Exception: pass → except Exception: logger.exception(...)
# ---------------------------------------------------------------------------

def fix_swallowed_exception(source: str, finding) -> Optional[FixPlan]:
    """Fix bare 'except: pass' or 'except Exception: pass'."""
    lines = source.split("\n")
    line_idx = finding.line - 1
    if line_idx < 0 or line_idx >= len(lines):
        return None

    # The finding line might be the `try:`, `except:`, or nearby.
    # Search the region around the finding for the except + pass pattern.
    except_line_idx = None
    search_start = max(0, line_idx - 2)
    search_end = min(len(lines), line_idx + 10)
    for j in range(search_start, search_end):
        if lines[j].strip().startswith("except"):
            except_line_idx = j
            break

    if except_line_idx is None:
        return None

    line_idx = except_line_idx
    line = lines[line_idx]
    stripped = line.strip()

    # Find the pass line (next non-blank line)
    pass_line_idx = None
    for j in range(line_idx + 1, min(line_idx + 3, len(lines))):
        if lines[j].strip() == "pass":
            pass_line_idx = j
            break

    if pass_line_idx is None:
        return None

    # Get indentation of the pass line
    indent = lines[pass_line_idx][:len(lines[pass_line_idx]) - len(lines[pass_line_idx].lstrip())]

    # Check if logging is available
    has_logger = any("logger" in l and ("getLogger" in l or "get_logger" in l)
                     for l in lines[:50])
    has_logging_import = any(
        l.strip() == "import logging" or l.strip().startswith("from logging")
        for l in lines[:50] if not l.startswith(" ") and not l.startswith("\t")
    )

    if has_logger:
        new_pass = f'{indent}logger.exception("Unexpected error")'
    else:
        new_pass = f'{indent}logging.exception("Unexpected error")'

    # Also fix bare except to except Exception
    new_except = line
    if stripped == "except:":
        new_except = line.replace("except:", "except Exception:")

    edits = []
    # Add `import logging` if not present and no logger exists
    if not has_logger and not has_logging_import:
        last_import = _find_last_import_line(lines)
        if last_import > 0 and not _is_already_imported(lines, "import logging"):
            edits.append(Edit(action="insert_after", start_line=last_import, new_code="import logging"))
    if new_except != line:
        edits.append(Edit(action="replace", start_line=line_idx + 1, new_code=new_except))
    edits.append(Edit(action="replace", start_line=pass_line_idx + 1, new_code=new_pass))

    return FixPlan(
        edits=edits,
        explanation="Replaced silent `except: pass` with `logging.exception()`.",
    )


# ---------------------------------------------------------------------------
# Fixer: Silent error swallowing (except Exception: pass from ruff)
# ---------------------------------------------------------------------------

def fix_bare_except(source: str, finding) -> Optional[FixPlan]:
    """Fix bare `except:` → `except Exception:` (without touching the body)."""
    lines = source.split("\n")
    line_idx = finding.line - 1
    if line_idx < 0 or line_idx >= len(lines):
        return None

    # Search around the finding line for the bare except
    search_start = max(0, line_idx - 2)
    search_end = min(len(lines), line_idx + 10)
    for j in range(search_start, search_end):
        stripped = lines[j].strip()
        if stripped == "except:" or stripped == "except :":
            new_line = lines[j].replace("except:", "except Exception:", 1)
            return FixPlan(
                edits=[Edit(action="replace", start_line=j + 1, new_code=new_line)],
                explanation="Changed bare `except:` to `except Exception:` to avoid catching SystemExit/KeyboardInterrupt.",
            )
    return None


def fix_silent_error(source: str, finding) -> Optional[FixPlan]:
    """Fix 'Silent error swallowing: except Exception: pass'."""
    # This is the same pattern as swallowed_exception but from a different scanner
    return fix_swallowed_exception(source, finding)


# ---------------------------------------------------------------------------
# Fixer: f-string without placeholders (F541)
# ---------------------------------------------------------------------------

def fix_fstring_no_placeholders(source: str, finding) -> Optional[FixPlan]:
    """Fix F541: f-string without any placeholders → remove the f prefix."""
    lines = source.split("\n")
    line_idx = finding.line - 1
    if line_idx < 0 or line_idx >= len(lines):
        return None

    line = lines[line_idx]

    # Match f"..." or f'...' that has no { in it (confirmed by ruff already)
    # Replace f" with " and f' with '
    new_line = re.sub(r'\bf(""")', r'\1', line)
    new_line = re.sub(r"\bf(''')", r'\1', new_line)
    new_line = re.sub(r'\bf(")', r'\1', new_line)
    new_line = re.sub(r"\bf(')", r'\1', new_line)

    if new_line == line:
        return None

    return FixPlan(
        edits=[Edit(action="replace", start_line=finding.line, new_code=new_line)],
        explanation="Removed `f` prefix from string with no placeholders.",
    )


# ---------------------------------------------------------------------------
# Fixer: Unused loop variable (B007)
# ---------------------------------------------------------------------------

def fix_unused_loop_variable(source: str, finding) -> Optional[FixPlan]:
    """Fix B007: loop control variable not used — prefix with underscore."""
    lines = source.split("\n")
    line_idx = finding.line - 1
    if line_idx < 0 or line_idx >= len(lines):
        return None

    line = lines[line_idx]
    msg = finding.message

    # Extract variable name from message: "Loop control variable `x` not used"
    m = re.search(r'[`"\'](\w+)[`"\']', msg)
    if not m:
        return None

    var_name = m.group(1)
    if var_name.startswith("_"):
        return None  # already prefixed

    # Replace in the for loop line: `for x in` → `for _x in`
    # Be careful to only replace in the loop header, not in the iterable
    new_line = re.sub(
        rf'\bfor\s+{re.escape(var_name)}\b',
        f'for _{var_name}',
        line,
        count=1,
    )
    # Also handle tuple unpacking: `for x, y in` → `for _x, y in`
    if new_line == line:
        new_line = re.sub(
            rf'\b{re.escape(var_name)}\s*,',
            f'_{var_name},',
            line,
            count=1,
        )
    if new_line == line:
        new_line = re.sub(
            rf',\s*{re.escape(var_name)}\b',
            f', _{var_name}',
            line,
            count=1,
        )

    if new_line == line:
        return None

    return FixPlan(
        edits=[Edit(action="replace", start_line=finding.line, new_code=new_line)],
        explanation=f"Prefixed unused loop variable `{var_name}` with underscore.",
    )


# ---------------------------------------------------------------------------
# Fixer: Unused import (F401)
# ---------------------------------------------------------------------------

def fix_unused_import(source: str, finding) -> Optional[FixPlan]:
    """Fix F401: imported but unused — remove the import line."""
    lines = source.split("\n")
    line_idx = finding.line - 1
    if line_idx < 0 or line_idx >= len(lines):
        return None

    line = lines[line_idx]
    stripped = line.strip()

    # Extract the unused name from the message
    msg = finding.message
    m = re.search(r'[`"\'](\w+(?:\.\w+)*)[`"\']', msg)
    if not m:
        return None
    unused_name = m.group(1)
    # Get just the last part: "os.path" → "path"
    short_name = unused_name.split(".")[-1]

    # Case 1: simple `import X` — delete the whole line
    if stripped.startswith("import ") and "," not in stripped:
        return FixPlan(
            edits=[Edit(action="delete", start_line=finding.line)],
            explanation=f"Removed unused import `{unused_name}`.",
        )

    # Case 2: `from X import Y` with single name — delete the whole line
    fm = re.match(r'from\s+\S+\s+import\s+(\w+)\s*$', stripped)
    if fm:
        return FixPlan(
            edits=[Edit(action="delete", start_line=finding.line)],
            explanation=f"Removed unused import `{unused_name}`.",
        )

    # Case 3: `from X import A, B, C` — remove just the unused name
    fm = re.match(r'(from\s+\S+\s+import\s+)(.*)', stripped)
    if fm:
        prefix = fm.group(1)
        names_str = fm.group(2)
        names = [n.strip() for n in names_str.split(",")]
        new_names = [n for n in names if n != short_name]
        if len(new_names) < len(names) and new_names:
            indent = line[:len(line) - len(line.lstrip())]
            new_line = f"{indent}{prefix}{', '.join(new_names)}"
            return FixPlan(
                edits=[Edit(action="replace", start_line=finding.line, new_code=new_line)],
                explanation=f"Removed unused import `{short_name}` from import statement.",
            )

    return None


# ---------------------------------------------------------------------------
# Fixer: Comparison to None/True/False using == (E711, E712)
# ---------------------------------------------------------------------------

def fix_comparison_to_singleton(source: str, finding) -> Optional[FixPlan]:
    """Fix E711/E712: comparison to None/True/False should use is/is not."""
    lines = source.split("\n")
    line_idx = finding.line - 1
    if line_idx < 0 or line_idx >= len(lines):
        return None

    line = lines[line_idx]

    new_line = line
    # E711: == None → is None, != None → is not None
    new_line = re.sub(r'\b(\w+)\s*==\s*None\b', r'\1 is None', new_line)
    new_line = re.sub(r'\b(\w+)\s*!=\s*None\b', r'\1 is not None', new_line)
    # E712: == True → is True, == False → is False
    # Handle chained attribute/method calls like user.get("x") == True
    new_line = re.sub(r'([\w.]+(?:\([^)]*\))?)\s*==\s*True\b', r'\1 is True', new_line)
    new_line = re.sub(r'([\w.]+(?:\([^)]*\))?)\s*==\s*False\b', r'\1 is False', new_line)
    new_line = re.sub(r'([\w.]+(?:\([^)]*\))?)\s*!=\s*True\b', r'\1 is not True', new_line)
    new_line = re.sub(r'\b(\w+)\s*!=\s*False\b', r'\1 is not False', new_line)

    if new_line == line:
        return None

    return FixPlan(
        edits=[Edit(action="replace", start_line=finding.line, new_code=new_line)],
        explanation="Changed comparison to use `is`/`is not` instead of `==`/`!=`.",
    )


# ---------------------------------------------------------------------------
# Fixer: Type comparison with == (E721)
# ---------------------------------------------------------------------------

def fix_type_comparison(source: str, finding) -> Optional[FixPlan]:
    """Fix E721: type(x) == T → isinstance(x, T)."""
    lines = source.split("\n")
    line_idx = finding.line - 1
    if line_idx < 0 or line_idx >= len(lines):
        return None

    line = lines[line_idx]
    new_line = line

    # type(x) == T → isinstance(x, T)
    new_line = re.sub(
        r'type\(([^)]+)\)\s*==\s*(\w+)',
        r'isinstance(\1, \2)',
        new_line,
    )
    # type(x) != T → not isinstance(x, T)
    new_line = re.sub(
        r'type\(([^)]+)\)\s*!=\s*(\w+)',
        r'not isinstance(\1, \2)',
        new_line,
    )
    # T == type(x) → isinstance(x, T)  (reversed)
    new_line = re.sub(
        r'(\w+)\s*==\s*type\(([^)]+)\)',
        r'isinstance(\2, \1)',
        new_line,
    )

    if new_line == line:
        return None

    return FixPlan(
        edits=[Edit(action="replace", start_line=finding.line, new_code=new_line)],
        explanation="Replaced `type()` comparison with `isinstance()` for proper type checking.",
    )


# ---------------------------------------------------------------------------
# Fixer: Missing explicit return (RET503)
# ---------------------------------------------------------------------------

def fix_missing_return(source: str, finding) -> Optional[FixPlan]:
    """Fix RET503: missing explicit return at end of function.

    When a function has some paths that return a value and others that don't,
    add `return None` at the implicit return point.
    RET503 reports the `def` line, so we need to find the end of the function.
    """
    lines = source.split("\n")
    line_idx = finding.line - 1
    if line_idx < 0 or line_idx >= len(lines):
        return None

    line = lines[line_idx]
    stripped = line.strip()

    # If the finding line is the def line, find the end of the function body
    if stripped.startswith("def "):
        # Get the indentation of the def line
        def_indent = len(line) - len(line.lstrip())
        body_indent = def_indent + 4  # standard indent

        # Find the last line of the function body
        last_body_line = line_idx
        for j in range(line_idx + 1, len(lines)):
            ln = lines[j]
            if not ln.strip():  # blank line
                continue
            current_indent = len(ln) - len(ln.lstrip())
            if current_indent <= def_indent and ln.strip():
                # We've exited the function (hit a line at same or lower indent)
                break
            last_body_line = j

        # Don't add if last body line already returns at the function's body level
        last_line_indent = len(lines[last_body_line]) - len(lines[last_body_line].lstrip())
        if lines[last_body_line].strip().startswith("return") and last_line_indent == body_indent:
            return None

        indent = " " * body_indent
        return FixPlan(
            edits=[Edit(action="insert_after", start_line=last_body_line + 1,
                         new_code=f"{indent}return None")],
            explanation="Added explicit `return None` for missing return path.",
        )

    # If finding line is inside the body (not the def), use its indentation
    indent = line[:len(line) - len(line.lstrip())]
    if stripped.startswith("return"):
        return None

    return FixPlan(
        edits=[Edit(action="insert_after", start_line=finding.line,
                     new_code=f"{indent}return None")],
        explanation="Added explicit `return None` for missing return path.",
    )


# ---------------------------------------------------------------------------
# Fixer dispatcher — tries all deterministic fixers before LLM
# ---------------------------------------------------------------------------

_FIXER_RULES: list[tuple[callable, list[str]]] = [
    # (fixer_func, list of message patterns that trigger it)
    (fix_undefined_name, [
        "is not defined",
        "Undefined name",
    ]),
    (fix_type_comparison, [
        "Use `is` and `is not` for type comparisons",
        "E721",
    ]),
    (fix_mutable_default, [
        "Mutable default argument",
        "mutable default argument",
        "B006",
    ]),
    (fix_bare_except, [
        "Bare except",
        "bare except",
        "Do not use bare `except`",
    ]),
    (fix_swallowed_exception, [
        "Catching exception and doing nothing",
        "symptom suppression",
    ]),
    (fix_silent_error, [
        "Silent error swallowing",
        "except Exception: pass",
    ]),
    (fix_fstring_no_placeholders, [
        "F541",
        "f-string without any placeholders",
    ]),
    (fix_unused_loop_variable, [
        "B007",
        "Loop control variable",
        "not used within loop body",
    ]),
    (fix_unused_import, [
        "F401",
        "imported but unused",
        "unused import",
    ]),
    (fix_comparison_to_singleton, [
        "E711",
        "E712",
        "comparison to None",
        "comparison to True",
        "comparison to False",
        "Comparison to `None`",
        "Comparison to `True`",
        "Comparison to `False`",
        "comparisons to `None`",
        "comparisons to `True`",
        "comparisons to `False`",
        "equality comparisons to",
        "Avoid equality comparisons",
    ]),
    (fix_type_comparison, [
        "E721",
        "type comparison",
        "type comparisons",
        "Use `is` and `is not` for type comparisons",
        "isinstance",
    ]),
    (fix_missing_return, [
        "RET503",
        "missing explicit `return`",
    ]),
]


# ---------------------------------------------------------------------------
# JS/TS fixers
# ---------------------------------------------------------------------------

def fix_js_eqeqeq(source: str, finding) -> Optional[FixPlan]:
    """Fix eslint eqeqeq: == → ===, != → !==."""
    lines = source.split("\n")
    line_idx = finding.line - 1
    if line_idx < 0 or line_idx >= len(lines):
        return None
    line = lines[line_idx]
    new_line = line
    # Replace == with === (but not already ===)
    new_line = re.sub(r'(?<!=)(?<!\!)={2}(?!=)', '===', new_line)
    # Replace != with !== (but not already !==)
    new_line = re.sub(r'!={1}(?!=)', '!==', new_line)
    if new_line == line:
        return None
    return FixPlan(
        edits=[Edit(action="replace", start_line=finding.line, new_code=new_line)],
        explanation="Replaced `==`/`!=` with `===`/`!==` for strict equality.",
    )


def fix_js_unused_import(source: str, finding) -> Optional[FixPlan]:
    """Fix eslint no-unused-vars on imports: remove the unused import line."""
    lines = source.split("\n")
    line_idx = finding.line - 1
    if line_idx < 0 or line_idx >= len(lines):
        return None
    line = lines[line_idx]
    # Extract the unused variable name from the message
    # Format: [eslint:no-unused-vars] 'Foo' is defined but never used
    m = re.search(r"'(\w+)'\s+is\s+(defined|assigned|declared)\s+but\s+never\s+used", finding.message)
    if not m:
        return None
    var_name = m.group(1)
    # Only handle import lines
    if not re.match(r'\s*import\s', line):
        return None
    # Simple case: single default import or namespace import — delete the line
    if re.match(r'\s*import\s+' + re.escape(var_name) + r'\s+from\s', line):
        return FixPlan(
            edits=[Edit(action="delete", start_line=finding.line)],
            explanation=f"Removed unused import `{var_name}`.",
        )
    # Named import: import { Foo, Bar } from '...'
    # Remove just the unused name from the destructuring
    if re.search(r'\{[^}]*\b' + re.escape(var_name) + r'\b[^}]*\}', line):
        # Remove "var_name, " or ", var_name" or lone "var_name"
        new_line = re.sub(r'\b' + re.escape(var_name) + r'\s*,\s*', '', line)
        if new_line == line:
            new_line = re.sub(r',\s*' + re.escape(var_name) + r'\b', '', line)
        if new_line == line:
            # Only import in braces — delete the whole line
            new_line = re.sub(r'\{\s*' + re.escape(var_name) + r'\s*\}', '{ }', line)
            if '{ }' in new_line:
                return FixPlan(
                    edits=[Edit(action="delete", start_line=finding.line)],
                    explanation=f"Removed unused import `{var_name}`.",
                )
        return FixPlan(
            edits=[Edit(action="replace", start_line=finding.line, new_code=new_line)],
            explanation=f"Removed unused import `{var_name}` from destructured import.",
        )
    return None


_JS_FIXER_RULES: list[tuple[callable, list[str]]] = [
    (fix_js_eqeqeq, [
        "eqeqeq",
        "Expected '===' and instead saw '=='",
        "Expected '!==' and instead saw '!='",
    ]),
    (fix_js_unused_import, [
        "no-unused-vars",
        "is defined but never used",
        "is assigned a value but never used",
    ]),
]


def try_deterministic_fix(source: str, finding) -> Optional[FixPlan]:
    """Try all deterministic fixers for a finding. Returns FixPlan or None."""
    msg = finding.message
    all_rules = list(_FIXER_RULES) + list(_JS_FIXER_RULES)
    for fixer_func, patterns in all_rules:
        if any(p.lower() in msg.lower() for p in patterns):
            try:
                plan = fixer_func(source, finding)
                if plan is not None:
                    return plan
            except Exception:
                continue
    return None
