import UserNotifications
import Security

/// Notification Service Extension for Nextcloud push-v2.
///
/// Architecture: Nextcloud pushes an E2E-encrypted notification to the
/// MarianumConnect proxy, which forwards it via FCM to this device as an
/// `alert` push with `mutable-content: 1`. That flag makes iOS spin up this
/// extension *before* showing the notification, giving us ~30 s (wall clock,
/// shared with the CPU budget) to decrypt the payload and replace the
/// placeholder alert with the real content.
///
/// The FCM proxy places the two relevant fields at the top level of the APNs
/// payload (mirrored into `request.content.userInfo`):
///   - `subject`   base64( RSA-encrypted subject JSON ) — encrypted with THIS
///                 device's public key, so we decrypt with our private key.
///   - `signature` base64( SHA-512-with-RSA over the *encrypted* subject bytes )
///                 — signed by the per-user server key we stored at registration.
///
/// Key material lives in the shared (App Group) keychain, written by the Dart
/// side via flutter_secure_storage with
/// `IOSOptions(groupId: kPushKeychainGroup, accessibility: first_unlock)`:
///   - `push_device_private_key_pem`   crypton PKCS#8-in-"RSA PRIVATE KEY" PEM
///   - `push_server_public_key_pem`    SPKI "PUBLIC KEY" PEM (from NC)
///
/// Robustness contract: on ANY failure (missing keys, bad signature, decrypt
/// failure, malformed JSON, timeout) we deliver the untouched placeholder
/// content. We never crash and never deliver empty content.
class NotificationService: UNNotificationServiceExtension {

    /// Must exactly match `kPushKeychainGroup` in lib/push/push_secure_storage.dart
    /// and the `keychain-access-groups` entitlement of BOTH the Runner and this
    /// extension. Wrong value here => keychain reads return nil => placeholder.
    private static let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"

    private static let devicePrivateKeyAccount = "push_device_private_key_pem"
    private static let serverPublicKeyAccount = "push_server_public_key_pem"

    private static let talkCategoryId = "TALK_MESSAGE"

    private var contentHandler: ((UNNotificationContent) -> Void)?
    private var bestAttemptContent: UNMutableNotificationContent?

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

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

        // Any early return delivers the placeholder unchanged — that is the
        // intended fallback, never an error path that hides the notification.
        let userInfo = content.userInfo
        guard
            let subjectB64 = userInfo["subject"] as? String,
            let signatureB64 = userInfo["signature"] as? String,
            let encrypted = Data(base64Encoded: subjectB64),
            let signature = Data(base64Encoded: signatureB64)
        else {
            deliver(content)
            return
        }

        guard let privateKey = loadPrivateKey() else {
            NSLog("[NSE] no device private key in keychain, delivering placeholder")
            deliver(content)
            return
        }

        // Signature verification is defence-in-depth (the proxy already verified
        // it). Skip only if we somehow have no server key; never deliver on a
        // *failed* verification.
        if let serverKey = loadServerPublicKey() {
            if !verifySignature(signature, over: encrypted, with: serverKey) {
                NSLog("[NSE] signature verification failed, delivering placeholder")
                deliver(content)
                return
            }
        }

        guard
            let plaintext = decrypt(encrypted, with: privateKey),
            let subject = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any]
        else {
            NSLog("[NSE] could not decrypt/parse subject, delivering placeholder")
            deliver(content)
            return
        }

        apply(subject: subject, to: content)
        deliver(content)
    }

    override func serviceExtensionTimeWillExpire() {
        // Ran out of time — hand back whatever we have (at worst the placeholder).
        if let handler = contentHandler, let content = bestAttemptContent {
            handler(content)
        }
    }

    private func deliver(_ content: UNNotificationContent) {
        contentHandler?(content)
    }

    // MARK: - Content shaping

    private func apply(subject: [String: Any], to content: UNMutableNotificationContent) {
        // Delete pushes normally arrive as silent `background` pushes that never
        // invoke this extension. If one reaches us anyway, an NSE cannot suppress
        // the banner (iOS always shows *something*), so we leave the placeholder.
        // Actual delete cleanup is done by the Flutter app on next open. See
        // ios/PUSH_NSE_SETUP.md "Delete-Handling".
        let isDelete = (subject["delete"] as? Bool == true)
            || (subject["delete-multiple"] as? Bool == true)
            || (subject["delete-all"] as? Bool == true)
        if isDelete {
            NSLog("[NSE] delete push reached NSE — cannot suppress, leaving placeholder")
            return
        }

        let app = subject["app"] as? String
        let text = subject["subject"] as? String ?? "Neue Benachrichtigung"
        let objectId = stringValue(subject["id"])
        let nid = subject["nid"]

        if app == "spreed" {
            // Talk: "Sender: message" -> title = Sender, body = message.
            let (sender, message) = splitSender(text)
            content.title = sender
            content.body = message
            content.categoryIdentifier = NotificationService.talkCategoryId
            if let token = objectId, !token.isEmpty {
                content.threadIdentifier = "talk_\(token)"
                var info = content.userInfo
                info["chatToken"] = token
                if let nid = nid { info["nid"] = nid }
                content.userInfo = info
            }
        } else {
            content.title = text
            content.body = ""
        }
        // Badge intentionally left untouched (the app recomputes it from the
        // delivered notifications).
    }

    /// Splits a Talk subject into header and message. nextcloud/spreed's
    /// Notifier separates them with a NEWLINE — `"{user}\n{message}"` for 1:1
    /// chats, `"{user} in {call}\n{message}"` for groups (verified against
    /// spreed; mirrors parseTalkSubject in lib/push/push_subject.dart). The
    /// legacy `": "` form is kept as fallback for older payloads.
    private func splitSender(_ subject: String) -> (String, String) {
        if let range = subject.range(of: "\n") {
            let sender = String(subject[..<range.lowerBound])
                .trimmingCharacters(in: .whitespaces)
            let message = String(subject[range.upperBound...])
            if !sender.isEmpty && !message.isEmpty {
                return (sender, message)
            }
        }
        if let range = subject.range(of: ": ") {
            let sender = String(subject[..<range.lowerBound])
            let message = String(subject[range.upperBound...])
            if !sender.isEmpty && !message.isEmpty {
                return (sender, message)
            }
        }
        return ("Talk", subject)
    }

    private func stringValue(_ value: Any?) -> String? {
        if let s = value as? String { return s }
        if let n = value as? NSNumber { return n.stringValue }
        return nil
    }

    // MARK: - Crypto

    private func verifySignature(_ signature: Data, over message: Data, with key: SecKey) -> Bool {
        var error: Unmanaged<CFError>?
        let ok = SecKeyVerifySignature(
            key,
            .rsaSignatureMessagePKCS1v15SHA512,
            message as CFData,
            signature as CFData,
            &error
        )
        return ok
    }

    private func decrypt(_ data: Data, with key: SecKey) -> Data? {
        // NC 32 defaults to OAEP(SHA-1); older instances use PKCS#1 v1.5. Try
        // OAEP first, fall back to PKCS#1 — mirrors the Dart PushDecryptor.
        for algorithm in [SecKeyAlgorithm.rsaEncryptionOAEPSHA1,
                          SecKeyAlgorithm.rsaEncryptionPKCS1] {
            var error: Unmanaged<CFError>?
            if let plain = SecKeyCreateDecryptedData(key, algorithm, data as CFData, &error) as Data? {
                return plain
            }
        }
        return nil
    }

    // MARK: - Key loading

    private func loadPrivateKey() -> SecKey? {
        guard let pem = keychainString(NotificationService.devicePrivateKeyAccount),
              let der = PEM.der(fromPem: pem),
              // crypton labels it "RSA PRIVATE KEY" but the body is PKCS#8.
              let pkcs1 = PEM.pkcs1PrivateKey(fromPkcs8: der)
        else { return nil }
        return PEM.rsaKey(pkcs1: pkcs1, isPrivate: true)
    }

    private func loadServerPublicKey() -> SecKey? {
        guard let pem = keychainString(NotificationService.serverPublicKeyAccount),
              let der = PEM.der(fromPem: pem),
              let pkcs1 = PEM.pkcs1PublicKey(fromSpki: der)
        else { return nil }
        return PEM.rsaKey(pkcs1: pkcs1, isPrivate: false)
    }

    // MARK: - Keychain

    /// Reads a flutter_secure_storage entry from the shared keychain. Must match
    /// exactly how flutter_secure_storage_darwin 0.3.2 stores items:
    ///   kSecClass       = kSecClassGenericPassword
    ///   kSecAttrAccount = the Dart key, verbatim
    ///   kSecAttrService = (unset — the Dart IOSOptions set no accountName)
    ///   kSecAttrAccessGroup = the team-prefixed shared keychain group
    ///   value           = raw UTF-8 bytes of the string
    private func keychainString(_ account: String) -> String? {
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrAccount: account,
            kSecAttrAccessGroup: NotificationService.keychainAccessGroup,
            kSecReturnData: true,
            kSecMatchLimit: kSecMatchLimitOne,
        ]
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        guard status == errSecSuccess, let data = result as? Data else {
            if status != errSecItemNotFound {
                NSLog("[NSE] keychain read '\(account)' failed: \(status)")
            }
            return nil
        }
        return String(data: data, encoding: .utf8)
    }
}
