/**
 * Expression-aware editor intelligence (docs/architecture/08 → editor
 * augmentation) — the help the Vega-Lite JSON schema *structurally can't* give,
 * because the expression language lives inside opaque JSON strings (`calculate`,
 * `filter`, `expr`, `test`):
 *
 *   - **Completion** — after `datum.` / `datum['…'`, the bound view's real column
 *     names; otherwise the expression language's functions (`if`, `datetime`, …)
 *     and constants (`PI`, `E`), names derived from the parser itself
 *     (core/vega-expr-catalog).
 *   - **Signature help** — parameter hints for the curated common functions, with
 *     the active argument tracked as you type past each comma.
 *   - **Hover** — over an expression string, a live validity check
 *     (core/expr-validate, the same parser the chart uses).
 *   - **Markers** — every expression string is parsed; a malformed one squiggles
 *     in place. This is the app's only editor-marker source besides the JSON
 *     worker, so it owns a distinct marker namespace (`vega-expr`).
 *
 * The three language providers register **once, globally for JSON** (like the
 * schema, formatter, and dataset hints); the marker pass is **per editor**
 * (it writes to a specific model and is torn down with it). All read the draft
 * buffer and are gated to the draft view — the published view is a read-only
 * reference, where expression authoring help is marginal.
 */

import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { activeCall, validateExpression } from '@core/expr-validate';
import { valueKeyAtOffset, stringValueAtOffset } from '@core/spec-cursor';
import { EXPRESSION_KEYS, expressionStringsIn } from '@core/spec-expressions';
import {
  EXPR_CONSTANT_NAMES,
  EXPR_FUNCTION_NAMES,
  EXPR_SIGNATURES,
  signatureLabel,
} from '@core/vega-expr-catalog';
import { availableFieldsAt, type FieldHint } from './active-dataset';
import { useSnippetStore } from '../stores/SnippetStore';

/** A name that can follow `datum.`; others (with spaces, etc.) need bracket access. */
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
/** Cursor sitting in `datum.<partial>` — completing a field by dot access. */
const DATUM_DOT = /datum\.([A-Za-z0-9_$]*)$/;
/** Cursor sitting in `datum['<partial>` — completing a field by bracket access. */
const DATUM_BRACKET = /datum\[\s*['"]([^'"]*)$/;
/** Debounce for the marker recompute — responsive without thrashing on every key. */
const MARKER_DEBOUNCE_MS = 300;
/** Marker namespace, kept distinct from the JSON worker's own markers. */
const MARKER_OWNER = 'vega-expr';

/**
 * The expression text from the start of the cursor's expression string up to the
 * cursor, or null when the cursor is not inside one (or not on the draft view).
 * Drives both completion ("what am I typing?") and signature help ("which call am
 * I in?"). The containing string is located via the core enumerator so the prefix
 * excludes the JSON `"key": "` framing.
 */
function expressionPrefixAt(
  model: monaco.editor.ITextModel,
  position: monaco.Position,
): string | null {
  if (useSnippetStore.getState().editorView !== 'draft') return null;
  const text = model.getValue();
  const offset = model.getOffsetAt(position);
  const key = valueKeyAtOffset(text, offset);
  if (key === null || !EXPRESSION_KEYS.has(key)) return null;
  for (const span of expressionStringsIn(text)) {
    if (offset >= span.offset && offset <= span.offset + span.length) {
      return text.slice(span.offset, offset);
    }
  }
  return null;
}

/** A completion item for a data field (a real column or a transform-derived one). */
function fieldItem(field: FieldHint, range: monaco.IRange): monaco.languages.CompletionItem {
  return {
    label: field.name,
    kind:
      field.derived || field.type === null
        ? monaco.languages.CompletionItemKind.Variable
        : monaco.languages.CompletionItemKind.Field,
    detail: field.derived || field.type === null ? 'derived field' : field.type,
    insertText: field.name,
    range,
  };
}

/** A completion item for an expression function — curated signature as the detail. */
function functionItem(name: string, range: monaco.IRange): monaco.languages.CompletionItem {
  const sig = EXPR_SIGNATURES[name];
  const noArgs = sig !== undefined && sig.params.length === 0;
  return {
    label: name,
    kind: monaco.languages.CompletionItemKind.Function,
    detail: sig ? signatureLabel(sig) : undefined,
    documentation: sig ? { value: sig.doc } : undefined,
    // Land the cursor inside the parens (snippet `$1`), unless the function takes
    // no arguments — then close the call outright.
    insertText: noArgs ? `${name}()` : `${name}($1)`,
    insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
    range,
  };
}

/** A completion item for an expression constant (`PI`, `E`, …). */
function constantItem(name: string, range: monaco.IRange): monaco.languages.CompletionItem {
  return {
    label: name,
    kind: monaco.languages.CompletionItemKind.Constant,
    insertText: name,
    range,
  };
}

let registered = false;

/** Register the expression completion / signature-help / hover providers once. */
export function configureSpecExpressionHints(): void {
  if (registered) return;
  registered = true;

  monaco.languages.registerCompletionItemProvider('json', {
    // `.` opens field completion after `datum`; the quote characters open it inside
    // `datum['…']`; quick-suggest (strings:true) covers the function-name case.
    triggerCharacters: ['.', '"', "'"],
    provideCompletionItems(model, position) {
      const prefix = expressionPrefixAt(model, position);
      if (prefix === null) return { suggestions: [] };

      // Build the replace range from the partial WE parse out of the prefix, never
      // from Monaco's JSON word: that language's wordPattern treats `.` and `(` as
      // word characters, so getWordUntilPosition after `datum.` (or `fn(`) returns
      // the whole `datum.`/`fn(` token — which would both mis-target the edit and
      // filter every suggestion out (none start with `datum.`).
      const replaceRange = (partialLength: number): monaco.Range =>
        new monaco.Range(
          position.lineNumber,
          position.column - partialLength,
          position.lineNumber,
          position.column,
        );

      const dot = DATUM_DOT.exec(prefix);
      const bracket = dot ? null : DATUM_BRACKET.exec(prefix);
      if (dot || bracket) {
        const fields = availableFieldsAt(model.getValue(), model.getOffsetAt(position));
        if (fields.length === 0) return { suggestions: [] };
        // The partial typed after `datum.` / `datum['` — replace only that, leaving
        // the `datum` token before it intact.
        const partial = dot ? dot[1] : bracket![1];
        const range = replaceRange(partial.length);
        // Only identifier-safe names are usable after a dot; brackets take any name.
        const candidates = dot ? fields.filter((f) => IDENTIFIER.test(f.name)) : fields;
        return { suggestions: candidates.map((f) => fieldItem(f, range)) };
      }

      // Function / constant context: the partial is the trailing identifier (Monaco's
      // JSON word would reach back across a preceding `(` and break filtering).
      const ident = /[A-Za-z_$][A-Za-z0-9_$]*$/.exec(prefix);
      const range = replaceRange(ident ? ident[0].length : 0);
      const suggestions: monaco.languages.CompletionItem[] = [
        ...EXPR_FUNCTION_NAMES.map((name) => functionItem(name, range)),
        ...EXPR_CONSTANT_NAMES.map((name) => constantItem(name, range)),
        {
          label: 'datum',
          kind: monaco.languages.CompletionItemKind.Keyword,
          detail: 'the current data record',
          insertText: 'datum',
          range,
        },
      ];
      return { suggestions };
    },
  });

  monaco.languages.registerSignatureHelpProvider('json', {
    signatureHelpTriggerCharacters: ['(', ','],
    signatureHelpRetriggerCharacters: [','],
    provideSignatureHelp(model, position) {
      const prefix = expressionPrefixAt(model, position);
      if (prefix === null) return null;
      const call = activeCall(prefix);
      if (!call) return null;
      const sig = EXPR_SIGNATURES[call.name];
      if (!sig || sig.params.length === 0) return null;

      const info: monaco.languages.SignatureInformation = {
        label: signatureLabel(sig),
        documentation: { value: sig.doc },
        parameters: sig.params.map((p, i) => ({
          // The label must be a substring of the signature label so Monaco can
          // highlight the active parameter — `...rest` for the variadic tail.
          label: sig.variadic && i === sig.params.length - 1 ? `...${p}` : p,
        })),
      };
      // A variadic tail keeps highlighting its last parameter past the final comma.
      const activeParameter = sig.variadic
        ? Math.min(call.activeParam, sig.params.length - 1)
        : call.activeParam;
      return { value: { signatures: [info], activeSignature: 0, activeParameter }, dispose() {} };
    },
  });

  monaco.languages.registerHoverProvider('json', {
    provideHover(model, position) {
      if (useSnippetStore.getState().editorView !== 'draft') return null;
      const text = model.getValue();
      const offset = model.getOffsetAt(position);
      const key = valueKeyAtOffset(text, offset);
      if (key === null || !EXPRESSION_KEYS.has(key)) return null;
      const expr = stringValueAtOffset(text, offset);
      if (expr === null) return null;
      const result = validateExpression(expr);
      return {
        contents: [
          {
            value: result.valid
              ? '✓ Valid Vega expression'
              : `✗ ${result.error ?? 'Invalid expression'}`,
          },
        ],
      };
    },
  });
}

/**
 * Validate every expression string in this editor's model and squiggle the invalid
 * ones (per editor — it writes markers to one model). Recomputes debounced on edit,
 * and on a draft↔published toggle so the draft-only gate is honored even when the
 * two buffers are identical. Disposed with the editor; clears its markers on the
 * way out.
 */
export function installExpressionMarkers(
  editor: monaco.editor.IStandaloneCodeEditor,
): monaco.IDisposable {
  let timer: ReturnType<typeof setTimeout> | null = null;

  const recompute = (): void => {
    const model = editor.getModel();
    if (!model) return;
    // The published view is a read-only reference — no authoring markers there.
    if (useSnippetStore.getState().editorView !== 'draft') {
      monaco.editor.setModelMarkers(model, MARKER_OWNER, []);
      return;
    }
    const text = model.getValue();
    const markers: monaco.editor.IMarkerData[] = [];
    for (const span of expressionStringsIn(text)) {
      if (span.length === 0) continue; // an empty expression isn't an error
      const result = validateExpression(span.value);
      if (result.valid) continue;
      const start = model.getPositionAt(span.offset);
      const end = model.getPositionAt(span.offset + span.length);
      markers.push({
        severity: monaco.MarkerSeverity.Error,
        message: result.error ?? 'Invalid Vega expression.',
        startLineNumber: start.lineNumber,
        startColumn: start.column,
        endLineNumber: end.lineNumber,
        endColumn: end.column,
      });
    }
    monaco.editor.setModelMarkers(model, MARKER_OWNER, markers);
  };

  const schedule = (): void => {
    if (timer) clearTimeout(timer);
    timer = setTimeout(recompute, MARKER_DEBOUNCE_MS);
  };

  const contentSub = editor.onDidChangeModelContent(schedule);
  const viewSub = useSnippetStore.subscribe((s, prev) => {
    if (s.editorView !== prev.editorView) recompute();
  });
  recompute(); // initial pass

  return {
    dispose() {
      if (timer) clearTimeout(timer);
      contentSub.dispose();
      viewSub();
      const model = editor.getModel();
      if (model) monaco.editor.setModelMarkers(model, MARKER_OWNER, []);
    },
  };
}
