import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ELEMENT_TOOL_NAMES } from '@zseven-w/pen-mcp';
import {
  dispatchElementToolCall,
  dispatchElementToolCalls,
  looksLikeJsonl,
} from '../element-tools-dispatcher';
import { SUPPORTED_EMBEDDED_ELEMENT_TOOLS } from '../element-tool-shims';
import { useHistoryStore } from '@/stores/history-store';
import { useDocumentStore } from '@/stores/document-store';
import { useCanvasStore } from '@/stores/canvas-store';
import type { DesignOutputShape } from '../design-parser';

describe('dispatchElementToolCalls (multi-tag batch)', () => {
  beforeEach(() => {
    useHistoryStore.getState().clear();
    vi.stubGlobal(
      'fetch',
      vi.fn(() => Promise.reject(new Error('fetch not available in test env'))),
    );
  });

  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it('empty shape list → status=empty + no batch touched', async () => {
    const startSpy = vi.spyOn(useHistoryStore.getState(), 'startBatch');
    const result = await dispatchElementToolCalls([]);
    expect(result.status).toBe('empty');
    expect(result.results).toHaveLength(0);
    expect(startSpy).not.toHaveBeenCalled();
    startSpy.mockRestore();
  });

  it('3 successful shim calls → one batch, one undo entry, 3 results', async () => {
    const undoBefore = useHistoryStore.getState().undoStack.length;
    const startSpy = vi.spyOn(useHistoryStore.getState(), 'startBatch');
    const endSpy = vi.spyOn(useHistoryStore.getState(), 'endBatch');

    const shapes: DesignOutputShape[] = [
      {
        kind: 'element-tool',
        name: 'add_heading_v0',
        arguments: { content: 'A' },
        raw: '',
      },
      {
        kind: 'element-tool',
        name: 'add_body_text_v0',
        arguments: { content: 'B paragraph body text' },
        raw: '',
      },
      {
        kind: 'element-tool',
        name: 'add_divider_v0',
        arguments: {},
        raw: '',
      },
    ];
    const result = await dispatchElementToolCalls(shapes);

    expect(result.status).toBe('applied');
    expect(result.results).toHaveLength(3);
    expect(result.insertedNodes.length).toBeGreaterThanOrEqual(3);
    // Exactly one batch pair
    expect(startSpy).toHaveBeenCalledTimes(1);
    expect(endSpy).toHaveBeenCalledTimes(1);
    // History: at most 1 new entry (batch commit). Can be 0 if the
    // empty-frame replacement made startBatch see "no-change" — but
    // typically 1 here.
    const undoAfter = useHistoryStore.getState().undoStack.length;
    expect(undoAfter - undoBefore).toBeLessThanOrEqual(1);

    startSpy.mockRestore();
    endSpy.mockRestore();
  });

  it('mix of applied + unsupported → status=partial, per-shape results preserved', async () => {
    const shapes: DesignOutputShape[] = [
      {
        kind: 'element-tool',
        name: 'add_heading_v0',
        arguments: { content: 'Real' },
        raw: '',
      },
      {
        kind: 'element-tool',
        name: 'add_fictional_xyz_v0', // not in registry → unsupported
        arguments: {},
        raw: '',
      },
    ];
    const result = await dispatchElementToolCalls(shapes);
    expect(result.status).toBe('partial');
    expect(result.results[0].status).toBe('applied');
    expect(result.results[1].status).toBe('unsupported');
    // insertedNodes sums across — only the first succeeded
    expect(result.insertedNodes.length).toBe(1);
  });

  it('all unsupported → status=all-failed', async () => {
    const shapes: DesignOutputShape[] = [
      {
        kind: 'element-tool',
        name: 'add_fictional_a_v0',
        arguments: {},
        raw: '',
      },
      {
        kind: 'element-tool',
        name: 'add_fictional_b_v0',
        arguments: {},
        raw: '',
      },
    ];
    const result = await dispatchElementToolCalls(shapes);
    expect(result.status).toBe('all-failed');
    expect(result.results.every((r) => r.status === 'unsupported')).toBe(true);
    expect(result.insertedNodes).toEqual([]);
  });

  it('preserves emit order in results (position N output matches position N input)', async () => {
    const shapes: DesignOutputShape[] = [
      {
        kind: 'element-tool',
        name: 'add_heading_v0',
        arguments: { content: 'First' },
        raw: '',
      },
      {
        kind: 'element-tool',
        name: 'add_fictional_xyz_v0',
        arguments: {},
        raw: '',
      },
      {
        kind: 'element-tool',
        name: 'add_body_text_v0',
        arguments: { content: 'Third paragraph text' },
        raw: '',
      },
    ];
    const result = await dispatchElementToolCalls(shapes);
    expect(result.results[0].toolName).toBe('add_heading_v0');
    expect(result.results[1].toolName).toBe('add_fictional_xyz_v0');
    expect(result.results[2].toolName).toBe('add_body_text_v0');
  });
});

/**
 * Drift-guard: the embedded shim registry MUST cover every tool
 * name pen-mcp exposes. Adding a pen-mcp element tool without
 * adding a matching pen-core builder + shim + Nitro SERVER_BUILDERS
 * entry silently breaks AI generation under the flag (tool gets
 * short-circuited to `unsupported` and the AI sees
 * advertised-but-not-executable output).
 */
describe('SUPPORTED_EMBEDDED_ELEMENT_TOOLS drift guard', () => {
  it('covers every add_*_vN name pen-mcp exposes', () => {
    const penMcpAddNames = [...ELEMENT_TOOL_NAMES].filter((n) => /^add_.+_v\d+$/.test(n));
    const shimSet = new Set(SUPPORTED_EMBEDDED_ELEMENT_TOOLS);
    const missing = penMcpAddNames.filter((n) => !shimSet.has(n));
    expect(
      missing,
      `Embedded shim registry is missing the following pen-mcp tools — add pen-core builder + ` +
        `shim + Nitro SERVER_BUILDERS entry together: ${missing.join(', ')}`,
    ).toEqual([]);
  });

  it('does not register shims for names pen-mcp does not expose', () => {
    // The other direction: a stale shim for a removed tool would
    // silently send stale output. Keep shim ⊆ pen-mcp exactly.
    const penMcpSet = new Set(ELEMENT_TOOL_NAMES);
    const orphaned = SUPPORTED_EMBEDDED_ELEMENT_TOOLS.filter((n) => !penMcpSet.has(n));
    expect(
      orphaned,
      `Shim registry has entries pen-mcp does not expose (stale / renamed tool): ${orphaned.join(', ')}`,
    ).toEqual([]);
  });
});

/**
 * Phase 2 M2/M3 invariants under test:
 *   1. Every dispatch runs inside exactly one startBatch/endBatch pair
 *   2. Known tool → client shim applies tree via addNode (status='applied')
 *   3. Unknown tool → HTTP fallback attempted; fetch failure in test env
 *      collapses to status='unsupported' (never silently drops)
 *   4. batch_design DSL → HTTP fallback (M3 stubs it, same collapse)
 *   5. Multiple back-to-back dispatches don't leak batches
 */

describe('dispatchElementToolCall — M2 shim + M3 HTTP fallback', () => {
  beforeEach(() => {
    useHistoryStore.getState().clear();
    // In test env there's no Nitro backend, so /api/mcp/exec-tool fetch
    // will fail. Explicitly stub fetch to reject so assertions are
    // deterministic (don't rely on jsdom fetch error phrasing).
    vi.stubGlobal(
      'fetch',
      vi.fn(() => Promise.reject(new Error('fetch not available in test env'))),
    );
  });

  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it('known element tool → applied via client shim + returns inserted node', async () => {
    const shape: DesignOutputShape = {
      kind: 'element-tool',
      name: 'add_card_row_v0',
      arguments: {
        items: [
          { title: 'Hiit', subtitle: '30 min' },
          { title: 'Yoga', subtitle: '25 min' },
        ],
      },
      raw: '<op_tool>...</op_tool>',
    };

    const result = await dispatchElementToolCall(shape);

    expect(result.status).toBe('applied');
    expect(result.route).toBe('element-tool');
    expect(result.toolName).toBe('add_card_row_v0');
    // Critical for orchestrator accounting: applied result MUST return
    // the inserted nodes so the caller can update its progress tally.
    // Without this the renderer-based tally stays at 0 and upstream
    // treats the subtask as "produced nothing → fail".
    expect(result.insertedNodes).toHaveLength(1);
    expect(result.insertedNodes[0].type).toBe('frame');
    expect(typeof result.insertedNodes[0].id).toBe('string');

    // Verify the inserted node actually reached the document store.
    // Can't assert on page-children length growing by 1 because
    // insertStreamingNode's frame branch may REPLACE an empty root
    // frame (generation replaces the placeholder) rather than
    // appending — both paths are valid "applied". Existence lookup
    // is the stable post-condition.
    const insertedId = result.insertedNodes[0].id;
    const docStore = useDocumentStore.getState();
    // Either the returned node id lives under a page, OR an id
    // remapped from it does (insertStreamingNode dedupes ids). The
    // "some id landed" check is what orchestrator accounting cares
    // about, which is what we surface to callers.
    const flat = docStore.getFlatNodes();
    const matched = flat.some((n) => n.id === insertedId);
    const anyCardFrame = flat.some((n) => (n as { role?: string }).role === 'scroll-row-wrapper');
    expect(matched || anyCardFrame).toBe(true);
  });

  it('element tool outside SUPPORTED_EMBEDDED_ELEMENT_TOOLS → short-circuits before HTTP', async () => {
    // The dispatcher must NOT waste an HTTP roundtrip on a tool the
    // Nitro endpoint is guaranteed to 404 (shim/server registries
    // are kept identical by convention). Fail fast with the canonical
    // supported list in the diagnostic so callers / UI can route to
    // batch_design instead.
    const fetchFn = vi.fn(() => Promise.reject(new Error('should not be called')));
    vi.stubGlobal('fetch', fetchFn);

    const shape: DesignOutputShape = {
      kind: 'element-tool',
      // Fictional tool name — all 42 pen-mcp element tools are now in
      // the shim registry so any real name would succeed. This name
      // is what the assertion below hinges on (short-circuit for
      // anything NOT in SUPPORTED_EMBEDDED_ELEMENT_TOOLS).
      name: 'add_fictional_future_v1',
      arguments: {},
      raw: '<op_tool>...</op_tool>',
    };

    const result = await dispatchElementToolCall(shape);

    expect(result.status).toBe('unsupported');
    expect(result.route).toBe('element-tool');
    expect(result.toolName).toBe('add_fictional_future_v1');
    expect(result.message).toContain('add_fictional_future_v1');
    // The canonical covered list is embedded in the diagnostic
    expect(result.message).toContain('add_card_row_v0');
    expect(result.message).toContain('batch_design');
    // Critical: no HTTP fallback attempt — we already know the
    // endpoint cannot handle this name either.
    expect(fetchFn).not.toHaveBeenCalled();
    expect(result.insertedNodes).toEqual([]);
  });

  it('batch-design-dsl route → runs in-browser (no HTTP), status=applied', async () => {
    // Post-#44: The advertised FALLBACK branch runs runBatchDesignDsl
    // directly against the document-store. The pure executor lives in
    // `@zseven-w/pen-mcp` and is guarded browser-safe by
    // `batch-design-dsl-browser-safe.test.ts`. HTTP is only the error
    // fallback now — this happy path MUST NOT hit the network.
    const fetchFn = vi.fn(() => Promise.reject(new Error('should not be called')));
    vi.stubGlobal('fetch', fetchFn);

    const result = await dispatchElementToolCall({
      kind: 'batch-design-dsl',
      dsl: 'root=I(null, {"type":"frame","name":"Batch Root","width":200,"height":200})',
      raw: '<op_tool>...</op_tool>',
    });

    expect(fetchFn).not.toHaveBeenCalled();
    expect(result.status).toBe('applied');
    expect(result.route).toBe('batch-design-dsl');
    expect(result.toolName).toBe('batch_design');
    // Pure executor emits one result per I() op → dispatcher extracts
    // the inserted node and forwards to caller for orchestrator accounting.
    expect(result.insertedNodes.length).toBeGreaterThanOrEqual(1);
  });

  it('batch-design-dsl route → malformed DSL surfaces per-op errors (status=failed)', async () => {
    // The pure executor collects per-line errors instead of throwing.
    // Dispatcher surfaces those verbatim so the user / orchestrator
    // sees which specific op failed, not a generic HTTP 500.
    const fetchFn = vi.fn(() => Promise.reject(new Error('should not be called')));
    vi.stubGlobal('fetch', fetchFn);

    const shape: DesignOutputShape = {
      kind: 'batch-design-dsl',
      dsl: 'not valid DSL at all',
      raw: '<op_tool>...</op_tool>',
    };

    const result = await dispatchElementToolCall(shape);

    expect(fetchFn).not.toHaveBeenCalled();
    expect(result.status).toBe('failed');
    expect(result.route).toBe('batch-design-dsl');
    expect(result.toolName).toBe('batch_design');
    expect(result.message).toContain('failing operation');
  });

  it('wraps every dispatch in exactly one startBatch/endBatch pair', async () => {
    const startSpy = vi.spyOn(useHistoryStore.getState(), 'startBatch');
    const endSpy = vi.spyOn(useHistoryStore.getState(), 'endBatch');

    await dispatchElementToolCall({
      kind: 'element-tool',
      name: 'add_card_row_v0',
      arguments: { items: [{ title: 'X' }] },
      raw: '',
    });

    expect(startSpy).toHaveBeenCalledTimes(1);
    expect(endSpy).toHaveBeenCalledTimes(1);

    const startCallOrder = startSpy.mock.invocationCallOrder[0];
    const endCallOrder = endSpy.mock.invocationCallOrder[0];
    expect(startCallOrder).toBeLessThan(endCallOrder);

    startSpy.mockRestore();
    endSpy.mockRestore();
  });

  it('batchDepth returns to 0 after dispatch (no leaked batches)', async () => {
    const shape: DesignOutputShape = {
      kind: 'element-tool',
      name: 'add_card_row_v0',
      arguments: { items: [{ title: 'X' }] },
      raw: '',
    };

    expect(useHistoryStore.getState().batchDepth).toBe(0);
    await dispatchElementToolCall(shape);
    expect(useHistoryStore.getState().batchDepth).toBe(0);
  });

  it('multiple dispatches each produce exactly one batch pair (no cross-call leak)', async () => {
    const startSpy = vi.spyOn(useHistoryStore.getState(), 'startBatch');
    const endSpy = vi.spyOn(useHistoryStore.getState(), 'endBatch');

    const shape: DesignOutputShape = {
      kind: 'element-tool',
      name: 'add_card_row_v0',
      arguments: { items: [{ title: 'A' }] },
      raw: '',
    };
    await dispatchElementToolCall(shape);
    await dispatchElementToolCall(shape);
    await dispatchElementToolCall(shape);

    expect(startSpy).toHaveBeenCalledTimes(3);
    expect(endSpy).toHaveBeenCalledTimes(3);
    expect(useHistoryStore.getState().batchDepth).toBe(0);

    startSpy.mockRestore();
    endSpy.mockRestore();
  });

  it('defaultParentId from ctx → rootless call lands under it + appends', async () => {
    // Mimics the orchestrator handing dispatcher subtask.parentFrameId:
    // the model's `<op_tool>` payload has no parent_id, but the
    // generation's root frame is the real target. Without ctx the
    // dispatcher would insert at page root (wrong scope). With ctx,
    // it must land inside the generation parent.
    const rootId = `gen-root-${Math.random().toString(36).slice(2)}`;
    useDocumentStore.getState().addNode(null, {
      id: rootId,
      type: 'frame',
      name: 'Generation Root',
      width: 800,
      height: 600,
      layout: 'vertical',
      children: [
        {
          id: `${rootId}-existing-child`,
          type: 'text',
          name: 'Existing',
          content: 'Already here',
        },
      ],
    });

    const result = await dispatchElementToolCall(
      {
        kind: 'element-tool',
        name: 'add_heading_v0',
        arguments: { content: 'New Heading' },
        raw: '',
      },
      { defaultParentId: rootId },
    );

    expect(result.status).toBe('applied');
    expect(result.message).toContain(rootId);

    const rootNode = useDocumentStore.getState().getNodeById(rootId) as
      | { children?: { id: string }[] }
      | undefined;
    const rootChildren = rootNode?.children ?? [];
    expect(rootChildren.length).toBe(2);
    // Append semantics — the new element lands AFTER the pre-existing
    // child, matching the streaming path's generation-order behavior.
    // (If the dispatcher reverted to prepend, this would fail because
    // the new element would take index 0 and push the existing child
    // to index 1.)
    expect(rootChildren[0].id).toBe(`${rootId}-existing-child`);
    expect(rootChildren[1].id).toBe(result.insertedNodes[0].id);
  });

  it('explicit parent_id wins over defaultParentId', async () => {
    // Two candidate parents seeded; model names the "real" one via
    // parent_id while ctx.defaultParentId points elsewhere. Payload
    // parent_id must win (the model had a specific intent).
    const ctxDefaultId = `ctx-default-${Math.random().toString(36).slice(2)}`;
    const realTargetId = `real-${Math.random().toString(36).slice(2)}`;
    const store = useDocumentStore.getState();
    store.addNode(null, {
      id: ctxDefaultId,
      type: 'frame',
      name: 'Ctx Default Parent',
      width: 400,
      height: 200,
      layout: 'vertical',
      children: [],
    });
    store.addNode(null, {
      id: realTargetId,
      type: 'frame',
      name: 'Real Target',
      width: 400,
      height: 200,
      layout: 'vertical',
      children: [],
    });

    const result = await dispatchElementToolCall(
      {
        kind: 'element-tool',
        name: 'add_heading_v0',
        arguments: { content: 'Targeted', parent_id: realTargetId },
        raw: '',
      },
      { defaultParentId: ctxDefaultId },
    );

    expect(result.status).toBe('applied');
    const realTarget = useDocumentStore.getState().getNodeById(realTargetId) as
      | { children?: unknown[] }
      | undefined;
    const ctxDefault = useDocumentStore.getState().getNodeById(ctxDefaultId) as
      | { children?: unknown[] }
      | undefined;
    expect(realTarget?.children?.length).toBe(1);
    expect(ctxDefault?.children?.length).toBe(0);
  });

  it('stale defaultParentId (node not in document) → failed, no write', async () => {
    // Simulates an orchestrator handing dispatcher a subtask.parentFrameId
    // that was valid when the plan was built but has since been deleted
    // / remapped out of the document. Without validation, insertStreamingNode
    // silently retargets via its module-global generationRootFrameId fallback
    // and the subtree lands somewhere the AI never intended — but the
    // dispatcher still reports `applied`. The validation catches this.
    const docBefore = useDocumentStore.getState().document;

    const result = await dispatchElementToolCall(
      {
        kind: 'element-tool',
        name: 'add_heading_v0',
        arguments: { content: 'Orphan Heading' },
        raw: '',
      },
      { defaultParentId: 'stale-or-never-existed' },
    );

    expect(result.status).toBe('failed');
    expect(result.message).toContain('stale-or-never-existed');
    expect(result.message).toContain('stale');
    // Document reference equality — no write happened despite the
    // insertStreamingNode path being set up to silently retarget.
    expect(useDocumentStore.getState().document).toBe(docBefore);
  });

  it('explicit parent_id valid + stale defaultParentId → applied (payload wins, default ignored)', async () => {
    // When the AI gives an explicit parent_id that resolves, the stale
    // ctx default must NOT block the apply — the payload takes precedence
    // and the default is never used, so its staleness is irrelevant.
    const realTargetId = `real-${Math.random().toString(36).slice(2)}`;
    useDocumentStore.getState().addNode(null, {
      id: realTargetId,
      type: 'frame',
      name: 'Explicit Target',
      width: 400,
      height: 300,
      layout: 'vertical',
      children: [],
    });

    const result = await dispatchElementToolCall(
      {
        kind: 'element-tool',
        name: 'add_heading_v0',
        arguments: { content: 'Targeted', parent_id: realTargetId },
        raw: '',
      },
      { defaultParentId: 'would-be-stale-but-unused' },
    );

    expect(result.status).toBe('applied');
    const target = useDocumentStore.getState().getNodeById(realTargetId) as
      | { children?: unknown[] }
      | undefined;
    expect(target?.children?.length).toBe(1);
  });

  it('parent_id pointing at a real node → applied under that parent', async () => {
    // Seed: insert a Section frame at root, then route a shim call
    // with parent_id pointing to it. Shim MUST honor parent_id, not
    // silently insert at root.
    const sectionId = `section-${Math.random().toString(36).slice(2)}`;
    useDocumentStore.getState().addNode(null, {
      id: sectionId,
      type: 'frame',
      name: 'Seeded Section',
      width: 800,
      height: 400,
      layout: 'vertical',
      children: [],
    });

    const result = await dispatchElementToolCall({
      kind: 'element-tool',
      name: 'add_card_row_v0',
      arguments: {
        parent_id: sectionId,
        items: [{ title: 'A' }, { title: 'B' }],
      },
      raw: '',
    });

    expect(result.status).toBe('applied');
    expect(result.message).toContain(sectionId);

    // Verify the inserted node actually landed UNDER the section,
    // not at the page root. This is the load-bearing assertion
    // guarding against the "silently drops parent_id" regression.
    const parent = useDocumentStore.getState().getNodeById(sectionId);
    const parentChildren = (parent as { children?: unknown[] } | undefined)?.children ?? [];
    expect(parentChildren.length).toBe(1);
    expect((parentChildren[0] as { id?: string }).id).toBe(result.insertedNodes[0].id);
  });

  it('parent_id pointing at a missing node → failed, no write', async () => {
    const docBefore = useDocumentStore.getState().document;

    const result = await dispatchElementToolCall({
      kind: 'element-tool',
      name: 'add_card_row_v0',
      arguments: {
        parent_id: 'definitely-does-not-exist',
        items: [{ title: 'A' }],
      },
      raw: '',
    });

    expect(result.status).toBe('failed');
    expect(result.message).toContain('definitely-does-not-exist');
    // Store must not have been written. batchDepth=0 means endBatch ran;
    // the base snapshot === current means nothing landed.
    expect(useDocumentStore.getState().document).toBe(docBefore);
  });

  it('pageId matching active page → applied', async () => {
    const activePageId = useCanvasStore.getState().activePageId;
    // Only meaningful when there IS an active page — otherwise the
    // "null activePageId" branch is exercised elsewhere. Bail out
    // rather than assert misleadingly.
    if (!activePageId) return;

    const result = await dispatchElementToolCall({
      kind: 'element-tool',
      name: 'add_heading_v0',
      arguments: { content: 'Same Page', pageId: activePageId },
      raw: '',
    });

    expect(result.status).toBe('applied');
  });

  it('filePath set to a real path → failed with hint, no write (client shim has no file I/O)', async () => {
    const docBefore = useDocumentStore.getState().document;

    const result = await dispatchElementToolCall({
      kind: 'element-tool',
      name: 'add_heading_v0',
      arguments: { content: 'Saved Heading', filePath: '/tmp/design.op' },
      raw: '',
    });

    expect(result.status).toBe('failed');
    expect(result.message).toContain('/tmp/design.op');
    expect(result.message).toContain('stdio');
    // Store reference equality — neither an addNode nor applyExternalDocument
    // may have fired (filePath rejection happens before either).
    expect(useDocumentStore.getState().document).toBe(docBefore);
  });

  it('filePath="live://canvas" is the live-canvas sentinel → applied', async () => {
    // pen-mcp's resolveDocPath treats `live://canvas` the same as
    // undefined. Rejecting it as "unsupported filePath" would be a
    // regression — the sentinel IS the default target. This test
    // pairs with the "/tmp/design.op → failed" case above.
    const result = await dispatchElementToolCall({
      kind: 'element-tool',
      name: 'add_heading_v0',
      arguments: { content: 'Sentinel Heading', filePath: 'live://canvas' },
      raw: '',
    });

    expect(result.status).toBe('applied');
    expect(result.insertedNodes).toHaveLength(1);
  });

  it('pageId NOT matching active page → failed with hint, no write', async () => {
    const docBefore = useDocumentStore.getState().document;

    const result = await dispatchElementToolCall({
      kind: 'element-tool',
      name: 'add_heading_v0',
      arguments: { content: 'Wrong Page', pageId: 'some-other-page-id' },
      raw: '',
    });

    expect(result.status).toBe('failed');
    expect(result.message).toContain('some-other-page-id');
    expect(result.message).toContain('HTTP fallback');
    expect(useDocumentStore.getState().document).toBe(docBefore);
  });

  it('N successful shim applies collapse into 1 history undo entry', async () => {
    // Record undoStack size before a batch of 3 applies
    const undoBefore = useHistoryStore.getState().undoStack.length;

    const shape: DesignOutputShape = {
      kind: 'element-tool',
      name: 'add_card_row_v0',
      arguments: { items: [{ title: 'A' }, { title: 'B' }] },
      raw: '',
    };
    // Each dispatch is its own batch (orchestrator emits one <op_tool>
    // per response), so 3 dispatches = 3 undo entries. This is the
    // *intended* M1/M2 behavior — the batch-per-tool-call invariant is
    // about preventing N-PER-TOOL-call fragmentation from internal
    // store writes, not about collapsing multiple tool calls across
    // a whole generation (that's an orchestrator-level concern).
    await dispatchElementToolCall(shape);
    await dispatchElementToolCall(shape);
    await dispatchElementToolCall(shape);

    const undoAfter = useHistoryStore.getState().undoStack.length;
    // 3 dispatches → exactly 3 batches committed (one per dispatch)
    expect(undoAfter - undoBefore).toBe(3);
  });
});

// afterEach is imported implicitly via vitest globals; re-import for clarity
import { afterEach } from 'vitest';

describe('looksLikeJsonl', () => {
  // Each weak/mid-tier model puts the JSONL in a slightly different
  // wrapper. The detector has to recognize ALL of them so the
  // dispatcher can re-route to applyBatchDesignAsJsonl instead of
  // failing the whole subtask via the DSL parser.

  it('matches pure JSONL (one node per line, starts with {)', () => {
    const jsonl =
      '{"_parent":null,"id":"r","type":"frame","children":[]}\n' +
      '{"_parent":"r","id":"t","type":"text","content":"hi"}';
    expect(looksLikeJsonl(jsonl)).toBe(true);
  });

  it('matches JSON-array-of-nodes (M2.7 food-app shape)', () => {
    // Real failure from the M2.7 food-app run: model emitted the full
    // subtask design wrapped in a single JSON array literal. The
    // previous gate only checked `startsWith('{')` so this fell
    // through to the DSL parser, every line of `[`/`{`/`}` failed,
    // and the subtask returned empty → orchestrator retry → minimal-
    // skills fallback → still empty → user got a single-frame
    // placeholder with one section instead of the full design.
    const jsonArray = `[
  {
    "id": "filterChips-root",
    "type": "frame",
    "name": "Filter Chips Section",
    "role": "section",
    "_parent": null,
    "children": []
  }
]`;
    expect(looksLikeJsonl(jsonArray)).toBe(true);
  });

  it('matches array shape with leading whitespace (newlines / indent)', () => {
    const padded = '\n\n  [{"_parent":null,"id":"x","type":"frame"}]';
    expect(looksLikeJsonl(padded)).toBe(true);
  });

  it('rejects DSL-style assignment lines (foo=I("parent",{...}))', () => {
    // Real DSL: dispatch should NOT re-route to JSONL apply.
    const dsl = 'root=I(null,{"type":"frame"})\nlabel=I(root,{"type":"text"})';
    expect(looksLikeJsonl(dsl)).toBe(false);
  });

  it('rejects empty / non-bracketed strings', () => {
    expect(looksLikeJsonl('')).toBe(false);
    expect(looksLikeJsonl('   ')).toBe(false);
    expect(looksLikeJsonl('hello world')).toBe(false);
  });

  it('rejects bracketed-but-non-JSONL content (e.g. JSON array of strings)', () => {
    // A `[ ... ]` literal that doesn't carry JSONL shape keys (no
    // `_parent`, no PenNode `type`) should NOT match — otherwise
    // we'd reroute legit non-design array operations into the
    // JSONL apply path.
    expect(looksLikeJsonl('["foo", "bar"]')).toBe(false);
    expect(looksLikeJsonl('[{"foo": "bar"}]')).toBe(false);
  });
});
