import AppKit
import CoreGraphics

/// The card window's fixed geometry — **derived from font metrics, never written down in points**
/// (05-card-window.md ▸ Composition: the attributes sidebar has a "fixed narrow width derived from
/// font metrics (full relative scaling, 10-accessibility.md)"; 10 ▸ Text: "relative text styles
/// everywhere, no fixed point sizes … metrics derive from font metrics, so layout survives the
/// largest system text sizes").
///
/// ### The derivation, named once
///
/// Every width here is **a character count in the body font**, and the arithmetic behind it is one
/// expression used three times:
///
/// ```
/// width = characters × averageCharacterAdvance × pointSize + 2 × gutter
/// ```
///
/// `averageCharacterAdvance` is the system font's rough average advance for mixed-case Latin text as
/// a fraction of its point size — half an em, the classic typesetter's estimate. It is deliberately
/// an *estimate* rather than a measurement: the sidebar is sized so a filename, a palette grid and a
/// key/value row have room, not so any particular string fits exactly, and a measured advance would
/// make this geometry depend on which glyphs happened to be on screen. The gutter is one em, which
/// is what keeps the whole thing scaling together.
///
/// ### Why the point size is a parameter
///
/// So the rule is a pure function and a test can hold it still. `bodyPointSize` below is the one
/// place that asks the system what the body font actually is; everything else takes it as an
/// argument, which is also what makes "the sidebar is narrower at 11pt and wider at 18pt" a fact a
/// suite can assert rather than something to be verified by eye at three text sizes.
enum CardWindowMetrics {

    // MARK: - The unit

    /// The system font's approximate average advance per character, as a fraction of its point size.
    static let averageCharacterAdvance: CGFloat = 0.5

    /// The horizontal inset on each side of a column: one em, so it scales with everything else.
    static func gutter(bodyPointSize: CGFloat) -> CGFloat {
        bodyPointSize
    }

    /// A column `characters` body-characters wide, gutters included — the one expression.
    static func columnWidth(characters: CGFloat, bodyPointSize: CGFloat) -> CGFloat {
        let text = characters * averageCharacterAdvance * bodyPointSize
        return (text + 2 * gutter(bodyPointSize: bodyPointSize)).rounded()
    }

    /// A line's height in the body font — the vertical counterpart of the advance, used only for the
    /// window's minimum and default heights.
    static func lineHeight(bodyPointSize: CGFloat) -> CGFloat {
        bodyPointSize * 1.4
    }

    // MARK: - The sidebar

    /// How wide the attributes sidebar is, in characters. Narrow by contract — it holds a
    /// middle-truncated filename, a palette grid and a key/value row, and nothing in it ever wants
    /// the window's spare width, which all goes to the body (05 ▸ Composition).
    static let sidebarCharacters: CGFloat = 26

    /// **The sidebar's width, and the only place it is decided.** Fixed for a given text size: the
    /// window's resize flex goes entirely to the body column, so this is not a fraction of anything.
    static func sidebarWidth(bodyPointSize: CGFloat) -> CGFloat {
        columnWidth(characters: sidebarCharacters, bodyPointSize: bodyPointSize)
    }

    /// What a sidebar *section* actually gets to lay out in: the column minus its two gutters.
    ///
    /// Named because a section can need a number rather than a proposal — the embedded style editor's
    /// grids, a fixed count of fixed-size wells per row whose count had to be decided before the
    /// layout ran (`StyleEditorLayout.sidebar(contentWidth:)`). The Appearance section's rectangles
    /// (`CardStyleSection`'s symbol and background columns) no longer size off this figure — since
    /// 2026-08-09 they carry their own intrinsic width (`PickerRectMetrics`) and simply fill what
    /// they are proposed, like everything else in the sidebar.
    static func sidebarContentWidth(bodyPointSize: CGFloat) -> CGFloat {
        sidebarWidth(bodyPointSize: bodyPointSize) - 2 * gutter(bodyPointSize: bodyPointSize)
    }

    // MARK: - The body column

    /// The narrowest the body column is allowed to get — a measure of prose short enough to be a
    /// floor rather than a preference.
    static let bodyMinimumCharacters: CGFloat = 44

    /// The body column at its resting default: a comfortable measure, which the user then resizes.
    static let bodyDefaultCharacters: CGFloat = 74

    static func bodyMinimumWidth(bodyPointSize: CGFloat) -> CGFloat {
        columnWidth(characters: bodyMinimumCharacters, bodyPointSize: bodyPointSize)
    }

    // MARK: - A sidebar section

    /// The gap between a sidebar section's header and its content, and between two rows of it —
    /// half a gutter, which is the attachment rows' inset and the rendered body's rhythm too, so the
    /// whole window is spaced by one unit rather than by three that happen to agree.
    static func sidebarRowSpacing(bodyPointSize: CGFloat) -> CGFloat {
        previewPadding(bodyPointSize: bodyPointSize)
    }

    // MARK: - The attachments section

    /// An attachment row's thumbnail: a **small** square, one and a half ems on a side
    /// (05-card-window.md ▸ Attachments: "Compact rows: small QuickLook thumbnail (Finder-icon
    /// fallback) + middle-truncated filename, one row per file").
    ///
    /// Derived rather than a point size, like everything else here, and deliberately *small*: this
    /// is an inventory, not a gallery — "viewing media is the card window's job" was settled about
    /// the window, not about this list, and a row tall enough to see a screenshot in would push the
    /// four sections beneath it off the sidebar.
    static func attachmentThumbnailSide(bodyPointSize: CGFloat) -> CGFloat {
        (bodyPointSize * 1.5).rounded()
    }

    /// The inset inside an attachment row, and the gap between its thumbnail and its filename —
    /// half a gutter, the rendered body's rhythm, so the sidebar's list and the body's blocks are
    /// spaced by the same unit.
    static func attachmentRowPadding(bodyPointSize: CGFloat) -> CGFloat {
        previewPadding(bodyPointSize: bodyPointSize)
    }

    // MARK: - The comments pane

    /// How wide the comments pane is when it is mounted **beside** the body, in characters
    /// (05-card-window.md ▸ Composition; ▸ The comments column).
    ///
    /// Wider than the sidebar and narrower than the body's default measure, which is what it holds:
    /// a rendered Markdown paragraph, an author line, a wrapping chip or two, and a composer.
    /// **The resting default, not a ceiling** (re-ruled 2026-08-09 — the divider between it and the
    /// body is user-draggable now, `clampedCommentsColumnWidth` below): this is what a fresh window
    /// opens at before anyone has dragged it, `bodyDefaultCharacters`'s own relationship to the body
    /// column read for this one instead.
    ///
    /// Stacked, the pane takes the body's width instead and this number is not consulted at all,
    /// which is why the window's minimum grows only in the beside mount (`CommentsMount.widensWindow`).
    static let commentsColumnCharacters: CGFloat = 40

    static func commentsColumnWidth(bodyPointSize: CGFloat) -> CGFloat {
        columnWidth(characters: commentsColumnCharacters, bodyPointSize: bodyPointSize)
    }

    /// The narrowest the comments pane is allowed to get — the floor its share of the window's
    /// minimum is measured at, a shorter measure than the body's because a comment is a remark rather
    /// than a document. **And, since 2026-08-09, the same number the divider drag may not cross** —
    /// one figure serving both jobs is what keeps the window's minimum and the drag's floor from
    /// silently disagreeing (`clampedCommentsColumnWidth`).
    static let commentsMinimumCharacters: CGFloat = 28

    static func commentsMinimumWidth(bodyPointSize: CGFloat) -> CGFloat {
        columnWidth(characters: commentsMinimumCharacters, bodyPointSize: bodyPointSize)
    }

    /// **Clamps a proposed comments-column width to what the divider drag may reach** (05-card-window.md
    /// ▸ The comments column, extended 2026-08-09 — the beside mount's divider is user-resizable).
    ///
    /// Two floors, read from opposite ends:
    /// - never narrower than `commentsMinimumWidth` — "the comments pane shouldn't collapse below a
    ///   usable width", the same figure the window's own minimum already reserves for it;
    /// - never wide enough to leave the body under `bodyMinimumWidth` — "main content keeps a
    ///   healthy minimum" made exact, which is why this needs `containerWidth` at all: the beside
    ///   `HStack`'s own width, the window's content area minus the fixed sidebar, is what the second
    ///   floor is measured against.
    ///
    /// A container too narrow for both floors at once — a window part-way through animating to its
    /// minimum, say — still answers `commentsMinimumWidth` rather than a maximum below the minimum,
    /// which a bare `min(max(proposed, minimum), maximum)` would produce for a negative `maximum`.
    /// That is `CommentsMount.bodyHeight`'s `max(0, …)` posture again: the floor wins over an input
    /// that has gone past what the arithmetic can honor.
    ///
    /// Pure, and deliberately unaware of any live window — a drag session and a unit test alike call
    /// it with whatever numbers they have; nothing here reads `NSFont` or an `NSWindow`.
    static func clampedCommentsColumnWidth(
        _ proposed: CGFloat,
        bodyPointSize: CGFloat,
        containerWidth: CGFloat
    ) -> CGFloat {
        let minimum = commentsMinimumWidth(bodyPointSize: bodyPointSize)
        let maximum = max(minimum, containerWidth - bodyMinimumWidth(bodyPointSize: bodyPointSize))
        return min(max(proposed, minimum), maximum)
    }

    /// The comments divider's interactive width — wider than the hairline it draws so the cursor has
    /// something real to land the resize pointer on, the lane strip's own reasoning
    /// (`BoardMetrics.resizeHandleWidth`) applied to a divider that is a genuine `HStack` element
    /// rather than an overlay hung off a neighbour. Narrower than a full gutter: it is a grab target,
    /// not a gap.
    static func commentsDividerHitWidth(bodyPointSize: CGFloat) -> CGFloat {
        (bodyPointSize * 0.7).rounded()
    }

    /// The smallest an attachment chip may be before the row wraps — a thumbnail, a few characters of
    /// filename, and the padding around them. Middle truncation does the rest, so a long name shrinks
    /// rather than widening the pane.
    static func commentChipMinimumWidth(bodyPointSize: CGFloat) -> CGFloat {
        (attachmentThumbnailSide(bodyPointSize: bodyPointSize) + bodyPointSize * 6).rounded()
    }

    /// The composer's resting height — **four lines and a bit**, which is the shape of the thing it
    /// invites: enough that a two-sentence remark is visible whole, short enough that it never
    /// dominates a thread. It scrolls internally past that rather than growing the pane, so a long
    /// draft cannot push the thread off screen.
    static func composerHeight(bodyPointSize: CGFloat) -> CGFloat {
        (lineHeight(bodyPointSize: bodyPointSize) * 4.5).rounded()
    }

    /// An inline edit session's editor, one line taller than the composer: it opens over text that
    /// already exists, so the common case is reading it before changing it.
    static func inlineEditorHeight(bodyPointSize: CGFloat) -> CGFloat {
        (lineHeight(bodyPointSize: bodyPointSize) * 5.5).rounded()
    }

    /// The find bar's query field — **twelve characters**, the shortest measure that still shows a
    /// two-word search whole. It is deliberately narrower than the board's search field (seventeen):
    /// this bar shares a strip with a counter and three controls inside a fixed-width pane, and a
    /// field sized to the query would push the Done button off the end of the narrowest window the
    /// design allows.
    static func findFieldWidth(bodyPointSize: CGFloat) -> CGFloat {
        columnWidth(characters: 12, bodyPointSize: bodyPointSize)
    }

    /// The gap between two comments in the thread — a full gutter, one step larger than the rhythm
    /// *inside* a comment (`sidebarRowSpacing`), so the eye groups an author line with its body
    /// rather than with its neighbour.
    static func commentSpacing(bodyPointSize: CGFloat) -> CGFloat {
        gutter(bodyPointSize: bodyPointSize)
    }

    // MARK: - The rendered body

    /// One step of structural indent in Preview — a list level, a quote level. One and a half ems,
    /// which is wide enough for a bullet plus its space and narrow enough that four nested levels
    /// still leave a measure worth reading.
    static func previewIndent(bodyPointSize: CGFloat) -> CGFloat {
        (bodyPointSize * 1.5).rounded()
    }

    /// The inside padding of a table cell and the inset of a code block — half a gutter, so the
    /// rendered body's rhythm is the column's rhythm halved rather than a second, unrelated one.
    static func previewPadding(bodyPointSize: CGFloat) -> CGFloat {
        (gutter(bodyPointSize: bodyPointSize) / 2).rounded()
    }

    /// The widest an inline image is drawn at.
    ///
    /// A number rather than "the text container's width" on purpose: a `NSTextAttachment`'s bounds
    /// are fixed at build time, so an image sized to the window would have to be rebuilt on every
    /// resize — and the body column is resizable by contract. A generous cap keeps a screenshot
    /// legible without letting a 4000-pixel-wide one push the measure around, and an image narrower
    /// than the cap is never enlarged.
    static func previewImageMaximumWidth(bodyPointSize: CGFloat) -> CGFloat {
        columnWidth(characters: 56, bodyPointSize: bodyPointSize)
    }

    // MARK: - The window

    /// The window's minimum size: the sidebar's fixed width plus the body's floor, and tall enough
    /// for a title, its date line and a few lines of body.
    ///
    /// - Parameter sidebar: whether the attributes sidebar is currently shown — View ▸ Show Sidebar
    ///   (05-card-window.md ▸ Composition, extended for the toggle). Hidden, it costs the window no
    ///   width at all, the comments column's own "the pane costs the window no width while it is not
    ///   mounted" rule applied to the sidebar's own visibility. Defaulted to `true` so every caller
    ///   that predates the toggle still asks the same question it always did.
    /// - Parameter commentsColumn: whether the comments pane is currently mounted **beside** the body
    ///   — "the window's minimum width grows only while the column is shown side-by-side"
    ///   (05-card-window.md ▸ Composition). Stacked, or hidden, the pane costs the window no width at
    ///   all, which is the narrow-display case the layout option exists for. The height is unchanged
    ///   either way: a stacked pane divides the height it is given rather than demanding more, and a
    ///   window at its minimum height simply gets a short thread.
    ///
    ///   Defaulted to `false` so every caller that predates the comments pane still asks the same
    ///   question it always did.
    static func minimumSize(bodyPointSize: CGFloat, sidebar: Bool = true, commentsColumn: Bool = false) -> CGSize {
        let sidebarComponent = sidebar ? sidebarWidth(bodyPointSize: bodyPointSize) : 0
        let comments = commentsColumn ? commentsMinimumWidth(bodyPointSize: bodyPointSize) : 0
        return CGSize(
            width: sidebarComponent
                + bodyMinimumWidth(bodyPointSize: bodyPointSize)
                + comments,
            height: (lineHeight(bodyPointSize: bodyPointSize) * 16).rounded()
        )
    }

    /// The size a card window opens at when there is no last-used size to open at — a first-ever
    /// card window, and nothing else (05 ▸ Window: "New windows open at the last-used card-window
    /// size, cascaded"; `AppPreferences.lastCardWindowSize` is that memory).
    static func defaultSize(bodyPointSize: CGFloat) -> CGSize {
        CGSize(
            width: sidebarWidth(bodyPointSize: bodyPointSize)
                + columnWidth(characters: bodyDefaultCharacters, bodyPointSize: bodyPointSize),
            height: (lineHeight(bodyPointSize: bodyPointSize) * 32).rounded()
        )
    }

    // MARK: - The live metric

    /// The body font's point size as the system currently reports it — the one impure read, kept to
    /// one line so every derivation above stays testable.
    @MainActor
    static var bodyPointSize: CGFloat {
        NSFont.preferredFont(forTextStyle: .body).pointSize
    }
}
