/**
 * CompositionWireframe — the read-only structure tree (arch 08 / arch 10 §5).
 *
 * Guards the load-bearing behavior: the affordance is hidden for a single-view
 * spec (nothing to schematize), the disclosed panel is an APG tree with one
 * roving tab stop, arrow keys walk it in document order, and activating a box
 * asks the editor to reveal that view's exact source range. The viewTree model
 * itself is covered in core (spec-view-tree.test.ts).
 */

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { useAppStore } from '../stores/AppStore';
import { usePopoverStore } from '../stores/PopoverStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { CompositionWireframe } from './CompositionWireframe';
import { markIconName } from './mark-icon';

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

const COMPOSED = JSON.stringify({ vconcat: [{ mark: 'point' }, { mark: 'bar' }] }, null, 2);
const LAYERED = JSON.stringify({ layer: [{ mark: 'bar' }, { mark: 'line' }] }, null, 2);

let container: HTMLDivElement;
let root: Root;

const setSpec = (text: string) => {
  useSnippetStore.getState().reset();
  useSnippetStore.setState({ draftText: text });
};

/** Like `setSpec`, but with an active draft so reorder affordances are enabled. */
const setEditableSpec = (text: string) => {
  useSnippetStore.getState().reset();
  useSnippetStore.setState({ draftText: text, activeSnippetId: 'snip', editorView: 'draft' });
};

const key = (e: KeyboardEventInit) => new KeyboardEvent('keydown', { bubbles: true, ...e });

const treeItems = () =>
  Array.from(document.body.querySelectorAll<HTMLElement>('[role="treeitem"]'));
const item = (key: string) => document.body.querySelector<HTMLElement>(`[data-key="${key}"]`)!;

async function renderOpen() {
  await act(async () => {
    root.render(<CompositionWireframe />);
    await Promise.resolve();
  });
  await act(async () => {
    usePopoverStore.getState().show('composition-wireframe');
    await Promise.resolve();
  });
}

beforeEach(() => {
  usePopoverStore.setState({ openId: null });
  useAppStore.setState({ revealTarget: null, composeRequest: null });
  setSpec(COMPOSED);
  container = document.createElement('div');
  document.body.appendChild(container);
  root = createRoot(container);
});

afterEach(() => {
  act(() => root.unmount());
  container.remove();
  usePopoverStore.setState({ openId: null });
  useAppStore.setState({ revealTarget: null, composeRequest: null });
  useSnippetStore.getState().reset();
});

describe('CompositionWireframe', () => {
  test('renders nothing for a single-view spec', () => {
    setSpec('{"mark":"point"}');
    act(() => root.render(<CompositionWireframe />));
    expect(container.querySelector('button')).toBeNull();
  });

  test('shows a structure trigger for a composed spec', () => {
    act(() => root.render(<CompositionWireframe />));
    expect(container.querySelector('button[aria-controls="composition-wireframe"]')).not.toBeNull();
  });

  test('discloses an APG tree: the vconcat root and its two leaf views', async () => {
    await renderOpen();
    expect(document.body.querySelector('[role="tree"]')).not.toBeNull();
    expect(treeItems().map((el) => el.dataset.key)).toEqual(['root', 'vconcat|0', 'vconcat|1']);
  });

  test('roving tabindex: exactly one treeitem is in the tab order', async () => {
    await renderOpen();
    expect(treeItems().filter((el) => el.tabIndex === 0)).toHaveLength(1);
    expect(item('root').tabIndex).toBe(0); // the root holds it on open
  });

  test('ArrowDown moves the roving tab stop in document order', async () => {
    await renderOpen();
    act(() => {
      item('root').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
    });
    expect(item('root').tabIndex).toBe(-1);
    expect(item('vconcat|0').tabIndex).toBe(0);
  });

  test('activating a box asks the editor to reveal that view’s source range', async () => {
    await renderOpen();
    act(() => item('vconcat|1').click());
    const target = useAppStore.getState().revealTarget!;
    expect(COMPOSED.slice(target.offset, target.offset + target.length)).toContain('"bar"');
  });

  test('a layer discloses its marks as reorderable treeitems (z-order)', async () => {
    // The layer renders as one frame of mark glyphs, but each mark stays a treeitem
    // so selection and Alt+arrow z-order reorder keep working.
    setEditableSpec(LAYERED);
    await renderOpen();
    expect(treeItems().map((el) => el.dataset.key)).toEqual(['root', 'layer|0', 'layer|1']);
    act(() => {
      item('root').dispatchEvent(key({ key: 'ArrowDown' }));
    });
    act(() => {
      item('layer|0').dispatchEvent(key({ key: 'ArrowDown', altKey: true }));
    });
    expect(useAppStore.getState().composeRequest).toMatchObject({
      kind: 'move',
      arrayPath: ['layer'],
      from: 0,
      to: 1,
    });
  });

  test('Alt+ArrowDown asks the editor to reorder the focused view down', async () => {
    setEditableSpec(COMPOSED);
    await renderOpen();
    // Move the roving focus onto the first leaf, then reorder it down.
    act(() => {
      item('root').dispatchEvent(key({ key: 'ArrowDown' }));
    });
    act(() => {
      item('vconcat|0').dispatchEvent(key({ key: 'ArrowDown', altKey: true }));
    });
    const req = useAppStore.getState().composeRequest!;
    expect(req).toMatchObject({ arrayPath: ['vconcat'], from: 0, to: 1 });
  });

  test('Alt+ArrowUp at the start does not reorder', async () => {
    setEditableSpec(COMPOSED);
    await renderOpen();
    act(() => {
      item('root').dispatchEvent(key({ key: 'ArrowDown' }));
    });
    act(() => {
      item('vconcat|0').dispatchEvent(key({ key: 'ArrowUp', altKey: true }));
    });
    expect(useAppStore.getState().composeRequest).toBeNull();
  });

  test('reorder is inert on a read-only (non-draft) view', async () => {
    // Default COMPOSED is set with no active snippet → not editable.
    await renderOpen();
    act(() => {
      item('root').dispatchEvent(key({ key: 'ArrowDown' }));
    });
    act(() => {
      item('vconcat|0').dispatchEvent(key({ key: 'ArrowDown', altKey: true }));
    });
    expect(useAppStore.getState().composeRequest).toBeNull();
  });
});

describe('CompositionWireframe — drag to restructure', () => {
  // happy-dom has no layout, so the drop hit-test (which reads box rects) needs them
  // stubbed. A vertical stack of two equal halves, keyed by the box's data-key.
  const RECTS: Record<string, { x: number; y: number; w: number; h: number }> = {
    root: { x: 0, y: 0, w: 200, h: 200 },
    'vconcat|0': { x: 0, y: 0, w: 200, h: 100 },
    'vconcat|1': { x: 0, y: 100, w: 200, h: 100 },
  };
  beforeEach(() => {
    vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
      this: HTMLElement,
    ): DOMRect {
      const r = (this.dataset?.key && RECTS[this.dataset.key]) || { x: 0, y: 0, w: 0, h: 0 };
      return {
        left: r.x,
        top: r.y,
        right: r.x + r.w,
        bottom: r.y + r.h,
        width: r.w,
        height: r.h,
        x: r.x,
        y: r.y,
        toJSON: () => ({}),
      };
    });
  });
  afterEach(() => {
    vi.restoreAllMocks();
  });

  const drag = (fromKey: string, to: { x: number; y: number }, shift = false) => {
    act(() => {
      item(fromKey).dispatchEvent(
        new MouseEvent('pointerdown', { bubbles: true, clientX: 100, clientY: 150 }),
      );
    });
    act(() => {
      window.dispatchEvent(
        new MouseEvent('pointermove', { clientX: to.x, clientY: to.y, shiftKey: shift }),
      );
    });
    act(() => {
      window.dispatchEvent(new MouseEvent('pointerup', {}));
    });
  };

  test('dropping onto a view’s far cross edge pairs the two into a row', async () => {
    setEditableSpec(COMPOSED);
    await renderOpen();
    drag('vconcat|1', { x: 190, y: 50 }); // right edge of the top view (cross axis)
    expect(useAppStore.getState().composeRequest).toMatchObject({
      kind: 'wrap',
      targetPath: ['vconcat', 0],
      sourcePath: ['vconcat', 1],
      axis: 'horizontal',
      side: 'after',
    });
  });

  test('dropping along the container reorders within it', async () => {
    setEditableSpec(COMPOSED);
    await renderOpen();
    drag('vconcat|1', { x: 100, y: 8 }); // top edge of the top view
    expect(useAppStore.getState().composeRequest).toMatchObject({
      kind: 'move',
      arrayPath: ['vconcat'],
      from: 1,
      to: 0,
    });
  });
});

describe('CompositionWireframe — pull a view out via the frame margin', () => {
  const HCON = JSON.stringify({ hconcat: [{ mark: 'point' }, { mark: 'bar' }, { mark: 'line' }] });
  // A row of three boxes inset inside the root frame, leaving a margin (the pull-out
  // zone) all around. happy-dom has no layout, so the hit-test rects are stubbed.
  const RECTS: Record<string, { x: number; y: number; w: number; h: number }> = {
    root: { x: 0, y: 0, w: 300, h: 120 },
    'hconcat|0': { x: 24, y: 24, w: 80, h: 72 },
    'hconcat|1': { x: 110, y: 24, w: 80, h: 72 },
    'hconcat|2': { x: 196, y: 24, w: 80, h: 72 },
  };
  beforeEach(() => {
    vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
      this: HTMLElement,
    ): DOMRect {
      const r = (this.dataset?.key && RECTS[this.dataset.key]) || { x: 0, y: 0, w: 0, h: 0 };
      const box = {
        left: r.x,
        top: r.y,
        right: r.x + r.w,
        bottom: r.y + r.h,
        width: r.w,
        height: r.h,
        x: r.x,
        y: r.y,
      };
      return { ...box, toJSON: () => ({}) };
    });
  });
  afterEach(() => vi.restoreAllMocks());

  const drag = (fromKey: string, to: { x: number; y: number }, shift = false) => {
    act(() => {
      item(fromKey).dispatchEvent(
        new MouseEvent('pointerdown', { bubbles: true, clientX: 60, clientY: 60 }),
      );
    });
    act(() => {
      window.dispatchEvent(
        new MouseEvent('pointermove', { clientX: to.x, clientY: to.y, shiftKey: shift }),
      );
    });
    act(() => {
      window.dispatchEvent(new MouseEvent('pointerup', {}));
    });
  };

  test('dropping in the cross-axis margin pulls the view into a new full-span row', async () => {
    setEditableSpec(HCON);
    await renderOpen();
    drag('hconcat|0', { x: 150, y: 8 }); // top margin of the row — across its axis
    expect(useAppStore.getState().composeRequest).toMatchObject({
      kind: 'wrap-container',
      containerPath: [],
      sourcePath: ['hconcat', 0],
      axis: 'vertical',
      side: 'before',
    });
  });

  test('a with-axis margin reorders to that end of the row, not a pull', async () => {
    setEditableSpec(HCON);
    await renderOpen();
    drag('hconcat|1', { x: 8, y: 60 }); // left margin (along the axis), before every box
    expect(useAppStore.getState().composeRequest).toMatchObject({
      kind: 'move',
      arrayPath: ['hconcat'],
      from: 1,
      to: 0,
    });
  });

  test('dropping over a sibling’s central band reorders past it', async () => {
    setEditableSpec(HCON);
    await renderOpen();
    drag('hconcat|2', { x: 50, y: 70 }); // central band of the first box → before it
    expect(useAppStore.getState().composeRequest).toMatchObject({
      kind: 'move',
      arrayPath: ['hconcat'],
      from: 2,
      to: 0,
    });
  });

  test('dropping on a sibling’s top edge stacks the two into a column', async () => {
    setEditableSpec(HCON);
    await renderOpen();
    drag('hconcat|2', { x: 130, y: 30 }); // top edge of the middle box (cross axis)
    expect(useAppStore.getState().composeRequest).toMatchObject({
      kind: 'wrap',
      targetPath: ['hconcat', 1],
      sourcePath: ['hconcat', 2],
      axis: 'vertical',
      side: 'before',
    });
  });
});

describe('CompositionWireframe — simplify redundant wrappers', () => {
  const simplifyButton = () =>
    Array.from(document.body.querySelectorAll('button')).find((b) => b.textContent === 'Simplify');

  test('offers Simplify for a single-child composition and dispatches it', async () => {
    setEditableSpec(JSON.stringify({ hconcat: [{ mark: 'point' }] }));
    await renderOpen();
    const btn = simplifyButton();
    expect(btn).toBeTruthy();
    act(() => btn!.click());
    expect(useAppStore.getState().composeRequest).toMatchObject({ kind: 'simplify' });
  });

  test('no Simplify prompt when every composition has multiple views', async () => {
    setEditableSpec(COMPOSED); // a vconcat of two
    await renderOpen();
    expect(simplifyButton()).toBeUndefined();
  });
});

describe('markIconName', () => {
  test('maps marks to glyphs, collapsing synonyms', () => {
    expect(markIconName('bar')).toBe('mark-bar');
    expect(markIconName('circle')).toBe('mark-point');
    expect(markIconName('square')).toBe('mark-point');
    expect(markIconName('trail')).toBe('mark-line');
  });

  test('falls back to a generic glyph for an unknown or absent mark', () => {
    expect(markIconName('boxplot')).toBe('mark-generic');
    expect(markIconName(undefined)).toBe('mark-generic');
  });
});
