import Testing
import Foundation
@testable import Rosetta

// MARK: - Push Notification Badge Management Tests

/// Tests for App Group badge count (NSE writes, AppDelegate reads/decrements).
/// Badge key: `app_badge_count` in `group.com.rosetta.dev`.
@MainActor
struct PushNotificationBadgeTests {

    private static let appGroupID = "group.com.rosetta.dev"
    private static let badgeKey = "app_badge_count"

    private var shared: UserDefaults? { UserDefaults(suiteName: Self.appGroupID) }

    private func resetBadge() {
        shared?.set(0, forKey: Self.badgeKey)
    }

    // MARK: - Badge Increment (NSE behavior simulation)

    @Test("Badge increments from 0 to 1 on first message")
    func badgeIncrementFromZero() {
        resetBadge()
        let current = shared?.integer(forKey: Self.badgeKey) ?? 0
        let newBadge = current + 1
        shared?.set(newBadge, forKey: Self.badgeKey)
        #expect(shared?.integer(forKey: Self.badgeKey) == 1)
    }

    @Test("Badge increments cumulatively for multiple messages")
    func badgeIncrementMultiple() {
        resetBadge()
        for i in 1...5 {
            let current = shared?.integer(forKey: Self.badgeKey) ?? 0
            shared?.set(current + 1, forKey: Self.badgeKey)
            #expect(shared?.integer(forKey: Self.badgeKey) == i)
        }
    }

    @Test("Badge decrement on read push never goes below 0")
    func badgeDecrementFloor() {
        resetBadge()
        shared?.set(2, forKey: Self.badgeKey)
        // Simulate clearing 5 notifications (more than badge count)
        let current = shared?.integer(forKey: Self.badgeKey) ?? 0
        let newBadge = max(current - 5, 0)
        shared?.set(newBadge, forKey: Self.badgeKey)
        #expect(shared?.integer(forKey: Self.badgeKey) == 0)
    }

    @Test("Badge decrement from 3 by 2 cleared notifications = 1")
    func badgeDecrementPartial() {
        resetBadge()
        shared?.set(3, forKey: Self.badgeKey)
        let current = shared?.integer(forKey: Self.badgeKey) ?? 0
        let newBadge = max(current - 2, 0)
        shared?.set(newBadge, forKey: Self.badgeKey)
        #expect(shared?.integer(forKey: Self.badgeKey) == 1)
    }

    @Test("Badge not incremented for muted chats")
    func badgeNotIncrementedForMuted() {
        resetBadge()
        let mutedKeys = ["02muted_sender"]
        shared?.set(mutedKeys, forKey: "muted_chats_keys")

        let senderKey = "02muted_sender"
        let isMuted = mutedKeys.contains(senderKey)
        #expect(isMuted == true)

        // NSE skips badge increment for muted — badge stays 0
        if !isMuted {
            let current = shared?.integer(forKey: Self.badgeKey) ?? 0
            shared?.set(current + 1, forKey: Self.badgeKey)
        }
        #expect(shared?.integer(forKey: Self.badgeKey) == 0)

        // Cleanup
        shared?.removeObject(forKey: "muted_chats_keys")
    }
}

// MARK: - NSE Sender Dedup Window Tests

/// Tests for 10-second sender dedup window (Android parity).
/// Uses in-memory dictionaries to avoid UserDefaults parallel test interference.
struct PushNotificationSenderDedupTests {

    private static let dedupWindow: TimeInterval = 10

    @Test("First notification from sender is NOT a duplicate")
    func firstNotificationNotDuplicate() {
        let senderKey = "02first_sender"
        let timestamps: [String: Double] = [:]
        let isDuplicate = timestamps[senderKey].map { Date().timeIntervalSince1970 - $0 < Self.dedupWindow } ?? false
        #expect(isDuplicate == false)
    }

    @Test("Second notification within 10s IS a duplicate")
    func secondWithinWindowIsDuplicate() {
        let senderKey = "02dup_sender"
        let now = Date().timeIntervalSince1970
        // Simulate first notification recorded
        let timestamps: [String: Double] = [senderKey: now]
        // Check second notification (same sender, within window)
        let isDuplicate = timestamps[senderKey].map { now - $0 < Self.dedupWindow } ?? false
        #expect(isDuplicate == true)
    }

    @Test("Notification after 10s is NOT a duplicate")
    func afterWindowNotDuplicate() {
        let senderKey = "02old_sender"
        let now = Date().timeIntervalSince1970
        // First notification 11 seconds ago
        let timestamps: [String: Double] = [senderKey: now - 11]
        let isDuplicate = timestamps[senderKey].map { now - $0 < Self.dedupWindow } ?? false
        #expect(isDuplicate == false)
    }

    @Test("Different senders are independent (no cross-dedup)")
    func differentSendersIndependent() {
        let now = Date().timeIntervalSince1970
        let timestamps: [String: Double] = ["02sender_a": now]
        let isDupA = timestamps["02sender_a"].map { now - $0 < Self.dedupWindow } ?? false
        let isDupB = timestamps["02sender_b"].map { now - $0 < Self.dedupWindow } ?? false
        #expect(isDupA == true)
        #expect(isDupB == false)
    }

    @Test("Empty sender key uses __no_sender__ dedup key")
    func emptySenderKeyDedup() {
        let now = Date().timeIntervalSince1970
        let dedupKey = "__no_sender__"
        let timestamps: [String: Double] = [dedupKey: now]
        let isDuplicate = timestamps[dedupKey].map { now - $0 < Self.dedupWindow } ?? false
        #expect(isDuplicate == true)
    }

    @Test("Stale entries (>120s) are evicted on write")
    func staleEntriesEvicted() {
        let now = Date().timeIntervalSince1970
        var timestamps: [String: Double] = [
            "02stale": now - 200,  // > 120s — should be evicted
            "02recent": now - 5    // < 120s — should be kept
        ]
        // Simulate NSE eviction logic
        timestamps = timestamps.filter { now - $0.value < 120 }
        #expect(timestamps["02stale"] == nil)
        #expect(timestamps["02recent"] != nil)
    }
}

// MARK: - NSE Message ID Dedup Tests

/// Tests for messageId-based dedup (unique message delivery tracking).
/// Uses in-memory arrays to simulate NSE dedup logic.
struct PushNotificationMessageIdDedupTests {

    private static let maxProcessedIds = 100

    @Test("New messageId is NOT a duplicate")
    func newMessageIdNotDuplicate() {
        let processedIds: [String] = []
        #expect(processedIds.contains("msg_new_123") == false)
    }

    @Test("Already-processed messageId IS a duplicate")
    func processedMessageIdIsDuplicate() {
        let processedIds = ["msg_abc", "msg_def"]
        #expect(processedIds.contains("msg_abc") == true)
        #expect(processedIds.contains("msg_def") == true)
        #expect(processedIds.contains("msg_xyz") == false)
    }

    @Test("Processed IDs capped at 100 (oldest evicted)")
    func processedIdsCapped() {
        var ids = (0..<105).map { "msg_\($0)" }
        // Simulate NSE eviction: keep only last 100
        if ids.count > Self.maxProcessedIds {
            ids = Array(ids.suffix(Self.maxProcessedIds))
        }
        #expect(ids.count == 100)
        // Oldest 5 should be evicted
        #expect(ids.contains("msg_0") == false)
        #expect(ids.contains("msg_4") == false)
        // Newest should remain
        #expect(ids.contains("msg_104") == true)
        #expect(ids.contains("msg_5") == true)
    }

    @Test("Duplicate messageId does NOT increment badge")
    func duplicateMessageIdNoBadgeIncrement() {
        let processedIds = ["msg_dup_1"]
        var badgeCount = 0

        let isMessageIdDuplicate = processedIds.contains("msg_dup_1")
        #expect(isMessageIdDuplicate == true)

        // NSE: if duplicate, badge stays unchanged
        if !isMessageIdDuplicate {
            badgeCount += 1
        }
        #expect(badgeCount == 0)
    }
}

// MARK: - Desktop-Active Suppression Tests

/// Tests for 30-second Desktop-active suppression window.
/// When Desktop reads a dialog, iOS NSE suppresses message pushes for 30s.
/// Uses in-memory dictionaries to avoid parallel test interference.
struct PushNotificationDesktopSuppressionTests {

    private static let recentlyReadWindow: TimeInterval = 30

    @Test("No recent read — message NOT suppressed")
    func noRecentReadNotSuppressed() {
        let senderKey = "02alice"
        let recentlyRead: [String: Double] = [:]
        let shouldSuppress: Bool
        if let lastReadTime = recentlyRead[senderKey] {
            shouldSuppress = Date().timeIntervalSince1970 - lastReadTime < Self.recentlyReadWindow
        } else {
            shouldSuppress = false
        }
        #expect(shouldSuppress == false)
    }

    @Test("Desktop read 5s ago — message IS suppressed")
    func recentDesktopReadSuppresses() {
        let senderKey = "02alice"
        let now = Date().timeIntervalSince1970
        let recentlyRead: [String: Double] = [senderKey: now - 5]
        let shouldSuppress: Bool
        if let lastReadTime = recentlyRead[senderKey] {
            shouldSuppress = now - lastReadTime < Self.recentlyReadWindow
        } else {
            shouldSuppress = false
        }
        #expect(shouldSuppress == true)
    }

    @Test("Desktop read 31s ago — message NOT suppressed (window expired)")
    func expiredDesktopReadNotSuppressed() {
        let senderKey = "02alice"
        let now = Date().timeIntervalSince1970
        let recentlyRead: [String: Double] = [senderKey: now - 31]
        let shouldSuppress: Bool
        if let lastReadTime = recentlyRead[senderKey] {
            shouldSuppress = now - lastReadTime < Self.recentlyReadWindow
        } else {
            shouldSuppress = false
        }
        #expect(shouldSuppress == false)
    }

    @Test("Desktop read for dialog A does NOT suppress dialog B")
    func suppressionPerDialog() {
        let now = Date().timeIntervalSince1970
        let recentlyRead: [String: Double] = ["02alice": now - 5]
        let suppressAlice = recentlyRead["02alice"].map { now - $0 < Self.recentlyReadWindow } ?? false
        let suppressBob = recentlyRead["02bob"].map { now - $0 < Self.recentlyReadWindow } ?? false
        #expect(suppressAlice == true)
        #expect(suppressBob == false)
    }

    @Test("Stale entries (>60s) evicted on read push")
    func staleEntriesEvicted() {
        let now = Date().timeIntervalSince1970
        var recentlyRead: [String: Double] = [
            "02stale_dialog": now - 90,    // > 60s — should be evicted
            "02recent_dialog": now - 10     // < 60s — should be kept
        ]
        // Simulate NSE eviction (runs on each READ push)
        recentlyRead = recentlyRead.filter { now - $0.value < 60 }
        #expect(recentlyRead["02stale_dialog"] == nil)
        #expect(recentlyRead["02recent_dialog"] != nil)
    }

    @Test("Read push records dialog in recently-read map")
    func readPushRecordsDialog() {
        let dialogKey = "02alice"
        let now = Date().timeIntervalSince1970
        var recentlyRead: [String: Double] = [:]
        recentlyRead[dialogKey] = now
        #expect(recentlyRead[dialogKey] != nil)
        #expect(abs(recentlyRead[dialogKey]! - now) < 1)
    }

    @Test("Desktop read at exact boundary (30s) — NOT suppressed")
    func exactBoundaryNotSuppressed() {
        let senderKey = "02alice"
        let now = Date().timeIntervalSince1970
        let recentlyRead: [String: Double] = [senderKey: now - 30]
        let shouldSuppress = recentlyRead[senderKey].map { now - $0 < Self.recentlyReadWindow } ?? false
        #expect(shouldSuppress == false)
    }

    // MARK: - AppDelegate → App Group flag (READ push writes nse_recently_read_dialogs)

    @Test("handleReadPush stores recently-read flag in App Group for NSE")
    func readPushStoresRecentlyReadFlagInAppGroup() {
        let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
        let key = "nse_recently_read_dialogs"
        let originalData = shared?.dictionary(forKey: key)

        // Simulate what handleReadPush now does: write the recently-read flag.
        let dialogKey = "02test_desktop_read_flag"
        let now = Date().timeIntervalSince1970
        var recentlyRead = shared?.dictionary(forKey: key) as? [String: Double] ?? [:]
        recentlyRead[dialogKey] = now
        recentlyRead = recentlyRead.filter { now - $0.value < 60 }
        shared?.set(recentlyRead, forKey: key)

        // Verify flag exists and is recent.
        let stored = shared?.dictionary(forKey: key) as? [String: Double] ?? [:]
        #expect(stored[dialogKey] != nil)
        if let ts = stored[dialogKey] {
            #expect(abs(ts - now) < 2)
        }

        // Verify NSE suppression logic would fire for this dialog.
        if let lastReadTime = stored[dialogKey] {
            let elapsed = now - lastReadTime
            #expect(elapsed < Self.recentlyReadWindow)
        }

        // Cleanup.
        shared?.set(originalData, forKey: key)
    }
}

// MARK: - Read Push Group Key Normalization Tests

/// Server sends `dialog` field with `#group:` prefix for group reads.
/// NSE must strip the prefix before matching notifications.
@MainActor
struct PushNotificationReadGroupKeyTests {

    @Test("Read push dialog with #group: prefix is stripped")
    func groupPrefixStripped() {
        let dialogKey = "#group:abc123"
        var normalized = dialogKey
        if normalized.hasPrefix("#group:") {
            normalized = String(normalized.dropFirst("#group:".count))
        }
        #expect(normalized == "abc123")
    }

    @Test("Read push dialog without prefix is unchanged")
    func personalDialogKeyUnchanged() {
        let dialogKey = "02abc123def456"
        var normalized = dialogKey
        if normalized.hasPrefix("#group:") {
            normalized = String(normalized.dropFirst("#group:".count))
        }
        #expect(normalized == "02abc123def456")
    }

    @Test("Empty dialog key stays empty")
    func emptyDialogKeyStaysEmpty() {
        let dialogKey = ""
        var normalized = dialogKey
        if normalized.hasPrefix("#group:") {
            normalized = String(normalized.dropFirst("#group:".count))
        }
        #expect(normalized == "")
    }

    @Test("#group: prefix only → empty after strip")
    func prefixOnlyBecomesEmpty() {
        let dialogKey = "#group:"
        var normalized = dialogKey
        if normalized.hasPrefix("#group:") {
            normalized = String(normalized.dropFirst("#group:".count))
        }
        #expect(normalized == "")
    }
}

// MARK: - Cross-Platform Payload Parity Tests

/// Server sends specific payload formats for each push type.
/// iOS must correctly parse them. Validates parity with Server FCM.java.
@MainActor
struct PushNotificationPayloadParityTests {

    // MARK: - Personal Message Payload

    @Test("personal_message payload extracts sender from 'dialog' field")
    func personalMessagePayload() {
        let serverPayload: [AnyHashable: Any] = [
            "type": "personal_message",
            "dialog": "02abc123def456789",
            "title": "Alice"
        ]
        let senderKey = AppDelegate.extractSenderKey(from: serverPayload)
        #expect(senderKey == "02abc123def456789")
    }

    @Test("personal_message with missing dialog falls back to sender_public_key")
    func personalMessageFallback() {
        let serverPayload: [AnyHashable: Any] = [
            "type": "personal_message",
            "sender_public_key": "02fallback_key",
            "title": "Bob"
        ]
        let senderKey = AppDelegate.extractSenderKey(from: serverPayload)
        #expect(senderKey == "02fallback_key")
    }

    // MARK: - Group Message Payload

    @Test("group_message payload has dialog = group ID (no #group: prefix)")
    func groupMessagePayload() {
        // Server sends group ID without #group: prefix in push payload
        let serverPayload: [AnyHashable: Any] = [
            "type": "group_message",
            "dialog": "groupIdABC123"
        ]
        let senderKey = AppDelegate.extractSenderKey(from: serverPayload)
        #expect(senderKey == "groupIdABC123")
    }

    // MARK: - Read Payload

    @Test("read payload with personal dialog")
    func readPayloadPersonal() {
        let serverPayload: [AnyHashable: Any] = [
            "type": "read",
            "dialog": "02opponent_key"
        ]
        let dialogKey = serverPayload["dialog"] as? String ?? ""
        #expect(dialogKey == "02opponent_key")
    }

    @Test("read payload with group dialog (#group: prefixed)")
    func readPayloadGroup() {
        let serverPayload: [AnyHashable: Any] = [
            "type": "read",
            "dialog": "#group:groupIdXYZ"
        ]
        var dialogKey = serverPayload["dialog"] as? String ?? ""
        if dialogKey.hasPrefix("#group:") {
            dialogKey = String(dialogKey.dropFirst("#group:".count))
        }
        #expect(dialogKey == "groupIdXYZ")
    }

    // MARK: - Call Payload

    @Test("call payload has callId and joinToken")
    func callPayload() {
        let serverPayload: [AnyHashable: Any] = [
            "type": "call",
            "dialog": "02caller_key",
            "callId": "550e8400-e29b-41d4-a716-446655440000",
            "joinToken": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
        ]
        let callerKey = serverPayload["dialog"] as? String ?? ""
        let callId = serverPayload["callId"] as? String ?? ""
        let joinToken = serverPayload["joinToken"] as? String ?? ""
        #expect(callerKey == "02caller_key")
        #expect(!callId.isEmpty)
        #expect(!joinToken.isEmpty)
    }

    // MARK: - Type Field Routing

    @Test("Push type correctly identified from payload")
    func pushTypeRouting() {
        let types: [(String, String)] = [
            ("personal_message", "personal_message"),
            ("group_message", "group_message"),
            ("read", "read"),
            ("call", "call")
        ]
        for (input, expected) in types {
            let payload: [AnyHashable: Any] = ["type": input]
            let pushType = payload["type"] as? String ?? ""
            #expect(pushType == expected)
        }
    }

    @Test("Missing type field defaults to empty string")
    func missingTypeFieldDefault() {
        let payload: [AnyHashable: Any] = ["dialog": "02abc"]
        let pushType = payload["type"] as? String ?? ""
        #expect(pushType == "")
    }
}

// MARK: - Mute Check with Group Key Variants

/// Tests mute suppression with different group key formats.
/// Server sends group ID without #group: prefix in push `dialog` field.
/// Mute list may store with or without prefix.
@MainActor
struct PushNotificationMuteVariantsTests {

    private static let appGroupID = "group.com.rosetta.dev"

    private var shared: UserDefaults? { UserDefaults(suiteName: Self.appGroupID) }

    private func clearMuteList() {
        shared?.removeObject(forKey: "muted_chats_keys")
    }

    @Test("Personal chat muted by exact key match")
    func personalChatMuted() {
        clearMuteList()
        shared?.set(["02muted_user"], forKey: "muted_chats_keys")

        let senderKey = "02muted_user"
        let mutedKeys = shared?.stringArray(forKey: "muted_chats_keys") ?? []
        #expect(mutedKeys.contains(senderKey) == true)
        clearMuteList()
    }

    @Test("Non-muted personal chat is NOT suppressed")
    func nonMutedPersonalChat() {
        clearMuteList()
        shared?.set(["02other_user"], forKey: "muted_chats_keys")

        let senderKey = "02not_muted"
        let mutedKeys = shared?.stringArray(forKey: "muted_chats_keys") ?? []
        #expect(mutedKeys.contains(senderKey) == false)
        clearMuteList()
    }

    @Test("Group chat muted — server sends raw ID, mute list has raw ID")
    func groupMutedRawId() {
        clearMuteList()
        shared?.set(["groupABC"], forKey: "muted_chats_keys")

        // Server sends `dialog: "groupABC"` (no prefix)
        let senderKey = "groupABC"
        let mutedKeys = shared?.stringArray(forKey: "muted_chats_keys") ?? []
        #expect(mutedKeys.contains(senderKey) == true)
        clearMuteList()
    }

    @Test("Group chat muted with #group: prefix in mute list")
    func groupMutedWithPrefix() {
        clearMuteList()
        // iOS stores with #group: prefix
        shared?.set(["#group:groupABC", "groupABC"], forKey: "muted_chats_keys")

        // Server sends raw ID
        let senderKey = "groupABC"
        let mutedKeys = shared?.stringArray(forKey: "muted_chats_keys") ?? []
        // At least one variant should match
        let isMuted = mutedKeys.contains(senderKey) || mutedKeys.contains("#group:\(senderKey)")
        #expect(isMuted == true)
        clearMuteList()
    }

    @Test("Empty mute list — nothing suppressed")
    func emptyMuteList() {
        clearMuteList()
        let senderKey = "02any_user"
        let mutedKeys = shared?.stringArray(forKey: "muted_chats_keys") ?? []
        #expect(mutedKeys.contains(senderKey) == false)
    }
}

// MARK: - Sender Key Extraction Extended Tests

/// Extended tests for `AppDelegate.extractSenderKey(from:)`.
/// Covers edge cases not in ForegroundNotificationTests.
@MainActor
struct PushSenderKeyExtractionExtendedTests {

    @Test("Whitespace-only dialog field falls back to next key")
    func whitespaceDialogFallback() {
        let payload: [AnyHashable: Any] = [
            "dialog": "   ",
            "sender_public_key": "02real_key"
        ]
        let key = AppDelegate.extractSenderKey(from: payload)
        // Should skip whitespace-only dialog and fall back
        #expect(key == "02real_key")
    }

    @Test("All keys present — dialog wins (priority order)")
    func dialogWinsPriority() {
        let payload: [AnyHashable: Any] = [
            "dialog": "02dialog_key",
            "sender_public_key": "02sender_key",
            "from_public_key": "02from_key",
            "fromPublicKey": "02fromPK_key",
            "public_key": "02pk_key",
            "publicKey": "02PK_key"
        ]
        let key = AppDelegate.extractSenderKey(from: payload)
        #expect(key == "02dialog_key")
    }

    @Test("Only publicKey present (last fallback)")
    func lastFallbackPublicKey() {
        let payload: [AnyHashable: Any] = [
            "publicKey": "02last_resort"
        ]
        let key = AppDelegate.extractSenderKey(from: payload)
        #expect(key == "02last_resort")
    }

    @Test("Non-string dialog value returns empty")
    func nonStringDialogReturnsEmpty() {
        let payload: [AnyHashable: Any] = [
            "dialog": 12345  // Int, not String
        ]
        let key = AppDelegate.extractSenderKey(from: payload)
        #expect(key == "")
    }

    @Test("Unicode sender key preserved")
    func unicodeSenderKeyPreserved() {
        let payload: [AnyHashable: Any] = [
            "dialog": "02абвгд_тест_🔑"
        ]
        let key = AppDelegate.extractSenderKey(from: payload)
        #expect(key == "02абвгд_тест_🔑")
    }

    @Test("Very long sender key handled")
    func longSenderKeyHandled() {
        let longKey = String(repeating: "a", count: 1000)
        let payload: [AnyHashable: Any] = ["dialog": longKey]
        let key = AppDelegate.extractSenderKey(from: payload)
        #expect(key == longKey)
        #expect(key.count == 1000)
    }
}

// MARK: - In-App Banner Suppression Extended Tests

/// Extended tests for `InAppNotificationManager.shouldSuppress(senderKey:)`.
/// Covers mute + active dialog combinations.
@MainActor
struct PushInAppBannerSuppressionExtendedTests {

    private static let appGroupID = "group.com.rosetta.dev"
    private var shared: UserDefaults? { UserDefaults(suiteName: Self.appGroupID) }

    private func clearState() {
        for key in MessageRepository.shared.activeDialogKeys {
            MessageRepository.shared.setDialogActive(key, isActive: false)
        }
        shared?.removeObject(forKey: "muted_chats_keys")
    }

    @Test("Both muted AND active — suppressed (double reason)")
    func mutedAndActiveSuppressed() {
        clearState()
        MessageRepository.shared.setDialogActive("02both", isActive: true)
        shared?.set(["02both"], forKey: "muted_chats_keys")

        #expect(InAppNotificationManager.shouldSuppress(senderKey: "02both") == true)

        MessageRepository.shared.setDialogActive("02both", isActive: false)
        shared?.removeObject(forKey: "muted_chats_keys")
    }

    @Test("Muted but NOT active — still suppressed")
    func mutedNotActiveSuppressed() {
        clearState()
        // Production checks DialogRepository (in-memory), not UserDefaults.
        DialogRepository.shared.ensureDialog(
            opponentKey: "02muted_only", title: "", username: "", myPublicKey: "me"
        )
        DialogRepository.shared.toggleMute(opponentKey: "02muted_only")

        #expect(InAppNotificationManager.shouldSuppress(senderKey: "02muted_only") == true)

        DialogRepository.shared.toggleMute(opponentKey: "02muted_only")
    }

    @Test("Active but NOT muted — suppressed (active chat open)")
    func activeNotMutedSuppressed() {
        clearState()
        MessageRepository.shared.setDialogActive("02active_only", isActive: true)

        #expect(InAppNotificationManager.shouldSuppress(senderKey: "02active_only") == true)

        MessageRepository.shared.setDialogActive("02active_only", isActive: false)
    }

    @Test("Neither muted nor active — NOT suppressed")
    func neitherMutedNorActivNotSuppressed() {
        clearState()
        #expect(InAppNotificationManager.shouldSuppress(senderKey: "02normal") == false)
    }

    @Test("System presentation suppresses all foreground notifications (Telegram parity)")
    func systemPresentationShowsBanner() {
        clearState()
        // Telegram parity: all foreground notifications suppressed — messages arrive via WebSocket.
        let options = AppDelegate.foregroundPresentationOptions(for: ["dialog": "02any_user"])
        #expect(options == [])
    }
}

// MARK: - NSE Thread Identifier (Notification Grouping) Tests

/// Tests notification threading (grouping by conversation).
/// NSE sets `threadIdentifier = senderKey` on notifications.
struct PushNotificationThreadingTests {

    @Test("Thread identifier matches sender key for personal messages")
    func threadIdPersonalMessage() {
        let senderKey = "02alice_key"
        let threadIdentifier = senderKey.isEmpty ? nil : senderKey
        #expect(threadIdentifier == "02alice_key")
    }

    @Test("Thread identifier matches group ID for group messages")
    func threadIdGroupMessage() {
        let senderKey = "groupABC123"
        let threadIdentifier = senderKey.isEmpty ? nil : senderKey
        #expect(threadIdentifier == "groupABC123")
    }

    @Test("Empty sender key produces nil thread identifier")
    func threadIdEmptySender() {
        let senderKey = ""
        let threadIdentifier = senderKey.isEmpty ? nil : senderKey
        #expect(threadIdentifier == nil)
    }
}

// MARK: - Push Notification Packet Extended Tests

/// Validates PacketPushNotification for both FCM and VoIP token types.
struct PushNotificationPacketExtendedTests {

    @Test("FCM subscribe packet preserves all fields through round-trip")
    func fcmSubscribeRoundTrip() throws {
        var original = PacketPushNotification()
        original.notificationsToken = "dGVzdF9mY21fdG9rZW5fMTIzNDU2Nzg5MA=="
        original.action = .subscribe
        original.tokenType = .fcm
        original.deviceId = "DEVICE-UUID-1234"

        let decoded = try decodePacket(original)

        #expect(decoded.notificationsToken == original.notificationsToken)
        #expect(decoded.action == .subscribe)
        #expect(decoded.tokenType == .fcm)
        #expect(decoded.deviceId == "DEVICE-UUID-1234")
    }

    @Test("VoIP subscribe packet preserves all fields through round-trip")
    func voipSubscribeRoundTrip() throws {
        var original = PacketPushNotification()
        original.notificationsToken = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
        original.action = .subscribe
        original.tokenType = .voipApns
        original.deviceId = "VOIP-DEVICE-5678"

        let decoded = try decodePacket(original)

        #expect(decoded.notificationsToken == original.notificationsToken)
        #expect(decoded.action == .subscribe)
        #expect(decoded.tokenType == .voipApns)
        #expect(decoded.deviceId == "VOIP-DEVICE-5678")
    }

    @Test("Unsubscribe packet round-trip")
    func unsubscribeRoundTrip() throws {
        var original = PacketPushNotification()
        original.notificationsToken = "old_token_to_remove"
        original.action = .unsubscribe
        original.tokenType = .fcm
        original.deviceId = "DEVICE-CLEANUP"

        let decoded = try decodePacket(original)

        #expect(decoded.action == .unsubscribe)
        #expect(decoded.notificationsToken == "old_token_to_remove")
    }

    @Test("Both token types have correct raw values (server parity)")
    func tokenTypeRawValues() {
        // Server TokenType.java: FCM(0), VoIPApns(1)
        #expect(PushTokenType.fcm.rawValue == 0)
        #expect(PushTokenType.voipApns.rawValue == 1)
    }

    @Test("Both actions have correct raw values (server parity)")
    func actionRawValues() {
        // Server NetworkNotificationAction.java: SUBSCRIBE(0), UNSUBSCRIBE(1)
        #expect(PushNotificationAction.subscribe.rawValue == 0)
        #expect(PushNotificationAction.unsubscribe.rawValue == 1)
    }

    // MARK: - Helper

    private func decodePacket(
        _ packet: PacketPushNotification
    ) throws -> PacketPushNotification {
        let encoded = PacketRegistry.encode(packet)
        guard let decoded = PacketRegistry.decode(from: encoded),
              let decodedPacket = decoded.packet as? PacketPushNotification
        else {
            throw NSError(
                domain: "PushNotificationPacketExtendedTests", code: 1,
                userInfo: [NSLocalizedDescriptionKey: "Failed to decode PacketPushNotification"]
            )
        }
        #expect(decoded.packetId == 0x10)
        return decodedPacket
    }
}

// MARK: - NSE Category Assignment Tests

/// Tests notification category assignment for message routing.
struct PushNotificationCategoryTests {

    @Test("Message type gets 'message' category")
    func messageCategoryAssignment() {
        let pushType = "personal_message"
        let category: String
        if pushType == "call" {
            category = "call"
        } else {
            category = "message"
        }
        #expect(category == "message")
    }

    @Test("Call type gets 'call' category")
    func callCategoryAssignment() {
        let pushType = "call"
        let category: String
        if pushType == "call" {
            category = "call"
        } else {
            category = "message"
        }
        #expect(category == "call")
    }

    @Test("Group message type gets 'message' category (not 'group')")
    func groupMessageCategoryIsMessage() {
        let pushType = "group_message"
        let category: String
        if pushType == "call" {
            category = "call"
        } else {
            category = "message"
        }
        #expect(category == "message")
    }
}

// MARK: - Contact Name Resolution Tests

/// Tests name resolution logic (NSE + AppDelegate use this pattern).
/// Uses in-memory dictionaries to simulate App Group cache.
struct PushNotificationNameResolutionTests {

    @Test("Resolved name from contact_display_names cache")
    func resolvedNameFromCache() {
        let contactNames: [String: String] = [
            "02alice": "Alice Smith",
            "02bob": "Bob Jones"
        ]
        #expect(contactNames["02alice"] == "Alice Smith")
        #expect(contactNames["02bob"] == "Bob Jones")
        #expect(contactNames["02unknown"] == nil)
    }

    @Test("Fallback to push payload title when not in cache")
    func fallbackToPayloadTitle() {
        let contactNames: [String: String] = [:]  // Empty cache
        let payload: [AnyHashable: Any] = [
            "dialog": "02unknown_sender",
            "title": "Server Title"
        ]
        let senderKey = payload["dialog"] as? String ?? ""
        let resolvedName = contactNames[senderKey] ?? (payload["title"] as? String)
        #expect(resolvedName == "Server Title")
    }

    @Test("Empty cache and no title — name is nil")
    func emptyEverythingNameNil() {
        let contactNames: [String: String] = [:]  // Empty cache
        let payload: [AnyHashable: Any] = [
            "dialog": "02no_name_sender"
        ]
        let senderKey = payload["dialog"] as? String ?? ""
        let resolvedName = contactNames[senderKey] ?? (payload["title"] as? String)
        #expect(resolvedName == nil)
    }

    @Test("Cache prefers local name over server title")
    func cachePreferredOverServerTitle() {
        let contactNames: [String: String] = ["02alice": "Local Alice"]
        let payload: [AnyHashable: Any] = [
            "dialog": "02alice",
            "title": "Server Alice"
        ]
        let senderKey = payload["dialog"] as? String ?? ""
        let resolvedName = contactNames[senderKey] ?? (payload["title"] as? String)
        #expect(resolvedName == "Local Alice")
    }
}
