import { Tooltip } from '@/Components/ui';
import { cn } from '@/lib/cn';
import type { Layer, LayerType } from '@/types/layout';
import {
    ChevronDown,
    ChevronLeft,
    ChevronRight,
    Circle,
    Copy,
    Crosshair,
    Eye,
    EyeOff,
    Folder,
    Hexagon,
    Image as ImageIcon,
    Lock,
    Minus,
    MoveUpRight,
    Palette,
    PanelLeftOpen,
    PenTool,
    Scissors,
    Smartphone,
    Square,
    Star,
    Trash2,
    Type,
    Unlock,
    type LucideIcon,
} from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { ContextMenu } from './ContextMenu';
import { defaultLabelFor } from './factories';
import { useEditor } from './store';

// Mirrors the icons used by the toolbar's "add layer" affordances so the
// Layers panel reads consistently with what the user picked when creating
// the layer. Shape layers resolve per-`shape` since shape is a multi-kind
// layer type (rect / ellipse / line / arrow / polygon / star / pen-path).
const baseTypeIcon: Record<Exclude<LayerType, 'shape'>, LucideIcon> = {
    text: Type,
    background: Palette,
    image: ImageIcon,
    frame: Smartphone,
    group: Folder,
    callout: Crosshair,
};

const shapeIcon: Record<string, LucideIcon> = {
    rect: Square,
    ellipse: Circle,
    line: Minus,
    arrow: MoveUpRight,
    polygon: Hexagon,
    star: Star,
    path: PenTool,
};

function iconForLayer(layer: Layer): LucideIcon {
    if (layer.type === 'shape') {
        return shapeIcon[layer.shape] ?? Square;
    }
    return baseTypeIcon[layer.type];
}

type DropPos = 'above' | 'below' | 'inside';
type DropTarget = { id: string; pos: DropPos };

type VisualRow = {
    layer: Layer;
    depth: 0 | 1;
    /** When true, render the scissors badge next to the label (clip-bound child). */
    clipBadge?: boolean;
};

type LayersPanelProps = {
    screenId: number;
    /** Read-only — another editor holds the edit lock (P4/Δ21). The panel
     *  renders for context but every mutation (add/delete/reorder/rename/
     *  visibility/lock) is suppressed. */
    readOnly?: boolean;
};

export default function LayersPanel({
    screenId: _screenId,
    readOnly = false,
}: LayersPanelProps) {
    const layers = useEditor((s) => s.layout.layers);
    const selection = useEditor((s) => s.selection);
    const setSelection = useEditor((s) => s.setSelection);
    const updateLayer = useEditor((s) => s.updateLayer);
    const reorderLayers = useEditor((s) => s.reorderLayers);
    const deleteLayer = useEditor((s) => s.deleteLayer);
    const duplicateSelected = useEditor((s) => s.duplicateSelected);

    const [draggedId, setDraggedId] = useState<string | null>(null);
    const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
    const [editingId, setEditingId] = useState<string | null>(null);
    const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
        () => new Set<string>(),
    );
    const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
    // Panel-collapse state persisted across editor sessions via localStorage —
    // user's pref carries through reloads + Inertia navigations within the
    // editor. Defaults to EXPANDED for first-time visitors: kits ship
    // with non-trivial layer trees (8+ layers, grouped) and the panel is
    // the only affordance for selecting layers obscured by others. Once
    // the user collapses it, the localStorage key sticks and subsequent
    // visits respect that choice.
    const [collapsed, setCollapsed] = useState<boolean>(() => {
        if (typeof window === 'undefined') return false;
        try {
            const v = window.localStorage.getItem('appshot:layers-panel-collapsed');
            // Treat missing key as expanded; only an explicit '1' (user
            // collapsed once) closes the panel by default on subsequent loads.
            return v === '1';
        } catch {
            return false;
        }
    });
    const togglePanelCollapsed = () => {
        setCollapsed((prev) => {
            const next = !prev;
            try {
                window.localStorage.setItem(
                    'appshot:layers-panel-collapsed',
                    next ? '1' : '0',
                );
            } catch {
                /* storage blocked — DOM state still wins for this session */
            }
            return next;
        });
    };

    const draggedIdRef = useRef<string | null>(null);
    const layersRef = useRef(layers);
    layersRef.current = layers;

    /**
     * Array order is bottom-first (end of array = top z-order). Within that array,
     * children of a group are stored immediately BEFORE their group so that
     * reversing for the panel renders the group header above its children.
     *
     * Visual rows: top-of-panel first. For each top-level layer in reverse-array
     * order, emit the layer; if it is a group and expanded, emit its children
     * (also in reverse-array order) one depth level in.
     *
     * Clip-shape nesting: image layers with `clipShapeId` pointing at an
     * existing shape are pulled out of their top-level slot and rendered as
     * depth-1 children beneath the shape. A clipped layer that already belongs
     * to a group (has `parentId`) stays inside its group — group nesting wins
     * since depth is capped at 1.
     */
    const visualRows = useMemo<VisualRow[]>(() => {
        const byId = new Map<string, Layer>();
        for (const layer of layers) byId.set(layer.id, layer);

        const childrenByParent = new Map<string, Layer[]>();
        for (const layer of layers) {
            const parentId = layer.parentId;
            if (parentId) {
                const bucket = childrenByParent.get(parentId);
                if (bucket) bucket.push(layer);
                else childrenByParent.set(parentId, [layer]);
            }
        }

        // Build clip-shape buckets. Only promote clipped layers that are NOT
        // already inside a group — group children stay with their group. And
        // only promote if the host shape itself is top-level (no parentId,
        // not clipped by another shape) — otherwise the shape will appear as
        // a depth-1 group child and the clipped image would never be inserted
        // anywhere (orphaned from the panel while still rendering on canvas).
        const clippedByShape = new Map<string, Layer[]>();
        const clippedIds = new Set<string>();
        for (const layer of layers) {
            if (layer.parentId) continue;
            if (layer.type !== 'image') continue;
            const shapeId = layer.clipShapeId;
            if (!shapeId) continue;
            const shape = byId.get(shapeId);
            if (!shape || shape.type !== 'shape') continue;
            if (shape.parentId) continue;
            const bucket = clippedByShape.get(shapeId);
            if (bucket) bucket.push(layer);
            else clippedByShape.set(shapeId, [layer]);
            clippedIds.add(layer.id);
        }

        const rows: VisualRow[] = [];
        // Top-level = no parentId (or parent missing → orphan rescue), AND
        // not visually adopted by a clip-shape. Orphan rescue keeps deleted-
        // parent children visible after a parent's Reset/delete so they don't
        // hide-in-panel while still rendering on canvas.
        const topLevel = layers.filter((l) => {
            if (clippedIds.has(l.id)) return false;
            if (!l.parentId) return true;
            return !byId.has(l.parentId);
        });
        for (let i = topLevel.length - 1; i >= 0; i--) {
            const layer = topLevel[i];
            rows.push({ layer, depth: 0 });
            if (layer.type === 'group' && !collapsedGroups.has(layer.id)) {
                const kids = childrenByParent.get(layer.id);
                if (kids && kids.length > 0) {
                    for (let j = kids.length - 1; j >= 0; j--) {
                        rows.push({ layer: kids[j], depth: 1 });
                    }
                }
            } else if (layer.type === 'shape') {
                const clipped = clippedByShape.get(layer.id);
                if (clipped && clipped.length > 0) {
                    for (let j = clipped.length - 1; j >= 0; j--) {
                        rows.push({ layer: clipped[j], depth: 1, clipBadge: true });
                    }
                }
            }
        }
        return rows;
    }, [layers, collapsedGroups]);

    const toggleCollapsed = useCallback((id: string) => {
        setCollapsedGroups((prev) => {
            const next = new Set(prev);
            if (next.has(id)) next.delete(id);
            else next.add(id);
            return next;
        });
    }, []);

    const commitRename = useCallback(
        (id: string, value: string) => {
            const trimmed = value.trim();
            updateLayer(id, (draft) => {
                draft.name = trimmed.length > 0 ? trimmed : undefined;
            });
            setEditingId(null);
        },
        [updateLayer],
    );

    const computeDropPos = (
        e: React.DragEvent<HTMLElement>,
        isGroup: boolean,
    ): DropPos => {
        const rect = e.currentTarget.getBoundingClientRect();
        const y = e.clientY - rect.top;
        if (isGroup) {
            // Three-zone on a group row: top third = above, middle = inside, bottom third = below.
            if (y < rect.height * 0.33) return 'above';
            if (y > rect.height * 0.67) return 'below';
            return 'inside';
        }
        return y < rect.height / 2 ? 'above' : 'below';
    };

    const beginDrag = useCallback((id: string) => {
        draggedIdRef.current = id;
        setDraggedId(id);
    }, []);

    const handleDragOver = useCallback(
        (e: React.DragEvent<HTMLElement>, overId: string, isGroup: boolean) => {
            if (!draggedIdRef.current) return;
            if (draggedIdRef.current === overId) return;
            e.preventDefault();
            if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
            const pos = computeDropPos(e, isGroup);
            setDropTarget((prev) =>
                prev && prev.id === overId && prev.pos === pos ? prev : { id: overId, pos },
            );
        },
        [],
    );

    /**
     * Rebuild the full `layers` array after a drag-drop. Works on the full
     * layer set (not the visual rows) so hidden children of collapsed groups
     * keep their position.
     *
     * Array order invariant: bottom-first (end of array = top z-order), with
     * each group's children placed immediately BEFORE the group so that the
     * reversed panel view renders the group header above its indented children.
     */
    const commitDrop = useCallback(
        (overId: string, pos: DropPos) => {
            const draggingId = draggedIdRef.current;
            if (!draggingId || draggingId === overId) return;

            const currentLayers = layersRef.current;
            const dragged = currentLayers.find((l) => l.id === draggingId);
            const over = currentLayers.find((l) => l.id === overId);
            if (!dragged || !over) return;

            // Groups cannot be nested (v1) — block reparenting a group into a group
            // and block dropping a group into another group.
            if (dragged.type === 'group' && (pos === 'inside' || over.parentId)) {
                return;
            }

            // Resolve the target parent for the dragged layer after the drop.
            let newParentId: string | undefined;
            if (pos === 'inside') {
                newParentId = over.type === 'group' ? over.id : undefined;
            } else {
                newParentId = over.parentId;
            }

            // Working set keyed by id with the effective new parentId applied to
            // the dragged layer. Everyone else keeps their current parentId.
            const effectiveParent = (id: string): string | undefined =>
                id === draggingId ? newParentId : currentLayers.find((l) => l.id === id)?.parentId;

            // Build the new TOP-LEVEL order as the user sees it after the drop.
            // Start from the current panel view of top-level layers (top-first =
            // reverse-array order), then move the dragged id into the right slot
            // if the move touches the top-level sequence.
            const topLevelPanel = currentLayers
                .filter((l) => !l.parentId)
                .map((l) => l.id)
                .reverse(); // top-first

            // Always pull the dragged id out of the top-level panel first — if it
            // becomes a child, it stays out; if it stays top-level, we re-insert below.
            const nextTopLevelPanel = topLevelPanel.filter((id) => id !== draggingId);

            if (!newParentId) {
                // Dragged is (or becomes) top-level. Drop it next to the right sibling.
                if (pos === 'inside') {
                    // Only hit when `over` is a non-group — newParentId is undefined;
                    // insert just below `over` in the top-level sequence.
                    const idx = nextTopLevelPanel.indexOf(overId);
                    if (idx !== -1) nextTopLevelPanel.splice(idx + 1, 0, draggingId);
                    else nextTopLevelPanel.push(draggingId);
                } else {
                    // Drop above/below the top-level ANCESTOR of the hovered row.
                    const anchorTop = over.parentId ?? overId;
                    const idx = nextTopLevelPanel.indexOf(anchorTop);
                    if (idx === -1) return;
                    nextTopLevelPanel.splice(pos === 'above' ? idx : idx + 1, 0, draggingId);
                }
            }
            // When newParentId is set, dragged lives inside a group; top-level
            // sequence is `nextTopLevelPanel` as-is (already had dragged removed).

            // Build ordered children for each group (top-first within the group).
            const childOrderByGroup = new Map<string, string[]>();
            // Preserve current top-first child order, but pull the dragged layer
            // out of its old home first.
            for (const l of [...currentLayers].reverse()) {
                const parent = effectiveParent(l.id);
                if (!parent) continue;
                if (l.id === draggingId) continue; // will be re-inserted below
                const arr = childOrderByGroup.get(parent);
                if (arr) arr.push(l.id);
                else childOrderByGroup.set(parent, [l.id]);
            }

            if (newParentId) {
                const siblings = childOrderByGroup.get(newParentId) ?? [];
                if (pos === 'inside') {
                    // Last child of the target group (bottom of children cluster
                    // in the top-first visual list).
                    siblings.push(draggingId);
                } else if (over.parentId === newParentId) {
                    // Dropped next to a sibling child — honor above/below.
                    const idx = siblings.indexOf(overId);
                    if (idx === -1) siblings.push(draggingId);
                    else siblings.splice(pos === 'above' ? idx : idx + 1, 0, draggingId);
                } else {
                    // Fallback: append as last child.
                    siblings.push(draggingId);
                }
                childOrderByGroup.set(newParentId, siblings);
            }

            // Compose the final array (bottom-first). For each top-level id in
            // reverse of the new top-first panel order, if it's a group emit its
            // children (in reverse of the top-first child order) then the group.
            const bottomFirstTopLevel = nextTopLevelPanel.slice().reverse();
            const arrayOrder: string[] = [];
            for (const topId of bottomFirstTopLevel) {
                const topLayer = currentLayers.find((l) => l.id === topId);
                if (topLayer && topLayer.type === 'group') {
                    const kidsTopFirst = childOrderByGroup.get(topId) ?? [];
                    const kidsBottomFirst = kidsTopFirst.slice().reverse();
                    for (const kid of kidsBottomFirst) arrayOrder.push(kid);
                }
                arrayOrder.push(topId);
            }

            // Sanity check — must contain every id exactly once.
            if (arrayOrder.length !== currentLayers.length) return;
            const seen = new Set(arrayOrder);
            if (seen.size !== arrayOrder.length) return;

            // Reparent first (records its own undo patch), then reorder.
            if (dragged.parentId !== newParentId) {
                updateLayer(draggingId, (draft) => {
                    if (newParentId) draft.parentId = newParentId;
                    else delete (draft as { parentId?: string }).parentId;
                });
            }

            reorderLayers(arrayOrder);
        },
        [reorderLayers, updateLayer],
    );

    const resetDrag = useCallback(() => {
        draggedIdRef.current = null;
        setDraggedId(null);
        setDropTarget(null);
    }, []);

    const handleRootDrop = useCallback(
        (e: React.DragEvent<HTMLElement>) => {
            // Dropped on empty list space (not a row) — treat as "move to end
            // of top-level" (bottom of panel = bottom of visual list).
            const draggingId = draggedIdRef.current;
            if (!draggingId) return;
            e.preventDefault();

            const currentLayers = layersRef.current;
            const dragged = currentLayers.find((l) => l.id === draggingId);
            if (!dragged) {
                resetDrag();
                return;
            }

            // Find the last visible top-level row; drop below it.
            const topRows = visualRows.filter((r) => r.depth === 0);
            const last = topRows[topRows.length - 1];
            if (!last || last.layer.id === draggingId) {
                resetDrag();
                return;
            }
            commitDrop(last.layer.id, 'below');
            resetDrag();
        },
        [commitDrop, resetDrag, visualRows],
    );

    if (collapsed) {
        return (
            <aside className="flex w-9 shrink-0 flex-col items-center border-r border-border bg-card pt-2">
                <button
                    type="button"
                    onClick={togglePanelCollapsed}
                    aria-label="Show layers panel"
                    data-testid="layers-panel-expand"
                    className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
                    title="Show layers"
                >
                    <PanelLeftOpen className="h-4 w-4" strokeWidth={2} />
                </button>
                {layers.length > 0 && (
                    <span className="mt-2 font-mono text-[10px] text-muted-foreground/60">
                        {layers.length}
                    </span>
                )}
            </aside>
        );
    }

    return (
        <aside
            className={cn(
                'flex w-52 shrink-0 flex-col border-r border-border bg-card',
                // Locked-out editor: visibly disabled + non-interactive so no
                // mutation slips through (P4/Δ21).
                readOnly && 'pointer-events-none select-none opacity-60',
            )}
            aria-disabled={readOnly || undefined}
        >
            <div className="flex h-9 shrink-0 items-center border-b border-border px-3">
                <h2 className="text-[11px] font-medium text-muted-foreground">
                    Layers
                </h2>
                <span className="ml-auto font-mono text-[10px] text-muted-foreground/60">
                    {layers.length > 0 ? layers.length : ''}
                </span>
                <button
                    type="button"
                    onClick={togglePanelCollapsed}
                    aria-label="Hide layers panel"
                    data-testid="layers-panel-collapse"
                    className="ml-2 inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
                    title="Hide layers"
                >
                    <ChevronLeft className="h-3 w-3" strokeWidth={2} />
                </button>
            </div>

            {layers.length === 0 ? (
                <div
                    data-testid="layers-empty"
                    className="flex flex-1 items-center justify-center px-4 text-center text-sm text-muted-foreground/70"
                >
                    No layers yet
                </div>
            ) : (
                <ul
                    className="flex-1 overflow-y-auto py-1"
                    data-testid="layers-list"
                    onDragOver={(e) => {
                        if (draggedIdRef.current) e.preventDefault();
                    }}
                    onDrop={handleRootDrop}
                >
                    {visualRows.map(({ layer, depth, clipBadge }) => {
                        const selected = selection.includes(layer.id);
                        const visible = layer.visible !== false;
                        const locked = layer.locked === true;
                        const Icon = iconForLayer(layer);
                        const dragging = draggedId === layer.id;
                        const isGroup = layer.type === 'group';
                        const collapsed = isGroup && collapsedGroups.has(layer.id);
                        const showAbove =
                            dropTarget?.id === layer.id &&
                            dropTarget.pos === 'above' &&
                            draggedId !== layer.id;
                        const showBelow =
                            dropTarget?.id === layer.id &&
                            dropTarget.pos === 'below' &&
                            draggedId !== layer.id;
                        const showInside =
                            dropTarget?.id === layer.id &&
                            dropTarget.pos === 'inside' &&
                            draggedId !== layer.id &&
                            isGroup;

                        const editing = editingId === layer.id;

                        // Orphan source — callout pointing at a missing layer.
                        // Surfaces a destructive dot so the user knows to
                        // re-pick before exporting (export renders a dashed
                        // placeholder for orphans).
                        const orphanSource =
                            layer.type === 'callout' &&
                            !layers.some((l) => l.id === layer.sourceLayerId);

                        return (
                            <li key={layer.id}>
                                <Row
                                    layer={layer}
                                    depth={depth}
                                    clipBadge={clipBadge === true}
                                    isGroup={isGroup}
                                    collapsed={collapsed}
                                    selected={selected}
                                    visible={visible}
                                    locked={locked}
                                    Icon={Icon}
                                    dragging={dragging}
                                    showAbove={showAbove}
                                    showBelow={showBelow}
                                    showInside={showInside}
                                    editing={editing}
                                    orphanSource={orphanSource}
                                    onToggleCollapsed={() => toggleCollapsed(layer.id)}
                                    onSelect={(additive) => {
                                        if (additive) {
                                            const set = new Set(selection);
                                            if (set.has(layer.id)) set.delete(layer.id);
                                            else set.add(layer.id);
                                            setSelection(Array.from(set));
                                        } else {
                                            setSelection([layer.id]);
                                        }
                                    }}
                                    onStartRename={() => setEditingId(layer.id)}
                                    onCommitRename={(value) => commitRename(layer.id, value)}
                                    onCancelRename={() => setEditingId(null)}
                                    onToggleVisibility={() =>
                                        updateLayer(layer.id, (draft) => {
                                            draft.visible = !visible;
                                        })
                                    }
                                    onToggleLock={() =>
                                        updateLayer(layer.id, (draft) => {
                                            draft.locked = !locked;
                                        })
                                    }
                                    onDuplicate={() => {
                                        if (!selection.includes(layer.id)) {
                                            setSelection([layer.id]);
                                        }
                                        duplicateSelected();
                                    }}
                                    onDelete={() => deleteLayer(layer.id)}
                                    onDragStart={(e) => {
                                        beginDrag(layer.id);
                                        if (e.dataTransfer) {
                                            e.dataTransfer.effectAllowed = 'move';
                                            // Firefox requires setData to start drag.
                                            e.dataTransfer.setData('text/plain', layer.id);
                                        }
                                    }}
                                    onDragOver={(e) =>
                                        handleDragOver(e, layer.id, isGroup)
                                    }
                                    onDragLeave={() => {
                                        setDropTarget((prev) =>
                                            prev?.id === layer.id ? null : prev,
                                        );
                                    }}
                                    onDrop={(e) => {
                                        e.preventDefault();
                                        e.stopPropagation();
                                        const pos = computeDropPos(e, isGroup);
                                        commitDrop(layer.id, pos);
                                        resetDrag();
                                    }}
                                    onDragEnd={resetDrag}
                                    onContextMenu={(e) => {
                                        e.preventDefault();
                                        if (!selection.includes(layer.id)) {
                                            setSelection([layer.id]);
                                        }
                                        setContextMenu({
                                            x: e.clientX,
                                            y: e.clientY,
                                        });
                                    }}
                                />
                            </li>
                        );
                    })}
                </ul>
            )}
            {contextMenu && (
                <ContextMenu
                    x={contextMenu.x}
                    y={contextMenu.y}
                    onClose={() => setContextMenu(null)}
                />
            )}
        </aside>
    );
}

type RowProps = {
    layer: Layer;
    depth: 0 | 1;
    clipBadge: boolean;
    isGroup: boolean;
    collapsed: boolean;
    selected: boolean;
    visible: boolean;
    locked: boolean;
    Icon: LucideIcon;
    dragging: boolean;
    showAbove: boolean;
    showBelow: boolean;
    showInside: boolean;
    editing: boolean;
    orphanSource: boolean;
    onToggleCollapsed: () => void;
    onSelect: (additive: boolean) => void;
    onStartRename: () => void;
    onCommitRename: (value: string) => void;
    onCancelRename: () => void;
    onToggleVisibility: () => void;
    onToggleLock: () => void;
    onDuplicate: () => void;
    onDelete: () => void;
    onDragStart: (e: React.DragEvent<HTMLElement>) => void;
    onDragOver: (e: React.DragEvent<HTMLElement>) => void;
    onDragLeave: (e: React.DragEvent<HTMLElement>) => void;
    onDrop: (e: React.DragEvent<HTMLElement>) => void;
    onDragEnd: (e: React.DragEvent<HTMLElement>) => void;
    onContextMenu: (e: React.MouseEvent<HTMLElement>) => void;
};

function Row({
    layer,
    depth,
    clipBadge,
    isGroup,
    collapsed,
    selected,
    visible,
    locked,
    Icon,
    dragging,
    showAbove,
    showBelow,
    showInside,
    editing,
    orphanSource,
    onToggleCollapsed,
    onSelect,
    onStartRename,
    onCommitRename,
    onCancelRename,
    onToggleVisibility,
    onToggleLock,
    onDuplicate,
    onDelete,
    onDragStart,
    onDragOver,
    onDragLeave,
    onDrop,
    onDragEnd,
    onContextMenu,
}: RowProps) {
    const label = defaultLabelFor(layer) || (isGroup ? 'Group' : '');

    return (
        <div
            role="button"
            tabIndex={0}
            draggable={!editing}
            data-testid={`layer-row-${layer.id}`}
            data-layer-type={layer.type}
            data-selected={selected ? 'true' : 'false'}
            data-dragging={dragging ? 'true' : 'false'}
            data-depth={depth}
            onClick={(e) => {
                if (editing) return;
                onSelect(e.shiftKey || e.metaKey || e.ctrlKey);
            }}
            onKeyDown={(e) => {
                if (editing) return;
                if (e.key === 'Enter' || e.key === ' ') {
                    e.preventDefault();
                    onSelect(e.shiftKey || e.metaKey || e.ctrlKey);
                } else if (e.key === 'F2' && selected) {
                    e.preventDefault();
                    onStartRename();
                }
            }}
            onDragStart={onDragStart}
            onDragOver={onDragOver}
            onDragLeave={onDragLeave}
            onDrop={onDrop}
            onDragEnd={onDragEnd}
            onContextMenu={onContextMenu}
            className={cn(
                'group relative flex items-center gap-1.5 border-l-2 border-transparent py-0.5 pr-1.5 text-[11px] text-foreground/80 transition-colors hover:bg-muted',
                depth === 0 ? 'pl-1.5' : 'pl-5',
                editing ? 'cursor-text' : 'cursor-grab active:cursor-grabbing',
                selected && 'border-accent bg-accent/5 text-foreground',
                !visible && 'text-muted-foreground/50',
                locked && 'text-muted-foreground',
                dragging && 'opacity-40',
                showInside && 'bg-accent/10 ring-1 ring-inset ring-accent/40',
            )}
        >
            {showAbove && (
                <span
                    data-testid={`layer-drop-above-${layer.id}`}
                    className="pointer-events-none absolute inset-x-1 top-0 h-0.5 rounded-full bg-accent"
                />
            )}
            {showBelow && (
                <span
                    data-testid={`layer-drop-below-${layer.id}`}
                    className="pointer-events-none absolute inset-x-1 bottom-0 h-0.5 rounded-full bg-accent"
                />
            )}

            {isGroup ? (
                <button
                    type="button"
                    data-testid={`layer-disclosure-${layer.id}`}
                    aria-label={collapsed ? 'Expand group' : 'Collapse group'}
                    aria-expanded={!collapsed}
                    onClick={(e) => {
                        e.stopPropagation();
                        onToggleCollapsed();
                    }}
                    className="-mr-1 flex h-3 w-3 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground"
                >
                    {collapsed ? (
                        <ChevronRight className="h-2.5 w-2.5" strokeWidth={2.5} />
                    ) : (
                        <ChevronDown className="h-2.5 w-2.5" strokeWidth={2.5} />
                    )}
                </button>
            ) : (
                <span className="-mr-1 h-3 w-3 shrink-0" aria-hidden="true" />
            )}
            <Icon
                className={cn(
                    'h-3 w-3 shrink-0',
                    selected ? 'text-accent' : 'text-muted-foreground',
                )}
                strokeWidth={2}
            />
            {editing ? (
                <RenameInput
                    testId={`layer-rename-${layer.id}`}
                    initialValue={label}
                    onCommit={onCommitRename}
                    onCancel={onCancelRename}
                />
            ) : (
                <span
                    data-testid={`layer-label-${layer.id}`}
                    className="min-w-0 flex-1 truncate"
                    onDoubleClick={(e) => {
                        e.stopPropagation();
                        onStartRename();
                    }}
                >
                    {label}
                </span>
            )}

            {clipBadge && (
                <span
                    data-testid={`layer-clip-badge-${layer.id}`}
                    title="Clipped by shape"
                    aria-label="Clipped by shape"
                    className="shrink-0 text-muted-foreground/70"
                >
                    <Scissors className="h-2.5 w-2.5" strokeWidth={2} aria-hidden="true" />
                </span>
            )}

            {orphanSource && (
                <Tooltip
                    label="Source layer is missing — open Properties and Re-pick to bind a new source."
                    side="top"
                    multiline
                >
                    <span
                        data-testid={`layer-orphan-source-${layer.id}`}
                        aria-label="Callout source missing"
                        onMouseDown={(e) => e.stopPropagation()}
                        className="-mx-1 -my-1 flex shrink-0 cursor-help items-center justify-center p-1"
                    >
                        <span className="h-1.5 w-1.5 rounded-full bg-destructive" />
                    </span>
                </Tooltip>
            )}

            <div className="flex items-center gap-0.5">
                <RowAction
                    testId={`layer-lock-${layer.id}`}
                    label={locked ? 'Unlock layer' : 'Lock layer'}
                    icon={locked ? Lock : Unlock}
                    onClick={onToggleLock}
                    forceVisible={locked || selected}
                    variant="neutral"
                />
                <RowAction
                    testId={`layer-visibility-${layer.id}`}
                    label={visible ? 'Hide layer' : 'Show layer'}
                    icon={visible ? Eye : EyeOff}
                    onClick={onToggleVisibility}
                    forceVisible={!visible || selected}
                    variant="neutral"
                />
                <RowAction
                    testId={`layer-duplicate-${layer.id}`}
                    label="Duplicate layer"
                    icon={Copy}
                    onClick={onDuplicate}
                    forceVisible={selected}
                    variant="neutral"
                />
                <RowAction
                    testId={`layer-delete-${layer.id}`}
                    label="Delete layer"
                    icon={Trash2}
                    onClick={onDelete}
                    forceVisible={selected}
                    variant="destructive"
                />
            </div>
        </div>
    );
}

type RowActionProps = {
    testId: string;
    label: string;
    icon: LucideIcon;
    onClick: () => void;
    forceVisible: boolean;
    variant: 'neutral' | 'destructive';
};

function RowAction({
    testId,
    label,
    icon: Icon,
    onClick,
    forceVisible,
    variant,
}: RowActionProps) {
    return (
        <button
            type="button"
            data-testid={testId}
            aria-label={label}
            title={label}
            onClick={(e) => {
                e.stopPropagation();
                onClick();
            }}
            className={cn(
                'flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors',
                variant === 'destructive'
                    ? 'hover:bg-destructive/10 hover:text-destructive'
                    : 'hover:bg-background hover:text-foreground',
                forceVisible
                    ? 'opacity-100'
                    : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
            )}
        >
            <Icon className="h-3 w-3" strokeWidth={2} />
        </button>
    );
}

type RenameInputProps = {
    testId: string;
    initialValue: string;
    onCommit: (value: string) => void;
    onCancel: () => void;
};

function RenameInput({ testId, initialValue, onCommit, onCancel }: RenameInputProps) {
    return (
        <input
            type="text"
            autoFocus
            defaultValue={initialValue}
            data-testid={testId}
            onClick={(e) => e.stopPropagation()}
            onFocus={(e) => e.currentTarget.select()}
            onBlur={(e) => onCommit(e.currentTarget.value)}
            onKeyDown={(e) => {
                if (e.key === 'Enter') {
                    e.preventDefault();
                    onCommit(e.currentTarget.value);
                } else if (e.key === 'Escape') {
                    e.preventDefault();
                    onCancel();
                }
                // Stop keydowns from bubbling to the document-level delete/undo handlers.
                e.stopPropagation();
            }}
            className="min-w-0 flex-1 rounded border border-accent/50 bg-background px-1.5 py-0.5 text-sm text-foreground outline-none focus:border-accent"
        />
    );
}
