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

import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.abs
import kotlin.test.Test
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

/**
 * JBM cross-reference harness (Kotlin/JVM).
 *
 * Purpose (see scopedope-shared/docs-working/REQUIREMENTS.md, 2026-05-31 session):
 *   1. Provide an INDEPENDENT reference (jbmballistics.com) for engine output, to
 *      replace the engine-regenerated DOPE in the Sub-3 cases whose Δ Physics ≈ 0
 *      is otherwise tautological.
 *   2. Settle whether the fired long-range under-prediction in the validation
 *      report is an engine defect or a BC/MV/atmosphere-data effect. The engine
 *      and JBM are both McCoy point-mass G1/G7 solvers, so at identical inputs they
 *      should agree to ~0.003 mil. If engine ≈ JBM but BOTH miss the fired data,
 *      the cause is the load/atmosphere data, not the engine. If JBM matches fired
 *      and the engine does not, that is a real engine bug.
 *
 * JBM has no public API, so reference trajectories are entered manually from the
 * web calculator into sidecar JSON files under `ballistics-test-data/jbm-refs/`
 * (one per case, schema below; see that dir's README.md for the workflow).
 *
 * This test never fails the build — it is a reporting harness like
 * [BallisticsValidationTest]. Divergences are flagged in the Markdown output and
 * echoed to the console so they are noticed.
 *
 * Outputs (under `ballistics-test-data/jbm-refs/generated/`):
 *   - `jbm-input-sheets-<stamp>.md`  — exact JBM web inputs to enter, per case.
 *   - `jbm-crossref-<stamp>.md`      — engine-vs-JBM(-vs-fired) comparison for every
 *                                       sidecar that has at least one filled point.
 */
class JbmCrossReferenceTest {

    // Both solvers are McCoy point-mass; identical inputs should agree to ~0.003 mil.
    private val agreementThresholdMil = 0.01

    private val json = Json { ignoreUnknownKeys = true; isLenient = true }

    // ── Sidecar schema ─────────────────────────────────────────────────────────

    @Serializable
    data class JbmInputs(
        @SerialName("drag_model")     val dragModel:     String,
        val bc:                                          Double,
        @SerialName("mv_fps")         val mvFps:         Double,
        @SerialName("zero_yards")     val zeroYards:     Double,
        @SerialName("sight_height_in") val sightHeightIn: Double = 1.5,
        @SerialName("altitude_ft")    val altitudeFt:    Double = 0.0,
        @SerialName("temp_f")         val tempF:         Double = 59.0,
        @SerialName("pressure_hpa")   val pressureHpa:   Double = 0.0,
        @SerialName("humidity_pct")   val humidityPct:   Double = 0.0,
        val notes:                                       String = "",
    )

    @Serializable
    data class JbmTrajPoint(
        val yards:                                       Double,
        // null = not yet entered from JBM; such points are skipped in the comparison.
        @SerialName("elevation_mil")  val elevationMil:  Double? = null,
    )

    @Serializable
    data class JbmReference(
        @SerialName("case_id")        val caseId:        String,
        val calculator:                                  String = "https://www.jbmballistics.com/",
        @SerialName("captured_date")  val capturedDate:  String = "",
        val inputs:                                      JbmInputs,
        val trajectory:                                  List<JbmTrajPoint> = emptyList(),
    )

    // ── Entry point ────────────────────────────────────────────────────────────

    @Test
    fun runJbmCrossReference() {
        val dataRoot = resolveDataRoot()
        val cases = loadCases(dataRoot)
        val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
        val human = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())

        val outDir = File(dataRoot, "jbm-refs/generated").apply { mkdirs() }

        // 1. Always (re)generate the per-case JBM input sheets.
        File(outDir, "jbm-input-sheets-$stamp.md").writeText(buildInputSheets(cases, human))

        // 2. Compare against any sidecars that have at least one filled point.
        val sidecars = loadSidecars(dataRoot)
        val report = buildCrossReference(cases.associateBy { it.id }, sidecars, human)
        File(outDir, "jbm-crossref-$stamp.md").writeText(report.markdown)

        println("JBM cross-reference: ${cases.size} cases, ${sidecars.size} sidecar(s), " +
                "${report.comparedPoints} filled point(s) compared, " +
                "${report.divergences} divergence(s) > $agreementThresholdMil mil.")
        if (report.divergences > 0) {
            println("  ⚠ engine-vs-JBM divergence detected — see ${outDir}/jbm-crossref-$stamp.md")
        }
    }

    // ── Loading ────────────────────────────────────────────────────────────────

    private fun loadCases(dataRoot: File): List<TestCase> {
        // Real-world fired DOPE is merged into the single test_cases/ dataset (each
        // such case carries realworld=true), so this one directory covers all cases.
        val files = File(dataRoot, "test_cases").listFiles { f -> f.extension == "json" }
            ?.toList().orEmpty().sortedBy { it.name }
        return files.mapNotNull { f ->
            runCatching { json.decodeFromString<TestCase>(f.readText()) }
                .onFailure { println("WARN: failed to parse ${f.name}: ${it.message}") }
                .getOrNull()
        }
    }

    private fun loadSidecars(dataRoot: File): List<JbmReference> {
        val dir = File(dataRoot, "jbm-refs")
        if (!dir.isDirectory) return emptyList()
        return dir.listFiles { f -> f.extension == "json" }?.toList().orEmpty()
            .sortedBy { it.name }
            .mapNotNull { f ->
                runCatching { json.decodeFromString<JbmReference>(f.readText()) }
                    .onFailure { println("WARN: failed to parse JBM sidecar ${f.name}: ${it.message}") }
                    .getOrNull()
            }
    }

    // ── Engine mapping (mirrors ExhaustiveCVRunner.toParams) ────────────────────

    private fun JbmInputs.toParams(): BallisticsParams = BallisticsParams(
        muzzleVelocityFps = mvFps,
        bc                = bc,
        bcModel           = if (dragModel.equals("G7", ignoreCase = true)) BcModel.G7 else BcModel.G1,
        zeroDistanceYards = zeroYards,
        sightHeightIn     = sightHeightIn,
        altitudeFt        = altitudeFt,
        tempF             = tempF,
        pressureHpa       = pressureHpa,
        humidityPct       = humidityPct,
    )

    // ── Input-sheet generation ──────────────────────────────────────────────────

    private fun buildInputSheets(cases: List<TestCase>, human: String): String {
        val sb = StringBuilder()
        sb.appendLine("# JBM Web-Calculator Input Sheets")
        sb.appendLine("Generated: $human")
        sb.appendLine()
        sb.appendLine("Enter each case at <https://www.jbmballistics.com/> (Trajectory). Enter the")
        sb.appendLine("distances listed, read the **elevation** column in **mil**, and paste the")
        sb.appendLine("values into the matching sidecar at `jbm-refs/<case_id>.json` (`elevation_mil`).")
        sb.appendLine()
        sb.appendLine("**Atmosphere convention (match the engine):** the engine derives density from")
        sb.appendLine("altitude via the ICAO troposphere model when no station pressure is given.")
        sb.appendLine("For altitude-only cases, set JBM to altitude + sea-level pressure 29.92 inHg")
        sb.appendLine("(\"corrected to sea level\"), temperature as listed, humidity as listed, no wind.")
        sb.appendLine("For cases with a measured station pressure, enter that absolute pressure with")
        sb.appendLine("altitude 0. Small (~0.003 mil) JBM-vs-engine differences are expected and OK.")
        sb.appendLine()

        for (c in cases) {
            val yards = c.dopePoints.map { it.yards }.filter { it != 0.0 && it != c.zeroYards }
                .distinct().sorted()
            if (yards.isEmpty()) continue
            val cond = c.conditions
            val stationInHg = icaoStationInHg(cond.altitudeFt)
            sb.appendLine("## ${c.id} — ${c.caliber} ${c.loadName}")
            sb.appendLine()
            sb.appendLine("| Field | Value |")
            sb.appendLine("|---|---|")
            sb.appendLine("| Drag model | ${c.bcModel} |")
            sb.appendLine("| BC | ${c.bc} |")
            sb.appendLine("| Muzzle velocity (fps) | ${fmt(c.mvFps)} |")
            sb.appendLine("| Zero range (yd) | ${fmt(c.zeroYards)} |")
            sb.appendLine("| Sight height (in) | ${fmt(c.sightHeightIn)} |")
            sb.appendLine("| Altitude (ft) | ${fmt(cond.altitudeFt)} |")
            sb.appendLine("| Temperature (°F) | ${fmt(cond.tempF)} |")
            if (cond.pressureHpa > 0.0) {
                sb.appendLine("| Station pressure | ${fmt(cond.pressureHpa)} hPa = ${fmt2(cond.pressureHpa * HPA_TO_INHG)} inHg (absolute, altitude 0) |")
            } else {
                sb.appendLine("| Pressure | 29.92 inHg corrected to sea level (engine-equivalent station ≈ ${fmt2(stationInHg)} inHg @ ${fmt(cond.altitudeFt)} ft) |")
            }
            sb.appendLine("| Humidity (%) | ${fmt(cond.humidityPct)} |")
            sb.appendLine("| Wind | none |")
            sb.appendLine("| Distances (yd) | ${yards.joinToString(", ") { fmt(it) }} |")
            sb.appendLine()
        }
        return sb.toString()
    }

    // ── Cross-reference comparison ──────────────────────────────────────────────

    private data class CrossRefReport(val markdown: String, val comparedPoints: Int, val divergences: Int)

    private fun buildCrossReference(
        casesById: Map<String, TestCase>,
        sidecars:  List<JbmReference>,
        human:     String,
    ): CrossRefReport {
        val sb = StringBuilder()
        var compared = 0
        var divergences = 0

        sb.appendLine("# JBM Cross-Reference — Engine vs JBM (vs Fired)")
        sb.appendLine("Generated: $human")
        sb.appendLine("Engine: BallisticsEngine.kt — Kotlin KMP test")
        sb.appendLine("Agreement threshold (engine vs JBM): $agreementThresholdMil mil (both McCoy point-mass)")
        sb.appendLine()

        val filled = sidecars.filter { sc -> sc.trajectory.any { it.elevationMil != null } }
        if (filled.isEmpty()) {
            sb.appendLine("_No sidecar contains filled JBM data yet. Generate input sheets, enter the")
            sb.appendLine("loads at jbmballistics.com, and paste the mil values into the sidecars._")
            val awaiting = sidecars.filter { it.trajectory.all { p -> p.elevationMil == null } }
            if (awaiting.isNotEmpty()) {
                sb.appendLine()
                sb.appendLine("Awaiting data: ${awaiting.joinToString(", ") { it.caseId }}")
            }
            return CrossRefReport(sb.toString(), 0, 0)
        }

        for (sc in filled) {
            val case = casesById[sc.caseId]
            val params = sc.inputs.toParams()
            val pts = sc.trajectory.filter { it.elevationMil != null }.sortedBy { it.yards }
            val engineByYd = BallisticsEngine.calculateTrajectory(params, pts.map { it.yards })
            // Fired ground truth, when this case carries observed/fired DOPE.
            val firedByYd = case?.dopePoints?.filter { it.fired }?.associate { it.yards to it.mil } ?: emptyMap()
            val hasFired = firedByYd.isNotEmpty()

            sb.appendLine("## ${sc.caseId}")
            case?.let { sb.appendLine("${it.caliber} ${it.loadName} — tier ${it.tier}") }
            sb.appendLine()
            sb.appendLine("Inputs: ${sc.inputs.dragModel} BC ${sc.inputs.bc}, MV ${fmt(sc.inputs.mvFps)} fps, " +
                          "zero ${fmt(sc.inputs.zeroYards)} yd, ${fmt(sc.inputs.altitudeFt)} ft, ${fmt(sc.inputs.tempF)} °F" +
                          (if (sc.inputs.pressureHpa > 0) ", ${fmt(sc.inputs.pressureHpa)} hPa" else "") +
                          ", ${fmt(sc.inputs.humidityPct)}% RH")
            if (sc.inputs.notes.isNotBlank()) sb.appendLine("Notes: ${sc.inputs.notes}")
            sb.appendLine()

            if (hasFired) {
                sb.appendLine("| Dist (yd) | Engine (mil) | JBM (mil) | Δ Eng−JBM | Fired (mil) | Δ Eng−Fired | Δ JBM−Fired | Flag |")
                sb.appendLine("|-----------|-------------|-----------|-----------|------------|-------------|-------------|------|")
            } else {
                sb.appendLine("| Dist (yd) | Engine (mil) | JBM (mil) | Δ Eng−JBM | Flag |")
                sb.appendLine("|-----------|-------------|-----------|-----------|------|")
            }

            for (p in pts) {
                val jbm = p.elevationMil ?: continue
                val eng = engineByYd[p.yards]?.elevationMil
                if (eng == null) { sb.appendLine("| ${fmt(p.yards)} | (no engine result) | ${fmt3(jbm)} | — | — |"); continue }
                compared++
                val dEngJbm = eng - jbm
                val flag = if (abs(dEngJbm) > agreementThresholdMil) { divergences++; "DIVERGENCE" } else "ok"
                if (hasFired) {
                    val fired = firedByYd[p.yards]
                    val dEngF = fired?.let { eng - it }
                    val dJbmF = fired?.let { jbm - it }
                    sb.appendLine("| ${fmt(p.yards)} | ${fmt3(eng)} | ${fmt3(jbm)} | ${signed(dEngJbm)} | " +
                                  "${fired?.let { fmt3(it) } ?: "—"} | ${dEngF?.let { signed(it) } ?: "—"} | " +
                                  "${dJbmF?.let { signed(it) } ?: "—"} | $flag |")
                } else {
                    sb.appendLine("| ${fmt(p.yards)} | ${fmt3(eng)} | ${fmt3(jbm)} | ${signed(dEngJbm)} | $flag |")
                }
            }
            sb.appendLine()
            if (hasFired) {
                sb.appendLine("> Interpretation: if **Δ Eng−JBM ≈ 0** but **Δ Eng−Fired** and **Δ JBM−Fired**")
                sb.appendLine("> are both large and similar, the miss is in the load/atmosphere data, not the")
                sb.appendLine("> engine. A non-zero **Δ Eng−JBM** points at the engine implementation.")
                sb.appendLine()
            }
        }
        return CrossRefReport(sb.toString(), compared, divergences)
    }

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

    /** ICAO troposphere station pressure (inHg) at [altitudeFt], for the input sheet cross-check. */
    private fun icaoStationInHg(altitudeFt: Double): Double {
        val hPa = STD_PRESSURE_HPA * Math.pow(1.0 - 6.87559e-6 * altitudeFt, 5.2561)
        return hPa * HPA_TO_INHG
    }

    private fun fmt(v: Double): String =
        if (v == v.toLong().toDouble()) v.toLong().toString() else String.format(Locale.US, "%.2f", v)
    private fun fmt2(v: Double): String = String.format(Locale.US, "%.2f", v)
    private fun fmt3(v: Double): String = String.format(Locale.US, "%.3f", v)
    private fun signed(v: Double): String = String.format(Locale.US, "%+.4f", v)

    /**
     * Locate the `ballistics-test-data` root. Primary source is the
     * `ballistics.testdata.dir` system property set by build.gradle.kts (it points
     * at `<root>/test_cases`, so its parent is the dataset root) — identical to how
     * [BallisticsValidationTest] resolves data, and layout-agnostic (works whether
     * built standalone from scopedope-shared or as the scopedope-android submodule).
     * Falls back to walking up for `ballistics-test-data` for ad-hoc runs.
     */
    private fun resolveDataRoot(): File {
        System.getProperty("ballistics.testdata.dir")?.let { tc ->
            val parent = File(tc).parentFile
            if (parent != null && parent.isDirectory) return parent
        }
        var dir: File? = File(System.getProperty("user.dir"))
        while (dir != null) {
            File(dir, "ballistics-test-data").takeIf { it.isDirectory }?.let { return it }
            File(dir, "shared/ballistics-test-data").takeIf { it.isDirectory }?.let { return it }
            dir = dir.parentFile
        }
        error("Could not locate ballistics-test-data from ${System.getProperty("user.dir")}")
    }

    private companion object {
        private const val STD_PRESSURE_HPA = 1013.25
        private const val HPA_TO_INHG = 0.0295299830714
    }
}
