//
//  HTTPTransport.swift
//  Navvo Maps SDK for iOS
//
//  Internal HTTP transport for `NavvoServices`: injects the `x-api-key`
//  header, retries idempotent GETs with exponential backoff + full jitter,
//  honours `Retry-After`, applies a per-attempt timeout, and maps HTTP status
//  codes to the typed `NavvoError` hierarchy.
//

import Foundation

final class HTTPTransport {
    let baseURL: URL
    let apiKey: () throws -> String
    let timeout: TimeInterval
    let maxRetries: Int
    let defaultLanguage: String?
    let session: URLSession

    static let userAgent = "navvo-maps-ios/0.2.0 (swift)"

    init(
        baseURL: URL,
        apiKey: @escaping () throws -> String,
        timeout: TimeInterval = 20,
        maxRetries: Int = 3,
        defaultLanguage: String? = nil,
        session: URLSession? = nil
    ) {
        self.baseURL = baseURL
        self.apiKey = apiKey
        self.timeout = timeout
        self.maxRetries = max(0, maxRetries)
        self.defaultLanguage = defaultLanguage
        if let session {
            self.session = session
        } else {
            let configuration = URLSessionConfiguration.default
            configuration.timeoutIntervalForRequest = timeout
            configuration.requestCachePolicy = .useProtocolCachePolicy
            self.session = URLSession(configuration: configuration)
        }
    }

    /// The key redacted to `nvvo_****<last4>` — safe to log.
    var redactedKey: String {
        guard let key = try? apiKey(), key.count >= 4 else { return "nvvo_****" }
        return "nvvo_****\(key.suffix(4))"
    }

    /// Perform a GET (retried) and return the decoded JSON body.
    func get(_ path: String, query: [String: String] = [:]) async throws -> NavvoJSON {
        try await send(method: "GET", path: path, query: query, body: nil, retriable: true)
    }

    /// Perform a POST (not retried) and return the decoded JSON body.
    /// `201 Created` is a success (routing endpoints answer 201).
    func post(_ path: String, body: NavvoJSON) async throws -> NavvoJSON {
        try await send(method: "POST", path: path, query: [:], body: body, retriable: false)
    }

    // MARK: - Internals

    private func makeURL(_ path: String, query: [String: String]) throws -> URL {
        guard var components = URLComponents(string: baseURL.absoluteString + path) else {
            throw NavvoError.config("invalid request path: \(path)")
        }
        if !query.isEmpty {
            var items = components.queryItems ?? []
            for (key, value) in query.sorted(by: { $0.key < $1.key }) {
                items.append(URLQueryItem(name: key, value: value))
            }
            components.queryItems = items
        }
        guard let url = components.url else {
            throw NavvoError.config("could not build URL for path: \(path)")
        }
        return url
    }

    private func send(
        method: String,
        path: String,
        query: [String: String],
        body: NavvoJSON?,
        retriable: Bool
    ) async throws -> NavvoJSON {
        let url = try makeURL(path, query: query)
        var request = URLRequest(url: url)
        request.httpMethod = method
        request.timeoutInterval = timeout
        request.setValue(try apiKey(), forHTTPHeaderField: "x-api-key")
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent")
        if let defaultLanguage {
            request.setValue(defaultLanguage, forHTTPHeaderField: "Accept-Language")
        }
        if let body {
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            request.httpBody = try body.encodedData()
        }

        let attempts = retriable ? maxRetries + 1 : 1
        var lastError = NavvoError.network("request failed")

        for attempt in 0..<attempts {
            let data: Data
            let response: URLResponse
            do {
                (data, response) = try await dataTask(for: request)
            } catch let urlError as URLError {
                if urlError.code == .cancelled { throw CancellationError() }
                if urlError.code == .timedOut {
                    lastError = .timeout(seconds: timeout, attempts: attempt + 1)
                } else {
                    lastError = .network("network error: \(urlError.localizedDescription)")
                }
                if retriable && attempt < attempts - 1 {
                    try await backoff(attempt: attempt, retryAfter: nil)
                    continue
                }
                throw lastError
            } catch {
                if error is CancellationError { throw error }
                lastError = .network("network error: \(error.localizedDescription)")
                if retriable && attempt < attempts - 1 {
                    try await backoff(attempt: attempt, retryAfter: nil)
                    continue
                }
                throw lastError
            }

            guard let http = response as? HTTPURLResponse else {
                throw NavvoError.network("non-HTTP response for \(method) \(path)")
            }
            let status = http.statusCode
            let parsed = NavvoJSON.parse(data)
                ?? (data.isEmpty ? .null : .string(String(decoding: data, as: UTF8.self)))

            let retryableStatus = status == 429 || status == 408 || status >= 500
            if retryableStatus && retriable && attempt < attempts - 1 {
                try await backoff(attempt: attempt, retryAfter: retryAfterInterval(http))
                continue
            }

            if status >= 400 {
                throw mapError(status: status, http: http, body: parsed, path: path, method: method)
            }
            if status < 300 {
                // 2xx — including the 201 that POST compute endpoints return.
                return parsed
            }
            throw NavvoError.server(
                NavvoErrorContext(
                    message: "unexpected HTTP \(status)",
                    statusCode: status,
                    path: path,
                    method: method,
                    body: parsed
                )
            )
        }

        throw lastError
    }

    private func dataTask(for request: URLRequest) async throws -> (Data, URLResponse) {
        if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) {
            return try await session.data(for: request)
        }
        return try await withCheckedThrowingContinuation { continuation in
            let task = session.dataTask(with: request) { data, response, error in
                if let error {
                    continuation.resume(throwing: error)
                } else if let data, let response {
                    continuation.resume(returning: (data, response))
                } else {
                    continuation.resume(throwing: URLError(.badServerResponse))
                }
            }
            task.resume()
        }
    }

    private func retryAfterInterval(_ http: HTTPURLResponse) -> TimeInterval? {
        guard
            let header = http.value(forHTTPHeaderField: "Retry-After"),
            let seconds = TimeInterval(header.trimmingCharacters(in: .whitespaces))
        else { return nil }
        return seconds
    }

    private func backoff(attempt: Int, retryAfter: TimeInterval?) async throws {
        let base = 0.5
        let factor = 2.0
        let ceiling = min(20.0, base * pow(factor, Double(attempt)))
        var delay = Double.random(in: 0...ceiling) // full jitter
        if let retryAfter { delay = max(retryAfter, delay) }
        if delay > 0 {
            try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
        }
    }

    private func mapError(
        status: Int,
        http: HTTPURLResponse,
        body: NavvoJSON,
        path: String,
        method: String
    ) -> NavvoError {
        let message = serverMessage(body) ?? "HTTP \(status)"
        let context = NavvoErrorContext(
            message: message, statusCode: status, path: path, method: method, body: body
        )
        switch status {
        case 400, 422:
            return .validation(context)
        case 401:
            return .auth(context)
        case 403:
            // A 403 can be a missing scope or a quota throttle; disambiguate
            // from the message/body, else treat as a scope error.
            let lowered = message.lowercased()
            let reason = (body["reason"]?.stringValue ?? body["error"]?.stringValue ?? "")
                .lowercased()
            if lowered.contains("quota") || lowered.contains("rate limit")
                || lowered.contains("/minute") || lowered.contains("/day")
                || reason.contains("quota")
            {
                return .rateLimit(retryAfter: retryAfterInterval(http), context)
            }
            var missingScope = body["scope"]?.stringValue
            if missingScope == nil,
               let range = message.range(
                   of: "missing required scopes:", options: [.caseInsensitive]
               )
            {
                missingScope = String(message[range.upperBound...])
                    .trimmingCharacters(in: .whitespaces)
            }
            return .scope(missingScope: missingScope, context)
        case 404:
            return .notFound(context)
        case 429:
            return .rateLimit(retryAfter: retryAfterInterval(http), context)
        default:
            return .server(context)
        }
    }

    private func serverMessage(_ body: NavvoJSON) -> String? {
        if let message = body["message"]?.stringValue, !message.isEmpty { return message }
        if let parts = body["message"]?.arrayValue {
            let strings = parts.compactMap { $0.stringValue }
            if !strings.isEmpty { return strings.joined(separator: "; ") }
        }
        for key in ["error", "detail", "reason"] {
            if let value = body[key]?.stringValue, !value.isEmpty { return value }
        }
        if let text = body.stringValue, !text.isEmpty { return String(text.prefix(300)) }
        return nil
    }
}
