// SessionConnectionLifecycleTests.swift
// T-2.10 — Background lifecycle: suspend / resume / stale-frame-freeze /
//           keep-alive foreground-only / idempotency / reconnect latency.
//
// Strategy: tests compile against stub extensions at the bottom of this file.
// The stubs return sentinel values that make every T-2.10-specific assertion
// fail. The impl agent replaces the stubs with real implementations inside
// SessionConnection.swift.

import Combine
import XCTest
@testable import piRemote

// MARK: - Helpers

private func fakeCredential() -> SidecarCredential {
    SidecarCredential(
        sidecarId: "test-sidecar",
        host: "127.0.0.1",
        port: 19991,           // unreachable localhost port — no real WS needed
        bearerToken: "test-token",
        tlsFingerprint: "deadbeef",
        sidecarName: "test",
        pairedAt: Date()
    )
}

// MARK: - Test class

@MainActor
final class SessionConnectionLifecycleTests: XCTestCase {

    private var cancellables = Set<AnyCancellable>()

    override func tearDown() async throws {
        cancellables.removeAll()
    }

    // =========================================================================
    // MARK: 1. Suspend / Resume basic state transitions
    // =========================================================================

    /// Regression: suspend() always leaves state as .disconnected.
    func test_suspend_setsConnectionStateToDisconnected() async {
        let conn = SessionConnection(id: "s1", credential: fakeCredential())
        XCTAssertEqual(conn.connectionState, .disconnected,
                       "Fresh connection must start .disconnected")
        await conn.suspend()
        XCTAssertEqual(conn.connectionState, .disconnected,
                       "suspend() must keep/leave connectionState as .disconnected")
    }

    /// Calling suspend() twice must not crash or corrupt state.
    func test_idempotentSuspend_doesNotCrash() async {
        let conn = SessionConnection(id: "s2", credential: fakeCredential())
        await conn.suspend()
        await conn.suspend()   // second call — must be a no-op
        XCTAssertEqual(conn.connectionState, .disconnected,
                       "Double suspend must remain .disconnected")
    }

    /// resume(from:nil) must transition the connection to .connecting.
    func test_resume_nil_transitionsToConnecting() async throws {
        let conn = SessionConnection(id: "s3", credential: fakeCredential())
        let exp = expectation(description: "connectionState reaches .connecting")

        conn.$connectionState
            .filter { $0 == .connecting }
            .first()
            .sink { _ in exp.fulfill() }
            .store(in: &cancellables)

        await conn.resume(from: nil)
        await fulfillment(of: [exp], timeout: 2.0)
    }

    /// After an explicit suspend(), resume(from:lastSeq) must reconnect.
    func test_resumeAfterSuspend_transitionsToConnecting() async throws {
        let conn = SessionConnection(id: "s4", credential: fakeCredential())
        await conn.suspend()

        let exp = expectation(description: "resume after suspend reaches .connecting")
        conn.$connectionState
            .filter { $0 == .connecting }
            .first()
            .sink { _ in exp.fulfill() }
            .store(in: &cancellables)

        await conn.resume(from: 42)
        await fulfillment(of: [exp], timeout: 2.0)
    }

    /// Calling resume() twice must not crash and must leave the connection in
    /// a consistent (suspend-able) state. This is a regression guard — the
    /// existing resume() implementation calls suspend() first so it is already
    /// idempotent; this test should PASS and must not regress.
    func test_idempotentResume_doesNotCrashOrOrphanPublishers() async throws {
        let conn = SessionConnection(id: "s5", credential: fakeCredential())

        // Count how many times state transitions to .connecting; a correct
        // double-resume may produce at most 2 transitions (one per call).
        var connectingCount = 0
        conn.$connectionState
            .filter { $0 == .connecting }
            .sink { _ in connectingCount += 1 }
            .store(in: &cancellables)

        await conn.resume(from: nil)
        await conn.resume(from: nil)   // second call — must not crash

        // Teardown must always succeed, regardless of double-resume.
        await conn.suspend()
        XCTAssertEqual(conn.connectionState, .disconnected,
            "After suspend following double-resume, state must be .disconnected")

        // At most 2 .connecting transitions (one per resume() call).
        XCTAssertLessThanOrEqual(connectingCount, 2,
            "Double resume must not produce more than 2 .connecting transitions")
    }

    // =========================================================================
    // MARK: 2. Stale-frame freeze
    //
    // During the reconnect window (after resume(from: nonNil)), the stream
    // publisher must NOT forward new frames to consumers until the first delta
    // chunk arrives OR a snapshot completes. This prevents stale/mismatched
    // content being fed to the terminal.
    //
    // FAILS until impl adds SessionConnection.isStreamFrozen.
    // =========================================================================

    /// After resume(from: nonNil), the stream must be frozen.
    func test_staleFrameFreeze_isFrozenDuringReconnect() async throws {
        let conn = SessionConnection(id: "freeze-1", credential: fakeCredential())
        await conn.resume(from: 100)   // non-nil seq → reconnect scenario

        // Allow the async connect sequence to start
        try await Task.sleep(nanoseconds: 50_000_000)  // 50 ms

        // FAILS: isStreamFrozen stub returns false; real impl must return true
        // during the reconnect window.
        XCTAssertTrue(conn.isStreamFrozen,
            "T-2.10: Stream must be frozen while reconnecting (after resume with non-nil lastSeq). " +
            "Impl agent: add SessionConnection.isStreamFrozen: Bool that returns true between " +
            "resume(from: nonNil) and first delta/snapshot arrival.")

        await conn.suspend()
    }

    /// After a fresh attach (resume(from: nil)), freeze should NOT be active —
    /// the initial snapshot flow handles the cleared screen.
    func test_staleFrameFreeze_notFrozenOnFreshAttach() async throws {
        let conn = SessionConnection(id: "freeze-2", credential: fakeCredential())
        await conn.resume(from: nil)   // nil seq → fresh attach, no freeze needed

        try await Task.sleep(nanoseconds: 50_000_000)  // 50 ms

        // The impl may set isStreamFrozen=false on fresh attach. The stub also
        // returns false, so this assertion PASSES either way — it serves as a
        // specification of intent and a future regression guard.
        XCTAssertFalse(conn.isStreamFrozen,
            "T-2.10: Fresh attach (lastSeq=nil) must NOT freeze the stream.")

        await conn.suspend()
    }

    // =========================================================================
    // MARK: 3. Keep-alive ping is foreground-only
    //
    // FAILS until impl adds SessionConnection.isKeepAliveActive.
    // =========================================================================

    /// After resume(), the keep-alive heartbeat must be scheduled (active).
    func test_keepAlivePing_activeAfterResume() async throws {
        let conn = SessionConnection(id: "ping-1", credential: fakeCredential())
        await conn.resume(from: nil)
        try await Task.sleep(nanoseconds: 50_000_000)  // 50 ms

        // FAILS: stub returns false; real impl must return true when connected/connecting.
        XCTAssertTrue(conn.isKeepAliveActive,
            "T-2.10: Keep-alive heartbeat must be active while the connection is open. " +
            "Impl agent: add SessionConnection.isKeepAliveActive: Bool and schedule a " +
            "repeating ping timer on resume().")

        await conn.suspend()
    }

    /// After suspend(), the keep-alive heartbeat must be cancelled.
    func test_keepAlivePing_inactiveAfterSuspend() async throws {
        let conn = SessionConnection(id: "ping-2", credential: fakeCredential())
        await conn.resume(from: nil)
        try await Task.sleep(nanoseconds: 50_000_000)  // 50 ms

        // First check that it was active — this assertion will FAIL
        // (see test_keepAlivePing_activeAfterResume). continueAfterFailure=true
        // so we continue to the suspend check.
        XCTAssertTrue(conn.isKeepAliveActive,
            "T-2.10: Keep-alive must be active after resume() (precondition for this test).")

        await conn.suspend()

        XCTAssertFalse(conn.isKeepAliveActive,
            "T-2.10: Keep-alive must be stopped after suspend().")
    }

    // =========================================================================
    // MARK: 4. Reconnect latency P-3
    //
    // The suspend→resume roundtrip must have no artificial delays blocking the
    // reconnect path. P-3 acceptance: <1 s on LAN. Here we check that the
    // in-process part (excluding actual TCP round-trip) takes <200 ms.
    // =========================================================================

    func test_reconnectLatency_suspendResumeCycleUnder200ms() async throws {
        let conn = SessionConnection(id: "latency-1", credential: fakeCredential())
        await conn.resume(from: nil)
        try await Task.sleep(nanoseconds: 50_000_000)  // let .connecting settle

        let start = Date()
        await conn.suspend()
        await conn.resume(from: 0)
        let elapsed = Date().timeIntervalSince(start)

        XCTAssertLessThan(elapsed, 0.2,
            "T-2.10 P-3: suspend()+resume() in-process roundtrip must be <200 ms " +
            "(i.e. no artificial sleep / synchronous blocking in the reconnect path)")

        await conn.suspend()
    }
}

// MARK: - Stubs removed
// isStreamFrozen and isKeepAliveActive are now implemented on SessionConnection
// directly (T-2.10 impl). The extension stubs above would cause a redeclaration
// error and are therefore removed.

// MARK: - CG-1: End-to-end real-seq path

@MainActor
final class EndToEndSeqPathTests: XCTestCase {

    private var cancellables = Set<AnyCancellable>()

    override func tearDown() async throws {
        cancellables.removeAll()
    }

    /// CG-1: Verifies the full production path:
    ///   binary frame received
    ///     → ResumeCursor.update (B-1 fix)
    ///     → background (lastCapturedSeq = cursor.lastSeq)
    ///     → foreground resume(from: nonNil)
    ///     → isStreamFrozen = true
    ///     → first delta received → isStreamFrozen = false, forwarded to stream
    ///
    /// Uses no hardcoded seq sentinels and exercises the real cursor update
    /// path via `_testOnly_receiveBinaryFrame` (which calls `handleBinaryFrame`,
    /// the same private method the production `incomingBinary` sink invokes).
    func test_cg1_endToEnd_realSeqFlowsThrough() async throws {
        // Isolated cursor so this test doesn't pollute real UserDefaults.
        let suiteName = "CG1.\(UUID().uuidString)"
        let defaults  = UserDefaults(suiteName: suiteName)!
        let cursor    = ResumeCursor(defaults: defaults)
        defer { defaults.removePersistentDomain(forName: suiteName) }

        let conn = SessionConnection(
            id: "cg1-session",
            credential: fakeCredential(),
            cursor: cursor
        )

        // ── Step 1: receive a binary frame with a real seq ────────────────
        let seq1: UInt64 = 42
        let frame1 = BinaryFrame(seq: seq1, data: Data("hello".utf8))
        conn._testOnly_receiveBinaryFrame(frame1)

        // ── Step 2: cursor is now updated (what MainTerminalView reads on bg)
        let persistedSeq = cursor.lastSeq(for: "cg1-session")
        XCTAssertEqual(persistedSeq, seq1,
            "CG-1 / B-1: ResumeCursor must be updated with the frame's seq.")

        // ── Step 3: simulate background — capture the cursor value ────────
        let lastCapturedSeq = persistedSeq          // non-nil because B-1 is fixed
        XCTAssertNotNil(lastCapturedSeq,
            "CG-1: lastCapturedSeq must be non-nil after a real binary frame")

        // ── Step 4: foreground — resume from captured seq ─────────────────
        await conn.resume(from: lastCapturedSeq)
        try await Task.sleep(nanoseconds: 50_000_000)  // 50 ms

        XCTAssertTrue(conn.isStreamFrozen,
            "CG-1: isStreamFrozen must be true immediately after resume(from: nonNil)")

        // ── Step 5: first delta arrives → freeze clears, stream receives data
        var receivedData: [Data] = []
        let streamExp = expectation(description: "first delta forwarded to stream")
        conn.stream
            .first()
            .sink { data in
                receivedData.append(data)
                streamExp.fulfill()
            }
            .store(in: &cancellables)

        let frame2 = BinaryFrame(seq: seq1 + 1, data: Data("world".utf8))
        conn._testOnly_receiveBinaryFrame(frame2)

        await fulfillment(of: [streamExp], timeout: 1.0)

        XCTAssertFalse(conn.isStreamFrozen,
            "CG-1: isStreamFrozen must clear on first binary frame (first delta IS forwarded)")
        XCTAssertEqual(receivedData.count, 1,
            "CG-1: Exactly one frame must reach stream")
        XCTAssertEqual(receivedData.first, Data("world".utf8),
            "CG-1: The delta payload must be forwarded unchanged")
        XCTAssertEqual(cursor.lastSeq(for: "cg1-session"), seq1 + 1,
            "CG-1: Cursor must be updated to the new seq after the first delta")

        await conn.suspend()
    }
}

// MARK: - CG-3: isStreamFrozen gating behaviour

@MainActor
final class StreamFrozenGatingTests: XCTestCase {

    private var cancellables = Set<AnyCancellable>()

    override func tearDown() async throws {
        cancellables.removeAll()
    }

    /// CG-3 decision: INFORMATIONAL (not a hard gate on stream delivery).
    ///
    /// In the IC-1 protocol the server only starts sending bytes after it
    /// processes our `resume` frame, so there are no stale bytes that could
    /// arrive while `isStreamFrozen == true`. The first binary frame IS
    /// the first meaningful delta and IS forwarded to `stream`.
    ///
    /// `isStreamFrozen` drives the status-bar label ("Reconnecting…" vs
    /// "Connecting…") and exposes the freeze state for observability, but
    /// does not technically block bytes from reaching `stream.send()`.
    ///
    /// This test verifies:
    ///  - After resume(from: nonNil): isStreamFrozen = true
    ///  - After the first binary frame: isStreamFrozen = false
    ///  - The first binary frame IS forwarded to stream (first delta IS forwarded)
    func test_cg3_firstDeltaThawsAndIsForwarded() async throws {
        let conn = SessionConnection(id: "cg3", credential: fakeCredential())

        await conn.resume(from: 50)   // non-nil → freeze
        try await Task.sleep(nanoseconds: 50_000_000)
        XCTAssertTrue(conn.isStreamFrozen, "Must be frozen after resume(from: nonNil)")

        var received: [Data] = []
        conn.stream
            .sink { received.append($0) }
            .store(in: &cancellables)

        // First delta arrives while frozen: must thaw AND be forwarded.
        let firstDelta = BinaryFrame(seq: 51, data: Data("delta".utf8))
        conn._testOnly_receiveBinaryFrame(firstDelta)

        XCTAssertFalse(conn.isStreamFrozen,
            "CG-3: First binary frame must clear isStreamFrozen")
        XCTAssertEqual(received.count, 1,
            "CG-3: First delta must be forwarded to stream (not dropped)")

        // Subsequent frames also flow normally.
        conn._testOnly_receiveBinaryFrame(BinaryFrame(seq: 52, data: Data("more".utf8)))
        XCTAssertEqual(received.count, 2,
            "CG-3: Subsequent frames must also be forwarded")

        await conn.suspend()
    }

    /// Additional CG-3 guard: fresh attach (nil seq) must never set isStreamFrozen.
    func test_cg3_freshAttach_neverFreezes() async throws {
        let conn = SessionConnection(id: "cg3-fresh", credential: fakeCredential())
        await conn.resume(from: nil)   // nil → no freeze
        try await Task.sleep(nanoseconds: 50_000_000)
        XCTAssertFalse(conn.isStreamFrozen,
            "CG-3: Fresh attach must not freeze stream")
        await conn.suspend()
    }
}

// MARK: - CG-4: Multi-cycle reconnect with seq progression

@MainActor
final class MultiCycleSeqTests: XCTestCase {

    private var cancellables = Set<AnyCancellable>()

    override func tearDown() async throws {
        cancellables.removeAll()
    }

    /// CG-4: Two full suspend/resume cycles with seq advancing on each.
    ///   Cycle 1: receive seq=100 → suspend → resume(from:100) → frozen
    ///   Cycle 2: receive seq=120 → suspend → resume(from:120) → frozen
    /// Verifies cursor advances and isStreamFrozen toggles correctly.
    func test_cg4_multiCycle_seqProgression() async throws {
        let suiteName = "CG4.\(UUID().uuidString)"
        let defaults  = UserDefaults(suiteName: suiteName)!
        let cursor    = ResumeCursor(defaults: defaults)
        defer { defaults.removePersistentDomain(forName: suiteName) }

        let conn = SessionConnection(
            id: "cg4-session",
            credential: fakeCredential(),
            cursor: cursor
        )

        // ──────────────────────────────── Cycle 1 ────────────────────────────
        // Receive a frame with seq=100.
        conn._testOnly_receiveBinaryFrame(BinaryFrame(seq: 100, data: Data("a".utf8)))
        XCTAssertEqual(cursor.lastSeq(for: "cg4-session"), 100,
            "CG-4 cycle 1: cursor must be 100 after receiving seq=100")

        // Suspend (isStreamFrozen cleared by suspend).
        await conn.suspend()
        XCTAssertFalse(conn.isStreamFrozen, "suspend() must clear isStreamFrozen")

        // Resume from captured seq=100 → frozen.
        await conn.resume(from: 100)
        try await Task.sleep(nanoseconds: 50_000_000)
        XCTAssertTrue(conn.isStreamFrozen,
            "CG-4 cycle 1: must be frozen after resume(from: 100)")

        // Receive seq=120 → thaws.
        conn._testOnly_receiveBinaryFrame(BinaryFrame(seq: 120, data: Data("b".utf8)))
        XCTAssertFalse(conn.isStreamFrozen,
            "CG-4 cycle 1: first delta must clear freeze")
        XCTAssertEqual(cursor.lastSeq(for: "cg4-session"), 120,
            "CG-4 cycle 1: cursor must advance to 120")

        // ──────────────────────────────── Cycle 2 ────────────────────────────
        await conn.suspend()
        XCTAssertFalse(conn.isStreamFrozen, "suspend() must clear isStreamFrozen")

        // Resume from captured seq=120 → frozen.
        await conn.resume(from: 120)
        try await Task.sleep(nanoseconds: 50_000_000)
        XCTAssertTrue(conn.isStreamFrozen,
            "CG-4 cycle 2: must be frozen after resume(from: 120)")

        // One more frame → thaws and cursor advances.
        conn._testOnly_receiveBinaryFrame(BinaryFrame(seq: 150, data: Data("c".utf8)))
        XCTAssertFalse(conn.isStreamFrozen,
            "CG-4 cycle 2: second cycle delta must also clear freeze")
        XCTAssertEqual(cursor.lastSeq(for: "cg4-session"), 150,
            "CG-4 cycle 2: cursor must advance to 150")

        await conn.suspend()
    }
}
