import { useEffect, useMemo, useRef, useState } from 'react';
import {
    DndContext, closestCenter, PointerSensor, useSensor, useSensors,
} from '@dnd-kit/core';
import {
    SortableContext, verticalListSortingStrategy, useSortable, arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
    IconEye, IconEyeOff, IconLock, IconLockOpen, IconChevronRight, IconBoxMultiple,
    IconFrame, IconLoader2, IconMask, IconCornerLeftDown, IconCornerLeftUp,
} from '@tabler/icons-react';
import { usePrintEditorStore } from '../state/usePrintEditorStore';
import {
    getSortedElements, getGroupElements, getGroupAncestry, getGroupSubtreeIds, getFrames,
} from '../schema/documentSchema';
import { T } from '../ui/figma';
import { elementIcon } from '../ui/elementIcons';
import ContextMenu from '../canvas/CanvasContextMenu';
import { buildElementMenu, buildFrameMenu } from '../menus/elementMenuItems';

/* ------------------------------------------------------------------ *
 *  Row chrome shared by element + group rows
 * ------------------------------------------------------------------ */

/**
 * The lock in THIS list is the admin's own — it pins the layer inside this
 * editor so a stray drag cannot move finished artwork. The lock that stops the
 * end user lives in the right-hand panel ("Lock for user"); the two are
 * separate flags (`adminLocked` / `locked`) and neither implies the other.
 */
function RowActions({ locked, hidden, onLock, onEye, pinLock, pinEye }) {
    return (
        <>
            <button
                type="button"
                title={locked ? 'Unlock here (admin lock)' : 'Lock here (admin lock — does not lock it for the user)'}
                onClick={onLock}
                onPointerDown={(e) => e.stopPropagation()}
                className={`h-5 w-5 shrink-0 items-center justify-center rounded-[4px] ${T.textSoft} hover:!text-black dark:hover:!text-white
                    ${pinLock ? 'flex' : 'hidden group-hover:flex'}`}
            >
                {locked ? <IconLock size={12} /> : <IconLockOpen size={12} />}
            </button>
            <button
                type="button"
                title={hidden ? 'Show' : 'Hide'}
                onClick={onEye}
                onPointerDown={(e) => e.stopPropagation()}
                className={`h-5 w-5 shrink-0 items-center justify-center rounded-[4px] ${T.textSoft} hover:!text-black dark:hover:!text-white
                    ${pinEye ? 'flex' : 'hidden group-hover:flex'}`}
            >
                {hidden ? <IconEyeOff size={12} /> : <IconEye size={12} />}
            </button>
        </>
    );
}

/**
 * `startRenaming` is how the context menu's Rename row reaches in — the edit is
 * otherwise started by double-clicking the name, and a menu has nowhere to type.
 */
function RenamableName({ name, hidden, onRename, bold = false, startRenaming = false, onRenameEnd }) {
    const [renaming, setRenaming] = useState(false);
    useEffect(() => {
        if (startRenaming) setRenaming(true);
    }, [startRenaming]);
    const stop = () => { setRenaming(false); onRenameEnd?.(); };
    if (renaming) {
        return (
            <input
                autoFocus
                defaultValue={name}
                className={`flex-1 min-w-0 h-5 px-1 text-[11px] rounded-[3px] outline-none ring-1 ring-[#0d99ff] bg-white dark:bg-[#383838] ${T.text}`}
                onFocus={(e) => e.target.select()}
                onBlur={(e) => { onRename(e.target.value); stop(); }}
                onKeyDown={(e) => {
                    if (e.key === 'Enter') e.currentTarget.blur();
                    if (e.key === 'Escape') stop();
                    e.stopPropagation();
                }}
                onPointerDown={(e) => e.stopPropagation()}
            />
        );
    }
    return (
        <span
            onDoubleClick={(e) => { e.stopPropagation(); setRenaming(true); }}
            className={`flex-1 min-w-0 truncate text-[11px] ${bold ? 'font-medium' : ''} ${hidden ? T.textFaint : T.text}`}
        >
            {name}
        </span>
    );
}

/* ------------------------------------------------------------------ *
 *  Sortable rows
 * ------------------------------------------------------------------ */

/** Each nesting level indents by this much (Figma-ish). */
const INDENT = 12;
/** Map key standing in for "top level" (no parent group). */
const ROOT = ' root';

function ElementRow({ row, active, onSelect, onRename, updateElement, onContextMenu, renaming, onRenameEnd }) {
    const el = row.el;
    const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: row.id });
    // A mask says what it is instead of what it is made of: the layer no longer
    // paints its own picture or its own words, so showing the type icon there is
    // showing something the artboard does not have any more.
    const Icon = row.maskRole === 'mask' ? IconMask : elementIcon(el);
    const hidden = el.visible === false;
    const bgJob = usePrintEditorStore((s) => s.bgRemoval[el.id]);

    return (
        <div
            ref={setNodeRef}
            style={{
                transform: CSS.Transform.toString(transform),
                transition,
                paddingLeft: 8 + (row.depth || 0) * INDENT,
            }}
            {...attributes}
            {...listeners}
            onClick={(e) => onSelect(e, el)}
            onContextMenu={onContextMenu}
            className={`group flex items-center gap-1.5 h-7 pr-2 mx-2 rounded-[5px] cursor-default select-none
                ${isDragging ? 'z-10 relative shadow-md ring-1 ring-[#0d99ff] bg-white dark:bg-[#383838]' : ''}
                ${active ? T.selectedRow : T.hoverBg}`}
        >
            {/* Clipped by the mask BELOW it — the arrow points the way the layers
                list is read, down towards the layer doing the cutting. */}
            {row.maskRole === 'masked' && (
                <span className="shrink-0 -mr-0.5 flex" title={`Masked by “${row.maskName}”`}>
                    {/* Points at the row the mask is actually on, which is below
                        this one until someone reorders the two. */}
                    {row.maskAbove ? (
                        <IconCornerLeftUp size={12} stroke={1.75} className={hidden ? T.textFaint : T.textSoft} />
                    ) : (
                        <IconCornerLeftDown size={12} stroke={1.75} className={hidden ? T.textFaint : T.textSoft} />
                    )}
                </span>
            )}
            <span className="shrink-0 flex" title={row.maskRole === 'mask' ? 'Used as a mask' : undefined}>
                <Icon
                    size={12}
                    stroke={1.75}
                    className={hidden ? T.textFaint
                        : row.maskRole === 'mask' ? 'text-[#8638e5] dark:text-[#c79bff]' : T.textSoft}
                />
            </span>
            <RenamableName
                name={el.name || el.type}
                hidden={hidden}
                onRename={(v) => onRename(el, v)}
                startRenaming={renaming}
                onRenameEnd={onRenameEnd}
            />
            {/* A background removal runs for minutes on a cold model. Shown on the
                LAYER as well as in the panel, so the work is visible while another
                layer is selected. */}
            {bgJob?.busy && (
                <span
                    title={`Removing background… ${bgJob.pct || 0}%`}
                    className={`shrink-0 flex items-center gap-0.5 text-[9px] tabular-nums ${T.textSoft}`}
                >
                    <IconLoader2 size={11} className="animate-spin" />
                    {bgJob.pct || 0}%
                </span>
            )}
            <RowActions
                locked={!!el.adminLocked}
                hidden={hidden}
                pinLock={!!el.adminLocked}
                pinEye={hidden}
                onLock={(e) => { e.stopPropagation(); updateElement(el.id, { adminLocked: !el.adminLocked }); }}
                onEye={(e) => { e.stopPropagation(); updateElement(el.id, { visible: hidden }); }}
            />
        </div>
    );
}

function GroupRow({
    row, active, expanded, onToggle, onSelect, onRename, onLockAll, onEyeAll,
    onContextMenu, renaming, onRenameEnd,
}) {
    const { group, members } = row;
    const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: row.id });
    const anyVisible = members.some((m) => m.visible !== false);
    const allLocked = members.every((m) => m.adminLocked);

    return (
        <div
            ref={setNodeRef}
            style={{
                transform: CSS.Transform.toString(transform),
                transition,
                paddingLeft: 2 + (row.depth || 0) * INDENT,
            }}
            {...attributes}
            {...listeners}
            onClick={(e) => onSelect(e, row)}
            onContextMenu={onContextMenu}
            className={`group flex items-center gap-0.5 h-7 pr-2 mx-2 rounded-[5px] cursor-default select-none
                ${isDragging ? 'z-10 relative shadow-md ring-1 ring-[#0d99ff] bg-white dark:bg-[#383838]' : ''}
                ${active ? T.selectedRow : T.hoverBg}`}
        >
            <button
                type="button"
                title={expanded ? 'Collapse' : 'Expand'}
                onClick={(e) => { e.stopPropagation(); onToggle(group.id); }}
                onPointerDown={(e) => e.stopPropagation()}
                className={`h-5 w-5 shrink-0 flex items-center justify-center rounded-[4px] ${T.textSoft} hover:!text-black dark:hover:!text-white`}
            >
                <IconChevronRight size={11} className={`transition-transform ${expanded ? 'rotate-90' : ''}`} />
            </button>
            <IconBoxMultiple size={12} stroke={1.75} className={`shrink-0 mr-1 ${anyVisible ? 'text-[#8638e5] dark:text-[#c79bff]' : T.textFaint}`} />
            <RenamableName
                name={group.name || 'Group'}
                hidden={!anyVisible}
                onRename={(v) => onRename(group, v)}
                bold
                startRenaming={renaming}
                onRenameEnd={onRenameEnd}
            />
            <span className={`text-[10px] mr-0.5 ${T.textFaint}`}>{members.length}</span>
            <RowActions
                locked={allLocked}
                hidden={!anyVisible}
                pinLock={allLocked}
                pinEye={!anyVisible}
                onLock={(e) => { e.stopPropagation(); onLockAll(row, !allLocked); }}
                onEye={(e) => { e.stopPropagation(); onEyeAll(row, !anyVisible); }}
            />
        </div>
    );
}

/* ------------------------------------------------------------------ *
 *  Layers tree — Figma-style; top row = front-most
 * ------------------------------------------------------------------ */

/**
 * Frame header — heads its block of layers. Not sortable: frames are the scene's
 * containers, and their order in this list is not a z-order to shuffle.
 */
function FrameRow({ frame, active, onSelect, onRename, onContextMenu, renaming, onRenameEnd }) {
    return (
        <div
            className={`group flex items-center gap-1.5 h-7 pl-2 pr-1 cursor-pointer
                ${active ? 'bg-[#0d99ff]/10' : T.hoverBg}`}
            onClick={() => onSelect(frame)}
            onContextMenu={onContextMenu}
        >
            <IconFrame
                size={13}
                stroke={1.75}
                className={`shrink-0 ${active ? 'text-[#0d99ff]' : T.textSoft}`}
            />
            <RenamableName
                name={frame.name}
                onRename={(v) => onRename(frame, v)}
                bold
                startRenaming={renaming}
                onRenameEnd={onRenameEnd}
            />
            <span className={`text-[10px] tabular-nums shrink-0 ${T.textFaint}`}>
                {frame.width}×{frame.height}
            </span>
        </div>
    );
}

export default function LayersList() {
    const {
        document: doc,
        selectedIds,
        selectedFrameId,
        selectFrame,
        enteredGroupId,
        select,
        enterGroup,
        updateElement,
        updateElements,
        renameGroup,
        renameFrame,
        restructureLayers,
    } = usePrintEditorStore();

    const [expanded, setExpanded] = useState({});
    /** Open right-click menu: `{ at, items }` in client coordinates. */
    const [menu, setMenu] = useState(null);
    /** Row whose name the menu asked to rename — frame / group / element id. */
    const [renameId, setRenameId] = useState(null);

    // display = front → back
    const display = useMemo(() => [...getSortedElements(doc.elements)].reverse(), [doc.elements]);

    /**
     * Frame blocks, each holding its own nested tree of group blocks and loose
     * elements, front → back. Frames head their block the way Figma does, so it is
     * obvious which artboard a layer belongs to (and therefore which export).
     */
    const rows = useMemo(() => {
        const groups = doc.groups || [];
        const idxOf = new Map(display.map((e, i) => [e.id, i]));
        const byParent = new Map();
        // First registration of an id wins: a document that ended up with the same
        // group listed twice (a re-import onto an existing board) must still render
        // one row per group, not two rows fighting over one key.
        const seenGroup = new Set();
        groups.forEach((g) => {
            if (!g?.id || seenGroup.has(g.id)) return;
            seenGroup.add(g.id);
            const k = g.parentId || ROOT;
            if (!byParent.has(k)) byParent.set(k, []);
            byParent.get(k).push(g);
        });

        // Which layers are cutting, and which are being cut — Figma marks both in
        // the list, and it is the only place the relationship is visible: a mask
        // paints nothing on the artboard, so without this the layer that made the
        // artwork disappear looks like any other.
        const maskNameOf = new Map();
        const maskAboveOf = new Map();
        const cuttingIds = new Set();
        doc.elements.forEach((e) => {
            if (!e.maskId) return;
            const m = doc.elements.find((x) => x.id === e.maskId);
            if (!m || m.id === e.id) return;
            maskNameOf.set(e.id, m.name || m.content || m.type);
            // Which WAY the arrow points is not decorative: the mask is normally
            // the lower layer, but the stacking is the user's to change, and an
            // arrow pointing down at a mask that now sits above the layer is an
            // arrow pointing at the wrong row.
            maskAboveOf.set(e.id, (idxOf.get(m.id) ?? 0) < (idxOf.get(e.id) ?? 0));
            cuttingIds.add(m.id);
        });
        const maskRoleOf = (el) => (cuttingIds.has(el.id) ? 'mask'
            : maskNameOf.has(el.id) ? 'masked' : null);

        const out = [];
        const walk = (frameId, containerId, depth) => {
            if (depth > 32) return;
            const items = [];
            display.forEach((el) => {
                if (el.frameId !== frameId) return;
                if ((el.groupId || null) === containerId) {
                    items.push({ t: 'el', el, order: idxOf.get(el.id) });
                }
            });
            (byParent.get(containerId || ROOT) || []).forEach((g) => {
                const members = getGroupElements(doc.elements, groups, g.id)
                    .filter((m) => m.frameId === frameId);
                if (!members.length) return;
                const order = Math.min(...members.map((m) => idxOf.get(m.id) ?? Infinity));
                items.push({ t: 'g', g, members, order });
            });
            items.sort((a, b) => a.order - b.order);
            items.forEach((it) => {
                if (it.t === 'el') {
                    out.push({
                        type: 'el',
                        id: it.el.id,
                        el: it.el,
                        depth,
                        containerId,
                        frameId,
                        maskRole: maskRoleOf(it.el),
                        maskName: maskNameOf.get(it.el.id),
                        maskAbove: maskAboveOf.get(it.el.id) || false,
                    });
                    return;
                }
                // Frame-scoped row id: a group whose members ended up on two frames
                // (a member dragged or pasted across) is listed once per frame, and
                // two rows carrying the same id break both React keys and dnd-kit.
                out.push({
                    type: 'group',
                    id: `g:${frameId}:${it.g.id}`,
                    group: it.g,
                    members: it.members,
                    depth,
                    containerId,
                    frameId,
                });
                if (expanded[it.g.id]) walk(frameId, it.g.id, depth + 1);
            });
        };
        const frames = getFrames(doc);
        // Frames read top-down (frame 1 first) while layers inside read front→back —
        // matching Figma, where the page list is not a z-order.
        frames.forEach((frame) => {
            out.push({ type: 'frame', id: `f:${frame.id}`, frame, depth: 0, containerId: null, frameId: frame.id });
            walk(frame.id, null, 1);
        });
        return out;
    }, [display, doc, expanded]);

    // Reveal children when a group is entered or a grouped child gets selected.
    // Expands the WHOLE ancestry so a deeply nested sub-layer is actually visible.
    useEffect(() => {
        const reveal = new Set();
        const addChain = (gid) => getGroupAncestry(doc.groups || [], gid).forEach((g) => reveal.add(g.id));
        if (enteredGroupId) addChain(enteredGroupId);
        if (selectedIds.length === 1) {
            const el = doc.elements.find((e) => e.id === selectedIds[0]);
            if (el?.groupId) addChain(el.groupId);
        }
        if (!reveal.size) return;
        setExpanded((prev) => {
            const next = { ...prev };
            let changed = false;
            reveal.forEach((gid) => {
                if (!next[gid]) { next[gid] = true; changed = true; }
            });
            return changed ? next : prev;
        });
    }, [enteredGroupId, selectedIds, doc.elements]);

    const sensors = useSensors(
        useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
    );

    /* ----- selection ----- */

    /**
     * Shift-click selects the whole run of rows between the last clicked row and
     * this one, the way every layer list does.
     *
     * Two things are remembered rather than derived: the anchor row, because the
     * run has to be measured from where the user last clicked and not from
     * whatever happens to be selected; and the selection that existed when that
     * anchor was set, so shift-clicking again re-measures the run from the same
     * anchor instead of piling the previous run on top of it — dragging the range
     * shorter actually shortens it, while anything picked with Cmd beforehand
     * survives.
     */
    const anchorRef = useRef(null);
    const anchorBaseRef = useRef([]);

    const setAnchor = (rowId, base) => {
        anchorRef.current = rowId;
        anchorBaseRef.current = base;
    };

    /** The element ids a row stands for — a group row means all of its members. */
    const rowElementIds = (row) => {
        if (row.type === 'el') return [row.el.id];
        if (row.type === 'group') return row.members.map((m) => m.id);
        return []; // a frame header is a heading, not a selectable layer
    };

    /** Ids covered by the visible rows between the anchor and `rowId`, or null. */
    const rangeIds = (rowId) => {
        const ids = rows.map((r) => r.id);
        const from = ids.indexOf(anchorRef.current);
        const to = ids.indexOf(rowId);
        if (from < 0 || to < 0) return null;
        const [lo, hi] = from <= to ? [from, to] : [to, from];
        const out = [];
        for (let i = lo; i <= hi; i += 1) out.push(...rowElementIds(rows[i]));
        return out;
    };

    /**
     * One click on a layer row. `ids` is what the row itself stands for; the
     * modifiers decide whether that replaces, toggles or extends the selection.
     * Returns true when it was an extend, which is deep-selection's cue to step
     * out of any entered group — a run can cross group boundaries.
     */
    const applyRowClick = (e, row, ids) => {
        if (e.shiftKey && anchorRef.current) {
            const range = rangeIds(row.id);
            if (range) {
                select([...new Set([...anchorBaseRef.current, ...range])]);
                return true;
            }
        }
        if (e.metaKey || e.ctrlKey || e.shiftKey) {
            const allIn = ids.every((id) => selectedIds.includes(id));
            const next = allIn
                ? selectedIds.filter((id) => !ids.includes(id))
                : [...new Set([...selectedIds, ...ids])];
            select(next);
            // Extending from here should keep everything picked so far.
            setAnchor(row.id, next);
            return false;
        }
        select(ids);
        setAnchor(row.id, []);
        return false;
    };

    const onSelectElement = (e, el) => {
        const row = rows.find((r) => r.type === 'el' && r.el.id === el.id);
        if (!row) return;
        const ranged = applyRowClick(e, row, [el.id]);
        // Panel selection is deep selection — behave as if the group was entered
        enterGroup(ranged ? null : (el.groupId || null));
    };

    const onSelectGroup = (e, row) => {
        applyRowClick(e, row, row.members.map((m) => m.id));
        enterGroup(null);
    };

    const onRenameElement = (el, value) => {
        updateElement(el.id, { name: (value || '').trim() || el.type });
    };

    const onLockAll = (row, adminLocked) => {
        const patches = {};
        row.members.forEach((m) => { patches[m.id] = { adminLocked }; });
        updateElements(patches);
    };

    const onEyeAll = (row, visible) => {
        const patches = {};
        row.members.forEach((m) => { patches[m.id] = { visible }; });
        updateElements(patches);
    };

    /* ----- right-click menu ----- */

    /**
     * Figma's rule: right-clicking a row that is NOT part of the current
     * selection picks it first, so the menu always acts on what was clicked;
     * right-clicking inside a selection leaves that selection alone, which is
     * how a menu is used on several layers at once.
     *
     * The rows are the same ones the canvas menu offers (see menus/
     * elementMenuItems) — the point of having them here is that the layer is
     * picked by NAME, with no chance of the canvas handing the click to
     * whatever happens to overlap it.
     */
    const openRowMenu = (e, row) => {
        e.preventDefault();
        e.stopPropagation();
        const at = { x: e.clientX, y: e.clientY };

        if (row.type === 'frame') {
            selectFrame(row.frame.id);
            const store = usePrintEditorStore.getState();
            setMenu({
                at,
                items: buildFrameMenu({
                    store,
                    mode: store.mode,
                    frame: row.frame,
                    onRename: () => setRenameId(row.frame.id),
                }),
            });
            return;
        }

        const rowIds = rowElementIds(row);
        const inSelection = rowIds.length > 0 && rowIds.every((id) => selectedIds.includes(id));
        if (!inSelection) {
            select(rowIds);
            enterGroup(row.type === 'el' ? (row.el.groupId || null) : null);
        }
        // Read AFTER the select above — the menu's labels and its actions must
        // both be about the selection the menu is opening on.
        const store = usePrintEditorStore.getState();
        setMenu({
            at,
            items: buildElementMenu({
                store,
                mode: store.mode,
                ids: store.selectedIds,
                onRename: () => setRenameId(row.type === 'el' ? row.el.id : row.group.id),
            }),
        });
    };

    /* ----- drag reorder (incl. into / out of groups) ----- */

    const onDragStart = ({ active }) => {
        const row = rows.find((r) => r.id === String(active.id));
        if (!row || row.type !== 'group') return;
        const gid = row.group.id;
        if (expanded[gid]) setExpanded((p) => ({ ...p, [gid]: false }));
    };

    const onDragEnd = ({ active, over }) => {
        if (!over || active.id === over.id) return;
        const ids = rows.map((r) => r.id);
        const from = ids.indexOf(active.id);
        const to = ids.indexOf(over.id);
        if (from < 0 || to < 0) return;

        const draggedRow = rows[from];
        const newRows = arrayMove(rows, from, to);
        const membership = {};
        const parentByGroup = {};
        const frameByEl = {};

        // The container a row dropped here belongs to: the row above decides it —
        // an OPEN group header adopts the row as its first child, anything else
        // hands over its own container. Works at any nesting depth.
        const i = newRows.indexOf(draggedRow);
        const P = newRows[i - 1];
        const container = P
            ? (P.type === 'group' && expanded[P.group.id] ? P.group.id : (P.containerId || null))
            : null;
        // Which frame's block the row landed in — the nearest frame header above it.
        let frameId = draggedRow.frameId;
        for (let k = i - 1; k >= 0; k -= 1) {
            if (newRows[k].type === 'frame') { frameId = newRows[k].frame.id; break; }
        }

        if (draggedRow.type === 'el') {
            membership[draggedRow.el.id] = container;
            frameByEl[draggedRow.el.id] = frameId;
        } else if (draggedRow.type === 'group') {
            // Never drop a group inside itself / its own descendants.
            const inside = getGroupSubtreeIds(doc.groups || [], draggedRow.group.id);
            parentByGroup[draggedRow.group.id] = (container && !inside.has(container)) ? container : null;
            // A whole group crosses frames together.
            draggedRow.members.forEach((m) => { frameByEl[m.id] = frameId; });
        }

        // Rebuild the full front→back order (collapsed groups contribute their block)
        const fullOrder = [];
        newRows.forEach((r) => {
            if (r.type === 'frame') return; // headers aren't layers
            if (r.type === 'group') {
                // `members` arrive in Z-ORDER — back to front — while this list is
                // front to back, so a collapsed group's block has to be turned
                // round before it is appended. Appending it as-is reversed the
                // stacking inside EVERY collapsed group in the document on every
                // single drag: the white card background inside one jumped to the
                // front and blanked the artwork on both frames at once.
                if (!expanded[r.group.id]) {
                    for (let k = r.members.length - 1; k >= 0; k -= 1) fullOrder.push(r.members[k].id);
                }
                return; // expanded groups: children are their own rows
            }
            fullOrder.push(r.el.id);
        });

        restructureLayers([...fullOrder].reverse(), membership, parentByGroup, frameByEl);
    };

    // Frames are always listed, even empty ones — they are what the user adds
    // designs to, so hiding them until an element exists would hide the structure.
    if (!display.length && getFrames(doc).length <= 1) {
        return (
            <p className={`px-4 py-3 text-[11px] leading-relaxed ${T.textSoft}`}>
                No layers yet. Add elements from the toolbar below.
            </p>
        );
    }

    return (
        <DndContext
            sensors={sensors}
            collisionDetection={closestCenter}
            onDragStart={onDragStart}
            onDragEnd={onDragEnd}
        >
            <SortableContext
                items={rows.filter((r) => r.type !== 'frame').map((r) => r.id)}
                strategy={verticalListSortingStrategy}
            >
                <div className="py-1">
                    {rows.map((row) => (
                        row.type === 'frame' ? (
                            <FrameRow
                                key={row.id}
                                frame={row.frame}
                                active={selectedFrameId === row.frame.id}
                                onSelect={(f) => selectFrame(f.id)}
                                onRename={(f, v) => renameFrame(f.id, v)}
                                onContextMenu={(e) => openRowMenu(e, row)}
                                renaming={renameId === row.frame.id}
                                onRenameEnd={() => setRenameId(null)}
                            />
                        ) : row.type === 'group' ? (
                            <GroupRow
                                key={row.id}
                                row={row}
                                expanded={!!expanded[row.group.id]}
                                active={row.members.every((m) => selectedIds.includes(m.id))}
                                onToggle={(gid) => setExpanded((p) => ({ ...p, [gid]: !p[gid] }))}
                                onSelect={onSelectGroup}
                                onRename={(g, v) => renameGroup(g.id, v)}
                                onLockAll={onLockAll}
                                onEyeAll={onEyeAll}
                                onContextMenu={(e) => openRowMenu(e, row)}
                                renaming={renameId === row.group.id}
                                onRenameEnd={() => setRenameId(null)}
                            />
                        ) : (
                            <ElementRow
                                key={row.id}
                                row={row}
                                active={selectedIds.includes(row.el.id)}
                                onSelect={onSelectElement}
                                onRename={onRenameElement}
                                updateElement={updateElement}
                                onContextMenu={(e) => openRowMenu(e, row)}
                                renaming={renameId === row.el.id}
                                onRenameEnd={() => setRenameId(null)}
                            />
                        )
                    ))}
                </div>
            </SortableContext>

            {/* Fixed to the viewport, not to the list: the panel scrolls and clips,
                and a menu opened on its last row would be cut off. */}
            {menu && (
                <ContextMenu
                    at={menu.at}
                    items={menu.items}
                    positioning="fixed"
                    onClose={() => setMenu(null)}
                />
            )}
        </DndContext>
    );
}
