//
//  NavvoMapView.swift
//  Navvo Maps SDK for iOS
//
//  The UIKit map view. Wraps the embedded rendering engine and points it at
//  Navvo's `/tiles/style` endpoint — the single rendering entry point. The
//  public surface exposes only Navvo types.
//

#if canImport(UIKit) && canImport(MapLibre)
import UIKit
import CoreLocation
import MapLibre

// MARK: - NavvoMarker

/// A marker pinned to the map. Reference type so tap callbacks can identify
/// the exact instance that was tapped.
public final class NavvoMarker {
    /// A stable identifier (used for diffing in the SwiftUI wrapper).
    public let id: String

    /// The pin position.
    public var position: LatLng

    /// Callout title.
    public var title: String?

    /// Callout subtitle.
    public var subtitle: String?

    /// Custom icon image; `nil` uses the default pin.
    public var icon: UIImage?

    /// Arbitrary app payload.
    public var userData: Any?

    /// Per-marker tap handler (also delivered to the view delegate and the
    /// map's `onMarkerTap` hook).
    public var onTap: ((NavvoMarker) -> Void)?

    /// Create a marker.
    public init(
        id: String = UUID().uuidString,
        position: LatLng,
        title: String? = nil,
        subtitle: String? = nil,
        icon: UIImage? = nil,
        userData: Any? = nil,
        onTap: ((NavvoMarker) -> Void)? = nil
    ) {
        self.id = id
        self.position = position
        self.title = title
        self.subtitle = subtitle
        self.icon = icon
        self.userData = userData
        self.onTap = onTap
    }
}

// MARK: - Overlay styling

/// Styling for a GeoJSON overlay added via `NavvoMapView.addGeoJSON`.
public enum NavvoOverlayStyle {
    /// Stroke lines (LineString / MultiLineString features).
    case line(color: UIColor, width: CGFloat)

    /// Fill polygons, with an optional outline.
    case fill(color: UIColor, opacity: CGFloat, strokeColor: UIColor?)

    /// Draw points as circles.
    case circle(color: UIColor, radius: CGFloat)
}

// MARK: - Delegate

/// Delegate hooks for ``NavvoMapView``. All methods have default no-op
/// implementations — adopt only what you need.
public protocol NavvoMapViewDelegate: AnyObject {
    /// The style finished loading (fires again after `setStyle`).
    func mapViewDidFinishLoadingStyle(_ mapView: NavvoMapView)

    /// The user tapped the basemap (not a marker).
    func mapView(_ mapView: NavvoMapView, didTapAt coordinate: LatLng)

    /// The user long-pressed the map.
    func mapView(_ mapView: NavvoMapView, didLongPressAt coordinate: LatLng)

    /// The user tapped a marker.
    func mapView(_ mapView: NavvoMapView, didTap marker: NavvoMarker)

    /// The camera changed (during gestures/animations).
    func mapView(_ mapView: NavvoMapView, cameraDidChange camera: CameraPosition)

    /// All camera motion settled.
    func mapViewDidBecomeIdle(_ mapView: NavvoMapView)

    /// The map failed to load (configuration or network problem).
    func mapView(_ mapView: NavvoMapView, didFail error: NavvoError)
}

public extension NavvoMapViewDelegate {
    func mapViewDidFinishLoadingStyle(_ mapView: NavvoMapView) {}
    func mapView(_ mapView: NavvoMapView, didTapAt coordinate: LatLng) {}
    func mapView(_ mapView: NavvoMapView, didLongPressAt coordinate: LatLng) {}
    func mapView(_ mapView: NavvoMapView, didTap marker: NavvoMarker) {}
    func mapView(_ mapView: NavvoMapView, cameraDidChange camera: CameraPosition) {}
    func mapViewDidBecomeIdle(_ mapView: NavvoMapView) {}
    func mapView(_ mapView: NavvoMapView, didFail error: NavvoError) {}
}

// MARK: - NavvoMapView

/// A UIKit view that renders the Navvo basemap.
///
/// ```swift
/// NavvoMaps.configure(apiKey: "nvvo_YOUR_KEY")
///
/// var options = NavvoMapView.Options()
/// options.style = .standard
/// options.camera = CameraPosition(target: LatLng(31.95, 35.91), zoom: 12)
/// let mapView = NavvoMapView(frame: view.bounds, options: options)
/// view.addSubview(mapView)
/// ```
///
/// The view renders via an embedded open-source engine compiled into your
/// app; every URL it loads (styles, tiles, sprites, glyphs) is Navvo's.
/// Attribution ("© Navvo") is shown by default and must remain visible.
public final class NavvoMapView: UIView {

    /// Creation options for a ``NavvoMapView``.
    public struct Options {
        /// The initial basemap style.
        public var style: NavvoMapStyle

        /// The initial camera.
        public var camera: CameraPosition

        /// Enable terrain (pairs with ``NavvoMapStyle/threeD``).
        public var terrain: Bool

        /// Show the user-location puck (requires the location usage string
        /// in Info.plist and authorization handled by the host app).
        public var showsUserLocation: Bool

        /// Minimum zoom level (default 0).
        public var minimumZoom: Double

        /// Maximum zoom level (default 22).
        public var maximumZoom: Double

        /// Create options.
        public init(
            style: NavvoMapStyle = NavvoMaps.defaultStyle,
            camera: CameraPosition = CameraPosition(target: LatLng(31.95, 35.91), zoom: 12),
            terrain: Bool = false,
            showsUserLocation: Bool = false,
            minimumZoom: Double = 0,
            maximumZoom: Double = 22
        ) {
            self.style = style
            self.camera = camera
            self.terrain = terrain
            self.showsUserLocation = showsUserLocation
            self.minimumZoom = minimumZoom
            self.maximumZoom = maximumZoom
        }
    }

    // MARK: Public state

    /// The delegate for map events.
    public weak var delegate: NavvoMapViewDelegate?

    /// Closure hook: basemap tap.
    public var onTap: ((LatLng) -> Void)?

    /// Closure hook: long press.
    public var onLongPress: ((LatLng) -> Void)?

    /// Closure hook: style finished loading (fires again after `setStyle`).
    public var onStyleLoaded: (() -> Void)?

    /// Closure hook: marker tap.
    public var onMarkerTap: ((NavvoMarker) -> Void)?

    /// Closure hook: camera became idle.
    public var onCameraIdle: ((CameraPosition) -> Void)?

    /// The current basemap style.
    public private(set) var style: NavvoMapStyle

    /// Whether terrain is enabled.
    public private(set) var terrainEnabled: Bool

    // MARK: Internals

    private let engineView: MLNMapView
    private var engineProxy: EngineProxy?
    private var markersByAnnotation: [ObjectIdentifier: NavvoMarker] = [:]
    private var annotationsByMarkerID: [String: MLNPointAnnotation] = [:]
    private var overlays: [String: ManagedOverlay] = [:]

    private struct ManagedOverlay {
        let data: Data
        let style: NavvoOverlayStyle
    }

    // MARK: Init

    /// Create a map view.
    public init(frame: CGRect, options: Options = Options()) {
        self.style = options.style
        self.terrainEnabled = options.terrain
        self.engineView = MLNMapView(frame: frame)
        super.init(frame: frame)
        commonInit(options: options)
    }

    /// Create from a storyboard/xib (uses default ``Options``).
    public required init?(coder: NSCoder) {
        let options = Options()
        self.style = options.style
        self.terrainEnabled = options.terrain
        self.engineView = MLNMapView(frame: .zero)
        super.init(coder: coder)
        commonInit(options: options)
    }

    private func commonInit(options: Options) {
        let proxy = EngineProxy(owner: self)
        engineProxy = proxy
        engineView.delegate = proxy
        engineView.frame = bounds
        engineView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        addSubview(engineView)

        // Brand surface: hide the engine logo; keep the attribution control
        // (it reads the style's own attribution — "© Navvo").
        engineView.logoView.isHidden = true
        engineView.attributionButton.isHidden = false

        engineView.minimumZoomLevel = options.minimumZoom
        engineView.maximumZoomLevel = options.maximumZoom
        engineView.showsUserLocation = options.showsUserLocation

        setCamera(options.camera, animated: false)
        applyStyle(options.style, terrain: options.terrain)

        let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
        for recognizer in engineView.gestureRecognizers ?? [] {
            if let otherTap = recognizer as? UITapGestureRecognizer {
                tap.require(toFail: otherTap)
            }
        }
        engineView.addGestureRecognizer(tap)

        let longPress = UILongPressGestureRecognizer(
            target: self, action: #selector(handleLongPress(_:))
        )
        engineView.addGestureRecognizer(longPress)
    }

    // MARK: Style

    /// Switch the basemap style. Managed markers and GeoJSON overlays are
    /// re-applied automatically when the new style finishes loading.
    public func setStyle(_ style: NavvoMapStyle, terrain: Bool = false) {
        self.style = style
        self.terrainEnabled = terrain
        applyStyle(style, terrain: terrain)
    }

    private func applyStyle(_ style: NavvoMapStyle, terrain: Bool) {
        do {
            engineView.styleURL = try NavvoMaps.styleURL(for: style, terrain: terrain)
        } catch {
            reportError(error)
        }
    }

    // MARK: Camera

    /// The current camera position. Setting moves instantly.
    public var camera: CameraPosition {
        get {
            CameraPosition(
                target: LatLng(engineView.centerCoordinate),
                zoom: engineView.zoomLevel,
                bearing: engineView.direction,
                tilt: Double(engineView.camera.pitch)
            )
        }
        set { setCamera(newValue, animated: false) }
    }

    /// Move the camera, optionally animated.
    public func setCamera(_ position: CameraPosition, animated: Bool) {
        engineView.setCenter(
            position.target.clCoordinate,
            zoomLevel: position.zoom,
            direction: position.bearing,
            animated: animated
        )
        if Double(engineView.camera.pitch) != position.tilt {
            let engineCamera = engineView.camera
            engineCamera.pitch = CGFloat(position.tilt)
            engineView.setCamera(engineCamera, animated: animated)
        }
    }

    /// Animate to a camera position over `duration` seconds.
    public func animate(to position: CameraPosition, duration: TimeInterval = 0.8) {
        let engineCamera = engineView.camera
        engineCamera.centerCoordinate = position.target.clCoordinate
        engineCamera.heading = position.bearing
        engineCamera.pitch = CGFloat(position.tilt)
        engineCamera.altitude = MLNAltitudeForZoomLevel(
            position.zoom,
            CGFloat(position.tilt),
            position.target.latitude,
            engineView.frame.size
        )
        engineView.setCamera(
            engineCamera,
            withDuration: duration,
            animationTimingFunction: CAMediaTimingFunction(name: .easeInEaseOut)
        )
    }

    /// Fit a bounds with padding.
    public func fit(bounds: LatLngBounds, padding: UIEdgeInsets = .zero, animated: Bool = true) {
        let engineBounds = MLNCoordinateBounds(
            sw: bounds.southwest.clCoordinate,
            ne: bounds.northeast.clCoordinate
        )
        engineView.setVisibleCoordinateBounds(
            engineBounds, edgePadding: padding, animated: animated, completionHandler: nil
        )
    }

    /// Zoom in one level.
    public func zoomIn(animated: Bool = true) {
        engineView.setZoomLevel(engineView.zoomLevel + 1, animated: animated)
    }

    /// Zoom out one level.
    public func zoomOut(animated: Bool = true) {
        engineView.setZoomLevel(engineView.zoomLevel - 1, animated: animated)
    }

    /// The bounds currently visible on screen.
    public var visibleBounds: LatLngBounds {
        let engineBounds = engineView.visibleCoordinateBounds
        return LatLngBounds(
            southwest: LatLng(engineBounds.sw),
            northeast: LatLng(engineBounds.ne)
        )
    }

    /// Show/hide the user-location puck.
    public var showsUserLocation: Bool {
        get { engineView.showsUserLocation }
        set { engineView.showsUserLocation = newValue }
    }

    /// Content inset shifting the camera focal point (e.g. above a bottom sheet).
    public var contentInset: UIEdgeInsets {
        get { engineView.contentInset }
        set { engineView.contentInset = newValue }
    }

    // MARK: Markers

    /// Add a marker.
    public func addMarker(_ marker: NavvoMarker) {
        guard annotationsByMarkerID[marker.id] == nil else { return }
        let annotation = MLNPointAnnotation()
        annotation.coordinate = marker.position.clCoordinate
        annotation.title = marker.title
        annotation.subtitle = marker.subtitle
        markersByAnnotation[ObjectIdentifier(annotation)] = marker
        annotationsByMarkerID[marker.id] = annotation
        engineView.addAnnotation(annotation)
    }

    /// Add several markers.
    public func addMarkers(_ markers: [NavvoMarker]) {
        for marker in markers { addMarker(marker) }
    }

    /// Remove a marker.
    public func removeMarker(_ marker: NavvoMarker) {
        guard let annotation = annotationsByMarkerID.removeValue(forKey: marker.id) else {
            return
        }
        markersByAnnotation.removeValue(forKey: ObjectIdentifier(annotation))
        engineView.removeAnnotation(annotation)
    }

    /// Remove every managed marker.
    public func clearMarkers() {
        let annotations = Array(annotationsByMarkerID.values)
        if !annotations.isEmpty { engineView.removeAnnotations(annotations) }
        annotationsByMarkerID.removeAll()
        markersByAnnotation.removeAll()
    }

    /// The managed markers currently on the map.
    public var markers: [NavvoMarker] {
        Array(markersByAnnotation.values)
    }

    // MARK: GeoJSON overlays

    /// Add (or replace) a styled GeoJSON overlay. Positions are `[lng, lat]`
    /// — every Navvo GeoJSON endpoint returns this order, pass responses in
    /// as-is. The overlay is re-applied automatically after style switches.
    public func addGeoJSON(id: String, data: Data, style overlayStyle: NavvoOverlayStyle) {
        let overlay = ManagedOverlay(data: data, style: overlayStyle)
        overlays[id] = overlay
        if let loaded = engineView.style {
            applyOverlay(id: id, overlay: overlay, to: loaded)
        }
    }

    /// Add a GeoJSON overlay from a parsed ``NavvoJSON`` document (e.g. an
    /// isochrone or incidents FeatureCollection straight from `NavvoServices`).
    public func addGeoJSON(id: String, json: NavvoJSON, style overlayStyle: NavvoOverlayStyle) {
        guard let data = try? json.encodedData() else { return }
        addGeoJSON(id: id, data: data, style: overlayStyle)
    }

    /// Draw a path (e.g. a decoded route geometry) as a line overlay.
    @discardableResult
    public func addPolyline(
        id: String = UUID().uuidString,
        path: [LatLng],
        color: UIColor = .systemBlue,
        width: CGFloat = 5
    ) -> String {
        let feature: NavvoJSON = .object([
            "type": .string("Feature"),
            "properties": .object([:]),
            "geometry": .object([
                "type": .string("LineString"),
                "coordinates": .array(path.map { $0.geoJSONPositionJSON() }),
            ]),
        ])
        addGeoJSON(id: id, json: feature, style: .line(color: color, width: width))
        return id
    }

    /// Draw a polygon ring as a fill overlay.
    @discardableResult
    public func addPolygon(
        id: String = UUID().uuidString,
        ring: [LatLng],
        color: UIColor = .systemBlue,
        opacity: CGFloat = 0.25,
        strokeColor: UIColor? = nil
    ) -> String {
        var closed = ring
        if let first = ring.first, ring.last != first { closed.append(first) }
        let feature: NavvoJSON = .object([
            "type": .string("Feature"),
            "properties": .object([:]),
            "geometry": .object([
                "type": .string("Polygon"),
                "coordinates": .array([.array(closed.map { $0.geoJSONPositionJSON() })]),
            ]),
        ])
        addGeoJSON(
            id: id, json: feature,
            style: .fill(color: color, opacity: opacity, strokeColor: strokeColor)
        )
        return id
    }

    /// Remove a GeoJSON overlay (also removes polylines/polygons by id).
    public func removeGeoJSON(id: String) {
        overlays.removeValue(forKey: id)
        guard let loaded = engineView.style else { return }
        removeOverlayLayers(id: id, from: loaded)
    }

    /// Remove every managed overlay.
    public func clearGeoJSON() {
        let ids = Array(overlays.keys)
        for id in ids { removeGeoJSON(id: id) }
    }

    // MARK: Private overlay plumbing

    private func sourceID(_ id: String) -> String { "navvo-overlay-src-\(id)" }
    private func layerID(_ id: String) -> String { "navvo-overlay-layer-\(id)" }
    private func strokeLayerID(_ id: String) -> String { "navvo-overlay-stroke-\(id)" }

    private func removeOverlayLayers(id: String, from loaded: MLNStyle) {
        if let layer = loaded.layer(withIdentifier: layerID(id)) {
            loaded.removeLayer(layer)
        }
        if let layer = loaded.layer(withIdentifier: strokeLayerID(id)) {
            loaded.removeLayer(layer)
        }
        if let source = loaded.source(withIdentifier: sourceID(id)) {
            loaded.removeSource(source)
        }
    }

    private func applyOverlay(id: String, overlay: ManagedOverlay, to loaded: MLNStyle) {
        removeOverlayLayers(id: id, from: loaded)
        guard
            let shape = try? MLNShape(
                data: overlay.data, encoding: String.Encoding.utf8.rawValue
            )
        else { return }
        let source = MLNShapeSource(identifier: sourceID(id), shape: shape, options: nil)
        loaded.addSource(source)

        switch overlay.style {
        case .line(let color, let width):
            let layer = MLNLineStyleLayer(identifier: layerID(id), source: source)
            layer.lineColor = NSExpression(forConstantValue: color)
            layer.lineWidth = NSExpression(forConstantValue: width)
            layer.lineCap = NSExpression(forConstantValue: "round")
            layer.lineJoin = NSExpression(forConstantValue: "round")
            loaded.addLayer(layer)
        case .fill(let color, let opacity, let strokeColor):
            let layer = MLNFillStyleLayer(identifier: layerID(id), source: source)
            layer.fillColor = NSExpression(forConstantValue: color)
            layer.fillOpacity = NSExpression(forConstantValue: opacity)
            loaded.addLayer(layer)
            if let strokeColor {
                let stroke = MLNLineStyleLayer(identifier: strokeLayerID(id), source: source)
                stroke.lineColor = NSExpression(forConstantValue: strokeColor)
                stroke.lineWidth = NSExpression(forConstantValue: 2)
                loaded.addLayer(stroke)
            }
        case .circle(let color, let radius):
            let layer = MLNCircleStyleLayer(identifier: layerID(id), source: source)
            layer.circleColor = NSExpression(forConstantValue: color)
            layer.circleRadius = NSExpression(forConstantValue: radius)
            loaded.addLayer(layer)
        }
    }

    private func reapplyOverlays(to loaded: MLNStyle) {
        for (id, overlay) in overlays {
            applyOverlay(id: id, overlay: overlay, to: loaded)
        }
    }

    private func reportError(_ error: Error) {
        let navvoError: NavvoError
        if let typed = error as? NavvoError {
            navvoError = typed
        } else {
            navvoError = .network(error.localizedDescription)
        }
        delegate?.mapView(self, didFail: navvoError)
    }

    // MARK: Gestures

    @objc private func handleTap(_ gesture: UITapGestureRecognizer) {
        guard gesture.state == .ended else { return }
        let point = gesture.location(in: engineView)
        // Let the engine's own annotation selection win when a managed marker
        // is under the finger.
        for annotation in engineView.annotations ?? [] {
            guard markersByAnnotation[ObjectIdentifier(annotation as AnyObject)] != nil else {
                continue
            }
            let annotationPoint = engineView.convert(
                annotation.coordinate, toPointTo: engineView
            )
            if hypot(annotationPoint.x - point.x, annotationPoint.y - point.y) < 24 {
                return
            }
        }
        let coordinate = engineView.convert(point, toCoordinateFrom: engineView)
        let latLng = LatLng(coordinate)
        onTap?(latLng)
        delegate?.mapView(self, didTapAt: latLng)
    }

    @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
        guard gesture.state == .began else { return }
        let point = gesture.location(in: engineView)
        let coordinate = engineView.convert(point, toCoordinateFrom: engineView)
        let latLng = LatLng(coordinate)
        onLongPress?(latLng)
        delegate?.mapView(self, didLongPressAt: latLng)
    }

    // MARK: Engine delegate proxy

    /// Internal engine-delegate proxy. Keeps engine types out of the public
    /// surface of `NavvoMapView`.
    private final class EngineProxy: NSObject, MLNMapViewDelegate {
        weak var owner: NavvoMapView?

        init(owner: NavvoMapView) {
            self.owner = owner
        }

        func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) {
            guard let owner else { return }
            owner.reapplyOverlays(to: style)
            owner.onStyleLoaded?()
            owner.delegate?.mapViewDidFinishLoadingStyle(owner)
        }

        func mapView(_ mapView: MLNMapView, didFailToLoadMapWithError error: Error) {
            owner?.reportError(error)
        }

        func mapView(_ mapView: MLNMapView, didSelect annotation: MLNAnnotation) {
            guard
                let owner,
                let marker = owner.markersByAnnotation[ObjectIdentifier(annotation as AnyObject)]
            else { return }
            marker.onTap?(marker)
            owner.onMarkerTap?(marker)
            owner.delegate?.mapView(owner, didTap: marker)
        }

        func mapView(_ mapView: MLNMapView, annotationCanShowCallout annotation: MLNAnnotation) -> Bool {
            (annotation.title ?? nil) != nil
        }

        func mapView(_ mapView: MLNMapView, imageFor annotation: MLNAnnotation) -> MLNAnnotationImage? {
            guard
                let owner,
                let marker = owner.markersByAnnotation[ObjectIdentifier(annotation as AnyObject)],
                let icon = marker.icon
            else { return nil }
            let reuseIdentifier = "navvo-marker-\(marker.id)"
            if let existing = mapView.dequeueReusableAnnotationImage(
                withIdentifier: reuseIdentifier
            ) {
                return existing
            }
            return MLNAnnotationImage(image: icon, reuseIdentifier: reuseIdentifier)
        }

        func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) {
            guard let owner else { return }
            owner.delegate?.mapView(owner, cameraDidChange: owner.camera)
        }

        func mapViewDidBecomeIdle(_ mapView: MLNMapView) {
            guard let owner else { return }
            owner.onCameraIdle?(owner.camera)
            owner.delegate?.mapViewDidBecomeIdle(owner)
        }
    }
}
#endif
