/**
 * Vega expression validation (spec §06 → Data; chart-builder enhancement 1E).
 *
 * Portable core: no browser APIs, no React. The Chart Builder's filter
 * (expression mode) and calculated-field controls let the user write a raw Vega
 * expression; this module checks it with **the same parser Vega-Lite uses at render
 * time** (`vega-expression`'s `parseExpression`), so the inline "is this valid?"
 * feedback agrees exactly with what the chart will accept — no second, divergent
 * grammar. It also extracts the `datum.<field>` references so the UI can flag a typo
 * against the dataset's actual columns before the chart silently renders empty.
 *
 * `vega-expression` is declared as a direct dependency but adds no bundle weight —
 * Vega already ships it for rendering; this import reuses the same copy.
 */

import { parseExpression } from 'vega-expression';

/** The outcome of validating one expression string. */
export interface ExprValidation {
  /** True when the expression parses (or is empty — an empty field isn't an error). */
  valid: boolean;
  /** A short parser message when invalid; absent when valid. */
  error?: string;
}

/**
 * Validate a Vega expression. An **empty** string is treated as valid (it is an
 * incomplete entry, not a mistake — the assembler skips it), so the UI shows no
 * error until the user actually types something malformed. A parse failure returns
 * the parser's message, trimmed of the noisy position suffix where present.
 */
export function validateExpression(expr: string): ExprValidation {
  if (expr.trim() === '') return { valid: true };
  try {
    parseExpression(expr);
    return { valid: true };
  } catch (e) {
    return { valid: false, error: cleanParserMessage((e as Error).message) };
  }
}

/** Tidy a vega-expression parse error for inline display (drop a trailing "(N)"). */
function cleanParserMessage(message: string): string {
  return message.replace(/\s*\(\d+\)\s*$/, '').trim() || 'Invalid expression.';
}

/**
 * The distinct `datum.<field>` (and `datum['field']`) column names an expression
 * references, in first-seen order — best-effort, for flagging unknown fields in the
 * UI. Returns an empty list for an empty or unparseable expression (validation
 * surfaces the parse error separately). Only direct member access off `datum` is
 * collected; dynamic access (`datum[someVar]`) is not a static field name and is
 * skipped.
 */
export function referencedFields(expr: string): string[] {
  if (expr.trim() === '') return [];
  let ast: unknown;
  try {
    ast = parseExpression(expr);
  } catch {
    return [];
  }
  const fields: string[] = [];
  const seen = new Set<string>();
  const visit = (node: unknown): void => {
    if (Array.isArray(node)) {
      for (const child of node) visit(child);
      return;
    }
    if (!node || typeof node !== 'object') return;
    const rec = node as Record<string, unknown>;
    if (rec.type === 'MemberExpression') {
      const name = datumMemberName(rec);
      if (name !== null && !seen.has(name)) {
        seen.add(name);
        fields.push(name);
      }
    }
    for (const key of Object.keys(rec)) {
      if (key === 'type') continue;
      visit(rec[key]);
    }
  };
  visit(ast);
  return fields;
}

/**
 * The static field name of a `datum.<name>` / `datum['name']` member access, or
 * `null` when the node isn't such an access (a different object, computed-dynamic
 * access, or a non-string key).
 */
function datumMemberName(member: Record<string, unknown>): string | null {
  const object = member.object as Record<string, unknown> | undefined;
  if (!object || object.type !== 'Identifier' || object.name !== 'datum') return null;
  const property = member.property as Record<string, unknown> | undefined;
  if (!property) return null;
  if (member.computed) {
    // datum['field'] — a string literal key is a static field name.
    return property.type === 'Literal' && typeof property.value === 'string'
      ? property.value
      : null;
  }
  // datum.field — an identifier key.
  return property.type === 'Identifier' && typeof property.name === 'string' ? property.name : null;
}
