import UIKit
import UserNotifications
import Intents

/// Notification Service Extension — runs as a separate process even when the main app
/// is terminated. Intercepts push notifications with `mutable-content: 1` and:
/// 1. Adds `.default` sound for vibration (server payload has no sound)
/// 2. Increments the app icon badge from shared App Group storage
/// 3. Normalizes sender_public_key in userInfo (Android parity: multi-key fallback)
/// 4. Filters muted chats
/// 5. Creates Communication Notification via INSendMessageIntent (CarPlay + Focus parity)
final class NotificationService: UNNotificationServiceExtension {

    private static let appGroupID = "group.com.rosetta.dev"
    private static let badgeKey = "app_badge_count"
    private static let processedIdsKey = "nse_processed_message_ids"
    /// Max dedup entries kept in App Group — NSE has tight memory limits.
    private static let maxProcessedIds = 100
    /// Android parity: suppress duplicate notifications from the same sender
    /// within a short window to avoid FCM burst duplicates.
    private static let recentSenderNotificationTimestampsKey = "nse_recent_sender_notif_timestamps"
    private static let senderDedupWindowSeconds: TimeInterval = 10
    /// Tracks dialogs recently read on another device (e.g. Desktop).
    /// When a READ push arrives, we store {dialogKey: timestamp}. Subsequent message
    /// pushes for the same dialog within the window are suppressed — the user is actively
    /// reading on Desktop, so the phone should stay silent.
    private static let recentlyReadKey = "nse_recently_read_dialogs"
    private static let recentlyReadWindow: TimeInterval = 30

    /// Android parity: multiple key names for sender public key extraction.
    /// Server sends `dialog` field (was `from`). Both kept for backward compat.
    private static let senderKeyNames = [
        "dialog", "sender_public_key", "from_public_key", "fromPublicKey",
        "public_key", "publicKey"
    ]
    private static let senderNameKeyNames = [
        "sender_name", "from_title", "sender", "title", "name"
    ]

    private var contentHandler: ((UNNotificationContent) -> Void)?
    private var bestAttemptContent: UNMutableNotificationContent?
    /// Stores Communication Notification result so serviceExtensionTimeWillExpire can use it.
    private var communicationContent: UNNotificationContent?

    override func didReceive(
        _ request: UNNotificationRequest,
        withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
    ) {
        self.contentHandler = contentHandler
        bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent

        guard let content = bestAttemptContent else {
            contentHandler(request.content)
            return
        }

        let shared = UserDefaults(suiteName: Self.appGroupID)
        let pushType = content.userInfo["type"] as? String ?? ""

        // MARK: type=read — clear notifications for dialog, deliver silently.
        // Server sends read push when user reads a dialog on another device.
        if pushType == "read" {
            var dialogKey = content.userInfo["dialog"] as? String ?? ""
            if dialogKey.hasPrefix("#group:") {
                dialogKey = String(dialogKey.dropFirst("#group:".count))
            }

            // Track this dialog as "recently read on another device" (Desktop parity).
            // Next message push for this dialog within 30s will be suppressed.
            if !dialogKey.isEmpty, let shared {
                let now = Date().timeIntervalSince1970
                var recentlyRead = shared.dictionary(forKey: Self.recentlyReadKey) as? [String: Double] ?? [:]
                recentlyRead[dialogKey] = now
                // Evict stale entries (> 60s) to prevent unbounded growth.
                recentlyRead = recentlyRead.filter { now - $0.value < 60 }
                shared.set(recentlyRead, forKey: Self.recentlyReadKey)
            }

            // Deliver silently — no sound, no alert.
            content.sound = nil
            content.title = ""
            content.body = ""

            guard !dialogKey.isEmpty else {
                contentHandler(content)
                return
            }

            // Clear notifications for the dialog BEFORE calling contentHandler.
            // NSE process can be suspended immediately after contentHandler returns,
            // so the async callback must complete first.
            let center = UNUserNotificationCenter.current()
            center.getDeliveredNotifications { delivered in
                let idsToRemove = delivered
                    .filter { $0.request.content.userInfo["sender_public_key"] as? String == dialogKey }
                    .map { $0.request.identifier }
                if !idsToRemove.isEmpty {
                    center.removeDeliveredNotifications(withIdentifiers: idsToRemove)
                    let current = shared?.integer(forKey: Self.badgeKey) ?? 0
                    let newBadge = max(current - idsToRemove.count, 0)
                    shared?.set(newBadge, forKey: Self.badgeKey)
                    UNUserNotificationCenter.current().setBadgeCount(newBadge)
                }
                contentHandler(content)
            }
            return
        }

        // MARK: type=call — incoming call notification (no badge increment).
        // Server sends this when someone calls and the recipient's WebSocket is not connected.
        // NSE adds sound for vibration and caller name; no badge (calls don't affect unread).
        if pushType == "call" {
            content.sound = .default
            content.categoryIdentifier = "call"

            let callerKey = content.userInfo["dialog"] as? String
                ?? Self.extractSenderKey(from: content.userInfo)
            let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:]
            let callerName = contactNames[callerKey]
                ?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames)

            if let callerName, !callerName.isEmpty, content.title.isEmpty {
                content.title = callerName
            }
            if content.body.isEmpty {
                content.body = "Incoming call"
            }

            var updatedInfo = content.userInfo
            if !callerKey.isEmpty {
                updatedInfo["sender_public_key"] = callerKey
                updatedInfo["type"] = "call"
            }
            content.userInfo = updatedInfo
            content.interruptionLevel = .timeSensitive

            contentHandler(content)
            return
        }

        // MARK: Message types (personal_message / group_message)

        // 1. Add sound for vibration — server APNs payload has no sound field.
        content.sound = .default

        // 2. Extract sender key — server sends `dialog` field (was `from`).
        let senderKey = content.userInfo["dialog"] as? String
            ?? Self.extractSenderKey(from: content.userInfo)
        var shouldCollapseDuplicateBurst = false

        // 3. Filter muted chats BEFORE badge increment — muted chats must not inflate badge.
        if let shared {
            let mutedKeys = shared.stringArray(forKey: "muted_chats_keys") ?? []
            if !senderKey.isEmpty, mutedKeys.contains(senderKey) {
                // Muted: deliver silently (no sound, no alert, no badge increment).
                content.sound = nil
                content.title = ""
                content.body = ""
                contentHandler(content)
                return
            }

            // 3.1 Desktop-active suppression: if this dialog was read on another device
            // (Desktop) within the last 30s, suppress the notification. The user is
            // actively reading on Desktop — no need to buzz the phone.
            if !senderKey.isEmpty {
                let recentlyRead = shared.dictionary(forKey: Self.recentlyReadKey) as? [String: Double] ?? [:]
                if let lastReadTime = recentlyRead[senderKey] {
                    let elapsed = Date().timeIntervalSince1970 - lastReadTime
                    if elapsed < Self.recentlyReadWindow {
                        content.sound = nil
                        content.title = ""
                        content.body = ""
                        contentHandler(content)
                        return
                    }
                }
            }

            // 3.5 Dedup:
            // - messageId duplicate: keep badge unchanged (exact duplicate delivery)
            // - sender-window duplicate: collapse UI to latest notification, but keep
            //   badge increment so unread-message count is preserved.
            let messageId = content.userInfo["message_id"] as? String
                ?? content.userInfo["messageId"] as? String
                ?? request.identifier
            var processedIds = shared.stringArray(forKey: Self.processedIdsKey) ?? []
            let isSenderWindowDuplicate = Self.isSenderWindowDuplicate(senderKey: senderKey, shared: shared)
            let isMessageIdDuplicate = processedIds.contains(messageId)
            shouldCollapseDuplicateBurst = isSenderWindowDuplicate || isMessageIdDuplicate

            if isMessageIdDuplicate {
                // Already counted — show notification but don't inflate badge.
                let currentBadge = shared.integer(forKey: Self.badgeKey)
                content.badge = NSNumber(value: currentBadge)
            } else {
                // 4. Increment badge count — only for non-muted, non-duplicate chats.
                let current = shared.integer(forKey: Self.badgeKey)
                let newBadge = current + 1
                shared.set(newBadge, forKey: Self.badgeKey)
                content.badge = NSNumber(value: newBadge)

                // Track this message ID. Evict oldest if over limit.
                processedIds.append(messageId)
                if processedIds.count > Self.maxProcessedIds {
                    processedIds = Array(processedIds.suffix(Self.maxProcessedIds))
                }
                shared.set(processedIds, forKey: Self.processedIdsKey)
            }
        }

        // 5. Group notifications by conversation (Telegram parity).
        // iOS stacks notifications from the same chat together.
        if !senderKey.isEmpty {
            content.threadIdentifier = senderKey
        }

        // 6. Normalize sender_public_key in userInfo for tap navigation.
        var updatedInfo = content.userInfo
        if !senderKey.isEmpty {
            updatedInfo["sender_public_key"] = senderKey
        }

        // 7. Resolve sender name from App Group cache (synced by DialogRepository).
        let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:]
        let resolvedName = contactNames[senderKey]
            ?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames)
        if let resolvedName, !resolvedName.isEmpty {
            updatedInfo["sender_name"] = resolvedName
            // Always prefer local name — server sends title at push time,
            // but user may have a custom contact name in App Group cache.
            content.title = resolvedName
        }
        content.userInfo = updatedInfo

        // 8. Ensure notification category for CarPlay parity.
        if content.categoryIdentifier.isEmpty {
            content.categoryIdentifier = "message"
        }

        // 9. Create Communication Notification via INSendMessageIntent.
        let senderName = resolvedName
            ?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames)
            ?? content.title
        let isGroup = pushType == "group_message"
        let finalContent = Self.makeCommunicationNotification(
            content: content,
            senderName: senderName,
            senderKey: senderKey,
            isGroup: isGroup
        )
        self.communicationContent = finalContent

        // Android parity: for duplicate bursts, keep only the latest notification
        // for this sender instead of stacking multiple identical entries.
        if !senderKey.isEmpty, shouldCollapseDuplicateBurst {
            Self.replaceDeliveredNotifications(for: senderKey) {
                contentHandler(finalContent)
            }
            return
        }

        contentHandler(finalContent)
    }

    override func serviceExtensionTimeWillExpire() {
        if let handler = contentHandler {
            // Prefer Communication Notification result (has avatar) over raw bestAttemptContent.
            if let comm = communicationContent {
                handler(comm)
                return
            }
            if let content = bestAttemptContent {
                // Read pushes must stay silent even on timeout — no sound, no alert.
                let pushType = content.userInfo["type"] as? String ?? ""
                if pushType == "read" {
                    content.sound = nil
                    content.title = ""
                    content.body = ""
                } else {
                    content.sound = .default
                }
                handler(content)
            }
        }
    }

    // MARK: - Sender Dedup Helpers

    /// Returns true if the sender has a recent notification in the dedup window.
    /// When `shouldRecord` is true, records current timestamp for non-duplicates.
    private static func isSenderWindowDuplicate(
        senderKey: String,
        shared: UserDefaults,
        shouldRecord: Bool = true
    ) -> Bool {
        let dedupKey = senderKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
            ? "__no_sender__"
            : senderKey.trimmingCharacters(in: .whitespacesAndNewlines)

        let now = Date().timeIntervalSince1970
        var timestamps = shared.dictionary(forKey: recentSenderNotificationTimestampsKey) as? [String: Double] ?? [:]
        // Keep the map compact under NSE memory constraints.
        timestamps = timestamps.filter { now - $0.value < 120 }

        if let last = timestamps[dedupKey], now - last < senderDedupWindowSeconds {
            shared.set(timestamps, forKey: recentSenderNotificationTimestampsKey)
            return true
        }

        if shouldRecord {
            timestamps[dedupKey] = now
            shared.set(timestamps, forKey: recentSenderNotificationTimestampsKey)
        } else {
            shared.set(timestamps, forKey: recentSenderNotificationTimestampsKey)
        }
        return false
    }

    /// Removes delivered notifications for this sender so duplicate bursts collapse
    /// into a single latest entry (Android parity).
    private static func replaceDeliveredNotifications(
        for senderKey: String,
        then completion: @escaping () -> Void
    ) {
        let center = UNUserNotificationCenter.current()
        center.getDeliveredNotifications { delivered in
            let idsToRemove = delivered
                .filter { notification in
                    let info = notification.request.content.userInfo
                    let infoSender = info["sender_public_key"] as? String
                        ?? info["dialog"] as? String
                        ?? ""
                    return infoSender == senderKey || notification.request.content.threadIdentifier == senderKey
                }
                .map { $0.request.identifier }

            if !idsToRemove.isEmpty {
                center.removeDeliveredNotifications(withIdentifiers: idsToRemove)
            }

            completion()
        }
    }

    // MARK: - Communication Notification (CarPlay + Focus)

    /// Wraps the notification content with an INSendMessageIntent so iOS treats it
    /// as a Communication Notification. This enables:
    /// - Display on CarPlay
    /// - Proper grouping in Focus modes
    /// - Sender name/avatar in notification UI
    private static func makeCommunicationNotification(
        content: UNMutableNotificationContent,
        senderName: String,
        senderKey: String,
        isGroup: Bool = false
    ) -> UNNotificationContent {
        let handle = INPersonHandle(value: senderKey, type: .unknown)
        let displayName = senderName.isEmpty ? "Rosetta" : senderName
        let avatarImage: INImage? = {
            if let cached = loadNotificationAvatar(for: senderKey) {
                return cached
            }
            if isGroup {
                return generateGroupAvatar(name: displayName, key: senderKey)
            }
            return generateLetterAvatar(name: displayName, key: senderKey)
        }()
        let sender = INPerson(
            personHandle: handle,
            nameComponents: nil,
            displayName: displayName,
            image: avatarImage,
            contactIdentifier: nil,
            customIdentifier: senderKey
        )

        let intent = INSendMessageIntent(
            recipients: nil,
            outgoingMessageType: .outgoingMessageText,
            content: content.body,
            speakableGroupName: isGroup ? INSpeakableString(spokenPhrase: displayName) : nil,
            conversationIdentifier: senderKey,
            serviceName: "Rosetta",
            sender: sender,
            attachments: nil
        )

        // Set avatar on sender parameter (prefer real avatar from App Group, fallback to letter avatar).
        if let avatarImage {
            intent.setImage(avatarImage, forParameterNamed: \.sender)
        }

        // Donate the intent so Siri can learn communication patterns.
        let interaction = INInteraction(intent: intent, response: nil)
        interaction.direction = .incoming
        interaction.donate(completion: nil)

        // Update the notification content with the intent.
        // This returns a new content object that iOS recognizes as a Communication Notification.
        do {
            let updatedContent = try content.updating(from: intent)
            return updatedContent
        } catch {
            // If updating fails, return original content — notification still works,
            // just without CarPlay / Communication Notification features.
            return content
        }
    }

    // MARK: - Avatar Generation (Mantine v8 parity with main app)

    /// Mantine v8 avatar palette — exact copy from Colors.swift:135-147.
    /// tint = shade-6 (circle fill for groups, 15% overlay for personal)
    /// text = shade-3 (dark mode initials color)
    private static let avatarColors: [(tint: UInt32, text: UInt32)] = [
        (0x228be6, 0x74c0fc), // blue
        (0x15aabf, 0x66d9e8), // cyan
        (0xbe4bdb, 0xe599f7), // grape
        (0x40c057, 0x8ce99a), // green
        (0x4c6ef5, 0x91a7ff), // indigo
        (0x82c91e, 0xc0eb75), // lime
        (0xfd7e14, 0xffc078), // orange
        (0xe64980, 0xfaa2c1), // pink
        (0xfa5252, 0xffa8a8), // red
        (0x12b886, 0x63e6be), // teal
        (0x7950f2, 0xb197fc), // violet
    ]

    /// Mantine dark body background (#1A1B1E) — matches AvatarView.swift.
    private static let mantineDarkBody: UInt32 = 0x1A1B1E

    /// Desktop parity: deterministic hash based on display name.
    /// Exact copy of RosettaColors.avatarColorIndex(for:publicKey:) from Colors.swift:151-164.
    private static func avatarColorIndex(for name: String, publicKey: String = "") -> Int {
        let trimmed = name.trimmingCharacters(in: .whitespaces)
        let input = trimmed.isEmpty ? String(publicKey.prefix(7)) : trimmed
        var hash: Int32 = 0
        for char in input.unicodeScalars {
            hash = (hash &<< 5) &- hash &+ Int32(truncatingIfNeeded: char.value)
        }
        let count = Int32(avatarColors.count)
        var index = abs(hash) % count
        if index < 0 { index += count }
        return Int(index)
    }

    /// Single-letter initial for group avatars.
    /// Copy of RosettaColors.groupInitial(name:publicKey:) from Colors.swift.
    private static func groupInitial(name: String, publicKey: String) -> String {
        let trimmed = name.trimmingCharacters(in: .whitespaces)
        if let first = trimmed.first { return String(first).uppercased() }
        if !publicKey.isEmpty { return String(publicKey.prefix(1)).uppercased() }
        return "?"
    }

    /// Desktop parity: 2-letter initials from display name.
    /// Exact copy of RosettaColors.initials(name:publicKey:) from Colors.swift.
    private static func initials(name: String, publicKey: String) -> String {
        let words = name.trimmingCharacters(in: .whitespaces)
            .split(whereSeparator: { $0.isWhitespace })
            .filter { !$0.isEmpty }
        switch words.count {
        case 0:
            return publicKey.isEmpty ? "??" : String(publicKey.prefix(2)).uppercased()
        case 1:
            return String(words[0].prefix(2)).uppercased()
        default:
            let first = words[0].first.map(String.init) ?? ""
            let second = words[1].first.map(String.init) ?? ""
            return (first + second).uppercased()
        }
    }

    private static func uiColor(hex: UInt32, alpha: CGFloat = 1) -> UIColor {
        UIColor(
            red: CGFloat((hex >> 16) & 0xFF) / 255,
            green: CGFloat((hex >> 8) & 0xFF) / 255,
            blue: CGFloat(hex & 0xFF) / 255,
            alpha: alpha
        )
    }

    /// Generates a 50x50 Mantine "light" variant avatar for personal chats.
    /// Dark base (#1A1B1E) + 15% tint overlay + shade-3 text — matches AvatarView.swift:69-74.
    private static func generateLetterAvatar(name: String, key: String) -> INImage? {
        let size: CGFloat = 50
        let colorIndex = avatarColorIndex(for: name, publicKey: key)
        let colors = avatarColors[colorIndex]
        let text = initials(name: name, publicKey: key)

        // Try 2.0 scale first; fallback to 1.0 if NSE memory is constrained.
        let image = renderLetterAvatar(size: size, colors: colors, text: text, scale: 2.0)
            ?? renderLetterAvatar(size: size, colors: colors, text: text, scale: 1.0)

        guard let pngData = image?.pngData() else { return nil }
        if let tempURL = storeTemporaryImage(data: pngData, key: "letter-\(key)", fileExtension: "png") {
            return INImage(url: tempURL)
        }
        return INImage(imageData: pngData)
    }

    /// Renders letter avatar at given scale. Returns nil if UIGraphics context can't be allocated.
    private static func renderLetterAvatar(
        size: CGFloat,
        colors: (tint: UInt32, text: UInt32),
        text: String,
        scale: CGFloat
    ) -> UIImage? {
        UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, scale)
        guard UIGraphicsGetCurrentContext() != nil else { return nil }

        let rect = CGRect(x: 0, y: 0, width: size, height: size)
        uiColor(hex: mantineDarkBody).setFill()
        UIBezierPath(ovalIn: rect).fill()
        uiColor(hex: colors.tint, alpha: 0.15).setFill()
        UIBezierPath(ovalIn: rect).fill()

        let textColor = uiColor(hex: colors.text)
        let font = UIFont.systemFont(ofSize: size * 0.38, weight: .bold)
        let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: textColor]
        let textSize = (text as NSString).size(withAttributes: attrs)
        let textRect = CGRect(
            x: (size - textSize.width) / 2,
            y: (size - textSize.height) / 2,
            width: textSize.width,
            height: textSize.height
        )
        (text as NSString).draw(in: textRect, withAttributes: attrs)

        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return image
    }

    /// Generates a 50x50 group avatar with single-letter initial on Mantine light circle.
    private static func generateGroupAvatar(name: String, key: String) -> INImage? {
        let size: CGFloat = 50
        let colorIndex = avatarColorIndex(for: name, publicKey: key)
        let colors = avatarColors[colorIndex]
        let text = groupInitial(name: name, publicKey: key)

        let image = renderLetterAvatar(size: size, colors: colors, text: text, scale: 2.0)
            ?? renderLetterAvatar(size: size, colors: colors, text: text, scale: 1.0)

        guard let pngData = image?.pngData() else { return nil }
        if let tempURL = storeTemporaryImage(data: pngData, key: "group-\(key)", fileExtension: "png") {
            return INImage(url: tempURL)
        }
        return INImage(imageData: pngData)
    }

    /// Loads sender avatar from shared App Group cache written by the main app.
    /// Falls back to letter avatar when no real image is available.
    private static func loadNotificationAvatar(for senderKey: String) -> INImage? {
        guard let appGroupURL = FileManager.default.containerURL(
                forSecurityApplicationGroupIdentifier: Self.appGroupID
              ) else {
            return nil
        }

        let avatarsDir = appGroupURL.appendingPathComponent("NotificationAvatars", isDirectory: true)
        for candidate in avatarKeyCandidates(for: senderKey) {
            let normalized = normalizedAvatarKey(candidate)
            guard !normalized.isEmpty else { continue }
            let avatarURL = avatarsDir.appendingPathComponent("\(normalized).jpg")
            if let data = try? Data(contentsOf: avatarURL), !data.isEmpty {
                if let tempURL = storeTemporaryImage(data: data, key: "photo-\(normalized)", fileExtension: "jpg") {
                    return INImage(url: tempURL)
                }
                return INImage(imageData: data)
            }
        }
        return nil
    }

    /// Server may send group dialog key in different forms (`raw`, `#group:raw`, `group:raw`).
    /// Probe all variants so NSE can find avatar mirrored by the main app.
    private static func avatarKeyCandidates(for senderKey: String) -> [String] {
        let trimmed = senderKey.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return [] }

        var candidates: [String] = [trimmed]
        let lower = trimmed.lowercased()
        if lower.hasPrefix("#group:") {
            let raw = String(trimmed.dropFirst("#group:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
            if !raw.isEmpty {
                candidates.append(raw)
                candidates.append("group:\(raw)")
            }
        } else if lower.hasPrefix("group:") {
            let raw = String(trimmed.dropFirst("group:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
            if !raw.isEmpty {
                candidates.append(raw)
                candidates.append("#group:\(raw)")
            }
        } else if !trimmed.isEmpty {
            candidates.append("#group:\(trimmed)")
            candidates.append("group:\(trimmed)")
        }

        // Keep first-seen order and drop duplicates.
        var seen = Set<String>()
        return candidates.filter { seen.insert($0).inserted }
    }

    private static func normalizedAvatarKey(_ key: String) -> String {
        key
            .trimmingCharacters(in: .whitespacesAndNewlines)
            .replacingOccurrences(of: "0x", with: "")
            .lowercased()
    }

    /// Writes image data to NSTemporaryDirectory so INImage can reference it via file URL.
    /// Telegram approach: INImage(imageData:) is unreliable in NSE — file URL works.
    private static func storeTemporaryImage(data: Data, key: String, fileExtension: String) -> URL? {
        let imagesPath = NSTemporaryDirectory() + "aps-data"
        try? FileManager.default.createDirectory(
            at: URL(fileURLWithPath: imagesPath),
            withIntermediateDirectories: true
        )
        let fileName = key.replacingOccurrences(of: "/", with: "_")
        let tempURL = URL(fileURLWithPath: imagesPath)
            .appendingPathComponent("\(fileName).\(fileExtension)")
        // Always overwrite — iOS cleans NSTemporaryDirectory() between notifications,
        // so a stale fileExists check would return a URL to a deleted file.
        do {
            try data.write(to: tempURL, options: [.atomic])
            return tempURL
        } catch {
            return nil
        }
    }

    // MARK: - Helpers

    /// Android parity: extract sender key from multiple possible key names.
    private static func extractSenderKey(from userInfo: [AnyHashable: Any]) -> String {
        firstNonBlank(userInfo, keys: senderKeyNames) ?? ""
    }

    private static func firstNonBlank(_ dict: [AnyHashable: Any], keys: [String]) -> String? {
        for key in keys {
            if let value = dict[key] as? String, !value.trimmingCharacters(in: .whitespaces).isEmpty {
                return value
            }
        }
        return nil
    }
}
