"""
Purpose: Detects single-statement patterns in Python code for DRY linter filtering

Scope: AST-based analysis to identify single logical statements that should not be flagged as duplicates

Overview: Provides sophisticated single-statement pattern detection to filter false positives in the
    DRY linter. Analyzes Python AST to identify when a code block represents a single logical
    statement (class field definitions, decorated functions, multi-line calls, assignments) that
    should not be flagged as duplicate code. Uses line-to-node indexing for O(1) lookups and
    supports various Python language constructs including classes, functions, decorators, and
    nested structures.

Dependencies: ast module for Python AST parsing

Exports: SingleStatementDetector class

Interfaces: SingleStatementDetector.is_single_statement(content, start_line, end_line) -> bool

Implementation: AST walking with line-to-node index optimization for performance

Suppressions:
    - type:ignore[attr-defined]: Tree-sitter Node.text attribute access (optional dependency)
    - type:ignore[operator]: Tree-sitter Node comparison operations (optional dependency)
    - too-many-arguments,too-many-positional-arguments: Builder pattern with related params
    - srp.violation: Complex AST analysis algorithm for single-statement detection. See SRP Exception below.

SRP Exception: SingleStatementDetector has 33 methods and 308 lines (exceeds max 8 methods/200 lines)
    Justification: Complex AST analysis algorithm for single-statement pattern detection with sophisticated
    false positive filtering. Methods form tightly coupled algorithm pipeline: class field detection,
    decorator handling, function call analysis, assignment patterns, and context-aware filtering. Similar
    to parser or compiler pass architecture where algorithmic cohesion is critical. Splitting would
    fragment the algorithm logic and make maintenance harder by separating interdependent AST analysis
    steps. All methods contribute to single responsibility: accurately detecting single-statement patterns
    to prevent false positives in duplicate code detection.
"""

import ast
from collections.abc import Callable
from typing import cast

# AST context checking constants
AST_LOOKBACK_LINES = 10
AST_LOOKFORWARD_LINES = 5

# Type alias for AST nodes with line number attributes
ASTWithLineNumbers = ast.stmt | ast.expr


class SingleStatementDetector:  # thailint: ignore[srp.violation]
    """Detects single-statement patterns in Python code for duplicate filtering.

    SRP suppression: Complex AST analysis algorithm requires 33 methods to implement
    sophisticated single-statement detection with false positive filtering. See file header for justification.
    """

    def __init__(
        self,
        cached_ast: ast.Module | None = None,
        cached_content: str | None = None,
        line_to_nodes: dict[int, list[ast.AST]] | None = None,
    ):
        """Initialize detector with optional cached AST data.

        Args:
            cached_ast: Pre-parsed AST tree (for performance)
            cached_content: Content that was parsed into cached_ast
            line_to_nodes: Pre-built line-to-node index
        """
        self._cached_ast = cached_ast
        self._cached_content = cached_content
        self._line_to_nodes = line_to_nodes

    def is_single_statement(self, content: str, start_line: int, end_line: int) -> bool:
        """Check if a line range in the original source is a single logical statement.

        Performance optimization: Uses cached AST if available to avoid re-parsing.

        Args:
            content: Source code content
            start_line: Starting line number (1-indexed)
            end_line: Ending line number (1-indexed)

        Returns:
            True if range represents a single logical statement
        """
        tree = self._get_ast_tree(content)
        if tree is None:
            return False

        return self._check_overlapping_nodes(tree, start_line, end_line)

    def _get_ast_tree(self, content: str) -> ast.Module | None:
        """Get AST tree, using cache if available."""
        if self._cached_ast is not None and content == self._cached_content:
            return self._cached_ast
        return self._parse_content_safe(content)

    @staticmethod
    def _parse_content_safe(content: str) -> ast.Module | None:
        """Parse content, returning None on syntax error."""
        try:
            return ast.parse(content)
        except SyntaxError:
            return None

    @staticmethod
    def build_line_to_node_index(tree: ast.Module | None) -> dict[int, list[ast.AST]] | None:
        """Build an index mapping each line number to overlapping AST nodes.

        Performance optimization: Allows O(1) lookups instead of O(n) ast.walk() calls.

        Args:
            tree: Parsed AST tree (None if parsing failed)

        Returns:
            Dictionary mapping line numbers to list of AST nodes overlapping that line
        """
        if tree is None:
            return None

        line_to_nodes: dict[int, list[ast.AST]] = {}
        for node in ast.walk(tree):
            if SingleStatementDetector._node_has_line_info(node):
                SingleStatementDetector._add_node_to_index(node, line_to_nodes)

        return line_to_nodes

    @staticmethod
    def _node_has_line_info(node: ast.AST) -> bool:
        """Check if node has valid line number information."""
        if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
            return False
        return node.lineno is not None and node.end_lineno is not None

    @staticmethod
    def _add_node_to_index(node: ast.AST, line_to_nodes: dict[int, list[ast.AST]]) -> None:
        """Add node to all lines it overlaps in the index."""
        for line_num in range(node.lineno, node.end_lineno + 1):  # type: ignore[attr-defined]
            if line_num not in line_to_nodes:
                line_to_nodes[line_num] = []
            line_to_nodes[line_num].append(node)

    def _check_overlapping_nodes(self, tree: ast.Module, start_line: int, end_line: int) -> bool:
        """Check if any AST node overlaps and matches single-statement pattern."""
        if self._line_to_nodes is not None:
            return self._check_nodes_via_index(start_line, end_line)
        return self._check_nodes_via_walk(tree, start_line, end_line)

    def _check_nodes_via_index(self, start_line: int, end_line: int) -> bool:
        """Check nodes using line-to-node index for O(1) lookups."""
        candidates = self._collect_candidate_nodes(start_line, end_line)
        return self._any_node_matches_pattern(candidates, start_line, end_line)

    def _collect_candidate_nodes(self, start_line: int, end_line: int) -> set[ast.AST]:
        """Collect unique nodes that overlap with the line range from index."""
        candidate_nodes: set[ast.AST] = set()
        for line_num in range(start_line, end_line + 1):
            if self._line_to_nodes and line_num in self._line_to_nodes:
                candidate_nodes.update(self._line_to_nodes[line_num])
        return candidate_nodes

    def _any_node_matches_pattern(
        self, nodes: set[ast.AST], start_line: int, end_line: int
    ) -> bool:
        """Check if any node matches single-statement pattern."""
        return any(self._is_single_statement_pattern(node, start_line, end_line) for node in nodes)

    def _check_nodes_via_walk(self, tree: ast.Module, start_line: int, end_line: int) -> bool:
        """Check nodes using ast.walk() fallback."""
        return any(
            self._node_matches_via_walk(node, start_line, end_line) for node in ast.walk(tree)
        )

    def _node_matches_via_walk(self, node: ast.AST, start_line: int, end_line: int) -> bool:
        """Check if a single node overlaps and matches pattern."""
        if not self._node_overlaps_range(node, start_line, end_line):
            return False
        return self._is_single_statement_pattern(node, start_line, end_line)

    @staticmethod
    def _node_overlaps_range(node: ast.AST, start_line: int, end_line: int) -> bool:
        """Check if node overlaps with the given line range."""
        if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
            return False
        node_end = node.end_lineno
        node_start = node.lineno
        return not (node_end < start_line or node_start > end_line)

    def _is_single_statement_pattern(self, node: ast.AST, start_line: int, end_line: int) -> bool:
        """Check if an AST node represents a single-statement pattern to filter."""
        contains = self._node_contains_range(node, start_line, end_line)
        if contains is None:
            return False

        return self._dispatch_pattern_check(node, start_line, end_line, contains)

    def _node_contains_range(self, node: ast.AST, start_line: int, end_line: int) -> bool | None:
        """Check if node completely contains the range. Returns None if invalid."""
        if not self._has_valid_line_numbers(node):
            return None
        typed_node = cast(ASTWithLineNumbers, node)
        return typed_node.lineno <= start_line and typed_node.end_lineno >= end_line  # type: ignore[operator]

    @staticmethod
    def _has_valid_line_numbers(node: ast.AST) -> bool:
        """Check if node has valid line number attributes."""
        if not (hasattr(node, "lineno") and hasattr(node, "end_lineno")):
            return False
        return node.lineno is not None and node.end_lineno is not None

    def _dispatch_pattern_check(
        self, node: ast.AST, start_line: int, end_line: int, contains: bool
    ) -> bool:
        """Dispatch to node-type-specific pattern checkers."""
        if isinstance(node, ast.Expr):
            return contains

        return self._check_specific_pattern(node, start_line, end_line, contains)

    def _check_specific_pattern(
        self, node: ast.AST, start_line: int, end_line: int, contains: bool
    ) -> bool:
        """Check specific node types with their pattern rules."""
        if isinstance(node, ast.ClassDef):
            return self._check_class_def_pattern(node, start_line, end_line)
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            return self._check_function_def_pattern(node, start_line, end_line)
        if isinstance(node, ast.Call):
            return self._check_call_pattern(node, start_line, end_line, contains)
        if isinstance(node, ast.Assign):
            return self._check_assign_pattern(node, start_line, end_line, contains)
        return False

    def _check_class_def_pattern(self, node: ast.ClassDef, start_line: int, end_line: int) -> bool:
        """Check if range is in class field definitions (not method bodies)."""
        first_method_line = self._find_first_method_line(node)
        class_start = self._get_class_start_with_decorators(node)
        return self._is_in_class_fields_area(
            class_start, start_line, end_line, first_method_line, node.end_lineno
        )

    @staticmethod
    def _find_first_method_line(node: ast.ClassDef) -> int | None:
        """Find line number of first method in class."""
        for item in node.body:
            if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
                return item.lineno
        return None

    @staticmethod
    def _get_class_start_with_decorators(node: ast.ClassDef) -> int:
        """Get class start line, including decorators if present."""
        if node.decorator_list:
            return min(d.lineno for d in node.decorator_list)
        return node.lineno

    @staticmethod
    def _is_in_class_fields_area(
        class_start: int,
        start_line: int,
        end_line: int,
        first_method_line: int | None,
        class_end_line: int | None,
    ) -> bool:
        """Check if range is in class fields area (before methods)."""
        if first_method_line is not None:
            return class_start <= start_line and end_line < first_method_line
        if class_end_line is not None:
            return class_start <= start_line and class_end_line >= end_line
        return False

    def _check_function_def_pattern(
        self, node: ast.FunctionDef | ast.AsyncFunctionDef, start_line: int, end_line: int
    ) -> bool:
        """Check if range is in function decorator pattern."""
        if not node.decorator_list:
            return False

        first_decorator_line = min(d.lineno for d in node.decorator_list)
        first_body_line = self._get_function_body_start(node)

        if first_body_line is None:
            return False

        return start_line >= first_decorator_line and end_line < first_body_line

    @staticmethod
    def _get_function_body_start(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int | None:
        """Get the line number where function body starts."""
        if not node.body or not hasattr(node.body[0], "lineno"):
            return None
        return node.body[0].lineno

    def _check_call_pattern(
        self, node: ast.Call, start_line: int, end_line: int, contains: bool
    ) -> bool:
        """Check if range is part of a function/constructor call."""
        return self._check_multiline_or_contained(node, start_line, end_line, contains)

    def _check_assign_pattern(
        self, node: ast.Assign, start_line: int, end_line: int, contains: bool
    ) -> bool:
        """Check if range is part of a multi-line assignment."""
        return self._check_multiline_or_contained(node, start_line, end_line, contains)

    def _check_multiline_or_contained(
        self, node: ast.AST, start_line: int, end_line: int, contains: bool
    ) -> bool:
        """Check if node is multiline containing start, or single-line containing range."""
        if not self._has_valid_line_numbers(node):
            return False

        typed_node = cast(ASTWithLineNumbers, node)
        is_multiline = typed_node.lineno < typed_node.end_lineno  # type: ignore[operator]
        if is_multiline:
            return typed_node.lineno <= start_line <= typed_node.end_lineno  # type: ignore[operator]
        return contains

    def is_standalone_single_statement(
        self, lines: list[str], start_line: int, end_line: int
    ) -> bool:
        """Check if the exact range parses as a single statement on its own."""
        source_lines = lines[start_line - 1 : end_line]
        source_snippet = "\n".join(source_lines)

        try:
            tree = ast.parse(source_snippet)
            return len(tree.body) == 1
        except SyntaxError:
            return False

    def check_ast_context(  # pylint: disable=too-many-arguments,too-many-positional-arguments
        self,
        lines: list[str],
        start_line: int,
        end_line: int,
        lookback: int,
        lookforward: int,
        predicate: Callable[[ast.Module, int], bool],
    ) -> bool:
        """Generic helper for AST-based context checking.

        Args:
            lines: Source file lines
            start_line: Starting line number (1-indexed)
            end_line: Ending line number (1-indexed)
            lookback: Number of lines to look backward
            lookforward: Number of lines to look forward
            predicate: Function that takes AST tree and lookback_start, returns bool

        Returns:
            True if predicate returns True for the parsed context
        """
        lookback_start = max(0, start_line - lookback)
        lookforward_end = min(len(lines), end_line + lookforward)

        context_lines = lines[lookback_start:lookforward_end]
        context = "\n".join(context_lines)

        try:
            tree = ast.parse(context)
            return predicate(tree, lookback_start)
        except SyntaxError:
            pass

        return False

    def is_part_of_decorator(self, lines: list[str], start_line: int, end_line: int) -> bool:
        """Check if lines are part of a decorator + function definition."""

        def has_decorators(tree: ast.Module, _lookback_start: int) -> bool:
            """Check if any function or class in the tree has decorators."""
            return any(
                isinstance(stmt, (ast.FunctionDef, ast.ClassDef)) and stmt.decorator_list
                for stmt in tree.body
            )

        return self.check_ast_context(lines, start_line, end_line, 10, 10, has_decorators)

    def is_part_of_function_call(self, lines: list[str], start_line: int, end_line: int) -> bool:
        """Check if lines are arguments inside a function/constructor call."""

        def is_single_non_function_statement(tree: ast.Module, _lookback_start: int) -> bool:
            """Check if context has exactly one statement that's not a function/class def."""
            return len(tree.body) == 1 and not isinstance(
                tree.body[0], (ast.FunctionDef, ast.ClassDef)
            )

        return self.check_ast_context(
            lines, start_line, end_line, 10, 10, is_single_non_function_statement
        )

    def is_part_of_class_body(self, lines: list[str], start_line: int, end_line: int) -> bool:
        """Check if lines are field definitions inside a class body."""

        def is_within_class_body(tree: ast.Module, lookback_start: int) -> bool:
            """Check if flagged range falls within a class body."""
            class_defs = (s for s in tree.body if isinstance(s, ast.ClassDef))
            for stmt in class_defs:
                class_start_in_context = stmt.lineno
                class_end_in_context = stmt.end_lineno if stmt.end_lineno else stmt.lineno

                class_start_original = lookback_start + class_start_in_context
                class_end_original = lookback_start + class_end_in_context

                if start_line >= class_start_original and end_line <= class_end_original:
                    return True
            return False

        return self.check_ast_context(
            lines,
            start_line,
            end_line,
            AST_LOOKBACK_LINES,
            AST_LOOKFORWARD_LINES,
            is_within_class_body,
        )
