"""FlowFabric expressions — the server side of the one small, safe language
used for formula fields, "show when" conditions, validation and action
conditions. It mirrors frontend/src/lib/expr.ts exactly; both run the same
cases (frontend/src/lib/expr.cases.json, see tests/test_expr.py), so a
condition decides the same way in the browser and here.

Syntax and semantics are documented at the top of expr.ts. In short:
{Field name} / dotted.paths (mapping over lists), literals, + - * / %,
comparisons, and/or/not, `in`, `c ? a : b`, and a fixed set of functions.
Blank propagates through arithmetic; "+" with text joins text; evaluation
never raises on data (anything uncomputable is None); only syntax errors
raise ExprError. No Python object ever reaches the evaluator — it walks its
own AST over plain JSON values — so tenant-authored expressions can't reach
attributes, imports or calls."""

from __future__ import annotations

import calendar
import math
import re
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from functools import lru_cache
from typing import Any


class ExprError(ValueError):
    pass


_OPS = ["==", "!=", "<>", "<=", ">=", "&&", "||", "+", "-", "*", "/", "%", "(", ")", ",", "?", ":", "[", "]", "!", "<", ">", "=", "."]
_NUM_TOKEN = re.compile(r"(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?")
_ID_TOKEN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")


def _tokenize(src: str) -> list[tuple[str, str, int]]:
    out: list[tuple[str, str, int]] = []
    i = 0
    while i < len(src):
        c = src[i]
        if c.isspace():
            i += 1
            continue
        if c.isdigit() or (c == "." and i + 1 < len(src) and src[i + 1].isdigit()):
            m = _NUM_TOKEN.match(src, i)
            out.append(("num", m.group(0), i))
            i = m.end()
            continue
        if c in ("'", '"'):
            j, s = i + 1, []
            while j < len(src) and src[j] != c:
                if src[j] == "\\" and j + 1 < len(src):
                    n = src[j + 1]
                    s.append("\n" if n == "n" else "\t" if n == "t" else n)
                    j += 2
                else:
                    s.append(src[j])
                    j += 1
            if j >= len(src):
                raise ExprError(f"Unclosed text starting at {i + 1}")
            out.append(("str", "".join(s), i))
            i = j + 1
            continue
        if c == "{":
            j = src.find("}", i)
            if j < 0:
                raise ExprError(f"Unclosed {{ at {i + 1}")
            name = src[i + 1 : j].strip()
            if not name:
                raise ExprError(f"Empty {{}} at {i + 1}")
            out.append(("field", name, i))
            i = j + 1
            continue
        if c.isalpha() or c == "_":
            m = _ID_TOKEN.match(src, i)
            out.append(("id", m.group(0), i))
            i = m.end()
            continue
        op = next((o for o in _OPS if src.startswith(o, i)), None)
        if op is None:
            raise ExprError(f'Unexpected "{c}" at {i + 1}')
        out.append(("op", op, i))
        i += len(op)
    out.append(("end", "", len(src)))
    return out


FUNCS: dict[str, tuple[int, int]] = {
    "IF": (2, 3), "AND": (1, -1), "OR": (1, -1), "NOT": (1, 1), "ISBLANK": (1, 1), "COALESCE": (1, -1),
    "LEN": (1, 1), "UPPER": (1, 1), "LOWER": (1, 1), "TRIM": (1, 1), "CONCAT": (0, -1), "CONTAINS": (2, 2),
    "STARTSWITH": (2, 2), "ENDSWITH": (2, 2), "ROUND": (1, 2), "ABS": (1, 1), "FLOOR": (1, 1), "CEIL": (1, 1),
    "MIN": (1, -1), "MAX": (1, -1), "SUM": (0, -1), "AVG": (1, -1), "COUNT": (0, -1), "NUMBER": (1, 1), "FLOAT": (1, 1),
    "INT": (1, 1), "TEXT": (1, 1), "TODAY": (0, 0), "NOW": (0, 0), "DATEADD": (3, 3), "DATEDIFF": (2, 3),
    "YEAR": (1, 1), "MONTH": (1, 1), "DAY": (1, 1), "HASROLE": (1, 1), "IN": (2, -1),
    "PAD": (2, 3), "SEQ": (1, 2),
}

# SEQ(name, digits) — the next number of a running sequence, given out by
# the server when a form is submitted (app/routers/form_submissions.py):
# the context's SEQ_SCOPE holds the numbers given out, by name; a name it
# lacks is recorded in SEQ_WANTED (when that's a list) and the value is blank.
SEQ_SCOPE = "__seq__"
SEQ_WANTED = "__seq_wanted__"
_SEQ_CALL = re.compile(r"\bSEQ\s*\(", re.IGNORECASE)


def uses_sequence(src: str | None) -> bool:
    return bool(src and _SEQ_CALL.search(src))
_KEYWORD_OPS = {"and": "&&", "or": "||", "not": "!", "in": "in"}


class _Parser:
    def __init__(self, toks):
        self.toks, self.i = toks, 0

    def peek(self):
        return self.toks[self.i]

    def next(self):
        t = self.toks[self.i]
        self.i += 1
        return t

    def is_op(self, v: str) -> bool:
        k, val, _ = self.peek()
        return (k == "op" and val == v) or (k == "id" and _KEYWORD_OPS.get(val.lower()) == v)

    def expect(self, v: str) -> None:
        if not self.is_op(v):
            raise ExprError(f'Expected "{v}" at {self.peek()[2] + 1}')
        self.next()

    def parse(self):
        n = self.ternary()
        if self.peek()[0] != "end":
            raise ExprError(f'Unexpected "{self.peek()[1]}" at {self.peek()[2] + 1}')
        return n

    def ternary(self):
        c = self.or_()
        if self.is_op("?"):
            self.next()
            a = self.ternary()
            self.expect(":")
            return ("cond", c, a, self.ternary())
        return c

    def or_(self):
        a = self.and_()
        while self.is_op("||"):
            self.next()
            a = ("bin", "||", a, self.and_())
        return a

    def and_(self):
        a = self.not_()
        while self.is_op("&&"):
            self.next()
            a = ("bin", "&&", a, self.not_())
        return a

    def not_(self):
        if self.is_op("!"):
            self.next()
            return ("un", "!", self.not_())
        return self.compare()

    def compare(self):
        a = self.additive()
        while True:
            k, v, _ = self.peek()
            op = v if k == "op" and v in ("==", "!=", "<>", "<", "<=", ">", ">=", "=") else "in" if k == "id" and v.lower() == "in" else None
            if op is None:
                return a
            self.next()
            norm = "==" if op == "=" else "!=" if op == "<>" else op
            a = ("bin", norm, a, self.additive())

    def additive(self):
        a = self.multiplicative()
        while self.is_op("+") or self.is_op("-"):
            op = self.next()[1]
            a = ("bin", op, a, self.multiplicative())
        return a

    def multiplicative(self):
        a = self.unary()
        while self.is_op("*") or self.is_op("/") or self.is_op("%"):
            op = self.next()[1]
            a = ("bin", op, a, self.unary())
        return a

    def unary(self):
        if self.is_op("-"):
            self.next()
            return ("un", "-", self.unary())
        if self.is_op("+"):
            self.next()
            return self.unary()
        return self.primary()

    def primary(self):
        k, v, pos = self.next()
        if k == "num":
            return ("lit", float(v))
        if k == "str":
            return ("lit", v)
        if k == "field":
            return self.member([v])
        if k == "op" and v == "(":
            n = self.ternary()
            self.expect(")")
            return n
        if k == "op" and v == "[":
            items = []
            if not self.is_op("]"):
                items.append(self.ternary())
                while self.is_op(","):
                    self.next()
                    items.append(self.ternary())
            self.expect("]")
            return ("list", items)
        if k == "id":
            lower = v.lower()
            if lower == "true":
                return ("lit", True)
            if lower == "false":
                return ("lit", False)
            if lower == "null":
                return ("lit", None)
            if self.is_op("("):
                fn = v.upper()
                arity = FUNCS.get(fn)
                if arity is None:
                    raise ExprError(f"Unknown function {v}()")
                self.next()
                args = []
                if not self.is_op(")"):
                    args.append(self.ternary())
                    while self.is_op(","):
                        self.next()
                        args.append(self.ternary())
                self.expect(")")
                lo, hi = arity
                if len(args) < lo or (hi >= 0 and len(args) > hi):
                    want = f"at least {lo}" if hi < 0 else str(lo) if lo == hi else f"{lo}–{hi}"
                    raise ExprError(f"{fn}() takes {want} argument(s)")
                return ("call", fn, args)
            return self.member([v])
        raise ExprError("The expression ends too early" if k == "end" else f'Unexpected "{v}" at {pos + 1}')

    def member(self, path: list[str]):
        while self.is_op("."):
            self.next()
            k, v, pos = self.next()
            if k not in ("id", "field"):
                raise ExprError(f'Expected a name after "." at {pos + 1}')
            path.append(v)
        return ("ref", path)


@lru_cache(maxsize=1024)
def _parse(src: str):
    return _Parser(_tokenize(src)).parse()


def check_expression(src: str) -> None:
    """Raises ExprError when `src` isn't a valid expression."""
    _parse(src)


def expression_error(src: str) -> str | None:
    try:
        _parse(src)
        return None
    except ExprError as exc:
        return str(exc)


def expression_references(src: str) -> list[str]:
    """Root names an expression reads ({a} * b.c -> ["a", "b"])."""
    out: list[str] = []

    def walk(n):
        kind = n[0]
        if kind == "ref" and n[1][0] not in out:
            out.append(n[1][0])
        elif kind == "list":
            for i in n[1]:
                walk(i)
        elif kind == "un":
            walk(n[2])
        elif kind == "bin":
            walk(n[2])
            walk(n[3])
        elif kind == "cond":
            walk(n[1])
            walk(n[2])
            walk(n[3])
        elif kind == "call":
            for a in n[2]:
                walk(a)

    walk(_parse(src))
    return out


# ---- Values ------------------------------------------------------------------------------------

_NUM_RE = re.compile(r"^\s*[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?\s*$")


def is_blank(v: Any) -> bool:
    return v is None or v == "" or (isinstance(v, list) and not v)


def _num(v: Any) -> float | None:
    if isinstance(v, bool):
        return 1.0 if v else 0.0
    if isinstance(v, (int, float)):
        return float(v) if math.isfinite(v) else None
    if isinstance(v, str) and _NUM_RE.match(v):
        return float(v)
    return None


def _fmt_num(x: float) -> str:
    """JavaScript's Number#toString, so TEXT() and "+" print numbers the
    same way in both implementations."""
    if x == 0:
        return "0"
    if x < 0:
        return "-" + _fmt_num(-x)
    d = Decimal(repr(float(x))).normalize()
    sign, digits, exp = d.as_tuple()
    s = "".join(map(str, digits))
    k = len(s)
    n = k + exp
    if k <= n <= 21:
        return s + "0" * (n - k)
    if 0 < n <= 21:
        return s[:n] + "." + s[n:]
    if -6 < n <= 0:
        return "0." + "0" * (-n) + s
    e = n - 1
    mant = s if k == 1 else s[0] + "." + s[1:]
    return f"{mant}e{'+' if e >= 0 else '-'}{abs(e)}"


def to_text(v: Any) -> str:
    if v is None:
        return ""
    if isinstance(v, str):
        return v
    if isinstance(v, bool):
        return "true" if v else "false"
    if isinstance(v, (int, float)):
        return _fmt_num(float(v))
    if isinstance(v, list):
        return ", ".join(to_text(x) for x in v)
    import json

    return json.dumps(v, separators=(",", ":"), ensure_ascii=False)


def truthy(v: Any) -> bool:
    if is_blank(v):
        return False
    if isinstance(v, bool):
        return v
    if isinstance(v, (int, float)):
        return v != 0
    return True


def _equal(a: Any, b: Any) -> bool:
    if is_blank(a) or is_blank(b):
        return is_blank(a) and is_blank(b)
    na, nb = _num(a), _num(b)
    if na is not None and nb is not None and not (isinstance(a, str) and isinstance(b, str)):
        return na == nb
    if isinstance(a, bool) or isinstance(b, bool):
        return truthy(a) == truthy(b)
    return to_text(a) == to_text(b)


def _compare(a: Any, b: Any) -> float | None:
    if is_blank(a) or is_blank(b):
        return None
    na, nb = _num(a), _num(b)
    if na is not None and nb is not None:
        return na - nb
    ta, tb = to_text(a), to_text(b)
    return -1 if ta < tb else 1 if ta > tb else 0


def _flatten(values: list) -> list:
    out = []
    for v in values:
        out.extend(_flatten(v) if isinstance(v, list) else [v])
    return out


def _numbers(values: list) -> list[float]:
    return [n for n in (_num(v) for v in _flatten(values)) if n is not None]


def _round_half_away(x: float, digits: int) -> float:
    f = math.pow(10, digits)
    return math.copysign(1, x) * math.floor(abs(x) * f + 0.5) / f if x != 0 else 0.0


# ---- Dates (calendar arithmetic in UTC) ----------------------------------------------------------

_DATE_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$")
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
_UNIT_MS = {"days": 86400000, "weeks": 604800000, "hours": 3600000, "minutes": 60000, "seconds": 1000}


def _parse_date(v: Any) -> tuple[int, bool] | None:
    if not isinstance(v, str):
        return None
    m = _DATE_RE.match(v.strip())
    if not m:
        return None
    y, mo, d, h, mi, s, tz = m.groups()
    try:
        dt = datetime(int(y), int(mo), int(d), int(h or 0), int(mi or 0), int(s or 0), tzinfo=timezone.utc)
    except ValueError:
        return None
    ms = int((dt - _EPOCH).total_seconds() * 1000)
    if tz and tz != "Z":
        sign = -1 if tz[0] == "-" else 1
        digits = tz[1:].replace(":", "")
        ms -= sign * (int(digits[:2]) * 60 + int(digits[2:4] or 0)) * 60000
    return ms, h is None


def _dt(ms: int) -> datetime:
    return _EPOCH + timedelta(milliseconds=ms)


def _fmt_date(ms: int, date_only: bool) -> str:
    d = _dt(ms)
    text = f"{d.year:04d}-{d.month:02d}-{d.day:02d}"
    return text if date_only else f"{text}T{d.hour:02d}:{d.minute:02d}:{d.second:02d}Z"


def _add_months(ms: int, months: int) -> int:
    d = _dt(ms)
    total = d.year * 12 + (d.month - 1) + months
    y, mo = divmod(total, 12)
    last = calendar.monthrange(y, mo + 1)[1]
    nd = datetime(y, mo + 1, min(d.day, last), d.hour, d.minute, d.second, tzinfo=timezone.utc)
    return int((nd - _EPOCH).total_seconds() * 1000)


def _unit(v: Any) -> str:
    u = to_text(v).lower().strip()
    return u if u.endswith("s") else u + "s"


def _date_add(d: Any, n: Any, unit: Any) -> str | None:
    p, k = _parse_date(d), _num(n)
    if p is None or k is None:
        return None
    ms, date_only = p
    u, whole = _unit(unit), int(math.trunc(k))
    if u == "months":
        return _fmt_date(_add_months(ms, whole), date_only)
    if u == "years":
        return _fmt_date(_add_months(ms, whole * 12), date_only)
    if u not in _UNIT_MS:
        return None
    return _fmt_date(ms + whole * _UNIT_MS[u], date_only and u in ("days", "weeks"))


def _date_diff(a: Any, b: Any, unit: Any) -> float | None:
    pa, pb = _parse_date(a), _parse_date(b)
    if pa is None or pb is None:
        return None
    u = _unit(unit if unit is not None else "days")
    if u in ("months", "years"):
        da, db = _dt(pa[0]), _dt(pb[0])
        months = (db.year - da.year) * 12 + (db.month - da.month)
        rest_a = pa[0] - int((datetime(da.year, da.month, 1, tzinfo=timezone.utc) - _EPOCH).total_seconds() * 1000)
        rest_b = pb[0] - int((datetime(db.year, db.month, 1, tzinfo=timezone.utc) - _EPOCH).total_seconds() * 1000)
        if months > 0 and rest_b < rest_a:
            months -= 1
        if months < 0 and rest_b > rest_a:
            months += 1
        return float(int(months / 12) if u == "years" else months)
    if u not in _UNIT_MS:
        return None
    return float(int((pb[0] - pa[0]) / _UNIT_MS[u]))


def _date_part(v: Any, part: str) -> float | None:
    p = _parse_date(v)
    if p is None:
        return None
    d = _dt(p[0])
    return float(d.year if part == "y" else d.month if part == "m" else d.day)


# ---- Evaluation --------------------------------------------------------------------------------


def _lookup(ctx: dict, path: list[str]) -> Any:
    cur: Any = ctx
    for key in path:
        if isinstance(cur, list):
            cur = [item.get(key) if isinstance(item, dict) else None for item in cur]
        elif isinstance(cur, dict):
            cur = cur.get(key)
        else:
            return None
        if cur is None:
            return None
    return cur


def _call(fn: str, args: list, ctx: dict) -> Any:
    def v(i: int) -> Any:
        return _ev(args[i], ctx)

    def all_() -> list:
        return [_ev(a, ctx) for a in args]

    if fn == "IF":
        return v(1) if truthy(v(0)) else (v(2) if len(args) > 2 else None)
    if fn == "AND":
        return all(truthy(_ev(a, ctx)) for a in args)
    if fn == "OR":
        return any(truthy(_ev(a, ctx)) for a in args)
    if fn == "NOT":
        return not truthy(v(0))
    if fn == "ISBLANK":
        return is_blank(v(0))
    if fn == "COALESCE":
        for a in args:
            x = _ev(a, ctx)
            if not is_blank(x):
                return x
        return None
    if fn == "LEN":
        x = v(0)
        return float(len(x)) if isinstance(x, list) else 0.0 if is_blank(x) else float(len(to_text(x)))
    if fn == "UPPER":
        return to_text(v(0)).upper()
    if fn == "LOWER":
        return to_text(v(0)).lower()
    if fn == "TRIM":
        return to_text(v(0)).strip()
    if fn == "CONCAT":
        return "".join(to_text(x) for x in all_())
    if fn == "CONTAINS":
        h, n = v(0), v(1)
        if isinstance(h, list):
            return any(_equal(x, n) for x in h)
        return to_text(n).lower() in to_text(h).lower()
    if fn == "STARTSWITH":
        return to_text(v(0)).lower().startswith(to_text(v(1)).lower())
    if fn == "ENDSWITH":
        return to_text(v(0)).lower().endswith(to_text(v(1)).lower())
    if fn == "ROUND":
        x = _num(v(0))
        d = _num(v(1)) if len(args) > 1 else 0.0
        return None if x is None or d is None else _round_half_away(x, int(math.trunc(d)))
    if fn in ("ABS", "FLOOR", "CEIL"):
        x = _num(v(0))
        if x is None:
            return None
        return abs(x) if fn == "ABS" else float(math.floor(x)) if fn == "FLOOR" else float(math.ceil(x))
    if fn in ("MIN", "MAX"):
        ns = _numbers(all_())
        return (min(ns) if fn == "MIN" else max(ns)) if ns else None
    if fn == "SUM":
        return _sum(_numbers(all_()))
    if fn == "AVG":
        ns = _numbers(all_())
        return _sum(ns) / len(ns) if ns else None
    if fn == "COUNT":
        return float(len([x for x in _flatten(all_()) if not is_blank(x)]))
    if fn in ("NUMBER", "FLOAT"):
        return _num(v(0))
    if fn == "INT":
        x = _num(v(0))
        return None if x is None else float(math.trunc(x))
    if fn == "TEXT":
        return to_text(v(0))
    if fn == "TODAY":
        return date.today().isoformat()
    if fn == "NOW":
        return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    if fn == "DATEADD":
        return _date_add(v(0), v(1), v(2))
    if fn == "DATEDIFF":
        return _date_diff(v(0), v(1), v(2) if len(args) > 2 else "days")
    if fn in ("YEAR", "MONTH", "DAY"):
        return _date_part(v(0), {"YEAR": "y", "MONTH": "m", "DAY": "d"}[fn])
    if fn == "HASROLE":
        roles = _lookup(ctx, ["user", "roles"])
        want = to_text(v(0)).lower()
        return isinstance(roles, list) and any(to_text(r).lower() == want for r in roles)
    if fn == "IN":
        x = v(0)
        return any(_equal(x, y) for y in _flatten([_ev(a, ctx) for a in args[1:]]))
    if fn == "PAD":
        return _pad(v(0), v(1), v(2) if len(args) > 2 else "0")
    if fn == "SEQ":
        name = to_text(v(0)).strip()
        if not name:
            return None
        given = ctx.get(SEQ_SCOPE) if isinstance(ctx, dict) else None
        if isinstance(given, dict) and name in given:
            return _pad(given[name], v(1) if len(args) > 1 else 1, "0")
        wanted = ctx.get(SEQ_WANTED) if isinstance(ctx, dict) else None
        if isinstance(wanted, list) and name not in wanted:
            wanted.append(name)
        return None
    return None


def _pad(value: Any, width: Any, fill: Any) -> Any:
    """Text left-padded to `width` with `fill` (one character, default "0")."""
    if is_blank(value):
        return None
    w = _num(width)
    text = to_text(value)
    ch = (to_text(fill) or "0")[0]
    return text if w is None else text.rjust(min(int(w), 50), ch)


def _sum(ns: list[float]) -> float:
    total = 0.0
    for n in ns:  # left to right, like the browser's reduce — not fsum
        total += n
    return total


def _ev(n, ctx: dict) -> Any:
    kind = n[0]
    if kind == "lit":
        return n[1]
    if kind == "ref":
        return _lookup(ctx, n[1])
    if kind == "list":
        return [_ev(i, ctx) for i in n[1]]
    if kind == "cond":
        return _ev(n[2], ctx) if truthy(_ev(n[1], ctx)) else _ev(n[3], ctx)
    if kind == "call":
        return _call(n[1], n[2], ctx)
    if kind == "un":
        a = _ev(n[2], ctx)
        if n[1] == "!":
            return not truthy(a)
        x = _num(a)
        return None if x is None else -x
    op = n[1]
    if op == "&&":
        return truthy(_ev(n[2], ctx)) and truthy(_ev(n[3], ctx))
    if op == "||":
        return truthy(_ev(n[2], ctx)) or truthy(_ev(n[3], ctx))
    a, b = _ev(n[2], ctx), _ev(n[3], ctx)
    if op == "==":
        return _equal(a, b)
    if op == "!=":
        return not _equal(a, b)
    if op in ("<", "<=", ">", ">="):
        c = _compare(a, b)
        if c is None:
            return False
        return c < 0 if op == "<" else c <= 0 if op == "<=" else c > 0 if op == ">" else c >= 0
    if op == "in":
        if isinstance(b, list):
            return any(_equal(a, y) for y in b)
        return not is_blank(a) and to_text(a).lower() in to_text(b).lower()
    if op == "+" and (isinstance(a, str) or isinstance(b, str)):
        return to_text(a) + to_text(b)
    x, y = _num(a), _num(b)
    if x is None or y is None:
        return None
    if op == "+":
        r = x + y
    elif op == "-":
        r = x - y
    elif op == "*":
        r = x * y
    elif op == "/":
        if y == 0:
            return None
        r = x / y
    else:
        if y == 0:
            return None
        r = math.fmod(x, y)
    return r if math.isfinite(r) else None


def evaluate(src: str | None, ctx: dict) -> Any:
    """The expression's value, or None when it can't be computed (including
    a syntax error). Whole-number results come back as int."""
    if not src or not src.strip():
        return None
    try:
        node = _parse(src)
    except ExprError:
        return None
    return _clean(_ev(node, ctx))


def evaluate_condition(src: str | None, ctx: dict, when_blank: bool = True) -> bool:
    if not src or not src.strip():
        return when_blank
    return truthy(evaluate(src, ctx))


def _clean(v: Any) -> Any:
    if isinstance(v, float) and v.is_integer() and abs(v) < 2**53:
        return int(v)
    if isinstance(v, list):
        return [_clean(x) for x in v]
    return v
