import AppKit
import SwiftUI

/// **The board popover** — "the one board-level surface" (03-board-ui.md § Board popover), and the
/// widget in the window's titlebar that opens it.
///
/// **Tabbed since 2026-08-07.** The symbol/name header stays at the top; below it sit the tabs —
/// **Info**, **Theme**, **Sync** — each the settings surface for one aspect of board configuration,
/// each settled in its own dedicated design session: `BoardInfoTabView`, the metrics dossier;
/// `BoardThemeTabView`, the Solid color / Pattern picker (the Background tab's original name, before
/// the same session widened it past the generated-only picker and folded manual styling back out to
/// Style… ⌥⌘S); and `BoardSyncTabView`, a standing placeholder for the future ops-based sync service.
/// The Git tab that once sat here left with the git excision (strategy/01-git-excision.md,
/// 2026-08-08).
///
/// **Selection resets to Info on every open** — the tab session's ruling, unaffected by the git
/// excision: see `BoardInfoTab`.
///
/// ### One home, deliberately
///
/// The popover has **no toolbar item** (§ Toolbar: "the window-title widget is its committed home
/// … and a second entry would muddy it"). It has exactly two ways in: the widget, and File ▸ Board
/// Info ⌘I, which is the same widget's popover reached from the keyboard (11-command-nexus.md's
/// class **C** — "the keyboard path is reachability … not bindings").
///
/// **And it is the board's only configuration home** (ruled 2026-08-07, reversing the 2026-07-31
/// popover/sheet split): setup briefly lived in a board settings sheet with its own menu command and
/// a row here pointing at it. The sheet is retired and Board ▸ Board Settings… left the menu bar with
/// it. So "one home per control" — the split's own promise — is now satisfied by there being one
/// surface, and ⌘I is the door to all of it.

// MARK: - Presentation state

/// Whether **this window's** board popover is open.
///
/// Per window rather than per board or per app, and that is the point: ⌘I has to mean "the board in
/// front", so the flag travels with the window through the focus system (`FocusedValues.boardInfo`)
/// exactly as the store does. Two board windows each hold their own and can never toggle each
/// other's — which a flag on the store could not promise the day a board is allowed two windows.
///
/// It is also why this is not in `TransientBoardState`: everything there is *board*-scoped and
/// shared with the board's card windows, and a popover living in one window's titlebar is not that.
@MainActor
@Observable
final class BoardInfoPresentation {

    var isPresented = false

    /// ⌘I's whole behaviour and the widget's alike. **Toggling, not opening**: a shortcut aimed at a
    /// disclosure that could only ever open would leave the popover with no keyboard way out.
    func toggle() {
        isPresented.toggle()
    }
}

/// The focused board window's popover, beside `FocusedValues.boardStore` — see that key for why
/// board-window menu items reach their window this way rather than through the app model.
struct FocusedBoardInfoKey: FocusedValueKey {
    typealias Value = BoardInfoPresentation
}

extension FocusedValues {
    var boardInfo: BoardInfoPresentation? {
        get { self[FocusedBoardInfoKey.self] }
        set { self[FocusedBoardInfoKey.self] = newValue }
    }
}

// MARK: - The window-title widget

/// The titlebar widget: the board's glyph beside its name, with a trailing disclosure chevron, whose
/// one job is this popover.
///
/// **Was a two-line stack** (03-board-ui.md ▸ Board popover, the window-title widget passage): a
/// git-mode board's branch sat under the name in its own smaller, secondary line. The branch line
/// left with app-managed git (strategy/01-git-excision.md, 2026-08-08); the widget is back to the
/// single title line, vertically centred beside the glyph.
///
/// **The popover is anchored to the widget itself** — it hangs from the button rather than from the
/// window or the board — which is what makes the affordance and the surface read as one thing. A
/// `.popover` rather than a hand-driven `NSPopover` because SwiftUI's is already transient (a click
/// outside dismisses it), and because the content is SwiftUI either way; the AppKit half of this is
/// only the *placement* (`boardInfoTitlebarAccessory`).
///
/// **Whole-area clickable, not just the chevron** (the card that widened this from a 20×18 chevron
/// button to the full name/chevron button): the title string sits inside the same `Button`, so a
/// click anywhere across the board's name opens the popover exactly as a click on the chevron
/// always has.
struct BoardInfoWidget: View {

    let store: BoardStore
    let recents: StyleRecents

    @Bindable var presentation: BoardInfoPresentation

    /// The widget's title string, computed fresh on every body evaluation rather than cached
    /// anywhere. That matters here specifically: `boardInfoTitlebarAccessory` builds this view
    /// exactly **once** at install, so a value read anywhere but inside `body` would freeze at the
    /// widget's birth and never see a later rename. `store.snapshot` is `@Observable`, so reading it
    /// here is what makes the title live.
    private var summary: BoardInfoTitlebarSummary {
        BoardInfoTitlebarSummary(
            snapshotTitle: store.snapshot.title.value,
            rootURL: store.rootURL
        )
    }

    var body: some View {
        Button {
            presentation.toggle()
        } label: {
            HStack(spacing: 6) {
                // The board's own glyph beside its name — the identity pair the popover header
                // states, restated where the board is named all day (2026-08-07). Read inside
                // `body` for `summary`'s reason: `icon`/`iconColor` are `@Observable` fields, so a
                // restyle from the popover repaints this widget without reinstalling it. Lenient on
                // both dimensions — an unresolvable glyph draws the board default, an unresolvable
                // tint draws the standard secondary.
                //
                // **Its own font, not the container's** (the two-line rework, 2026-08-07): at 22pt
                // it draws about twice the height `.imageScale(.small)` gave it on the container's
                // 13pt, which is what lets one glyph span the title and branch lines instead of
                // sitting beside the upper one. The number is the icon's own size rather than a
                // scale factor because that is the dimension being chosen — the block's height.
                Image(systemName: ItemSymbol.name(store.snapshot.icon, fallback: ItemSymbol.board))
                    .font(.system(size: 22))
                    .foregroundStyle(iconTint)
                    // Decorative beside the name it repeats — the button's own label already says
                    // everything VoiceOver needs (`accessibilityLabel` below).
                    .accessibilityHidden(true)

                // The identity block: the board's name. The `HStack`'s own centring puts it level
                // with the glyph.
                Text(summary.title)
                    // Styled like a titlebar title, because that is what it now stands in for
                    // (`BoardWindowHost` hides the system title display in favor of this widget).
                    .font(.system(size: 13, weight: .semibold))
                    .foregroundStyle(.primary)
                    .lineLimit(1)
                    .truncationMode(.tail)
                // Yields space to the chevron first when the title doesn't fit inside the width cap
                // below — the board's identity is the more load-bearing half of the pair.
                .layoutPriority(1)

                Image(systemName: "chevron.down")
                    .imageScale(.small)
                    .fontWeight(.semibold)
                    .foregroundStyle(.secondary)
            }
            .font(.system(size: 13))
            // A long board name must not swallow the whole titlebar — capped rather than left to
            // grow, with the truncation above doing the rest. The height keeps the accessory
            // titlebar-appropriate: no taller than a standard title bar carries.
            .frame(maxWidth: 400, alignment: .leading)
            .frame(height: 32)
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .help("Board Info")
        .accessibilityLabel(accessibilityLabel)
        .accessibilityHint("Shows board info")
        .popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
            BoardInfoView(store: store, recents: recents)
        }
    }

    /// What VoiceOver reads for the button: the board's name — `.help` keeps the shorter "Board
    /// Info" wording as the hover tooltip, and `.accessibilityHint` on the widget itself still names
    /// what the button does.
    private var accessibilityLabel: String {
        summary.title
    }

    /// The widget glyph's tint: the board's `iconColor` where it resolves, the quiet secondary
    /// otherwise — `CardFaceView`'s `iconTint` rule at the board's own level.
    private var iconTint: AnyShapeStyle {
        if let color = Palette.color(for: store.snapshot.iconColor) {
            return AnyShapeStyle(color)
        }
        return AnyShapeStyle(.secondary)
    }
}

// MARK: - The widget's strings

/// **The window-title widget's title string, as one pure function** of the board's on-disk title and
/// its folder — pulled out so the fallback rule is assertable without a widget on screen
/// (`BoardInfoTitlebarSummaryTests`).
///
/// **Title.** `AppModel.displayName(of:)` is the same rule applied to the window's actual title
/// (`BoardWindowHost.windowTitle` reads it, and `.navigationTitle` keeps feeding it to the Window
/// menu, Exposé, VoiceOver and restoration even though the title bar's own rendering of it is now
/// hidden — see `BoardWindowHost.configureWindow`): the on-disk `title`, falling back to the folder
/// name sans extension when absent or empty (01-storage-format.md § Board naming). Restated here
/// against the raw title string and `rootURL` rather than a `BoardStore`, so this seam is testable
/// with plain values and no fixture board on disk — the one duplication this card leaves behind
/// rather than reshaping `AppModel.displayName(of:)`'s signature to fit both call sites.
///
/// **Branch.** The widget once carried a second line — the board's git branch, shown on a git-mode
/// board — as a `branch` field here. That line left with app-managed git
/// (strategy/01-git-excision.md, 2026-08-08); the summary is the title alone now.
struct BoardInfoTitlebarSummary: Equatable {

    let title: String

    init(snapshotTitle: String?, rootURL: URL) {
        if let snapshotTitle, !snapshotTitle.isEmpty {
            self.title = snapshotTitle
        } else {
            self.title = rootURL.deletingPathExtension().lastPathComponent
        }
    }
}

/// The widget wearing AppKit's clothes, because SwiftUI has no way to put a view in the titlebar:
/// an `NSTitlebarAccessoryViewController` hosting the button, laid out `.leading` so it sits in the
/// title bar beside the window title rather than in the window's content.
///
/// `HostedWindowController.installTitlebarAccessory` owns the rest of the lifecycle — one per
/// window, removed on detach — for the same reason it owns the delegate proxying: the window is
/// SwiftUI's, and anything hung on it has to be taken back off.
@MainActor
func boardInfoTitlebarAccessory(
    store: BoardStore,
    recents: StyleRecents,
    presentation: BoardInfoPresentation
) -> NSTitlebarAccessoryViewController {
    let hosting = NSHostingView(
        rootView: BoardInfoWidget(
            store: store,
            recents: recents,
            presentation: presentation
        )
    )
    // The titlebar lays its accessories out by fitting size, and a hosting view that measured itself
    // as zero would be an invisible, unclickable widget. `.intrinsicContentSize` re-measures on every
    // SwiftUI update, so this starting frame only has to survive the first layout pass before the
    // widget's real content replaces it — but that first pass is exactly what a 20×18 placeholder
    // (the old chevron-only width) would clamp now that the widget's content can run out to 400pt:
    // wide enough that the widest realistic first paint is never visibly clipped before the resize.
    // The height is the widget's own two-line figure (2026-08-07), for the same reason the width is
    // generous — a first pass clamped to the old one-line 18 would paint a clipped block.
    hosting.sizingOptions = [.intrinsicContentSize]
    hosting.frame = NSRect(x: 0, y: 0, width: 200, height: 32)

    let controller = NSTitlebarAccessoryViewController()
    controller.view = hosting
    controller.layoutAttribute = .leading
    return controller
}

/// The widget's stand-in for the board window's brief life before it has a store — the loading
/// window's own name (`BoardWindowHost.loadingTitle`), styled to match the real widget's title
/// line, with no icon (the registry record carries no cached glyph, `BoardRecord`), no chevron and
/// no popover: there is nothing to open yet.
///
/// **Why the loading window needs any widget at all**, now that `KanbanApp`'s board scene declares
/// `.windowToolbarStyle(.unified(showsTitle: false))` unconditionally: that modifier is the scene's,
/// not this window's, so it cannot wait for the store the way `HostedWindowController.hideTitle()`
/// used to — every board window it creates keeps the system title hidden from its very first frame,
/// loading or not. Leaving the loading window with no name anywhere in its chrome would be exactly
/// what 02-architecture.md's "its chrome carrying the registry record's cached title" rules out, so
/// `BoardWindowHost.configureLoadingWindow` installs this in the system title's place and
/// `configureWindow` swaps it for `boardInfoTitlebarAccessory` the moment the real one exists.
@MainActor
func boardLoadingTitlebarAccessory(title: String) -> NSTitlebarAccessoryViewController {
    let hosting = NSHostingView(
        rootView: HStack(spacing: 6) {
            // A glyph-shaped gap rather than the glyph itself — there is no cached icon to draw
            // (`BoardRecord` carries none), but leaving the text flush left would have it jump right
            // by the icon's width at the swap. 22pt is `BoardInfoWidget`'s own icon size.
            Color.clear.frame(width: 22, height: 22)
            Text(title)
                // The same line the real widget draws (`BoardInfoWidget.body`), so the swap to the
                // interactive widget reads as a content change close to in place, not a jump.
                .font(.system(size: 13, weight: .semibold))
                .foregroundStyle(.primary)
                .lineLimit(1)
                .truncationMode(.tail)
        }
        .frame(maxWidth: 400, alignment: .leading)
        .frame(height: 32)
    )
    hosting.sizingOptions = [.intrinsicContentSize]
    hosting.frame = NSRect(x: 0, y: 0, width: 200, height: 32)

    let controller = NSTitlebarAccessoryViewController()
    controller.view = hosting
    controller.layoutAttribute = .leading
    return controller
}

// MARK: - Tabs

/// The popover's aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab
/// restructure): **Info** (`BoardInfoTabView`), **Theme** (`BoardThemeTabView`) — and **Sync**
/// (`BoardSyncTabView`), added 2026-08-07 as a standing placeholder: the strip claims the position
/// now, the surface says honestly that nothing lives there yet, and the future sync-service
/// workstream is where its contents get ruled. A fourth tab, Git, sat here until the git excision
/// (strategy/01-git-excision.md, 2026-08-08) removed it. The raw values are the segmented control's
/// own labels, so the strip needs no separate label function.
enum BoardInfoTab: String, CaseIterable, Identifiable {

    case info = "Info"
    case theme = "Theme"
    case sync = "Sync"

    var id: Self { self }

    // Membership is simply `allCases`, in `allCases`' own order (Info, Theme, Sync) — every board
    // carries the whole strip, unconditionally.
}

// MARK: - The popover's content

/// The symbol/name header, then the tab bar, then the selected tab's surface — one view
/// (03-board-ui.md § Board popover).
///
/// Width is the style editor's — the number that keeps the Style… popover narrow enough to sit
/// beside a card — kept through the restructure so the popover's footprint didn't wander while the
/// tabs filled in; every settled tab (Info, Theme, Sync) kept it, so whether the tabbed surface
/// ever wants its own width remains open, but nothing has needed one yet.
struct BoardInfoView: View {

    let store: BoardStore
    let recents: StyleRecents

    /// The selected tab, and **it resets to Info on every open** — a ruling, not an accident: the
    /// popover is transient and Info is the board's face, and a remembered tab could strand
    /// selection on a tab the next board doesn't offer. `@State` on the popover's content, which
    /// `BoardInfoWidget` hands `.popover` fresh on every open, is exactly that rule and nothing more.
    @State private var tab: BoardInfoTab = .info

    /// The style editor brings its own padding, so the sections around it carry the same number by
    /// hand instead of an outer padding that would double up on it — **the editor's own figure**
    /// (`StyleEditorLayout.sectionSpacing`), which is font-derived, so the popover's chrome scales
    /// with the grids inside it (10-accessibility.md's full-relative-scaling rule).
    private var inset: CGFloat {
        StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
    }

    init(
        store: BoardStore,
        recents: StyleRecents
    ) {
        self.store = store
        self.recents = recents
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            VStack(alignment: .leading, spacing: 6) {
                HStack(spacing: 6) {
                    // The board's own icon, inline with its name — the same field the embedded style
                    // editor's symbol section below writes, offered here too since a board's identity
                    // is its name *and* its glyph together (03-board-ui.md § Styling ▸ Controls). No
                    // `undo:` — the board popover has none of its own, so this reaches the board's
                    // stack exactly as the embedded editor's writes do.
                    SymbolPicker(
                        current: store.snapshot.icon.value,
                        fallback: ItemSymbol.board,
                        // Board-level: project/container/identity-flavored, not the picker's
                        // domain-agnostic default (`CuratedSymbols`' three-set split, 2026-08-09) —
                        // this well is the board's own glyph, so it reaches for the same vocabulary
                        // the style editor's grid would offer a board target.
                        symbols: CuratedSymbols.availableBoards,
                        onSelect: { name in
                            StyleCommand.apply(
                                icon: name.map { StyleChange.set($0) } ?? .remove,
                                to: .board,
                                in: store,
                                recents: recents
                            )
                        },
                        // The colour row — the picker's 4×2 tint grid, writing `iconColor` through
                        // the same funnel the glyph writes `icon`: None removes the key, a well
                        // writes the palette name (2026-08-07).
                        currentColor: store.snapshot.iconColor.value,
                        onSelectColor: { name in
                            StyleCommand.apply(
                                iconColor: name.map { StyleChange.set($0) } ?? .remove,
                                to: .board,
                                in: store,
                                recents: recents
                            )
                        }
                    )
                    .disabled(!store.acceptsBoardMutations)
                    BoardRenameField(store: store)
                }
            }
            .padding(inset)

            Divider()

            // The tab bar: a segmented control rather than a `TabView`, because the popover is a
            // compact settings surface and the segmented idiom is the macOS shape for switching
            // between a handful of peer panes inside one. The label is hidden visually but stays
            // the control's accessibility name. It iterates `allCases` — every board carries the
            // whole strip, unconditionally (03-board-ui.md ▸ Board popover).
            Picker("Board configuration", selection: $tab) {
                ForEach(BoardInfoTab.allCases) { tab in
                    Text(tab.rawValue)
                }
            }
            .pickerStyle(.segmented)
            .labelsHidden()
            .padding(.horizontal, inset)
            .padding(.top, inset)

            // The selected tab's surface — Info and Theme settled 2026-08-07, each in its own
            // dedicated session and its own file; Sync is that day's standing placeholder. Each pads
            // itself by `inset`, so the switch adds nothing.
            switch tab {
            case .info:
                BoardInfoTabView(store: store, inset: inset)
            case .theme:
                BoardThemeTabView(store: store, inset: inset)
            case .sync:
                BoardSyncTabView(inset: inset)
            }
        }
        // The style editor's popover width, taken from the editor rather than restated — the number
        // that keeps a compact settings popover narrow enough to sit beside a card. It is
        // font-derived, so the whole surface scales with the grids inside it (10-accessibility.md).
        .frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width)
    }
}

// MARK: - Rename

/// The board rename field (03-board-ui.md § Board popover; 01-storage-format.md § Board naming).
///
/// The exits are the inline editors' — Return and focus loss commit, Escape abandons — but the
/// **placeholder is the whole of the board-naming rule made visible**: an empty field shows the
/// folder name, because that is what the window title will say, and committing empty is how a user
/// asks for exactly that. "Untitled" appears nowhere; boards do not have it.
///
/// **It is not born focused**, unlike the three inline editors. Those are each opened by a gesture
/// that means "edit this now"; this one is one control among several on a configuration surface,
/// where the keyboard path is Tab-reachability rather than a caret waiting in the first field
/// (11-command-nexus.md's class **C**).
///
/// Every handler is idempotent, for `InlineTitleField`'s reason: the exits overlap by construction —
/// Return commits and then something takes focus, which fires the focus-loss commit an instant
/// later — and `BoardStore.renameBoard` skips an unchanged title, so the second call writes nothing.
private struct BoardRenameField: View {

    let store: BoardStore

    @State private var draft = ""
    @FocusState private var isFocused: Bool

    var body: some View {
        TextField(fallbackName, text: $draft)
            .textFieldStyle(.roundedBorder)
            .lineLimit(1)
            .focused($isFocused)
            .onSubmit { store.renameBoard(draft) }
            // **Escape steps outward one layer per press** (04-interactions.md ▸ Grammar): a dirty
            // field abandons its edit and keeps the popover open, and an unedited one lets the press
            // through to the popover's own dismissal. Reverting first is also what makes a dismissal
            // safe on the paths where the press never reaches here — the focus-loss commit that
            // follows sees a draft equal to what is on disk and writes nothing.
            .onKeyPress(.escape) {
                guard draft != committed else { return .ignored }
                draft = committed
                return .handled
            }
            .onChange(of: isFocused) { _, focused in
                guard !focused else { return }
                store.renameBoard(draft)
            }
            .onAppear { draft = committed }
            // A foreign rename — an agent, a hand edit, a sync — landing behind an open popover
            // updates the field, but never under the user's fingers: a draft being typed is the
            // user's, and the reload is not an edit to it (02-architecture.md § Live-reload
            // resilience, the same courtesy the inline editors get by tracking their UUID).
            .onChange(of: committed) { _, title in
                guard !isFocused else { return }
                draft = title
            }
            // The popover can be dismissed without the field ever reporting focus loss, and a
            // dismissal is a commit like any other click-away. Idempotent with the handler above.
            .onDisappear { store.renameBoard(draft) }
            // The read-only lock disables every mutating surface (02-architecture.md § The lock's
            // scope) — and the popover *stays open* under it, which is the style popover's settled
            // precedent: the lock is a condition the banner is already explaining, not a reason to
            // yank a surface away. `acceptsBoardMutations` rather than `isReadOnly` alone so this
            // field and the editor below it disable as one surface rather than in halves.
            .disabled(!store.acceptsBoardMutations)
    }

    /// The `title` on disk, as the last reload read it — "" for a board that has none.
    private var committed: String { store.snapshot.title.value ?? "" }

    /// The folder name, sans extension: what the window title shows when `title` is absent
    /// (01-storage-format.md § Board naming), and therefore the honest thing for an empty field to
    /// promise. Read off `rootURL` rather than `snapshot.rootURL` so a Finder rename absorbed
    /// mid-session shows here immediately.
    private var fallbackName: String {
        store.rootURL.deletingPathExtension().lastPathComponent
    }
}
