import CoreGraphics
import Foundation
import Testing
@testable import Kanban

/// The comments pane's **pure** seams: where it mounts, which way the thread runs, what a comment's
/// author line says, and which folder a dropped file lands in (05-card-window.md ▸ Composition, ▸ The
/// comments column; ▸ Attachments' carve-out).
///
/// Every one of them is a rule that fails silently in a running window — a 3:2 split that is 2:3
/// looks deliberate, a composer at the wrong end looks like a design choice, and a file that landed
/// in the card's folder instead of the draft's is not visible at all until someone opens Finder. So
/// they are values and functions, and this is their suite; the views that arrange them are the
/// manual-verification list's.

// MARK: - The mount

@Suite("Comments ▸ where the pane mounts")
struct CommentsMountTests {

    @Test("The menu row's bit reads one way: checked is beside")
    func theBitReadsOneWay() {
        #expect(CommentsMount(besideBody: true) == .beside)
        #expect(CommentsMount(besideBody: false) == .stacked)
    }

    @Test("Stacked splits three parts body to two parts comments")
    func stackedSplitsThreeToTwo() {
        let mount = CommentsMount.stacked
        #expect(mount.bodyHeight(in: 500) == 300)
        #expect(mount.commentsHeight(in: 500) == 200)
    }

    @Test("The two heights always add up to the window — no gap, no overlap")
    func theHeightsSum() {
        for height in [0.0, 1.0, 37.0, 500.0, 1013.5] as [CGFloat] {
            let mount = CommentsMount.stacked
            #expect(mount.bodyHeight(in: height) + mount.commentsHeight(in: height) == height)
        }
    }

    @Test("Beside, the body owns the full height and the comments pane divides none of it")
    func besideDividesNoHeight() {
        #expect(CommentsMount.beside.bodyHeight(in: 500) == 500)
        #expect(CommentsMount.beside.commentsHeight(in: 500) == 0)
    }

    @Test("A negative proposed height is clamped rather than laid out")
    func negativeHeightsClamp() {
        #expect(CommentsMount.stacked.bodyHeight(in: -10) == 0)
        #expect(CommentsMount.beside.bodyHeight(in: -10) == 0)
    }

    @Test("Only the beside mount widens the window")
    func onlyBesideWidens() {
        // "The window's minimum width grows only while the column is shown side-by-side" (05 ▸
        // Composition) — which is the entire reason the stacked mount exists.
        #expect(CommentsMount.beside.widensWindow)
        #expect(!CommentsMount.stacked.widensWindow)
    }

    @Test("The continuous document arrangement is Preview-only, and stacked-only")
    func continuousDocumentIsStackedPreviewOnly() {
        // Title, body and the thread share one scroll only when the mount is stacked *and* the body
        // is showing Preview — Edit needs its own stable scroll, and beside never had a split to
        // fold away in the first place.
        #expect(CommentsMount.stacked.showsContinuousDocument(mode: .preview))
        #expect(!CommentsMount.stacked.showsContinuousDocument(mode: .edit))
        #expect(!CommentsMount.beside.showsContinuousDocument(mode: .preview))
        #expect(!CommentsMount.beside.showsContinuousDocument(mode: .edit))
    }
}

// MARK: - The window's minimum

@Suite("Comments ▸ the window minimum")
struct CommentsWindowMinimumTests {

    @Test("The minimum grows by the comments floor only with the column beside the body")
    func theMinimumGrowsOnlyBeside() {
        let without = CardWindowMetrics.minimumSize(bodyPointSize: 13)
        let with = CardWindowMetrics.minimumSize(bodyPointSize: 13, commentsColumn: true)

        #expect(with.width == without.width + CardWindowMetrics.commentsMinimumWidth(bodyPointSize: 13))
        #expect(with.height == without.height, "a stacked pane divides height rather than demanding more")
    }

    @Test("Every comments measurement scales with the body font")
    func measurementsScale() {
        // 10-accessibility.md ▸ Text: "metrics derive from font metrics, so layout survives the
        // largest system text sizes". The pane's numbers are no exception to the window's rule.
        #expect(
            CardWindowMetrics.commentsColumnWidth(bodyPointSize: 18)
                > CardWindowMetrics.commentsColumnWidth(bodyPointSize: 11)
        )
        #expect(
            CardWindowMetrics.composerHeight(bodyPointSize: 18)
                > CardWindowMetrics.composerHeight(bodyPointSize: 11)
        )
        #expect(
            CardWindowMetrics.inlineEditorHeight(bodyPointSize: 13)
                > CardWindowMetrics.composerHeight(bodyPointSize: 13),
            "an inline editor opens over text that already exists"
        )
    }
}

// MARK: - The column divider's clamp

@Suite("Comments ▸ the column divider")
struct CommentsColumnDividerClampTests {

    @Test("A proposal between the two floors passes through untouched")
    func withinBoundsPassesThrough() {
        // A generous container — 1200pt — so neither floor is anywhere near the proposal.
        #expect(
            CardWindowMetrics.clampedCommentsColumnWidth(400, bodyPointSize: 13, containerWidth: 1200) == 400
        )
    }

    @Test("A proposal narrower than the minimum clamps up to it")
    func tooNarrowClampsToMinimum() {
        let minimum = CardWindowMetrics.commentsMinimumWidth(bodyPointSize: 13)
        #expect(
            CardWindowMetrics.clampedCommentsColumnWidth(1, bodyPointSize: 13, containerWidth: 1200)
                == minimum
        )
        // Zero and negative proposals — a drag that overshoots past the window's own edge — read the
        // same way, never as a width smaller than the floor.
        #expect(
            CardWindowMetrics.clampedCommentsColumnWidth(-50, bodyPointSize: 13, containerWidth: 1200)
                == minimum
        )
    }

    @Test("A proposal that would starve the body clamps down to the body's own floor")
    func tooWideClampsToLeaveTheBodyItsFloor() {
        let bodyFloor = CardWindowMetrics.bodyMinimumWidth(bodyPointSize: 13)
        let containerWidth: CGFloat = 700
        let clamped = CardWindowMetrics.clampedCommentsColumnWidth(
            10_000, bodyPointSize: 13, containerWidth: containerWidth
        )
        #expect(clamped == containerWidth - bodyFloor)
        #expect(containerWidth - clamped == bodyFloor, "the body keeps exactly its floor, not less")
    }

    @Test("A container too narrow for both floors still answers the comments floor, never less")
    func aStarvedContainerStillAnswersTheFloor() {
        // A container narrower than `commentsMinimumWidth + bodyMinimumWidth` — the two floors
        // cannot both be honored, and the comments floor wins rather than the arithmetic producing a
        // maximum below the minimum (`min(max(proposed, minimum), maximum)` with a negative maximum).
        let minimum = CardWindowMetrics.commentsMinimumWidth(bodyPointSize: 13)
        #expect(
            CardWindowMetrics.clampedCommentsColumnWidth(minimum, bodyPointSize: 13, containerWidth: 10)
                == minimum
        )
        #expect(
            CardWindowMetrics.clampedCommentsColumnWidth(10_000, bodyPointSize: 13, containerWidth: 10)
                == minimum
        )
    }

    @Test("At the window's own minimum size, the clamp forces exactly the comments floor")
    func atTheWindowMinimumTheClampAgreesWithIt() {
        // `CardWindowMetrics.minimumSize(commentsColumn: true)` is sidebar + body floor + comments
        // floor. The beside `HStack`'s container at that window size is the window minus the sidebar
        // — body floor plus comments floor — so a persisted width far larger than either floor still
        // has to land on exactly the comments floor here, or the window's own minimum and the
        // divider's drag floor would disagree about what "as small as this gets" means.
        let bodyPointSize: CGFloat = 13
        let minimum = CardWindowMetrics.minimumSize(bodyPointSize: bodyPointSize, commentsColumn: true)
        let sidebarWidth = CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize)
        let containerWidth = minimum.width - sidebarWidth

        let clamped = CardWindowMetrics.clampedCommentsColumnWidth(
            10_000, bodyPointSize: bodyPointSize, containerWidth: containerWidth
        )
        #expect(clamped == CardWindowMetrics.commentsMinimumWidth(bodyPointSize: bodyPointSize))
    }

    @Test("The floor scales with the body font, like every other measurement in this window")
    func theFloorScalesWithTheBodyFont() {
        let small = CardWindowMetrics.clampedCommentsColumnWidth(1, bodyPointSize: 11, containerWidth: 1200)
        let large = CardWindowMetrics.clampedCommentsColumnWidth(1, bodyPointSize: 24, containerWidth: 1200)
        #expect(large > small)
    }
}

// MARK: - The sort direction

@Suite("Comments ▸ the sort direction")
struct CommentSortDirectionTests {

    private func comments(_ ids: [String]) -> [Kanban.Comment] {
        ids.map { id in
            Kanban.Comment(
                id: ItemID(rawValue: id),
                schema: .valid(1),
                author: .missing,
                created: .missing,
                modified: .missing,
                modifiedBy: .missing,
                attachments: [],
                document: FrontmatterDocument(body: "")
            )
        }
    }

    @Test("Ascending is the default, and the control's bit reads one way")
    func ascendingIsTheDefault() {
        #expect(CommentSortDirection(newestFirst: false) == .ascending)
        #expect(CommentSortDirection(newestFirst: true) == .descending)
        #expect(!CommentSortDirection.ascending.isNewestFirst)
    }

    @Test("Descending is the loader's order reversed, never a second sort")
    func descendingReverses() {
        let thread = comments([CommentIdent.one, CommentIdent.two, CommentIdent.three])

        #expect(CommentSortDirection.ascending.apply(to: thread).map(\.id) == thread.map(\.id))
        #expect(CommentSortDirection.descending.apply(to: thread).map(\.id) == thread.reversed().map(\.id))
    }

    @Test("An empty thread reverses to an empty thread")
    func emptyReverses() {
        #expect(CommentSortDirection.descending.apply(to: []).isEmpty)
    }

    @Test("The composer sits at the newest end — bottom ascending, top descending")
    func theComposerSitsAtTheNewestEnd() {
        #expect(!CommentSortDirection.ascending.placesComposerFirst)
        #expect(CommentSortDirection.descending.placesComposerFirst)
    }

    @Test("The control names the order it would give, and names it once")
    func theControlLabelIsOneString() {
        #expect(CommentSortDirection.ascending.controlLabel == "Oldest First")
        #expect(CommentSortDirection.descending.controlLabel == "Newest First")
    }
}

// MARK: - The header

@Suite("Comments ▸ the header count")
struct CommentsHeaderTests {

    @Test("An empty thread still counts — the pane is an invitation, not an absence")
    func zeroIsACount() {
        #expect(CommentsHeader.title(count: 0) == "Comments · 0")
        #expect(CommentsHeader.title(count: 3) == "Comments · 3")
    }
}

// MARK: - The author line

@Suite("Comments ▸ the author line")
struct CommentAuthorLineTests {

    @Test("Name and timestamp join with the window's own separator")
    func nameAndTimestamp() {
        #expect(
            CommentAuthorLine.text(author: "Ada Lovelace", timestamp: "1 Jan 2026", isEdited: false)
                == "Ada Lovelace · 1 Jan 2026"
        )
    }

    @Test("A missing author renders without a name — never a placeholder string")
    func missingAuthorRendersNothingInItsPlace() {
        // "Missing renders unattributed" (`Comment.author`), and unattributed is the absence of a
        // name: a placeholder there would be the app inventing an identity for a file that carries
        // none, which is the same reason there are no avatars.
        #expect(CommentAuthorLine.text(author: nil, timestamp: "1 Jan 2026", isEdited: false) == "1 Jan 2026")
    }

    @Test("An empty author string is absent too — the Writer never writes one")
    func blankAuthorIsAbsent() {
        #expect(CommentAuthorLine.text(author: "", timestamp: "1 Jan 2026", isEdited: false) == "1 Jan 2026")
        #expect(CommentAuthorLine.text(author: "   ", timestamp: "1 Jan 2026", isEdited: false) == "1 Jan 2026")
    }

    @Test("'edited' appends when modified differs from created")
    func editedAppends() {
        #expect(
            CommentAuthorLine.text(author: "Ada", timestamp: "1 Jan 2026", isEdited: true)
                == "Ada · 1 Jan 2026 · edited"
        )
        #expect(CommentAuthorLine.text(author: nil, timestamp: "1 Jan 2026", isEdited: true) == "1 Jan 2026 · edited")
    }

    @Test("A comment with neither a name nor a date renders no line at all")
    func nothingToSayIsNoLine() {
        #expect(CommentAuthorLine.text(author: nil, timestamp: nil, isEdited: false) == nil)
        // Not even for the edit marker: "· edited" hangs off something, and a row saying only
        // "edited" would be a row about nothing.
        #expect(CommentAuthorLine.text(author: nil, timestamp: nil, isEdited: true) == nil)
    }
}

// MARK: - The drop carve-out

@Suite("Comments ▸ the drop carve-out")
struct CommentDropCarveOutTests {

    @Test("Everywhere else in the window is the card's — the default stands")
    func theWindowDefaultStands() {
        #expect(CommentDropCarveOut.landing(for: CommentDropCarveOut.Hover()) == .card)
    }

    @Test("Within the composer's bounds, files land in the draft")
    func composerLandsInTheDraft() {
        #expect(
            CommentDropCarveOut.landing(for: CommentDropCarveOut.Hover(isOverComposer: true))
                == .comment(.draft)
        )
    }

    @Test("Within an inline edit session's bounds, files land in that comment")
    func inlineEditLandsInItsComment() {
        let id = ItemID(rawValue: CommentIdent.one)
        #expect(
            CommentDropCarveOut.landing(for: CommentDropCarveOut.Hover(inlineEdit: id))
                == .comment(.comment(id))
        )
    }

    @Test("The inline session outranks the composer where they would ever overlap")
    func theInlineSessionWins() {
        // Moot today — an inline session opens over a comment row and the composer is a separate
        // surface — and fixed anyway, because the case where it stops being moot is a layout change
        // and a layout change must not silently move a user's files into the wrong folder.
        let id = ItemID(rawValue: CommentIdent.one)
        let hover = CommentDropCarveOut.Hover(isOverComposer: true, inlineEdit: id)
        #expect(CommentDropCarveOut.landing(for: hover) == .comment(.comment(id)))
    }
}

// MARK: - Where a target lives

@Suite("Comments ▸ the target's folder")
struct CommentTargetTests {

    @Test("The draft and a posted comment resolve to their own folders, and the thread's rule owns both")
    func targetsResolve() {
        let card = URL(fileURLWithPath: "/board/lane/card", isDirectory: true)
        let id = ItemID(rawValue: CommentIdent.one)

        #expect(CommentTarget.draft.folder(inCard: card) == CommentThread.draftFolder(inCard: card))
        #expect(
            CommentTarget.comment(id).folder(inCard: card)
                == CommentThread.commentFolder(id, inCard: card)
        )
    }
}
