import AppKit
import SwiftUI

/// **The board popover's Theme tab** (03-board-ui.md § Board popover ▸ Theme tab, settled 2026-08-07)
/// — reworked, same day, from the Background tab it replaces: a "Solid color" vs "Pattern" picker
/// over one shared Tone/Saturation state, rather than a manual colour grid stacked over a
/// generated-only picker.
///
/// ### No manual grid here
///
/// The `StyleEditorView` embed this tab carried at first — the palette grid, its own "Board"
/// subtitle, the None well — is gone. Manual board styling stays reachable through **Style… ⌥⌘S**
/// (`StyleEditorPopover`), the anchor every other target already uses; nothing is lost, and this tab
/// no longer says "Board" about a surface that is, self-evidently, this board's own popover.
///
/// ### Solid color and Pattern share one filters struct
///
/// `BoardThemeFilters` carries all four axes — tone, hue strategy, mesh density, saturation — but
/// Solid color only ever reads two of them (tone, saturation): a solid swatch is a `FacetsRecipe`'s
/// `primaryColor` at the filters' own tone/saturation level, seed-independent by construction
/// (`FacetsRecipe.primaryColor` never reads `seed`). Switching modes leaves tone and saturation
/// exactly where they were — a picker that reset them on every mode flip would make "Pattern, then
/// back to Solid" lose a choice nobody asked to undo.
///
/// ### One carousel, two fills
///
/// The eight-hue strip is one component in both modes: Pattern's swatches are the rendered mesh
/// previews this tab has always shown (`FacetsGenerator.render`, 384px); Solid's are flat fills of
/// the same eight hues, in the same wheel order, at the same tone/saturation. **Chevrons page it** —
/// `chevron.compact.left`/`.right`, tall and narrow, flanking the scroll window — because eight
/// swatches at this width run past the popover's edge and a strip with no other way to say "there is
/// more here" than a fade nobody can click is not a discoverable one.
///
/// Every Pattern swatch previews the exact recipe a click would apply — same filters, same seed, only
/// the pixel width differs (384 for the strip, 3072 for the file) — so "what's clicked is what lands"
/// (`FacetsGenerator`'s own claim) still holds. A Solid swatch's claim is stronger still: there is no
/// seed to differ on, so the swatch *is* the colour, exactly.

// MARK: - Mode

/// **Solid color vs Pattern** — the tab's top control (`BoardThemeTabView`). Raw values are the
/// segmented picker's own labels, so this enum needs no separate label function the way the filter
/// rows below do.
enum BoardThemeMode: String, CaseIterable, Identifiable, Hashable {
    case solid = "Solid color"
    case pattern = "Pattern"

    var id: Self { self }
}

// MARK: - Filters

/// **The Theme tab's filter state**, and the pure mapping from it (plus a hue and a seed) to a
/// `FacetsRecipe` — pulled out of the view so the default-tone rule and the recipe assembly are each
/// assertable without a popover on screen (`BoardThemeFiltersTests`).
///
/// Shared by both modes (`BoardThemeMode`): Solid color reads `tone` and `saturation` alone, Pattern
/// reads all four. A mode switch never resets this struct — that is what keeps a chosen tone and
/// saturation live across "Pattern, then Solid, then Pattern again".
struct BoardThemeFilters: Equatable {

    var tone: FacetsRecipe.Tone
    var colors: FacetsRecipe.Strategy
    var mesh: FacetsRecipe.Density
    var saturation: FacetsRecipe.Saturation

    /// The picker's opening state: mono colours, medium mesh, mid saturation always — only tone
    /// follows the system, which is `defaultTone(colorScheme:)`'s own job.
    static func initial(colorScheme: ColorScheme) -> BoardThemeFilters {
        BoardThemeFilters(
            tone: defaultTone(colorScheme: colorScheme), colors: .mono, mesh: .medium, saturation: .mid
        )
    }

    /// Light appearance opens on Tone Light, dark on Tone Dark — read once, at first appearance, so a
    /// picker opened on a dark-mode Mac starts on swatches that read correctly against the popover
    /// around them rather than ones chosen for the other appearance. `.light` covers every
    /// `ColorScheme` case but `.dark` — there is no third case today.
    static func defaultTone(colorScheme: ColorScheme) -> FacetsRecipe.Tone {
        colorScheme == .dark ? .dark : .light
    }

    /// One hue's recipe under these filters and a given seed — the whole of "click a swatch, get a
    /// board". Pattern's clicks pass a minted seed; Solid's pass `0` and never look at it
    /// (`FacetsRecipe.primaryColor` is seed-independent).
    func recipe(hue: FacetsRecipe.Hue, seed: UInt64) -> FacetsRecipe {
        FacetsRecipe(hue: hue, strategy: colors, density: mesh, tone: tone, saturation: saturation, seed: seed)
    }
}

// MARK: - The tab

struct BoardThemeTabView: View {

    let store: BoardStore

    /// The popover's own padding figure (`BoardInfoView.inset`), matching `BoardInfoTabView`'s own
    /// parameter — everything here pads by this amount instead of restating the derivation.
    let inset: CGFloat

    @Environment(\.colorScheme) private var colorScheme
    @Environment(\.colorSchemeContrast) private var contrast

    /// Solid color vs Pattern. Defaulted in `init`, not `onAppear`: unlike the tone default below,
    /// which needs the colour-scheme environment and so can only be read once the view is actually on
    /// screen, `store.snapshot` is already loaded the moment this view is built, so there is no reason
    /// to let the picker flash open on one mode and settle on another a frame later.
    @State private var mode: BoardThemeMode

    @State private var filters = BoardThemeFilters.initial(colorScheme: .light)

    /// One seed per hue, in wheel order — minted on appear and re-minted by Reroll. A filter change
    /// leaves these alone (same geometry, new treatment); Reroll is the one gesture that changes them
    /// (new geometry). Solid color never reads this — its swatches are seed-independent by
    /// construction — but the dictionary is still minted eagerly so Pattern has seeds ready the
    /// instant a user switches to it.
    @State private var seeds: [FacetsRecipe.Hue: UInt64] = [:]

    /// The carousel's Pattern previews, keyed by hue — absent until the render for the current
    /// `(filters, seeds)` pair lands, which is what the placeholder chip is for. Unused in Solid mode.
    @State private var previews: [FacetsRecipe.Hue: CGImage] = [:]

    /// Whether a Pattern swatch's 3072px render is in flight — every swatch disables and the header
    /// grows a small spinner for the duration, so a second click cannot race the first. Solid applies
    /// synchronously (there is no picture to render), so this never goes true for a Solid click.
    @State private var isApplying = false

    /// The carousel's paging position, in swatch indices — what the chevrons move and what disables
    /// them at either end of the eight-hue strip (`carousel`).
    @State private var scrollIndex = 0

    private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
    private let swatchWidth: CGFloat = 96
    private var swatchHeight: CGFloat { (swatchWidth * 10 / 16).rounded() }
    private let swatchCornerRadius: CGFloat = 6
    /// The chevrons' own width — narrow enough to read as a paging control rather than a third
    /// swatch, per `chevron.compact`'s own "tall and skinny" form.
    private let chevronWidth: CGFloat = 15
    private var lastSwatchIndex: Int { FacetsRecipe.Hue.allCases.count - 1 }

    init(store: BoardStore, inset: CGFloat) {
        self.store = store
        self.inset = inset
        // The simplest honest default: a board whose image *is* this generator's own output opens on
        // Pattern, since that is the surface that made it; every other board — no image, or a
        // hand-placed one the app does not own — opens on Solid color, which is also the mode that
        // never overwrites a foreign picture by accident.
        //
        // Two spellings answer "is it ours" since the `.backgrounds/` ruling (2026-08-09): the
        // qualified reference every current write lands at (`BoardStore.boardImageName`), and the
        // legacy bare name a board written before that ruling still carries at board root — a board
        // moved from neither location by this check, so both keep opening on Pattern.
        let image = store.snapshot.backgroundImage.value
        let generatesThisImage = image == FacetsGenerator.fileName
            || image == "\(BoardBackdrop.backgroundsFolderName)/\(FacetsGenerator.fileName)"
        _mode = State(initialValue: generatesThisImage ? .pattern : .solid)
    }

    var body: some View {
        VStack(alignment: .leading, spacing: StyleEditorLayout.wellSpacing(bodyPointSize: pointSize)) {
            modePicker

            Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 8, verticalSpacing: 6) {
                filterRow("Tone", selection: $filters.tone) { $0 == .light ? "Light" : "Dark" }
                if mode == .pattern {
                    filterRow("Colors", selection: $filters.colors, label: colorsLabel)
                    filterRow("Mesh", selection: $filters.mesh, label: meshLabel)
                }
                filterRow("Saturation", selection: $filters.saturation, label: saturationLabel)
            }

            carouselAccessories
            carousel
        }
        .padding(inset)
        .font(.callout)
        // The whole tab disables as one surface under the read-only lock — the same coarse rule
        // `StyleEditorView` applies to itself: an editor whose gestures would be refused should not
        // look available, and there is nothing here worth half-enabling.
        .disabled(!store.acceptsBoardMutations)
        .onAppear {
            filters.tone = BoardThemeFilters.defaultTone(colorScheme: colorScheme)
            if seeds.isEmpty { seeds = Self.mintSeeds() }
        }
        .task(id: previewKey) {
            await renderPreviews()
        }
    }

    // MARK: - Mode picker

    private var modePicker: some View {
        Picker("Theme", selection: $mode) {
            ForEach(BoardThemeMode.allCases) { value in
                Text(value.rawValue).tag(value)
            }
        }
        .pickerStyle(.segmented)
        .labelsHidden()
    }

    // MARK: - Filter rows

    /// One filter: a trailing-aligned label — matching the Info tab's row shape — beside a compact
    /// segmented picker. The label is hidden from VoiceOver; the picker's own title (identical text)
    /// is what it announces, so nothing is read twice.
    private func filterRow<Value>(
        _ title: String,
        selection: Binding<Value>,
        label: @escaping (Value) -> String
    ) -> some View where Value: Hashable, Value: CaseIterable, Value.AllCases: RandomAccessCollection {
        GridRow {
            Text(title)
                .foregroundStyle(.secondary)
                .gridColumnAlignment(.trailing)
                .accessibilityHidden(true)
            Picker(title, selection: selection) {
                ForEach(Array(Value.allCases), id: \.self) { value in
                    Text(label(value)).tag(value)
                }
            }
            .pickerStyle(.segmented)
            .labelsHidden()
            .controlSize(.small)
        }
    }

    private func colorsLabel(_ value: FacetsRecipe.Strategy) -> String {
        switch value {
        case .mono: "Mono"
        case .duo: "Duo"
        case .trio: "Trio"
        }
    }

    private func meshLabel(_ value: FacetsRecipe.Density) -> String {
        switch value {
        case .coarse: "Coarse"
        case .medium: "Medium"
        case .fine: "Fine"
        }
    }

    private func saturationLabel(_ value: FacetsRecipe.Saturation) -> String {
        switch value {
        case .soft: "Soft"
        case .mid: "Mid"
        case .rich: "Rich"
        }
    }

    // MARK: - Carousel accessories

    /// The spinner and the Reroll button — Reroll only matters in Pattern mode (Solid color has no
    /// geometry to reroll), so it is hidden rather than disabled in Solid mode, on the same "nothing
    /// here worth half-enabling" reasoning the tab's own `.disabled` applies.
    private var carouselAccessories: some View {
        HStack(spacing: 6) {
            Spacer()
            if isApplying {
                ProgressView()
                    .controlSize(.small)
                    .accessibilityLabel("Applying")
            }
            if mode == .pattern {
                Button {
                    reroll()
                } label: {
                    Image(systemName: "arrow.clockwise")
                }
                .buttonStyle(.borderless)
                .accessibilityLabel("New variations")
            }
        }
    }

    // MARK: - Carousel

    /// The eight-hue strip, flanked by paging chevrons — one component for both modes
    /// (`swatchFace(_:)` is the only thing that reads `mode`).
    ///
    /// **Chevrons page by index, not by offset.** `scrollIndex` names the swatch nearest the leading
    /// edge; a press moves it three hues (roughly three swatch widths, since consecutive swatches are
    /// laid out one `swatchWidth + spacing` apart) and clamps at either end of the wheel, which is
    /// also what disables a chevron that has nothing left to reveal. `ScrollViewProxy.scrollTo` — not
    /// the newer `scrollPosition(id:)` binding — because a proxy scroll is a one-shot "go there" the
    /// tracked index already drives, while the binding form exists to *report back* which item is
    /// visible, a question this carousel never asks.
    private var carousel: some View {
        ScrollViewReader { proxy in
            HStack(spacing: StyleEditorLayout.wellSpacing(bodyPointSize: pointSize)) {
                chevron(
                    systemName: "chevron.compact.left",
                    accessibilityLabel: "Earlier hues",
                    isDisabled: scrollIndex <= 0
                ) { page(by: -3, proxy: proxy) }

                ScrollView(.horizontal, showsIndicators: false) {
                    HStack(spacing: StyleEditorLayout.wellSpacing(bodyPointSize: pointSize)) {
                        ForEach(Array(FacetsRecipe.Hue.allCases.enumerated()), id: \.offset) { index, hue in
                            swatch(hue)
                                .id(index)
                        }
                    }
                }

                chevron(
                    systemName: "chevron.compact.right",
                    accessibilityLabel: "Later hues",
                    isDisabled: scrollIndex >= lastSwatchIndex
                ) { page(by: 3, proxy: proxy) }
            }
        }
    }

    /// One paging button — tall and narrow, full swatch height, vertically centered beside the scroll
    /// window. `chevron.compact.left`/`.right` are SF Symbols' own tall-skinny variants, so no custom
    /// shape is needed to get the form the design asks for.
    private func chevron(
        systemName: String,
        accessibilityLabel: String,
        isDisabled: Bool,
        action: @escaping () -> Void
    ) -> some View {
        Button(action: action) {
            Image(systemName: systemName)
                .frame(width: chevronWidth, height: swatchHeight)
                .contentShape(Rectangle())
        }
        .buttonStyle(.borderless)
        .disabled(isDisabled)
        .help(accessibilityLabel)
        .accessibilityLabel(accessibilityLabel)
    }

    /// Moves `scrollIndex` by `delta`, clamped to the strip's ends, and animates the scroll view to
    /// the swatch that lands on — a no-op past either end, which is what the chevron's own `disabled`
    /// state already promises but costs nothing to restate here.
    private func page(by delta: Int, proxy: ScrollViewProxy) {
        let newIndex = min(max(scrollIndex + delta, 0), lastSwatchIndex)
        guard newIndex != scrollIndex else { return }
        scrollIndex = newIndex
        withAnimation {
            proxy.scrollTo(newIndex, anchor: .leading)
        }
    }

    private func swatch(_ hue: FacetsRecipe.Hue) -> some View {
        Button {
            apply(hue)
        } label: {
            swatchFace(hue)
        }
        .buttonStyle(.plain)
        // Layered on top of the tab's own disabling: this half additionally freezes every swatch for
        // the one Pattern render already running, so a second click cannot race the first's write.
        // Always `false` in Solid mode, where nothing is ever in flight.
        .disabled(isApplying)
        .opacity(isApplying ? 0.6 : 1)
        .help(hue.displayName)
        .accessibilityLabel(accessibilityLabel(for: hue))
    }

    private func accessibilityLabel(for hue: FacetsRecipe.Hue) -> String {
        switch mode {
        case .solid: "\(hue.displayName) — set solid background"
        case .pattern: "\(hue.displayName) — set generated background"
        }
    }

    @ViewBuilder
    private func swatchFace(_ hue: FacetsRecipe.Hue) -> some View {
        Group {
            switch mode {
            case .solid:
                solidColor(hue)
            case .pattern:
                if let image = previews[hue] {
                    Image(decorative: image, scale: 1)
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                } else {
                    // The pending placeholder: the recipe's own primary colour, so the strip reads as
                    // "still painting this picture" rather than as a hole — and is already the right
                    // colour if the render never manages to beat a quick reroll.
                    chip(hue)
                }
            }
        }
        .frame(width: swatchWidth, height: swatchHeight)
        .clipShape(RoundedRectangle(cornerRadius: swatchCornerRadius))
        .overlay(
            RoundedRectangle(cornerRadius: swatchCornerRadius)
                .strokeBorder(.separator, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
        )
    }

    /// A Solid color swatch's fill — the filters' own tone/saturation level at this hue, seed `0` and
    /// seed-independent (`FacetsRecipe.primaryColor` never reads it): the same colour every time, for
    /// the same filters and hue, which is the whole of what makes a flat swatch honest about what
    /// clicking it applies.
    private func solidColor(_ hue: FacetsRecipe.Hue) -> Color {
        let components = filters.recipe(hue: hue, seed: 0).primaryColor.components
        return Color(red: components.red, green: components.green, blue: components.blue)
    }

    private func chip(_ hue: FacetsRecipe.Hue) -> Color {
        guard let seed = seeds[hue] else { return Color(nsColor: .textBackgroundColor) }
        let components = filters.recipe(hue: hue, seed: seed).primaryColor.components
        return Color(red: components.red, green: components.green, blue: components.blue)
    }

    // MARK: - Rendering and applying

    private struct PreviewKey: Equatable {
        var filters: BoardThemeFilters
        var seeds: [FacetsRecipe.Hue: UInt64]
    }

    private var previewKey: PreviewKey { PreviewKey(filters: filters, seeds: seeds) }

    /// The strip's eight Pattern previews, off the main actor — `FacetsGenerator.render` is pure, so
    /// this is exactly the render `applyPattern(_:)` below would do at 3072px, just smaller and for
    /// every hue at once. Re-runs whenever `previewKey` changes (`.task(id:)`), which a filter edit or
    /// a Reroll both do — the same detach-and-await shape `BoardInfoTabView`'s disk walk uses.
    ///
    /// Runs regardless of `mode`: tone and saturation are shared, so a filter edited while Solid color
    /// is showing still has to leave Pattern's previews correct for the instant the user switches back
    /// to it, and eight 384px renders are cheap enough that gating them on the visible mode would save
    /// little for the complexity it would cost.
    private func renderPreviews() async {
        previews = [:]
        let filters = self.filters
        let seeds = self.seeds
        let rendered = await Task.detached(priority: .utility) { () -> [FacetsRecipe.Hue: CGImage] in
            var rendered: [FacetsRecipe.Hue: CGImage] = [:]
            for hue in FacetsRecipe.Hue.allCases {
                guard let seed = seeds[hue] else { continue }
                if let image = FacetsGenerator.render(recipe: filters.recipe(hue: hue, seed: seed), pixelWidth: 384) {
                    rendered[hue] = image
                }
            }
            return rendered
        }.value
        // `.task(id:)` cancels this task when the key moves on, but cancellation is cooperative and
        // the detached render finishes regardless — without this gate a slow stale strip could land
        // *after* the newer key's own renders and quietly replace them.
        guard !Task.isCancelled else { return }
        previews = rendered
    }

    /// A swatch, clicked — Solid color writes a colour and clears the image subkey synchronously;
    /// Pattern renders and writes a picture off the main actor, exactly as this tab always has.
    private func apply(_ hue: FacetsRecipe.Hue) {
        switch mode {
        case .solid:
            applySolid(hue)
        case .pattern:
            applyPattern(hue)
        }
    }

    /// The Solid color half: no render, no detour off the main actor — just the colour this hue's
    /// level names, through the one gesture every solid background lands through
    /// (`BoardStore.applySolidBackground`). A refused write under the lock leaves the board exactly as
    /// it was; the write path's own banners cover that half.
    private func applySolid(_ hue: FacetsRecipe.Hue) {
        let colorHex = filters.recipe(hue: hue, seed: 0).primaryColorHex
        _ = store.applySolidBackground(colorHex: colorHex)
    }

    /// The Pattern half: the same recipe the preview showed, rendered at the file's own width and
    /// written through the one gesture every generated background lands through
    /// (`BoardStore.applyGeneratedBackground`). Failures — a `nil` render, a refused write under the
    /// lock — leave the board exactly as it was; the write path's own banners cover the write half.
    private func applyPattern(_ hue: FacetsRecipe.Hue) {
        guard let seed = seeds[hue] else { return }
        let recipe = filters.recipe(hue: hue, seed: seed)
        isApplying = true
        Task {
            let data = await Task.detached(priority: .userInitiated) {
                FacetsGenerator.pngData(recipe: recipe, pixelWidth: 3072)
            }.value
            if let data {
                _ = store.applyGeneratedBackground(png: data, colorHex: recipe.primaryColorHex)
            }
            isApplying = false
        }
    }

    private static func mintSeeds() -> [FacetsRecipe.Hue: UInt64] {
        Dictionary(uniqueKeysWithValues: FacetsRecipe.Hue.allCases.map { ($0, UInt64.random(in: UInt64.min...UInt64.max)) })
    }

    private func reroll() {
        seeds = Self.mintSeeds()
    }
}

// MARK: - Hue display names

private extension FacetsRecipe.Hue {
    /// The wheel's own names, capitalized for the carousel's `.help` and accessibility label — the
    /// same eight words 03-board-ui.md's faceted-gallery notes use.
    var displayName: String {
        switch self {
        case .clay: "Clay"
        case .amber: "Amber"
        case .olive: "Olive"
        case .forest: "Forest"
        case .teal: "Teal"
        case .sky: "Sky"
        case .iris: "Iris"
        case .rose: "Rose"
        }
    }
}
