"""Evaluates a Dashboard/chart widget's custom-expression metric (FR-DSH
custom expressions, Phase 4) — e.g. "SUM(revenue) / SUM(orders)" or
"(SUM(revenue) - SUM(cost)) / SUM(revenue) * 100" — against an already-
filtered, already-grouped row set. Same safety approach as
app/crud/formula.py's per-row formula fields: each whitelisted aggregate call
is swapped for a generated safe identifier bound to its already-computed
numeric value, then the resulting pure-arithmetic string is handed to
simpleeval — no field names, attribute access, imports, or arbitrary calls
ever reach the evaluator, since this expression string is tenant-authored,
untrusted input. Unlike formula.py's {field} tokens (one row's own values),
this module's "variables" are aggregate functions over a whole row set, since
a Dashboard expression combines already-reduced numbers, not raw row data."""

import re
from typing import Any

from simpleeval import EvalWithCompoundTypes, InvalidExpression

from app.crud.dataset_query import _aggregate_value

_AGG_TOKEN = re.compile(r"\b(SUM|AVG|COUNT|MIN|MAX)\(([A-Za-z0-9_]*)\)", re.IGNORECASE)
_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
_GENERATED_NAME = re.compile(r"^_a\d+$")


class ExpressionError(ValueError):
    """A ValueError subclass (not a plain Exception) so raising it directly
    inside a pydantic @model_validator (see schemas/navigation.py's
    WidgetConfig/WidgetSeriesDef) is automatically turned into a clean 422
    response instead of propagating as an unhandled 500."""


def _substitute_aggregates(expression: str, rows: list[dict[str, Any]]) -> tuple[str, dict[str, float]]:
    names: dict[str, float] = {}

    def _substitute(match: re.Match[str]) -> str:
        fn, field = match.group(1).upper(), match.group(2)
        if fn == "COUNT":
            if field:
                raise ExpressionError("COUNT() takes no field")
            value = float(len(rows))
        else:
            if not field:
                raise ExpressionError(f"{fn}(...) requires a field name, e.g. {fn}(revenue)")
            value = _aggregate_value(fn.lower(), [r.get(field) for r in rows])
        var_name = f"_a{len(names)}"
        names[var_name] = value
        return var_name

    python_expr = _AGG_TOKEN.sub(_substitute, expression)
    for token in _IDENTIFIER.findall(python_expr):
        if not _GENERATED_NAME.match(token):
            raise ExpressionError(f"Unknown identifier {token!r} — only SUM/AVG/COUNT/MIN/MAX(field) are allowed")
    return python_expr, names


def parse_expression(expression: str) -> None:
    """Validates `expression`'s grammar without needing real row data —
    called from schemas/navigation.py's WidgetConfig/WidgetSeriesDef
    validators so a malformed expression is rejected with a 422 at save
    time, before it ever reaches the DB. Raises ExpressionError (a
    pydantic @model_validator re-raises this as a ValueError) on anything
    outside the whitelisted SUM/AVG/COUNT/MIN/MAX(field) + arithmetic
    grammar."""
    if not expression or not expression.strip():
        raise ExpressionError("Expression is empty")
    python_expr, names = _substitute_aggregates(expression, rows=[])
    try:
        EvalWithCompoundTypes(names=names, functions={}).eval(python_expr)
    except InvalidExpression as exc:
        raise ExpressionError(str(exc)) from exc
    except ZeroDivisionError:
        pass  # placeholder zero values dividing by zero is still grammar-valid


def evaluate_expression(expression: str, rows: list[dict[str, Any]]) -> float:
    """Evaluates `expression` (already grammar-validated by parse_expression
    at save time) against `rows` — a chart series' group of rows, or a KPI's
    whole filtered row set. Division by zero returns 0.0, matching
    _aggregate_value's own empty-input convention elsewhere in this module."""
    python_expr, names = _substitute_aggregates(expression, rows)
    try:
        return float(EvalWithCompoundTypes(names=names, functions={}).eval(python_expr))
    except InvalidExpression as exc:
        raise ExpressionError(str(exc)) from exc
    except ZeroDivisionError:
        return 0.0
