//
// VoiceCaptureSessionTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//

import Foundation
import Testing
@testable import bitchat

@MainActor
private final class StubPTTCapture: PTTCapturing {
    var onFrames: (([Data]) -> Void)?
    var stopResult: (url: URL?, encodedFrames: Int)
    var startError: Error?
    private(set) var startCount = 0
    private(set) var cancelCount = 0

    init(
        stopResult: (url: URL?, encodedFrames: Int),
        startError: Error? = nil
    ) {
        self.stopResult = stopResult
        self.startError = startError
    }

    func start(outputURL: URL) async throws {
        startCount += 1
        if let startError {
            throw startError
        }
    }

    func stop() -> (url: URL?, encodedFrames: Int) {
        stopResult
    }

    func cancel() {
        cancelCount += 1
    }
}

private final class CaptureLeaseSession: SessionApplying, @unchecked Sendable {
    private let lock = NSLock()
    private var _activationCalls: [Bool] = []

    var activationCalls: [Bool] { lock.withLock { _activationCalls } }

    func setCategory(_ category: AudioSessionCoordinator.Category) throws {}

    func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
        lock.withLock { _activationCalls.append(active) }
    }
}

@MainActor
private final class GatedVoiceCaptureSession: VoiceCaptureSession {
    let isLive = false
    private let startError: Error?
    private(set) var finishStarted = false
    private(set) var cancelCount = 0
    private(set) var panicCancelCount = 0
    private var finishContinuation: CheckedContinuation<URL?, Never>?

    init(startError: Error? = nil) {
        self.startError = startError
    }

    func requestPermission() async -> Bool { true }
    func start() async throws {
        if let startError { throw startError }
    }

    func finish() async -> URL? {
        finishStarted = true
        return await withCheckedContinuation { continuation in
            finishContinuation = continuation
        }
    }

    func cancel() async {
        cancelCount += 1
    }

    func panicCancelSynchronously() {
        panicCancelCount += 1
    }

    func resolveFinish(with url: URL?) {
        let continuation = finishContinuation
        finishContinuation = nil
        continuation?.resume(returning: url)
    }
}

@MainActor
struct VoiceCaptureSessionTests {
    private func waitUntil(
        _ condition: () -> Bool,
        sourceLocation: SourceLocation = #_sourceLocation
    ) async {
        let deadline = ContinuousClock.now.advanced(by: .seconds(5))
        while !condition(), ContinuousClock.now < deadline {
            await Task.yield()
            try? await Task.sleep(nanoseconds: 1_000_000)
        }
        #expect(condition(), sourceLocation: sourceLocation)
    }

    private func isRecording(_ state: VoiceRecordingViewModel.State) -> Bool {
        if case .recording = state { return true }
        return false
    }

    @Test func staleCaptureCallbackCannotInvalidateNewGeneration() {
        let generations = PTTCaptureGeneration()
        let old = generations.begin()
        generations.invalidate()
        let current = generations.begin()

        #expect(!generations.invalidate(ifCurrent: old))
        #expect(generations.isCurrent(current))
        #expect(generations.invalidate(ifCurrent: current))
        #expect(!generations.isCurrent(current))
    }

    @Test func coordinatorCancellationIsNotReportedAsAStartedCapture() async {
        let capture = StubPTTCapture(
            stopResult: (nil, 0),
            startError: CancellationError()
        )
        let session = PTTLiveVoiceSession(
            sendPacket: { _ in },
            capture: capture
        )

        await #expect(throws: CancellationError.self) {
            try await session.start()
        }
    }

    @Test func interruptedShortCaptureIsCanceledEvenAfterLongHold() async throws {
        let url = FileManager.default.temporaryDirectory
            .appendingPathComponent("ptt-interrupted-test-\(UUID().uuidString).m4a")
        _ = FileManager.default.createFile(atPath: url.path, contents: Data([0x00]))
        defer { try? FileManager.default.removeItem(at: url) }

        let capture = StubPTTCapture(stopResult: (url, 1))
        var sentPackets: [Data] = []
        var now = Date()
        let session = PTTLiveVoiceSession(
            sendPacket: { sentPackets.append($0) },
            capture: capture,
            now: { now },
            burstID: Data(repeating: 0xA5, count: 8)
        )

        try await session.start()
        now = now.addingTimeInterval(2)
        let result = await session.finish()

        #expect(result == nil)
        #expect(!FileManager.default.fileExists(atPath: url.path))
        let packet = try #require(sentPackets.last.flatMap(VoiceBurstPacket.decode))
        guard case .canceled = packet.kind else {
            Issue.record("Expected a canceled control packet for a subsecond interrupted capture")
            return
        }
    }

    @Test func droppingCaptureLeaseReturnsItsCoordinatorToken() async throws {
        let rawSession = CaptureLeaseSession()
        let coordinator = AudioSessionCoordinator(session: rawSession)
        let token = try await coordinator.acquire(.capture) {}
        var lease: PTTCaptureSessionLease? = PTTCaptureSessionLease(coordinator: coordinator)
        lease?.install(token)

        lease = nil
        await coordinator.drain()

        #expect(rawSession.activationCalls == [true, false])
    }

    @Test func rejectedNewHoldAndStaleFinalizeLeaveNewerGenerationIdle() async {
        let oldSession = GatedVoiceCaptureSession()
        let newSession = GatedVoiceCaptureSession(
            startError: VoiceRecorder.RecorderError.recordingInProgress
        )
        var sessions: [GatedVoiceCaptureSession] = [oldSession, newSession]
        let viewModel = VoiceRecordingViewModel()
        viewModel.sessionProvider = { sessions.removeFirst() }

        viewModel.start(shouldShow: true)
        await waitUntil { self.isRecording(viewModel.state) }
        viewModel.finish(completion: { _ in })
        await waitUntil { oldSession.finishStarted }

        viewModel.start(shouldShow: true)
        await waitUntil { newSession.cancelCount == 1 && viewModel.state == .idle }
        #expect(viewModel.state == .idle)

        // The older finalize now fails after the newer press has completed.
        // It must not replace the newer generation's idle state with an alert.
        oldSession.resolveFinish(with: nil)
        for _ in 0..<20 {
            await Task.yield()
        }
        #expect(viewModel.state == .idle)
    }

    @Test func panicSynchronouslyCancelsActiveCaptureAndResetsUI() async {
        let session = GatedVoiceCaptureSession()
        let viewModel = VoiceRecordingViewModel()
        viewModel.sessionProvider = { session }

        viewModel.start(shouldShow: true)
        await waitUntil { self.isRecording(viewModel.state) }

        viewModel.panicWipe()

        #expect(session.panicCancelCount == 1)
        #expect(viewModel.state == .idle)
        #expect(!viewModel.isLiveStreaming)
    }

    @Test func panicInvalidatesARecordingAlreadyFinalizing() async throws {
        let session = GatedVoiceCaptureSession()
        let viewModel = VoiceRecordingViewModel()
        viewModel.sessionProvider = { session }
        let url = FileManager.default.temporaryDirectory
            .appendingPathComponent("voice-panic-\(UUID().uuidString).m4a")
        try Data([0x01]).write(to: url)
        var delivered = false

        viewModel.start(shouldShow: true)
        await waitUntil { self.isRecording(viewModel.state) }
        viewModel.finish { _ in delivered = true }
        await waitUntil { session.finishStarted }

        viewModel.panicWipe()
        session.resolveFinish(with: url)
        await waitUntil {
            !FileManager.default.fileExists(atPath: url.path)
        }

        #expect(!delivered)
        #expect(viewModel.state == .idle)
    }

    @Test func liveSessionPanicStopsCaptureWithoutSendingControl() {
        let capture = StubPTTCapture(stopResult: (nil, 0))
        var sentPackets: [Data] = []
        let session = PTTLiveVoiceSession(
            sendPacket: { sentPackets.append($0) },
            capture: capture
        )

        session.panicCancelSynchronously()

        #expect(capture.cancelCount == 1)
        #expect(sentPackets.isEmpty)
    }
}
