import { describe, test, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { CodeEditor, type CodeEditorHandle } from './CodeEditor';

/* ---------- DOM test harness (no @testing-library/react in this repo; see
   src/ui/AssetPicker.test.tsx / src/ui/Modal.test.tsx for the same
   react-dom/client + react-dom/test-utils `act` pattern). ----------

   CodeMirror is loaded via dynamic import() (see CodeEditor.tsx), which is
   always async -- even for an already-resolved/cached module, `import()`
   only settles on a later microtask. That means immediately after the
   initial synchronous `act(() => root.render(...))` below, the component is
   still in its 'loading' state and renders the <textarea> fallback. These
   tests deliberately assert against that first-tick DOM (never awaiting
   the CodeMirror promise), so they exercise exactly the fallback path a
   real headless/offline environment would fall back to, deterministically
   and without needing to mock @codemirror/*. */

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);
  });
}

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

function fallbackTextarea(): HTMLTextAreaElement {
  const el = container.querySelector<HTMLTextAreaElement>('[data-testid="code-editor-fallback"]');
  if (!el) throw new Error('fallback textarea not found');
  return el;
}

describe('CodeEditor', () => {
  test('shows the textarea fallback immediately (CodeMirror loads async)', () => {
    render(<CodeEditor value="<p>hi</p>" onChange={vi.fn()} />);
    const textarea = fallbackTextarea();
    expect(textarea.value).toBe('<p>hi</p>');
  });

  test('the CodeMirror mount root is hidden while the fallback is showing', () => {
    render(<CodeEditor value="" onChange={vi.fn()} />);
    const cmRoot = container.querySelector<HTMLDivElement>('[data-testid="code-editor-cm-root"]');
    expect(cmRoot?.style.display).toBe('none');
  });

  test('onChange fires with the new value when typing in the fallback textarea', () => {
    const onChange = vi.fn();
    render(<CodeEditor value="<p>hi</p>" onChange={onChange} />);
    const textarea = fallbackTextarea();
    act(() => {
      const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')!.set!;
      setter.call(textarea, '<p>updated</p>');
      textarea.dispatchEvent(new Event('input', { bubbles: true }));
    });
    expect(onChange).toHaveBeenCalledWith('<p>updated</p>');
  });

  test('accepts a language prop without throwing (html/css/javascript/auto)', () => {
    for (const language of ['html', 'css', 'javascript', 'auto'] as const) {
      expect(() => render(<CodeEditor value="" onChange={vi.fn()} language={language} />)).not.toThrow();
      const textarea = fallbackTextarea();
      expect(textarea.dataset.language).toBe(language);
      act(() => { root.unmount(); });
      container.remove();
    }
  });

  test('defaults to html language when none is passed', () => {
    render(<CodeEditor value="" onChange={vi.fn()} />);
    expect(fallbackTextarea().dataset.language).toBe('html');
  });

  test('respects a custom height', () => {
    render(<CodeEditor value="" onChange={vi.fn()} height={480} />);
    expect((container.firstElementChild as HTMLElement).style.height).toBe('480px');
  });
});

describe('CodeEditor imperative handle (textarea fallback mode)', () => {
  test('insertAtCursor replaces the selection and emits onChange', async () => {
    const onChange = vi.fn();
    const ref = React.createRef<CodeEditorHandle>();
    render(<CodeEditor ref={ref} value="<div></div>" onChange={onChange} />);

    const ta = container.querySelector('[data-testid="code-editor-fallback"]') as HTMLTextAreaElement;
    expect(ta).not.toBeNull();
    ta.selectionStart = 5;
    ta.selectionEnd = 5;

    act(() => {
      ref.current!.insertAtCursor('<p></p>');
    });

    expect(onChange).toHaveBeenCalledWith('<div><p></p></div>');
  });

  test('getValue returns the current document', () => {
    const ref = React.createRef<CodeEditorHandle>();
    render(<CodeEditor ref={ref} value="<span>x</span>" onChange={vi.fn()} />);
    expect(ref.current!.getValue()).toBe('<span>x</span>');
  });

  test('caretOffset positions the caret inside the inserted snippet', () => {
    const ref = React.createRef<CodeEditorHandle>();
    render(<CodeEditor ref={ref} value="" onChange={vi.fn()} />);
    const ta = container.querySelector('[data-testid="code-editor-fallback"]') as HTMLTextAreaElement;
    act(() => {
      ref.current!.insertAtCursor('<p></p>', 3);
    });
    expect(ta.selectionStart).toBe(3);
  });

  test('insertAtCursor replaces a non-empty selection (not just an empty caret)', () => {
    const onChange = vi.fn();
    const ref = React.createRef<CodeEditorHandle>();
    render(<CodeEditor ref={ref} value="<div>old</div>" onChange={onChange} />);

    const ta = container.querySelector('[data-testid="code-editor-fallback"]') as HTMLTextAreaElement;
    // "<div>old</div>" -- select "old" (indices 5-8).
    ta.selectionStart = 5;
    ta.selectionEnd = 8;

    act(() => {
      ref.current!.insertAtCursor('new');
    });

    expect(onChange).toHaveBeenCalledWith('<div>new</div>');
  });
});

describe('CodeEditor imperative handle: fallback insert survives a later CodeMirror mount', () => {
  // Regression test for a data-loss bug: insertAtCursor's textarea-fallback
  // branch used to advance lastEmittedRef to the post-insertion value. The
  // mount effect's dynamic import() closes over `value` as of the initial
  // render, so if CodeMirror finishes loading *after* a fallback-mode
  // insertion, it mounts with the pre-insertion doc. The only thing that
  // repairs that is the value-sync effect, which is gated on `value !==
  // lastEmittedRef.current` -- advancing lastEmittedRef made that gate see
  // them as already equal and skip the repair, silently dropping the
  // insertion. This test drives the component through that exact sequence
  // using the real @codemirror/* packages (no mocks, no fake timers) to
  // prove the fix holds.
  test('insertAtCursor in fallback mode is not lost once CodeMirror mounts', async () => {
    const handleRef = React.createRef<CodeEditorHandle>();
    function Harness() {
      const [value, setValue] = React.useState('<div></div>');
      return <CodeEditor ref={handleRef} value={value} onChange={setValue} />;
    }
    render(<Harness />);

    // First tick: the dynamic import() chain is always async, so this is
    // still the textarea fallback (see the file-level comment above).
    const ta = container.querySelector('[data-testid="code-editor-fallback"]') as HTMLTextAreaElement;
    expect(ta).not.toBeNull();
    ta.selectionStart = 5;
    ta.selectionEnd = 5;
    act(() => {
      handleRef.current!.insertAtCursor('<p></p>');
    });

    // Let the real dynamic import() of @codemirror/* actually resolve and
    // the view mount (real elapsed time, not mocked/faked).
    for (let i = 0; i < 5; i += 1) {
      // eslint-disable-next-line no-await-in-loop
      await act(async () => {
        await new Promise((resolve) => { setTimeout(resolve, 50); });
      });
    }

    // Confirm CodeMirror actually mounted (fallback textarea gone, replaced
    // by the CodeMirror root) -- otherwise this assertion would trivially
    // pass by reading back the fallback textarea's own value and wouldn't
    // exercise the bug at all.
    expect(container.querySelector('[data-testid="code-editor-fallback"]')).toBeNull();

    // The value-sync effect must have pushed the post-insertion value into
    // the freshly-mounted doc -- the insertion must not have been dropped.
    expect(handleRef.current!.getValue()).toBe('<div><p></p></div>');
  });
});
