/**
 * Condition evaluator for the Ion workflow engine.
 *
 * Parses and evaluates `when:` conditions that reference node outputs.
 * Supports comparison operators, AND/OR compounds, and literal values.
 *
 * Grammar (informal):
 *   condition    = orExpr
 *   orExpr       = andExpr ( "OR" andExpr )*
 *   andExpr      = comparison ( "AND" comparison )*
 *   comparison   = value operator value
 *   value        = nodeRef | literal
 *   nodeRef      = "$" nodeId "." field
 *   literal      = number | boolean | quotedString
 *   operator     = "==" | "!=" | "<" | ">" | "<=" | ">="
 */

import { resolveNodeOutputField, OutputRefError } from './output-ref.js';

export class ConditionError extends Error {
  public readonly expression: string;

  constructor(expression: string, message: string) {
    super(`Condition evaluation error in "${expression}": ${message}`);
    this.name = 'ConditionError';
    this.expression = expression;
  }
}

type TokenType =
  | 'NODE_REF'   // $nodeId.field
  | 'NUMBER'     // 42, 3.14
  | 'BOOLEAN'    // true, false
  | 'STRING'     // "hello" or 'hello'
  | 'OPERATOR'   // ==, !=, <, >, <=, >=
  | 'AND'        // AND keyword
  | 'OR'         // OR keyword
  | 'LPAREN'     // (
  | 'RPAREN'     // )
  | 'EOF';

interface Token {
  type: TokenType;
  value: string;
}

const OPERATORS = new Set(['==', '!=', '<=', '>=', '<', '>']);

function tokenize(expression: string): Token[] {
  const tokens: Token[] = [];
  let pos = 0;

  while (pos < expression.length) {
    // Skip whitespace.
    if (/\s/.test(expression[pos]!)) {
      pos++;
      continue;
    }

    // Parentheses.
    if (expression[pos] === '(') {
      tokens.push({ type: 'LPAREN', value: '(' });
      pos++;
      continue;
    }
    if (expression[pos] === ')') {
      tokens.push({ type: 'RPAREN', value: ')' });
      pos++;
      continue;
    }

    // Node reference: $nodeId.field
    if (expression[pos] === '$') {
      const start = pos;
      pos++; // skip $
      let field = '';
      // Read the nodeId (alphanumeric, underscores, hyphens).
      while (pos < expression.length && /[\w-]/.test(expression[pos]!)) {
        pos++;
      }
      const nodeId = expression.slice(start + 1, pos);
      if (nodeId.length === 0) {
        throw new ConditionError(expression, `Expected node identifier after $ at position ${start}`);
      }
      // Expect a dot then field name.
      if (expression[pos] !== '.') {
        throw new ConditionError(expression, `Expected "." after node reference $${nodeId} at position ${pos}`);
      }
      pos++; // skip dot
      const fieldStart = pos;
      while (pos < expression.length && /[\w-]/.test(expression[pos]!)) {
        pos++;
      }
      field = expression.slice(fieldStart, pos);
      if (field.length === 0) {
        throw new ConditionError(expression, `Expected field name after $${nodeId}. at position ${fieldStart}`);
      }
      tokens.push({ type: 'NODE_REF', value: `${nodeId}.${field}` });
      continue;
    }

    // Quoted string.
    if (expression[pos] === '"' || expression[pos] === "'") {
      const quote = expression[pos]!;
      const start = pos;
      pos++;
      let str = '';
      while (pos < expression.length && expression[pos] !== quote) {
        if (expression[pos] === '\\' && pos + 1 < expression.length) {
          pos++; // skip escape
          str += expression[pos]!;
        } else {
          str += expression[pos]!;
        }
        pos++;
      }
      if (pos >= expression.length) {
        throw new ConditionError(expression, `Unterminated string starting at position ${start}`);
      }
      pos++; // skip closing quote
      tokens.push({ type: 'STRING', value: str });
      continue;
    }

    // Two-character operators.
    if (pos + 1 < expression.length) {
      const twoChar = expression.slice(pos, pos + 2);
      if (OPERATORS.has(twoChar)) {
        tokens.push({ type: 'OPERATOR', value: twoChar });
        pos += 2;
        continue;
      }
    }

    // Single-character operators.
    const oneChar = expression[pos]!;
    if (OPERATORS.has(oneChar)) {
      tokens.push({ type: 'OPERATOR', value: oneChar });
      pos++;
      continue;
    }

    // AND / OR keywords.
    const remaining = expression.slice(pos);
    const andMatch = remaining.match(/^AND(?=\s|\(|$)/i);
    if (andMatch) {
      tokens.push({ type: 'AND', value: 'AND' });
      pos += 3;
      continue;
    }
    const orMatch = remaining.match(/^OR(?=\s|\(|$)/i);
    if (orMatch) {
      tokens.push({ type: 'OR', value: 'OR' });
      pos += 2;
      continue;
    }

    // Boolean literals.
    const trueMatch = remaining.match(/^true(?=\s|\)|$)/i);
    if (trueMatch) {
      tokens.push({ type: 'BOOLEAN', value: 'true' });
      pos += 4;
      continue;
    }
    const falseMatch = remaining.match(/^false(?=\s|\)|$)/i);
    if (falseMatch) {
      tokens.push({ type: 'BOOLEAN', value: 'false' });
      pos += 5;
      continue;
    }

    // Number literal.
    const numMatch = remaining.match(/^(-?\d+\.?\d*)/);
    if (numMatch && numMatch[1] !== undefined) {
      tokens.push({ type: 'NUMBER', value: numMatch[1] });
      pos += numMatch[1].length;
      continue;
    }

    throw new ConditionError(
      expression,
      `Unexpected character "${expression[pos]}" at position ${pos}`,
    );
  }

  tokens.push({ type: 'EOF', value: '' });
  return tokens;
}

class ConditionParser {
  private pos = 0;

  constructor(
    private tokens: Token[],
    private expression: string,
    private nodeOutputs: Record<string, Record<string, unknown>>,
  ) {}

  parse(): boolean {
    const result = this.parseOr();
    if (this.tokens[this.pos]!.type !== 'EOF') {
      throw new ConditionError(
        this.expression,
        `Unexpected token "${this.tokens[this.pos]!.value}" after expression`,
      );
    }
    return result;
  }

  // orExpr = andExpr ( "OR" andExpr )*
  private parseOr(): boolean {
    let result = this.parseAnd();
    while (this.tokens[this.pos]!.type === 'OR') {
      this.pos++; // consume OR
      const right = this.parseAnd();
      result = result || right;
    }
    return result;
  }

  // andExpr = comparison ( "AND" comparison )*
  private parseAnd(): boolean {
    let result = this.parseComparison();
    while (this.tokens[this.pos]!.type === 'AND') {
      this.pos++; // consume AND
      const right = this.parseComparison();
      result = result && right;
    }
    return result;
  }

  // comparison = value operator value | "(" orExpr ")"
  private parseComparison(): boolean {
    // Parenthesized expression.
    if (this.tokens[this.pos]!.type === 'LPAREN') {
      this.pos++; // consume (
      const result = this.parseOr();
      if (this.tokens[this.pos]!.type !== 'RPAREN') {
        throw new ConditionError(this.expression, 'Expected closing ")"');
      }
      this.pos++; // consume )
      return result;
    }

    // value operator value
    const left = this.resolveValue();
    const opToken = this.tokens[this.pos]!;

    if (opToken.type !== 'OPERATOR') {
      throw new ConditionError(
        this.expression,
        `Expected comparison operator, got "${opToken.value}" (${opToken.type})`,
      );
    }

    this.pos++; // consume operator
    const right = this.resolveValue();

    return this.compare(left, opToken.value, right);
  }

  private resolveValue(): string | number | boolean {
    const token = this.tokens[this.pos]!;

    switch (token.type) {
      case 'NODE_REF': {
        this.pos++;
        const dotIndex = token.value.indexOf('.');
        if (dotIndex === -1) {
          throw new ConditionError(
            this.expression,
            `Invalid node reference: ${token.value}`,
          );
        }
        const nodeId = token.value.slice(0, dotIndex);
        const field = token.value.slice(dotIndex + 1);

        const output = this.nodeOutputs[nodeId];
        if (!output) {
          throw new ConditionError(
            this.expression,
            `Node "${nodeId}" has no output available. Available nodes: ${Object.keys(this.nodeOutputs).join(', ') || '(none)'}`,
          );
        }

        try {
          const result = resolveNodeOutputField(output, nodeId, field);
          // For comparison, we need the raw value, not the stringified version.
          const rawValue = output[field];
          if (typeof rawValue === 'number') return rawValue;
          if (typeof rawValue === 'boolean') return rawValue;
          return result.value;
        } catch (err) {
          if (err instanceof OutputRefError) {
            throw new ConditionError(this.expression, err.message);
          }
          throw err;
        }
      }

      case 'NUMBER': {
        this.pos++;
        const num = Number(token.value);
        if (Number.isNaN(num)) {
          throw new ConditionError(
            this.expression,
            `Invalid number literal: ${token.value}`,
          );
        }
        return num;
      }

      case 'BOOLEAN': {
        this.pos++;
        return token.value.toLowerCase() === 'true';
      }

      case 'STRING': {
        this.pos++;
        return token.value;
      }

      default:
        throw new ConditionError(
          this.expression,
          `Expected value (node reference, number, boolean, or string), got "${token.value}" (${token.type})`,
        );
    }
  }

  private compare(
    left: string | number | boolean,
    op: string,
    right: string | number | boolean,
  ): boolean {
    // Coerce types for comparison.
    const leftNum = typeof left === 'number' ? left : Number(left);
    const rightNum = typeof right === 'number' ? right : Number(right);

    switch (op) {
      case '==':
        return left === right;
      case '!=':
        return left !== right;
      case '<':
        if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) {
          return leftNum < rightNum;
        }
        return String(left) < String(right);
      case '>':
        if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) {
          return leftNum > rightNum;
        }
        return String(left) > String(right);
      case '<=':
        if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) {
          return leftNum <= rightNum;
        }
        return String(left) <= String(right);
      case '>=':
        if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) {
          return leftNum >= rightNum;
        }
        return String(left) >= String(right);
      default:
        throw new ConditionError(this.expression, `Unknown operator: ${op}`);
    }
  }
}

/**
 * Evaluate a `when:` condition expression against node outputs.
 *
 * Supports:
 * - Node output references: `$nodeId.field`
 * - Comparison operators: `==`, `!=`, `<`, `>`, `<=`, `>=`
 * - Logical compounds: `AND`, `OR`
 * - Parenthesized sub-expressions
 * - Literal values: numbers, booleans, quoted strings
 *
 * Returns `true` or `false`. Throws `ConditionError` on parse failure (fail-closed).
 */
export function evaluateCondition(
  expression: string,
  nodeOutputs: Record<string, Record<string, unknown>>,
): boolean {
  if (!expression || expression.trim().length === 0) {
    // Empty condition is always true (no guard = proceed).
    return true;
  }

  const trimmed = expression.trim();
  const tokens = tokenize(trimmed);
  const parser = new ConditionParser(tokens, trimmed, nodeOutputs);

  try {
    return parser.parse();
  } catch (err) {
    if (err instanceof ConditionError) {
      throw err;
    }
    throw new ConditionError(
      trimmed,
      `Unexpected error: ${err instanceof Error ? err.message : String(err)}`,
    );
  }
}