import SwiftUI
import UniformTypeIdentifiers

// MARK: - CardWindowView

/// The card window's content: **three componentized panes** — a wide body pane leading, the comments
/// pane in the middle when it is shown, and the narrow attributes sidebar trailing
/// (05-card-window.md ▸ Composition, re-composed 2026-07-29).
///
/// ### The composition arranges; the panes do not know about each other
///
/// > each an independent component with its own scroll, arranged by the window's layout rather than
/// > wired to each other; componentization is the rule, so the comments pane mounts beside the body
/// > or below it (the layout option) without either pane knowing which.
///
/// That is enforced here by there being nothing to enforce: `CardCommentsPane` takes no layout
/// parameter and the body column takes none either. This view puts one of them in a frame; the
/// arithmetic behind the frame is `CommentsMount`, which is pure and therefore checkable.
///
/// The sidebar is unchanged by any of it — it is a third pane, it has always been fixed-width, and
/// the resize flex still goes to the body and never to the two fixed panes.
///
/// ### What this milestone builds, and what it deliberately does not
///
/// The *shell*: the two columns, their scrolling, the sidebar's fixed width, and the read-only
/// renderings of what the loader already knows — the card's title and its created/modified line —
/// over the body column's two live surfaces (Preview and Edit, `CardBodySurface`), with the
/// raw-source outlet swapping the pair of columns out entirely when it is active. Everything that
/// reads or writes beyond that is later work and is marked where it lands:
///
/// - the title as an editable field (commit on Return / focus loss, Escape abandons),
/// - the sidebar's History section, whose place in the stack is reserved and whose content waits on
///   a git mode to be honest about (`historySlot`).
///
/// The placeholders are structural rather than apologetic: the sidebar's inventory and its order are
/// settled (05 ▸ The attributes sidebar), so the shell states them and the sections fill in
/// underneath without the composition moving.
///
/// ### The width rule, in one line
///
/// The sidebar has a fixed width from `CardWindowMetrics`; the body column takes `.infinity`. That
/// is the whole of "the window's resize flex goes to the body" — no split view, no stored divider
/// position, nothing for a drag to disagree with.
///
/// ### Why the title no longer scrolls with the body
///
/// The body surface is a hosted `NSScrollView` (`CardBodySurface`), because ⌘F's find bar lives in
/// one — "Edit ▸ Find (⌘F) is find-in-text here … the standard find bar" (05 ▸ Preview). A scroll
/// view inside a scroll view is a scroll view that fights, so the column's header — the title and
/// its created/modified line — sits above the body's scroller rather than inside it. 05 fixes the
/// column's *order* ("Body column, top to bottom") and the columns' independent scrolling, and both
/// still hold; which of the two things scrolls the title away was never settled, and pinning the
/// card's name over its own body is the better reading of a window whose subtitle already follows it.
struct CardWindowView: View {

    let card: Card
    /// The board this card belongs to.
    ///
    /// The one place in this window a whole store is handed to a view rather than a narrow seam, and
    /// the sidebar is why: the Style section hosts the **shared** style editor, whose API is
    /// store-shaped by design (it reads the target set's current values and writes through the one
    /// `applyStyle` bracket every anchor shares), and the Actions section's Delete is the store's own
    /// move-to-trash. Routing either through a closure of this window's own would be a second
    /// card-styling or card-deleting path to keep in step with the first — exactly what "one
    /// component, one behavior" and "exactly the ⌫ delete" forbid.
    let store: BoardStore
    /// The app-wide quick-style recents the embedded editor feeds (03-board-ui.md ▸ Styling ▸
    /// Controls) — app state, not board state, which is why it arrives beside the store rather than
    /// on it.
    let recents: StyleRecents
    /// The card's folder on disk — what relative images and links in the body resolve against
    /// (05 ▸ Preview). `nil` only where a caller has no board root to build it from.
    let cardFolder: URL?
    /// This window's body-column state: which mode it is in, and the find-bar hook.
    let bodyPresentation: CardBodyPresentation
    /// This window's Edit buffer. It holds the text **both** surfaces show: the editor writes into
    /// it, Preview renders it, and `adopt(diskBody:)` below is where the snapshot gets a say —
    /// which is exactly the point at which dirty-buffer-wins is decided.
    let bodySession: CardBodyEditSession
    /// This window's raw-source outlet. While it is active the two columns are gone entirely — see
    /// `body`.
    let rawSource: CardRawSourceSession
    /// Whether a checkbox may write — `false` under the board's read-only lock.
    let isEditable: Bool
    /// This window's attachments section: the listing, the selection, and the two writes it starts
    /// (05 ▸ Attachments).
    let attachments: CardAttachments
    /// This window's comments pane: the thread, the composer's draft buffer, and the one open inline
    /// edit session (05 ▸ The comments column). It lives on the window's *session* so the close flush
    /// can reach it, which is why it arrives here rather than being made here.
    let comments: CardComments
    /// This window's thumbnail memory, held by the host so it outlives a snapshot.
    let thumbnails: AttachmentThumbnailCache
    /// **This window's undo stack** (13-native-undo.md ▸ Rules ▸ two levels). It arrives for exactly
    /// one consumer — the Style section, the one sidebar anchor that *writes* — because a gesture
    /// issued in this window registers on this window's stack. It lives on the window's session so
    /// the close can fold it, which is why it arrives here rather than being made here.
    let undo: CardWindowUndo
    /// **This card's commit trail** (05 ▸ History), or `nil` on every board with no app-managed git —
    /// the free tier, mode none, and repo-nested boards. The `nil` *is* the section's absence rule;
    /// see `historySlot`.
    let history: CardHistory?
    /// The whole-window file drop (05 ▸ Attachments: "the drop surface remains the **whole
    /// window**"). `nil` only where a caller has no store to import through.
    let fileDrop: CardWindowDropDelegate?
    /// Commits a checkbox flip: the marker's byte offset in the body, and the state the user saw.
    let onToggleTask: (Int, Bool) -> Void

    /// **View ▸ Show Comments** and **View ▸ Comments Beside Body** — app-wide, persisted, read here
    /// rather than passed in (05 ▸ The comments column; ▸ Composition).
    ///
    /// `@AppStorage` because the two bits genuinely are app-wide: every open card window obeys the
    /// same pair, so a window that took them as parameters would need something above it keeping
    /// every window in step with a value that has exactly one instance. It is also what makes the
    /// menu rows' checkmarks and these panes provably the same bit (`ShowCommentsCommand`).
    @AppStorage(AppPreferences.showCommentsKey) private var showComments = true
    @AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = true

    /// The body font's point size, read once per body evaluation: every measurement in this view —
    /// the sidebar's width, both gutters, the vertical rhythm — is derived from it, so they scale
    /// together when the system text size changes.
    private var bodyPointSize: CGFloat { CardWindowMetrics.bodyPointSize }

    /// Where the comments pane sits, when it is shown at all.
    private var mount: CommentsMount { CommentsMount(besideBody: commentsBesideBody) }

    /// The two columns — **or the raw-source editor in place of both of them**.
    ///
    /// A swap rather than an overlay, which is 05 ▸ Raw source outlet's own word for it ("swaps the
    /// **entire content area — title, body, and sidebar —** for the literal on-disk `index.md`") and
    /// what the rule underneath it requires: the same frontmatter is being edited as raw text, so a
    /// sidebar still offering to restyle the card, or a title field still writing to `title`, would
    /// be two editors racing for one file. Unmounting them is the only version of "they can't fight"
    /// that cannot be got wrong later.
    ///
    /// The cost is one thing and it is accepted: the body editor's scroll position and selection do
    /// not survive a round trip through source mode, because its text view genuinely goes away. What
    /// does survive is the buffer, which is the Edit session's, not the view's — and it was flushed
    /// to disk on the way in regardless.
    var body: some View {
        content
            // **The drop surface is the whole window** (05 ▸ Attachments), which is why it hangs
            // here — outside the raw-source swap, so a file dropped while the source outlet is open
            // still imports — rather than on the attachments section it fills. The body editor lets
            // file drags through to this by declining the file types
            // (`CardBodyTextView.acceptableDragTypes`); dragged *text* never matches `.fileURL` and
            // so is never offered here at all, which is the other half of the payload split.
            .modifier(WindowFileDrop(delegate: fileDrop))
    }

    @ViewBuilder
    private var content: some View {
        if rawSource.isActive {
            // **All three panes**, comments included: "Raw Source still swaps the entire content area
            // — all panes, comments included; the raw outlet's rule is unchanged" (05 ▸ The comments
            // column). The swap encloses the whole composition below rather than any one pane, which
            // is what keeps that true as the composition grows.
            CardRawSourceView(session: rawSource, presentation: bodyPresentation)
        } else {
            HStack(spacing: 0) {
                contentPanes
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)

                Divider()

                sidebar
                    // Fixed, and the one place it comes from.
                    .frame(width: CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize))
                    .frame(maxHeight: .infinity, alignment: .top)
                    .background(.background.secondary)
            }
        }
    }

    /// The body pane and the comments pane, in whichever of the two mounts is current — or the body
    /// pane alone, when Show Comments is off.
    ///
    /// **The thread stays visible through body Edit in either mount**, and needs no rule of its own:
    /// Edit swaps the content of the body pane (`CardBodySurface`), which is *inside* the body column
    /// here, so nothing about the composition changes when the mode flips. That is the sidebar's own
    /// precedent, which 05 names when it states the rule.
    @ViewBuilder
    private var contentPanes: some View {
        if showComments {
            switch mount {
            case .beside:
                HStack(spacing: 0) {
                    bodyColumn
                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)

                    Divider()

                    commentsPane
                        // Fixed, like the sidebar: "resize flex always goes to the body, never the
                        // fixed panes" (05 ▸ Composition).
                        .frame(width: CardWindowMetrics.commentsColumnWidth(bodyPointSize: bodyPointSize))
                        .frame(maxHeight: .infinity, alignment: .top)
                }

            case .stacked:
                // The ≈3:2 split needs a height to divide, and a `GeometryReader` is the only way to
                // have one — `layoutPriority` and flexible frames express *preferences*, and this is
                // a ratio the design fixes. The body takes its share; the comments pane takes the
                // remainder, so the divider between them can never leave a gap or overlap.
                GeometryReader { proxy in
                    VStack(spacing: 0) {
                        bodyColumn
                            .frame(height: mount.bodyHeight(in: proxy.size.height))
                            .frame(maxWidth: .infinity, alignment: .topLeading)

                        Divider()

                        commentsPane
                            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                    }
                }
            }
        } else {
            bodyColumn
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
        }
    }

    private var commentsPane: some View {
        CardCommentsPane(comments: comments, cardFolder: cardFolder, thumbnails: thumbnails)
    }

    // MARK: - Body column

    /// Title, the quiet created/modified line, then the body — 05's top-to-bottom order.
    private var bodyColumn: some View {
        VStack(alignment: .leading, spacing: 0) {
            VStack(alignment: .leading, spacing: bodyPointSize * 0.5) {
                // m6-card-body: the title *field* — large and borderless, committing to frontmatter
                // on Return or focus loss, clearing to remove the `title` key, Escape abandoning to
                // the on-disk title. Read-only here; the placeholder rendering is already final.
                Text(card.title.value ?? "Untitled")
                    .font(.largeTitle)
                    // "Untitled" is a rendering, never a value (03-board-ui.md § Card face) — the
                    // same secondary treatment the face gives it.
                    .foregroundStyle(card.title.value == nil ? .secondary : .primary)
                    .textSelection(.enabled)

                if let dateLine {
                    Text(dateLine)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                        .textSelection(.enabled)
                }
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
            .padding(.top, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))

            CardBodySurface(
                // The session's text, never `card.body` directly: a dirty buffer outranks the
                // snapshot (05 ▸ Write rules) and a flushed one is ahead of it by a reload, so the
                // buffer is the truer of the two in both modes — which is also how Preview shows the
                // text that produced it the instant Edit is left.
                body: bodySession.text,
                mode: bodyPresentation.mode,
                cardFolder: cardFolder,
                presentation: bodyPresentation,
                session: bodySession,
                isTaskToggleEnabled: isEditable,
                onToggleTask: onToggleTask
            )
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
        // **Dirty-buffer-wins, applied on every snapshot** (05 ▸ Write rules): the session takes
        // disk's word for what the file says, and takes it into the editor only when the buffer has
        // nothing unsaved. `initial: true` is also how the buffer is filled at all — a window opens
        // by adopting its card's body.
        .onChange(of: card.body, initial: true) { _, body in
            bodySession.adopt(diskBody: body)
        }
        // **The opening rule, applied once** (05 ▸ Mode grammar): a card opens in Preview unless its
        // body is empty, in which case it opens straight into Edit. `openIfNeeded` is what makes it
        // "once" — a later reload that empties the file must not drag a reader into Edit.
        .task { bodyPresentation.openIfNeeded(body: card.body) }
    }

    /// "Created ⟨date⟩ · Modified ⟨date⟩ · by ⟨modified-by⟩", **omitting whichever keys are absent**
    /// (05 ▸ Composition) — the whole line disappears when the card carries none of the three.
    ///
    /// The "by" segment renders only with the self-reported provenance stamp present
    /// (01-storage-format.md), which is the point of showing it at all: provenance made visible where
    /// git history may not exist.
    private var dateLine: String? {
        var parts: [String] = []
        if let created = card.created.value {
            parts.append("Created \(Self.dateText(created))")
        }
        if let modified = card.modified.value {
            parts.append("Modified \(Self.dateText(modified))")
        }
        if let by = card.modifiedBy.value, !by.isEmpty {
            parts.append("by \(by)")
        }
        return parts.isEmpty ? nil : parts.joined(separator: " · ")
    }

    private static func dateText(_ date: Date) -> String {
        date.formatted(date: .abbreviated, time: .shortened)
    }

    // MARK: - Attributes sidebar

    /// The sidebar's sections, **in 05's settled order**: Attachments, Style, Details, History,
    /// Actions.
    ///
    /// Two of the five are conditional, and both conditions are the section's own rather than a rule
    /// restated here: **Details** renders nothing when the card carries no unknown frontmatter keys
    /// ("shown only when any exist"), and **History** is absent on boards without app-managed git.
    /// Everything else in the stack is unconditional, so the composition a user learns on one card is
    /// the composition they get on the next.
    private var sidebar: some View {
        ScrollView(.vertical) {
            VStack(alignment: .leading, spacing: bodyPointSize * 1.25) {
                CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails)

                CardStyleSection(store: store, recents: recents, cardID: card.id, undo: undo)

                // The snapshot's own document, not a re-read: the loader parsed this file, unknown
                // keys and their order included, and `Card` has carried it since (`BoardModel`).
                CardDetailsSection(rows: CardDetails.rows(of: card.document))

                historySlot

                CardActionsSection(store: store, cardID: card.id, cardFolder: cardFolder)
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding(CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
        }
    }

    /// **The History section** — between Details and Actions, 05's order (05 ▸ History: "the card's
    /// commit trail, read-only … newest first — semantic subject, relative date, author").
    ///
    /// **Absence is the `nil`, and it is the whole rule.** "The section is absent on boards without
    /// app-managed git (mode none, repo-nested) — same honesty rule as the popover's git section",
    /// and the free tier has no git state at all (12-editions.md ▸ The free tier and `.git`). The host
    /// builds a `CardHistory` only in mode `git`, so there is no placeholder here to decide about:
    /// what the slot reserves is the **position**, and on every other board that position is empty.
    ///
    // A later card: View ▸ History, which focuses this section (11-command-nexus.md).
    @ViewBuilder
    private var historySlot: some View {
        if let history {
            CardHistorySection(history: history)
        }
    }
}

// MARK: - The window-wide drop

/// Attaches the whole-window file drop, or nothing at all.
///
/// A modifier rather than an `if` inside `body` because `.onDrop` has to be applied to the *same*
/// view identity in both cases: a window whose store arrives a turn after its view would otherwise
/// re-mount its entire content when the drop target appeared, throwing away the body's scroll
/// position for nothing.
private struct WindowFileDrop: ViewModifier {

    let delegate: CardWindowDropDelegate?

    func body(content: Content) -> some View {
        if let delegate {
            // `.fileURL` alone: a text drag never matches, so it is never offered here and falls to
            // the Edit editor, where `NSTextView` inserts it at the caret (05 ▸ Attachments).
            content.onDrop(of: [.fileURL], delegate: delegate)
        } else {
            content
        }
    }
}
