// AppStateLifecycleTests.swift
// T-2.10 — Background lifecycle: AppState surface for lifecycle events.
//
// Covers:
//   • Face-ID gate regression (appDidBackground / appWillForeground existing logic).
//   • New T-2.10 requirement: AppState must publish lifecycle transitions so
//     an active SessionConnection can react (suspend on background, resume on
//     foreground).
//
// Strategy: stub extension at the bottom provides `lifecycleTransitions` as an
// always-empty publisher. Tests expecting emissions will FAIL until the impl
// agent adds a real publisher on AppState.

import Combine
import XCTest
@testable import piRemote

@MainActor
final class AppStateLifecycleTests: XCTestCase {

    private var cancellables = Set<AnyCancellable>()

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

    // =========================================================================
    // MARK: 1. Face-ID gate regression (existing logic must not regress)
    // =========================================================================

    /// Within the 60-second grace period, appWillForeground must NOT lock.
    func test_appWillForeground_withinGracePeriod_doesNotLock() async {
        let state = AppState.shared
        // Simulate a recent background event (< 60 s ago)
        state.appDidBackground()
        // Immediately foreground — elapsed ≈ 0 s, well within the 60 s window.
        await state.appWillForeground()
        XCTAssertFalse(state.isLocked,
            "Within 60 s grace period, appWillForeground must NOT set isLocked=true")
    }

    /// appDidBackground() must record the current date in lastForegroundedAt
    /// so the elapsed time calculation is correct on the next foreground.
    func test_appDidBackground_updatesLastForegroundedAt() {
        let state = AppState.shared
        let before = Date()
        state.appDidBackground()
        let after = Date()
        XCTAssertGreaterThanOrEqual(state.lastForegroundedAt, before,
            "lastForegroundedAt must be updated to ≥ the call time")
        XCTAssertLessThanOrEqual(state.lastForegroundedAt, after,
            "lastForegroundedAt must be updated to ≤ the call time")
    }

    // =========================================================================
    // MARK: 2. T-2.10 — AppState must publish background/foreground transitions
    //
    // FAILS until impl agent adds lifecycleTransitions publisher to AppState.
    // =========================================================================

    /// After appDidBackground(), lifecycleTransitions must emit `true`.
    ///
    /// FAILS: stub publisher never emits → expectation times out.
    func test_appDidBackground_publishesBackgroundTransition() {
        let state = AppState.shared
        let exp = expectation(description: "lifecycleTransitions emits true on background")

        state.lifecycleTransitions
            .filter { $0 == true }
            .first()
            .sink { _ in exp.fulfill() }
            .store(in: &cancellables)

        state.appDidBackground()

        // Stub publisher never emits → this wait TIMES OUT → test FAILS.
        // Impl agent: add an AnyPublisher<Bool, Never> on AppState that sends
        // `true` from appDidBackground() and `false` from appWillForeground().
        wait(for: [exp], timeout: 0.5)
    }

    /// After appWillForeground(), lifecycleTransitions must emit `false`.
    ///
    /// FAILS: stub publisher never emits → expectation times out.
    func test_appWillForeground_publishesForegroundTransition() async {
        let state = AppState.shared
        // Prime with a recent timestamp so FaceID gate is skipped.
        state.appDidBackground()

        let exp = expectation(description: "lifecycleTransitions emits false on foreground")

        state.lifecycleTransitions
            .filter { $0 == false }
            .first()
            .sink { _ in exp.fulfill() }
            .store(in: &cancellables)

        await state.appWillForeground()

        // Stub publisher never emits → this wait TIMES OUT → test FAILS.
        await fulfillment(of: [exp], timeout: 0.5)
    }

    /// lifecycleTransitions must emit in the correct order: true then false.
    ///
    /// FAILS: stub publisher never emits → first expectation times out.
    func test_lifecycleTransitions_correctOrder_backgroundThenForeground() async {
        let state = AppState.shared

        var emissions: [Bool] = []
        let backgroundExp = expectation(description: "background emission (true)")
        let foregroundExp = expectation(description: "foreground emission (false)")

        state.lifecycleTransitions
            .prefix(2)
            .collect()
            .sink { values in
                emissions = values
                if values.first == true { backgroundExp.fulfill() }
                if values.last == false { foregroundExp.fulfill() }
            }
            .store(in: &cancellables)

        state.appDidBackground()
        await state.appWillForeground()

        // Stub publisher never emits → both expectations time out → FAILS.
        await fulfillment(of: [backgroundExp, foregroundExp], timeout: 0.5)
        XCTAssertEqual(emissions, [true, false],
            "T-2.10: lifecycleTransitions must emit [true, false] for " +
            "background→foreground sequence")
    }

    // =========================================================================
    // MARK: 3. T-2.10 — Lifecycle transitions must be accessible for connection
    //         subscription without requiring access to ContentView internals.
    // =========================================================================

    /// lifecycleTransitions must be a valid publisher (non-nil) on AppState.
    /// Tests the structural presence of the publisher surface.
    ///
    /// PASSES immediately once the property is added; the emission tests above
    /// verify that it actually fires.
    func test_lifecycleTransitions_publisherExists() {
        // This test passes once lifecycleTransitions property exists on AppState.
        // It always FAILS because the stub (Empty publisher) technically exists,
        // but we add a stronger check: we verify the publisher has sent at least
        // one value by checking a side-channel flag.
        //
        // Use a simple check: the value we receive after posting a background event
        // is `true`. Since stub never emits, `receivedValue` stays nil → FAILS.

        let state = AppState.shared
        var receivedValue: Bool? = nil
        state.lifecycleTransitions
            .first()
            .sink { value in receivedValue = value }
            .store(in: &cancellables)

        state.appDidBackground()

        // Stub never emits → receivedValue stays nil → FAILS.
        XCTAssertNotNil(receivedValue,
            "T-2.10: AppState.lifecycleTransitions must emit when appDidBackground() is called. " +
            "Impl agent: add a PassthroughSubject<Bool, Never> to AppState, send(true) in " +
            "appDidBackground(), send(false) in appWillForeground(). Expose as " +
            "AnyPublisher<Bool, Never>.")
    }
}

// MARK: - Stub removed
// lifecycleTransitions is now implemented on AppState directly (T-2.10 impl).

// MARK: - CG-2: Post-Face-ID reconnect signal

@MainActor
final class PostFaceIDReconnectTests: XCTestCase {

    private var cancellables = Set<AnyCancellable>()

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

    /// CG-2 (depends on N-2): When elapsed > 60 s and Face-ID succeeds,
    /// appWillForeground must emit TWO `false` transitions:
    ///   1. The first emission fires before Face-ID (isLocked=true at that
    ///      point — MainTerminalView skips the reconnect via the guard).
    ///   2. After successful auth isLocked=false, a second emission fires
    ///      so MainTerminalView actually re-resumes the connection.
    ///
    /// Face-ID is disabled in this test (faceid.enabled not set), so
    /// FaceIDGate.authenticate() returns `true` immediately without UI.
    func test_cg2_postFaceID_emitsForegroundTwiceOnAuthSuccess() async throws {
        let state = AppState.shared

        // Ensure Face ID is disabled → FaceIDGate.authenticate() returns true.
        UserDefaults.standard.removeObject(forKey: "faceid.enabled")

        // Prime a "recent" background event, then rewind the timestamp so
        // elapsed > 60 s (bypasses the grace-period guard).
        state.appDidBackground()
        state.lastForegroundedAt = Date().addingTimeInterval(-70)

        var falseEmissions = 0
        let exp = expectation(description: "two false emissions after Face-ID success")
        exp.expectedFulfillmentCount = 2

        state.lifecycleTransitions
            .filter { $0 == false }
            .sink { _ in
                falseEmissions += 1
                if falseEmissions <= 2 { exp.fulfill() }
            }
            .store(in: &cancellables)

        await state.appWillForeground()

        await fulfillment(of: [exp], timeout: 3.0)

        XCTAssertEqual(falseEmissions, 2,
            "CG-2 / N-2: exactly two false emissions expected " +
            "(one pre-auth, one post-auth-success)")
        XCTAssertFalse(state.isLocked,
            "CG-2: isLocked must be false after successful Face-ID auth")
    }

    /// CG-2 guard: within the 60 s grace period only ONE false emission occurs
    /// (no spurious second emission from the N-2 fix).
    func test_cg2_withinGracePeriod_emitsForegroundOnce() async throws {
        let state = AppState.shared
        UserDefaults.standard.removeObject(forKey: "faceid.enabled")

        state.appDidBackground()
        // Do NOT rewind timestamp — elapsed ≈ 0 s, within grace period.

        var falseCount = 0
        state.lifecycleTransitions
            .filter { $0 == false }
            .sink { _ in falseCount += 1 }
            .store(in: &cancellables)

        await state.appWillForeground()

        // Allow any async propagation to settle.
        try await Task.sleep(nanoseconds: 100_000_000)

        XCTAssertEqual(falseCount, 1,
            "CG-2 guard: within grace period must emit exactly ONE false " +
            "(no spurious second emission from N-2 fix)")
    }
}
