// SPDX-License-Identifier: GPL-3.0-only

import CryptoKit
import Foundation
import Security

final class WebSocketChannel: NSObject, URLSessionWebSocketDelegate, URLSessionDelegate {
    private let request: URLRequest
    private let expectedFingerprint: String?
    private let caCertificate: SecCertificate?
    private let lock = NSLock()
    private var session: URLSession!
    private var task: URLSessionWebSocketTask?
    private var opened: CheckedContinuation<Void, Error>?
    private var openTimeout: DispatchWorkItem?
    private var hasOpened = false
    private var closing = false
    private var disconnectReported = false
    var onMessage: ((String) -> Void)?
    var onDisconnect: (() -> Void)?

    init(request: URLRequest, fingerprint: String? = nil, caCertificatePem: String? = nil) {
        self.request = request
        expectedFingerprint = fingerprint?.split(separator: "/").last.map(String.init)?.lowercased().filter(\.isHexDigit)
        if let pem = caCertificatePem,
           let data = Data(base64Encoded: pem.components(separatedBy: .newlines)
                .filter { !$0.hasPrefix("---") }.joined()) {
            caCertificate = SecCertificateCreateWithData(nil, data as CFData)
        } else { caCertificate = nil }
        super.init()
        let configuration = URLSessionConfiguration.ephemeral
        configuration.timeoutIntervalForRequest = 10
        configuration.timeoutIntervalForResource = 30
        session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
    }

    func connect(timeout: TimeInterval = 10) async throws {
        try await withCheckedThrowingContinuation { continuation in
            lock.lock()
            opened = continuation
            hasOpened = false
            closing = false
            disconnectReported = false
            let newTask = session.webSocketTask(with: request)
            task = newTask
            let timeoutWork = DispatchWorkItem { [weak self] in
                self?.reportFailure(ChannelError.timedOut(timeout))
            }
            openTimeout = timeoutWork
            lock.unlock()
            newTask.resume()
            receive()
            DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + timeout,
                                                            execute: timeoutWork)
        }
    }

    func send(_ text: String) async throws {
        guard let current = currentTask() else { throw ChannelError.closed }
        try await current.send(.string(text))
    }

    func ping() async throws {
        guard let current = currentTask() else { throw ChannelError.closed }
        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
            current.sendPing { error in
                if let error { continuation.resume(throwing: error) }
                else { continuation.resume() }
            }
        }
    }

    func close() {
        lock.lock()
        closing = true
        let current = task
        task = nil
        let continuation = opened
        opened = nil
        let timeoutWork = openTimeout
        openTimeout = nil
        lock.unlock()
        timeoutWork?.cancel()
        continuation?.resume(throwing: ChannelError.closed)
        current?.cancel(with: .normalClosure, reason: nil)
        session.invalidateAndCancel()
    }

    private func receive() {
        currentTask()?.receive { [weak self] result in
            guard let self else { return }
            switch result {
            case .success(.string(let value)): onMessage?(value); receive()
            case .success: receive()
            case .failure(let error): reportFailure(error)
            }
        }
    }

    private func currentTask() -> URLSessionWebSocketTask? {
        lock.lock()
        defer { lock.unlock() }
        return task
    }

    private func completeOpen() {
        lock.lock()
        let continuation = opened
        opened = nil
        let timeoutWork = openTimeout
        openTimeout = nil
        if continuation != nil { hasOpened = true }
        lock.unlock()
        timeoutWork?.cancel()
        continuation?.resume()
    }

    private func reportFailure(_ error: Error) {
        lock.lock()
        let continuation = opened
        opened = nil
        let timeoutWork = openTimeout
        openTimeout = nil
        let shouldReport = hasOpened && !closing && !disconnectReported
        if shouldReport { disconnectReported = true }
        lock.unlock()
        timeoutWork?.cancel()
        continuation?.resume(throwing: error)
        if shouldReport { onDisconnect?() }
    }

    func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask,
                    didOpenWithProtocol protocol: String?) {
        completeOpen()
    }

    func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask,
                    didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
        let detail = reason.flatMap { String(data: $0, encoding: .utf8) }
        reportFailure(ChannelError.closedByPeer(Int(closeCode.rawValue), detail))
    }

    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if let error { reportFailure(error) }
    }

    func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
                    completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
              let trust = challenge.protectionSpace.serverTrust else {
            completionHandler(.performDefaultHandling, nil)
            return
        }
        if let expectedFingerprint {
            guard let certificate = SecTrustGetCertificateAtIndex(trust, 0) else {
                reportFailure(ChannelError.missingCertificate)
                completionHandler(.cancelAuthenticationChallenge, nil)
                return
            }
            let data = SecCertificateCopyData(certificate) as Data
            let actual = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
            guard actual == expectedFingerprint else {
                reportFailure(ChannelError.fingerprintMismatch(expected: expectedFingerprint, actual: actual))
                completionHandler(.cancelAuthenticationChallenge, nil)
                return
            }
            completionHandler(.useCredential, URLCredential(trust: trust))
            return
        }
        if let caCertificate {
            SecTrustSetAnchorCertificates(trust, [caCertificate] as CFArray)
            SecTrustSetAnchorCertificatesOnly(trust, true)
            var error: CFError?
            let accepted = SecTrustEvaluateWithError(trust, &error)
            guard accepted else {
                reportFailure(ChannelError.untrustedCertificate(error.map { $0 as Error }))
                completionHandler(.cancelAuthenticationChallenge, nil)
                return
            }
            completionHandler(.useCredential, URLCredential(trust: trust))
            return
        }
        completionHandler(.performDefaultHandling, nil)
    }
}

enum ChannelError: LocalizedError {
    case closed
    case closedByPeer(Int, String?)
    case timedOut(TimeInterval)
    case missingCertificate
    case fingerprintMismatch(expected: String, actual: String)
    case untrustedCertificate(Error?)

    var errorDescription: String? {
        switch self {
        case .closed:
            L10n.text("error.websocket_closed", fallback: "WebSocket channel closed")
        case .closedByPeer(let code, let reason):
            reason?.isEmpty == false
                ? L10n.text(
                    "error.websocket_closed_detail",
                    fallback: "WebSocket closed by the runtime (%d): %@",
                    code,
                    reason!
                )
                : L10n.text(
                    "error.websocket_closed_code",
                    fallback: "WebSocket closed by the runtime (%d)",
                    code
                )
        case .timedOut(let timeout):
            L10n.text(
                "error.runtime_timeout_seconds",
                fallback: "The runtime did not respond within %d seconds",
                Int(timeout)
            )
        case .missingCertificate:
            L10n.text(
                "error.runtime_missing_certificate",
                fallback: "The runtime did not provide a TLS certificate"
            )
        case .fingerprintMismatch:
            L10n.text(
                "error.runtime_certificate_mismatch",
                fallback: "The runtime TLS certificate does not match the session"
            )
        case .untrustedCertificate(let error):
            error?.localizedDescription ?? L10n.text(
                "error.runtime_untrusted_certificate",
                fallback: "The runtime TLS certificate is not trusted"
            )
        }
    }

    var isRetryable: Bool {
        switch self {
        case .missingCertificate, .fingerprintMismatch, .untrustedCertificate: false
        default: true
        }
    }
}
