//
//  BeansLocationBridge.swift
//  Beans Maps Widget — iOS WKWebView location bridge
//
//  Feeds the device's location into a WKWebView running the Beans maps widget so
//  the WebView never raises its own location permission prompt. The host app holds
//  the CoreLocation authorization; the page consumes this stream instead of calling
//  `navigator.geolocation`.
//
//  Drop this single file into your target — no third-party dependencies.
//
//  Minimal integration:
//
//      final class MapViewController: UIViewController {
//        private var webView: WKWebView!
//        private var bridge: BeansLocationBridge!
//
//        override func viewDidLoad() {
//          super.viewDidLoad()
//
//          let config = WKWebViewConfiguration()
//          webView = WKWebView(frame: view.bounds, configuration: config)
//          view.addSubview(webView)
//
//          // attach BEFORE loading, so the shim is injected at document start
//          bridge = BeansLocationBridge(webView: webView)
//
//          webView.load(URLRequest(url: URL(string: "https://your-host/your-map-page.html")!))
//        }
//
//        override func viewWillDisappear(_ animated: Bool) {
//          super.viewWillDisappear(animated)
//          bridge.pause()
//        }
//
//        override func viewWillAppear(_ animated: Bool) {
//          super.viewWillAppear(animated)
//          bridge.resume()
//        }
//      }
//
//  Info.plist:
//      NSLocationWhenInUseUsageDescription — required, else CoreLocation refuses
//      to start and iOS logs an authorization error.
//
//  The page needs `navOptions.userLocation = 'LIVE'` (already the case for live
//  location) — no page-side change required.
//
//  Note on WKWebView: there is no delegate hook for the geolocation prompt on iOS,
//  so injecting the location is the only way to avoid it. This bridge replaces
//  `navigator.geolocation` in the page, which means neither the widget nor any
//  other script can trigger it.
//

import CoreLocation
import Foundation
import WebKit

public final class BeansLocationBridge: NSObject {

    // MARK: - Options

    public struct Options {
        /// Remote URL of `beans-native-location-<version>.js`, used when
        /// `shimBundleResource` is nil. Subject to the page's CSP.
        public var shimURL: String
        /// Name of the shim file bundled in the app (for example
        /// `"beans-native-location-1.0.0.js"`). Recommended: works offline, is not
        /// affected by CSP, and is injected before the page's own scripts run.
        public var shimBundleResource: String?
        /// Request When-In-Use authorization if the app has not asked yet.
        public var autoRequestAuthorization: Bool
        public var desiredAccuracy: CLLocationAccuracy
        /// Minimum movement (metres) between fixes handed to the page.
        public var distanceFilter: CLLocationDistance
        /// Also feed `heading` from the device compass while stationary.
        public var useHeading: Bool
        public var debug: Bool

        public init(
            shimURL: String = BeansLocationBridge.defaultShimURL,
            shimBundleResource: String? = nil,
            autoRequestAuthorization: Bool = true,
            desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest,
            distanceFilter: CLLocationDistance = 1,
            useHeading: Bool = false,
            debug: Bool = false
        ) {
            self.shimURL = shimURL
            self.shimBundleResource = shimBundleResource
            self.autoRequestAuthorization = autoRequestAuthorization
            self.desiredAccuracy = desiredAccuracy
            self.distanceFilter = distanceFilter
            self.useHeading = useHeading
            self.debug = debug
        }
    }

    public static let defaultShimURL =
        "https://www.beans.ai/mapswidget/js/beans-native-location-1.0.0.js"

    public static let messageHandlerName = "beansLocation"

    // MARK: - State

    private weak var webView: WKWebView?
    private let options: Options
    private let locationManager = CLLocationManager()

    private var requestedByPage = false
    private var updating = false
    private var pausedWhileUpdating = false
    private var lastHeading: CLLocationDirection?

    // MARK: - Lifecycle

    /// Installs the shim and the `beansLocation` message handler on `webView`.
    /// Call before loading the page.
    public init(webView: WKWebView, options: Options = Options()) {
        self.webView = webView
        self.options = options
        super.init()

        locationManager.delegate = self
        locationManager.desiredAccuracy = options.desiredAccuracy
        locationManager.distanceFilter = options.distanceFilter

        let controller = webView.configuration.userContentController

        // Weak proxy so the user content controller does not retain us (and through
        // us, the web view) forever.
        controller.add(ScriptMessageProxy(target: self), name: Self.messageHandlerName)

        controller.addUserScript(WKUserScript(
            source: Self.bootstrapQueueJS,
            injectionTime: .atDocumentStart,
            forMainFrameOnly: false
        ))
        controller.addUserScript(WKUserScript(
            source: shimSource(),
            injectionTime: .atDocumentStart,
            forMainFrameOnly: false
        ))

        debugLog("installed message handler \(Self.messageHandlerName)")
    }

    /// Removes the message handler and stops updates. Injected user scripts live on
    /// the configuration; discard the web view (or its controller) to drop them.
    public func detach() {
        stopUpdating()
        webView?.configuration.userContentController
            .removeScriptMessageHandler(forName: Self.messageHandlerName)
    }

    deinit {
        locationManager.stopUpdatingLocation()
        if options.useHeading { locationManager.stopUpdatingHeading() }
    }

    /// Pauses location updates while keeping the page's subscription intact.
    public func pause() {
        pausedWhileUpdating = updating
        stopUpdating()
    }

    /// Resumes updates if the page had asked for them before `pause()`.
    public func resume() {
        if pausedWhileUpdating || requestedByPage { startUpdating() }
        pausedWhileUpdating = false
    }

    // MARK: - Shim source

    private func shimSource() -> String {
        if let resource = options.shimBundleResource {
            let name = (resource as NSString).deletingPathExtension
            let ext = (resource as NSString).pathExtension.isEmpty
                ? "js" : (resource as NSString).pathExtension
            if let url = Bundle.main.url(forResource: name, withExtension: ext),
               let source = try? String(contentsOf: url, encoding: .utf8) {
                return source
            }
            debugLog("shimBundleResource '\(resource)' not found; falling back to shimURL")
        }
        let quoted = jsonString(options.shimURL)
        return """
        (function(){
          if (document.getElementById('beans-native-location-shim')) return;
          var s = document.createElement('script');
          s.id = 'beans-native-location-shim';
          s.src = \(quoted);
          s.async = false;
          (document.head || document.documentElement).appendChild(s);
        })();
        """
    }

    private static let bootstrapQueueJS = """
    (function(){
      if (window.BeansNativeLocation) return;
      var q = [];
      window.BeansNativeLocation = {
        version: 'boot',
        __queue: q,
        push: function(p){ q.push({method:'push', args:[p]}); return true; },
        pushError: function(c,m){ q.push({method:'pushError', args:[c,m]}); return true; },
        pushStatus: function(s){ q.push({method:'pushStatus', args:[s]}); return true; }
      };
    })();
    """

    // MARK: - Location control

    private func startUpdating() {
        guard !updating else { return }

        let status: CLAuthorizationStatus
        if #available(iOS 14.0, *) {
            status = locationManager.authorizationStatus
        } else {
            status = CLLocationManager.authorizationStatus()
        }

        switch status {
        case .notDetermined:
            if options.autoRequestAuthorization {
                debugLog("requesting when-in-use authorization")
                locationManager.requestWhenInUseAuthorization()
            } else {
                pushError(code: 1, message: "Location authorization not requested by host app")
            }
            return
        case .denied, .restricted:
            pushStatus("denied")
            pushError(code: 1, message: "Location authorization denied in host app")
            return
        default:
            break
        }

        guard CLLocationManager.locationServicesEnabled() else {
            pushError(code: 2, message: "Location services are off on the device")
            return
        }

        locationManager.startUpdatingLocation()
        if options.useHeading, CLLocationManager.headingAvailable() {
            locationManager.startUpdatingHeading()
        }
        updating = true
        debugLog("started updating")
        pushStatus("ready")

        if let last = locationManager.location { push(location: last) }
    }

    private func stopUpdating() {
        guard updating else { return }
        locationManager.stopUpdatingLocation()
        if options.useHeading { locationManager.stopUpdatingHeading() }
        updating = false
        debugLog("stopped updating")
    }

    // MARK: - Push to page

    /// Sends a `CLLocation` to the page.
    public func push(location: CLLocation) {
        var payload: [String: Any] = [
            "lat": location.coordinate.latitude,
            "lng": location.coordinate.longitude,
            "timestamp": Int(location.timestamp.timeIntervalSince1970 * 1000)
        ]
        if location.horizontalAccuracy >= 0 { payload["accuracy"] = location.horizontalAccuracy }
        if location.verticalAccuracy >= 0 {
            payload["altitude"] = location.altitude
            payload["altitudeAccuracy"] = location.verticalAccuracy
        }
        if location.course >= 0 {
            payload["heading"] = location.course
        } else if let heading = lastHeading, heading >= 0 {
            payload["heading"] = heading
        }
        if location.speed >= 0 { payload["speed"] = location.speed }

        evaluate("window.BeansNativeLocation&&window.BeansNativeLocation.push(\(json(payload)));")
    }

    /// Sends an explicit lat/lng, e.g. a simulated or app-chosen location.
    public func push(latitude: Double, longitude: Double, accuracy: Double? = nil) {
        var payload: [String: Any] = [
            "lat": latitude,
            "lng": longitude,
            "timestamp": Int(Date().timeIntervalSince1970 * 1000)
        ]
        if let accuracy = accuracy { payload["accuracy"] = accuracy }
        evaluate("window.BeansNativeLocation&&window.BeansNativeLocation.push(\(json(payload)));")
    }

    /// code: 1 = permission denied, 2 = position unavailable, 3 = timeout.
    public func pushError(code: Int, message: String) {
        debugLog("pushError \(code) \(message)")
        evaluate(
            "window.BeansNativeLocation&&window.BeansNativeLocation.pushError("
            + "\(code),\(jsonString(message)));"
        )
    }

    /// status: "ready" | "denied" | "stopped".
    public func pushStatus(_ status: String) {
        evaluate(
            "window.BeansNativeLocation&&window.BeansNativeLocation.pushStatus("
            + "\(jsonString(status)));"
        )
    }

    private func evaluate(_ js: String) {
        guard let webView = webView else { return }
        if Thread.isMainThread {
            webView.evaluateJavaScript(js, completionHandler: nil)
        } else {
            DispatchQueue.main.async { webView.evaluateJavaScript(js, completionHandler: nil) }
        }
    }

    // MARK: - Helpers

    private func json(_ dictionary: [String: Any]) -> String {
        guard let data = try? JSONSerialization.data(withJSONObject: dictionary),
              let string = String(data: data, encoding: .utf8) else {
            return "{}"
        }
        return string
    }

    private func jsonString(_ value: String) -> String {
        guard let data = try? JSONSerialization.data(
                withJSONObject: [value], options: [.fragmentsAllowed]),
              let array = String(data: data, encoding: .utf8) else {
            return "\"\""
        }
        // ["value"] -> "value"
        return String(array.dropFirst().dropLast())
    }

    private func debugLog(_ message: String) {
        if options.debug { NSLog("[BeansLocationBridge] %@", message) }
    }

    // MARK: - Message handling

    fileprivate func handle(message body: Any) {
        guard let dict = body as? [String: Any],
              let action = dict["action"] as? String else {
            debugLog("unrecognised message body: \(body)")
            return
        }

        if let highAccuracy = dict["highAccuracy"] as? Bool {
            locationManager.desiredAccuracy =
                highAccuracy ? kCLLocationAccuracyBest : kCLLocationAccuracyHundredMeters
        }
        if let minDistance = dict["minDistanceM"] as? Double, minDistance > 0 {
            locationManager.distanceFilter = minDistance
        }

        debugLog("<- \(action)")
        switch action {
        case "start", "subscribe":
            requestedByPage = true
            startUpdating()
        case "once":
            if let last = locationManager.location {
                push(location: last)
            }
            if !updating { startUpdating() }
        case "stop", "unsubscribe":
            requestedByPage = false
            stopUpdating()
        default:
            debugLog("ignoring action \(action)")
        }
    }
}

// MARK: - CLLocationManagerDelegate

extension BeansLocationBridge: CLLocationManagerDelegate {

    public func locationManager(
        _ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]
    ) {
        guard let location = locations.last else { return }
        push(location: location)
    }

    public func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
        lastHeading = newHeading.trueHeading >= 0 ? newHeading.trueHeading : newHeading.magneticHeading
    }

    public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        let clError = error as? CLError
        if clError?.code == .denied {
            pushStatus("denied")
            pushError(code: 1, message: "Location authorization denied in host app")
        } else {
            // .locationUnknown is transient — CoreLocation keeps trying.
            debugLog("location error: \(error.localizedDescription)")
        }
    }

    @available(iOS 14.0, *)
    public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        switch manager.authorizationStatus {
        case .authorizedAlways, .authorizedWhenInUse:
            if requestedByPage { startUpdating() }
        case .denied, .restricted:
            pushStatus("denied")
        default:
            break
        }
    }

    // iOS 13 and earlier
    public func locationManager(
        _ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus
    ) {
        if #available(iOS 14.0, *) { return } // handled above
        switch status {
        case .authorizedAlways, .authorizedWhenInUse:
            if requestedByPage { startUpdating() }
        case .denied, .restricted:
            pushStatus("denied")
        default:
            break
        }
    }
}

// MARK: - Weak message-handler proxy

/// `WKUserContentController` retains its script message handlers. Proxying through
/// a weak reference keeps the bridge (and the web view it points at) collectable.
private final class ScriptMessageProxy: NSObject, WKScriptMessageHandler {
    private weak var target: BeansLocationBridge?

    init(target: BeansLocationBridge) {
        self.target = target
    }

    func userContentController(
        _ userContentController: WKUserContentController, didReceive message: WKScriptMessage
    ) {
        target?.handle(message: message.body)
    }
}
