"""
Core analyzer module for detecting duplicate variable names in Python files.
"""

import ast
import os
from collections import defaultdict
from typing import Dict, List, Optional, Tuple

# Type alias for tracking assignments: maps variable name to list of (line_number, value_source)
# value_source is the string representation of the assigned value (RHS) or None
AssignmentsWithValues = Dict[str, List[Tuple[int, Optional[str]]]]


class VariableAnalyzer(ast.NodeVisitor):
    """
    AST visitor that collects variable and attribute assignments per scope.

    Tracks each assignment target (name, self.attr, tuple unpacking, subscript)
    with its line number and optional RHS source snippet. Scopes are "module" and
    each class body; duplicates are reported only within the same scope.
    """

    def __init__(self, source: str = ""):
        """
        Initialize the variable analyzer.

        Args:
            source: The original source code of the file being analyzed. Used to extract
                   value source segments for comparison. If empty, value comparison
                   will be disabled.
        """
        # Stack to track current scope hierarchy (module, class1, nested_class, ...)
        self.scope_stack = ["module"]
        # Nested dict: scope_name -> { var_name: [(lineno, value_source), ...] }
        # Tracks all variable assignments per scope with their line numbers and values
        self.var_lines = defaultdict(lambda: defaultdict(list))
        # Store original source code for extracting value segments via ast.get_source_segment
        self.source = source

    def _current_scope(self) -> str:
        """Return the active scope name (either 'module' or the current class name)."""
        return self.scope_stack[-1]

    def _get_value_source(self, value_node) -> Optional[str]:
        """
        Extract the source code representation of an assignment's right-hand side (RHS).

        This method retrieves the actual source code text for the value being assigned,
        which is used later to compare whether duplicate assignments have the same or
        different values.

        Args:
            value_node: The AST node representing the value being assigned (e.g., ast.Constant,
                       ast.Call, ast.Name, etc.)

        Returns:
            A string containing the source code of the value, stripped of leading/trailing
            whitespace. Returns None if the node is missing, source code is not available,
            or extraction fails.
        """
        if value_node is None or not self.source:
            return None
        try:
            segment = ast.get_source_segment(self.source, value_node)
            return segment.strip() if segment else None
        except (AttributeError, TypeError):
            return None

    def _add_assignment(self, name: str, lineno: int, value_source: Optional[str] = None) -> None:
        """
        Record a variable assignment in the current scope.

        Args:
            name: The name of the variable or attribute being assigned
            lineno: The line number where the assignment occurs
            value_source: Optional source code string of the assigned value (RHS)
        """
        scope = self._current_scope()
        self.var_lines[scope][name].append((lineno, value_source))

    def _get_names_from_target(self, target: ast.AST) -> List[Tuple[str, int]]:
        """
        Extract variable names and line numbers from an assignment target node.

        This method handles various assignment target types:
            - ast.Name: Simple variable (e.g., 'x = 1')
            - ast.Attribute: Attribute assignment (e.g., 'self.x = 1', 'obj.attr = 1')
            - ast.Tuple: Tuple unpacking (e.g., 'a, b = 1, 2')
            - ast.Subscript: Subscript assignment (e.g., 'x[i] = 1')

        Args:
            target: The AST node representing the assignment target (left-hand side)

        Returns:
            A list of tuples (variable_name, line_number). For tuple unpacking,
            returns multiple entries. For unsupported target types, returns empty list.
        """
        if isinstance(target, ast.Name):
            return [(target.id, target.lineno)]
        if isinstance(target, ast.Attribute):
            # self.attr or other.attr: we track the attribute name for class scope
            return [(target.attr, target.lineno)]
        if isinstance(target, ast.Tuple):
            result = []
            for elt in target.elts:
                result.extend(self._get_names_from_target(elt))
            return result
        if isinstance(target, ast.Subscript):
            # x[i] = ...: track the name of the object being subscripted
            if isinstance(target.value, ast.Name):
                return [(target.value.id, target.lineno)]
            return []
        return []

    def visit_Assign(self, node: ast.Assign) -> None:
        """
        Process standard assignment statements (e.g., 'x = 1', 'a, b = 1, 2').

        This visitor method is called by the AST traversal for each assignment node.
        It extracts variable names from all targets and records them with their
        line numbers and value sources.

        Args:
            node: The ast.Assign node representing the assignment statement
        """
        value_source = self._get_value_source(node.value)
        # Handle multiple targets (e.g., 'x = y = 1')
        for target in node.targets:
            for name, lineno in self._get_names_from_target(target):
                self._add_assignment(name, lineno, value_source)
        self.generic_visit(node)

    def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
        """
        Process type-annotated assignment statements (e.g., 'x: int = 1').

        Only records assignments that have a value (RHS). Type annotations without
        values are ignored as they don't represent actual assignments.

        Args:
            node: The ast.AnnAssign node representing the annotated assignment
        """
        if node.value is not None:
            value_source = self._get_value_source(node.value)
            target = node.target
            for name, lineno in self._get_names_from_target(target):
                self._add_assignment(name, lineno, value_source)
        self.generic_visit(node)

    def visit_ClassDef(self, node: ast.ClassDef) -> None:
        """
        Process class definitions and manage scope tracking.

        When entering a class, pushes the class name onto the scope stack. After
        visiting the class body (which may contain nested classes), pops the scope
        to restore the previous scope context. This ensures nested classes are
        tracked in their correct scope hierarchy.

        Args:
            node: The ast.ClassDef node representing the class definition
        """
        self.scope_stack.append(node.name)
        self.generic_visit(node)
        self.scope_stack.pop()

    def get_duplicates(self) -> Dict[str, AssignmentsWithValues]:
        """
        Identify and return all duplicate variable assignments per scope.

        Filters the collected assignments to only include variables that appear
        more than once in the same scope. Entries are sorted by line number for
        consistent output ordering.

        Returns:
            A dictionary mapping scope names to dictionaries of duplicate variables.
            Format: { scope_name: { var_name: [(lineno, value_source), ...] } }
            Scopes with no duplicates are excluded from the result.
        """
        duplicates = {}
        for scope, names_to_entries in self.var_lines.items():
            scope_dups = {
                name: sorted(entries, key=lambda x: x[0])
                for name, entries in names_to_entries.items()
                if len(entries) > 1
            }
            if scope_dups:
                duplicates[scope] = scope_dups
        return duplicates


def analyze_file(file_path: str) -> Dict:
    """
    Analyze a single Python file for duplicate variable names within the same scope.

    This function reads the file, parses it using Python's AST module, and uses
    VariableAnalyzer to identify duplicate variable assignments. Handles various
    file I/O and parsing errors gracefully.

    Args:
        file_path: Path to the Python file to analyze

    Returns:
        A dictionary with one of the following structures:
        - On success: {'duplicates': { scope: { var_name: [(lineno, value_source), ...] } } }
          Only variables with more than one assignment are included.
        - On error: {'error': 'error message string'}
          Errors include: file not found, permission denied, encoding issues, syntax errors
    """
    try:
        with open(file_path, "r", encoding="utf-8") as f:
            source_code = f.read()
    except FileNotFoundError:
        return {"error": f"File not found: {file_path}"}
    except PermissionError:
        return {"error": f"Permission denied: {file_path}"}
    except UnicodeDecodeError:
        return {"error": f"Encoding error reading file: {file_path}"}

    try:
        tree = ast.parse(source_code, filename=file_path)
    except SyntaxError as e:
        return {"error": f"Syntax error: {e}"}

    analyzer = VariableAnalyzer(source=source_code)
    analyzer.visit(tree)

    return {
        "duplicates": analyzer.get_duplicates(),
    }


def _normalize_value(s: Optional[str]) -> str:
    """
    Normalize a value source string for comparison.

    Strips leading/trailing whitespace and collapses all internal whitespace
    (spaces, tabs, newlines) into single spaces. This allows comparison to be
    insensitive to formatting differences while preserving meaningful content.

    Args:
        s: The value source string to normalize, or None

    Returns:
        Normalized string with collapsed whitespace, or empty string if input is None
    """
    if s is None:
        return ""
    return " ".join(s.split())


def compare_duplicate_values(
    duplicates: Dict[str, AssignmentsWithValues],
) -> Dict[str, Dict[str, str]]:
    """
    Compare values assigned to duplicate variables to determine if they are identical.

    For each duplicate variable found, this function normalizes and compares all
    assigned values to determine whether they are the same or different. This helps
    identify potential copy-paste errors (same values) vs. intentional reassignments
    (different values).

    Args:
        duplicates: Dictionary of duplicate variables per scope, as returned by
                   VariableAnalyzer.get_duplicates()

    Returns:
        A dictionary mapping scope names to variable classification dictionaries.
        Format: { scope_name: { var_name: "(same values)" | "(different values)" } }
    """
    result = {}
    for scope, names_to_entries in duplicates.items():
        result[scope] = {}
        for var_name, entries in names_to_entries.items():
            values = [_normalize_value(val) for _line, val in entries]
            if len(set(values)) <= 1:
                result[scope][var_name] = "(same values)"
            else:
                result[scope][var_name] = "(different values)"
    return result


def print_analysis_results(file_path: str, results: Dict, base_dir: str) -> bool:
    """
    Print analysis results to stdout in a CI/CD-friendly format.

    Outputs one line per duplicate variable found, showing the file path (relative
    to base_dir if applicable), variable name, value comparison status, and line
    numbers. Errors are printed with [ERROR] prefix.

    Args:
        file_path: Full path to the analyzed file
        results: Analysis results dictionary from analyze_file()
        base_dir: Base directory path for generating relative paths in output

    Returns:
        True if any errors or duplicates were found, False otherwise
    """
    display_path = file_path
    if base_dir and file_path.startswith(base_dir):
        display_path = file_path[len(base_dir) :].lstrip(os.sep)

    if "error" in results:
        print(f"[ERROR] {results['error']}\n")
        return True

    duplicates = results.get("duplicates", {})
    value_comparison = compare_duplicate_values(duplicates)
    found = False
    for scope, names_to_entries in duplicates.items():
        scope_compare = value_comparison.get(scope, {})
        for var_name, entries in names_to_entries.items():
            lines = [lineno for lineno, _ in entries]
            lines_str = ", ".join(map(str, lines))
            suffix = scope_compare.get(var_name, "(same values)")
            label = f"{var_name} {suffix}"
            print(f"{display_path}: {label} - {lines_str}")
            found = True
    return found


def get_pageobjects_dir() -> str:
    """
    Get the default pageobjects directory path.

    When installed as a package, looks for 'pageobjects' directory in the
    current working directory. This provides a sensible default for most use cases.

    Returns:
        Absolute path to the pageobjects directory
    """
    cwd = os.getcwd()
    pageobjects_path = os.path.join(cwd, "pageobjects")
    if os.path.isdir(pageobjects_path):
        return pageobjects_path
    # Fallback to current directory
    return os.path.join(cwd, "pageobjects")


def main(path: str) -> bool:
    """
    Scan Python file(s) for duplicate variables.

    Supports:
        - Single Python file
        - Directory (recursive)
        - None → default pageobjects directory
    """

    path = os.path.abspath(path)

    if not os.path.exists(path):
        print(f"[ERROR] Path not found: {path}")
        print("Usage: python check_duplicate_variables.py [file_or_directory]")
        return True

    # Determine Python files
    if os.path.isfile(path):
        if not path.endswith(".py"):
            print(f"[INFO] Skipping non-Python file: {path}")
            return False

        py_files = [path]
        base_dir = os.path.dirname(path)

    else:
        if not os.path.isdir(path):
            print(f"[ERROR] Directory not found to analyze: {path}")
            return True

        base_dir = path
        py_files = [
            os.path.join(root, f)
            for root, _dirs, files in os.walk(path)
            for f in files
            if f.endswith(".py")
        ]

    if not py_files:
        print(f"[INFO] No Python files found in {path}")
        return False

    any_issues = False

    for file_path in sorted(py_files):
        results = analyze_file(file_path)
        if print_analysis_results(file_path, results, base_dir):
            any_issues = True

    return any_issues
