import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import type { NodeTree, Node } from '@craftjs/core';
import { useKeyboardShortcuts } from './useKeyboardShortcuts';
import { getClipboardTree, setClipboardTree } from './clipboard';

/**
 * Regression coverage: Ctrl/Cmd+V must run the copied subtree through
 * `regenerateTreeIds` BEFORE handing it to `actions.addNodeTree`. Pasting
 * with the source node's ORIGINAL ids re-inserted them into the live Craft.js
 * node map, producing duplicate ids and corrupting the tree -- that bug is
 * exactly what this suite guards against. Also covers the sibling-parent /
 * ROOT-fallback targeting, the empty-clipboard no-op, and the existing
 * input-focus guard.
 *
 * Also covers the cross-page clipboard fix: copy stores a detached TREE
 * SNAPSHOT (`setClipboardTree`), not a bare node id -- so paste never needs
 * to re-resolve the original node via `query.node(id)`, which is exactly
 * what breaks once the canvas has been re-deserialized to a different page
 * (see `hooks/clipboard.ts` and the cross-page integration test in
 * `test-utils/integration/duplicate-paste.integration.test.tsx`).
 *
 * Mock pattern mirrors PageContext.pure-updaters.test.tsx /
 * PageContext.slug.test.tsx: a fake `useEditor` exposing `query`/`actions`,
 * mounted via a bare consumer component, with REAL `keydown` events
 * dispatched on `document` (where useKeyboardShortcuts attaches its
 * listener) to exercise the actual handler rather than calling it directly.
 *
 * `regenerateTreeIds` itself is NOT mocked -- we partially mock
 * `../utils/craft-tree` to wrap the real implementation in a `vi.fn()` so we
 * can assert it was called, while still getting genuinely fresh ids back.
 */

const addNodeTreeMock = vi.fn();
const selectNodeMock = vi.fn();
let selectedIds: string[] = [];

function makeNode(id: string, parent: string | null, children: string[] = []): Node {
  return {
    id,
    data: {
      props: {},
      type: { resolvedName: 'Container' },
      name: 'Container',
      displayName: 'Container',
      isCanvas: false,
      parent,
      linkedNodes: {},
      nodes: children,
      hidden: false,
    },
    info: {},
    events: { selected: false, dragged: false, hovered: false },
    dom: null,
    related: {},
    rules: {},
    _hydrationTimestamp: 0,
  } as unknown as Node;
}

// The tree `toNodeTree()` returns for the node that was Ctrl+C'd -- a root
// with one child, so regeneration has more than one id to remap.
const COPIED_TREE: NodeTree = {
  rootNodeId: 'copied-root-1',
  nodes: {
    'copied-root-1': makeNode('copied-root-1', null, ['copied-child-1']),
    'copied-child-1': makeNode('copied-child-1', 'copied-root-1'),
  },
};

const nodeStore: Record<string, { data: { parent: string | null; nodes?: string[] } }> = {
  'selected-1': { data: { parent: 'parent-container-1' } },
  'parent-container-1': { data: { parent: null, nodes: ['other-sibling', 'selected-1'] } },
  ROOT: { data: { parent: null, nodes: [] } },
  'copied-root-1': { data: { parent: 'wherever-it-originally-lived' } },
};

function makeQueryNode(id: string) {
  return {
    get: () => nodeStore[id] ?? null,
    toNodeTree: () => {
      if (id !== 'copied-root-1') {
        throw new Error(`unexpected toNodeTree() call for "${id}"`);
      }
      return COPIED_TREE;
    },
  };
}

vi.mock('@craftjs/core', () => ({
  useEditor: () => ({
    query: {
      getEvent: () => ({ all: () => selectedIds }),
      node: (id: string) => makeQueryNode(id),
    },
    actions: {
      addNodeTree: addNodeTreeMock,
      selectNode: selectNodeMock,
      history: { undo: vi.fn(), redo: vi.fn() },
      delete: vi.fn(),
      clearEvents: vi.fn(),
    },
  }),
}));

// Partial mock: keep the real `regenerateTreeIds` implementation (so pasted
// trees genuinely get fresh ids) but wrap it in a spy so we can assert the
// handler actually calls it, rather than only inferring that from the
// output.
vi.mock('../utils/craft-tree', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../utils/craft-tree')>();
  return {
    ...actual,
    regenerateTreeIds: vi.fn(actual.regenerateTreeIds),
  };
});

import { regenerateTreeIds } from '../utils/craft-tree';
const regenerateTreeIdsMock = vi.mocked(regenerateTreeIds);

let container: HTMLDivElement;
let root: Root;

function render(ui: React.ReactElement) {
  container = document.createElement('div');
  document.body.appendChild(container);
  act(() => {
    root = createRoot(container);
    root.render(ui);
  });
}

function unmount() {
  act(() => {
    root.unmount();
  });
  container.remove();
}

function pressKey(key: string, opts: Partial<KeyboardEventInit> = {}) {
  act(() => {
    document.dispatchEvent(
      new KeyboardEvent('keydown', { key, ctrlKey: true, bubbles: true, cancelable: true, ...opts }),
    );
  });
}

const Consumer: React.FC = () => {
  useKeyboardShortcuts();
  return null;
};

beforeEach(() => {
  selectedIds = [];
  addNodeTreeMock.mockClear();
  selectNodeMock.mockClear();
  regenerateTreeIdsMock.mockClear();
  setClipboardTree(null);
});

afterEach(() => {
  setClipboardTree(null);
  if (root) unmount();
});

describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
  test('Ctrl+C copies the selected node`s subtree (a tree snapshot, not a bare id) to the clipboard', () => {
    render(<Consumer />);
    selectedIds = ['copied-root-1'];

    pressKey('c');

    const clip = getClipboardTree();
    expect(clip).not.toBeNull();
    expect(clip!.rootNodeId).toBe('copied-root-1');
    expect(Object.keys(clip!.nodes).sort()).toEqual(['copied-child-1', 'copied-root-1']);
  });

  test('Ctrl+V pastes as a sibling of the selection, immediately after it, with FRESH ids', () => {
    render(<Consumer />);

    selectedIds = ['copied-root-1'];
    pressKey('c');
    expect(getClipboardTree()!.rootNodeId).toBe('copied-root-1');

    selectedIds = ['selected-1'];
    pressKey('v');

    // regenerateTreeIds actually ran before the tree was handed to Craft.js.
    expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
    expect(regenerateTreeIdsMock).toHaveBeenCalledWith(getClipboardTree());

    expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
    const [pastedTree, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];

    // Sibling of the current selection: selected-1's data.parent.
    expect(targetParent).toBe('parent-container-1');
    // Immediately after selected-1 (index 1 among parent-container-1's
    // children), matching duplicate()'s "insert right after" UX.
    expect(insertIndex).toBe(2);

    // The regression this guards: pasted ids must be fresh, never reuse the
    // ids the copied node already occupies in the live Craft.js tree.
    expect(pastedTree.rootNodeId).not.toBe(COPIED_TREE.rootNodeId);
    const originalIds = new Set(Object.keys(COPIED_TREE.nodes));
    const pastedIds = new Set(Object.keys(pastedTree.nodes));
    expect(pastedIds.size).toBe(originalIds.size);
    for (const id of pastedIds) {
      expect(originalIds.has(id)).toBe(false);
    }

    // The new copy is selected, same as duplicate()'s existing UX.
    expect(selectNodeMock).toHaveBeenCalledWith(pastedTree.rootNodeId);
  });

  test('Ctrl+V with selection at ROOT falls back to appending into ROOT', () => {
    render(<Consumer />);

    selectedIds = ['copied-root-1'];
    pressKey('c');

    selectedIds = ['ROOT'];
    pressKey('v');

    expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
    const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
    expect(targetParent).toBe('ROOT');
    expect(insertIndex).toBeUndefined();
  });

  test('Ctrl+V with nothing selected falls back to appending into ROOT', () => {
    render(<Consumer />);

    selectedIds = ['copied-root-1'];
    pressKey('c');

    selectedIds = [];
    pressKey('v');

    expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
    const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
    expect(targetParent).toBe('ROOT');
    expect(insertIndex).toBeUndefined();
  });

  test('Ctrl+V with an empty clipboard is a no-op', () => {
    render(<Consumer />);
    selectedIds = ['selected-1'];

    pressKey('v');

    expect(addNodeTreeMock).not.toHaveBeenCalled();
    expect(regenerateTreeIdsMock).not.toHaveBeenCalled();
  });

  test('shortcuts are ignored while focus is in an input element', () => {
    const input = document.createElement('input');
    document.body.appendChild(input);
    input.focus();
    expect(document.activeElement).toBe(input);

    render(<Consumer />);
    selectedIds = ['selected-1'];

    pressKey('c');
    expect(getClipboardTree()).toBeNull();

    setClipboardTree(COPIED_TREE);
    pressKey('v');
    expect(addNodeTreeMock).not.toHaveBeenCalled();

    input.remove();
  });

  test('shortcuts are ignored while focus is in a contentEditable element', () => {
    // jsdom does not implement `HTMLElement.isContentEditable` (it always
    // reports false regardless of the contentEditable attribute -- a known
    // jsdom limitation), so a real contentEditable + focus() can't exercise
    // this branch of the guard. Stub `document.activeElement` directly with
    // a fake element that reports isContentEditable: true, matching what
    // the guard (`isInputFocused` in useKeyboardShortcuts.ts) actually reads.
    const fakeEditable = { tagName: 'DIV', isContentEditable: true } as unknown as Element;
    const activeElementSpy = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(fakeEditable);

    render(<Consumer />);
    selectedIds = ['selected-1'];

    pressKey('c');
    expect(getClipboardTree()).toBeNull();

    activeElementSpy.mockRestore();
  });
});
