/**
 * Structural spec transforms as editor actions (docs/architecture/08 → editor
 * augmentation) — the refactor counterpart to spec-config-actions. Wrap the
 * focused view in a composition (layer / hconcat / vconcat / facet / repeat) or
 * collapse a single-child composition back to a unit, over the portable core
 * transforms (core/spec-transforms).
 *
 * **Scope** is the current selection when there is one (the user said exactly
 * what to target); otherwise the view the cursor sits in — an element of a
 * layer/concat or a facet/repeat child, resolved by core/spec-cursor — falling
 * back to the whole document for a flat unit spec with no inner view.
 *
 * **Surfacing** mirrors spec-config-actions' three-tier model:
 *   - the editor toolbar's **Transform menu** (SpecEditor) is the discoverable
 *     home, calling `runWrap` / `runUnwrap`;
 *   - the **lightbulb** (`configureSpecTransformCodeActions`, registered once,
 *     global per-language) offers the same transforms contextually at the cursor;
 *   - the **F1 palette** (`installSpecTransformActions`, per editor) is the
 *     keyboard accelerator. These are *not* added to the right-click menu — the
 *     lightbulb already covers the in-place case, and config-actions hold the
 *     three context-menu slots; nine items there would be a thicket.
 *
 * Edits go through `executeEdits` (toolbar/palette) or a `WorkspaceEdit` (the
 * lightbulb, which has no editor handle) — both build the replacement text via
 * the one `buildNext` + `formatScoped` path, so there is a single transform
 * path, only the application differs. ⌘Z restores the previous text; invalid
 * JSON no-ops with a toast; `!editorReadonly` hides the actions on the published
 * view, and the lightbulb is gated to the active draft.
 */

import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { defaultFieldType } from '@core/chart-builder';
import { formatJson } from '@core/json-format';
import { isJsonObject, type JsonObject } from '@core/spec-config';
import { findViewRange } from '@core/spec-cursor';
import {
  compositionTargetAt,
  elementOffset,
  insertView,
  moveView,
  type SpecPath,
} from '@core/spec-insert';
import {
  unwrapSingleton,
  wrapInConcat,
  wrapInFacet,
  wrapInLayer,
  wrapInRepeat,
} from '@core/spec-transforms';
import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { boundColumnsAt } from './active-dataset';
import { parseSpecObject } from './spec-config-actions';

/** The composition operators the wrap actions offer. */
type WrapKind = 'layer' | 'hconcat' | 'vconcat' | 'facet' | 'repeat';

/** The slice of the document a transform reads and rewrites. */
interface Scope {
  range: monaco.Range;
  text: string;
  /** Whole-document offset of the focused view, for resolving its data binding. */
  offset: number;
  /** Column the slice starts at (0-based), so re-indented output stays aligned. */
  baseCol: number;
}

const isEmptyRange = (r: monaco.IRange): boolean =>
  r.startLineNumber === r.endLineNumber && r.startColumn === r.endColumn;

/** The whole document, as a scope. */
function wholeDocument(model: monaco.editor.ITextModel): Scope {
  const range = model.getFullModelRange();
  return { range, text: model.getValue(), offset: 0, baseCol: 0 };
}

/**
 * The slice a transform acts on: an explicit selection if present; else the view
 * the cursor sits in (core/spec-cursor); else the whole document. `offset` is the
 * cursor/selection-start position in the whole document, used to resolve the
 * focused view's data binding (facet/repeat field defaults).
 */
function resolveScope(model: monaco.editor.ITextModel, range: monaco.IRange | null): Scope {
  if (!range) return wholeDocument(model);
  const offset = model.getOffsetAt({
    lineNumber: range.startLineNumber,
    column: range.startColumn,
  });
  if (!isEmptyRange(range)) {
    const r = monaco.Range.lift(range);
    return { range: r, text: model.getValueInRange(r), offset, baseCol: r.startColumn - 1 };
  }
  const node = findViewRange(model.getValue(), offset);
  if (!node) return wholeDocument(model);
  const start = model.getPositionAt(node.offset);
  const end = model.getPositionAt(node.offset + node.length);
  const r = new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column);
  return { range: r, text: model.getValueInRange(r), offset, baseCol: start.column - 1 };
}

/** Indent every line after the first by `baseCol`, so a scoped edit stays aligned. */
function reindent(text: string, baseCol: number): string {
  if (baseCol <= 0) return text;
  const pad = ' '.repeat(baseCol);
  return text
    .split('\n')
    .map((line, i) => (i === 0 ? line : pad + line))
    .join('\n');
}

/** Serialize the replacement in the app's compact JSON style, aligned to the scope. */
function formatScoped(model: monaco.editor.ITextModel, scope: Scope, next: JsonObject): string {
  const raw = JSON.stringify(next);
  const formatted = formatJson(raw, { indent: model.getOptions().tabSize }) ?? raw;
  return reindent(formatted, scope.baseCol);
}

/** A categorical column to facet by (first nominal/ordinal), or a placeholder. */
function defaultFacet(text: string, offset: number): { field: string; type: string } {
  const cols = boundColumnsAt(text, offset);
  const categorical = cols.find((c) => {
    const t = defaultFieldType(c.type);
    return t === 'nominal' || t === 'ordinal';
  });
  const col = categorical ?? cols[0];
  return col
    ? { field: col.name, type: defaultFieldType(col.type) }
    : { field: 'field', type: 'nominal' };
}

/** Quantitative columns to repeat over + the channel to rewire, with fallbacks. */
function defaultRepeat(
  spec: JsonObject,
  text: string,
  offset: number,
): { fields: string[]; channel: string | null } {
  const cols = boundColumnsAt(text, offset);
  const numeric = cols
    .filter((c) => defaultFieldType(c.type) === 'quantitative')
    .map((c) => c.name);
  const fields = numeric.length > 0 ? numeric.slice(0, 3) : cols.slice(0, 2).map((c) => c.name);
  const encoding = isJsonObject(spec.encoding) ? spec.encoding : null;
  const channel = encoding ? ('y' in encoding ? 'y' : (Object.keys(encoding)[0] ?? null)) : null;
  return { fields: fields.length > 0 ? fields : ['field1', 'field2'], channel };
}

/**
 * Apply a wrap of the given kind to the parsed (scoped) spec. `text`/`offset` are
 * the whole document and the focused view's position, used to resolve that view's
 * data binding for the facet/repeat field defaults.
 */
function buildNext(spec: JsonObject, kind: WrapKind, text: string, offset: number): JsonObject {
  switch (kind) {
    case 'layer':
      return wrapInLayer(spec);
    case 'hconcat':
      return wrapInConcat(spec, 'h');
    case 'vconcat':
      return wrapInConcat(spec, 'v');
    case 'facet': {
      const { field, type } = defaultFacet(text, offset);
      return wrapInFacet(spec, field, type);
    }
    case 'repeat': {
      const { fields, channel } = defaultRepeat(spec, text, offset);
      return wrapInRepeat(spec, fields, channel);
    }
  }
}

const WRAP_NOUN: Record<WrapKind, string> = {
  layer: 'a layer',
  hconcat: 'a horizontal concat',
  vconcat: 'a vertical concat',
  facet: 'a facet',
  repeat: 'a repeat',
};

/** Replace the scope as one undoable edit (the toolbar / palette path). */
function writeBack(
  editor: monaco.editor.IStandaloneCodeEditor,
  range: monaco.Range,
  text: string,
): void {
  editor.pushUndoStop();
  editor.executeEdits('spec-transform', [{ range, text }]);
  editor.pushUndoStop();
  editor.focus();
}

/**
 * The model, focused scope, and the spec parsed off it — the shared prologue of
 * the scoped actions, or null (after toasting on invalid JSON) when there is
 * nothing to act on.
 */
function resolveTarget(
  editor: monaco.editor.IStandaloneCodeEditor,
): { model: monaco.editor.ITextModel; scope: Scope; spec: JsonObject } | null {
  const model = editor.getModel();
  if (!model) return null;
  const scope = resolveScope(model, editor.getSelection());
  const spec = parseSpecObject(scope.text);
  return spec ? { model, scope, spec } : null;
}

/** Wrap the focused view (selection, else whole document) in a composition. */
export function runWrap(editor: monaco.editor.IStandaloneCodeEditor, kind: WrapKind): void {
  const target = resolveTarget(editor);
  if (!target) return;
  const { model, scope, spec } = target;
  const next = buildNext(spec, kind, model.getValue(), scope.offset);
  writeBack(editor, scope.range, formatScoped(model, scope, next));
  notify({
    kind: 'success',
    title: 'View wrapped',
    message: `Wrapped in ${WRAP_NOUN[kind]}. Undo with ⌘/Ctrl+Z.`,
  });
}

/** Collapse a single-child layer/concat in the focused scope back to a unit. */
export function runUnwrap(editor: monaco.editor.IStandaloneCodeEditor): void {
  const target = resolveTarget(editor);
  if (!target) return;
  const { model, scope, spec } = target;
  const next = unwrapSingleton(spec);
  if (!next) {
    notify({
      kind: 'info',
      title: 'Nothing to simplify',
      message: 'Select a layer or concat with a single child to collapse it.',
    });
    return;
  }
  writeBack(editor, scope.range, formatScoped(model, scope, next));
  notify({
    kind: 'success',
    title: 'Composition simplified',
    message: 'Collapsed the single-child composition. Undo with ⌘/Ctrl+Z.',
  });
}

/**
 * Apply a structural array edit (insert / move) as one undoable whole-document
 * rewrite, then put the cursor on the affected view so the lenses re-anchor to it
 * and a repeated click keeps acting on the same view. `followIndex` is the view's
 * index in `arrayPath` *after* the edit. Reformatted in the app's compact style,
 * which is idempotent on an already-formatted draft.
 */
function applyArrayEdit(
  editor: monaco.editor.IStandaloneCodeEditor,
  build: (spec: JsonObject) => JsonObject | null,
  arrayPath: SpecPath,
  followIndex: number,
  successTitle: string,
): void {
  const model = editor.getModel();
  if (!model) return;
  const spec = parseSpecObject(model.getValue());
  if (!spec) return;
  const next = build(spec);
  if (!next) {
    notify({
      kind: 'info',
      title: 'Could not change the composition',
      message: 'The composition changed — try the affordance again.',
    });
    return;
  }
  const formatted = formatScoped(model, wholeDocument(model), next);
  writeBack(editor, model.getFullModelRange(), formatted);
  const offset = elementOffset(formatted, arrayPath, followIndex);
  if (offset !== null) {
    const pos = model.getPositionAt(offset);
    editor.setPosition(pos);
    editor.revealPositionInCenterIfOutsideViewport(pos);
  }
  notify({ kind: 'success', title: successTitle, message: 'Undo with ⌘/Ctrl+Z.' });
}

/** Insert an empty view at `index` of the composition at `arrayPath`. */
function runAddView(
  editor: monaco.editor.IStandaloneCodeEditor,
  arrayPath: SpecPath,
  index: number,
): void {
  applyArrayEdit(editor, (s) => insertView(s, arrayPath, index), arrayPath, index, 'View added');
}

/** Swap the view at `index` with its sibling `delta` steps away (±1). */
function runMoveView(
  editor: monaco.editor.IStandaloneCodeEditor,
  arrayPath: SpecPath,
  index: number,
  delta: number,
): void {
  applyArrayEdit(
    editor,
    (s) => moveView(s, arrayPath, index, delta),
    arrayPath,
    index + delta,
    delta < 0 ? 'View moved up' : 'View moved down',
  );
}

/** Insert a view above/below the one the cursor is in (the keyboard path). */
function runInsertRelative(
  editor: monaco.editor.IStandaloneCodeEditor,
  where: 'above' | 'below',
): void {
  const model = editor.getModel();
  const pos = editor.getPosition();
  if (!model || !pos) return;
  const target = compositionTargetAt(model.getValue(), model.getOffsetAt(pos));
  if (!target) {
    notify({
      kind: 'info',
      title: 'No composition here',
      message: 'Place the cursor in a view inside a layer or concat.',
    });
    return;
  }
  if (!target.element) {
    runAddView(editor, target.arrayPath, 0); // empty composition → its first view
    return;
  }
  runAddView(
    editor,
    target.arrayPath,
    where === 'above' ? target.element.index : target.element.index + 1,
  );
}

/** Move the view the cursor is in up/down among its siblings (the keyboard path). */
function runMoveRelative(editor: monaco.editor.IStandaloneCodeEditor, delta: number): void {
  const model = editor.getModel();
  const pos = editor.getPosition();
  if (!model || !pos) return;
  const target = compositionTargetAt(model.getValue(), model.getOffsetAt(pos));
  if (!target?.element) {
    notify({
      kind: 'info',
      title: 'No view to move',
      message: 'Place the cursor in a view inside a layer or concat.',
    });
    return;
  }
  const to = target.element.index + delta;
  if (to < 0 || to >= target.count) {
    notify({
      kind: 'info',
      title: delta < 0 ? 'Already first' : 'Already last',
      message: 'This view is at the edge of its composition.',
    });
    return;
  }
  runMoveView(editor, target.arrayPath, target.element.index, delta);
}

/**
 * Install the cursor-aware composition CodeLens (per editor — the lens commands
 * need this editor's handle to apply the edit). Over the view the cursor sits in
 * it offers `＋ Add view above/below` at the view's edges and `↑/↓ Move` to
 * reorder it among its siblings; an empty composition gets a single `＋ Add view`.
 * Returns a disposable; dispose on unmount.
 */
export function installSpecTransformCodeLens(
  editor: monaco.editor.IStandaloneCodeEditor,
): monaco.IDisposable {
  const addCmd = editor.addCommand(0, (_a, path: SpecPath, index: number) =>
    runAddView(editor, path, index),
  );
  const moveCmd = editor.addCommand(0, (_a, path: SpecPath, index: number, delta: number) =>
    runMoveView(editor, path, index, delta),
  );

  const lens = (
    line: number,
    title: string,
    id: string | null,
    args: unknown[],
  ): monaco.languages.CodeLens => ({
    range: new monaco.Range(line, 1, line, 1),
    command: { id: id ?? '', title, arguments: args },
  });

  // The lenses depend on the cursor, so signal a refresh when the enclosing view
  // changes — keyed so typing within one view does not re-render them.
  const onDidChange = new monaco.Emitter<monaco.languages.CodeLensProvider>();
  const codeLensProvider: monaco.languages.CodeLensProvider = {
    onDidChange: onDidChange.event,
    provideCodeLenses(model) {
      if (useSnippetStore.getState().editorView !== 'draft') return { lenses: [], dispose() {} };
      const pos = editor.getPosition();
      if (!pos) return { lenses: [], dispose() {} };
      const target = compositionTargetAt(model.getValue(), model.getOffsetAt(pos));
      if (!target) return { lenses: [], dispose() {} };
      const lenses: monaco.languages.CodeLens[] = [];
      if (!target.element) {
        // Off a view, only an empty composition gets an affordance — the array's
        // own line stays clean otherwise.
        if (target.count === 0) {
          const line = model.getPositionAt(target.arrayOffset).lineNumber;
          lenses.push(lens(line, '$(add) Add view', addCmd, [target.arrayPath, 0]));
        }
        return { lenses, dispose() {} };
      }
      const { index, offset, length } = target.element;
      // "above" actions sit on the view's first line, "below" on the line after
      // its last — so up-actions group at the top, down-actions at the bottom.
      const topLine = model.getPositionAt(offset).lineNumber;
      const bottomLine = Math.min(
        model.getLineCount(),
        model.getPositionAt(offset + length).lineNumber + 1,
      );
      lenses.push(lens(topLine, '$(add) Add view above', addCmd, [target.arrayPath, index]));
      if (index > 0)
        lenses.push(lens(topLine, '$(arrow-up) Move up', moveCmd, [target.arrayPath, index, -1]));
      lenses.push(lens(bottomLine, '$(add) Add view below', addCmd, [target.arrayPath, index + 1]));
      if (index < target.count - 1)
        lenses.push(
          lens(bottomLine, '$(arrow-down) Move down', moveCmd, [target.arrayPath, index, 1]),
        );
      return { lenses, dispose() {} };
    },
  };
  const registration = monaco.languages.registerCodeLensProvider('json', codeLensProvider);

  let lastKey = '';
  const cursorSub = editor.onDidChangeCursorPosition(() => {
    const model = editor.getModel();
    const pos = editor.getPosition();
    const target =
      model && pos && useSnippetStore.getState().editorView === 'draft'
        ? compositionTargetAt(model.getValue(), model.getOffsetAt(pos))
        : null;
    const key = target
      ? JSON.stringify([target.arrayPath, target.element?.index ?? -1, target.count])
      : '';
    if (key !== lastKey) {
      lastKey = key;
      onDidChange.fire(codeLensProvider);
    }
  });

  return {
    dispose() {
      cursorSub.dispose();
      onDidChange.dispose();
      registration.dispose();
    },
  };
}

/**
 * Register the F1-palette actions on the editor (the keyboard accelerator).
 * Returns a disposable; dispose on editor unmount, like the other per-editor
 * installs. Deliberately no `contextMenuGroupId` — see the module header.
 */
export function installSpecTransformActions(
  editor: monaco.editor.IStandaloneCodeEditor,
): monaco.IDisposable {
  const actions = [
    editor.addAction({
      id: 'astrolabe.wrap-layer',
      label: 'Wrap View in a Layer',
      precondition: '!editorReadonly',
      run: () => runWrap(editor, 'layer'),
    }),
    editor.addAction({
      id: 'astrolabe.wrap-hconcat',
      label: 'Wrap View in Horizontal Concat',
      precondition: '!editorReadonly',
      run: () => runWrap(editor, 'hconcat'),
    }),
    editor.addAction({
      id: 'astrolabe.wrap-vconcat',
      label: 'Wrap View in Vertical Concat',
      precondition: '!editorReadonly',
      run: () => runWrap(editor, 'vconcat'),
    }),
    editor.addAction({
      id: 'astrolabe.wrap-facet',
      label: 'Wrap View in a Facet',
      precondition: '!editorReadonly',
      run: () => runWrap(editor, 'facet'),
    }),
    editor.addAction({
      id: 'astrolabe.wrap-repeat',
      label: 'Wrap View in a Repeat',
      precondition: '!editorReadonly',
      run: () => runWrap(editor, 'repeat'),
    }),
    editor.addAction({
      id: 'astrolabe.unwrap',
      label: 'Simplify Single-Child Composition',
      precondition: '!editorReadonly',
      run: () => runUnwrap(editor),
    }),
    editor.addAction({
      id: 'astrolabe.insert-view-above',
      label: 'Insert View Above',
      precondition: '!editorReadonly',
      run: () => runInsertRelative(editor, 'above'),
    }),
    editor.addAction({
      id: 'astrolabe.insert-view-below',
      label: 'Insert View Below',
      precondition: '!editorReadonly',
      run: () => runInsertRelative(editor, 'below'),
    }),
    editor.addAction({
      id: 'astrolabe.move-view-up',
      label: 'Move View Up',
      precondition: '!editorReadonly',
      run: () => runMoveRelative(editor, -1),
    }),
    editor.addAction({
      id: 'astrolabe.move-view-down',
      label: 'Move View Down',
      precondition: '!editorReadonly',
      run: () => runMoveRelative(editor, 1),
    }),
  ];
  return {
    dispose() {
      for (const action of actions) action.dispose();
    },
  };
}

/**
 * The lightbulb has no editor handle, so it returns a `WorkspaceEdit` instead of
 * calling `executeEdits`; both paths build the replacement through `buildNext` +
 * `formatScoped`.
 */
function editAction(
  model: monaco.editor.ITextModel,
  scope: Scope,
  title: string,
  next: JsonObject,
): monaco.languages.CodeAction {
  return {
    title,
    kind: 'refactor.rewrite',
    edit: {
      edits: [
        {
          resource: model.uri,
          versionId: model.getVersionId(),
          textEdit: { range: scope.range, text: formatScoped(model, scope, next) },
        },
      ],
    },
  };
}

let codeActionsRegistered = false;

/**
 * Register the wrap/simplify refactors as code actions (the lightbulb) once,
 * globally for JSON — like the schema and formatter, not per editor. Idempotent.
 */
export function configureSpecTransformCodeActions(): void {
  if (codeActionsRegistered) return;
  codeActionsRegistered = true;

  monaco.languages.registerCodeActionProvider('json', {
    provideCodeActions(model, range) {
      const empty = { actions: [], dispose() {} };
      // Global provider, no editor handle: gate on the store the way the run*
      // path is gated by `!editorReadonly` — only on the active snippet's draft.
      const snippet = useSnippetStore.getState();
      if (snippet.activeSnippetId === null || snippet.editorView !== 'draft') return empty;

      const scope = resolveScope(model, range);
      let spec: unknown;
      try {
        spec = JSON.parse(scope.text);
      } catch {
        return empty;
      }
      if (!isJsonObject(spec)) return empty;
      const viewSpec = spec;

      const fullText = model.getValue();
      const build = (kind: WrapKind) => buildNext(viewSpec, kind, fullText, scope.offset);
      const actions: monaco.languages.CodeAction[] = [
        editAction(model, scope, 'Wrap view in a layer', build('layer')),
        editAction(model, scope, 'Wrap view in horizontal concat', build('hconcat')),
        editAction(model, scope, 'Wrap view in vertical concat', build('vconcat')),
        editAction(model, scope, 'Wrap view in a facet', build('facet')),
        editAction(model, scope, 'Wrap view in a repeat', build('repeat')),
      ];
      const collapsed = unwrapSingleton(spec);
      if (collapsed)
        actions.push(editAction(model, scope, 'Simplify single-child composition', collapsed));

      return { actions, dispose() {} };
    },
  });
}
