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

import com.coldboreballisticsllc.scopedope.ballistics.BcModel
import com.coldboreballisticsllc.scopedope.ballistics.TwistDirection
import com.coldboreballisticsllc.scopedope.domain.model.*
import kotlin.math.roundToInt
import org.junit.Assert.*
import org.junit.Test

/**
 * Tests for [CalculateDopeUseCase]: MV sensitivity, click values, spin drift,
 * Coriolis passthrough, DOPE-less pure-physics path, and last-call cache.
 *
 * Reference elevation values match BallisticsEngineTest (same RK4+KD algorithm);
 * conditions use pressureHpa=0 + humidityPct=0 → sea-level density ratio = 1.0.
 */
class CalculateDopeUseCaseTest {

    private val useCase = CalculateDopeUseCase()

    // ── Shared fixtures ───────────────────────────────────────────────────────

    // .308 Win 175gr SMK, G7 BC 0.505 — matches BallisticsEngineTest std308
    private val stdAmmo = AmmoLoad(
        id                   = 1,
        caliber              = "308 Win",
        name                 = "175gr SMK",
        muzzleVelFps         = 2650,
        bc                   = 0.505,
        bcModel              = BcModel.G7,
        mvSensitivityFpsPerF = 0.0,
        mvReferenceTempF     = 59.0,
    )

    // Sea-level standard conditions; pressureHpa=0 → ICAO derivation → density ratio=1.0
    private val stdConditions = RangeConditions(
        altitudeFt   = 0,
        temperatureF = 59.0,
        pressureHpa  = 0.0,
        humidityPct  = 0.0,
    )

    private val noWind = WindConditions(speedMph = 0.0, angleDeg = 0.0)

    private val stdInput = CalculationInput(
        ammo              = stdAmmo,
        zeroDistanceYards = 100.0,
        sightHeightIn     = 1.5,
        dopeEntries       = emptyList(),
        conditions        = stdConditions,
        wind              = noWind,
        outputDistances   = listOf(200, 300, 500),
        clickValue        = "0.1_mrad",
    )

    // ── Pure-physics (no DOPE) ────────────────────────────────────────────────

    @Test fun `empty dopeEntries returns pure physics elevation`() {
        val output = useCase(stdInput)
        val elev500 = output.rows.first { it.distanceYards == 500 }.elevationMil
        assertTrue("500 yd elevation must be positive for this load", elev500 > 0.0)
        assertTrue("500 yd elevation must be < 10 mil for this load", elev500 < 10.0)
    }

    @Test fun `zero distance returns zero elevation`() {
        val output = useCase(stdInput.copy(outputDistances = listOf(100)))
        assertEquals(0.0, output.rows.first { it.distanceYards == 100 }.elevationMil, 0.001)
    }

    // ── Click value conversions ───────────────────────────────────────────────
    // Derive elevation from the engine at runtime to stay independent of engine version.

    private fun elevMilAt500(): Double =
        useCase(stdInput.copy(clickValue = "0.1_mrad"))
            .rows.first { it.distanceYards == 500 }.elevationMil

    @Test fun `toClicks 0_1_mrad default is correct`() {
        val elev = elevMilAt500()
        val clicks = useCase(stdInput.copy(clickValue = "0.1_mrad"))
            .rows.first { it.distanceYards == 500 }.clicks
        assertEquals((elev / 0.1).roundToInt().toDouble(), clicks.toDouble(), 1.0)
    }

    @Test fun `toClicks 0_05_mrad is correct`() {
        val elev = elevMilAt500()
        val clicks = useCase(stdInput.copy(clickValue = "0.05_mrad"))
            .rows.first { it.distanceYards == 500 }.clicks
        assertEquals((elev / 0.05).roundToInt().toDouble(), clicks.toDouble(), 1.0)
    }

    @Test fun `toClicks 0_25_moa is correct`() {
        val elev = elevMilAt500()
        val elevMoa = elev * 3.43775
        val clicks = useCase(stdInput.copy(clickValue = "0.25_moa"))
            .rows.first { it.distanceYards == 500 }.clicks
        assertEquals((elevMoa / 0.25).roundToInt().toDouble(), clicks.toDouble(), 1.0)
    }

    @Test fun `toClicks 0_5_moa is correct`() {
        val elev = elevMilAt500()
        val elevMoa = elev * 3.43775
        val clicks = useCase(stdInput.copy(clickValue = "0.5_moa"))
            .rows.first { it.distanceYards == 500 }.clicks
        assertEquals((elevMoa / 0.5).roundToInt().toDouble(), clicks.toDouble(), 1.0)
    }

    @Test fun `toClicks 1_moa is correct`() {
        val elev = elevMilAt500()
        val elevMoa = elev * 3.43775
        val clicks = useCase(stdInput.copy(clickValue = "1_moa"))
            .rows.first { it.distanceYards == 500 }.clicks
        assertEquals((elevMoa / 1.0).roundToInt().toDouble(), clicks.toDouble(), 1.0)
    }

    // ── MV temperature sensitivity ────────────────────────────────────────────

    @Test fun `positive sensitivity increases MV at warmer temperature`() {
        val sensitiveAmmo = stdAmmo.copy(
            mvSensitivityFpsPerF = 1.5,
            mvReferenceTempF     = 59.0,
        )
        // Reference temp: standard 59°F → range MV = 2650 fps
        val coldOutput = useCase(stdInput.copy(ammo = sensitiveAmmo))
        // Warm range: 79°F → +20°F × 1.5 fps/°F = +30 fps → MV 2680 fps
        val warmOutput = useCase(stdInput.copy(
            ammo       = sensitiveAmmo,
            conditions = stdConditions.copy(temperatureF = 79.0),
        ))
        // Higher MV → less bullet drop → lower elevation reading at 500 yd
        assertTrue(
            warmOutput.rows.first { it.distanceYards == 500 }.elevationMil <
            coldOutput.rows.first { it.distanceYards == 500 }.elevationMil
        )
    }

    // ── Spin drift passthrough ────────────────────────────────────────────────

    @Test fun `RH twist produces positive spin drift`() {
        val sdMil = useCase(stdInput.copy(
            twistRateIn     = 10.0,
            twistDirection  = TwistDirection.RH,
            outputDistances = listOf(500),
        )).rows.first().spinDriftMil
        assertTrue("RH spin drift must be positive; got $sdMil", sdMil > 0.0)
    }

    @Test fun `LH twist produces negative spin drift`() {
        val sdMil = useCase(stdInput.copy(
            twistRateIn     = 10.0,
            twistDirection  = TwistDirection.LH,
            outputDistances = listOf(500),
        )).rows.first().spinDriftMil
        assertTrue("LH spin drift must be negative; got $sdMil", sdMil < 0.0)
    }

    @Test fun `zero twist rate produces zero spin drift`() {
        val sdMil = useCase(stdInput.copy(
            twistRateIn     = 0.0,
            outputDistances = listOf(500),
        )).rows.first().spinDriftMil
        assertEquals(0.0, sdMil, 1e-9)
    }

    // ── Coriolis passthrough ──────────────────────────────────────────────────

    @Test fun `NH Coriolis windage is positive`() {
        val cwMil = useCase(stdInput.copy(
            useCoriolis         = true,
            coriolisLatitudeDeg = 45.0,
            firingAzimuthDeg    = 0.0,
            outputDistances     = listOf(1000),
        )).rows.first().coriolisWindMil
        assertTrue("NH Coriolis windage must be positive; got $cwMil", cwMil > 0.0)
    }

    @Test fun `disabled Coriolis produces zero corrections`() {
        val row = useCase(stdInput.copy(
            useCoriolis     = false,
            outputDistances = listOf(1000),
        )).rows.first()
        assertEquals(0.0, row.coriolisWindMil, 1e-9)
        assertEquals(0.0, row.coriolisElevMil, 1e-9)
    }

    @Test fun `firing east in NH produces negative elevation Coriolis`() {
        val ceMil = useCase(stdInput.copy(
            useCoriolis         = true,
            coriolisLatitudeDeg = 45.0,
            firingAzimuthDeg    = 90.0,
            outputDistances     = listOf(1000),
        )).rows.first().coriolisElevMil
        assertTrue("NH east Coriolis elevation must be negative (dial down); got $ceMil", ceMil < 0.0)
    }

    // ── Last-call cache ───────────────────────────────────────────────────────

    @Test fun `repeated identical input returns same elevation values`() {
        val output1 = useCase(stdInput)
        val output2 = useCase(stdInput)
        output1.rows.zip(output2.rows).forEach { (a, b) ->
            assertEquals(a.elevationMil, b.elevationMil, 0.0)
            assertEquals(a.clicks,       b.clicks)
        }
    }

    @Test fun `different input after cached input recomputes correctly`() {
        val output1 = useCase(stdInput)
        // Change conditions to bust the cache
        val output2 = useCase(stdInput.copy(
            conditions = stdConditions.copy(altitudeFt = 5000),
        ))
        // 5000 ft altitude → lower air density → lower drag → lower elevation
        assertTrue(
            output2.rows.first { it.distanceYards == 500 }.elevationMil <
            output1.rows.first { it.distanceYards == 500 }.elevationMil
        )
    }

    // ── DOPE-Blend correction path ────────────────────────────────────────────

    @Test fun `DOPE entry at exact distance returns adjustmentMil as elevation`() {
        val entry = DopeEntry(distanceYards = 500, adjustmentMil = 3.0)
        val elevation = useCase(stdInput.copy(dopeEntries = listOf(entry)))
            .rows.first { it.distanceYards == 500 }.elevationMil
        assertEquals(3.0, elevation, 0.001)
    }

    @Test fun `DOPE entries with uniform positive bias shift interior elevation upward`() {
        // Derive actual physics values so the bias is independent of the engine's exact numbers.
        val uc = CalculateDopeUseCase()
        val physAt200 = uc(stdInput.copy(outputDistances = listOf(200))).rows.first().elevationMil
        val physAt300 = uc(stdInput.copy(outputDistances = listOf(300))).rows.first().elevationMil
        val physAt500 = uc(stdInput.copy(outputDistances = listOf(500))).rows.first().elevationMil
        val entries = listOf(
            DopeEntry(distanceYards = 200, adjustmentMil = physAt200 + 0.20),
            DopeEntry(distanceYards = 500, adjustmentMil = physAt500 + 0.20),
        )
        val corrected = useCase(stdInput.copy(dopeEntries = entries))
            .rows.first { it.distanceYards == 300 }.elevationMil
        assertTrue(
            "DOPE-corrected elevation at 300 yd should exceed physics by ~0.20; got corrected=$corrected physics=$physAt300",
            corrected > physAt300 + 0.15,
        )
    }

    @Test fun `DOPE entries with uniform negative bias shift interior elevation downward`() {
        val uc = CalculateDopeUseCase()
        val physAt200 = uc(stdInput.copy(outputDistances = listOf(200))).rows.first().elevationMil
        val physAt300 = uc(stdInput.copy(outputDistances = listOf(300))).rows.first().elevationMil
        val physAt500 = uc(stdInput.copy(outputDistances = listOf(500))).rows.first().elevationMil
        val entries = listOf(
            DopeEntry(distanceYards = 200, adjustmentMil = physAt200 - 0.20),
            DopeEntry(distanceYards = 500, adjustmentMil = physAt500 - 0.20),
        )
        val corrected = useCase(stdInput.copy(dopeEntries = entries))
            .rows.first { it.distanceYards == 300 }.elevationMil
        assertTrue(
            "DOPE-corrected elevation at 300 yd should be below physics by ~0.20; got corrected=$corrected physics=$physAt300",
            corrected < physAt300 - 0.15,
        )
    }

    @Test fun `wind change does not affect DOPE-corrected elevation at entry distance`() {
        val entry = DopeEntry(distanceYards = 500, adjustmentMil = 3.0)
        val calmElev = useCase(stdInput.copy(
            dopeEntries = listOf(entry), wind = noWind,
        )).rows.first { it.distanceYards == 500 }.elevationMil
        val windyOutput = useCase(stdInput.copy(
            dopeEntries = listOf(entry),
            wind = WindConditions(speedMph = 10.0, angleDeg = 90.0),
        ))
        assertEquals(calmElev, windyOutput.rows.first { it.distanceYards == 500 }.elevationMil, 0.001)
        // Windage must differ — confirms wind is not collapsed
        val calmWind  = useCase(stdInput.copy(dopeEntries = listOf(entry), wind = noWind)).rows.first { it.distanceYards == 500 }.windageMil
        assertTrue(kotlin.math.abs(windyOutput.rows.first { it.distanceYards == 500 }.windageMil - calmWind) > 0.05)
    }

    @Test fun `ammo BC change recomputes bias for interior DOPE interpolation`() {
        val uc = CalculateDopeUseCase()
        // Pin two entry adjustments to physics values of stdAmmo → zero bias
        val physAt200 = uc(stdInput.copy(outputDistances = listOf(200))).rows.first().elevationMil
        val physAt500 = uc(stdInput.copy(outputDistances = listOf(500))).rows.first().elevationMil
        val entries = listOf(
            DopeEntry(distanceYards = 200, adjustmentMil = physAt200),
            DopeEntry(distanceYards = 500, adjustmentMil = physAt500),
        )
        val corrAt300WithStdAmmo = useCase(stdInput.copy(dopeEntries = entries))
            .rows.first { it.distanceYards == 300 }.elevationMil

        // Lower BC → more drop → higher physics elevation → bias becomes negative for same entries
        val lowerBcAmmo = stdAmmo.copy(bc = 0.35)
        val corrAt300WithLowBc = useCase(stdInput.copy(
            ammo        = lowerBcAmmo,
            dopeEntries = entries,
        )).rows.first { it.distanceYards == 300 }.elevationMil

        assertTrue(
            "Bias must recompute when ammo BC changes; elevations should differ; " +
            "stdAmmo=$corrAt300WithStdAmmo lowBc=$corrAt300WithLowBc",
            corrAt300WithStdAmmo != corrAt300WithLowBc,
        )
    }
}
