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

import kotlinx.serialization.json.Json
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

/**
 * Ballistics engine validation suite.
 *
 * Runs exhaustive C(N,k) cross-validation for k=1..N-3 against all test cases
 * in `shared/ballistics-test-data/test_cases/`. Asserts that the primary verdict
 * (k=1 interior LOO interpolation) is not FAIL.
 *
 * Run with:  ./gradlew :shared:ballistics:jvmTest   (from scopedope-android)
 *       or:  ./gradlew :ballistics:jvmTest          (from the standalone scopedope-shared)
 * Report at: ballistics/reports/ballistics-validation-<timestamp>.md
 *
 * Threshold basis (researched 2026-05-29):
 *   - JBM vs Applied Ballistics solver agreement: ~0.003 mil at 1000yd
 *   - Standard scope click: 0.1 MRAD; fine ELR: 0.05 MRAD
 *   - Top F-class vertical scatter at 1000yd: ~0.097 mil (1/3 MOA)
 *   - Sub-3 0.025 mil: between solver floor and hardware floor
 *   - Tier 3 0.05 mil: one fine-click; real-world conditions limit tighter validation
 */
class BallisticsValidationTest {

    private val json = Json { ignoreUnknownKeys = true }

    @Test
    fun validateBallisticsEngine() {
        val testDataDir = requireSystemProp("ballistics.testdata.dir")
        val reportDir   = requireSystemProp("ballistics.report.dir")

        val jsonFiles = File(testDataDir)
            .listFiles { f -> f.extension == "json" }
            ?.sorted()
            ?: emptyList()

        assertTrue(
            "No test cases found in $testDataDir — ensure shared/ballistics-test-data is populated",
            jsonFiles.isNotEmpty(),
        )

        println("\nScopeDOPE Ballistics Engine Validation — Exhaustive C(N,k)")
        println("=".repeat(65))
        println("Loaded ${jsonFiles.size} test cases from $testDataDir\n")

        val runner   = ExhaustiveCVRunner()
        val results  = mutableListOf<CaseResult>()
        val skipped  = mutableListOf<Pair<String, String>>()
        var firedVal = FiredValidationResult("17hmr_hornady_vm", emptyList())
        var altSens  = AltSensResult("N/A", "N/A", emptyList())

        for (file in jsonFiles) {
            val tc     = json.decodeFromString<TestCase>(file.readText())
            print("  ${tc.id.padEnd(30)}")

            val cr = runner.runCase(tc)
            results += cr

            if (cr.skipped) {
                skipped += tc.id to cr.skipReason
                println("[SKIPPED: ${cr.skipReason}]")
                continue
            }
            if (cr.looSkipped) {
                println("[LOO_SKIP: ${cr.looSkipWhy}]")
                continue
            }

            val nPts   = cr.distStats.size
            val totalC = (1..maxOf(1, nPts - 3)).sumOf { k -> comb(nPts, k) }
            print("[$totalC combos] ")

            if (tc.id == "17hmr_hornady_vm") {
                firedVal = runner.runFiredValidation(tc)
                print("+ fired_val ")
            }
            if (tc.id == "308_fed175_smk") {
                altSens = runner.runAltSensitivity(tc)
                print("+ alt_sens ")
            }

            // Quick per-case verdict — use explicit severity order, not string length
            val worstK1  = cr.distStats.map { it.verdictK1 }.maxByOrNull { severityOf(it) } ?: "PASS"
            val worstAll = cr.distStats.map { it.verdictAll }.maxByOrNull { severityOf(it) } ?: "PASS"
            println("[k1:$worstK1 all:$worstAll]")
        }

        val primary = runner.primaryVerdict(results, firedVal)
        val stress  = stressVerdict(results, firedVal)

        println()
        println("Generating validation report...")

        val stamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"))
        val reportFile = File(reportDir, "ballistics-validation-$stamp.md")

        ReportWriter(reportFile).write(
            results  = results,
            firedVal = firedVal,
            altSens  = altSens,
            skipped  = skipped,
            primary  = primary,
            stress   = stress,
        )

        println("Report: $reportFile")
        println()
        println("Primary verdict (k=1 interior LOO): $primary")
        println("Stress-test verdict (all k):        $stress")

        // Global stats
        val k1Means = results.filter { !it.skipped && !it.looSkipped }
            .flatMap { cr -> cr.distStats.mapNotNull { ds -> ds.byK[1]?.meanAbs } }
        val k1Maxes = results.filter { !it.skipped && !it.looSkipped }
            .flatMap { cr -> cr.distStats.mapNotNull { ds -> ds.byK[1]?.maxAbs } }
        if (k1Means.isNotEmpty()) {
            println("k=1 mean |Δ| (global):              %.4f mil".format(k1Means.average()))
            println("k=1 max  |Δ| (global):              %.4f mil".format(k1Maxes.max()))
        }

        // Gate the build on the primary verdict. Real-world cases are scored against their
        // own wider field band (per-case `realworld` flag), so they participate in the gate
        // at field-realistic tolerances alongside the calibrated reference cases.
        assertNotEquals(
            "Primary verdict (k=1 interior LOO) is FAIL. See $reportFile for details.",
            "FAIL",
            primary,
        )
    }

    // ── Helpers ───────────────────────────────────────────────────────────────

    private fun requireSystemProp(key: String): String =
        System.getProperty(key)
            ?: error(
                "System property '$key' not set. Run via the ballistics jvmTest Gradle task " +
                "(:shared:ballistics:jvmTest from scopedope-android, :ballistics:jvmTest from scopedope-shared)."
            )

    private fun stressVerdict(results: List<CaseResult>, fired: FiredValidationResult): String {
        var worst = "PASS"
        fun bump(v: String) {
            if (v == "FAIL") worst = "FAIL"
            else if (v == "INVESTIGATE" && worst != "FAIL") worst = "INVESTIGATE"
        }
        for (cr in results) { if (cr.skipped || cr.looSkipped) continue; cr.distStats.forEach { bump(it.verdictAll) } }
        for (fp in fired.firedPts) bump(classify(fp.deltaDope, "3", "TRANSONIC_ZONE" in fp.flags, false))
        return worst
    }
}

/** FAIL > INVESTIGATE > OK > PASS > N/A — N/A is not a verdict, never beats a real result. */
private fun severityOf(v: String) = when (v) {
    "FAIL"        -> 4
    "INVESTIGATE" -> 3
    "OK"          -> 2
    "PASS"        -> 1
    else          -> 0  // N/A, blank, unknown
}

private fun comb(n: Int, k: Int): Int {
    if (k < 0 || k > n) return 0
    if (k == 0 || k == n) return 1
    var r = 1
    for (i in 0 until minOf(k, n - k)) r = r * (n - i) / (i + 1)
    return r
}
