import Foundation

enum APIError: LocalizedError {
    case http(Int, String)
    case transport
    case decoding

    var errorDescription: String? {
        switch self {
        case let .http(_, message): return message
        case .transport, .decoding: return "error_network".localized
        }
    }
}

/// Minimal async URLSession client. Attaches the bearer token and `X-User-Id` (a scaffold
/// stand-in for gateway-injected identity that booking/wallet/payment expect).
final class APIClient {

    private let session: SessionStore
    private let decoder = JSONDecoder()
    private let encoder = JSONEncoder()

    init(session: SessionStore) {
        self.session = session
    }

    func get<T: Decodable>(_ url: URL) async throws -> T {
        let data = try await perform(makeRequest(url, method: "GET", body: nil))
        return try decode(data)
    }

    func post<T: Decodable, B: Encodable>(_ url: URL, _ body: B) async throws -> T {
        let data = try await perform(makeRequest(url, method: "POST", body: try encoder.encode(body)))
        return try decode(data)
    }

    func postNoContent<B: Encodable>(_ url: URL, _ body: B) async throws {
        _ = try await perform(makeRequest(url, method: "POST", body: try encoder.encode(body)))
    }

    // MARK: - internals

    private func makeRequest(_ url: URL, method: String, body: Data?) -> URLRequest {
        var request = URLRequest(url: url)
        request.httpMethod = method
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        if let token = session.token {
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }
        if let userId = session.userId {
            request.setValue(userId, forHTTPHeaderField: "X-User-Id")
        }
        request.httpBody = body
        return request
    }

    /// The auth base URL, needed to refresh. Set once by `AppContainer`, because the client is
    /// built before the config it would otherwise read.
    var authBaseURL: URL?

    /// Serialises refreshes so ten concurrent 401s do not burn ten refresh tokens: the rotation
    /// is single-use, and racing it would look exactly like a replay attack to the server.
    private let refreshGate = RefreshGate()

    private func perform(_ request: URLRequest, isRetry: Bool = false) async throws -> Data {
        let data: Data
        let response: URLResponse
        do {
            (data, response) = try await URLSession.shared.data(for: request)
        } catch {
            throw APIError.transport
        }
        guard let http = response as? HTTPURLResponse else { throw APIError.transport }

        // An expired access token is refreshed once and the call replayed, so a rider mid-ride is
        // not signed out. Only if the refresh itself fails is the session really over, and then
        // it is cleared so the app returns to sign-in rather than failing silently: only
        // auth-service validates tokens, so a stale session would otherwise look signed in
        // forever while every auth-backed feature quietly broke.
        if http.statusCode == 401, request.value(forHTTPHeaderField: "Authorization") != nil {
            if !isRetry, await refreshAccessToken() {
                return try await perform(makeRequest(request), isRetry: true)
            }
            await MainActor.run { session.clear() }
        }

        guard (200..<300).contains(http.statusCode) else {
            let problem = try? decoder.decode(ProblemDetail.self, from: data)
            throw APIError.http(http.statusCode, problem?.detail ?? problem?.message ?? "Request failed")
        }
        return data
    }

    /// Rebuild a request with the current token, keeping method and body.
    private func makeRequest(_ original: URLRequest) -> URLRequest {
        var request = original
        if let token = session.token {
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }
        return request
    }

    /// Exchange the refresh token for a new pair. Returns false when the session is truly over.
    private func refreshAccessToken() async -> Bool {
        guard let base = authBaseURL,
              let refreshToken = session.refreshToken,
              let userId = session.userId else { return false }

        return await refreshGate.run {
            var request = URLRequest(url: base.appendingPathComponent("api/v1/auth/refresh"))
            request.httpMethod = "POST"
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            // Deliberately no Authorization header: the token that was just rejected is not a
            // credential here, and sending it would only invite another 401.
            request.httpBody = try? JSONEncoder().encode(
                RefreshRequest(userId: userId, refreshToken: refreshToken)
            )
            guard let (data, response) = try? await URLSession.shared.data(for: request),
                  let http = response as? HTTPURLResponse,
                  (200..<300).contains(http.statusCode),
                  let renewed = try? JSONDecoder().decode(AuthResponse.self, from: data),
                  let rotated = renewed.refreshToken else {
                return false
            }
            await MainActor.run {
                session.updateTokens(access: renewed.accessToken, refresh: rotated)
            }
            return true
        }
    }

    private func decode<T: Decodable>(_ data: Data) throws -> T {
        do {
            return try decoder.decode(T.self, from: data)
        } catch {
            throw APIError.decoding
        }
    }
}

/// Lets one refresh happen at a time.
///
/// Refresh tokens are single-use and rotate, so two concurrent 401s racing to refresh would burn
/// the same token twice: the second attempt looks like a replay to the server, which revokes
/// everything and signs the rider out. The gate turns that race into one refresh and one wait.
private actor RefreshGate {
    private var inFlight: Task<Bool, Never>?

    func run(_ operation: @escaping () async -> Bool) async -> Bool {
        if let inFlight {
            return await inFlight.value
        }
        let task = Task { await operation() }
        inFlight = task
        let result = await task.value
        inFlight = nil
        return result
    }
}
