import React, {
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';

import { PdfFieldType, type PdfPlacedField } from '../../types';
import { MIN_BOX_SIZE_FOR_FONT_SIZE } from '../components/FormCreation/pdf/utils/constants';
import {
  clamp,
  isOptionsField,
  isTextSensitiveFieldType,
  shouldFieldAdaptToOptions,
} from '../components/FormCreation/pdf/utils';
import { newServiceItemFields } from '../forms';

type PdfCreationContextType = {
  fields: PdfPlacedField[];
  fieldsByPage: Map<number, PdfPlacedField[]>;
  updateField: (id: string, patch: Partial<PdfPlacedField>) => void;
  addField: (field: Omit<PdfPlacedField, 'id'>) => void;
  removeField: (id: string) => void;
  duplicateField: (id: string) => void;
  addChoiceToSelectionField: (fieldId: string, choice: string) => void;
  addChoicesToSelectionField: (fieldId: string, choices: string[]) => void;
  removeChoiceFromSelectionField: (fieldId: string, choiceId: string) => void;
  ensureFieldHeightFitsFont: (
    field: PdfPlacedField,
    newFontSize: number,
  ) => void;
  selectedField: PdfPlacedField | null;
  selectedFieldId: string | null;
  selectedFieldIndex: number;
  pageScale: number;
  currentPage: number;
  totalPages: number;
  activeDrag: ActiveDrag | null;
  hoveredPageIndex: number | null;
  setActiveDrag: (drag: ActiveDrag | null) => void;
  setSelectedFieldId: (id: string | null) => void;
  setPageScale: (scale: number) => void;
  setCurrentPage: (page: number) => void;
  setTotalPages: (pages: number) => void;
  setHoveredPageIndex: (pageIndex: number | null) => void;
  registerScrollContainer: (el: HTMLElement | null) => void;
  registerPageElement: (pageIndex: number, el: HTMLElement | null) => void;
  updatePageOverlayRect: (
    pageIndex: number,
    rect: DOMRect,
    mousePosition: { x: number; y: number },
  ) => void;
  getPageOverlayRect: (
    pageIndex: number,
  ) => { rect: DOMRect; mousePosition: { x: number; y: number } } | undefined;
  handleZoomIn: () => void;
  handleZoomOut: () => void;
  handlePageChange: (page: number) => void;
  scrollToFieldPosition: (
    pageIndex: number,
    yRelative: number,
    behavior?: ScrollBehavior,
  ) => void;
};

const CHOICE_GAP_SIZE_PX = 8;
export const MAX_PAGE_SCALE = 2; // 200%
export const MIN_PAGE_SCALE = 0.5; // 50%
const PROGRAMMATIC_SCROLL_TIMEOUT_MS = 1500;

const PdfCreationContext = React.createContext<PdfCreationContextType | null>(
  null,
);

export const usePdfCreation = () => {
  return useContext(PdfCreationContext) as PdfCreationContextType;
};

export type ActiveDrag = { type: PdfFieldType };

const PdfCreationProvider = ({ children }: { children: JSX.Element }) => {
  const [activeDrag, setActiveDrag] = useState<ActiveDrag | null>(null);
  const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
  const [pageScale, setPageScale] = useState<number>(1);
  const [currentPage, setCurrentPage] = useState(1);
  const [totalPages, setTotalPages] = useState(0);
  const [hoveredPageIndex, setHoveredPageIndex] = useState<number | null>(null);

  // Scrolling and page elements tracking
  const scrollContainerRef = useRef<HTMLElement | null>(null);
  const pageElementsRef = useRef<Map<number, HTMLElement>>(new Map());
  const scrollListenerRef = useRef<((e: Event) => void) | null>(null);
  const requestedPageRef = useRef<number | null>(null);
  const isProgrammaticScrollRef = useRef(false);
  const programmaticScrollTimeoutRef = useRef<number | null>(null);
  const targetScrollTopRef = useRef<number | null>(null);
  const lastScrollDirectionRef = useRef<'up' | 'down' | null>(null);
  const lastScrollTopRef = useRef<number>(0);

  const computeVisiblePageIndex = useCallback((): number | null => {
    const container = scrollContainerRef.current;
    if (!container || pageElementsRef.current.size === 0) return null;

    const containerRect = container.getBoundingClientRect();
    const containerTop = containerRect.top;

    let bestIndex: number | null = null;
    let bestDistance = Number.POSITIVE_INFINITY;

    for (const [idx, el] of pageElementsRef.current.entries()) {
      const r = el.getBoundingClientRect();
      const distance = Math.abs(r.top - containerTop);
      if (distance < bestDistance) {
        bestDistance = distance;
        bestIndex = idx;
      }
    }

    return bestIndex;
  }, []);

  const onScroll = useCallback(() => {
    const container = scrollContainerRef.current;
    const idx = computeVisiblePageIndex();
    if (!container || idx == null) return;
    const visiblePage = idx + 1;

    // Track scroll direction
    const containerScrollTop = container.scrollTop;
    const prevScrollTop = lastScrollTopRef.current;
    if (containerScrollTop > prevScrollTop)
      lastScrollDirectionRef.current = 'down';
    else if (containerScrollTop < prevScrollTop)
      lastScrollDirectionRef.current = 'up';
    lastScrollTopRef.current = containerScrollTop;

    if (isProgrammaticScrollRef.current) {
      const requested = requestedPageRef.current;
      const targetTop = targetScrollTopRef.current;
      const pixelTolerance = 2; // px tolerance for arrival
      const atTarget =
        targetTop != null &&
        Math.abs(containerScrollTop - targetTop) <= pixelTolerance;
      const passedTarget =
        targetTop != null &&
        ((lastScrollDirectionRef.current === 'down' &&
          containerScrollTop >= targetTop) ||
          (lastScrollDirectionRef.current === 'up' &&
            containerScrollTop <= targetTop));

      if (
        requested != null &&
        (visiblePage === requested || atTarget || passedTarget)
      ) {
        setCurrentPage(requested);
        isProgrammaticScrollRef.current = false;
        requestedPageRef.current = null;
        targetScrollTopRef.current = null;
        if (programmaticScrollTimeoutRef.current != null) {
          window.clearTimeout(programmaticScrollTimeoutRef.current);
          programmaticScrollTimeoutRef.current = null;
        }
      }
      return;
    }

    setCurrentPage(visiblePage);
  }, [computeVisiblePageIndex, setCurrentPage]);

  const registerScrollContainer = useCallback(
    (el: HTMLElement | null) => {
      // Detach from previous container
      const prev = scrollContainerRef.current;
      if (prev && scrollListenerRef.current) {
        prev.removeEventListener('scroll', scrollListenerRef.current);
      }

      scrollContainerRef.current = el;

      if (el) {
        scrollListenerRef.current = () => onScroll();
        el.addEventListener('scroll', scrollListenerRef.current, {
          passive: true,
        });
        // Initialize current page based on current scroll position
        onScroll();
      } else {
        scrollListenerRef.current = null;
      }
    },
    [onScroll],
  );

  const registerPageElement = useCallback(
    (pageIndex: number, el: HTMLElement | null) => {
      if (el) pageElementsRef.current.set(pageIndex, el);
      else pageElementsRef.current.delete(pageIndex);
      // Recompute visible page when pages mount/unmount
      onScroll();
    },
    [onScroll],
  );

  const pageOverlayRectsRef = useRef<
    Map<number, { rect: DOMRect; mousePosition: { x: number; y: number } }>
  >(new Map());

  const updatePageOverlayRect = useCallback(
    (
      pageIndex: number,
      rect: DOMRect,
      mousePosition: { x: number; y: number },
    ) => {
      const prev = pageOverlayRectsRef.current.get(pageIndex);
      if (prev) {
        const sameMouse =
          prev.mousePosition.x === mousePosition.x &&
          prev.mousePosition.y === mousePosition.y;
        const sameRect =
          prev.rect.width === rect.width &&
          prev.rect.height === rect.height &&
          prev.rect.left === rect.left &&
          prev.rect.top === rect.top;
        if (sameMouse && sameRect) return;
      }
      pageOverlayRectsRef.current.set(pageIndex, { rect, mousePosition });
    },
    [],
  );

  const getPageOverlayRect = useCallback(
    (pageIndex: number) => pageOverlayRectsRef.current.get(pageIndex),
    [],
  );

  // Zoom helpers
  const scrollToPageIndex = useCallback((pageIndex: number) => {
    const container = scrollContainerRef.current;
    const pageEl = pageElementsRef.current.get(pageIndex);
    if (!container || !pageEl) return;

    const containerRect = container.getBoundingClientRect();
    const pageRect = pageEl.getBoundingClientRect();
    const currentScrollTop = container.scrollTop;

    // Scroll so that the page's top aligns with the container's top
    const targetTop = currentScrollTop + (pageRect.top - containerRect.top);
    container.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' });
  }, []);

  const getTargetTopForPage = useCallback((pageIndex: number) => {
    const container = scrollContainerRef.current;
    const pageEl = pageElementsRef.current.get(pageIndex);
    if (!container || !pageEl) return null;

    const containerRect = container.getBoundingClientRect();
    const pageRect = pageEl.getBoundingClientRect();
    const currentScrollTop = container.scrollTop;
    return currentScrollTop + (pageRect.top - containerRect.top);
  }, []);

  const handlePageChange = (page: number) => {
    // Treat as 1-based page index
    const maxPage = Math.max(1, totalPages || 1);
    const clamped = Math.max(1, Math.min(page, maxPage));
    requestedPageRef.current = clamped;
    isProgrammaticScrollRef.current = true;

    if (programmaticScrollTimeoutRef.current != null) {
      window.clearTimeout(programmaticScrollTimeoutRef.current);
      programmaticScrollTimeoutRef.current = null;
    }

    const container = scrollContainerRef.current;
    const targetTop = getTargetTopForPage(clamped - 1);
    targetScrollTopRef.current = targetTop;

    // Choose behavior based on distance to avoid long smooth scrolling -> slow and error prone
    const currentTop = container?.scrollTop ?? 0;
    const distance = targetTop != null ? Math.abs(currentTop - targetTop) : 0;
    const isLongJump =
      Math.abs(clamped - currentPage) >= 3 ||
      distance > (container?.clientHeight ?? 0) * 1.5;

    setCurrentPage(clamped);
    if (targetTop != null && container) {
      container.scrollTo({
        top: Math.max(0, targetTop),
        behavior: isLongJump ? 'auto' : 'smooth',
      });
    } else {
      // Fallback to previous helper
      scrollToPageIndex(clamped - 1);
    }

    // Failsafe in case no scroll confirmation arrives
    programmaticScrollTimeoutRef.current = window.setTimeout(() => {
      isProgrammaticScrollRef.current = false;
      requestedPageRef.current = null;
      targetScrollTopRef.current = null;
      programmaticScrollTimeoutRef.current = null;
    }, PROGRAMMATIC_SCROLL_TIMEOUT_MS);
  };

  const scrollToFieldPosition = useCallback(
    (
      pageIndex: number,
      yRelative: number,
      behavior: ScrollBehavior = 'smooth',
    ) => {
      const container = scrollContainerRef.current;
      const pageEl = pageElementsRef.current.get(pageIndex);
      if (!container || !pageEl) return;

      const containerRect = container.getBoundingClientRect();
      const pageRect = pageEl.getBoundingClientRect();
      const currentScrollTop = container.scrollTop;

      const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
      const yRel = clamp01(Number.isFinite(yRelative) ? yRelative : 0);

      // Align page top to container top, then offset inside the page by yRel * pageHeight
      const topToAlignPage =
        currentScrollTop + (pageRect.top - containerRect.top);
      const offsetInsidePage = yRel * pageRect.height;
      const targetTop = Math.max(0, topToAlignPage + offsetInsidePage);

      container.scrollTo({ top: targetTop, behavior });
    },
    [],
  );

  const handleZoomIn = () =>
    setPageScale(prev => Math.min(prev + 0.1, MAX_PAGE_SCALE));
  const handleZoomOut = () =>
    setPageScale(prev => Math.max(prev - 0.1, MIN_PAGE_SCALE));

  const { control, getValues } = useFormContext();
  const fieldsName = newServiceItemFields.form.pdf.fields.all();

  const {
    append,
    remove: rhfRemove,
    update: rhfUpdate,
  } = useFieldArray({
    control,
    name: fieldsName,
    keyName: '__key',
  });

  const fields: PdfPlacedField[] = useWatch({
    control,
    name: fieldsName,
    // Hydrate with the current form value so the provider reflects existing items immediately - even after reset
    defaultValue: (getValues(fieldsName) as PdfPlacedField[] | undefined) ?? [],
  }) as PdfPlacedField[];

  // id -> index map for O(1) updates/removals
  const idToIndexRef = useRef(new Map<string, number>());

  useEffect(() => {
    const m = new Map<string, number>();
    fields.forEach((f, i) => m.set(f.id, i));
    idToIndexRef.current = m;
  }, [fields]);

  const updateField = useCallback(
    (id: string, patch: Partial<PdfPlacedField>) => {
      const idx = idToIndexRef.current.get(id);
      if (idx == null) return;
      // Use getValues to read the latest form store values instead of the stale
      // fields closure, so concurrent setValue calls (e.g. fontSize) are not overwritten.
      const prev = getValues(newServiceItemFields.form.pdf.fields.detail(idx));
      rhfUpdate(idx, { ...prev, ...patch });
    },
    [getValues, rhfUpdate],
  );

  const addField = useCallback(
    (field: Omit<PdfPlacedField, 'id'>, id?: string) => {
      const newFieldId = id || crypto.randomUUID();
      append({ id: newFieldId, ...field }, { shouldFocus: false });
      setSelectedFieldId(newFieldId);
    },
    [append],
  );

  const removeField = useCallback(
    (id: string) => {
      const idx = idToIndexRef.current.get(id);
      if (idx == null) return;
      rhfRemove(idx);
      // Clear selection immediately to unmount Moveable
      setSelectedFieldId(prev => (prev === id ? null : prev));
    },
    [rhfRemove],
  );

  const duplicateField = useCallback(
    (id: string) => {
      const idx = idToIndexRef.current.get(id);
      if (idx == null) return;
      const field = fields[idx];
      if (!field) return;

      // eslint-disable-next-line @typescript-eslint/naming-convention
      const { id: _, ...rest } = field;

      // Convert 8px to normalized offset using the stored overlay rect for that page
      const OFFSET_PX = 8;
      const pageData = getPageOverlayRect(field.pageIndex);
      const dx = pageData ? OFFSET_PX / pageData.rect.width : 0.01;
      const dy = pageData ? OFFSET_PX / pageData.rect.height : 0.01;

      const x = clamp(rest.x + dx, 0, 1 - rest.width);
      const y = clamp(rest.y + dy, 0, 1 - rest.height);

      addField({ ...rest, x: +x.toFixed(4), y: +y.toFixed(4) });
    },
    [fields, addField, getPageOverlayRect],
  );

  const addChoiceToSelectionField = useCallback(
    (fieldId: string, choice: string) => {
      const idx = idToIndexRef.current.get(fieldId);
      if (idx == null) return;
      const field = fields[idx];
      if (!field || !isOptionsField(field)) return;
      const choices = field.choices;

      const newChoice = { id: crypto.randomUUID(), title: choice };
      const newOptions = [...(choices || []), newChoice];
      // If we don't have overlay metrics or there are no existing choices, just update choices
      const pageData = getPageOverlayRect(field.pageIndex);
      if (
        !pageData ||
        !choices ||
        choices.length === 0 ||
        !shouldFieldAdaptToOptions(field)
      ) {
        updateField(fieldId, { choices: newOptions });
        return;
      }

      // Compute new height in px using the provided rule
      const N = choices.length; // current number of choices before adding
      const currentHeightPx = field.height * pageData.rect.height;
      const totalPaddingPx = CHOICE_GAP_SIZE_PX * 2; // 8 top + 8 bottom
      const totalGapsPx = CHOICE_GAP_SIZE_PX * Math.max(0, N - 1); // gap between choices
      const contentHeightPx = Math.max(
        0,
        currentHeightPx - totalPaddingPx - totalGapsPx,
      );
      const heightOfChoicePx = contentHeightPx / N;

      const newHeightPx =
        heightOfChoicePx * (N + 1) + CHOICE_GAP_SIZE_PX * (N + 2);
      const newHeightNorm = newHeightPx / pageData.rect.height;

      // Keep width unchanged; adjust Y so bottom edge stays in place if overflowing
      const clampedHeight = Math.min(1, Math.max(0, newHeightNorm));
      const bottom = field.y + field.height;
      let newY = field.y;
      if (newY + clampedHeight > 1) {
        newY = Math.max(0, bottom - clampedHeight);
      }

      updateField(fieldId, {
        choices: newOptions,
        height: +clampedHeight.toFixed(4),
        y: +newY.toFixed(4),
      });
    },
    [fields, getPageOverlayRect],
  );

  const addChoicesToSelectionField = useCallback(
    (fieldId: string, choicesToAdd: string[]) => {
      if (!choicesToAdd || choicesToAdd.length === 0) return;
      const idx = idToIndexRef.current.get(fieldId);
      if (idx == null) return;
      const field = fields[idx];
      if (!field || !isOptionsField(field)) return;
      const existingChoices = field.choices || [];

      const mappedNewChoices = choicesToAdd.map(title => ({
        id: crypto.randomUUID(),
        title,
      }));
      const newOptions = [...existingChoices, ...mappedNewChoices];

      const pageData = getPageOverlayRect(field.pageIndex);
      const N = existingChoices.length;
      const K = mappedNewChoices.length;

      if (!pageData || N === 0 || !shouldFieldAdaptToOptions(field)) {
        updateField(fieldId, { choices: newOptions });
        return;
      }

      const currentHeightPx = field.height * pageData.rect.height;
      const totalPaddingPx = CHOICE_GAP_SIZE_PX * 2; // 8 top + 8 bottom
      const totalGapsPx = CHOICE_GAP_SIZE_PX * Math.max(0, N - 1); // gap between choices
      const contentHeightPx = Math.max(
        0,
        currentHeightPx - totalPaddingPx - totalGapsPx,
      );
      const heightOfChoicePx = contentHeightPx / N;

      const newN = N + K;
      const newHeightPx =
        heightOfChoicePx * newN + CHOICE_GAP_SIZE_PX * (newN + 1);
      const newHeightNorm = newHeightPx / pageData.rect.height;

      const clampedHeight = Math.min(1, Math.max(0, newHeightNorm));
      const bottom = field.y + field.height;
      let newY = field.y;
      if (newY + clampedHeight > 1) {
        newY = Math.max(0, bottom - clampedHeight);
      }

      updateField(fieldId, {
        choices: newOptions,
        height: +clampedHeight.toFixed(4),
        y: +newY.toFixed(4),
      });
    },
    [fields, getPageOverlayRect],
  );

  const removeChoiceFromSelectionField = useCallback(
    (fieldId: string, choiceId: string) => {
      const idx = idToIndexRef.current.get(fieldId);
      if (idx == null) return;
      const field = fields[idx];
      if (!field || !isOptionsField(field)) return;
      const choices = field.choices;
      const newOptions =
        choices?.filter(option => option.id !== choiceId) || [];

      // If we don't have overlay metrics or less than 2 choices before removal, just update choices
      const pageData = getPageOverlayRect(field.pageIndex);
      const N = choices?.length ?? 0; // current number before removal
      if (!pageData || N <= 1 || !shouldFieldAdaptToOptions(field)) {
        updateField(fieldId, { choices: newOptions });
        return;
      }

      // Compute per-choice height from current rect, then apply removal formula
      const currentHeightPx = field.height * pageData.rect.height;
      const totalPaddingPx = CHOICE_GAP_SIZE_PX * 2; // 8 top + 8 bottom
      const totalGapsPx = CHOICE_GAP_SIZE_PX * Math.max(0, N - 1); // gap between choices
      const contentHeightPx = Math.max(
        0,
        currentHeightPx - totalPaddingPx - totalGapsPx,
      );
      const heightOfChoicePx = contentHeightPx / N;

      const newN = N - 1;
      // For a field with M choices, total non-content vertical space is 8 * (M + 1)
      // so when removing one choice, use (newN + 1) here to keep add/remove invertible.
      const newHeightPx =
        heightOfChoicePx * newN + CHOICE_GAP_SIZE_PX * (newN + 1);
      const newHeightNorm = newHeightPx / pageData.rect.height;

      const clampedHeight = Math.min(1, Math.max(0, newHeightNorm));
      // Shrinking won't overflow. Keep y unchanged.

      updateField(fieldId, {
        choices: newOptions,
        height: +clampedHeight.toFixed(4),
      });
    },
    [fields, getPageOverlayRect],
  );

  const ensureFieldHeightFitsFont = useCallback(
    (field: PdfPlacedField, newFontSize: number) => {
      const pageData = getPageOverlayRect(field.pageIndex);

      if (!pageData || !isTextSensitiveFieldType(field.type)) return;

      const baseMinHeightPx = (
        MIN_BOX_SIZE_FOR_FONT_SIZE as Record<number, number>
      )[newFontSize];
      if (baseMinHeightPx == null) return;

      // Scale the minimum height by the current page zoom to make the
      // computation invariant to zoom level (overlay rect is scaled too).
      const minHeightPx = baseMinHeightPx * pageScale;

      const currentHeightPx = field.height * pageData.rect.height;
      const targetHeightNorm = Math.min(
        1,
        Math.max(0, minHeightPx / pageData.rect.height),
      );

      // Only grow to minimum (font size given) if current height is insufficient
      if (field.type === PdfFieldType.TEXT) {
        if (currentHeightPx >= minHeightPx) return;

        const bottom = field.y + field.height;
        let newY = field.y;
        if (newY + targetHeightNorm > 1) {
          newY = Math.max(0, bottom - targetHeightNorm);
        }

        updateField(field.id, {
          height: +targetHeightNorm.toFixed(4),
          y: +newY.toFixed(4),
        });
        return;
      }

      // Non-text:Always adjust height to mapped minimum (increase or decrease)
      const bottom = field.y + field.height;
      let newY = field.y;
      if (newY + targetHeightNorm > 1) {
        newY = Math.max(0, bottom - targetHeightNorm);
      }

      updateField(field.id, {
        height: +targetHeightNorm.toFixed(4),
        y: +newY.toFixed(4),
      });
    },
    [getPageOverlayRect, updateField, pageScale],
  );

  // Refs to read the latest values without re-registering the listener
  const selectedFieldIdRef = useRef(selectedFieldId);
  const hoveredPageIndexRef = useRef(hoveredPageIndex);
  const copiedFieldRef = useRef<Omit<PdfPlacedField, 'id'> | null>(null);

  useEffect(() => {
    selectedFieldIdRef.current = selectedFieldId;
  }, [selectedFieldId]);

  useEffect(() => {
    hoveredPageIndexRef.current = hoveredPageIndex;
  }, [hoveredPageIndex]);

  // Cancel programmatic mode on explicit user interactions
  useEffect(() => {
    const cancelIfProgrammatic = () => {
      if (!isProgrammaticScrollRef.current) return;
      isProgrammaticScrollRef.current = false;
      requestedPageRef.current = null;
      targetScrollTopRef.current = null;
    };

    window.addEventListener('wheel', cancelIfProgrammatic, {
      passive: true,
    });
    window.addEventListener('touchstart', cancelIfProgrammatic, {
      passive: true,
    });
    window.addEventListener('keydown', cancelIfProgrammatic);
    return () => {
      window.removeEventListener('wheel', cancelIfProgrammatic);
      window.removeEventListener('touchstart', cancelIfProgrammatic);
      window.removeEventListener('keydown', cancelIfProgrammatic);
    };
  }, []);

  // keep latest arrays/ids in refs so the listener can be stable
  const fieldsRef = useRef<PdfPlacedField[]>([]);

  useEffect(() => {
    fieldsRef.current = fields;
  }, [fields]);

  useEffect(() => {
    const isEditableTarget = (target: EventTarget | null) => {
      const el = target as HTMLElement | null;
      if (!el) return false;
      return !!el.closest(
        'input, textarea, select, [contenteditable]:not([contenteditable="false"])',
      );
    };

    const isPrimaryMod = (e: KeyboardEvent) => e.metaKey || e.ctrlKey; // ⌘ on mac, Ctrl on win/linux
    const isKey = (
      e: KeyboardEvent,
      letterCode: `Key${string}`,
      letterKey: string,
    ) => {
      // Prefer code, fallback to key
      const codeOk = e.code === letterCode;
      const keyOk = (e.key || '').toLowerCase() === letterKey;
      return codeOk || keyOk;
    };

    const onKeyDown = (e: KeyboardEvent) => {
      if (isEditableTarget(e.target)) return;

      const selectedId = selectedFieldIdRef.current;
      const hoveredPageIdx = hoveredPageIndexRef.current;

      // DELETE (handle both Backspace and Delete; avoid browser "Back" on Backspace)
      if (!isPrimaryMod(e) && (e.key === 'Backspace' || e.key === 'Delete')) {
        if (selectedId) {
          e.preventDefault(); // prevent browser nav on Backspace
          removeField(selectedId);
          setSelectedFieldId(null);
        }
        return;
      }

      // COPY: Ctrl/⌘ + C
      if (isPrimaryMod(e) && isKey(e, 'KeyC', 'c')) {
        if (selectedId) {
          const idx = idToIndexRef.current.get(selectedId);
          const arr = fieldsRef.current;
          if (idx != null && arr[idx]) {
            const { id, ...fieldToCopy } = arr[idx];
            copiedFieldRef.current = fieldToCopy;
            e.preventDefault(); // don't copy DOM selection
          }
        }
        return;
      }

      // PASTE: Ctrl/⌘ + V
      if (isPrimaryMod(e) && isKey(e, 'KeyV', 'v')) {
        const copied = copiedFieldRef.current;
        if (!copied || hoveredPageIdx == null) return;

        const pageData = getPageOverlayRect(hoveredPageIdx);
        if (!pageData) return;

        e.preventDefault(); // don't paste into focused element

        const { rect, mousePosition } = pageData;
        const nx = mousePosition.x / rect.width;
        const ny = mousePosition.y / rect.height;

        const x = Math.max(0, Math.min(nx, 1 - copied.width));
        const y = Math.max(0, Math.min(ny, 1 - copied.height));

        addField({ ...copied, pageIndex: hoveredPageIdx, x, y });
        copiedFieldRef.current = null;
        return;
      }
    };

    window.addEventListener('keydown', onKeyDown);
    return () => window.removeEventListener('keydown', onKeyDown);
  }, [addField, getPageOverlayRect, removeField, setSelectedFieldId]);

  const fieldsByPage = useMemo(() => {
    const map = new Map<number, PdfPlacedField[]>();
    for (const f of fields) {
      const arr = map.get(f.pageIndex);
      if (arr) arr.push(f);
      else map.set(f.pageIndex, [f]);
    }
    return map;
  }, [fields]);

  const selectedField = useMemo(
    () =>
      selectedFieldId
        ? fields.find(f => f.id === selectedFieldId) || null
        : null,
    [fields, selectedFieldId],
  );

  const selectedFieldIndex = useMemo(() => {
    if (!selectedFieldId) return 0;
    const idx = fields.findIndex(f => f.id === selectedFieldId);
    return idx >= 0 ? idx : 0;
  }, [fields, selectedFieldId]);

  // ───────────────────────── memoize context value ─────────────────────────
  const ctxValue = useMemo<PdfCreationContextType>(
    () => ({
      // data
      fields,
      fieldsByPage,
      updateField,
      addField,
      removeField,
      duplicateField,
      addChoiceToSelectionField,
      addChoicesToSelectionField,
      removeChoiceFromSelectionField,
      ensureFieldHeightFitsFont,
      // ui
      selectedField,
      selectedFieldId,
      selectedFieldIndex,
      pageScale,
      currentPage,
      totalPages,
      activeDrag,
      hoveredPageIndex,
      // setters
      setActiveDrag,
      setSelectedFieldId,
      setPageScale,
      setCurrentPage,
      setTotalPages,
      setHoveredPageIndex,
      registerScrollContainer,
      registerPageElement,
      // overlay rects API
      updatePageOverlayRect,
      getPageOverlayRect,
      // zoom/page
      handleZoomIn,
      handleZoomOut,
      handlePageChange,
      scrollToFieldPosition,
    }),
    [
      fields,
      fieldsByPage,
      updateField,
      addField,
      removeField,
      duplicateField,
      ensureFieldHeightFitsFont,
      selectedField,
      selectedFieldId,
      pageScale,
      currentPage,
      totalPages,
      activeDrag,
      hoveredPageIndex,
      updatePageOverlayRect,
      getPageOverlayRect,
    ],
  );

  return (
    <PdfCreationContext.Provider value={ctxValue}>
      {children}
    </PdfCreationContext.Provider>
  );
};

export default PdfCreationProvider;
