"""Per-symbol cognitive complexity analysis using tree-sitter ASTs.

Computes multi-factor complexity metrics for functions/methods:
- cognitive_complexity: weighted composite score (SonarSource-inspired)
- nesting_depth: max control-flow nesting
- param_count: number of parameters
- line_count: lines of code in the body
- return_count: return/throw statements
- bool_op_count: boolean operator count (&&/||/and/or)
- callback_depth: max depth of nested function/lambda definitions
"""

from __future__ import annotations

import sqlite3
from dataclasses import dataclass, field

# -- Node-type mappings per language family ----------------------------

# Control flow nodes that increment complexity AND increase nesting
_CONTROL_FLOW = {
    # Python
    "if_statement",
    "for_statement",
    "while_statement",
    "try_statement",
    "except_clause",
    "with_statement",
    "match_statement",
    # JS/TS
    "for_in_statement",
    "do_statement",
    "switch_statement",
    "catch_clause",
    # Java/C#/Go/Rust
    "enhanced_for_statement",
    "foreach_statement",
    "if_expression",
    "match_expression",
    # General
    "conditional_expression",
    "ternary_expression",
}

# Continuation nodes: +1 for flow break but NO nesting increment.
# Per SonarSource cognitive complexity spec: elif/else/case are continuations
# of the same mental model, not new nesting levels.
_CONTINUATION_FLOW = {
    "elif_clause",
    "else_clause",  # Python
    "case_clause",  # Python match/case
    "switch_case",  # JS/TS case
    "match_arm",  # Rust
}

# Nodes that only increment complexity (no extra nesting)
_FLOW_BREAK = {
    "break_statement",
    "continue_statement",
    "goto_statement",
}

# Boolean operators
_BOOL_OPS = {
    # JS/TS/Java/C#/Go — needs child token check for actual boolean ops
    "binary_expression",
}
# Python "boolean_operator" is handled separately (line ~165) since it's always boolean

_BOOL_OP_TOKENS = {"&&", "||", "and", "or", "??"}

# Return/throw nodes
_RETURN_NODES = {
    "return_statement",
    "throw_statement",
    "raise_statement",
    "yield",
    "yield_statement",
}

# Function/lambda definition nodes (for callback depth)
_FUNCTION_NODES = {
    "function_definition",
    "function_declaration",
    "method_definition",
    "method_declaration",
    "arrow_function",
    "lambda",
    "lambda_expression",
    "anonymous_function",
    "closure_expression",
    "function_expression",
    "generator_function_declaration",
}

# Parameter list nodes
_PARAM_NODES = {
    "parameters",
    "formal_parameters",
    "parameter_list",
    "function_parameters",
    "type_parameters",
}


def _count_params(node) -> int:
    """Count parameters from a function node's parameter list."""
    for child in node.children:
        if child.type in _PARAM_NODES:
            # Count named children that are actual parameters
            # (skip commas, parens, etc.)
            count = 0
            for p in child.children:
                if p.is_named and p.type not in (
                    "(",
                    ")",
                    ",",
                    "comment",
                    "block_comment",
                    "type_annotation",
                    "type",
                ):
                    count += 1
            return count
    return 0


def _walk_complexity(node, source: bytes, depth: int = 0) -> dict:
    """Recursively walk an AST subtree and accumulate complexity factors.

    Returns a dict with:
        cognitive: int, nesting: int, returns: int,
        bool_ops: int, callback_depth: int
    """
    result = {
        "cognitive": 0,
        "nesting": 0,
        "returns": 0,
        "bool_ops": 0,
        "callback_depth": 0,
    }

    ntype = node.type

    # Control flow: +1 base, +triangular nesting penalty.
    # Triangular number depth*(depth+1)/2 models the superlinear cognitive
    # load of deeply nested code (Sweller's Cognitive Load Theory, 1988).
    # depth 0→+1, 1→+2, 2→+4, 3→+7, 4→+11 (vs linear: 1,2,3,4,5).
    if ntype in _CONTROL_FLOW:
        result["cognitive"] += 1 + depth * (depth + 1) // 2
        result["nesting"] = max(result["nesting"], depth + 1)
        for child in node.children:
            child_r = _walk_complexity(child, source, depth + 1)
            _merge(result, child_r)
        return result

    # Continuation flow (elif/else/case): +1 flat, NO nesting penalty.
    # Per SonarSource spec, these are continuations of the parent structure.
    # Children stay at the same depth — not depth+1.
    if ntype in _CONTINUATION_FLOW:
        result["cognitive"] += 1
        for child in node.children:
            child_r = _walk_complexity(child, source, depth)
            _merge(result, child_r)
        return result

    # Flow breaks: +1, no nesting increase
    if ntype in _FLOW_BREAK:
        result["cognitive"] += 1
        return result

    # Return/throw
    if ntype in _RETURN_NODES:
        result["returns"] += 1

    # Boolean operators
    if ntype in _BOOL_OPS:
        # Check if it's actually a boolean op (not arithmetic)
        for child in node.children:
            if not child.is_named:
                op_text = source[child.start_byte : child.end_byte].decode("utf-8", errors="replace")
                if op_text in _BOOL_OP_TOKENS:
                    result["bool_ops"] += 1
                    result["cognitive"] += 1
                    break

    # Python boolean_operator is always boolean
    if ntype == "boolean_operator":
        result["bool_ops"] += 1
        result["cognitive"] += 1

    # Nested function/lambda (callback depth)
    if ntype in _FUNCTION_NODES and depth > 0:
        result["callback_depth"] = max(result["callback_depth"], 1)
        # Recurse inside the nested function, resetting depth for its own
        # nesting but tracking callback depth
        for child in node.children:
            child_r = _walk_complexity(child, source, depth + 1)
            _merge(result, child_r)
            result["callback_depth"] = max(result["callback_depth"], child_r["callback_depth"] + 1)
        return result

    # Default: recurse children at same depth
    for child in node.children:
        child_r = _walk_complexity(child, source, depth)
        _merge(result, child_r)

    return result


def _merge(target: dict, source_r: dict):
    """Merge child complexity results into parent."""
    target["cognitive"] += source_r["cognitive"]
    target["nesting"] = max(target["nesting"], source_r["nesting"])
    target["returns"] += source_r["returns"]
    target["bool_ops"] += source_r["bool_ops"]
    target["callback_depth"] = max(target["callback_depth"], source_r["callback_depth"])


# -- Halstead metrics ------------------------------------------------

# Operator node types (control flow, assignments, calls, etc.)
_OPERATOR_TYPES = {
    "if_statement",
    "for_statement",
    "while_statement",
    "do_statement",
    "switch_statement",
    "try_statement",
    "catch_clause",
    "return_statement",
    "throw_statement",
    "raise_statement",
    "break_statement",
    "continue_statement",
    "for_in_statement",
    "match_statement",
    "match_expression",
    "with_statement",
    "conditional_expression",
    "ternary_expression",
    "assignment_expression",
    "augmented_assignment",
    "call_expression",
    "new_expression",
    "yield_statement",
    "yield",
}

# Operand node types (identifiers, literals)
_OPERAND_TYPES = {
    "identifier",
    "property_identifier",
    "shorthand_property_identifier",
    "number",
    "integer",
    "float",
    "string",
    "template_string",
    "true",
    "false",
    "none",
    "null",
    "undefined",
}


def _compute_halstead(func_node, source: bytes) -> dict:
    """Compute Halstead complexity metrics from AST.

    Counts distinct and total operators (control flow, assignments, calls)
    and operands (identifiers, literals) to compute:
      Volume   = N * log2(n)     — information content in bits
      Difficulty = (n1/2) * (N2/n2)
      Effort   = D * V           — mental effort to understand
      Bugs     = V / 3000        — estimated delivered bugs

    Reference: Halstead (1977), "Elements of Software Science."
    """
    import math as _math

    operators = set()
    operands = set()
    total_operators = 0
    total_operands = 0

    def _walk(node):
        nonlocal total_operators, total_operands
        ntype = node.type

        if ntype in _OPERATOR_TYPES:
            operators.add(ntype)
            total_operators += 1
        elif ntype in _OPERAND_TYPES:
            text = source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
            operands.add(text)
            total_operands += 1

        # Also count non-named operator tokens (+, -, =, ==, etc.)
        if (
            not node.is_named
            and node.parent
            and node.parent.type
            in (
                "binary_expression",
                "unary_expression",
                "assignment_expression",
                "augmented_assignment",
                "comparison_operator",
                "boolean_operator",
            )
        ):
            op_text = source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
            if op_text.strip():
                operators.add(op_text)
                total_operators += 1

        for child in node.children:
            _walk(child)

    _walk(func_node)

    n1 = len(operators)  # distinct operators
    n2 = len(operands)  # distinct operands
    N1 = total_operators  # total operators
    N2 = total_operands  # total operands

    n = n1 + n2  # vocabulary
    N = N1 + N2  # length

    if n <= 0 or n2 <= 0:
        return {"volume": 0.0, "difficulty": 0.0, "effort": 0.0, "bugs": 0.0}

    volume = round(N * _math.log2(n), 1) if n > 1 else 0.0
    difficulty = round((n1 / 2.0) * (N2 / n2), 1) if n2 > 0 else 0.0
    effort = round(difficulty * volume, 0)
    bugs = round(volume / 3000.0, 3)

    return {
        "volume": volume,
        "difficulty": difficulty,
        "effort": effort,
        "bugs": bugs,
    }


def _find_function_node(tree, line_start: int, line_end: int):
    """Find the tree-sitter node for a function at the given line range.

    Walks the tree looking for function nodes whose line range matches
    (with 1-line tolerance for decorators).
    """
    if tree is None:
        return None
    root = tree.root_node

    def _search(node):
        # tree-sitter lines are 0-indexed, our line_start/end are 1-indexed
        node_start = node.start_point[0] + 1
        node_end = node.end_point[0] + 1

        if node.type in _FUNCTION_NODES:
            # Allow 1-3 lines tolerance for decorators/annotations
            if abs(node_start - line_start) <= 3 and abs(node_end - line_end) <= 1:
                return node

        for child in node.children:
            # Skip children that are entirely outside our range
            child_start = child.start_point[0] + 1
            child_end = child.end_point[0] + 1
            if child_end < line_start - 3 or child_start > line_end + 1:
                continue
            found = _search(child)
            if found:
                return found
        return None

    return _search(root)


def compute_symbol_complexity(tree, source: bytes, line_start: int, line_end: int, *, func_node=None) -> dict:
    """Compute complexity metrics for a single symbol.

    Args:
        tree: tree-sitter parse tree
        source: raw source bytes
        line_start: 1-indexed start line of the symbol
        line_end: 1-indexed end line of the symbol
        func_node: pre-located function AST node. When supplied, skips the
            ``_find_function_node`` lookup — caller has already done it
            (e.g. ``compute_and_store`` which uses the same node twice
            per iteration). Pass ``None`` to do the lookup here.

    Returns dict with all metric fields, or None if symbol node not found.
    """
    if func_node is None:
        func_node = _find_function_node(tree, line_start, line_end)
    if func_node is None:
        # Fall back: compute from source line range
        return _complexity_from_source(source, line_start, line_end)

    # Params
    param_count = _count_params(func_node)

    # Line count (body only)
    body_lines = (func_node.end_point[0] - func_node.start_point[0]) + 1

    # Walk AST for cognitive complexity
    metrics = _walk_complexity(func_node, source, depth=0)

    # Halstead metrics: count operators and operands from AST
    halstead = _compute_halstead(func_node, source)

    # Cyclomatic density: complexity / lines (Gill & Kemerer, IEEE TSE)
    cc_density = round(metrics["cognitive"] / body_lines, 3) if body_lines > 0 else 0.0

    return {
        "cognitive_complexity": round(metrics["cognitive"], 2),
        "nesting_depth": metrics["nesting"],
        "param_count": param_count,
        "line_count": body_lines,
        "return_count": metrics["returns"],
        "bool_op_count": metrics["bool_ops"],
        "callback_depth": metrics["callback_depth"],
        "cyclomatic_density": cc_density,
        "halstead_volume": halstead["volume"],
        "halstead_difficulty": halstead["difficulty"],
        "halstead_effort": halstead["effort"],
        "halstead_bugs": halstead["bugs"],
    }


def _complexity_from_source(source: bytes, line_start: int, line_end: int) -> dict:
    """Fallback: estimate complexity from raw source when AST node not found."""
    lines = source.split(b"\n")
    start_idx = max(0, line_start - 1)
    end_idx = min(len(lines), line_end)
    body = lines[start_idx:end_idx]

    # Simple heuristics
    max_indent = 0
    returns = 0
    bool_ops = 0

    for line in body:
        expanded = line.expandtabs(4)
        stripped = expanded.lstrip()
        if not stripped:
            continue
        indent = (len(expanded) - len(stripped)) // 4
        max_indent = max(max_indent, indent)

        text = stripped.decode("utf-8", errors="replace")
        # Count returns
        if text.startswith(("return ", "return;", "throw ", "raise ")):
            returns += 1
        # Count boolean ops
        for op in (" and ", " or ", " && ", " || "):
            bool_ops += text.count(op)

    line_count = end_idx - start_idx
    # Rough cognitive complexity estimate
    cognitive = max_indent * 2 + bool_ops + max(returns - 1, 0)

    cc_density = round(cognitive / line_count, 3) if line_count > 0 else 0.0

    return {
        "cognitive_complexity": round(cognitive, 2),
        "nesting_depth": max_indent,
        "param_count": 0,  # Can't reliably count from raw source
        "line_count": line_count,
        "return_count": returns,
        "bool_op_count": bool_ops,
        "callback_depth": 0,
        "cyclomatic_density": cc_density,
        "halstead_volume": 0.0,
        "halstead_difficulty": 0.0,
        "halstead_effort": 0.0,
        "halstead_bugs": 0.0,
    }


# -- Math signal extraction (piggybacks on per-symbol AST walk) ------

# Loop node types (cross-language)
_LOOP_NODES = {
    "for_statement",
    "while_statement",
    "do_statement",
    "for_in_statement",
    "enhanced_for_statement",
    "foreach_statement",
    "for_expression",
}

# Subscript / index access nodes
_SUBSCRIPT_NODES = {
    "subscript",
    "subscript_expression",
    "index_expression",
    "element_access_expression",
    "bracket_access",
}

# Comparison operator tokens. ``===`` / ``!==`` are PHP/JS strict-
# equality (semantically equality with type check); treating them as
# "compare" lets PHP nested-lookup callers (e.g.
# InsuranceReportController::annualFundSummary) reach the detector
# triplet — without my new ``loop_eq_with_dependent_write`` signal they
# wouldn't be useful FP-prone signals on their own.
_COMPARE_OPS = {"<", ">", "<=", ">=", "==", "!=", "<>", "===", "!=="}

# Augmented assignment node types and tokens
_AUGMENTED_ASSIGN_NODES = {"augmented_assignment"}
_AUGMENTED_ASSIGN_OPS = {"+=", "-=", "*=", "/=", "%=", "**=", "//=", "&=", "|=", "^=", "<<=", ">>="}

# Math/operator tokens used by algorithm detectors
_MULTIPLY_OPS = {"*", "*="}
_MODULO_OPS = {"%", "%="}

# Calls that indicate repeated linear scans when invoked on an invariant
# receiver with loop-varying arguments (e.g. blacklist.contains(item)).
_LOOP_LOOKUP_CALLS = {
    "index",
    "indexOf",
    "lastIndexOf",
    "contains",
    "includes",
    "Contains",
    "IndexOf",
}


@dataclass
class _MathSignalState:
    symbol_lower: str = ""
    max_loop_depth: int = 0
    has_nested: bool = False
    calls_in_loops: list[str] = field(default_factory=list)
    calls_in_loops_qualified: list[str] = field(default_factory=list)
    subscript_in_loops: bool = False
    has_self_call: bool = False
    self_call_count: int = 0
    loop_with_compare: bool = False
    loop_with_accumulator: bool = False
    loop_with_multiplication: bool = False
    loop_with_modulo: bool = False
    str_concat_in_loop: bool = False
    loop_invariant_calls: list[str] = field(default_factory=list)
    loop_lookup_calls: list[str] = field(default_factory=list)
    front_ops_in_loop: bool = False
    loop_bound_small: bool = False
    # Discriminator for the nested-lookup detector: True iff some loop
    # body contains an `if` whose condition is an equality comparing two
    # DIFFERENT per-iteration variables and whose then-branch contains a
    # write (assignment or call) that references at least one of those
    # variables. This is the structural fingerprint of an O(n*m) hash-
    # joinable lookup; streaming-output and matrix-traversal patterns
    # never match. See `detect_nested_lookup` for usage.
    loop_eq_with_dependent_write: bool = False


def _dedupe_preserve_order(items: list[str]) -> list[str]:
    seen: set[str] = set()
    out: list[str] = []
    for item in items:
        if item not in seen:
            seen.add(item)
            out.append(item)
    return out


def _operator_tokens(node, source: bytes) -> list[str]:
    return [
        source[child.start_byte : child.end_byte].decode("utf-8", errors="replace")
        for child in node.children
        if not child.is_named
    ]


def _enter_loop(node, source: bytes, loop_depth: int, loop_vars: set[str], state: _MathSignalState):
    new_depth = loop_depth + 1
    state.max_loop_depth = max(state.max_loop_depth, new_depth)
    if new_depth >= 2:
        state.has_nested = True
    if _is_bounded_loop(node, source):
        state.loop_bound_small = True
    return new_depth, loop_vars | _extract_loop_vars(node, source)


def _record_self_call(node, source: bytes, state: _MathSignalState) -> None:
    name = _call_target_name(node, source)
    if name and state.symbol_lower and name.lower() == state.symbol_lower:
        state.has_self_call = True
        state.self_call_count += 1


def _record_loop_call(node, source: bytes, loop_vars: set[str], state: _MathSignalState) -> None:
    name = _call_target_name(node, source)
    qualified = _call_target_qualified(node, source)
    if name:
        state.calls_in_loops.append(name)
        if state.symbol_lower and name.lower() == state.symbol_lower:
            state.has_self_call = True
            state.self_call_count += 1
    if qualified:
        state.calls_in_loops_qualified.append(qualified)
    if not name:
        return

    receiver_uses_loop = _call_receiver_uses_loop_vars(node, source, loop_vars)
    args_use_loop = _call_args_use_loop_vars(node, source, loop_vars)
    if loop_vars and not (receiver_uses_loop or args_use_loop):
        state.loop_invariant_calls.append(qualified or name)
    if name in _LOOP_LOOKUP_CALLS and loop_vars and not receiver_uses_loop and args_use_loop:
        state.loop_lookup_calls.append(qualified or name)
    if _is_front_operation_call(node, source, name):
        state.front_ops_in_loop = True


def _record_compare_ops(node, source: bytes, state: _MathSignalState) -> None:
    for op in _operator_tokens(node, source):
        if op in _COMPARE_OPS:
            state.loop_with_compare = True
        if op in _MULTIPLY_OPS:
            state.loop_with_multiplication = True
        if op in _MODULO_OPS:
            state.loop_with_modulo = True


def _record_augmented_assignment(node, source: bytes, string_vars: set[str], state: _MathSignalState) -> None:
    state.loop_with_accumulator = True
    for op in _operator_tokens(node, source):
        if op in _MULTIPLY_OPS:
            state.loop_with_multiplication = True
        if op in _MODULO_OPS:
            state.loop_with_modulo = True
    target = _augmented_assign_target(node, source)
    if target and target in string_vars:
        state.str_concat_in_loop = True


def _record_expression_ops(node, source: bytes, state: _MathSignalState) -> None:
    for op in _operator_tokens(node, source):
        if op in _AUGMENTED_ASSIGN_OPS:
            state.loop_with_accumulator = True
        if op in _MULTIPLY_OPS:
            state.loop_with_multiplication = True
        if op in _MODULO_OPS:
            state.loop_with_modulo = True


def _record_loop_node(node, source: bytes, loop_vars: set[str], string_vars: set[str], state: _MathSignalState) -> None:
    ntype = node.type
    if ntype in ("call_expression", "call"):
        _record_loop_call(node, source, loop_vars, state)
    if ntype in _SUBSCRIPT_NODES:
        state.subscript_in_loops = True
    if ntype in ("binary_expression", "comparison_operator"):
        _record_compare_ops(node, source, state)
    if ntype in _AUGMENTED_ASSIGN_NODES:
        _record_augmented_assignment(node, source, string_vars, state)
    elif ntype in ("binary_expression", "assignment_expression", "expression_statement"):
        _record_expression_ops(node, source, state)


def _extract_math_signals(func_node, source: bytes, symbol_name: str) -> dict:
    """Walk a function AST node once and extract algorithmic signals.

    Returns a dict suitable for storing in the ``math_signals`` table.
    """
    # --- Pre-scan: find variables initialized to string literals ---
    # This enables detection of quadratic string building (str += in loop)
    string_vars: set[str] = set()
    _scan_string_inits(func_node, source, string_vars)
    state = _MathSignalState(symbol_lower=symbol_name.lower() if symbol_name else "")

    def _walk(node, loop_depth: int, loop_vars: set[str]):
        ntype = node.type

        if ntype in _LOOP_NODES:
            new_depth, new_loop_vars = _enter_loop(node, source, loop_depth, loop_vars, state)
            # Loop iterator expression vs loop body: the iterator (e.g.
            # the ``conn.execute(...).fetchall()`` in
            # ``for r in conn.execute(...).fetchall():``) is evaluated
            # ONCE before the loop, not per-iteration. We must not count
            # calls inside it as "calls in loop body" — that produces
            # N+1 false positives for the canonical
            # "for r in conn.execute(...).fetchall(): pure_python_work()"
            # pattern.  Field-named children (Python: ``body``; Go:
            # ``body``; JS / Java: a ``statement_block``) are the body;
            # everything else is the head/iterator and walks at the
            # OUTER loop depth.
            body_node = None
            try:
                body_node = node.child_by_field_name("body")
            except (AttributeError, TypeError):
                # tree-sitter node API varies by grammar/binding: an alternate
                # binding may lack ``child_by_field_name`` (AttributeError) or
                # differ in signature (TypeError). Genuine bugs propagate.
                body_node = None
            if body_node is None:
                # Fallback for grammars that don't expose ``body`` as a
                # field: take the last block-like child as the body.
                for child in reversed(node.children):
                    if child.type in (
                        "block",
                        "compound_statement",
                        "statement_block",
                        "do_block",
                        "suite",
                    ):
                        body_node = child
                        break
            if body_node is not None:
                # ``==`` not ``is`` — tree-sitter returns a fresh Python
                # wrapper from ``child_by_field_name`` even when the
                # underlying C-level node is identical to one in
                # ``.children``. Identity check would always be False
                # and we'd walk the body at the outer depth.
                for child in node.children:
                    if child == body_node:
                        _walk(child, new_depth, new_loop_vars)
                    else:
                        _walk(child, loop_depth, loop_vars)
            else:
                # Last-resort: preserve old behaviour (walk all children
                # at increased depth) so we don't silently drop signals
                # on languages whose grammar we don't recognise here.
                for child in node.children:
                    _walk(child, new_depth, new_loop_vars)
            return

        # nested function bodies establish a fresh scope:
        # an arrow function in a parameter default, a callback passed to map(),
        # or a local function declared inside a loop should NOT inherit the
        # enclosing function's loop_depth. Without this isolation, a default
        # parameter like `(item) => item.name || item.id` was getting flagged
        # as I/O in loop because its calls were attributed to whatever loop
        # depth the enclosing function had reached. We still walk the inner
        # function so its own `_record_self_call` opportunities aren't lost,
        # but we reset loop_depth to 0 and clear loop_vars at the boundary.
        if ntype in _FUNCTION_NODES and node is not func_node:
            for child in node.children:
                _walk(child, 0, set())
            return

        if loop_depth > 0:
            _record_loop_node(node, source, loop_vars, string_vars, state)
            # Nested-lookup discriminator: when we see an ``if`` inside a
            # loop body, check whether its condition compares two
            # different loop iter vars for equality and whether the
            # then-branch writes to one of them. This is the structural
            # tell that separates a real hash-joinable lookup from a
            # streaming-output O(n*m) (e.g. CSV row*col emission).
            if ntype == "if_statement" and not state.loop_eq_with_dependent_write:
                _check_equality_with_dependent_write(node, source, loop_vars, state)
        if loop_depth == 0 and ntype in ("call_expression", "call"):
            _record_self_call(node, source, state)

        for child in node.children:
            _walk(child, loop_depth, loop_vars)

    _walk(func_node, 0, set())

    return {
        "loop_depth": state.max_loop_depth,
        "has_nested_loops": int(state.has_nested),
        "calls_in_loops": _dedupe_preserve_order(state.calls_in_loops),
        "calls_in_loops_qualified": _dedupe_preserve_order(state.calls_in_loops_qualified),
        "subscript_in_loops": int(state.subscript_in_loops),
        "has_self_call": int(state.has_self_call),
        "self_call_count": state.self_call_count,
        "loop_with_compare": int(state.loop_with_compare),
        "loop_with_accumulator": int(state.loop_with_accumulator),
        "loop_with_multiplication": int(state.loop_with_multiplication),
        "loop_with_modulo": int(state.loop_with_modulo),
        "str_concat_in_loop": int(state.str_concat_in_loop),
        "loop_invariant_calls": _dedupe_preserve_order(state.loop_invariant_calls),
        "loop_lookup_calls": _dedupe_preserve_order(state.loop_lookup_calls),
        "front_ops_in_loop": int(state.front_ops_in_loop),
        "loop_bound_small": int(state.loop_bound_small),
        "loop_eq_with_dependent_write": int(state.loop_eq_with_dependent_write),
    }


def _call_target_name(node, src: bytes) -> str:
    """Best-effort extraction of the call target identifier."""
    for child in node.children:
        if child.type == "identifier":
            return src[child.start_byte : child.end_byte].decode("utf-8", errors="replace")
        if child.type in ("member_expression", "attribute", "field_expression"):
            # Take the last identifier (e.g. obj.method -> method)
            for sub in reversed(child.children):
                if sub.type in ("identifier", "property_identifier", "field_identifier"):
                    return src[sub.start_byte : sub.end_byte].decode("utf-8", errors="replace")
    return ""


def _call_target_qualified(node, src: bytes) -> str:
    """Best-effort extraction of the call target with receiver path.

    Examples:
    - ``requests.get(...)`` -> ``requests.get``
    - ``session.execute(...)`` -> ``session.execute``
    - ``query(...)`` -> ``query``
    """
    callee = _call_callee_node(node)
    if callee is None:
        return ""
    return _qualified_name(callee, src)


def _call_callee_node(call_node):
    """Return the callable node for a call expression."""
    for child in call_node.children:
        if child.type in ("argument_list", "arguments", "template_string"):
            break
        if child.is_named:
            return child
    return None


def _qualified_name(node, src: bytes) -> str:
    """Extract a dotted qualified name from an AST node when possible."""
    ident_types = {
        "identifier",
        "property_identifier",
        "field_identifier",
        "type_identifier",
    }
    member_types = {"member_expression", "attribute", "field_expression"}
    if node.type in ident_types:
        return src[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
    if node.type in member_types:
        named = [c for c in node.children if c.is_named]
        if len(named) >= 2:
            left = _qualified_name(named[0], src)
            right = _qualified_name(named[-1], src)
            if left and right:
                return f"{left}.{right}"
            return right or left
        if named:
            return _qualified_name(named[-1], src)
    # Fallback: choose the last identifier-like child.
    for child in reversed(node.children):
        if child.type in ident_types:
            return src[child.start_byte : child.end_byte].decode("utf-8", errors="replace")
    return ""


def _scan_string_inits(func_node, source: bytes, string_vars: set[str]):
    """Scan top-level assignments in a function for string initializations.

    Detects patterns like: x = "", x = '', x = str(), x = f"", x = ""\"\"\"\"\"\"
    Populates string_vars with the variable names.
    """
    body = None
    for child in func_node.children:
        if child.type in ("block", "statement_block", "compound_statement"):
            body = child
            break
    if body is None:
        return

    for stmt in body.children:
        # Python: expression_statement > assignment
        # JS/TS: variable_declaration, expression_statement > assignment_expression
        if stmt.type == "expression_statement":
            for child in stmt.children:
                if child.type == "assignment":
                    _check_string_assign(child, source, string_vars)
                elif child.type == "assignment_expression":
                    _check_string_assign(child, source, string_vars)
        elif stmt.type in ("assignment", "assignment_expression"):
            _check_string_assign(stmt, source, string_vars)
        # Stop scanning once we hit a loop — we only want pre-loop inits
        if stmt.type in _LOOP_NODES:
            break


def _check_string_assign(assign_node, source: bytes, string_vars: set[str]):
    """Check if an assignment initializes a variable to a string value."""
    children = assign_node.children
    if len(children) < 3:
        return
    lhs = children[0]
    rhs = children[-1]

    if lhs.type != "identifier":
        return

    var_name = source[lhs.start_byte : lhs.end_byte].decode("utf-8", errors="replace")

    # Check RHS for string patterns
    if rhs.type in ("string", "concatenated_string", "template_string"):
        string_vars.add(var_name)
    elif rhs.type in ("call_expression", "call"):
        # str() constructor
        fn_name = _call_target_name(rhs, source)
        if fn_name in ("str", "String", "StringBuilder", "StringBuffer"):
            string_vars.add(var_name)


def _augmented_assign_target(node, source: bytes) -> str:
    """Extract the target variable name from an augmented assignment node."""
    for child in node.children:
        if child.type == "identifier":
            return source[child.start_byte : child.end_byte].decode("utf-8", errors="replace")
    return ""


def _extract_identifier_var(node, source: bytes) -> set[str]:
    """Extract a bare identifier loop variable (Python, Go, etc.)."""
    return {source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")}


def _extract_php_variable_name_var(node, source: bytes) -> set[str]:
    """Extract a PHP variable_name loop variable, stripping the leading ``$``."""
    name_node = None
    try:
        name_node = node.child_by_field_name("name")
    except (AttributeError, TypeError):
        # tree-sitter node API varies by grammar/binding (missing method
        # or differing signature); fall back to child scan. Genuine bugs
        # propagate.
        name_node = None
    if name_node is None:
        for sub in node.children:
            if sub.type == "name":
                name_node = sub
                break
    if name_node is not None:
        return {source[name_node.start_byte : name_node.end_byte].decode("utf-8", errors="replace")}
    raw = source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
    return {raw.lstrip("$")}


def _extract_pattern_list_vars(node, source: bytes) -> set[str]:
    """Extract identifiers from a pattern_list / tuple_pattern / pair node."""
    result: set[str] = set()
    for sub in node.children:
        if sub.type == "identifier":
            result.add(source[sub.start_byte : sub.end_byte].decode("utf-8", errors="replace"))
    return result


def _extract_js_var_declarator_vars(node, source: bytes) -> set[str]:
    """Extract identifiers from JS variable_declaration / lexical_declaration nodes."""
    result: set[str] = set()
    for sub in node.children:
        if sub.type == "variable_declarator":
            for ssub in sub.children:
                if ssub.type == "identifier":
                    result.add(source[ssub.start_byte : ssub.end_byte].decode("utf-8", errors="replace"))
    return result


def _extract_loop_vars(loop_node, source: bytes) -> set[str]:
    """Extract loop variable names from a for/foreach loop node.

    Strips PHP's leading ``$`` so a foreach ``$templates as $t`` yields
    ``{"t"}`` — matches identifier text we later see inside the body
    (where the receiver of ``$t->code`` is a ``variable_name`` whose
    inner ``name`` child reads as ``t``).
    """
    result: set[str] = set()
    for child in loop_node.children:
        # Python: for x in ..., Go: for i, v := range ...
        if child.type in ("identifier",):
            result.update(_extract_identifier_var(child, source))
        # PHP: foreach ($templates as $t) — the iter var is a
        # variable_name whose `name` field is the bare identifier.
        elif child.type == "variable_name":
            result.update(_extract_php_variable_name_var(child, source))
        # Python pattern_list / tuple: for x, y in ...
        elif child.type in ("pattern_list", "tuple_pattern", "pair"):
            result.update(_extract_pattern_list_vars(child, source))
        # JS: for (const x of ...) / for (let x in ...)
        elif child.type in ("variable_declaration", "lexical_declaration"):
            result.update(_extract_js_var_declarator_vars(child, source))
        # Stop at the body — we only want the loop header vars
        if child.type in ("block", "statement_block", "compound_statement"):
            break
    return result


def _call_uses_loop_vars(call_node, source: bytes, loop_vars: set[str]) -> bool:
    """Check if a call expression depends on a loop variable.

    Checks both arguments AND the call receiver (the object before ``.method()``).
    For example, ``loop_var.method()`` and ``dict[loop_var].call()`` are both
    loop-dependent even if the argument list is empty or constant.
    """

    if _call_receiver_uses_loop_vars(call_node, source, loop_vars):
        return True
    return _call_args_use_loop_vars(call_node, source, loop_vars)


def _node_has_loop_var(node, source: bytes, loop_vars: set[str]) -> bool:
    """Return True when ``node`` references any variable from ``loop_vars``.

    Handles both bare ``identifier`` nodes (Python/JS/Go/Java/…) and PHP's
    ``variable_name`` wrapper, whose inner ``name`` child holds the raw
    identifier text without the ``$`` sigil.
    """
    ntype = node.type
    if ntype == "identifier":
        name = source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
        if name in loop_vars:
            return True
    elif ntype == "variable_name":
        # PHP: $t — the `name` child is the bare identifier we matched on
        # in _extract_loop_vars (after stripping the `$`).
        for sub in node.children:
            if sub.type == "name":
                name = source[sub.start_byte : sub.end_byte].decode("utf-8", errors="replace")
                if name in loop_vars:
                    return True
                break
    for child in node.children:
        if _node_has_loop_var(child, source, loop_vars):
            return True
    return False


def _call_receiver_node(call_node):
    """Return the receiver object node in ``receiver.method(...)`` calls."""
    for child in call_node.children:
        if child.type in ("member_expression", "attribute", "field_expression"):
            named = [c for c in child.children if c.is_named]
            if len(named) >= 2:
                return named[0]
    return None


def _call_args_node(call_node):
    """Return the argument list node for a call expression."""
    for child in call_node.children:
        if child.type in ("argument_list", "arguments"):
            return child
    return None


def _call_receiver_uses_loop_vars(call_node, source: bytes, loop_vars: set[str]) -> bool:
    recv = _call_receiver_node(call_node)
    if recv is None:
        return False
    return _node_has_loop_var(recv, source, loop_vars)


def _call_args_use_loop_vars(call_node, source: bytes, loop_vars: set[str]) -> bool:
    args = _call_args_node(call_node)
    if args is None:
        return False
    return _node_has_loop_var(args, source, loop_vars)


def _call_arg_texts(call_node, source: bytes) -> list[str]:
    """Return argument source snippets for a call expression."""
    args = _call_args_node(call_node)
    if args is None:
        return []
    out: list[str] = []
    for child in args.children:
        if child.is_named:
            out.append(source[child.start_byte : child.end_byte].decode("utf-8", errors="replace").strip())
    return out


# -- Equality + dependent-write detection (nested-lookup discriminator) --

# AST node types that constitute "writes" inside an if-branch — used to
# distinguish a real lookup pattern from streaming output.
_WRITE_NODES = {
    "assignment",
    "assignment_expression",
    "augmented_assignment",
}
_CALL_NODES = {"call", "call_expression"}
# Equality operator tokens. PHP includes the strict variants ``===``/``!==``.
_EQ_OPS = {"==", "===", "!=", "!==", "<>", "eq", "ne", "is", "is not"}


def _equality_operands(cmp_node, source: bytes):
    """If ``cmp_node`` is an equality comparison, return its two operand
    nodes; otherwise ``None``. Handles tree-sitter's ``binary_expression``
    (PHP/JS/Java/Go/C#) and Python's dedicated ``comparison_operator``."""
    ntype = cmp_node.type
    if ntype not in ("binary_expression", "comparison_operator"):
        return None
    named = [c for c in cmp_node.children if c.is_named]
    # Find the operator token among the unnamed children.
    op_tokens = [
        source[c.start_byte : c.end_byte].decode("utf-8", errors="replace") for c in cmp_node.children if not c.is_named
    ]
    has_eq = any(t in _EQ_OPS for t in op_tokens)
    if not has_eq:
        return None
    if len(named) < 2:
        return None
    return named[0], named[-1]


def _root_var_name(node, source: bytes) -> str:
    """Walk down the leftmost spine of ``node`` to find the root variable
    that an expression like ``$e->code`` or ``a['id']`` is rooted at.
    Returns the bare identifier text (no ``$``) or empty string."""
    cur = node
    # Descend until we hit a leaf identifier / variable_name.
    while True:
        ntype = cur.type
        if ntype == "identifier":
            return source[cur.start_byte : cur.end_byte].decode("utf-8", errors="replace")
        if ntype == "variable_name":
            for sub in cur.children:
                if sub.type == "name":
                    return source[sub.start_byte : sub.end_byte].decode("utf-8", errors="replace")
            return source[cur.start_byte : cur.end_byte].decode("utf-8", errors="replace").lstrip("$")
        # Descend into the leftmost named child.
        next_child = None
        for c in cur.children:
            if c.is_named:
                next_child = c
                break
        if next_child is None or next_child is cur:
            return ""
        cur = next_child


def _if_condition_node(if_node):
    """Return the condition expression node of an ``if`` statement
    across language grammars. Handles Python's bare condition child,
    PHP/JS/Java's parenthesized_expression wrapper, and Rust's
    let_condition."""
    try:
        cond = if_node.child_by_field_name("condition")
    except (AttributeError, TypeError):
        # tree-sitter node API varies by grammar/binding (missing method or
        # differing signature); fall back to named-child scan. Genuine bugs
        # propagate.
        cond = None
    if cond is None:
        # Fallback: first named child that's not an "if" keyword.
        for c in if_node.children:
            if c.is_named and c.type not in ("if", "else", "block", "compound_statement", "statement_block", "suite"):
                cond = c
                break
    if cond is None:
        return None
    # Unwrap parenthesized_expression / parenthesized_expression-like wrappers.
    if cond.type in (
        "parenthesized_expression",
        "parenthesized_expression",
    ):
        for c in cond.children:
            if c.is_named:
                return c
    return cond


def _if_consequence_nodes(if_node):
    """Return the then-branch body node(s) of an ``if`` statement."""
    try:
        body = if_node.child_by_field_name("consequence")
    except (AttributeError, TypeError):
        # tree-sitter node API varies by grammar/binding (missing method or
        # differing signature); fall back to block-child scan. Genuine bugs
        # propagate.
        body = None
    if body is None:
        try:
            body = if_node.child_by_field_name("body")
        except (AttributeError, TypeError):
            # tree-sitter node API varies by grammar/binding (missing method or
            # differing signature); fall back to block-child scan. Genuine bugs
            # propagate.
            body = None
    if body is not None:
        return [body]
    # Fallback: take the first block-like child after the condition.
    bodies = []
    seen_cond = False
    for c in if_node.children:
        if c.type in ("block", "compound_statement", "statement_block", "suite"):
            bodies.append(c)
        if c.type in ("parenthesized_expression", "binary_expression", "comparison_operator"):
            seen_cond = True
    if not bodies and seen_cond:
        return [c for c in if_node.children if c.is_named]
    return bodies


def _branch_has_dependent_write(branch_node, source: bytes, vars_of_interest: set[str]) -> bool:
    """Walk ``branch_node`` recursively. Return True iff some assignment
    or call subtree references one of ``vars_of_interest``."""
    if branch_node is None or not vars_of_interest:
        return False
    ntype = branch_node.type
    if ntype in _WRITE_NODES:
        if _node_has_loop_var(branch_node, source, vars_of_interest):
            return True
    if ntype in _CALL_NODES:
        if _node_has_loop_var(branch_node, source, vars_of_interest):
            return True
    for child in branch_node.children:
        if _branch_has_dependent_write(child, source, vars_of_interest):
            return True
    return False


def _check_equality_with_dependent_write(if_node, source: bytes, loop_vars: set[str], state: _MathSignalState) -> None:
    """If ``if_node`` is an if-statement whose condition compares two
    different per-iteration variables for equality AND whose then-branch
    writes to one of them, set the discriminator flag on ``state``."""
    if state.loop_eq_with_dependent_write:
        return  # short-circuit: one true match is enough
    if not loop_vars:
        return
    cond = _if_condition_node(if_node)
    if cond is None:
        return
    operands = _equality_operands(cond, source)
    if operands is None:
        return
    left, right = operands
    left_root = _root_var_name(left, source)
    right_root = _root_var_name(right, source)
    if not left_root or not right_root:
        return
    if left_root == right_root:
        return  # equality on the same var (e.g. self-compare) — not a join
    # Both roots must be loop iteration variables we've observed.
    if left_root not in loop_vars or right_root not in loop_vars:
        return
    vars_of_interest = {left_root, right_root}
    for branch in _if_consequence_nodes(if_node):
        if _branch_has_dependent_write(branch, source, vars_of_interest):
            state.loop_eq_with_dependent_write = True
            return


def _is_zero_literal(text: str) -> bool:
    t = text.strip().lower()
    return t in {"0", "0u", "0l", "0ul", "0.0", "0.0f", "0.0d"}


def _is_front_operation_call(call_node, source: bytes, call_name: str) -> bool:
    """Detect front-insert/front-remove operations used inside loops."""
    # JS/Ruby/PHP-style dedicated front operations.
    if call_name in {"unshift", "shift", "appendleft", "popleft"}:
        return True

    arg_texts = _call_arg_texts(call_node, source)

    # Python/Java/C#: insert(0, x), add(0, x), pop(0), removeAt(0)
    if call_name in {"insert", "add", "Insert", "Add", "pop", "removeAt", "RemoveAt"}:
        if arg_texts and _is_zero_literal(arg_texts[0]):
            return True

    # C++ vector::erase(begin())-style front removal.
    if call_name in {"erase", "Erase"} and arg_texts:
        first = arg_texts[0].replace(" ", "")
        if first.startswith("begin(") or first.endswith(".begin()"):
            return True

    return False


def _integer_node_value(node, source: bytes) -> int | None:
    if node.type not in ("integer", "number"):
        return None
    text = source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
    try:
        return int(text)
    except (ValueError, OverflowError):
        return None


def _call_argument_nodes(call_node) -> list:
    for child in call_node.children:
        if child.type in ("argument_list", "arguments"):
            return [arg for arg in child.children if arg.is_named]
    return []


def _is_small_range_call(node, source: bytes) -> bool:
    if node.type not in ("call_expression", "call"):
        return False
    if _call_target_name(node, source) != "range":
        return False
    return any(
        (value := _integer_node_value(arg, source)) is not None and value < 100 for arg in _call_argument_nodes(node)
    )


def _is_small_literal_collection(node) -> bool:
    if node.type not in ("list", "tuple", "set"):
        return False
    return sum(1 for child in node.children if child.is_named) < 20


def _is_bounded_loop(loop_node, source: bytes) -> bool:
    """Check if a loop iterates over a known-small bounded range.

    Detects: range(N) where N < 100, iteration over tuple/list literals.
    """
    return any(
        _is_small_range_call(child, source) or _is_small_literal_collection(child) for child in loop_node.children
    )


# -- Batch computation + storage --------------------------------------


def compute_and_store(
    conn: sqlite3.Connection,
    file_id: int,
    tree,
    source: bytes,
):
    """Compute complexity metrics for all function/method symbols in a file
    and store them in the symbol_metrics table.

    Only processes symbols with kind in ('function', 'method', 'generator',
    'constructor', 'property') — classes/modules are skipped.
    """
    CALLABLE_KINDS = (
        "function",
        "method",
        "generator",
        "constructor",
        "property",
        "closure",
        "lambda",
    )

    rows = conn.execute(
        "SELECT id, name, kind, line_start, line_end FROM symbols WHERE file_id = ?",
        (file_id,),
    ).fetchall()

    metrics_batch = []
    math_batch = []
    for row in rows:
        kind = row["kind"] or ""
        if kind not in CALLABLE_KINDS:
            continue
        ls = row["line_start"]
        le = row["line_end"]
        if ls is None or le is None:
            continue

        # Locate the function node ONCE — both complexity + math signals
        # need it. Saves an entire AST descent per callable on big files.
        func_node = _find_function_node(tree, ls, le)

        metrics = compute_symbol_complexity(tree, source, ls, le, func_node=func_node)
        if metrics is None:
            continue

        metrics_batch.append(
            (
                row["id"],
                metrics["cognitive_complexity"],
                metrics["nesting_depth"],
                metrics["param_count"],
                metrics["line_count"],
                metrics["return_count"],
                metrics["bool_op_count"],
                metrics["callback_depth"],
                metrics.get("cyclomatic_density", 0.0),
                metrics.get("halstead_volume", 0.0),
                metrics.get("halstead_difficulty", 0.0),
                metrics.get("halstead_effort", 0.0),
                metrics.get("halstead_bugs", 0.0),
            )
        )

        # Extract math signals from same AST node (re-using func_node).
        if func_node is not None:
            import json as _json_mod

            msig = _extract_math_signals(func_node, source, row["name"])
            math_batch.append(
                (
                    row["id"],
                    msig["loop_depth"],
                    msig["has_nested_loops"],
                    _json_mod.dumps(msig["calls_in_loops"]),
                    _json_mod.dumps(msig["calls_in_loops_qualified"]),
                    msig["subscript_in_loops"],
                    msig["has_self_call"],
                    msig["loop_with_compare"],
                    msig["loop_with_accumulator"],
                    msig["loop_with_multiplication"],
                    msig["loop_with_modulo"],
                    msig["self_call_count"],
                    msig["str_concat_in_loop"],
                    _json_mod.dumps(msig["loop_invariant_calls"]),
                    _json_mod.dumps(msig["loop_lookup_calls"]),
                    msig["front_ops_in_loop"],
                    msig["loop_bound_small"],
                    msig["loop_eq_with_dependent_write"],
                )
            )

    if metrics_batch:
        conn.executemany(
            """INSERT OR REPLACE INTO symbol_metrics
               (symbol_id, cognitive_complexity, nesting_depth, param_count,
                line_count, return_count, bool_op_count, callback_depth,
                cyclomatic_density, halstead_volume, halstead_difficulty,
                halstead_effort, halstead_bugs)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
            metrics_batch,
        )

    if math_batch:
        conn.executemany(
            """INSERT OR REPLACE INTO math_signals
               (symbol_id, loop_depth, has_nested_loops, calls_in_loops,
                calls_in_loops_qualified, subscript_in_loops, has_self_call,
                loop_with_compare, loop_with_accumulator,
                loop_with_multiplication, loop_with_modulo, self_call_count,
                str_concat_in_loop, loop_invariant_calls, loop_lookup_calls,
                front_ops_in_loop, loop_bound_small,
                loop_eq_with_dependent_write)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
            math_batch,
        )
