package expo.modules.mapmapreactnative

import ai.mapmap.MapMap
import ai.mapmap.nav.VoiceGuidance
import ai.mapmap.routing.OfflineRouter
import ai.mapmap.routing.Route
import ai.mapmap.routing.RoutePoint
import ai.mapmap.routing.RouteQuery
import ai.mapmap.routing.ValhallaMobileRouter
import ai.mapmap.territory.TerritoryStore
import android.content.Context
import expo.modules.kotlin.functions.Coroutine
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File

/**
 * Android side of the MapmapNav bridge (contract:
 * modules/mapmap-react-native/src/MapmapNav.types.ts — implemented exactly).
 *
 * Thin async facade over ai.mapmap:core 0.2.0: no business logic here beyond
 * shape mapping, error-code mapping and event plumbing.
 */
class MapmapReactNativeModule : Module() {

  private companion object {
    const val DEFAULT_GATEWAY = "https://api.mapmap.ai"
  }

  /** Everything created by init(); null until init() resolves. */
  private class CoreState(
    val apiKey: String,
    val gateway: GatewayClient,
    val store: TerritoryStore,
    val storeRootDir: File,
    val router: OfflineRouter,
    val voice: VoiceGuidance,
    val territories: TerritoryService,
    val guidance: GuidanceService,
  ) {
    /** SDK routes from the last computeRoute(), addressed by contract index. */
    var lastRoutes: List<Route> = emptyList()
  }

  private val moduleScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
  private val initMutex = Mutex()

  @Volatile
  private var core: CoreState? = null

  private fun requireCore(): CoreState =
    core ?: throw BridgeException(E.NOT_INITIALISED, "call init() before any other MapmapNav method")

  private fun emit(name: String, body: Map<String, Any?>) {
    sendEvent(name, body)
  }

  private fun emitVoiceState(state: CoreState) {
    // FINDING: VoiceGuidance exposes muted/volume but no speaking signal, so
    // `speaking` is reported as false (see FINDINGS.md).
    emit(
      "voiceState",
      mapOf(
        "muted" to state.voice.muted,
        "volume" to state.voice.volume.toDouble(),
        "speaking" to false,
      ),
    )
  }

  override fun definition() = ModuleDefinition {
    Name("MapmapNav")

    Events("territoryProgress", "guidance", "voiceState", "bridgeError")

    /* ---------- init ---------- */

    AsyncFunction("init") Coroutine { apiKey: String, gatewayBaseUrl: String? ->
      initMutex.withLock {
        if (core != null) return@Coroutine // idempotent per contract
        val context: Context = appContext.reactContext?.applicationContext
          ?: throw BridgeException(E.INIT_FAILED, "Android application context unavailable")
        withContext(Dispatchers.IO) {
          try {
            MapMap.ensureLoaded()
          } catch (t: Throwable) {
            rethrowIfCancellation(t)
            throw BridgeException(E.INIT_FAILED, "native core failed to load: ${t.message}", t)
          }
          try {
            val base = (gatewayBaseUrl ?: DEFAULT_GATEWAY).trimEnd('/')
            val gateway = GatewayClient(base, apiKey)
            val rootDir = File(context.filesDir, "mapmap/territories").apply { mkdirs() }
            val store = TerritoryStore(
              rootDir = rootDir,
              verifyingKeyHex = BuildConfig.MAPMAP_FACTORY_PUBKEY_HEX,
            )
            val voice = VoiceGuidance(context)
            core = CoreState(
              apiKey = apiKey,
              gateway = gateway,
              store = store,
              storeRootDir = rootDir,
              router = ValhallaMobileRouter(store),
              voice = voice,
              territories = TerritoryService(store, gateway, ::emit),
              guidance = GuidanceService(context, voice, moduleScope, ::emit),
            )
          } catch (t: Throwable) {
            rethrowIfCancellation(t)
            throw BridgeException(E.INIT_FAILED, "core initialisation failed: ${t.message}", t)
          }
        }
      }
    }

    /* ---------- territories ---------- */

    AsyncFunction("listAvailableTerritories") Coroutine { ->
      val state = requireCore()
      withContext(Dispatchers.IO) { state.territories.listAvailable() }
    }

    AsyncFunction("listInstalledTerritories") Coroutine { ->
      requireCore().territories.listInstalled()
    }

    AsyncFunction("installTerritory") Coroutine { territoryId: String ->
      val state = requireCore()
      withContext(Dispatchers.IO) { state.territories.install(territoryId) }
    }

    AsyncFunction("removeTerritory") Coroutine { territoryId: String ->
      val state = requireCore()
      withContext(Dispatchers.IO) { state.territories.remove(territoryId) }
    }

    AsyncFunction("getTerritoryStyle") Coroutine { territoryId: String, theme: String ->
      val state = requireCore()
      if (theme != "light" && theme != "dark") {
        throw BridgeException(E.INTERNAL, "theme must be \"light\" or \"dark\", got \"$theme\"")
      }
      if (!state.territories.isInstalled(territoryId)) {
        throw BridgeException(E.TERRITORY_NOT_FOUND, "territory \"$territoryId\" is not installed")
      }
      withContext(Dispatchers.IO) {
        OfflineStyleResolver.resolve(state.storeRootDir, territoryId, theme)
      }
    }

    /* ---------- search ---------- */

    AsyncFunction("search") Coroutine { _: String, _: LatLngRecord?, _: Int? ->
      requireCore()
      searchUnavailable()
    }

    /* ---------- routing ---------- */

    AsyncFunction("computeRoute") Coroutine { request: RouteRequestRecord ->
      val state = requireCore()
      if (!state.territories.hasActiveTerritory()) {
        throw BridgeException(E.NO_TERRITORY, "no active territory — install one before routing")
      }
      val costing = when (request.profile) {
        "car" -> "auto"
        "bicycle" -> "bicycle"
        "walking" -> "pedestrian"
        else -> throw BridgeException(E.INTERNAL, "unknown profile \"${request.profile}\"")
      }
      val query = RouteQuery(
        costing = costing,
        locations = listOf(
          RoutePoint(request.origin.latitude, request.origin.longitude),
          RoutePoint(request.destination.latitude, request.destination.longitude),
        ),
        alternates = (request.alternatives ?: 2).coerceIn(0, 3),
      )
      val routes = withContext(Dispatchers.Default) {
        try {
          state.router.route(query)
        } catch (t: Throwable) {
          rethrowIfCancellation(t)
          throw mapRouteError(t)
        }
      }
      if (routes.isEmpty()) {
        throw BridgeException(E.NO_ROUTE, "no route found between the given points")
      }
      state.lastRoutes = routes
      ContractMapping.routeResult(routes)
    }

    /* ---------- guidance ---------- */

    AsyncFunction("startGuidance") Coroutine { options: GuidanceOptionsRecord ->
      val state = requireCore()
      val route = state.lastRoutes.getOrNull(options.routeIndex)
        ?: throw BridgeException(
          E.INTERNAL,
          "routeIndex ${options.routeIndex} is not a route from the last computeRoute() " +
            "(${state.lastRoutes.size} available)",
        )
      state.guidance.start(route, options.source)
      emitVoiceState(state)
    }

    AsyncFunction("stopGuidance") Coroutine { ->
      val state = requireCore()
      state.guidance.stop()
      emitVoiceState(state)
    }

    /* ---------- voice ---------- */

    AsyncFunction("setVoiceMuted") Coroutine { muted: Boolean ->
      val state = requireCore()
      state.voice.muted = muted
      emitVoiceState(state)
    }

    AsyncFunction("setVoiceVolume") Coroutine { volume: Double ->
      val state = requireCore()
      state.voice.volume = volume.coerceIn(0.0, 1.0).toFloat()
      emitVoiceState(state)
    }

    OnDestroy {
      core?.guidance?.shutdown()
      moduleScope.cancel()
    }
  }

  /**
   * FINDING: ai.mapmap:core 0.2.0 exposes no documented on-device search API
   * (territory packages bundle geocode indices, but no public Kotlin surface
   * reaches them; hosted /geocode returns 501). Contract-mandated rejection
   * rather than silent redesign. Typed so the search lambda's return type is
   * SearchResult[]-shaped, not Nothing.
   */
  private fun searchUnavailable(): List<Map<String, Any?>> =
    throw BridgeException(E.INTERNAL, "on-device search unavailable")

  /** Router failures: genuine no-path → E_NO_ROUTE, the rest → E_INTERNAL. */
  private fun mapRouteError(t: Throwable): BridgeException {
    if (t is BridgeException) return t
    val message = (t.message ?: "").lowercase()
    val noRouteHints = listOf("no route", "no path", "unreachable", "not routable", "no legs")
    if (noRouteHints.any { message.contains(it) }) {
      return BridgeException(E.NO_ROUTE, t.message ?: "no route found", t)
    }
    return asBridgeException(t)
  }
}
