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

import Foundation
import SignalCoreKit
import CFNetwork

/// This extension sacrifices Dictionary performance in order to ignore http
/// header case and should not be generally used. Since the number of http
/// headers is generally small, this is an acceptable tradeoff for this use case
/// but may not be for other use cases.
private extension Dictionary where Key == String {
    subscript(header header: String) -> Value? {
        get {
            if let key = keys.first(where: { $0.caseInsensitiveCompare(header) == .orderedSame }) {
                return self[key]
            }
            return nil
        }
        set {
            if let key = keys.first(where: { $0.caseInsensitiveCompare(header) == .orderedSame }) {
                self[key] = newValue
            } else {
                self[header] = newValue
            }
        }
    }
}

class HTTPUtils: Dependencies {
    #if TESTABLE_BUILD
    public static func logCurl(for task: URLSessionTask) {
        guard let request = task.originalRequest else {
            Logger.debug("attempted to log curl on a task with no original request")
            return
        }
        logCurl(for: request)
    }

    public static func logCurl(for request: URLRequest) {
        guard let httpMethod = request.httpMethod else {
            Logger.debug("attempted to log curl on a request with no http method")
            return
        }
        var curlComponents = ["curl", "-v", "-k", "-X", httpMethod]

        for (header, headerValue) in request.allHTTPHeaderFields ?? [:] {
            // We don't yet support escaping header values.
            // If these asserts trip, we'll need to add that.
            owsAssertDebug(!header.contains("'"))
            owsAssertDebug(!headerValue.contains("'"))

            curlComponents.append("-H")
            curlComponents.append("'\(header): \(headerValue)'")
        }

        if let httpBody = request.httpBody,
           !httpBody.isEmpty {
            let contentType = request.allHTTPHeaderFields?[header: "Content-Type"]
            switch contentType {
            case MimeType.applicationJson.rawValue:
                guard let jsonBody = String(data: httpBody, encoding: .utf8) else {
                    Logger.debug("data attached to request as json was not utf8 encoded")
                    return
                }
                // We don't yet support escaping JSON.
                // If these asserts trip, we'll need to add that.
                owsAssertDebug(!jsonBody.contains("'"))
                curlComponents.append("--data-ascii")
                curlComponents.append("'\(jsonBody)'")
            case MimeType.applicationXProtobuf.rawValue, "application/x-www-form-urlencoded", "application/vnd.signal-messenger.mrm":
                let filename = "\(UUID().uuidString).tmp"
                var echoBytes = ""
                for byte in httpBody {
                    echoBytes.append(String(format: "\\\\x%02X", byte))
                }
                let echoCommand = "echo -n -e \(echoBytes) > \(filename)"

                Logger.verbose("curl for request: \(echoCommand)")
                curlComponents.append("--data-binary")
                curlComponents.append("@\(filename)")
            default:
                owsFailDebug("Unknown content type: \(contentType ?? "<nil>")")
            }

        }
        // TODO: Add support for cookies.
        guard let url = request.url else {
            Logger.debug("attempted to log curl on a request with no url")
            return
        }
        curlComponents.append("\"\(url.absoluteString)\"")
        let curlCommand = curlComponents.joined(separator: " ")
        Logger.verbose("curl for request: \(curlCommand)")
    }
    #endif

    // This DRYs up handling of main service errors
    // so that REST and websocket errors are handled
    // in the same way.
    public static func preprocessMainServiceHTTPError(
        request: TSRequest,
        requestUrl: URL,
        responseStatus: Int,
        responseHeaders: OWSHttpHeaders,
        responseData: Data?
    ) -> OWSHTTPError {
        let httpError = HTTPUtils.buildServiceError(
            request: request,
            requestUrl: requestUrl,
            responseStatus: responseStatus,
            responseHeaders: responseHeaders,
            responseData: responseData
        )

        applyHTTPError(httpError)

#if TESTABLE_BUILD
        HTTPUtils.logCurl(for: request as URLRequest)
#endif

        return httpError
    }

    // This DRYs up handling of main service errors so that
    // REST and websocket errors are handled in the same way.
    public static func applyHTTPError(_ httpError: OWSHTTPError) {

        if httpError.isNetworkConnectivityError {
            Self.outageDetection.reportConnectionFailure()
        }

        if httpError.responseStatusCode == AppExpiryImpl.appExpiredStatusCode {
            let appExpiry = DependenciesBridge.shared.appExpiry
            let db = DependenciesBridge.shared.db
            appExpiry.setHasAppExpiredAtCurrentVersion(db: db)
        }
    }

    private static func buildServiceError(
        request: TSRequest,
        requestUrl: URL,
        responseStatus: Int,
        responseHeaders: OWSHttpHeaders,
        responseData: Data?
    ) -> OWSHTTPError {

        let retryAfterDate: Date? = responseHeaders.retryAfterDate
        func buildServiceResponseError(
            localizedDescription: String? = nil,
            localizedRecoverySuggestion: String? = nil
        ) -> OWSHTTPError {
            .forServiceResponse(
                requestUrl: requestUrl,
                responseStatus: responseStatus,
                responseHeaders: responseHeaders,
                responseError: nil,
                responseData: responseData,
                customRetryAfterDate: retryAfterDate,
                customLocalizedDescription: localizedDescription,
                customLocalizedRecoverySuggestion: localizedRecoverySuggestion
            )
        }

        switch responseStatus {
        case 0:
            return .networkFailure(requestUrl: requestUrl)
        case 413, 429:
            let description = OWSLocalizedString("REGISTER_RATE_LIMITING_ERROR", comment: "")
            let recoverySuggestion = OWSLocalizedString("REGISTER_RATE_LIMITING_BODY", comment: "")
            return buildServiceResponseError(
                localizedDescription: description,
                localizedRecoverySuggestion: recoverySuggestion
            )
        case 417:
            // TODO: Is this response code obsolete?
            let description = OWSLocalizedString("REGISTRATION_ERROR", comment: "")
            let recoverySuggestion = OWSLocalizedString("RELAY_REGISTERED_ERROR_RECOVERY", comment: "")
            return buildServiceResponseError(
                localizedDescription: description,
                localizedRecoverySuggestion: recoverySuggestion
            )
        default:
            return buildServiceResponseError()
        }
    }
}

// MARK: -

public extension Error {
    var httpRetryAfterDate: Date? {
        HTTPUtils.httpRetryAfterDate(forError: self)
    }

    var httpResponseData: Data? {
        HTTPUtils.httpResponseData(forError: self)
    }

    var httpStatusCode: Int? {
        HTTPUtils.httpStatusCode(forError: self)
    }

    var httpRequestUrl: URL? {
        guard let error = self as? HTTPError else {
            return nil
        }
        return error.requestUrl
    }

    var httpResponseHeaders: OWSHttpHeaders? {
        guard let error = self as? HTTPError else {
            return nil
        }
        return error.responseHeaders
    }

    var httpResponseJson: Any? {
        guard let data = httpResponseData else {
            return nil
        }
        do {
            let json = try JSONSerialization.jsonObject(with: data, options: [])
            return json
        } catch {
            owsFailDebug("Could not parse JSON: \(error).")
            return nil
        }
    }

    var isNetworkFailureOrTimeout: Bool {
        HTTPUtils.isNetworkFailureOrTimeout(forError: self)
    }

    func hasFatalHttpStatusCode() -> Bool {
        guard let statusCode = self.httpStatusCode else {
            return false
        }
        if statusCode == 429 {
            // "Too Many Requests", retry with backoff.
            return false
        }
        return 400 <= statusCode && statusCode <= 499
    }
}

// MARK: -

public extension NSError {
    @objc
    @available(swift, obsoleted: 1.0)
    var httpStatusCode: NSNumber? {
        guard let statusCode = HTTPUtils.httpStatusCode(forError: self) else {
            return nil
        }
        owsAssertDebug(statusCode > 0)
        return NSNumber(value: statusCode)
    }

    @objc
    @available(swift, obsoleted: 1.0)
    var isNetworkFailureOrTimeout: Bool {
        HTTPUtils.isNetworkFailureOrTimeout(forError: self)
    }
}

// MARK: -

// This extension contains the canonical implementations for
// extracting various HTTP metadata from errors.  They should
// only be called from the convenience accessors on Error and
// NSError above.
fileprivate extension HTTPUtils {
    static func httpRetryAfterDate(forError error: Error) -> Date? {
        guard let httpError = error as? OWSHTTPError else {
            return nil
        }
        if let retryAfterDate = httpError.customRetryAfterDate {
            return retryAfterDate
        }
        if let retryAfterDate = httpError.responseHeaders?.retryAfterDate {
            return retryAfterDate
        }
        if let responseError = httpError.responseError {
            return httpRetryAfterDate(forError: responseError)
        }
        return nil
    }

    static func httpResponseData(forError error: Error) -> Data? {
        guard let httpError = error as? OWSHTTPError else {
            return nil
        }
        if let responseData = httpError.responseBodyData {
            return responseData
        }
        if let responseError = httpError.responseError {
            return httpResponseData(forError: responseError)
        }
        return nil
    }

    static func httpStatusCode(forError error: Error) -> Int? {
        guard let httpError = error as? OWSHTTPError else {
            return nil
        }
        let statusCode = httpError.responseStatusCode
        guard statusCode > 0 else {
            return nil
        }
        return statusCode
    }

    static func isNetworkFailureOrTimeout(forError error: Error?) -> Bool {
        guard let error else {
            return false
        }
        switch error {
        case URLError.timedOut: return true
        case URLError.cannotConnectToHost: return true
        case URLError.networkConnectionLost: return true
        case URLError.dnsLookupFailed: return true
        case URLError.notConnectedToInternet: return true
        case URLError.secureConnectionFailed: return true
        case URLError.cannotLoadFromNetwork: return true
        case URLError.cannotFindHost: return true
        case URLError.badURL: return true
        case POSIXError.EPROTO: return true
        case let httpError as OWSHTTPError: return httpError.isNetworkConnectivityError
        case GroupsV2Error.timeout: return true
        case PaymentsError.timeout: return true
        default: return false
        }
    }
}

// MARK: -

@inlinable
public func owsFailDebugUnlessNetworkFailure(_ error: Error,
                                             file: String = #file,
                                             function: String = #function,
                                             line: Int = #line) {
    if error.isNetworkFailureOrTimeout {
        // Log but otherwise ignore network failures.
        Logger.warn("Error: \(error)", file: file, function: function, line: line)
    } else {
        owsFailDebug("Error: \(error)", file: file, function: function, line: line)
    }
}

@inlinable
public func owsFailBetaUnlessNetworkFailure(
    _ error: Error,
    file: String = #file,
    function: String = #function,
    line: Int = #line
) {
    if error.isNetworkFailureOrTimeout {
        // Log but otherwise ignore network failures.
        Logger.warn("Error: \(error)", file: file, function: function, line: line)
    } else {
        owsFailBeta("Error: \(error)", file: file, function: function, line: line)
    }
}

// MARK: -

extension NSError {
    @objc
    public func matchesDomainAndCode(of other: NSError) -> Bool {
        other.hasDomain(domain, code: code)
    }

    @objc
    public func hasDomain(_ domain: String, code: Int) -> Bool {
        self.domain == domain && self.code == code
    }
}

// MARK: -

extension OWSHttpHeaders {

    // fallback retry-after delay if we fail to parse a non-empty retry-after string
    private static var kOWSFallbackRetryAfter: TimeInterval { 60 }
    private static var kOWSRetryAfterHeaderKey: String { "Retry-After" }

    public var retryAfterDate: Date? {
        if let retryAfterValue = value(forHeader: Self.kOWSRetryAfterHeaderKey) {
            return Self.parseRetryAfterHeaderValue(retryAfterValue)
        } else {
            return nil
        }
    }

    static func parseRetryAfterHeaderValue(_ rawValue: String?) -> Date? {
        guard let value = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty else {
            return nil
        }
        if let result = Date.ows_parseFromHTTPDateString(value) {
            return result
        }
        if let result = Date.ows_parseFromISO8601String(value) {
            return result
        }
        func parseWithScanner() -> Date? {
            // We need to use NSScanner instead of -[NSNumber doubleValue] so we can differentiate
            // because the NSNumber method returns 0.0 on a parse failure. NSScanner lets us detect
            // a parse failure.
            let scanner = Scanner(string: value)
            guard let delay = scanner.scanDouble(),
                  scanner.isAtEnd else {
                      // Only return the delay if we've made it to the end.
                      // Helps to prevent things like: 8/11/1994 being interpreted as delay: 8.
                      return nil
                  }
            return Date(timeIntervalSinceNow: max(0, delay))
        }
        if let result = parseWithScanner() {
            return result
        }
        if !CurrentAppContext().isRunningTests {
            owsFailDebug("Failed to parse retry-after string: \(String(describing: rawValue))")
        }
        return Date(timeIntervalSinceNow: Self.kOWSFallbackRetryAfter)
    }
}
