/**
 * Expression strings within a spec document (docs/architecture/08 → editor
 * augmentation). Portable core: `jsonc-parser`'s error-tolerant tree only — no
 * browser APIs, no React, no Monaco — so it keeps working while the draft is
 * briefly unparseable mid-edit.
 *
 * Vega-Lite embeds the Vega expression language inside a handful of JSON string
 * values; the JSON schema sees them as opaque strings. This module finds every
 * such string so the editor can validate them (→ markers) and so the completion /
 * hover providers share one definition of "is the cursor in an expression?". The
 * single-expression analysis itself lives in core/expr-validate; this module only
 * *locates* the expressions in a document.
 */

import { parseTree, type Node } from 'jsonc-parser';
import { validateExpression } from './expr-validate';

/**
 * Property keys whose **string** value is a Vega expression. `filter` and `test`
 * also accept *object* predicates (`{field, gt}`, `{param}`) — those are not
 * expressions and are excluded by the string-value check, not by this set.
 */
export const EXPRESSION_KEYS: ReadonlySet<string> = new Set([
  'calculate',
  'filter',
  'expr',
  'test',
]);

/** One expression string located in the document. */
export interface ExpressionSpan {
  /** The property the expression sits under (`calculate`, `filter`, `expr`, `test`). */
  key: string;
  /** The expression text (the JSON string's unescaped value). */
  value: string;
  /** Byte offset of the first character *inside* the quotes. */
  offset: number;
  /** Length of the characters between the quotes (the raw token, escapes intact). */
  length: number;
}

/** This property node's key + value when it is `<expr-key>: <string>`, else null. */
function expressionValue(node: Node): { key: string; valueNode: Node } | null {
  if (node.type !== 'property' || !node.children) return null;
  const [keyNode, valueNode] = node.children;
  const key = typeof keyNode?.value === 'string' ? keyNode.value : undefined;
  if (key === undefined || !EXPRESSION_KEYS.has(key)) return null;
  return valueNode?.type === 'string' && typeof valueNode.value === 'string'
    ? { key, valueNode }
    : null;
}

/**
 * Every Vega expression string in the document, in source order. The span covers
 * the characters *between* the quotes (so a marker squiggles the expression, not
 * the JSON punctuation); an empty string yields a zero-length span the caller can
 * ignore. Returns `[]` for unparseable text.
 */
export function expressionStringsIn(text: string): ExpressionSpan[] {
  const tree = parseTree(text);
  if (!tree) return [];
  const spans: ExpressionSpan[] = [];
  const walk = (node: Node): void => {
    const found = expressionValue(node);
    if (found) {
      // Inner span = the quoted token minus its two quotes. Computed from the raw
      // token length (not value.length) so escapes inside the string stay aligned.
      spans.push({
        key: found.key,
        value: found.valueNode.value as string,
        offset: found.valueNode.offset + 1,
        length: Math.max(0, found.valueNode.length - 2),
      });
    }
    if (node.children) for (const child of node.children) walk(child);
  };
  walk(tree);
  return spans;
}

/** A syntactically-invalid expression located in the document. */
export interface ExpressionError {
  /** The property it sits under (`filter`, `calculate`, …) — to name the problem. */
  key: string;
  /** The offending expression text. */
  expr: string;
  /** The parser's message — the same text the editor marker and hover show. */
  message: string;
  /** 1-based line of the expression, so the message can point at it (matches the
   *  editor's line numbers). */
  line: number;
}

/** The 1-based line number of a byte offset in `text` (newlines counted before it). */
function lineAtOffset(text: string, offset: number): number {
  let line = 1;
  for (let i = 0; i < offset && i < text.length; i++) {
    if (text[i] === '\n') line++;
  }
  return line;
}

/**
 * The first syntactically-invalid expression in the document, or null when every
 * expression parses. Lets a failed render attribute itself to a broken expression
 * — a precise, located message that agrees with the editor's squiggle — instead of
 * the generic "check your JSON" fallback (the JSON is valid; the expression isn't).
 */
export function firstExpressionError(text: string): ExpressionError | null {
  for (const span of expressionStringsIn(text)) {
    if (span.value.trim() === '') continue;
    const result = validateExpression(span.value);
    if (!result.valid) {
      return {
        key: span.key,
        expr: span.value,
        message: result.error ?? 'Invalid expression.',
        line: lineAtOffset(text, span.offset),
      };
    }
  }
  return null;
}
