import AppKit
import SwiftUI
import os

/// The template chooser — File ▸ New Board… (⌥⌘N), 09-templates.md's picker.
///
/// ### Pages' shape, over real board folders
///
/// A grid of template cards, each showing a **mini per-lane preview** above its name, one selected at
/// a time, with Cancel and Choose at the bottom (03-board-ui.md § Welcome screen & templates: "a
/// Pages-style chooser with a mini per-lane preview per template"). The grid is filled by
/// `TemplateEngine.chooserRows()` — the app bundle's `Templates/` folder, then the user store — each
/// entry loaded through the ordinary `BoardLoader`, so name, blurb, icon and preview all come off the
/// template board's own `index.md` rather than from a Swift catalog.
///
/// **The order is 09's, and it is the engine's** (09 ▸ Storage: "bundled templates by
/// `template.order`, then keyed user templates by `template.order`, then keyless user boards last,
/// sorted by display name"). This view renders the list it is handed and never re-sorts it.
///
/// ### One bad template never fails the chooser
///
/// A user-store folder the loader rejects is **listed anyway** — by folder name, marked unloadable,
/// carrying the loader's fail-fast specifics, not previewable and not choosable (09 ▸ Why this
/// format). The store is hand-editable, so a malformed board there is one edit away; a chooser that
/// refused to open, or that silently dropped the row, would leave the user with no way to see which
/// folder is broken or why. Selecting the row shows the loader's own sentence where a blurb would be.
///
/// ### Reveal in Finder, and how fresh the list is
///
/// 09 keeps the app-side store honest with "a **Reveal in Finder** affordance in the
/// template chooser": the button beside the header, which **creates the store and then reveals it**
/// (`TemplateEngine.createUserStore`) — the store's two minters are Save as Template and this, so a
/// user who has never saved one still gets a folder to drop a board into rather than a Finder window
/// full of nothing.
///
/// **The store is not watched** — 09 asks for a Reveal affordance, not a live folder — so the list is
/// re-read on every presentation and again whenever the app comes back to the front. The second is
/// the Reveal round trip made honest: the user reveals the folder, drops a board in, comes back, and
/// the row is there. A folder dropped in with this window already frontmost appears on the next
/// activation or the next opening, which is the documented minimum rather than an oversight.
///
/// ### Choosing is three steps, and the middle one is a save panel
///
/// Choose runs `NSSavePanel`, instantiates into the chosen location, and opens the result. The panel
/// is not ceremony: a sandboxed app cannot write anywhere the user has not pointed at, so the panel
/// *is* how a new board gets a location it is allowed to occupy (09 files this under "seed the save
/// panel's suggested name"). The chooser stays open if the panel is cancelled — a cancelled location
/// is not a cancelled choice.
///
// m9-templates: the in-progress row with Cancel that copy-shaped work is owed (02-architecture.md
// § The banner surface) still has nowhere to live here — this window has no banner surface, and the
// board that would host one does not exist yet. The engine takes the cancellation seam
// (`TemplateEngine.instantiate(…, isCancelled:)`) and this view runs the copy off the main actor, so
// the row has something to spin over when the window grows a strip. Save as Template, whose copy
// *does* have a board window behind it, already carries its row.
///
/// ### Failure is an alert here, deliberately
///
/// Everywhere else in the app a failed write is a banner in the window that produced it
/// (02-architecture.md § Write-failure surfacing). A failed *create* has no such window: the board
/// that would host the banner is the one that did not get created, welcome may not be open (⌥⌘N
/// works from a board window), and filing it under `AppModel.launchFailures` would put it in a
/// recents-adjacent list belonging to boards the registry knows — which this one, having never
/// existed, is not. So it is an `NSAlert`, continuing the modal conversation the user is already in
/// with the save panel, in the same sentence the banner would have used
/// (`BannerCenter.headline(for:)`) so the app has one vocabulary for a failed write rather than two.
struct TemplateChooserView: View {

    @Environment(AppModel.self) private var appModel
    @Environment(\.dismiss) private var dismiss

    /// The chooser's rows — both tiers, in 09's order, re-read on presentation and on activation
    /// (see the type's doc). `@State` rather than a computed property because discovery *loads every
    /// template board*, and a computed one would re-run that on every SwiftUI rebuild.
    @State private var rows: [TemplateRow] = []

    @State private var selection: TemplateRow.ID?

    /// Which tile holds the keyboard.
    ///
    /// **The grid's Full Keyboard Access wiring** (10-accessibility.md ▸ Full Keyboard Access: "every
    /// control — … template chooser — is Tab-reachable"). Before this the tiles were bare
    /// `onTapGesture`s: the chooser could be Tabbed as far as Cancel and Choose, but the *choice*
    /// itself was pointer-only, so a keyboard user could only ever create the default template.
    ///
    /// Focus and selection are deliberately the same thing here, unlike the style editor's grids
    /// where "selection is never implied by focus" because a well writes to disk. A tile writes
    /// nothing — it names what Choose will act on — so moving focus onto one *is* choosing it, which
    /// is how every list and icon grid on the system behaves.
    @FocusState private var focusedRow: TemplateRow.ID?

    private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "templates")

    /// The selected row, defaulting to the first — which is Basic, the bundled tier's lowest order,
    /// so the chooser always opens with something choosable in hand.
    private var selected: TemplateRow? {
        rows.first { $0.id == selection } ?? rows.first
    }

    // MARK: - Geometry
    //
    // Every figure the sheet lays out on, as a multiple of the body font — `BoardMetrics`' rule
    // applied to a window rather than to the board (10-accessibility.md ▸ Text scaling & visual
    // accommodations). At the standard 13pt body they reproduce the numbers the chooser has always
    // drawn: a 620 × 480 sheet, a 20pt inset, and tiles at least 170 points across.

    @MainActor private static var pointSize: CGFloat { BoardMetrics.bodyPointSize }

    @MainActor static var windowWidth: CGFloat { BoardMetrics.em(47.7, bodyPointSize: pointSize) }
    @MainActor static var windowHeight: CGFloat { BoardMetrics.em(37, bodyPointSize: pointSize) }
    @MainActor static var inset: CGFloat { BoardMetrics.em(1.55, bodyPointSize: pointSize) }
    @MainActor static var tileMinimumWidth: CGFloat { BoardMetrics.em(13, bodyPointSize: pointSize) }

    /// The width the grid actually gets — the sheet minus its two insets. Used only by the arrow
    /// handler, which needs a column count `.adaptive` never tells it.
    @MainActor static var gridWidth: CGFloat { windowWidth - 2 * inset }

    var body: some View {
        VStack(spacing: 0) {
            header
            Divider()
            grid
            Divider()
            footer
        }
        // Font-derived, like every other frame in the app (10-accessibility.md ▸ Text scaling: "no
        // fixed point sizes"). This is a *fixed* sheet — the user cannot resize their way out of a
        // clipped one — so a 620×480 literal would put the header's two lines and the footer's blurb
        // outside the window at a large system text size.
        .frame(width: Self.windowWidth, height: Self.windowHeight)
        .task {
            rescan()
            // Returning to the foreground is when a folder dropped into the revealed store becomes
            // this window's problem — `ClipboardStore`'s activation observer, in the shape a view can
            // hold: the sequence ends with the task, which ends with the window.
            for await _ in NotificationCenter.default.notifications(named: NSApplication.didBecomeActiveNotification) {
                rescan()
            }
        }
    }

    /// Re-reads both stores, keeping the selection if the row it named is still there.
    private func rescan() {
        rows = TemplateEngine.chooserRows()
        if let selection, !rows.contains(where: { $0.id == selection }) {
            self.selection = nil
        }
    }

    // MARK: Header

    private var header: some View {
        HStack(alignment: .firstTextBaseline) {
            VStack(alignment: .leading, spacing: 2) {
                Text("Choose a Template")
                    .font(.title3.weight(.semibold))
                Text("Every template is an ordinary board — lanes and cards you can change afterwards.")
                    .font(.callout)
                    .foregroundStyle(.secondary)
            }

            Spacer(minLength: 16)

            // 09's honesty affordance: the store is plain board folders, and this is where the user
            // is shown that. It mints the folder on the way — see the type's doc.
            Button {
                revealUserStore()
            } label: {
                Label("My Templates", systemImage: "folder")
            }
            .help("Reveal your templates folder in the Finder. Any board folder you put there becomes a template.")
        }
        .padding(Self.inset)
    }

    // MARK: Grid

    private var grid: some View {
        ScrollView {
            LazyVGrid(
                columns: [GridItem(.adaptive(minimum: Self.tileMinimumWidth), spacing: Self.inset)],
                spacing: Self.inset
            ) {
                ForEach(rows) { row in
                    TemplateCard(row: row, isSelected: row.id == selected?.id)
                        // A double click is how a chooser is answered without reaching for a
                        // button (welcome's list convention). One recogniser branching on
                        // `PointerClick.count`, never a second two-tap one — stacked, it delays
                        // the single click by the whole double-click interval; simultaneous, it
                        // still holds clicks on a view with no drag source (`PointerClick`). The
                        // first click of the pair selects the tile, which is also what aims
                        // `choose()` at the clicked row.
                        .onTapGesture {
                            if PointerClick.count > 1 { choose() } else { selection = row.id }
                        }
                        // **Tab-reachable, and a button to the accessibility tree** — the tile is
                        // the chooser's one act of choosing, so it has to be a control rather than a
                        // decorated rectangle that happens to answer clicks (10-accessibility.md ▸
                        // Full Keyboard Access).
                        .focusable()
                        .focused($focusedRow, equals: row.id)
                        .accessibilityAddTraits(row.id == selected?.id ? [.isButton, .isSelected] : [.isButton])
                        // Space picks the focused tile — the keyboard face of the single click above.
                        // Return is deliberately *not* handled here: it is the sheet's default action
                        // (Choose), and a tile that swallowed it would leave a keyboard user focused
                        // on their choice with no way to answer the chooser.
                        .onKeyPress(.space) {
                            selection = row.id
                            return .handled
                        }
                }
            }
            .padding(Self.inset)
            // The arrows walk the tiles — `StyleWellGrid`'s handler on the container, for its
            // reason: a focused control does not consume arrow keys, so the press bubbles here and
            // moving focus is all it does.
            .onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in
                move(press.key)
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
        .background(Color(nsColor: .controlBackgroundColor))
        // Focus *is* selection in this grid (see `focusedRow`), so the two are kept in step in one
        // direction only: moving focus names the choice, and a pointer click that named a choice
        // leaves focus alone rather than yanking it out from under the keyboard.
        .onChange(of: focusedRow) { _, focused in
            guard let focused else { return }
            selection = focused
        }
    }

    /// One step per press, clamped at the ends rather than wrapped — `StyleWellGrid.move`'s rule,
    /// for its reason: a grid whose last row is short would wrap into a hole.
    ///
    /// The vertical step is the grid's own column count, which `.adaptive` decides at layout time
    /// and no one here can read. It is recomputed from the same two numbers the `GridItem` was built
    /// from, so ↑/↓ land a row away rather than an arbitrary distance.
    private func move(_ key: KeyEquivalent) -> KeyPress.Result {
        guard !rows.isEmpty else { return .ignored }
        let columns = max(1, Int(Self.gridWidth / (Self.tileMinimumWidth + Self.inset)))
        let delta: Int
        switch key {
        case .leftArrow: delta = -1
        case .rightArrow: delta = 1
        case .upArrow: delta = -columns
        case .downArrow: delta = columns
        default: return .ignored
        }
        let current = rows.firstIndex { $0.id == (focusedRow ?? selected?.id) } ?? 0
        let next = min(max(0, current + delta), rows.count - 1)
        focusedRow = rows[next].id
        return .handled
    }

    // MARK: Footer

    /// The blurb — or, for an unloadable row, **the loader's own sentence**: fail-fast's specifics,
    /// unreworded, in the place the description would have been. It is the whole of what the user
    /// needs to go and fix the file.
    private var footer: some View {
        HStack(alignment: .firstTextBaseline) {
            if let unloadable = selected?.unloadable {
                Label(BannerCenter.headline(for: unloadable.error), systemImage: "exclamationmark.triangle")
                    .font(.callout)
                    .foregroundStyle(.secondary)
                    .lineLimit(2)
            } else {
                Text(selected?.template?.blurb ?? "")
                    .font(.callout)
                    .foregroundStyle(.secondary)
                    .lineLimit(2)
            }

            Spacer(minLength: 16)

            Button("Cancel", role: .cancel) { dismiss() }
                .keyboardShortcut(.cancelAction)

            Button("Choose") { choose() }
                .keyboardShortcut(.defaultAction)
                // An unloadable row "can't be instantiated or previewed" (09), which is this line.
                .disabled(selected?.template == nil)
        }
        .padding(Self.inset)
    }

    // MARK: - Reveal

    /// Creates the user store if it isn't there, then reveals it — 09's affordance, and one of the
    /// store's two minters (`TemplateEngine.createUserStore`).
    ///
    /// A store that cannot be created is logged and *still* revealed at its parent by
    /// `activateFileViewerSelecting`, which is the honest failure: something is wrong with
    /// the app-side store, and the user is standing where they can see it.
    private func revealUserStore() {
        let store = TemplateEngine.userStore
        do {
            try TemplateEngine.createUserStore(at: store)
        } catch {
            Self.logger.error("could not create the user template store: \(error.localizedDescription, privacy: .public)")
        }
        NSWorkspace.shared.activateFileViewerSelecting([store])
    }

    // MARK: - Choosing

    /// Panel, instantiate, open — and only then dismiss, so a cancelled panel leaves the chooser
    /// exactly as the user left it.
    ///
    /// The copy runs in a detached task, `DuplicateBoardCommand`'s reasoning at a smaller scale: a
    /// user template can be a real board with real attachments, and a main thread blocked inside a
    /// tree copy is a frozen window. Detached rather than a child task so its cancellation is only
    /// ever the one a Cancel affordance hands it, never something inherited.
    private func choose() {
        guard let template = selected?.template, let url = Self.chooseLocation(for: template) else { return }
        let title = TemplateEngine.documentName(of: url)

        Task { @MainActor in
            let outcome = await Task.detached(priority: .userInitiated) {
                () -> Result<URL, TemplateEngine.Failure> in
                do throws(TemplateEngine.Failure) {
                    return .success(try TemplateEngine.instantiate(template: template, to: url, title: title))
                } catch {
                    return .failure(error)
                }
            }.value

            switch outcome {
            case .success:
                dismiss()
                // The ordinary open path, so the new board joins recents, gets its bookmark, and
                // closes welcome on the way in exactly like a board opened from a row.
                appModel.openBoard(at: url)
            case .failure(.cancelled):
                // Nothing was created and nothing failed, so nothing is said — the duplicate rule.
                Self.logger.notice("template instantiation cancelled — the partial board was removed")
            case let .failure(.failed(error)):
                Self.logger.error("template instantiation failed: \(error.description, privacy: .public)")
                Self.present(error)
            }
        }
    }

    /// The save panel — where the board goes and what it is called.
    ///
    /// **The suggestion is the template's own title** (`"Basic.kanban"`, `"Bug Tracker.kanban"`) —
    /// 09 ▸ Instantiation: "seed the save panel's suggested name from the template title". Whatever
    /// the user types instead becomes the new board's `title` as well as its folder name, so the two
    /// start out matching (01-storage-format.md § Board naming). The package extension is visible and
    /// editable, because an extension-less board folder is equally legal (§ Document packaging) and
    /// deleting the suffix should therefore work rather than be silently undone.
    ///
    /// A name that already exists gets the panel's own replace prompt; agreeing to it does not delete
    /// anything (the panel never does), so the engine's refusal to clobber an existing board is what
    /// the user sees — as an alert, naming the path. That is the honest outcome: this flow is a
    /// *create*, and quietly replacing a board with an empty one is not a thing it should be able to
    /// do.
    private static func chooseLocation(for template: BoardTemplate) -> URL? {
        let panel = NSSavePanel()
        panel.nameFieldStringValue = TemplateEngine.suggestedFileName(for: template)
        panel.canCreateDirectories = true
        panel.isExtensionHidden = false
        panel.allowsOtherFileTypes = true
        panel.prompt = "Create"
        panel.message = "Choose where to keep the new board."

        guard panel.runModal() == .OK, let url = panel.url else { return nil }
        return url
    }

    private static func present(_ error: BoardWriteError) {
        let alert = NSAlert()
        alert.alertStyle = .warning
        alert.messageText = BannerCenter.headline(for: error)
        alert.informativeText = error.path
        alert.addButton(withTitle: "OK")
        alert.runModal()
    }
}

// MARK: - Template card

/// One row in the grid: its preview, its name, and the selection ring — or, for a folder that did not
/// load, the same frame with an unloadable badge where the preview would be.
///
/// The two cases share a frame deliberately: an unloadable template is **the same kind of thing** as
/// the ones beside it, one edit away from working (09), so hiding it in a separate list would say the
/// opposite of what 09 means by "still listed".
private struct TemplateCard: View {

    let row: TemplateRow
    let isSelected: Bool

    /// Increase Contrast, for the tile's frame below — 10-accessibility.md names both halves of it
    /// ("strengthens borders and the selection indicator"), and this one shape is both
    /// (`Accommodations`).
    @Environment(\.colorSchemeContrast) private var contrast

    private var pointSize: CGFloat { BoardMetrics.bodyPointSize }

    private var cornerRadius: CGFloat { BoardMetrics.em(0.6, bodyPointSize: pointSize) }

    var body: some View {
        VStack(spacing: BoardMetrics.em(0.6, bodyPointSize: pointSize)) {
            content
                .frame(height: BoardMetrics.em(7.4, bodyPointSize: pointSize))
                .frame(maxWidth: .infinity)
                .background(RoundedRectangle(cornerRadius: cornerRadius).fill(Color(nsColor: .textBackgroundColor)))
                .overlay(
                    RoundedRectangle(cornerRadius: cornerRadius)
                        .strokeBorder(isSelected ? Color.accentColor : Color(nsColor: .separatorColor),
                                      lineWidth: Accommodations.borderWidth(isSelected ? 3 : 1, contrast: contrast))
                )

            Label(row.name, systemImage: icon)
                .font(.callout)
                .labelStyle(.titleAndIcon)
                .lineLimit(1)
        }
        .contentShape(Rectangle())
        .accessibilityElement(children: .combine)
        .accessibilityLabel(row.name)
        .accessibilityHint(hint)
    }

    @ViewBuilder
    private var content: some View {
        switch row {
        case let .template(template):
            TemplatePreview(template: template)
        case .unloadable:
            // No preview, because there is no board to preview — the badge says why the tile is
            // empty rather than leaving it looking like a template with no lanes.
            VStack(spacing: 6) {
                Image(systemName: "exclamationmark.triangle")
                    .font(.title2)
                Text("Can't be read")
                    .font(.caption)
            }
            .foregroundStyle(.secondary)
        }
    }

    private var icon: String {
        switch row {
        case let .template(template): template.icon
        case .unloadable: "exclamationmark.triangle"
        }
    }

    private var hint: String {
        switch row {
        case let .template(template): template.blurb
        case let .unloadable(unloadable): BannerCenter.headline(for: unloadable.error)
        }
    }
}

/// The mini per-lane preview: one column per lane, a tinted title bar over the lane's **own cards**.
///
/// It renders **from the template's loaded `BoardModel`** (09-templates.md ▸ Why this format: "The
/// picker's mini per-lane preview renders from a real `BoardModel` via the normal loader"), and every
/// mark in it is read off that model rather than decorated in: the number of columns is the lane
/// count, each column's tint is the lane's own `iconColor` through `Palette`, and the number of card
/// shapes is how many cards the lane actually holds — templates "may contain starter cards", so a
/// board with a "How this board works" card in its first lane looks different here from one without.
///
/// Deliberately **textless**: legible lane names are not available at this size, and the point of the
/// preview is the shape of the board. A lane with no cards draws an empty column, which is the honest
/// picture of an empty lane rather than a decorative one.
private struct TemplatePreview: View {

    let template: BoardTemplate

    /// How many lanes and cards a tile can show before the marks stop being distinguishable. A
    /// template with more of either is truncated rather than shrunk to threads — the preview is an
    /// impression, and every bundled template fits inside both.
    private static let laneLimit = 6
    private static let cardLimit = 4

    private var pointSize: CGFloat { BoardMetrics.bodyPointSize }

    var body: some View {
        // Every mark is a fraction of the body font, like the tile that holds it — the preview is a
        // miniature of the board, and the board scales (10-accessibility.md's full-relative-scaling
        // rule). A fixed 5pt lane band inside a tile that grew would read as a hairline.
        HStack(alignment: .top, spacing: BoardMetrics.em(0.4, bodyPointSize: pointSize)) {
            ForEach(template.lanes.prefix(Self.laneLimit)) { lane in
                VStack(spacing: BoardMetrics.em(0.3, bodyPointSize: pointSize)) {
                    RoundedRectangle(cornerRadius: BoardMetrics.em(0.15, bodyPointSize: pointSize))
                        .fill(Self.tint(of: lane))
                        .frame(height: BoardMetrics.laneAccentBandHeight(bodyPointSize: pointSize))
                    ForEach(0 ..< min(lane.cards.count, Self.cardLimit), id: \.self) { _ in
                        RoundedRectangle(cornerRadius: BoardMetrics.em(0.25, bodyPointSize: pointSize))
                            .fill(.quaternary)
                            .frame(height: BoardMetrics.em(0.9, bodyPointSize: pointSize))
                    }
                    Spacer(minLength: 0)
                }
            }
        }
        .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
        .accessibilityHidden(true)
    }

    /// The lane's palette tint, falling back to the accent colour — `Palette` is the one place a
    /// colour name is resolved, and a lane that names none looks like the chrome default here exactly
    /// as it does on a board.
    private static func tint(of lane: Lane) -> Color {
        (Palette.color(for: lane.iconColor) ?? .accentColor).opacity(0.65)
    }
}
