import Foundation
#if canImport(MapMapKit)
import MapMapKit
#endif
#if canImport(MapMapValhalla)
import MapMapValhalla
#endif

/// All bridge state + logic, isolated behind an actor so the JS-facing
/// module stays a thin adapter. Every method maps 1:1 onto a contract
/// method in `MapmapNav.types.ts` and throws `BridgeError` on failure.
///
/// Grounding: every SDK signature used here is confirmed from the public
/// `mapmap-ios` sources. Points that depend on the SDK's UniFFI-GENERATED
/// types — absent from the public 0.2.0 package (see FINDINGS) — are
/// marked `INFERRED`; they are the only parts not verifiable until the
/// generated bindings ship.
actor MapmapNavCore {

  typealias Emit = @Sendable (_ name: String, _ body: [String: Any]) -> Void

  private let emit: Emit

  // Set by init(); nil means "not initialised".
  private var apiKey: String?
  private var gatewayBase: URL?
  private var store: TerritoryStore?

  // Routing/guidance session state.
  private var routeResponsesByIndex: [Int: String] = [:]
  private var guidanceTask: Task<Void, Never>?
  private var engine: NavigationEngine?
  private var voice: VoiceGuidance?

  // Voice preferences persist across sessions.
  private var voiceMuted = false
  private var voiceVolume: Float = 1.0

  init(emit: @escaping Emit) {
    self.emit = emit
  }

  // MARK: - Lifecycle

  func initialize(apiKey: String, gatewayBaseUrl: String?) throws {
    guard !apiKey.isEmpty else {
      throw BridgeError(.initFailed, "apiKey must not be empty")
    }
    let base = gatewayBaseUrl?.isEmpty == false ? gatewayBaseUrl! : "https://api.mapmap.ai"
    guard let baseUrl = URL(string: base) else {
      throw BridgeError(.initFailed, "invalid gatewayBaseUrl: \(base)")
    }

    // Surface packaging/bindings problems as an immediate error rather than
    // a mid-drive crash.
    MapMap.ensureLoaded()

    let root = try territoriesDirectory()
    let store = TerritoryStore(rootDir: root, verifyingKeyHex: try verifyingKeyHex())

    self.apiKey = apiKey
    self.gatewayBase = baseUrl
    self.store = store
  }

  private func requireStore() throws -> TerritoryStore {
    guard let store = store else {
      throw BridgeError(.notInitialised, "call init() before any other method")
    }
    return store
  }

  private func requireApiKey() throws -> String {
    guard let apiKey = apiKey else {
      throw BridgeError(.notInitialised, "call init() before any other method")
    }
    return apiKey
  }

  // MARK: - Territories

  /// The catalogue of installable territories. The SDK has no "list
  /// available" API — this is a gateway concern — so we GET the gateway's
  /// territory index. INFERRED gateway path/shape (see FINDINGS).
  func listAvailableTerritories() async throws -> [[String: Any]] {
    let apiKey = try requireApiKey()
    guard let base = gatewayBase else {
      throw BridgeError(.notInitialised, "call init() first")
    }
    let transport = GatewayTransport(apiKey: apiKey)
    let url = base.appendingPathComponent("v1/territories").absoluteString
    let data = try transport.get(url)
    guard let array = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else {
      throw BridgeError(.internalError, "gateway territory index was not a JSON array")
    }
    return array.map { item in
      [
        "id": item["id"] as? String ?? "",
        "name": item["name"] as? String ?? "",
        "sizeBytes": (item["sizeBytes"] as? NSNumber)?.int64Value ?? 0,
        "version": item["version"] as? String ?? "",
      ]
    }
  }

  func listInstalledTerritories() async throws -> [[String: Any]] {
    let store = try requireStore()
    let infos = try await store.refresh()
    let activeId = try await store.active()
    return infos.map { info in
      // Confirmed against the vendored bindings: `TerritoryInfo` exposes
      // `displayName` (human name) and `totalBytes` (on-disk size); the JS
      // contract keys (`name`/`sizeOnDiskBytes`) are unchanged.
      [
        "id": info.territoryId,
        "name": info.displayName,
        "version": info.version,
        "sizeOnDiskBytes": info.totalBytes,
        "active": info.territoryId == activeId,
      ]
    }
  }

  /// Acquire a territory via the SDK's signed update path (CONTRACTS.md
  /// §Territory acquisition): fetch the signed channel index, plan/apply
  /// through an authenticated fetcher (the core verifies signatures), then
  /// activate. Emits `territoryProgress` throughout.
  func installTerritory(_ territoryId: String) async throws {
    let store = try requireStore()
    let apiKey = try requireApiKey()
    guard let base = gatewayBase else {
      throw BridgeError(.notInitialised, "call init() first")
    }

    let transport = GatewayTransport(apiKey: apiKey)
    // Capture the Sendable emit closure so progress can be posted from the
    // URLSession thread without hopping back onto the actor.
    let emit = self.emit
    let bytesCounter = ByteCounter()
    transport.onBytes = { delta in
      let total = bytesCounter.add(delta)
      emit("territoryProgress", [
        "territoryId": territoryId, "phase": "downloading",
        "bytesDone": total, "bytesTotal": 0,
      ])
    }
    let fetcher = GatewayLayerFetcher(transport: transport, base: base)

    do {
      emit("territoryProgress", progress(territoryId, "downloading", bytesCounter.value, 0))

      // INFERRED gateway paths for the signed channel index + signature.
      let indexUrl = base.appendingPathComponent("v1/territories/\(territoryId)/index.json")
      let sigUrl = base.appendingPathComponent("v1/territories/\(territoryId)/index.json.sig")
      let indexJson = String(decoding: try transport.get(indexUrl.absoluteString), as: UTF8.self)
      let indexSig = String(decoding: try transport.get(sigUrl.absoluteString), as: UTF8.self)

      guard
        let update = try await store.checkForUpdate(
          territoryId: territoryId,
          indexJson: indexJson,
          indexSigBase64: indexSig,
          fetcher: fetcher)
      else {
        // Already at latest / channel does not list it. If nothing is
        // installed, that's a not-found; otherwise it's a no-op success.
        let infos = try await store.refresh()
        if !infos.contains(where: { $0.territoryId == territoryId }) {
          throw BridgeError(.territoryNotFound, "gateway channel has no territory '\(territoryId)'")
        }
        emit("territoryProgress", progress(territoryId, "done", bytesCounter.value, bytesCounter.value))
        return
      }

      emit("territoryProgress", progress(territoryId, "verifying", bytesCounter.value, bytesCounter.value))
      _ = try await store.applyUpdate(territoryId: territoryId, update: update, fetcher: fetcher)

      emit("territoryProgress", progress(territoryId, "activating", bytesCounter.value, bytesCounter.value))
      try await store.setActive(territoryId)

      emit("territoryProgress", progress(territoryId, "done", bytesCounter.value, bytesCounter.value))
    } catch {
      let bridge = error.asBridgeError(default: .internalError)
      var body = progress(territoryId, "failed", bytesCounter.value, 0)
      body["error"] = bridge.asDictionary
      emit("territoryProgress", body)
      throw bridge
    }
  }

  func removeTerritory(_ territoryId: String) async throws {
    let store = try requireStore()
    do {
      try await store.remove(territoryId)
    } catch {
      throw error.asBridgeError(default: .internalError)
    }
  }

  /// A complete MapLibre style JSON whose sources point at LOCAL PMTiles,
  /// so rendering works with the radios off. Only `layerPath` is an SDK
  /// call (confirmed); the style document itself is standard MapLibre.
  func getTerritoryStyle(_ territoryId: String, theme: String) async throws -> String {
    let store = try requireStore()
    let pmtiles: URL
    do {
      pmtiles = try await store.layerPath(territoryId: territoryId, kind: .pmtiles)
    } catch {
      throw error.asBridgeError(default: .territoryNotFound)
    }
    return MapStyle.json(pmtilesPath: pmtiles.path, dark: theme == "dark")
  }

  // MARK: - Search (unsupported by the SDK)

  func search(query: String, near: [String: Any]?, limit: Int?) async throws -> [[String: Any]] {
    // The MapMap iOS SDK exposes no on-device geocoder/search API (confirmed:
    // no search/geocode symbol in the FFI, no type in the sources). Per the
    // task brief we reject rather than calling the network from native.
    throw BridgeError(.internalError, "on-device search unavailable")
  }

  // MARK: - Routing

  func computeRoute(_ request: [String: Any]) async throws -> [String: Any] {
    let store = try requireStore()
    guard let activeId = try await store.active() else {
      throw BridgeError(.noTerritory, "no active territory; install and activate one first")
    }

    guard
      let origin = latLng(request["origin"]),
      let destination = latLng(request["destination"])
    else {
      throw BridgeError(.internalError, "route request missing origin/destination")
    }
    let profile = request["profile"] as? String ?? "car"
    let alternatives = max(0, min(3, (request["alternatives"] as? NSNumber)?.intValue ?? 2))

    // Build the on-device router for the active territory's Valhalla tiles.
    let tiles = try await store.layerPath(territoryId: activeId, kind: .valhallaTiles)
    let scratch = try scratchDirectory()
    let router: ValhallaMobileRouter
    do {
      router = try ValhallaMobileRouter.fromTileDir(tiles, scratchDir: scratch)
    } catch {
      throw error.asBridgeError(default: .internalError)
    }

    // INFERRED: `RoutePoint(lat:lon:)` and the bicycle/pedestrian
    // `CostingModel` cases (`.auto`/`.truck` are confirmed from source).
    let waypoints = [
      RoutePoint(lat: origin.lat, lon: origin.lon),
      RoutePoint(lat: destination.lat, lon: destination.lon),
    ]
    let costing = costingModel(for: profile)

    do {
      var requestJson = try OfflineRouting.buildRequest(waypoints: waypoints, costing: costing)
      if alternatives > 0 {
        requestJson = injectingAlternates(requestJson, count: alternatives)
      }
      let responseJson = router.route(requestJson: requestJson)
      let routes = try ValhallaResponse.routes(fromResponseJson: responseJson)

      // Keep a per-index single-trip response so startGuidance can drive
      // any alternative through a NavigationEngine.
      routeResponsesByIndex = perIndexResponses(fromResponseJson: responseJson)

      return ["routes": routes]
    } catch let bridge as BridgeError {
      throw bridge
    } catch {
      throw error.asBridgeError(default: .noRoute)
    }
  }

  // MARK: - Guidance

  func startGuidance(_ options: [String: Any]) async throws {
    guard guidanceTask == nil else {
      throw BridgeError(.guidanceActive, "a guidance session is already running")
    }
    let routeIndex = (options["routeIndex"] as? NSNumber)?.intValue ?? 0
    guard let routeResponseJson = routeResponsesByIndex[routeIndex] else {
      throw BridgeError(.noRoute, "no computed route at index \(routeIndex); call computeRoute first")
    }
    guard let source = options["source"] as? [String: Any] else {
      throw BridgeError(.internalError, "guidance options missing source")
    }

    let provider: any LocationProvider
    switch source["kind"] as? String {
    case "replay":
      guard let corpusPath = source["corpusPath"] as? String else {
        throw BridgeError(.replayCorpus, "replay source missing corpusPath")
      }
      let corpus: DriveCorpus
      do {
        corpus = try DriveCorpus.parse(contentsOf: URL(fileURLWithPath: corpusPath))
      } catch {
        throw BridgeError(.replayCorpus, "corpus unreadable/malformed: \(error)")
      }
      let speed = (source["speedMultiplier"] as? NSNumber)?.doubleValue ?? 1.0
      provider = ReplayLocationProvider(
        corpus: corpus,
        timeScale: speed,
        epochStartMs: Int64(Date().timeIntervalSince1970 * 1000))
    case "live", .some(_), .none:
      provider = CoreLocationProvider()
    }

    let engine = NavigationEngine(routeResponseJson: routeResponseJson)
    let voice = VoiceGuidance()
    voice.muted = voiceMuted
    voice.volume = voiceVolume
    self.engine = engine
    self.voice = voice
    emit("voiceState", GuidanceMapping.voiceState(muted: voiceMuted, volume: voiceVolume, speaking: false))

    // Drive the guidance stream; emit one `guidance` event per state
    // (≥1Hz given fix cadence) plus voice handling.
    guidanceTask = Task { [weak self] in
      guard let self else { return }
      let stream = await engine.navigate(provider: provider)
      do {
        for try await state in stream {
          if Task.isCancelled { break }
          voice.handle(state)
          await self.forward(state: state)
        }
      } catch {
        await self.emitError(error)
      }
      await self.clearGuidance()
    }
  }

  func stopGuidance() async throws {
    guard guidanceTask != nil else {
      throw BridgeError(.noGuidance, "no guidance session is running")
    }
    guidanceTask?.cancel()
    guidanceTask = nil
    await engine?.stop()
    voice?.stop()
    engine = nil
    voice = nil
  }

  private func forward(state: NavigationState) {
    emit("guidance", GuidanceMapping.event(from: state))
  }

  private func emitError(_ error: Error) {
    emit("bridgeError", error.asBridgeError(default: .internalError).asDictionary)
  }

  private func clearGuidance() {
    guidanceTask = nil
    engine = nil
    voice?.stop()
    voice = nil
  }

  // MARK: - Voice

  func setVoiceMuted(_ muted: Bool) async throws {
    voiceMuted = muted
    voice?.muted = muted
    emit("voiceState", GuidanceMapping.voiceState(muted: muted, volume: voiceVolume, speaking: false))
  }

  func setVoiceVolume(_ volume: Double) async throws {
    let clamped = Float(min(1.0, max(0.0, volume)))
    voiceVolume = clamped
    voice?.volume = clamped
    emit("voiceState", GuidanceMapping.voiceState(muted: voiceMuted, volume: clamped, speaking: false))
  }

  // MARK: - Helpers

  /// Map a bridge profile to an SDK `CostingModel`.
  /// `.auto`/`.truck` are confirmed; `.bicycle`/`.pedestrian` are INFERRED
  /// from Valhalla's costing names (see FINDINGS).
  private func costingModel(for profile: String) -> CostingModel {
    switch profile {
    case "bicycle": return .bicycle
    case "walking": return .pedestrian
    default: return .auto
    }
  }

  private struct Coord { let lat: Double; let lon: Double }

  private func latLng(_ value: Any?) -> Coord? {
    guard let dict = value as? [String: Any],
          let lat = (dict["latitude"] as? NSNumber)?.doubleValue,
          let lon = (dict["longitude"] as? NSNumber)?.doubleValue else { return nil }
    return Coord(lat: lat, lon: lon)
  }

  /// Add `"alternates": count` to a Valhalla request JSON string.
  private func injectingAlternates(_ json: String, count: Int) -> String {
    guard
      let data = json.data(using: .utf8),
      var obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
    else { return json }
    obj["alternates"] = count
    guard let out = try? JSONSerialization.data(withJSONObject: obj),
          let str = String(data: out, encoding: .utf8) else { return json }
    return str
  }

  /// Split a multi-trip Valhalla response into one single-trip response
  /// JSON per contract route index, for NavigationEngine.
  private func perIndexResponses(fromResponseJson json: String) -> [Int: String] {
    guard
      let data = json.data(using: .utf8),
      let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
    else { return [:] }
    var trips: [[String: Any]] = []
    if let trip = root["trip"] as? [String: Any] { trips.append(trip) }
    if let alternates = root["alternates"] as? [[String: Any]] {
      for alt in alternates { if let t = alt["trip"] as? [String: Any] { trips.append(t) } }
    }
    var result: [Int: String] = [:]
    for (index, trip) in trips.enumerated() {
      if let data = try? JSONSerialization.data(withJSONObject: ["trip": trip]),
         let str = String(data: data, encoding: .utf8) {
        result[index] = str
      }
    }
    return result
  }

  private func progress(
    _ id: String, _ phase: String, _ done: Int64, _ total: Int64
  ) -> [String: Any] {
    ["territoryId": id, "phase": phase, "bytesDone": done, "bytesTotal": total]
  }

  private func territoriesDirectory() throws -> URL {
    let base = try FileManager.default.url(
      for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
    let dir = base.appendingPathComponent("mapmap/territories", isDirectory: true)
    try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
    return dir
  }

  private func scratchDirectory() throws -> URL {
    let base = try FileManager.default.url(
      for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
    let dir = base.appendingPathComponent("mapmap", isDirectory: true)
    try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
    return dir
  }

  /// The factory signing key's public half (64 hex), required to construct
  /// `TerritoryStore`. The contract's `init(apiKey, gatewayBaseUrl?)` has
  /// no slot for it, so it must be pinned at build time via the app's
  /// Info.plist key `MAPMAP_TERRITORY_VERIFYING_KEY` (see FINDINGS).
  private func verifyingKeyHex() throws -> String {
    if let key = Bundle.main.object(forInfoDictionaryKey: "MAPMAP_TERRITORY_VERIFYING_KEY") as? String,
       !key.isEmpty {
      return key
    }
    throw BridgeError(
      .initFailed,
      "missing Info.plist MAPMAP_TERRITORY_VERIFYING_KEY (factory signing public key)")
  }
}
