mod common;

use std::path::{Path, PathBuf};

use bone_jig::{RunStatus, Scenario, StepOutcome, run_scenario};
use bone_render::{PixelDiff, PixelDiffThreshold, decode_png};
use common::{find_artifact, manifest_path, run_library_scenario};

const GOLDEN_DIFF_TOLERANCE: f64 = 16.0 / 255.0;
const UPDATE_ENV: &str = "BONE_UPDATE_JIG_SMOKE";

fn check_golden(actual_png: &Path, golden: &Path) {
    let bytes = match std::fs::read(actual_png) {
        Ok(b) => b,
        Err(e) => panic!("read {}: {e}", actual_png.display()),
    };
    if std::env::var(UPDATE_ENV).is_ok() {
        if let Some(parent) = golden.parent()
            && let Err(e) = std::fs::create_dir_all(parent)
        {
            panic!("create {}: {e}", parent.display());
        }
        if let Err(e) = std::fs::write(golden, &bytes) {
            panic!("write {}: {e}", golden.display());
        }
        return;
    }
    let Ok(golden_bytes) = std::fs::read(golden) else {
        panic!(
            "golden missing at {}: rerun with {UPDATE_ENV}=1 to generate",
            golden.display(),
        );
    };
    let ((actual_extent, actual_rgba), (golden_extent, golden_rgba)) =
        match (decode_png(&bytes), decode_png(&golden_bytes)) {
            (Ok(a), Ok(g)) => (a, g),
            (Err(e), _) | (_, Err(e)) => panic!("decode png: {e}"),
        };
    assert_eq!(actual_extent, golden_extent, "golden extent drift");
    let threshold = PixelDiffThreshold::new(GOLDEN_DIFF_TOLERANCE);
    let report =
        match PixelDiff::compare_bytes(actual_extent, &actual_rgba, &golden_rgba, threshold) {
            Ok(r) => r,
            Err(e) => panic!("PixelDiff rejected inputs: {e}"),
        };
    assert!(
        report.is_clean(),
        "smoke frame drifted from golden: {} mismatches, worst {:?}",
        report.over_threshold(),
        report.worst(),
    );
}

#[test]
fn shell_smoke_matches_golden() {
    let dir = match tempfile::tempdir() {
        Ok(d) => d,
        Err(e) => panic!("tempdir: {e}"),
    };
    let result = run_library_scenario("smoke.ron", dir.path());
    assert_eq!(result.status, RunStatus::Passed, "result: {result:?}");
    let snapshot = find_artifact(dir.path(), "_final.png");
    check_golden(&snapshot, &manifest_path("tests/goldens/smoke_final.png"));
}

fn run_text(text: &str, out_dir: &Path) -> bone_jig::RunResult {
    let scenario = match Scenario::parse(text) {
        Ok(s) => s,
        Err(e) => panic!("parse scenario: {e}"),
    };
    let mut sink = Vec::new();
    match run_scenario(&scenario, out_dir, &mut sink) {
        Ok(result) => result,
        Err(e) => panic!("run scenario: {e}"),
    }
}

fn read_bytes(path: &Path) -> Vec<u8> {
    match std::fs::read(path) {
        Ok(bytes) => bytes,
        Err(e) => panic!("read {}: {e}", path.display()),
    }
}

#[test]
fn step_import_lands_one_frame_after_open() {
    let dir = match tempfile::tempdir() {
        Ok(d) => d,
        Err(e) => panic!("tempdir: {e}"),
    };
    let fixture = manifest_path("../bone-interop/tests/goldens/cube.step");
    let Some(path) = fixture.to_str() else {
        panic!("fixture path is not utf-8: {}", fixture.display());
    };
    let text = format!(
        r#"Scenario(steps: [Advance(2), OpenDocument({path:?}), Advance(1), DumpTree("cube")])"#
    );
    let result = run_text(&text, dir.path());
    assert_eq!(result.status, RunStatus::Passed, "result: {result:?}");
    let tree = match std::fs::read_to_string(dir.path().join("003_cube.tree.json")) {
        Ok(t) => t,
        Err(e) => panic!("read tree: {e}"),
    };
    assert!(
        tree.contains("cube") && !tree.contains("Untitled"),
        "a synchronous open replaces the default document within one frame; tree: {tree}",
    );
}

#[test]
fn missing_step_document_fails_the_step_and_halts() {
    let dir = match tempfile::tempdir() {
        Ok(d) => d,
        Err(e) => panic!("tempdir: {e}"),
    };
    let result = run_text(
        r#"Scenario(steps: [OpenDocument("/nonexistent/clam.step"), Snapshot("after")])"#,
        dir.path(),
    );
    assert_eq!(
        result.status,
        RunStatus::Failed,
        "a missing import must fail the run",
    );
    assert_eq!(
        result.steps.len(),
        1,
        "the snapshot after a failed open must not run",
    );
    assert!(matches!(result.steps[0].outcome, StepOutcome::Failed(_)));
    assert!(!dir.path().join("001_after.png").exists());
}

#[test]
fn smoke_run_is_byte_identical_across_runs() {
    let first = match tempfile::tempdir() {
        Ok(d) => d,
        Err(e) => panic!("tempdir: {e}"),
    };
    let second = match tempfile::tempdir() {
        Ok(d) => d,
        Err(e) => panic!("tempdir: {e}"),
    };
    let a = run_library_scenario("smoke.ron", first.path());
    let b = run_library_scenario("smoke.ron", second.path());
    assert_eq!(a.status, RunStatus::Passed, "first run: {a:?}");
    assert_eq!(b.status, RunStatus::Passed, "second run: {b:?}");
    assert_eq!(
        read_bytes(&find_artifact(first.path(), "_final.png")),
        read_bytes(&find_artifact(second.path(), "_final.png")),
        "two runs of the same scenario must produce byte-identical pixels",
    );
    assert_eq!(
        read_bytes(&find_artifact(first.path(), "_final.tree.json")),
        read_bytes(&find_artifact(second.path(), "_final.tree.json")),
        "two runs must produce byte-identical trees",
    );
}

#[test]
fn every_library_scenario_runs_clean() {
    let dir = manifest_path("scenarios");
    let entries = match std::fs::read_dir(&dir) {
        Ok(it) => it,
        Err(e) => panic!("read {}: {e}", dir.display()),
    };
    let ran: Vec<PathBuf> = entries
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .filter(|path| path.extension().is_some_and(|ext| ext == "ron"))
        .inspect(|path| {
            let out = match tempfile::tempdir() {
                Ok(d) => d,
                Err(e) => panic!("tempdir: {e}"),
            };
            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
                panic!("scenario path is not utf-8: {}", path.display());
            };
            let result = run_library_scenario(name, out.path());
            assert_eq!(
                result.status,
                RunStatus::Passed,
                "{name} did not run clean: {result:?}",
            );
        })
        .collect();
    assert!(ran.len() >= 12, "library shrank: {ran:?}");
}
