//
//  NavvoServices.swift
//  Navvo Maps SDK for iOS
//
//  The full async/await client for Navvo's REST services — no UIKit
//  dependency, so it runs in app extensions, background tasks and unit tests
//  without instantiating a map.
//

import Foundation

/// A typed, `async/await` client for Navvo's REST API.
///
/// It injects the `x-api-key` header, normalises coordinates so callers never
/// touch wire axis order, retries idempotent GETs with backoff, and maps HTTP
/// status codes to the typed ``NavvoError`` hierarchy.
///
/// ```swift
/// let services = NavvoServices.shared            // uses the configured key
/// // or an explicit client (different key / base URL):
/// let other = NavvoServices(apiKey: "nvvo_...")
///
/// let result = try await services.search("coffee in Abdoun")
/// ```
public actor NavvoServices {

    /// The shared client bound to the globally configured key
    /// (``NavvoMaps/configure(apiKey:baseURL:defaultStyle:)``).
    public static let shared = NavvoServices()

    /// The API base URL this client targets.
    public nonisolated let baseURL: URL

    private let http: HTTPTransport

    /// Create a client. Falls back to the global `NavvoMaps` configuration
    /// when `apiKey` / `baseURL` are omitted.
    ///
    /// - Parameters:
    ///   - apiKey: Explicit key; `nil` uses the globally configured key.
    ///   - baseURL: Explicit base URL; `nil` uses the configured one.
    ///   - timeout: Per-attempt request timeout (seconds).
    ///   - maxRetries: Max retries for idempotent GETs (total = retries + 1).
    ///   - defaultLanguage: `Accept-Language` sent on every request.
    ///   - session: Inject a custom `URLSession` (tests / custom caching).
    public init(
        apiKey: String? = nil,
        baseURL: URL? = nil,
        timeout: TimeInterval = 20,
        maxRetries: Int = 3,
        defaultLanguage: String? = nil,
        session: URLSession? = nil
    ) {
        let resolvedBase = baseURL ?? NavvoMaps.baseURL
        self.baseURL = resolvedBase
        let keyProvider: () throws -> String
        if let apiKey, !apiKey.isEmpty {
            keyProvider = { apiKey }
        } else {
            keyProvider = { try NavvoMaps.requireAPIKey() }
        }
        self.http = HTTPTransport(
            baseURL: resolvedBase,
            apiKey: keyProvider,
            timeout: timeout,
            maxRetries: maxRetries,
            defaultLanguage: defaultLanguage,
            session: session
        )
    }

    // =====================================================================
    // MARK: Tiles / styles / map config
    // =====================================================================

    /// The catalog of available map styles. `GET /tiles/styles`.
    public func styles() async throws -> [NavvoStyleInfo] {
        let body = try await http.get("/tiles/styles")
        guard let list = body["styles"]?.arrayValue else { return [] }
        return list.compactMap(NavvoStyleInfo.init(json:))
    }

    /// Build a renderer-fetchable style URL (key as `?api_key=`).
    /// Delegates to ``NavvoMaps/styleURL(for:terrain:)``.
    public nonisolated func styleURL(for style: NavvoMapStyle, terrain: Bool = false) throws -> URL {
        try NavvoMaps.styleURL(for: style, terrain: terrain)
    }

    /// The runtime map configuration manifest. `GET /map/config`.
    public func mapConfig() async throws -> NavvoJSON {
        try await http.get("/map/config")
    }

    // =====================================================================
    // MARK: Search & geocode
    // =====================================================================

    /// Full-text / natural-language place search. `GET /search`. Scope `search`.
    public func search(
        _ query: String,
        near: LatLng? = nil,
        limit: Int? = nil,
        page: Int? = nil,
        locale: String? = nil,
        bbox: LatLngBounds? = nil
    ) async throws -> SearchResult {
        guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
            throw NavvoError.config("search: query must be non-empty")
        }
        var params: [String: String] = ["q": query]
        if let limit { params["limit"] = String(limit) }
        if let page { params["page"] = String(page) }
        if let locale { params["locale"] = locale }
        if let bbox { params["bbox"] = bbox.bboxParameter }
        if let near {
            try near.validated()
            params["lat"] = String(near.latitude)
            params["lng"] = String(near.longitude)
        }
        return SearchResult(raw: try await http.get("/search", query: params))
    }

    /// Forward geocode a free-text query. `GET /geocode/search`. Scope `geocode`.
    public func geocode(
        _ query: String,
        near: LatLng? = nil,
        limit: Int? = nil,
        locale: String? = nil,
        bbox: LatLngBounds? = nil
    ) async throws -> [GeocodeResult] {
        guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
            throw NavvoError.config("geocode: query must be non-empty")
        }
        var params: [String: String] = ["q": query]
        if let limit { params["limit"] = String(min(50, max(1, limit))) }
        if let locale { params["locale"] = locale }
        if let bbox { params["bbox"] = bbox.bboxParameter }
        if let near {
            try near.validated()
            params["lat"] = String(near.latitude)
            params["lon"] = String(near.longitude) // geocode uses `lon`
        }
        let body = try await http.get("/geocode/search", query: params)
        guard let data = body["data"]?.arrayValue else { return [] }
        return data.map { GeocodeResult(raw: $0) }
    }

    /// Reverse geocode a coordinate to the nearest address.
    /// `GET /geocode/reverse` — the backend takes separate `lat` & `lon`
    /// query params; the SDK marshals for you.
    public func reverseGeocode(_ point: LatLng) async throws -> GeocodeResult? {
        try point.validated()
        let body = try await http.get(
            "/geocode/reverse",
            query: ["lat": String(point.latitude), "lon": String(point.longitude)]
        )
        guard let data = body["data"], let object = data.objectValue, !object.isEmpty else {
            return nil
        }
        return GeocodeResult(raw: data)
    }

    /// Bulk forward geocode. `POST /geocode/batch`. Auto-chunks to 50/request.
    public func batchGeocode(
        _ queries: [String],
        locale: String? = nil,
        bbox: LatLngBounds? = nil
    ) async throws -> [GeocodeResult] {
        guard !queries.isEmpty else {
            throw NavvoError.config("batchGeocode: queries must be non-empty")
        }
        var output: [GeocodeResult] = []
        var index = 0
        while index < queries.count {
            let chunk = Array(queries[index..<min(index + 50, queries.count)])
            var body: [String: NavvoJSON] = [
                "queries": .array(chunk.map { NavvoJSON.string($0) })
            ]
            if let locale { body["locale"] = .string(locale) }
            if let bbox { body["bbox"] = .string(bbox.bboxParameter) }
            let response = try await http.post("/geocode/batch", body: .object(body))
            if let results = response["results"]?.arrayValue {
                output.append(contentsOf: results.map { GeocodeResult(raw: $0) })
            }
            index += 50
        }
        return output
    }

    // =====================================================================
    // MARK: Places
    // =====================================================================

    /// Paginated place listing. `GET /places`. Scope `places`.
    public func places(
        query: String? = nil,
        category: String? = nil,
        near: LatLng? = nil,
        radiusKm: Double? = nil,
        openNow: Bool? = nil,
        minRating: Double? = nil,
        sort: PlaceSort? = nil,
        page: Int = 1,
        limit: Int = 20
    ) async throws -> PlacesPage {
        var params: [String: String] = [
            "page": String(page),
            "limit": String(limit),
        ]
        if let query { params["q"] = query }
        if let category { params["category"] = category }
        if let radiusKm { params["radiusKm"] = String(radiusKm) }
        if let openNow { params["openNow"] = openNow ? "true" : "false" }
        if let minRating { params["minRating"] = String(minRating) }
        if let sort { params["sort"] = sort.rawValue }
        if let near {
            try near.validated()
            params["near"] = "\(near.latitude),\(near.longitude)"
        }
        return PlacesPage(raw: try await http.get("/places", query: params))
    }

    /// Full place detail. `GET /places/{slug}`.
    ///
    /// Soft-nil getter: an unknown slug yields a 404 (or a `200 null` body),
    /// which this method returns as `nil` — it does **not** throw on
    /// not-found. Every other typed error still throws.
    public func place(_ slug: String) async throws -> PlaceDetails? {
        guard !slug.isEmpty else {
            throw NavvoError.config("place: slug is required")
        }
        let encoded = slug.addingPercentEncoding(
            withAllowedCharacters: .urlPathAllowed
        ) ?? slug
        do {
            let body = try await http.get("/places/\(encoded)")
            guard let object = body.objectValue, !object.isEmpty else { return nil }
            return PlaceDetails(raw: body)
        } catch let error as NavvoError {
            if case .notFound = error { return nil }
            throw error
        }
    }

    /// The recommended entrance to route to for a place, else its centre.
    public static func routeTarget(for place: PlaceDetails?) -> LatLng? {
        place?.recommendedRouteTarget ?? place?.center
    }

    /// Lightweight thumbnail + open-state for a place. `GET /places/thumb`.
    public func placeThumb(_ slug: String) async throws -> NavvoJSON {
        try await http.get("/places/thumb", query: ["slug": slug])
    }

    /// The full category taxonomy tree. `GET /places/categories`.
    public func placeCategories() async throws -> NavvoJSON {
        try await http.get("/places/categories")
    }

    /// Isochrone-bounded "what's reachable". `POST /places/reachable`.
    public func reachable(_ request: ReachableRequest) async throws -> ReachableResult {
        try request.origin.validated()
        var body: [String: NavvoJSON] = ["origin": request.origin.latLngJSON()]
        if let minutes = request.minutes { body["minutes"] = .number(Double(minutes)) }
        if let costing = request.costing { body["costing"] = .string(costing) }
        if let query = request.query { body["q"] = .string(query) }
        if let categories = request.categories, !categories.isEmpty {
            body["categories"] = .string(categories.joined(separator: ","))
        }
        if let limit = request.limit { body["limit"] = .number(Double(limit)) }
        let response = try await http.post("/places/reachable", body: .object(body))
        // The reachable payload may nest under `reachable`.
        if let inner = response["reachable"], inner.objectValue != nil {
            return ReachableResult(raw: inner)
        }
        return ReachableResult(raw: response)
    }

    // =====================================================================
    // MARK: Routing
    // =====================================================================

    /// High-level corridor-aware route planner. `POST /routing/plan`
    /// (HTTP 201 on success).
    ///
    /// Locations serialise as `{lon, lat, name?}`. The engine envelope is
    /// nested under the `engine` key — ``RoutePlan/geometry`` /
    /// ``RoutePlan/trip`` read it for you. Throws
    /// ``NavvoError/engineUnavailable(_:)`` when the engine is down.
    public func plan(_ request: RoutePlanRequest) async throws -> RoutePlan {
        guard request.locations.count >= 2 else {
            throw NavvoError.config("plan: at least 2 locations are required")
        }
        for stop in request.locations { try stop.point.validated() }
        var body: [String: NavvoJSON] = [
            "locations": .array(request.locations.map { $0.located.lonLatJSON() })
        ]
        if let costing = request.costing { body["costing"] = .string(costing.rawValue) }
        if let preference = request.preference {
            body["preference"] = .string(preference.rawValue)
        }
        if let avoid = request.avoid, !avoid.isEmpty {
            body["avoid"] = .array(avoid.map { NavvoJSON.string($0.rawValue) })
        }
        if let optimize = request.optimize { body["optimize"] = .bool(optimize) }
        if let includeLabels = request.includeLabels {
            body["includeLabels"] = .bool(includeLabels)
        }
        if let avoidIncidents = request.avoidIncidents {
            body["avoidIncidents"] = .bool(avoidIncidents)
        }
        if let language = request.language { body["language"] = .string(language) }
        if let constraintsText = request.constraintsText {
            body["constraintsText"] = .string(constraintsText)
        }
        if let departureTime = request.departureTime {
            body["departureTime"] = .string(departureTime)
        }
        if let truck = request.truck { body["vehicle"] = truck.json() }

        let response = try await http.post("/routing/plan", body: .object(body))
        try assertEngineAvailable(response, path: "/routing/plan")
        return RoutePlan(raw: response)
    }

    /// Low-level passthrough directions (no avoidance). `POST /routing/route`.
    ///
    /// Locations serialise as `{lat, lon}`; the response is flat (`data.trip`).
    public func route(_ request: RouteRequest) async throws -> RouteResult {
        guard request.locations.count >= 2 else {
            throw NavvoError.config("route: at least 2 locations are required")
        }
        for point in request.locations { try point.validated() }
        var payload: [String: NavvoJSON] = [
            "locations": .array(request.locations.map { $0.latLonJSON() })
        ]
        if let costing = request.costing { payload["costing"] = .string(costing.rawValue) }
        if request.units != nil || request.language != nil {
            var options: [String: NavvoJSON] = [:]
            if let units = request.units { options["units"] = .string(units.rawValue) }
            if let language = request.language { options["language"] = .string(language) }
            payload["directions_options"] = .object(options)
        }
        if let alternates = request.alternates {
            payload["alternates"] = .number(Double(alternates))
        }
        let response = try await http.post(
            "/routing/route", body: .object(["payload": .object(payload)])
        )
        try assertEngineAvailable(response, path: "/routing/route")
        return RouteResult(raw: response)
    }

    /// Traffic-aware ETA + congestion segments. `POST /traffic/route`.
    ///
    /// Needs both `routing` and `traffic` scopes. Locations serialise as
    /// `{lon, lat}` (same shape as ``plan(_:)``).
    public func trafficRoute(_ request: RouteRequest) async throws -> TrafficRoute {
        guard request.locations.count >= 2 else {
            throw NavvoError.config("trafficRoute: at least 2 locations are required")
        }
        for point in request.locations { try point.validated() }
        var body: [String: NavvoJSON] = [
            "locations": .array(request.locations.map { $0.lonLatJSON() })
        ]
        if let costing = request.costing { body["costing"] = .string(costing.rawValue) }
        if let departureTime = request.departureTime {
            body["departureTime"] = .string(departureTime)
        }
        if let language = request.language { body["language"] = .string(language) }
        let response = try await http.post("/traffic/route", body: .object(body))
        try assertEngineAvailable(response, path: "/traffic/route")
        return TrafficRoute(raw: response)
    }

    /// Aggregate live traffic over a bbox. `GET /traffic/summary`. Scope `traffic`.
    public func trafficSummary(_ bbox: LatLngBounds) async throws -> NavvoJSON {
        try await http.get("/traffic/summary", query: ["bbox": bbox.bboxParameter])
    }

    /// Travel-time / distance matrix. `POST /routing/matrix` (HTTP 201).
    ///
    /// Sources/targets serialise as `{lat, lon}`; unreachable pairs are `null`.
    public func matrix(_ request: MatrixRequest) async throws -> TravelMatrix {
        for point in request.sources { try point.validated() }
        for point in request.targets { try point.validated() }
        var payload: [String: NavvoJSON] = [
            "sources": .array(request.sources.map { $0.latLonJSON() }),
            "targets": .array(request.targets.map { $0.latLonJSON() }),
        ]
        if let costing = request.costing { payload["costing"] = .string(costing.rawValue) }
        let response = try await http.post(
            "/routing/matrix", body: .object(["payload": .object(payload)])
        )
        try assertEngineAvailable(response, path: "/routing/matrix")
        if let data = response["data"], data.objectValue != nil {
            return TravelMatrix(raw: .object(["data": data]))
        }
        return TravelMatrix(raw: response)
    }

    /// Reachability polygons (GeoJSON). `POST /routing/isochrone` (HTTP 201).
    public func isochrone(_ request: IsochroneRequest) async throws -> IsochroneResult {
        try request.origin.validated()
        var payload: [String: NavvoJSON] = [
            "locations": .array([request.origin.latLonJSON()]),
            "contours": .array(request.contours.map { $0.json() }),
        ]
        if let costing = request.costing { payload["costing"] = .string(costing.rawValue) }
        if let polygons = request.polygons { payload["polygons"] = .bool(polygons) }
        let response = try await http.post(
            "/routing/isochrone", body: .object(["payload": .object(payload)])
        )
        try assertEngineAvailable(response, path: "/routing/isochrone")
        // The GeoJSON FeatureCollection is under `data`.
        if let data = response["data"], data.objectValue != nil {
            return IsochroneResult(geoJSON: data)
        }
        return IsochroneResult(geoJSON: response)
    }

    /// Snap a GPS trace to roads. `POST /routing/map-match` (HTTP 201).
    public func mapMatch(_ request: MapMatchRequest) async throws -> RouteResult {
        for trace in request.trace { try trace.point.validated() }
        let shape: [NavvoJSON] = request.trace.map { trace in
            var object: [String: NavvoJSON] = [
                "lat": .number(trace.point.latitude),
                "lon": .number(trace.point.longitude),
            ]
            if let time = trace.time { object["time"] = .number(Double(time)) }
            return .object(object)
        }
        var payload: [String: NavvoJSON] = ["shape": .array(shape)]
        if let costing = request.costing { payload["costing"] = .string(costing.rawValue) }
        if let shapeMatch = request.shapeMatch {
            payload["shape_match"] = .string(shapeMatch.rawValue)
        }
        let response = try await http.post(
            "/routing/map-match", body: .object(["payload": .object(payload)])
        )
        try assertEngineAvailable(response, path: "/routing/map-match")
        return RouteResult(raw: response)
    }

    /// TSP stop ordering via the routing engine. `POST /routing/optimize`.
    ///
    /// First and last locations are the fixed start/end.
    public func optimize(_ request: OptimizeRequest) async throws -> RoutePlan {
        for point in request.locations { try point.validated() }
        var payload: [String: NavvoJSON] = [
            "locations": .array(request.locations.map { $0.latLonJSON() })
        ]
        if let costing = request.costing { payload["costing"] = .string(costing.rawValue) }
        let response = try await http.post(
            "/routing/optimize", body: .object(["payload": .object(payload)])
        )
        try assertEngineAvailable(response, path: "/routing/optimize")
        // RoutePlan reads both an `engine`-nested and a flat `data.trip` shape.
        return RoutePlan(raw: response)
    }

    private func assertEngineAvailable(_ body: NavvoJSON, path: String) throws {
        // The engine envelope may sit at the top level (route/matrix) or under
        // `engine` (plan); check both for an explicit `available: false`.
        let available = body["available"]?.boolValue
            ?? body["engine"]?["available"]?.boolValue
        if available == false {
            throw NavvoError.engineUnavailable(
                NavvoErrorContext(
                    message: "routing engine unavailable (available: false)",
                    statusCode: nil,
                    path: path,
                    method: "POST",
                    body: body
                )
            )
        }
    }

    // =====================================================================
    // MARK: Incidents / road edits / risk
    // =====================================================================

    /// Active incidents as GeoJSON. `GET /incidents/geojson`. Scope `traffic`.
    public func incidents(
        bbox: LatLngBounds? = nil,
        type: String? = nil,
        limit: Int? = nil
    ) async throws -> NavvoJSON {
        var params: [String: String] = [:]
        if let bbox { params["bbox"] = bbox.bboxParameter }
        if let type { params["type"] = type }
        if let limit { params["limit"] = String(limit) }
        return try await http.get("/incidents/geojson", query: params)
    }

    /// The catalog of incident types. `GET /incidents/types`.
    public func incidentTypes() async throws -> NavvoJSON {
        try await http.get("/incidents/types")
    }

    /// Platform road edits/closures as GeoJSON. `GET /road-edits/geojson`.
    public func roadEdits(bbox: LatLngBounds? = nil) async throws -> NavvoJSON {
        var params: [String: String] = [:]
        if let bbox { params["bbox"] = bbox.bboxParameter }
        return try await http.get("/road-edits/geojson", query: params)
    }

    /// Risk scoring for a route geometry. `POST /map/route-risk`.
    ///
    /// Scores the path against live traffic, weather, incidents, logistics
    /// zones and enforcement cameras.
    public func routeRisk(geometry: [LatLng]) async throws -> NavvoJSON {
        try await http.post(
            "/map/route-risk",
            body: .object([
                "geometry": .array(geometry.map { $0.geoJSONPositionJSON() })
            ])
        )
    }

    /// Risk scoring for a raw GeoJSON geometry (LineString/Feature,
    /// positions `[lng, lat]`). `POST /map/route-risk`.
    public func routeRisk(geometryJSON: NavvoJSON) async throws -> NavvoJSON {
        try await http.post("/map/route-risk", body: .object(["geometry": geometryJSON]))
    }

    // =====================================================================
    // MARK: Weather / air quality
    // =====================================================================

    /// Current weather + air quality at a point. `GET /map/weather/point`.
    public func weatherPoint(at point: LatLng, units: String? = nil) async throws -> NavvoJSON {
        try point.validated()
        var params: [String: String] = [
            "lat": String(point.latitude),
            "lng": String(point.longitude),
        ]
        if let units { params["units"] = units }
        return try await http.get("/map/weather/point", query: params)
    }

    /// Hourly forecast at a point. `GET /map/weather/forecast`.
    public func weatherForecast(
        at point: LatLng,
        hours: Int? = nil,
        units: String? = nil
    ) async throws -> NavvoJSON {
        try point.validated()
        var params: [String: String] = [
            "lat": String(point.latitude),
            "lng": String(point.longitude),
        ]
        if let hours { params["hours"] = String(hours) }
        if let units { params["units"] = units }
        return try await http.get("/map/weather/forecast", query: params)
    }

    /// City weather pins in a bbox. `GET /map/weather/cities`.
    public func weatherCities(
        in bbox: LatLngBounds,
        zoom: Int? = nil,
        units: String? = nil,
        limit: Int? = nil
    ) async throws -> NavvoJSON {
        var params: [String: String] = ["bbox": bbox.bboxParameter]
        if let zoom { params["zoom"] = String(zoom) }
        if let units { params["units"] = units }
        if let limit { params["limit"] = String(limit) }
        return try await http.get("/map/weather/cities", query: params)
    }

    /// City air-quality pins in a bbox. `GET /map/airquality/cities`.
    public func airQualityCities(
        in bbox: LatLngBounds,
        zoom: Int? = nil,
        limit: Int? = nil
    ) async throws -> NavvoJSON {
        var params: [String: String] = ["bbox": bbox.bboxParameter]
        if let zoom { params["zoom"] = String(zoom) }
        if let limit { params["limit"] = String(limit) }
        return try await http.get("/map/airquality/cities", query: params)
    }

    // =====================================================================
    // MARK: EV / transit
    // =====================================================================

    /// EV charging stations in a bbox. `GET /ev/stations`.
    public func evStations(
        bbox: LatLngBounds? = nil,
        minKw: Double? = nil,
        connector: String? = nil,
        available: Bool? = nil,
        network: String? = nil,
        limit: Int? = nil
    ) async throws -> NavvoJSON {
        var params: [String: String] = [:]
        if let bbox { params["bbox"] = bbox.bboxParameter }
        if let minKw { params["minKw"] = String(minKw) }
        if let connector { params["connector"] = connector }
        if let available { params["available"] = available ? "true" : "false" }
        if let network { params["network"] = network }
        if let limit { params["limit"] = String(limit) }
        return try await http.get("/ev/stations", query: params)
    }

    /// Nearby public-transit stops. `GET /transit/stops`.
    public func transitStops(near point: LatLng, radius: Int? = nil) async throws -> NavvoJSON {
        try point.validated()
        var params: [String: String] = [
            "lat": String(point.latitude),
            "lng": String(point.longitude),
        ]
        if let radius { params["radius"] = String(radius) }
        return try await http.get("/transit/stops", query: params)
    }

    /// Nearby shared-mobility (GBFS) feed. `GET /transit/gbfs`.
    public func gbfs(
        near point: LatLng,
        radius: Int? = nil,
        type: String? = nil
    ) async throws -> NavvoJSON {
        try point.validated()
        var params: [String: String] = [
            "lat": String(point.latitude),
            "lng": String(point.longitude),
        ]
        if let radius { params["radius"] = String(radius) }
        if let type { params["type"] = type }
        return try await http.get("/transit/gbfs", query: params)
    }
}
