import { describe, test, expect } from 'vitest';
import type { NodeTree, Node } from '@craftjs/core';
import type { SerializedTreeNode } from '../types/sitesmith';
import { regenerateTreeIds, flattenTreeForCraft, sanitizeAiTree } from './craft-tree';

function makeNode(overrides: Partial<Node>): Node {
  return {
    id: 'placeholder',
    data: {
      props: {},
      type: 'div',
      name: 'div',
      displayName: 'div',
      isCanvas: false,
      parent: null,
      linkedNodes: {},
      nodes: [],
      hidden: false,
    },
    info: {},
    events: { selected: false, dragged: false, hovered: false },
    dom: null,
    related: {},
    rules: {
      canDrag: () => true,
      canDrop: () => true,
      canMoveIn: () => true,
      canMoveOut: () => true,
    },
    _hydrationTimestamp: 0,
    ...overrides,
  } as Node;
}

function makeTree(): NodeTree {
  // root -> [childA, childB]; root also has a linkedNodes entry -> childB
  const root = makeNode({
    id: 'root-1',
    data: {
      ...makeNode({}).data,
      parent: null,
      nodes: ['child-a-1'],
      linkedNodes: { slot: 'child-b-1' },
    },
  });
  const childA = makeNode({
    id: 'child-a-1',
    data: {
      ...makeNode({}).data,
      parent: 'root-1',
      nodes: [],
      linkedNodes: {},
    },
  });
  const childB = makeNode({
    id: 'child-b-1',
    data: {
      ...makeNode({}).data,
      parent: 'root-1',
      nodes: [],
      linkedNodes: {},
    },
  });

  return {
    rootNodeId: 'root-1',
    nodes: {
      'root-1': root,
      'child-a-1': childA,
      'child-b-1': childB,
    },
  };
}

describe('regenerateTreeIds', () => {
  test('produces an id set fully disjoint from the input', () => {
    const input = makeTree();
    const inputIds = Object.keys(input.nodes);
    const output = regenerateTreeIds(input);
    const outputIds = Object.keys(output.nodes);

    for (const id of outputIds) {
      expect(inputIds).not.toContain(id);
    }
  });

  test('rootNodeId is a key in nodes and equals that node id', () => {
    const output = regenerateTreeIds(makeTree());
    expect(output.nodes[output.rootNodeId]).toBeDefined();
    expect(output.nodes[output.rootNodeId].id).toBe(output.rootNodeId);
  });

  test('every child data.parent points to a NEW id that exists in the output', () => {
    const output = regenerateTreeIds(makeTree());
    for (const id of Object.keys(output.nodes)) {
      const node = output.nodes[id];
      if (node.data.parent !== null) {
        expect(output.nodes[node.data.parent]).toBeDefined();
      }
    }
    // specifically the two children under the (new) root
    const newRoot = output.nodes[output.rootNodeId];
    expect(newRoot.data.nodes.length).toBe(1);
    const newChildAId = newRoot.data.nodes[0];
    expect(output.nodes[newChildAId].data.parent).toBe(output.rootNodeId);
  });

  test('linkedNodes values are remapped to existing new ids', () => {
    const output = regenerateTreeIds(makeTree());
    const newRoot = output.nodes[output.rootNodeId];
    const linkedId = newRoot.data.linkedNodes.slot;
    expect(linkedId).toBeDefined();
    expect(output.nodes[linkedId]).toBeDefined();
    // linked node's parent should still reference the new root
    expect(output.nodes[linkedId].data.parent).toBe(output.rootNodeId);
  });

  test('structure/count is preserved', () => {
    const input = makeTree();
    const output = regenerateTreeIds(input);
    expect(Object.keys(output.nodes).length).toBe(Object.keys(input.nodes).length);
    expect(output.nodes[output.rootNodeId].data.nodes.length).toBe(
      input.nodes[input.rootNodeId].data.nodes.length
    );
  });

  test('does not mutate the input tree', () => {
    const input = makeTree();
    const snapshot = JSON.parse(JSON.stringify(input));
    regenerateTreeIds(input);
    expect(JSON.parse(JSON.stringify(input))).toEqual(snapshot);
  });

  test('output node data.props is a deep clone, not shared with the input node', () => {
    const input = makeTree();
    input.nodes['root-1'].data.props = { alignment: 'left' };

    const output = regenerateTreeIds(input);
    const outputRoot = output.nodes[output.rootNodeId];

    // Clone must preserve content at the time of cloning.
    expect(outputRoot.data.props).toEqual({ alignment: 'left' });

    // Mutating the output's props (simulating Craft.js's in-place setProp)
    // must NOT affect the input node's props object.
    (outputRoot.data.props as { alignment: string }).alignment = 'right';

    expect(input.nodes['root-1'].data.props).toEqual({ alignment: 'left' });
  });

  // Regression test (found via real-browser Playwright touch verification,
  // Phase B): a LIVE Craft.js node's `data.type` is the actual component
  // function reference (not the `{resolvedName}` string wrapper used only
  // in SERIALIZED state) -- `query.node(id).toNodeTree()` returns nodes
  // shaped exactly like this. The previous implementation ran
  // `structuredClone(oldNode.data)` on the WHOLE data object, which throws
  // `DataCloneError` the instant `data.type` is a function, silently
  // breaking Duplicate (ContextMenu.tsx / useNodeActions.ts) and Ctrl+V
  // Paste (useKeyboardShortcuts.ts) for every real node in the app --
  // masked by every other test in this suite using a plain string `type`
  // ('div'), and by unit tests elsewhere that mock `@craftjs/core` entirely
  // (so `toNodeTree()` never actually returns a function there).
  test('does not throw when data.type is a live component function reference', () => {
    const ButtonLikeComponent = (props: { text?: string }) => props.text;
    const input = makeTree();
    input.nodes['root-1'].data.type = ButtonLikeComponent as unknown as Node['data']['type'];
    input.nodes['root-1'].data.props = { text: 'Click Me' };

    expect(() => regenerateTreeIds(input)).not.toThrow();

    const output = regenerateTreeIds(input);
    const outputRoot = output.nodes[output.rootNodeId];
    // The component reference itself is preserved (shared, not cloned --
    // it's a stable reference, not mutable data).
    expect(outputRoot.data.type).toBe(ButtonLikeComponent);
    // Props are still genuinely deep-cloned (the actual bug fix's point).
    expect(outputRoot.data.props).toEqual({ text: 'Click Me' });
    (outputRoot.data.props as { text: string }).text = 'changed';
    expect(input.nodes['root-1'].data.props).toEqual({ text: 'Click Me' });
  });
});

describe('flattenTreeForCraft', () => {
  test('style: [] normalizes to {}', () => {
    const tree: SerializedTreeNode = {
      type: { resolvedName: 'Heading' },
      props: { node_id: 'h1', style: [] as unknown as Record<string, unknown> },
      nodes: [],
    };
    const { nodes } = flattenTreeForCraft(tree);
    expect(nodes['h1'].props.style).toEqual({});
  });

  test('a ColumnLayout with direct children flattens them into col-N linkedNodes', () => {
    const tree: SerializedTreeNode = {
      type: { resolvedName: 'ColumnLayout' },
      props: { node_id: 'cols' },
      nodes: [
        { type: { resolvedName: 'Heading' }, props: { node_id: 'a' }, nodes: [] },
        { type: { resolvedName: 'Heading' }, props: { node_id: 'b' }, nodes: [] },
      ],
    };
    const { nodes } = flattenTreeForCraft(tree);
    expect(nodes['cols'].nodes).toEqual([]);
    expect(nodes['cols'].linkedNodes).toEqual({ 'col-0': 'a', 'col-1': 'b' });
    expect(nodes['cols'].props.columns).toBe(2);
    expect(nodes['a'].parent).toBe('cols');
    expect(nodes['a'].isCanvas).toBe(true);
    expect(nodes['b'].parent).toBe('cols');
  });

  test('a Section with direct children wraps them in a synthetic section-inner Container', () => {
    const tree: SerializedTreeNode = {
      type: { resolvedName: 'Section' },
      props: { node_id: 'sec' },
      nodes: [
        { type: { resolvedName: 'Heading' }, props: { node_id: 'h1' }, nodes: [] },
      ],
    };
    const { rootNodeId, nodes, syntheticIds } = flattenTreeForCraft(tree);
    expect(rootNodeId).toBe('sec');
    const innerId = nodes['sec'].linkedNodes['section-inner'];
    expect(innerId).toBe('sec__section-inner');
    expect(syntheticIds.has(innerId)).toBe(true);
    expect(nodes[innerId].nodes).toEqual(['h1']);
    expect(nodes[innerId].type.resolvedName).toBe('Container');
    expect(nodes['h1'].parent).toBe(innerId);
    expect(nodes['sec'].nodes).toEqual([]);
  });

  test('a small tree with both a ColumnLayout and a SHELL_INNER wrapper flattens the same way regardless of caller intent', () => {
    // Same shape both PageContext.treeToCraftState and apply-ai-response's
    // buildNodeTree feed through this helper — the walk itself must not care
    // which one is calling it.
    const tree: SerializedTreeNode = {
      type: { resolvedName: 'Section' },
      props: { node_id: 'sec' },
      nodes: [
        {
          type: { resolvedName: 'ColumnLayout' },
          props: { node_id: 'cols' },
          nodes: [
            { type: { resolvedName: 'Heading' }, props: { node_id: 'a' }, nodes: [] },
          ],
        },
      ],
    };
    const run1 = flattenTreeForCraft(tree);
    const run2 = flattenTreeForCraft(tree);
    expect(run1.rootNodeId).toBe(run2.rootNodeId);
    expect(Object.keys(run1.nodes).sort()).toEqual(Object.keys(run2.nodes).sort());
    const innerId = run1.nodes['sec'].linkedNodes['section-inner'];
    expect(run1.nodes[innerId].linkedNodes).toEqual({});
    expect(run1.nodes[innerId].nodes).toEqual(['cols']);
    expect(run1.nodes['cols'].linkedNodes).toEqual({ 'col-0': 'a' });
    expect(run1.nodes['a'].parent).toBe('cols');
  });

  test('auto-generates an id when node_id is missing', () => {
    const tree: SerializedTreeNode = { type: { resolvedName: 'Heading' }, props: {}, nodes: [] };
    const { rootNodeId, nodes } = flattenTreeForCraft(tree);
    expect(typeof rootNodeId).toBe('string');
    expect(nodes[rootNodeId]).toBeDefined();
  });

  test('isCanvas is true only for Container', () => {
    const containerTree: SerializedTreeNode = { type: { resolvedName: 'Container' }, props: { node_id: 'c1' }, nodes: [] };
    const headingTree: SerializedTreeNode = { type: { resolvedName: 'Heading' }, props: { node_id: 'h1' }, nodes: [] };
    expect(flattenTreeForCraft(containerTree).nodes['c1'].isCanvas).toBe(true);
    expect(flattenTreeForCraft(headingTree).nodes['h1'].isCanvas).toBe(false);
  });
});

describe('sanitizeAiTree (re-exported for treeToCraftState parity with buildNodeTree)', () => {
  test('unknown resolvedName at the root is rejected (returns null)', () => {
    const tree = { type: { resolvedName: 'NotARealComponent' }, props: {}, nodes: [] };
    expect(sanitizeAiTree(tree, new Set())).toBeNull();
  });

  test('unknown resolvedName on a nested child drops only that subtree', () => {
    const tree = {
      type: { resolvedName: 'Section' },
      props: { node_id: 's1' },
      nodes: [
        { type: { resolvedName: 'NotARealComponent' }, props: {}, nodes: [] },
        { type: { resolvedName: 'Heading' }, props: { node_id: 'h1' }, nodes: [] },
      ],
    };
    const out = sanitizeAiTree(tree, new Set());
    expect(out!.nodes!.length).toBe(1);
    expect(out!.nodes![0].props.node_id).toBe('h1');
  });
});
