//
//  Geo.swift
//  Navvo Maps SDK for iOS
//
//  Geographic value types and the coordinate adapters that hide Navvo's
//  non-uniform wire axis order behind one named public type. App code only
//  ever uses `LatLng(lat, lng)`; the SDK marshals per endpoint (GeoJSON
//  `[lng, lat]`, WKT `POINT(lng lat)`, bbox `west,south,east,north`,
//  `{lon, lat}` vs `{lat, lon}` bodies, separate `lat`/`lon` query params).
//

import Foundation
#if canImport(CoreLocation)
import CoreLocation
#endif

// MARK: - LatLng

/// A geographic point with explicit, named fields.
///
/// Always constructed positionally as `LatLng(lat, lng)` — latitude first,
/// longitude second — so the order can never be confused. The optional
/// ``name`` labels routing stops (used by `/routing/plan` locations).
public struct LatLng: Sendable {
    /// Latitude in degrees, `[-90, 90]`.
    public var latitude: Double

    /// Longitude in degrees, `[-180, 180]`.
    public var longitude: Double

    /// Optional stop label (used by `/routing/plan` / `/traffic/route`).
    public var name: String?

    /// Create a point. Latitude first, longitude second.
    public init(_ latitude: Double, _ longitude: Double, name: String? = nil) {
        self.latitude = latitude
        self.longitude = longitude
        self.name = name
    }

    /// Create a point with labeled arguments.
    public init(latitude: Double, longitude: Double, name: String? = nil) {
        self.init(latitude, longitude, name: name)
    }

    /// Build from a GeoJSON `[lng, lat]` position.
    public init?(geoJSONPosition position: [Double]) {
        guard position.count >= 2 else { return nil }
        self.init(position[1], position[0])
    }

    /// Parse a WKT `POINT(<lng> <lat>)` string (place `center` and entrance
    /// `location` fields use this shape — longitude first, space separated).
    /// Returns `nil` for empty/malformed input so callers can fall back.
    public init?(wkt: String?) {
        guard let wkt, !wkt.isEmpty else { return nil }
        let pattern = "POINT\\s*\\(\\s*(-?\\d+(?:\\.\\d+)?)\\s+(-?\\d+(?:\\.\\d+)?)\\s*\\)"
        guard
            let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]),
            let match = regex.firstMatch(
                in: wkt, options: [], range: NSRange(wkt.startIndex..., in: wkt)
            ),
            let lngRange = Range(match.range(at: 1), in: wkt),
            let latRange = Range(match.range(at: 2), in: wkt),
            let lng = Double(wkt[lngRange]),
            let lat = Double(wkt[latRange])
        else { return nil }
        self.init(lat, lng)
    }

    /// A copy of this point with an attached ``name``.
    public func named(_ name: String) -> LatLng {
        LatLng(latitude, longitude, name: name)
    }

    /// Validate the point (finite, in range). Throws ``NavvoError/config(_:)``.
    @discardableResult
    public func validated() throws -> LatLng {
        guard latitude.isFinite, longitude.isFinite else {
            throw NavvoError.config("LatLng coordinates must be finite: \(self)")
        }
        guard latitude >= -90, latitude <= 90 else {
            throw NavvoError.config("latitude out of range [-90, 90]: \(latitude)")
        }
        guard longitude >= -180, longitude <= 180 else {
            throw NavvoError.config("longitude out of range [-180, 180]: \(longitude)")
        }
        return self
    }
}

extension LatLng: Equatable, Hashable {
    /// Equality compares coordinates only (the ``name`` label is ignored).
    public static func == (lhs: LatLng, rhs: LatLng) -> Bool {
        lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude
    }

    public func hash(into hasher: inout Hasher) {
        hasher.combine(latitude)
        hasher.combine(longitude)
    }
}

extension LatLng: CustomStringConvertible {
    public var description: String { "LatLng(\(latitude), \(longitude))" }
}

// Internal wire marshallers — never surfaced to app code.
extension LatLng {
    /// `{lon, lat[, name]}` — `/routing/plan` and `/traffic/route`.
    func lonLatJSON() -> NavvoJSON {
        var object: [String: NavvoJSON] = [
            "lon": .number(longitude),
            "lat": .number(latitude),
        ]
        if let name { object["name"] = .string(name) }
        return .object(object)
    }

    /// `{lat, lon}` — the routing-engine passthrough (`/routing/route`,
    /// `/routing/matrix`, `/routing/isochrone`, `/routing/optimize`).
    func latLonJSON() -> NavvoJSON {
        .object(["lat": .number(latitude), "lon": .number(longitude)])
    }

    /// `{lat, lng}` — places / EV / transit bodies.
    func latLngJSON() -> NavvoJSON {
        .object(["lat": .number(latitude), "lng": .number(longitude)])
    }

    /// `[lng, lat]` — GeoJSON position order.
    func geoJSONPositionJSON() -> NavvoJSON {
        .array([.number(longitude), .number(latitude)])
    }
}

#if canImport(CoreLocation)
extension LatLng {
    /// Bridge from a CoreLocation coordinate.
    public init(_ coordinate: CLLocationCoordinate2D) {
        self.init(coordinate.latitude, coordinate.longitude)
    }

    /// The point as a CoreLocation coordinate.
    public var clCoordinate: CLLocationCoordinate2D {
        CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
    }
}
#endif

// MARK: - LatLngBounds

/// An axis-aligned bounding box. The named corners hide the longitude-first
/// wire order (`bbox=west,south,east,north`).
public struct LatLngBounds: Equatable, Sendable {
    /// The south-west (minimum lat/lng) corner.
    public var southwest: LatLng

    /// The north-east (maximum lat/lng) corner.
    public var northeast: LatLng

    /// Create a bounds from its two opposite corners.
    public init(southwest: LatLng, northeast: LatLng) {
        self.southwest = southwest
        self.northeast = northeast
    }

    /// The smallest bounds containing every point, or `nil` when empty.
    public init?(fitting points: [LatLng]) {
        guard let first = points.first else { return nil }
        var minLat = first.latitude
        var maxLat = first.latitude
        var minLng = first.longitude
        var maxLng = first.longitude
        for point in points.dropFirst() {
            minLat = Swift.min(minLat, point.latitude)
            maxLat = Swift.max(maxLat, point.latitude)
            minLng = Swift.min(minLng, point.longitude)
            maxLng = Swift.max(maxLng, point.longitude)
        }
        self.init(
            southwest: LatLng(minLat, minLng),
            northeast: LatLng(maxLat, maxLng)
        )
    }

    /// The wire string `west,south,east,north` used by `bbox=` query params.
    public var bboxParameter: String {
        "\(southwest.longitude),\(southwest.latitude),"
            + "\(northeast.longitude),\(northeast.latitude)"
    }
}

extension LatLngBounds: CustomStringConvertible {
    public var description: String { "LatLngBounds(\(southwest), \(northeast))" }
}

// MARK: - CameraPosition

/// The position of the map camera: target, zoom, bearing and tilt.
///
/// `bearing` is degrees clockwise from north; `tilt` is the pitch in degrees
/// (`0...60`, meaningful on vector styles).
public struct CameraPosition: Equatable, Sendable {
    /// The geographic point the camera is centred on.
    public var target: LatLng

    /// The zoom level, `0...22`.
    public var zoom: Double

    /// The bearing in degrees, clockwise from north.
    public var bearing: Double

    /// The tilt (pitch) in degrees, `0...60`.
    public var tilt: Double

    /// Create a camera position.
    public init(target: LatLng, zoom: Double = 12, bearing: Double = 0, tilt: Double = 0) {
        self.target = target
        self.zoom = zoom
        self.bearing = bearing
        self.tilt = tilt
    }
}

extension CameraPosition: CustomStringConvertible {
    public var description: String {
        "CameraPosition(target: \(target), zoom: \(zoom), bearing: \(bearing), tilt: \(tilt))"
    }
}

extension CameraPosition {
    /// Loose equality used by the SwiftUI binding sync to avoid feedback loops.
    func isApproximately(_ other: CameraPosition) -> Bool {
        abs(target.latitude - other.target.latitude) < 1e-6
            && abs(target.longitude - other.target.longitude) < 1e-6
            && abs(zoom - other.zoom) < 0.01
            && abs(bearing - other.bearing) < 0.1
            && abs(tilt - other.tilt) < 0.1
    }
}

// MARK: - Polyline codec

/// Encoder/decoder for the compressed polyline geometry used by Navvo route
/// responses. Route leg shapes are polyline6 (precision `1e6`), the default;
/// pass `precision: 5` for classic `1e5` polylines.
public enum NavvoPolylineCodec {

    /// Decode an encoded polyline string into points.
    public static func decode(_ encoded: String, precision: Int = 6) -> [LatLng] {
        guard !encoded.isEmpty else { return [] }
        let factor = pow(10.0, Double(precision))
        let bytes = Array(encoded.utf8)
        var points: [LatLng] = []
        var index = 0
        var lat = 0
        var lng = 0
        while index < bytes.count {
            guard let latDelta = decodeChunk(bytes, &index) else { break }
            lat += latDelta
            guard let lngDelta = decodeChunk(bytes, &index) else { break }
            lng += lngDelta
            points.append(LatLng(Double(lat) / factor, Double(lng) / factor))
        }
        return points
    }

    /// Encode points into a polyline string (round-trips ``decode(_:precision:)``).
    public static func encode(_ points: [LatLng], precision: Int = 6) -> String {
        let factor = pow(10.0, Double(precision))
        var output = ""
        var previousLat = 0
        var previousLng = 0

        func append(_ delta: Int) {
            var value = delta < 0 ? ~(delta << 1) : (delta << 1)
            while value >= 0x20 {
                output.append(Character(UnicodeScalar(UInt8((0x20 | (value & 0x1f)) + 63))))
                value >>= 5
            }
            output.append(Character(UnicodeScalar(UInt8(value + 63))))
        }

        for point in points {
            let lat = Int((point.latitude * factor).rounded())
            let lng = Int((point.longitude * factor).rounded())
            append(lat - previousLat)
            append(lng - previousLng)
            previousLat = lat
            previousLng = lng
        }
        return output
    }

    private static func decodeChunk(_ bytes: [UInt8], _ index: inout Int) -> Int? {
        var shift = 0
        var result = 0
        while index < bytes.count {
            let byte = Int(bytes[index]) - 63
            index += 1
            result |= (byte & 0x1f) << shift
            shift += 5
            if byte < 0x20 {
                return (result & 1) != 0 ? ~(result >> 1) : (result >> 1)
            }
        }
        return nil
    }
}
