"""Core algorithm for AST-based code duplication detection (Type 1 & Type 2)."""

from __future__ import annotations

import hashlib
from collections import defaultdict
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from tree_sitter import Node

from vibelexity.metrics.duplication.config import LanguageConfig
from vibelexity.models import DuplicateCluster, FileComplexity, FunctionComplexity

MIN_NODE_COUNT = 5


def _is_docstring(node: Node) -> bool:
    """Check if a node is a Python-style docstring (expression_statement containing only a string)."""
    if node.type != "expression_statement":
        return False
    named = [c for c in node.children if c.is_named]
    return len(named) == 1 and named[0].type == "string"


def _collect_named_leaves(
    node: Node, config: LanguageConfig, acc: list[tuple[str, str]]
) -> None:
    """DFS traversal collecting (node_type, node_text) for named leaf nodes, skipping comments and docstrings."""
    if node.type in config.comment_types:
        return
    if _is_docstring(node):
        return
    if node.child_count == 0 and node.is_named:
        acc.append((node.type, node.text.decode()))
        return
    for child in node.children:
        _collect_named_leaves(child, config, acc)


_FUNC_NAME_TYPES = {"identifier", "property_identifier", "field_identifier", "name"}


def _collect_body_leaves(
    func_node: Node, config: LanguageConfig
) -> list[tuple[str, str]]:
    """Collect named leaves from a function node, excluding the function's own name."""
    leaves: list[tuple[str, str]] = []
    skipped_name = False
    for child in func_node.children:
        if (
            not skipped_name
            and child.type in _FUNC_NAME_TYPES
            and child.child_count == 0
        ):
            skipped_name = True
            continue
        _collect_named_leaves(child, config, leaves)
    return leaves


def compute_type1_hash(func_node: Node, config: LanguageConfig) -> str | None:
    """Hash a function's named-leaf sequence with exact text values (Type 1 / exact clone)."""
    leaves = _collect_body_leaves(func_node, config)
    if len(leaves) < MIN_NODE_COUNT:
        return None
    raw = "|".join(f"{ntype}:{text}" for ntype, text in leaves)
    return hashlib.sha256(raw.encode()).hexdigest()


def compute_type2_hash(func_node: Node, config: LanguageConfig) -> str | None:
    """Hash with identifiers as positional placeholders and literals as LIT (Type 2 / parametric clone)."""
    leaves = _collect_body_leaves(func_node, config)
    if len(leaves) < MIN_NODE_COUNT:
        return None
    var_map: dict[str, str] = {}
    tokens: list[str] = []
    for ntype, text in leaves:
        if ntype in config.identifier_types:
            if text not in var_map:
                var_map[text] = f"VAR_{len(var_map)}"
            tokens.append(f"{ntype}:{var_map[text]}")
        elif ntype in config.literal_types:
            tokens.append(f"{ntype}:LIT")
        else:
            tokens.append(f"{ntype}:{text}")
    raw = "|".join(tokens)
    return hashlib.sha256(raw.encode()).hexdigest()


def count_statements(func_node: Node) -> int:
    """Count top-level executable statements in a function body for Type 2 filtering."""
    block_types = {"block", "function_body", "statement_block"}
    for child in func_node.children:
        if child.type in block_types:
            stmt_count = 0
            for stmt in child.children:
                if stmt.is_named and stmt.type.endswith("_statement"):
                    if stmt.type == "expression_statement" and _is_docstring(stmt):
                        continue
                    stmt_count += 1
                elif stmt.type == "statement_list":
                    for sub_stmt in stmt.children:
                        if sub_stmt.is_named and sub_stmt.type.endswith("_statement"):
                            if (
                                sub_stmt.type == "expression_statement"
                                and _is_docstring(sub_stmt)
                            ):
                                continue
                            stmt_count += 1
            return stmt_count
    return 0


def find_duplicates(results: list[FileComplexity]) -> list[DuplicateCluster]:
    """Group functions by hash across files; return clusters with 2+ members."""
    type1_groups: dict[str, list[tuple[str, FunctionComplexity]]] = defaultdict(list)
    type2_groups: dict[str, list[tuple[str, FunctionComplexity]]] = defaultdict(list)

    for result in results:
        for func in result.functions:
            if func.type1_hash:
                type1_groups[func.type1_hash].append((result.path, func))
            if func.type2_hash:
                type2_groups[func.type2_hash].append((result.path, func))

    clusters: list[DuplicateCluster] = []
    type1_hashes = set()

    for h, members in type1_groups.items():
        if len(members) >= 2:
            clusters.append(DuplicateCluster(dup_type=1, hash_value=h, members=members))
            type1_hashes.add(h)

    # Type 2 clusters exclude groups already fully caught as Type 1
    type1_member_sets: dict[str, frozenset] = {}
    for c in clusters:
        keys = frozenset((p, f.name, f.line) for p, f in c.members)
        type1_member_sets[c.hash_value] = keys

    for h, members in type2_groups.items():
        if len(members) < 2:
            continue
        member_keys = frozenset((p, f.name, f.line) for p, f in members)
        already_covered = any(
            member_keys == t1_keys for t1_keys in type1_member_sets.values()
        )
        if not already_covered:
            filtered = [m for m in members if m[1].stmt_count >= 2]
            if len(filtered) >= 2:
                clusters.append(
                    DuplicateCluster(dup_type=2, hash_value=h, members=filtered)
                )

    return clusters
