/**
 * engine/safe-expr.js — Hand-rolled recursive-descent expression evaluator.
 *
 * Phase 3.1 of feat/plan-resilient-watches (P-w6f4a3e8). Used by watch action
 * guards (P-w7c5d8b3) and predicate-based watches (Phase 2.x) to evaluate
 * user-supplied boolean expressions against a runtime context such as
 * `{ target, newState, previousState, condition, ... }`.
 *
 * Hard rules — DO NOT REGRESS:
 *   1. Never use `eval()` or `new Function()`. Both are RCE vectors. The unit
 *      test suite greps the source of this module to enforce this.
 *   2. Never throw out of `evaluate()`. Watches must keep ticking even if a
 *      user types a malformed predicate. On any parse or evaluation error,
 *      log a warn tagged `[safe-expr]` and return `false`.
 *   3. Identifier resolution does an own-property walk only. Reaching
 *      `Object.prototype` (`toString`, `constructor`, `__proto__`, ...) is a
 *      sandbox-escape vector for an attacker who controls a watch expression.
 *
 * Grammar (lowest to highest precedence):
 *   expression  -> or
 *   or          -> and ('||' and)*
 *   and         -> equality ('&&' equality)*
 *   equality    -> comparison (('==='|'=='|'!=='|'!=') comparison)*
 *   comparison  -> unary (('<='|'<'|'>='|'>') unary)*
 *   unary       -> '!' unary | primary
 *   primary     -> NUMBER | STRING | true | false | null | undefined
 *                | IDENT ('.' IDENT)* | '(' expression ')'
 *
 * Top-level result is coerced via `Boolean(...)` so callers always see a
 * boolean (the canonical guard contract: truthy = run, falsy = skip).
 */

'use strict';

// ── Lexer ────────────────────────────────────────────────────────────────────

const TOK = {
  NUMBER: 'NUMBER',
  STRING: 'STRING',
  IDENT: 'IDENT',
  BOOLEAN: 'BOOLEAN',
  NULL: 'NULL',
  UNDEFINED: 'UNDEFINED',
  DOT: 'DOT',
  LPAREN: 'LPAREN',
  RPAREN: 'RPAREN',
  EQ_STRICT: 'EQ_STRICT',
  NEQ_STRICT: 'NEQ_STRICT',
  EQ_LOOSE: 'EQ_LOOSE',
  NEQ_LOOSE: 'NEQ_LOOSE',
  LT: 'LT',
  LTE: 'LTE',
  GT: 'GT',
  GTE: 'GTE',
  AND: 'AND',
  OR: 'OR',
  NOT: 'NOT',
  MINUS: 'MINUS',
  EOF: 'EOF',
};

function _isDigit(c) { return c >= '0' && c <= '9'; }
function _isIdentStart(c) {
  return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_' || c === '$';
}
function _isIdentBody(c) { return _isIdentStart(c) || _isDigit(c); }

function tokenize(src) {
  const tokens = [];
  const len = src.length;
  let i = 0;

  while (i < len) {
    const c = src[i];

    // Whitespace
    if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; }

    // String literals (single or double quoted, with basic backslash escapes)
    if (c === '"' || c === "'") {
      const quote = c;
      let j = i + 1;
      let val = '';
      while (j < len && src[j] !== quote) {
        if (src[j] === '\\' && j + 1 < len) {
          const next = src[j + 1];
          if (next === 'n') val += '\n';
          else if (next === 't') val += '\t';
          else if (next === 'r') val += '\r';
          else if (next === '\\') val += '\\';
          else if (next === quote) val += quote;
          else val += next;
          j += 2;
        } else {
          val += src[j];
          j++;
        }
      }
      if (j >= len) throw new Error(`Unterminated string literal starting at ${i}`);
      tokens.push({ type: TOK.STRING, value: val });
      i = j + 1;
      continue;
    }

    // Number literals (positive only — unary minus handled in parsePrimary)
    if (_isDigit(c)) {
      let j = i;
      let sawDot = false;
      while (j < len) {
        const ch = src[j];
        if (_isDigit(ch)) { j++; continue; }
        if (ch === '.' && !sawDot) { sawDot = true; j++; continue; }
        break;
      }
      const numStr = src.slice(i, j);
      const num = Number(numStr);
      if (Number.isNaN(num)) throw new Error(`Invalid number "${numStr}" at ${i}`);
      tokens.push({ type: TOK.NUMBER, value: num });
      i = j;
      continue;
    }

    // Identifiers and keywords
    if (_isIdentStart(c)) {
      let j = i;
      while (j < len && _isIdentBody(src[j])) j++;
      const word = src.slice(i, j);
      if (word === 'true') tokens.push({ type: TOK.BOOLEAN, value: true });
      else if (word === 'false') tokens.push({ type: TOK.BOOLEAN, value: false });
      else if (word === 'null') tokens.push({ type: TOK.NULL, value: null });
      else if (word === 'undefined') tokens.push({ type: TOK.UNDEFINED, value: undefined });
      else tokens.push({ type: TOK.IDENT, value: word });
      i = j;
      continue;
    }

    // Multi-char operators (longest match first)
    if (c === '=' && src[i + 1] === '=' && src[i + 2] === '=') { tokens.push({ type: TOK.EQ_STRICT }); i += 3; continue; }
    if (c === '!' && src[i + 1] === '=' && src[i + 2] === '=') { tokens.push({ type: TOK.NEQ_STRICT }); i += 3; continue; }
    if (c === '=' && src[i + 1] === '=') { tokens.push({ type: TOK.EQ_LOOSE }); i += 2; continue; }
    if (c === '!' && src[i + 1] === '=') { tokens.push({ type: TOK.NEQ_LOOSE }); i += 2; continue; }
    if (c === '<' && src[i + 1] === '=') { tokens.push({ type: TOK.LTE }); i += 2; continue; }
    if (c === '>' && src[i + 1] === '=') { tokens.push({ type: TOK.GTE }); i += 2; continue; }
    if (c === '&' && src[i + 1] === '&') { tokens.push({ type: TOK.AND }); i += 2; continue; }
    if (c === '|' && src[i + 1] === '|') { tokens.push({ type: TOK.OR }); i += 2; continue; }

    // Single-char operators
    if (c === '<') { tokens.push({ type: TOK.LT }); i++; continue; }
    if (c === '>') { tokens.push({ type: TOK.GT }); i++; continue; }
    if (c === '!') { tokens.push({ type: TOK.NOT }); i++; continue; }
    if (c === '(') { tokens.push({ type: TOK.LPAREN }); i++; continue; }
    if (c === ')') { tokens.push({ type: TOK.RPAREN }); i++; continue; }
    if (c === '.') { tokens.push({ type: TOK.DOT }); i++; continue; }
    if (c === '-') { tokens.push({ type: TOK.MINUS }); i++; continue; }

    throw new Error(`Unexpected character '${c}' at ${i}`);
  }

  tokens.push({ type: TOK.EOF });
  return tokens;
}

// ── Parser (recursive descent) ───────────────────────────────────────────────

function parse(tokens) {
  let pos = 0;
  const peek = () => tokens[pos];
  const match = (...types) => types.includes(tokens[pos].type);

  function parseOr() {
    let left = parseAnd();
    while (match(TOK.OR)) {
      pos++;
      const right = parseAnd();
      left = { kind: 'logical', op: '||', left, right };
    }
    return left;
  }

  function parseAnd() {
    let left = parseEquality();
    while (match(TOK.AND)) {
      pos++;
      const right = parseEquality();
      left = { kind: 'logical', op: '&&', left, right };
    }
    return left;
  }

  function parseEquality() {
    let left = parseComparison();
    while (match(TOK.EQ_STRICT, TOK.NEQ_STRICT, TOK.EQ_LOOSE, TOK.NEQ_LOOSE)) {
      const op = tokens[pos++].type;
      const right = parseComparison();
      left = { kind: 'binary', op, left, right };
    }
    return left;
  }

  function parseComparison() {
    let left = parseUnary();
    while (match(TOK.LT, TOK.LTE, TOK.GT, TOK.GTE)) {
      const op = tokens[pos++].type;
      const right = parseUnary();
      left = { kind: 'binary', op, left, right };
    }
    return left;
  }

  function parseUnary() {
    if (match(TOK.NOT)) {
      pos++;
      return { kind: 'not', expr: parseUnary() };
    }
    return parsePrimary();
  }

  function parsePrimary() {
    const tok = peek();

    // Unary minus is only supported as a prefix on a numeric literal — we have
    // no arithmetic operators, so '-' anywhere else is a parse error.
    if (tok.type === TOK.MINUS) {
      pos++;
      const next = peek();
      if (next.type !== TOK.NUMBER) throw new Error(`Expected number after '-'`);
      pos++;
      return { kind: 'literal', value: -next.value };
    }

    if (
      tok.type === TOK.NUMBER ||
      tok.type === TOK.STRING ||
      tok.type === TOK.BOOLEAN ||
      tok.type === TOK.NULL ||
      tok.type === TOK.UNDEFINED
    ) {
      pos++;
      return { kind: 'literal', value: tok.value };
    }

    if (tok.type === TOK.IDENT) {
      pos++;
      const segments = [tok.value];
      while (match(TOK.DOT)) {
        pos++;
        const next = peek();
        if (next.type !== TOK.IDENT) throw new Error(`Expected identifier after '.'`);
        pos++;
        segments.push(next.value);
      }
      return { kind: 'path', segments };
    }

    if (tok.type === TOK.LPAREN) {
      pos++;
      const expr = parseOr();
      if (peek().type !== TOK.RPAREN) throw new Error(`Expected ')'`);
      pos++;
      return expr;
    }

    throw new Error(`Unexpected token ${tok.type}`);
  }

  const ast = parseOr();
  if (peek().type !== TOK.EOF) {
    throw new Error(`Unexpected trailing token ${peek().type}`);
  }
  return ast;
}

// ── Evaluator ────────────────────────────────────────────────────────────────

function _resolvePath(segments, ctx) {
  let cur = ctx;
  for (const seg of segments) {
    if (cur == null) return undefined;
    // Own-property walk only. Reaching Object.prototype (toString,
    // constructor, __proto__, ...) is a sandbox-escape vector for any
    // attacker who controls the expression string.
    if (!Object.prototype.hasOwnProperty.call(cur, seg)) return undefined;
    cur = cur[seg];
  }
  return cur;
}

function _evalNode(node, ctx) {
  switch (node.kind) {
    case 'literal':
      return node.value;
    case 'path':
      return _resolvePath(node.segments, ctx);
    case 'not':
      return !_evalNode(node.expr, ctx);
    case 'logical': {
      const left = _evalNode(node.left, ctx);
      if (node.op === '&&') return left ? _evalNode(node.right, ctx) : left;
      if (node.op === '||') return left ? left : _evalNode(node.right, ctx);
      return false;
    }
    case 'binary': {
      const l = _evalNode(node.left, ctx);
      const r = _evalNode(node.right, ctx);
      switch (node.op) {
        case TOK.EQ_STRICT: return l === r;
        case TOK.NEQ_STRICT: return l !== r;
        case TOK.EQ_LOOSE: return l == r;   // intentional loose equality
        case TOK.NEQ_LOOSE: return l != r;  // intentional loose equality
        case TOK.LT: return l < r;
        case TOK.LTE: return l <= r;
        case TOK.GT: return l > r;
        case TOK.GTE: return l >= r;
        default: return false;
      }
    }
    default:
      return false;
  }
}

// ── Public API ───────────────────────────────────────────────────────────────

function evaluate(exprString, context) {
  if (typeof exprString !== 'string' || exprString.trim() === '') {
    console.warn(`[safe-expr] invalid expression (expected non-empty string): ${JSON.stringify(exprString)}`);
    return false;
  }

  let ast;
  try {
    const tokens = tokenize(exprString);
    ast = parse(tokens);
  } catch (err) {
    console.warn(`[safe-expr] parse error in "${exprString}": ${err.message}`);
    return false;
  }

  let result;
  try {
    result = _evalNode(ast, context == null ? {} : context);
  } catch (err) {
    console.warn(`[safe-expr] evaluation error in "${exprString}": ${err.message}`);
    return false;
  }

  return Boolean(result);
}

module.exports = { evaluate };
