import { useState, useCallback, useRef, useEffect } from 'react';
import { makeElementId } from '../utils/makeId';

const MAX_HISTORY = 50;
const DEBOUNCE_DELAY_MS = 300;

// ────────────────────────────────────────────────────────────────────
// Diagnostic history logging.
//
// Enable by running this in the browser console, then reload:
//   localStorage.setItem('paw_debug_history', '1')
// Disable with:
//   localStorage.removeItem('paw_debug_history')
//
// Every line is prefixed `[history]` so you can filter in DevTools.
// Designed specifically for diagnosing the crop+undo "canvas ends up
// blank" bug: when undo lands on an unexpected snapshot, this log
// reveals which push got missed or deduped, or whether history was
// wiped between the action and the undo.
//
// Each event logs `{ idx, len, source, ...details }` so a single
// scroll-through tells you:
//   • What the index was at each step — if undo walks from N to N-1
//     and N-1 is unexpectedly an empty snapshot, the wipe happened
//     earlier in the log.
//   • What the history length was — truncations (redo-stack discard)
//     and the MAX_HISTORY shift are visible.
//   • Where the call came from — every push includes `source` so you
//     can tell addElement vs replaceElements vs updateAndCommit vs
//     the debounced updateElement timer apart.
// ────────────────────────────────────────────────────────────────────
const HISTORY_DEBUG = (() => {
  try {
    return typeof localStorage !== 'undefined'
      && localStorage.getItem('paw_debug_history') === '1';
  } catch {
    return false;
  }
})();

// Short summary of an elements array — we don't dump the full JSON in
// every log line because it's huge and obscures the signal. We do show
// element count and first-three IDs, which together are enough to
// distinguish "the image array", "the cropped array", and "empty"
// in a glance.
function summarizeElements(arr) {
  if (!Array.isArray(arr)) return '<not-array>';
  if (arr.length === 0) return '[] (empty)';
  const ids = arr.slice(0, 3).map((el) => el?.id ?? '?').join(', ');
  const tail = arr.length > 3 ? `, …+${arr.length - 3}` : '';
  return `${arr.length} els [${ids}${tail}]`;
}

function hlog(event, info) {
  if (!HISTORY_DEBUG) return;
  // Using a single console.log call (not console.group) keeps the lines
  // copyable as a single block when the user wants to share the log.
  // The leading prefix is intentionally verbose so it survives DevTools
  // filter-by-text searches even when the rest of the line wraps.
  // eslint-disable-next-line no-console
  console.log(`[history] ${event}`, info);
}

export function useDesignEditor() {
  const [elements, setElements] = useState([]);
  // Multi-select state (S3). The authoritative selection is a Set of element
  // ids. Single-selection callers read `selectedId` (a derived value that's
  // non-null only when exactly one element is selected) and use it like
  // before — that path is unchanged. Multi-select callers (LayersPanel) read
  // `selectedIds` directly.
  const [selectedIds, setSelectedIds] = useState(() => new Set());
  const selectedId = selectedIds.size === 1 ? [...selectedIds][0] : null;

  const [canUndo, setCanUndo] = useState(false);
  const [canRedo, setCanRedo] = useState(false);
  const historyRef = useRef([]);
  const historyIndexRef = useRef(-1);
  const historyTimerRef = useRef(null);
  const pendingChangesRef = useRef(null);

  const syncUndoRedo = useCallback(() => {
    setCanUndo(historyIndexRef.current > 0);
    setCanRedo(historyIndexRef.current < historyRef.current.length - 1);
  }, []);

  // `source` is a label identifying who called us (addElement,
  // replaceElements, updateAndCommit, flushPendingChanges-timer, etc.).
  // It's only consulted by the diagnostic logger — production behaviour
  // is unchanged. Defaults to '?' so callers that don't bother to pass
  // it still work.
  const saveToHistory = useCallback((newElements, source = '?') => {
    const json = JSON.stringify(newElements);
    const beforeIdx = historyIndexRef.current;
    const beforeLen = historyRef.current.length;

    // Dedupe consecutive identical snapshots. The primary motivation is React
    // StrictMode, which double-invokes the setElements updater in dev to detect
    // impure updaters; the inner saveToHistory side-effect runs twice with the
    // same input and used to push two identical entries (so undo had to be
    // pressed twice to undo one action). This dedupe is also a real correctness
    // improvement outside StrictMode — calling saveToHistory with no actual
    // change is a no-op rather than a bloated history slot.
    if (historyIndexRef.current >= 0 && historyRef.current[historyIndexRef.current] === json) {
      hlog('saveToHistory DEDUPE', {
        source,
        idx: beforeIdx,
        len: beforeLen,
        snapshot: summarizeElements(newElements),
        reason: 'identical to current entry',
      });
      return;
    }

    const willTruncate = historyIndexRef.current < historyRef.current.length - 1;
    if (willTruncate) {
      historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1);
    }
    historyRef.current.push(json);
    let shifted = false;
    if (historyRef.current.length > MAX_HISTORY) {
      historyRef.current.shift();
      shifted = true;
    } else {
      historyIndexRef.current++;
    }
    syncUndoRedo();

    hlog('saveToHistory PUSH', {
      source,
      idx: `${beforeIdx} → ${historyIndexRef.current}`,
      len: `${beforeLen} → ${historyRef.current.length}`,
      snapshot: summarizeElements(newElements),
      truncated: willTruncate ? `dropped ${beforeLen - 1 - beforeIdx} redo entries` : false,
      shifted: shifted ? `MAX_HISTORY exceeded; oldest entry dropped` : false,
    });
  }, [syncUndoRedo]);

  const flushPendingChanges = useCallback(() => {
    if (pendingChangesRef.current) {
      hlog('flushPendingChanges', {
        reason: 'draining pending debounced updateElement',
        snapshot: summarizeElements(pendingChangesRef.current),
      });
      saveToHistory(pendingChangesRef.current, 'flushPendingChanges');
      pendingChangesRef.current = null;
    }
    if (historyTimerRef.current) { clearTimeout(historyTimerRef.current); historyTimerRef.current = null; }
  }, [saveToHistory]);

  useEffect(() => { return () => { if (historyTimerRef.current) clearTimeout(historyTimerRef.current); }; }, []);

  const makeId = makeElementId;

  const addElement = useCallback((element) => {
    flushPendingChanges();
    const newElement = { ...element, id: makeId() };
    hlog('addElement', { type: element?.type, newId: newElement.id });
    setElements((prev) => {
      const newElements = [...prev, newElement];
      saveToHistory(newElements, 'addElement');
      return newElements;
    });
    setSelectedIds(new Set([newElement.id]));
    return newElement.id;
  }, [flushPendingChanges, saveToHistory]);

  // Add many elements as a single history entry.
  // Used for template loading so an undo collapses the whole template back at once,
  // and so the elements appear atomically rather than racing each other through
  // setTimeout-based scheduling.
  //
  // ID handling: preserves the input element's `id` if present, generates a
  // fresh one if not. See replaceElements below for the rationale on why we
  // preserve rather than regenerate — the short version is that regeneration
  // breaks saveToHistory's dedupe when StrictMode invokes the path twice.
  const addElements = useCallback((items) => {
    if (!items || items.length === 0) return [];
    flushPendingChanges();
    const newOnes = items.map((el, i) => ({
      ...el,
      id: el?.id || makeElementId(i),
    }));
    setElements((prev) => { const newElements = [...prev, ...newOnes]; saveToHistory(newElements); return newElements; });
    setSelectedIds(new Set());
    return newOnes.map((e) => e.id);
  }, [flushPendingChanges, saveToHistory]);

  // Replace all elements in one history entry.
  //
  // Used for two flows:
  //   1. Template loading (App.jsx restore effect, the urlTemplateValid /
  //      no-saved-state branch). Template constants ship elements WITHOUT
  //      ids — we generate them here.
  //   2. Session restoration (App.jsx restore effect, the saved-state
  //      branch). Restored elements come from localStorage with their
  //      original ids intact — we preserve them.
  //
  // ID handling: preserves the input element's `id` if present, generates a
  // fresh one with `makeElementId(i)` if not.
  //
  // Why preserve rather than always regenerate
  // ───────────────────────────────────────────────────
  // Pre-May 23 2026 this function unconditionally regenerated all ids via
  // `makeElementId(i)` (which embeds Date.now()). That made the function
  // non-idempotent: two calls with the same input produced two outputs with
  // DIFFERENT ids (different millisecond timestamps).
  //
  // The visible consequence: React 18 StrictMode artificially double-mounts
  // in dev, so the session-restoration effect ran twice, both calls to
  // replaceElements pushed snapshots, and saveToHistory's dedupe couldn't
  // catch them because their JSON differed (different ids). Result: two
  // history entries for the same visual state, so undoing a crop required
  // two Cmd+Z presses in dev to walk past both restoration entries.
  //
  // Preserving input ids when present makes the function idempotent for the
  // session-restoration path — second call produces the SAME JSON, dedupe
  // catches it, single history entry. Template loading still generates
  // fresh ids because template constants don't ship with them; that path
  // doesn't trip the dedupe regression because templates are loaded once
  // per URL navigation, not by an effect that StrictMode double-runs.
  //
  // The fallback to `makeElementId(i)` rather than throwing on missing id
  // is deliberate: callers shouldn't need to know whether their inputs
  // have ids, just hand us elements and let us sort it out.
  const replaceElements = useCallback((items) => {
    flushPendingChanges();
    const next = (items || []).map((el, i) => ({
      ...el,
      id: el?.id || makeElementId(i),
    }));
    hlog('replaceElements', { snapshot: summarizeElements(next) });
    setElements(next);
    saveToHistory(next, 'replaceElements');
    setSelectedIds(new Set());
    return next.map((e) => e.id);
  }, [flushPendingChanges, saveToHistory]);

  const updateElement = useCallback((id, attrs) => {
    setElements((prev) => {
      const newElements = prev.map((el) => (el.id === id ? { ...el, ...attrs } : el));
      pendingChangesRef.current = newElements;
      if (historyTimerRef.current) clearTimeout(historyTimerRef.current);
      historyTimerRef.current = setTimeout(() => { flushPendingChanges(); }, DEBOUNCE_DELAY_MS);
      return newElements;
    });
    // Logged AFTER the setElements call so the timer reset is visible
    // — a rapid burst of updates collapses into a single history push
    // when the timer finally fires, and this log helps confirm that.
    hlog('updateElement (debounced)', { id, attrs: Object.keys(attrs || {}) });
  }, [flushPendingChanges]);

  // Immediate-commit variant of updateElement — applies the change AND
  // pushes the new state to history within the same React commit, no
  // debounce. Use this for atomic one-shot mutations where the caller
  // is certain the change is final (crop apply, photo-edit complete,
  // etc.) and needs the history snapshot to be available right away.
  //
  // Why this exists (the bug that motivated it)
  // ───────────────────────────────────────────────
  // The natural pattern `updateElement(...); commitHistory();` doesn't
  // work for synchronous immediate-commit, because updateElement uses
  // a debounced path: it queues a setElements updater that sets
  // pendingChangesRef + schedules a 300ms timer. React doesn't run
  // the updater synchronously — it runs it during the next commit
  // phase — so when commitHistory's flushPendingChanges runs in the
  // same synchronous block, pendingChangesRef is still null and the
  // flush is a no-op. The actual history push happens 300ms later
  // via the timer.
  //
  // If the user clicks Undo within that 300ms window, undo() walks
  // historyIndexRef back to the previous snapshot — which is the
  // PRE-change state. The post-change snapshot was never written, so
  // the user appears to lose more state than they undid.
  //
  // For the crop-apply case specifically, the user could legitimately
  // click Apply then immediately Undo to undo an accidental crop. With
  // the debounce-then-flush path, that produced a confusing "undo wiped
  // everything" outcome because the post-crop snapshot was never
  // captured.
  //
  // This method follows addElement's pattern (saveToHistory inside the
  // updater) so the history push happens during React's commit, before
  // any subsequent user event can fire undo().
  const updateAndCommit = useCallback((id, attrs) => {
    hlog('updateAndCommit', { id, attrs: Object.keys(attrs || {}) });
    flushPendingChanges();
    setElements((prev) => {
      const newElements = prev.map((el) => (el.id === id ? { ...el, ...attrs } : el));
      saveToHistory(newElements, 'updateAndCommit');
      return newElements;
    });
  }, [flushPendingChanges, saveToHistory]);

  const deleteElement = useCallback((id) => {
    flushPendingChanges();
    hlog('deleteElement', { id });
    setElements((prev) => {
      const newElements = prev.filter((el) => el.id !== id);
      saveToHistory(newElements, 'deleteElement');
      return newElements;
    });
    setSelectedIds((prev) => {
      if (!prev.has(id)) return prev;
      const next = new Set(prev);
      next.delete(id);
      return next;
    });
  }, [flushPendingChanges, saveToHistory]);

  // Bulk-delete (S3). Wipes every id in `ids` from the elements array as a
  // single history entry, and clears them from the selection set. No-op if
  // `ids` is empty.
  const deleteMany = useCallback((ids) => {
    if (!ids || ids.length === 0) return;
    const idSet = new Set(ids);
    flushPendingChanges();
    hlog('deleteMany', { count: ids.length });
    setElements((prev) => {
      const next = prev.filter((el) => !idSet.has(el.id));
      saveToHistory(next, 'deleteMany');
      return next;
    });
    setSelectedIds((prev) => {
      let changed = false;
      const next = new Set(prev);
      for (const id of ids) {
        if (next.delete(id)) changed = true;
      }
      return changed ? next : prev;
    });
  }, [flushPendingChanges, saveToHistory]);

  // Duplicate the given element. The copy lands a few pixels offset so it's
  // visually distinct from the original, and becomes the new selection so the
  // user can immediately drag/edit it.
  //
  // Both the existence check AND the new-id generation happen synchronously
  // BEFORE setElements is called. The previous version of this function set a
  // `didDuplicate` flag inside the setElements updater — but in React 18,
  // updater functions run asynchronously when the update queue is processed,
  // not at the call site. So `if (didDuplicate)` observed the stale (false)
  // value, and selection never flipped to the copy.
  const duplicateElement = useCallback((id) => {
    flushPendingChanges();
    const sourceIdx = elements.findIndex((e) => e.id === id);
    if (sourceIdx === -1) return null;
    const newId = makeId();
    hlog('duplicateElement', { sourceId: id, newId });
    setElements((prev) => {
      const idx = prev.findIndex((e) => e.id === id);
      if (idx === -1) return prev;
      const original = prev[idx];
      const copy = {
        ...original,
        id: newId,
        x: (original.x || 0) + 12,
        y: (original.y || 0) + 12,
        // Fresh slot relationship: a duplicated element shouldn't claim the
        // same template slot as its source — that would double-fill the slot.
        slotId: undefined,
      };
      // Insert immediately above the source so it visually appears on top.
      const next = [...prev.slice(0, idx + 1), copy, ...prev.slice(idx + 1)];
      saveToHistory(next, 'duplicateElement');
      return next;
    });
    setSelectedIds(new Set([newId]));
    return newId;
  }, [elements, flushPendingChanges, saveToHistory]);

  // Z-order helpers. Konva's render order matches array order: elements later
  // in the array paint on top. "Bring forward" moves the element one slot
  // higher in the array; "send backward" moves it one slot lower.
  const bringForward = useCallback((id) => {
    flushPendingChanges();
    setElements((prev) => {
      const idx = prev.findIndex((e) => e.id === id);
      if (idx === -1 || idx === prev.length - 1) return prev;
      const next = prev.slice();
      [next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
      saveToHistory(next, 'bringForward');
      return next;
    });
  }, [flushPendingChanges, saveToHistory]);

  const sendBackward = useCallback((id) => {
    flushPendingChanges();
    setElements((prev) => {
      const idx = prev.findIndex((e) => e.id === id);
      if (idx <= 0) return prev;
      const next = prev.slice();
      [next[idx], next[idx - 1]] = [next[idx - 1], next[idx]];
      saveToHistory(next, 'sendBackward');
      return next;
    });
  }, [flushPendingChanges, saveToHistory]);

  // Drag-reorder (S3). Moves the source element to immediately before or
  // after the target in the elements array, producing a single history
  // entry. The LayersPanel renders top-down (last-in-array on top), so
  // "before" in the panel UI means "after" in the array, and vice-versa
  // — the caller is responsible for that translation. We just take the
  // semantic position and apply it directly.
  const reorderElement = useCallback((sourceId, targetId, position = 'after') => {
    if (sourceId === targetId) return;
    flushPendingChanges();
    setElements((prev) => {
      const sourceIdx = prev.findIndex((e) => e.id === sourceId);
      const targetIdx = prev.findIndex((e) => e.id === targetId);
      if (sourceIdx === -1 || targetIdx === -1) return prev;
      const next = prev.slice();
      const [source] = next.splice(sourceIdx, 1);
      // After the splice, the target's index may have shifted by one if
      // it was after the source's original position. Re-find by id.
      const newTargetIdx = next.findIndex((e) => e.id === targetId);
      if (newTargetIdx === -1) return prev;
      const insertAt = position === 'before' ? newTargetIdx : newTargetIdx + 1;
      next.splice(insertAt, 0, source);
      saveToHistory(next, 'reorderElement');
      return next;
    });
  }, [flushPendingChanges, saveToHistory]);

  // Selection setters. `selectElement` and `deselectAll` keep the same
  // single-select semantics as before. `toggleInSelection` is the
  // multi-select primitive: toggles the id's membership in the set.
  // `setSelection` replaces the entire set with a fresh one built from
  // the provided id array — used by canvas marquee drag and the Cmd+A
  // "select all" keyboard shortcut to commit a many-id selection in a
  // single state update (vs. clearing + toggling N times, which would
  // produce N+1 renders and could flicker the Transformer's bound nodes
  // mid-update).
  const selectElement = useCallback((id) => {
    setSelectedIds(id ? new Set([id]) : new Set());
  }, []);
  const deselectAll = useCallback(() => setSelectedIds(new Set()), []);
  const toggleInSelection = useCallback((id) => {
    setSelectedIds((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);
  const setSelection = useCallback((ids) => {
    setSelectedIds(new Set(ids || []));
  }, []);
  const commitHistory = useCallback(() => flushPendingChanges(), [flushPendingChanges]);

  // Undo / redo restore the elements snapshot at the new history index AND
  // try to preserve selection. The previous version unconditionally cleared
  // selection, which felt punishing — undo a property tweak and you'd lose
  // your selection too, so refining further required reselecting first.
  //
  // We preserve any ids in the current selection that still exist in the
  // restored snapshot. Selections of already-deleted elements naturally
  // drop out.
  const undo = useCallback(() => {
    if (historyIndexRef.current > 0) {
      const beforeIdx = historyIndexRef.current;
      historyIndexRef.current--;
      const next = JSON.parse(historyRef.current[historyIndexRef.current]);
      hlog('undo', {
        idx: `${beforeIdx} → ${historyIndexRef.current}`,
        len: historyRef.current.length,
        restored: summarizeElements(next),
      });
      setElements(next);
      setSelectedIds((prev) => {
        const liveIds = new Set(next.map((e) => e.id));
        const filtered = new Set();
        for (const id of prev) if (liveIds.has(id)) filtered.add(id);
        return filtered.size === prev.size ? prev : filtered;
      });
      syncUndoRedo();
    } else {
      hlog('undo NO-OP', {
        idx: historyIndexRef.current,
        len: historyRef.current.length,
        reason: 'already at history floor',
      });
    }
  }, [syncUndoRedo]);

  const redo = useCallback(() => {
    if (historyIndexRef.current < historyRef.current.length - 1) {
      const beforeIdx = historyIndexRef.current;
      historyIndexRef.current++;
      const next = JSON.parse(historyRef.current[historyIndexRef.current]);
      hlog('redo', {
        idx: `${beforeIdx} → ${historyIndexRef.current}`,
        len: historyRef.current.length,
        restored: summarizeElements(next),
      });
      setElements(next);
      setSelectedIds((prev) => {
        const liveIds = new Set(next.map((e) => e.id));
        const filtered = new Set();
        for (const id of prev) if (liveIds.has(id)) filtered.add(id);
        return filtered.size === prev.size ? prev : filtered;
      });
      syncUndoRedo();
    } else {
      hlog('redo NO-OP', {
        idx: historyIndexRef.current,
        len: historyRef.current.length,
        reason: 'no redo entries available',
      });
    }
  }, [syncUndoRedo]);

  // Seed an initial empty-canvas entry as the history floor IF AND ONLY
  // IF history is empty. When session restoration has already pushed
  // snapshots via replaceElements (URL template, saved localStorage state,
  // etc.), those entries ARE the correct floor and must not be touched.
  //
  // History of this function
  // ────────────────────────────────────────────────────────────────────────────────────
  // Previously this function UNCONDITIONALLY set history to
  // `[JSON.stringify([])]`, regardless of what was already there. That was
  // the root cause of the "crop + Cmd+Z = blank canvas" bug for any user
  // with a restored session:
  //
  //   1. App.jsx's persistence effect calls replaceElements(saved.elements)
  //      — saveToHistory pushes the restored 5-element snapshot at idx 0.
  //   2. React 18 StrictMode artificially unmounts and remounts the
  //      component in development. The persistence effect re-runs.
  //      replaceElements regenerates element IDs (makeElementId uses fresh
  //      timestamps), so the new snapshot's JSON differs from the first
  //      one and saveToHistory's dedupe doesn't catch it. Second push at
  //      idx 1.
  //   3. App.jsx's setRestorationComplete(true) effect fires once, after
  //      both mounts have settled. initializeHistory ran here and WIPED
  //      both snapshots, leaving history = [[]] at idx 0 — even though
  //      `elements` was still showing the 5 restored items.
  //   4. User crops a photo. updateAndCommit's saveToHistory pushes the
  //      cropped state at idx 1. History = [[], [cropped]], idx = 1.
  //   5. User presses Cmd+Z. undo walks idx 1 → 0, sets elements = [],
  //      blanking the canvas.
  //
  // The fix is the guard below: skip the seed when history already has
  // entries. That keeps the restored snapshot(s) intact as the undo floor.
  //
  // The fresh-session case (no URL template, no saved state) still works:
  // history is empty when this fires, so we push the [] floor. The first
  // user action then sits at idx 1, and Cmd+Z takes the user back to the
  // empty starting canvas — the expected behavior.
  //
  // The StrictMode duplicate snapshot (idx 0 = old-ID set, idx 1 = new-ID
  // set in the example above) is a separate pre-existing oddity: in dev,
  // the user's history will contain TWO entries representing the same
  // visual state with different element IDs. Their first Cmd+Z lands on
  // the still-uncropped state (visually correct — different IDs but the
  // same picture), a second Cmd+Z lands on the OTHER uncropped state
  // (visually identical), and a third is a no-op at the floor. Annoying
  // in dev but harmless; in production builds StrictMode's double-mount
  // doesn't run, so there's only one restored snapshot and one Cmd+Z to
  // undo the crop. Fixing the dev-only duplicate would require making
  // replaceElements idempotent (deterministic IDs across calls) and is
  // out of scope for this fix.
  const initializeHistory = useCallback(() => {
    const beforeIdx = historyIndexRef.current;
    const beforeLen = historyRef.current.length;
    if (historyRef.current.length === 0) {
      historyRef.current = [JSON.stringify([])];
      historyIndexRef.current = 0;
      hlog('initializeHistory SEED', {
        reason: 'history was empty; seeding [] as the undo floor',
        newState: 'history = [[]] (single empty entry), idx = 0',
      });
    } else {
      hlog('initializeHistory NO-OP', {
        reason: 'history already has entries; restored snapshots preserved',
        idx: beforeIdx,
        len: beforeLen,
        currentEntry: beforeIdx >= 0
          ? summarizeElements(JSON.parse(historyRef.current[beforeIdx]))
          : '<none>',
      });
    }
    syncUndoRedo();
  }, [syncUndoRedo]);

  return {
    elements, selectedId, selectedIds,
    addElement, addElements, replaceElements,
    updateElement, updateAndCommit, deleteElement, deleteMany,
    duplicateElement, bringForward, sendBackward, reorderElement,
    selectElement, deselectAll, toggleInSelection, setSelection, commitHistory,
    undo, redo, canUndo, canRedo, initializeHistory,
  };
}
