import Foundation
import Testing
@testable import Oppi

// MARK: - Catch-Up Decision Logic

@Suite("SessionStreamCoordinator Catch-Up Decision")
@MainActor
struct SessionStreamCoordinatorCatchUpTests {

    @Test func noGapWhenCurrentSeqEqualsLastSeen() async {
        let coordinator = SessionStreamCoordinator()
        coordinator.seedLastSeenSeq(sessionId: "s1", value: 10)

        let decision = coordinator.catchUpDecision(sessionId: "s1", currentSeq: 10)

        #expect(decision == .noGap)
    }

    @Test func fetchSinceWhenCurrentSeqAhead() async {
        let coordinator = SessionStreamCoordinator()
        coordinator.seedLastSeenSeq(sessionId: "s1", value: 5)

        let decision = coordinator.catchUpDecision(sessionId: "s1", currentSeq: 10)

        #expect(decision == .fetchSince(5))
    }

    @Test func seqRegressionWhenCurrentSeqBehind() async {
        let coordinator = SessionStreamCoordinator()
        coordinator.seedLastSeenSeq(sessionId: "s1", value: 10)

        let decision = coordinator.catchUpDecision(sessionId: "s1", currentSeq: 5)

        #expect(decision == .seqRegression(resetTo: 5))
    }

    /// After seqRegression, lastSeen should be reset to currentSeq.
    /// Subsequent catchUpDecision with the same seq should show noGap.
    @Test func seqRegressionResetsLastSeenToCurrentSeq() async {
        let coordinator = SessionStreamCoordinator()
        coordinator.seedLastSeenSeq(sessionId: "s1", value: 10)

        _ = coordinator.catchUpDecision(sessionId: "s1", currentSeq: 5)

        let lastSeen = coordinator.lastSeenSeq(sessionId: "s1")
        #expect(lastSeen == 5, "After regression, lastSeen should be reset to currentSeq")

        let decision = coordinator.catchUpDecision(sessionId: "s1", currentSeq: 5)
        #expect(decision == .noGap, "Same seq after regression should be noGap")
    }

    /// Fresh session: lastSeen defaults to 0, server reports currentSeq 0.
    @Test func freshSessionWithCurrentSeqZeroGivesNoGap() async {
        let coordinator = SessionStreamCoordinator()

        let decision = coordinator.catchUpDecision(sessionId: "s1", currentSeq: 0)

        #expect(decision == .noGap)
    }

    /// Fresh session: lastSeen defaults to 0, server reports currentSeq > 0.
    /// Client missed all messages, needs to fetch from 0.
    @Test func freshSessionWithCurrentSeqAboveZeroGivesFetchSinceZero() async {
        let coordinator = SessionStreamCoordinator()

        let decision = coordinator.catchUpDecision(sessionId: "s1", currentSeq: 5)

        #expect(decision == .fetchSince(0))
    }

    /// Server restart scenario: had seq 10, server comes back with seq 0.
    @Test func seqRegressionToZero() async {
        let coordinator = SessionStreamCoordinator()
        coordinator.seedLastSeenSeq(sessionId: "s1", value: 10)

        let decision = coordinator.catchUpDecision(sessionId: "s1", currentSeq: 0)

        #expect(decision == .seqRegression(resetTo: 0))
        #expect(coordinator.lastSeenSeq(sessionId: "s1") == 0)
    }

    /// Integration: connect bound stream -> receive messages -> reconnect -> catch-up.
    /// After consuming messages up to seq 8, reconnect sees currentSeq 12.
    @Test func catchUpAfterConsumedMessages() async {
        let coordinator = SessionStreamCoordinator()
        coordinator.seedLastSeenSeq(sessionId: "s1", value: 5)

        // Receive messages 6, 7, 8 during streaming
        _ = coordinator.consumeLiveSeq(sessionId: "s1", seq: 6)
        _ = coordinator.consumeLiveSeq(sessionId: "s1", seq: 7)
        _ = coordinator.consumeLiveSeq(sessionId: "s1", seq: 8)

        // Reconnect — server reports currentSeq 12
        let decision = coordinator.catchUpDecision(sessionId: "s1", currentSeq: 12)

        // We last saw 8, server is at 12, fetch the gap
        #expect(decision == .fetchSince(8))
    }
}

// MARK: - consumeLiveSeq

@Suite("SessionStreamCoordinator consumeLiveSeq")
@MainActor
struct SessionStreamCoordinatorConsumeSeqTests {

    @Test func returnsTrueAndAdvancesForNewSeq() async {
        let coordinator = SessionStreamCoordinator()

        let consumed = coordinator.consumeLiveSeq(sessionId: "s1", seq: 1)

        #expect(consumed)
        #expect(coordinator.lastSeenSeq(sessionId: "s1") == 1)
    }

    @Test func returnsFalseForDuplicateSeq() async {
        let coordinator = SessionStreamCoordinator()
        _ = coordinator.consumeLiveSeq(sessionId: "s1", seq: 5)

        let consumed = coordinator.consumeLiveSeq(sessionId: "s1", seq: 5)

        #expect(!consumed, "Duplicate seq should be rejected")
    }

    @Test func returnsFalseForLowerSeq() async {
        let coordinator = SessionStreamCoordinator()
        _ = coordinator.consumeLiveSeq(sessionId: "s1", seq: 10)

        let consumed = coordinator.consumeLiveSeq(sessionId: "s1", seq: 5)

        #expect(!consumed, "Out-of-order (lower) seq should be rejected")
        #expect(coordinator.lastSeenSeq(sessionId: "s1") == 10, "lastSeen should not regress")
    }

    /// Edge case: seq 0 on a fresh session (lastSeen defaults to 0).
    /// The guard is `seq > current`, so 0 > 0 is false.
    ///
    /// FINDING: seq 0 is never consumable on a fresh session. If the
    /// server uses 0-based seq numbering, the first message would be
    /// silently dropped. In practice servers appear to use 1-based seqs.
    @Test func returnsFalseForSeqZeroOnFreshSession() async {
        let coordinator = SessionStreamCoordinator()

        let consumed = coordinator.consumeLiveSeq(sessionId: "s1", seq: 0)

        #expect(!consumed, "seq 0 rejected because guard requires seq > current (0)")
    }

    @Test func returnsTrueForSeq1OnFreshSession() async {
        let coordinator = SessionStreamCoordinator()

        let consumed = coordinator.consumeLiveSeq(sessionId: "s1", seq: 1)

        #expect(consumed)
    }

    @Test func sequentialConsumptionWorks() async {
        let coordinator = SessionStreamCoordinator()

        for seq in 1...10 {
            let consumed = coordinator.consumeLiveSeq(sessionId: "s1", seq: seq)
            #expect(consumed, "seq \(seq) should be consumed")
        }

        #expect(coordinator.lastSeenSeq(sessionId: "s1") == 10)
    }

    /// Server may skip seqs (messages for other subscriptions or gaps).
    /// Any seq > current should be accepted.
    @Test func nonSequentialGapsAreAccepted() async {
        let coordinator = SessionStreamCoordinator()

        #expect(coordinator.consumeLiveSeq(sessionId: "s1", seq: 1))
        #expect(coordinator.consumeLiveSeq(sessionId: "s1", seq: 5))
        #expect(coordinator.consumeLiveSeq(sessionId: "s1", seq: 10))

        #expect(coordinator.lastSeenSeq(sessionId: "s1") == 10)
    }

    /// After a regression resets lastSeen, previously-consumed seqs above
    /// the new baseline should be accepted again.
    @Test func consumeWorksAfterRegressionReset() async {
        let coordinator = SessionStreamCoordinator()

        // Consume up to seq 8
        for seq in 1...8 {
            _ = coordinator.consumeLiveSeq(sessionId: "s1", seq: seq)
        }

        // Regression to 3
        _ = coordinator.catchUpDecision(sessionId: "s1", currentSeq: 3)

        // Seq 5 was previously consumed, but after regression it's above baseline (3)
        let consumed = coordinator.consumeLiveSeq(sessionId: "s1", seq: 5)
        #expect(consumed, "After regression, seq 5 should be accepted (above new baseline 3)")
    }

    @Test func duplicateAndOutOfOrderSequencesAcceptOnlyMonotonicFrontier() {
        struct Scenario {
            let name: String
            let input: [Int]
            let accepted: [Int]
            let finalSeq: Int
        }

        let scenarios = [
            Scenario(name: "duplicates", input: [1, 1, 2, 2, 3], accepted: [1, 2, 3], finalSeq: 3),
            Scenario(name: "out of order", input: [4, 2, 5, 3, 6], accepted: [4, 5, 6], finalSeq: 6),
            Scenario(name: "gaps then stale", input: [2, 10, 7, 10, 11], accepted: [2, 10, 11], finalSeq: 11),
        ]

        for scenario in scenarios {
            let coordinator = SessionStreamCoordinator()
            let accepted = scenario.input.filter {
                coordinator.consumeLiveSeq(sessionId: scenario.name, seq: $0)
            }

            #expect(accepted == scenario.accepted, "\(scenario.name): accepted frontier")
            #expect(coordinator.lastSeenSeq(sessionId: scenario.name) == scenario.finalSeq)
        }
    }
}

// MARK: - Seq State Management

@Suite("SessionStreamCoordinator Seq State")
@MainActor
struct SessionStreamCoordinatorSeqStateTests {

    @Test func lastSeenSeqDefaultsToZero() async {
        let coordinator = SessionStreamCoordinator()

        #expect(coordinator.lastSeenSeq(sessionId: "never-seen") == 0)
    }

    @Test func seedSetsValue() async {
        let coordinator = SessionStreamCoordinator()

        coordinator.seedLastSeenSeq(sessionId: "s1", value: 42)

        #expect(coordinator.lastSeenSeq(sessionId: "s1") == 42)
    }

    @Test func seedOverwritesPreviousValue() async {
        let coordinator = SessionStreamCoordinator()
        coordinator.seedLastSeenSeq(sessionId: "s1", value: 10)

        coordinator.seedLastSeenSeq(sessionId: "s1", value: 20)

        #expect(coordinator.lastSeenSeq(sessionId: "s1") == 20)
    }

    @Test func applyCatchUpProgressAdvancesForward() async {
        let coordinator = SessionStreamCoordinator()
        coordinator.seedLastSeenSeq(sessionId: "s1", value: 5)

        coordinator.applyCatchUpProgress(sessionId: "s1", seq: 10)

        #expect(coordinator.lastSeenSeq(sessionId: "s1") == 10)
    }

    @Test func applyCatchUpProgressDoesNotRegress() async {
        let coordinator = SessionStreamCoordinator()
        coordinator.seedLastSeenSeq(sessionId: "s1", value: 10)

        coordinator.applyCatchUpProgress(sessionId: "s1", seq: 5)

        #expect(coordinator.lastSeenSeq(sessionId: "s1") == 10, "applyCatchUpProgress should not decrease lastSeen")
    }

    @Test func applyCatchUpProgressNoOpForEqualSeq() async {
        let coordinator = SessionStreamCoordinator()
        coordinator.seedLastSeenSeq(sessionId: "s1", value: 10)

        coordinator.applyCatchUpProgress(sessionId: "s1", seq: 10)

        #expect(coordinator.lastSeenSeq(sessionId: "s1") == 10)
    }

    /// Full lifecycle: seed -> consume -> apply catch-up -> catch-up decision
    @Test func fullLifecycleSequence() async {
        let coordinator = SessionStreamCoordinator()

        // 1. Subscribe, server reports currentSeq 5
        coordinator.seedLastSeenSeq(sessionId: "s1", value: 5)

        // 2. Receive live messages 6, 7, 8
        _ = coordinator.consumeLiveSeq(sessionId: "s1", seq: 6)
        _ = coordinator.consumeLiveSeq(sessionId: "s1", seq: 7)
        _ = coordinator.consumeLiveSeq(sessionId: "s1", seq: 8)
        #expect(coordinator.lastSeenSeq(sessionId: "s1") == 8)

        // 3. Reconnect, server reports currentSeq 12
        let decision = coordinator.catchUpDecision(sessionId: "s1", currentSeq: 12)
        #expect(decision == .fetchSince(8))

        // 4. Catch-up batch: apply progress for seqs 9, 10, 11, 12
        coordinator.applyCatchUpProgress(sessionId: "s1", seq: 12)
        #expect(coordinator.lastSeenSeq(sessionId: "s1") == 12)

        // 5. Verify no gap after catch-up
        let decision2 = coordinator.catchUpDecision(sessionId: "s1", currentSeq: 12)
        #expect(decision2 == .noGap)
    }
}

// MARK: - State Machine

@Suite("SessionStreamCoordinator State Machine")
@MainActor
struct SSCStateMachineTests {

    @Test func initialStateIsIdle() async {
        let coordinator = SessionStreamCoordinator()

        #expect(coordinator.state == .idle)
    }

    @Test func noteStreamDisconnectedResetsToIdle() async {
        let coordinator = SessionStreamCoordinator()

        coordinator.noteStreamDisconnected()

        #expect(coordinator.state == .idle)
    }

    /// noteStreamDisconnected is valid from idle (.disconnected is in
    /// idle's allowed events). Should remain idle without logging a
    /// warning about unexpected transitions.
    @Test func noteStreamDisconnectedFromIdleStaysIdle() async {
        let coordinator = SessionStreamCoordinator()
        #expect(coordinator.state == .idle)

        // Should not crash or produce unexpected behavior
        coordinator.noteStreamDisconnected()
        coordinator.noteStreamDisconnected()
        coordinator.noteStreamDisconnected()

        #expect(coordinator.state == .idle)
    }

    @Test func streamSessionPreservesExplicitThinkingLevelFromSessionSnapshot() async throws {
        let (connection, _) = makeTestConnection(sessionId: "s1")
        connection.sessionStore.upsert(makeTestSession(
            id: "s1",
            workspaceId: "w1",
            thinkingLevel: "max"
        ))
        connection.wsClient?._setStatusForTesting(.connected)
        connection.streamConsumptionTask = makeCancellableNeverCompletingTaskForTesting()
        connection.setFocusedSessionStreamEndpointKindForTesting("split_session")
        connection._sendMessageForTesting = { _ in }

        _ = try #require(await connection.sessionStreamCoordinator.streamSession(
            connection: connection,
            sessionId: "s1",
            workspaceId: "w1"
        ))

        #expect(connection.chatState.thinkingLevel == .max)
        connection.streamConsumptionTask?.cancel()
        connection.disconnectSession()
    }

    @Test func staleContinuationTerminationCannotRemoveReplacementForSameSession() async throws {
        let connection = ServerConnection()
        _ = connection.configure(credentials: makeTestCredentials())
        connection.wsClient?._setStatusForTesting(.connected)
        connection.streamConsumptionTask = makeCancellableNeverCompletingTaskForTesting()
        connection.setFocusedSessionStreamEndpointKindForTesting("split_session")
        connection._sendMessageForTesting = { _ in }
        let coordinator = connection.sessionStreamCoordinator

        let firstStream = try #require(await coordinator.streamSession(
            connection: connection,
            sessionId: "s1",
            workspaceId: "w1"
        ))
        let firstConsumer = Task {
            for await _ in firstStream {}
        }
        let secondStream = try #require(await coordinator.streamSession(
            connection: connection,
            sessionId: "s1",
            workspaceId: "w1"
        ))
        var replacementEvents: [SessionStreamEvent] = []
        let secondConsumer = Task { @MainActor in
            for await event in secondStream {
                replacementEvents.append(event)
            }
        }

        firstConsumer.cancel()
        await firstConsumer.value
        for _ in 0..<5 { await Task.yield() }

        connection.routeStreamMessage(StreamMessage(
            sessionId: "s1",
            seq: 1,
            currentSeq: 1,
            message: .agentStart
        ))

        #expect(await waitForMainActorCondition(timeout: .milliseconds(500)) {
            replacementEvents.count == 1
        })
        #expect(replacementEvents.first?.message == .agentStart)
        #expect(connection.sessionEventContinuations["s1"] != nil)

        secondConsumer.cancel()
        connection.disconnectSession()
    }
}

// MARK: - Multi-Session Isolation

@Suite("SessionStreamCoordinator Multi-Session Isolation")
@MainActor
struct SSCMultiSessionTests {

    @Test func lastSeenSeqIndependentPerSession() async {
        let coordinator = SessionStreamCoordinator()
        coordinator.seedLastSeenSeq(sessionId: "s1", value: 10)
        coordinator.seedLastSeenSeq(sessionId: "s2", value: 20)

        #expect(coordinator.lastSeenSeq(sessionId: "s1") == 10)
        #expect(coordinator.lastSeenSeq(sessionId: "s2") == 20)
        #expect(coordinator.lastSeenSeq(sessionId: "s3") == 0)
    }

    @Test func consumeLiveSeqIndependentPerSession() async {
        let coordinator = SessionStreamCoordinator()
        _ = coordinator.consumeLiveSeq(sessionId: "s1", seq: 5)
        _ = coordinator.consumeLiveSeq(sessionId: "s2", seq: 10)

        // Consuming for s1 should not affect s2
        let consumed = coordinator.consumeLiveSeq(sessionId: "s1", seq: 6)
        #expect(consumed)
        #expect(coordinator.lastSeenSeq(sessionId: "s1") == 6)
        #expect(coordinator.lastSeenSeq(sessionId: "s2") == 10)
    }

    @Test func catchUpDecisionIndependentPerSession() async {
        let coordinator = SessionStreamCoordinator()
        coordinator.seedLastSeenSeq(sessionId: "s1", value: 5)
        coordinator.seedLastSeenSeq(sessionId: "s2", value: 15)

        let d1 = coordinator.catchUpDecision(sessionId: "s1", currentSeq: 10)
        let d2 = coordinator.catchUpDecision(sessionId: "s2", currentSeq: 10)

        #expect(d1 == .fetchSince(5), "s1: lastSeen 5, server at 10, need catch-up")
        #expect(d2 == .seqRegression(resetTo: 10), "s2: lastSeen 15, server at 10, regression")
    }

    @Test func applyCatchUpProgressIndependentPerSession() async {
        let coordinator = SessionStreamCoordinator()
        coordinator.seedLastSeenSeq(sessionId: "s1", value: 5)
        coordinator.seedLastSeenSeq(sessionId: "s2", value: 5)

        coordinator.applyCatchUpProgress(sessionId: "s1", seq: 20)

        #expect(coordinator.lastSeenSeq(sessionId: "s1") == 20)
        #expect(coordinator.lastSeenSeq(sessionId: "s2") == 5, "s2 should be unaffected")
    }

    /// Regression on one session should not affect another.
    @Test func regressionIsolatedPerSession() async {
        let coordinator = SessionStreamCoordinator()
        coordinator.seedLastSeenSeq(sessionId: "s1", value: 10)
        coordinator.seedLastSeenSeq(sessionId: "s2", value: 10)

        // Regress s1 to 3
        let d1 = coordinator.catchUpDecision(sessionId: "s1", currentSeq: 3)
        #expect(d1 == .seqRegression(resetTo: 3))

        // s2 should be unaffected
        #expect(coordinator.lastSeenSeq(sessionId: "s1") == 3)
        #expect(coordinator.lastSeenSeq(sessionId: "s2") == 10)

        // s2 can still catch up normally
        let d2 = coordinator.catchUpDecision(sessionId: "s2", currentSeq: 15)
        #expect(d2 == .fetchSince(10))
    }
}
