import type {
    Anchors,
    DevicePresetDto,
    FrameScreen,
    GroupLayer,
    Layer,
    LayerStyle,
    LayerType,
    Layout,
    PathNode,
    ShapeLayer,
    TextLayer,
} from '@/types/layout';
import { emptyLayout, FORM_FACTOR_DEFAULT_CANVAS } from '@/types/layout';
import { migrateLayout } from '@/types/migrateLayout';
import { router } from '@inertiajs/react';
import { applyPatches, enablePatches, produceWithPatches, type Patch } from 'immer';
import { create } from 'zustand';
import { resolveAnchors, rectToAnchors } from './anchors';
import { applyLaurelSpacing, applyStarRating } from './elements';
import { createShapeLayer } from './factories';
import {
    applyLocaleEdit,
    applyLocaleOverrides,
    applyLocaleScreenPaths,
} from './responsive-resolver';

const newId = () =>
    typeof crypto !== 'undefined' && 'randomUUID' in crypto
        ? crypto.randomUUID()
        : `l_${Math.random().toString(36).slice(2, 10)}`;

type Rect = { x: number; y: number; width: number; height: number };

/**
 * Discriminated unit shape for align/distribute. A "leaf" unit is a regular
 * (non-group) layer with its own anchors. A "group" unit represents a group
 * layer treated as a single bbox enclosing its alignable children — the bbox
 * is the alignment target, and translations apply to every child as a unit.
 *
 * Why: groups carry zero-box anchors so they can't participate in bbox math
 * directly. Without expansion the user couldn't align/distribute a group
 * relative to other layers — selecting a group was a silent no-op.
 */
type AlignUnit =
    | { kind: 'leaf'; id: string; rect: Rect; anchors: Layer['anchors'] }
    | {
          kind: 'group';
          id: string;
          rect: Rect;
          childIds: string[];
      };

/**
 * Rewrite element link refs (laurel spacing / star rating) onto cloned ids when
 * the referenced text was duplicated/pasted alongside. Leaves the ref untouched
 * (harmless + dormant) when the linked layer didn't come along.
 */
function remapElementLinks(layer: Layer, idMap: Map<string, string>): void {
    if (layer.type !== 'shape') return;
    if (layer.spacingLink && idMap.has(layer.spacingLink.valueId)) {
        layer.spacingLink = { ...layer.spacingLink, valueId: idMap.get(layer.spacingLink.valueId)! };
    }
    if (layer.ratingLink && idMap.has(layer.ratingLink.scoreId)) {
        layer.ratingLink = { ...layer.ratingLink, scoreId: idMap.get(layer.ratingLink.scoreId)! };
    }
}

/**
 * Resolve a selection into align/distribute units. Group layers expand into
 * a single virtual rect = bbox of their non-locked, non-background children.
 * Empty/locked-only groups drop out. Locked + background leaf layers also
 * drop out (their position can't be mutated). Returns the units in selection
 * order; downstream sort (distribute) reorders by axis position.
 */
function buildAlignUnits(selection: string[], layout: Layout): AlignUnit[] {
    const units: AlignUnit[] = [];
    for (const id of selection) {
        const layer = layout.layers.find((l) => l.id === id);
        if (!layer) continue;
        if (layer.type === 'group') {
            const kids = layout.layers.filter(
                (l) =>
                    l.parentId === id &&
                    l.type !== 'background' &&
                    l.type !== 'group' &&
                    l.locked !== true,
            );
            if (kids.length === 0) continue;
            const kidRects = kids.map((k) => resolveAnchors(k.anchors, layout.canvas));
            const left = Math.min(...kidRects.map((r) => r.x));
            const top = Math.min(...kidRects.map((r) => r.y));
            const right = Math.max(...kidRects.map((r) => r.x + r.width));
            const bottom = Math.max(...kidRects.map((r) => r.y + r.height));
            units.push({
                kind: 'group',
                id,
                rect: { x: left, y: top, width: right - left, height: bottom - top },
                childIds: kids.map((k) => k.id),
            });
        } else if (
            layer.type !== 'background' &&
            layer.locked !== true
        ) {
            units.push({
                kind: 'leaf',
                id,
                rect: resolveAnchors(layer.anchors, layout.canvas),
                anchors: layer.anchors,
            });
        }
    }
    return units;
}

/**
 * Apply an (dx, dy) translation to a unit. Leaf units rewrite their own
 * anchors; group units translate every child by the same delta so the
 * group's internal layout is preserved as a whole.
 */
function applyUnitTranslate(
    draft: Layout,
    unit: AlignUnit,
    dx: number,
    dy: number,
    canvas: Layout['canvas'],
): void {
    if (dx === 0 && dy === 0) return;
    if (unit.kind === 'leaf') {
        const target = draft.layers.find((l) => l.id === unit.id);
        if (!target) return;
        const rect = resolveAnchors(target.anchors, canvas);
        target.anchors = rectToAnchors(
            { ...rect, x: rect.x + dx, y: rect.y + dy },
            canvas,
            target.anchors,
        );
        return;
    }
    for (const childId of unit.childIds) {
        const child = draft.layers.find((l) => l.id === childId);
        if (!child) continue;
        const rect = resolveAnchors(child.anchors, canvas);
        child.anchors = rectToAnchors(
            { ...rect, x: rect.x + dx, y: rect.y + dy },
            canvas,
            child.anchors,
        );
    }
}

/**
 * Ephemeral snapshot of a layer's visual style produced by `copyStyle` and
 * consumed by `pasteStyle`. Not serialized to `layout.json`; not pushed through
 * the undo stack on copy (clipboard is UI state, not layout state). Paste is
 * undoable since it mutates layers.
 *
 * - `style`: the type-agnostic `LayerStyle` bag (fill/stroke/opacity/shadow/etc).
 * - `textFields`: typography-specific knobs that live directly on `TextLayer`
 *   (not inside `style`). Only applied on paste when target.type === 'text'.
 * - `shapeFields`: shape-specific visual knobs that live directly on
 *   `ShapeLayer` (not inside `style`). Only applied on paste when
 *   target.type === 'shape'.
 */
export type StyleSnapshot = {
    from: LayerType;
    style?: LayerStyle;
    textFields?: Partial<TextLayer>;
    shapeFields?: Partial<ShapeLayer>;
};

export type ArrangeDirection = 'front' | 'forward' | 'backward' | 'back';
export type AlignDirection =
    | 'left'
    | 'centerH'
    | 'right'
    | 'top'
    | 'centerV'
    | 'bottom';
export type DistributeAxis = 'horizontal' | 'vertical';

enablePatches();

const MAX_HISTORY = 100;

type HistoryEntry = { forward: Patch[]; inverse: Patch[] };

export type EditorState = {
    /**
     * Layout the renderer + autosave + undo all consume. Carries the locale
     * overlay applied (per-layer `localeOverrides[activeLocale]` deep-merged,
     * `screen.localePaths[activeLocale]` swapped onto `screen.storagePath`).
     * The persist subscriber relies on reference identity
     * (`state.layout !== prev.layout`) to detect edits.
     */
    layout: Layout;
    /**
     * Raw screen Layout WITHOUT any locale overlay applied. Mutations from a
     * non-default locale diff against this baseline so per-locale edits land
     * cleanly in `layer.localeOverrides[locale]` instead of clobbering the
     * default-locale body. The default-locale layout IS this baseline.
     */
    baseLayout: Layout;
    /**
     * Monotonic user-edit counter. Bumped ONLY by `writeBackToBase` (real user
     * mutations), never by `replaceLayout` (server hydrate / reload / factor +
     * screen switch). Autosave watches this instead of the `baseLayout`
     * reference so a server-driven re-hydrate doesn't fire a spurious save —
     * which under Reverb would feed a reload→save→broadcast→reload loop and
     * race the optimistic-concurrency guard.
     */
    editSeq: number;
    /**
     * Active locale — copied from URL state on every `Editor.tsx` reload so
     * locale-aware selectors (text rendering, RTL behavior, per-locale
     * screenshot lookup) can resolve without prop-drilling.
     */
    activeLocale: string;
    /**
     * Locales the user picked at project creation. The toolbar locale
     * switcher + export modal locale selector filter through this. Seeded
     * by `Editor.tsx` from the `project.active_locales` prop; always
     * contains at least the project's default locale.
     */
    activeLocales: string[];
    /**
     * Project's default locale — the "master" the resolver falls back to
     * when a per-locale override is absent. Mirrored from
     * `project.default_locale`. Constant for the lifetime of the editor
     * session; never edited from here.
     */
    defaultLocale: string;
    selection: string[];
    past: HistoryEntry[];
    future: HistoryEntry[];
    devicePresets: DevicePresetDto[];
    /**
     * Server-resolved id of the screen the editor is currently authoring.
     * Set from `Editor.tsx` whenever the active screen changes; consumed by
     * server-action store helpers (e.g. `setFrameScreenLocalePath`) so they
     * don't have to re-derive it from props or URL parsing.
     */
    activeScreenId: number | null;
    /** Ephemeral per-layer aspect lock. Presence of a layerId in this map
     * means the layer's proportions are locked; the value is the ratio
     * (w/h) captured at toggle time. Not persisted to layout.json. */
    aspectLocks: Record<string, number>;
    /** Id of the raster layer currently in interactive-crop mode. Ephemeral;
     * clears on selection change, screen switch, or Esc. */
    cropModeLayerId: string | null;
    /** Pen-tool drawing state. `penDraft` holds placed nodes in canvas-pixel
     * space so the coords stay stable while the bbox grows; commit normalizes
     * to bbox-fraction. Ephemeral; never serialized. */
    penMode: boolean;
    penDraft: PathNode[] | null;
    penClosed: boolean;
    /** Callout single-drag authoring state. `idle` = not active. `armed` =
     * shape has been picked from the toolbar dropdown; next mousedown on a
     * frame/image layer starts the drag. The drawn rect becomes both the
     * source region AND the display rect (in-place highlight — zoom = 1).
     * Ephemeral; never serialized. */
    calloutMode: 'idle' | 'armed';
    /** Pre-picked shape for the next callout commit. Picked when the user
     * chooses an option from the toolbar's Callout dropdown. */
    calloutShape: 'rounded-rect' | 'ellipse' | 'pill';
    /** When non-null, the next callout commit UPDATES the layer with this id
     * instead of creating a new one. Set by `repickCalloutSource`; cleared by
     * `enterCalloutMode` (toolbar fresh-create) and `exitCalloutMode` (cancel
     * or successful commit). Ephemeral; never serialized. */
    calloutEditingLayerId: string | null;
    /** Layer id whose path nodes are currently being edited (double-click into
     * a path shape). Ephemeral; exits on Esc or selection change. */
    pathEditLayerId: string | null;
    /** Layer id currently being edited inline as text (double-click on a text
     * layer). Ephemeral; the Konva text node hides while the HTML textarea
     * overlay takes over rendering. Exits on Esc, blur, or selection change. */
    textEditLayerId: string | null;
    /**
     * Ephemeral style clipboard. Set by `copyStyle`, consumed by `pasteStyle`.
     * Not serialized; survives until overwritten, cleared, or the page unloads.
     */
    clipboardStyle: StyleSnapshot | null;
    /**
     * Ephemeral layer clipboard. Set by `copyLayers`, consumed by `pasteLayers`.
     * Stores deep-cloned layers (with original IDs + parentId refs intact);
     * paste regenerates IDs so repeated pastes don't collide. Not serialized.
     */
    clipboardLayers: Layer[] | null;
    /**
     * The screen the clipboard layers were copied from. Used by `pasteLayers`
     * to decide whether to offset the paste position: same-screen paste gets
     * the 2% offset (so the clone is visible alongside the original), cross-
     * screen paste lands at the same anchors (no offset — user expects the
     * layer to appear at the same spot on the destination screen).
     */
    clipboardSourceScreenId: number | null;

    addLayer: (layer: Layer, options?: { select?: boolean }) => void;
    /** Insert several layers as ONE undoable edit — used for curated elements
     *  (a group + its children). Optionally select `selectId` (the group). */
    addLayers: (layers: Layer[], options?: { selectId?: string }) => void;
    updateLayer: (id: string, recipe: (draft: Layer) => void) => void;
    previewLayer: (id: string, recipe: (draft: Layer) => void) => void;
    deleteLayer: (id: string) => void;
    deleteSelected: () => void;
    duplicateSelected: () => void;
    arrange: (direction: ArrangeDirection) => void;
    alignSelected: (direction: AlignDirection) => void;
    distributeSelected: (axis: DistributeAxis) => void;
    nudgeSelected: (dx: number, dy: number) => void;
    /**
     * Commit a whole-group move/resize as ONE history entry. Each update sets a
     * member's anchors (+ optional rotation). `scale` (present only for a group
     * RESIZE, uniform) additionally scales type-specific props — text fontSize/
     * spacing, shape stroke/radius/shadow — and flips auto-grow text to fixed,
     * so the element scales as a unit and a single undo reverts the whole thing.
     */
    commitGroupTransform: (
        updates: Array<{ id: string; anchors: Anchors; rotation?: number; scale?: number }>,
    ) => void;
    moveLayer: (id: string, direction: 'up' | 'down' | 'top' | 'bottom') => void;
    reorderLayers: (orderedIds: string[]) => void;
    setSelection: (ids: string[]) => void;
    /**
     * Canvas-click selection: promotes a click on a grouped child to its
     * outermost ancestor group. Additive (shift/meta) clicks bypass promotion
     * so users can drill into a group member. Panel-side selection should go
     * through `setSelection` directly — no promotion wanted there.
     */
    selectWithParent: (id: string, options?: { additive?: boolean }) => void;
    clearSelection: () => void;
    /**
     * Wrap the current selection in a new GroupLayer. No-op when <2 selected
     * layers remain after filtering nested groups (v1 disallows nested groups).
     */
    groupSelected: () => void;
    /**
     * For every selected GroupLayer: unparent its children and remove the
     * group. Selection moves to the freed children.
     */
    ungroupSelected: () => void;
    undo: () => void;
    redo: () => void;
    /**
     * Replace the current screen's layout (e.g. on screen-switch). `layout`
     * becomes the locale-overlay-applied version of the input; `baseLayout`
     * stores the raw input. Undo/redo history clears.
     */
    replaceLayout: (layout: Layout) => void;
    /**
     * Switch the active locale. Re-applies the locale overlay so
     * `state.layout` reflects the new locale's screenshot + per-layer
     * overrides on top of `baseLayout`. No undo-history reset — locale
     * changes don't mutate base structure.
     */
    setActiveLocale: (locale: string) => void;
    /**
     * Replace the project's active locale list + default locale. Called by
     * `Editor.tsx` on mount from `project.active_locales` +
     * `project.default_locale` props.
     */
    setActiveLocales: (locales: string[], defaultLocale: string) => void;
    setDevicePresets: (presets: DevicePresetDto[]) => void;
    setAspectLock: (layerId: string, locked: boolean, ratio?: number) => void;
    enterCropMode: (layerId: string) => void;
    exitCropMode: () => void;
    enterPenMode: () => void;
    exitPenMode: () => void;
    addPenNode: (node: PathNode) => void;
    updatePenNode: (index: number, patch: Partial<PathNode>) => void;
    setPenClosed: (closed: boolean) => void;
    commitPen: () => void;
    /** Callout single-drag authoring lifecycle. `enterCalloutMode(shape)`
     * arms the gesture with the chosen shape; next drag on a frame/image
     * commits a new in-place callout. `exitCalloutMode` cancels without
     * committing. */
    enterCalloutMode: (shape: 'rounded-rect' | 'ellipse' | 'pill') => void;
    exitCalloutMode: () => void;
    /** Re-pick source flow: re-arm the gesture for an existing callout so
     * the next drag UPDATEs its source region in place. */
    repickCalloutSource: (layerId: string) => void;
    enterPathEdit: (layerId: string) => void;
    exitPathEdit: () => void;
    /** Enter inline text edit on a text layer. Selects the layer and puts the
     * HTML textarea overlay into focus. No-op for non-text layers. */
    enterTextEdit: (layerId: string) => void;
    exitTextEdit: () => void;
    /** Snapshot the first-selected layer's visual style into `clipboardStyle`.
     * Not undoable — clipboard is ephemeral UI state. No-op when selection is empty. */
    copyStyle: () => void;
    /** Apply `clipboardStyle` to every layer in the current selection. Undoable.
     * Typography/shape-specific fields only apply on same-type targets; cross-type
     * paste replaces `style` only. */
    pasteStyle: () => void;
    /** Snapshot the current selection (expanded to include group descendants)
     * into `clipboardLayers`. Not undoable — ephemeral UI state. */
    copyLayers: () => void;
    /** Append fresh copies of `clipboardLayers` to the current layout with
     * regenerated IDs, rewritten parentId refs, and a small positional offset.
     * Selection moves to the pasted top-level layers. Undoable. */
    pasteLayers: () => void;
    /** Bind an image layer to render clipped by a shape layer's path. Shape
     * must be closed (not line/arrow). Target keeps its anchors; the shape
     * acts purely as a mask. Undoable. */
    bindImageToShape: (imageId: string, shapeId: string) => void;
    /** Release the clip binding on an image layer. Undoable. */
    unbindImageClip: (imageId: string) => void;
    /**
     * Attach an app-screen image to a frame. Replaces any existing
     * `frame.screen`. Undoable. No-op if the target id isn't a frame layer.
     */
    setFrameScreen: (frameId: string, screen: FrameScreen) => void;
    /**
     * Mutate the bound `frame.screen` in place — used by properties-panel
     * controls (pan, zoom, fit, crop, adjust). No-op if the frame has no
     * bound screen. Undoable.
     */
    updateFrameScreen: (frameId: string, recipe: (draft: FrameScreen) => void) => void;
    /** Detach the bound screenshot from a frame. Undoable. No-op if absent. */
    clearFrameScreen: (frameId: string) => void;
    /** Set the active screen id; called from `Editor.tsx`. */
    setActiveScreenId: (screenId: number | null) => void;
    /**
     * Set or clear a frame layer's per-locale screenshot path
     * (`layer.screen.localePaths[locale]`). `null` clears the entry. The
     * resolver swaps `storagePath` to the matching locale's path at render
     * time. Triggers a full nav refresh on success so the in-memory layout
     * picks up the new path.
     */
    setFrameScreenLocalePath: (
        layerId: string,
        locale: string,
        storagePath: string | null,
    ) => Promise<void>;
};

const DEFAULT_CANVAS = {
    width: FORM_FACTOR_DEFAULT_CANVAS.iphone_portrait.w,
    height: FORM_FACTOR_DEFAULT_CANVAS.iphone_portrait.h,
} as const;

/**
 * Apply the locale overlay on top of a base Layout. Default-locale or
 * empty-locale short-circuits — `layout` is the canonical render shape.
 */
function applyLocaleOverlay(
    baseLayout: Layout,
    locale: string,
    defaultLocale: string,
): Layout {
    if (!locale || locale === defaultLocale) return baseLayout;
    return applyLocaleScreenPaths(
        applyLocaleOverrides(baseLayout, locale, defaultLocale),
        locale,
    );
}

export const useEditor = create<EditorState>()((set, get) => {
    /**
     * Reflect the latest user-edited Layout back into `baseLayout` (the
     * pre-overlay source of truth) via {@see applyLocaleEdit}. Default-locale
     * edits write straight through; non-default edits land on
     * `layer.localeOverrides[locale]` so a Hindi edit only affects Hindi.
     */
    const writeBackToBase = (edited: Layout): void => {
        const locale = get().activeLocale;
        const defaultLocale = get().defaultLocale;
        const baseLayout = get().baseLayout;
        const nextBase = applyLocaleEdit(baseLayout, locale, defaultLocale, edited);
        set((state) => ({ baseLayout: nextBase, editSeq: state.editSeq + 1 }));
    };

    const mutate = (recipe: (draft: Layout) => void) => {
        const { activeLocale, defaultLocale } = get();
        const [next, forward, inverse] = produceWithPatches(get().layout, (draft) => {
            recipe(draft);
            // Content-aware spacing (laurel badges): re-space fronds whose linked
            // value text changed width. No-op for everything else.
            applyLaurelSpacing(draft, activeLocale, defaultLocale);
            // Rating stars: rebuild the gold overlay when the linked score changes.
            applyStarRating(draft, activeLocale, defaultLocale);
        });
        if (forward.length === 0) return;
        set((state) => ({
            layout: next,
            past: [...state.past, { forward, inverse }].slice(-MAX_HISTORY),
            future: [],
        }));
        writeBackToBase(next);
    };

    return {
        layout: emptyLayout({ ...DEFAULT_CANVAS }),
        baseLayout: emptyLayout({ ...DEFAULT_CANVAS }),
        editSeq: 0,
        activeLocale: 'en',
        activeLocales: ['en'],
        defaultLocale: 'en',
        selection: [],
        past: [],
        future: [],
        devicePresets: [],
        activeScreenId: null,
        aspectLocks: {},
        cropModeLayerId: null,
        penMode: false,
        penDraft: null,
        penClosed: false,
        calloutMode: 'idle',
        calloutShape: 'rounded-rect',
        calloutEditingLayerId: null,
        pathEditLayerId: null,
        textEditLayerId: null,
        clipboardStyle: null,
        clipboardLayers: null,
        clipboardSourceScreenId: null,

        addLayer: (layer, options) => {
            mutate((draft) => {
                draft.layers.push(layer);
            });
            // Aspect lock by default for layer types whose proportions
            // are physically meaningful — images (don't want to squash
            // photos), frames (device chassis aspect must match the
            // PNG), callouts (circle-mode needs square bbox anyway,
            // rounded-rect is more usable when the box stays square).
            // Shapes intentionally NOT locked — users often want a
            // free rectangle / ellipse where width and height move
            // independently (banner-style accents, full-width dividers,
            // arbitrary callouts). Text, groups, and backgrounds also
            // skipped: their box geometry isn't proportion-bound.
            // Stored as `1` since the Transformer reads `keepRatio` as
            // a boolean — the stored ratio is just a truthy flag
            // enabling lock; current bbox ratio drives the actual
            // constraint at drag time.
            if (
                layer.type === 'image'
                || layer.type === 'frame'
                || layer.type === 'callout'
            ) {
                set((state) => ({
                    aspectLocks: { ...state.aspectLocks, [layer.id]: 1 },
                }));
            }
            if (options?.select !== false) {
                set({ selection: [layer.id] });
            }
        },

        addLayers: (layers, options) => {
            if (layers.length === 0) return;
            mutate((draft) => {
                draft.layers.push(...layers);
            });
            if (options?.selectId) {
                set({ selection: [options.selectId] });
            }
        },

        updateLayer: (id, recipe) => {
            mutate((draft) => {
                const layer = draft.layers.find((l) => l.id === id);
                if (!layer) return;
                recipe(layer);
            });
        },

        previewLayer: (id, recipe) => {
            const [next, forward] = produceWithPatches(get().layout, (draft) => {
                const layer = draft.layers.find((l) => l.id === id);
                if (!layer) return;
                recipe(layer);
            });
            if (forward.length === 0) return;
            set({ layout: next });
        },

        deleteLayer: (id) => {
            // Cascade: deleting a group also removes its direct children.
            const layers = get().layout.layers;
            const target = layers.find((l) => l.id === id);
            const doomed = new Set<string>([id]);
            if (target?.type === 'group') {
                for (const l of layers) {
                    if (l.parentId === id) doomed.add(l.id);
                }
            }
            mutate((draft) => {
                draft.layers = draft.layers.filter((l) => !doomed.has(l.id));
                // Unbind (not cascade-delete) any images clipped to a doomed shape.
                for (const l of draft.layers) {
                    if (l.type === 'image' && l.clipShapeId && doomed.has(l.clipShapeId)) {
                        delete l.clipShapeId;
                    }
                }
            });
            set((state) => ({
                selection: state.selection.filter((sid) => !doomed.has(sid)),
            }));
        },

        deleteSelected: () => {
            const ids = new Set(get().selection);
            if (ids.size === 0) return;
            // Cascade: when a group is selected, delete its descendants too.
            const layers = get().layout.layers;
            for (const l of layers) {
                if (l.parentId && ids.has(l.parentId)) ids.add(l.id);
            }
            mutate((draft) => {
                draft.layers = draft.layers.filter((l) => !ids.has(l.id));
                // Unbind (not cascade-delete) images clipped to a doomed shape.
                for (const l of draft.layers) {
                    if (l.type === 'image' && l.clipShapeId && ids.has(l.clipShapeId)) {
                        delete l.clipShapeId;
                    }
                }
            });
            set({ selection: [] });
        },

        duplicateSelected: () => {
            const { selection, layout } = get();
            if (selection.length === 0) return;
            const selectionSet = new Set(selection);
            // Expand: when a group is selected, implicitly duplicate its
            // descendants so the clone keeps the same parent relationship.
            const expanded = new Set(selection);
            for (const l of layout.layers) {
                if (l.parentId && selectionSet.has(l.parentId)) expanded.add(l.id);
            }
            const originals = layout.layers.filter((l) => expanded.has(l.id));
            if (originals.length === 0) return;

            const offsetPx = {
                x: layout.canvas.width * 0.02,
                y: layout.canvas.height * 0.02,
            };
            // ID remap so child.parentId points at the cloned group.
            const idMap = new Map<string, string>();
            for (const layer of originals) idMap.set(layer.id, newId());

            const clones: Layer[] = originals.map((layer) => {
                const cloned = structuredClone(layer) as Layer;
                cloned.id = idMap.get(layer.id)!;
                if (cloned.parentId && idMap.has(cloned.parentId)) {
                    cloned.parentId = idMap.get(cloned.parentId)!;
                } else if (cloned.parentId && !selectionSet.has(cloned.parentId)) {
                    // Parent wasn't selected, drop the link so clones are
                    // free-standing. Shouldn't happen via expanded, but
                    // keeps the invariant safe.
                    delete cloned.parentId;
                }
                // Clip bindings: rewrite if the clip shape was duplicated
                // alongside this image, otherwise drop — leaving the clone
                // bound to the original shape would mask both siblings
                // through the same shape.
                if (cloned.type === 'image') {
                    if (cloned.clipShapeId && idMap.has(cloned.clipShapeId)) {
                        cloned.clipShapeId = idMap.get(cloned.clipShapeId)!;
                    } else if (cloned.clipShapeId) {
                        delete cloned.clipShapeId;
                    }
                }
                remapElementLinks(cloned, idMap);
                // Groups are non-visual — skip the anchor offset (zero-box
                // anyway) so we don't end up with garbage anchor values.
                if (cloned.type !== 'group') {
                    const rect = resolveAnchors(layer.anchors, layout.canvas);
                    cloned.anchors = rectToAnchors(
                        { ...rect, x: rect.x + offsetPx.x, y: rect.y + offsetPx.y },
                        layout.canvas,
                        layer.anchors,
                    );
                }
                return cloned;
            });

            mutate((draft) => {
                draft.layers.push(...clones);
            });
            // Select what the user originally selected, remapped to clones.
            const nextSelection = selection
                .map((id) => idMap.get(id))
                .filter((id): id is string => !!id);
            set({ selection: nextSelection });
        },

        arrange: (direction) => {
            const ids = new Set(get().selection);
            if (ids.size === 0) return;
            mutate((draft) => {
                const layers = draft.layers;
                const selectedLayers = layers.filter((l) => ids.has(l.id));
                const otherLayers = layers.filter((l) => !ids.has(l.id));
                if (selectedLayers.length === 0) return;

                if (direction === 'front') {
                    draft.layers = [...otherLayers, ...selectedLayers];
                } else if (direction === 'back') {
                    draft.layers = [...selectedLayers, ...otherLayers];
                } else if (direction === 'forward') {
                    const indices = layers
                        .map((l, i) => (ids.has(l.id) ? i : -1))
                        .filter((i) => i !== -1)
                        .sort((a, b) => b - a);
                    const result = [...layers];
                    const moving = new Set(ids);
                    for (const i of indices) {
                        if (i < result.length - 1 && !moving.has(result[i + 1].id)) {
                            [result[i], result[i + 1]] = [result[i + 1], result[i]];
                        }
                    }
                    draft.layers = result;
                } else {
                    const indices = layers
                        .map((l, i) => (ids.has(l.id) ? i : -1))
                        .filter((i) => i !== -1)
                        .sort((a, b) => a - b);
                    const result = [...layers];
                    const moving = new Set(ids);
                    for (const i of indices) {
                        if (i > 0 && !moving.has(result[i - 1].id)) {
                            [result[i], result[i - 1]] = [result[i - 1], result[i]];
                        }
                    }
                    draft.layers = result;
                }
            });
        },

        commitGroupTransform: (updates) => {
            if (updates.length === 0) return;
            mutate((draft) => {
                for (const u of updates) {
                    const l = draft.layers.find((x) => x.id === u.id);
                    if (!l) continue;
                    l.anchors = u.anchors;
                    if (u.rotation !== undefined) l.rotation = u.rotation;
                    if (u.scale === undefined) continue; // move only — no scaling
                    const s = u.scale;
                    if (l.type === 'text') {
                        if (l.fitMode === 'auto') l.fitMode = 'fixed';
                        if (Math.abs(s - 1) > 1e-4) {
                            if (l.fontSize) l.fontSize = Math.max(1, l.fontSize * s);
                            if (l.letterSpacing) l.letterSpacing *= s;
                        }
                    } else if (l.type === 'shape' && Math.abs(s - 1) > 1e-4) {
                        if (l.style?.radius) l.style.radius = Math.max(0, l.style.radius * s);
                        if (l.style?.stroke?.width) {
                            l.style.stroke.width = Math.max(0, l.style.stroke.width * s);
                        }
                        if (l.style?.shadow) {
                            l.style.shadow.blur = Math.max(0, l.style.shadow.blur * s);
                            l.style.shadow.offsetX *= s;
                            l.style.shadow.offsetY *= s;
                        }
                    }
                }
            });
        },

        nudgeSelected: (dx, dy) => {
            if (dx === 0 && dy === 0) return;
            const { selection, layout } = get();
            if (selection.length === 0) return;
            // Groups are non-visual containers (zero-box anchors). Selecting
            // a group + arrow-nudge expands the group to its children — the
            // user expects the visual contents to move as a unit. Locked +
            // background layers are still excluded from the expanded set.
            const expandedIds = new Set<string>();
            for (const id of selection) {
                const layer = layout.layers.find((l) => l.id === id);
                if (!layer) continue;
                if (layer.type === 'group') {
                    for (const child of layout.layers) {
                        if (child.parentId === id) expandedIds.add(child.id);
                    }
                } else {
                    expandedIds.add(id);
                }
            }
            const selected = Array.from(expandedIds)
                .map((id) => layout.layers.find((l) => l.id === id))
                .filter((l): l is Layer =>
                    // Full-bleed background + locked layers can't be nudged.
                    !!l &&
                    l.type !== 'background' &&
                    l.type !== 'group' &&
                    l.locked !== true,
                );
            if (selected.length === 0) return;

            const rects = selected.map((l) => ({
                id: l.id,
                anchors: l.anchors,
                rect: resolveAnchors(l.anchors, layout.canvas),
            }));

            mutate((draft) => {
                for (const { id, anchors, rect } of rects) {
                    const target = draft.layers.find((l) => l.id === id);
                    if (!target) continue;
                    target.anchors = rectToAnchors(
                        { ...rect, x: rect.x + dx, y: rect.y + dy },
                        layout.canvas,
                        anchors,
                    );
                }
            });
        },

        alignSelected: (direction) => {
            const { selection, layout } = get();
            if (selection.length < 2) return;
            const units = buildAlignUnits(selection, layout);
            // When a background layer is in the selection, treat the canvas
            // as the alignment frame — `buildAlignUnits` strips backgrounds
            // (they're locked to the canvas), so we'd otherwise bail on the
            // ≥2-units guard for selections like `background + 1 layer`.
            const bgInSelection = selection.some((id) => {
                const l = layout.layers.find((x) => x.id === id);
                return l?.type === 'background';
            });
            if (bgInSelection) {
                if (units.length < 1) return;
            } else {
                if (units.length < 2) return;
            }

            const bbox = bgInSelection
                ? { left: 0, right: layout.canvas.width, top: 0, bottom: layout.canvas.height }
                : {
                      left: Math.min(...units.map((u) => u.rect.x)),
                      right: Math.max(...units.map((u) => u.rect.x + u.rect.width)),
                      top: Math.min(...units.map((u) => u.rect.y)),
                      bottom: Math.max(...units.map((u) => u.rect.y + u.rect.height)),
                  };
            const centerX = (bbox.left + bbox.right) / 2;
            const centerY = (bbox.top + bbox.bottom) / 2;

            mutate((draft) => {
                for (const unit of units) {
                    const target = { x: unit.rect.x, y: unit.rect.y };
                    switch (direction) {
                        case 'left':
                            target.x = bbox.left;
                            break;
                        case 'right':
                            target.x = bbox.right - unit.rect.width;
                            break;
                        case 'centerH':
                            target.x = centerX - unit.rect.width / 2;
                            break;
                        case 'top':
                            target.y = bbox.top;
                            break;
                        case 'bottom':
                            target.y = bbox.bottom - unit.rect.height;
                            break;
                        case 'centerV':
                            target.y = centerY - unit.rect.height / 2;
                            break;
                    }
                    applyUnitTranslate(draft, unit, target.x - unit.rect.x, target.y - unit.rect.y, layout.canvas);
                }
            });
        },

        distributeSelected: (axis) => {
            const { selection, layout } = get();
            if (selection.length < 3) return;
            const units = buildAlignUnits(selection, layout);
            // When a background layer is in the selection, treat the canvas
            // edges as virtual zero-width endpoints so the middle items
            // redistribute across the full canvas span. Only ≥2 real movers
            // needed (vs ≥3 without a background).
            const bgInSelection = selection.some((id) => {
                const l = layout.layers.find((x) => x.id === id);
                return l?.type === 'background';
            });
            if (bgInSelection) {
                if (units.length < 2) return;
            } else {
                if (units.length < 3) return;
            }

            // Reuse the same flat shape `align` consumed (the prior
            // implementation worked on { id, anchors, rect } records — now
            // we have unit records that may be group bboxes). Sort + spacing
            // math operates on unit rects; the apply step translates groups
            // wholesale.
            //
            // Virtual endpoint sentinels — `unit: null` skips the translate
            // path in `applyUnitTranslate` since we never iterate index 0
            // or N-1 (those are the anchors that don't move).
            const movers = units.map((u) => ({
                id: u.id,
                anchors: 'anchors' in u ? u.anchors : undefined,
                rect: u.rect,
                unit: u as AlignUnit | null,
            }));
            const rects = bgInSelection
                ? [
                      {
                          id: '__canvas_start__',
                          anchors: undefined,
                          rect: { x: 0, y: 0, width: 0, height: 0 },
                          unit: null,
                      },
                      ...movers,
                      {
                          id: '__canvas_end__',
                          anchors: undefined,
                          rect: {
                              x: layout.canvas.width,
                              y: layout.canvas.height,
                              width: 0,
                              height: 0,
                          },
                          unit: null,
                      },
                  ]
                : movers;

            if (axis === 'horizontal') {
                rects.sort(
                    (a, b) =>
                        a.rect.x + a.rect.width / 2 - (b.rect.x + b.rect.width / 2),
                );
                const first = rects[0];
                const last = rects[rects.length - 1];
                const span = last.rect.x + last.rect.width - first.rect.x;
                const totalWidth = rects.reduce((sum, r) => sum + r.rect.width, 0);
                const gap = (span - totalWidth) / (rects.length - 1);

                mutate((draft) => {
                    let cursor = first.rect.x + first.rect.width + gap;
                    for (let i = 1; i < rects.length - 1; i++) {
                        const { rect, unit } = rects[i];
                        if (unit) applyUnitTranslate(draft, unit, cursor - rect.x, 0, layout.canvas);
                        cursor += rect.width + gap;
                    }
                });
            } else {
                rects.sort(
                    (a, b) =>
                        a.rect.y + a.rect.height / 2 - (b.rect.y + b.rect.height / 2),
                );
                const first = rects[0];
                const last = rects[rects.length - 1];
                const span = last.rect.y + last.rect.height - first.rect.y;
                const totalHeight = rects.reduce((sum, r) => sum + r.rect.height, 0);
                const gap = (span - totalHeight) / (rects.length - 1);

                mutate((draft) => {
                    let cursor = first.rect.y + first.rect.height + gap;
                    for (let i = 1; i < rects.length - 1; i++) {
                        const { rect, unit } = rects[i];
                        if (unit) applyUnitTranslate(draft, unit, 0, cursor - rect.y, layout.canvas);
                        cursor += rect.height + gap;
                    }
                });
            }
        },

        moveLayer: (id, direction) => {
            mutate((draft) => {
                const i = draft.layers.findIndex((l) => l.id === id);
                if (i === -1) return;
                const last = draft.layers.length - 1;
                let target: number;
                switch (direction) {
                    case 'up':
                        target = Math.min(last, i + 1);
                        break;
                    case 'down':
                        target = Math.max(0, i - 1);
                        break;
                    case 'top':
                        target = last;
                        break;
                    case 'bottom':
                        target = 0;
                        break;
                }
                if (target === i) return;
                const [moved] = draft.layers.splice(i, 1);
                draft.layers.splice(target, 0, moved);
            });
        },

        reorderLayers: (orderedIds) => {
            mutate((draft) => {
                if (orderedIds.length !== draft.layers.length) return;
                const byId = new Map(draft.layers.map((l) => [l.id, l]));
                const next: Layer[] = [];
                for (const id of orderedIds) {
                    const layer = byId.get(id);
                    if (!layer) return;
                    next.push(layer);
                }
                const same = next.every((l, i) => l.id === draft.layers[i].id);
                if (same) return;
                draft.layers = next;
            });
        },

        setSelection: (ids) => set({ selection: ids }),

        selectWithParent: (id, options) => {
            const additive = options?.additive === true;
            const layers = get().layout.layers;

            if (additive) {
                // Keep raw id on additive clicks so users can drill into groups.
                if (!layers.some((l) => l.id === id)) return;
                const { selection } = get();
                const set2 = new Set(selection);
                if (set2.has(id)) set2.delete(id);
                else set2.add(id);
                set({ selection: Array.from(set2) });
                return;
            }

            // Walk up the parent chain to the outermost ancestor. v1 caps at
            // one hop (no nested groups), but loop-guard anyway.
            let current = layers.find((l) => l.id === id);
            const guard = new Set<string>();
            while (current?.parentId) {
                if (guard.has(current.id)) break;
                guard.add(current.id);
                const parent = layers.find((l) => l.id === current!.parentId);
                if (!parent) break;
                current = parent;
            }
            const promoted = current?.id ?? id;

            const { selection } = get();
            if (selection.length === 1 && selection[0] === promoted) return;
            set({ selection: [promoted] });
        },

        clearSelection: () => set({ selection: [] }),

        groupSelected: () => {
            const { selection, layout } = get();
            if (selection.length < 2) return;

            // Dedup: drop any child whose parent is also selected — the
            // parent will carry it along.
            const selectedSet = new Set(selection);
            const leaves = selection.filter((id) => {
                const layer = layout.layers.find((l) => l.id === id);
                if (!layer) return false;
                // v1: disallow nesting. Skip any layer that is itself a group.
                if (layer.type === 'group') return false;
                if (layer.parentId && selectedSet.has(layer.parentId)) return false;
                return true;
            });
            if (leaves.length < 2) return;

            const leafSet = new Set(leaves);
            // Insert the group just after the topmost selected layer so it
            // sits at the visual top of the selection (panel reverses the
            // array, so topmost = highest index).
            const maxIndex = layout.layers.reduce(
                (max, l, i) => (leafSet.has(l.id) && i > max ? i : max),
                -1,
            );
            if (maxIndex < 0) return;
            const insertAt = maxIndex + 1;

            const group: GroupLayer = {
                id: newId(),
                type: 'group',
                name: 'Group',
                anchors: {
                    left: { type: 'percent', value: 0 },
                    top: { type: 'percent', value: 0 },
                    width: { type: 'percent', value: 0 },
                    height: { type: 'percent', value: 0 },
                },
            };

            mutate((draft) => {
                for (const l of draft.layers) {
                    if (leafSet.has(l.id)) l.parentId = group.id;
                }
                draft.layers.splice(insertAt, 0, group);
            });
            set({ selection: [group.id] });
        },

        ungroupSelected: () => {
            const { selection, layout } = get();
            if (selection.length === 0) return;
            const groupIds = new Set(
                selection.filter((id) => {
                    const layer = layout.layers.find((l) => l.id === id);
                    return layer?.type === 'group';
                }),
            );
            if (groupIds.size === 0) return;

            const freed: string[] = [];
            for (const l of layout.layers) {
                if (l.parentId && groupIds.has(l.parentId)) freed.push(l.id);
            }

            mutate((draft) => {
                for (const l of draft.layers) {
                    if (l.parentId && groupIds.has(l.parentId)) {
                        delete l.parentId;
                    }
                }
                draft.layers = draft.layers.filter((l) => !groupIds.has(l.id));
            });
            set({ selection: freed });
        },

        undo: () => {
            const { past, future, layout } = get();
            if (past.length === 0) return;
            const last = past[past.length - 1];
            const next = applyPatches(layout, last.inverse);
            set({
                layout: next,
                past: past.slice(0, -1),
                future: [last, ...future],
            });
            writeBackToBase(next);
        },

        redo: () => {
            const { past, future, layout } = get();
            if (future.length === 0) return;
            const [head, ...rest] = future;
            const next = applyPatches(layout, head.forward);
            set({
                layout: next,
                past: [...past, head],
                future: rest,
            });
            writeBackToBase(next);
        },

        replaceLayout: (layout) => {
            // Defensive migration: server should already migrate before
            // sending, but a stale fixture slipping through is upgraded
            // transparently here. Idempotent for current-version inputs.
            const migrated = migrateLayout(layout);
            const locale = get().activeLocale;
            const defaultLocale = get().defaultLocale;
            // Seed aspectLocks for every layer that supports proportional
            // resize. addLayer does this for freshly-created layers, but a
            // project loaded from disk skips that path — without seeding
            // here, kit/DB-installed frame layers would let users drag a
            // corner handle and stretch the device frame off its native
            // aspect. Frame, shape, image, callout all default-locked.
            //
            // Stored value is the layer's CURRENT bbox aspect ratio
            // (width/height), so the W/H inputs in PropertiesPanel can do
            // the cross-axis sync correctly. Konva's Transformer corner-
            // drag uses the live bbox aspect at drag-start regardless of
            // this value, but the stored value matters for the keypad
            // inputs.
            const aspectLocks: Record<string, number> = {};
            for (const layer of migrated.layers) {
                // Skip shapes — see addLayer() for the rationale; same
                // list of types kept here so layouts loaded from disk
                // behave the same as newly-added layers.
                if (
                    layer.type !== 'frame' &&
                    layer.type !== 'image' &&
                    layer.type !== 'callout'
                ) {
                    continue;
                }
                const rect = resolveAnchors(layer.anchors, migrated.canvas);
                aspectLocks[layer.id] =
                    rect.height > 0 ? rect.width / rect.height : 1;
            }
            // Preserve any selection whose layer ids survive into the new
            // layout. Layer ids are unique per screen, so a true screen switch
            // drops everything (no id overlap) — but a server-driven re-hydrate
            // of the SAME screen (a peer's save echoed in via router.reload
            // under Reverb, or a factor switch where linked ids persist) keeps
            // the selection, so a collaborator's edit no longer blanks your
            // properties panel mid-work. Ephemeral edit modes still reset.
            const surviving = new Set(migrated.layers.map((l) => l.id));
            const selection = get().selection.filter((id) => surviving.has(id));
            set({
                baseLayout: migrated,
                layout: applyLocaleOverlay(migrated, locale, defaultLocale),
                aspectLocks,
                past: [],
                future: [],
                selection,
                textEditLayerId: null,
                pathEditLayerId: null,
                cropModeLayerId: null,
            });
        },

        setActiveLocale: (locale) => {
            if (get().activeLocale === locale) return;
            const baseLayout = get().baseLayout;
            const defaultLocale = get().defaultLocale;
            set({
                activeLocale: locale,
                layout: applyLocaleOverlay(baseLayout, locale, defaultLocale),
            });
        },

        setActiveLocales: (locales, defaultLocale) => {
            // Guarantee the project's default locale is present — selectors
            // fall through to it when a per-locale override is absent, so
            // dropping it would orphan text rendering. Dedupe while
            // preserving the catalog order the caller passed in.
            const seen = new Set<string>();
            const ordered: string[] = [];
            for (const code of [defaultLocale, ...locales]) {
                if (!code || seen.has(code)) continue;
                seen.add(code);
                ordered.push(code);
            }
            const current = get().activeLocales;
            const sameList =
                current.length === ordered.length &&
                current.every((c, i) => c === ordered[i]);
            const sameDefault = get().defaultLocale === defaultLocale;
            if (sameList && sameDefault) return;
            set({ activeLocales: ordered, defaultLocale });
        },

        setDevicePresets: (presets) => set({ devicePresets: presets }),

        setAspectLock: (layerId, locked, ratio) =>
            set((state) => {
                const next = { ...state.aspectLocks };
                if (locked) {
                    next[layerId] = ratio ?? state.aspectLocks[layerId] ?? 1;
                } else {
                    delete next[layerId];
                }
                return { aspectLocks: next };
            }),

        enterCropMode: (layerId) => set({ cropModeLayerId: layerId }),
        exitCropMode: () => set({ cropModeLayerId: null }),

        enterPenMode: () =>
            set({
                penMode: true,
                penDraft: [],
                penClosed: false,
                selection: [],
                cropModeLayerId: null,
                pathEditLayerId: null,
            }),

        exitPenMode: () =>
            set({ penMode: false, penDraft: null, penClosed: false }),

        addPenNode: (node) =>
            set((state) => ({
                penDraft: state.penDraft ? [...state.penDraft, node] : [node],
            })),

        updatePenNode: (index, patch) =>
            set((state) => {
                if (!state.penDraft) return {};
                if (index < 0 || index >= state.penDraft.length) return {};
                const next = state.penDraft.slice();
                next[index] = { ...next[index], ...patch };
                return { penDraft: next };
            }),

        setPenClosed: (closed) => set({ penClosed: closed }),

        commitPen: () => {
            const { penDraft, penClosed } = get();
            if (!penDraft || penDraft.length < 2) {
                set({ penMode: false, penDraft: null, penClosed: false });
                return;
            }

            let minX = Infinity,
                minY = Infinity,
                maxX = -Infinity,
                maxY = -Infinity;
            for (const n of penDraft) {
                const pts: Array<{ x: number; y: number }> = [{ x: n.x, y: n.y }];
                if (n.handleIn) pts.push({ x: n.x + n.handleIn.x, y: n.y + n.handleIn.y });
                if (n.handleOut) pts.push({ x: n.x + n.handleOut.x, y: n.y + n.handleOut.y });
                for (const p of pts) {
                    if (p.x < minX) minX = p.x;
                    if (p.y < minY) minY = p.y;
                    if (p.x > maxX) maxX = p.x;
                    if (p.y > maxY) maxY = p.y;
                }
            }
            const bboxW = Math.max(1, maxX - minX);
            const bboxH = Math.max(1, maxY - minY);

            const normalized: PathNode[] = penDraft.map((n) => {
                const node: PathNode = {
                    x: (n.x - minX) / bboxW,
                    y: (n.y - minY) / bboxH,
                };
                if (n.handleIn) {
                    node.handleIn = {
                        x: n.handleIn.x / bboxW,
                        y: n.handleIn.y / bboxH,
                    };
                }
                if (n.handleOut) {
                    node.handleOut = {
                        x: n.handleOut.x / bboxW,
                        y: n.handleOut.y / bboxH,
                    };
                }
                return node;
            });

            const layer = createShapeLayer('path', {
                anchors: {
                    left: { type: 'px', value: Math.round(minX) },
                    top: { type: 'px', value: Math.round(minY) },
                    width: { type: 'px', value: Math.max(1, Math.round(bboxW)) },
                    height: { type: 'px', value: Math.max(1, Math.round(bboxH)) },
                },
                nodes: normalized,
                closed: penClosed,
            });

            const [next, forward, inverse] = produceWithPatches(get().layout, (draft) => {
                draft.layers.push(layer);
            });
            if (forward.length > 0) {
                set((state) => ({
                    layout: next,
                    past: [...state.past, { forward, inverse }].slice(-MAX_HISTORY),
                    future: [],
                }));
                writeBackToBase(next);
            }

            set({
                penMode: false,
                penDraft: null,
                penClosed: false,
                selection: [layer.id],
            });
        },

        enterCalloutMode: (shape) =>
            set({
                calloutMode: 'armed',
                calloutShape: shape,
                // Toolbar entry = fresh-create, never edit.
                calloutEditingLayerId: null,
                // Mutually exclusive with other authoring modes.
                penMode: false,
                penDraft: null,
                penClosed: false,
                cropModeLayerId: null,
                pathEditLayerId: null,
                textEditLayerId: null,
            }),

        exitCalloutMode: () =>
            set({
                calloutMode: 'idle',
                calloutEditingLayerId: null,
            }),

        repickCalloutSource: (layerId) => {
            const target = get().layout.layers.find((l) => l.id === layerId);
            const shape =
                target?.type === 'callout' ? target.shape : get().calloutShape;
            set({
                calloutMode: 'armed',
                calloutShape: shape,
                calloutEditingLayerId: layerId,
                // Mutually exclusive with other authoring modes.
                penMode: false,
                penDraft: null,
                penClosed: false,
                cropModeLayerId: null,
                pathEditLayerId: null,
                textEditLayerId: null,
            });
        },

        enterPathEdit: (layerId) =>
            set({
                pathEditLayerId: layerId,
                penMode: false,
                penDraft: null,
                penClosed: false,
            }),

        exitPathEdit: () => set({ pathEditLayerId: null }),

        enterTextEdit: (layerId) => {
            const layer = get().layout.layers.find((l) => l.id === layerId);
            if (!layer || layer.type !== 'text') return;
            set({
                textEditLayerId: layerId,
                selection: [layerId],
                pathEditLayerId: null,
                cropModeLayerId: null,
                penMode: false,
            });
        },

        exitTextEdit: () => set({ textEditLayerId: null }),

        copyStyle: () => {
            const { selection, layout } = get();
            if (selection.length === 0) return;
            const source = layout.layers.find((l) => l.id === selection[0]);
            if (!source) return;

            const snapshot: StyleSnapshot = { from: source.type };

            if (source.style) {
                snapshot.style = structuredClone(source.style);
            }

            if (source.type === 'text') {
                // Everything that defines how text LOOKS but not what it SAYS.
                // Skip `content` (that's the payload), skip anchors/rotation/etc
                // (geometry, not style), skip `color`/`fill` since color lives
                // inside `style` via `LayerStyle.fill` for gradients + the
                // `color` scalar is already semantically a style knob that we
                // intentionally co-locate with typography here.
                const t = source;
                const textFields: Partial<TextLayer> = {
                    fontFamily: t.fontFamily,
                    fontSize: t.fontSize,
                    minFontSize: t.minFontSize,
                    maxFontSize: t.maxFontSize,
                    fitMode: t.fitMode,
                    color: t.color,
                };
                if (t.fontWeight !== undefined) textFields.fontWeight = t.fontWeight;
                if (t.fontStyle !== undefined) textFields.fontStyle = t.fontStyle;
                if (t.align !== undefined) textFields.align = t.align;
                if (t.lineHeight !== undefined) textFields.lineHeight = t.lineHeight;
                if (t.letterSpacing !== undefined) textFields.letterSpacing = t.letterSpacing;
                if (t.textTransform !== undefined) textFields.textTransform = t.textTransform;
                if (t.fill !== undefined) textFields.fill = structuredClone(t.fill);
                snapshot.textFields = textFields;
            } else if (source.type === 'shape') {
                // Shape-specific visual knobs that live OUTSIDE `style`. Stroke
                // dash/cap/join/position live inside `style.stroke` and are
                // captured by `snapshot.style`. `shape` itself (rect/ellipse/...)
                // isn't a style knob — don't rewrite the shape kind on paste.
                const s = source;
                const shapeFields: Partial<ShapeLayer> = {};
                if (s.sides !== undefined) shapeFields.sides = s.sides;
                if (s.starPoints !== undefined) shapeFields.starPoints = s.starPoints;
                if (s.innerRatio !== undefined) shapeFields.innerRatio = s.innerRatio;
                if (s.startAngle !== undefined) shapeFields.startAngle = s.startAngle;
                if (s.sweepAngle !== undefined) shapeFields.sweepAngle = s.sweepAngle;
                if (s.arrowHead !== undefined) shapeFields.arrowHead = s.arrowHead;
                if (s.arrowAt !== undefined) shapeFields.arrowAt = s.arrowAt;
                if (s.closed !== undefined) shapeFields.closed = s.closed;
                snapshot.shapeFields = shapeFields;
            }

            set({ clipboardStyle: snapshot });
        },

        pasteStyle: () => {
            const { clipboardStyle, selection } = get();
            if (!clipboardStyle) return;
            if (selection.length === 0) return;

            const ids = new Set(selection);
            mutate((draft) => {
                for (const layer of draft.layers) {
                    if (!ids.has(layer.id)) continue;

                    // Type-agnostic: overwrite (not deep-merge) the `style` bag.
                    if (clipboardStyle.style) {
                        layer.style = structuredClone(clipboardStyle.style);
                    }

                    // Type-scoped typography fields on text → text.
                    if (
                        clipboardStyle.from === 'text' &&
                        layer.type === 'text' &&
                        clipboardStyle.textFields
                    ) {
                        Object.assign(layer, structuredClone(clipboardStyle.textFields));
                    }

                    // Type-scoped shape fields on shape → shape.
                    if (
                        clipboardStyle.from === 'shape' &&
                        layer.type === 'shape' &&
                        clipboardStyle.shapeFields
                    ) {
                        Object.assign(layer, structuredClone(clipboardStyle.shapeFields));
                    }
                }
            });
        },

        copyLayers: () => {
            const { selection, layout, activeScreenId } = get();
            if (selection.length === 0) return;
            const selectionSet = new Set(selection);
            // Expand: selecting a group implicitly copies its descendants so
            // paste rebuilds the group intact. Mirrors duplicateSelected.
            const expanded = new Set(selection);
            for (const l of layout.layers) {
                if (l.parentId && selectionSet.has(l.parentId)) expanded.add(l.id);
            }
            const snapshot = layout.layers
                .filter((l) => expanded.has(l.id))
                .map((l) => structuredClone(l));
            if (snapshot.length === 0) return;
            set({ clipboardLayers: snapshot, clipboardSourceScreenId: activeScreenId });
        },

        pasteLayers: () => {
            const { clipboardLayers, clipboardSourceScreenId, layout, activeScreenId } = get();
            if (!clipboardLayers || clipboardLayers.length === 0) return;

            const clipboardIds = new Set(clipboardLayers.map((l) => l.id));
            const idMap = new Map<string, string>();
            for (const layer of clipboardLayers) idMap.set(layer.id, newId());

            // Same-screen paste: nudge the clone 2% so it's visibly distinct
            // from the original. Cross-screen paste: place at the same anchors
            // so the user can build matching layouts across screens (the most
            // common reason to copy from one screen to another).
            const sameScreen =
                clipboardSourceScreenId !== null &&
                clipboardSourceScreenId === activeScreenId;
            const offsetPx = sameScreen
                ? { x: layout.canvas.width * 0.02, y: layout.canvas.height * 0.02 }
                : { x: 0, y: 0 };

            const clones: Layer[] = clipboardLayers.map((layer) => {
                const cloned = structuredClone(layer) as Layer;
                cloned.id = idMap.get(layer.id)!;
                if (cloned.parentId && idMap.has(cloned.parentId)) {
                    cloned.parentId = idMap.get(cloned.parentId)!;
                } else if (cloned.parentId) {
                    // Parent isn't in the clipboard — drop the link so pasted
                    // layers stand alone instead of pointing at a stale id.
                    delete cloned.parentId;
                }
                // Clip bindings: rewrite if the clip shape came along in the
                // copy, otherwise drop (don't leave the clone bound to the
                // original shape, which would mask both siblings together).
                if (cloned.type === 'image') {
                    if (cloned.clipShapeId && idMap.has(cloned.clipShapeId)) {
                        cloned.clipShapeId = idMap.get(cloned.clipShapeId)!;
                    } else if (cloned.clipShapeId) {
                        delete cloned.clipShapeId;
                    }
                }
                remapElementLinks(cloned, idMap);
                if (cloned.type !== 'group') {
                    if (sameScreen) {
                        const rect = resolveAnchors(layer.anchors, layout.canvas);
                        cloned.anchors = rectToAnchors(
                            { ...rect, x: rect.x + offsetPx.x, y: rect.y + offsetPx.y },
                            layout.canvas,
                            layer.anchors,
                        );
                    }
                    // Cross-screen: keep layer.anchors verbatim. Percent
                    // anchors render at the same proportional position on the
                    // destination canvas; px anchors at the same pixel offset.
                }
                return cloned;
            });

            mutate((draft) => {
                draft.layers.push(...clones);
            });

            // Select top-level pasted layers only — matches the user's original
            // selection shape before copy (descendants were implicit).
            const topLevel = clipboardLayers
                .filter((l) => !l.parentId || !clipboardIds.has(l.parentId))
                .map((l) => idMap.get(l.id)!)
                .filter((id): id is string => !!id);
            set({ selection: topLevel });
        },

        bindImageToShape: (imageId, shapeId) => {
            const layers = get().layout.layers;
            const image = layers.find((l) => l.id === imageId);
            const shape = layers.find((l) => l.id === shapeId);
            if (!image || image.type !== 'image') return;
            if (!shape || shape.type !== 'shape') return;
            // line/arrow have no enclosed area — nothing to clip into.
            if (shape.shape === 'line' || shape.shape === 'arrow') return;
            // Open pen paths also have no enclosed area.
            if (shape.shape === 'path' && !shape.closed) return;
            mutate((draft) => {
                const l = draft.layers.find((x) => x.id === imageId);
                if (!l || l.type !== 'image') return;
                l.clipShapeId = shapeId;
            });
        },

        unbindImageClip: (imageId) => {
            mutate((draft) => {
                const l = draft.layers.find((x) => x.id === imageId);
                if (!l || l.type !== 'image') return;
                delete l.clipShapeId;
            });
        },

        setFrameScreen: (frameId, screen) => {
            mutate((draft) => {
                const l = draft.layers.find((x) => x.id === frameId);
                if (!l || l.type !== 'frame') return;
                l.screen = screen;
            });
        },

        updateFrameScreen: (frameId, recipe) => {
            mutate((draft) => {
                const l = draft.layers.find((x) => x.id === frameId);
                if (!l || l.type !== 'frame' || !l.screen) return;
                recipe(l.screen);
            });
        },

        clearFrameScreen: (frameId) => {
            mutate((draft) => {
                const l = draft.layers.find((x) => x.id === frameId);
                if (!l || l.type !== 'frame') return;
                delete l.screen;
            });
        },

        setActiveScreenId: (screenId) => set({ activeScreenId: screenId }),

        setFrameScreenLocalePath: async (layerId, locale, storagePath) => {
            const screenId = get().activeScreenId;
            if (screenId == null) return;

            const xsrf = document
                .querySelector('meta[name="csrf-token"]')
                ?.getAttribute('content');
            const cookieMatch = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
            const cookieToken = cookieMatch
                ? decodeURIComponent(cookieMatch[1])
                : null;
            const headers: Record<string, string> = {
                Accept: 'application/json',
                'Content-Type': 'application/json',
                'X-Requested-With': 'XMLHttpRequest',
            };
            if (xsrf) headers['X-CSRF-TOKEN'] = xsrf;
            if (cookieToken) headers['X-XSRF-TOKEN'] = cookieToken;

            const res = await fetch(
                `/screens/${screenId}/master/frame-screen-locale-path`,
                {
                    method: 'PATCH',
                    credentials: 'same-origin',
                    headers,
                    body: JSON.stringify({ layerId, locale, storagePath }),
                },
            );
            if (!res.ok) return;

            router.visit(window.location.href, { preserveScroll: true });
        },
    };
});

// Expose the editor store on the window so Playwright tests can drive
// state imperatively (skip slow upload flows, seed fixture state, etc.).
// Pure read/write access to in-memory editor state — no auth, no PII.
if (typeof window !== 'undefined') {
    (window as unknown as { __APPSHOT_EDITOR__?: typeof useEditor }).__APPSHOT_EDITOR__ = useEditor;
}

export const selectLayerById = (id: string) => (state: EditorState) =>
    state.layout.layers.find((l) => l.id === id);

/**
 * Resolved Layout for the active (screen, locale). Mirror of `state.layout`.
 */
export const selectActiveLayout = (state: EditorState): Layout => state.layout;
