import Foundation
import Testing
import UIKit
@testable import Oppi

@Suite("ChatTimelineCollectionHost.Controller")
@MainActor
struct ChatTimelineCoordinatorTests {

    @MainActor
    @Test func uniqueItemsKeepingLastRetainsLatestDuplicate() {
        let first = ChatItem.systemEvent(id: "dup", message: "first")
        let middle = ChatItem.error(id: "middle", message: "middle")
        let second = ChatItem.systemEvent(id: "dup", message: "second")

        let result = ChatTimelineCollectionHost.Controller.uniqueItemsKeepingLast([first, middle, second])

        #expect(result.orderedIDs == ["middle", "dup"])
        #expect(result.itemByID["dup"] == second)
        #expect(result.itemByID["middle"] == middle)
    }

    @MainActor
    @Test func applyConfigurationWiresBackSwipeCallbackOntoCollectionController() {
        let harness = makeTimelineHarness(sessionId: "session-back")
        let controller = ChatTimelineCollectionHost.Controller()
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: ChatTimelineCollectionHost.makeTestLayout())
        var callbackCount = 0
        let config = makeTimelineConfiguration(
            items: [],
            sessionId: "session-back",
            reducer: harness.reducer,
            toolOutputStore: harness.toolOutputStore,
            toolArgsStore: harness.toolArgsStore,
            connection: harness.connection,
            scrollController: harness.scrollController,
            audioPlayer: harness.audioPlayer,
            onBackSwipe: { callbackCount += 1 }
        )

        controller.apply(configuration: config, to: collectionView)
        controller.onBackSwipe?()

        #expect(callbackCount == 1)
    }

    @MainActor
    @Test func toolRowsKeepDeferredAttachmentFetchersBeforeAPIClientIsReady() async throws {
        let collectionView = UICollectionView(
            frame: CGRect(x: 0, y: 0, width: 390, height: 844),
            collectionViewLayout: ChatTimelineCollectionHost.makeTestLayout()
        )
        let controller = ChatTimelineCollectionHost.Controller()
        controller.configureDataSource(collectionView: collectionView)

        let reducer = TimelineReducer()
        let toolOutputStore = ToolOutputStore()
        let toolArgsStore = ToolArgsStore()
        let toolSegmentStore = ToolSegmentStore()
        let connection = ServerConnection()
        #expect(connection.apiClient == nil)
        let toolItem = ChatItem.toolCall(
            id: "tool-read-attachment",
            tool: "read",
            argsSummary: "path: /tmp/screenshot.png",
            outputPreview: "Read image file [image/png]",
            outputByteCount: 128,
            isError: false,
            isDone: true
        )

        let config = makeTimelineConfiguration(
            items: [toolItem],
            sessionId: "session-attachment-scope",
            reducer: reducer,
            toolOutputStore: toolOutputStore,
            toolArgsStore: toolArgsStore,
            toolSegmentStore: toolSegmentStore,
            connection: connection,
            scrollController: ChatScrollController(),
            audioPlayer: AudioPlayerService(),
            workspaceId: nil
        )
        controller.apply(configuration: config, to: collectionView)
        collectionView.layoutIfNeeded()

        let cell = try configuredTimelineCell(in: collectionView, item: 0)
        let rowConfig = try #require(cell.contentConfiguration as? ToolTimelineRowConfiguration)
        #expect(rowConfig.sessionAttachmentFetcher != nil)
        let sourceProvider = try #require(rowConfig.sessionAttachmentMediaSourceProvider)

        #expect(connection.configure(credentials: ServerCredentials(
            host: "127.0.0.1",
            port: 7749,
            token: "test-token",
            name: "Test Server",
            scheme: .https
        )))
        let source = try await sourceProvider("att-image", "image/png", "png")
        #expect(source.url.path == "/sessions/session-attachment-scope/attachments/att-image")
    }

    @MainActor
    @Test func toolRowsUseSessionRawMediaSourceForExternalVideoPaths() async throws {
        let collectionView = UICollectionView(
            frame: CGRect(x: 0, y: 0, width: 390, height: 844),
            collectionViewLayout: ChatTimelineCollectionHost.makeTestLayout()
        )
        let controller = ChatTimelineCollectionHost.Controller()
        controller.configureDataSource(collectionView: collectionView)
        let connection = ServerConnection()
        #expect(connection.apiClient == nil)
        let item = ChatItem.toolCall(
            id: "tool-read-video",
            tool: "read",
            argsSummary: "path: /tmp/movie.mp4",
            outputPreview: "Read video file",
            outputByteCount: 64,
            isError: false,
            isDone: true
        )
        let config = makeTimelineConfiguration(
            items: [item],
            sessionId: "session-video",
            reducer: TimelineReducer(),
            toolOutputStore: ToolOutputStore(),
            toolArgsStore: ToolArgsStore(),
            toolSegmentStore: ToolSegmentStore(),
            connection: connection,
            scrollController: ChatScrollController(),
            audioPlayer: AudioPlayerService(),
            workspaceId: nil
        )
        controller.apply(configuration: config, to: collectionView)
        collectionView.layoutIfNeeded()

        let cell = try configuredTimelineCell(in: collectionView, item: 0)
        let rowConfig = try #require(cell.contentConfiguration as? ToolTimelineRowConfiguration)
        #expect(rowConfig.sessionFileDataFetcher != nil)
        let provider = try #require(rowConfig.sessionFileMediaSourceProvider)

        connection.sessionStore.upsert(makeTestSession(
            id: "session-video",
            workspaceId: "workspace-video"
        ))
        #expect(connection.configure(credentials: ServerCredentials(
            host: "127.0.0.1",
            port: 7749,
            token: "test-token",
            name: "Test Server",
            scheme: .https
        )))
        let source = try await provider("/tmp/movie.mp4")
        #expect(
            URLComponents(url: source.url, resolvingAgainstBaseURL: false)?.percentEncodedPath ==
                "/workspaces/workspace-video/sessions/session-video/raw/%2Ftmp%2Fmovie.mp4"
        )
    }

    @MainActor
    @Test func toolOutputCompletionDispositionGuardsStaleAndCanceledStates() {
        #expect(
            ChatTimelineCollectionHost.Controller.toolOutputCompletionDisposition(
                output: "ok",
                isTaskCancelled: false,
                activeSessionID: "s1",
                currentSessionID: "s1",
                itemExists: true
            ) == .apply
        )

        #expect(
            ChatTimelineCollectionHost.Controller.toolOutputCompletionDisposition(
                output: "ok",
                isTaskCancelled: true,
                activeSessionID: "s1",
                currentSessionID: "s1",
                itemExists: true
            ) == .canceled
        )

        #expect(
            ChatTimelineCollectionHost.Controller.toolOutputCompletionDisposition(
                output: "ok",
                isTaskCancelled: false,
                activeSessionID: "s1",
                currentSessionID: "s2",
                itemExists: true
            ) == .staleSession
        )

        #expect(
            ChatTimelineCollectionHost.Controller.toolOutputCompletionDisposition(
                output: "ok",
                isTaskCancelled: false,
                activeSessionID: "s1",
                currentSessionID: "s1",
                itemExists: false
            ) == .missingItem
        )

        #expect(
            ChatTimelineCollectionHost.Controller.toolOutputCompletionDisposition(
                output: "",
                isTaskCancelled: false,
                activeSessionID: "s1",
                currentSessionID: "s1",
                itemExists: true
            ) == .emptyOutput
        )
    }

    @MainActor
    @Test func assistantMarkdownRowsRenderNatively() throws {
        let harness = makeTimelineHarness(sessionId: "session-a")

        let markdownItem = ChatItem.assistantMessage(
            id: "assistant-md-1",
            text: "# Heading\n\n```swift\nprint(\"hi\")\n```",
            timestamp: Date()
        )

        let config = makeTimelineConfiguration(
            items: [markdownItem],
            sessionId: "session-a",
            reducer: harness.reducer,
            toolOutputStore: harness.toolOutputStore,
            toolArgsStore: harness.toolArgsStore,
            connection: harness.connection,
            scrollController: harness.scrollController,
            audioPlayer: harness.audioPlayer
        )
        harness.coordinator.apply(configuration: config, to: harness.collectionView)

        let cell = try configuredTimelineCell(in: harness.collectionView, item: 0)
        // Markdown-bearing assistant messages now render natively via
        // AssistantTimelineRowConfiguration — no SwiftUI fallback needed.
        let nativeConfig = try #require(cell.contentConfiguration as? AssistantTimelineRowConfiguration)
        #expect(nativeConfig.text.contains("# Heading"))
    }

    @MainActor
    @Test func userRowsWithImagesRenderNatively() throws {
        let harness = makeTimelineHarness(sessionId: "session-a")

        let imageItem = ChatItem.userMessage(
            id: "user-image-1",
            text: "",
            images: [ImageAttachment(data: "aGVsbG8=", mimeType: "image/png")],
            timestamp: Date()
        )

        let config = makeTimelineConfiguration(
            items: [imageItem],
            sessionId: "session-a",
            reducer: harness.reducer,
            toolOutputStore: harness.toolOutputStore,
            toolArgsStore: harness.toolArgsStore,
            connection: harness.connection,
            scrollController: harness.scrollController,
            audioPlayer: harness.audioPlayer
        )
        harness.coordinator.apply(configuration: config, to: harness.collectionView)

        let cell = try configuredTimelineCell(in: harness.collectionView, item: 0)
        let nativeConfig = try #require(cell.contentConfiguration as? UserTimelineRowConfiguration)
        #expect(nativeConfig.images.count == 1)
    }

    @MainActor
    @Test func systemAndErrorRowsRenderWithNativeConfiguration() throws {
        let harness = makeTimelineHarness(sessionId: "session-a")

        let rows: [ChatItem] = [
            .systemEvent(id: "system-1", message: "Model changed"),
            .error(id: "error-1", message: "Permission denied"),
        ]

        let config = makeTimelineConfiguration(
            items: rows,
            sessionId: "session-a",
            reducer: harness.reducer,
            toolOutputStore: harness.toolOutputStore,
            toolArgsStore: harness.toolArgsStore,
            connection: harness.connection,
            scrollController: harness.scrollController,
            audioPlayer: harness.audioPlayer
        )
        harness.coordinator.apply(configuration: config, to: harness.collectionView)

        let firstCell = try configuredTimelineCell(in: harness.collectionView, item: 0)
        let secondCell = try configuredTimelineCell(in: harness.collectionView, item: 1)

        #expect(firstCell.contentConfiguration is SystemTimelineRowConfiguration)
        #expect(secondCell.contentConfiguration is ErrorTimelineRowConfiguration)
    }

    @MainActor
    @Test func customEventsRenderWithNativeCardConfiguration() throws {
        let harness = makeTimelineHarness(sessionId: "session-a")
        let presentation = TraceEventPresentation(
            kind: "custom",
            title: "Task Notification",
            subtitle: "agent-1",
            status: "completed",
            body: "Background work finished.",
            fields: [TraceEventPresentationField(label: "Result", value: "Readable result")],
            accent: "success"
        )

        let config = makeTimelineConfiguration(
            items: [
                .customEvent(
                    id: "custom-1",
                    message: "Task Notification\nResult: Readable result",
                    presentation: presentation
                ),
            ],
            sessionId: "session-a",
            reducer: harness.reducer,
            toolOutputStore: harness.toolOutputStore,
            toolArgsStore: harness.toolArgsStore,
            connection: harness.connection,
            scrollController: harness.scrollController,
            audioPlayer: harness.audioPlayer
        )
        harness.coordinator.apply(configuration: config, to: harness.collectionView)

        let cell = try configuredTimelineCell(in: harness.collectionView, item: 0)
        #expect(cell.contentConfiguration is CustomTimelineRowConfiguration)
    }

    @MainActor
    @Test func compactionRowsRenderWithNativeConfiguration() throws {
        let harness = makeTimelineHarness(sessionId: "session-a")
        let router = ReviewCommentSelectionRouter { _ in }

        let rows: [ChatItem] = [
            .systemEvent(
                id: "compaction-1",
                message: "Context compacted (12,345 tokens): ## Goal\n1. Keep calm"
            ),
        ]

        let config = makeTimelineConfiguration(
            items: rows,
            sessionId: "session-a",
            reducer: harness.reducer,
            toolOutputStore: harness.toolOutputStore,
            toolArgsStore: harness.toolArgsStore,
            connection: harness.connection,
            scrollController: harness.scrollController,
            audioPlayer: harness.audioPlayer,
            reviewCommentSelectionRouter: router
        )
        harness.coordinator.apply(configuration: config, to: harness.collectionView)

        let cell = try configuredTimelineCell(in: harness.collectionView, item: 0)
        let compactionConfig = try #require(cell.contentConfiguration as? CompactionTimelineRowConfiguration)
        #expect(compactionConfig.presentation.phase == .completed)
        #expect(compactionConfig.presentation.tokensBefore == 12_345)
        #expect(compactionConfig.canExpand)
        #expect(compactionConfig.itemID == "compaction-1")
        #expect(compactionConfig.interactionContext === harness.coordinator.interactionContext)
    }

    @MainActor
    @Test func thinkingRowsAutoRenderExpandedWithNativeConfiguration() throws {
        let harness = makeTimelineHarness(sessionId: "session-a")

        let rows: [ChatItem] = [
            .thinking(id: "thinking-1", preview: "full reasoning block", hasMore: false, isDone: true),
        ]

        let config = makeTimelineConfiguration(
            items: rows,
            sessionId: "session-a",
            reducer: harness.reducer,
            toolOutputStore: harness.toolOutputStore,
            toolArgsStore: harness.toolArgsStore,
            connection: harness.connection,
            scrollController: harness.scrollController,
            audioPlayer: harness.audioPlayer
        )
        harness.coordinator.apply(configuration: config, to: harness.collectionView)

        let cell = try configuredTimelineCell(in: harness.collectionView, item: 0)
        let thinkingConfig = try #require(cell.contentConfiguration as? ThinkingTimelineRowConfiguration)
        #expect(thinkingConfig.isDone)
        #expect(thinkingConfig.displayText == "full reasoning block")
    }

    @MainActor
    @Test func audioClipRowsRenderWithNativeConfiguration() throws {
        let harness = makeTimelineHarness(sessionId: "session-a")

        let rows: [ChatItem] = [
            .audioClip(
                id: "audio-1",
                title: "Harness Clip",
                fileURL: URL(fileURLWithPath: "/tmp/harness-audio.wav"),
                timestamp: Date()
            ),
        ]

        let config = makeTimelineConfiguration(
            items: rows,
            sessionId: "session-a",
            reducer: harness.reducer,
            toolOutputStore: harness.toolOutputStore,
            toolArgsStore: harness.toolArgsStore,
            connection: harness.connection,
            scrollController: harness.scrollController,
            audioPlayer: harness.audioPlayer
        )
        harness.coordinator.apply(configuration: config, to: harness.collectionView)

        let cell = try configuredTimelineCell(in: harness.collectionView, item: 0)
        #expect(cell.contentConfiguration is AudioClipTimelineRowConfiguration)
    }

    @MainActor
    @Test func loadMoreAndWorkingRowsRenderWithNativeConfiguration() throws {
        var showEarlierTapped = 0

        do {
            let harness = makeTimelineHarness(sessionId: "session-a")
            let withHiddenRows = makeTimelineConfiguration(
                items: [.systemEvent(id: "system-1", message: "Context compacted")],
                hiddenCount: 4,
                renderWindowStep: 2,
                onShowEarlier: { showEarlierTapped += 1 },
                sessionId: "session-a",
                reducer: harness.reducer,
                toolOutputStore: harness.toolOutputStore,
                toolArgsStore: harness.toolArgsStore,
                connection: harness.connection,
                scrollController: harness.scrollController,
                audioPlayer: harness.audioPlayer
            )
            harness.coordinator.apply(configuration: withHiddenRows, to: harness.collectionView)

            let loadMoreCell = try configuredTimelineCell(in: harness.collectionView, item: 0)
            #expect(loadMoreCell.contentConfiguration is LoadMoreTimelineRowConfiguration)

            // sanity check closure is wired in config payload
            if let config = loadMoreCell.contentConfiguration as? LoadMoreTimelineRowConfiguration {
                config.onTap()
            }
            #expect(showEarlierTapped == 1)
        }

        do {
            let harness = makeTimelineHarness(sessionId: "session-a")
            let busy = makeTimelineConfiguration(
                items: [],
                isBusy: true,
                streamingAssistantID: nil,
                sessionId: "session-a",
                reducer: harness.reducer,
                toolOutputStore: harness.toolOutputStore,
                toolArgsStore: harness.toolArgsStore,
                connection: harness.connection,
                scrollController: harness.scrollController,
                audioPlayer: harness.audioPlayer
            )
            harness.coordinator.apply(configuration: busy, to: harness.collectionView)

            let workingCell = try configuredTimelineCell(in: harness.collectionView, item: 0)
            #expect(workingCell.contentConfiguration is WorkingIndicatorTimelineRowConfiguration)
        }
    }

    @MainActor
    @Test func loadMoreRowButtonCoversVisibleRowAndInvokesCallbackOnce() throws {
        var callbackCount = 0
        let harness = makeTimelineHarness(sessionId: "session-load-more")
        let config = makeTimelineConfiguration(
            items: [.systemEvent(id: "system-1", message: "Context compacted")],
            hiddenCount: 196,
            renderWindowStep: 60,
            onShowEarlier: { callbackCount += 1 },
            sessionId: "session-load-more",
            reducer: harness.reducer,
            toolOutputStore: harness.toolOutputStore,
            toolArgsStore: harness.toolArgsStore,
            connection: harness.connection,
            scrollController: harness.scrollController,
            audioPlayer: harness.audioPlayer
        )
        harness.coordinator.apply(configuration: config, to: harness.collectionView)
        harness.collectionView.layoutIfNeeded()

        let cell = try configuredTimelineCell(in: harness.collectionView, item: 0)
        let row = try #require(timelineFirstView(ofType: LoadMoreTimelineRowContentView.self, in: cell.contentView))
        let button = try #require(timelineFirstView(ofType: UIButton.self, in: row))

        #expect(button.title(for: .normal) == "Show 60 earlier messages (196 hidden)")
        #expect(button.frame.minX == 0)
        #expect(button.frame.minY == 0)
        #expect(abs(button.frame.width - row.bounds.width) <= 1)
        #expect(abs(button.frame.height - row.bounds.height) <= 1)

        button.sendActions(for: .touchUpInside)
        #expect(callbackCount == 1)
    }

    @MainActor
    @Test func loadMoreRowSelfSizesToAComfortableTapTarget() throws {
        let windowed = makeWindowedTimelineHarness(sessionId: "session-load-more-tap-target")
        windowed.applyItems(
            [.systemEvent(id: "system-1", message: "Context compacted")],
            hiddenCount: 9,
            renderWindowStep: 60,
            isBusy: false
        )

        let cell = try configuredTimelineCell(in: windowed.collectionView, item: 0)
        let row = try #require(timelineFirstView(ofType: LoadMoreTimelineRowContentView.self, in: cell.contentView))
        let button = try #require(timelineFirstView(ofType: UIButton.self, in: row))

        #expect(
            cell.bounds.height >= 44,
            "load-more row self-sized to \(cell.bounds.height)pt, below the 44pt minimum touch target"
        )

        // A real finger lands anywhere in the row, not just on the glyphs.
        for relativeY in [0.15, 0.5, 0.85] {
            let pointInCell = CGPoint(x: cell.bounds.midX, y: cell.bounds.height * relativeY)
            let pointInCollectionView = windowed.collectionView.convert(pointInCell, from: cell)
            let hit = windowed.collectionView.hitTest(pointInCollectionView, with: nil)
            #expect(
                hit === button || hit?.isDescendant(of: button) == true,
                "tap at \(relativeY) of the row height hit \(String(describing: hit)) instead of the button"
            )
        }
    }

    @MainActor
    @Test func loadMoreRowSelectionIsNotASecondActionOwner() throws {
        var callbackCount = 0
        let harness = makeTimelineHarness(sessionId: "session-load-more-selection")
        let config = makeTimelineConfiguration(
            items: [.systemEvent(id: "system-1", message: "Context compacted")],
            hiddenCount: 196,
            renderWindowStep: 60,
            onShowEarlier: { callbackCount += 1 },
            sessionId: "session-load-more-selection",
            reducer: harness.reducer,
            toolOutputStore: harness.toolOutputStore,
            toolArgsStore: harness.toolArgsStore,
            connection: harness.connection,
            scrollController: harness.scrollController,
            audioPlayer: harness.audioPlayer
        )
        harness.coordinator.apply(configuration: config, to: harness.collectionView)
        harness.collectionView.layoutIfNeeded()
        _ = try configuredTimelineCell(in: harness.collectionView, item: 0)

        let indexPath = IndexPath(item: 0, section: 0)
        #expect(
            harness.coordinator.collectionView(
                harness.collectionView,
                shouldSelectItemAt: indexPath
            ) == false
        )

        // Force a selected state the production shouldSelect path would refuse,
        // then prove didSelect is a no-op that clears sticky selection.
        harness.collectionView.selectItem(at: indexPath, animated: false, scrollPosition: [])
        #expect(harness.collectionView.indexPathsForSelectedItems == [indexPath])

        harness.coordinator.collectionView(
            harness.collectionView,
            didSelectItemAt: indexPath
        )
        #expect(callbackCount == 0)
        #expect(harness.collectionView.indexPathsForSelectedItems?.isEmpty ?? true)
    }

    @MainActor
    @Test func timelineKeyboardDismissGestureYieldsToControls() {
        let button = UIButton(type: .system)
        let textField = UITextField()
        let label = UILabel()

        #expect(!ChatTimelineCollectionHost.Controller.shouldReceiveTimelineGestureTouch(from: button))
        #expect(!ChatTimelineCollectionHost.Controller.shouldReceiveTimelineGestureTouch(from: textField))
        #expect(ChatTimelineCollectionHost.Controller.shouldReceiveTimelineGestureTouch(from: label))
    }

    @MainActor
    @Test func tappingToolRowTogglesExpansionEvenWithoutMaterializedCell() {
        let harness = makeTimelineHarness(sessionId: "session-a")
        let toolID = "tool-read-1"

        harness.toolArgsStore.set(["path": .string("src/main.swift")], for: toolID)
        let config = makeTimelineConfiguration(
            items: [
                .toolCall(
                    id: toolID,
                    tool: "read",
                    argsSummary: "path: src/main.swift",
                    outputPreview: "line1\nline2",
                    outputByteCount: 16,
                    isError: false,
                    isDone: true
                ),
            ],
            isBusy: true,
            streamingAssistantID: "assistant-streaming",
            sessionId: "session-a",
            reducer: harness.reducer,
            toolOutputStore: harness.toolOutputStore,
            toolArgsStore: harness.toolArgsStore,
            connection: harness.connection,
            scrollController: harness.scrollController,
            audioPlayer: harness.audioPlayer
        )
        harness.coordinator.apply(configuration: config, to: harness.collectionView)

        // Intentionally do not materialize the cell via `configuredTimelineCell(...)`.
        // Selection handling should still toggle expansion state based on item ID.
        harness.coordinator.collectionView(
            harness.collectionView,
            didSelectItemAt: IndexPath(item: 0, section: 0)
        )

        #expect(harness.reducer.expandedItemIDs.contains(toolID))
    }

    @MainActor
    @Test func tappingReadImageToolExpandsWhenCollapsedPreviewUnavailable() throws {
        let harness = makeTimelineHarness(sessionId: "session-read-image")
        let toolID = "tool-read-image-1"
        let pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="

        harness.toolArgsStore.set(["path": .string("fixtures/icon.png")], for: toolID)
        harness.toolOutputStore.append("Read image file [image/png]\n\ndata:image/png;base64,\(pngBase64)", to: toolID)

        harness.applyAndLayout(
            items: [
                .toolCall(
                    id: toolID,
                    tool: "read",
                    argsSummary: "path: fixtures/icon.png",
                    outputPreview: "data:image/png;base64,\(pngBase64)",
                    outputByteCount: pngBase64.utf8.count,
                    isError: false,
                    isDone: true
                ),
            ],
            isBusy: false
        )

        let cell = try configuredTimelineCell(in: harness.collectionView, item: 0)
        let config = try #require(cell.contentConfiguration as? ToolTimelineRowConfiguration)
        #expect(config.collapsedImageBase64 == nil)

        harness.coordinator.collectionView(
            harness.collectionView,
            didSelectItemAt: IndexPath(item: 0, section: 0)
        )

        #expect(harness.reducer.expandedItemIDs.contains(toolID))
    }

    @MainActor
    @Test func compactionChevronActionTogglesExpansionWhenSummaryIsLong() throws {
        let harness = makeTimelineHarness(sessionId: "session-a")
        let itemID = "compaction-expand-1"
        let summary = String(repeating: "keep-calm ", count: 24)

        let config = makeTimelineConfiguration(
            items: [
                .systemEvent(id: itemID, message: "Context compacted: \(summary)"),
            ],
            sessionId: "session-a",
            reducer: harness.reducer,
            toolOutputStore: harness.toolOutputStore,
            toolArgsStore: harness.toolArgsStore,
            connection: harness.connection,
            scrollController: harness.scrollController,
            audioPlayer: harness.audioPlayer
        )

        harness.coordinator.apply(configuration: config, to: harness.collectionView)

        let cell = try configuredTimelineCell(in: harness.collectionView, item: 0)
        let compactionConfig = try #require(cell.contentConfiguration as? CompactionTimelineRowConfiguration)
        let toggle = try #require(compactionConfig.onToggleExpand)

        toggle()
        #expect(harness.reducer.expandedItemIDs.contains(itemID))

        toggle()
        #expect(!harness.reducer.expandedItemIDs.contains(itemID))
    }

    @MainActor
    @Test func branchContextRendersAsExpandableSummaryRow() throws {
        let harness = makeTimelineHarness(sessionId: "session-a")
        let itemID = "branch-summary-1"
        let summary = String(repeating: "preserve tree QA notes ", count: 12)

        let config = makeTimelineConfiguration(
            items: [
                .systemEvent(id: itemID, message: "Branch context: ## Goal\n\(summary)"),
            ],
            sessionId: "session-a",
            reducer: harness.reducer,
            toolOutputStore: harness.toolOutputStore,
            toolArgsStore: harness.toolArgsStore,
            connection: harness.connection,
            scrollController: harness.scrollController,
            audioPlayer: harness.audioPlayer
        )

        harness.coordinator.apply(configuration: config, to: harness.collectionView)

        let cell = try configuredTimelineCell(in: harness.collectionView, item: 0)
        let branchConfig = try #require(cell.contentConfiguration as? CompactionTimelineRowConfiguration)
        #expect(branchConfig.presentation.phase == .branchSummary)
        #expect(branchConfig.presentation.detail?.hasPrefix("## Goal") == true)
        #expect(branchConfig.canExpand)
        #expect(branchConfig.onToggleExpand != nil)
    }

    @MainActor
    @Test func tappingCompactionRowDoesNotToggleExpansion() {
        let harness = makeTimelineHarness(sessionId: "session-a")
        let itemID = "compaction-expand-1"
        let summary = String(repeating: "keep-calm ", count: 24)

        let config = makeTimelineConfiguration(
            items: [
                .systemEvent(id: itemID, message: "Context compacted: \(summary)"),
            ],
            sessionId: "session-a",
            reducer: harness.reducer,
            toolOutputStore: harness.toolOutputStore,
            toolArgsStore: harness.toolArgsStore,
            connection: harness.connection,
            scrollController: harness.scrollController,
            audioPlayer: harness.audioPlayer
        )

        harness.coordinator.apply(configuration: config, to: harness.collectionView)

        harness.coordinator.collectionView(
            harness.collectionView,
            didSelectItemAt: IndexPath(item: 0, section: 0)
        )

        #expect(!harness.reducer.expandedItemIDs.contains(itemID))
    }

    @MainActor
    @Test func compactionRowContentViewReportsFiniteFittingSize() {
        let config = CompactionTimelineRowConfiguration(
            presentation: .init(
                phase: .completed,
                detail: "## Goal\n1. Continue migration\n2. Keep animations subtle",
                tokensBefore: 98_765
            ),
            isExpanded: false
        )

        let view = CompactionTimelineRowContentView(configuration: config)
        let fitting = fittedTimelineSize(for: view, width: 338)

        #expect(fitting.width.isFinite)
        #expect(fitting.height.isFinite)
        #expect(fitting.height < 10_000)
    }

    @MainActor
    @Test func compactionRowCollapsedDetailUsesSingleLinePreview() throws {
        let config = CompactionTimelineRowConfiguration(
            presentation: .init(
                phase: .completed,
                detail: "## Goal\n1. Continue migration\n2. Keep animations subtle",
                tokensBefore: 98_765
            ),
            isExpanded: false
        )

        let view = CompactionTimelineRowContentView(configuration: config)
        _ = fittedTimelineSize(for: view, width: 338)

        let detailLabel = try #require(timelineAllLabels(in: view).first {
            timelineRenderedText(of: $0).contains("Goal") || timelineRenderedText(of: $0).contains("Continue migration")
        })

        #expect(detailLabel.numberOfLines == 1)
    }

    @MainActor
    @Test func compactionRowExpandedUsesMarkdownRenderer() throws {
        let config = CompactionTimelineRowConfiguration(
            presentation: .init(
                phase: .completed,
                detail: "## Goal\n1. Continue migration\n2. Keep animations subtle",
                tokensBefore: 98_765
            ),
            isExpanded: true
        )

        let view = CompactionTimelineRowContentView(configuration: config)
        _ = fittedTimelineSize(for: view, width: 338)

        let markdownView = try #require(timelineFirstView(ofType: AssistantMarkdownContentView.self, in: view))
        #expect(!markdownView.isHidden)

        let rendered = timelineAllTextViews(in: markdownView)
            .map { $0.attributedText?.string ?? $0.text ?? "" }
            .joined(separator: "\n")
        #expect(rendered.contains("Goal"))
    }

    @MainActor
    @Test func compactionRowExpandedSelectedTextEditMenuPrependsCommentAction() throws {
        let interactionCtx = TimelineInteractionContext()
        interactionCtx.reviewCommentSelectionRouter = ReviewCommentSelectionRouter { _ in }
        interactionCtx.sessionId = "session-1"

        let config = CompactionTimelineRowConfiguration(
            presentation: .init(
                phase: .completed,
                detail: "## Goal\nAlpha beta gamma",
                tokensBefore: 98_765
            ),
            isExpanded: true,
            interactionContext: interactionCtx,
            itemID: "compaction-1"
        )

        let view = CompactionTimelineRowContentView(configuration: config)
        _ = fittedTimelineSize(for: view, width: 338)

        let markdownView = try #require(timelineFirstView(ofType: AssistantMarkdownContentView.self, in: view))
        let textView = try #require(timelineFirstTextView(in: markdownView))
        let menu = try #require(markdownView.textView(
            textView,
            editMenuForTextIn: NSRange(location: 0, length: 5),
            suggestedActions: [UIAction(title: "Copy") { _ in }]
        ))

        #expect(timelineActionTitles(in: menu) == ["Comment", "Copy"])
    }

    @MainActor
    @Test func compactionRowCollapseAfterExpandShrinksHeight() {
        let detail = String(repeating: "reasoning line\n", count: 100)

        // Expand first: markdown view renders fully.
        let expandedConfig = CompactionTimelineRowConfiguration(
            presentation: .init(phase: .completed, detail: detail, tokensBefore: 42_000),
            isExpanded: true
        )
        let view = CompactionTimelineRowContentView(configuration: expandedConfig)
        let expandedSize = fittedTimelineSize(for: view, width: 338)

        // Collapse: reconfigure with isExpanded = false.
        let collapsedConfig = CompactionTimelineRowConfiguration(
            presentation: .init(phase: .completed, detail: detail, tokensBefore: 42_000),
            isExpanded: false
        )
        view.configuration = collapsedConfig
        let collapsedSize = fittedTimelineSize(for: view, width: 338)

        // Collapsed must be substantially shorter than expanded (header + 1-line detail).
        // Expanded 100 lines of text should be > 500pt. Collapsed should be < 80pt.
        #expect(expandedSize.height > 200, "expanded should be tall, got \(expandedSize.height)")
        #expect(collapsedSize.height < 80, "collapsed should shrink, got \(collapsedSize.height)")
    }

    @MainActor
    @Test func assistantRowContentViewReportsFiniteFittingSizeForMarkdown() {
        let markdown = """
        # Opus Session

        | Key | Value |
        | --- | ----- |
        | mode | opus |
        | state | active |

        ```swift
        for i in 0..<200 {
            print("line \\(i)")
        }
        ```
        """

        let config = AssistantTimelineRowConfiguration(
            text: markdown,
            isStreaming: false,
            canFork: false,
            onFork: nil
        )

        let view = AssistantTimelineRowContentView(configuration: config)
        let fitting = fittedTimelineSize(for: view, width: 338)

        #expect(fitting.width.isFinite)
        #expect(fitting.height.isFinite)
        #expect(fitting.height < 10_000)
    }

    @MainActor
    @Test func audioClipRowContentViewReportsFiniteFittingSize() {
        let config = AudioClipTimelineRowConfiguration(
            id: "audio-1",
            title: "Harness Clip",
            fileURL: URL(fileURLWithPath: "/tmp/harness-audio.wav"),
            audioPlayer: AudioPlayerService()
        )

        let view = AudioClipTimelineRowContentView(configuration: config)
        let fitting = fittedTimelineSize(for: view, width: 338)

        #expect(fitting.width.isFinite)
        #expect(fitting.height.isFinite)
        #expect(fitting.height < 10_000)
    }

    @MainActor
    @Test func thinkingRowContentViewExpandedReportsFiniteFittingSize() {
        let config = ThinkingTimelineRowConfiguration(
            isDone: true,
            previewText: "preview",
            fullText: String(repeating: "reasoning line\n", count: 500)
        )

        let view = ThinkingTimelineRowContentView(configuration: config)
        let fitting = fittedTimelineSize(for: view, width: 338)

        #expect(fitting.width.isFinite)
        #expect(fitting.height.isFinite)
        #expect(fitting.height < 10_000)
    }

    @MainActor
    @Test func thinkingRowExpandedUsesCappedViewportHeight() {
        let config = ThinkingTimelineRowConfiguration(
            isDone: true,
            previewText: "preview",
            fullText: String(repeating: "reasoning line\n", count: 900)
        )

        let view = ThinkingTimelineRowContentView(configuration: config)
        let fitting = fittedTimelineSize(for: view, width: 338)

        #expect(fitting.height < 280)
        #expect(fitting.height > 140)
    }

    @MainActor
    @Test func thinkingRowExpandedShrinksForShortText() {
        let short = ThinkingTimelineRowConfiguration(
            isDone: true,
            previewText: "preview",
            fullText: "short thought"
        )
        let long = ThinkingTimelineRowConfiguration(
            isDone: true,
            previewText: "preview",
            fullText: String(repeating: "reasoning line\n", count: 900)
        )

        let shortView = ThinkingTimelineRowContentView(configuration: short)
        let longView = ThinkingTimelineRowContentView(configuration: long)

        let shortSize = fittedTimelineSize(for: shortView, width: 338)
        let longSize = fittedTimelineSize(for: longView, width: 338)

        #expect(shortSize.height < longSize.height)
    }

    @MainActor
    @Test func thinkingRowShowsTextContent() throws {
        let config = ThinkingTimelineRowConfiguration(
            isDone: true,
            previewText: "Reviewing checklist for updates",
            fullText: nil
        )

        let view = ThinkingTimelineRowContentView(configuration: config)
        _ = fittedTimelineSize(for: view, width: 338)

        let textView = try #require(timelineAllTextViews(in: view).first {
            timelineRenderedText(of: $0).contains("Reviewing")
        })
        #expect(timelineRenderedText(of: textView) == "Reviewing checklist for updates")
    }

    @MainActor
    @Test func thinkingRowStreamingShowsLivePreviewText() throws {
        let config = ThinkingTimelineRowConfiguration(
            isDone: false,
            previewText: "Let me analyze this step by step",
            fullText: nil
        )

        let view = ThinkingTimelineRowContentView(configuration: config)
        let fitting = fittedTimelineSize(for: view, width: 338)

        // Should have nonzero height (spinner header + preview container)
        #expect(fitting.height > 20)

        // Preview text should be rendered in the thinking text view.
        let textView = try #require(timelineAllTextViews(in: view).first {
            timelineRenderedText(of: $0).contains("Let me analyze")
        })
        #expect(timelineRenderedText(of: textView).contains("Let me analyze"))
    }

    @MainActor
    @Test func thinkingRowStreamingWithEmptyPreviewHidesContainer() {
        let config = ThinkingTimelineRowConfiguration(
            isDone: false,
            previewText: "",
            fullText: nil
        )

        let view = ThinkingTimelineRowContentView(configuration: config)
        _ = fittedTimelineSize(for: view, width: 338)

        // No rendered text views should contain thinking text beyond the header.
        let textViews = timelineAllTextRenderViews(in: view)
        let rendered = textViews
            .map { timelineRenderedText(of: $0).trimmingCharacters(in: .whitespacesAndNewlines) }
            .filter { !$0.isEmpty }
            .filter { $0 != "Thinking…" }
        #expect(rendered.isEmpty)
    }

    @MainActor
    @Test func thinkingRowStreamingUsesFixedViewportHeight() {
        let short = ThinkingTimelineRowConfiguration(
            isDone: false,
            previewText: "hmm",
            fullText: nil,
        )
        let long = ThinkingTimelineRowConfiguration(
            isDone: false,
            previewText: String(repeating: "thinking about this problem ", count: 15),
            fullText: nil,
        )

        let shortView = ThinkingTimelineRowContentView(configuration: short)
        let longView = ThinkingTimelineRowContentView(configuration: long)

        let shortSize = fittedTimelineSize(for: shortView, width: 338)
        let longSize = fittedTimelineSize(for: longView, width: 338)

        // Streaming uses a fixed viewport — cell height stays constant
        // regardless of content length, matching the tool row contract.
        #expect(
            shortSize.height == longSize.height,
            "Streaming bubble should use fixed height; short=\(shortSize.height) long=\(longSize.height)"
        )
    }

    @MainActor
    @Test func audioStateChangeReconfiguresAffectedAudioRows() async {
        let harness = makeTimelineHarness(sessionId: "session-a")

        let rows: [ChatItem] = [
            .audioClip(id: "audio-1", title: "Clip 1", fileURL: URL(fileURLWithPath: "/tmp/audio-1.wav"), timestamp: Date()),
            .systemEvent(id: "system-1", message: "separator"),
            .audioClip(id: "audio-2", title: "Clip 2", fileURL: URL(fileURLWithPath: "/tmp/audio-2.wav"), timestamp: Date()),
        ]
        let config = makeTimelineConfiguration(
            items: rows,
            sessionId: "session-a",
            reducer: harness.reducer,
            toolOutputStore: harness.toolOutputStore,
            toolArgsStore: harness.toolArgsStore,
            connection: harness.connection,
            scrollController: harness.scrollController,
            audioPlayer: harness.audioPlayer
        )
        harness.coordinator.apply(configuration: config, to: harness.collectionView)

        NotificationCenter.default.post(
            name: AudioPlayerService.stateDidChangeNotification,
            object: harness.audioPlayer,
            userInfo: [
                AudioPlayerService.previousPlayingItemIDUserInfoKey: "audio-1",
                AudioPlayerService.playingItemIDUserInfoKey: "audio-2",
                AudioPlayerService.previousLoadingItemIDUserInfoKey: "",
                AudioPlayerService.loadingItemIDUserInfoKey: "",
            ]
        )

        #expect(await waitForTimelineCondition(timeoutMs: 300) {
            await MainActor.run {
                harness.coordinator._audioStateRefreshCountForTesting == 1
            }
        })

        #expect(harness.coordinator._audioStateRefreshedItemIDsForTesting == ["audio-1", "audio-2"])
    }

    @MainActor
    @Test func audioStateChangeReconfiguresCorrelatedToolVoiceRows() async {
        let harness = makeTimelineHarness(sessionId: "session-a")

        let rows: [ChatItem] = [
            .toolCall(
                id: "tool-voice-1",
                tool: "example_tts_speak",
                argsSummary: "text: hi",
                outputPreview: "Voice message",
                outputByteCount: 13,
                isError: false,
                isDone: false
            ),
        ]
        let config = makeTimelineConfiguration(
            items: rows,
            sessionId: "session-a",
            reducer: harness.reducer,
            toolOutputStore: harness.toolOutputStore,
            toolArgsStore: harness.toolArgsStore,
            connection: harness.connection,
            scrollController: harness.scrollController,
            audioPlayer: harness.audioPlayer
        )
        harness.coordinator.apply(configuration: config, to: harness.collectionView)

        NotificationCenter.default.post(
            name: AudioPlayerService.stateDidChangeNotification,
            object: harness.audioPlayer,
            userInfo: [
                AudioPlayerService.playingItemIDUserInfoKey: "audio-stream-tool-voice-1",
            ]
        )

        #expect(await waitForTimelineCondition(timeoutMs: 300) {
            await MainActor.run {
                harness.coordinator._audioStateRefreshCountForTesting == 1
            }
        })

        #expect(harness.coordinator._audioStateRefreshedItemIDsForTesting == ["tool-voice-1"])
    }

    @MainActor
    @Test func audioStateChangeWithoutIDsRefreshesAllVisibleAudioRows() async {
        let harness = makeTimelineHarness(sessionId: "session-a")

        let rows: [ChatItem] = [
            .audioClip(id: "audio-1", title: "Clip 1", fileURL: URL(fileURLWithPath: "/tmp/audio-1.wav"), timestamp: Date()),
            .audioClip(id: "audio-2", title: "Clip 2", fileURL: URL(fileURLWithPath: "/tmp/audio-2.wav"), timestamp: Date()),
        ]
        let config = makeTimelineConfiguration(
            items: rows,
            sessionId: "session-a",
            reducer: harness.reducer,
            toolOutputStore: harness.toolOutputStore,
            toolArgsStore: harness.toolArgsStore,
            connection: harness.connection,
            scrollController: harness.scrollController,
            audioPlayer: harness.audioPlayer
        )
        harness.coordinator.apply(configuration: config, to: harness.collectionView)

        NotificationCenter.default.post(
            name: AudioPlayerService.stateDidChangeNotification,
            object: harness.audioPlayer,
            userInfo: nil
        )

        #expect(await waitForTimelineCondition(timeoutMs: 300) {
            await MainActor.run {
                harness.coordinator._audioStateRefreshCountForTesting == 1
            }
        })

        #expect(harness.coordinator._audioStateRefreshedItemIDsForTesting == ["audio-1", "audio-2"])
    }

    @MainActor
    @Test func audioStateChangeFromDifferentPlayerIsIgnored() async {
        let harness = makeTimelineHarness(sessionId: "session-a")

        let rows: [ChatItem] = [
            .audioClip(id: "audio-1", title: "Clip 1", fileURL: URL(fileURLWithPath: "/tmp/audio-1.wav"), timestamp: Date()),
        ]
        let config = makeTimelineConfiguration(
            items: rows,
            sessionId: "session-a",
            reducer: harness.reducer,
            toolOutputStore: harness.toolOutputStore,
            toolArgsStore: harness.toolArgsStore,
            connection: harness.connection,
            scrollController: harness.scrollController,
            audioPlayer: harness.audioPlayer
        )
        harness.coordinator.apply(configuration: config, to: harness.collectionView)

        let otherPlayer = AudioPlayerService()
        NotificationCenter.default.post(
            name: AudioPlayerService.stateDidChangeNotification,
            object: otherPlayer,
            userInfo: [
                AudioPlayerService.playingItemIDUserInfoKey: "audio-1",
            ]
        )

        try? await Task.sleep(for: .milliseconds(80))
        #expect(harness.coordinator._audioStateRefreshCountForTesting == 0)
    }
}
