"""Formula engine — DAG, topological sort, and evaluation.

Builds a dependency graph of formula fields, detects circular references,
and evaluates formulas in topological order.

Aggregate context (KTD3):
- ``SUM({f1})`` — f1 is an aggregate reference (entire column)
- ``{f1} + 1`` — f1 is a row reference (current record's value)
- ``{f1} + SUM({f2})`` — mixed: row f1 + column f2 sum

The engine distinguishes these by checking if a field reference appears
as a direct argument to an aggregate function.
"""

from __future__ import annotations

import ast
from collections import deque

from agentkit.bitable.formula.functions import AGGREGATE_FUNCTIONS, FUNCTION_REGISTRY
from agentkit.bitable.formula.parser import (
    FormulaParseError,
    evaluate_ast,
    parse_formula,
)


class CircularReferenceError(Exception):
    """Raised when formula fields form a circular dependency."""


class FormulaEngine:
    """Formula engine: parse, build DAG, detect cycles, evaluate.

    Usage::

        engine = FormulaEngine()
        # Register formula fields
        engine.add_formula(field_id="calc", formula="=SUM({src})", field_refs={"src"})
        # Evaluate for a specific record
        result = engine.evaluate(field_id="calc", field_values={"src": [1, 2, 3]})
    """

    def __init__(self) -> None:
        # field_id → (ast_tree, field_mapping, aggregate_refs, row_refs)
        self._formulas: dict[str, _FormulaEntry] = {}
        # DAG: field_id → set of field_ids it depends on
        self._dag: dict[str, set[str]] = {}

    def add_formula(self, field_id: str, formula: str) -> None:
        """Register a formula for a field.

        Raises:
            FormulaParseError: Syntax error.
            FormulaSecurityError: Disallowed AST node.
            UnknownFunctionError: Unregistered function.
            CircularReferenceError: Adding this formula creates a cycle.
        """
        tree, field_mapping = parse_formula(formula, set(FUNCTION_REGISTRY.keys()))

        # Classify field refs into aggregate vs row context
        aggregate_refs, row_refs = _classify_refs(tree, field_mapping)

        entry = _FormulaEntry(
            tree=tree,
            field_mapping=field_mapping,
            aggregate_refs=aggregate_refs,
            row_refs=row_refs,
            formula=formula,
        )
        self._formulas[field_id] = entry
        # Update DAG: this field depends on all referenced fields
        self._dag[field_id] = aggregate_refs | row_refs

        # Check for cycles
        cycle = _detect_cycle(self._dag)
        if cycle:
            # Rollback
            del self._formulas[field_id]
            del self._dag[field_id]
            raise CircularReferenceError(f"Circular reference detected: {' → '.join(cycle)}")

    def remove_formula(self, field_id: str) -> None:
        """Remove a formula from the engine."""
        self._formulas.pop(field_id, None)
        self._dag.pop(field_id, None)
        # Remove edges pointing to this field
        for deps in self._dag.values():
            deps.discard(field_id)

    def get_dependencies(self, field_id: str) -> set[str]:
        """Get the set of field IDs that ``field_id`` depends on."""
        return self._dag.get(field_id, set()).copy()

    def get_dependents(self, field_id: str) -> set[str]:
        """Get the set of formula field IDs that depend on ``field_id``."""
        return {fid for fid, deps in self._dag.items() if field_id in deps}

    def topological_order(self) -> list[str]:
        """Return all formula field IDs in topological order (Kahn's algorithm)."""
        return _topological_sort(self._dag)

    def evaluate(
        self,
        field_id: str,
        row_values: dict[str, object],
        column_values: dict[str, list[object]] | None = None,
    ) -> object:
        """Evaluate a formula field for a specific record.

        Args:
            field_id: The formula field to evaluate.
            row_values: Field ID → value for the current record (row context).
            column_values: Field ID → list of all values in that column
                (aggregate context). Required for aggregate references.

        Returns:
            The computed value.

        Raises:
            KeyError: Field ID not registered.
            FormulaParseError: Field reference not found in values.
        """
        if field_id not in self._formulas:
            raise KeyError(f"Formula not registered: {field_id}")

        entry = self._formulas[field_id]
        column_values = column_values or {}

        # Build the field_values dict for the evaluator
        # Aggregate refs get column values (lists), row refs get row values (scalars)
        eval_values: dict[str, object] = {}

        # Map real field IDs to safe names
        for safe_name, real_id in entry.field_mapping.items():
            if real_id in entry.aggregate_refs:
                eval_values[safe_name] = column_values.get(real_id, [])
            else:
                eval_values[safe_name] = row_values.get(real_id)

        return evaluate_ast(entry.tree, eval_values, FUNCTION_REGISTRY)

    def evaluate_all_for_record(
        self,
        row_values: dict[str, object],
        column_values: dict[str, list[object]] | None = None,
    ) -> dict[str, object]:
        """Evaluate all registered formulas for a record.

        Returns a dict of field_id → computed value.
        Formulas are evaluated in topological order so that formula-to-formula
        dependencies are resolved correctly.
        """
        results: dict[str, object] = {}
        column_values = column_values or {}

        for field_id in self.topological_order():
            # Include already-computed formula results in row_values
            merged_row = {**row_values, **results}
            try:
                results[field_id] = self.evaluate(field_id, merged_row, column_values)
            except (FormulaParseError, ZeroDivisionError, TypeError) as e:
                results[field_id] = {"__error": str(e)}

        return results


# ── Internal data structures ──────────────────────────────


class _FormulaEntry:
    """Parsed formula metadata."""

    __slots__ = ("tree", "field_mapping", "aggregate_refs", "row_refs", "formula")

    def __init__(
        self,
        tree: ast.Expression,
        field_mapping: dict[str, str],
        aggregate_refs: set[str],
        row_refs: set[str],
        formula: str,
    ) -> None:
        self.tree = tree
        self.field_mapping = field_mapping
        self.aggregate_refs = aggregate_refs
        self.row_refs = row_refs
        self.formula = formula


# ── DAG utilities ─────────────────────────────────────────


def _detect_cycle(dag: dict[str, set[str]]) -> list[str] | None:
    """Detect a cycle in the DAG using DFS. Returns the cycle path or None."""
    WHITE, GRAY, BLACK = 0, 1, 2
    color: dict[str, int] = {node: WHITE for node in dag}
    parent: dict[str, str | None] = {node: None for node in dag}

    def _dfs(node: str) -> list[str] | None:
        color[node] = GRAY
        for neighbor in dag.get(node, set()):
            if neighbor not in color:
                color[neighbor] = WHITE
                parent[neighbor] = None
            if color[neighbor] == GRAY:
                # Found cycle — reconstruct path
                cycle = [neighbor]
                current = node
                while current is not None and current != neighbor:
                    cycle.append(current)
                    current = parent.get(current)
                cycle.append(neighbor)
                cycle.reverse()
                return cycle
            if color[neighbor] == WHITE:
                parent[neighbor] = node
                result = _dfs(neighbor)
                if result is not None:
                    return result
        color[node] = BLACK
        return None

    for node in dag:
        if color.get(node, WHITE) == WHITE:
            result = _dfs(node)
            if result is not None:
                return result
    return None


def _topological_sort(dag: dict[str, set[str]]) -> list[str]:
    """Kahn's algorithm for topological sort."""
    # Build in-degree map
    in_degree: dict[str, int] = {node: 0 for node in dag}
    for node, deps in dag.items():
        for dep in deps:
            if dep in in_degree:
                in_degree[node] += 1

    # Start with nodes that have no dependencies
    queue = deque(node for node, degree in in_degree.items() if degree == 0)
    result: list[str] = []

    while queue:
        node = queue.popleft()
        result.append(node)
        # Find all nodes that depend on this node
        for other_node, deps in dag.items():
            if node in deps:
                in_degree[other_node] -= 1
                if in_degree[other_node] == 0 and other_node not in result:
                    queue.append(other_node)

    return result


def _classify_refs(
    tree: ast.Expression, field_mapping: dict[str, str]
) -> tuple[set[str], set[str]]:
    """Classify field references into aggregate (column) and row context.

    A field reference is aggregate if it appears as a direct argument to
    an aggregate function (SUM/AVG/COUNT/MIN/MAX). Otherwise it's row context.
    """
    aggregate_refs: set[str] = set()
    row_refs: set[str] = set()

    # Get all safe names → real field IDs
    safe_to_real = field_mapping

    class _Classifier(ast.NodeVisitor):
        def visit_Call(self, node: ast.Call) -> None:
            if isinstance(node.func, ast.Name) and node.func.id in AGGREGATE_FUNCTIONS:
                # Direct arguments to aggregate functions are column refs
                for arg in node.args:
                    if isinstance(arg, ast.Name) and arg.id in safe_to_real:
                        aggregate_refs.add(safe_to_real[arg.id])
                    else:
                        # Non-field args (e.g., literals) — visit normally
                        self.visit(arg)
            else:
                self.generic_visit(node)

        def visit_Name(self, node: ast.Name) -> None:
            if node.id in safe_to_real:
                real_id = safe_to_real[node.id]
                if real_id not in aggregate_refs:
                    row_refs.add(real_id)

    _Classifier().visit(tree)
    return aggregate_refs, row_refs
