//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import LibSignalClient
import XCTest

@testable import SignalServiceKit

final class IdentityKeyMismatchManagerTest: XCTestCase {
    private struct TestKVStore {
        private static let hasSuspectedIssueKey = "hasSuspectedIssue"

        private let db: any DB
        private let kvStore: KeyValueStore

        init(db: any DB) {
            self.db = db
            self.kvStore = KeyValueStore(collection: "LinkedDevicePniKeyManagerImpl")
        }

        func hasDecryptionError() -> Bool {
            return db.read { kvStore.getBool(Self.hasSuspectedIssueKey, defaultValue: false, transaction: $0) }
        }
    }

    private var db: InMemoryDB!
    private var identityKeyCheckerMock: IdentityKeyCheckerMock!
    private var kvStore: TestKVStore!
    private var messageProcessorMock: MessageProcessorMock!
    private var registrationStateChangeManagerMock: MockRegistrationStateChangeManager!
    private var tsAccountManagerMock: MockTSAccountManager!
    private var whoAmIManagerMock: MockWhoAmIManager!

    private var identityKeyMismatchManager: IdentityKeyMismatchManagerImpl!

    private var isMarkedDeregistered: Bool = false

    override func setUp() {
        db = InMemoryDB()
        identityKeyCheckerMock = IdentityKeyCheckerMock()
        kvStore = TestKVStore(db: db)
        messageProcessorMock = MessageProcessorMock()
        registrationStateChangeManagerMock = .init()
        tsAccountManagerMock = .init()
        whoAmIManagerMock = MockWhoAmIManager()

        registrationStateChangeManagerMock.setIsDeregisteredOrDelinkedMock = { [weak self] isDeregistered in
            self?.isMarkedDeregistered = isDeregistered
        }

        tsAccountManagerMock.registrationStateMock = { .provisioned }

        identityKeyMismatchManager = IdentityKeyMismatchManagerImpl(
            db: db,
            identityKeyChecker: identityKeyCheckerMock,
            messageProcessor: messageProcessorMock,
            registrationStateChangeManager: registrationStateChangeManagerMock,
            tsAccountManager: tsAccountManagerMock,
            whoAmIManager: whoAmIManagerMock,
        )
    }

    private func runRunRun(recordIssue: Bool) async {
        if recordIssue {
            await db.awaitableWrite { tx in
                identityKeyMismatchManager.recordSuspectedIssueWithPniIdentityKey(tx: tx)
            }
        }
        try? await identityKeyMismatchManager.validateLocalPniIdentityKeyIfNecessary()
    }

    func testDoesntRecordIfPrimaryDevice() async {
        messageProcessorMock.waitForFetchingAndProcessingMock = {}
        tsAccountManagerMock.registrationStateMock = { .registered }

        await db.awaitableWrite { tx in
            return identityKeyMismatchManager.recordSuspectedIssueWithPniIdentityKey(tx: tx)
        }
        try? await identityKeyMismatchManager.validateLocalPniIdentityKeyIfNecessary()

        XCTAssertFalse(kvStore.hasDecryptionError())
    }

    func testUnlinkedIfDecryptionErrorAndMissingPni() async {
        let localIdentifiers = LocalIdentifiers.mock
        messageProcessorMock.waitForFetchingAndProcessingMock = {}
        whoAmIManagerMock.whoAmIResponse = .value(.forUnitTest(localIdentifiers: localIdentifiers))
        tsAccountManagerMock.localIdentifiersMock = { localIdentifiers.withoutPni() }

        await runRunRun(recordIssue: true)

        XCTAssertFalse(kvStore.hasDecryptionError())
        XCTAssertTrue(self.isMarkedDeregistered)
    }

    func testUnlinkedIfDecryptionErrorAndMismatchedIdentityKey() async {
        let localIdentifiers = LocalIdentifiers.mock
        messageProcessorMock.waitForFetchingAndProcessingMock = {}
        whoAmIManagerMock.whoAmIResponse = .value(.forUnitTest(localIdentifiers: localIdentifiers))
        tsAccountManagerMock.localIdentifiersMock = { localIdentifiers }
        var serverHasSameKeyResponses = [false]
        identityKeyCheckerMock.serverHasSameKeyAsLocalMock = { _, _ in serverHasSameKeyResponses.popFirst()! }

        await runRunRun(recordIssue: true)

        XCTAssertFalse(kvStore.hasDecryptionError())
        XCTAssertTrue(self.isMarkedDeregistered)
        XCTAssertEqual(serverHasSameKeyResponses, [])
    }

    func testNotUnlinkedIfIdentityKeyCheckingFails() async {
        let localIdentifiers = LocalIdentifiers.mock
        messageProcessorMock.waitForFetchingAndProcessingMock = {}
        whoAmIManagerMock.whoAmIResponse = .value(.forUnitTest(localIdentifiers: localIdentifiers))
        tsAccountManagerMock.localIdentifiersMock = { localIdentifiers }
        var serverHasSameKeyResponses = [OWSGenericError("")]
        identityKeyCheckerMock.serverHasSameKeyAsLocalMock = { _, _ in throw serverHasSameKeyResponses.popFirst()! }

        await runRunRun(recordIssue: true)

        XCTAssertTrue(kvStore.hasDecryptionError())
        XCTAssertFalse(self.isMarkedDeregistered)
        XCTAssertTrue(serverHasSameKeyResponses.isEmpty)
    }

    func testNotUnlinkedIfIdentityKeyMatches() async {
        let localIdentifiers = LocalIdentifiers.mock
        messageProcessorMock.waitForFetchingAndProcessingMock = {}
        whoAmIManagerMock.whoAmIResponse = .value(.forUnitTest(localIdentifiers: localIdentifiers))
        tsAccountManagerMock.localIdentifiersMock = { localIdentifiers }
        var serverHasSameKeyResponses = [true]
        identityKeyCheckerMock.serverHasSameKeyAsLocalMock = { _, _ in serverHasSameKeyResponses.popFirst()! }

        await runRunRun(recordIssue: true)

        XCTAssertFalse(kvStore.hasDecryptionError())
        XCTAssertFalse(self.isMarkedDeregistered)
        XCTAssertEqual(serverHasSameKeyResponses, [])
    }

    func testEarlyExitIfPrimary() async {
        messageProcessorMock.waitForFetchingAndProcessingMock = {}
        tsAccountManagerMock.registrationStateMock = { .registered }

        // This will fail if it doesn't early-exit, due to missing mocks.
        await runRunRun(recordIssue: false)

        XCTAssertFalse(kvStore.hasDecryptionError())
        XCTAssertFalse(self.isMarkedDeregistered)
    }

    func testEarlyExitIfNoError() async {
        messageProcessorMock.waitForFetchingAndProcessingMock = {}

        // This will fail if it doesn't early-exit, due to missing mocks.
        await runRunRun(recordIssue: false)

        XCTAssertFalse(kvStore.hasDecryptionError())
        XCTAssertFalse(self.isMarkedDeregistered)
    }

    /// It's important that we don't check for the decryption error until
    /// *after* the message queue is cleared, because that's where we'll
    /// register the error.
    func testChecksForDecryptionErrorAfterClearingQueue() async {
        messageProcessorMock.waitForFetchingAndProcessingMock = { [db, identityKeyMismatchManager] in
            await db!.awaitableWrite { tx in
                identityKeyMismatchManager!.recordSuspectedIssueWithPniIdentityKey(tx: tx)
            }
        }
        let localIdentifiers = LocalIdentifiers.mock
        whoAmIManagerMock.whoAmIResponse = .value(.forUnitTest(localIdentifiers: localIdentifiers))
        tsAccountManagerMock.localIdentifiersMock = { localIdentifiers }
        var serverHasSameKeyResponses = [false]
        identityKeyCheckerMock.serverHasSameKeyAsLocalMock = { _, _ in serverHasSameKeyResponses.popFirst()! }

        await runRunRun(recordIssue: false)

        // Expect an unlink
        XCTAssertFalse(kvStore.hasDecryptionError())
        XCTAssertTrue(self.isMarkedDeregistered)
        XCTAssertEqual(serverHasSameKeyResponses, [])
    }
}

// MARK: - Mocks

private extension LocalIdentifiers {
    static let mock: LocalIdentifiers = .forUnitTests
}

private class MessageProcessorMock: IdentityKeyMismatchManagerImpl.Shims.MessageProcessor {
    var waitForFetchingAndProcessingMock: (() async throws(CancellationError) -> Void)!

    func waitForFetchingAndProcessing() async throws(CancellationError) {
        try await waitForFetchingAndProcessingMock!()
    }
}

private class IdentityKeyCheckerMock: IdentityKeyChecker {
    var serverHasSameKeyAsLocalMock: ((_ identity: OWSIdentity, _ localIdentifier: ServiceId) async throws -> Bool)!

    func serverHasSameKeyAsLocal(for identity: OWSIdentity, localIdentifier: ServiceId) async throws -> Bool {
        return try await serverHasSameKeyAsLocalMock!(identity, localIdentifier)
    }
}
