import AppKit
import SwiftUI

// MARK: - The trashed lane's row

/// A **trashed lane**, as the one row the trash column gives it (03-board-ui.md § Trash, re-ruled
/// 2026-07-29 — "Lanes trash too"):
///
/// > A trashed lane is an opaque unit: one distinct dimmed row showing its title and held-card count
/// > ("Doing — 5 cards"), no styling accents, never expandable; its cards are invisible to search and
/// > not individually addressable — it restores whole or purges whole.
///
/// ### Why it is not a `CardFaceView` role
///
/// The card face has one axis — which container it is drawn in — and the trash side of it draws *a
/// card*: its stripe, its icon tint, its attachments chip, its four-line title. A trashed lane has
/// none of that to draw and must not appear to: the design asks for a **distinct** dimmed row
/// precisely so the column never reads as "these are all cards", and 03's "no styling accents"
/// forbids the stripe the face exists to paint. A third `CardFaceRole` would be a role whose every
/// branch was an absence, over a `Card` this view does not have (`TrashedLane` is deliberately not a
/// `Lane` — see its own note). So this is its own small view, and what it shares with the face — the
/// selection stroke's vocabulary, the drag dim, the marquee registration, the container-scoped click
/// funnel — it shares by calling the same store paths and the same modifiers.
///
/// ### What it does *not* have, and why each absence is a ruling
///
/// - **No expansion, no cards** — "never expandable; its cards are … not individually addressable".
///   The subtree is not in the snapshot at all, so there is nothing here that could be drawn even by
///   accident (`TrashedLane`).
/// - **No styling accents** — no left stripe, no top band, no icon tint, whatever the lane's
///   `background`/`icon`/`icon-color` say. The bytes ride along untouched for the restore; the row
///   simply does not read them.
/// - **No rename, no Style…, no width** — "everything edit-shaped is disabled on trash selections …
///   and lane width ops on lane rows" (04-interactions.md ▸ The trash). Absences rather than
///   disabled modifiers, `CardFaceRole`'s rule: the menu-bar items answer the same way through
///   `renameTarget`/`boardStyleTarget`/`LaneWidthCommands`, all of which require a `.board`
///   selection.
/// - **No Finder file drop** — the column's own delegate clears the file highlight (`TrashDrop`).
struct TrashLaneRowView: View, Equatable {

    let store: BoardStore
    let lane: TrashedLane

    /// The window's purge-alert host — this row's Delete is the **permanent** one, so it goes
    /// through the same confirmation the menu bar's does (03-board-ui.md § Trash; `CardFaceView`
    /// carries the same collaborator for the same reason).
    let confirmations: TrashConfirmations

    /// The board window's drop machinery: this row's drag is a **lane** session in the `.trash`
    /// container — the same payload a live lane header produces, which is what makes drag-restore
    /// ordinary (04-interactions.md ▸ The trash ▸ Drag-to-restore).
    let drops: BoardDropContext

    /// The strip's rubber band. The row registers its frame like a card face does — **not** so the
    /// band can sweep it (it never selects lane rows) but because the same registry is the arrows'
    /// geometry and the band's begin guard (`MarqueeTargetRegistry`).
    let marquee: MarqueeControl

    /// Whether this row is in the trash-side selection — a parameter for `CardFaceView.isSelected`'s
    /// reason, and resolved by the same hoisted read in `TrashLaneView.scrollableCards` that feeds
    /// the faces beside it, so a row and a card in one column can never disagree about what is
    /// selected.
    let isSelected: Bool

    /// The size of the selection this row belongs to — **1 when unselected**, normalized by the
    /// parent. The drag replica's fan and count badge are its only reader (`dragReplica`).
    let selectedCount: Int

    /// Increase Contrast, for the plate's borders — `CardFaceView`'s rule, so a selected row and a
    /// selected card wear the same ring at the same strength.
    @Environment(\.colorSchemeContrast) private var contrast

    /// The board's ruler (`BoardZoom`) — `CardFaceView`'s rule again, so a trash row and a card face
    /// are drawn on one scale.
    @Environment(\.boardZoom) private var zoom

    private var pointSize: CGFloat { zoom.bodyPointSize }

    /// The card plate's radius: the rows sit in one column and a row with a different corner would
    /// read as a different *kind of surface* rather than as a different kind of row. What
    /// distinguishes it is the dimming and the wording, per 03.
    private var cornerRadius: CGFloat { BoardMetrics.cardCornerRadius(bodyPointSize: pointSize) }

    /// This row's drawn width — the drag replica's, measured for `CardFaceView`'s reason.
    @State private var measuredWidth: CGFloat = 0

    /// The row's rebuild gate — `CardFaceView.==`'s twin, one level over: the lane value (a
    /// `TrashedLane` is `Equatable` down to its title and held-card count), the two selection figures
    /// the column resolves, the store and the confirmation host by identity, and the band and the
    /// drop machinery by their own equivalence tests, which exist because the window rebuilds both
    /// structs — closures and all — on every body pass.
    ///
    /// **This row had no gate until the selection came down as a parameter, and that is the reason it
    /// has one now.** Before, the column's body re-ran only when the trash's contents changed, so
    /// there was nothing worth suppressing; hoisting the selection read into
    /// `TrashLaneView.scrollableCards` made that body re-run on every selection change on the trash
    /// side, and without a gate here every row would rebuild on each one — the very shape the change
    /// exists to remove (`CardFaceView.isSelected`). Applied through `.equatable()` at the call site,
    /// exactly as the card face beside it is.
    ///
    /// Deliberately NOT compared: `@State` (per-identity, preserved across updates anyway) and
    /// `@Environment` values (SwiftUI invalidates on those itself). And, as with the face, the gate
    /// has no say over this body's remaining Observation reads — `store.transient.pendingCut`,
    /// `drops.session.isDragging` — which invalidate it directly.
    nonisolated static func == (lhs: TrashLaneRowView, rhs: TrashLaneRowView) -> Bool {
        lhs.lane == rhs.lane
            && lhs.isSelected == rhs.isSelected
            && lhs.selectedCount == rhs.selectedCount
            && lhs.store === rhs.store
            && lhs.confirmations === rhs.confirmations
            && lhs.marquee.isEquivalent(to: rhs.marquee)
            && lhs.drops.isEquivalent(to: rhs.drops)
    }

    var body: some View {
        row
            .contextMenu { menu }
            // The menu's rows as VoiceOver custom actions — "its actions are the same Delete /
            // Reveal in Finder" (10-accessibility.md ▸ Trash lane), each calling the same method its
            // menu row does so the two surfaces cannot drift.
            .accessibilityActions { actions }
    }

    /// Title and held-card count, dimmed — "one distinct dimmed row showing its title and held-card
    /// count" (03-board-ui.md § Trash).
    ///
    /// The dimming is the *whole* row's, secondary throughout: this is a thing that has been thrown
    /// away, and the column's cards beside it are the live-looking ones. **State is never
    /// colour-alone** (10-accessibility.md): the row also says "deleted lane" in its accessibility
    /// label and carries the lane glyph, so the distinction survives without the wash.
    private var row: some View {
        HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
            // The lane glyph, at the level default and **never the lane's own `icon`** — "no styling
            // accents" (03 § Trash). It says *lane*, which is the one thing the row must not be
            // mistaken about.
            Image(systemName: ItemSymbol.lane)
                .foregroundStyle(.secondary)
                .imageScale(.medium)
            Text(lane.title.value ?? AccessibilityPhrases.untitled)
                .boardFont(.body)
                .foregroundStyle(.secondary)
                .lineLimit(1)
                .truncationMode(.tail)
                .frame(maxWidth: .infinity, alignment: .leading)
            heldCount
        }
        .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
        .frame(maxWidth: .infinity, alignment: .leading)
        // Quieter than a card's plate, which is what "dimmed" is here: the cards beside it keep the
        // ordinary card plate (`BoardSurface.cardPlate`), and this row sits a step further back.
        .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.tertiary))
        .overlay(
            RoundedRectangle(cornerRadius: cornerRadius)
                .strokeBorder(plateStroke, lineWidth: plateStrokeWidth)
        )
        // The deferred cut's dim — "⌘X on a trashed lane row + ⌘V after the anchor lane restores"
        // (04-interactions.md ▸ The trash ▸ Clipboard), so a pending cut has to show here exactly as
        // it does on a card.
        .cutTreatment(of: lane.id, in: store)
        // Dragged out, the row stays visible and dims: a restore is not a removal until the write
        // lands (`TrashLaneView.renderedRows`, the card side's rule).
        .opacity(drops.session.isDragging(lane.id) ? ClipboardTreatment.dimmedOpacity : 1)
        .contentShape(Rectangle())
        // **One flattened element, never a container** — "A trashed lane is one flattened opaque
        // element — '⟨title⟩, deleted lane, N cards' — never a container: its cards are not in the
        // tree" (10-accessibility.md ▸ Trash lane). `.ignore` unconditionally: nothing in this row is
        // ever a control, because nothing here is editable.
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(AccessibilityPhrases.trashedLaneLabel(title: lane.title.value, cards: lane.heldCards))
        // The cut-pending phrase, on the row's value — the card element's rule, minus the attachment
        // and comment counts an opaque unit has no answer for (comments are card-level only, and a
        // trashed lane's cards are not individually addressable).
        .accessibilityValue(AccessibilityPhrases.cardValue(
            attachments: 0,
            comments: 0,
            isCutPending: store.transient.pendingCut.ids.contains(lane.id)
        ))
        .accessibilityAddTraits(isSelected ? [.isSelected] : [])
        // VO-Space toggles, through the same `BoardStore.click` funnel the pointer uses — the
        // ⌘-click analogue, one uniform rule whatever the element's kind (10-accessibility.md).
        .accessibilityAction { toggleSelection() }
        // The click grammar, in the **trash** container at the **lane** level: "lane rows join by
        // click grammar" (04-interactions.md ▸ The trash), and the kind travelling with the click is
        // what makes a ⌘-click from a trash card degrade to a replace rather than mixing kinds.
        //
        // `togglesOnRepeat` is deliberately false: it is the *live* lane header's behaviour
        // ("click again to unselect"), and this row is not a lane header — it is a row in a column,
        // and Finder does not deselect a row by clicking it twice.
        .onTapGesture {
            store.click(SelectionTarget(id: lane.id, kind: .lane, container: .trash), modifier: .current)
        }
        // **Double-click does nothing** — "a trashed lane row never expands" (03 § Trash), which is
        // the absence of a recogniser rather than a gesture that fires and refuses.
        .onDrag(startDrag, preview: { dragReplica })
        .onGeometryChange(for: CGSize.self) { $0.size } action: { measuredWidth = $0.width }
        .marqueeTarget(lane.id, kind: .lane, container: .trash, in: marquee.registry)
    }

    /// "Doing — 5 cards": the row's other half (03-board-ui.md § Trash), counted at load and never
    /// derived from a walked subtree (`TrashedLane.heldCards`).
    ///
    /// A plain caption rather than the lane header's capsule badge: the badge is a live lane's
    /// furniture, and this row is deliberately not one.
    private var heldCount: some View {
        Text(AccessibilityPhrases.cardCount(lane.heldCards))
            .boardFont(.caption)
            .monospacedDigit()
            .foregroundStyle(.tertiary)
            // Folded into the flattened element's label above, like the card face's chips.
            .accessibilityHidden(true)
    }

    // MARK: - The drag out

    /// The row's drag — **the restore**, and deliberately the same session a live lane header starts
    /// (04-interactions.md ▸ The trash: "Drag-to-restore follows the locality model: … a trashed lane
    /// row onto its own board's strip — is an ordinary move to the drop position. Dropped on
    /// *another* board it follows the copy default … ⌘-drag forces the true cross-board
    /// restore-move").
    ///
    /// A `.lanes` session in the `.trash` container, carrying the same `DragPayload` shape
    /// `LaneView.startLaneDrag` produces with the folder pointing into `.trash/`. Everything that
    /// makes it behave — which strip slot it proposes, which operation the badge shows, what the
    /// release writes — is then the ordinary lane machinery (`DragLocality.operation`,
    /// `BoardDropContext.commitDrop`).
    ///
    /// **Multi-drag carries the whole trash-side lane selection**, in the column's own order, and a
    /// row outside the selection drags alone — the card face's targeting rule at the other level.
    ///
    /// **A kind-blind selection can span both kinds, and the session says so** (04-interactions.md ▸
    /// The trash, ruled 2026-07-31): a `.lanes` session cannot carry the cards in it, so rather than
    /// drop them silently the flag travels and the release refuses with the notice
    /// (`DragSession.mixesKinds`) — "pickup is allowed — the selection is legal".
    ///
    /// Refused under the read-only lock and while an inline editor is focused, like every other
    /// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
    private func startDrag() -> NSItemProvider {
        guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
        let ids = draggedIDs
        let rows = store.snapshot.trashedLanes.filter { ids.contains($0.id) }
        guard !rows.isEmpty else { return NSItemProvider() }
        let mixesKinds = SelectionGrammar.mixesKinds(
            ItemReferenceSet(ids: ids, container: .trash), in: store.snapshot)

        let root = store.rootURL
        let payload = DragPayload(
            boardRoot: root,
            kind: .lanes,
            container: .trash,
            items: rows.map {
                DragPayload.Item(
                    id: $0.id.rawValue,
                    folder: ItemPath.trashLane($0.id).folder(under: root).path,
                    title: $0.title.value
                )
            }
        )
        drops.session.beginLanes(
            rows.map(\.id),
            folders: payload.folders,
            // **One unit per row.** A lane's width is layout the *opaque unit does not carry*
            // (`TrashedLane` models the row and nothing else), so the shadow spans the strip's
            // standard width; the restored lane then draws at whatever `width` its untouched
            // frontmatter still says, on the next reload.
            units: rows.map { _ in 1 },
            // **The session's container is this row's**, matching the payload's — which is what
            // routes the release to `BoardStore.restoreLanes` (the move *out* of `.trash/`) rather
            // than to `moveLanes`' strip permutation, and what the mixed-payload exit keys on.
            container: .trash,
            source: store,
            mixesKinds: mixesKinds
        )
        return payload.itemProvider()
    }

    /// What travels: the whole selection when this row is in it, else this row alone — container-
    /// and kind-scoped, since the selection is homogeneous on both axes.
    private var draggedIDs: Set<ItemID> {
        let selection = store.selection
        guard selection.container == .trash,
              selection.ids.contains(lane.id),
              selection.ids.count > 1
        else { return [lane.id] }
        return selection.ids
    }

    /// The image under the cursor: this row at its drawn width, fanned when the whole selection
    /// rides along — `CardFaceView.dragReplica`'s treatment, so a restore looks like every other
    /// drag on the board.
    ///
    /// Off `selectedCount` rather than the store, for the face's reason exactly: `.onDrag`'s preview
    /// builder is non-escaping, so a read here happens at body time and would keep this row
    /// subscribed to every selection change on the board. The `max` is belt over the parent's braces,
    /// `CardFaceView.dragReplica`'s note again.
    private var dragReplica: some View {
        let count = max(1, selectedCount)
        return ZStack {
            if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) }
            if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) }
            replicaFace
        }
        .overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
        .padding(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
    }

    /// A static rendition of the row — no gestures, no geometry observer, and crucially no marquee
    /// registration (`CardFaceView.replicaFace`'s note explains what one would steal).
    private var replicaFace: some View {
        HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
            Image(systemName: ItemSymbol.lane)
                .foregroundStyle(.secondary)
                .imageScale(.medium)
            Text(lane.title.value ?? AccessibilityPhrases.untitled)
                .boardFont(.body)
                .foregroundStyle(.secondary)
                .lineLimit(1)
                .frame(maxWidth: .infinity, alignment: .leading)
            Text(AccessibilityPhrases.cardCount(lane.heldCards))
                .boardFont(.caption)
                .monospacedDigit()
                .foregroundStyle(.tertiary)
        }
        .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
        .frame(
            width: BoardMetrics.cardReplicaWidth(measured: measuredWidth, bodyPointSize: pointSize),
            alignment: .leading
        )
        .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.tertiary))
        .dragReplicaShadow(zoom: zoom)
    }

    // MARK: - The row's two rows of menu

    /// Delete and Reveal in Finder — "its actions are the same Delete / Reveal in Finder"
    /// (10-accessibility.md ▸ Trash lane), which is the trash card's inventory exactly
    /// (11-command-nexus.md ▸ Context menus). No Open, no Rename, no Style…, no Width: the row is
    /// opaque and nothing in the trash is edit-shaped.
    @ViewBuilder
    private var menu: some View {
        Button("Delete") { requestPurge() }
            .disabled(!store.acceptsBoardMutations)

        Divider()

        Button("Reveal in Finder") { revealInFinder() }
    }

    @ViewBuilder
    private var actions: some View {
        Button("Delete") { requestPurge() }
            .disabled(!store.acceptsBoardMutations)
        Button("Reveal in Finder") { revealInFinder() }
    }

    /// The **permanent** delete, through the window's confirmation host — the alert names the
    /// freight ("Permanently delete lane 'Doing' and its 5 cards?", `TrashModel.purgePrompt`), which
    /// is the whole reason an opaque row's count is carried in the snapshot at all.
    private func requestPurge() {
        confirmations.requestTrashDelete(of: targetIDs, in: store)
    }

    private func revealInFinder() {
        NSWorkspace.shared.activateFileViewerSelecting(
            ItemPath.resolve(targetIDs, in: .trash, snapshot: store.snapshot)
                .map { $0.folder(under: store.rootURL) }
        )
    }

    /// VO-Space's landing: the ⌘-click funnel, on this row, in its own container and kind.
    private func toggleSelection() {
        store.click(
            SelectionTarget(id: lane.id, kind: .lane, container: .trash),
            modifier: .command
        )
    }

    /// What this row's menu acts on: the whole selection when this row is part of it, else this row
    /// alone — standard macOS context-menu targeting, container-scoped like the card face's.
    private var targetIDs: Set<ItemID> {
        guard store.selection.container == .trash, store.selection.ids.contains(lane.id) else {
            return [lane.id]
        }
        return store.selection.ids
    }

    // MARK: - Selection treatment

    /// The accent ring when selected, a separator hairline under Increase Contrast, nothing
    /// otherwise — `CardFaceView.plateStroke`'s three-way branch, minus the file-drop hover the
    /// trash never has.
    private var plateStroke: AnyShapeStyle {
        if isSelected {
            AnyShapeStyle(Color.accentColor)
        } else if Accommodations.drawsRestingBorder(contrast: contrast) {
            AnyShapeStyle(.separator)
        } else {
            AnyShapeStyle(.clear)
        }
    }

    private var plateStrokeWidth: CGFloat {
        Accommodations.borderWidth(isSelected ? 1.5 : 1, contrast: contrast)
    }
}
