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};

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

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

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 left_meta = None;
    let mut right_ccr = None;
    let mut right_cir = None;
    let mut right_meta = None;
    let mut out_dir = None;
    let mut sample_limit = 200usize;
    let mut compare_view_trust_anchor = "unknown".to_string();
    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")?,
                ));
            }
            "--left-meta" => {
                index += 1;
                left_meta = Some(PathBuf::from(
                    argv.get(index).ok_or("--left-meta 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")?,
                ));
            }
            "--right-meta" => {
                index += 1;
                right_meta = Some(PathBuf::from(
                    argv.get(index).ok_or("--right-meta 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}"))?;
            }
            "--compare-view-trust-anchor" => {
                index += 1;
                compare_view_trust_anchor = argv
                    .get(index)
                    .ok_or("--compare-view-trust-anchor requires a value")?
                    .clone();
            }
            "-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()))?,
        left_meta: left_meta.ok_or_else(|| format!("--left-meta 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()))?,
        right_meta: right_meta.ok_or_else(|| format!("--right-meta is required\n{}", usage()))?,
        out_dir: out_dir.ok_or_else(|| format!("--out-dir is required\n{}", usage()))?,
        sample_limit,
        compare_view_trust_anchor,
    })
}

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_meta = read_json(&args.left_meta)?;
    let right_meta = read_json(&args.right_meta)?;
    let ccr_summary = build_ccr_summary(&args)?;
    let cir_summary = build_cir_summary(&args, &left_meta, &right_meta)?;
    let input_integrity = build_input_integrity(&args, &left_meta, &right_meta);
    let diagnosis = diagnose(&ccr_summary, &cir_summary, &input_integrity);
    let triage = json!({
        "schemaVersion": 1,
        "generatedBy": "triage_ccr_cir_pair",
        "left": side_summary("left", &args.left_ccr, &args.left_cir, &args.left_meta, &left_meta),
        "right": side_summary("right", &args.right_ccr, &args.right_cir, &args.right_meta, &right_meta),
        "sampleLimit": args.sample_limit,
        "diagnosis": diagnosis,
        "primaryDiagnosis": diagnosis,
        "allMatch": diagnosis == "same_state",
        "inputIntegrity": input_integrity,
        "ccr": ccr_summary,
        "cir": cir_summary,
        "manualInvestigationHints": manual_hints(&diagnosis),
    });
    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 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 fallback = if state_digest_match {
        None
    } else {
        Some(build_compare_view_summary(
            &left,
            &right,
            &args.compare_view_trust_anchor,
            args.sample_limit,
        )?)
    };
    Ok(json!({
        "stateDigestMatch": state_digest_match,
        "comparePath": if state_digest_match {
            "ccr_state_digest_match"
        } else if fallback.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,
        },
        "right": {
            "version": comparison.peer.version,
            "hashAlg": comparison.peer.hash_alg_oid,
        },
        "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": fallback.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_der: &[u8],
    right_der: &[u8],
    trust_anchor: &str,
    sample_limit: usize,
) -> Result<CompareViewSummary, String> {
    let left = decode_content_info(left_der).map_err(|e| e.to_string())?;
    let right = decode_content_info(right_der).map_err(|e| e.to_string())?;
    let (left_vrps, left_vaps) =
        rpki::ccr::decode_ccr_compare_views(&left, trust_anchor).map_err(|e| e.to_string())?;
    let (right_vrps, right_vaps) =
        rpki::ccr::decode_ccr_compare_views(&right, trust_anchor).map_err(|e| e.to_string())?;
    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!({
            "trustAnchor": trust_anchor,
            "vrps": vrps.to_json_with(|row| json!([row.asn, row.ip_prefix, row.max_length, row.trust_anchor])),
            "vaps": vaps.to_json_with(|row| json!([row.customer_asn, row.providers, row.trust_anchor])),
        }),
    })
}

fn build_cir_summary(args: &Args, left_meta: &Value, right_meta: &Value) -> 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
        .objects
        .iter()
        .map(|item| (item.rsync_uri.clone(), hex::encode(&item.sha256)))
        .collect::<BTreeMap<_, _>>();
    let right_objects = right
        .objects
        .iter()
        .map(|item| (item.rsync_uri.clone(), hex::encode(&item.sha256)))
        .collect::<BTreeMap<_, _>>();
    let left_rejects = left
        .rejected_objects
        .iter()
        .map(|item| item.object_uri.clone())
        .collect::<BTreeSet<_>>();
    let right_rejects = right
        .rejected_objects
        .iter()
        .map(|item| item.object_uri.clone())
        .collect::<BTreeSet<_>>();
    let left_trust_anchors = left
        .trust_anchors
        .iter()
        .map(|item| {
            (
                item.ta_rsync_uri.clone(),
                hex::encode(&item.ta_certificate_sha256),
            )
        })
        .collect::<BTreeMap<_, _>>();
    let right_trust_anchors = right
        .trust_anchors
        .iter()
        .map(|item| {
            (
                item.ta_rsync_uri.clone(),
                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_object_maps(&left_trust_anchors, &right_trust_anchors, args.sample_limit);
    let left_fixture = fixture_ta_map(left_meta);
    let right_fixture = fixture_ta_map(right_meta);
    let left_fixture_match = trust_anchor_fixture_match(&left_trust_anchors, &left_fixture);
    let right_fixture_match = trust_anchor_fixture_match(&right_trust_anchors, &right_fixture);
    let reject_hash_match = left.reject_list_sha256 == right.reject_list_sha256;
    Ok(json!({
        "allMatch": objects.match_ && rejects.match_ && trust_anchors.match_ && reject_hash_match,
        "objects": objects.to_json(),
        "rejects": rejects.to_json_with(|item| json!(item)),
        "trustAnchors": trust_anchors.to_json(),
        "trustAnchorFixture": {
            "leftMatch": left_fixture_match,
            "rightMatch": right_fixture_match,
        },
        "rejectListSha256Match": reject_hash_match,
        "left": {
            "objectCount": left.objects.len(),
            "rejectCount": left.rejected_objects.len(),
            "trustAnchorCount": left.trust_anchors.len(),
            "rejectListSha256": hex::encode(&left.reject_list_sha256),
            "validationTime": left.validation_time.to_string(),
        },
        "right": {
            "objectCount": right.objects.len(),
            "rejectCount": right.rejected_objects.len(),
            "trustAnchorCount": right.trust_anchors.len(),
            "rejectListSha256": hex::encode(&right.reject_list_sha256),
            "validationTime": right.validation_time.to_string(),
        },
    }))
}

fn diagnose(ccr: &Value, cir: &Value, integrity: &Value) -> String {
    if integrity["metadataValid"].as_bool() == Some(false) {
        return "metadata_invalid".to_string();
    }
    if integrity["runExitOk"].as_bool() == Some(false) {
        return "run_failed".to_string();
    }
    if integrity["taFixturePinned"].as_bool() == Some(false) {
        return "ta_fixture_not_pinned".to_string();
    }
    if ccr["stateDigestMatch"].as_bool().unwrap_or(false)
        && cir["allMatch"].as_bool().unwrap_or(false)
    {
        return "same_state".to_string();
    }
    if cir["trustAnchorFixture"]["leftMatch"].as_bool() == Some(false)
        || cir["trustAnchorFixture"]["rightMatch"].as_bool() == Some(false)
    {
        return "ta_fixture_not_pinned".to_string();
    }
    if cir["trustAnchors"]["match"].as_bool() != Some(true) {
        return "trust_anchor_input_difference".to_string();
    }
    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 {
        return "sync_input_object_difference".to_string();
    }
    if cir["objects"]["hashMismatchCount"].as_u64().unwrap_or(0) > 0 {
        return "sync_input_object_content_difference".to_string();
    }
    if cir["rejects"]["match"].as_bool() != Some(true)
        || cir["rejectListSha256Match"].as_bool() != Some(true)
    {
        return "validation_reject_policy_difference".to_string();
    }
    if ccr["stateDigestMatch"].as_bool().unwrap_or(false) {
        return "equivalent_output_process_difference".to_string();
    }
    if ccr["compareViews"]["vrps"]["match"].as_bool() == Some(true)
        && ccr["compareViews"]["vaps"]["match"].as_bool() == Some(true)
    {
        return "ccr_projection_or_encoding_difference".to_string();
    }
    "unknown_needs_manual_object_analysis".to_string()
}

fn manual_hints(diagnosis: &str) -> Vec<String> {
    match diagnosis {
        "same_state" => vec!["CCR state digest matches; no object-level investigation needed.".into()],
        "metadata_invalid" => vec![
            "Run metadata is incomplete or artifact paths do not match the explicit CLI inputs.".into(),
            "Fix the experiment driver metadata envelope before interpreting RP behavior.".into(),
        ],
        "run_failed" => vec![
            "At least one side reported a non-zero run exit code in run-meta.json.".into(),
            "Inspect the registered run logs outside standard triage before comparing behavior.".into(),
        ],
        "ta_fixture_not_pinned" => vec![
            "At least one side reported that the local TA fixture was not pinned.".into(),
            "Fix fixture pinning before interpreting CCR/CIR differences.".into(),
        ],
        "trust_anchor_input_difference" => vec![
            "Compare CIR TrustAnchor entries and fixture-proof TAL/TA hashes.".into(),
            "If fixture hashes differ, fix experiment input before analyzing RP behavior.".into(),
        ],
        "sync_input_object_difference" => vec![
            "Inspect CIR object URI/hash differences first; this points to sync/manifest consumption differences.".into(),
            "Use a separate object parser workflow for selected URI samples if manual root cause analysis is needed.".into(),
        ],
        "sync_input_object_content_difference" => vec![
            "CIR object URI sets match but at least one object hash differs.".into(),
            "For live all5 runs this usually points to repository view drift or sync content differences.".into(),
        ],
        "validation_reject_policy_difference" => vec![
            "Inspect reject-only URI sets; this points to validation policy or parser strictness differences.".into(),
            "Reject reasons are advisory and are not part of reject-list digest comparison.".into(),
        ],
        "ccr_projection_or_encoding_difference" => vec![
            "CIR inputs match but CCR state differs; inspect CCR projection, ordering, or encoding.".into(),
        ],
        "equivalent_output_process_difference" => vec![
            "CCR state digest matches but CIR process inputs differ.".into(),
            "Record this as a behavior difference even though the final CCR state is equivalent.".into(),
        ],
        _ => vec![
            "CCR and standard CIR triage cannot fully explain this mismatch.".into(),
            "Use manual parser tools on selected objects outside the standard triage workflow.".into(),
        ],
    }
}

fn side_summary(label: &str, ccr: &Path, cir: &Path, meta: &Path, meta_json: &Value) -> Value {
    json!({
        "label": label,
        "ccr": path_string(ccr),
        "cir": path_string(cir),
        "metadata": path_string(meta),
        "experimentId": meta_json.get("experimentId"),
        "sideLabel": meta_json.get("sideLabel"),
        "step": meta_json.get("step"),
        "rp": meta_json.get("rp"),
        "scope": meta_json.get("scope"),
    })
}

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

fn build_input_integrity(args: &Args, left_meta: &Value, right_meta: &Value) -> Value {
    let mut issues = Vec::new();
    validate_side_metadata(
        "left",
        left_meta,
        &args.left_ccr,
        &args.left_cir,
        &args.left_meta,
        &mut issues,
    );
    validate_side_metadata(
        "right",
        right_meta,
        &args.right_ccr,
        &args.right_cir,
        &args.right_meta,
        &mut issues,
    );
    let left_step = left_meta["step"].as_str();
    let right_step = right_meta["step"].as_str();
    if left_step.is_some() && right_step.is_some() && left_step != right_step {
        issues.push(format!(
            "left/right step mismatch: left={} right={}",
            left_step.unwrap_or("-"),
            right_step.unwrap_or("-")
        ));
    }
    let run_exit_ok = meta_exit_code(left_meta) == Some(0) && meta_exit_code(right_meta) == Some(0);
    let ta_fixture_pinned = match (
        meta_ta_fixture_pinned(left_meta),
        meta_ta_fixture_pinned(right_meta),
    ) {
        (Some(false), _) | (_, Some(false)) => Some(false),
        (Some(true), Some(true)) => Some(true),
        _ => None,
    };
    json!({
        "metadataValid": issues.is_empty(),
        "issues": issues,
        "runExitOk": run_exit_ok,
        "leftExitCode": meta_exit_code(left_meta),
        "rightExitCode": meta_exit_code(right_meta),
        "sameStep": left_step.is_some() && right_step.is_some() && left_step == right_step,
        "step": if left_step == right_step { left_step } else { None },
        "scopeAll5": left_meta["scope"]["all5"].as_bool() == Some(true)
            && right_meta["scope"]["all5"].as_bool() == Some(true),
        "taFixturePinned": ta_fixture_pinned,
    })
}

fn validate_side_metadata(
    label: &str,
    meta: &Value,
    ccr: &Path,
    cir: &Path,
    meta_path: &Path,
    issues: &mut Vec<String>,
) {
    if meta.get("schemaVersion").is_none() {
        issues.push(format!("{label}: missing schemaVersion"));
    }
    if meta.get("experimentId").is_none() {
        issues.push(format!("{label}: missing experimentId"));
    }
    if meta.get("sideLabel").is_none() {
        issues.push(format!("{label}: missing sideLabel"));
    }
    if meta.get("step").is_none() {
        issues.push(format!("{label}: missing step"));
    }
    if meta.get("scope").is_none() {
        issues.push(format!("{label}: missing scope"));
    }
    validate_artifact_path(label, meta, "ccr", ccr, issues);
    validate_artifact_path(label, meta, "cir", cir, issues);
    validate_artifact_path(label, meta, "runMeta", meta_path, issues);
}

fn validate_artifact_path(
    label: &str,
    meta: &Value,
    field: &str,
    actual: &Path,
    issues: &mut Vec<String>,
) {
    let Some(recorded) = meta["artifacts"][field].as_str() else {
        issues.push(format!("{label}: missing artifacts.{field}"));
        return;
    };
    if !paths_match(recorded, actual) {
        issues.push(format!(
            "{label}: artifacts.{field} does not match CLI input: meta={} cli={}",
            recorded,
            actual.display()
        ));
    }
}

fn paths_match(recorded: &str, actual: &Path) -> bool {
    let recorded_path = Path::new(recorded);
    if recorded_path == actual || recorded == actual.to_string_lossy() {
        return true;
    }
    if !recorded_path.is_absolute()
        && let Ok(actual_real) = std::fs::canonicalize(actual)
    {
        let recorded_normalized = normalize_relative_path(recorded_path);
        if actual_real.ends_with(&recorded_normalized) {
            return true;
        }
    }
    match (
        std::fs::canonicalize(recorded_path),
        std::fs::canonicalize(actual),
    ) {
        (Ok(recorded_real), Ok(actual_real)) => recorded_real == actual_real,
        _ => false,
    }
}

fn normalize_relative_path(path: &Path) -> PathBuf {
    let mut normalized = PathBuf::new();
    for component in path.components() {
        match component {
            std::path::Component::CurDir => {}
            std::path::Component::ParentDir => normalized.push(".."),
            std::path::Component::Normal(part) => normalized.push(part),
            std::path::Component::RootDir | std::path::Component::Prefix(_) => {}
        }
    }
    normalized
}

fn meta_exit_code(meta: &Value) -> Option<i64> {
    meta["metrics"]["exitCode"].as_i64()
}

fn meta_ta_fixture_pinned(meta: &Value) -> Option<bool> {
    meta.pointer("/fixtureProof/taFixturePinned")
        .and_then(Value::as_bool)
        .or_else(|| {
            meta.pointer("/fixtureProofSummary/taFixturePinned")
                .and_then(Value::as_bool)
        })
}

fn fixture_ta_map(meta: &Value) -> BTreeMap<String, String> {
    meta.pointer("/fixtureProof/trustAnchors")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(|item| {
            Some((
                item.get("taRsyncUri")?.as_str()?.to_string(),
                item.get("taCertificateSha256")?
                    .as_str()?
                    .to_ascii_lowercase(),
            ))
        })
        .collect()
}

fn trust_anchor_fixture_match(
    actual: &BTreeMap<String, String>,
    fixture: &BTreeMap<String, String>,
) -> Option<bool> {
    if fixture.is_empty() {
        return None;
    }
    Some(actual.iter().all(|(uri, hash)| {
        fixture
            .get(uri)
            .is_some_and(|fixture_hash| fixture_hash == hash)
    }))
}

struct SetSummary<T> {
    left: usize,
    right: usize,
    match_: bool,
    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 {
        Self {
            left: left.len(),
            right: right.len(),
            match_: left == right,
            only_in_left: left.difference(right).take(sample_limit).cloned().collect(),
            only_in_right: right.difference(left).take(sample_limit).cloned().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_,
            "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,
        ),
    }
}

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

fn read_json(path: &Path) -> Result<Value, String> {
    serde_json::from_slice(&read_file(path)?)
        .map_err(|e| format!("decode json 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 lines = vec![
        "# CCR/CIR Triage Summary".to_string(),
        String::new(),
        format!(
            "- `diagnosis`: `{}`",
            triage["diagnosis"].as_str().unwrap_or("-")
        ),
        format!(
            "- `allMatch`: `{}`",
            triage["allMatch"].as_bool().unwrap_or(false)
        ),
        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)
        ),
    ];
    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"],
    );
    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::{
        CIR_VERSION_V3, CanonicalInputRepresentation, CirHashAlgorithm, CirObject,
        CirRejectedObject, CirTrustAnchor, compute_reject_list_sha256, encode_cir, sha256,
    };
    use rpki::data_model::roa::{IpPrefix, RoaAfi};
    use rpki::validation::objects::{AspaAttestation, Vrp};

    #[test]
    fn parse_args_accepts_left_right_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(),
            "--left-meta".to_string(),
            "left.json".to_string(),
            "--right-ccr".to_string(),
            "right.ccr".to_string(),
            "--right-cir".to_string(),
            "right.cir".to_string(),
            "--right-meta".to_string(),
            "right.json".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 diagnose_prefers_cir_explanation_order() {
        let ccr = json!({
            "stateDigestMatch": false,
            "compareViews": {
                "vrps": {"match": false},
                "vaps": {"match": true}
            }
        });
        let integrity = json!({
            "metadataValid": true,
            "runExitOk": true,
        });
        let mut cir = json!({
            "trustAnchors": {"match": false},
            "objects": {
                "match": false,
                "onlyInLeftCount": 1,
                "onlyInRightCount": 0,
                "hashMismatchCount": 0
            },
            "rejects": {"match": false},
            "rejectListSha256Match": false
        });
        assert_eq!(
            diagnose(&ccr, &cir, &integrity),
            "trust_anchor_input_difference"
        );
        cir["trustAnchors"]["match"] = json!(true);
        assert_eq!(
            diagnose(&ccr, &cir, &integrity),
            "sync_input_object_difference"
        );
        cir["objects"]["onlyInLeftCount"] = json!(0);
        cir["objects"]["hashMismatchCount"] = json!(1);
        assert_eq!(
            diagnose(&ccr, &cir, &integrity),
            "sync_input_object_content_difference"
        );
        cir["objects"]["match"] = json!(true);
        cir["objects"]["hashMismatchCount"] = json!(0);
        assert_eq!(
            diagnose(&ccr, &cir, &integrity),
            "validation_reject_policy_difference"
        );
        cir["rejects"]["match"] = json!(true);
        cir["rejectListSha256Match"] = json!(true);
        assert_eq!(
            diagnose(&ccr, &cir, &integrity),
            "unknown_needs_manual_object_analysis"
        );
    }

    #[test]
    fn diagnose_covers_same_state_and_ccr_only_paths() {
        let integrity = json!({
            "metadataValid": true,
            "runExitOk": true,
        });
        let mut ccr = json!({
            "stateDigestMatch": true,
            "compareViews": null
        });
        let cir = json!({
            "allMatch": true,
            "trustAnchors": {"match": true},
            "objects": {
                "match": true,
                "onlyInLeftCount": 0,
                "onlyInRightCount": 0,
                "hashMismatchCount": 0
            },
            "rejects": {"match": true},
            "rejectListSha256Match": true
        });
        assert_eq!(diagnose(&ccr, &cir, &integrity), "same_state");

        ccr["stateDigestMatch"] = json!(false);
        ccr["compareViews"] = json!({
            "vrps": {"match": true},
            "vaps": {"match": true}
        });
        assert_eq!(
            diagnose(&ccr, &cir, &integrity),
            "ccr_projection_or_encoding_difference"
        );
    }

    #[test]
    fn run_reports_same_state_with_fixture_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,
            left_meta: left.2,
            right_ccr: right.0,
            right_cir: right.1,
            right_meta: right.2,
            out_dir: out.clone(),
            sample_limit: 20,
            compare_view_trust_anchor: "test".to_string(),
        })
        .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["primaryDiagnosis"], "same_state");
        assert_eq!(triage["allMatch"], true);
        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,
            left_meta: left.2,
            right_ccr: right.0,
            right_cir: right.1,
            right_meta: right.2,
            out_dir: out.clone(),
            sample_limit: 20,
            compare_view_trust_anchor: "test".to_string(),
        })
        .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["primaryDiagnosis"],
            "ccr_projection_or_encoding_difference"
        );
        assert_eq!(
            triage["ccr"]["comparePath"],
            "ccr_state_digest_mismatch_with_compare_views_match"
        );
    }

    #[test]
    fn run_reports_trust_anchor_object_and_reject_differences() {
        let temp = tempfile::tempdir().expect("tempdir");
        let ccr = sample_ccr(64496, None);

        let ta_left = write_side_bundle(
            temp.path(),
            "ta-left",
            &ccr,
            sample_cir_with_ta(0x11, None, sample_trust_anchor("ta-a")),
        );
        let ta_right = write_side_bundle(
            temp.path(),
            "ta-right",
            &ccr,
            sample_cir_with_ta(0x11, None, sample_trust_anchor("ta-b")),
        );
        assert_eq!(
            run_and_read_diagnosis(temp.path(), "ta", ta_left, ta_right),
            "trust_anchor_input_difference"
        );

        let object_left =
            write_side_bundle(temp.path(), "obj-left", &ccr, sample_cir(0x11, None, "ta"));
        let object_right =
            write_side_bundle(temp.path(), "obj-right", &ccr, sample_cir(0x22, None, "ta"));
        assert_eq!(
            run_and_read_diagnosis(temp.path(), "object", object_left, object_right),
            "sync_input_object_content_difference"
        );

        let reject_left = write_side_bundle(
            temp.path(),
            "rej-left",
            &ccr,
            sample_cir(
                0x11,
                Some("rsync://example.net/repo/rejected-left.roa"),
                "ta",
            ),
        );
        let reject_right = write_side_bundle(
            temp.path(),
            "rej-right",
            &ccr,
            sample_cir(
                0x11,
                Some("rsync://example.net/repo/rejected-right.roa"),
                "ta",
            ),
        );
        assert_eq!(
            run_and_read_diagnosis(temp.path(), "reject", reject_left, reject_right),
            "validation_reject_policy_difference"
        );
    }

    #[test]
    fn trust_anchor_compare_uses_fixture_ta_hash_not_tal_uri() {
        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_diagnosis(temp.path(), "ta-uri", left, right),
            "same_state"
        );
    }

    #[test]
    fn fixture_match_allows_strict_policy_to_drop_trust_anchor() {
        let mut fixture = BTreeMap::new();
        fixture.insert(
            "rsync://example.net/repo/ta-a.cer".to_string(),
            "aaaaaaaa".to_string(),
        );
        fixture.insert(
            "rsync://example.net/repo/ta-b.cer".to_string(),
            "bbbbbbbb".to_string(),
        );
        let mut actual = BTreeMap::new();
        actual.insert(
            "rsync://example.net/repo/ta-a.cer".to_string(),
            "aaaaaaaa".to_string(),
        );

        assert_eq!(trust_anchor_fixture_match(&actual, &fixture), Some(true));

        actual.insert(
            "rsync://example.net/repo/ta-online.cer".to_string(),
            "cccccccc".to_string(),
        );
        assert_eq!(trust_anchor_fixture_match(&actual, &fixture), Some(false));
    }

    #[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 run_and_read_diagnosis(
        root: &Path,
        name: &str,
        left: (PathBuf, PathBuf, PathBuf),
        right: (PathBuf, PathBuf, PathBuf),
    ) -> String {
        let out = root.join(format!("compare-{name}"));
        run(Args {
            left_ccr: left.0,
            left_cir: left.1,
            left_meta: left.2,
            right_ccr: right.0,
            right_cir: right.1,
            right_meta: right.2,
            out_dir: out.clone(),
            sample_limit: 20,
            compare_view_trust_anchor: "test".to_string(),
        })
        .expect("run");
        let triage: Value =
            serde_json::from_slice(&std::fs::read(out.join("triage.json")).expect("read triage"))
                .expect("triage json");
        triage["primaryDiagnosis"].as_str().unwrap().to_string()
    }

    fn write_side_bundle(
        root: &Path,
        label: &str,
        ccr: &[u8],
        cir: CanonicalInputRepresentation,
    ) -> (PathBuf, 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");
        let meta_path = dir.join("run-meta.json");
        std::fs::write(&ccr_path, ccr).expect("write ccr");
        std::fs::write(&cir_path, encode_cir(&cir).expect("encode cir")).expect("write cir");
        let fixture_proof = fixture_proof_for_cir(&cir);
        let meta = json!({
            "schemaVersion": 1,
            "experimentId": "test",
            "sideLabel": label,
            "step": "snapshot",
            "scope": {"rirs": ["apnic"], "all5": false},
            "rp": {"kind": "test", "mode": "standard"},
            "metrics": {"exitCode": 0},
            "fixtureProof": fixture_proof,
            "artifacts": {
                "ccr": ccr_path.to_string_lossy(),
                "cir": cir_path.to_string_lossy(),
                "runMeta": meta_path.to_string_lossy()
            }
        });
        std::fs::write(
            &meta_path,
            serde_json::to_vec_pretty(&meta).expect("encode meta"),
        )
        .expect("write meta");
        (ccr_path, cir_path, meta_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 {
        let rejected_objects = rejected_uri
            .map(|uri| {
                vec![CirRejectedObject {
                    object_uri: uri.to_string(),
                    reason: Some("test".to_string()),
                }]
            })
            .unwrap_or_default();
        CanonicalInputRepresentation {
            version: CIR_VERSION_V3,
            hash_alg: CirHashAlgorithm::Sha256,
            validation_time: time::OffsetDateTime::UNIX_EPOCH,
            objects: vec![CirObject {
                rsync_uri: "rsync://example.net/repo/a.roa".to_string(),
                sha256: vec![object_hash_fill; 32],
            }],
            trust_anchors: vec![trust_anchor],
            reject_list_sha256: compute_reject_list_sha256(
                rejected_objects.iter().map(|item| item.object_uri.as_str()),
            ),
            rejected_objects,
        }
    }

    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,
        }
    }

    fn fixture_proof_for_cir(cir: &CanonicalInputRepresentation) -> Value {
        json!({
            "schemaVersion": 1,
            "taFixturePinned": true,
            "trustAnchors": cir.trust_anchors.iter().map(|item| json!({
                "taRsyncUri": item.ta_rsync_uri,
                "taCertificateSha256": hex::encode(&item.ta_certificate_sha256),
            })).collect::<Vec<_>>(),
        })
    }
}
