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

import com.coldboreballisticsllc.scopedope.ballistics.BcModel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

/**
 * Reference values generated from the current BallisticsEngine algorithm:
 * RK4 spatial integrator, Fritsch-Carlson cubic-spline Cd, KD drag constant
 * (KD = ρ₀ / (2 × 144) = 0.0764/288). Values re-validated after the v0.9.0
 * engine upgrade from the original Euler / step-function-Cd / 0.5·Cd·v²/(BC·c²)
 * integrator. All assertions use tolerance = 0.001 mil unless otherwise noted.
 */
class BallisticsEngineTest {

    private val tol = 0.001  // mil tolerance (spec: < 0.01 mil)

    // ── Standard test params: .308 Win 175gr SMK, G7 ─────────────────────────

    private val std308 = BallisticsParams(
        muzzleVelocityFps = 2650.0,
        bc                = 0.505,
        bcModel           = BcModel.G7,
        zeroDistanceYards = 100.0,
        sightHeightIn     = 1.5,
        altitudeFt        = 0.0,
        tempF             = 59.0,
        windSpeedFps      = 0.0,
        windAngleDeg      = 0.0,
    )

    // ── Air density ratio ─────────────────────────────────────────────────────

    @Test fun `airDensityRatio at sea level standard temp is 1`() {
        assertEquals(1.0, BallisticsEngine.airDensityRatio(0.0, 59.0), 0.000001)
    }

    @Test fun `airDensityRatio at 5000ft 80F matches Python reference`() {
        assertEquals(0.79966455, BallisticsEngine.airDensityRatio(5000.0, 80.0), 0.000001)
    }

    // ── Wind components ───────────────────────────────────────────────────────

    @Test fun `windComponents 90deg is pure crosswind`() {
        val (hw, cw) = BallisticsEngine.windComponents(20.0, 90.0)
        assertEquals(0.0,  hw, 0.0001)
        assertEquals(20.0, cw, 0.0001)
    }

    @Test fun `windComponents 0deg is pure headwind`() {
        val (hw, cw) = BallisticsEngine.windComponents(20.0, 0.0)
        assertEquals(20.0, hw, 0.0001)
        assertEquals(0.0,  cw, 0.0001)
    }

    @Test fun `windComponents 180deg is pure tailwind`() {
        val (hw, cw) = BallisticsEngine.windComponents(20.0, 180.0)
        assertEquals(-20.0, hw, 0.0001)
        assertEquals(0.0,   cw, 0.001)
    }

    // ── Trajectory — no wind ──────────────────────────────────────────────────

    @Test fun `elevation at zero distance is 0`() {
        val r = BallisticsEngine.calculateTrajectory(std308, listOf(100.0))
        assertEquals(0.0, r[100.0]!!.elevationMil, tol)
    }

    @Test fun `windage with no wind is 0 at all distances`() {
        val r = BallisticsEngine.calculateTrajectory(
            std308, listOf(100.0, 200.0, 300.0, 400.0, 500.0)
        )
        r.values.forEach { assertEquals(0.0, it.windageMil, tol) }
    }

    @Test fun `G7 trajectory elevation increases monotonically with distance`() {
        val r = BallisticsEngine.calculateTrajectory(
            std308, listOf(100.0, 200.0, 300.0, 400.0, 500.0)
        )
        assertEquals(0.0, r[100.0]!!.elevationMil, tol)
        val e200 = r[200.0]!!.elevationMil
        val e300 = r[300.0]!!.elevationMil
        val e400 = r[400.0]!!.elevationMil
        val e500 = r[500.0]!!.elevationMil
        assertTrue("elevation must increase with distance", e200 < e300 && e300 < e400 && e400 < e500)
        assertTrue("500 yd elevation must be > 2.0 mil for this load", e500 > 2.0)
        assertTrue("200 yd elevation must be > 0.1 mil for this load", e200 > 0.1)
    }

    // ── Trajectory — with crosswind ───────────────────────────────────────────

    @Test fun `windage with 10mph crosswind is positive and increases with distance`() {
        // 10 mph = 14.6667 fps; 90 deg = pure R→L crosswind → positive windage
        val params = std308.copy(
            windSpeedFps = BallisticsEngine.mphToFps(10.0),
            windAngleDeg = 90.0,
        )
        val r = BallisticsEngine.calculateTrajectory(params, listOf(200.0, 300.0, 500.0))
        val w200 = r[200.0]!!.windageMil
        val w300 = r[300.0]!!.windageMil
        val w500 = r[500.0]!!.windageMil
        assertTrue("windage must be positive for R→L crosswind", w200 > 0.0 && w300 > 0.0 && w500 > 0.0)
        assertTrue("windage must increase with distance", w200 < w300 && w300 < w500)
    }

    @Test fun `crosswind does not affect elevation`() {
        val noWind  = BallisticsEngine.calculateTrajectory(std308, listOf(200.0, 300.0, 500.0))
        val params  = std308.copy(windSpeedFps = BallisticsEngine.mphToFps(10.0), windAngleDeg = 90.0)
        val withWind = BallisticsEngine.calculateTrajectory(params, listOf(200.0, 300.0, 500.0))
        // Pure crosswind (hw=0) must not change elevation
        assertEquals(noWind[200.0]!!.elevationMil, withWind[200.0]!!.elevationMil, tol)
        assertEquals(noWind[300.0]!!.elevationMil, withWind[300.0]!!.elevationMil, tol)
        assertEquals(noWind[500.0]!!.elevationMil, withWind[500.0]!!.elevationMil, tol)
    }

    // ── G1 drag model ─────────────────────────────────────────────────────────

    @Test fun `G1 trajectory matches Python reference values`() {
        val params = BallisticsParams(
            muzzleVelocityFps = 2700.0,
            bc                = 0.47,
            bcModel           = BcModel.G1,
            zeroDistanceYards = 100.0,
            sightHeightIn     = 1.5,
            altitudeFt        = 0.0,
            tempF             = 59.0,
        )
        val r = BallisticsEngine.calculateTrajectory(params, listOf(200.0, 300.0, 500.0))

        // RK4+KD reference: G1 2700fps BC 0.47
        assertEquals(0.543660, r[200.0]!!.elevationMil, tol)
        assertEquals(1.297258, r[300.0]!!.elevationMil, tol)
        assertEquals(3.150543, r[500.0]!!.elevationMil, tol)
    }

    // ── DOPE interpolation ────────────────────────────────────────────────────

    private val testDope = listOf(
        DopeEntryInput(100.0,  0.0),
        DopeEntryInput(200.0,  2.4),
        DopeEntryInput(300.0,  5.8),
        DopeEntryInput(500.0, 14.2),
    )

    @Test fun `interpolateDope at exact entry distance returns entry value`() {
        val r200 = BallisticsEngine.interpolateDope(testDope, 200.0, std308)
        val r300 = BallisticsEngine.interpolateDope(testDope, 300.0, std308)
        val r500 = BallisticsEngine.interpolateDope(testDope, 500.0, std308)

        // Python reference values
        assertEquals(2.4,  r200.elevationMil, 0.01)
        assertEquals(5.8,  r300.elevationMil, 0.01)
        assertEquals(14.2, r500.elevationMil, 0.01)
    }

    @Test fun `interpolateDope between entries uses monotone cubic spline`() {
        // RK4+KD reference (cubic-spline bias interpolation): 250yd → 3.993, 400yd → 9.888
        val r250 = BallisticsEngine.interpolateDope(testDope, 250.0, std308)
        val r400 = BallisticsEngine.interpolateDope(testDope, 400.0, std308)

        assertEquals(3.993, r250.elevationMil, 0.01)
        assertEquals(9.888, r400.elevationMil, 0.01)
    }

    @Test fun `interpolateDope with no entries returns pure physics`() {
        val rPhysics = BallisticsEngine.calculateTrajectory(std308, listOf(300.0))[300.0]!!
        val rInterp  = BallisticsEngine.interpolateDope(emptyList(), 300.0, std308)
        assertEquals(rPhysics.elevationMil, rInterp.elevationMil, tol)
    }

    @Test fun `interpolateDope windage is not bias-corrected`() {
        val params = std308.copy(
            windSpeedFps = BallisticsEngine.mphToFps(10.0),
            windAngleDeg = 90.0,
        )
        val rPhysics = BallisticsEngine.calculateTrajectory(params, listOf(300.0))[300.0]!!
        val rInterp  = BallisticsEngine.interpolateDope(testDope, 300.0, params)
        assertEquals(rPhysics.windageMil, rInterp.windageMil, tol)
    }

    // ── Coriolis sign convention ──────────────────────────────────────────────
    // Northern hemisphere: bullet always drifts right (positive windage).
    // Southern hemisphere: bullet always drifts left (negative windage).
    // Elevation sign: firing east (az=90°) in NH → dial down (negative elev correction).

    @Test fun `coriolisMils northern hemisphere windage is positive`() {
        val (_, windMil) = BallisticsEngine.coriolisMils(
            latitudeDeg = 45.0, azimuthDeg = 0.0,
            distYards = 1000.0, tofS = 1.5,
            muzzleVelocityFps = 2650.0, terminalVelocityFps = 1800.0,
        )
        assert(windMil > 0.0) { "NH Coriolis windage must be positive (right drift); got $windMil" }
    }

    @Test fun `coriolisMils southern hemisphere windage is negative`() {
        val (_, windMil) = BallisticsEngine.coriolisMils(
            latitudeDeg = -45.0, azimuthDeg = 0.0,
            distYards = 1000.0, tofS = 1.5,
            muzzleVelocityFps = 2650.0, terminalVelocityFps = 1800.0,
        )
        assert(windMil < 0.0) { "SH Coriolis windage must be negative (left drift); got $windMil" }
    }

    @Test fun `coriolisMils firing east in NH elevation is negative`() {
        val (elevMil, _) = BallisticsEngine.coriolisMils(
            latitudeDeg = 45.0, azimuthDeg = 90.0,
            distYards = 1000.0, tofS = 1.5,
            muzzleVelocityFps = 2650.0, terminalVelocityFps = 1800.0,
        )
        assert(elevMil < 0.0) { "NH firing east Coriolis elevation must be negative (dial down); got $elevMil" }
    }

    @Test fun `coriolisMils at equator windage is zero`() {
        val (_, windMil) = BallisticsEngine.coriolisMils(
            latitudeDeg = 0.0, azimuthDeg = 0.0,
            distYards = 1000.0, tofS = 1.5,
            muzzleVelocityFps = 2650.0, terminalVelocityFps = 1800.0,
        )
        assertEquals(0.0, windMil, 1e-9)
    }

    // ── Unit conversions ──────────────────────────────────────────────────────

    @Test fun `milToMoa conversion is correct`() {
        assertEquals(3.43775, BallisticsEngine.milToMoa(1.0), 0.0001)
    }

    @Test fun `mphToFps conversion is correct`() {
        assertEquals(1.46667, BallisticsEngine.mphToFps(1.0), 0.0001)
    }
}
