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

use rpki::ccr::{compare_state_digests, decode_content_info};
use rpki::cir::decode_cir;
use serde_json::{Value, json};
use time::format_description::well_known::Rfc3339;

#[derive(Debug, PartialEq, Eq)]
struct Args {
    left_ccr: PathBuf,
    left_cir: PathBuf,
    right_ccr: PathBuf,
    right_cir: PathBuf,
    out_dir: PathBuf,
    sample_limit: usize,
}

fn usage() -> &'static str {
    "Usage: triage_ccr_cir_pair --left-ccr <path> --left-cir <path> --right-ccr <path> --right-cir <path> --out-dir <path> [--sample-limit <n>]"
}

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 parse_args(argv: &[String]) -> Result<Args, String> {
    let mut left_ccr = None;
    let mut left_cir = None;
    let mut right_ccr = None;
    let mut right_cir = None;
    let mut out_dir = None;
    let mut sample_limit = 200usize;
    let mut index = 1usize;
    while index < argv.len() {
        match argv[index].as_str() {
            "--left-ccr" => {
                index += 1;
                left_ccr = Some(PathBuf::from(
                    argv.get(index).ok_or("--left-ccr requires a value")?,
                ));
            }
            "--left-cir" => {
                index += 1;
                left_cir = Some(PathBuf::from(
                    argv.get(index).ok_or("--left-cir requires a value")?,
                ));
            }
            "--right-ccr" => {
                index += 1;
                right_ccr = Some(PathBuf::from(
                    argv.get(index).ok_or("--right-ccr requires a value")?,
                ));
            }
            "--right-cir" => {
                index += 1;
                right_cir = Some(PathBuf::from(
                    argv.get(index).ok_or("--right-cir requires a value")?,
                ));
            }
            "--out-dir" => {
                index += 1;
                out_dir = Some(PathBuf::from(
                    argv.get(index).ok_or("--out-dir requires a value")?,
                ));
            }
            "--sample-limit" => {
                index += 1;
                let value = argv.get(index).ok_or("--sample-limit requires a value")?;
                sample_limit = value
                    .parse::<usize>()
                    .map_err(|_| format!("invalid --sample-limit: {value}"))?;
            }
            "--left-meta" | "--right-meta" | "--compare-view-trust-anchor" => {
                return Err(format!(
                    "{} is no longer supported by four-file triage\n{}",
                    argv[index],
                    usage()
                ));
            }
            "-h" | "--help" => return Err(usage().to_string()),
            other => return Err(format!("unknown argument: {other}\n{}", usage())),
        }
        index += 1;
    }
    Ok(Args {
        left_ccr: left_ccr.ok_or_else(|| format!("--left-ccr is required\n{}", usage()))?,
        left_cir: left_cir.ok_or_else(|| format!("--left-cir is required\n{}", usage()))?,
        right_ccr: right_ccr.ok_or_else(|| format!("--right-ccr is required\n{}", usage()))?,
        right_cir: right_cir.ok_or_else(|| format!("--right-cir is required\n{}", usage()))?,
        out_dir: out_dir.ok_or_else(|| format!("--out-dir is required\n{}", usage()))?,
        sample_limit,
    })
}

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 ccr_summary = build_ccr_summary(&args)?;
    let cir_summary = build_cir_summary(&args)?;
    let findings = build_findings(&ccr_summary, &cir_summary);
    let dominant_findings = dominant_findings(&findings);
    let all_match = ccr_summary["stateDigestMatch"].as_bool().unwrap_or(false)
        && cir_summary["allMatch"].as_bool().unwrap_or(false);
    let triage = json!({
        "schemaVersion": 2,
        "generatedBy": "triage_ccr_cir_pair",
        "inputContract": "four-file-ccr-cir-only",
        "left": side_summary("left", &args.left_ccr, &args.left_cir),
        "right": side_summary("right", &args.right_ccr, &args.right_cir),
        "sampleLimit": args.sample_limit,
        "outputRelation": if ccr_summary["stateDigestMatch"].as_bool().unwrap_or(false) { "same" } else { "different" },
        "processRelation": if cir_summary["allMatch"].as_bool().unwrap_or(false) { "same" } else { "different" },
        "allMatch": all_match,
        "findings": findings,
        "dominantFindings": dominant_findings,
        "ccr": ccr_summary,
        "cir": cir_summary,
        "manualInvestigationHints": manual_hints(),
        "interpretationLimits": interpretation_limits(),
    });
    write_json(&args.out_dir.join("ccr-summary.json"), &triage["ccr"])?;
    write_json(&args.out_dir.join("cir-summary.json"), &triage["cir"])?;
    write_json(&args.out_dir.join("triage.json"), &triage)?;
    write_markdown(&args.out_dir.join("triage.md"), &triage)?;
    write_samples_jsonl(&args.out_dir.join("diff-samples.jsonl"), &triage)?;
    println!("{}", args.out_dir.display());
    Ok(())
}

fn build_ccr_summary(args: &Args) -> Result<Value, String> {
    let left = read_file(&args.left_ccr)?;
    let right = read_file(&args.right_ccr)?;
    let comparison = compare_state_digests(&left, &right).map_err(|e| e.to_string())?;
    let left_content = decode_content_info(&left).map_err(|e| e.to_string())?;
    let right_content = decode_content_info(&right).map_err(|e| e.to_string())?;
    let state_digest_match = comparison.matches();
    let mismatched_states = comparison.mismatched_state_names();
    let mut mismatched_components = Vec::new();
    if comparison.ours.version != comparison.peer.version {
        mismatched_components.push("version".to_string());
    }
    if comparison.ours.hash_alg_oid != comparison.peer.hash_alg_oid {
        mismatched_components.push("hashAlgorithm".to_string());
    }
    mismatched_components.extend(mismatched_states.iter().map(|item| (*item).to_string()));
    let compare_views = if state_digest_match {
        None
    } else {
        Some(build_compare_view_summary(
            &left_content,
            &right_content,
            args.sample_limit,
        )?)
    };
    Ok(json!({
        "stateDigestMatch": state_digest_match,
        "comparePath": if state_digest_match {
            "ccr_state_digest_match"
        } else if compare_views.as_ref().is_some_and(|item| item.vrps_match && item.vaps_match) {
            "ccr_state_digest_mismatch_with_compare_views_match"
        } else {
            "ccr_state_digest_mismatch_with_set_diff"
        },
        "mismatchedStates": mismatched_states,
        "mismatchedComponents": mismatched_components,
        "versionMatch": comparison.ours.version == comparison.peer.version,
        "hashAlgorithmMatch": comparison.ours.hash_alg_oid == comparison.peer.hash_alg_oid,
        "left": {
            "version": comparison.ours.version,
            "hashAlg": comparison.ours.hash_alg_oid,
            "producedAt": format_time(left_content.content.produced_at)?,
        },
        "right": {
            "version": comparison.peer.version,
            "hashAlg": comparison.peer.hash_alg_oid,
            "producedAt": format_time(right_content.content.produced_at)?,
        },
        "states": comparison.states.iter().map(|state| json!({
            "name": state.name,
            "match": state.matches,
            "leftPresent": state.ours_present,
            "rightPresent": state.peer_present,
            "leftHash": state.ours_hash_hex,
            "rightHash": state.peer_hash_hex,
        })).collect::<Vec<_>>(),
        "compareViews": compare_views.map(CompareViewSummary::to_json),
    }))
}

struct CompareViewSummary {
    vrps_match: bool,
    vaps_match: bool,
    json: Value,
}

impl CompareViewSummary {
    fn to_json(self) -> Value {
        self.json
    }
}

fn build_compare_view_summary(
    left: &rpki::ccr::CcrContentInfo,
    right: &rpki::ccr::CcrContentInfo,
    sample_limit: usize,
) -> Result<CompareViewSummary, String> {
    let left_vrps = ccr_vrp_rows(left)?;
    let right_vrps = ccr_vrp_rows(right)?;
    let left_vaps = ccr_vap_rows(left);
    let right_vaps = ccr_vap_rows(right);
    let vrps = compare_sets(&left_vrps, &right_vrps, sample_limit);
    let vaps = compare_sets(&left_vaps, &right_vaps, sample_limit);
    Ok(CompareViewSummary {
        vrps_match: vrps.match_,
        vaps_match: vaps.match_,
        json: json!({
            "vrps": vrps.to_json_with(|row| json!({
                "asn": row.0,
                "prefix": row.1,
                "maxLength": row.2,
            })),
            "vaps": vaps.to_json_with(|row| json!({
                "customerAsn": row.0,
                "providers": row.1,
            })),
        }),
    })
}

fn ccr_vrp_rows(
    content_info: &rpki::ccr::CcrContentInfo,
) -> Result<BTreeSet<(String, String, String)>, String> {
    Ok(rpki::ccr::extract_vrp_rows(content_info)
        .map_err(|e| e.to_string())?
        .into_iter()
        .map(|(asn, prefix, max_length)| (format!("AS{asn}"), prefix, max_length.to_string()))
        .collect())
}

fn ccr_vap_rows(content_info: &rpki::ccr::CcrContentInfo) -> BTreeSet<(String, String)> {
    content_info
        .content
        .vaps
        .as_ref()
        .map(|state| {
            state
                .aps
                .iter()
                .map(|vap| {
                    let providers = vap
                        .providers
                        .iter()
                        .map(|provider| format!("AS{provider}"))
                        .collect::<Vec<_>>()
                        .join(";");
                    (format!("AS{}", vap.customer_as_id), providers)
                })
                .collect()
        })
        .unwrap_or_default()
}

fn build_cir_summary(args: &Args) -> Result<Value, String> {
    let left = decode_cir(&read_file(&args.left_cir)?).map_err(|e| e.to_string())?;
    let right = decode_cir(&read_file(&args.right_cir)?).map_err(|e| e.to_string())?;
    let left_objects = left
        .validated_objects()
        .map(|item| (item.rsync_uri.clone(), hex::encode(&item.sha256)))
        .collect::<BTreeMap<_, _>>();
    let right_objects = right
        .validated_objects()
        .map(|item| (item.rsync_uri.clone(), hex::encode(&item.sha256)))
        .collect::<BTreeMap<_, _>>();
    let left_rejects = left
        .rejected_objects()
        .map(|item| item.object_uri.clone())
        .collect::<BTreeSet<_>>();
    let right_rejects = right
        .rejected_objects()
        .map(|item| item.object_uri.clone())
        .collect::<BTreeSet<_>>();
    let left_trust_anchors = left
        .trust_anchors
        .iter()
        .map(|item| {
            (
                item.ta_rsync_uri.clone(),
                TrustAnchorProjection {
                    tal_uri: item.tal_uri.clone(),
                    tal_sha256: hex::encode(rpki::cir::sha256(&item.tal_bytes)),
                    ta_certificate_sha256: hex::encode(&item.ta_certificate_sha256),
                },
            )
        })
        .collect::<BTreeMap<_, _>>();
    let right_trust_anchors = right
        .trust_anchors
        .iter()
        .map(|item| {
            (
                item.ta_rsync_uri.clone(),
                TrustAnchorProjection {
                    tal_uri: item.tal_uri.clone(),
                    tal_sha256: hex::encode(rpki::cir::sha256(&item.tal_bytes)),
                    ta_certificate_sha256: hex::encode(&item.ta_certificate_sha256),
                },
            )
        })
        .collect::<BTreeMap<_, _>>();
    let objects = compare_object_maps(&left_objects, &right_objects, args.sample_limit);
    let rejects = compare_sets(&left_rejects, &right_rejects, args.sample_limit);
    let trust_anchors = compare_projection_maps(
        &left_trust_anchors,
        &right_trust_anchors,
        args.sample_limit,
        |projection| projection.to_json(),
    );
    let reject_hash_match = left.reject_list_sha256 == right.reject_list_sha256;
    let validation_time_match = left.validation_time == right.validation_time;
    Ok(json!({
        "allMatch": objects.match_ && rejects.match_ && trust_anchors.match_ && reject_hash_match && validation_time_match,
        "objects": objects.to_json(),
        "rejects": rejects.to_json_with(|item| json!(item)),
        "trustAnchors": trust_anchors.to_json(),
        "rejectListSha256Match": reject_hash_match,
        "validationTimeMatch": validation_time_match,
        "left": {
            "objectCount": left.validated_object_count(),
            "rejectCount": left.rejected_object_count(),
            "trustAnchorCount": left.trust_anchors.len(),
            "rejectListSha256": hex::encode(&left.reject_list_sha256),
            "validationTime": format_time(left.validation_time)?,
        },
        "right": {
            "objectCount": right.validated_object_count(),
            "rejectCount": right.rejected_object_count(),
            "trustAnchorCount": right.trust_anchors.len(),
            "rejectListSha256": hex::encode(&right.reject_list_sha256),
            "validationTime": format_time(right.validation_time)?,
        },
    }))
}

#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct TrustAnchorProjection {
    tal_uri: String,
    tal_sha256: String,
    ta_certificate_sha256: String,
}

impl TrustAnchorProjection {
    fn to_json(&self) -> Value {
        json!({
            "talUri": self.tal_uri,
            "talSha256": self.tal_sha256,
            "taCertificateSha256": self.ta_certificate_sha256,
        })
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
struct Finding {
    code: &'static str,
    title: &'static str,
    evidence_ref: Vec<&'static str>,
    reasoning: &'static str,
}

impl Finding {
    fn to_json(&self) -> Value {
        json!({
            "code": self.code,
            "title": self.title,
            "evidenceRef": self.evidence_ref,
            "reasoning": self.reasoning,
        })
    }
}

fn build_findings(ccr: &Value, cir: &Value) -> Vec<Value> {
    let mut findings = Vec::new();
    if cir["trustAnchors"]["match"].as_bool() != Some(true) {
        findings.push(
            Finding {
                code: "P1_TRUST_ANCHOR_DIFFERENCE",
                title: "TrustAnchor projection differs",
                evidence_ref: vec!["cir.trustAnchors"],
                reasoning: "CIR trustAnchors differ by TA URI or self-contained TAL/TA certificate projection.",
            }
            .to_json(),
        );
    }
    let object_uri_diff = cir["objects"]["onlyInLeftCount"].as_u64().unwrap_or(0) > 0
        || cir["objects"]["onlyInRightCount"].as_u64().unwrap_or(0) > 0;
    if object_uri_diff {
        findings.push(
            Finding {
                code: "P2_OBJECT_URI_SET_DIFFERENCE",
                title: "Consumed object URI set differs",
                evidence_ref: vec!["cir.objects.onlyInLeft", "cir.objects.onlyInRight"],
                reasoning: "CIR objects encode the actual validation input objects consumed by each side; URI set difference indicates different consumed input.",
            }
            .to_json(),
        );
    }
    if cir["objects"]["hashMismatchCount"].as_u64().unwrap_or(0) > 0 {
        findings.push(
            Finding {
                code: "P3_OBJECT_CONTENT_HASH_DIFFERENCE",
                title: "Consumed object content differs",
                evidence_ref: vec!["cir.objects.hashMismatches"],
                reasoning: "Both sides consumed the same object URI but recorded different SHA-256 values.",
            }
            .to_json(),
        );
    }
    if cir["rejects"]["match"].as_bool() != Some(true)
        || cir["rejectListSha256Match"].as_bool() != Some(true)
    {
        findings.push(
            Finding {
                code: "P4_REJECT_DECISION_DIFFERENCE",
                title: "Reject decision differs",
                evidence_ref: vec!["cir.rejects", "cir.rejectListSha256Match"],
                reasoning: "Reject-list comparison is based on sorted rejected object URIs; reject reasons are advisory and not part of the digest.",
            }
            .to_json(),
        );
    }
    if cir["validationTimeMatch"].as_bool() != Some(true) {
        findings.push(
            Finding {
                code: "P5_VALIDATION_TIME_DIFFERENCE",
                title: "Validation time differs",
                evidence_ref: vec!["cir.left.validationTime", "cir.right.validationTime"],
                reasoning: "Validation time can affect time-window checks; four-file triage reports it as a candidate explanation, not a proven root cause.",
            }
            .to_json(),
        );
    }
    let ccr_match = ccr["stateDigestMatch"].as_bool().unwrap_or(false);
    let has_process_findings = !findings.is_empty();
    if !ccr_match && !has_process_findings {
        findings.push(
            Finding {
                code: "P6_CCR_ONLY_DIFFERENCE",
                title: "CCR differs without CIR-level explanation",
                evidence_ref: vec!["ccr.states", "ccr.compareViews"],
                reasoning: "CCR state digest differs, but trust anchors, consumed input, reject decisions, and validation time are equivalent in CIR.",
            }
            .to_json(),
        );
    } else if ccr_match && has_process_findings {
        findings.push(
            Finding {
                code: "P7_EQUIVALENT_OUTPUT_PROCESS_DIFFERENCE",
                title: "Final CCR equal but validation process differs",
                evidence_ref: vec!["ccr.stateDigestMatch", "cir"],
                reasoning: "The final CCR state is equivalent even though CIR shows process/input differences.",
            }
            .to_json(),
        );
    } else if ccr_match && !has_process_findings {
        findings.push(
            Finding {
                code: "P8_ALL_EQUAL",
                title: "CCR and CIR are equivalent",
                evidence_ref: vec!["ccr.stateDigestMatch", "cir.allMatch"],
                reasoning: "CCR state digest and all CIR comparison dimensions match.",
            }
            .to_json(),
        );
    }
    findings
}

fn dominant_findings(findings: &[Value]) -> Vec<Value> {
    let dominant = [
        "P0_ARTIFACT_INVALID",
        "P1_TRUST_ANCHOR_DIFFERENCE",
        "P2_OBJECT_URI_SET_DIFFERENCE",
        "P3_OBJECT_CONTENT_HASH_DIFFERENCE",
        "P4_REJECT_DECISION_DIFFERENCE",
        "P5_VALIDATION_TIME_DIFFERENCE",
        "P6_CCR_ONLY_DIFFERENCE",
        "P7_EQUIVALENT_OUTPUT_PROCESS_DIFFERENCE",
        "P8_ALL_EQUAL",
    ];
    for code in dominant {
        let selected = findings
            .iter()
            .filter(|finding| finding["code"].as_str() == Some(code))
            .cloned()
            .collect::<Vec<_>>();
        if !selected.is_empty() {
            return selected;
        }
    }
    Vec::new()
}

fn manual_hints() -> Vec<String> {
    vec![
        "If P1 is present, first inspect CIR trustAnchors because different TA inputs can dominate all downstream differences.".into(),
        "If P2 or P3 is present, inspect consumed object URI/hash samples; this usually points to sync or repository-view differences.".into(),
        "If P4 is present without P2/P3, focus on validation policy, parser strictness, or reject decision differences.".into(),
        "If only P5 is present, treat validation time as an observation unless a separate object-level investigation proves time-window impact.".into(),
        "If P6 is present, CCR projection, ordering, encoding, or CCR-only state needs manual inspection.".into(),
    ]
}

fn interpretation_limits() -> Vec<String> {
    vec![
        "Four-file triage only uses two CCR files and two CIR files; it never reads run metadata, logs, report JSON, cache, mirror, repo-bytes DB, or raw objects.".into(),
        "The tool can classify observable CCR/CIR differences but cannot prove low-level object root cause without a separate parser workflow.".into(),
        "Reject reasons are preserved as advisory fields in CIR but reject-list equality is defined by sorted rejected object URI set.".into(),
        "Validation-time differences are reported as candidate explanations, not as definitive causes.".into(),
    ]
}

fn side_summary(label: &str, ccr: &Path, cir: &Path) -> Value {
    json!({
        "label": label,
        "ccr": path_string(ccr),
        "cir": path_string(cir),
    })
}

fn path_string(path: &Path) -> String {
    path.to_string_lossy().into_owned()
}

#[derive(Clone, Debug)]
struct SetSummary<T> {
    left: usize,
    right: usize,
    match_: bool,
    only_in_left_count: usize,
    only_in_right_count: usize,
    only_in_left: Vec<T>,
    only_in_right: Vec<T>,
}

impl<T: Ord + Clone> SetSummary<T> {
    fn build(left: &BTreeSet<T>, right: &BTreeSet<T>, sample_limit: usize) -> Self {
        let only_in_left_all = left.difference(right).cloned().collect::<Vec<_>>();
        let only_in_right_all = right.difference(left).cloned().collect::<Vec<_>>();
        Self {
            left: left.len(),
            right: right.len(),
            match_: left == right,
            only_in_left_count: only_in_left_all.len(),
            only_in_right_count: only_in_right_all.len(),
            only_in_left: only_in_left_all.into_iter().take(sample_limit).collect(),
            only_in_right: only_in_right_all.into_iter().take(sample_limit).collect(),
        }
    }

    fn to_json_with<F>(&self, mut encode: F) -> Value
    where
        F: FnMut(&T) -> Value,
    {
        json!({
            "left": self.left,
            "right": self.right,
            "match": self.match_,
            "onlyInLeftCount": self.only_in_left_count,
            "onlyInRightCount": self.only_in_right_count,
            "onlyInLeft": self.only_in_left.iter().map(&mut encode).collect::<Vec<_>>(),
            "onlyInRight": self.only_in_right.iter().map(&mut encode).collect::<Vec<_>>(),
        })
    }
}

fn compare_sets<T: Ord + Clone>(
    left: &BTreeSet<T>,
    right: &BTreeSet<T>,
    sample_limit: usize,
) -> SetSummary<T> {
    SetSummary::build(left, right, sample_limit)
}

struct ObjectSummary {
    left: usize,
    right: usize,
    match_: bool,
    only_in_left_count: usize,
    only_in_right_count: usize,
    hash_mismatch_count: usize,
    only_in_left: Vec<String>,
    only_in_right: Vec<String>,
    hash_mismatches: Vec<Value>,
    only_in_left_by_extension: BTreeMap<String, usize>,
    only_in_right_by_extension: BTreeMap<String, usize>,
    hash_mismatches_by_extension: BTreeMap<String, usize>,
    only_in_left_by_host: Vec<Value>,
    only_in_right_by_host: Vec<Value>,
    hash_mismatches_by_host: Vec<Value>,
}

impl ObjectSummary {
    fn to_json(&self) -> Value {
        json!({
            "left": self.left,
            "right": self.right,
            "match": self.match_,
            "onlyInLeftCount": self.only_in_left_count,
            "onlyInRightCount": self.only_in_right_count,
            "hashMismatchCount": self.hash_mismatch_count,
            "onlyInLeft": self.only_in_left,
            "onlyInRight": self.only_in_right,
            "hashMismatches": self.hash_mismatches,
            "onlyInLeftByExtension": self.only_in_left_by_extension,
            "onlyInRightByExtension": self.only_in_right_by_extension,
            "hashMismatchesByExtension": self.hash_mismatches_by_extension,
            "onlyInLeftByHostTop": self.only_in_left_by_host,
            "onlyInRightByHostTop": self.only_in_right_by_host,
            "hashMismatchesByHostTop": self.hash_mismatches_by_host,
        })
    }
}

fn compare_object_maps(
    left: &BTreeMap<String, String>,
    right: &BTreeMap<String, String>,
    sample_limit: usize,
) -> ObjectSummary {
    let left_keys = left.keys().cloned().collect::<BTreeSet<_>>();
    let right_keys = right.keys().cloned().collect::<BTreeSet<_>>();
    let only_in_left_all = left_keys
        .difference(&right_keys)
        .cloned()
        .collect::<Vec<_>>();
    let only_in_right_all = right_keys
        .difference(&left_keys)
        .cloned()
        .collect::<Vec<_>>();
    let mut hash_mismatch_uris = Vec::new();
    let mut hash_mismatches = Vec::new();
    for key in left_keys.intersection(&right_keys) {
        let left_hash = left.get(key).expect("key from map");
        let right_hash = right.get(key).expect("key from map");
        if left_hash != right_hash {
            hash_mismatch_uris.push(key.clone());
            if hash_mismatches.len() < sample_limit {
                hash_mismatches.push(json!({
                    "uri": key,
                    "leftSha256": left_hash,
                    "rightSha256": right_hash,
                }));
            }
        }
    }
    ObjectSummary {
        left: left.len(),
        right: right.len(),
        match_: left == right,
        only_in_left_count: only_in_left_all.len(),
        only_in_right_count: only_in_right_all.len(),
        hash_mismatch_count: hash_mismatch_uris.len(),
        only_in_left: only_in_left_all
            .iter()
            .take(sample_limit)
            .cloned()
            .collect(),
        only_in_right: only_in_right_all
            .iter()
            .take(sample_limit)
            .cloned()
            .collect(),
        hash_mismatches,
        only_in_left_by_extension: group_by_extension(only_in_left_all.iter().map(String::as_str)),
        only_in_right_by_extension: group_by_extension(
            only_in_right_all.iter().map(String::as_str),
        ),
        hash_mismatches_by_extension: group_by_extension(
            hash_mismatch_uris.iter().map(String::as_str),
        ),
        only_in_left_by_host: top_hosts(only_in_left_all.iter().map(String::as_str), sample_limit),
        only_in_right_by_host: top_hosts(
            only_in_right_all.iter().map(String::as_str),
            sample_limit,
        ),
        hash_mismatches_by_host: top_hosts(
            hash_mismatch_uris.iter().map(String::as_str),
            sample_limit,
        ),
    }
}

struct ProjectionSummary {
    left: usize,
    right: usize,
    match_: bool,
    only_in_left_count: usize,
    only_in_right_count: usize,
    value_mismatch_count: usize,
    only_in_left: Vec<String>,
    only_in_right: Vec<String>,
    value_mismatches: Vec<Value>,
}

impl ProjectionSummary {
    fn to_json(&self) -> Value {
        json!({
            "left": self.left,
            "right": self.right,
            "match": self.match_,
            "onlyInLeftCount": self.only_in_left_count,
            "onlyInRightCount": self.only_in_right_count,
            "valueMismatchCount": self.value_mismatch_count,
            "onlyInLeft": self.only_in_left,
            "onlyInRight": self.only_in_right,
            "valueMismatches": self.value_mismatches,
        })
    }
}

fn compare_projection_maps<T, F>(
    left: &BTreeMap<String, T>,
    right: &BTreeMap<String, T>,
    sample_limit: usize,
    mut encode_value: F,
) -> ProjectionSummary
where
    T: Ord + Clone,
    F: FnMut(&T) -> Value,
{
    let left_keys = left.keys().cloned().collect::<BTreeSet<_>>();
    let right_keys = right.keys().cloned().collect::<BTreeSet<_>>();
    let only_in_left_all = left_keys
        .difference(&right_keys)
        .cloned()
        .collect::<Vec<_>>();
    let only_in_right_all = right_keys
        .difference(&left_keys)
        .cloned()
        .collect::<Vec<_>>();
    let mut mismatches = Vec::new();
    let mut mismatch_count = 0usize;
    for key in left_keys.intersection(&right_keys) {
        let left_value = left.get(key).expect("key from map");
        let right_value = right.get(key).expect("key from map");
        if left_value != right_value {
            mismatch_count += 1;
            if mismatches.len() < sample_limit {
                mismatches.push(json!({
                    "key": key,
                    "left": encode_value(left_value),
                    "right": encode_value(right_value),
                }));
            }
        }
    }
    ProjectionSummary {
        left: left.len(),
        right: right.len(),
        match_: left == right,
        only_in_left_count: only_in_left_all.len(),
        only_in_right_count: only_in_right_all.len(),
        value_mismatch_count: mismatch_count,
        only_in_left: only_in_left_all.into_iter().take(sample_limit).collect(),
        only_in_right: only_in_right_all.into_iter().take(sample_limit).collect(),
        value_mismatches: mismatches,
    }
}

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<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()
}

fn format_time(value: time::OffsetDateTime) -> Result<String, String> {
    value
        .to_offset(time::UtcOffset::UTC)
        .format(&Rfc3339)
        .map_err(|e| format!("format RFC3339 time failed: {e}"))
}

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: &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, triage: &Value) -> Result<(), String> {
    let codes = triage["findings"]
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|finding| finding["code"].as_str())
        .collect::<Vec<_>>()
        .join(", ");
    let dominant = triage["dominantFindings"]
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|finding| finding["code"].as_str())
        .collect::<Vec<_>>()
        .join(", ");
    let lines = vec![
        "# CCR/CIR Triage Summary".to_string(),
        String::new(),
        format!(
            "- `outputRelation`: `{}`",
            triage["outputRelation"].as_str().unwrap_or("-")
        ),
        format!(
            "- `processRelation`: `{}`",
            triage["processRelation"].as_str().unwrap_or("-")
        ),
        format!("- `findings`: `{codes}`"),
        format!("- `dominantFindings`: `{dominant}`"),
        format!(
            "- `ccrStateDigestMatch`: `{}`",
            triage["ccr"]["stateDigestMatch"].as_bool().unwrap_or(false)
        ),
        format!(
            "- `cirObjectsMatch`: `{}`",
            triage["cir"]["objects"]["match"].as_bool().unwrap_or(false)
        ),
        format!(
            "- `cirRejectsMatch`: `{}`",
            triage["cir"]["rejects"]["match"].as_bool().unwrap_or(false)
        ),
        format!(
            "- `cirTrustAnchorsMatch`: `{}`",
            triage["cir"]["trustAnchors"]["match"]
                .as_bool()
                .unwrap_or(false)
        ),
        format!(
            "- `validationTimeMatch`: `{}`",
            triage["cir"]["validationTimeMatch"]
                .as_bool()
                .unwrap_or(false)
        ),
    ];
    std::fs::write(path, lines.join("\n") + "\n")
        .map_err(|e| format!("write markdown failed: {}: {e}", path.display()))
}

fn write_samples_jsonl(path: &Path, triage: &Value) -> Result<(), String> {
    let mut rows = Vec::new();
    push_rows(
        &mut rows,
        "cir.object.only_in_left",
        &triage["cir"]["objects"]["onlyInLeft"],
    );
    push_rows(
        &mut rows,
        "cir.object.only_in_right",
        &triage["cir"]["objects"]["onlyInRight"],
    );
    push_rows(
        &mut rows,
        "cir.object.hash_mismatch",
        &triage["cir"]["objects"]["hashMismatches"],
    );
    push_rows(
        &mut rows,
        "cir.reject.only_in_left",
        &triage["cir"]["rejects"]["onlyInLeft"],
    );
    push_rows(
        &mut rows,
        "cir.reject.only_in_right",
        &triage["cir"]["rejects"]["onlyInRight"],
    );
    push_rows(
        &mut rows,
        "cir.trust_anchor.only_in_left",
        &triage["cir"]["trustAnchors"]["onlyInLeft"],
    );
    push_rows(
        &mut rows,
        "cir.trust_anchor.only_in_right",
        &triage["cir"]["trustAnchors"]["onlyInRight"],
    );
    push_rows(
        &mut rows,
        "cir.trust_anchor.value_mismatch",
        &triage["cir"]["trustAnchors"]["valueMismatches"],
    );
    let mut body = String::new();
    for row in rows {
        body.push_str(&serde_json::to_string(&row).map_err(|e| e.to_string())?);
        body.push('\n');
    }
    std::fs::write(path, body).map_err(|e| format!("write jsonl failed: {}: {e}", path.display()))
}

fn push_rows(rows: &mut Vec<Value>, kind: &str, values: &Value) {
    if let Some(items) = values.as_array() {
        for item in items {
            rows.push(json!({
                "kind": kind,
                "value": item,
            }));
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rpki::ccr::{
        CcrContentInfo, CcrDigestAlgorithm, ManifestState, RpkiCanonicalCacheRepresentation,
        build_aspa_payload_state, build_roa_payload_state, encode_content_info,
    };
    use rpki::cir::{
        CanonicalInputRepresentation, CirObject, CirRejectedObject, CirTrustAnchor, encode_cir,
        sha256,
    };
    use rpki::data_model::roa::{IpPrefix, RoaAfi};
    use rpki::validation::objects::{AspaAttestation, Vrp};

    #[test]
    fn parse_args_accepts_only_four_artifact_inputs() {
        let args = parse_args(&[
            "triage_ccr_cir_pair".to_string(),
            "--left-ccr".to_string(),
            "left.ccr".to_string(),
            "--left-cir".to_string(),
            "left.cir".to_string(),
            "--right-ccr".to_string(),
            "right.ccr".to_string(),
            "--right-cir".to_string(),
            "right.cir".to_string(),
            "--out-dir".to_string(),
            "out".to_string(),
            "--sample-limit".to_string(),
            "9".to_string(),
        ])
        .expect("parse args");
        assert_eq!(args.left_ccr, PathBuf::from("left.ccr"));
        assert_eq!(args.right_cir, PathBuf::from("right.cir"));
        assert_eq!(args.sample_limit, 9);
    }

    #[test]
    fn parse_args_rejects_removed_meta_and_trust_anchor_flags() {
        let err = parse_args(&[
            "triage_ccr_cir_pair".to_string(),
            "--left-ccr".to_string(),
            "left.ccr".to_string(),
            "--left-cir".to_string(),
            "left.cir".to_string(),
            "--left-meta".to_string(),
            "left.json".to_string(),
            "--right-ccr".to_string(),
            "right.ccr".to_string(),
            "--right-cir".to_string(),
            "right.cir".to_string(),
            "--out-dir".to_string(),
            "out".to_string(),
        ])
        .expect_err("left-meta must be rejected");
        assert!(err.contains("no longer supported"));
    }

    #[test]
    fn build_findings_allows_p1_p2_p4_p5_to_coexist() {
        let ccr = json!({"stateDigestMatch": false});
        let cir = json!({
            "trustAnchors": {"match": false},
            "objects": {
                "match": false,
                "onlyInLeftCount": 1,
                "onlyInRightCount": 2,
                "hashMismatchCount": 0
            },
            "rejects": {"match": false},
            "rejectListSha256Match": false,
            "validationTimeMatch": false
        });
        let findings = build_findings(&ccr, &cir);
        let codes = finding_codes(&findings);
        assert_eq!(
            codes,
            vec![
                "P1_TRUST_ANCHOR_DIFFERENCE",
                "P2_OBJECT_URI_SET_DIFFERENCE",
                "P4_REJECT_DECISION_DIFFERENCE",
                "P5_VALIDATION_TIME_DIFFERENCE",
            ]
        );
    }

    #[test]
    fn build_findings_covers_ccr_only_equivalent_process_and_all_equal() {
        let mut ccr = json!({"stateDigestMatch": false});
        let mut cir = equal_cir_summary_json();
        let findings = build_findings(&ccr, &cir);
        assert_eq!(finding_codes(&findings), vec!["P6_CCR_ONLY_DIFFERENCE"]);

        ccr["stateDigestMatch"] = json!(true);
        cir["objects"]["hashMismatchCount"] = json!(1);
        let findings = build_findings(&ccr, &cir);
        assert_eq!(
            finding_codes(&findings),
            vec![
                "P3_OBJECT_CONTENT_HASH_DIFFERENCE",
                "P7_EQUIVALENT_OUTPUT_PROCESS_DIFFERENCE",
            ]
        );

        cir["objects"]["hashMismatchCount"] = json!(0);
        assert_eq!(
            finding_codes(&build_findings(&ccr, &cir)),
            vec!["P8_ALL_EQUAL"]
        );
    }

    #[test]
    fn run_reports_same_state_with_four_files() {
        let temp = tempfile::tempdir().expect("tempdir");
        let left = write_side_bundle(
            temp.path(),
            "left",
            &sample_ccr(64496, None),
            sample_cir(0x11, None, "ta"),
        );
        let right = write_side_bundle(
            temp.path(),
            "right",
            &sample_ccr(64496, None),
            sample_cir(0x11, None, "ta"),
        );
        let out = temp.path().join("compare");

        run(Args {
            left_ccr: left.0,
            left_cir: left.1,
            right_ccr: right.0,
            right_cir: right.1,
            out_dir: out.clone(),
            sample_limit: 20,
        })
        .expect("run");

        let triage: Value =
            serde_json::from_slice(&std::fs::read(out.join("triage.json")).expect("read triage"))
                .expect("triage json");
        assert_eq!(triage["schemaVersion"], 2);
        assert_eq!(triage["inputContract"], "four-file-ccr-cir-only");
        assert_eq!(triage["allMatch"], true);
        assert_eq!(
            finding_codes_from_value(&triage["findings"]),
            vec!["P8_ALL_EQUAL"]
        );
        assert!(out.join("ccr-summary.json").exists());
        assert!(out.join("cir-summary.json").exists());
        assert!(out.join("diff-samples.jsonl").exists());
    }

    #[test]
    fn run_reports_ccr_only_difference_with_same_cir() {
        let temp = tempfile::tempdir().expect("tempdir");
        let left = write_side_bundle(
            temp.path(),
            "left",
            &sample_ccr(64496, Some(0x11)),
            sample_cir(0x11, None, "ta"),
        );
        let right = write_side_bundle(
            temp.path(),
            "right",
            &sample_ccr(64496, Some(0x22)),
            sample_cir(0x11, None, "ta"),
        );
        let out = temp.path().join("compare");

        run(Args {
            left_ccr: left.0,
            left_cir: left.1,
            right_ccr: right.0,
            right_cir: right.1,
            out_dir: out.clone(),
            sample_limit: 20,
        })
        .expect("run");

        let triage: Value =
            serde_json::from_slice(&std::fs::read(out.join("triage.json")).expect("read triage"))
                .expect("triage json");
        assert_eq!(
            finding_codes_from_value(&triage["findings"]),
            vec!["P6_CCR_ONLY_DIFFERENCE"]
        );
        assert_eq!(
            triage["ccr"]["comparePath"],
            "ccr_state_digest_mismatch_with_compare_views_match"
        );
    }

    #[test]
    fn run_reports_trust_anchor_object_reject_and_validation_time_differences() {
        let temp = tempfile::tempdir().expect("tempdir");
        let ccr = sample_ccr(64496, None);
        let left = write_side_bundle(
            temp.path(),
            "left",
            &ccr,
            sample_cir_with_ta_time(
                0x11,
                Some("rsync://example.net/repo/rejected-left.roa"),
                sample_trust_anchor("ta-a"),
                time::OffsetDateTime::UNIX_EPOCH,
            ),
        );
        let right = write_side_bundle(
            temp.path(),
            "right",
            &ccr,
            sample_cir_with_ta_time(
                0x22,
                Some("rsync://example.net/repo/rejected-right.roa"),
                sample_trust_anchor("ta-b"),
                time::OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(60),
            ),
        );
        let codes = run_and_read_finding_codes(temp.path(), "multi", left, right);
        assert_eq!(
            codes,
            vec![
                "P1_TRUST_ANCHOR_DIFFERENCE",
                "P2_OBJECT_URI_SET_DIFFERENCE",
                "P3_OBJECT_CONTENT_HASH_DIFFERENCE",
                "P4_REJECT_DECISION_DIFFERENCE",
                "P5_VALIDATION_TIME_DIFFERENCE",
                "P7_EQUIVALENT_OUTPUT_PROCESS_DIFFERENCE",
            ]
        );
    }

    #[test]
    fn trust_anchor_projection_is_self_contained() {
        let temp = tempfile::tempdir().expect("tempdir");
        let ccr = sample_ccr(64496, None);
        let left_ta = sample_trust_anchor("ta");
        let mut right_ta = sample_trust_anchor("ta");
        right_ta.tal_uri = "https://different.example.net/ta.tal".to_string();
        let left = write_side_bundle(
            temp.path(),
            "left-tal-uri",
            &ccr,
            sample_cir_with_ta(0x11, None, left_ta),
        );
        let right = write_side_bundle(
            temp.path(),
            "right-tal-uri",
            &ccr,
            sample_cir_with_ta(0x11, None, right_ta),
        );
        assert_eq!(
            run_and_read_finding_codes(temp.path(), "ta-uri", left, right),
            vec![
                "P1_TRUST_ANCHOR_DIFFERENCE",
                "P7_EQUIVALENT_OUTPUT_PROCESS_DIFFERENCE",
            ]
        );
    }

    #[test]
    fn grouping_helpers_are_stable() {
        let uris = [
            "rsync://b.example/repo/a.ROA",
            "rsync://a.example/repo/b.mft",
            "rsync://b.example/repo/c",
        ];
        let extensions = group_by_extension(uris.iter().copied());
        assert_eq!(extensions.get(".roa").copied(), Some(1));
        assert_eq!(extensions.get(".mft").copied(), Some(1));
        assert_eq!(extensions.get("<none>").copied(), Some(1));
        let hosts = top_hosts(uris.iter().copied(), 1);
        assert_eq!(hosts[0]["host"], "b.example");
        assert_eq!(hosts[0]["count"], 2);
    }

    fn equal_cir_summary_json() -> Value {
        json!({
            "allMatch": true,
            "trustAnchors": {"match": true},
            "objects": {
                "match": true,
                "onlyInLeftCount": 0,
                "onlyInRightCount": 0,
                "hashMismatchCount": 0
            },
            "rejects": {"match": true},
            "rejectListSha256Match": true,
            "validationTimeMatch": true
        })
    }

    fn finding_codes(findings: &[Value]) -> Vec<&str> {
        findings
            .iter()
            .map(|finding| finding["code"].as_str().unwrap())
            .collect()
    }

    fn finding_codes_from_value(findings: &Value) -> Vec<&str> {
        findings
            .as_array()
            .unwrap()
            .iter()
            .map(|finding| finding["code"].as_str().unwrap())
            .collect()
    }

    fn run_and_read_finding_codes(
        root: &Path,
        name: &str,
        left: (PathBuf, PathBuf),
        right: (PathBuf, PathBuf),
    ) -> Vec<String> {
        let out = root.join(format!("compare-{name}"));
        run(Args {
            left_ccr: left.0,
            left_cir: left.1,
            right_ccr: right.0,
            right_cir: right.1,
            out_dir: out.clone(),
            sample_limit: 20,
        })
        .expect("run");
        let triage: Value =
            serde_json::from_slice(&std::fs::read(out.join("triage.json")).expect("read triage"))
                .expect("triage json");
        triage["findings"]
            .as_array()
            .unwrap()
            .iter()
            .map(|finding| finding["code"].as_str().unwrap().to_string())
            .collect()
    }

    fn write_side_bundle(
        root: &Path,
        label: &str,
        ccr: &[u8],
        cir: CanonicalInputRepresentation,
    ) -> (PathBuf, PathBuf) {
        let dir = root.join(label);
        std::fs::create_dir_all(&dir).expect("side dir");
        let ccr_path = dir.join("result.ccr");
        let cir_path = dir.join("result.cir");
        std::fs::write(&ccr_path, ccr).expect("write ccr");
        std::fs::write(&cir_path, encode_cir(&cir).expect("encode cir")).expect("write cir");
        (ccr_path, cir_path)
    }

    fn sample_ccr(asn: u32, manifest_hash_fill: Option<u8>) -> Vec<u8> {
        let vrps = build_roa_payload_state(&[Vrp {
            asn,
            prefix: IpPrefix {
                afi: RoaAfi::Ipv4,
                prefix_len: 24,
                addr: [192, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
            },
            max_length: 24,
        }])
        .expect("build vrps");
        let vaps = build_aspa_payload_state(&[AspaAttestation {
            customer_as_id: asn,
            provider_as_ids: vec![64497],
        }])
        .expect("build vaps");
        let mfts = manifest_hash_fill.map(|fill| ManifestState {
            mis: Vec::new(),
            most_recent_update: time::OffsetDateTime::UNIX_EPOCH,
            hash: vec![fill; 32],
        });
        let ccr = CcrContentInfo::new(RpkiCanonicalCacheRepresentation {
            version: 0,
            hash_alg: CcrDigestAlgorithm::Sha256,
            produced_at: time::OffsetDateTime::UNIX_EPOCH,
            mfts,
            vrps: Some(vrps),
            vaps: Some(vaps),
            tas: None,
            rks: None,
        });
        encode_content_info(&ccr).expect("encode ccr")
    }

    fn sample_cir(
        object_hash_fill: u8,
        rejected_uri: Option<&str>,
        trust_anchor_name: &str,
    ) -> CanonicalInputRepresentation {
        sample_cir_with_ta(
            object_hash_fill,
            rejected_uri,
            sample_trust_anchor(trust_anchor_name),
        )
    }

    fn sample_cir_with_ta(
        object_hash_fill: u8,
        rejected_uri: Option<&str>,
        trust_anchor: CirTrustAnchor,
    ) -> CanonicalInputRepresentation {
        sample_cir_with_ta_time(
            object_hash_fill,
            rejected_uri,
            trust_anchor,
            time::OffsetDateTime::UNIX_EPOCH,
        )
    }

    fn sample_cir_with_ta_time(
        object_hash_fill: u8,
        rejected_uri: Option<&str>,
        trust_anchor: CirTrustAnchor,
        validation_time: time::OffsetDateTime,
    ) -> CanonicalInputRepresentation {
        let rejected_objects = rejected_uri
            .map(|uri| {
                vec![CirRejectedObject {
                    object_uri: uri.to_string(),
                    reason: Some("test".to_string()),
                }]
            })
            .unwrap_or_default();
        let mut objects = vec![CirObject {
            rsync_uri: "rsync://example.net/repo/a.roa".to_string(),
            sha256: vec![object_hash_fill; 32],
        }];
        for rejected in &rejected_objects {
            if !objects
                .iter()
                .any(|object| object.rsync_uri == rejected.object_uri)
            {
                objects.push(CirObject {
                    rsync_uri: rejected.object_uri.clone(),
                    sha256: vec![object_hash_fill; 32],
                });
            }
        }
        objects.sort_by(|a, b| a.rsync_uri.cmp(&b.rsync_uri));
        CanonicalInputRepresentation::new_v4(
            validation_time,
            objects,
            Vec::new(),
            vec![trust_anchor],
            rejected_objects,
            Vec::new(),
        )
    }

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