import AppKit
import Observation
import SwiftUI

// MARK: - TrashLaneView

/// The trash column: the trailing, visually distinct column the board's `.trash/` entries live in
/// (03-board-ui.md § Trash, resettled 2026-07-28 — the materialized trash; lanes rejoined
/// 2026-07-29).
///
/// ### Ordinary cards, in a column that says where they are
///
/// **Its cards are ordinary cards in a special place**, so there is nothing to derive and nothing
/// to draw differently: the column renders `store.snapshot.trash` — which the loader parsed with the
/// same card parse the lanes use and sorted by `modified` descending, the trash's own order — through the very
/// same `CardFaceView` a lane renders. "A trashed card is an ordinary card in a special place —
/// search, selection, rendering, styling, and clipboard all treat it exactly like any other card"
/// (03 § Trash), and one view is the only way to make *rendering* literally true rather than
/// approximately so: a trashed card keeps its icon, its icon tint, its left-edge accent stripe, its
/// attachments chip and its four-line title, because it is the same card and the same face.
///
/// ### And its other kind, which is the opposite case
///
/// **A trashed lane is an opaque unit** — "one distinct dimmed row showing its title and held-card
/// count … no styling accents, never expandable" (03 § Trash) — so it gets its own small view
/// (`TrashLaneRowView`) rather than a third card-face role: what a card face draws is exactly what
/// the design says this row must not. The two kinds **interleave by `modified` descending**, which is
/// `BoardModel.trashEntries`' merge and not a second one written here.
///
/// Newest-first falls out of the stamp (every arrival of either kind restamps `modified` on the way
/// in — 03 § Trash, re-ruled 2026-07-31), so no rank is minted anywhere in this container.
///
/// ### What makes it a column and not a lane
///
/// The chrome 03 asks for, and nothing beyond it — "trailing (rightmost) position when shown,
/// visually distinct — dimmed/hatched header, trash SF Symbol, count badge; no new-card button; not
/// draggable, not resizable, excluded from lane reordering":
///
/// - it spans a **fixed one width unit** — no `width` frontmatter, no stepper, no resize handle, and
///   the edge drag never reaches it (`LaneLayoutMath.totalUnits(of:trashUnits:)` supplies the unit,
///   so there is no `Lane` value for any of those to act on);
/// - it is **not draggable and not reorderable** — the header carries no gesture, and it is absent
///   from the drop proposal's slot list by construction, since `BoardView` builds that from the
///   snapshot's lanes;
/// - it has **no new-card button**: nothing is created in the trash. There is deliberately no Empty
///   Trash button either — that command's home is File ▸ Empty Trash… (⇧⌘⌫), and 11-command-nexus.md
///   gives the column no pointer affordance of its own.
///
/// ### The drop it takes, and the drag it starts
///
/// **"Dropping a live card — or lane — on the shown trash deletes it"** (04-interactions.md ▸ The
/// trash, lanes extended 2026-07-29) — the drag becomes the pointer's delete gesture, and release
/// moves the dragged item(s) into `.trash/`. So the column declares an `onDrop`
/// (`TrashDropDelegate`), and it is the narrowest one on the board: a board drag from **this** board,
/// unmodified. It diverges from every other drop in one way, and the stamp is what makes the
/// divergence honest: **the shadow always takes the topmost row**, because every arrival restamps
/// `modified` and the column is sorted by it, newest first.
///
/// The drag *out* is the restore, and it is deliberately not special at either level: a trash card's
/// drag is an ordinary `.cards` session in the `.trash` container
/// (`CardFaceView.startTrashCardDrag`) and a trashed lane row's is an ordinary `.lanes` session in it
/// (`TrashLaneRowView.startDrag`), which `BoardDropContext.commitDrop` hands to the same
/// `moveCards`/`copyCards`/`receiveCards` and `restoreLanes`/`receiveLanes` every board item uses.
/// "Restoring is an ordinary move out … there is no restore-specific machinery and no Put Back"
/// (03 § Trash).
///
/// **Finder file drops stay inert** — "Finder file drops on trash cards are inert" (▸ The trash) —
/// and say so twice: the delegate clears the file highlight over the column, and the face's hover
/// treatment is board-only (`CardFaceRole`).
///
/// ### No editing in the trash
///
/// "No editing in the trash: trash cards don't open — double-click stops at selection; move it out
/// first." That is the face's `.trash` role: no double-tap recogniser, no rename editor, no Style…
/// rows — absences rather than a pile of `disabled` modifiers.
///
/// ### Accessibility
///
/// "When shown, it is the last container, labeled as Trash with its count. Its cards are ordinary
/// card elements" (10-accessibility.md, resettled 2026-07-28). The container name and value are set
/// here; the card elements inside are ordinary card faces because they *are* ordinary card faces, and
/// a trashed lane is "one flattened opaque element … never a container" (▸ Trash lane, lanes rejoined
/// 2026-07-29), which is `TrashLaneRowView`'s own doing.
struct TrashLaneView: View {

    let store: BoardStore

    /// The window's purge-alert host, threaded down rather than read from the focus system: a
    /// context menu's content is built in its own host, where a `@FocusedValue` is not reliably the
    /// board window's, and the row's Delete must raise the *same* alert the menu bar's does.
    let confirmations: TrashConfirmations

    /// The board window's drop machinery — a card's drag is an ordinary card session in the
    /// **trash** container (`DragSession`, 04-interactions.md ▸ The trash).
    let drops: BoardDropContext

    /// The strip's rubber band. The column's empty space is its third surface, in the **trash**
    /// container — "the rubber band stays on the side it started on" (04-interactions.md ▸ The
    /// trash) — and every card face registers its frame into the same registry.
    let marquee: MarqueeControl

    /// Reduce Motion, for the card transition below — 10-accessibility.md names the trash
    /// specifically ("and trash animations all get reduced variants").
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// Reduce Transparency, for the column's two washes below — "glass underlays go solid, wherever
    /// they appear" (10-accessibility.md). The trash plate and its hatched header are the board's
    /// only translucent surfaces, and they sit over a background the *user* chose (03-board-ui.md §
    /// Styling), which is exactly the case the setting exists for (`Accommodations.Wash`).
    @Environment(\.accessibilityReduceTransparency) private var reduceTransparency

    /// The window's appearance — 10-accessibility.md's runtime-contrast input, read in `body` so a
    /// light/dark flip recomputes the header's ink (`LaneView.colorScheme`'s twin, for its reason).
    @Environment(\.colorScheme) private var colorScheme

    /// The board's ruler (`BoardZoom`) — the same one the live lanes read, since the trash column has
    /// to stay their sibling at every zoom level as well as at every text size.
    @Environment(\.boardZoom) private var zoom

    /// The live body metric — this column's geometry is `LaneView`'s, derived from the same font
    /// (`BoardMetrics`), because these are the same cards in a column that must read as their
    /// sibling.
    private var pointSize: CGFloat { zoom.bodyPointSize }

    /// The lane plate's corner radius — matched to `LaneView`'s so the column reads as a sibling of
    /// the lanes rather than as a different kind of object.
    private var cornerRadius: CGFloat { BoardMetrics.laneCornerRadius(bodyPointSize: pointSize) }

    /// Between the cards — `LaneView.cardSpacing`, because these are the same cards.
    private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }

    /// The column's scroll viewport height — the rows' height *floor* (`scrollableCards`), measured
    /// for `LaneView.scrollViewportHeight`'s reason: a `ScrollView` proposes nothing along its
    /// scroll axis, so only an explicit minimum can stretch the content to fill it.
    @State private var scrollViewportHeight: CGFloat = 0

    var body: some View {
        // The strip's third body level, observed (`BoardRenderMetrics`) — DEBUG only, and a
        // `let _` because `body` is a `@ViewBuilder` and a bare `Void` call is not a view.
        #if DEBUG
        let _ = BoardRenderMetrics.countTrashLaneBody()
        #endif
        VStack(alignment: .leading, spacing: 0) {
            header
            cards
        }
        .background(
            RoundedRectangle(cornerRadius: cornerRadius)
                .fill(Accommodations.trashPlateWash(reduceTransparency: reduceTransparency).style)
        )
        // **The delete gesture's drop target**, over the whole column — the header included, since
        // the ruling is "dropping a live card on the shown trash", not on one of its rows. It
        // declares every type the board's other targets do, because single-target dispatch hands the
        // deepest region whatever session is in flight and a narrower target would strand the rest
        // (`TrashDropDelegate`).
        .onDrop(of: boardDropTypes, delegate: TrashDropDelegate(context: drops))
        // "The last container, labeled as Trash with its count" (10-accessibility.md ▸ Trash lane).
        // `.contain` rather than `.combine`: the cards inside are ordinary card elements and must
        // stay individually reachable — combining them would collapse the container the design asks
        // VoiceOver to enter.
        .accessibilityElement(children: .contain)
        .accessibilityLabel(AccessibilityPhrases.trashLabel)
        // The **rendered** counts, like a lane's: the shown trash's rows participate in the filter,
        // so a query narrows the spoken count exactly as it narrows the badge and the column itself.
        // Both kinds are named, because both are rows the VoiceOver cursor is about to walk into.
        .accessibilityValue(AccessibilityPhrases.trashValue(
            cards: renderedCards.count,
            lanes: renderedRows.count - renderedCards.count
        ))
    }

    /// The rows the column shows — **both kinds, interleaved by `modified` descending**
    /// (03-board-ui.md § Trash: "Lane rows and cards interleave in the one trash column by `modified`
    /// descending").
    ///
    /// **Shown, the trash's contents "participate in the filter exactly like any other card"**
    /// (03 § Trash — "the point of the pivot"), so the search predicate narrows this collection
    /// exactly as it narrows `LaneView.renderedCards`; a lane row matches by title alone, which is
    /// `SearchFilter`'s own rule for the opaque unit. Hidden, the column renders nothing and
    /// registers nothing, so "hidden trash is invisible to search" needs no code at all.
    ///
    /// **A row being dragged out renders here anyway**, unlike a lane's on the strip: the source
    /// stays visible in the trash while the session is in flight, dimmed by the row's own treatment,
    /// because a restore is not a removal until the write lands.
    private var renderedRows: [TrashEntry] {
        Self.rendered(store.snapshot, filter: store.searchFilter)
    }

    /// The card half of `renderedRows` — what the badge and the spoken card count read.
    private var renderedCards: [TrashEntry] {
        renderedRows.filter { $0.kind == .card }
    }

    /// `renderedRows` as a pure function of its two inputs — `LaneView.rendered`'s trash-side twin,
    /// split out for the same reason: so the rule can be pinned without a view
    /// (`SearchFilterTests`). The column, its count badge and its marquee registration all read the
    /// property, which reads this.
    ///
    /// Two of the lane's four inputs are absent, and each absence is a ruling: **no rename
    /// exemption**, because nothing renames in the trash (04 ▸ The trash), and **no drag hiding**,
    /// because a row dragged *out* of the trash stays visible in it until the write lands.
    nonisolated static func rendered(_ snapshot: BoardModel, filter: SearchFilter) -> [TrashEntry] {
        snapshot.trashEntries.filter { filter.matches($0) }
    }

    // MARK: - The delete gesture's landing

    /// Where the delete gesture's shadow run opens in this column — always the topmost row — or `nil`
    /// when no proposal names the trash, which is every other moment of the app's life.
    private var proposal: Int? {
        drops.session.trashProposal(onBoardRooted: store.rootKey)
    }

    /// The slots the column lays out: the rows, with the delete gesture's shadow run opened at the
    /// top.
    ///
    /// The run stands until the echo reload brings the real rows — the committed-overlay hold keeps
    /// the arrangement the release proposed on screen for that round trip, exactly as every other
    /// container's does (`CommittedHold`).
    private var slots: [TrashSlot] {
        var result = renderedRows.map(TrashSlot.entry)
        guard let proposal else { return result }
        let run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
        result.insert(contentsOf: run, at: min(max(0, proposal), result.count))
        return result
    }

    // MARK: - Header

    /// Dimmed and hatched, with the trash symbol, the stable "Trash" title and a count badge
    /// (03-board-ui.md § Trash ▸ Rendering).
    ///
    /// The hatching is what makes the column read as *not a lane* at a glance — the design asks for
    /// "visually distinct", and a lane's header is the surface this must not be mistaken for. Now
    /// that the cards inside wear their ordinary faces, this header is the *whole* of "you are
    /// looking at the trash", which is why it keeps its full treatment rather than softening.
    /// **State is never colour-alone** (10-accessibility.md): the header is hatched *plus* labeled.
    ///
    /// It carries no gesture at all: no selection (the column "is never selectable as a lane"), no
    /// reorder drag, no context menu.
    private var header: some View {
        HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
            Image(systemName: "trash")
                .foregroundStyle(.secondary)
                .imageScale(.medium)
            Text("Trash")
                .boardFont(.headline)
                .foregroundStyle(.secondary)
                .lineLimit(1)
                .truncationMode(.tail)
            countBadge
            Spacer(minLength: 0)
        }
        .padding(.horizontal, BoardMetrics.trashHeaderHorizontalPadding(bodyPointSize: pointSize))
        .padding(.vertical, BoardMetrics.trashHeaderVerticalPadding(bodyPointSize: pointSize))
        .background {
            UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius)
                .fill(Accommodations.trashHeaderWash(reduceTransparency: reduceTransparency).style)
                .overlay {
                    // The hatch pitch scales with the text (`BoardMetrics.trashHatchSpacing`): this
                    // is the trash's **non-colour** distinction, and a fixed 7pt pitch behind a
                    // headline twice its usual size reads as a texture rather than as hatching.
                    DiagonalHatch(spacing: BoardMetrics.trashHatchSpacing(bodyPointSize: pointSize))
                        .stroke(.quaternary, lineWidth: 1)
                        .clipShape(UnevenRoundedRectangle(
                            topLeadingRadius: cornerRadius,
                            topTrailingRadius: cornerRadius
                        ))
                }
        }
        // **The board's computed contrast ink** (10-accessibility.md; `BoardTextInk`) — the trash
        // header is the second and last place on the board where text sits on the user's colour,
        // whether that colour came from the palette grid or from a hand-written hex.
        //
        // The wash between the text and the board background does not save it: `.quaternary` at 50%
        // is a ~5%-alpha tint (`Accommodations.trashHeaderWash`), so the glyphs are effectively on
        // the board colour, and the decision is deliberately taken against that colour rather than
        // against a three-layer composite — modelling a tint that shifts the surface by a couple of
        // luminance points would add a whole layer of arithmetic to move no decision.
        //
        // **Outside the `.background`, unlike the lane's**, so the wash and the hatch flip with the
        // text: both are drawn in `.quaternary` — the same label vocabulary — and a hatch left in the
        // window's appearance over an inverted board is a dark texture on a dark surface, which
        // would quietly cost the trash the *non-colour* distinction 10-accessibility.md's
        // "state is never colour-alone" requires of it.
        .boardTextInk(BoardTextInk.scheme(
            forBoardBackground: store.snapshot.background,
            appearance: colorScheme
        ))
        // The container carries the label and the count (see `body`), so the header itself is
        // decoration for VoiceOver rather than a second element saying the same thing.
        .accessibilityHidden(true)
    }

    /// The row count — the same collection the body renders, so the badge cannot disagree with
    /// what is on screen (`LaneView.countBadge`'s rule).
    ///
    /// **Rows, not cards**: a lane row is a row in this column, and a badge reading `3` above four
    /// drawn rows would break the one property this badge has. The *breakdown* is the spoken value's
    /// (`AccessibilityPhrases.trashValue`) and the purge confirmations' (`TrashModel.Freight`), where
    /// the difference between a card and a lane's freight actually matters.
    private var countBadge: some View {
        Text("\(renderedRows.count)")
            .boardFont(.caption)
            .monospacedDigit()
            .foregroundStyle(.secondary)
            .padding(.horizontal, BoardMetrics.badgeHorizontalPadding(bodyPointSize: pointSize))
            .padding(.vertical, BoardMetrics.badgeVerticalPadding(bodyPointSize: pointSize))
            .background(Capsule().fill(.quaternary))
    }

    // MARK: - Cards

    /// The rows, scrollable, with the navigation head kept in view.
    ///
    /// **"Selection scrolls into view"** (04-interactions.md ▸ Grammar), watching the head rather
    /// than the whole selection so exactly one column responds to any one arrow — `LaneView`'s rule,
    /// on the trash side. Either kind of row can be the head: plain arrows walk them all.
    private var cards: some View {
        ScrollViewReader { proxy in
            scrollableCards
                .onChange(of: store.transient.selectionHead) { _, head in
                    guard let head, renderedRows.contains(where: { $0.id == head }) else { return }
                    proxy.scrollTo(TrashSlot.identity(of: head))
                }
        }
    }

    /// One trashed card's face — **extracted purely to keep the slot switch type-checkable**. The
    /// column's `ForEach` closure is one expression covering three slot kinds, and the face's own
    /// argument list is long enough that inlining it here pushed the whole thing past the solver's
    /// budget. Every value it needs is hoisted once per body and handed in, so nothing about the
    /// lifetime or the subscriptions changes by moving these lines.
    private func cardRow(
        _ card: Card,
        cardsFolder: URL,
        selectedIDs: Set<ItemID>,
        selectedCount: Int
    ) -> some View {
        CardFaceView(
            store: store,
            card: card,
            role: .trash(confirmations: confirmations),
            marquee: marquee,
            drops: drops,
            hero: CardHero.imageURL(for: card, inContainer: cardsFolder),
            isSelected: selectedIDs.contains(card.id),
            selectedCount: selectedIDs.contains(card.id) ? selectedCount : 1
        )
        // The value gate, `LaneView`'s rule on the trash side (`CardFaceView.==`).
        .equatable()
    }

    private var scrollableCards: some View {
        // **The trash-side selection, read once for the whole column — and this is a new subscription,
        // deliberately.** `LaneView` was already reading the selection for its header, so hoisting it
        // there was free; this body was not, and now is. The trade is the point: one column body per
        // selection change, in place of one body per *row* in it, which is what every face and row
        // here cost while each read `store.selection` for itself (`CardFaceView.isSelected`,
        // RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card bodies). A trash holds one board's
        // deletions, so even the bad side of that trade is small — but the shape is what matters, and
        // the shape is now O(1) bodies rather than O(rows).
        let selection = store.selection
        let selectedIDs = selection.container == .trash ? selection.ids : []
        let selectedCount = max(1, selectedIDs.count)
        // The trash's own folder — the container its cards resolve their heroes against, `LaneView`'s
        // hoist one container over. A trashed card is an ordinary card in a special place, so it wears
        // its hero exactly as it did in its lane; only the folder its attachments now sit under moved.
        let cardsFolder = BoardWriter.trashFolder(inBoard: store.rootURL)
        return ScrollView(.vertical) {
            // **`MasonryLayout` at one column, and a plain `VStack` deliberately not.** The trash is
            // one width unit, so its masonry is a single column — but it is the *same* layout the
            // lanes use, which is what makes the drag's make-room reflow read as positional slides
            // here exactly as it does there (DRAG-REORDER.md § The card masonry).
            //
            // Not lazy, for `LaneView`'s reason squared: every face must keep its drawn frame
            // registered in `MarqueeTargetRegistry` — the rubber band sweeps those frames and the
            // arrows navigate by them (`NavigationMath`) — and a lazy stack only builds the rows it
            // has scrolled to, so an unbuilt row is invisible to both. The constraint is affordable
            // because a trash is small: it holds one board's deletions, and Empty Trash… exists.
            MasonryLayout(columns: 1, spacing: cardSpacing) {
                ForEach(Array(slots.enumerated()), id: \.element.id) { index, slot in
                    Group {
                        switch slot {
                        case let .entry(.card(card)):
                            cardRow(
                                card,
                                cardsFolder: cardsFolder,
                                selectedIDs: selectedIDs,
                                selectedCount: selectedCount
                            )
                        case let .entry(.lane(lane)):
                            // The opaque unit's row — its own view, because "no styling accents" and
                            // "never expandable" are exactly what a card face is not
                            // (`TrashLaneRowView`, 03-board-ui.md § Trash).
                            TrashLaneRowView(
                                store: store,
                                lane: lane,
                                confirmations: confirmations,
                                drops: drops,
                                marquee: marquee,
                                isSelected: selectedIDs.contains(lane.id),
                                selectedCount: selectedIDs.contains(lane.id) ? selectedCount : 1
                            )
                            // The row's own gate (`TrashLaneRowView.==`) — worth having now that the
                            // column's body re-runs on every selection change rather than only on a
                            // reload.
                            .equatable()
                        case .shadow:
                            // The delete gesture's shadow, holding the topmost row open
                            // (04-interactions.md ▸ The trash). At the nominal card height: the cards
                            // being proposed have no face here yet to be measured.
                            DragShadow(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
                                .frame(maxWidth: .infinity)
                                .frame(height: drops.registry.nominalCardHeight)
                        }
                    }
                    // A slot is a row in this column, so it arrives and leaves in the card's dialect
                    // whatever its kind — a delete files one in, a restore or a purge takes one out,
                    // and both halves of that pair should read alike from either side of the strip.
                    // A lane row takes the same transition deliberately: what enters and leaves here
                    // is a *row*, and the strip's lane transition is about a column of the board.
                    // The transaction is the reload's, like the lanes' (`Motion.reloadAnimates`).
                    .transition(Motion.cardTransition(reduced: reduceMotion))
                    // `order`-keyed traversal, `LaneView`'s rule on the trash side. The column is
                    // one masonry column, so geometry and `order` agree here and the priority is
                    // belt over braces — written anyway because the *reason* it agrees is the
                    // column's fixed one width unit, which is a layout fact rather than a
                    // traversal guarantee, and a two-unit trash would silently read column-major.
                    .accessibilitySortPriority(Double(slots.count - index))
                    // The scroll target — `LaneView`'s rule, and outermost for its reason.
                    .id(slot.id)
                }
            }
            // The reflow that opens the topmost row for the shadow, keyed on **the drop proposal and
            // nothing else** (03-board-ui.md § Motion's narrow keys) — the trash column's own copy of
            // the rule `BoardView` applies to the strip and `LaneView` to its masonry.
            .animation(Motion.dragReflow(reduced: reduceMotion), value: proposal)
            // **The measured viewport is the rows' height floor** — this is what makes the gesture
            // surface below reach the column's full height rather than stopping where the last card
            // ends. `maxHeight: .infinity` alone could not: a `ScrollView` proposes *nothing* along
            // its scroll axis, so no frame maximum stretches the content, and a view sized to fit
            // its cards leaves the blank space beneath them un-hit-testable. "The column's gesture
            // surface is full height" (04-interactions.md ▸ The trash, settled) needs that blank
            // space to actually belong to the view the gesture below is on, so the measured floor
            // supplies it — less the plate padding, which sits inside the scroll content here and
            // would otherwise make an empty column scrollable by its own inset
            // (`LaneView.scrollableCards` is the twin, with its padding outside).
            .frame(
                maxWidth: .infinity,
                minHeight: max(0, scrollViewportHeight - 2 * BoardMetrics.lanePlatePadding(bodyPointSize: pointSize)),
                maxHeight: .infinity,
                alignment: .topLeading
            )
            .padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
            .contentShape(Rectangle())
            // The band's trash-side surface. It arms from the column's empty space, full height
            // (above) included, so a drag can start from the blank area below the last card exactly
            // as the board background allows on the live side — a drag begun on a face instead is that
            // card's drag-out, and the begin guard makes that geometric rather than a matter of
            // gesture priority (`MarqueeControl`).
            .simultaneousGesture(marquee.gesture(in: .trash))
        }
        // The floor's measurement — `LaneView`'s, on the trash side: the scroll view's own height
        // is the space the rows must cover for the empty-surface gesture above, and the content
        // growing to the floor never moves the floor.
        .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { scrollViewportHeight = $0 }
    }
}

// MARK: - What the column lays out

/// One slot of the trash column — a row of either kind, or one slot of the delete gesture's run.
///
/// `LaneSlot`'s smaller sibling — smaller by exactly one case, because nothing is ever created in the
/// trash so there is no placeholder to stand in for it — and keyed on the same principle: a slot's id
/// decides whether the echo reload reads as a *swap* or as a removal and an insertion.
///
/// The two kinds share one case (`TrashEntry`) rather than taking one each, because the column's
/// ordering question is about *rows*: a card and a lane row are interchangeable to everything here
/// except the one `switch` that draws them.
private enum TrashSlot: Identifiable {

    /// A row the snapshot's trash already holds — a card, or a trashed lane's opaque unit.
    case entry(TrashEntry)

    /// One of the drag's N shadows, holding the topmost rows open (04-interactions.md ▸ The trash).
    case shadow(index: Int)

    var id: String {
        switch self {
        case let .entry(entry): Self.identity(of: entry.id)
        // Constant per position in the run, so a run that grows or shrinks animates as slots rather
        // than blinking (`LaneSlot`'s rule).
        case let .shadow(index): "shadow:\(index)"
        }
    }

    /// A row slot's id, spelled once so `scrollTo` and the slot cannot disagree about what the
    /// scroll reader is looking for (`LaneSlot.identity(of:)`).
    static func identity(of item: ItemID) -> String { "trash:\(item.rawValue)" }
}

// MARK: - The hatch

/// Diagonal hatching for the trash header — the "dimmed/hatched" treatment 03-board-ui.md asks for,
/// drawn rather than imaged so it takes whatever width the division gives the column.
///
/// The lines start a full header-height to the left of the leading edge so the first stroke reaches
/// the top-left corner instead of beginning partway across.
///
/// The pitch is the caller's (`BoardMetrics.trashHatchSpacing`), because it scales with the text:
/// the hatch is the trash's non-colour distinction (10-accessibility.md's never-colour-alone rule),
/// and a distinction that stops reading at a large text size is not one.
private struct DiagonalHatch: Shape {
    var spacing: CGFloat

    func path(in rect: CGRect) -> Path {
        var path = Path()
        guard spacing > 0, rect.height > 0 else { return path }
        var x = rect.minX - rect.height
        while x < rect.maxX {
            path.move(to: CGPoint(x: x, y: rect.maxY))
            path.addLine(to: CGPoint(x: x + rect.height, y: rect.minY))
            x += spacing
        }
        return path
    }
}
