mod args;
mod churn;
mod io;
mod loader;
mod model;
mod output;
mod sandwich;

use args::{Args, parse_args};
use churn::build_intra_rp_churn_streaming;
use loader::load_sequence_meta;
use model::Side;
use output::{build_output_from_meta, write_json, write_markdown};
use sandwich::build_sandwich_analysis_streaming;

pub fn main_entry() -> Result<(), String> {
    real_main()
}

fn real_main() -> Result<(), String> {
    let args = parse_args(&std::env::args().collect::<Vec<_>>())?;
    run(args)
}

fn run(args: Args) -> Result<(), String> {
    std::fs::create_dir_all(&args.out_dir)
        .map_err(|e| format!("create out-dir failed: {}: {e}", args.out_dir.display()))?;
    let left = load_sequence_meta(&args.left_sequence, Side::Left)?;
    let right = load_sequence_meta(&args.right_sequence, Side::Right)?;
    if left.is_empty() || right.is_empty() {
        return Err("left and right sequences must both contain at least one sample".into());
    }

    let sandwich = build_sandwich_analysis_streaming(&args, &left, &right)?;
    let churn = build_intra_rp_churn_streaming(&left, &right)?;
    let output = build_output_from_meta(&args, &left, &right, &sandwich, &churn);
    write_json(&args.out_dir.join("sequence-triage.json"), &output)?;
    write_markdown(&args.out_dir.join("sequence-triage.md"), &output)?;
    println!("{}", args.out_dir.display());
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ccr::{
        CcrContentInfo, CcrDigestAlgorithm, RpkiCanonicalCacheRepresentation,
        build_aspa_payload_state, build_roa_payload_state, encode_content_info,
    };
    use crate::cir::{
        CanonicalInputRepresentation, CirObject, CirRejectedObject, CirTrustAnchor, encode_cir,
        sha256,
    };
    use crate::data_model::roa::{IpPrefix, RoaAfi};
    use crate::validation::objects::{AspaAttestation, Vrp};
    use io::format_time;
    use serde_json::{Value, json};
    use std::path::Path;
    use time::OffsetDateTime;

    #[test]
    fn parse_args_accepts_required_flags() {
        let argv = vec![
            "sequence_triage_ccr_cir".to_string(),
            "--left-sequence".to_string(),
            "left.jsonl".to_string(),
            "--right-sequence".to_string(),
            "right.jsonl".to_string(),
            "--out-dir".to_string(),
            "out".to_string(),
            "--align-window-runs".to_string(),
            "3".to_string(),
        ];
        let args = parse_args(&argv).expect("parse");
        assert_eq!(args.align_window_runs, 3);
        assert_eq!(args.align_window_secs, 1800);
        assert_eq!(args.warmup_samples, 1);
        assert_eq!(args.cooldown_samples, 1);
        assert_eq!(args.timeline_sample_limit, 0);
    }

    #[test]
    fn run_outputs_sandwich_only_schema_and_churn() {
        let temp = tempfile::tempdir().expect("tempdir");
        let root = temp.path();
        write_sample(
            root,
            "left1",
            "left",
            1,
            &[object("rsync://example.net/a.roa", 0x11)],
            &["rsync://example.net/reject-old.roa"],
            64496,
        );
        write_sample(
            root,
            "left2",
            "left",
            2,
            &[
                object("rsync://example.net/a.roa", 0x11),
                object("rsync://example.net/persistent.roa", 0x44),
                object("rsync://example.net/reject-old.roa", 0xee),
            ],
            &[],
            64496,
        );
        write_sample(root, "right1", "right", 1, &[], &[], 64497);
        write_sample(
            root,
            "right2",
            "right",
            2,
            &[object("rsync://example.net/a.roa", 0x11)],
            &[],
            64497,
        );
        std::fs::write(
            root.join("left.jsonl"),
            jsonl(&[
                item("left", 1, "left1/result.ccr", "left1/result.cir"),
                item("left", 2, "left2/result.ccr", "left2/result.cir"),
            ]),
        )
        .unwrap();
        std::fs::write(
            root.join("right.jsonl"),
            jsonl(&[
                item("right", 1, "right1/result.ccr", "right1/result.cir"),
                item("right", 2, "right2/result.ccr", "right2/result.cir"),
            ]),
        )
        .unwrap();
        run(Args {
            left_sequence: root.join("left.jsonl"),
            right_sequence: root.join("right.jsonl"),
            out_dir: root.join("out"),
            align_window_runs: 2,
            align_window_secs: 3600,
            sample_limit: 20,
            warmup_samples: 1,
            cooldown_samples: 0,
            timeline_sample_limit: 0,
        })
        .expect("run");
        let output: Value = serde_json::from_str(
            &std::fs::read_to_string(root.join("out/sequence-triage.json")).unwrap(),
        )
        .unwrap();
        assert_eq!(output["schemaVersion"].as_u64(), Some(3));
        assert!(output.get("classificationCounts").is_none());
        assert!(output.get("adjusted").is_none());
        assert_eq!(
            churn_record(&output, "left", "object", "left-1", "left-2")["changedAbs"].as_u64(),
            Some(1)
        );
        assert_eq!(
            churn_record(&output, "left", "output", "left-1", "left-2")["changedAbs"].as_u64(),
            Some(2)
        );
        assert_eq!(
            churn_record(&output, "right", "object", "right-1", "right-2")["changedAbs"].as_u64(),
            Some(1)
        );
        assert_eq!(
            churn_record(&output, "left", "reject", "left-1", "left-2")["changedAbs"].as_u64(),
            Some(1)
        );
    }

    #[test]
    fn run_single_sample_keeps_legacy_sections_absent() {
        let temp = tempfile::tempdir().expect("tempdir");
        let root = temp.path();
        let objects = [object("rsync://example.net/a.roa", 0x11)];
        write_sample(
            root,
            "left1",
            "left",
            1,
            &objects,
            &["rsync://example.net/a.roa"],
            64496,
        );
        write_sample(root, "right1", "right", 1, &objects, &[], 64497);
        std::fs::write(
            root.join("left.jsonl"),
            jsonl(&[item("left", 1, "left1/result.ccr", "left1/result.cir")]),
        )
        .unwrap();
        std::fs::write(
            root.join("right.jsonl"),
            jsonl(&[item("right", 1, "right1/result.ccr", "right1/result.cir")]),
        )
        .unwrap();
        run(Args {
            left_sequence: root.join("left.jsonl"),
            right_sequence: root.join("right.jsonl"),
            out_dir: root.join("out"),
            align_window_runs: 0,
            align_window_secs: 0,
            sample_limit: 20,
            warmup_samples: 0,
            cooldown_samples: 0,
            timeline_sample_limit: 0,
        })
        .expect("run");
        let output: Value = serde_json::from_str(
            &std::fs::read_to_string(root.join("out/sequence-triage.json")).unwrap(),
        )
        .unwrap();
        assert_eq!(output["schemaVersion"].as_u64(), Some(3));
        assert!(output.get("classificationCounts").is_none());
        assert!(output.get("adjusted").is_none());
        assert_eq!(
            output["sandwich"]["totals"]["occurrences"].as_u64(),
            Some(0)
        );
        assert_eq!(output["intraRpChurn"]["left"].as_array().unwrap().len(), 0);
        assert_eq!(output["intraRpChurn"]["right"].as_array().unwrap().len(), 0);
    }

    #[test]
    fn run_content_rollover_fixture_keeps_legacy_sections_absent() {
        let temp = tempfile::tempdir().expect("tempdir");
        let root = temp.path();
        let uri = "rsync://example.net/a.roa";
        write_sample(root, "left1", "left", 1, &[object(uri, 0x11)], &[], 64496);
        write_sample(root, "left2", "left", 2, &[object(uri, 0x22)], &[], 64496);
        write_sample(root, "right1", "right", 1, &[object(uri, 0x22)], &[], 64496);
        write_sample(root, "right2", "right", 2, &[object(uri, 0x22)], &[], 64496);
        std::fs::write(
            root.join("left.jsonl"),
            jsonl(&[
                item("left", 1, "left1/result.ccr", "left1/result.cir"),
                item("left", 2, "left2/result.ccr", "left2/result.cir"),
            ]),
        )
        .unwrap();
        std::fs::write(
            root.join("right.jsonl"),
            jsonl(&[
                item("right", 1, "right1/result.ccr", "right1/result.cir"),
                item("right", 2, "right2/result.ccr", "right2/result.cir"),
            ]),
        )
        .unwrap();
        run(Args {
            left_sequence: root.join("left.jsonl"),
            right_sequence: root.join("right.jsonl"),
            out_dir: root.join("out"),
            align_window_runs: 0,
            align_window_secs: 0,
            sample_limit: 20,
            warmup_samples: 1,
            cooldown_samples: 0,
            timeline_sample_limit: 0,
        })
        .expect("run");
        let output: Value = serde_json::from_str(
            &std::fs::read_to_string(root.join("out/sequence-triage.json")).unwrap(),
        )
        .unwrap();
        assert_eq!(output["schemaVersion"].as_u64(), Some(3));
        assert!(output.get("classificationCounts").is_none());
        assert!(output.get("adjusted").is_none());
    }

    #[test]
    fn run_middle_content_fixture_keeps_legacy_sections_absent() {
        let temp = tempfile::tempdir().expect("tempdir");
        let root = temp.path();
        let uri = "rsync://example.net/a.roa";
        for seq in 1..=3 {
            write_sample(
                root,
                &format!("left{seq}"),
                "left",
                seq,
                &[object(uri, 0x11)],
                &[],
                64496,
            );
            write_sample(
                root,
                &format!("right{seq}"),
                "right",
                seq,
                &[object(uri, 0x22)],
                &[],
                64496,
            );
        }
        std::fs::write(
            root.join("left.jsonl"),
            jsonl(&[
                item("left", 1, "left1/result.ccr", "left1/result.cir"),
                item("left", 2, "left2/result.ccr", "left2/result.cir"),
                item("left", 3, "left3/result.ccr", "left3/result.cir"),
            ]),
        )
        .unwrap();
        std::fs::write(
            root.join("right.jsonl"),
            jsonl(&[
                item("right", 1, "right1/result.ccr", "right1/result.cir"),
                item("right", 2, "right2/result.ccr", "right2/result.cir"),
                item("right", 3, "right3/result.ccr", "right3/result.cir"),
            ]),
        )
        .unwrap();
        run(Args {
            left_sequence: root.join("left.jsonl"),
            right_sequence: root.join("right.jsonl"),
            out_dir: root.join("out"),
            align_window_runs: 0,
            align_window_secs: 0,
            sample_limit: 20,
            warmup_samples: 1,
            cooldown_samples: 1,
            timeline_sample_limit: 0,
        })
        .expect("run");
        let output: Value = serde_json::from_str(
            &std::fs::read_to_string(root.join("out/sequence-triage.json")).unwrap(),
        )
        .unwrap();
        assert_eq!(output["schemaVersion"].as_u64(), Some(3));
        assert!(output.get("classificationCounts").is_none());
        assert!(output.get("adjusted").is_none());
    }

    #[test]
    fn run_trailing_output_fixture_keeps_legacy_sections_absent() {
        let temp = tempfile::tempdir().expect("tempdir");
        let root = temp.path();
        let objects = [object("rsync://example.net/a.roa", 0x11)];
        write_sample(root, "left1", "left", 1, &objects, &[], 64496);
        write_sample(root, "left2", "left", 2, &objects, &[], 64497);
        write_sample(root, "right1", "right", 1, &objects, &[], 64496);
        write_sample(root, "right2", "right", 2, &objects, &[], 64496);
        std::fs::write(
            root.join("left.jsonl"),
            jsonl(&[
                item("left", 1, "left1/result.ccr", "left1/result.cir"),
                item("left", 2, "left2/result.ccr", "left2/result.cir"),
            ]),
        )
        .unwrap();
        std::fs::write(
            root.join("right.jsonl"),
            jsonl(&[
                item("right", 1, "right1/result.ccr", "right1/result.cir"),
                item("right", 2, "right2/result.ccr", "right2/result.cir"),
            ]),
        )
        .unwrap();
        run(Args {
            left_sequence: root.join("left.jsonl"),
            right_sequence: root.join("right.jsonl"),
            out_dir: root.join("out"),
            align_window_runs: 0,
            align_window_secs: 0,
            sample_limit: 20,
            warmup_samples: 0,
            cooldown_samples: 1,
            timeline_sample_limit: 0,
        })
        .expect("run");
        let output: Value = serde_json::from_str(
            &std::fs::read_to_string(root.join("out/sequence-triage.json")).unwrap(),
        )
        .unwrap();
        assert_eq!(output["schemaVersion"].as_u64(), Some(3));
        assert!(output.get("classificationCounts").is_none());
        assert!(output.get("adjusted").is_none());
    }

    #[test]
    fn run_object_group_fixture_keeps_legacy_sections_absent() {
        let temp = tempfile::tempdir().expect("tempdir");
        let root = temp.path();
        let missing = "rsync://example.net/repo/pp/a.roa";
        write_sample(
            root,
            "left1",
            "left",
            1,
            &[object("rsync://example.net/repo/pp/base.roa", 0x10)],
            &[],
            64496,
        );
        write_sample(
            root,
            "left2",
            "left",
            2,
            &[
                object("rsync://example.net/repo/pp/base.roa", 0x10),
                object(missing, 0x11),
            ],
            &[],
            64496,
        );
        write_sample(
            root,
            "left3",
            "left",
            3,
            &[object("rsync://example.net/repo/pp/base.roa", 0x10)],
            &[],
            64496,
        );
        for seq in 1..=3 {
            write_sample(
                root,
                &format!("right{seq}"),
                "right",
                seq,
                &[object("rsync://example.net/repo/pp/base.roa", 0x10)],
                &[],
                64496,
            );
        }
        std::fs::write(
            root.join("left.jsonl"),
            jsonl(&[
                item("left", 1, "left1/result.ccr", "left1/result.cir"),
                item("left", 2, "left2/result.ccr", "left2/result.cir"),
                item("left", 3, "left3/result.ccr", "left3/result.cir"),
            ]),
        )
        .unwrap();
        std::fs::write(
            root.join("right.jsonl"),
            jsonl(&[
                item("right", 1, "right1/result.ccr", "right1/result.cir"),
                item("right", 2, "right2/result.ccr", "right2/result.cir"),
                item("right", 3, "right3/result.ccr", "right3/result.cir"),
            ]),
        )
        .unwrap();
        run(Args {
            left_sequence: root.join("left.jsonl"),
            right_sequence: root.join("right.jsonl"),
            out_dir: root.join("out"),
            align_window_runs: 0,
            align_window_secs: 0,
            sample_limit: 20,
            warmup_samples: 1,
            cooldown_samples: 1,
            timeline_sample_limit: 0,
        })
        .expect("run");
        let output: Value = serde_json::from_str(
            &std::fs::read_to_string(root.join("out/sequence-triage.json")).unwrap(),
        )
        .unwrap();
        assert_eq!(output["schemaVersion"].as_u64(), Some(3));
        assert!(output.get("classificationCounts").is_none());
        assert!(output.get("adjusted").is_none());
    }

    #[test]
    fn run_sandwich_detects_object_hash_reject_and_output_anomalies() {
        let temp = tempfile::tempdir().expect("tempdir");
        let root = temp.path();
        let stable_missing = object("rsync://example.net/pp/missing.roa", 0x11);
        let stable_mismatch = object("rsync://example.net/pp/mismatch.roa", 0x22);
        let peer_mismatch = object("rsync://example.net/pp/mismatch.roa", 0x33);
        write_sample_with_ccr_seq(
            root,
            "left1",
            1,
            &[stable_missing.clone(), stable_mismatch.clone()],
            &["rsync://example.net/pp/rejected.roa"],
            9,
            64496,
        );
        write_sample_with_ccr_seq(
            root,
            "left3",
            3,
            &[stable_missing, stable_mismatch],
            &["rsync://example.net/pp/rejected.roa"],
            9,
            64496,
        );
        write_sample_with_ccr_seq(
            root,
            "right2",
            2,
            &[
                peer_mismatch,
                object("rsync://example.net/pp/rejected.roa", 0xee),
            ],
            &[],
            10,
            64497,
        );
        std::fs::write(
            root.join("left.jsonl"),
            jsonl(&[
                item("left", 1, "left1/result.ccr", "left1/result.cir"),
                item("left", 3, "left3/result.ccr", "left3/result.cir"),
            ]),
        )
        .unwrap();
        std::fs::write(
            root.join("right.jsonl"),
            jsonl(&[item("right", 2, "right2/result.ccr", "right2/result.cir")]),
        )
        .unwrap();
        run(Args {
            left_sequence: root.join("left.jsonl"),
            right_sequence: root.join("right.jsonl"),
            out_dir: root.join("out"),
            align_window_runs: 0,
            align_window_secs: 0,
            sample_limit: 20,
            warmup_samples: 0,
            cooldown_samples: 0,
            timeline_sample_limit: 0,
        })
        .expect("run");
        let output: Value = serde_json::from_str(
            &std::fs::read_to_string(root.join("out/sequence-triage.json")).unwrap(),
        )
        .unwrap();
        assert_eq!(
            sandwich_class_occurrences(&output, "PEER_MISSING_STABLE_OBJECT"),
            1
        );
        assert_eq!(
            sandwich_class_occurrences(&output, "PEER_HASH_MISMATCH_STABLE_OBJECT"),
            1
        );
        assert_eq!(
            sandwich_class_occurrences(&output, "PEER_MISSING_STABLE_REJECT"),
            1
        );
        assert_eq!(
            sandwich_class_occurrences(&output, "PEER_MISSING_STABLE_OUTPUT"),
            2
        );
        assert_eq!(output["sandwich"]["heatmap"].as_array().unwrap().len(), 1);
        assert_eq!(output["sandwich"]["heatmap"][0]["total"].as_u64(), Some(5));
        assert_eq!(
            uri_prefix_group_occurrences(&output, "object", "rsync://example.net/"),
            2
        );
        assert_eq!(
            uri_prefix_group_occurrences(&output, "reject_uri", "rsync://example.net/"),
            1
        );
        assert_eq!(asn_group_occurrences(&output, "vrp_output", "AS64496"), 1);
        assert_eq!(asn_group_occurrences(&output, "vap_output", "AS64496"), 1);
        assert!(output.get("classificationCounts").is_none());
        assert!(output.get("adjusted").is_none());
    }

    #[test]
    fn run_uses_cir_validation_time_and_sorts_by_time_not_raw_seq() {
        let temp = tempfile::tempdir().expect("tempdir");
        let root = temp.path();
        let stable = object("rsync://example.net/pp/stable.roa", 0x11);
        write_sample(
            root,
            "left5",
            "left",
            5,
            std::slice::from_ref(&stable),
            &[],
            64496,
        );
        write_sample(
            root,
            "left1",
            "left",
            1,
            std::slice::from_ref(&stable),
            &[],
            64496,
        );
        write_sample(root, "right3", "right", 3, &[], &[], 64497);
        std::fs::write(
            root.join("left.jsonl"),
            jsonl(&[
                item("left", 1, "left5/result.ccr", "left5/result.cir"),
                item("left", 2, "left1/result.ccr", "left1/result.cir"),
            ]),
        )
        .unwrap();
        std::fs::write(
            root.join("right.jsonl"),
            jsonl(&[item("right", 10, "right3/result.ccr", "right3/result.cir")]),
        )
        .unwrap();
        run(Args {
            left_sequence: root.join("left.jsonl"),
            right_sequence: root.join("right.jsonl"),
            out_dir: root.join("out"),
            align_window_runs: 0,
            align_window_secs: 0,
            sample_limit: 20,
            warmup_samples: 0,
            cooldown_samples: 0,
            timeline_sample_limit: 0,
        })
        .expect("run");
        let output: Value = serde_json::from_str(
            &std::fs::read_to_string(root.join("out/sequence-triage.json")).unwrap(),
        )
        .unwrap();
        assert_eq!(output["left"]["samples"][0]["seq"].as_u64(), Some(2));
        assert_eq!(output["left"]["samples"][1]["seq"].as_u64(), Some(1));
        assert_eq!(
            sandwich_class_occurrences(&output, "PEER_MISSING_STABLE_OBJECT"),
            1
        );
        assert_eq!(
            output["parameters"]["sequenceSemantics"]["timeSource"].as_str(),
            Some("cir.validation_time")
        );
    }

    #[test]
    fn run_uses_adjacent_source_windows_only() {
        let temp = tempfile::tempdir().expect("tempdir");
        let root = temp.path();
        let stable = object("rsync://example.net/pp/stable.roa", 0x11);
        write_sample(
            root,
            "left1",
            "left",
            1,
            std::slice::from_ref(&stable),
            &[],
            64496,
        );
        write_sample(root, "left3", "left", 3, &[], &[], 64496);
        write_sample(
            root,
            "left5",
            "left",
            5,
            std::slice::from_ref(&stable),
            &[],
            64496,
        );
        write_sample(root, "right4", "right", 4, &[], &[], 64497);
        std::fs::write(
            root.join("left.jsonl"),
            jsonl(&[
                item("left", 1, "left1/result.ccr", "left1/result.cir"),
                item("left", 3, "left3/result.ccr", "left3/result.cir"),
                item("left", 5, "left5/result.ccr", "left5/result.cir"),
            ]),
        )
        .unwrap();
        std::fs::write(
            root.join("right.jsonl"),
            jsonl(&[item("right", 4, "right4/result.ccr", "right4/result.cir")]),
        )
        .unwrap();
        run(Args {
            left_sequence: root.join("left.jsonl"),
            right_sequence: root.join("right.jsonl"),
            out_dir: root.join("out"),
            align_window_runs: 0,
            align_window_secs: 0,
            sample_limit: 20,
            warmup_samples: 0,
            cooldown_samples: 0,
            timeline_sample_limit: 0,
        })
        .expect("run");
        let output: Value = serde_json::from_str(
            &std::fs::read_to_string(root.join("out/sequence-triage.json")).unwrap(),
        )
        .unwrap();
        assert_eq!(
            sandwich_class_occurrences(&output, "PEER_MISSING_STABLE_OBJECT"),
            0
        );
    }

    #[test]
    fn run_checks_every_peer_sample_inside_adjacent_window() {
        let temp = tempfile::tempdir().expect("tempdir");
        let root = temp.path();
        let stable = object("rsync://example.net/pp/stable.roa", 0x11);
        write_sample(
            root,
            "left1",
            "left",
            1,
            std::slice::from_ref(&stable),
            &[],
            64496,
        );
        write_sample(
            root,
            "left5",
            "left",
            5,
            std::slice::from_ref(&stable),
            &[],
            64496,
        );
        write_sample(root, "right2", "right", 2, &[], &[], 64497);
        write_sample(root, "right4", "right", 4, &[], &[], 64497);
        write_sample(root, "right6", "right", 6, &[], &[], 64497);
        std::fs::write(
            root.join("left.jsonl"),
            jsonl(&[
                item("left", 1, "left1/result.ccr", "left1/result.cir"),
                item("left", 5, "left5/result.ccr", "left5/result.cir"),
            ]),
        )
        .unwrap();
        std::fs::write(
            root.join("right.jsonl"),
            jsonl(&[
                item("right", 2, "right2/result.ccr", "right2/result.cir"),
                item("right", 4, "right4/result.ccr", "right4/result.cir"),
                item("right", 6, "right6/result.ccr", "right6/result.cir"),
            ]),
        )
        .unwrap();
        run(Args {
            left_sequence: root.join("left.jsonl"),
            right_sequence: root.join("right.jsonl"),
            out_dir: root.join("out"),
            align_window_runs: 0,
            align_window_secs: 0,
            sample_limit: 20,
            warmup_samples: 0,
            cooldown_samples: 0,
            timeline_sample_limit: 0,
        })
        .expect("run");
        let output: Value = serde_json::from_str(
            &std::fs::read_to_string(root.join("out/sequence-triage.json")).unwrap(),
        )
        .unwrap();
        assert_eq!(
            sandwich_class_occurrences(&output, "PEER_MISSING_STABLE_OBJECT"),
            2
        );
        assert_eq!(output["right"]["sampleCount"].as_u64(), Some(3));
    }

    fn sandwich_class_occurrences(output: &Value, class: &str) -> u64 {
        output["sandwich"]["classificationCounts"]
            .as_array()
            .unwrap()
            .iter()
            .find(|item| item["classification"].as_str() == Some(class))
            .and_then(|item| item["occurrences"].as_u64())
            .unwrap_or(0)
    }

    fn uri_prefix_group_occurrences(output: &Value, set_type: &str, uri_prefix: &str) -> u64 {
        output["sandwich"]["uriPrefixGroups"]
            .as_array()
            .unwrap()
            .iter()
            .find(|item| {
                item["setType"].as_str() == Some(set_type)
                    && item["uriPrefix"].as_str() == Some(uri_prefix)
            })
            .and_then(|item| item["occurrences"].as_u64())
            .unwrap_or(0)
    }

    fn asn_group_occurrences(output: &Value, set_type: &str, asn: &str) -> u64 {
        output["sandwich"]["asnGroups"]
            .as_array()
            .unwrap()
            .iter()
            .find(|item| {
                item["setType"].as_str() == Some(set_type) && item["asn"].as_str() == Some(asn)
            })
            .and_then(|item| item["occurrences"].as_u64())
            .unwrap_or(0)
    }

    fn churn_record<'a>(
        output: &'a Value,
        side: &str,
        set_type: &str,
        from_run_id: &str,
        to_run_id: &str,
    ) -> &'a Value {
        output["intraRpChurn"][side]
            .as_array()
            .unwrap()
            .iter()
            .find(|item| {
                item["setType"].as_str() == Some(set_type)
                    && item["fromRunId"].as_str() == Some(from_run_id)
                    && item["toRunId"].as_str() == Some(to_run_id)
            })
            .unwrap()
    }

    fn object(uri: &str, byte: u8) -> CirObject {
        CirObject {
            rsync_uri: uri.to_string(),
            sha256: vec![byte; 32],
        }
    }

    fn write_sample(
        root: &Path,
        dir: &str,
        side: &str,
        seq: u32,
        objects: &[CirObject],
        rejected: &[&str],
        asn: u32,
    ) {
        let dir = root.join(dir);
        std::fs::create_dir_all(&dir).unwrap();
        let cir = sample_cir(seq, objects, rejected);
        let ccr = sample_ccr(seq, asn);
        std::fs::write(dir.join("result.cir"), encode_cir(&cir).unwrap()).unwrap();
        std::fs::write(dir.join("result.ccr"), encode_content_info(&ccr).unwrap()).unwrap();
        let _ = side;
    }

    fn write_sample_with_ccr_seq(
        root: &Path,
        dir: &str,
        cir_seq: u32,
        objects: &[CirObject],
        rejected: &[&str],
        ccr_seq: u32,
        asn: u32,
    ) {
        let dir = root.join(dir);
        std::fs::create_dir_all(&dir).unwrap();
        let cir = sample_cir(cir_seq, objects, rejected);
        let ccr = sample_ccr(ccr_seq, asn);
        std::fs::write(dir.join("result.cir"), encode_cir(&cir).unwrap()).unwrap();
        std::fs::write(dir.join("result.ccr"), encode_content_info(&ccr).unwrap()).unwrap();
    }

    fn sample_time(seq: u32) -> OffsetDateTime {
        OffsetDateTime::from_unix_timestamp(1_800_000_000 + i64::from(seq * 60)).unwrap()
    }

    fn sample_cir(
        seq: u32,
        objects: &[CirObject],
        rejected: &[&str],
    ) -> CanonicalInputRepresentation {
        let mut objects = objects.to_vec();
        let rejected_objects = rejected
            .iter()
            .map(|uri| CirRejectedObject {
                object_uri: (*uri).to_string(),
                reason: Some("test".to_string()),
            })
            .collect::<Vec<_>>();
        for rejected_uri in rejected {
            if !objects
                .iter()
                .any(|object| object.rsync_uri == *rejected_uri)
            {
                objects.push(CirObject {
                    rsync_uri: (*rejected_uri).to_string(),
                    sha256: vec![0xee; 32],
                });
            }
        }
        objects.sort_by(|a, b| a.rsync_uri.cmp(&b.rsync_uri));
        CanonicalInputRepresentation::new_v4(
            sample_time(seq),
            objects,
            Vec::new(),
            vec![sample_trust_anchor()],
            rejected_objects,
            Vec::new(),
        )
    }

    fn sample_trust_anchor() -> CirTrustAnchor {
        let ta_uri = "rsync://example.net/ta.cer";
        let ta_der = b"ta-der".to_vec();
        CirTrustAnchor {
            ta_rsync_uri: ta_uri.to_string(),
            tal_uri: "https://example.net/root.tal".to_string(),
            tal_bytes: format!("{ta_uri}\n\nAQID\n").into_bytes(),
            ta_certificate_der: ta_der.clone(),
            ta_certificate_sha256: sha256(&ta_der),
        }
    }

    fn sample_ccr(seq: u32, asn: u32) -> CcrContentInfo {
        let vrps = build_roa_payload_state(&[Vrp {
            asn,
            prefix: IpPrefix {
                afi: RoaAfi::Ipv4,
                prefix_len: 24,
                addr: [192, 0, seq as u8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
            },
            max_length: 24,
        }])
        .unwrap();
        let vaps = build_aspa_payload_state(&[AspaAttestation {
            customer_as_id: asn,
            provider_as_ids: vec![64497],
        }])
        .unwrap();
        CcrContentInfo::new(RpkiCanonicalCacheRepresentation {
            version: 0,
            hash_alg: CcrDigestAlgorithm::Sha256,
            produced_at: sample_time(seq),
            mfts: None,
            vrps: Some(vrps),
            vaps: Some(vaps),
            tas: None,
            rks: None,
        })
    }

    fn item(side: &str, seq: u32, ccr: &str, cir: &str) -> Value {
        json!({
            "schemaVersion": 1,
            "rpId": format!("{side}-rp"),
            "side": side,
            "seq": seq,
            "runId": format!("{side}-{seq}"),
            "syncMode": if seq == 1 { "snapshot" } else { "delta" },
            "status": "success",
            "validationTime": format_time(sample_time(seq)),
            "ccrPath": ccr,
            "cirPath": cir
        })
    }

    fn jsonl(items: &[Value]) -> String {
        let mut out = String::new();
        for item in items {
            out.push_str(&serde_json::to_string(item).unwrap());
            out.push('\n');
        }
        out
    }
}
