import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { Editor, Frame, Element, useEditor } from '@craftjs/core';
import { componentResolver } from '../components/resolver';
import { Container } from '../components/layout/Container';
import { EditorConfigProvider } from '../state/EditorConfigContext';

/**
 * Real-`@craftjs/core` integration test harness.
 *
 * Mounts an ACTUAL `<Editor resolver={componentResolver}>` + `<Frame>` via
 * `react-dom/client`'s `createRoot` (no `@testing-library/react`, matching
 * the existing `*.test.tsx` convention in this repo -- see
 * `useKeyboardShortcuts.test.tsx` / `RenderNode.test.tsx`). Nothing in
 * `@craftjs/core` is mocked here: `query`/`actions` come straight out of a
 * live `useEditor()` call, so `regenerateTreeIds`, `buildNodeTree`,
 * `parseFreshNode`, `addNodeTree`, `deserialize`, and `serialize` all run
 * their REAL implementations against a REAL Craft.js `EditorStore`.
 *
 * Why this catches bugs the mocked unit tests didn't: a hand-rolled fake
 * `useEditor`/`query`/`node().toNodeTree()` can only echo back whatever
 * plain-data shape the test author imagined -- it has no way to reproduce
 * Craft's actual internal node shape (`data.type` as a live component
 * function reference, real `parseFreshNode` resolver validation, real
 * `<Frame>` rendering). Every one of the three historical bugs this suite
 * targets (`regenerateTreeIds` + `structuredClone`, `TemplateModal` dropping
 * `children`, `buildNodeTree` emitting `data.type={resolvedName}`) slipped
 * past its mocked unit test for exactly that reason.
 *
 * jsdom shims: as of writing, mounting `<Editor><Frame>...` in this repo's
 * jsdom (v29, see vitest.config.ts) did NOT require stubbing
 * `ResizeObserver`/`matchMedia`/`getBoundingClientRect` -- none of the
 * components exercised by these integration tests read layout geometry
 * during render. `ensureJsdomShims()` below still installs a `ResizeObserver`
 * stub defensively (idempotent, only if absent) since it's the one API
 * Craft/DOM-heavy component code most commonly reaches for and a future
 * template/component could easily start needing it; add further shims here
 * (not per-test) if a future component trips over a missing browser API.
 */

let shimsInstalled = false;

/** Install browser APIs jsdom doesn't implement, if not already present. Safe
 *  to call multiple times (idempotent) and safe in a real browser (guarded by
 *  `typeof` checks, so it never clobbers a real implementation). */
export function ensureJsdomShims(): void {
  if (shimsInstalled) return;
  shimsInstalled = true;

  if (typeof (globalThis as any).ResizeObserver === 'undefined') {
    class ResizeObserverStub {
      observe() {}
      unobserve() {}
      disconnect() {}
    }
    (globalThis as any).ResizeObserver = ResizeObserverStub;
  }

  if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') {
    window.matchMedia = ((query: string) => ({
      matches: false,
      media: query,
      onchange: null,
      addListener: () => {},
      removeListener: () => {},
      addEventListener: () => {},
      removeEventListener: () => {},
      dispatchEvent: () => false,
    })) as unknown as typeof window.matchMedia;
  }

  // jsdom (as of v29, used by this repo's vitest.config.ts) does NOT
  // implement `HTMLElement.prototype.innerText` at all -- `'innerText' in
  // document.createElement('div')` is `false`, so assigning to it (as
  // `Heading.tsx`/`TextBlock.tsx` do imperatively in a `useEffect`, to avoid
  // fighting `contentEditable`'s caret position on every prop update) just
  // sets a throwaway own-property that never touches `textContent`/
  // `innerHTML` -- real rendered text from these two components would
  // silently read back as empty in any DOM assertion. Shimmed as a plain
  // `textContent` alias (real `innerText` also collapses whitespace/applies
  // layout-based visibility, neither of which matters for these tests).
  if (typeof HTMLElement !== 'undefined') {
    if (!Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'innerText')) {
      Object.defineProperty(HTMLElement.prototype, 'innerText', {
        configurable: true,
        get(this: HTMLElement) {
          return this.textContent;
        },
        set(this: HTMLElement, value: string) {
          this.textContent = value;
        },
      });
    }
  }
}

interface CapturedApi {
  query: any;
  actions: any;
}

/** Invisible child that grabs a live `query`/`actions` pair out of a real
 *  `useEditor()` call and writes them into a mutable ref the harness owns --
 *  the same "Capture" pattern the brief describes and
 *  `template-zone-export.test.tsx` already uses ad hoc; this hoists it into a
 *  reusable helper. `query`/`actions` from Craft.js are live references (they
 *  read the current store at call time, not a react-state snapshot), so
 *  capturing them once on first render is sufficient -- no need to re-grab
 *  them after every store mutation. */
const Capture: React.FC<{ apiRef: React.MutableRefObject<CapturedApi | null> }> = ({ apiRef }) => {
  const { query, actions } = useEditor();
  apiRef.current = { query, actions };
  return null;
};

export interface EditorHarnessOptions {
  /** A `query.serialize()`-shaped JSON string to `actions.deserialize()`
   *  immediately after mount, replacing the default empty ROOT. */
  initialState?: string;
  /** Craft.js `Frame` seed root tag -- 'div' for a regular page, 'header'/
   *  'footer' for zone editing (mirrors `Canvas.tsx`'s `frameTag`). Ignored
   *  once `initialState` is given (deserialize replaces ROOT anyway); only
   *  affects the very first (pre-deserialize) paint. */
  frameTag?: 'div' | 'header' | 'footer';
}

export interface EditorHarness {
  /** Live `query` from the real `useEditor()` -- reads current store state. */
  readonly query: any;
  /** Live `actions` from the real `useEditor()`. */
  readonly actions: any;
  /** `query.serialize()` -- the exact string `PageContext`/`useWhpApi` persist
   *  and `exportBodyHtml` consumes. */
  getSerialized: () => string;
  /** The DOM node the whole tree (including the real rendered `<Frame>`
   *  content) is mounted under -- assert on `.textContent`/`.innerHTML` here
   *  to verify actual rendering, not just serialized state. */
  container: HTMLDivElement;
  /** Re-exported `act` from `react-dom/test-utils` for convenience, matching
   *  the existing `*.test.tsx` files in this repo. */
  act: typeof act;
  /** Mount (or replace) an additional child inside the live `<Editor>` tree,
   *  alongside `<Frame>` -- e.g. a probe component that calls a real hook
   *  (`useNodeActions`, `useKeyboardShortcuts`) so the test can drive it
   *  against the REAL editor instance this harness set up. Wrapped in `act`. */
  mountChild: (node: React.ReactNode) => void;
  /** Unmounts the React tree and removes `container` from `document.body`. */
  unmount: () => void;
}

/**
 * Mounts a real `<Editor resolver={componentResolver}>` + `<Frame>` (via
 * `EditorConfigProvider config={null}` -- standalone mode, no WHP backend)
 * and returns live `query`/`actions` plus helpers for driving further
 * real-editor interactions from a test.
 */
export function renderEditorHarness(opts: EditorHarnessOptions = {}): EditorHarness {
  ensureJsdomShims();

  const container = document.createElement('div');
  document.body.appendChild(container);

  const apiRef: React.MutableRefObject<CapturedApi | null> = { current: null };
  let extraChild: React.ReactNode = null;
  let root: Root;

  const frameTag = opts.frameTag ?? 'div';

  function paint() {
    root.render(
      <EditorConfigProvider config={null}>
        <Editor resolver={componentResolver} enabled={true}>
          <Frame>
            <Element is={Container} canvas tag={frameTag} style={{}} />
          </Frame>
          <Capture apiRef={apiRef} />
          {extraChild}
        </Editor>
      </EditorConfigProvider>,
    );
  }

  act(() => {
    root = createRoot(container);
    paint();
  });

  if (opts.initialState) {
    act(() => {
      apiRef.current!.actions.deserialize(opts.initialState!);
    });
  }

  return {
    get query() {
      return apiRef.current!.query;
    },
    get actions() {
      return apiRef.current!.actions;
    },
    getSerialized: () => apiRef.current!.query.serialize(),
    container,
    act,
    mountChild: (node: React.ReactNode) => {
      extraChild = node;
      act(() => {
        paint();
      });
    },
    unmount: () => {
      act(() => {
        root.unmount();
      });
      container.remove();
    },
  };
}
