import CoreLocation
import SMap
import UIKit

/// The native side of `<SMap>` on iOS: holds one `SMapView`, created by `start` with the first props (the config is
/// read then; a new config arrives as a new view). The Fabric component view (`RNSMapNativeView`) forwards props and
/// commands here and turns `onEvent` calls into React Native events.
@objc(RNSMapHost)
public final class RNSMapHost: UIView {
    /// Called with an event name (`mapReady`, `mapError`, `layerTap`, `tokenRequest`) and its payload.
    @objc public var onEvent: ((String, [String: Any]) -> Void)?

    private var map: SMapView?
    private var appliedStyle = ""
    private var appliedLanguage = ""
    private var appliedCamera = ""
    private var appliedBuildings = false
    private var nextTokenRequest = 0
    private var tokenRequests: [Int: CheckedContinuation<String, Error>] = [:]

    @objc public var isStarted: Bool { map != nil }

    /// Creates the map. `config`: JSON {baseUrl, nodes, apiKey, token, nearest, getToken}.
    @objc public func start(config: String, style: String, language: String, camera: String, buildings: Bool) {
        guard map == nil else { return }
        let c = RNSMapHost.object(config)
        let cam = RNSMapHost.object(camera)
        var options = SMapOptions()
        if let base = c["baseUrl"] as? String, let url = URL(string: base) { options.baseURL = url }
        options.nodes = (c["nodes"] as? [String] ?? []).compactMap { URL(string: $0) }
        options.token = (c["token"] as? String).flatMap { $0.isEmpty ? nil : $0 }
        options.apiKey = (c["apiKey"] as? String).flatMap { $0.isEmpty ? nil : $0 }
        options.nearest = c["nearest"] as? Bool ?? true
        if c["getToken"] as? Bool == true {
            options.getToken = { [weak self] in
                guard let self = self else { throw SMapError.notReady }
                return try await self.requestToken()
            }
        }
        options.style = SMapStyle(rawValue: style) ?? .light
        options.language = language.isEmpty ? nil : language
        if let lat = RNSMapHost.number(cam["lat"]), let lng = RNSMapHost.number(cam["lng"]) {
            options.center = CLLocationCoordinate2D(latitude: lat, longitude: lng)
        }
        options.zoom = RNSMapHost.number(cam["zoom"])
        options.bearing = RNSMapHost.number(cam["bearing"]) ?? 0
        options.pitch = RNSMapHost.number(cam["pitch"]) ?? 0
        appliedStyle = style
        appliedLanguage = language
        appliedCamera = camera
        appliedBuildings = buildings

        let m = SMapView(frame: bounds, options: options)
        m.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        m.onError = { [weak self] error in self?.emit("mapError", ["message": error.localizedDescription]) }
        m.onReady = { [weak self] ready in
            let node = ready.node.map { "\($0.scheme ?? "https")://\($0.host ?? "")" } ?? ""
            self?.emit("mapReady", ["node": node])
        }
        addSubview(m)
        map = m
        if buildings { m.setBuildings3D(true) }
    }

    /// Applies props that changed since the last call.
    @objc public func update(style: String, language: String, camera: String, buildings: Bool) {
        guard let m = map else { return }
        if style != appliedStyle {
            appliedStyle = style
            m.setStyle(SMapStyle(rawValue: style) ?? .light)
        }
        if language != appliedLanguage {
            appliedLanguage = language
            if !language.isEmpty { m.setLanguage(language) }
        }
        if camera != appliedCamera {
            appliedCamera = camera
            move(m, RNSMapHost.object(camera))
        }
        if buildings != appliedBuildings {
            appliedBuildings = buildings
            m.setBuildings3D(buildings)
        }
    }

    private func move(_ m: SMapView, _ cam: [String: Any]) {
        let engine = m.mapView
        let camera = engine.camera
        if let lat = RNSMapHost.number(cam["lat"]), let lng = RNSMapHost.number(cam["lng"]) {
            camera.centerCoordinate = CLLocationCoordinate2D(latitude: lat, longitude: lng)
        }
        if let bearing = RNSMapHost.number(cam["bearing"]) { camera.heading = bearing }
        if let pitch = RNSMapHost.number(cam["pitch"]) { camera.pitch = CGFloat(pitch) }
        engine.setCamera(camera, animated: false)
        if let zoom = RNSMapHost.number(cam["zoom"]) { engine.setZoomLevel(zoom, animated: false) }
    }

    // MARK: - Commands

    @objc public func addLayer(kind: String, id: String, data: String, options: String) {
        guard let m = map else { return }
        let o = RNSMapHost.object(options)
        let d = SMapData.geoJSON(Data(data.utf8))
        do {
            switch kind {
            case "points": try m.addPoints(id, data: d, options: RNSMapHost.pointOptions(o))
            case "lines": try m.addLines(id, data: d, options: RNSMapHost.lineOptions(o))
            case "polygons": try m.addPolygons(id, data: d, options: RNSMapHost.polygonOptions(o))
            default:
                emit("mapError", ["message": "sMap: unknown layer kind \(kind)"])
                return
            }
            m.onTap(id) { [weak self] properties, coordinate in
                let json = (try? JSONSerialization.data(withJSONObject: RNSMapHost.jsonSafe(properties))).flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
                self?.emit("layerTap", ["layerId": id, "properties": json, "lat": coordinate.latitude, "lng": coordinate.longitude])
            }
        } catch {
            emit("mapError", ["message": error.localizedDescription])
        }
    }

    @objc public func setData(id: String, data: String) {
        do { try map?.setData(id, data: .geoJSON(Data(data.utf8))) } catch { emit("mapError", ["message": error.localizedDescription]) }
    }

    @objc public func setVisible(id: String, visible: Bool) { map?.setVisible(id, visible) }

    @objc public func removeLayer(id: String) { map?.removeLayer(id) }

    @objc public func fitLayer(id: String, padding: Double, maxZoom: Double) { map?.fitLayer(id, padding: padding, maxZoom: maxZoom) }

    @objc public func flyTo(lat: Double, lng: Double, zoom: Double) {
        map?.flyTo(CLLocationCoordinate2D(latitude: lat, longitude: lng), zoom: zoom < 0 ? nil : zoom)
    }

    @objc public func resolveToken(requestId: Int, token: String, error: String) {
        guard let c = tokenRequests.removeValue(forKey: requestId) else { return }
        if token.isEmpty {
            c.resume(throwing: SMapError.network(url: "", message: error.isEmpty ? "no token" : error))
        } else {
            c.resume(returning: token)
        }
    }

    /// Ends the map and fails any token request still waiting.
    @objc public func stop() {
        for (_, c) in tokenRequests { c.resume(throwing: SMapError.notReady) }
        tokenRequests.removeAll()
        map?.removeFromSuperview()
        map = nil
    }

    private func requestToken() async throws -> String {
        nextTokenRequest += 1
        let id = nextTokenRequest
        return try await withCheckedThrowingContinuation { c in
            tokenRequests[id] = c
            emit("tokenRequest", ["requestId": id])
        }
    }

    private func emit(_ name: String, _ payload: [String: Any]) { onEvent?(name, payload) }

    // MARK: - Options from JSON (the names every sMap SDK uses; `minzoom` / `maxzoom` as in the Web SDK too)

    static func object(_ json: String) -> [String: Any] {
        guard !json.isEmpty, let data = json.data(using: .utf8),
              let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
        return o
    }

    static func number(_ v: Any?) -> Double? {
        guard let n = v as? NSNumber, !(v is Bool) else { return nil }
        return n.doubleValue
    }

    private static func zoom(_ o: [String: Any], _ name: String) -> Double? {
        number(o[name]) ?? number(o[name.lowercased()])
    }

    private static func color(_ v: Any?) -> UIColor? { (v as? String).map { UIColor(smapHex: $0) } }

    private static func value(_ v: Any?, _ fallback: Double) -> SMapValue {
        if let s = v as? String { return .property(s) }
        return .number(number(v) ?? fallback)
    }

    static func pointOptions(_ o: [String: Any]) -> SMapPointOptions {
        var p = SMapPointOptions()
        if let c = o["cluster"] as? [String: Any] {
            p.cluster = SMapCluster(radius: number(c["radius"]) ?? 50, maxZoom: number(c["maxZoom"]) ?? 14)
        } else if o["cluster"] as? Bool == true {
            p.cluster = SMapCluster()
        }
        if let c = color(o["color"]) { p.color = c }
        if let r = number(o["radius"]) { p.radius = r }
        if let c = color(o["strokeColor"]) { p.strokeColor = c }
        if let w = number(o["strokeWidth"]) { p.strokeWidth = w }
        p.icon = o["icon"] as? String
        if let s = number(o["iconSize"]) { p.iconSize = s }
        p.label = o["label"] as? String
        p.minZoom = zoom(o, "minZoom")
        p.maxZoom = zoom(o, "maxZoom")
        p.zIndex = Int(number(o["zIndex"]) ?? 0)
        return p
    }

    static func lineOptions(_ o: [String: Any]) -> SMapLineOptions {
        var l = SMapLineOptions()
        if let c = color(o["color"]) { l.color = c }
        if let w = number(o["width"]) { l.width = w }
        l.dash = (o["dash"] as? [NSNumber])?.map { $0.doubleValue }
        if let a = number(o["opacity"]) { l.opacity = a }
        l.minZoom = zoom(o, "minZoom")
        l.maxZoom = zoom(o, "maxZoom")
        l.zIndex = Int(number(o["zIndex"]) ?? 0)
        return l
    }

    static func polygonOptions(_ o: [String: Any]) -> SMapPolygonOptions {
        var p = SMapPolygonOptions()
        if let c = color(o["fill"]) ?? color(o["color"]) { p.fill = c }
        p.opacity = number(o["opacity"])
        p.outline = color(o["outline"])
        if let w = number(o["outlineWidth"]) { p.outlineWidth = w }
        if let e = o["extrude"] as? [String: Any] {
            p.extrude = SMapExtrude(height: value(e["height"], 20), base: value(e["base"], 0))
        }
        p.minZoom = zoom(o, "minZoom")
        p.maxZoom = zoom(o, "maxZoom")
        p.zIndex = Int(number(o["zIndex"]) ?? 0)
        return p
    }

    private static func jsonSafe(_ value: Any) -> Any {
        switch value {
        case let d as [String: Any]: return d.mapValues { jsonSafe($0) }
        case let a as [Any]: return a.map { jsonSafe($0) }
        case is String, is NSNumber, is NSNull: return value
        default: return String(describing: value)
        }
    }
}
