"""Analyzer for anti-patterns in tests."""

from __future__ import annotations

import ast
from typing import TYPE_CHECKING

from pytest_review.analyzers.base import (
    AnalyzerResult,
    Issue,
    Severity,
    StaticAnalyzer,
    TestItemInfo,
)

if TYPE_CHECKING:
    from pytest_review.config import ReviewConfig


class PatternVisitor(ast.NodeVisitor):
    """AST visitor that detects anti-patterns."""

    def __init__(self) -> None:
        self.issues: list[tuple[int, str, str, Severity, str | None]] = []
        # (line, rule, message, severity, suggestion)
        self._with_context_calls: set[int] = set()  # ids of Call nodes in with-items

    def visit_With(self, node: ast.With) -> None:
        # Record Call nodes that appear as context expressions in with-items
        for item in node.items:
            if isinstance(item.context_expr, ast.Call):
                self._with_context_calls.add(id(item.context_expr))
        self.generic_visit(node)

    def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
        # Check for bare except
        if node.type is None:
            self.issues.append(
                (
                    node.lineno,
                    "patterns.bare_except",
                    "Bare 'except:' clause catches all exceptions including KeyboardInterrupt",
                    Severity.WARNING,
                    "Specify the exception type, e.g., 'except Exception:'",
                )
            )
        # Check for except Exception with pass
        if (
            node.type
            and isinstance(node.type, ast.Name)
            and node.type.id == "Exception"
            and len(node.body) == 1
            and isinstance(node.body[0], ast.Pass)
        ):
            self.issues.append(
                (
                    node.lineno,
                    "patterns.swallowed_exception",
                    "Exception is caught and silently ignored",
                    Severity.WARNING,
                    "Log the exception or re-raise if appropriate",
                )
            )
        self.generic_visit(node)

    def visit_Call(self, node: ast.Call) -> None:
        func = node.func

        # Check for time.sleep()
        if (
            isinstance(func, ast.Attribute)
            and func.attr == "sleep"
            and isinstance(func.value, ast.Name)
            and func.value.id == "time"
        ):
            self.issues.append(
                (
                    node.lineno,
                    "patterns.sleep_in_test",
                    "time.sleep() in test makes it slow and potentially flaky",
                    Severity.WARNING,
                    "Use mocking or async patterns instead of sleeping",
                )
            )

        # Check for print() statements
        if isinstance(func, ast.Name) and func.id == "print":
            self.issues.append(
                (
                    node.lineno,
                    "patterns.print_statement",
                    "print() statement in test - use logging or assertions instead",
                    Severity.INFO,
                    "Remove print or use proper logging/capfd fixture",
                )
            )

        # Check for open() without context manager
        if (
            isinstance(func, ast.Name)
            and func.id == "open"
            and id(node) not in self._with_context_calls
        ):
            self.issues.append(
                (
                    node.lineno,
                    "patterns.open_without_context",
                    "open() should be used with a context manager (with statement)",
                    Severity.INFO,
                    "Use 'with open(...) as f:' to ensure file is properly closed",
                )
            )

        # Check for subprocess.run() without check=True
        if (
            isinstance(func, ast.Attribute)
            and func.attr == "run"
            and isinstance(func.value, ast.Name)
            and func.value.id == "subprocess"
        ):
            has_check = any(isinstance(kw.arg, str) and kw.arg == "check" for kw in node.keywords)
            if not has_check:
                self.issues.append(
                    (
                        node.lineno,
                        "patterns.subprocess_no_check",
                        "subprocess.run() without check=True silently ignores failures",
                        Severity.WARNING,
                        "Add check=True to raise on non-zero exit codes",
                    )
                )

        # Check for pytest.raises(Exception) - overly broad
        if (
            isinstance(func, ast.Attribute)
            and func.attr == "raises"
            and isinstance(func.value, ast.Name)
            and func.value.id == "pytest"
            and node.args
            and isinstance(node.args[0], ast.Name)
            and node.args[0].id == "Exception"
        ):
            self.issues.append(
                (
                    node.lineno,
                    "patterns.broad_raises",
                    "pytest.raises(Exception) is too broad; use a specific exception type",
                    Severity.WARNING,
                    "Specify the exact exception, e.g., pytest.raises(ValueError)",
                )
            )

        # Check for os.system() or subprocess without proper handling
        if (
            isinstance(func, ast.Attribute)
            and isinstance(func.value, ast.Name)
            and func.value.id == "os"
            and func.attr == "system"
        ):
            self.issues.append(
                (
                    node.lineno,
                    "patterns.os_system",
                    "os.system() is deprecated, use subprocess module",
                    Severity.INFO,
                    "Use subprocess.run() for better control and security",
                )
            )

        # Check for network/IO calls that may be slow without mocking
        self._check_slow_call(node)

        self.generic_visit(node)

    # module.method patterns that indicate potentially slow operations
    _SLOW_CALLS: dict[str, set[str]] = {
        "requests": {"get", "post", "put", "patch", "delete", "head", "options", "request"},
        "httpx": {"get", "post", "put", "patch", "delete", "head", "options", "request"},
        "urllib": {"urlopen"},
        "urlopen": set(),  # handled via Name check
    }

    def _check_slow_call(self, node: ast.Call) -> None:
        """Detect network calls and DB operations that may be slow."""
        func = node.func
        # module.method() -- requests.get(), httpx.post(), etc.
        if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
            module = func.value.id
            method = func.attr
            if module in self._SLOW_CALLS and (
                not self._SLOW_CALLS[module] or method in self._SLOW_CALLS[module]
            ):
                self.issues.append(
                    (
                        node.lineno,
                        "patterns.slow_call",
                        f"{module}.{method}() may perform network I/O without mocking",
                        Severity.INFO,
                        "Mock network calls or use a fixture to avoid slow/flaky tests",
                    )
                )
                return
        # urllib.request.urlopen()
        if (
            isinstance(func, ast.Attribute)
            and func.attr == "urlopen"
            and isinstance(func.value, ast.Attribute)
            and func.value.attr == "request"
            and isinstance(func.value.value, ast.Name)
            and func.value.value.id == "urllib"
        ):
            self.issues.append(
                (
                    node.lineno,
                    "patterns.slow_call",
                    "urllib.request.urlopen() performs network I/O",
                    Severity.INFO,
                    "Mock network calls or use a fixture to avoid slow/flaky tests",
                )
            )
            return
        # cursor.execute() / session.query() -- common DB patterns
        if (
            isinstance(func, ast.Attribute)
            and func.attr in ("execute", "query")
            and isinstance(func.value, ast.Name)
            and func.value.id in ("cursor", "session", "conn", "connection", "db")
        ):
            self.issues.append(
                (
                    node.lineno,
                    "patterns.slow_call",
                    f"{func.value.id}.{func.attr}() may perform database I/O",
                    Severity.INFO,
                    "Use a test database fixture or mock DB calls",
                )
            )

    def visit_Constant(self, node: ast.Constant) -> None:
        # Check for hardcoded paths (basic heuristic)
        if isinstance(node.value, str):
            value = node.value
            # Check for absolute paths
            if value.startswith("/") and len(value) > 5 and "/" in value[1:]:
                # Looks like an absolute path
                if any(
                    p in value.lower() for p in ["/home/", "/users/", "/tmp/", "/var/", "/etc/"]
                ):
                    display = f"{value[:50]}..." if len(value) > 50 else value
                    self.issues.append(
                        (
                            node.lineno,
                            "patterns.hardcoded_path",
                            f"Hardcoded absolute path: '{display}'",
                            Severity.WARNING,
                            "Use tmp_path fixture or pathlib for cross-platform paths",
                        )
                    )
            # Windows paths
            elif len(value) > 3 and value[1:3] == ":\\":
                self.issues.append(
                    (
                        node.lineno,
                        "patterns.hardcoded_path",
                        "Hardcoded Windows path detected",
                        Severity.WARNING,
                        "Use tmp_path fixture or pathlib for cross-platform paths",
                    )
                )
        self.generic_visit(node)

    def visit_Import(self, node: ast.Import) -> None:
        for alias in node.names:
            if alias.name == "mock":
                self.issues.append(
                    (
                        node.lineno,
                        "patterns.legacy_mock",
                        "Using 'import mock' - prefer unittest.mock (built-in since Python 3.3)",
                        Severity.INFO,
                        "Use 'from unittest.mock import Mock, patch' instead",
                    )
                )
        self.generic_visit(node)

    def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
        if node.module == "mock":
            self.issues.append(
                (
                    node.lineno,
                    "patterns.legacy_mock",
                    "Using 'from mock import' - prefer unittest.mock",
                    Severity.INFO,
                    "Use 'from unittest.mock import ...' instead",
                )
            )
        self.generic_visit(node)

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        self._check_mutable_defaults(node)
        self.generic_visit(node)

    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
        self._check_mutable_defaults(node)
        self.generic_visit(node)

    def _check_mutable_defaults(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
        """Check for mutable default arguments like def f(items=[])."""
        for default in node.args.defaults + node.args.kw_defaults:
            if default is None:
                continue
            if isinstance(default, (ast.List, ast.Dict, ast.Set)):
                self.issues.append(
                    (
                        default.lineno,
                        "patterns.mutable_default",
                        "Mutable default argument (shared between calls)",
                        Severity.WARNING,
                        "Use None as default and create inside the function",
                    )
                )
            elif (
                isinstance(default, ast.Call)
                and isinstance(default.func, ast.Name)
                and default.func.id in ("list", "dict", "set")
            ):
                self.issues.append(
                    (
                        default.lineno,
                        "patterns.mutable_default",
                        f"Mutable default argument: {default.func.id}()",
                        Severity.WARNING,
                        "Use None as default and create inside the function",
                    )
                )

    def visit_Assign(self, node: ast.Assign) -> None:
        self.generic_visit(node)

    def visit_Compare(self, node: ast.Compare) -> None:
        # Check for 'is' comparison with literals
        for i, op in enumerate(node.ops):
            if isinstance(op, (ast.Is, ast.IsNot)):
                comparator = node.comparators[i] if i < len(node.comparators) else None
                left = node.left if i == 0 else node.comparators[i - 1]

                for operand in [left, comparator]:
                    if (
                        isinstance(operand, ast.Constant)
                        and isinstance(operand.value, (int, str, float))
                        and operand.value not in (True, False, None)
                    ):
                        self.issues.append(
                            (
                                node.lineno,
                                "patterns.is_literal",
                                "Using 'is' with literal - use '==' instead",
                                Severity.WARNING,
                                "'is' compares identity, not equality; use '=='",
                            )
                        )
                        break
        self.generic_visit(node)


class PatternsAnalyzer(StaticAnalyzer):
    """Analyzes tests for anti-patterns."""

    name = "patterns"
    description = "Detects common anti-patterns in tests"

    def __init__(self, config: ReviewConfig) -> None:
        super().__init__(config)

    def _analyze_ast(self, test: TestItemInfo, result: AnalyzerResult) -> None:
        visitor = PatternVisitor()
        visitor.visit(test.node)

        for line, rule, message, severity, suggestion in visitor.issues:
            result.add_issue(
                Issue(
                    rule=rule,
                    message=message,
                    severity=severity,
                    file_path=test.file_path,
                    line=line,
                    test_name=test.name,
                    suggestion=suggestion,
                )
            )

        # Store metadata
        result.metadata["pattern_issues"] = len(visitor.issues)
