use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use crate::ccr::decode_content_info;
use crate::cir::decode_cir;
use serde::Serialize;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};

const LARGE_PP_OBJECT_THRESHOLDS: &[u64] = &[10, 50, 100, 500, 1000, 5000, 10000, 50000];
const PP_SYNC_SECONDS_BUCKETS: &[f64] = &[0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0];
const RTR_COUNT_TYPES: &[&str] = &["total", "vrp", "router_key", "aspa"];

#[derive(Clone, Debug, PartialEq, Eq)]
struct Args {
    run_root: PathBuf,
    listen: String,
    poll_secs: u64,
    instance: String,
    once: bool,
    out_metrics: Option<PathBuf>,
    out_status: Option<PathBuf>,
    ccr_check_bin: Option<PathBuf>,
    ccr_check_timeout_secs: u64,
    ccr_check_max_per_scan: u64,
}

fn usage() -> &'static str {
    "Usage: rpki_artifact_metrics --run-root <path> [--listen <addr:port>] [--poll-secs <n>] [--instance <name>] [--once] [--out-metrics <path>] [--out-status <path>] [--ccr-check-bin <path>] [--ccr-check-timeout-secs <n>] [--ccr-check-max-per-scan <n>]"
}

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

fn real_main() -> Result<(), String> {
    let args = parse_args(&std::env::args().collect::<Vec<_>>())?;
    let ccr_check_config = CcrCheckConfig::from_args(&args);
    let rtr_report_dir = configured_rtr_report_dir();
    if args.once {
        let snapshot = scan_run_root_incremental_with_rtr_dir(
            &args.run_root,
            &args.instance,
            None,
            rtr_report_dir.as_deref(),
            &ccr_check_config,
        )?;
        let metrics = render_metrics(&snapshot);
        let status = render_status_json(&snapshot)?;
        if let Some(path) = args.out_metrics.as_ref() {
            write_file(path, metrics.as_bytes())?;
        } else {
            print!("{metrics}");
        }
        if let Some(path) = args.out_status.as_ref() {
            write_file(path, status.as_bytes())?;
        }
        return Ok(());
    }

    let shared = Arc::new(RwLock::new(scan_run_root_incremental_with_rtr_dir(
        &args.run_root,
        &args.instance,
        None,
        rtr_report_dir.as_deref(),
        &ccr_check_config,
    )?));
    let scanner = Arc::clone(&shared);
    let run_root = args.run_root.clone();
    let instance = args.instance.clone();
    let poll_secs = args.poll_secs.max(1);
    thread::spawn(move || {
        loop {
            thread::sleep(Duration::from_secs(poll_secs));
            let previous_snapshot = scanner.read().expect("metrics lock poisoned").clone();
            let next = match scan_run_root_incremental_with_rtr_dir(
                &run_root,
                &instance,
                Some(&previous_snapshot),
                rtr_report_dir.as_deref(),
                &ccr_check_config,
            ) {
                Ok(snapshot) => snapshot,
                Err(err) => {
                    let mut previous = scanner.write().expect("metrics lock poisoned");
                    previous
                        .service
                        .parse_errors
                        .push(format!("scan failed: {err}"));
                    previous.service.last_reload_success = false;
                    previous.service.last_scan_timestamp_seconds = unix_now_seconds();
                    continue;
                }
            };
            *scanner.write().expect("metrics lock poisoned") = next;
        }
    });

    serve_http(&args.listen, shared)
}

fn parse_args(argv: &[String]) -> Result<Args, String> {
    let mut run_root = None;
    let mut listen = "127.0.0.1:9556".to_string();
    let mut poll_secs = 10u64;
    let mut instance = "ours-rp".to_string();
    let mut once = false;
    let mut out_metrics = None;
    let mut out_status = None;
    let mut ccr_check_bin = None;
    let mut ccr_check_timeout_secs = 120u64;
    let mut ccr_check_max_per_scan = 2u64;
    let mut index = 1usize;
    while index < argv.len() {
        match argv[index].as_str() {
            "--run-root" => {
                index += 1;
                run_root = Some(PathBuf::from(value_at(argv, index, "--run-root")?));
            }
            "--listen" => {
                index += 1;
                listen = value_at(argv, index, "--listen")?.to_string();
            }
            "--poll-secs" => {
                index += 1;
                let value = value_at(argv, index, "--poll-secs")?;
                poll_secs = value
                    .parse::<u64>()
                    .map_err(|_| format!("invalid --poll-secs: {value}"))?;
            }
            "--instance" => {
                index += 1;
                instance = value_at(argv, index, "--instance")?.to_string();
            }
            "--once" => once = true,
            "--out-metrics" => {
                index += 1;
                out_metrics = Some(PathBuf::from(value_at(argv, index, "--out-metrics")?));
            }
            "--out-status" => {
                index += 1;
                out_status = Some(PathBuf::from(value_at(argv, index, "--out-status")?));
            }
            "--ccr-check-bin" => {
                index += 1;
                ccr_check_bin = Some(PathBuf::from(value_at(argv, index, "--ccr-check-bin")?));
            }
            "--ccr-check-timeout-secs" => {
                index += 1;
                let value = value_at(argv, index, "--ccr-check-timeout-secs")?;
                ccr_check_timeout_secs = value
                    .parse::<u64>()
                    .map_err(|_| format!("invalid --ccr-check-timeout-secs: {value}"))?;
            }
            "--ccr-check-max-per-scan" => {
                index += 1;
                let value = value_at(argv, index, "--ccr-check-max-per-scan")?;
                ccr_check_max_per_scan = value
                    .parse::<u64>()
                    .map_err(|_| format!("invalid --ccr-check-max-per-scan: {value}"))?;
            }
            "-h" | "--help" => return Err(usage().to_string()),
            other => return Err(format!("unknown argument: {other}\n{}", usage())),
        }
        index += 1;
    }
    Ok(Args {
        run_root: run_root.ok_or_else(|| format!("--run-root is required\n{}", usage()))?,
        listen,
        poll_secs,
        instance,
        once,
        out_metrics,
        out_status,
        ccr_check_bin,
        ccr_check_timeout_secs,
        ccr_check_max_per_scan,
    })
}

fn value_at<'a>(argv: &'a [String], index: usize, flag: &str) -> Result<&'a str, String> {
    argv.get(index)
        .map(|s| s.as_str())
        .ok_or_else(|| format!("{flag} requires a value"))
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct MetricsSnapshot {
    instance: String,
    service: ServiceMetrics,
    runs: RunScanSummary,
    latest_run: Option<LatestRunMetrics>,
    cumulative: CumulativeMetrics,
    repo_stats: Vec<RepoMetrics>,
    object_counts: BTreeMap<(String, String), u64>,
    large_pp_counts: BTreeMap<u64, u64>,
    pp_sync_histograms: BTreeMap<String, Histogram>,
    top_repos_by_sync_duration: Vec<TopRepo>,
    top_pp_by_object_count: Vec<TopPublicationPoint>,
    top_pp_by_sync_duration: Vec<TopPublicationPoint>,
    cir: Option<CirMetrics>,
    ccr: Option<CcrMetrics>,
    ccr_check: CcrCheckMetrics,
    rtr: RtrMetrics,
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct CcrCheckMetrics {
    enabled: bool,
    up: bool,
    bin: Option<String>,
    pass_total: u64,
    ccr_parse_error_total: u64,
    tool_error_total: u64,
    consecutive_failures: u64,
    last: Option<CcrCheckLastResult>,
    checked_run_dirs: Vec<String>,
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct CcrCheckLastResult {
    run_dir: String,
    run_seq: Option<u64>,
    outcome: String,
    duration_seconds: f64,
    checked_at_seconds: f64,
    detail: Option<String>,
}

#[derive(Clone, Debug)]
struct CcrCheckConfig {
    bin: Option<PathBuf>,
    timeout_secs: u64,
    max_per_scan: u64,
}

impl CcrCheckConfig {
    fn disabled() -> Self {
        Self {
            bin: None,
            timeout_secs: 120,
            max_per_scan: 2,
        }
    }

    fn from_args(args: &Args) -> Self {
        let bin = args.ccr_check_bin.clone().or_else(|| {
            std::env::var("RPKI_METRICS_CCR_CHECK_BIN")
                .ok()
                .map(|value| value.trim().to_string())
                .filter(|value| !value.is_empty())
                .map(PathBuf::from)
        });
        Self {
            bin,
            timeout_secs: args.ccr_check_timeout_secs,
            max_per_scan: args.ccr_check_max_per_scan,
        }
    }
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct ServiceMetrics {
    last_scan_timestamp_seconds: f64,
    last_scan_duration_seconds: f64,
    last_reload_success: bool,
    parse_errors: Vec<String>,
    run_root: String,
    runs_root: String,
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct RtrMetrics {
    enabled: bool,
    report_dir: Option<String>,
    parse_errors: Vec<String>,
    parse_errors_total: u64,
    files: BTreeMap<String, RtrFileScan>,
    source: Option<RtrSourceMetrics>,
    runtime: Option<RtrRuntimeMetrics>,
    clients: Option<RtrClientsMetrics>,
}

impl RtrMetrics {
    fn preferred_service(&self) -> Option<&RtrServiceMetrics> {
        self.runtime
            .as_ref()
            .and_then(|runtime| runtime.service.as_ref())
            .or_else(|| {
                self.clients
                    .as_ref()
                    .and_then(|clients| clients.service.as_ref())
            })
    }
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct RtrFileScan {
    seen: u64,
    latest_path: Option<String>,
    latest_generated_at: Option<String>,
    latest_timestamp_seconds: Option<f64>,
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct RtrSourceMetrics {
    file_path: String,
    generated_at: Option<String>,
    generated_at_seconds: Option<f64>,
    phase: Option<String>,
    refresh_status: Option<String>,
    refresh_changed: Option<bool>,
    refresh_duration_seconds: Option<f64>,
    refresh_consecutive_failures: Option<u64>,
    refresh_last_success_at: Option<String>,
    refresh_last_success_seconds: Option<f64>,
    ccr_file_size_bytes: Option<u64>,
    data_quality: BTreeMap<String, BTreeMap<String, u64>>,
    slurm_filters: BTreeMap<String, u64>,
    slurm_assertions: BTreeMap<String, u64>,
    cache_availability: Option<String>,
    cache_versions: Vec<RtrCacheVersion>,
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct RtrCacheVersion {
    version: u64,
    serial: Option<u64>,
    snapshot: BTreeMap<String, u64>,
    latest_delta_announced: BTreeMap<String, u64>,
    latest_delta_withdrawn: BTreeMap<String, u64>,
    delta_window_length: Option<u64>,
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct RtrRuntimeMetrics {
    file_path: String,
    generated_at: Option<String>,
    generated_at_seconds: Option<f64>,
    phase: Option<String>,
    process_rss_bytes: Option<u64>,
    service: Option<RtrServiceMetrics>,
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct RtrClientsMetrics {
    file_path: String,
    generated_at: Option<String>,
    generated_at_seconds: Option<f64>,
    phase: Option<String>,
    service: Option<RtrServiceMetrics>,
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct RtrServiceMetrics {
    started_at: Option<String>,
    uptime_seconds: Option<u64>,
    active_connections: Option<u64>,
    connections_by_transport: BTreeMap<String, u64>,
    session_listeners: Option<u64>,
    max_connections: Option<u64>,
    connection_utilization: Option<f64>,
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct RunScanSummary {
    known: u64,
    success: u64,
    failed: u64,
    partial: u64,
    consecutive_failures: u64,
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct CumulativeMetrics {
    completed_success_total: u64,
    completed_failed_total: u64,
    observed_duration_seconds_sum: f64,
    observed_duration_seconds_count: u64,
    observed_download_bytes_total: u64,
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct LatestRunMetrics {
    run_seq: u64,
    run_id: String,
    run_dir: String,
    status: String,
    sync_mode: String,
    snapshot_reason: Option<String>,
    started_at: Option<String>,
    finished_at: Option<String>,
    start_timestamp_seconds: Option<f64>,
    finish_timestamp_seconds: Option<f64>,
    wall_seconds: f64,
    user_cpu_seconds: Option<f64>,
    system_cpu_seconds: Option<f64>,
    cpu_percent: Option<f64>,
    max_rss_bytes: Option<u64>,
    exit_code: Option<i64>,
    vrps: u64,
    vrps_unique: Option<u64>,
    vaps: u64,
    publication_points: u64,
    warnings: u64,
    tree_instances_processed: Option<u64>,
    tree_instances_failed: Option<u64>,
    stage_seconds: BTreeMap<String, f64>,
    repo_sync_phase: BTreeMap<String, CountDuration>,
    repo_terminal_state: BTreeMap<String, CountDuration>,
    download_events: Option<u64>,
    download_bytes: Option<u64>,
    artifact_sizes: BTreeMap<String, u64>,
    state_path_sizes: BTreeMap<String, PathSize>,
    dead_repo_blacklist_entries: Option<u64>,
    dead_repo_blacklist_blacklisted: Option<u64>,
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct CountDuration {
    count: u64,
    duration_seconds_total: f64,
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct PathSize {
    total_size_bytes: u64,
    file_count: u64,
    dir_count: u64,
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct RepoMetrics {
    repo_id: String,
    uri: String,
    host: String,
    transport: String,
    publication_points: u64,
    sync_success: bool,
    download_bytes: u64,
    duration_seconds_sum: f64,
    duration_seconds_max: f64,
    duration_seconds_avg: f64,
    phase_counts: BTreeMap<String, u64>,
    terminal_state_counts: BTreeMap<String, u64>,
    object_counts: BTreeMap<String, u64>,
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct TopRepo {
    rank: usize,
    repo_id: String,
    uri: String,
    host: String,
    transport: String,
    duration_ms_max: u64,
    duration_ms_sum: u64,
    publication_points: u64,
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct TopPublicationPoint {
    rank: usize,
    pp_id: String,
    repo_id: String,
    uri: String,
    repo_uri: String,
    host: String,
    transport: String,
    object_count: u64,
    sync_duration_ms: u64,
    terminal_state: String,
    phase: String,
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Histogram {
    buckets: Vec<f64>,
    counts: Vec<u64>,
    sum: f64,
    count: u64,
}

impl Default for Histogram {
    fn default() -> Self {
        Self {
            buckets: Vec::new(),
            counts: Vec::new(),
            sum: 0.0,
            count: 0,
        }
    }
}

impl Histogram {
    fn new(buckets: &[f64]) -> Self {
        Self {
            buckets: buckets.to_vec(),
            counts: vec![0; buckets.len() + 1],
            sum: 0.0,
            count: 0,
        }
    }

    fn observe(&mut self, value: f64) {
        self.sum += value;
        self.count += 1;
        let mut placed = false;
        for (index, bucket) in self.buckets.iter().enumerate() {
            if value <= *bucket {
                self.counts[index] += 1;
                placed = true;
                break;
            }
        }
        if !placed {
            let last = self.counts.len() - 1;
            self.counts[last] += 1;
        }
    }

    fn cumulative_counts(&self) -> Vec<u64> {
        let mut out = Vec::with_capacity(self.counts.len());
        let mut running = 0u64;
        for count in &self.counts {
            running += *count;
            out.push(running);
        }
        out
    }
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct CirMetrics {
    version: u32,
    objects: u64,
    fresh_objects: u64,
    cached_objects: u64,
    trust_anchors: u64,
    rejected_objects: u64,
    fresh_rejected_objects: u64,
    cached_rejected_objects: u64,
    object_list_sha256: String,
    fresh_object_list_sha256: String,
    cached_object_list_sha256: String,
    reject_list_sha256: String,
    fresh_reject_list_sha256: String,
    cached_reject_list_sha256: String,
    objects_by_type: BTreeMap<String, u64>,
    fresh_objects_by_type: BTreeMap<String, u64>,
    cached_objects_by_type: BTreeMap<String, u64>,
    rejected_objects_by_type: BTreeMap<String, u64>,
    fresh_rejected_objects_by_type: BTreeMap<String, u64>,
    cached_rejected_objects_by_type: BTreeMap<String, u64>,
}

#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct CcrMetrics {
    version: u32,
    state_present: BTreeMap<String, bool>,
    state_items: BTreeMap<String, u64>,
    state_digests: BTreeMap<String, String>,
}

#[derive(Clone, Debug)]
struct RunRecord {
    path: PathBuf,
    status: String,
    summary: Option<Value>,
    meta: Option<Value>,
}

fn scan_run_root(input_root: &Path, instance: &str) -> Result<MetricsSnapshot, String> {
    let rtr_report_dir = configured_rtr_report_dir();
    scan_run_root_incremental_with_rtr_dir(
        input_root,
        instance,
        None,
        rtr_report_dir.as_deref(),
        &CcrCheckConfig::disabled(),
    )
}

fn scan_run_root_incremental_with_rtr_dir(
    input_root: &Path,
    instance: &str,
    previous: Option<&MetricsSnapshot>,
    rtr_report_dir: Option<&Path>,
    ccr_check_config: &CcrCheckConfig,
) -> Result<MetricsSnapshot, String> {
    let started = Instant::now();
    let runs_root = resolve_runs_root(input_root);
    let mut snapshot = MetricsSnapshot {
        instance: instance.to_string(),
        service: ServiceMetrics {
            run_root: input_root.display().to_string(),
            runs_root: runs_root.display().to_string(),
            ..ServiceMetrics::default()
        },
        ..MetricsSnapshot::default()
    };

    let records = collect_run_records(&runs_root, &mut snapshot.service.parse_errors)?;
    snapshot.runs.known = records.len() as u64;
    for record in &records {
        match record.status.as_str() {
            "success" => snapshot.runs.success += 1,
            "failed" | "spawn_failed" => snapshot.runs.failed += 1,
            _ => snapshot.runs.partial += 1,
        }
    }
    snapshot.runs.consecutive_failures = consecutive_failures(&records);
    snapshot.cumulative.completed_success_total = snapshot.runs.success;
    snapshot.cumulative.completed_failed_total = snapshot.runs.failed;

    for record in records.iter().filter(|record| record.status == "success") {
        if let Some(summary) = record.summary.as_ref() {
            let wall_seconds = json_u64(summary, &["wallMs"]).unwrap_or(0) as f64 / 1000.0;
            snapshot.cumulative.observed_duration_seconds_sum += wall_seconds;
            snapshot.cumulative.observed_duration_seconds_count += 1;
            if let Some(bytes) = json_u64(summary, &["stageTiming", "download_bytes_total"]) {
                snapshot.cumulative.observed_download_bytes_total = snapshot
                    .cumulative
                    .observed_download_bytes_total
                    .saturating_add(bytes);
            }
        }
    }

    if let Some(latest) = records
        .iter()
        .rev()
        .find(|record| record.status == "success")
    {
        if can_reuse_latest_metrics(latest, previous) {
            reuse_latest_artifact_metrics(previous.expect("checked previous"), &mut snapshot);
        } else {
            build_latest_metrics(latest, &mut snapshot);
            let _ = crate::memory_telemetry::malloc_trim_probe();
        }
    }

    snapshot.rtr = scan_rtr_reports(rtr_report_dir, previous.map(|snapshot| &snapshot.rtr));
    run_pending_ccr_checks(&records, previous, ccr_check_config, &mut snapshot);
    snapshot.service.last_scan_timestamp_seconds = unix_now_seconds();
    snapshot.service.last_scan_duration_seconds = started.elapsed().as_secs_f64();
    snapshot.service.last_reload_success =
        snapshot.service.parse_errors.is_empty() && snapshot.rtr.parse_errors.is_empty();
    Ok(snapshot)
}

fn can_reuse_latest_metrics(record: &RunRecord, previous: Option<&MetricsSnapshot>) -> bool {
    let Some(previous) = previous else {
        return false;
    };
    let Some(previous_latest) = previous.latest_run.as_ref() else {
        return false;
    };
    if previous_latest.run_dir != record.path.display().to_string() {
        return false;
    }
    if previous_latest.status != record.status {
        return false;
    }
    let summary = record.summary.as_ref();
    let meta = record.meta.as_ref();
    let finished_at = summary
        .and_then(|v| json_str(v, &["finishedAtRfc3339Utc"]))
        .or_else(|| meta.and_then(|v| json_str(v, &["completed_at_rfc3339_utc"])));
    let wall_seconds = summary.and_then(|v| json_u64(v, &["wallMs"])).unwrap_or(0) as f64 / 1000.0;
    previous_latest.finished_at.as_deref() == finished_at
        && (previous_latest.wall_seconds - wall_seconds).abs() < f64::EPSILON
}

fn reuse_latest_artifact_metrics(previous: &MetricsSnapshot, snapshot: &mut MetricsSnapshot) {
    snapshot.latest_run = previous.latest_run.clone();
    snapshot.repo_stats = previous.repo_stats.clone();
    snapshot.object_counts = previous.object_counts.clone();
    snapshot.large_pp_counts = previous.large_pp_counts.clone();
    snapshot.pp_sync_histograms = previous.pp_sync_histograms.clone();
    snapshot.top_repos_by_sync_duration = previous.top_repos_by_sync_duration.clone();
    snapshot.top_pp_by_object_count = previous.top_pp_by_object_count.clone();
    snapshot.top_pp_by_sync_duration = previous.top_pp_by_sync_duration.clone();
    snapshot.cir = previous.cir.clone();
    snapshot.ccr = previous.ccr.clone();
}

fn resolve_runs_root(input_root: &Path) -> PathBuf {
    let runs = input_root.join("runs");
    if runs.is_dir() {
        runs
    } else {
        input_root.to_path_buf()
    }
}

fn configured_rtr_report_dir() -> Option<PathBuf> {
    std::env::var("RPKI_METRICS_RTR_REPORT_DIR")
        .ok()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
}

fn scan_rtr_reports(report_dir: Option<&Path>, previous: Option<&RtrMetrics>) -> RtrMetrics {
    let mut metrics = RtrMetrics {
        enabled: report_dir.is_some(),
        report_dir: report_dir.map(|path| path.display().to_string()),
        parse_errors_total: previous.map(|prev| prev.parse_errors_total).unwrap_or(0),
        ..RtrMetrics::default()
    };

    let Some(report_dir) = report_dir else {
        return metrics;
    };

    if !report_dir.is_dir() {
        metrics.parse_errors.push(format!(
            "rtr report dir is not a directory: {}",
            report_dir.display()
        ));
        metrics.parse_errors_total = metrics
            .parse_errors_total
            .saturating_add(metrics.parse_errors.len() as u64);
        return metrics;
    }

    metrics.files.insert(
        "source".to_string(),
        scan_rtr_kind(
            report_dir,
            &["rtr-source-*.json"],
            &mut metrics.parse_errors,
        ),
    );
    metrics.files.insert(
        "runtime".to_string(),
        scan_rtr_kind(
            report_dir,
            &["rtr-runtime-*.json"],
            &mut metrics.parse_errors,
        ),
    );
    metrics.files.insert(
        "clients".to_string(),
        scan_rtr_kind(
            report_dir,
            &["rtr-clients-*.json", "rtr-client-*.json"],
            &mut metrics.parse_errors,
        ),
    );

    if let Some(file) = metrics
        .files
        .get("source")
        .and_then(|scan| scan.latest_path.as_deref())
    {
        if let Some(value) = read_rtr_json(&PathBuf::from(file), &mut metrics.parse_errors) {
            metrics.source = Some(parse_rtr_source_metrics(&PathBuf::from(file), &value));
            if let Some(source) = metrics.source.as_ref() {
                if let Some(scan) = metrics.files.get_mut("source") {
                    scan.latest_generated_at = source.generated_at.clone();
                    scan.latest_timestamp_seconds = source.generated_at_seconds;
                }
            }
        }
    }

    if let Some(file) = metrics
        .files
        .get("runtime")
        .and_then(|scan| scan.latest_path.as_deref())
    {
        if let Some(value) = read_rtr_json(&PathBuf::from(file), &mut metrics.parse_errors) {
            metrics.runtime = Some(parse_rtr_runtime_metrics(&PathBuf::from(file), &value));
            if let Some(runtime) = metrics.runtime.as_ref() {
                if let Some(scan) = metrics.files.get_mut("runtime") {
                    scan.latest_generated_at = runtime.generated_at.clone();
                    scan.latest_timestamp_seconds = runtime.generated_at_seconds;
                }
            }
        }
    }

    if let Some(file) = metrics
        .files
        .get("clients")
        .and_then(|scan| scan.latest_path.as_deref())
    {
        if let Some(value) = read_rtr_json(&PathBuf::from(file), &mut metrics.parse_errors) {
            metrics.clients = Some(parse_rtr_clients_metrics(&PathBuf::from(file), &value));
            if let Some(clients) = metrics.clients.as_ref() {
                if let Some(scan) = metrics.files.get_mut("clients") {
                    scan.latest_generated_at = clients.generated_at.clone();
                    scan.latest_timestamp_seconds = clients.generated_at_seconds;
                }
            }
        }
    }

    metrics.parse_errors_total = metrics
        .parse_errors_total
        .saturating_add(metrics.parse_errors.len() as u64);
    metrics
}

fn scan_rtr_kind(report_dir: &Path, patterns: &[&str], errors: &mut Vec<String>) -> RtrFileScan {
    let mut names = BTreeSet::new();
    for pattern in patterns {
        match glob_rtr_pattern(report_dir, pattern) {
            Ok(found) => {
                for path in found {
                    names.insert(path);
                }
            }
            Err(err) => errors.push(err),
        }
    }
    let latest_path = names.iter().next_back().cloned();
    RtrFileScan {
        seen: names.len() as u64,
        latest_path: latest_path.map(|path| path.display().to_string()),
        latest_generated_at: None,
        latest_timestamp_seconds: None,
    }
}

fn glob_rtr_pattern(report_dir: &Path, pattern: &str) -> Result<Vec<PathBuf>, String> {
    let Some((prefix, suffix)) = pattern.split_once('*') else {
        return Err(format!("unsupported RTR report pattern: {pattern}"));
    };
    let mut matches = Vec::new();
    let entries = fs::read_dir(report_dir)
        .map_err(|e| format!("read rtr report dir failed: {}: {e}", report_dir.display()))?;
    for entry in entries {
        let entry = entry.map_err(|e| format!("read rtr report entry failed: {e}"))?;
        let path = entry.path();
        if !path.is_file() {
            continue;
        }
        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
            continue;
        };
        if name.starts_with(prefix) && name.ends_with(suffix) {
            matches.push(path);
        }
    }
    matches.sort();
    Ok(matches)
}

fn read_rtr_json(path: &Path, errors: &mut Vec<String>) -> Option<Value> {
    let Ok(bytes) = fs::read(path) else {
        errors.push(format!("read rtr report failed: {}", path.display()));
        return None;
    };
    match serde_json::from_slice::<Value>(&bytes) {
        Ok(value) => Some(value),
        Err(err) => {
            errors.push(format!(
                "parse rtr report failed: {}: {err}",
                path.display()
            ));
            None
        }
    }
}

fn parse_rtr_source_metrics(path: &Path, value: &Value) -> RtrSourceMetrics {
    let generated_at = json_str(value, &["generated_at"]).map(|value| value.to_string());
    let generated_at_seconds = generated_at.as_deref().and_then(parse_rfc3339_to_unix);
    let mut data_quality = BTreeMap::new();
    for stage in ["ccr_input", "invalid", "before_slurm", "after_slurm"] {
        if let Some(stage_metrics) =
            json_counts_map(value.pointer(&format!("/data_quality/{stage}")))
        {
            data_quality.insert(stage.to_string(), stage_metrics);
        }
    }

    let cache_versions = value
        .pointer("/cache/versions")
        .and_then(|value| value.as_array())
        .map(|versions| {
            versions
                .iter()
                .map(|version| RtrCacheVersion {
                    version: json_u64(version, &["version"]).unwrap_or(0),
                    serial: json_u64(version, &["serial"]),
                    snapshot: json_counts_map(version.get("snapshot")).unwrap_or_default(),
                    latest_delta_announced: json_counts_map(
                        version.pointer("/latest_delta/announced"),
                    )
                    .unwrap_or_default(),
                    latest_delta_withdrawn: json_counts_map(
                        version.pointer("/latest_delta/withdrawn"),
                    )
                    .unwrap_or_default(),
                    delta_window_length: json_u64(version, &["delta_window", "length"]),
                })
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();

    RtrSourceMetrics {
        file_path: path.display().to_string(),
        generated_at,
        generated_at_seconds,
        phase: json_str(value, &["phase"]).map(|value| value.to_string()),
        refresh_status: json_str(value, &["refresh", "status"]).map(|value| value.to_string()),
        refresh_changed: json_bool(value, &["refresh", "changed"]),
        refresh_duration_seconds: json_u64(value, &["refresh", "duration_ms"])
            .map(|value| value as f64 / 1000.0),
        refresh_consecutive_failures: json_u64(value, &["refresh", "consecutive_failures"]),
        refresh_last_success_at: json_str(value, &["refresh", "last_success_at"])
            .map(|value| value.to_string()),
        refresh_last_success_seconds: json_str(value, &["refresh", "last_success_at"])
            .and_then(parse_rfc3339_to_unix),
        ccr_file_size_bytes: json_u64(value, &["source", "ccr_file_size_bytes"]),
        data_quality,
        slurm_filters: json_counts_map(value.pointer("/data_quality/slurm_filters"))
            .unwrap_or_default(),
        slurm_assertions: json_counts_map(value.pointer("/data_quality/slurm_assertions"))
            .unwrap_or_default(),
        cache_availability: json_str(value, &["cache", "availability"])
            .map(|value| value.to_string()),
        cache_versions,
    }
}

fn parse_rtr_runtime_metrics(path: &Path, value: &Value) -> RtrRuntimeMetrics {
    let generated_at = json_str(value, &["generated_at"]).map(|value| value.to_string());
    let generated_at_seconds = generated_at.as_deref().and_then(parse_rfc3339_to_unix);
    RtrRuntimeMetrics {
        file_path: path.display().to_string(),
        generated_at,
        generated_at_seconds,
        phase: json_str(value, &["phase"]).map(|value| value.to_string()),
        process_rss_bytes: json_u64(value, &["process", "rss_mib"])
            .map(|value| value.saturating_mul(1024 * 1024)),
        service: parse_rtr_service_metrics(value),
    }
}

fn parse_rtr_clients_metrics(path: &Path, value: &Value) -> RtrClientsMetrics {
    let generated_at = json_str(value, &["generated_at"]).map(|value| value.to_string());
    let generated_at_seconds = generated_at.as_deref().and_then(parse_rfc3339_to_unix);
    RtrClientsMetrics {
        file_path: path.display().to_string(),
        generated_at,
        generated_at_seconds,
        phase: json_str(value, &["phase"]).map(|value| value.to_string()),
        service: parse_rtr_service_metrics(value),
    }
}

fn parse_rtr_service_metrics(value: &Value) -> Option<RtrServiceMetrics> {
    let service = value.get("service")?;
    Some(RtrServiceMetrics {
        started_at: json_str(service, &["started_at"]).map(|value| value.to_string()),
        uptime_seconds: json_u64(service, &["uptime_seconds"]),
        active_connections: json_u64(service, &["active_connections"]),
        connections_by_transport: json_counts_map(service.get("connections_by_transport"))
            .unwrap_or_default(),
        session_listeners: json_u64(service, &["session_listeners"]),
        max_connections: json_u64(service, &["max_connections"]),
        connection_utilization: json_f64(service, &["connection_utilization"]),
    })
}

fn json_counts_map(value: Option<&Value>) -> Option<BTreeMap<String, u64>> {
    let mut out = BTreeMap::new();
    let object = value?.as_object()?;
    for (key, value) in object {
        if let Some(count) = value.as_u64() {
            out.insert(key.clone(), count);
        }
    }
    Some(out)
}

fn collect_run_records(
    runs_root: &Path,
    errors: &mut Vec<String>,
) -> Result<Vec<RunRecord>, String> {
    let mut records = Vec::new();
    if !runs_root.is_dir() {
        return Err(format!(
            "runs root is not a directory: {}",
            runs_root.display()
        ));
    }
    let entries = fs::read_dir(runs_root)
        .map_err(|e| format!("read runs root failed: {}: {e}", runs_root.display()))?;
    for entry in entries {
        let entry = entry.map_err(|e| format!("read runs entry failed: {e}"))?;
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
            continue;
        };
        if !name.starts_with("run_") {
            continue;
        }
        let summary = read_json_optional(&path.join("run-summary.json"), errors);
        let meta = read_json_optional(&path.join("run-meta.json"), errors);
        let status = classify_run_status(&summary, &meta, &path);
        records.push(RunRecord {
            path,
            status,
            summary,
            meta,
        });
    }
    records.sort_by(|left, right| left.path.cmp(&right.path));
    Ok(records)
}

fn classify_run_status(summary: &Option<Value>, meta: &Option<Value>, path: &Path) -> String {
    let summary_status = summary.as_ref().and_then(|v| json_str(v, &["status"]));
    let meta_status = meta.as_ref().and_then(|v| json_str(v, &["status"]));
    if summary_status == Some("success") && meta_status == Some("success") {
        return "success".to_string();
    }
    if matches!(summary_status, Some("failed" | "spawn_failed"))
        || matches!(meta_status, Some("failed" | "spawn_failed"))
    {
        return "failed".to_string();
    }
    if path.join("run-summary.json").exists() || path.join("run-meta.json").exists() {
        "partial".to_string()
    } else {
        "missing_metadata".to_string()
    }
}

fn consecutive_failures(records: &[RunRecord]) -> u64 {
    let mut count = 0u64;
    for record in records.iter().rev() {
        if record.status == "success" {
            break;
        }
        count += 1;
    }
    count
}

const MAX_CHECKED_RUN_DIRS: usize = 512;

fn run_pending_ccr_checks(
    records: &[RunRecord],
    previous: Option<&MetricsSnapshot>,
    config: &CcrCheckConfig,
    snapshot: &mut MetricsSnapshot,
) {
    let mut check = previous
        .map(|prev| prev.ccr_check.clone())
        .unwrap_or_default();
    check.enabled = config.bin.is_some();
    check.bin = config.bin.as_ref().map(|bin| bin.display().to_string());
    check.up = false;

    let Some(bin) = config.bin.as_ref() else {
        snapshot.ccr_check = check;
        return;
    };
    if !bin.is_file() {
        snapshot
            .service
            .parse_errors
            .push(format!("ccr check bin is not a file: {}", bin.display()));
        snapshot.ccr_check = check;
        return;
    }
    check.up = true;

    let mut pending: Vec<&RunRecord> = records
        .iter()
        .filter(|record| record.status == "success")
        .filter(|record| record.path.join("result.ccr").is_file())
        .filter(|record| {
            let dir = record.path.display().to_string();
            !check.checked_run_dirs.iter().any(|seen| seen == &dir)
        })
        .collect();

    if previous.is_none() {
        // On service start only the latest existing run is checked; older
        // history is marked as seen without executing checks.
        let latest_path = pending.last().map(|record| record.path.clone());
        for record in &pending {
            if Some(&record.path) != latest_path.as_ref() {
                check
                    .checked_run_dirs
                    .push(record.path.display().to_string());
            }
        }
        pending.retain(|record| Some(&record.path) == latest_path.as_ref());
    }

    for record in pending
        .into_iter()
        .take(config.max_per_scan.max(1) as usize)
    {
        let ccr_path = record.path.join("result.ccr");
        let outcome = execute_ccr_check(bin, &ccr_path, Duration::from_secs(config.timeout_secs));
        let run_seq = record
            .summary
            .as_ref()
            .and_then(|v| json_u64(v, &["runSeq"]))
            .or_else(|| {
                record
                    .meta
                    .as_ref()
                    .and_then(|v| json_u64(v, &["run_index"]))
            });
        let passed = outcome.outcome == "pass";
        match outcome.outcome.as_str() {
            "pass" => check.pass_total += 1,
            "ccr_parse_error" => check.ccr_parse_error_total += 1,
            _ => check.tool_error_total += 1,
        }
        check.consecutive_failures = if passed {
            0
        } else {
            check.consecutive_failures + 1
        };
        check.last = Some(CcrCheckLastResult {
            run_dir: record.path.display().to_string(),
            run_seq,
            outcome: outcome.outcome,
            duration_seconds: outcome.duration_seconds,
            checked_at_seconds: unix_now_seconds(),
            detail: outcome.detail,
        });
        check
            .checked_run_dirs
            .push(record.path.display().to_string());
    }
    if check.checked_run_dirs.len() > MAX_CHECKED_RUN_DIRS {
        let drain = check.checked_run_dirs.len() - MAX_CHECKED_RUN_DIRS;
        check.checked_run_dirs.drain(..drain);
    }
    snapshot.ccr_check = check;
}

struct CcrCheckOutcome {
    outcome: String,
    duration_seconds: f64,
    detail: Option<String>,
}

fn execute_ccr_check(bin: &Path, ccr_path: &Path, timeout: Duration) -> CcrCheckOutcome {
    let started = Instant::now();
    let log_path = std::env::temp_dir().join(format!(
        "rpki-artifact-metrics-ccr-check-{}-{}.log",
        std::process::id(),
        SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0)
    ));
    let (outcome, detail) = execute_ccr_check_logged(bin, ccr_path, timeout, &log_path);
    let _ = fs::remove_file(&log_path);
    CcrCheckOutcome {
        outcome,
        duration_seconds: started.elapsed().as_secs_f64(),
        detail,
    }
}

fn execute_ccr_check_logged(
    bin: &Path,
    ccr_path: &Path,
    timeout: Duration,
    log_path: &Path,
) -> (String, Option<String>) {
    let log_file = match fs::File::create(log_path) {
        Ok(file) => file,
        Err(err) => {
            return (
                "tool_error".to_string(),
                Some(format!("create ccr check log failed: {err}")),
            );
        }
    };
    let log_err = match log_file.try_clone() {
        Ok(file) => file,
        Err(err) => {
            return (
                "tool_error".to_string(),
                Some(format!("clone ccr check log handle failed: {err}")),
            );
        }
    };
    let mut child = match Command::new(bin)
        .arg("-f")
        .arg(ccr_path)
        .stdin(Stdio::null())
        .stdout(Stdio::from(log_file))
        .stderr(Stdio::from(log_err))
        .spawn()
    {
        Ok(child) => child,
        Err(err) => {
            return (
                "tool_error".to_string(),
                Some(format!("spawn ccr check failed: {err}")),
            );
        }
    };
    let started = Instant::now();
    let exit_code = loop {
        match child.try_wait() {
            Ok(Some(status)) => break status.code(),
            Ok(None) => {
                if started.elapsed() >= timeout {
                    let _ = child.kill();
                    let _ = child.wait();
                    return (
                        "tool_error".to_string(),
                        Some(format!("ccr check timed out after {}s", timeout.as_secs())),
                    );
                }
                thread::sleep(Duration::from_millis(100));
            }
            Err(err) => {
                return (
                    "tool_error".to_string(),
                    Some(format!("wait ccr check failed: {err}")),
                );
            }
        }
    };
    let output = fs::read_to_string(log_path).unwrap_or_default();
    classify_ccr_check_output(exit_code, ccr_path, &output)
}

fn classify_ccr_check_output(
    exit_code: Option<i32>,
    ccr_path: &Path,
    output: &str,
) -> (String, Option<String>) {
    if exit_code != Some(0) {
        return (
            "tool_error".to_string(),
            Some(format!("rpki-client exited with code {exit_code:?}")),
        );
    }
    // rpki-client exits 0 even when the CCR file itself fails to parse, so
    // the file-level error line is the authoritative failure signal. This
    // mirrors scripts/ccr/check_rpki_client_ccr.sh.
    let marker = format!("rpki-client: {}:", ccr_path.display());
    if let Some(line) = output.lines().find(|line| line.contains(&marker)) {
        return ("ccr_parse_error".to_string(), Some(line.trim().to_string()));
    }
    ("pass".to_string(), None)
}

fn read_json_optional(path: &Path, errors: &mut Vec<String>) -> Option<Value> {
    if !path.exists() {
        return None;
    }
    match fs::read(path)
        .ok()
        .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
    {
        Some(value) => Some(value),
        None => {
            errors.push(format!("parse json failed: {}", path.display()));
            None
        }
    }
}

fn build_latest_metrics(record: &RunRecord, snapshot: &mut MetricsSnapshot) {
    let summary = record.summary.as_ref();
    let meta = record.meta.as_ref();
    let run_seq = summary
        .and_then(|v| json_u64(v, &["runSeq"]))
        .or_else(|| meta.and_then(|v| json_u64(v, &["run_index"])))
        .unwrap_or_else(|| run_index_from_path(&record.path).unwrap_or(0));
    let run_id = summary
        .and_then(|v| json_str(v, &["runId"]))
        .or_else(|| meta.and_then(|v| json_str(v, &["run_id"])))
        .unwrap_or_else(|| {
            record
                .path
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("unknown")
        })
        .to_string();
    let sync_mode = meta
        .and_then(|v| json_str(v, &["sync_mode"]))
        .unwrap_or("unknown")
        .to_string();
    let snapshot_reason = meta
        .and_then(|v| json_str(v, &["snapshot_reason"]))
        .map(|s| s.to_string());
    let started_at = summary
        .and_then(|v| json_str(v, &["startedAtRfc3339Utc"]))
        .or_else(|| meta.and_then(|v| json_str(v, &["started_at_rfc3339_utc"])))
        .map(|s| s.to_string());
    let finished_at = summary
        .and_then(|v| json_str(v, &["finishedAtRfc3339Utc"]))
        .or_else(|| meta.and_then(|v| json_str(v, &["completed_at_rfc3339_utc"])))
        .map(|s| s.to_string());
    let wall_seconds = summary.and_then(|v| json_u64(v, &["wallMs"])).unwrap_or(0) as f64 / 1000.0;

    let mut latest = LatestRunMetrics {
        run_seq,
        run_id,
        run_dir: record.path.display().to_string(),
        status: record.status.clone(),
        sync_mode,
        snapshot_reason,
        started_at: started_at.clone(),
        finished_at: finished_at.clone(),
        start_timestamp_seconds: started_at.as_deref().and_then(parse_rfc3339_to_unix),
        finish_timestamp_seconds: finished_at.as_deref().and_then(parse_rfc3339_to_unix),
        wall_seconds,
        user_cpu_seconds: summary.and_then(|v| json_f64(v, &["processMetrics", "userSeconds"])),
        system_cpu_seconds: summary.and_then(|v| json_f64(v, &["processMetrics", "systemSeconds"])),
        cpu_percent: summary.and_then(|v| json_f64(v, &["processMetrics", "cpuPercent"])),
        max_rss_bytes: summary
            .and_then(|v| json_u64(v, &["processMetrics", "maxRssKb"]))
            .map(|kb| kb.saturating_mul(1024)),
        exit_code: summary.and_then(|v| json_i64(v, &["exitCode"])),
        ..LatestRunMetrics::default()
    };

    if let Some(summary) = summary {
        latest.vrps = json_u64(summary, &["reportCounts", "vrps"]).unwrap_or(0);
        latest.vaps = json_u64(summary, &["reportCounts", "aspas"]).unwrap_or(0);
        latest.publication_points =
            json_u64(summary, &["reportCounts", "publicationPoints"]).unwrap_or(0);
        latest.warnings = json_u64(summary, &["reportCounts", "warnings"]).unwrap_or(0);
        latest.tree_instances_processed =
            json_u64(summary, &["reportCounts", "treeInstancesProcessed"]);
        latest.tree_instances_failed = json_u64(summary, &["reportCounts", "treeInstancesFailed"]);
        latest.stage_seconds = extract_stage_seconds(summary.get("stageTiming"));
        latest.repo_sync_phase =
            extract_count_duration_map(summary.pointer("/repoSyncStats/by_phase"));
        latest.repo_terminal_state =
            extract_count_duration_map(summary.pointer("/repoSyncStats/by_terminal_state"));
        latest.download_events = json_u64(summary, &["stageTiming", "download_event_count"]);
        latest.download_bytes = json_u64(summary, &["stageTiming", "download_bytes_total"]);
        latest.artifact_sizes = extract_artifact_sizes(summary.get("artifacts"));
        latest.state_path_sizes = extract_path_sizes(summary.get("pathStats"));
        latest.dead_repo_blacklist_entries =
            json_u64(summary, &["deadRepoBlacklist", "entries"]);
        latest.dead_repo_blacklist_blacklisted =
            json_u64(summary, &["deadRepoBlacklist", "blacklisted"]);
    }

    parse_report(&record.path.join("report.json"), snapshot, &mut latest);
    latest.vrps_unique = count_vrp_csv_unique_keys_opt(
        &record.path.join("vrps.csv"),
        &mut snapshot.service.parse_errors,
    );
    parse_cir(&record.path.join("input.cir"), snapshot);
    parse_ccr(&record.path.join("result.ccr"), snapshot);
    snapshot.latest_run = Some(latest);
}

fn count_vrp_csv_unique_keys_opt(path: &Path, errors: &mut Vec<String>) -> Option<u64> {
    if !path.exists() {
        return None;
    }
    match count_vrp_csv_unique_keys(path) {
        Ok(count) => Some(count),
        Err(err) => {
            errors.push(err);
            None
        }
    }
}

fn count_vrp_csv_unique_keys(path: &Path) -> Result<u64, String> {
    let content = fs::read_to_string(path)
        .map_err(|e| format!("read VRP CSV failed: {}: {e}", path.display()))?;
    let mut unique = BTreeSet::new();
    for (index, line) in data_csv_lines(&content).enumerate() {
        if index == 0 {
            continue;
        }
        let columns = split_csv_simple(line);
        if columns.len() < 3 {
            return Err(format!(
                "invalid VRP CSV row in {}: expected at least 3 columns, got {}",
                path.display(),
                columns.len()
            ));
        }
        unique.insert((
            columns[0].to_string(),
            columns[1].to_string(),
            columns[2].to_string(),
        ));
    }
    Ok(unique.len() as u64)
}

fn data_csv_lines(content: &str) -> impl Iterator<Item = &str> {
    content
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .filter(|line| !line.starts_with('#'))
}

fn split_csv_simple(line: &str) -> Vec<&str> {
    line.split(',').map(str::trim).collect()
}

fn parse_report(path: &Path, snapshot: &mut MetricsSnapshot, latest: &mut LatestRunMetrics) {
    if !path.exists() {
        return;
    }
    let Ok(bytes) = fs::read(path) else {
        snapshot
            .service
            .parse_errors
            .push(format!("read report.json failed: {}", path.display()));
        return;
    };
    let Ok(report) = serde_json::from_slice::<Value>(&bytes) else {
        snapshot
            .service
            .parse_errors
            .push(format!("parse report.json failed: {}", path.display()));
        return;
    };
    if latest.vrps == 0 {
        latest.vrps = report
            .get("vrps")
            .and_then(|v| v.as_array())
            .map(|a| a.len() as u64)
            .unwrap_or(0);
    }
    if latest.vaps == 0 {
        latest.vaps = report
            .get("aspas")
            .and_then(|v| v.as_array())
            .map(|a| a.len() as u64)
            .unwrap_or(0);
    }
    latest.warnings = latest.warnings.max(
        report
            .pointer("/tree/warnings")
            .and_then(|v| v.as_array())
            .map(|a| a.len() as u64)
            .unwrap_or(0),
    );
    if let Some(processed) = json_u64(&report, &["tree", "instances_processed"]) {
        latest.tree_instances_processed = Some(processed);
    }
    if let Some(failed) = json_u64(&report, &["tree", "instances_failed"]) {
        latest.tree_instances_failed = Some(failed);
    }
    if latest.repo_sync_phase.is_empty() {
        latest.repo_sync_phase =
            extract_count_duration_map(report.pointer("/repo_sync_stats/by_phase"));
    }
    if latest.repo_terminal_state.is_empty() {
        latest.repo_terminal_state =
            extract_count_duration_map(report.pointer("/repo_sync_stats/by_terminal_state"));
    }
    if let Some(pps) = report.get("publication_points").and_then(|v| v.as_array()) {
        latest.publication_points = pps.len() as u64;
        extract_publication_point_metrics(pps, report.get("downloads"), snapshot);
    }
}

fn extract_publication_point_metrics(
    pps: &[Value],
    downloads: Option<&Value>,
    snapshot: &mut MetricsSnapshot,
) {
    let mut repos: BTreeMap<String, RepoMetrics> = BTreeMap::new();
    let mut pp_by_object_count = Vec::<TopPublicationPoint>::new();
    let mut pp_by_sync_duration = Vec::<TopPublicationPoint>::new();
    let mut large_pp_counts = BTreeMap::<u64, u64>::new();
    let mut pp_sync_histograms = BTreeMap::<String, Histogram>::new();
    let mut object_counts = BTreeMap::<(String, String), u64>::new();

    for pp in pps {
        let pp_uri = json_str(pp, &["publication_point_rsync_uri"])
            .or_else(|| json_str(pp, &["manifest_rsync_uri"]))
            .or_else(|| json_str(pp, &["rsync_base_uri"]))
            .unwrap_or("unknown");
        let repo_uri = json_str(pp, &["rrdp_notification_uri"])
            .or_else(|| json_str(pp, &["rsync_base_uri"]))
            .or_else(|| json_str(pp, &["publication_point_rsync_uri"]))
            .unwrap_or(pp_uri);
        let repo_id = short_sha256(repo_uri);
        let pp_id = short_sha256(pp_uri);
        let host = uri_host(repo_uri);
        let transport = json_str(pp, &["repo_sync_source"])
            .or_else(|| json_str(pp, &["source"]))
            .map(normalize_transport)
            .unwrap_or_else(|| infer_transport(repo_uri));
        let duration_ms = json_u64(pp, &["repo_sync_duration_ms"]).unwrap_or(0);
        let duration_seconds = duration_ms as f64 / 1000.0;
        let phase = json_str(pp, &["repo_sync_phase"])
            .unwrap_or("unknown")
            .to_string();
        let terminal_state = json_str(pp, &["repo_terminal_state"])
            .unwrap_or("unknown")
            .to_string();
        let object_count = pp
            .get("objects")
            .and_then(|v| v.as_array())
            .map(|a| a.len() as u64)
            .unwrap_or(0);

        let repo = repos.entry(repo_id.clone()).or_insert_with(|| RepoMetrics {
            repo_id: repo_id.clone(),
            uri: repo_uri.to_string(),
            host: host.clone(),
            transport: transport.clone(),
            sync_success: true,
            ..RepoMetrics::default()
        });
        repo.publication_points += 1;
        repo.duration_seconds_sum += duration_seconds;
        repo.duration_seconds_max = repo.duration_seconds_max.max(duration_seconds);
        if !is_success_terminal_state(&terminal_state) {
            repo.sync_success = false;
        }
        *repo.phase_counts.entry(phase.clone()).or_default() += 1;
        *repo
            .terminal_state_counts
            .entry(terminal_state.clone())
            .or_default() += 1;

        for threshold in LARGE_PP_OBJECT_THRESHOLDS {
            if object_count > *threshold {
                *large_pp_counts.entry(*threshold).or_default() += 1;
            }
        }
        pp_sync_histograms
            .entry(transport.clone())
            .or_insert_with(|| Histogram::new(PP_SYNC_SECONDS_BUCKETS))
            .observe(duration_seconds);

        if let Some(objects) = pp.get("objects").and_then(|v| v.as_array()) {
            for object in objects {
                let kind = json_str(object, &["kind"]).unwrap_or("unknown").to_string();
                let result = json_str(object, &["result"])
                    .unwrap_or("unknown")
                    .to_string();
                *object_counts.entry((kind.clone(), result)).or_default() += 1;
                *repo.object_counts.entry(kind).or_default() += 1;
            }
        }

        let top = TopPublicationPoint {
            rank: 0,
            pp_id,
            repo_id,
            uri: pp_uri.to_string(),
            repo_uri: repo_uri.to_string(),
            host,
            transport,
            object_count,
            sync_duration_ms: duration_ms,
            terminal_state,
            phase,
        };
        pp_by_object_count.push(top.clone());
        pp_by_sync_duration.push(top);
    }

    let mut repo_stats = repos.into_values().collect::<Vec<_>>();
    assign_download_bytes_to_repos(&mut repo_stats, downloads);
    for repo in &mut repo_stats {
        if repo.publication_points > 0 {
            repo.duration_seconds_avg = repo.duration_seconds_sum / repo.publication_points as f64;
        }
    }
    let mut top_repos = repo_stats
        .iter()
        .map(|repo| TopRepo {
            rank: 0,
            repo_id: repo.repo_id.clone(),
            uri: repo.uri.clone(),
            host: repo.host.clone(),
            transport: repo.transport.clone(),
            duration_ms_max: (repo.duration_seconds_max * 1000.0).round() as u64,
            duration_ms_sum: (repo.duration_seconds_sum * 1000.0).round() as u64,
            publication_points: repo.publication_points,
        })
        .collect::<Vec<_>>();
    top_repos.sort_by(|a, b| b.duration_ms_max.cmp(&a.duration_ms_max));
    top_repos.truncate(20);
    for (index, item) in top_repos.iter_mut().enumerate() {
        item.rank = index + 1;
    }

    pp_by_object_count.sort_by(|a, b| b.object_count.cmp(&a.object_count));
    pp_by_object_count.truncate(20);
    for (index, item) in pp_by_object_count.iter_mut().enumerate() {
        item.rank = index + 1;
    }
    pp_by_sync_duration.sort_by(|a, b| b.sync_duration_ms.cmp(&a.sync_duration_ms));
    pp_by_sync_duration.truncate(20);
    for (index, item) in pp_by_sync_duration.iter_mut().enumerate() {
        item.rank = index + 1;
    }

    snapshot.repo_stats = repo_stats;
    snapshot.object_counts = object_counts;
    snapshot.large_pp_counts = large_pp_counts;
    snapshot.pp_sync_histograms = pp_sync_histograms;
    snapshot.top_repos_by_sync_duration = top_repos;
    snapshot.top_pp_by_object_count = pp_by_object_count;
    snapshot.top_pp_by_sync_duration = pp_by_sync_duration;
}

fn assign_download_bytes_to_repos(repos: &mut [RepoMetrics], downloads: Option<&Value>) {
    let Some(downloads) = downloads.and_then(|v| v.as_array()) else {
        return;
    };
    for download in downloads {
        let Some(uri) = json_str(download, &["uri"]) else {
            continue;
        };
        let bytes = json_u64(download, &["bytes"]).unwrap_or(0);
        if bytes == 0 {
            continue;
        }
        if let Some(index) = find_repo_for_download(repos, uri) {
            repos[index].download_bytes = repos[index].download_bytes.saturating_add(bytes);
        }
    }
}

fn find_repo_for_download(repos: &[RepoMetrics], uri: &str) -> Option<usize> {
    if let Some(index) = repos.iter().position(|repo| repo.uri == uri) {
        return Some(index);
    }
    if uri.starts_with("rsync://") {
        return repos
            .iter()
            .enumerate()
            .filter(|(_, repo)| uri.starts_with(&repo.uri))
            .max_by_key(|(_, repo)| repo.uri.len())
            .map(|(index, _)| index);
    }

    let uri_host = uri_host(uri);
    let mut candidates = repos
        .iter()
        .enumerate()
        .filter(|(_, repo)| repo.host == uri_host && repo.uri.starts_with("http"))
        .collect::<Vec<_>>();
    if candidates.len() == 1 {
        return Some(candidates[0].0);
    }
    candidates.sort_by_key(|(_, repo)| common_prefix_len(&repo.uri, uri));
    candidates
        .last()
        .and_then(|(index, repo)| (common_prefix_len(&repo.uri, uri) > 0).then_some(*index))
}

fn is_success_terminal_state(state: &str) -> bool {
    matches!(state, "fresh" | "cached" | "reused" | "valid")
}

fn parse_cir(path: &Path, snapshot: &mut MetricsSnapshot) {
    if !path.exists() {
        return;
    }
    match fs::read(path)
        .map_err(|e| e.to_string())
        .and_then(|bytes| decode_cir(&bytes).map_err(|e| e.to_string()))
    {
        Ok(cir) => {
            let mut objects_by_type = BTreeMap::new();
            for object in cir.validated_objects() {
                *objects_by_type
                    .entry(object_type_from_uri(&object.rsync_uri))
                    .or_default() += 1;
            }
            let mut fresh_objects_by_type = BTreeMap::new();
            for object in &cir.fresh_validated_objects {
                *fresh_objects_by_type
                    .entry(object_type_from_uri(&object.rsync_uri))
                    .or_default() += 1;
            }
            let mut cached_objects_by_type = BTreeMap::new();
            for object in &cir.cached_validated_objects {
                *cached_objects_by_type
                    .entry(object_type_from_uri(&object.rsync_uri))
                    .or_default() += 1;
            }
            let mut rejected_objects_by_type = BTreeMap::new();
            for object in cir.rejected_objects() {
                *rejected_objects_by_type
                    .entry(object_type_from_uri(&object.object_uri))
                    .or_default() += 1;
            }
            let mut fresh_rejected_objects_by_type = BTreeMap::new();
            for object in &cir.fresh_rejected_objects {
                *fresh_rejected_objects_by_type
                    .entry(object_type_from_uri(&object.object_uri))
                    .or_default() += 1;
            }
            let mut cached_rejected_objects_by_type = BTreeMap::new();
            for object in &cir.cached_rejected_objects {
                *cached_rejected_objects_by_type
                    .entry(object_type_from_uri(&object.object_uri))
                    .or_default() += 1;
            }
            snapshot.cir = Some(CirMetrics {
                version: cir.version,
                objects: cir.validated_object_count() as u64,
                fresh_objects: cir.fresh_validated_objects.len() as u64,
                cached_objects: cir.cached_validated_objects.len() as u64,
                trust_anchors: cir.trust_anchors.len() as u64,
                rejected_objects: cir.rejected_object_count() as u64,
                fresh_rejected_objects: cir.fresh_rejected_objects.len() as u64,
                cached_rejected_objects: cir.cached_rejected_objects.len() as u64,
                object_list_sha256: hex::encode(&cir.object_list_sha256),
                fresh_object_list_sha256: hex::encode(&cir.fresh_object_list_sha256),
                cached_object_list_sha256: hex::encode(&cir.cached_object_list_sha256),
                reject_list_sha256: hex::encode(&cir.reject_list_sha256),
                fresh_reject_list_sha256: hex::encode(&cir.fresh_reject_list_sha256),
                cached_reject_list_sha256: hex::encode(&cir.cached_reject_list_sha256),
                objects_by_type,
                fresh_objects_by_type,
                cached_objects_by_type,
                rejected_objects_by_type,
                fresh_rejected_objects_by_type,
                cached_rejected_objects_by_type,
            });
        }
        Err(err) => snapshot
            .service
            .parse_errors
            .push(format!("decode CIR failed: {}: {err}", path.display())),
    }
}

fn parse_ccr(path: &Path, snapshot: &mut MetricsSnapshot) {
    if !path.exists() {
        return;
    }
    match fs::read(path)
        .map_err(|e| e.to_string())
        .and_then(|bytes| decode_content_info(&bytes).map_err(|e| e.to_string()))
    {
        Ok(ccr) => {
            let content = ccr.content;
            let mut state_present = BTreeMap::new();
            let mut state_items = BTreeMap::new();
            let mut state_digests = BTreeMap::new();
            if let Some(state) = content.mfts.as_ref() {
                state_present.insert("mfts".to_string(), true);
                state_items.insert("mfts".to_string(), state.mis.len() as u64);
                state_digests.insert("mfts".to_string(), hex::encode(&state.hash));
            } else {
                state_present.insert("mfts".to_string(), false);
            }
            if let Some(state) = content.vrps.as_ref() {
                state_present.insert("vrps".to_string(), true);
                state_items.insert("vrps".to_string(), state.rps.len() as u64);
                state_digests.insert("vrps".to_string(), hex::encode(&state.hash));
            } else {
                state_present.insert("vrps".to_string(), false);
            }
            if let Some(state) = content.vaps.as_ref() {
                state_present.insert("vaps".to_string(), true);
                state_items.insert("vaps".to_string(), state.aps.len() as u64);
                state_digests.insert("vaps".to_string(), hex::encode(&state.hash));
            } else {
                state_present.insert("vaps".to_string(), false);
            }
            if let Some(state) = content.tas.as_ref() {
                state_present.insert("tas".to_string(), true);
                state_items.insert("tas".to_string(), state.skis.len() as u64);
                state_digests.insert("tas".to_string(), hex::encode(&state.hash));
            } else {
                state_present.insert("tas".to_string(), false);
            }
            if let Some(state) = content.rks.as_ref() {
                state_present.insert("rks".to_string(), true);
                state_items.insert("rks".to_string(), state.rksets.len() as u64);
                state_digests.insert("rks".to_string(), hex::encode(&state.hash));
            } else {
                state_present.insert("rks".to_string(), false);
            }
            snapshot.ccr = Some(CcrMetrics {
                version: content.version,
                state_present,
                state_items,
                state_digests,
            });
        }
        Err(err) => snapshot
            .service
            .parse_errors
            .push(format!("decode CCR failed: {}: {err}", path.display())),
    }
}

fn render_metrics(snapshot: &MetricsSnapshot) -> String {
    let mut out = String::new();
    let mut writer = PromWriter::new(&mut out);
    let instance = snapshot.instance.as_str();

    writer.gauge(
        "ours_rp_metrics_service_up",
        "Artifact metrics service is up",
        &[label("instance", instance)],
        1.0,
    );
    writer.gauge(
        "ours_rp_metrics_service_last_scan_timestamp_seconds",
        "Unix timestamp of the last artifact scan",
        &[label("instance", instance)],
        snapshot.service.last_scan_timestamp_seconds,
    );
    writer.gauge(
        "ours_rp_metrics_service_last_scan_duration_seconds",
        "Duration of the last artifact scan",
        &[label("instance", instance)],
        snapshot.service.last_scan_duration_seconds,
    );
    writer.gauge(
        "ours_rp_metrics_service_last_reload_success",
        "Whether the last artifact reload had no parse errors",
        &[label("instance", instance)],
        bool_value(snapshot.service.last_reload_success),
    );
    writer.gauge(
        "ours_rp_metrics_service_parse_errors",
        "Current parse error count",
        &[label("instance", instance)],
        snapshot.service.parse_errors.len() as f64,
    );
    writer.gauge(
        "ours_rp_metrics_service_known_runs",
        "Known run directories by status",
        &[label("instance", instance), label("status", "success")],
        snapshot.runs.success as f64,
    );
    writer.gauge(
        "ours_rp_metrics_service_known_runs",
        "Known run directories by status",
        &[label("instance", instance), label("status", "failed")],
        snapshot.runs.failed as f64,
    );
    writer.gauge(
        "ours_rp_metrics_service_known_runs",
        "Known run directories by status",
        &[label("instance", instance), label("status", "partial")],
        snapshot.runs.partial as f64,
    );

    writer.counter(
        "ours_rp_run_completed_total",
        "Completed runs observed by the artifact metrics service",
        &[label("instance", instance), label("status", "success")],
        snapshot.cumulative.completed_success_total as f64,
    );
    writer.counter(
        "ours_rp_run_completed_total",
        "Completed runs observed by the artifact metrics service",
        &[label("instance", instance), label("status", "failed")],
        snapshot.cumulative.completed_failed_total as f64,
    );
    writer.counter(
        "ours_rp_run_observed_duration_seconds_sum",
        "Observed wall duration sum for successful runs",
        &[label("instance", instance)],
        snapshot.cumulative.observed_duration_seconds_sum,
    );
    writer.counter(
        "ours_rp_run_observed_duration_seconds_count",
        "Observed wall duration count for successful runs",
        &[label("instance", instance)],
        snapshot.cumulative.observed_duration_seconds_count as f64,
    );
    writer.counter(
        "ours_rp_run_observed_download_bytes_total",
        "Observed download bytes across successful runs",
        &[label("instance", instance)],
        snapshot.cumulative.observed_download_bytes_total as f64,
    );
    writer.gauge(
        "ours_rp_run_consecutive_failures",
        "Consecutive non-success runs at the end of the run list",
        &[label("instance", instance)],
        snapshot.runs.consecutive_failures as f64,
    );

    if let Some(latest) = snapshot.latest_run.as_ref() {
        render_latest_metrics(&mut writer, instance, latest);
    }
    render_repo_metrics(&mut writer, instance, &snapshot.repo_stats);
    render_failed_repo_metrics(&mut writer, instance, &snapshot.repo_stats);
    render_top_repo_metrics(&mut writer, instance, &snapshot.top_repos_by_sync_duration);
    render_object_metrics(&mut writer, instance, &snapshot.object_counts);
    render_large_pp_metrics(&mut writer, instance, &snapshot.large_pp_counts);
    render_top_publication_point_metrics(&mut writer, instance, &snapshot.top_pp_by_object_count);
    for (transport, histogram) in &snapshot.pp_sync_histograms {
        writer.histogram(
            "ours_rp_publication_point_sync_duration_seconds",
            "Distribution of sync duration per publication point",
            &[label("instance", instance), label("transport", transport)],
            histogram,
        );
    }
    if let Some(cir) = snapshot.cir.as_ref() {
        render_cir_metrics(&mut writer, instance, cir);
    }
    if let Some(ccr) = snapshot.ccr.as_ref() {
        render_ccr_metrics(&mut writer, instance, ccr);
    }
    render_ccr_check_metrics(&mut writer, instance, &snapshot.ccr_check);
    render_rtr_metrics(&mut writer, instance, snapshot);
    out
}

fn render_ccr_check_metrics(writer: &mut PromWriter<'_>, instance: &str, check: &CcrCheckMetrics) {
    writer.gauge(
        "ours_rp_ccr_format_check_up",
        "Whether the CCR rpki-client format checker binary is configured and present",
        &[label("instance", instance)],
        bool_value(check.up),
    );
    for (result, value) in [
        ("pass", check.pass_total),
        ("ccr_parse_error", check.ccr_parse_error_total),
        ("tool_error", check.tool_error_total),
    ] {
        writer.counter(
            "ours_rp_ccr_format_check_total",
            "CCR rpki-client format check results by classification",
            &[label("instance", instance), label("result", result)],
            value as f64,
        );
    }
    writer.gauge(
        "ours_rp_ccr_format_check_consecutive_failures",
        "Consecutive failed CCR rpki-client format checks",
        &[label("instance", instance)],
        check.consecutive_failures as f64,
    );
    if let Some(last) = check.last.as_ref() {
        writer.gauge(
            "ours_rp_ccr_format_check_last_success",
            "Whether the latest CCR rpki-client format check passed",
            &[label("instance", instance)],
            bool_value(last.outcome == "pass"),
        );
        if let Some(run_seq) = last.run_seq {
            writer.gauge(
                "ours_rp_ccr_format_check_last_run_sequence",
                "Run sequence of the latest checked CCR artifact",
                &[label("instance", instance)],
                run_seq as f64,
            );
        }
        writer.gauge(
            "ours_rp_ccr_format_check_last_duration_seconds",
            "Duration of the latest CCR rpki-client format check",
            &[label("instance", instance)],
            last.duration_seconds,
        );
    }
}

fn render_rtr_metrics(writer: &mut PromWriter<'_>, instance: &str, snapshot: &MetricsSnapshot) {
    let rtr = &snapshot.rtr;
    let scan_now = snapshot.service.last_scan_timestamp_seconds;

    writer.gauge(
        "ours_rp_rtr_metrics_enabled",
        "Whether RTR report collection is enabled by environment variable",
        &[label("instance", instance)],
        bool_value(rtr.enabled),
    );
    writer.counter(
        "ours_rp_rtr_metrics_parse_errors_total",
        "Total RTR report parse errors observed by the artifact metrics service",
        &[label("instance", instance)],
        rtr.parse_errors_total as f64,
    );
    writer.gauge(
        "ours_rp_rtr_metrics_parse_errors",
        "Current RTR report parse error count in the latest scan",
        &[label("instance", instance)],
        rtr.parse_errors.len() as f64,
    );

    for kind in ["source", "runtime", "clients"] {
        let scan = rtr.files.get(kind).cloned().unwrap_or_default();
        writer.gauge(
            "ours_rp_rtr_report_files_seen",
            "RTR report files seen by kind",
            &[label("instance", instance), label("kind", kind)],
            scan.seen as f64,
        );
        if let Some(timestamp) = scan.latest_timestamp_seconds {
            writer.gauge(
                "ours_rp_rtr_report_latest_timestamp_seconds",
                "Latest RTR report generated timestamp by kind",
                &[label("instance", instance), label("kind", kind)],
                timestamp,
            );
            writer.gauge(
                "ours_rp_rtr_report_age_seconds",
                "Age of the latest RTR report by kind at last scan time",
                &[label("instance", instance), label("kind", kind)],
                (scan_now - timestamp).max(0.0),
            );
        }
    }

    if let Some(source) = rtr.source.as_ref() {
        if let Some(status) = source.refresh_status.as_deref() {
            writer.gauge(
                "ours_rp_rtr_source_refresh_status",
                "Current RTR source refresh status",
                &[label("instance", instance), label("status", status)],
                1.0,
            );
        }
        if let Some(value) = source.refresh_changed {
            writer.gauge(
                "ours_rp_rtr_source_refresh_changed",
                "Whether the latest RTR source refresh changed data",
                &[label("instance", instance)],
                bool_value(value),
            );
        }
        if let Some(value) = source.refresh_duration_seconds {
            writer.gauge(
                "ours_rp_rtr_source_refresh_duration_seconds",
                "Latest RTR source refresh duration",
                &[label("instance", instance)],
                value,
            );
        }
        if let Some(value) = source.refresh_consecutive_failures {
            writer.gauge(
                "ours_rp_rtr_source_refresh_consecutive_failures",
                "Consecutive RTR source refresh failures",
                &[label("instance", instance)],
                value as f64,
            );
        }
        if let Some(value) = source.refresh_last_success_seconds {
            writer.gauge(
                "ours_rp_rtr_source_last_success_age_seconds",
                "Age of the last successful RTR source refresh at last scan time",
                &[label("instance", instance)],
                (scan_now - value).max(0.0),
            );
        }
        if let Some(value) = source.generated_at_seconds {
            writer.gauge(
                "ours_rp_rtr_source_report_age_seconds",
                "Age of the latest RTR source report at last scan time",
                &[label("instance", instance)],
                (scan_now - value).max(0.0),
            );
        }
        if let Some(value) = source.ccr_file_size_bytes {
            writer.gauge(
                "ours_rp_rtr_source_ccr_file_size_bytes",
                "RTR source CCR input file size",
                &[label("instance", instance)],
                value as f64,
            );
        }
        for (stage, counts) in &source.data_quality {
            for (kind, count) in counts {
                writer.gauge(
                    "ours_rp_rtr_data_quality_items",
                    "RTR data quality item counts by stage and type",
                    &[
                        label("instance", instance),
                        label("stage", stage),
                        label("type", kind),
                    ],
                    *count as f64,
                );
            }
        }
        for (kind, count) in &source.slurm_filters {
            writer.gauge(
                "ours_rp_rtr_slurm_filters",
                "RTR SLURM filtered item counts by type",
                &[label("instance", instance), label("type", kind)],
                *count as f64,
            );
        }
        for (kind, count) in &source.slurm_assertions {
            writer.gauge(
                "ours_rp_rtr_slurm_assertions",
                "RTR SLURM asserted item counts by type",
                &[label("instance", instance), label("type", kind)],
                *count as f64,
            );
        }
        if let Some(availability) = source.cache_availability.as_deref() {
            writer.gauge(
                "ours_rp_rtr_cache_ready",
                "Whether RTR cache availability is ready",
                &[label("instance", instance)],
                bool_value(availability == "ready"),
            );
        }
        for version in &source.cache_versions {
            let version_label = version.version.to_string();
            if let Some(serial) = version.serial {
                writer.gauge(
                    "ours_rp_rtr_cache_version_serial",
                    "RTR cache version serial by version",
                    &[
                        label("instance", instance),
                        label("version", &version_label),
                    ],
                    serial as f64,
                );
            }
            if let Some(length) = version.delta_window_length {
                writer.gauge(
                    "ours_rp_rtr_cache_delta_window_length",
                    "RTR cache delta window length by version",
                    &[
                        label("instance", instance),
                        label("version", &version_label),
                    ],
                    length as f64,
                );
            }
            for kind in RTR_COUNT_TYPES {
                writer.gauge(
                    "ours_rp_rtr_cache_snapshot_items",
                    "RTR cache snapshot item counts by version and type",
                    &[
                        label("instance", instance),
                        label("version", &version_label),
                        label("type", kind),
                    ],
                    version.snapshot.get(*kind).copied().unwrap_or(0) as f64,
                );
            }
            for (direction, counts) in [
                ("announced", &version.latest_delta_announced),
                ("withdrawn", &version.latest_delta_withdrawn),
            ] {
                for kind in RTR_COUNT_TYPES {
                    writer.gauge(
                        "ours_rp_rtr_cache_delta_items",
                        "RTR cache latest delta item counts by version, direction and type",
                        &[
                            label("instance", instance),
                            label("version", &version_label),
                            label("direction", direction),
                            label("type", kind),
                        ],
                        counts.get(*kind).copied().unwrap_or(0) as f64,
                    );
                }
            }
        }
    }

    if let Some(runtime) = rtr.runtime.as_ref() {
        if let Some(value) = runtime.process_rss_bytes {
            writer.gauge(
                "ours_rp_rtr_process_rss_bytes",
                "RTR process resident set size",
                &[label("instance", instance)],
                value as f64,
            );
        }
    }
    if let Some(runtime_age) = rtr
        .runtime
        .as_ref()
        .and_then(|runtime| runtime.generated_at_seconds)
        .map(|timestamp| (scan_now - timestamp).max(0.0))
    {
        writer.gauge(
            "ours_rp_rtr_runtime_report_age_seconds",
            "Age of the latest RTR runtime report at last scan time",
            &[label("instance", instance)],
            runtime_age,
        );
    }
    if let Some(clients_age) = rtr
        .clients
        .as_ref()
        .and_then(|clients| clients.generated_at_seconds)
        .map(|timestamp| (scan_now - timestamp).max(0.0))
    {
        writer.gauge(
            "ours_rp_rtr_clients_report_age_seconds",
            "Age of the latest RTR clients report at last scan time",
            &[label("instance", instance)],
            clients_age,
        );
    }

    if let Some(service) = rtr.preferred_service() {
        if let Some(value) = service.uptime_seconds {
            writer.gauge(
                "ours_rp_rtr_service_uptime_seconds",
                "RTR service uptime",
                &[label("instance", instance)],
                value as f64,
            );
        }
        if let Some(value) = service.active_connections {
            writer.gauge(
                "ours_rp_rtr_active_connections",
                "RTR active connections",
                &[label("instance", instance)],
                value as f64,
            );
        }
        for (transport, count) in &service.connections_by_transport {
            writer.gauge(
                "ours_rp_rtr_connections",
                "RTR connections by transport",
                &[label("instance", instance), label("transport", transport)],
                *count as f64,
            );
        }
        if let Some(value) = service.session_listeners {
            writer.gauge(
                "ours_rp_rtr_session_listeners",
                "RTR session listeners",
                &[label("instance", instance)],
                value as f64,
            );
        }
        if let Some(value) = service.max_connections {
            writer.gauge(
                "ours_rp_rtr_max_connections",
                "RTR maximum configured connections",
                &[label("instance", instance)],
                value as f64,
            );
        }
        if let Some(value) = service.connection_utilization {
            writer.gauge(
                "ours_rp_rtr_connection_utilization",
                "RTR connection utilization ratio",
                &[label("instance", instance)],
                value,
            );
        }
    }
}

fn render_latest_metrics(writer: &mut PromWriter<'_>, instance: &str, latest: &LatestRunMetrics) {
    writer.gauge(
        "ours_rp_run_sequence",
        "Latest successful run sequence",
        &[label("instance", instance)],
        latest.run_seq as f64,
    );
    writer.gauge(
        "ours_rp_run_success",
        "Whether the latest selected run is successful",
        &[label("instance", instance)],
        bool_value(latest.status == "success"),
    );
    writer.gauge(
        "ours_rp_run_sync_mode",
        "Latest run sync mode state",
        &[
            label("instance", instance),
            label("sync_mode", &latest.sync_mode),
        ],
        1.0,
    );
    if let Some(ts) = latest.start_timestamp_seconds {
        writer.gauge(
            "ours_rp_run_start_timestamp_seconds",
            "Latest run start timestamp",
            &[label("instance", instance)],
            ts,
        );
    }
    if let Some(ts) = latest.finish_timestamp_seconds {
        writer.gauge(
            "ours_rp_run_finish_timestamp_seconds",
            "Latest run finish timestamp",
            &[label("instance", instance)],
            ts,
        );
    }
    writer.gauge(
        "ours_rp_run_duration_seconds",
        "Latest run wall duration",
        &[label("instance", instance)],
        latest.wall_seconds,
    );
    if let Some(value) = latest.user_cpu_seconds {
        writer.gauge(
            "ours_rp_run_user_cpu_seconds",
            "Latest run user CPU seconds",
            &[label("instance", instance)],
            value,
        );
    }
    if let Some(value) = latest.system_cpu_seconds {
        writer.gauge(
            "ours_rp_run_system_cpu_seconds",
            "Latest run system CPU seconds",
            &[label("instance", instance)],
            value,
        );
    }
    if let Some(value) = latest.cpu_percent {
        writer.gauge(
            "ours_rp_run_cpu_percent",
            "Latest run CPU percent from GNU time",
            &[label("instance", instance)],
            value,
        );
    }
    if let Some(value) = latest.max_rss_bytes {
        writer.gauge(
            "ours_rp_run_max_rss_bytes",
            "Latest run maximum resident set size",
            &[label("instance", instance)],
            value as f64,
        );
    }
    if let Some(value) = latest.exit_code {
        writer.gauge(
            "ours_rp_run_exit_code",
            "Latest run exit code",
            &[label("instance", instance)],
            value as f64,
        );
    }
    writer.gauge(
        "ours_rp_vrps",
        "Latest run VRP count",
        &[label("instance", instance), label("kind", "total")],
        latest.vrps as f64,
    );
    if let Some(value) = latest.vrps_unique {
        writer.gauge(
            "ours_rp_vrps",
            "Latest run VRP count",
            &[label("instance", instance), label("kind", "unique")],
            value as f64,
        );
    }
    writer.gauge(
        "ours_rp_vaps",
        "Latest run VAP/ASPA count",
        &[label("instance", instance), label("kind", "total")],
        latest.vaps as f64,
    );
    writer.gauge(
        "ours_rp_publication_points",
        "Latest run publication point count",
        &[label("instance", instance)],
        latest.publication_points as f64,
    );
    writer.gauge(
        "ours_rp_warnings",
        "Latest run warning count",
        &[label("instance", instance)],
        latest.warnings as f64,
    );
    if let Some(value) = latest.tree_instances_processed {
        writer.gauge(
            "ours_rp_tree_instances",
            "Latest run tree instances by state",
            &[label("instance", instance), label("state", "processed")],
            value as f64,
        );
    }
    if let Some(value) = latest.tree_instances_failed {
        writer.gauge(
            "ours_rp_tree_instances",
            "Latest run tree instances by state",
            &[label("instance", instance), label("state", "failed")],
            value as f64,
        );
    }
    if let Some(value) = latest.dead_repo_blacklist_entries {
        writer.gauge(
            "ours_rp_dead_repo_blacklist_entries",
            "Dead-repo transport blacklist entries (latest run)",
            &[label("instance", instance), label("state", "total")],
            value as f64,
        );
    }
    if let Some(value) = latest.dead_repo_blacklist_blacklisted {
        writer.gauge(
            "ours_rp_dead_repo_blacklist_entries",
            "Dead-repo transport blacklist entries (latest run)",
            &[label("instance", instance), label("state", "blacklisted")],
            value as f64,
        );
    }
    for (stage, value) in &latest.stage_seconds {
        writer.gauge(
            "ours_rp_run_stage_duration_seconds",
            "Latest run stage duration",
            &[label("instance", instance), label("stage", stage)],
            *value,
        );
    }
    for (phase, stat) in &latest.repo_sync_phase {
        writer.gauge(
            "ours_rp_repo_sync_phase_count",
            "Publication points by repo sync phase",
            &[label("instance", instance), label("phase", phase)],
            stat.count as f64,
        );
        writer.gauge(
            "ours_rp_repo_sync_phase_duration_seconds_total",
            "Repo sync phase cumulative duration in latest run",
            &[label("instance", instance), label("phase", phase)],
            stat.duration_seconds_total,
        );
    }
    for (state, stat) in &latest.repo_terminal_state {
        writer.gauge(
            "ours_rp_repo_terminal_state_count",
            "Publication points by terminal state",
            &[label("instance", instance), label("terminal_state", state)],
            stat.count as f64,
        );
        writer.gauge(
            "ours_rp_repo_terminal_state_duration_seconds_total",
            "Terminal state cumulative duration in latest run",
            &[label("instance", instance), label("terminal_state", state)],
            stat.duration_seconds_total,
        );
    }
    if let Some(value) = latest.download_events {
        writer.gauge(
            "ours_rp_download_events",
            "Latest run download event count",
            &[label("instance", instance)],
            value as f64,
        );
    }
    if let Some(value) = latest.download_bytes {
        writer.gauge(
            "ours_rp_download_bytes",
            "Latest run download bytes",
            &[label("instance", instance)],
            value as f64,
        );
    }
    for (artifact, size) in &latest.artifact_sizes {
        writer.gauge(
            "ours_rp_artifact_size_bytes",
            "Latest run artifact size",
            &[label("instance", instance), label("artifact", artifact)],
            *size as f64,
        );
    }
    for (path, stat) in &latest.state_path_sizes {
        writer.gauge(
            "ours_rp_state_path_size_bytes",
            "State path size",
            &[label("instance", instance), label("path", path)],
            stat.total_size_bytes as f64,
        );
        writer.gauge(
            "ours_rp_state_path_files",
            "State path file count",
            &[label("instance", instance), label("path", path)],
            stat.file_count as f64,
        );
        writer.gauge(
            "ours_rp_state_db_size_bytes",
            "Persistent state database size from latest run pathStats",
            &[label("instance", instance), label("db", path)],
            stat.total_size_bytes as f64,
        );
        writer.gauge(
            "ours_rp_state_db_files",
            "Persistent state database file count from latest run pathStats",
            &[label("instance", instance), label("db", path)],
            stat.file_count as f64,
        );
        writer.gauge(
            "ours_rp_state_db_dirs",
            "Persistent state database directory count from latest run pathStats",
            &[label("instance", instance), label("db", path)],
            stat.dir_count as f64,
        );
    }
}

fn render_repo_metrics(writer: &mut PromWriter<'_>, instance: &str, repos: &[RepoMetrics]) {
    for repo in repos {
        let base = [
            label("instance", instance),
            label("repo_id", &repo.repo_id),
            label("host", &repo.host),
            label("uri", &repo.uri),
            label("transport", &repo.transport),
        ];
        writer.gauge("ours_rp_repository_info", "Repository metadata", &base, 1.0);
        writer.gauge(
            "ours_rp_repository_publication_points",
            "Publication points per repository",
            &base,
            repo.publication_points as f64,
        );
        writer.gauge(
            "ours_rp_repository_sync_success",
            "Whether repository sync is successful in the latest run",
            &base,
            bool_value(repo.sync_success),
        );
        writer.gauge(
            "ours_rp_repository_download_bytes",
            "Repository download bytes attributed from latest run download events",
            &base,
            repo.download_bytes as f64,
        );
        for (stat, value) in [
            ("sum", repo.duration_seconds_sum),
            ("max", repo.duration_seconds_max),
            ("avg", repo.duration_seconds_avg),
        ] {
            let labels = [
                label("instance", instance),
                label("repo_id", &repo.repo_id),
                label("host", &repo.host),
                label("transport", &repo.transport),
                label("stat", stat),
            ];
            writer.gauge(
                "ours_rp_repository_sync_duration_seconds",
                "Repository sync duration summary",
                &labels,
                value,
            );
        }
        for (phase, count) in &repo.phase_counts {
            let labels = [
                label("instance", instance),
                label("repo_id", &repo.repo_id),
                label("host", &repo.host),
                label("phase", phase),
            ];
            writer.gauge(
                "ours_rp_repository_sync_phase_publication_points",
                "Repository publication points by sync phase",
                &labels,
                *count as f64,
            );
        }
        for (state, count) in &repo.terminal_state_counts {
            let labels = [
                label("instance", instance),
                label("repo_id", &repo.repo_id),
                label("host", &repo.host),
                label("terminal_state", state),
            ];
            writer.gauge(
                "ours_rp_repository_terminal_state_publication_points",
                "Repository publication points by terminal state",
                &labels,
                *count as f64,
            );
        }
        for kind in ["manifest", "crl", "certificate", "roa", "aspa"] {
            let labels = [
                label("instance", instance),
                label("repo_id", &repo.repo_id),
                label("host", &repo.host),
                label("uri", &repo.uri),
                label("object_type", kind),
            ];
            writer.gauge(
                "ours_rp_repository_objects_by_type",
                "Repository object count by object type from latest run report",
                &labels,
                repo.object_counts.get(kind).copied().unwrap_or(0) as f64,
            );
        }
    }
}

fn render_failed_repo_metrics(writer: &mut PromWriter<'_>, instance: &str, repos: &[RepoMetrics]) {
    for repo in repos {
        if repo.phase_counts.contains_key("rrdp_failed_rsync_failed") {
            writer.gauge(
                "ours_rp_rrdp_rsync_failed_repository_duration_seconds",
                "Repositories whose RRDP and rsync sync both failed; value is max sync duration when available",
                &[
                    label("instance", instance),
                    label("repo_id", &repo.repo_id),
                    label("host", &repo.host),
                    label("phase", "rrdp_failed_rsync_failed"),
                    label("transport", &repo.transport),
                    label("uri", &repo.uri),
                ],
                repo.duration_seconds_max,
            );
        }
    }
}

fn render_top_repo_metrics(writer: &mut PromWriter<'_>, instance: &str, repos: &[TopRepo]) {
    for repo in repos {
        writer.gauge(
            "ours_rp_top_repository_sync_duration_seconds",
            "Top repositories by max sync duration in latest run",
            &[
                label("instance", instance),
                label("rank", &repo.rank.to_string()),
                label("repo_id", &repo.repo_id),
                label("host", &repo.host),
                label("transport", &repo.transport),
                label("publication_points", &repo.publication_points.to_string()),
                label("uri", &repo.uri),
            ],
            repo.duration_ms_max as f64 / 1000.0,
        );
    }
}

fn render_object_metrics(
    writer: &mut PromWriter<'_>,
    instance: &str,
    counts: &BTreeMap<(String, String), u64>,
) {
    for ((object_type, result), count) in counts {
        writer.gauge(
            "ours_rp_objects",
            "Latest run audited objects by type and result",
            &[
                label("instance", instance),
                label("object_type", object_type),
                label("result", result),
            ],
            *count as f64,
        );
    }
}

fn render_top_publication_point_metrics(
    writer: &mut PromWriter<'_>,
    instance: &str,
    publication_points: &[TopPublicationPoint],
) {
    for publication_point in publication_points {
        writer.gauge(
            "ours_rp_top_publication_point_object_count",
            "Top publication points by object count in latest run",
            &[
                label("instance", instance),
                label("rank", &publication_point.rank.to_string()),
                label("pp_id", &publication_point.pp_id),
                label("repo_id", &publication_point.repo_id),
                label("host", &publication_point.host),
                label("transport", &publication_point.transport),
                label("terminal_state", &publication_point.terminal_state),
                label("phase", &publication_point.phase),
                label("uri", &publication_point.uri),
            ],
            publication_point.object_count as f64,
        );
    }
}

fn render_large_pp_metrics(
    writer: &mut PromWriter<'_>,
    instance: &str,
    counts: &BTreeMap<u64, u64>,
) {
    for threshold in LARGE_PP_OBJECT_THRESHOLDS {
        writer.gauge(
            "ours_rp_large_publication_points",
            "Publication points with object count greater than threshold",
            &[
                label("instance", instance),
                label("object_count_gt", &threshold.to_string()),
            ],
            counts.get(threshold).copied().unwrap_or(0) as f64,
        );
    }
}

fn render_cir_metrics(writer: &mut PromWriter<'_>, instance: &str, cir: &CirMetrics) {
    writer.gauge(
        "ours_rp_cir_version",
        "CIR version",
        &[label("instance", instance)],
        cir.version as f64,
    );
    writer.gauge(
        "ours_rp_cir_objects",
        "CIR object count",
        &[label("instance", instance)],
        cir.objects as f64,
    );
    for (source, count) in [("fresh", cir.fresh_objects), ("cached", cir.cached_objects)] {
        writer.gauge(
            "ours_rp_cir_objects_by_source",
            "CIR object count by validation input source",
            &[label("instance", instance), label("source", source)],
            count as f64,
        );
    }
    writer.gauge(
        "ours_rp_cir_trust_anchors",
        "CIR trust anchor count",
        &[label("instance", instance)],
        cir.trust_anchors as f64,
    );
    writer.gauge(
        "ours_rp_cir_rejected_objects",
        "CIR rejected object count",
        &[label("instance", instance)],
        cir.rejected_objects as f64,
    );
    for (source, count) in [
        ("fresh", cir.fresh_rejected_objects),
        ("cached", cir.cached_rejected_objects),
    ] {
        writer.gauge(
            "ours_rp_cir_rejected_objects_by_source",
            "CIR rejected object count by validation input source",
            &[label("instance", instance), label("source", source)],
            count as f64,
        );
    }
    for (source, digest) in [
        ("merged", cir.object_list_sha256.as_str()),
        ("fresh", cir.fresh_object_list_sha256.as_str()),
        ("cached", cir.cached_object_list_sha256.as_str()),
    ] {
        writer.gauge(
            "ours_rp_cir_object_list_digest_present",
            "CIR object list digest is present by validation input source",
            &[label("instance", instance), label("source", source)],
            if digest.len() == 64 { 1.0 } else { 0.0 },
        );
    }
    writer.gauge(
        "ours_rp_cir_reject_list_digest_present",
        "CIR reject list digest is present",
        &[label("instance", instance)],
        if cir.reject_list_sha256.len() == 64 {
            1.0
        } else {
            0.0
        },
    );
    for (source, digest) in [
        ("merged", cir.reject_list_sha256.as_str()),
        ("fresh", cir.fresh_reject_list_sha256.as_str()),
        ("cached", cir.cached_reject_list_sha256.as_str()),
    ] {
        writer.gauge(
            "ours_rp_cir_reject_list_digest_present_by_source",
            "CIR reject list digest is present by validation input source",
            &[label("instance", instance), label("source", source)],
            if digest.len() == 64 { 1.0 } else { 0.0 },
        );
    }
    for (object_type, count) in &cir.objects_by_type {
        writer.gauge(
            "ours_rp_cir_objects_by_type",
            "CIR object count by file type",
            &[
                label("instance", instance),
                label("object_type", object_type),
            ],
            *count as f64,
        );
    }
    for (source, counts) in [
        ("fresh", &cir.fresh_objects_by_type),
        ("cached", &cir.cached_objects_by_type),
    ] {
        for (object_type, count) in counts {
            writer.gauge(
                "ours_rp_cir_objects_by_source_type",
                "CIR object count by validation input source and file type",
                &[
                    label("instance", instance),
                    label("source", source),
                    label("object_type", object_type),
                ],
                *count as f64,
            );
        }
    }
    for (object_type, count) in &cir.rejected_objects_by_type {
        writer.gauge(
            "ours_rp_cir_rejected_objects_by_type",
            "CIR rejected object count by file type",
            &[
                label("instance", instance),
                label("object_type", object_type),
            ],
            *count as f64,
        );
    }
    for (source, counts) in [
        ("fresh", &cir.fresh_rejected_objects_by_type),
        ("cached", &cir.cached_rejected_objects_by_type),
    ] {
        for (object_type, count) in counts {
            writer.gauge(
                "ours_rp_cir_rejected_objects_by_source_type",
                "CIR rejected object count by validation input source and file type",
                &[
                    label("instance", instance),
                    label("source", source),
                    label("object_type", object_type),
                ],
                *count as f64,
            );
        }
    }
}

fn render_ccr_metrics(writer: &mut PromWriter<'_>, instance: &str, ccr: &CcrMetrics) {
    writer.gauge(
        "ours_rp_ccr_version",
        "CCR version",
        &[label("instance", instance)],
        ccr.version as f64,
    );
    for (state, present) in &ccr.state_present {
        writer.gauge(
            "ours_rp_ccr_state_present",
            "CCR state presence",
            &[label("instance", instance), label("state", state)],
            bool_value(*present),
        );
    }
    for (state, count) in &ccr.state_items {
        writer.gauge(
            "ours_rp_ccr_state_items",
            "CCR state item count",
            &[label("instance", instance), label("state", state)],
            *count as f64,
        );
    }
    for state in ccr.state_digests.keys() {
        writer.gauge(
            "ours_rp_ccr_state_digest_present",
            "CCR state digest presence",
            &[label("instance", instance), label("state", state)],
            1.0,
        );
    }
}

fn render_status_json(snapshot: &MetricsSnapshot) -> Result<String, String> {
    serde_json::to_string_pretty(&json!({
        "schemaVersion": 1,
        "generatedBy": "rpki_artifact_metrics",
        "instance": snapshot.instance,
        "service": snapshot.service,
        "runs": snapshot.runs,
        "latestRun": snapshot.latest_run,
        "cir": snapshot.cir,
        "ccr": snapshot.ccr,
        "ccrCheck": snapshot.ccr_check,
        "rtr": snapshot.rtr,
        "topRepositoriesBySyncDuration": snapshot.top_repos_by_sync_duration,
        "topPublicationPointsByObjectCount": snapshot.top_pp_by_object_count,
        "topPublicationPointsBySyncDuration": snapshot.top_pp_by_sync_duration,
    }))
    .map_err(|e| e.to_string())
}

struct PromWriter<'a> {
    out: &'a mut String,
    emitted_headers: BTreeSet<String>,
}

#[derive(Clone, Debug)]
struct Label<'a> {
    key: &'a str,
    value: &'a str,
}

fn label<'a>(key: &'a str, value: &'a str) -> Label<'a> {
    Label { key, value }
}

impl<'a> PromWriter<'a> {
    fn new(out: &'a mut String) -> Self {
        Self {
            out,
            emitted_headers: BTreeSet::new(),
        }
    }

    fn gauge(&mut self, name: &str, help: &str, labels: &[Label<'_>], value: f64) {
        self.metric("gauge", name, help, labels, value);
    }

    fn counter(&mut self, name: &str, help: &str, labels: &[Label<'_>], value: f64) {
        self.metric("counter", name, help, labels, value);
    }

    fn metric(
        &mut self,
        metric_type: &str,
        name: &str,
        help: &str,
        labels: &[Label<'_>],
        value: f64,
    ) {
        self.header(name, help, metric_type);
        self.out.push_str(name);
        write_labels(self.out, labels);
        self.out.push(' ');
        self.out.push_str(&format_prom_value(value));
        self.out.push('\n');
    }

    fn histogram(
        &mut self,
        name: &str,
        help: &str,
        base_labels: &[Label<'_>],
        histogram: &Histogram,
    ) {
        self.header(name, help, "histogram");
        let cumulative = histogram.cumulative_counts();
        for (index, count) in cumulative.iter().enumerate() {
            let le = if index < histogram.buckets.len() {
                format_prom_value(histogram.buckets[index])
            } else {
                "+Inf".to_string()
            };
            let mut labels = base_labels.to_vec();
            labels.push(label("le", &le));
            self.out.push_str(name);
            self.out.push_str("_bucket");
            write_labels(self.out, &labels);
            self.out.push(' ');
            self.out.push_str(&count.to_string());
            self.out.push('\n');
        }
        self.out.push_str(name);
        self.out.push_str("_sum");
        write_labels(self.out, base_labels);
        self.out.push(' ');
        self.out.push_str(&format_prom_value(histogram.sum));
        self.out.push('\n');
        self.out.push_str(name);
        self.out.push_str("_count");
        write_labels(self.out, base_labels);
        self.out.push(' ');
        self.out.push_str(&histogram.count.to_string());
        self.out.push('\n');
    }

    fn header(&mut self, name: &str, help: &str, metric_type: &str) {
        if self.emitted_headers.insert(name.to_string()) {
            self.out.push_str("# HELP ");
            self.out.push_str(name);
            self.out.push(' ');
            self.out.push_str(&escape_help(help));
            self.out.push('\n');
            self.out.push_str("# TYPE ");
            self.out.push_str(name);
            self.out.push(' ');
            self.out.push_str(metric_type);
            self.out.push('\n');
        }
    }
}

fn write_labels(out: &mut String, labels: &[Label<'_>]) {
    if labels.is_empty() {
        return;
    }
    out.push('{');
    for (index, label) in labels.iter().enumerate() {
        if index > 0 {
            out.push(',');
        }
        out.push_str(label.key);
        out.push_str("=\"");
        out.push_str(&escape_label(label.value));
        out.push('"');
    }
    out.push('}');
}

fn serve_http(listen: &str, shared: Arc<RwLock<MetricsSnapshot>>) -> Result<(), String> {
    let listener = TcpListener::bind(listen).map_err(|e| format!("bind failed: {listen}: {e}"))?;
    for stream in listener.incoming() {
        match stream {
            Ok(mut stream) => {
                let snapshot = shared.read().expect("metrics lock poisoned").clone();
                if let Err(err) = handle_http_stream(&mut stream, &snapshot) {
                    eprintln!("http request failed: {err}");
                }
            }
            Err(err) => eprintln!("accept failed: {err}"),
        }
    }
    Ok(())
}

fn handle_http_stream(stream: &mut TcpStream, snapshot: &MetricsSnapshot) -> Result<(), String> {
    let mut buf = [0u8; 4096];
    let len = stream.read(&mut buf).map_err(|e| e.to_string())?;
    let req = String::from_utf8_lossy(&buf[..len]);
    let path = req
        .lines()
        .next()
        .and_then(|line| line.split_whitespace().nth(1))
        .unwrap_or("/");
    match path {
        "/metrics" => write_http_response(
            stream,
            "200 OK",
            "text/plain; version=0.0.4",
            &render_metrics(snapshot),
        ),
        "/status" => write_http_response(
            stream,
            "200 OK",
            "application/json",
            &render_status_json(snapshot)?,
        ),
        "/healthz" => write_http_response(stream, "200 OK", "text/plain", "ok\n"),
        _ => write_http_response(stream, "404 Not Found", "text/plain", "not found\n"),
    }
}

fn write_http_response(
    stream: &mut TcpStream,
    status: &str,
    content_type: &str,
    body: &str,
) -> Result<(), String> {
    let header = format!(
        "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
        body.as_bytes().len()
    );
    stream
        .write_all(header.as_bytes())
        .map_err(|e| e.to_string())?;
    stream.write_all(body.as_bytes()).map_err(|e| e.to_string())
}

fn write_file(path: &Path, bytes: &[u8]) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .map_err(|e| format!("create parent failed: {}: {e}", parent.display()))?;
    }
    fs::write(path, bytes).map_err(|e| format!("write failed: {}: {e}", path.display()))
}

fn extract_stage_seconds(value: Option<&Value>) -> BTreeMap<String, f64> {
    let mut out = BTreeMap::new();
    let Some(value) = value else {
        return out;
    };
    let mapping = [
        ("validation_ms", "validation"),
        ("report_build_ms", "report_build"),
        ("report_write_ms", "report_write"),
        ("ccr_build_ms", "ccr_build"),
        ("ccr_write_ms", "ccr_write"),
        ("compare_view_build_ms", "compare_view_build"),
        ("compare_view_write_ms", "compare_view_write"),
        ("cir_build_cir_ms", "cir_build"),
        ("cir_write_cir_ms", "cir_write"),
        ("cir_total_ms", "cir_total"),
        ("total_ms", "total"),
        ("repo_sync_ms_total", "repo_sync_total"),
        ("rrdp_download_ms_total", "rrdp_download_total"),
        ("rsync_download_ms_total", "rsync_download_total"),
    ];
    for (field, stage) in mapping {
        if let Some(ms) = json_u64(value, &[field]) {
            out.insert(stage.to_string(), ms as f64 / 1000.0);
        }
    }
    out
}

fn extract_count_duration_map(value: Option<&Value>) -> BTreeMap<String, CountDuration> {
    let mut out = BTreeMap::new();
    let Some(object) = value.and_then(|v| v.as_object()) else {
        return out;
    };
    for (key, value) in object {
        out.insert(
            key.clone(),
            CountDuration {
                count: json_u64(value, &["count"]).unwrap_or(0),
                duration_seconds_total: json_u64(value, &["duration_ms_total"]).unwrap_or(0) as f64
                    / 1000.0,
            },
        );
    }
    out
}

fn extract_artifact_sizes(value: Option<&Value>) -> BTreeMap<String, u64> {
    let mut out = BTreeMap::new();
    for item in value.and_then(|v| v.as_array()).into_iter().flatten() {
        let artifact = json_str(item, &["type"])
            .or_else(|| {
                json_str(item, &["path"])
                    .and_then(|path| Path::new(path).file_name().and_then(|name| name.to_str()))
            })
            .unwrap_or("unknown");
        let size = json_u64(item, &["sizeBytes"])
            .or_else(|| json_u64(item, &["size"]))
            .unwrap_or(0);
        *out.entry(artifact.to_string()).or_default() += size;
    }
    out
}

fn extract_path_sizes(value: Option<&Value>) -> BTreeMap<String, PathSize> {
    let mut out = BTreeMap::new();
    for item in value.and_then(|v| v.as_array()).into_iter().flatten() {
        let label = json_str(item, &["label"]).unwrap_or("unknown").to_string();
        out.insert(
            label,
            PathSize {
                total_size_bytes: json_u64(item, &["totalSizeBytes"]).unwrap_or(0),
                file_count: json_u64(item, &["fileCount"]).unwrap_or(0),
                dir_count: json_u64(item, &["dirCount"]).unwrap_or(0),
            },
        );
    }
    out
}

fn run_index_from_path(path: &Path) -> Option<u64> {
    path.file_name()
        .and_then(|name| name.to_str())
        .and_then(|name| name.strip_prefix("run_"))
        .and_then(|value| value.parse::<u64>().ok())
}

fn json_str<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> {
    let mut current = value;
    for key in path {
        current = current.get(*key)?;
    }
    current.as_str()
}

fn json_u64(value: &Value, path: &[&str]) -> Option<u64> {
    let mut current = value;
    for key in path {
        current = current.get(*key)?;
    }
    current.as_u64()
}

fn json_i64(value: &Value, path: &[&str]) -> Option<i64> {
    let mut current = value;
    for key in path {
        current = current.get(*key)?;
    }
    current.as_i64()
}

fn json_f64(value: &Value, path: &[&str]) -> Option<f64> {
    let mut current = value;
    for key in path {
        current = current.get(*key)?;
    }
    current
        .as_f64()
        .or_else(|| current.as_u64().map(|v| v as f64))
}

fn json_bool(value: &Value, path: &[&str]) -> Option<bool> {
    let mut current = value;
    for key in path {
        current = current.get(*key)?;
    }
    current.as_bool()
}

fn parse_rfc3339_to_unix(value: &str) -> Option<f64> {
    time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339)
        .ok()
        .map(|dt| dt.unix_timestamp() as f64)
}

fn unix_now_seconds() -> f64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs_f64())
        .unwrap_or(0.0)
}

fn bool_value(value: bool) -> f64 {
    if value { 1.0 } else { 0.0 }
}

fn normalize_transport(value: &str) -> String {
    let lower = value.to_ascii_lowercase();
    if lower.contains("rrdp") || lower.contains("https") {
        "rrdp".to_string()
    } else if lower.contains("rsync") {
        "rsync".to_string()
    } else {
        lower
    }
}

fn infer_transport(uri: &str) -> String {
    if uri.starts_with("http://") || uri.starts_with("https://") {
        "rrdp".to_string()
    } else if uri.starts_with("rsync://") {
        "rsync".to_string()
    } else {
        "unknown".to_string()
    }
}

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

fn object_type_from_uri(uri: &str) -> String {
    let lower = uri.to_ascii_lowercase();
    for (suffix, kind) in [
        (".mft", "manifest"),
        (".crl", "crl"),
        (".cer", "certificate"),
        (".roa", "roa"),
        (".asa", "aspa"),
        (".gbr", "gbr"),
    ] {
        if lower.ends_with(suffix) {
            return kind.to_string();
        }
    }
    "other".to_string()
}

fn short_sha256(value: &str) -> String {
    let digest = Sha256::digest(value.as_bytes());
    hex::encode(&digest[..6])
}

fn common_prefix_len(left: &str, right: &str) -> usize {
    left.bytes()
        .zip(right.bytes())
        .take_while(|(l, r)| l == r)
        .count()
}

fn format_prom_value(value: f64) -> String {
    if value.is_infinite() && value.is_sign_positive() {
        "+Inf".to_string()
    } else if value.fract() == 0.0 {
        format!("{value:.0}")
    } else {
        format!("{value:.6}")
            .trim_end_matches('0')
            .trim_end_matches('.')
            .to_string()
    }
}

fn escape_label(value: &str) -> String {
    value
        .replace('\\', "\\\\")
        .replace('\n', "\\n")
        .replace('"', "\\\"")
}

fn escape_help(value: &str) -> String {
    value.replace('\\', "\\\\").replace('\n', "\\n")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ccr::model::CCR_VERSION_V0;
    use crate::ccr::{
        CcrContentInfo, CcrDigestAlgorithm, RpkiCanonicalCacheRepresentation, TrustAnchorState,
        encode_content_info,
    };
    use crate::cir::{
        CanonicalInputRepresentation, CirObject, CirRejectedObject, CirTrustAnchor, encode_cir,
        sha256,
    };
    use tempfile::TempDir;

    #[test]
    fn parse_args_accepts_once_outputs() {
        let args = parse_args(&[
            "rpki_artifact_metrics".to_string(),
            "--run-root".to_string(),
            "root".to_string(),
            "--once".to_string(),
            "--out-metrics".to_string(),
            "metrics.prom".to_string(),
            "--out-status".to_string(),
            "status.json".to_string(),
        ])
        .expect("parse");
        assert!(args.once);
        assert_eq!(args.run_root, PathBuf::from("root"));
        assert_eq!(args.out_metrics.as_deref(), Some(Path::new("metrics.prom")));
    }

    #[test]
    fn scan_fixture_exports_repo_pp_cir_and_ccr_metrics() {
        let td = TempDir::new().expect("tempdir");
        let run = td.path().join("runs/run_0001");
        fs::create_dir_all(&run).expect("create run");
        fs::write(
            run.join("run-meta.json"),
            r#"{"status":"success","run_index":1,"run_id":"run_0001","sync_mode":"snapshot","snapshot_reason":"first_run","started_at_rfc3339_utc":"2026-05-25T00:00:00Z","completed_at_rfc3339_utc":"2026-05-25T00:00:10Z"}"#,
        )
        .expect("meta");
        fs::write(
            run.join("run-summary.json"),
            r#"{"runSeq":1,"runId":"run_0001","runDir":"RUN","startedAtRfc3339Utc":"2026-05-25T00:00:00Z","finishedAtRfc3339Utc":"2026-05-25T00:00:10Z","wallMs":10000,"status":"success","exitCode":0,"processMetrics":{"userSeconds":2.5,"systemSeconds":1.5,"cpuPercent":40,"maxRssKb":1000},"stageTiming":{"validation_ms":7000,"total_ms":9000,"download_event_count":2,"download_bytes_total":1234},"reportCounts":{"vrps":3,"aspas":1,"publicationPoints":2,"warnings":0},"repoSyncStats":{"by_phase":{"rrdp_delta":{"count":2,"duration_ms_total":3000}},"by_terminal_state":{"fresh":{"count":2,"duration_ms_total":3000}}},"pathStats":[{"label":"work-db","totalSizeBytes":99,"fileCount":2,"dirCount":1}],"artifacts":[{"path":"report.json","sizeBytes":10}]}"#,
        )
        .expect("summary");
        fs::write(run.join("process-time.txt"), "time").expect("time");
        fs::write(run.join("stage-timing.json"), "{}").expect("stage");
        fs::write(
            run.join("vrps.csv"),
            "ASN,IP Prefix,Max Length,Trust Anchor,Expires\n\
             AS64496,192.0.2.0/24,24,ta,2026-05-25T01:00:00Z\n\
             AS64496,192.0.2.0/24,24,ta,2026-05-25T01:00:00Z\n\
             AS64497,2001:db8::/32,48,ta,2026-05-25T01:00:00Z\n",
        )
        .expect("vrps");
        fs::write(
            run.join("report.json"),
            r#"{"tree":{"instances_processed":2,"instances_failed":0,"warnings":[]},"vrps":[{},{},{}],"aspas":[{}],"downloads":[{"kind":"rrdp_notification","uri":"https://repo.example/notify.xml","success":true,"duration_ms":100,"bytes":111},{"kind":"rrdp_delta","uri":"https://repo.example/session/1/delta.xml","success":true,"duration_ms":200,"bytes":222}],"publication_points":[{"rsync_base_uri":"rsync://repo.example/a/","manifest_rsync_uri":"rsync://repo.example/a/a.mft","publication_point_rsync_uri":"rsync://repo.example/a/","rrdp_notification_uri":"https://repo.example/notify.xml","repo_sync_source":"rrdp","repo_sync_phase":"rrdp_delta","repo_sync_duration_ms":1000,"repo_terminal_state":"fresh","objects":[{"kind":"roa","result":"ok"},{"kind":"manifest","result":"ok"}]},{"rsync_base_uri":"rsync://repo.example/b/","manifest_rsync_uri":"rsync://repo.example/b/b.mft","publication_point_rsync_uri":"rsync://repo.example/b/","rrdp_notification_uri":"https://repo.example/notify.xml","repo_sync_source":"rrdp","repo_sync_phase":"rrdp_delta","repo_sync_duration_ms":2000,"repo_terminal_state":"fresh","objects":[{"kind":"roa","result":"ok"}]}],"repo_sync_stats":{"publication_points_total":2,"by_phase":{"rrdp_delta":{"count":2,"duration_ms_total":3000}},"by_terminal_state":{"fresh":{"count":2,"duration_ms_total":3000}}}}"#,
        )
        .expect("report");
        fs::write(run.join("input.cir"), sample_cir()).expect("cir");
        fs::write(run.join("result.ccr"), sample_ccr()).expect("ccr");

        let snapshot = scan_run_root(td.path(), "test").expect("scan");
        assert_eq!(snapshot.runs.success, 1);
        assert_eq!(snapshot.repo_stats.len(), 1);
        assert!(snapshot.repo_stats[0].sync_success);
        assert_eq!(snapshot.repo_stats[0].download_bytes, 333);
        assert_eq!(snapshot.repo_stats[0].object_counts["roa"], 2);
        assert_eq!(snapshot.repo_stats[0].object_counts["manifest"], 1);
        assert_eq!(snapshot.top_pp_by_object_count[0].object_count, 2);
        assert_eq!(snapshot.cir.as_ref().unwrap().objects, 2);
        assert_eq!(snapshot.ccr.as_ref().unwrap().state_items["tas"], 1);
        let metrics = render_metrics(&snapshot);
        assert!(metrics.contains("ours_rp_repository_info"));
        assert!(metrics.contains("ours_rp_repository_sync_success"));
        assert!(metrics.contains("ours_rp_repository_download_bytes"));
        assert!(metrics.contains("ours_rp_repository_objects_by_type"));
        assert!(metrics.contains(r#"ours_rp_repository_objects_by_type{instance="test",repo_id="#));
        assert!(metrics.contains(r#"object_type="roa"} 2"#));
        assert!(metrics.contains(r#"object_type="manifest"} 1"#));
        assert!(metrics.contains("ours_rp_large_publication_points"));
        assert!(metrics.contains("ours_rp_cir_objects"));
        assert!(metrics.contains("ours_rp_cir_objects_by_source"));
        assert!(metrics.contains("ours_rp_cir_rejected_objects_by_source"));
        assert!(metrics.contains("ours_rp_cir_objects_by_source_type"));
        assert!(metrics.contains("ours_rp_cir_rejected_objects_by_source_type"));
        assert!(metrics.contains("ours_rp_cir_object_list_digest_present"));
        assert!(metrics.contains("ours_rp_cir_reject_list_digest_present_by_source"));
        assert!(metrics.contains("ours_rp_ccr_state_items"));
        assert!(
            metrics.contains(r#"ours_rp_state_db_size_bytes{instance="test",db="work-db"} 99"#)
        );
        assert!(metrics.contains(r#"ours_rp_state_db_files{instance="test",db="work-db"} 2"#));
        assert!(metrics.contains(r#"ours_rp_state_db_dirs{instance="test",db="work-db"} 1"#));
        assert!(
            metrics.contains(r#"ours_rp_cir_objects_by_source{instance="test",source="fresh"} 2"#)
        );
        assert!(
            metrics.contains(r#"ours_rp_cir_objects_by_source{instance="test",source="cached"} 0"#)
        );
        assert!(metrics.contains(
            r#"ours_rp_cir_rejected_objects_by_source{instance="test",source="fresh"} 1"#
        ));
        assert!(metrics.contains(r#"ours_rp_vrps{instance="test",kind="total"} 3"#));
        assert!(metrics.contains(r#"ours_rp_vrps{instance="test",kind="unique"} 2"#));
        let status = render_status_json(&snapshot).expect("status");
        assert!(status.contains("topPublicationPointsByObjectCount"));
        assert!(status.contains(r#""vrpsUnique": 2"#));
        assert!(status.contains(r#""freshObjects": 2"#));
        assert!(status.contains(r#""cachedObjects": 0"#));
    }

    #[test]
    fn partial_run_does_not_become_latest_success() {
        let td = TempDir::new().expect("tempdir");
        let run = td.path().join("runs/run_0001");
        fs::create_dir_all(&run).expect("create run");
        fs::write(run.join("run-meta.json"), r#"{"status":"running"}"#).expect("meta");
        let snapshot = scan_run_root(td.path(), "test").expect("scan");
        assert_eq!(snapshot.runs.partial, 1);
        assert!(snapshot.latest_run.is_none());
    }

    #[test]
    fn scan_fixture_exports_rtr_metrics_when_env_enabled() {
        let td = TempDir::new().expect("tempdir");
        let runs = td.path().join("runs");
        fs::create_dir_all(&runs).expect("create runs");
        let rtr = td.path().join("rtr");
        fs::create_dir_all(&rtr).expect("create rtr");

        fs::write(
            rtr.join("rtr-source-20260625142349411798736.json"),
            r#"{
  "schema_version": 2,
  "generated_at": "2026-06-25T14:23:49.411798736+08:00",
  "phase": "refresh_skipped_unchanged",
  "source": { "ccr_file_size_bytes": 21763268 },
  "refresh": {
    "last_success_at": "2026-06-25T14:23:49.411053688+08:00",
    "status": "success",
    "changed": false,
    "duration_ms": 33,
    "consecutive_failures": 0
  },
  "data_quality": {
    "ccr_input": { "total": 833492, "vrp": 832054, "router_key": 0, "aspa": 1438 },
    "invalid": { "total": 0, "vrp": 0, "router_key": 0, "aspa": 0 },
    "before_slurm": { "total": 833492, "vrp": 832054, "router_key": 0, "aspa": 1438 },
    "after_slurm": { "total": 828563, "vrp": 827126, "router_key": 0, "aspa": 1437 },
    "slurm_filters": { "prefix": 1, "router_key": 0, "aspa": 1 },
    "slurm_assertions": { "prefix": 0, "router_key": 0, "aspa": 0 }
  },
  "cache": {
    "availability": "ready",
    "versions": [
      {
        "version": 2,
        "serial": 9,
        "snapshot": { "total": 828563, "vrp": 827126, "router_key": 0, "aspa": 1437 },
        "latest_delta": {
          "announced": { "total": 1, "vrp": 1, "router_key": 0, "aspa": 0 },
          "withdrawn": { "total": 1, "vrp": 1, "router_key": 0, "aspa": 0 }
        },
        "delta_window": { "length": 4 }
      }
    ]
  }
}"#,
        )
        .expect("source");

        fs::write(
            rtr.join("rtr-runtime-20260625142610957948453.json"),
            r#"{
  "schema_version": 2,
  "generated_at": "2026-06-25T14:26:10.957948453+08:00",
  "phase": "runtime_periodic",
  "service": {
    "started_at": "2026-06-24T11:51:08.599427589+08:00",
    "uptime_seconds": 95714,
    "active_connections": 0,
    "connections_by_transport": { "tcp": 0, "tls": 0, "ssh": 0 },
    "session_listeners": 0,
    "max_connections": 512,
    "connection_utilization": 0.0
  },
  "process": { "rss_mib": 1285 }
}"#,
        )
        .expect("runtime");

        fs::write(
            rtr.join("rtr-clients-20260624115123282266076.json"),
            r#"{
  "schema_version": 2,
  "generated_at": "2026-06-24T11:51:23.282266076+08:00",
  "phase": "startup",
  "service": {
    "started_at": "2026-06-24T11:51:08.599427589+08:00",
    "uptime_seconds": 14,
    "active_connections": 0,
    "connections_by_transport": { "tcp": 0, "tls": 0, "ssh": 0 },
    "session_listeners": 0,
    "max_connections": 512,
    "connection_utilization": 0.0
  }
}"#,
        )
        .expect("clients");

        let snapshot = scan_run_root_incremental_with_rtr_dir(
            td.path(),
            "test",
            None,
            Some(&rtr),
            &CcrCheckConfig::disabled(),
        )
        .expect("scan");
        assert!(snapshot.rtr.enabled);
        assert_eq!(snapshot.rtr.files["source"].seen, 1);
        assert_eq!(
            snapshot
                .rtr
                .source
                .as_ref()
                .unwrap()
                .refresh_status
                .as_deref(),
            Some("success")
        );
        assert_eq!(
            snapshot.rtr.source.as_ref().unwrap().data_quality["after_slurm"]["aspa"],
            1437
        );
        assert_eq!(
            snapshot.rtr.runtime.as_ref().unwrap().process_rss_bytes,
            Some(1285 * 1024 * 1024)
        );
        assert_eq!(
            snapshot
                .rtr
                .preferred_service()
                .and_then(|service| service.max_connections),
            Some(512)
        );

        let metrics = render_metrics(&snapshot);
        assert!(metrics.contains("ours_rp_rtr_metrics_enabled"));
        assert!(metrics.contains(r#"ours_rp_rtr_cache_ready{instance="test"} 1"#));
        assert!(metrics.contains(
            r#"ours_rp_rtr_data_quality_items{instance="test",stage="after_slurm",type="aspa"} 1437"#
        ));
        assert!(metrics.contains(
            r#"ours_rp_rtr_cache_snapshot_items{instance="test",version="2",type="vrp"} 827126"#
        ));
        assert!(metrics.contains(
            r#"ours_rp_rtr_cache_delta_items{instance="test",version="2",direction="withdrawn",type="total"} 1"#
        ));
        assert!(metrics.contains(r#"ours_rp_rtr_process_rss_bytes{instance="test"} 1347420160"#));
        assert!(metrics.contains(r#"ours_rp_rtr_max_connections{instance="test"} 512"#));
        assert!(metrics.contains(r#"ours_rp_rtr_connections{instance="test",transport="ssh"} 0"#));

        let status = render_status_json(&snapshot).expect("status");
        assert!(status.contains(r#""enabled": true"#));
        assert!(status.contains(r#""processRssBytes": 1347420160"#));
    }

    #[test]
    fn rtr_bad_json_and_missing_fields_do_not_crash() {
        let td = TempDir::new().expect("tempdir");
        let runs = td.path().join("runs");
        fs::create_dir_all(&runs).expect("create runs");
        let rtr = td.path().join("rtr");
        fs::create_dir_all(&rtr).expect("create rtr");

        fs::write(rtr.join("rtr-source-20260625000000000000000.json"), "{bad").expect("bad");
        fs::write(
            rtr.join("rtr-runtime-20260625000000000000000.json"),
            r#"{"generated_at":"2026-06-25T14:26:10+08:00","process":{}}"#,
        )
        .expect("runtime");
        fs::write(
            rtr.join("rtr-clients-20260625000000000000000.json"),
            r#"{"generated_at":"2026-06-25T14:26:10+08:00"}"#,
        )
        .expect("clients");

        let snapshot = scan_run_root_incremental_with_rtr_dir(
            td.path(),
            "test",
            None,
            Some(&rtr),
            &CcrCheckConfig::disabled(),
        )
        .expect("scan");
        assert!(snapshot.rtr.enabled);
        assert_eq!(snapshot.rtr.files["source"].seen, 1);
        assert!(snapshot.rtr.source.is_none());
        assert!(snapshot.rtr.runtime.is_some());
        assert!(snapshot.rtr.clients.is_some());
        assert!(!snapshot.rtr.parse_errors.is_empty());

        let metrics = render_metrics(&snapshot);
        assert!(metrics.contains("ours_rp_rtr_metrics_parse_errors_total"));
        assert!(metrics.contains(r#"ours_rp_rtr_metrics_enabled{instance="test"} 1"#));
    }

    #[test]
    fn parse_args_accepts_ccr_check_options() {
        let args = parse_args(&[
            "rpki_artifact_metrics".to_string(),
            "--run-root".to_string(),
            "root".to_string(),
            "--ccr-check-bin".to_string(),
            "/opt/ours-rp/bin/rpki-client".to_string(),
            "--ccr-check-timeout-secs".to_string(),
            "60".to_string(),
            "--ccr-check-max-per-scan".to_string(),
            "3".to_string(),
        ])
        .expect("parse");
        assert_eq!(
            args.ccr_check_bin.as_deref(),
            Some(Path::new("/opt/ours-rp/bin/rpki-client"))
        );
        assert_eq!(args.ccr_check_timeout_secs, 60);
        assert_eq!(args.ccr_check_max_per_scan, 3);
    }

    #[test]
    fn ccr_check_disabled_without_bin() {
        let td = TempDir::new().expect("tempdir");
        write_success_run(td.path(), "run_0001", 1);
        let snapshot = scan_run_root(td.path(), "test").expect("scan");
        assert!(!snapshot.ccr_check.enabled);
        assert!(!snapshot.ccr_check.up);
        assert!(snapshot.ccr_check.last.is_none());
        let metrics = render_metrics(&snapshot);
        assert!(metrics.contains(r#"ours_rp_ccr_format_check_up{instance="test"} 0"#));
        assert!(!metrics.contains("ours_rp_ccr_format_check_last_success"));
    }

    #[test]
    fn ccr_check_pass_records_metrics() {
        let td = TempDir::new().expect("tempdir");
        write_success_run(td.path(), "run_0001", 1);
        let bin = write_fake_rpki_client(td.path(), "#!/bin/sh\nexit 0\n");
        let snapshot = scan_with_ccr_check(td.path(), None, &bin, 30);
        assert!(snapshot.ccr_check.up);
        assert_eq!(snapshot.ccr_check.pass_total, 1);
        assert_eq!(snapshot.ccr_check.consecutive_failures, 0);
        let last = snapshot.ccr_check.last.as_ref().expect("last result");
        assert_eq!(last.outcome, "pass");
        assert_eq!(last.run_seq, Some(1));
        let metrics = render_metrics(&snapshot);
        assert!(
            metrics.contains(r#"ours_rp_ccr_format_check_total{instance="test",result="pass"} 1"#)
        );
        assert!(metrics.contains(r#"ours_rp_ccr_format_check_last_success{instance="test"} 1"#));
        assert!(
            metrics.contains(r#"ours_rp_ccr_format_check_last_run_sequence{instance="test"} 1"#)
        );
        let status = render_status_json(&snapshot).expect("status");
        assert!(status.contains(r#""ccrCheck""#));
        assert!(status.contains(r#""passTotal": 1"#));
    }

    #[test]
    fn ccr_check_parse_error_detected_from_output() {
        let td = TempDir::new().expect("tempdir");
        write_success_run(td.path(), "run_0001", 1);
        let bin = write_fake_rpki_client(
            td.path(),
            "#!/bin/sh\necho \"rpki-client: $2: unexpected number of locations\" >&2\nexit 0\n",
        );
        let snapshot = scan_with_ccr_check(td.path(), None, &bin, 30);
        assert_eq!(snapshot.ccr_check.ccr_parse_error_total, 1);
        assert_eq!(snapshot.ccr_check.consecutive_failures, 1);
        let last = snapshot.ccr_check.last.as_ref().expect("last result");
        assert_eq!(last.outcome, "ccr_parse_error");
        assert!(
            last.detail
                .as_deref()
                .unwrap_or_default()
                .contains("unexpected number of locations")
        );
        let metrics = render_metrics(&snapshot);
        assert!(metrics.contains(
            r#"ours_rp_ccr_format_check_total{instance="test",result="ccr_parse_error"} 1"#
        ));
        assert!(metrics.contains(r#"ours_rp_ccr_format_check_last_success{instance="test"} 0"#));
    }

    #[test]
    fn ccr_check_nonzero_exit_is_tool_error() {
        let td = TempDir::new().expect("tempdir");
        write_success_run(td.path(), "run_0001", 1);
        let bin = write_fake_rpki_client(td.path(), "#!/bin/sh\nexit 3\n");
        let snapshot = scan_with_ccr_check(td.path(), None, &bin, 30);
        assert_eq!(snapshot.ccr_check.tool_error_total, 1);
        assert_eq!(
            snapshot.ccr_check.last.as_ref().expect("last").outcome,
            "tool_error"
        );
    }

    #[test]
    fn ccr_check_timeout_is_tool_error() {
        let td = TempDir::new().expect("tempdir");
        write_success_run(td.path(), "run_0001", 1);
        let bin = write_fake_rpki_client(td.path(), "#!/bin/sh\nsleep 30\n");
        let snapshot = scan_with_ccr_check(td.path(), None, &bin, 1);
        let last = snapshot.ccr_check.last.as_ref().expect("last result");
        assert_eq!(last.outcome, "tool_error");
        assert!(
            last.detail
                .as_deref()
                .unwrap_or_default()
                .contains("timed out")
        );
    }

    #[test]
    fn ccr_check_startup_only_checks_latest_and_new_runs_once() {
        let td = TempDir::new().expect("tempdir");
        write_success_run(td.path(), "run_0001", 1);
        write_success_run(td.path(), "run_0002", 2);
        let bin = write_fake_rpki_client(td.path(), "#!/bin/sh\nexit 0\n");

        // First scan at service start: only the latest existing run is
        // checked, older history is marked as seen without executing checks.
        let first = scan_with_ccr_check(td.path(), None, &bin, 30);
        assert_eq!(first.ccr_check.pass_total, 1);
        assert!(
            first
                .ccr_check
                .last
                .as_ref()
                .expect("last")
                .run_dir
                .contains("run_0002")
        );

        // Second scan: pre-existing history must not be backfilled.
        let second = scan_with_ccr_check(td.path(), Some(&first), &bin, 30);
        assert_eq!(second.ccr_check.pass_total, 1);

        // A newly completed run is checked exactly once.
        write_success_run(td.path(), "run_0003", 3);
        let third = scan_with_ccr_check(td.path(), Some(&second), &bin, 30);
        assert_eq!(third.ccr_check.pass_total, 2);
        assert_eq!(
            third.ccr_check.last.as_ref().expect("last").run_seq,
            Some(3)
        );
        let fourth = scan_with_ccr_check(td.path(), Some(&third), &bin, 30);
        assert_eq!(fourth.ccr_check.pass_total, 2);
    }

    #[test]
    fn ccr_check_missing_bin_marks_not_up() {
        let td = TempDir::new().expect("tempdir");
        write_success_run(td.path(), "run_0001", 1);
        let missing = td.path().join("no-such-rpki-client");
        let snapshot = scan_with_ccr_check(td.path(), None, &missing, 30);
        assert!(snapshot.ccr_check.enabled);
        assert!(!snapshot.ccr_check.up);
        assert!(snapshot.ccr_check.last.is_none());
        assert!(
            snapshot
                .service
                .parse_errors
                .iter()
                .any(|error| error.contains("ccr check bin is not a file"))
        );
        let metrics = render_metrics(&snapshot);
        assert!(metrics.contains(r#"ours_rp_ccr_format_check_up{instance="test"} 0"#));
    }

    fn write_success_run(root: &Path, name: &str, seq: u64) {
        let run = root.join("runs").join(name);
        fs::create_dir_all(&run).expect("create run");
        fs::write(
            run.join("run-meta.json"),
            format!(
                r#"{{"status":"success","run_index":{seq},"run_id":"{name}","sync_mode":"snapshot","snapshot_reason":"first_run","started_at_rfc3339_utc":"2026-07-21T00:00:00Z","completed_at_rfc3339_utc":"2026-07-21T00:00:10Z"}}"#
            ),
        )
        .expect("meta");
        fs::write(
            run.join("run-summary.json"),
            format!(
                r#"{{"runSeq":{seq},"runId":"{name}","startedAtRfc3339Utc":"2026-07-21T00:00:00Z","finishedAtRfc3339Utc":"2026-07-21T00:00:10Z","wallMs":10000,"status":"success","exitCode":0}}"#
            ),
        )
        .expect("summary");
        fs::write(run.join("result.ccr"), sample_ccr()).expect("ccr");
    }

    fn write_fake_rpki_client(root: &Path, body: &str) -> PathBuf {
        let path = root.join("fake-rpki-client.sh");
        fs::write(&path, body).expect("write fake rpki-client");
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod");
        }
        path
    }

    fn scan_with_ccr_check(
        root: &Path,
        previous: Option<&MetricsSnapshot>,
        bin: &Path,
        timeout_secs: u64,
    ) -> MetricsSnapshot {
        let config = CcrCheckConfig {
            bin: Some(bin.to_path_buf()),
            timeout_secs,
            max_per_scan: 2,
        };
        scan_run_root_incremental_with_rtr_dir(root, "test", previous, None, &config).expect("scan")
    }

    fn sample_cir() -> Vec<u8> {
        let rejected = vec![CirRejectedObject {
            object_uri: "rsync://repo.example/a/bad.roa".to_string(),
            reason: Some("bad".to_string()),
        }];
        let cir = CanonicalInputRepresentation::new_v4(
            time::OffsetDateTime::parse(
                "2026-05-25T00:00:00Z",
                &time::format_description::well_known::Rfc3339,
            )
            .unwrap(),
            vec![
                CirObject {
                    rsync_uri: "rsync://repo.example/a/a.roa".to_string(),
                    sha256: vec![1; 32],
                },
                CirObject {
                    rsync_uri: "rsync://repo.example/a/bad.roa".to_string(),
                    sha256: vec![2; 32],
                },
            ],
            Vec::new(),
            vec![CirTrustAnchor {
                ta_rsync_uri: "rsync://repo.example/ta.cer".to_string(),
                tal_uri: "https://tal.example/tal.tal".to_string(),
                tal_bytes: b"rsync://repo.example/ta.cer\n\nAQID\n".to_vec(),
                ta_certificate_der: b"ta".to_vec(),
                ta_certificate_sha256: sha256(b"ta"),
            }],
            rejected,
            Vec::new(),
        );
        encode_cir(&cir).expect("encode cir")
    }

    fn sample_ccr() -> Vec<u8> {
        let ci = CcrContentInfo::new(RpkiCanonicalCacheRepresentation {
            version: CCR_VERSION_V0,
            hash_alg: CcrDigestAlgorithm::Sha256,
            produced_at: time::OffsetDateTime::parse(
                "2026-05-25T00:00:00Z",
                &time::format_description::well_known::Rfc3339,
            )
            .unwrap(),
            mfts: None,
            vrps: None,
            vaps: None,
            tas: Some(TrustAnchorState {
                skis: vec![vec![1; 20]],
                hash: vec![2; 32],
            }),
            rks: None,
        });
        encode_content_info(&ci).expect("encode ccr")
    }

    #[test]
    fn dead_repo_blacklist_gauges_render_from_latest_summary() {
        let td = TempDir::new().expect("tempdir");
        write_success_run(td.path(), "run_0001", 1);
        // Rewrite the summary with the blacklist section present.
        let run = td.path().join("runs").join("run_0001");
        fs::write(
            run.join("run-summary.json"),
            r#"{"runSeq":1,"runId":"run_0001","startedAtRfc3339Utc":"2026-07-21T00:00:00Z","finishedAtRfc3339Utc":"2026-07-21T00:00:10Z","wallMs":10000,"status":"success","exitCode":0,"deadRepoBlacklist":{"path":"/state/dead-repo-blacklist.json","entries":5,"blacklisted":3}}"#,
        )
        .expect("summary");
        let snapshot = scan_run_root(td.path(), "test").expect("scan");
        let latest = snapshot.latest_run.as_ref().expect("latest run");
        assert_eq!(latest.dead_repo_blacklist_entries, Some(5));
        assert_eq!(latest.dead_repo_blacklist_blacklisted, Some(3));
        let metrics = render_metrics(&snapshot);
        assert!(metrics.contains(
            r#"ours_rp_dead_repo_blacklist_entries{instance="test",state="total"} 5"#
        ));
        assert!(metrics.contains(
            r#"ours_rp_dead_repo_blacklist_entries{instance="test",state="blacklisted"} 3"#
        ));
    }

    #[test]
    fn dead_repo_blacklist_gauges_absent_without_section() {
        let td = TempDir::new().expect("tempdir");
        write_success_run(td.path(), "run_0001", 1);
        let snapshot = scan_run_root(td.path(), "test").expect("scan");
        let metrics = render_metrics(&snapshot);
        assert!(!metrics.contains("ours_rp_dead_repo_blacklist_entries"));
    }
}
