/*
 * Recording-rule expression DSL.
 *
 * Grammar (recursive descent):
 *
 *   expr    := term (('+' | '-') term)*
 *   term    := factor (('*' | '/') factor)*
 *   factor  := number | identifier | '(' expr ')' | '-' factor
 *   number  := digits ('.' digits)?
 *   identifier := [A-Za-z_][A-Za-z0-9_]*
 *
 * Evaluation returns `null` when the result is not a finite real number —
 * division by zero, missing binding, overflow, or NaN. Callers treat `null`
 * as "skip this bucket".
 *
 * Intentionally small: no function calls, no power operator, no comparisons.
 * A future version can grow this DSL without breaking stored rules because
 * the whole expression is one string column on the model.
 */
const MAX_DEPTH = 32;
export function tokenize(input) {
    const tokens = [];
    let i = 0;
    while (i < input.length) {
        const ch = input[i];
        if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
            i++;
            continue;
        }
        if (ch === "(") {
            tokens.push({ type: "lparen", value: ch, pos: i });
            i++;
            continue;
        }
        if (ch === ")") {
            tokens.push({ type: "rparen", value: ch, pos: i });
            i++;
            continue;
        }
        if (ch === "+" || ch === "-" || ch === "*" || ch === "/") {
            tokens.push({ type: "op", value: ch, pos: i });
            i++;
            continue;
        }
        if (ch >= "0" && ch <= "9") {
            const start = i;
            while (i < input.length && input[i] >= "0" && input[i] <= "9") {
                i++;
            }
            if (input[i] === ".") {
                i++;
                while (i < input.length && input[i] >= "0" && input[i] <= "9") {
                    i++;
                }
            }
            tokens.push({ type: "number", value: input.slice(start, i), pos: start });
            continue;
        }
        if (isIdentStart(ch)) {
            const start = i;
            i++;
            while (i < input.length && isIdentPart(input[i])) {
                i++;
            }
            tokens.push({ type: "ident", value: input.slice(start, i), pos: start });
            continue;
        }
        return {
            ok: false,
            error: `Unexpected character "${ch}" at position ${i}`,
            position: i,
        };
    }
    return tokens;
}
function isIdentStart(ch) {
    return (ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z") || ch === "_";
}
function isIdentPart(ch) {
    return isIdentStart(ch) || (ch >= "0" && ch <= "9");
}
// Recursive-descent parser.
export function parse(input) {
    var _a, _b;
    const tokenized = tokenize(input);
    if (!Array.isArray(tokenized)) {
        return tokenized;
    }
    const tokens = tokenized;
    const state = { i: 0, depth: 0 };
    const identifiers = new Set();
    function peek() {
        return tokens[state.i];
    }
    function advance() {
        return tokens[state.i++];
    }
    function parseExpr() {
        if (state.depth > MAX_DEPTH) {
            return { ok: false, error: "Expression nesting too deep" };
        }
        state.depth++;
        let left = parseTerm();
        if ("ok" in left && left.ok === false) {
            state.depth--;
            return left;
        }
        while (true) {
            const next = peek();
            if ((next === null || next === void 0 ? void 0 : next.type) === "op" && (next.value === "+" || next.value === "-")) {
                advance();
                const right = parseTerm();
                if ("ok" in right && right.ok === false) {
                    state.depth--;
                    return right;
                }
                left = {
                    type: "binary",
                    op: next.value,
                    left: left,
                    right: right,
                };
                continue;
            }
            break;
        }
        state.depth--;
        return left;
    }
    function parseTerm() {
        let left = parseFactor();
        if ("ok" in left && left.ok === false) {
            return left;
        }
        while (true) {
            const next = peek();
            if ((next === null || next === void 0 ? void 0 : next.type) === "op" && (next.value === "*" || next.value === "/")) {
                advance();
                const right = parseFactor();
                if ("ok" in right && right.ok === false) {
                    return right;
                }
                left = {
                    type: "binary",
                    op: next.value,
                    left: left,
                    right: right,
                };
                continue;
            }
            break;
        }
        return left;
    }
    function parseFactor() {
        const tok = peek();
        if (!tok) {
            return {
                ok: false,
                error: "Unexpected end of expression",
            };
        }
        if (tok.type === "op" && tok.value === "-") {
            advance();
            const operand = parseFactor();
            if ("ok" in operand && operand.ok === false) {
                return operand;
            }
            return { type: "unary", op: "-", operand: operand };
        }
        if (tok.type === "number") {
            advance();
            const n = Number(tok.value);
            if (!Number.isFinite(n)) {
                return {
                    ok: false,
                    error: `Invalid number "${tok.value}"`,
                    position: tok.pos,
                };
            }
            return { type: "num", value: n };
        }
        if (tok.type === "ident") {
            advance();
            identifiers.add(tok.value);
            return { type: "ident", name: tok.value };
        }
        if (tok.type === "lparen") {
            advance();
            const inner = parseExpr();
            if ("ok" in inner && inner.ok === false) {
                return inner;
            }
            const close = advance();
            if (!close || close.type !== "rparen") {
                return Object.assign({ ok: false, error: "Expected ')'" }, (close ? { position: close.pos } : {}));
            }
            return inner;
        }
        return {
            ok: false,
            error: `Unexpected token "${tok.value}"`,
            position: tok.pos,
        };
    }
    const ast = parseExpr();
    if ("ok" in ast && ast.ok === false) {
        return ast;
    }
    if (state.i !== tokens.length) {
        const stray = tokens[state.i];
        return Object.assign({ ok: false, error: `Unexpected token "${(_a = stray === null || stray === void 0 ? void 0 : stray.value) !== null && _a !== void 0 ? _a : ""}" at position ${(_b = stray === null || stray === void 0 ? void 0 : stray.pos) !== null && _b !== void 0 ? _b : -1}` }, (stray ? { position: stray.pos } : {}));
    }
    return {
        ok: true,
        ast: ast,
        identifiers: Array.from(identifiers),
    };
}
/*
 * Evaluate an AST against the given variable bindings. Returns null when
 * the result is not a finite real number (division by zero, missing binding,
 * NaN, Infinity). The caller skips any output row whose result is null.
 */
export function evaluate(node, bindings) {
    switch (node.type) {
        case "num":
            return Number.isFinite(node.value) ? node.value : null;
        case "ident": {
            const v = bindings[node.name];
            if (typeof v !== "number" || !Number.isFinite(v)) {
                return null;
            }
            return v;
        }
        case "unary": {
            const inner = evaluate(node.operand, bindings);
            if (inner === null) {
                return null;
            }
            return -inner;
        }
        case "binary": {
            const l = evaluate(node.left, bindings);
            if (l === null) {
                return null;
            }
            const r = evaluate(node.right, bindings);
            if (r === null) {
                return null;
            }
            let out;
            switch (node.op) {
                case "+":
                    out = l + r;
                    break;
                case "-":
                    out = l - r;
                    break;
                case "*":
                    out = l * r;
                    break;
                case "/":
                    if (r === 0) {
                        return null;
                    }
                    out = l / r;
                    break;
            }
            return Number.isFinite(out) ? out : null;
        }
    }
}
/*
 * Parse + check that every identifier in the expression is in `allowed`.
 * Used at rule-save time so the form can fail fast before the cron runs.
 */
export function validate(expression, allowed) {
    const parsed = parse(expression);
    if (!parsed.ok) {
        return Object.assign({ ok: false, error: parsed.error }, (typeof parsed.position === "number"
            ? { position: parsed.position }
            : {}));
    }
    const allowedSet = new Set(allowed);
    const unknown = parsed.identifiers.filter((id) => {
        return !allowedSet.has(id);
    });
    if (unknown.length > 0) {
        return {
            ok: false,
            error: `Unknown identifier(s): ${unknown.join(", ")}`,
            unknownIdentifiers: unknown,
        };
    }
    return { ok: true };
}
//# sourceMappingURL=RecordingRuleExpression.js.map