use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

use rpki::cir::decode_cir;
use serde_json::json;

#[derive(Debug, Default, PartialEq, Eq)]
struct Args {
    ours_cir: Option<PathBuf>,
    peer_cir: Option<PathBuf>,
    out_json: Option<PathBuf>,
    out_md: Option<PathBuf>,
    sample_limit: usize,
}

fn usage() -> &'static str {
    "Usage: cir_state_compare --ours-cir <path> --rpki-client-cir <path> --out-json <path> [--out-md <path>] [--sample-limit <n>]"
}

fn parse_args(argv: &[String]) -> Result<Args, String> {
    let mut args = Args {
        sample_limit: 20,
        ..Args::default()
    };
    let mut index = 1usize;
    while index < argv.len() {
        match argv[index].as_str() {
            "--ours-cir" => {
                index += 1;
                args.ours_cir = Some(PathBuf::from(
                    argv.get(index).ok_or("--ours-cir requires a value")?,
                ));
            }
            "--rpki-client-cir" | "--peer-cir" => {
                index += 1;
                args.peer_cir = Some(PathBuf::from(
                    argv.get(index)
                        .ok_or("--rpki-client-cir requires a value")?,
                ));
            }
            "--out-json" => {
                index += 1;
                args.out_json = Some(PathBuf::from(
                    argv.get(index).ok_or("--out-json requires a value")?,
                ));
            }
            "--out-md" => {
                index += 1;
                args.out_md = Some(PathBuf::from(
                    argv.get(index).ok_or("--out-md requires a value")?,
                ));
            }
            "--sample-limit" => {
                index += 1;
                let value = argv.get(index).ok_or("--sample-limit requires a value")?;
                args.sample_limit = value
                    .parse::<usize>()
                    .map_err(|_| format!("invalid --sample-limit: {value}"))?;
            }
            "-h" | "--help" => return Err(usage().to_string()),
            other => return Err(format!("unknown argument: {other}\n{}", usage())),
        }
        index += 1;
    }
    if args.ours_cir.is_none() {
        return Err(format!("--ours-cir is required\n{}", usage()));
    }
    if args.peer_cir.is_none() {
        return Err(format!("--rpki-client-cir is required\n{}", usage()));
    }
    if args.out_json.is_none() {
        return Err(format!("--out-json is required\n{}", usage()));
    }
    Ok(args)
}

fn main() {
    if let Err(err) = real_main() {
        eprintln!("{err}");
        std::process::exit(1);
    }
}

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

fn run(args: Args) -> Result<(), String> {
    let ours_path = args.ours_cir.as_ref().expect("validated");
    let peer_path = args.peer_cir.as_ref().expect("validated");
    let ours =
        decode_cir(&read_file(ours_path)?).map_err(|e| format!("decode ours CIR failed: {e}"))?;
    let peer = decode_cir(&read_file(peer_path)?)
        .map_err(|e| format!("decode rpki-client CIR failed: {e}"))?;

    let ours_objects = ours
        .validated_objects()
        .map(|item| (item.rsync_uri.clone(), hex::encode(&item.sha256)))
        .collect::<BTreeMap<_, _>>();
    let peer_objects = peer
        .validated_objects()
        .map(|item| (item.rsync_uri.clone(), hex::encode(&item.sha256)))
        .collect::<BTreeMap<_, _>>();
    let ours_rejects = ours
        .rejected_objects()
        .map(|item| item.object_uri.clone())
        .collect::<BTreeSet<_>>();
    let peer_rejects = peer
        .rejected_objects()
        .map(|item| item.object_uri.clone())
        .collect::<BTreeSet<_>>();
    let ours_trust_anchors = ours
        .trust_anchors
        .iter()
        .map(|item| {
            (
                item.ta_rsync_uri.clone(),
                hex::encode(&item.ta_certificate_sha256),
            )
        })
        .collect::<BTreeMap<_, _>>();
    let peer_trust_anchors = peer
        .trust_anchors
        .iter()
        .map(|item| {
            (
                item.ta_rsync_uri.clone(),
                hex::encode(&item.ta_certificate_sha256),
            )
        })
        .collect::<BTreeMap<_, _>>();

    let object_summary = compare_object_maps(&ours_objects, &peer_objects, args.sample_limit);
    let reject_summary = compare_sets(&ours_rejects, &peer_rejects, args.sample_limit);
    let trust_anchor_summary =
        compare_object_maps(&ours_trust_anchors, &peer_trust_anchors, args.sample_limit);
    let reject_hash_match = ours.reject_list_sha256 == peer.reject_list_sha256;
    let all_match = object_summary.match_
        && reject_summary.match_
        && trust_anchor_summary.match_
        && reject_hash_match;

    let summary = json!({
        "allMatch": all_match,
        "objects": object_summary.to_json(),
        "rejects": reject_summary.to_json(),
        "trustAnchors": trust_anchor_summary.to_json(),
        "trust_anchors": trust_anchor_summary.to_json(),
        "rejectListSha256Match": reject_hash_match,
        "ours": {
            "objectCount": ours.validated_object_count(),
            "talCount": ours.trust_anchors.len(),
            "trustAnchorCount": ours.trust_anchors.len(),
            "rejectCount": ours.rejected_object_count(),
            "rejectListSha256": hex::encode(&ours.reject_list_sha256),
            "validationTime": ours.validation_time.to_string(),
        },
        "rpkiClient": {
            "objectCount": peer.validated_object_count(),
            "talCount": peer.trust_anchors.len(),
            "trustAnchorCount": peer.trust_anchors.len(),
            "rejectCount": peer.rejected_object_count(),
            "rejectListSha256": hex::encode(&peer.reject_list_sha256),
            "validationTime": peer.validation_time.to_string(),
        }
    });

    let out_json = args.out_json.as_ref().expect("validated");
    write_json(out_json, &summary)?;
    if let Some(out_md) = args.out_md.as_ref() {
        write_markdown(out_md, &summary)?;
    }
    println!("{}", out_json.display());
    Ok(())
}

fn read_file(path: &Path) -> Result<Vec<u8>, String> {
    std::fs::read(path).map_err(|e| format!("read file failed: {}: {e}", path.display()))
}

fn write_json(path: &Path, value: &serde_json::Value) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|e| format!("create parent dirs failed: {}: {e}", parent.display()))?;
    }
    std::fs::write(
        path,
        serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?,
    )
    .map_err(|e| format!("write json failed: {}: {e}", path.display()))
}

fn write_markdown(path: &Path, summary: &serde_json::Value) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|e| format!("create parent dirs failed: {}: {e}", parent.display()))?;
    }
    let lines = vec![
        "# CIR State Compare Summary".to_string(),
        String::new(),
        format!(
            "- `allMatch`: `{}`",
            summary["allMatch"].as_bool().unwrap_or(false)
        ),
        format!(
            "- `objectsMatch`: `{}`",
            summary["objects"]["match"].as_bool().unwrap_or(false)
        ),
        format!(
            "- `rejectsMatch`: `{}`",
            summary["rejects"]["match"].as_bool().unwrap_or(false)
        ),
        format!(
            "- `trustAnchorsMatch`: `{}`",
            summary["trustAnchors"]["match"].as_bool().unwrap_or(false)
        ),
        format!(
            "- `rejectListSha256Match`: `{}`",
            summary["rejectListSha256Match"].as_bool().unwrap_or(false)
        ),
        format!(
            "- `ours`: objects `{}`, trustAnchors `{}`, rejects `{}`",
            summary["ours"]["objectCount"].as_u64().unwrap_or(0),
            summary["ours"]["trustAnchorCount"].as_u64().unwrap_or(0),
            summary["ours"]["rejectCount"].as_u64().unwrap_or(0)
        ),
        format!(
            "- `rpkiClient`: objects `{}`, trustAnchors `{}`, rejects `{}`",
            summary["rpkiClient"]["objectCount"].as_u64().unwrap_or(0),
            summary["rpkiClient"]["trustAnchorCount"]
                .as_u64()
                .unwrap_or(0),
            summary["rpkiClient"]["rejectCount"].as_u64().unwrap_or(0)
        ),
    ];
    std::fs::write(path, lines.join("\n") + "\n")
        .map_err(|e| format!("write markdown failed: {}: {e}", path.display()))
}

struct SetSummary {
    ours: usize,
    peer: usize,
    match_: bool,
    only_in_ours: Vec<String>,
    only_in_peer: Vec<String>,
}

impl SetSummary {
    fn to_json(&self) -> serde_json::Value {
        json!({
            "ours": self.ours,
            "rpkiClient": self.peer,
            "match": self.match_,
            "onlyInOurs": self.only_in_ours,
            "onlyInRpkiClient": self.only_in_peer,
        })
    }
}

struct ObjectSummary {
    ours: usize,
    peer: usize,
    match_: bool,
    only_in_ours_count: usize,
    only_in_peer_count: usize,
    hash_mismatch_count: usize,
    only_in_ours: Vec<String>,
    only_in_peer: Vec<String>,
    hash_mismatches: Vec<serde_json::Value>,
    only_in_ours_by_extension: BTreeMap<String, usize>,
    only_in_peer_by_extension: BTreeMap<String, usize>,
    hash_mismatches_by_extension: BTreeMap<String, usize>,
    only_in_ours_by_host: Vec<serde_json::Value>,
    only_in_peer_by_host: Vec<serde_json::Value>,
    hash_mismatches_by_host: Vec<serde_json::Value>,
}

impl ObjectSummary {
    fn to_json(&self) -> serde_json::Value {
        json!({
            "ours": self.ours,
            "rpkiClient": self.peer,
            "match": self.match_,
            "onlyInOursCount": self.only_in_ours_count,
            "onlyInRpkiClientCount": self.only_in_peer_count,
            "hashMismatchCount": self.hash_mismatch_count,
            "onlyInOurs": self.only_in_ours,
            "onlyInRpkiClient": self.only_in_peer,
            "hashMismatches": self.hash_mismatches,
            "onlyInOursByExtension": self.only_in_ours_by_extension,
            "onlyInRpkiClientByExtension": self.only_in_peer_by_extension,
            "hashMismatchesByExtension": self.hash_mismatches_by_extension,
            "onlyInOursByHostTop": self.only_in_ours_by_host,
            "onlyInRpkiClientByHostTop": self.only_in_peer_by_host,
            "hashMismatchesByHostTop": self.hash_mismatches_by_host,
        })
    }
}

fn compare_sets(ours: &BTreeSet<String>, peer: &BTreeSet<String>, limit: usize) -> SetSummary {
    SetSummary {
        ours: ours.len(),
        peer: peer.len(),
        match_: ours == peer,
        only_in_ours: ours.difference(peer).take(limit).cloned().collect(),
        only_in_peer: peer.difference(ours).take(limit).cloned().collect(),
    }
}

fn compare_object_maps(
    ours: &BTreeMap<String, String>,
    peer: &BTreeMap<String, String>,
    limit: usize,
) -> ObjectSummary {
    let ours_keys = ours.keys().cloned().collect::<BTreeSet<_>>();
    let peer_keys = peer.keys().cloned().collect::<BTreeSet<_>>();
    let only_in_ours_all = ours_keys
        .difference(&peer_keys)
        .cloned()
        .collect::<Vec<_>>();
    let only_in_peer_all = peer_keys
        .difference(&ours_keys)
        .cloned()
        .collect::<Vec<_>>();
    let only_in_ours = ours_keys
        .difference(&peer_keys)
        .take(limit)
        .cloned()
        .collect();
    let only_in_peer = peer_keys
        .difference(&ours_keys)
        .take(limit)
        .cloned()
        .collect();
    let mut hash_mismatches = Vec::new();
    let mut hash_mismatch_uris = Vec::new();
    for key in ours_keys.intersection(&peer_keys) {
        let ours_hash = ours.get(key).expect("key from map");
        let peer_hash = peer.get(key).expect("key from map");
        if ours_hash != peer_hash {
            hash_mismatch_uris.push(key.clone());
            if hash_mismatches.len() < limit {
                hash_mismatches.push(json!({
                    "uri": key,
                    "oursSha256": ours_hash,
                    "rpkiClientSha256": peer_hash,
                }));
            }
        }
    }
    ObjectSummary {
        ours: ours.len(),
        peer: peer.len(),
        match_: ours == peer,
        only_in_ours_count: only_in_ours_all.len(),
        only_in_peer_count: only_in_peer_all.len(),
        hash_mismatch_count: hash_mismatch_uris.len(),
        only_in_ours,
        only_in_peer,
        hash_mismatches,
        only_in_ours_by_extension: group_by_extension(only_in_ours_all.iter().map(String::as_str)),
        only_in_peer_by_extension: group_by_extension(only_in_peer_all.iter().map(String::as_str)),
        hash_mismatches_by_extension: group_by_extension(
            hash_mismatch_uris.iter().map(String::as_str),
        ),
        only_in_ours_by_host: top_hosts(only_in_ours_all.iter().map(String::as_str), limit),
        only_in_peer_by_host: top_hosts(only_in_peer_all.iter().map(String::as_str), limit),
        hash_mismatches_by_host: top_hosts(hash_mismatch_uris.iter().map(String::as_str), limit),
    }
}

fn group_by_extension<'a>(uris: impl IntoIterator<Item = &'a str>) -> BTreeMap<String, usize> {
    let mut counts = BTreeMap::new();
    for uri in uris {
        *counts.entry(uri_extension(uri)).or_insert(0) += 1;
    }
    counts
}

fn top_hosts<'a>(uris: impl IntoIterator<Item = &'a str>, limit: usize) -> Vec<serde_json::Value> {
    let mut counts = BTreeMap::new();
    for uri in uris {
        *counts.entry(uri_host(uri)).or_insert(0usize) += 1;
    }
    let mut rows = counts.into_iter().collect::<Vec<_>>();
    rows.sort_by(|(host_a, count_a), (host_b, count_b)| {
        count_b.cmp(count_a).then_with(|| host_a.cmp(host_b))
    });
    rows.into_iter()
        .take(limit)
        .map(|(host, count)| {
            json!({
                "host": host,
                "count": count,
            })
        })
        .collect()
}

fn uri_host(uri: &str) -> String {
    let without_scheme = uri.split_once("://").map(|(_, rest)| rest).unwrap_or(uri);
    without_scheme
        .split('/')
        .next()
        .filter(|host| !host.is_empty())
        .unwrap_or("<unknown>")
        .to_string()
}

fn uri_extension(uri: &str) -> String {
    let path = uri.split_once("://").map(|(_, rest)| rest).unwrap_or(uri);
    let path = path.split_once('/').map(|(_, path)| path).unwrap_or(path);
    let file = path.rsplit('/').next().unwrap_or(path);
    if let Some((_, ext)) = file.rsplit_once('.')
        && !ext.is_empty()
    {
        return format!(".{}", ext.to_ascii_lowercase());
    }
    "<none>".to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use rpki::cir::{
        CanonicalInputRepresentation, CirObject, CirRejectedObject, CirTrustAnchor, encode_cir,
        sha256,
    };

    #[test]
    fn parse_args_accepts_required_flags() {
        let args = parse_args(&[
            "cir_state_compare".to_string(),
            "--ours-cir".to_string(),
            "ours.cir".to_string(),
            "--rpki-client-cir".to_string(),
            "peer.cir".to_string(),
            "--out-json".to_string(),
            "summary.json".to_string(),
            "--sample-limit".to_string(),
            "3".to_string(),
        ])
        .expect("parse args");
        assert_eq!(args.ours_cir.as_deref(), Some(Path::new("ours.cir")));
        assert_eq!(args.peer_cir.as_deref(), Some(Path::new("peer.cir")));
        assert_eq!(args.out_json.as_deref(), Some(Path::new("summary.json")));
        assert_eq!(args.sample_limit, 3);
    }

    #[test]
    fn parse_args_rejects_missing_and_invalid_flags() {
        let missing_peer = parse_args(&[
            "cir_state_compare".to_string(),
            "--ours-cir".to_string(),
            "ours.cir".to_string(),
            "--out-json".to_string(),
            "summary.json".to_string(),
        ])
        .unwrap_err();
        assert!(
            missing_peer.contains("--rpki-client-cir is required"),
            "{missing_peer}"
        );

        let invalid_limit = parse_args(&[
            "cir_state_compare".to_string(),
            "--ours-cir".to_string(),
            "ours.cir".to_string(),
            "--peer-cir".to_string(),
            "peer.cir".to_string(),
            "--out-json".to_string(),
            "summary.json".to_string(),
            "--sample-limit".to_string(),
            "not-a-number".to_string(),
        ])
        .unwrap_err();
        assert!(
            invalid_limit.contains("invalid --sample-limit"),
            "{invalid_limit}"
        );

        let unknown = parse_args(&[
            "cir_state_compare".to_string(),
            "--ours-cir".to_string(),
            "ours.cir".to_string(),
            "--peer-cir".to_string(),
            "peer.cir".to_string(),
            "--out-json".to_string(),
            "summary.json".to_string(),
            "--unexpected".to_string(),
        ])
        .unwrap_err();
        assert!(unknown.contains("unknown argument"), "{unknown}");
    }

    #[test]
    fn run_reports_identical_cirs_and_writes_outputs() {
        let temp = tempfile::tempdir().expect("tempdir");
        let cir = sample_cir(
            &[
                ("rsync://example.net/repo/a.cer", 0x11),
                ("rsync://example.net/repo/b.roa", 0x22),
            ],
            &["https://tal.example.net/apnic.tal"],
            &[("rsync://example.net/repo/rejected.roa", Some("bad roa"))],
        );
        let ours = temp.path().join("ours.cir");
        let peer = temp.path().join("peer.cir");
        write_cir(&ours, &cir);
        write_cir(&peer, &cir);
        let out_json = temp.path().join("nested/summary.json");
        let out_md = temp.path().join("nested/summary.md");

        run(Args {
            ours_cir: Some(ours),
            peer_cir: Some(peer),
            out_json: Some(out_json.clone()),
            out_md: Some(out_md.clone()),
            sample_limit: 10,
        })
        .expect("run");

        let summary: serde_json::Value =
            serde_json::from_slice(&std::fs::read(out_json).expect("read summary"))
                .expect("summary json");
        assert_eq!(summary["allMatch"], true);
        assert_eq!(summary["objects"]["match"], true);
        assert_eq!(summary["rejects"]["match"], true);
        assert_eq!(summary["trustAnchors"]["match"], true);
        assert_eq!(summary["rejectListSha256Match"], true);
        assert_eq!(summary["ours"]["objectCount"], 3);
        assert!(
            std::fs::read_to_string(out_md)
                .expect("read markdown")
                .contains("allMatch")
        );
    }

    #[test]
    fn run_reports_object_reject_trust_anchor_differences_with_samples() {
        let temp = tempfile::tempdir().expect("tempdir");
        let ours_cir = sample_cir(
            &[
                ("rsync://example.net/repo/a.roa", 0x11),
                ("rsync://example.net/repo/c.roa", 0x33),
            ],
            &["https://tal.example.net/apnic.tal"],
            &[("rsync://example.net/repo/rejected-a.roa", Some("ours"))],
        );
        let peer_cir = sample_cir(
            &[
                ("rsync://example.net/repo/a.roa", 0x22),
                ("rsync://example.net/repo/b.roa", 0x44),
            ],
            &["https://tal.example.net/arin.tal"],
            &[("rsync://example.net/repo/rejected-b.roa", Some("peer"))],
        );
        let ours = temp.path().join("ours.cir");
        let peer = temp.path().join("peer.cir");
        write_cir(&ours, &ours_cir);
        write_cir(&peer, &peer_cir);
        let out_json = temp.path().join("summary.json");

        run(Args {
            ours_cir: Some(ours),
            peer_cir: Some(peer),
            out_json: Some(out_json.clone()),
            out_md: None,
            sample_limit: 1,
        })
        .expect("run");

        let summary: serde_json::Value =
            serde_json::from_slice(&std::fs::read(out_json).expect("read summary"))
                .expect("summary json");
        assert_eq!(summary["allMatch"], false);
        assert_eq!(summary["objects"]["match"], false);
        assert_eq!(
            summary["objects"]["onlyInOurs"].as_array().unwrap().len(),
            1
        );
        assert_eq!(
            summary["objects"]["onlyInRpkiClient"]
                .as_array()
                .unwrap()
                .len(),
            1
        );
        assert_eq!(
            summary["objects"]["hashMismatches"]
                .as_array()
                .unwrap()
                .len(),
            1
        );
        assert_eq!(summary["rejects"]["match"], false);
        assert_eq!(summary["trustAnchors"]["match"], false);
        assert_eq!(summary["rejectListSha256Match"], false);
    }

    #[test]
    fn run_ignores_reject_reasons_for_state_comparison() {
        let temp = tempfile::tempdir().expect("tempdir");
        let ours_cir = sample_cir(
            &[("rsync://example.net/repo/a.roa", 0x11)],
            &["https://tal.example.net/apnic.tal"],
            &[("rsync://example.net/repo/rejected.roa", Some("reason-a"))],
        );
        let peer_cir = sample_cir(
            &[("rsync://example.net/repo/a.roa", 0x11)],
            &["https://tal.example.net/apnic.tal"],
            &[("rsync://example.net/repo/rejected.roa", Some("reason-b"))],
        );
        let ours = temp.path().join("ours.cir");
        let peer = temp.path().join("peer.cir");
        write_cir(&ours, &ours_cir);
        write_cir(&peer, &peer_cir);
        let out_json = temp.path().join("summary.json");

        run(Args {
            ours_cir: Some(ours),
            peer_cir: Some(peer),
            out_json: Some(out_json.clone()),
            out_md: None,
            sample_limit: 20,
        })
        .expect("run");

        let summary: serde_json::Value =
            serde_json::from_slice(&std::fs::read(out_json).expect("read summary"))
                .expect("summary json");
        assert_eq!(summary["allMatch"], true);
        assert_eq!(summary["rejects"]["match"], true);
        assert_eq!(summary["rejectListSha256Match"], true);
    }

    #[test]
    fn run_reports_decode_failure_with_side_label() {
        let temp = tempfile::tempdir().expect("tempdir");
        let ours = temp.path().join("ours.cir");
        let peer = temp.path().join("peer.cir");
        std::fs::write(&ours, b"not der").expect("write invalid");
        write_cir(
            &peer,
            &sample_cir(
                &[("rsync://example.net/repo/a.roa", 0x11)],
                &["https://tal.example.net/apnic.tal"],
                &[],
            ),
        );
        let err = run(Args {
            ours_cir: Some(ours),
            peer_cir: Some(peer),
            out_json: Some(temp.path().join("summary.json")),
            out_md: None,
            sample_limit: 20,
        })
        .unwrap_err();
        assert!(err.contains("decode ours CIR failed"), "{err}");
    }

    #[test]
    fn compare_object_maps_reports_hash_mismatch() {
        let ours = BTreeMap::from([
            ("rsync://example/a.roa".to_string(), "11".repeat(32)),
            ("rsync://example/b.mft".to_string(), "33".repeat(32)),
        ]);
        let peer = BTreeMap::from([
            ("rsync://example/a.roa".to_string(), "22".repeat(32)),
            ("rsync://peer.example/c.crl".to_string(), "44".repeat(32)),
        ]);
        let summary = compare_object_maps(&ours, &peer, 20);
        assert!(!summary.match_);
        assert_eq!(summary.only_in_ours_count, 1);
        assert_eq!(summary.only_in_peer_count, 1);
        assert_eq!(summary.hash_mismatch_count, 1);
        assert_eq!(summary.hash_mismatches.len(), 1);
        assert_eq!(
            summary.only_in_ours_by_extension.get(".mft").copied(),
            Some(1)
        );
        assert_eq!(
            summary.only_in_peer_by_extension.get(".crl").copied(),
            Some(1)
        );
        assert_eq!(
            summary.hash_mismatches_by_extension.get(".roa").copied(),
            Some(1)
        );
    }

    #[test]
    fn uri_grouping_handles_hosts_extensions_and_limit() {
        let uris = [
            "rsync://b.example/repo/a.ROA",
            "rsync://a.example/repo/b.mft",
            "rsync://b.example/repo/c",
        ];
        let by_extension = group_by_extension(uris.iter().copied());
        assert_eq!(by_extension.get(".roa").copied(), Some(1));
        assert_eq!(by_extension.get(".mft").copied(), Some(1));
        assert_eq!(by_extension.get("<none>").copied(), Some(1));

        let top = top_hosts(uris.iter().copied(), 1);
        assert_eq!(top.len(), 1);
        assert_eq!(top[0]["host"], "b.example");
        assert_eq!(top[0]["count"], 2);
    }

    fn sample_time() -> time::OffsetDateTime {
        time::OffsetDateTime::UNIX_EPOCH
    }

    fn sample_cir(
        objects: &[(&str, u8)],
        trust_anchors: &[&str],
        rejected_objects: &[(&str, Option<&str>)],
    ) -> CanonicalInputRepresentation {
        let mut cir_objects = objects
            .iter()
            .map(|(rsync_uri, fill)| CirObject {
                rsync_uri: (*rsync_uri).to_string(),
                sha256: vec![*fill; 32],
            })
            .collect::<Vec<_>>();
        for (object_uri, _) in rejected_objects {
            if cir_objects
                .iter()
                .all(|object| object.rsync_uri != *object_uri)
            {
                cir_objects.push(CirObject {
                    rsync_uri: (*object_uri).to_string(),
                    sha256: vec![0xee; 32],
                });
            }
        }
        let rejected_objects = rejected_objects
            .iter()
            .map(|(object_uri, reason)| CirRejectedObject {
                object_uri: (*object_uri).to_string(),
                reason: reason.map(str::to_string),
            })
            .collect::<Vec<_>>();
        CanonicalInputRepresentation::new_v4(
            sample_time(),
            cir_objects,
            Vec::new(),
            trust_anchors
                .iter()
                .map(|tal_uri| {
                    let name = tal_uri
                        .rsplit('/')
                        .next()
                        .unwrap_or("root.tal")
                        .trim_end_matches(".tal");
                    let ta_rsync_uri = format!("rsync://example.net/repo/{name}.cer");
                    let ta_certificate_der = format!("ta-der-{name}").into_bytes();
                    CirTrustAnchor {
                        ta_rsync_uri: ta_rsync_uri.clone(),
                        tal_uri: (*tal_uri).to_string(),
                        tal_bytes: format!("{ta_rsync_uri}\n\nAQID\n").into_bytes(),
                        ta_certificate_sha256: sha256(&ta_certificate_der),
                        ta_certificate_der,
                    }
                })
                .collect(),
            rejected_objects,
            Vec::new(),
        )
    }

    fn write_cir(path: &Path, cir: &CanonicalInputRepresentation) {
        std::fs::write(path, encode_cir(cir).expect("encode cir")).expect("write cir");
    }
}
