import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
import { useEditor } from '@craftjs/core';
import { PageData, PageSeo } from '../types';
import { SerializedTreeNode } from '../types/sitesmith';
import { useSiteDesign, SiteDesign } from './SiteDesignContext';
import { sanitizeAiTree, flattenTreeForCraft, FlatCraftNode } from '../utils/craft-tree';
import { repairOrphanNodes } from '../utils/orphan-repair';

interface PageContextValue {
  pages: PageData[];
  headerPage: PageData;
  footerPage: PageData;
  activePageId: string;
  isEditingHeader: boolean;
  isEditingFooter: boolean;
  switchPage: (pageId: string) => void;
  editHeader: () => void;
  editFooter: () => void;
  addPage: (name: string, slug: string) => void;
  deletePage: (pageId: string) => void;
  renamePage: (pageId: string, name: string, slug: string) => void;
  /**
   * Duplicates `pageId`, inserting the copy immediately after the source in
   * `pages` and switching the canvas to the new copy. If `pageId` is the
   * active page, its current on-canvas state is saved first so the copy
   * (and the original) both reflect what's actually on screen. The copy
   * gets its own unique slug (never `'index'` -- it's never at index 0) and
   * a name of `"<source name> copy"`; its `seo` is copied from the source.
   */
  duplicatePage: (pageId: string) => void;
  /**
   * Reorders `pageId` one slot `'up'` or `'down'` within `pages` (swap with
   * the adjacent page; no-op at either end). Does NOT touch the live
   * canvas -- only list order changes. Re-applies the landing-page
   * invariant afterward (see `applyLandingInvariant`) since a reorder can
   * move a different page into/out of index 0.
   */
  movePage: (pageId: string, direction: 'up' | 'down') => void;
  /**
   * Moves `pageId` to index 0 (making it the new landing page) and
   * re-applies the landing-page invariant. Does NOT touch the live canvas.
   */
  setLandingPage: (pageId: string) => void;
  /** Merges `seo` fields onto the target page's existing `seo` (creating it if absent). */
  updatePageSeo: (pageId: string, seo: Partial<PageSeo>) => void;
  setHeaderCraftState: (craftState: string) => void;
  setFooterCraftState: (craftState: string) => void;
  setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null; seo?: PageSeo }[]) => void;
  /**
   * Bookkeeping-only: point `activePageId` at an already-loaded page without
   * re-serializing/deserializing the canvas (the caller -- e.g. useWhpApi's
   * `load()` -- has already put the right state on the canvas itself). Used
   * to fix I-1: `activePageId` defaults to the hardcoded `'home'` and
   * `load()` never updated it, so after the original Home page was deleted
   * (its replacement gets a fresh `page_<ts>` id) and the app reloaded,
   * `activePageId` pointed at nothing in `pages`, and a subsequent edit+save
   * only reached the legacy top-level fields.
   */
  setActivePageIdDirect: (pageId: string) => void;
  /** AI helpers — replace entire site or page with a new tree */
  replaceAllPages: (pages: { name: string; tree: SerializedTreeNode }[]) => void;
  replaceCurrentPage: (page: { name: string; tree: SerializedTreeNode }) => void;
  setHeader: (tree: SerializedTreeNode) => void;
  setFooter: (tree: SerializedTreeNode) => void;
  siteDesign: SiteDesign;
}

const HEADER_ID = '__header__';
const FOOTER_ID = '__footer__';

// M-3: `page_${Date.now()}` alone collides when two pages are minted inside
// the same millisecond (addPage called twice in quick succession, or two AI
// replaceAllPages entries) -- then rename/delete/save operate on both pages
// at once since they share an id. A module-scoped monotonic counter,
// combined with the timestamp, guarantees uniqueness regardless of how many
// ids are minted within the same millisecond. This is state/id-minting code
// (not toHtml/export), so Date.now() here is fine -- see task-minors-brief.md.
let pageIdCounter = 0;

/** Mints a unique page id: timestamp (base36) + a monotonic per-process counter (base36). */
export function nextPageId(): string {
  return 'page_' + Date.now().toString(36) + '_' + (++pageIdCounter).toString(36);
}

export const EMPTY_CANVAS =
  '{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"100vh","backgroundColor":"#ffffff"},"tag":"div"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';

const EMPTY_HEADER =
  '{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"60px","backgroundColor":"#ffffff","padding":"12px 24px","display":"flex","alignItems":"center"},"tag":"header"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';

const EMPTY_FOOTER =
  '{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"60px","backgroundColor":"#0f172a","color":"#94a3b8","padding":"40px 24px","textAlign":"center"},"tag":"footer"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';

/**
 * Flatten a `SerializedTreeNode` (as produced by the AI, a template, etc.)
 * into a Craft.js `SerializedNodes` JSON string ready for
 * `actions.deserialize()`.
 *
 * Untrusted input is validated the same way `apply-ai-response.ts`'s
 * `buildNodeTree` validates AI `patch`/section-replace trees: any node whose
 * `type.resolvedName` isn't a registered component is dropped (subtree and
 * all) via `sanitizeAiTree`, and a bad/colliding/`'ROOT'` id is regenerated
 * — never left as-is. This matters here specifically because `replace`
 * scope `site`/`page` responses reach this function via `actions.deserialize`
 * with no further validation downstream, unlike the `buildNodeTree` path
 * which also gets Craft.js's own `parseFreshNode` as a second line of
 * defense. If the ROOT node itself is invalid, sanitizeAiTree returns null
 * and we fall back to an empty canvas rather than handing deserialize()
 * something that could throw.
 *
 * Exported standalone (not a hook) so it's directly unit-testable without
 * mounting a Craft.js `<Editor>`.
 */
export function treeToCraftState(tree: SerializedTreeNode): string {
  const sanitized = sanitizeAiTree(tree, new Set());
  if (!sanitized) return EMPTY_CANVAS;

  const { rootNodeId, nodes: flatNodes } = flattenTreeForCraft(sanitized);
  const nodes: Record<string, FlatCraftNode> = flatNodes;

  // Craft.js deserialize requires the root node keyed as 'ROOT'.
  if (rootNodeId !== 'ROOT') {
    nodes['ROOT'] = { ...nodes[rootNodeId], parent: null, isCanvas: true };
    delete nodes[rootNodeId];
    for (const childId of nodes['ROOT'].nodes) {
      if (nodes[childId]) nodes[childId].parent = 'ROOT';
    }
    // I-2: linkedNodes children (e.g. ColumnLayout's col-0/col-1, or a
    // Section/BackgroundSection/FormContainer's SHELL_INNER wrapper) need
    // the same reparenting as nodes[] children above. Without this, a
    // ColumnLayout/SHELL_INNER-rooted AI `replace` leaves those children's
    // `parent` pointing at the OLD root id, which is then `delete`d --
    // producing a dangling parent reference that breaks select/move/delete
    // of those nodes in the Craft.js editor.
    for (const linkedId of Object.values(nodes['ROOT'].linkedNodes)) {
      if (nodes[linkedId]) nodes[linkedId].parent = 'ROOT';
    }
  }
  return JSON.stringify(nodes);
}

// Default header seed: a ROOT header Container holding a Navbar with default
// links. New sites previously opened with an EMPTY header (just a bare
// Container), so there was no menu to edit and the empty zone rendered as a
// stray band above the page. Seeding a real Navbar gives every new site an
// editable menu-with-links out of the box (and removes the empty-header gap).
//
// Item 15: the nav used to link Home/About/Services/Contact, but a brand
// new site only has a "Home" page -- About/Services/Contact were dead links
// on first click. Rather than seeding three empty placeholder pages nobody
// asked for, the simpler default is a nav with just the one real page (Home)
// plus a CTA button, which is inert (`href: '#'`) rather than pointing at a
// page that doesn't exist. Users add pages/links as their site grows.
// Node shape matches treeToCraftState() / Craft's actions.deserialize().
export const DEFAULT_HEADER_STATE = JSON.stringify({
  ROOT: {
    type: { resolvedName: 'Container' },
    isCanvas: true,
    props: { style: { width: '100%' }, tag: 'header' },
    displayName: 'Container',
    custom: {},
    hidden: false,
    nodes: ['header-navbar'],
    linkedNodes: {},
  },
  'header-navbar': {
    type: { resolvedName: 'Navbar' },
    isCanvas: false,
    props: {
      logoType: 'text',
      logoText: 'MySite',
      logoImage: '',
      logoWidth: '120px',
      logoUrl: '/',
      logoFontFamily: 'Inter, sans-serif',
      logoFontSize: '20px',
      links: [
        { text: 'Home', href: '/' },
        { text: 'Get Started', href: '#', isCta: true },
      ],
      backgroundColor: '#ffffff',
      textColor: '#3f3f46',
      hoverColor: '#3b82f6',
      ctaColor: '#3b82f6',
      ctaTextColor: '#ffffff',
      padding: '16px 24px',
      navAlignment: 'space-between',
      isSticky: false,
      showMobileMenu: false,
      style: { borderBottom: '1px solid #e4e4e7' },
    },
    displayName: 'Navbar',
    parent: 'ROOT',
    custom: {},
    hidden: false,
    nodes: [],
    linkedNodes: {},
  },
});

const PageContext = createContext<PageContextValue>({
  pages: [],
  headerPage: { id: HEADER_ID, name: 'Header', slug: '__header__', craftState: null },
  footerPage: { id: FOOTER_ID, name: 'Footer', slug: '__footer__', craftState: null },
  activePageId: 'home',
  isEditingHeader: false,
  isEditingFooter: false,
  switchPage: () => {},
  editHeader: () => {},
  editFooter: () => {},
  addPage: () => {},
  deletePage: () => {},
  renamePage: () => {},
  duplicatePage: () => {},
  movePage: () => {},
  setLandingPage: () => {},
  updatePageSeo: () => {},
  setHeaderCraftState: () => {},
  setFooterCraftState: () => {},
  setPagesCraftState: () => {},
  setActivePageIdDirect: () => {},
  replaceAllPages: () => {},
  replaceCurrentPage: () => {},
  setHeader: () => {},
  setFooter: () => {},
  siteDesign: {} as SiteDesign,
});

export const usePages = () => useContext(PageContext);

function slugify(name: string): string {
  const slug = name
    .toLowerCase()
    .trim()
    .replace(/[^a-z0-9\s-]/g, '')
    .replace(/\s+/g, '-')
    .replace(/-+/g, '-');
  // M-4: a punctuation-only name (e.g. "!!!") strips down to '' -- without a
  // fallback, buildSavePayload would write filename = '' + '.html' for that
  // page. uniqueSlug's existing dedupe logic then applies on top of this
  // fallback the same way it does for any other base ('page', 'page-2', ...).
  return slug || 'page';
}

/**
 * Append `-2`, `-3`, … to `base` until it no longer collides with
 * `existingSlugs`. Two pages that slugify to the same string (e.g. both
 * named "About") must not both publish to `about.html` — the second write
 * would silently overwrite the first on publish.
 */
export function uniqueSlug(base: string, existingSlugs: string[]): string {
  if (!existingSlugs.includes(base)) return base;
  let i = 2;
  while (existingSlugs.includes(`${base}-${i}`)) i++;
  return `${base}-${i}`;
}

/**
 * Re-establishes the landing-page invariant on a REORDERED pages array: the
 * page now at index 0 is the landing page and its slug is locked to
 * `'index'` (regardless of whatever slug it held before it was moved there);
 * every other page keeps its slug UNLESS it's the page that previously held
 * `'index'` and has now been demoted to index > 0 -- that page needs a real,
 * unique slug of its own (derived from its name) since a page can no longer
 * publish to `index.html` from anywhere but index 0.
 *
 * Pure function of the array -- used by both `movePage` (swap two adjacent
 * pages) and `setLandingPage` (move an arbitrary page to index 0) as the
 * shared "fix the invariant up after reordering" step, and directly
 * unit-testable without mounting `PageProvider`.
 *
 * Normally at most one page enters with slug `'index'` (true for any array
 * that already satisfied the invariant before the reorder that produced this
 * input) -- exactly the case both callers hand it. Defensively, though, a
 * STRAY second page with slug `'index'` at index > 0 (e.g. from legacy
 * loaded data that predates this invariant) is also demoted rather than left
 * as a duplicate -- see the running `usedSlugs` accumulation below.
 */
export function applyLandingInvariant(pages: PageData[]): PageData[] {
  if (pages.length === 0) return pages;

  // Slugs that must not be collided into: 'index' (reserved for whoever
  // ends up at index 0) plus every non-landing page's existing slug except
  // any demoted page's (it currently holds 'index' and is about to be given
  // a new one). Computed upfront, over the WHOLE array, so a demoted page's
  // new slug is checked against every other page regardless of array order
  // -- checking only "slugs seen so far" while walking the array would miss
  // a collision against a page that appears LATER in the list than the
  // demoted one. Mutated (pushed to) as pages are demoted below so that two
  // demoted pages in the same pass can't collide with EACH OTHER either.
  const usedSlugs: string[] = ['index'];
  for (let i = 1; i < pages.length; i++) {
    if (pages[i].slug !== 'index') usedSlugs.push(pages[i].slug);
  }

  return pages.map((page, i) => {
    if (i === 0) {
      return page.slug === 'index' ? page : { ...page, slug: 'index' };
    }
    if (page.slug === 'index') {
      // Demoted landing page (or a stray extra 'index' page -- see doc
      // comment above) -- give it a real, unique slug of its own.
      const newSlug = uniqueSlug(slugify(page.name), usedSlugs);
      usedSlugs.push(newSlug);
      return { ...page, slug: newSlug };
    }
    return page;
  });
}

const DEFAULT_PAGE: PageData = {
  id: 'home',
  name: 'Home',
  slug: 'index',
  craftState: null,
};

const DEFAULT_HEADER: PageData = {
  id: HEADER_ID,
  name: 'Header',
  slug: '__header__',
  craftState: DEFAULT_HEADER_STATE,
};

const DEFAULT_FOOTER: PageData = {
  id: FOOTER_ID,
  name: 'Footer',
  slug: '__footer__',
  craftState: null,
};

export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const { query, actions } = useEditor();
  const { design } = useSiteDesign();
  const [pages, setPages] = useState<PageData[]>([DEFAULT_PAGE]);
  const [headerPage, setHeaderPage] = useState<PageData>(DEFAULT_HEADER);
  const [footerPage, setFooterPage] = useState<PageData>(DEFAULT_FOOTER);
  const [activePageId, setActivePageId] = useState('home');
  const activePageIdRef = useRef(activePageId);
  activePageIdRef.current = activePageId;

  // Mirror the latest state in refs so event handlers (switchPage,
  // deletePage) can synchronously read "what's current" without smuggling a
  // side effect into a setState updater function to peek at `prev` — updater
  // functions must stay pure since React (StrictMode) may invoke them twice.
  const pagesRef = useRef(pages);
  pagesRef.current = pages;
  const headerPageRef = useRef(headerPage);
  headerPageRef.current = headerPage;
  const footerPageRef = useRef(footerPage);
  footerPageRef.current = footerPage;

  const isEditingHeader = activePageId === HEADER_ID;
  const isEditingFooter = activePageId === FOOTER_ID;

  /** Save whatever is on the current Frame back to the right state slot */
  const saveCurrentState = useCallback(() => {
    const currentState = query.serialize();
    const currentId = activePageIdRef.current;

    if (currentId === HEADER_ID) {
      setHeaderPage((prev) => ({ ...prev, craftState: currentState }));
    } else if (currentId === FOOTER_ID) {
      setFooterPage((prev) => ({ ...prev, craftState: currentState }));
    } else {
      setPages((prev) =>
        prev.map((p) => (p.id === currentId ? { ...p, craftState: currentState } : p)),
      );
    }
  }, [query]);

  /** Load a craft state into the Frame.
   *
   *  Every state goes through `repairOrphanNodes` first: a node present in
   *  the serialized state but not reachable from ROOT via `nodes`/
   *  `linkedNodes` is never instantiated by Craft.js's `<Frame>` at all --
   *  it doesn't render, so it isn't merely unselectable, it's invisible and
   *  otherwise unrecoverable. Reattaching it to the end of ROOT makes it an
   *  ordinary child the user can see, select and delete. Cheap (single JSON
   *  round-trip) and a no-op -- returning the identical string -- for the
   *  overwhelmingly common healthy case.
   *
   *  Note this orphan-node repair is a different mechanism from the
   *  originally-reported symptom (a *visible* element on the canvas that
   *  can't be selected or deleted) -- that report is still unreproduced;
   *  see `orphan-repair.ts` for detail. */
  const loadState = useCallback(
    (craftState: string | null, fallback: string) => {
      setTimeout(() => {
        const source = craftState || fallback;
        const { state, repaired } = repairOrphanNodes(source);
        if (repaired.length > 0) {
          // I5: console-buffer.ts only patches console.error, and this
          // reattach signal is the single most diagnostic clue for the
          // still-unreproduced "elements drop off the canvas" report -- it
          // must reach the in-builder issue reporter's console buffer.
          console.error(
            `[site-builder] reattached ${repaired.length} unreachable node(s) to the page root:`,
            repaired.join(', '),
          );
        }
        try {
          actions.deserialize(state);
        } catch (e) {
          console.error('Failed to deserialize state:', e);
          try {
            // NOT run through repairOrphanNodes: `fallback` must always be
            // one of the module's own known-safe constants (EMPTY_CANVAS /
            // EMPTY_HEADER / EMPTY_FOOTER -- true at all current call sites),
            // never untrusted/stored data, since this is the last line of
            // defense before giving up silently below.
            actions.deserialize(fallback);
          } catch (_e2) {
            // give up
          }
        }
      }, 0);
    },
    [actions],
  );

  const switchPage = useCallback(
    (pageId: string) => {
      if (pageId === activePageIdRef.current) return;

      // Serialize the current Craft.js state synchronously BEFORE switching
      const currentState = query.serialize();
      const currentId = activePageIdRef.current;

      // Persist the serialized state to the correct page slot
      if (currentId === HEADER_ID) {
        setHeaderPage((prev) => ({ ...prev, craftState: currentState }));
      } else if (currentId === FOOTER_ID) {
        setFooterPage((prev) => ({ ...prev, craftState: currentState }));
      } else {
        setPages((prev) =>
          prev.map((p) => (p.id === currentId ? { ...p, craftState: currentState } : p)),
        );
      }

      // Load target page state. `pageId` can never equal `currentId` here
      // (guarded by the early return above), so the target's stored state
      // was untouched by the setState calls just above — the refs (kept in
      // sync with state on every render) are safe to read synchronously
      // without waiting for a re-render, and without running the load as a
      // side effect inside a setState updater.
      if (pageId === HEADER_ID) {
        loadState(headerPageRef.current.craftState, EMPTY_HEADER);
      } else if (pageId === FOOTER_ID) {
        loadState(footerPageRef.current.craftState, EMPTY_FOOTER);
      } else {
        const target = pagesRef.current.find((p) => p.id === pageId);
        loadState(target?.craftState || null, EMPTY_CANVAS);
      }

      setActivePageId(pageId);
      activePageIdRef.current = pageId;
    },
    [query, loadState],
  );

  const editHeader = useCallback(() => {
    switchPage(HEADER_ID);
  }, [switchPage]);

  const editFooter = useCallback(() => {
    switchPage(FOOTER_ID);
  }, [switchPage]);

  const addPage = useCallback(
    (name: string, slug: string) => {
      const requestedSlug = slug || slugify(name);
      const id = nextPageId();

      // Save current page first
      saveCurrentState();

      setPages((prev) => [
        ...prev,
        {
          id,
          name,
          slug: uniqueSlug(requestedSlug, prev.map((p) => p.slug)),
          craftState: null,
        },
      ]);

      // Switch to the new page with empty canvas
      loadState(null, EMPTY_CANVAS);

      setActivePageId(id);
      activePageIdRef.current = id;
    },
    [saveCurrentState, loadState],
  );

  const deletePage = useCallback(
    (pageId: string) => {
      // Can't delete header/footer
      if (pageId === HEADER_ID || pageId === FOOTER_ID) return;

      const prev = pagesRef.current;
      if (prev.length <= 1) return;

      const filtered = prev.filter((p) => p.id !== pageId);
      setPages(filtered);

      // If deleting the active page, switch to the first remaining. This
      // runs after the state update is computed (not inside the setPages
      // updater) so the side effects (deserialize, ref/state mutation) fire
      // exactly once regardless of how many times React invokes updaters.
      if (pageId === activePageIdRef.current) {
        const nextPage = filtered[0];
        setActivePageId(nextPage.id);
        activePageIdRef.current = nextPage.id;
        loadState(nextPage.craftState, EMPTY_CANVAS);
      }
    },
    [loadState],
  );

  /**
   * Duplicates `pageId`: inserts a copy immediately after the source in
   * `pages` and switches the live canvas to it. See the doc comment on
   * `PageContextValue.duplicatePage`.
   */
  const duplicatePage = useCallback(
    (pageId: string) => {
      // Always persist whatever is on the live canvas back into its page
      // slot BEFORE any teardown below (same as addPage/switchPage/deletePage
      // do unconditionally). Without this, duplicating a page OTHER than the
      // active one would tear down and switch the canvas via loadState()
      // further down without ever serializing the outgoing active page's
      // live edits into its slot -- silently discarding them.
      saveCurrentState();

      const isActive = pageId === activePageIdRef.current;
      const source = pagesRef.current.find((p) => p.id === pageId);
      if (!source) return;

      // If the source IS the active page, saveCurrentState() above just
      // wrote the live canvas into `source.craftState`'s slot -- but
      // `pagesRef.current` (captured above) may still be the pre-update
      // snapshot depending on render timing, so ask Craft.js directly for
      // the same value rather than re-reading the ref. If the source is a
      // NON-active page, its stored craftState is untouched by saving the
      // (different) active page above, so use it as-is.
      const sourceCraftState = isActive ? query.serialize() : source.craftState;
      const otherSlugs = pagesRef.current.map((p) => p.slug);
      const copyId = nextPageId();
      const copyName = `${source.name} copy`;
      // The copy is always inserted AFTER the source (index >= 1), so it
      // never needs the reserved 'index' slug -- a normal unique slug always
      // applies here regardless of whether the source itself is the landing
      // page.
      const copySlug = uniqueSlug(slugify(copyName), otherSlugs);
      const copy: PageData = {
        id: copyId,
        name: copyName,
        slug: copySlug,
        craftState: sourceCraftState,
        seo: source.seo ? { ...source.seo } : undefined,
      };

      setPages((prev) => {
        const idx = prev.findIndex((p) => p.id === pageId);
        if (idx === -1) return prev;
        const next = [...prev];
        next.splice(idx + 1, 0, copy);
        return next;
      });

      // Switch the canvas to the new copy so the user lands on it, same as
      // addPage switching to the freshly created page.
      loadState(copy.craftState, EMPTY_CANVAS);
      setActivePageId(copyId);
      activePageIdRef.current = copyId;
    },
    [query, saveCurrentState, loadState],
  );

  /**
   * Reorders `pageId` one slot up or down (swap with the adjacent page).
   * Pure list-order change -- does not touch the live canvas. See the doc
   * comment on `PageContextValue.movePage`.
   */
  const movePage = useCallback((pageId: string, direction: 'up' | 'down') => {
    setPages((prev) => {
      const idx = prev.findIndex((p) => p.id === pageId);
      if (idx === -1) return prev;
      const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
      if (swapIdx < 0 || swapIdx >= prev.length) return prev; // no-op at the ends

      const next = [...prev];
      [next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
      return applyLandingInvariant(next);
    });
  }, []);

  /**
   * Moves `pageId` to index 0, making it the new landing page. Pure list-
   * order change -- does not touch the live canvas. See the doc comment on
   * `PageContextValue.setLandingPage`.
   */
  const setLandingPage = useCallback((pageId: string) => {
    setPages((prev) => {
      const idx = prev.findIndex((p) => p.id === pageId);
      if (idx <= 0) return prev; // already the landing page, or not found

      const next = [...prev];
      const [moved] = next.splice(idx, 1);
      next.unshift(moved);
      return applyLandingInvariant(next);
    });
  }, []);

  const renamePage = useCallback((pageId: string, name: string, slug: string) => {
    setPages((prev) =>
      prev.map((p, i) => {
        if (p.id !== pageId) return p;
        // First page is the landing page — its slug is locked to 'index' so
        // the file always publishes to index.html regardless of the user-set
        // name. The display name can change freely.
        if (i === 0) return { ...p, name, slug: 'index' };
        const requestedSlug = slug || slugify(name);
        const otherSlugs = prev.filter((pp) => pp.id !== pageId).map((pp) => pp.slug);
        return { ...p, name, slug: uniqueSlug(requestedSlug, otherSlugs) };
      }),
    );
  }, []);

  /** Merges `seo` fields onto the target page's existing `seo` (creating it if absent). */
  const updatePageSeo = useCallback((pageId: string, seo: Partial<PageSeo>) => {
    setPages((prev) =>
      prev.map((p) => (p.id === pageId ? { ...p, seo: { ...p.seo, ...seo } } : p)),
    );
  }, []);

  /** Allow external code (e.g., load from API) to set the header craft state */
  const setHeaderCraftState = useCallback((craftState: string) => {
    setHeaderPage((prev) => ({ ...prev, craftState }));
  }, []);

  /** Allow external code (e.g., load from API) to set the footer craft state */
  const setFooterCraftState = useCallback((craftState: string) => {
    setFooterPage((prev) => ({ ...prev, craftState }));
  }, []);

  /**
   * Bookkeeping-only setter for `activePageId` -- see the doc comment on
   * `PageContextValue.setActivePageIdDirect`. Does NOT serialize/deserialize
   * the canvas; callers that need that should use `switchPage` instead.
   */
  const setActivePageIdDirect = useCallback((pageId: string) => {
    setActivePageId(pageId);
    activePageIdRef.current = pageId;
  }, []);

  /** Allow external code (e.g., load from API) to restore pages with craft states */
  const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null; seo?: PageSeo }[]) => {
    setPages(pagesData.map((p, i) => ({
      id: p.id,
      name: p.name,
      // Heal legacy projects whose first page was saved with slug='home' (or
      // any other) before the landing-page rule existed. The first page is
      // ALWAYS the landing page → slug 'index' → file index.html.
      slug: i === 0 ? 'index' : p.slug,
      craftState: p.craftState,
      seo: p.seo,
    })));
  }, []);

  /**
   * AI helper: replace all pages with newly generated trees.
   * Stores each page's serialized state without touching the live canvas
   * (the canvas still shows the currently active page — call switchPage() if needed).
   */
  const replaceAllPages = useCallback((newPages: { name: string; tree: SerializedTreeNode }[]) => {
    if (newPages.length === 0) return;
    // Track slugs as they're assigned so later pages dedupe against earlier
    // ones in the same batch (e.g. the AI generating two "About" pages).
    const seenSlugs: string[] = [];
    const built = newPages.map((p, i) => {
      // First page must publish to index.html so it serves at the site root.
      // Apache resolves '/' to index.html, not home.html — without this, the
      // AI's "Home" page lands at /home.html and visitors hit a blank root.
      const slug = i === 0 ? 'index' : uniqueSlug(slugify(p.name), seenSlugs);
      seenSlugs.push(slug);
      return {
        id: i === 0 ? 'home' : nextPageId(),
        name: p.name,
        slug,
        craftState: treeToCraftState(p.tree),
      };
    });
    setPages(built);
    // Load the first page into the live canvas
    const firstState = built[0].craftState;
    setActivePageId(built[0].id);
    activePageIdRef.current = built[0].id;
    loadState(firstState, EMPTY_CANVAS);
  }, [loadState]);

  /**
   * AI helper: replace the current page's tree.
   * Deserializes the new tree into the live Craft.js canvas and persists it.
   */
  const replaceCurrentPage = useCallback((page: { name: string; tree: SerializedTreeNode }) => {
    const craftState = treeToCraftState(page.tree);
    const currentId = activePageIdRef.current;
    if (currentId === HEADER_ID) {
      setHeaderPage((prev) => ({ ...prev, name: page.name, craftState }));
    } else if (currentId === FOOTER_ID) {
      setFooterPage((prev) => ({ ...prev, name: page.name, craftState }));
    } else {
      setPages((prev) =>
        prev.map((p) => (p.id === currentId ? { ...p, name: page.name, craftState } : p)),
      );
    }
    loadState(craftState, EMPTY_CANVAS);
  }, [loadState]);

  /**
   * AI helper: replace the shared header tree.
   * Updates stored state; does NOT switch the canvas to header view.
   */
  const setHeader = useCallback((tree: SerializedTreeNode) => {
    const craftState = treeToCraftState(tree);
    setHeaderPage((prev) => ({ ...prev, craftState }));
    // If the canvas is currently showing the header, refresh it live
    if (activePageIdRef.current === HEADER_ID) {
      loadState(craftState, EMPTY_HEADER);
    }
  }, [loadState]);

  /**
   * AI helper: replace the shared footer tree.
   * Updates stored state; does NOT switch the canvas to footer view.
   */
  const setFooter = useCallback((tree: SerializedTreeNode) => {
    const craftState = treeToCraftState(tree);
    setFooterPage((prev) => ({ ...prev, craftState }));
    // If the canvas is currently showing the footer, refresh it live
    if (activePageIdRef.current === FOOTER_ID) {
      loadState(craftState, EMPTY_FOOTER);
    }
  }, [loadState]);

  return (
    <PageContext.Provider
      value={{
        pages,
        headerPage,
        footerPage,
        activePageId,
        isEditingHeader,
        isEditingFooter,
        switchPage,
        editHeader,
        editFooter,
        addPage,
        deletePage,
        renamePage,
        duplicatePage,
        movePage,
        setLandingPage,
        updatePageSeo,
        setHeaderCraftState,
        setFooterCraftState,
        setPagesCraftState,
        setActivePageIdDirect,
        replaceAllPages,
        replaceCurrentPage,
        setHeader,
        setFooter,
        siteDesign: design,
      }}
    >
      {children}
    </PageContext.Provider>
  );
};
