"""Local Calculation IR evaluator.

Cloud ``calculation.evaluate`` is the authoritative execution path. This local
evaluator remains for local-mode IR; dataset snapshots must be supplied already
resolved.
"""

from __future__ import annotations

from datetime import date, datetime, timedelta
from decimal import Decimal, DivisionByZero, InvalidOperation
from functools import cmp_to_key
from typing import Any

from src.domain.calculation.arithmetic import apply_round, quantize_money, to_decimal
from src.domain.calculation.errors import (
    AuthoritativeNotAdmittedError,
    CalculationEvalError,
)
from src.domain.calculation.ir import (
    BinaryArithExpr,
    CalculationIR,
    ClampExpr,
    CompareExpr,
    DatasetExpr,
    DateAddExpr,
    DateDiffExpr,
    Expr,
    FieldExpr,
    FilterExpr,
    IfExpr,
    InputExpr,
    ItemExpr,
    ListAggExpr,
    ListExpr,
    LitExpr,
    MapExpr,
    OutputSpec,
    RecordExpr,
    RoundExpr,
    SortExpr,
    TypedInput,
    UnaryExpr,
    VariadicExpr,
)


def evaluate_local(
    ir: CalculationIR,
    *,
    inputs: dict[str, Any] | None = None,
    datasets: dict[str, list[dict[str, Any]]] | None = None,
) -> dict[str, Any]:
    """Run a local calculator. Does not query records."""
    if ir.execution == "authoritative":
        raise AuthoritativeNotAdmittedError(
            "execution: authoritative requires the Cloud calculation.evaluate path; "
            "the local evaluator does not execute authoritative IR"
        )
    bound_inputs = _bind_inputs(ir.inputs, inputs or {})
    snapshots = dict(datasets or {})
    for binding in ir.datasets:
        if binding.id not in snapshots:
            raise CalculationEvalError(f"dataset snapshot '{binding.id}' was not resolved")
        budgeted = snapshots[binding.id][:5000]
        snapshots[binding.id] = budgeted
    env = _Env(inputs=bound_inputs, fields={}, datasets=snapshots, items={})
    for field in ir.fields:
        env.fields[field.key] = env.eval(field.expr)
        if field.type == "money" and isinstance(env.fields[field.key], Decimal):
            env.fields[field.key] = quantize_money(env.fields[field.key])
    outputs: dict[str, Any] = {}
    for spec in ir.outputs:
        value = env.fields.get(spec.from_field, env.inputs.get(spec.from_field))
        outputs[spec.key] = _format_output(spec, value)
    return {"id": ir.id, "execution": "local", "outputs": outputs, "fields": dict(env.fields)}


class _Env:
    def __init__(
        self,
        *,
        inputs: dict[str, Any],
        fields: dict[str, Any],
        datasets: dict[str, list[dict[str, Any]]],
        items: dict[str, Any],
    ) -> None:
        self.inputs = inputs
        self.fields = fields
        self.datasets = datasets
        self.items = items

    def child(self, **items: Any) -> _Env:
        merged = dict(self.items)
        merged.update(items)
        return _Env(
            inputs=self.inputs,
            fields=self.fields,
            datasets=self.datasets,
            items=merged,
        )

    def eval(self, expr: Expr) -> Any:
        if isinstance(expr, LitExpr):
            return _coerce_lit(expr)
        if isinstance(expr, InputExpr):
            if expr.key not in self.inputs:
                raise CalculationEvalError(f"missing input '{expr.key}'")
            return self.inputs[expr.key]
        if isinstance(expr, FieldExpr):
            if expr.key not in self.fields:
                raise CalculationEvalError(f"field '{expr.key}' is not yet computed")
            return self.fields[expr.key]
        if isinstance(expr, DatasetExpr):
            if expr.id not in self.datasets:
                raise CalculationEvalError(f"unresolved dataset '{expr.id}'")
            return self.datasets[expr.id]
        if isinstance(expr, ItemExpr):
            row = self.items.get(expr.name)
            if row is None:
                raise CalculationEvalError(f"item '{expr.name}' is not in scope")
            if expr.field is None:
                return row
            if not isinstance(row, dict):
                raise CalculationEvalError(f"item '{expr.name}' has no fields")
            return row.get(expr.field)
        if isinstance(expr, UnaryExpr):
            value = self.eval(expr.arg)
            if expr.op == "neg":
                return -to_decimal(value)
            if expr.op == "abs":
                return abs(to_decimal(value))
            return not _truthy(value)
        if isinstance(expr, BinaryArithExpr):
            left = to_decimal(self.eval(expr.args[0]))
            right = to_decimal(self.eval(expr.args[1]))
            try:
                if expr.op == "add":
                    return left + right
                if expr.op == "sub":
                    return left - right
                if expr.op == "mul":
                    return left * right
                if expr.op == "div":
                    if right == 0:
                        raise CalculationEvalError("division by zero")
                    return left / right
                return left ** right
            except (DivisionByZero, InvalidOperation) as exc:
                raise CalculationEvalError(str(exc)) from exc
        if isinstance(expr, CompareExpr):
            left = self.eval(expr.args[0])
            right = self.eval(expr.args[1])
            return _compare(expr.op, left, right)
        if isinstance(expr, IfExpr):
            chosen = expr.then if _truthy(self.eval(expr.cond)) else expr.else_
            return self.eval(chosen)
        if isinstance(expr, VariadicExpr):
            values = [to_decimal(self.eval(arg)) for arg in expr.args]
            return min(values) if expr.op == "min" else max(values)
        if isinstance(expr, RoundExpr):
            value = to_decimal(self.eval(expr.value))
            if expr.op == "round":
                return apply_round(value, places=expr.places, mode=expr.mode)
            if expr.op == "floor":
                return value.to_integral_value(rounding="ROUND_FLOOR")
            return value.to_integral_value(rounding="ROUND_CEILING")
        if isinstance(expr, ClampExpr):
            value = to_decimal(self.eval(expr.value))
            low = to_decimal(self.eval(expr.min))
            high = to_decimal(self.eval(expr.max))
            return min(max(value, low), high)
        if isinstance(expr, DateAddExpr):
            start = _as_date(self.eval(expr.date))
            amount = int(to_decimal(self.eval(expr.amount)))
            return _date_add(start, amount, expr.unit).isoformat()
        if isinstance(expr, DateDiffExpr):
            start = _as_date(self.eval(expr.start))
            end = _as_date(self.eval(expr.end))
            return Decimal(_date_diff(start, end, expr.unit))
        if isinstance(expr, ListAggExpr):
            rows = _as_list(self.eval(expr.over))
            if expr.op == "count":
                return Decimal(len(rows))
            numbers = [_row_number(row, expr.field) for row in rows]
            if not numbers:
                return Decimal("0")
            total = sum(numbers, Decimal("0"))
            return total if expr.op == "sum" else total / Decimal(len(numbers))
        if isinstance(expr, SortExpr):
            rows = _as_list(self.eval(expr.over))
            return sorted(rows, key=cmp_to_key(lambda left, right: _compare_rows(left, right, expr.field, expr.direction)))
        if isinstance(expr, FilterExpr):
            rows = _as_list(self.eval(expr.over))
            kept = []
            for row in rows:
                if _truthy(self.child(**{expr.item: row}).eval(expr.where)):
                    kept.append(row)
            return kept
        if isinstance(expr, MapExpr):
            rows = _as_list(self.eval(expr.over))
            return [self.child(**{expr.item: row}).eval(expr.as_) for row in rows]
        if isinstance(expr, ListExpr):
            return [self.eval(item) for item in expr.items]
        if isinstance(expr, RecordExpr):
            return {key: self.eval(value) for key, value in expr.fields.items()}
        raise CalculationEvalError(f"unsupported op {getattr(expr, 'op', expr)!r}")


def _bind_inputs(specs: list[TypedInput], values: dict[str, Any]) -> dict[str, Any]:
    bound: dict[str, Any] = {}
    for spec in specs:
        if spec.key in values and values[spec.key] is not None:
            bound[spec.key] = _coerce_input(spec, values[spec.key])
        elif spec.default is not None:
            bound[spec.key] = _coerce_input(spec, spec.default)
        elif spec.required:
            raise CalculationEvalError(f"required input '{spec.key}' is missing")
    return bound


def _coerce_input(spec: TypedInput, value: Any) -> Any:
    if spec.type == "boolean":
        return _truthy(value)
    if spec.type == "date":
        return _as_date(value).isoformat()
    if spec.type == "select":
        text = str(value)
        if text not in spec.options:
            raise CalculationEvalError(f"input '{spec.key}' must be one of {spec.options}")
        return text
    if spec.type == "list":
        return _as_list(value)
    if spec.type in {"number", "money", "percent"}:
        number = to_decimal(value)
        if spec.type == "money":
            return quantize_money(number)
        return number
    return value


def _coerce_lit(expr: LitExpr) -> Any:
    if expr.type == "boolean":
        return _truthy(expr.value)
    if expr.type == "date":
        return _as_date(expr.value).isoformat()
    if expr.type == "list":
        return _as_list(expr.value)
    if expr.type in {"number", "money", "percent"}:
        number = to_decimal(expr.value)
        if expr.type == "money":
            return quantize_money(number)
        return number
    return expr.value


def _format_output(spec: OutputSpec, value: Any) -> Any:
    if spec.kind == "dataset":
        rows = _as_list(value)
        return {"shape": spec.shape or "series", "rows": [_jsonable(row) for row in rows]}
    return _jsonable(value)


def _jsonable(value: Any) -> Any:
    if isinstance(value, Decimal):
        return format(value, "f")
    if isinstance(value, date):
        return value.isoformat()
    if isinstance(value, dict):
        return {str(key): _jsonable(item) for key, item in value.items()}
    if isinstance(value, list):
        return [_jsonable(item) for item in value]
    return value


def _truthy(value: Any) -> bool:
    if isinstance(value, Decimal):
        return value != 0
    return bool(value)


def _compare_rows(left: Any, right: Any, field: str, direction: str) -> int:
    """Sort rows while keeping missing values at the end in either direction."""
    left_value = left.get(field) if isinstance(left, dict) else None
    right_value = right.get(field) if isinstance(right, dict) else None
    if left_value is None and right_value is None:
        return 0
    if left_value is None:
        return 1
    if right_value is None:
        return -1
    try:
        left_number = to_decimal(left_value)
        right_number = to_decimal(right_value)
        result = (left_number > right_number) - (left_number < right_number)
    except (InvalidOperation, ValueError, TypeError):
        left_text, right_text = str(left_value), str(right_value)
        result = (left_text > right_text) - (left_text < right_text)
    return -result if direction == "desc" else result


def _compare(op: str, left: Any, right: Any) -> bool:
    try:
        left_n = to_decimal(left)
        right_n = to_decimal(right)
        pair = (left_n, right_n)
        numeric = True
    except (InvalidOperation, TypeError, ValueError):
        pair = (left, right)
        numeric = False
    a, b = pair
    if op == "eq":
        return a == b
    if op == "neq":
        return a != b
    if not numeric and op in {"lt", "lte", "gt", "gte"}:
        a, b = str(left), str(right)
    if op == "lt":
        return a < b
    if op == "lte":
        return a <= b
    if op == "gt":
        return a > b
    return a >= b


def _as_list(value: Any) -> list[Any]:
    if value is None:
        return []
    if isinstance(value, list):
        return value
    raise CalculationEvalError("expected a list")


def _row_number(row: Any, field: str | None) -> Decimal:
    if field is None:
        return to_decimal(row)
    if not isinstance(row, dict):
        raise CalculationEvalError("list aggregation field requires record rows")
    return to_decimal(row.get(field))


def _as_date(value: Any) -> date:
    if isinstance(value, datetime):
        return value.date()
    if isinstance(value, date):
        return value
    text = str(value)
    return date.fromisoformat(text[:10])


def _date_add(start: date, amount: int, unit: str) -> date:
    if unit == "day":
        return start + timedelta(days=amount)
    month = start.month - 1 + (amount if unit == "month" else amount * 12)
    year = start.year + month // 12
    month = month % 12 + 1
    day = min(start.day, _days_in_month(year, month))
    return date(year, month, day)


def _date_diff(start: date, end: date, unit: str) -> int:
    if unit == "day":
        return (end - start).days
    months = (end.year - start.year) * 12 + (end.month - start.month)
    if unit == "month":
        return months
    return months // 12


def _days_in_month(year: int, month: int) -> int:
    if month == 12:
        nxt = date(year + 1, 1, 1)
    else:
        nxt = date(year, month + 1, 1)
    return (nxt - timedelta(days=1)).day
