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.
///
/// Two sections, in the design's own order: the board rename and the embedded style editor aimed at
/// the board. A third, contextual note joins them only on a board that carries an inert `.git` — see
/// `BoardGitNote` for the wording and why base's posture there is a quiet signpost rather than a
/// standing section (12-editions.md § Base and `.git`, ruled 2026-07-27, superseding the earlier
/// reserved-slot placeholder this file once carried).
///
/// ### 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").

// 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 name — and, on a git-mode Pro board, its branch — with a
/// trailing disclosure chevron, whose one job is this popover.
///
/// **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/branch/chevron button): the title and branch strings sit inside the same
/// `Button`, so a click anywhere across the board's name — or its branch, when shown — opens the
/// popover exactly as a click on the chevron always has.
struct BoardInfoWidget: View {

    let store: BoardStore
    let recents: StyleRecents

    /// The tier and the git state this board's **session** composed with — read once, at the moment
    /// the widget is installed, and never re-derived (12-editions.md ▸ The entitlement: "a lapse
    /// never interrupts an open session"). `git` is a reference type and `@Observable`, so add-git
    /// flipping the mode, or a branch switch, redraws the widget without anything here being
    /// re-created.
    let tier: Tier
    let git: HistoryStore?

    @Bindable var presentation: BoardInfoPresentation

    /// The window's settings sheet, so the popover's git section can carry the **Board Settings…**
    /// row that opens it (03-board-ui.md ▸ Board popover: "A Board Settings… row opens the sheet —
    /// the popover's one setup affordance"). `nil` where there is no window to present a sheet on,
    /// which is the accessory-installation tests' shape and reads as a popover with no row.
    let settings: BoardSettingsPresentation?

    /// The widget's two strings, 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 or branch switch. `store.snapshot` and `git.branch` are both
    /// `@Observable`, so reading them here is what makes the title and branch live.
    private var summary: BoardInfoTitlebarSummary {
        BoardInfoTitlebarSummary(
            snapshotTitle: store.snapshot.title.value,
            rootURL: store.rootURL,
            tier: tier,
            mode: git?.mode ?? .none,
            branch: git?.branch
        )
    }

    var body: some View {
        Button {
            presentation.toggle()
        } label: {
            HStack(spacing: 4) {
                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 branch string and chevron first when the two don't both
                    // fit inside the width cap below — the board's own name is the more load-bearing
                    // half of the pair.
                    .layoutPriority(1)

                if let branch = summary.branch {
                    Text("—")
                        .foregroundStyle(.secondary)
                    Text(branch)
                        .foregroundStyle(.secondary)
                        .lineLimit(1)
                        .truncationMode(.tail)
                }

                Image(systemName: "chevron.down")
                    .imageScale(.small)
                    .fontWeight(.semibold)
                    .foregroundStyle(.secondary)
            }
            .font(.system(size: 13))
            // A long board name (or branch) must not swallow the whole titlebar — capped rather
            // than left to grow, with the truncation above doing the rest. Height stays the
            // original chevron's, which is what keeps the accessory titlebar-appropriate.
            .frame(maxWidth: 400, alignment: .leading)
            .frame(height: 18)
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .help("Board Info")
        .accessibilityLabel(accessibilityLabel)
        .accessibilityHint("Shows board info")
        // Populates the branch line the moment a git-mode board's window opens, rather than waiting
        // on the popover's own read (`BoardGitControls`'s `.task`, which only runs once the popover
        // has actually been opened once). The widget is on screen from the start, so it is the
        // earlier honest place to ask; `refreshBranch()` is already a no-op outside git mode, so this
        // costs nothing on the other four postures.
        .task { await git?.refreshBranch() }
        .popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
            BoardInfoView(store: store, recents: recents, tier: tier, git: git, settings: settings)
        }
    }

    /// What VoiceOver reads for the button, now that it says more than "Board Info": the board's
    /// name, plus the branch when the widget is showing one — `.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 {
        guard let branch = summary.branch else { return summary.title }
        return "\(summary.title), branch \(branch)"
    }
}

// MARK: - The widget's strings

/// **The window-title widget's two strings, as one pure function** of the board's on-disk title, its
/// folder, and the session's git posture — pulled out so the fallback rule and the branch-visibility
/// rule are each assertable without a widget on screen (`BoardInfoTitlebarSummaryTests`), the same
/// reason `BoardGitSection.resolve` exists one level down in this file.
///
/// **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.** Shown only when the board is actually git-mode under Pro — `tier == .pro && mode ==
/// .git` with a non-`nil` branch — the same condition family `BoardGitSection.resolve`'s `.branch`
/// case covers. The free tier and an inert `.git` (mode `.none` or `.repoNested`) show no branch;
/// neither does a git-mode board whose branch has not been read yet (`HistoryStore.branch` starts
/// `nil` until `refreshBranch()` answers, which the widget's own `.task` kicks off at open).
struct BoardInfoTitlebarSummary: Equatable {

    let title: String
    let branch: String?

    init(snapshotTitle: String?, rootURL: URL, tier: Tier, mode: BoardGitMode, branch: String?) {
        if let snapshotTitle, !snapshotTitle.isEmpty {
            self.title = snapshotTitle
        } else {
            self.title = rootURL.deletingPathExtension().lastPathComponent
        }
        self.branch = (tier == .pro && mode == .git) ? branch : nil
    }
}

/// 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
/// `tier`/`git` default to the free tier's posture — a popover with no git section at all — and
/// `settings` to no sheet, so that a caller with no session in hand (the accessory-installation
/// tests, which are about AppKit plumbing rather than about git) describes a board honestly rather
/// than by accident. The app's own call site passes the session's values explicitly.
func boardInfoTitlebarAccessory(
    store: BoardStore,
    recents: StyleRecents,
    tier: Tier = .free,
    git: HistoryStore? = nil,
    presentation: BoardInfoPresentation,
    settings: BoardSettingsPresentation? = nil
) -> NSTitlebarAccessoryViewController {
    let hosting = NSHostingView(
        rootView: BoardInfoWidget(
            store: store,
            recents: recents,
            tier: tier,
            git: git,
            presentation: presentation,
            settings: settings
        )
    )
    // 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.
    hosting.sizingOptions = [.intrinsicContentSize]
    hosting.frame = NSRect(x: 0, y: 0, width: 200, height: 18)

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

// MARK: - The popover's content

/// Two sections and, on a `.git`-bearing board, a contextual note — one view (03-board-ui.md §
/// Board popover; 12-editions.md § Base and `.git`).
///
/// Width is the style editor's — 268 points, the number that keeps the Style… popover narrow enough
/// to sit beside a card — so the embedded editor lays out here exactly as it does at its other two
/// anchors rather than being stretched by a container with its own opinion.
struct BoardInfoView: View {

    let store: BoardStore
    let recents: StyleRecents
    let tier: Tier
    let git: HistoryStore?
    let settings: BoardSettingsPresentation?

    /// **The popover's own dismissal**, used by exactly one control: the Board Settings… row, whose
    /// job is to close this surface and open the sheet. The popover is presented by `isPresented`, so
    /// the environment action drives the same flag the widget's button does — nothing here has to be
    /// handed the widget's binding to put it down.
    @Environment(\.dismiss) private var dismiss

    /// Whether this board carries a `.git` — checked once, off disk, when the view is built (which
    /// is every time the popover opens, since `BoardInfoWidget` hands `.popover` a fresh instance).
    /// See `BoardGitNote.hasGitDirectory(at:)` for why a live-updating fact isn't needed here.
    ///
    /// **The free tier's input only.** Under Pro the section reads the session's detected mode
    /// instead — a fact settled at open, which is where 06-history-undo.md puts detection — and this
    /// stays what it always was: the one quiet question the free tier asks of a board's folder.
    private let hasGitDirectory: Bool

    /// 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,
        tier: Tier = .free,
        git: HistoryStore? = nil,
        settings: BoardSettingsPresentation? = nil
    ) {
        self.store = store
        self.recents = recents
        self.tier = tier
        self.git = git
        self.settings = settings
        // Asked only where it is the answer: under Pro the mode already knows, and a free-tier
        // board is the only one this question is for (12-editions.md ▸ The free tier and `.git`).
        self.hasGitDirectory = tier == .free && BoardGitNote.hasGitDirectory(at: store.rootURL)
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            VStack(alignment: .leading, spacing: 6) {
                sectionHeader("Title")
                BoardRenameField(store: store)
            }
            .padding(inset)

            Divider()

            VStack(alignment: .leading, spacing: 0) {
                sectionHeader("Styling")
                    .padding(.horizontal, inset)
                    .padding(.top, inset)
                // Always the board, whatever is selected. The ⌥⌘S anchor is the selection-aware one
                // ("nothing selected = the board"); this embed is the surface that exists *because*
                // the board is a style target, so it can have no other target (§ Styling ▸
                // Controls: "the board popover's target is the board itself").
                StyleEditorView(store: store, recents: recents, target: .board)
            }

            // Contextual, not standing (12-editions.md, settled 2026-07-27): an ordinary free-tier
            // board adds nothing here at all — no header, no divider, no placeholder — and the
            // popover ends at Styling, complete in itself. What a Pro board adds instead is the
            // mode-aware git section (03-board-ui.md ▸ Board popover), which is a *section*, header
            // and all, because under Pro git is a feature of the board rather than a signpost.
            gitSection
        }
        // The style editor's popover width, taken from the editor rather than restated: the embed
        // below must lay out here exactly as it does at its other two anchors, and that number is
        // now font-derived.
        .frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width)
    }

    /// The popover's closing section, whichever of the six postures this board is in — see
    /// `BoardGitSection`.
    @ViewBuilder
    private var gitSection: some View {
        switch BoardGitSection.resolve(tier: tier, mode: git?.mode ?? .none, hasGitDirectory: hasGitDirectory) {
        case .absent:
            EmptyView()

        case .proPointer:
            Divider()
            BoardGitNote()
                .padding(inset)

        case .noRepository:
            // Nothing daily to show on a board with no repository — so the section is the door and
            // its header. Add-git itself moved to the sheet with the 2026-07-31 split; what stays
            // here is the honest signpost that this board *could* have a history and where to say so.
            Divider()
            VStack(alignment: .leading, spacing: 6) {
                sectionHeader("Git")
                boardSettingsRow
            }
            .padding(inset)

        case .repoNested:
            Divider()
            VStack(alignment: .leading, spacing: 6) {
                sectionHeader("Git")
                BoardGitNestedNote()
            }
            .padding(inset)

        case .unverifiable:
            Divider()
            VStack(alignment: .leading, spacing: 6) {
                sectionHeader("Git")
                BoardGitUnverifiableNote()
            }
            .padding(inset)

        case .branch:
            Divider()
            VStack(alignment: .leading, spacing: 6) {
                sectionHeader("Git")
                if let git {
                    BoardGitControls(git: git, isEnabled: store.acceptsBoardMutations)
                }
                boardSettingsRow
            }
            .padding(inset)
        }
    }

    /// **The popover's one setup affordance** (03-board-ui.md ▸ Board popover) — the sheet's first
    /// door, the menu row being the second (11-command-nexus.md).
    ///
    /// **Shown only where the sheet is reachable** (`BoardSettingsAvailability`): a popover section
    /// describes *this board*, so a row pointing at a surface this board cannot have would be the
    /// disabled button 06 rules out one level up. The menu row is the opposite case and stays visible
    /// — a menu is an inventory of the app.
    ///
    /// **Dismiss first, then present.** The popover is transient and the sheet is not; leaving a
    /// transient surface hanging over a modal one would read as two surfaces arguing about which the
    /// user is in.
    ///
    /// Not disabled by the read-only lock: opening a configuration surface is not a mutation, and the
    /// controls inside it disable themselves (the Board Info ⌘I rule).
    @ViewBuilder
    private var boardSettingsRow: some View {
        if let settings, BoardSettingsAvailability.resolve(
            tier: tier,
            mode: git?.mode ?? .none,
            isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
        ) {
            Button("Board Settings…") {
                dismiss()
                settings.present()
            }
            .accessibilityHint("Opens the board settings sheet")
        }
    }

    /// The section titles, matching the style editor's own headers so the popover reads as one
    /// surface rather than borrowed ones.
    private func sectionHeader(_ title: String) -> some View {
        Text(title)
            .font(.subheadline.weight(.semibold))
    }
}

// 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
    }
}

// MARK: - Git

/// **What the popover's git slot is, for one board** (03-board-ui.md ▸ Board popover;
/// 06-history-undo.md ▸ Rules; 12-editions.md ▸ The free tier and `.git`) — a pure function of two
/// facts, so the posture matrix is provable without a popover on screen.
///
/// The free tier's two cases are settled 2026-07-27 and unchanged by this card: absent on an
/// ordinary board, a one-line Pro pointer on a board carrying an inert `.git`. The Pro cases are the
/// mode, one to one — and the mode-`none` and repo-nested pair is where the design is most
/// insistent: a repo-nested board gets **prose, not a disabled button**. "The option is absent
/// because it *can't* apply, and the UI should teach that rather than look broken" (06 ▸ Rules).
/// `unverifiable` (the git-detection axis) joins as a fourth Pro case, structurally identical to
/// `repoNested` but worded as its own honest prose — a denial is not a nesting.
///
/// **The 2026-07-31 popover/sheet split thinned two of these cases without removing either.** Setup
/// left the popover for the board settings sheet, so mode `none` no longer renders an action here at
/// all (the case was called `.addGit` when it did — a name that would now be describing a control
/// that lives in another file, so it is `.noRepository`), and the git-mode case lost branch creation
/// and the identity fields. What each case still *is* is a posture, which is why the matrix and its
/// test survived the move unchanged.
enum BoardGitSection: Equatable, CaseIterable {

    /// Nothing at all — the free tier's ordinary board, where "the popover is rename + style,
    /// complete in itself".
    case absent

    /// The free tier's one-line explanation of an inert `.git`, and the app's one in-context pointer
    /// to Pro (12 ▸ Tier naming).
    case proPointer

    /// Pro, mode `none`: a board that could have a history and has none. There is no daily surface
    /// for that — the section is the header and the Board Settings… row, where add-git now lives
    /// (03 ▸ Board settings sheet).
    case noRepository

    /// Pro, repo-nested: the honest explanation, no action — and no Board Settings… row either,
    /// since nothing setup-shaped can apply (`BoardSettingsAvailability`).
    case repoNested

    /// Pro, unverifiable: **not** `.repoNested` — a denied ancestor check, not a found repository
    /// (06 ▸ Rules ▸ Detection, "Denial is not absence"). Structurally identical to `.repoNested`
    /// (no action, no Board Settings… row, `BoardSettingsAvailability` false), but its own case so
    /// the view renders its own honest prose rather than the nested sentence — "unverifiable" is not
    /// "nested".
    case unverifiable

    /// Pro, git mode: the branch/source line with the **switch** picker, the abnormal-state
    /// explanation when the surface is held, and the Board Settings… row. The remote half —
    /// tracking, Pull/Push, the status badges — is 07-sync-collab.md's own card and joins this same
    /// posture.
    case branch

    static func resolve(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> BoardGitSection {
        switch tier {
        case .free:
            // Detection never runs under the free tier, so the mode is not consulted here — the one
            // question asked is whether the folder carries a `.git`, which is what the pointer is
            // about (12: "any `.git` is inert … a stray like any other, preserved verbatim").
            return hasGitDirectory ? .proPointer : .absent
        case .pro:
            switch mode {
            case .none: return .noRepository
            case .git: return .branch
            case .repoNested: return .repoNested
            case .unverifiable: return .unverifiable
            }
        }
    }
}

/// **The repo-nested explanation** (06-history-undo.md ▸ Rules), worded as the design words it:
/// short prose in place of an action, never a hidden or greyed-out add-git.
private struct BoardGitNestedNote: View {

    var body: some View {
        Text("This board lives inside a repository; Lanework leaves it to that repository.")
            .font(.caption)
            .foregroundStyle(.secondary)
            .fixedSize(horizontal: false, vertical: true)
    }
}

/// **The unverifiable explanation** (06-history-undo.md ▸ Rules ▸ Detection, "Denial is not
/// absence", ruled 2026-07-31), worded as its own honest sentence rather than borrowing
/// `BoardGitNestedNote`'s — a denied ancestor check is not a found repository, and telling a user
/// their board is nested when the truth is "couldn't check" would be a lie dressed as caution.
private struct BoardGitUnverifiableNote: View {

    var body: some View {
        Text("Lanework couldn't verify whether this board sits inside a repository, so it isn't offering to add one here.")
            .font(.caption)
            .foregroundStyle(.secondary)
            .fixedSize(horizontal: false, vertical: true)
    }
}

/// The contextual git note — **a quiet signpost, not a feature** (12-editions.md ▸ The free tier and
/// `.git`, settled 2026-07-27, carried through the one-app collapse). The free tier has no git
/// integration (that is the Pro subscription's), so this is not a grow-in-place slot the way the old
/// `BoardGitSlot` placeholder was: there is nothing here to grow. The free tier's whole git story is
/// one line, shown only when it is true — and it is **the one in-context pointer to Pro**, the second
/// of the three places the app names it (12 ▸ Tier naming; the other two are `AboutBox` and the
/// Settings Pro section).
///
/// On an ordinary board `BoardInfoView` never instantiates this type at all — the section is
/// *absent*, matching the card window's absent History section (12: "absent, no placeholder"). Only
/// a board that carries an inert `.git` (12 ▸ the inert posture: "any `.git` is inert" — the free
/// tier never reads or writes it, whether the board's own or one a lapsed subscription left behind)
/// earns this note, worded exactly as 12 rules: an honest explanation of what the folder is, named
/// exactly where the question arises, never a standing ad for Pro.
///
/// Not `private`: `hasGitDirectory(at:)` is the pure seam `BoardInfoPopoverTests.swift` pins directly
/// (a fixture board with `.git` → true, without → false), which needs it visible past this file even
/// though nothing outside `BoardInfoPopover.swift` calls it in the app itself.
struct BoardGitNote: View {

    var body: some View {
        Text("This board has a git history. Lanework Pro works with it.")
            .font(.caption)
            .foregroundStyle(.secondary)
            .fixedSize(horizontal: false, vertical: true)
    }

    /// Whether `boardRoot` carries a `.git` entry — the free tier's entire detection story, and a deliberately
    /// small one: nothing in `BoardStore`, `BoardModel`, or `BoardLoader` tracks this as a live fact
    /// today, because nothing needs it to be live. `FolderWatcher` filters `.git` out of the folder
    /// watch by design (§ .git filtering — it exists to ignore git churn), so there is no reload
    /// event this could hang off even if it wanted to; a plain, read-only `FileManager` check taken
    /// once, at the moment the popover is built, is the honest amount of machinery for a single quiet
    /// line. A `.git` added or removed while the popover happens to be open is stale until the next
    /// open — a gap this note's own posture makes harmless, since it is a signpost, not a control.
    static func hasGitDirectory(at boardRoot: URL) -> Bool {
        FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path)
    }
}
