// 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 nonisolated(unsafe) var cancellables = Set<AnyCancellable>()

    override func tearDown() {
        cancellables.removeAll()
        super.tearDown()
    }

    // =========================================================================
    // 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.
