import CoreGraphics
import Foundation

// MARK: - Where the comments pane mounts

/// **Body and comments, side by side or stacked** — the layout option, as a value
/// (05-card-window.md ▸ Composition, ruled 2026-07-29: "side-by-side is the default; **View ▸
/// Comments Beside Body** unchecked stacks them — body pane above, comments pane below at a fixed
/// ≈3:2 split, each keeping its own scroll — for narrow displays. App-wide, persisted").
///
/// It is an enum rather than the `Bool` the menu row stores because the two mounts differ in three
/// arithmetic facts — the axis, the split, and whether the window's minimum width grows — and a
/// `Bool` at each of those three sites is three chances to read it backwards.
///
/// **The panes are identical in both mounts.** Nothing below describes a pane; it describes the frame
/// one is given. That is the componentization the design asks for stated as code: `CardCommentsPane`
/// has no idea which of these it is inside, and neither has the body column.
public enum CommentsMount: Sendable, Equatable {
    /// Comments beside the body, sharing the window's width. The default.
    case beside
    /// Comments under the body, sharing the window's height at ≈3:2.
    case stacked

    /// The menu row's bit, read the one way — checked means beside.
    public init(besideBody: Bool) {
        self = besideBody ? .beside : .stacked
    }

    /// **The stacked split: three parts body to two parts comments** — 05's "≈3:2", written once.
    ///
    /// A fraction rather than a point height so the split survives every window size and every system
    /// text size, and a *pure* one so "the body keeps three fifths" is a fact a suite asserts rather
    /// than something checked by eye at one window height.
    public static let stackedBodyFraction: CGFloat = 3.0 / 5.0

    /// How tall the body pane is in a window `height` tall — `height` itself when the panes are side
    /// by side, since then the body owns the full column.
    ///
    /// Clamped at zero: a window mid-animation can propose a negative height, and a frame with one
    /// would be a layout error rather than a small pane.
    public func bodyHeight(in height: CGFloat) -> CGFloat {
        switch self {
        case .beside: max(0, height)
        case .stacked: max(0, height * Self.stackedBodyFraction)
        }
    }

    /// The comments pane's height, the remainder — so the two always add up to the window and the
    /// divider between them never has a gap or an overlap to account for.
    public func commentsHeight(in height: CGFloat) -> CGFloat {
        max(0, height) - bodyHeight(in: height)
    }

    /// **Whether the window's minimum width grows** — "the window's minimum width grows only while
    /// the column is shown side-by-side" (05 ▸ Composition).
    ///
    /// Stacked, the comments pane takes the width the body already had, so a narrow display keeps the
    /// minimum it has always had. That is the whole reason the option exists.
    public var widensWindow: Bool { self == .beside }

    /// **Whether Preview shows the stacked mount's continuous, single-scroll arrangement** — title,
    /// body and the comment thread stacked directly atop each other in one document, rather than the
    /// fixed ≈3:2 split with each pane keeping its own scroll.
    ///
    /// Pure and mode-driven, so "continuous is Preview-only; Edit keeps the split; beside is
    /// untouched" is a fact a test can hold rather than something read off a running window
    /// (`CardWindowView.contentPanes` is the one caller). Edit needs its own stable scroll — a text
    /// editor's caret, selection and undo session cannot share a document scroll with the thread
    /// beneath it and stay usable — and the beside mount never had a split to begin with, so neither
    /// case answers `true` regardless of `mode`.
    public func showsContinuousDocument(mode: CardBodyMode) -> Bool {
        self == .stacked && mode == .preview
    }
}

// MARK: - Which way the thread runs

/// **The thread's sort direction** — "chronological ascending by default, flippable to newest-first
/// (app-wide, persisted)" (05-card-window.md ▸ The comments column).
///
/// The *order* itself is storage's (`CommentThread.sorted`, `created` ascending with the undated
/// after the dated and a canonical folder-name tie-break) and is not re-derived here: descending is
/// that order reversed, so a tie between two undated comments breaks the same way in both directions
/// rather than two sort predicates agreeing by luck.
public enum CommentSortDirection: Sendable, Equatable, CaseIterable {
    /// Oldest first — chronology as it happened, and the default.
    case ascending
    /// Newest first.
    case descending

    /// The menu/header control's bit, read the one way.
    public init(newestFirst: Bool) {
        self = newestFirst ? .descending : .ascending
    }

    public var isNewestFirst: Bool { self == .descending }

    /// Applies the direction to a thread the loader already sorted.
    ///
    /// **A reverse, never a re-sort**: see the type's note — the loader's predicate is the only one
    /// in the app that decides what "before" means for two comments.
    public func apply(to comments: [Comment]) -> [Comment] {
        self == .ascending ? comments : comments.reversed()
    }

    /// **Whether the composer sits above the thread** — "The composer sits at the thread's newest end
    /// (bottom ascending, top descending)" (05 ▸ The comments column).
    ///
    /// One fact, derived once, because the composer's placement and the pane's opening scroll target
    /// are the same sentence read twice ("the window opens scrolled to it") and a window that opened
    /// at the wrong end would be wrong only for descending users.
    public var placesComposerFirst: Bool { self == .descending }

    /// What the header control says it will do — the help text and the accessibility label, which are
    /// the same string and must stay so.
    public var controlLabel: String {
        self == .ascending ? "Oldest First" : "Newest First"
    }
}

// MARK: - The header's count

/// The comments pane's small-caps header line — **"Comments · 3"** (05-card-window.md ▸ The comments
/// column: "The section header carries the count").
///
/// Pure and separate from the view for the reason every count line in this app is: the empty case is
/// the one that gets written wrong, and 05 is explicit that a comment-less card still shows the pane
/// ("the empty thread and the composer — the invitation is the point"), so the zero has to render as
/// a count rather than as an absence.
public enum CommentsHeader {
    public static func title(count: Int) -> String {
        "Comments · \(count)"
    }
}

// MARK: - One comment's author line

/// The line above a comment's body: **who says they wrote it, when, and whether it has been edited**
/// (05-card-window.md ▸ The comments column: "an author line (self-reported `author`, unattributed
/// when absent; timestamp; '· edited' when `modified` differs from `created`)").
///
/// ### Absent means absent
///
/// "Missing renders unattributed" (`Comment.author`) — and *unattributed* is the absence of a name,
/// not the word "unattributed" drawn in its place. A placeholder there would be this app inventing an
/// identity for a file that deliberately carries none, which is the same reason there are no avatars.
/// So a comment with no `author` renders its timestamp alone, and one with neither renders nothing at
/// all rather than an empty row of separators.
public enum CommentAuthorLine {

    /// The separator every segment of this window's quiet lines uses — the card's created/modified
    /// line's, shared so the two read as one family.
    private static let separator = " · "

    /// Composes the line, or `nil` when there is nothing to say.
    ///
    /// - Parameters:
    ///   - author: the self-reported `author`, already unwrapped. An **empty** string is treated as
    ///     absent: the Writer never writes `author: ""` (it omits the key instead), so one on disk is
    ///     a hand edit, and rendering a blank name with a separator beside it would be noise.
    ///   - timestamp: the already-formatted `created`, or `nil` where the key is missing or
    ///     unreadable (the coerce tier — a comment with no date still renders).
    ///   - isEdited: `Comment.isEdited`.
    public static func text(author: String?, timestamp: String?, isEdited: Bool) -> String? {
        var parts: [String] = []
        if let author, !author.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            parts.append(author)
        }
        if let timestamp, !timestamp.isEmpty {
            parts.append(timestamp)
        }
        // "· edited" hangs off something. On a comment with neither a name nor a date there is no
        // line to hang it from, and a bare "edited" would be a row that says only that.
        guard !parts.isEmpty else { return nil }
        if isEdited {
            parts.append("edited")
        }
        return parts.joined(separator: separator)
    }
}

// MARK: - The drop carve-out

/// **Which folder a file dropped in this window lands in** (05-card-window.md ▸ Attachments, ruled
/// 2026-07-29: "One carve-out by hover target … a file dropped **within the comment composer's
/// bounds** imports to the draft's `attachments/`, and within an **inline comment edit session's
/// bounds** to that comment's — the window-wide card default covers everywhere else").
///
/// ### Why this is a value and not three `.onDrop`s trusted to nest correctly
///
/// SwiftUI does dispatch a drop to the innermost target, and the implementation leans on exactly
/// that — the composer and the inline editor each carry their own drop delegate inside the
/// window-wide one. But *what the rule is* and *whether the nesting expresses it* are two questions,
/// and only the first one is checkable without a running window. This enum is the first question's
/// answer; the modifiers are the second's.
///
/// ### The precedence, and why it is stated at all
///
/// An inline edit session opens **over a comment row**, and the composer is a separate surface at the
/// thread's newest end, so in practice the two never overlap and the order is moot. It is fixed
/// anyway — the inline session wins — because the case where it stops being moot is a layout change,
/// and a layout change should not be able to silently move a user's files into the wrong folder.
public enum CommentDropCarveOut {

    /// What the pointer is over, as the window knows it.
    ///
    /// Deliberately two independent facts rather than one enum: each surface answers only for itself
    /// (a drop delegate knows its own bounds and nothing else), and the arbitration is this type's.
    public struct Hover: Sendable, Equatable {
        public var isOverComposer: Bool
        public var inlineEdit: ItemID?

        public init(isOverComposer: Bool = false, inlineEdit: ItemID? = nil) {
            self.isOverComposer = isOverComposer
            self.inlineEdit = inlineEdit
        }
    }

    /// Where the files go.
    public enum Landing: Sendable, Equatable {
        /// The window-wide default: the **card**'s `attachments/` (`CardWindowDropDelegate`).
        case card
        /// One of the two authoring surfaces.
        case comment(CommentTarget)
    }

    /// The rule, in one expression.
    public static func landing(for hover: Hover) -> Landing {
        if let editing = hover.inlineEdit { return .comment(.comment(editing)) }
        if hover.isOverComposer { return .comment(.draft) }
        return .card
    }
}
