// Copyright (C) 2026 Jason M. Schwefel. All Rights Reserved.
package com.coldboreballisticsllc.scopedope.ui.session

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.LocationManager
import android.net.Uri
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.UUID
import com.coldboreballisticsllc.scopedope.BuildConfig
import com.coldboreballisticsllc.scopedope.data.prefs.AppPreferences
import com.coldboreballisticsllc.scopedope.domain.sensor.WeatherSensorSource
import com.coldboreballisticsllc.scopedope.data.session.DopeSession
import com.coldboreballisticsllc.scopedope.data.session.DopeSessionSerializer
import com.coldboreballisticsllc.scopedope.data.session.RecentSession
import com.coldboreballisticsllc.scopedope.data.session.SessionCreatedBy
import com.coldboreballisticsllc.scopedope.data.session.ImportResult
import com.coldboreballisticsllc.scopedope.data.session.ScopeDOPEFileManager
import com.coldboreballisticsllc.scopedope.data.session.SerializedConditionsAtMeasurement
import com.coldboreballisticsllc.scopedope.data.session.SessionAmmo
import com.coldboreballisticsllc.scopedope.data.session.SessionConditions
import com.coldboreballisticsllc.scopedope.data.session.SessionDope
import com.coldboreballisticsllc.scopedope.data.session.SessionRifle
import com.coldboreballisticsllc.scopedope.data.session.SessionScope
import com.coldboreballisticsllc.scopedope.data.session.SessionUnitPrefs
import com.coldboreballisticsllc.scopedope.ballistics.TwistDirection
import com.coldboreballisticsllc.scopedope.domain.model.*
import com.coldboreballisticsllc.scopedope.domain.usecase.CalculateDopeUseCase
import com.coldboreballisticsllc.scopedope.domain.usecase.CalculationInput
import com.coldboreballisticsllc.scopedope.label.format.LabelFormat
import com.coldboreballisticsllc.scopedope.label.format.LabelFormats
import androidx.compose.runtime.Immutable
import java.time.Instant
import javax.inject.Inject
import kotlin.math.roundToInt

enum class AdjUnit { MIL, MOA }

// ── Measurement system + condition unit preferences ───────────────────────────

enum class MeasureSystem { US_CUSTOMARY, METRIC }

enum class AltitudeUnit(val suffix: String)  { FT("ft"), M("m") }
enum class TempUnit(val suffix: String)       { F("°F"),  C("°C"), K("K") }
enum class PressureUnit(val suffix: String)   { INHG("inHg"), HPA("hPa"), MBAR("mbar"), MMHG("mmHg"), KPA("kPa") }
enum class WindSpeedUnit(val suffix: String)  { MPH("mph"), KPH("km/h"), MPS("m/s"), KTS("kts") }

data class ConditionUnits(
    val altitude:  AltitudeUnit  = AltitudeUnit.FT,
    val temp:      TempUnit      = TempUnit.F,
    val pressure:  PressureUnit  = PressureUnit.INHG,
    val windSpeed: WindSpeedUnit = WindSpeedUnit.MPH,
) {
    companion object {
        fun forSystem(system: MeasureSystem) = when (system) {
            MeasureSystem.US_CUSTOMARY -> ConditionUnits()
            MeasureSystem.METRIC -> ConditionUnits(
                altitude  = AltitudeUnit.M,
                temp      = TempUnit.C,
                pressure  = PressureUnit.HPA,
                windSpeed = WindSpeedUnit.KPH,
            )
        }
    }
}

enum class DistancePreset(
    val label: String,
    val description: String,
    val distances: List<Int>,             // yards
    val metricDistancesYards: List<Int> = emptyList(),  // clean metric targets expressed in yards
) {
    NRL22("NRL22", "Short range — 25, 50, 75, 100",
        listOf(25, 50, 75, 100),
        listOf(27, 55, 82, 109)),       // 25 m, 50 m, 75 m, 100 m
    PRS("PRS", "Extended range — 200–1000",
        listOf(200, 300, 400, 500, 600, 700, 800, 900, 1000),
        listOf(219, 328, 437, 547, 656, 766, 875, 984, 1094)),  // 200–1000 m
    CUSTOM("Custom", "Manually configured distances", emptyList()),
}

data class RifleSetup(
    val name:           String         = "",
    val brand:          String         = "",
    val model:          String         = "",
    val zeroYards:      Int            = 100,
    val sightHeightIn:  Double         = 1.5,
    val twistRateIn:    Double         = 0.0,
    val twistDirection: TwistDirection = TwistDirection.RH,
    val zeroTempF:      Double?        = null,
    val zeroAltitudeFt: Double?        = null,
    val zeroPressureHpa: Double?       = null,
)

data class ScopeInfo(
    val name:  String = "",
    val brand: String = "",
    val model: String = "",
)

/** UI-layer state snapshot exposed to all screens. */
@Immutable
data class SessionState(
    val ammo:            AmmoLoad?       = null,
    val rifle:           RifleSetup      = RifleSetup(),
    val scope:           ScopeInfo       = ScopeInfo(),
    val dopeEntries:     List<DopeEntry> = emptyList(),
    val conditions:      RangeConditions = RangeConditions(),
    val wind:            WindConditions  = WindConditions(),
    val outputDistances: List<Int>        = emptyList(),
    val distancePreset:  DistancePreset  = DistancePreset.CUSTOM,
    val results:         List<ResultRow> = emptyList(),
    val useRealDope:     Boolean         = true,
    val labelFormat:     LabelFormat     = LabelFormats.CIRCLE_50MM,
    val adjUnit:         AdjUnit         = AdjUnit.MIL,
    val clickValue:      String          = "0.1_mrad",
    val measureSystem:          MeasureSystem   = MeasureSystem.US_CUSTOMARY,
    val conditionUnits:         ConditionUnits  = ConditionUnits(),
    val sessionName:     String          = "Unsaved",
    val sessionUuid:     String          = "",
    val conditionsSource: String         = "manual",
    val loadResult:      LoadResult?     = null,
    val useCoriolis:         Boolean = false,
    val coriolisLatitudeDeg: Double  = 0.0,
    val firingAzimuthDeg:    Double  = 0.0,
    val firingAngleDeg:      Double  = 0.0,
    val isDirty:             Boolean         = false,
    val recentSessions:      List<com.coldboreballisticsllc.scopedope.data.session.RecentSession> = emptyList(),
    val conditionsFrozen:    Boolean         = false,
    // Non-null when the last recalculate() call threw; cleared on the next successful calculation.
    val calculationError:    String?         = null,
    val shareError:          String?         = null,
    // Set by applySession() to restore persisted label selections on first recalculate(); then cleared.
    val pendingLabelDistances: Set<Int>?     = null,
)

sealed class LoadResult {
    data object Success : LoadResult()
    data class  Failure(val message: String) : LoadResult()
}

@HiltViewModel
class SessionViewModel @Inject constructor(
    @ApplicationContext private val context: Context,
    private val calculateDope:    CalculateDopeUseCase,
    private val serializer:       DopeSessionSerializer,
    private val btSensor:         WeatherSensorSource,
    private val fileManager:      ScopeDOPEFileManager,
    private val prefs:            AppPreferences,
    private val phoneCompass:     com.coldboreballisticsllc.scopedope.data.sensor.PhoneCompassManager,
) : ViewModel() {

    // Inputs that drive auto-calculation (excludes results to prevent infinite loop)
    private data class CalcInputs(
        val ammo:                AmmoLoad?,
        val rifle:               RifleSetup,
        val outputDistances:     List<Int>,
        val conditions:          RangeConditions,
        val wind:                WindConditions,
        val dopeEntries:         List<DopeEntry>,
        val useRealDope:         Boolean,
        val labelFormat:         LabelFormat,
        val useCoriolis:         Boolean,
        val coriolisLatitudeDeg: Double,
        val firingAzimuthDeg:    Double,
        val firingAngleDeg:      Double,
        val clickValue:          String,
    )

    private val _state = MutableStateFlow(SessionState())
    val state: StateFlow<SessionState> = _state.asStateFlow()

    // ── Recent session history ─────────────────────────────────────────────────

    fun removeFromRecentHistory(filePath: String) {
        viewModelScope.launch { prefs.removeFromRecentSessions(filePath) }
    }

    private fun addToRecentHistory(file: java.io.File) {
        val s = _state.value
        val entry = RecentSession(
            name        = s.sessionName.ifBlank { file.nameWithoutExtension },
            caliber     = s.ammo?.caliber ?: "",
            filePath    = file.absolutePath,
            timestampMs = System.currentTimeMillis(),
        )
        viewModelScope.launch { prefs.addToRecentSessions(entry) }
    }

    private val autosaveFile: File get() = File(context.filesDir, "_autosave.dope")

    init {
        viewModelScope.launch {
            loadPrefs()
            tryRestoreAutosave()
        }
        viewModelScope.launch {
            prefs.recentSessions.collect { sessions ->
                _state.update { it.copy(recentSessions = sessions) }
            }
        }

        // Autosave on meaningful state changes (debounced 2 s, only when ammo is set)
        viewModelScope.launch {
            _state
                .map { s -> if (s.ammo != null) buildSession(s) else null }
                .distinctUntilChanged()
                .debounce(2_000L)
                .collect { session ->
                    if (session != null) writeAutosave(session)
                }
        }

        // Auto-recalculate whenever inputs change (debounced 300 ms)
        viewModelScope.launch {
            _state
                .map { s -> CalcInputs(s.ammo, s.rifle, s.outputDistances, s.conditions, s.wind, s.dopeEntries, s.useRealDope, s.labelFormat, s.useCoriolis, s.coriolisLatitudeDeg, s.firingAzimuthDeg, s.firingAngleDeg, s.clickValue) }
                .distinctUntilChanged()
                .debounce(300L)
                .collectLatest { inputs -> recalculate(inputs) }
        }

        // Auto-populate conditions from sensor (rate-limited to 500 ms)
        viewModelScope.launch {
            btSensor.readings
                .debounce(500L)
                .collect { r ->
                    _state.update { s ->
                        if (s.conditionsFrozen) return@update s
                        var cond = s.conditions
                        r.temperatureF?.let { cond = cond.copy(temperatureF = it.toDouble()) }
                        r.pressureHpa?.let  { cond = cond.copy(pressureHpa  = it.toDouble()) }
                        r.humidityPct?.let  { cond = cond.copy(humidityPct  = it.toDouble()) }

                        var wind = s.wind
                        r.windSpeedMph?.let { wind = wind.copy(speedMph = it.toDouble()) }

                        s.copy(
                            conditions       = cond,
                            wind             = wind,
                            conditionsSource = btSensor.driverId,
                        )
                    }
                }
        }

        // Apply phone compass bearing to shooter-relative wind angle (rate-limited to 500 ms)
        viewModelScope.launch {
            phoneCompass.bearingDeg
                .debounce(500L)
                .collect { bearing ->
                    if (bearing == null) return@collect
                    _state.update { s ->
                        if (s.conditionsFrozen) return@update s
                        val relative = com.coldboreballisticsllc.scopedope.data.sensor.WindBearingConverter
                            .toShooterRelative(bearing, s.firingAzimuthDeg)
                        s.copy(wind = s.wind.copy(angleDeg = relative))
                    }
                }
        }
    }

    private suspend fun loadPrefs() {
        val useMetric             = prefs.useMetric.first()
        val system                = if (useMetric) MeasureSystem.METRIC else MeasureSystem.US_CUSTOMARY
        val systemUnits           = ConditionUnits.forSystem(system)
        val altUnit   = AltitudeUnit.entries.find  { it.name == prefs.altitudeUnit.first()  } ?: systemUnits.altitude
        val tempUnit  = TempUnit.entries.find      { it.name == prefs.tempUnit.first()      } ?: systemUnits.temp
        val presUnit  = PressureUnit.entries.find  { it.name == prefs.pressureUnit.first()  } ?: systemUnits.pressure
        val windUnit  = WindSpeedUnit.entries.find { it.name == prefs.windSpeedUnit.first() } ?: systemUnits.windSpeed
        val labelFormatId         = prefs.labelFormatId.first()
        val useCoriolis           = prefs.useCoriolis.first()
        val coriolisLatitudeDeg   = prefs.coriolisLatitudeDeg.first()
        val clickValue            = prefs.clickValue.first()
        val useRealDope           = prefs.useRealDope.first()
        _state.update { it.copy(
            measureSystem         = system,
            conditionUnits        = ConditionUnits(altUnit, tempUnit, presUnit, windUnit),
            labelFormat           = LabelFormats.all.find { it.id == labelFormatId } ?: LabelFormats.CIRCLE_50MM,
            useCoriolis           = useCoriolis,
            coriolisLatitudeDeg   = coriolisLatitudeDeg,
            clickValue            = clickValue,
            adjUnit               = if (clickValue.endsWith("_moa")) AdjUnit.MOA else AdjUnit.MIL,
            useRealDope           = useRealDope,
        ) }
    }

    private val _shareChannel = Channel<Uri>(Channel.BUFFERED)
    val shareEvent: Flow<Uri> = _shareChannel.receiveAsFlow()

    // ── Auto-calculation ──────────────────────────────────────────────────────

    private suspend fun recalculate(inputs: CalcInputs) {
        if (inputs.ammo == null || inputs.outputDistances.isEmpty()) {
            _state.update { it.copy(results = emptyList()) }
            return
        }
        val output = try {
            withContext(Dispatchers.Default) {
                calculateDope(CalculationInput(
                    ammo                = inputs.ammo,
                    zeroDistanceYards   = inputs.rifle.zeroYards.toDouble(),
                    sightHeightIn       = inputs.rifle.sightHeightIn,
                    dopeEntries         = if (inputs.useRealDope) inputs.dopeEntries else emptyList(),
                    conditions          = inputs.conditions,
                    wind                = inputs.wind,
                    outputDistances     = inputs.outputDistances,
                    zeroTempF           = inputs.rifle.zeroTempF ?: inputs.conditions.temperatureF,
                    zeroAltitudeFt      = inputs.rifle.zeroAltitudeFt,
                    zeroPressureHpa     = inputs.rifle.zeroPressureHpa,
                    twistRateIn         = inputs.rifle.twistRateIn,
                    twistDirection      = inputs.rifle.twistDirection,
                    useCoriolis         = inputs.useCoriolis,
                    coriolisLatitudeDeg = inputs.coriolisLatitudeDeg,
                    firingAzimuthDeg    = inputs.firingAzimuthDeg,
                    firingAngleDeg      = inputs.firingAngleDeg,
                    clickValue          = inputs.clickValue,
                ))
            }
        } catch (e: Exception) {
            if (BuildConfig.DEBUG) android.util.Log.w("SessionViewModel", "recalculate failed", e)
            _state.update { it.copy(results = emptyList(), calculationError = e.message) }
            return
        }
        val max     = inputs.labelFormat.maxEntries
        val current = _state.value
        // pendingLabelDistances is set when loading a session; takes priority over live results.
        val prevInclusions = current.pendingLabelDistances
            ?: current.results.filter { it.includeOnLabel }.map { it.distanceYards }.toSet()
        val rows = if (prevInclusions.isEmpty()) {
            // First calc or all deselected: auto-select first N
            output.rows.mapIndexed { i, row -> row.copy(includeOnLabel = i < max) }
        } else {
            // Preserve previous selections, cap at new format's max
            var count = 0
            output.rows.map { row ->
                val include = row.distanceYards in prevInclusions && count < max
                if (include) count++
                row.copy(includeOnLabel = include)
            }
        }
        _state.update { it.copy(results = rows, calculationError = null, pendingLabelDistances = null) }
    }

    // ── Ammo ──────────────────────────────────────────────────────────────────

    fun selectAmmo(ammo: AmmoLoad) {
        _state.update { s ->
            val currentZero = s.rifle.zeroYards
            val defaultZero = defaultZeroYards(ammo.caliber)
            val newZero = if (currentZero == 50 || currentZero == 100) defaultZero else currentZero
            s.copy(
                ammo        = ammo,
                rifle       = s.rifle.copy(zeroYards = newZero),
                sessionName = "Unsaved",
                isDirty     = true,
            )
        }
    }

    private fun defaultZeroYards(caliber: String): Int {
        val shortRange = setOf(
            ".22 LR", ".22 WMR", ".17 HMR", ".17 WSM",
            "5.7x28", "5.7×28", "5.7 x 28",
        )
        return if (shortRange.any { caliber.contains(it, ignoreCase = true) }) 50 else 100
    }

    // ── Rifle setup ───────────────────────────────────────────────────────────

    fun setRifleSetup(rifle: RifleSetup) {
        _state.update { it.copy(rifle = rifle, isDirty = true) }
    }

    fun setScopeInfo(scope: ScopeInfo) {
        _state.update { it.copy(scope = scope, isDirty = true) }
    }

    // ── DOPE entries ──────────────────────────────────────────────────────────

    fun setDopeEntry(index: Int, entry: DopeEntry) {
        val s = _state.value
        val updated = s.dopeEntries.toMutableList()
        val entryWithConditions = if (index >= updated.size && entry.conditionsAtMeasurement == null) {
            // New entry — snapshot current session conditions and timestamp automatically
            entry.copy(
                recordedAt = Instant.now().toString(),
                conditionsAtMeasurement = ConditionsAtMeasurement(
                    altitudeFt   = s.conditions.altitudeFt,
                    temperatureF = s.conditions.temperatureF,
                    pressureHpa  = s.conditions.pressureHpa,
                    humidityPct  = s.conditions.humidityPct,
                    windSpeedMph = s.wind.speedMph,
                    windAngleDeg = s.wind.angleDeg,
                ),
            )
        } else entry
        val isNew = index >= updated.size
        if (!isNew) {
            updated[index] = entryWithConditions
        } else {
            // For new entries, replace any existing entry at the same distance rather than
            // allowing duplicates that would produce NaN tangents in the spline interpolation.
            val existingIdx = updated.indexOfFirst { it.distanceYards == entryWithConditions.distanceYards }
            if (existingIdx >= 0) updated[existingIdx] = entryWithConditions else updated.add(entryWithConditions)
        }
        _state.update { it.copy(dopeEntries = updated.toList(), isDirty = true) }
    }

    fun removeDopeEntry(index: Int) {
        val updated = _state.value.dopeEntries.toMutableList()
        if (index in updated.indices) {
            updated.removeAt(index)
            _state.update { it.copy(dopeEntries = updated.toList(), isDirty = true) }
        }
    }

    fun clearDopeEntries() {
        _state.update { it.copy(dopeEntries = emptyList(), isDirty = true) }
    }

    // ── Output distances ──────────────────────────────────────────────────────

    fun setOutputDistances(distances: List<Int>) {
        if (distances.size > 100) return
        _state.update { it.copy(outputDistances = distances.sorted(), isDirty = true) }
    }

    fun addOutputDistance(yards: Int) {
        if (_state.value.outputDistances.contains(yards)) return
        val updated = (_state.value.outputDistances + yards).sorted()
        _state.update { it.copy(outputDistances = updated, distancePreset = DistancePreset.CUSTOM, isDirty = true) }
    }

    fun removeOutputDistance(yards: Int) {
        _state.update { it.copy(
            outputDistances = it.outputDistances.filter { d -> d != yards },
            distancePreset  = DistancePreset.CUSTOM,
            isDirty         = true,
        ) }
    }

    fun applyDistancePreset(preset: DistancePreset) {
        if (preset == DistancePreset.CUSTOM) return
        val distances = if (_state.value.measureSystem == MeasureSystem.METRIC &&
                            preset.metricDistancesYards.isNotEmpty())
            preset.metricDistancesYards
        else
            preset.distances
        _state.update { it.copy(outputDistances = distances, distancePreset = preset) }
    }

    // ── Conditions ────────────────────────────────────────────────────────────

    fun setConditions(conditions: RangeConditions) {
        _state.update { it.copy(conditions = conditions, conditionsSource = "manual", isDirty = true) }
    }

    fun setWind(wind: WindConditions) {
        _state.update { it.copy(wind = wind, isDirty = true) }
    }

    fun setFiringAzimuth(azimuthDeg: Double) {
        _state.update { it.copy(firingAzimuthDeg = azimuthDeg) }
    }

    fun setFiringAngle(angleDeg: Double) {
        _state.update { it.copy(firingAngleDeg = angleDeg, isDirty = true) }
    }

    // ── Coriolis ──────────────────────────────────────────────────────────────

    fun setUseCoriolis(enabled: Boolean) {
        viewModelScope.launch {
            prefs.setUseCoriolis(enabled)
            _state.update { it.copy(useCoriolis = enabled) }
        }
    }

    fun setCoriolisLatitude(latDeg: Double) {
        val clamped = latDeg.coerceIn(-90.0, 90.0)
        viewModelScope.launch {
            prefs.setCoriolisLatitudeDeg(clamped)
            _state.update { it.copy(coriolisLatitudeDeg = clamped) }
        }
    }

    // ── Label ─────────────────────────────────────────────────────────────────

    fun setLabelFormat(format: LabelFormat) {
        viewModelScope.launch {
            prefs.setLabelFormatId(format.id)
            _state.update { it.copy(labelFormat = format) }
        }
    }

    fun setSessionName(name: String) {
        _state.update { it.copy(sessionName = name.ifBlank { "Unsaved" }) }
    }

    fun setAdjUnit(unit: AdjUnit) {
        val currentClick = _state.value.clickValue
        viewModelScope.launch {
            // Save the outgoing unit's click value, then restore the incoming unit's saved value.
            if (currentClick.endsWith("_moa")) prefs.setSavedMoaClick(currentClick)
            else prefs.setSavedMradClick(currentClick)
            val restored = if (unit == AdjUnit.MOA) prefs.getSavedMoaClick()
                           else prefs.getSavedMradClick()
            _state.update { it.copy(adjUnit = unit, clickValue = restored) }
            prefs.setClickValue(restored)
        }
    }

    fun setClickValue(value: String) {
        val unit = if (value.endsWith("_moa")) AdjUnit.MOA else AdjUnit.MIL
        _state.update { it.copy(clickValue = value, adjUnit = unit) }
        viewModelScope.launch { prefs.setClickValue(value) }
    }

    fun setUseRealDope(enabled: Boolean) {
        viewModelScope.launch {
            prefs.setUseRealDope(enabled)
            _state.update { it.copy(useRealDope = enabled) }
        }
    }

    // ── Measurement system ────────────────────────────────────────────────────

    fun setMeasureSystem(system: MeasureSystem) {
        val units = ConditionUnits.forSystem(system)
        viewModelScope.launch {
            prefs.setUseMetric(system == MeasureSystem.METRIC)
            prefs.setAltitudeUnit(units.altitude.name)
            prefs.setTempUnit(units.temp.name)
            prefs.setPressureUnit(units.pressure.name)
            prefs.setWindSpeedUnit(units.windSpeed.name)
            _state.update { it.copy(measureSystem = system, conditionUnits = units) }
        }
    }

    fun setAltitudeUnit(unit: AltitudeUnit) {
        viewModelScope.launch {
            prefs.setAltitudeUnit(unit.name)
            _state.update { it.copy(conditionUnits = it.conditionUnits.copy(altitude = unit)) }
        }
    }

    fun setTempUnit(unit: TempUnit) {
        viewModelScope.launch {
            prefs.setTempUnit(unit.name)
            _state.update { it.copy(conditionUnits = it.conditionUnits.copy(temp = unit)) }
        }
    }

    fun setPressureUnit(unit: PressureUnit) {
        viewModelScope.launch {
            prefs.setPressureUnit(unit.name)
            _state.update { it.copy(conditionUnits = it.conditionUnits.copy(pressure = unit)) }
        }
    }

    fun setWindSpeedUnit(unit: WindSpeedUnit) {
        viewModelScope.launch {
            prefs.setWindSpeedUnit(unit.name)
            _state.update { it.copy(conditionUnits = it.conditionUnits.copy(windSpeed = unit)) }
        }
    }

    fun toggleLabelInclusion(distanceYards: Int) {
        _state.update { s ->
            val max     = s.labelFormat.maxEntries
            val target  = s.results.find { it.distanceYards == distanceYards } ?: return@update s
            if (target.includeOnLabel) {
                s.copy(results = s.results.map { r ->
                    if (r.distanceYards == distanceYards) r.copy(includeOnLabel = false) else r
                })
            } else {
                val included = s.results.filter { it.includeOnLabel }
                val evict    = if (included.size >= max) included.first().distanceYards else null
                s.copy(results = s.results.map { r ->
                    when (r.distanceYards) {
                        distanceYards -> r.copy(includeOnLabel = true)
                        evict         -> r.copy(includeOnLabel = false)
                        else          -> r
                    }
                })
            }
        }
    }

    // ── Sensor capture ────────────────────────────────────────────────────────

    private val _captureResult = MutableStateFlow<Set<String>?>(null)
    val captureResult: StateFlow<Set<String>?> = _captureResult.asStateFlow()

    fun captureConditions() {
        viewModelScope.launch {
            val captured = mutableSetOf<String>()

            val reading = btSensor.snapshot()
            if (reading?.temperatureF != null) {
                _state.update { it.copy(conditions = it.conditions.copy(temperatureF = reading.temperatureF.toDouble())) }
                captured.add("temperature")
            }
            if (reading?.windSpeedMph != null) {
                _state.update { it.copy(wind = it.wind.copy(speedMph = reading.windSpeedMph.toDouble())) }
                captured.add("windSpeed")
            }

            // Altitude requires FINE location: with coarse-only, Android returns
            // fuzzed locations with altitude stripped (hasAltitude()==false). SPEC §17.1.
            val hasPerm = ContextCompat.checkSelfPermission(
                context, Manifest.permission.ACCESS_FINE_LOCATION
            ) == PackageManager.PERMISSION_GRANTED
            if (hasPerm) {
                val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
                val loc = try {
                    listOf(
                        LocationManager.NETWORK_PROVIDER,
                        LocationManager.GPS_PROVIDER,
                        LocationManager.PASSIVE_PROVIDER,
                    ).firstNotNullOfOrNull { p ->
                        runCatching { lm.getLastKnownLocation(p) }.getOrNull()?.takeIf { it.hasAltitude() }
                    }
                } catch (_: SecurityException) { null }

                if (loc != null) {
                    // Prefer MSL altitude (real-world elevation) when available (API 34+);
                    // loc.altitude alone is WGS84 ellipsoidal and reads ~100 ft low. SPEC §17.1.
                    val altM = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
                        && loc.hasMslAltitude()
                    ) {
                        loc.mslAltitudeMeters
                    } else {
                        loc.altitude
                    }
                    val altFt = (altM / 0.3048).roundToInt()
                    _state.update { it.copy(conditions = it.conditions.copy(altitudeFt = altFt)) }
                    captured.add("altitude")
                }
            }

            _captureResult.value = captured
        }
    }

    fun clearCaptureResult() { _captureResult.value = null }

    // ── Conditions freeze ─────────────────────────────────────────────────────

    fun setConditionsFrozen(frozen: Boolean) {
        _state.update { it.copy(conditionsFrozen = frozen) }
    }

    // ── Session file I/O ──────────────────────────────────────────────────────

    fun saveSession(uri: Uri) {
        viewModelScope.launch {
            withContext(Dispatchers.IO) {
                serializer.saveTo(context, uri, buildSession(_state.value))
            }.onFailure { e ->
                _state.update { it.copy(shareError = e.message ?: "Save failed") }
            }
        }
    }

    fun shareDope() {
        viewModelScope.launch {
            try {
                val s    = _state.value
                val file = withContext(Dispatchers.IO) {
                    val json = serializer.serialize(buildSession(s))
                    val dir  = File(context.cacheDir, "sessions").also { it.mkdirs() }
                    val name = if (s.sessionName == "Unsaved") "scopedope_session.dope"
                               else "${s.sessionName.replace(" ", "_")}.dope"
                    File(dir, name).also { it.writeText(json) }
                }
                val uri  = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
                _state.update { it.copy(shareError = null) }
                _shareChannel.send(uri)
            } catch (e: Exception) {
                if (BuildConfig.DEBUG) android.util.Log.w("SessionViewModel", "shareDope failed", e)
                _state.update { it.copy(shareError = e.message ?: "Share failed") }
            }
        }
    }

    fun clearShareError() {
        _state.update { it.copy(shareError = null) }
    }

    fun loadSession(uri: Uri) {
        viewModelScope.launch {
            serializer.loadFrom(context, uri)
                .onSuccess { session ->
                    _state.update { applySession(session).copy(loadResult = LoadResult.Success) }
                    prefs.addToRecentSessions(RecentSession(
                        filePath    = uri.toString(),
                        name        = session.name.ifBlank { "Unsaved" },
                        caliber     = session.ammo?.caliber ?: "",
                        timestampMs = System.currentTimeMillis(),
                    ))
                }
                .onFailure { e ->
                    _state.update { it.copy(loadResult = LoadResult.Failure(e.message ?: "Unknown error")) }
                }
        }
    }

    fun loadSessionFromFile(file: File) {
        viewModelScope.launch {
            val result = runCatching {
                val text = withContext(Dispatchers.IO) { file.readText() }
                serializer.deserialize(text).getOrThrow()
            }
            result.onSuccess { session ->
                _state.update { current ->
                    applySession(session).copy(
                        loadResult     = LoadResult.Success,
                        recentSessions = current.recentSessions,
                    )
                }
                addToRecentHistory(file)
            }
            result.onFailure { e ->
                _state.update { it.copy(loadResult = LoadResult.Failure(e.message ?: "Read error")) }
            }
        }
    }

    fun loadFromRecentSession(session: RecentSession) {
        if (session.filePath.startsWith("content://")) {
            loadSession(android.net.Uri.parse(session.filePath))
            return
        }
        val file = File(session.filePath)
        if (!file.exists()) {
            _state.update { it.copy(loadResult = LoadResult.Failure("File not found: ${session.filePath}")) }
            removeFromRecentHistory(session.filePath)
            return
        }
        loadSessionFromFile(file)
    }

    fun clearLoadResult() {
        _state.update { it.copy(loadResult = null) }
    }

    // ── ScopeDOPE file library ────────────────────────────────────────────────

    val dopeDirectoryPath: String get() = fileManager.displayPath

    fun listDopeFiles(dopeOnly: Boolean = true): List<File> = fileManager.list(dopeOnly)

    fun resolveImportFilename(uri: Uri): String = fileManager.resolveFilename(uri)

    fun importDopeFile(uri: Uri, filename: String): ImportResult = fileManager.importUri(uri, filename)

    fun overwriteDopeFile(uri: Uri, filename: String): ImportResult = fileManager.overwrite(uri, filename)

    fun keepBothDopeFiles(uri: Uri, filename: String): ImportResult = fileManager.keepBoth(uri, filename)

    // ── Autosave ──────────────────────────────────────────────────────────────

    private suspend fun writeAutosave(session: DopeSession) {
        withContext(Dispatchers.IO) {
            runCatching { autosaveFile.writeText(serializer.serialize(session)) }
        }
    }

    private suspend fun tryRestoreAutosave() {
        val file = autosaveFile
        if (!file.exists()) return
        withContext(Dispatchers.IO) {
            runCatching { serializer.deserialize(file.readText()).getOrThrow() }
        }.onSuccess { session ->
            _state.update { current ->
                applySession(session).copy(recentSessions = current.recentSessions)
            }
        }
    }

    // ── Private helpers ───────────────────────────────────────────────────────

    private fun buildSession(s: SessionState) = DopeSession(
        createdBy       = SessionCreatedBy(version = BuildConfig.VERSION_NAME),
        sessionUuid     = s.sessionUuid.ifBlank { UUID.randomUUID().toString() },
        savedAt         = Instant.now().toString(),
        name            = if (s.sessionName == "Unsaved") "" else s.sessionName,
        labelFormatId   = s.labelFormat.id,
        ammo            = s.ammo?.let {
            SessionAmmo(
                caliber              = it.caliber,
                name                 = it.name,
                muzzleVelMps         = it.muzzleVelFps * 0.3048,
                bc                   = it.bc,
                bcModel              = it.bcModel.name,
                bcG1                 = it.bcG1,
                bcG7                 = it.bcG7,
                formFactor           = it.formFactor,
                bulletDiameterMm     = it.bulletDiameterMm,
                bulletLengthMm       = it.bulletLengthMm,
                bulletWeightG        = it.bulletWeightGr * 0.06479891,
                mvSensitivityMpsPerC = it.mvSensitivityFpsPerF * 0.54864,
                mvReferenceTempC     = (it.mvReferenceTempF - 32.0) * 5.0 / 9.0,
            )
        },
        rifle           = SessionRifle(
            name             = s.rifle.name,
            brand            = s.rifle.brand,
            model            = s.rifle.model,
            zeroM            = s.rifle.zeroYards * 0.9144,
            sightHeightMm    = s.rifle.sightHeightIn * 25.4,
            twistRateMm      = s.rifle.twistRateIn * 25.4,
            twistDirection   = s.rifle.twistDirection.name,
            zeroTemperatureC = s.rifle.zeroTempF?.let { (it - 32.0) * 5.0 / 9.0 },
            zeroAltitudeM    = s.rifle.zeroAltitudeFt?.let { it * 0.3048 },
            zeroPressureHpa  = s.rifle.zeroPressureHpa,
        ),
        scope           = SessionScope(
            name  = s.scope.name,
            brand = s.scope.brand,
            model = s.scope.model,
        ).takeIf { it.name.isNotBlank() || it.brand.isNotBlank() || it.model.isNotBlank() },
        conditions      = SessionConditions(
            altitudeM           = s.conditions.altitudeFt * 0.3048,
            temperatureC        = (s.conditions.temperatureF - 32.0) * 5.0 / 9.0,
            pressureHpa         = s.conditions.pressureHpa,
            humidityPct         = s.conditions.humidityPct,
            windSpeedMps        = s.wind.speedMph * 0.44704,
            windAngleDeg        = s.wind.angleDeg,
            firingAzimuthDeg    = s.firingAzimuthDeg,
            firingAngleDeg      = s.firingAngleDeg,
            conditionsSource    = s.conditionsSource,
            useCoriolis         = s.useCoriolis,
            coriolisLatitudeDeg = s.coriolisLatitudeDeg,
        ),
        dopeEntries     = s.dopeEntries.map { entry ->
            SessionDope(
                distanceM    = entry.distanceYards * 0.9144,
                elevationMil = entry.adjustmentMil,
                recordedAt   = entry.recordedAt,
                conditionsAtMeasurement = entry.conditionsAtMeasurement?.let { c ->
                    SerializedConditionsAtMeasurement(
                        altitudeM    = c.altitudeFt?.let { it * 0.3048 },
                        temperatureC = c.temperatureF?.let { (it - 32.0) * 5.0 / 9.0 },
                        pressureHpa  = c.pressureHpa,
                        humidityPct  = c.humidityPct,
                        windSpeedMps = c.windSpeedMph?.let { it * 0.44704 },
                        windAngleDeg = c.windAngleDeg,
                    )
                },
            )
        },
        outputDistancesM         = s.outputDistances.map { it * 0.9144 },
        labelSelectedDistancesM  = s.results.filter { it.includeOnLabel }.map { it.distanceYards * 0.9144 },
        unitPrefs        = SessionUnitPrefs(
            system     = if (s.measureSystem == MeasureSystem.METRIC) "metric" else "us",
            adjustment = s.adjUnit.name.lowercase(),
            clickValue = s.clickValue,
        ),
    )

    private fun applySession(session: DopeSession): SessionState {
        val clickValue    = session.unitPrefs?.clickValue ?: "0.1_mrad"
        val adjUnit       = if (clickValue.endsWith("_moa")) AdjUnit.MOA else AdjUnit.MIL
        val measureSystem = if (session.unitPrefs?.system == "metric") MeasureSystem.METRIC else MeasureSystem.US_CUSTOMARY
        val cond    = session.conditions
        val ammo    = session.ammo?.let { a ->
            val resolvedBcModel = runCatching { BcModel.valueOf(a.bcModel.uppercase()) }.getOrDefault(BcModel.G7)
            val resolvedBc = when (resolvedBcModel) {
                BcModel.G7 -> if (a.bcG7 > 0.0) a.bcG7 else a.bc
                BcModel.G1 -> if (a.bcG1 > 0.0) a.bcG1 else a.bc
            }
            AmmoLoad(
                id                   = 0,
                caliber              = a.caliber,
                name                 = a.name,
                muzzleVelFps         = (a.muzzleVelMps / 0.3048).roundToInt(),
                bc                   = resolvedBc,
                bcModel              = resolvedBcModel,
                bcG1                 = a.bcG1,
                bcG7                 = a.bcG7,
                formFactor           = a.formFactor,
                bulletDiameterMm     = a.bulletDiameterMm,
                bulletLengthMm       = a.bulletLengthMm,
                bulletWeightGr       = a.bulletWeightG / 0.06479891,
                mvSensitivityFpsPerF = a.mvSensitivityMpsPerC / 0.54864,
                mvReferenceTempF     = a.mvReferenceTempC * 9.0 / 5.0 + 32.0,
                isUserDefined        = false,
            )
        }
        val outputYards = session.outputDistancesM.map { (it / 0.9144).roundToInt() }
        val inferredPreset = DistancePreset.entries
            .filter { it != DistancePreset.CUSTOM }
            .firstOrNull { p -> p.distances == outputYards || p.metricDistancesYards == outputYards }
            ?: DistancePreset.CUSTOM
        return SessionState(
            sessionName      = if (session.name.isBlank()) "Unsaved" else session.name,
            sessionUuid      = session.sessionUuid,
            conditionsSource = session.conditions?.conditionsSource ?: "manual",
            ammo             = ammo,
            rifle            = RifleSetup(
                name           = session.rifle?.name ?: "",
                brand          = session.rifle?.brand ?: "",
                model          = session.rifle?.model ?: "",
                zeroYards      = session.rifle?.zeroM?.div(0.9144)?.roundToInt() ?: 100,
                sightHeightIn  = session.rifle?.sightHeightMm?.div(25.4) ?: 1.5,
                twistRateIn    = session.rifle?.twistRateMm?.div(25.4) ?: 0.0,
                twistDirection = runCatching {
                    TwistDirection.valueOf(session.rifle?.twistDirection?.uppercase() ?: "RH")
                }.getOrDefault(TwistDirection.RH),
                zeroTempF       = session.rifle?.zeroTemperatureC?.let { it * 9.0 / 5.0 + 32.0 },
                zeroAltitudeFt  = session.rifle?.zeroAltitudeM?.let { it / 0.3048 },
                zeroPressureHpa = session.rifle?.zeroPressureHpa,
            ),
            scope           = ScopeInfo(
                name  = session.scope?.name  ?: "",
                brand = session.scope?.brand ?: "",
                model = session.scope?.model ?: "",
            ),
            dopeEntries     = session.dopeEntries.map { d ->
                DopeEntry(
                    distanceYards = (d.distanceM / 0.9144).roundToInt(),
                    adjustmentMil = d.elevationMil,
                    recordedAt    = d.recordedAt,
                    conditionsAtMeasurement = d.conditionsAtMeasurement?.let { c ->
                        ConditionsAtMeasurement(
                            altitudeFt   = c.altitudeM?.let { (it / 0.3048).roundToInt() },
                            temperatureF = c.temperatureC?.let { it * 9.0 / 5.0 + 32.0 },
                            pressureHpa  = c.pressureHpa,
                            humidityPct  = c.humidityPct,
                            windSpeedMph = c.windSpeedMps?.let { it / 0.44704 },
                            windAngleDeg = c.windAngleDeg,
                        )
                    },
                )
            },
            conditions      = if (cond != null) RangeConditions(
                altitudeFt   = (cond.altitudeM / 0.3048).roundToInt(),
                temperatureF = cond.temperatureC * 9.0 / 5.0 + 32.0,
                pressureHpa  = cond.pressureHpa,
                humidityPct  = cond.humidityPct,
            ) else RangeConditions(),
            wind            = if (cond != null) WindConditions(
                speedMph = cond.windSpeedMps / 0.44704,
                angleDeg = cond.windAngleDeg,
            ) else WindConditions(),
            labelFormat      = LabelFormats.all.find { it.id == session.labelFormatId } ?: LabelFormats.CIRCLE_50MM,
            outputDistances  = outputYards,
            distancePreset   = inferredPreset,
            measureSystem    = measureSystem,
            conditionUnits   = ConditionUnits.forSystem(measureSystem),
            adjUnit               = adjUnit,
            clickValue            = clickValue,
            firingAzimuthDeg      = cond?.firingAzimuthDeg      ?: 0.0,
            firingAngleDeg        = cond?.firingAngleDeg        ?: 0.0,
            useCoriolis           = cond?.useCoriolis           ?: false,
            coriolisLatitudeDeg   = cond?.coriolisLatitudeDeg   ?: 0.0,
            pendingLabelDistances = session.labelSelectedDistancesM
                .map { (it / 0.9144).roundToInt() }
                .toSet()
                .takeIf { it.isNotEmpty() },
        )
    }
}
