/**
 * HiddenHeaderFooterPMs — persistent off-screen ProseMirror EditorViews for
 * every distinct header/footer part in the loaded document.
 *
 * Phase 1 of the HF editing unification (see
 * `openspec/changes/unify-hf-editing/`). The unification deletes the visible
 * inline overlay's PM and routes HF editing through the body's hidden-PM +
 * visible-painter model. This component is the first step: one hidden
 * `EditorView` per distinct HF `rId` from `Document.package.headers` and
 * `.footers`, mounted off-screen, always alive.
 *
 * Slot keying is by `rId`, NOT by `(hdrFtrType, kind)` — two sections that
 * share a header by referencing the same `rId` (the ECMA-376 §17.10.1
 * sharing-by-reference pattern) share one EditorView. The set is enumerated
 * as `Document.package.headers ∪ Document.package.footers` — every entry in
 * those two `Map<string, HeaderFooter>` maps gets one EditorView.
 *
 * In phase 1 these EditorViews carry no user input; the inline overlay still
 * owns editing. They exist so the painter pipeline can read from
 * `view.state.doc` (via `convertHeaderFooterPmDocToContent`) and stay in
 * lockstep with the future single source of truth. Subsequent phases move
 * click routing, selection drawing, and finally focus into these views.
 */

import {
  useRef,
  useEffect,
  useImperativeHandle,
  forwardRef,
  memo,
  useCallback,
  useMemo,
} from 'react';
import { EditorState } from 'prosemirror-state';
import type { EditorState as EditorStateT } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { schema, createDocumentStylesPlugin } from '@eigenpal/docx-editor-core/prosemirror';
import {
  headerFooterToProseDoc,
  proseDocToBlocks,
} from '@eigenpal/docx-editor-core/prosemirror/conversion';
import { createStarterKit } from '@eigenpal/docx-editor-core/prosemirror/extensions';
import { ExtensionManager } from '@eigenpal/docx-editor-core/prosemirror/extensions';
import type {
  Document,
  HeaderFooter,
  StyleDefinitions,
  Theme,
} from '@eigenpal/docx-editor-core/types/document';

import 'prosemirror-view/style/prosemirror.css';

export type HfPartKind = 'header' | 'footer';

export interface HfPartKey {
  /** Part-relationship id (`rId`) — the spec-faithful slot identity. */
  rId: string;
  /** Whether this rId belongs to `package.headers` or `package.footers`. */
  kind: HfPartKind;
}

export interface HiddenHeaderFooterPMsRef {
  /**
   * Look up the persistent EditorView for a given HF part. Returns `null`
   * before mount or when the rId is not present in the loaded document.
   */
  getView(rId: string): EditorView | null;
}

export interface HiddenHeaderFooterPMsProps {
  /** The loaded document — its `package.headers` / `package.footers` drives slot enumeration. */
  document: Document | null;
  /** Document styles, threaded into `headerFooterToProseDoc` for style resolution. */
  styles?: StyleDefinitions | null;
  /** Document theme, threaded for themed cell shading on initial PM build. */
  theme?: Theme | null;
  /** `defaultTabStop` from `state.doc.attrs.defaultTabStopTwips`, threaded to the HF PM doc. */
  defaultTabStopTwips?: number | null;
  /**
   * Called after every transaction lands on any HF EditorView. Phase 5 of
   * HF editing unification (openspec/changes/unify-hf-editing/) — the
   * persistent PM is now the sole editor and its transactions need to
   * trigger relayout (so the painter repaints) plus caret reposition.
   */
  onTransaction?: (rId: string, view: EditorView, docChanged: boolean) => void;
}

// ============================================================================
// IMPLEMENTATION
// ============================================================================

interface MountedView {
  rId: string;
  kind: HfPartKind;
  view: EditorView;
  /** The DOM node `view` is mounted to (one `<div>` per rId inside the off-screen host). */
  mountNode: HTMLElement;
}

function buildInitialState(
  hf: HeaderFooter,
  styles: StyleDefinitions | null | undefined,
  theme: Theme | null | undefined,
  defaultTabStopTwips: number | null | undefined,
  mgr: ExtensionManager
): EditorState {
  const pmDoc = headerFooterToProseDoc(hf.content, {
    styles: styles ?? undefined,
    theme: theme ?? null,
    defaultTabStopTwips: defaultTabStopTwips ?? null,
  });
  // Header/footer paragraphs share the document's style table, so they get the
  // same style-aware behavior (e.g. Enter after a heading → body text).
  const styleResolverPlugin = createDocumentStylesPlugin(styles);
  return EditorState.create({
    doc: pmDoc,
    schema,
    plugins: [...mgr.getPlugins(), styleResolverPlugin],
  });
}

function enumerateSlots(doc: Document | null): HfPartKey[] {
  if (!doc?.package) return [];
  const out: HfPartKey[] = [];
  const headers = doc.package.headers;
  if (headers) {
    for (const rId of headers.keys()) out.push({ rId, kind: 'header' });
  }
  const footers = doc.package.footers;
  if (footers) {
    // A document SHOULD NOT register the same `rId` under both headers and
    // footers — the OOXML schema keeps them disjoint per
    // `headerReference` vs `footerReference`. Defensive: dedupe anyway.
    for (const rId of footers.keys()) {
      if (!headers || !headers.has(rId)) out.push({ rId, kind: 'footer' });
    }
  }
  return out;
}

export const HiddenHeaderFooterPMs = memo(
  forwardRef<HiddenHeaderFooterPMsRef, HiddenHeaderFooterPMsProps>(function HiddenHeaderFooterPMs(
    { document, styles, theme, defaultTabStopTwips, onTransaction },
    ref
  ) {
    // Keep the callback stable across renders — every HF EditorView's
    // `dispatchTransaction` closes over it, so going through a ref lets
    // the parent pass a fresh callback each render without recreating
    // the EditorViews.
    const onTransactionRef = useRef(onTransaction);
    onTransactionRef.current = onTransaction;
    // Refs for the document writeback closure inside `dispatchTransaction`.
    // The EditorView is created once per rId; without a ref the
    // closure would capture the initial `document` and never see updates
    // (Map identity changes when `loadDocument` runs but the closure
    // would still write to the old map).
    const documentRef = useRef(document);
    documentRef.current = document;
    const syncHfBlocksToDocumentRef = useRef<
      ((rId: string, kind: HfPartKind, state: EditorStateT) => void) | null
    >(null);
    const hostRef = useRef<HTMLDivElement>(null);
    const mountedRef = useRef<Map<string, MountedView>>(new Map());
    const managersRef = useRef<Map<string, ExtensionManager>>(new Map());

    // Writeback: serialize the persistent PM's doc back into
    // `Document.package.headers[rId].content` (or `.footers[rId].content`)
    // so `save()` reads the latest HF content. This used to live in the
    // inline overlay's save-on-close path only; with the persistent PM
    // as sole editor, edits typed before close were lost. Now every doc
    // change syncs.
    syncHfBlocksToDocumentRef.current = (rId: string, kind: HfPartKind, state: EditorStateT) => {
      const doc = documentRef.current;
      const pkg = doc?.package;
      if (!pkg) return;
      const bag = kind === 'header' ? pkg.headers : pkg.footers;
      const hf = bag?.get(rId);
      if (!hf) return;
      // `proseDocToBlocks` returns `(Paragraph | Table)[]`; assign in
      // place — the bag Map is the canonical reference shared across
      // sections, so mutating `hf.content` propagates to every section
      // that references the same rId (the spec-faithful sharing pattern).
      hf.content = proseDocToBlocks(state.doc);
    };

    // Resolve a HeaderFooter from doc by rId — used both at mount and when
    // a slot's content needs to be re-synced (e.g. after the inline editor
    // saves via the existing onSave flow).
    const resolveHf = useCallback(
      (rId: string, kind: HfPartKind): HeaderFooter | null => {
        const pkg = document?.package;
        if (!pkg) return null;
        const bag = kind === 'header' ? pkg.headers : pkg.footers;
        return bag?.get(rId) ?? null;
      },
      [document]
    );

    // Enumerate target slots from the current document. Memoize on identity
    // of the headers/footers Maps so we don't churn the EditorViews when
    // unrelated parts of `document` change.
    const slots = useMemo<HfPartKey[]>(
      () => enumerateSlots(document),
      // Re-enumerate when the Maps themselves are swapped. Mutations to the
      // existing Map (inline editor save mutates in place today) won't
      // trigger this; that's intentional for phase 1 — the inline editor's
      // save path will be plumbed through to dispatch a transaction on the
      // persistent PM in phase 5.
      // eslint-disable-next-line react-hooks/exhaustive-deps
      [document?.package?.headers, document?.package?.footers]
    );

    // Mount / unmount EditorViews when the slot set changes.
    useEffect(() => {
      if (!hostRef.current) return;
      const host = hostRef.current;
      const want = new Map(slots.map((s) => [s.rId, s] as const));
      const have = mountedRef.current;

      // Tear down any rIds no longer present.
      for (const [rId, mounted] of have) {
        if (!want.has(rId)) {
          mounted.view.destroy();
          mounted.mountNode.remove();
          managersRef.current.get(rId)?.destroy();
          managersRef.current.delete(rId);
          have.delete(rId);
        }
      }

      // Bring up any rIds we don't yet have.
      for (const slot of slots) {
        if (have.has(slot.rId)) continue;
        const hf = resolveHf(slot.rId, slot.kind);
        if (!hf) continue;

        const mgr = new ExtensionManager(createStarterKit());
        mgr.buildSchema();
        mgr.initializeRuntime();
        managersRef.current.set(slot.rId, mgr);

        // `document` in this closure is the Document model (DOCX), not the
        // browser DOM `Document`. Create the mount node via the host's owner.
        const node = host.ownerDocument.createElement('div');
        node.dataset.hfRId = slot.rId;
        node.dataset.hfKind = slot.kind;
        host.appendChild(node);

        const state = buildInitialState(hf, styles, theme, defaultTabStopTwips, mgr);
        const slotRId = slot.rId;
        const slotKind = slot.kind;
        const view: EditorView = new EditorView(node, {
          state,
          // The persistent PM is the sole HF editor. Every transaction
          // (typing, click → setSelection, undo/redo) needs to:
          //   1. Re-run the layout pipeline so the painter repaints.
          //   2. Reposition the caret overlay.
          //   3. Sync `view.state.doc` back into `Document.package.headers/footers`
          //      so `save()` doesn't lose unsaved HF edits.
          // (1) + (2) ride through `onTransaction`; (3) happens here.
          dispatchTransaction(tr) {
            const newState = view.state.apply(tr);
            view.updateState(newState);
            if (tr.docChanged) syncHfBlocksToDocumentRef.current?.(slotRId, slotKind, newState);
            onTransactionRef.current?.(slotRId, view, tr.docChanged);
          },
        });
        have.set(slot.rId, { rId: slot.rId, kind: slot.kind, view, mountNode: node });
      }
      // Note: `document` intentionally excluded from deps. Slot enumeration
      // already flows through `slots`, and `resolveHf` reads from the same
      // ref; depending on `document` directly causes a full HF EditorView
      // re-mount on every body PM transaction (each Document.applyTransaction
      // returns a new identity), which destroys IME state and selection.
    }, [slots, resolveHf, styles, theme, defaultTabStopTwips]);

    // Tear everything down on unmount.
    useEffect(() => {
      const have = mountedRef.current;
      const mgrs = managersRef.current;
      return () => {
        for (const { view, mountNode } of have.values()) {
          view.destroy();
          mountNode.remove();
        }
        have.clear();
        for (const mgr of mgrs.values()) mgr.destroy();
        mgrs.clear();
      };
    }, []);

    useImperativeHandle(
      ref,
      () => ({
        getView(rId: string): EditorView | null {
          return mountedRef.current.get(rId)?.view ?? null;
        },
      }),
      []
    );

    // Off-screen host — positioned the same way `HiddenProseMirror` is, so
    // the EditorViews retain focusability and keyboard routing while being
    // visually absent. NOT `aria-hidden` (the body PM also avoids it) —
    // the editor remains in the accessibility tree, and screen readers /
    // focus management treat it as a real input. `pointer-events: none`
    // blocks mouse but does not block programmatic `view.focus()`.
    return (
      <div
        ref={hostRef}
        style={{
          position: 'fixed',
          left: -9999,
          top: 0,
          opacity: 0,
          zIndex: -1,
          pointerEvents: 'none',
        }}
      />
    );
  })
);

HiddenHeaderFooterPMs.displayName = 'HiddenHeaderFooterPMs';
