use serde::Serialize;
use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;

use crate::parallel::dead_repo_blacklist::DeadRepoBlacklist;
use crate::parallel::types::RepoTransportMode;

#[derive(Clone, Debug, PartialEq, Eq)]
struct Args {
    state_root: PathBuf,
    rpki_bin: PathBuf,
    interval_secs: u64,
    max_runs: Option<u64>,
    retain_runs: usize,
    status_json: Option<PathBuf>,
    summary_jsonl: Option<PathBuf>,
    work_db: PathBuf,
    repo_bytes_db: Option<PathBuf>,
    raw_store_db: Option<PathBuf>,
    db_stats_bin: Option<PathBuf>,
    db_stats_exact_every: Option<u64>,
    time_bin: Option<PathBuf>,
    /// Dead-repo blacklist (#141): enables daemon-side health checks and
    /// auto-injects the child `--dead-repo-blacklist` flag when absent.
    dead_repo_blacklist: Option<PathBuf>,
    dead_repo_blacklist_fail_threshold: Option<u32>,
    dead_repo_health_check_interval_secs: u64,
    dead_repo_probe_rsync_bin: PathBuf,
    child_args: Vec<String>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
struct RunContext {
    seq: u64,
    run_id: String,
    run_dir: PathBuf,
}

#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum DaemonState {
    Starting,
    Idle,
    Running,
    Collecting,
    Sleeping,
    Exited,
}

#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct DaemonStatus {
    state: DaemonState,
    updated_at_rfc3339_utc: String,
    runs_completed: u64,
    max_runs: Option<u64>,
    current_run_seq: Option<u64>,
    current_run_id: Option<String>,
    last_run_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    dead_repo_blacklist: Option<DeadRepoBlacklistStatus>,
}

#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct DeadRepoBlacklistStatus {
    path: String,
    entries: usize,
    blacklisted: usize,
    last_health_check_at_rfc3339_utc: Option<String>,
}

/// Mutable daemon-side health check bookkeeping (#141).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct DeadRepoHealthRuntime {
    last_health_check_at: Option<time::OffsetDateTime>,
}

#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum RunStatus {
    Success,
    Failed,
    SpawnFailed,
}

#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct ArtifactInfo {
    path: String,
    size_bytes: u64,
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ProcessMetrics {
    time_wrapper_used: bool,
    time_output_path: Option<String>,
    user_seconds: Option<f64>,
    system_seconds: Option<f64>,
    cpu_percent: Option<f64>,
    elapsed_raw: Option<String>,
    max_rss_kb: Option<u64>,
    exit_status_from_time: Option<i32>,
    parse_error: Option<String>,
}

#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct ReportCounts {
    vrps: usize,
    aspas: usize,
    publication_points: usize,
    rrdp_repos_unique: Option<u64>,
    tree_instances_processed: Option<u64>,
    tree_instances_failed: Option<u64>,
    warnings: usize,
}

#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct PathFileStats {
    label: String,
    path: String,
    exists: bool,
    is_dir: bool,
    total_size_bytes: u64,
    file_count: u64,
    dir_count: u64,
}

#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct DbStatsSummary {
    mode: String,
    db_path: String,
    output_path: Option<String>,
    stderr_path: Option<String>,
    status: String,
    exit_code: Option<i32>,
    error: Option<String>,
    metrics: BTreeMap<String, String>,
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct RunSummary {
    run_seq: u64,
    run_id: String,
    run_dir: String,
    started_at_rfc3339_utc: String,
    finished_at_rfc3339_utc: String,
    wall_ms: u64,
    status: RunStatus,
    exit_code: Option<i32>,
    exit_status: Option<String>,
    error: Option<String>,
    rpki_bin: String,
    child_args: Vec<String>,
    stdout_path: String,
    stderr_path: String,
    process_metrics: Option<ProcessMetrics>,
    stage_timing: Option<serde_json::Value>,
    report_counts: Option<ReportCounts>,
    repo_sync_stats: Option<serde_json::Value>,
    path_stats: Vec<PathFileStats>,
    db_stats: Vec<DbStatsSummary>,
    retention_deleted_runs: Vec<String>,
    artifacts: Vec<ArtifactInfo>,
    #[serde(skip_serializing_if = "Option::is_none")]
    dead_repo_blacklist: Option<DeadRepoBlacklistRunSummary>,
}

#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct DeadRepoBlacklistRunSummary {
    path: String,
    entries: usize,
    blacklisted: usize,
}

fn usage() -> String {
    let bin = "rpki_daemon";
    format!(
        "\
Usage:
  {bin} --state-root <path> --rpki-bin <path> [options] -- <rpki args...>

Options:
  --state-root <path>       Persistent daemon root containing state/, runs/, status, and JSONL summary
  --rpki-bin <path>         rpki child binary to execute for each run
  --interval-secs <n>       Sleep seconds between runs (default: 60)
  --max-runs <n>            Stop after n runs (default: run forever)
  --retain-runs <n>         Keep only the latest n run directories (default: 10)
  --status-json <path>      Override status JSON path (default: <state-root>/daemon-status.json)
  --summary-jsonl <path>    Override summary JSONL path (default: <state-root>/daemon-runs.jsonl)
  --work-db <path>          Work DB path for metrics (default: <state-root>/state/work-db)
  --repo-bytes-db <path>    Repo bytes DB path for file metrics (default: <state-root>/state/repo-bytes.db)
  --raw-store-db <path>     Raw store DB path for file metrics (optional)
  --db-stats-bin <path>     db_stats binary path (default: sibling db_stats next to this executable when present)
  --db-stats-exact-every <n>
                            Run db_stats --exact every n runs (default: disabled)
  --time-bin <path>         GNU time binary for child process metrics (default: /usr/bin/time when present)
  --no-time-wrapper         Disable GNU time wrapper
  --dead-repo-blacklist <path>
                            Enable the dead-repo transport blacklist (#141): daemon probes
                            blacklisted entries between runs and removes revived repos;
                            auto-injects the child flag when not already present
  --dead-repo-blacklist-fail-threshold <n>
                            Admission threshold forwarded to the child when its args do not
                            already set it (default: child default 3)
  --dead-repo-health-check-interval-secs <n>
                            Minimum seconds between health check sweeps (default: 600)
  --dead-repo-probe-rsync-bin <path>
                            rsync binary used for health probes (default: rsync)
  --help                    Show this help

Child argument placeholders:
  {{state_root}}             Daemon state root
  {{run_out}}                Current run output directory
  {{run_id}}                 Current run id, e.g. 000001-20260428T090000Z
  {{run_seq}}                Current run sequence number
"
    )
}

fn default_time_bin() -> Option<PathBuf> {
    let path = PathBuf::from("/usr/bin/time");
    if path.is_file() { Some(path) } else { None }
}

fn default_db_stats_bin() -> Option<PathBuf> {
    let mut path = std::env::current_exe().ok()?;
    path.set_file_name("db_stats");
    if path.is_file() { Some(path) } else { None }
}

fn parse_args(argv: &[String]) -> Result<Args, String> {
    if argv.iter().any(|arg| arg == "--help" || arg == "-h") {
        return Err(usage());
    }

    let mut state_root: Option<PathBuf> = None;
    let mut rpki_bin: Option<PathBuf> = None;
    let mut interval_secs = 60u64;
    let mut max_runs = None;
    let mut retain_runs = 10usize;
    let mut status_json: Option<PathBuf> = None;
    let mut summary_jsonl: Option<PathBuf> = None;
    let mut work_db: Option<PathBuf> = None;
    let mut repo_bytes_db: Option<PathBuf> = None;
    let mut raw_store_db: Option<PathBuf> = None;
    let mut db_stats_bin: Option<PathBuf> = None;
    let mut db_stats_exact_every = None;
    let mut time_bin = default_time_bin();
    let mut no_time_wrapper = false;
    let mut dead_repo_blacklist: Option<PathBuf> = None;
    let mut dead_repo_blacklist_fail_threshold: Option<u32> = None;
    let mut dead_repo_health_check_interval_secs = 600u64;
    let mut dead_repo_probe_rsync_bin = PathBuf::from("rsync");

    let mut i = 1usize;
    while i < argv.len() {
        match argv[i].as_str() {
            "--" => {
                let mut child_args = argv[i + 1..].to_vec();
                let state_root =
                    state_root.ok_or_else(|| format!("--state-root is required\n\n{}", usage()))?;
                let work_db = work_db.unwrap_or_else(|| state_root.join("state").join("work-db"));
                let repo_bytes_db =
                    repo_bytes_db.or_else(|| Some(state_root.join("state").join("repo-bytes.db")));
                if no_time_wrapper {
                    time_bin = None;
                }
                // Dead-repo blacklist (#141): forward enablement to the child
                // unless its args already carry the flag explicitly.
                if let Some(path) = dead_repo_blacklist.as_ref() {
                    if !child_args.iter().any(|arg| arg == "--dead-repo-blacklist") {
                        child_args.push("--dead-repo-blacklist".to_string());
                        child_args.push(path_string(path));
                    }
                    if let Some(threshold) = dead_repo_blacklist_fail_threshold {
                        if !child_args
                            .iter()
                            .any(|arg| arg == "--dead-repo-blacklist-fail-threshold")
                        {
                            child_args.push("--dead-repo-blacklist-fail-threshold".to_string());
                            child_args.push(threshold.to_string());
                        }
                    }
                }
                let args = Args {
                    state_root,
                    rpki_bin: rpki_bin
                        .ok_or_else(|| format!("--rpki-bin is required\n\n{}", usage()))?,
                    interval_secs,
                    max_runs,
                    retain_runs,
                    status_json,
                    summary_jsonl,
                    work_db,
                    repo_bytes_db,
                    raw_store_db,
                    db_stats_bin,
                    db_stats_exact_every,
                    time_bin,
                    dead_repo_blacklist,
                    dead_repo_blacklist_fail_threshold,
                    dead_repo_health_check_interval_secs,
                    dead_repo_probe_rsync_bin,
                    child_args,
                };
                return validate_args(args);
            }
            "--state-root" => {
                i += 1;
                state_root = Some(PathBuf::from(value_at(argv, i, "--state-root")?));
            }
            "--rpki-bin" => {
                i += 1;
                rpki_bin = Some(PathBuf::from(value_at(argv, i, "--rpki-bin")?));
            }
            "--interval-secs" => {
                i += 1;
                interval_secs =
                    parse_u64(value_at(argv, i, "--interval-secs")?, "--interval-secs")?;
            }
            "--max-runs" => {
                i += 1;
                let parsed = parse_u64(value_at(argv, i, "--max-runs")?, "--max-runs")?;
                if parsed == 0 {
                    return Err("--max-runs must be > 0".to_string());
                }
                max_runs = Some(parsed);
            }
            "--retain-runs" => {
                i += 1;
                let parsed = parse_usize(value_at(argv, i, "--retain-runs")?, "--retain-runs")?;
                if parsed == 0 {
                    return Err("--retain-runs must be > 0".to_string());
                }
                retain_runs = parsed;
            }
            "--status-json" => {
                i += 1;
                status_json = Some(PathBuf::from(value_at(argv, i, "--status-json")?));
            }
            "--summary-jsonl" => {
                i += 1;
                summary_jsonl = Some(PathBuf::from(value_at(argv, i, "--summary-jsonl")?));
            }
            "--work-db" => {
                i += 1;
                work_db = Some(PathBuf::from(value_at(argv, i, "--work-db")?));
            }
            "--repo-bytes-db" => {
                i += 1;
                repo_bytes_db = Some(PathBuf::from(value_at(argv, i, "--repo-bytes-db")?));
            }
            "--raw-store-db" => {
                i += 1;
                raw_store_db = Some(PathBuf::from(value_at(argv, i, "--raw-store-db")?));
            }
            "--db-stats-bin" => {
                i += 1;
                db_stats_bin = Some(PathBuf::from(value_at(argv, i, "--db-stats-bin")?));
            }
            "--db-stats-exact-every" => {
                i += 1;
                let parsed = parse_u64(
                    value_at(argv, i, "--db-stats-exact-every")?,
                    "--db-stats-exact-every",
                )?;
                if parsed == 0 {
                    return Err("--db-stats-exact-every must be > 0".to_string());
                }
                db_stats_exact_every = Some(parsed);
            }
            "--time-bin" => {
                i += 1;
                time_bin = Some(PathBuf::from(value_at(argv, i, "--time-bin")?));
            }
            "--no-time-wrapper" => {
                no_time_wrapper = true;
            }
            "--dead-repo-blacklist" => {
                i += 1;
                dead_repo_blacklist = Some(PathBuf::from(value_at(argv, i, "--dead-repo-blacklist")?));
            }
            "--dead-repo-blacklist-fail-threshold" => {
                i += 1;
                let parsed = parse_u64(
                    value_at(argv, i, "--dead-repo-blacklist-fail-threshold")?,
                    "--dead-repo-blacklist-fail-threshold",
                )?;
                if parsed == 0 || parsed > u32::MAX as u64 {
                    return Err("--dead-repo-blacklist-fail-threshold must be in 1..=u32::MAX".to_string());
                }
                dead_repo_blacklist_fail_threshold = Some(parsed as u32);
            }
            "--dead-repo-health-check-interval-secs" => {
                i += 1;
                dead_repo_health_check_interval_secs = parse_u64(
                    value_at(argv, i, "--dead-repo-health-check-interval-secs")?,
                    "--dead-repo-health-check-interval-secs",
                )?;
            }
            "--dead-repo-probe-rsync-bin" => {
                i += 1;
                dead_repo_probe_rsync_bin =
                    PathBuf::from(value_at(argv, i, "--dead-repo-probe-rsync-bin")?);
            }
            other => return Err(format!("unknown argument: {other}\n\n{}", usage())),
        }
        i += 1;
    }

    Err(format!("missing -- before child rpki args\n\n{}", usage()))
}

fn validate_args(args: Args) -> Result<Args, String> {
    if args.child_args.is_empty() {
        return Err(format!(
            "child rpki args are required after --\n\n{}",
            usage()
        ));
    }
    if args.dead_repo_blacklist_fail_threshold.is_some() && args.dead_repo_blacklist.is_none() {
        return Err(
            "--dead-repo-blacklist-fail-threshold requires --dead-repo-blacklist".to_string(),
        );
    }
    Ok(args)
}

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

fn parse_u64(raw: &str, flag: &str) -> Result<u64, String> {
    raw.parse::<u64>()
        .map_err(|_| format!("invalid {flag}: {raw}"))
}

fn parse_usize(raw: &str, flag: &str) -> Result<usize, String> {
    raw.parse::<usize>()
        .map_err(|_| format!("invalid {flag}: {raw}"))
}

fn status_path(args: &Args) -> PathBuf {
    args.status_json
        .clone()
        .unwrap_or_else(|| args.state_root.join("daemon-status.json"))
}

fn summary_jsonl_path(args: &Args) -> PathBuf {
    args.summary_jsonl
        .clone()
        .unwrap_or_else(|| args.state_root.join("daemon-runs.jsonl"))
}

fn utc_now() -> time::OffsetDateTime {
    time::OffsetDateTime::now_utc().to_offset(time::UtcOffset::UTC)
}

fn format_rfc3339(t: time::OffsetDateTime) -> Result<String, String> {
    t.format(&time::format_description::well_known::Rfc3339)
        .map_err(|e| format!("format RFC3339 failed: {e}"))
}

fn format_compact_utc(t: time::OffsetDateTime) -> String {
    format!(
        "{:04}{:02}{:02}T{:02}{:02}{:02}Z",
        t.year(),
        u8::from(t.month()),
        t.day(),
        t.hour(),
        t.minute(),
        t.second()
    )
}

fn write_json_pretty<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .map_err(|e| format!("create parent dir failed: {}: {e}", parent.display()))?;
    }
    let bytes = serde_json::to_vec_pretty(value)
        .map_err(|e| format!("serialize json failed: {}: {e}", path.display()))?;
    // Atomic write (tmp + rename) so concurrent readers never see a torn file.
    let mut tmp_name = path
        .file_name()
        .map(|name| name.to_os_string())
        .unwrap_or_default();
    tmp_name.push(".tmp");
    let tmp_path = path.with_file_name(tmp_name);
    fs::write(&tmp_path, &bytes)
        .map_err(|e| format!("write json tmp failed: {}: {e}", tmp_path.display()))?;
    fs::rename(&tmp_path, path)
        .map_err(|e| format!("rename json tmp failed: {} -> {}: {e}", tmp_path.display(), path.display()))
}

fn append_json_line<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .map_err(|e| format!("create parent dir failed: {}: {e}", parent.display()))?;
    }
    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .map_err(|e| format!("open jsonl failed: {}: {e}", path.display()))?;
    serde_json::to_writer(&mut file, value)
        .map_err(|e| format!("write jsonl failed: {}: {e}", path.display()))?;
    file.write_all(b"\n")
        .map_err(|e| format!("flush jsonl failed: {}: {e}", path.display()))
}

fn dead_repo_blacklist_status(
    args: &Args,
    health: &DeadRepoHealthRuntime,
) -> Option<DeadRepoBlacklistStatus> {
    let path = args.dead_repo_blacklist.as_ref()?;
    let (blacklist, _) = DeadRepoBlacklist::load(path);
    let last_health_check_at_rfc3339_utc = health
        .last_health_check_at
        .and_then(|t| format_rfc3339(t).ok());
    Some(DeadRepoBlacklistStatus {
        path: path_string(path),
        entries: blacklist.len(),
        blacklisted: blacklist.blacklisted_len(),
        last_health_check_at_rfc3339_utc,
    })
}

fn write_status(
    args: &Args,
    state: DaemonState,
    runs_completed: u64,
    current: Option<&RunContext>,
    last_run_id: Option<String>,
    health: &DeadRepoHealthRuntime,
) -> Result<(), String> {
    let updated_at_rfc3339_utc = format_rfc3339(utc_now())?;
    let status = DaemonStatus {
        state,
        updated_at_rfc3339_utc,
        runs_completed,
        max_runs: args.max_runs,
        current_run_seq: current.map(|ctx| ctx.seq),
        current_run_id: current.map(|ctx| ctx.run_id.clone()),
        last_run_id,
        dead_repo_blacklist: dead_repo_blacklist_status(args, health),
    };
    write_json_pretty(&status_path(args), &status)
}

/// Short-timeout HTTP GET of the RRDP notification file. Any HTTP status
/// (even 4xx) proves the transport is alive; only transport errors keep the
/// entry blacklisted.
fn probe_rrdp_transport(uri: &str) -> bool {
    let config = crate::fetch::http::HttpFetcherConfig {
        connect_timeout: Duration::from_secs(3),
        timeout: Duration::from_secs(6),
        large_body_timeout: Duration::from_secs(6),
        ..Default::default()
    };
    let fetcher = match crate::fetch::http::BlockingHttpFetcher::new(config) {
        Ok(fetcher) => fetcher,
        Err(_) => return false,
    };
    match fetcher.fetch_bytes(uri) {
        Ok(_) => true,
        Err(err) => err.starts_with("http status"),
    }
}

/// rsync list-only probe: connecting to the daemon and listing the module
/// root is enough to prove the transport is alive.
fn probe_rsync_transport(rsync_bin: &Path, base_uri: &str) -> bool {
    Command::new(rsync_bin)
        .arg("--contimeout=5")
        .arg("--timeout=8")
        .arg(base_uri)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map(|status| status.success())
        .unwrap_or(false)
}

/// Probe due blacklist entries between runs (#141). Runs in the supervisor
/// loop while no child is active, preserving the single-writer assumption.
fn maybe_run_dead_repo_health_check(args: &Args, health: &mut DeadRepoHealthRuntime) {
    let Some(path) = args.dead_repo_blacklist.clone() else {
        return;
    };
    let now = utc_now();
    if let Some(last) = health.last_health_check_at {
        let elapsed = (now - last).whole_seconds();
        if elapsed >= 0 && (elapsed as u64) < args.dead_repo_health_check_interval_secs {
            return;
        }
    }
    let (mut blacklist, warning) = DeadRepoBlacklist::load(&path);
    if let Some(warning) = warning {
        eprintln!("[dead-repo-health] {warning}");
    }
    let now_unix = now.unix_timestamp().max(0) as u64;
    let due = blacklist.probe_due_entries(now_unix, args.dead_repo_health_check_interval_secs);
    if due.is_empty() {
        health.last_health_check_at = Some(now);
        return;
    }
    // Probe in parallel: in the docker soak the daemon runs once per run
    // (--max-runs 1), so a serial sweep of ~12 dead entries would add up to a
    // minute of latency to every run.
    let rsync_bin = args.dead_repo_probe_rsync_bin.clone();
    let probe_results: Vec<(RepoTransportMode, String, bool)> = due
        .iter()
        .map(|entry| (entry.transport, entry.uri.clone()))
        .collect::<Vec<_>>()
        .into_iter()
        .map(|(transport, uri)| {
            let rsync_bin = rsync_bin.clone();
            std::thread::spawn(move || {
                let alive = match transport {
                    RepoTransportMode::Rrdp => probe_rrdp_transport(&uri),
                    RepoTransportMode::Rsync => probe_rsync_transport(&rsync_bin, &uri),
                };
                (transport, uri, alive)
            })
        })
        .filter_map(|handle| handle.join().ok())
        .collect();
    let mut removed = 0usize;
    let mut still_dead = 0usize;
    for (transport, uri, alive) in &probe_results {
        if *alive {
            blacklist.record_probe_success(*transport, uri);
            removed += 1;
            eprintln!("[dead-repo-health] removed {} {} (probe ok)", transport.as_str(), uri);
            crate::progress_log::emit(
                "dead_repo_blacklist_remove",
                serde_json::json!({
                    "transport": transport.as_str(),
                    "uri": uri,
                    "reason": "health_probe_ok",
                }),
            );
        } else {
            blacklist.record_probe_failure(*transport, uri, now_unix);
            still_dead += 1;
            eprintln!(
                "[dead-repo-health] still dead {} {} (probe failed)",
                transport.as_str(),
                uri
            );
        }
    }
    if let Err(err) = blacklist.store_atomic(&path, now_unix) {
        eprintln!(
            "[dead-repo-health] persist blacklist failed: {}: {err}",
            path.display()
        );
    }
    crate::progress_log::emit(
        "dead_repo_health_check",
        serde_json::json!({
            "probed": probe_results.len(),
            "removed": removed,
            "still_dead": still_dead,
        }),
    );
    health.last_health_check_at = Some(now);
}

fn render_child_args(args: &[String], daemon_args: &Args, ctx: &RunContext) -> Vec<String> {
    args.iter()
        .map(|arg| {
            arg.replace("{state_root}", &path_string(&daemon_args.state_root))
                .replace("{run_out}", &path_string(&ctx.run_dir))
                .replace("{run_id}", &ctx.run_id)
                .replace("{run_seq}", &ctx.seq.to_string())
        })
        .collect()
}

fn render_path_template(path: &Path, daemon_args: &Args, ctx: &RunContext) -> PathBuf {
    PathBuf::from(
        path_string(path)
            .replace("{state_root}", &path_string(&daemon_args.state_root))
            .replace("{run_out}", &path_string(&ctx.run_dir))
            .replace("{run_id}", &ctx.run_id)
            .replace("{run_seq}", &ctx.seq.to_string()),
    )
}

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

fn make_run_context(args: &Args, seq: u64, now: time::OffsetDateTime) -> RunContext {
    let run_id = format!("{seq:06}-{}", format_compact_utc(now));
    let run_dir = args.state_root.join("runs").join(&run_id);
    RunContext {
        seq,
        run_id,
        run_dir,
    }
}

fn collect_artifacts(run_dir: &Path) -> Result<Vec<ArtifactInfo>, String> {
    let mut artifacts = Vec::new();
    for entry in fs::read_dir(run_dir)
        .map_err(|e| format!("read run dir failed: {}: {e}", run_dir.display()))?
    {
        let entry = entry.map_err(|e| format!("read run dir entry failed: {e}"))?;
        if !entry
            .file_type()
            .map_err(|e| format!("read file type failed: {}: {e}", entry.path().display()))?
            .is_file()
        {
            continue;
        }
        let metadata = entry
            .metadata()
            .map_err(|e| format!("read metadata failed: {}: {e}", entry.path().display()))?;
        artifacts.push(ArtifactInfo {
            path: path_string(&entry.path()),
            size_bytes: metadata.len(),
        });
    }
    artifacts.sort_by(|a, b| a.path.cmp(&b.path));
    Ok(artifacts)
}

fn collect_process_metrics(time_wrapper_used: bool, time_output_path: &Path) -> ProcessMetrics {
    if !time_wrapper_used {
        return ProcessMetrics {
            time_wrapper_used,
            time_output_path: None,
            user_seconds: None,
            system_seconds: None,
            cpu_percent: None,
            elapsed_raw: None,
            max_rss_kb: None,
            exit_status_from_time: None,
            parse_error: None,
        };
    }

    let mut metrics = ProcessMetrics {
        time_wrapper_used,
        time_output_path: Some(path_string(time_output_path)),
        user_seconds: None,
        system_seconds: None,
        cpu_percent: None,
        elapsed_raw: None,
        max_rss_kb: None,
        exit_status_from_time: None,
        parse_error: None,
    };

    let text = match fs::read_to_string(time_output_path) {
        Ok(text) => text,
        Err(err) => {
            metrics.parse_error = Some(format!(
                "read process time output failed: {}: {err}",
                time_output_path.display()
            ));
            return metrics;
        }
    };

    for line in text.lines() {
        let line = line.trim();
        if let Some(value) = line.strip_prefix("User time (seconds):") {
            metrics.user_seconds = value.trim().parse::<f64>().ok();
        } else if let Some(value) = line.strip_prefix("System time (seconds):") {
            metrics.system_seconds = value.trim().parse::<f64>().ok();
        } else if let Some(value) = line.strip_prefix("Percent of CPU this job got:") {
            metrics.cpu_percent = value.trim().trim_end_matches('%').parse::<f64>().ok();
        } else if let Some(value) =
            line.strip_prefix("Elapsed (wall clock) time (h:mm:ss or m:ss):")
        {
            metrics.elapsed_raw = Some(value.trim().to_string());
        } else if let Some(value) = line.strip_prefix("Maximum resident set size (kbytes):") {
            metrics.max_rss_kb = value.trim().parse::<u64>().ok();
        } else if let Some(value) = line.strip_prefix("Exit status:") {
            metrics.exit_status_from_time = value.trim().parse::<i32>().ok();
        }
    }
    metrics
}

fn run_child_once(args: &Args, ctx: &RunContext) -> Result<RunSummary, String> {
    fs::create_dir_all(&ctx.run_dir)
        .map_err(|e| format!("create run dir failed: {}: {e}", ctx.run_dir.display()))?;

    let started_at = utc_now();
    let started_at_rfc3339_utc = format_rfc3339(started_at)?;
    let stdout_path = ctx.run_dir.join("stdout.log");
    let stderr_path = ctx.run_dir.join("stderr.log");
    let stdout = File::create(&stdout_path)
        .map_err(|e| format!("create stdout log failed: {}: {e}", stdout_path.display()))?;
    let stderr = File::create(&stderr_path)
        .map_err(|e| format!("create stderr log failed: {}: {e}", stderr_path.display()))?;
    let child_args = render_child_args(&args.child_args, args, ctx);
    let time_output_path = ctx.run_dir.join("process-time.txt");

    let mut command = if let Some(time_bin) = args.time_bin.as_ref() {
        let mut command = Command::new(time_bin);
        command
            .arg("-v")
            .arg("-o")
            .arg(&time_output_path)
            .arg("--")
            .arg(&args.rpki_bin)
            .args(&child_args);
        command
    } else {
        let mut command = Command::new(&args.rpki_bin);
        command.args(&child_args);
        command
    };
    command
        .stdout(Stdio::from(stdout))
        .stderr(Stdio::from(stderr));

    let (status, exit_code, exit_status, error) = match command.status() {
        Ok(status) if status.success() => (
            RunStatus::Success,
            status.code(),
            Some(status.to_string()),
            None,
        ),
        Ok(status) => (
            RunStatus::Failed,
            status.code(),
            Some(status.to_string()),
            None,
        ),
        Err(err) => (
            RunStatus::SpawnFailed,
            None,
            None,
            Some(format!("spawn child failed: {err}")),
        ),
    };

    let finished_at = utc_now();
    let finished_at_rfc3339_utc = format_rfc3339(finished_at)?;
    let wall_ms = (finished_at - started_at).whole_milliseconds().max(0) as u64;
    let process_metrics = Some(collect_process_metrics(
        args.time_bin.is_some(),
        &time_output_path,
    ));
    let artifacts = collect_artifacts(&ctx.run_dir)?;
    let summary = RunSummary {
        run_seq: ctx.seq,
        run_id: ctx.run_id.clone(),
        run_dir: path_string(&ctx.run_dir),
        started_at_rfc3339_utc,
        finished_at_rfc3339_utc,
        wall_ms,
        status,
        exit_code,
        exit_status,
        error,
        rpki_bin: path_string(&args.rpki_bin),
        child_args,
        stdout_path: path_string(&stdout_path),
        stderr_path: path_string(&stderr_path),
        process_metrics,
        stage_timing: None,
        report_counts: None,
        repo_sync_stats: None,
        path_stats: Vec::new(),
        db_stats: Vec::new(),
        retention_deleted_runs: Vec::new(),
        artifacts,
        dead_repo_blacklist: None,
    };
    Ok(summary)
}

fn apply_retention(runs_root: &Path, retain_runs: usize) -> Result<Vec<PathBuf>, String> {
    if !runs_root.exists() {
        return Ok(Vec::new());
    }
    let mut dirs = Vec::new();
    for entry in fs::read_dir(runs_root)
        .map_err(|e| format!("read runs dir failed: {}: {e}", runs_root.display()))?
    {
        let entry = entry.map_err(|e| format!("read runs dir entry failed: {e}"))?;
        if entry
            .file_type()
            .map_err(|e| format!("read file type failed: {}: {e}", entry.path().display()))?
            .is_dir()
        {
            dirs.push(entry.path());
        }
    }
    dirs.sort();
    let remove_count = dirs.len().saturating_sub(retain_runs);
    let mut removed = Vec::new();
    for dir in dirs.into_iter().take(remove_count) {
        fs::remove_dir_all(&dir)
            .map_err(|e| format!("remove old run dir failed: {}: {e}", dir.display()))?;
        removed.push(dir);
    }
    Ok(removed)
}

fn find_named_file(root: &Path, name: &str) -> Option<PathBuf> {
    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let entries = fs::read_dir(&dir).ok()?;
        for entry in entries.flatten() {
            let path = entry.path();
            let file_type = entry.file_type().ok()?;
            if file_type.is_file() && entry.file_name().to_string_lossy() == name {
                return Some(path);
            }
            if file_type.is_dir() {
                stack.push(path);
            }
        }
    }
    None
}

fn read_json_value_if_exists(path: &Path) -> Option<serde_json::Value> {
    let bytes = fs::read(path).ok()?;
    serde_json::from_slice(&bytes).ok()
}

fn json_array_len(value: &serde_json::Value, key: &str) -> usize {
    value
        .get(key)
        .and_then(serde_json::Value::as_array)
        .map(Vec::len)
        .unwrap_or(0)
}

fn parse_stdout_summary(run_dir: &Path) -> Option<ReportCounts> {
    let stdout_path = run_dir.join("stdout.log");
    let text = fs::read_to_string(stdout_path).ok()?;
    let mut vrps = None;
    let mut aspas = None;
    let mut publication_points = None;
    let mut rrdp_repos_unique = None;
    let mut tree_instances_processed = None;
    let mut tree_instances_failed = None;
    let mut warnings = None;

    for line in text.lines() {
        if let Some(value) = line.strip_prefix("vrps=") {
            vrps = value.trim().parse::<usize>().ok();
        } else if let Some(value) = line.strip_prefix("aspas=") {
            aspas = value.trim().parse::<usize>().ok();
        } else if let Some(value) = line.strip_prefix("audit_publication_points=") {
            publication_points = value.trim().parse::<usize>().ok();
        } else if let Some(value) = line.strip_prefix("rrdp_repos_unique=") {
            rrdp_repos_unique = value.trim().parse::<u64>().ok();
        } else if let Some(value) = line.strip_prefix("warnings_total=") {
            warnings = value.trim().parse::<usize>().ok();
        } else if let Some(rest) = line.strip_prefix("publication_points_processed=") {
            for token in rest.split_whitespace() {
                if let Some(value) = token.strip_prefix("publication_points_failed=") {
                    tree_instances_failed = value.parse::<u64>().ok();
                } else if tree_instances_processed.is_none() {
                    tree_instances_processed = token.parse::<u64>().ok();
                }
            }
        }
    }

    Some(ReportCounts {
        vrps: vrps?,
        aspas: aspas?,
        publication_points: publication_points?,
        rrdp_repos_unique,
        tree_instances_processed,
        tree_instances_failed,
        warnings: warnings.unwrap_or(0),
    })
}

fn parse_report_counts_fallback(report: &serde_json::Value) -> ReportCounts {
    let tree = report.get("tree");
    let tree_warnings = tree
        .and_then(|tree| tree.get("warnings"))
        .and_then(serde_json::Value::as_array)
        .map(Vec::len)
        .unwrap_or(0);
    let pp_warnings = report
        .get("publication_points")
        .and_then(serde_json::Value::as_array)
        .map(|items| {
            items
                .iter()
                .map(|pp| {
                    pp.get("warnings")
                        .and_then(serde_json::Value::as_array)
                        .map(Vec::len)
                        .unwrap_or(0)
                })
                .sum()
        })
        .unwrap_or(0);
    ReportCounts {
        vrps: json_array_len(report, "vrps"),
        aspas: json_array_len(report, "aspas"),
        publication_points: json_array_len(report, "publication_points"),
        rrdp_repos_unique: None,
        tree_instances_processed: tree
            .and_then(|tree| tree.get("instances_processed"))
            .and_then(serde_json::Value::as_u64),
        tree_instances_failed: tree
            .and_then(|tree| tree.get("instances_failed"))
            .and_then(serde_json::Value::as_u64),
        warnings: tree_warnings + pp_warnings,
    }
}

fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
    haystack
        .windows(needle.len())
        .position(|window| window == needle)
}

fn extract_json_object_field(path: &Path, field_name: &str) -> Option<serde_json::Value> {
    let bytes = fs::read(path).ok()?;
    let needle = format!("\"{field_name}\":");
    let pos = find_subslice(&bytes, needle.as_bytes())?;
    let mut i = pos + needle.len();
    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
        i += 1;
    }
    if bytes.get(i).copied()? != b'{' {
        return None;
    }
    let start = i;
    let mut depth = 0u32;
    let mut in_string = false;
    let mut escaped = false;
    for (offset, &b) in bytes[start..].iter().enumerate() {
        if in_string {
            if escaped {
                escaped = false;
            } else if b == b'\\' {
                escaped = true;
            } else if b == b'"' {
                in_string = false;
            }
            continue;
        }
        match b {
            b'"' => in_string = true,
            b'{' => depth = depth.saturating_add(1),
            b'}' => {
                depth = depth.saturating_sub(1);
                if depth == 0 {
                    let end = start + offset + 1;
                    return serde_json::from_slice(&bytes[start..end]).ok();
                }
            }
            _ => {}
        }
    }
    None
}

fn parse_report_metadata(run_dir: &Path) -> (Option<ReportCounts>, Option<serde_json::Value>) {
    let Some(report_path) = find_named_file(run_dir, "report.json") else {
        return (parse_stdout_summary(run_dir), None);
    };
    let counts = parse_stdout_summary(run_dir).or_else(|| {
        read_json_value_if_exists(&report_path).map(|report| parse_report_counts_fallback(&report))
    });
    let repo_sync_stats = extract_json_object_field(&report_path, "repo_sync_stats");
    (counts, repo_sync_stats)
}

fn collect_path_file_stats(label: &str, path: &Path) -> PathFileStats {
    let mut stats = PathFileStats {
        label: label.to_string(),
        path: path_string(path),
        exists: path.exists(),
        is_dir: path.is_dir(),
        total_size_bytes: 0,
        file_count: 0,
        dir_count: 0,
    };
    if !stats.exists {
        return stats;
    }
    if path.is_file() {
        if let Ok(metadata) = path.metadata() {
            stats.total_size_bytes = metadata.len();
            stats.file_count = 1;
        }
        return stats;
    }

    let mut stack = vec![path.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let Ok(entries) = fs::read_dir(&dir) else {
            continue;
        };
        for entry in entries.flatten() {
            let Ok(file_type) = entry.file_type() else {
                continue;
            };
            if file_type.is_dir() {
                stats.dir_count = stats.dir_count.saturating_add(1);
                stack.push(entry.path());
            } else if file_type.is_file() {
                stats.file_count = stats.file_count.saturating_add(1);
                if let Ok(metadata) = entry.metadata() {
                    stats.total_size_bytes = stats.total_size_bytes.saturating_add(metadata.len());
                }
            }
        }
    }
    stats
}

fn collect_state_path_stats(args: &Args, ctx: &RunContext) -> Vec<PathFileStats> {
    let mut stats = Vec::new();
    stats.push(collect_path_file_stats(
        "work_db",
        &render_path_template(&args.work_db, args, ctx),
    ));
    if let Some(path) = args.repo_bytes_db.as_ref() {
        stats.push(collect_path_file_stats(
            "repo_bytes_db",
            &render_path_template(path, args, ctx),
        ));
    }
    if let Some(path) = args.raw_store_db.as_ref() {
        stats.push(collect_path_file_stats(
            "raw_store_db",
            &render_path_template(path, args, ctx),
        ));
    }
    stats
}

fn parse_key_value_metrics(text: &str) -> BTreeMap<String, String> {
    let mut metrics = BTreeMap::new();
    for line in text.lines() {
        let Some((key, value)) = line.split_once('=') else {
            continue;
        };
        metrics.insert(key.trim().to_string(), value.trim().to_string());
    }
    metrics
}

fn run_db_stats_command(
    db_stats_bin: &Path,
    db_path: &Path,
    run_dir: &Path,
    mode: &str,
) -> DbStatsSummary {
    let output_path = run_dir.join(format!("db-stats-{mode}.txt"));
    let stderr_path = run_dir.join(format!("db-stats-{mode}.stderr.txt"));
    let mut summary = DbStatsSummary {
        mode: mode.to_string(),
        db_path: path_string(db_path),
        output_path: Some(path_string(&output_path)),
        stderr_path: None,
        status: "success".to_string(),
        exit_code: None,
        error: None,
        metrics: BTreeMap::new(),
    };

    if !db_path.exists() {
        summary.status = "skipped".to_string();
        summary.error = Some(format!("db path does not exist: {}", db_path.display()));
        summary.output_path = None;
        return summary;
    }

    let mut command = Command::new(db_stats_bin);
    command.arg("--db").arg(db_path);
    if mode == "exact" {
        command.arg("--exact");
    }
    match command.output() {
        Ok(output) => {
            summary.exit_code = output.status.code();
            if !output.status.success() {
                summary.status = "failed".to_string();
            }
            let stdout_text = String::from_utf8_lossy(&output.stdout).into_owned();
            if let Err(err) = fs::write(&output_path, stdout_text.as_bytes()) {
                summary.status = "failed".to_string();
                summary.error = Some(format!(
                    "write db_stats output failed: {}: {err}",
                    output_path.display()
                ));
            }
            summary.metrics = parse_key_value_metrics(&stdout_text);
            if !output.stderr.is_empty() {
                if fs::write(&stderr_path, &output.stderr).is_ok() {
                    summary.stderr_path = Some(path_string(&stderr_path));
                }
            }
            if !output.status.success() && summary.error.is_none() {
                summary.error = Some(String::from_utf8_lossy(&output.stderr).into_owned());
            }
        }
        Err(err) => {
            summary.status = "spawn_failed".to_string();
            summary.exit_code = None;
            summary.output_path = None;
            summary.error = Some(format!("spawn db_stats failed: {err}"));
        }
    }
    summary
}

fn collect_db_stats(args: &Args, ctx: &RunContext) -> Vec<DbStatsSummary> {
    let work_db = render_path_template(&args.work_db, args, ctx);
    let Some(db_stats_bin) = args
        .db_stats_bin
        .as_ref()
        .cloned()
        .or_else(default_db_stats_bin)
    else {
        return vec![DbStatsSummary {
            mode: "estimate".to_string(),
            db_path: path_string(&work_db),
            output_path: None,
            stderr_path: None,
            status: "skipped".to_string(),
            exit_code: None,
            error: Some(
                "db_stats binary not configured and sibling db_stats was not found".to_string(),
            ),
            metrics: BTreeMap::new(),
        }];
    };

    let mut stats = Vec::new();
    stats.push(run_db_stats_command(
        &db_stats_bin,
        &work_db,
        &ctx.run_dir,
        "estimate",
    ));
    if args
        .db_stats_exact_every
        .is_some_and(|every| ctx.seq % every == 0)
    {
        stats.push(run_db_stats_command(
            &db_stats_bin,
            &work_db,
            &ctx.run_dir,
            "exact",
        ));
    }
    stats
}

fn collect_post_run_metrics(args: &Args, ctx: &RunContext, summary: &mut RunSummary) {
    if let Some(path) = find_named_file(&ctx.run_dir, "stage-timing.json") {
        summary.stage_timing = read_json_value_if_exists(&path);
    }
    let (report_counts, repo_sync_stats) = parse_report_metadata(&ctx.run_dir);
    summary.report_counts = report_counts;
    summary.repo_sync_stats = repo_sync_stats;
    summary.path_stats = collect_state_path_stats(args, ctx);
    summary.db_stats = collect_db_stats(args, ctx);
    summary.artifacts = collect_artifacts(&ctx.run_dir).unwrap_or_default();
    if let Some(path) = args.dead_repo_blacklist.as_ref() {
        let (blacklist, _) = DeadRepoBlacklist::load(path);
        summary.dead_repo_blacklist = Some(DeadRepoBlacklistRunSummary {
            path: path_string(path),
            entries: blacklist.len(),
            blacklisted: blacklist.blacklisted_len(),
        });
    }
}

fn run_daemon(args: &Args) -> Result<(), String> {
    fs::create_dir_all(args.state_root.join("state")).map_err(|e| {
        format!(
            "create daemon state dir failed: {}: {e}",
            args.state_root.join("state").display()
        )
    })?;
    fs::create_dir_all(args.state_root.join("runs")).map_err(|e| {
        format!(
            "create daemon runs dir failed: {}: {e}",
            args.state_root.join("runs").display()
        )
    })?;

    let mut runs_completed = 0u64;
    let mut next_seq = 1u64;
    let mut last_run_id = None;
    let mut health = DeadRepoHealthRuntime::default();
    write_status(
        args,
        DaemonState::Starting,
        runs_completed,
        None,
        last_run_id.clone(),
        &health,
    )?;
    // Probe once at startup so revived repos are removed before the first run.
    maybe_run_dead_repo_health_check(args, &mut health);

    loop {
        if args.max_runs.is_some_and(|max| runs_completed >= max) {
            break;
        }

        write_status(
            args,
            DaemonState::Idle,
            runs_completed,
            None,
            last_run_id.clone(),
            &health,
        )?;
        let ctx = make_run_context(args, next_seq, utc_now());
        write_status(
            args,
            DaemonState::Running,
            runs_completed,
            Some(&ctx),
            last_run_id.clone(),
            &health,
        )?;
        let mut summary = run_child_once(args, &ctx)?;
        write_status(
            args,
            DaemonState::Collecting,
            runs_completed,
            Some(&ctx),
            last_run_id.clone(),
            &health,
        )?;
        collect_post_run_metrics(args, &ctx, &mut summary);
        let removed = apply_retention(&args.state_root.join("runs"), args.retain_runs)?;
        summary.retention_deleted_runs = removed.iter().map(|p| path_string(p)).collect();
        summary.artifacts = collect_artifacts(&ctx.run_dir).unwrap_or_default();
        write_json_pretty(&ctx.run_dir.join("run-summary.json"), &summary)?;
        append_json_line(&summary_jsonl_path(args), &summary)?;
        runs_completed += 1;
        next_seq += 1;
        last_run_id = Some(ctx.run_id);

        if args.max_runs.is_some_and(|max| runs_completed >= max) {
            break;
        }
        // Health probes run only while no child is active (single-writer).
        maybe_run_dead_repo_health_check(args, &mut health);
        write_status(
            args,
            DaemonState::Sleeping,
            runs_completed,
            None,
            last_run_id.clone(),
            &health,
        )?;
        if args.interval_secs > 0 {
            std::thread::sleep(Duration::from_secs(args.interval_secs));
        }
    }

    write_status(
        args,
        DaemonState::Exited,
        runs_completed,
        None,
        last_run_id,
        &health,
    )
}

pub fn main_entry() -> i32 {
    let argv: Vec<String> = std::env::args().collect();
    match parse_args(&argv) {
        Ok(args) => match run_daemon(&args) {
            Ok(()) => 0,
            Err(err) => {
                eprintln!("{err}");
                2
            }
        },
        Err(err) => {
            if argv.iter().any(|a| a == "--help" || a == "-h") {
                println!("{err}");
                return 0;
            }
            eprintln!("{err}");
            2
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn test_args(state_root: PathBuf) -> Args {
        Args {
            work_db: state_root.join("state/work-db"),
            repo_bytes_db: Some(state_root.join("state/repo-bytes.db")),
            state_root,
            rpki_bin: PathBuf::from("/bin/true"),
            interval_secs: 0,
            max_runs: Some(1),
            retain_runs: 10,
            status_json: None,
            summary_jsonl: None,
            raw_store_db: None,
            db_stats_bin: None,
            db_stats_exact_every: None,
            time_bin: None,
            dead_repo_blacklist: None,
            dead_repo_blacklist_fail_threshold: None,
            dead_repo_health_check_interval_secs: 600,
            dead_repo_probe_rsync_bin: PathBuf::from("rsync"),
            child_args: vec!["--version".to_string()],
        }
    }

    #[test]
    fn parse_args_accepts_required_flags_and_child_args() {
        let argv = vec![
            "rpki_daemon".to_string(),
            "--state-root".to_string(),
            "/tmp/daemon".to_string(),
            "--rpki-bin".to_string(),
            "/bin/echo".to_string(),
            "--interval-secs".to_string(),
            "0".to_string(),
            "--max-runs".to_string(),
            "2".to_string(),
            "--retain-runs".to_string(),
            "3".to_string(),
            "--".to_string(),
            "--db".to_string(),
            "{state_root}/state/work-db".to_string(),
            "--report-json".to_string(),
            "{run_out}/report.json".to_string(),
        ];

        let args = parse_args(&argv).expect("parse args");
        assert_eq!(args.state_root, PathBuf::from("/tmp/daemon"));
        assert_eq!(args.rpki_bin, PathBuf::from("/bin/echo"));
        assert_eq!(args.interval_secs, 0);
        assert_eq!(args.max_runs, Some(2));
        assert_eq!(args.retain_runs, 3);
        assert_eq!(args.work_db, PathBuf::from("/tmp/daemon/state/work-db"));
        assert_eq!(
            args.repo_bytes_db,
            Some(PathBuf::from("/tmp/daemon/state/repo-bytes.db"))
        );
        assert_eq!(
            args.child_args,
            vec![
                "--db",
                "{state_root}/state/work-db",
                "--report-json",
                "{run_out}/report.json"
            ]
        );
    }

    #[test]
    fn usage_and_parse_args_cover_optional_flags_and_errors() {
        let help = usage();
        assert!(help.contains("--db-stats-exact-every"));
        assert!(help.contains("{run_seq}"));

        let argv = vec![
            "rpki_daemon".to_string(),
            "--state-root".to_string(),
            "/tmp/daemon".to_string(),
            "--rpki-bin".to_string(),
            "/bin/echo".to_string(),
            "--status-json".to_string(),
            "/tmp/status.json".to_string(),
            "--summary-jsonl".to_string(),
            "/tmp/runs.jsonl".to_string(),
            "--work-db".to_string(),
            "{state_root}/work".to_string(),
            "--repo-bytes-db".to_string(),
            "{state_root}/repo-bytes".to_string(),
            "--raw-store-db".to_string(),
            "{state_root}/raw".to_string(),
            "--db-stats-bin".to_string(),
            "/bin/echo".to_string(),
            "--db-stats-exact-every".to_string(),
            "2".to_string(),
            "--time-bin".to_string(),
            "/usr/bin/time".to_string(),
            "--no-time-wrapper".to_string(),
            "--".to_string(),
            "child".to_string(),
        ];
        let args = parse_args(&argv).expect("optional args");
        assert_eq!(args.status_json, Some(PathBuf::from("/tmp/status.json")));
        assert_eq!(args.summary_jsonl, Some(PathBuf::from("/tmp/runs.jsonl")));
        assert_eq!(args.work_db, PathBuf::from("{state_root}/work"));
        assert_eq!(
            args.repo_bytes_db,
            Some(PathBuf::from("{state_root}/repo-bytes"))
        );
        assert_eq!(args.raw_store_db, Some(PathBuf::from("{state_root}/raw")));
        assert_eq!(args.db_stats_bin, Some(PathBuf::from("/bin/echo")));
        assert_eq!(args.db_stats_exact_every, Some(2));
        assert_eq!(args.time_bin, None);

        for (argv, expected) in [
            (vec!["rpki_daemon", "--help"], "Usage:"),
            (
                vec![
                    "rpki_daemon",
                    "--state-root",
                    "/tmp/x",
                    "--rpki-bin",
                    "/bin/echo",
                    "--max-runs",
                    "0",
                    "--",
                    "child",
                ],
                "--max-runs must be > 0",
            ),
            (
                vec![
                    "rpki_daemon",
                    "--state-root",
                    "/tmp/x",
                    "--rpki-bin",
                    "/bin/echo",
                    "--retain-runs",
                    "0",
                    "--",
                    "child",
                ],
                "--retain-runs must be > 0",
            ),
            (
                vec![
                    "rpki_daemon",
                    "--state-root",
                    "/tmp/x",
                    "--rpki-bin",
                    "/bin/echo",
                    "--db-stats-exact-every",
                    "0",
                    "--",
                    "child",
                ],
                "--db-stats-exact-every must be > 0",
            ),
            (vec!["rpki_daemon", "--unknown"], "unknown argument"),
            (
                vec!["rpki_daemon", "--state-root"],
                "--state-root requires a value",
            ),
            (vec!["rpki_daemon"], "missing -- before child rpki args"),
            (
                vec![
                    "rpki_daemon",
                    "--state-root",
                    "/tmp/x",
                    "--rpki-bin",
                    "/bin/echo",
                    "--",
                ],
                "child rpki args are required",
            ),
        ] {
            let owned: Vec<String> = argv.into_iter().map(str::to_string).collect();
            let err = parse_args(&owned).expect_err("parse should fail");
            assert!(err.contains(expected), "{err}");
        }
    }

    #[test]
    fn render_child_args_replaces_placeholders() {
        let args = Args {
            state_root: PathBuf::from("/tmp/root"),
            rpki_bin: PathBuf::from("/bin/echo"),
            interval_secs: 0,
            max_runs: Some(1),
            retain_runs: 10,
            status_json: None,
            summary_jsonl: None,
            work_db: PathBuf::from("/tmp/root/state/work-db"),
            repo_bytes_db: Some(PathBuf::from("/tmp/root/state/repo-bytes.db")),
            raw_store_db: None,
            db_stats_bin: None,
            db_stats_exact_every: None,
            time_bin: None,
            dead_repo_blacklist: None,
            dead_repo_blacklist_fail_threshold: None,
            dead_repo_health_check_interval_secs: 600,
            dead_repo_probe_rsync_bin: PathBuf::from("rsync"),
            child_args: vec![
                "{state_root}/state/work-db".to_string(),
                "{run_out}/result.ccr".to_string(),
                "{run_id}".to_string(),
                "{run_seq}".to_string(),
            ],
        };
        let ctx = RunContext {
            seq: 7,
            run_id: "000007-20260428T090000Z".to_string(),
            run_dir: PathBuf::from("/tmp/root/runs/000007-20260428T090000Z"),
        };

        assert_eq!(
            render_child_args(&args.child_args, &args, &ctx),
            vec![
                "/tmp/root/state/work-db",
                "/tmp/root/runs/000007-20260428T090000Z/result.ccr",
                "000007-20260428T090000Z",
                "7",
            ]
        );
    }

    #[test]
    fn path_json_and_report_helpers_cover_fallbacks_and_nested_stats() {
        let td = tempfile::tempdir().expect("tempdir");
        let state_root = td.path().join("daemon");
        let mut args = test_args(state_root.clone());
        args.work_db = PathBuf::from("{state_root}/state/work-db");
        args.repo_bytes_db = Some(PathBuf::from("{state_root}/state/repo-bytes.db"));
        args.raw_store_db = Some(PathBuf::from("{state_root}/state/raw-store.db"));

        let now = time::Date::from_calendar_date(2026, time::Month::April, 28)
            .expect("date")
            .with_hms(9, 0, 0)
            .expect("time")
            .assume_utc();
        let ctx = make_run_context(&args, 42, now);
        assert_eq!(ctx.run_id, "000042-20260428T090000Z");

        let rendered = render_path_template(Path::new("{run_out}/{run_id}/{run_seq}"), &args, &ctx);
        assert!(rendered.ends_with("000042-20260428T090000Z/000042-20260428T090000Z/42"));

        let nested_json = td.path().join("nested/out/status.json");
        write_json_pretty(&nested_json, &serde_json::json!({"ok": true})).expect("write json");
        append_json_line(
            &td.path().join("nested/out/runs.jsonl"),
            &serde_json::json!({"n": 1}),
        )
        .expect("append jsonl");

        fs::create_dir_all(ctx.run_dir.join("subdir")).expect("subdir");
        fs::write(ctx.run_dir.join("a.txt"), "aaa").expect("file");
        fs::write(ctx.run_dir.join("subdir/ignored.txt"), "bbb").expect("nested file");
        let artifacts = collect_artifacts(&ctx.run_dir).expect("artifacts");
        assert_eq!(artifacts.len(), 1);
        assert!(artifacts[0].path.ends_with("a.txt"));

        fs::create_dir_all(state_root.join("state/work-db/nested")).expect("work db");
        fs::write(state_root.join("state/work-db/file.sst"), "abc").expect("sst");
        fs::write(state_root.join("state/work-db/nested/inner.sst"), "def").expect("inner");
        fs::write(state_root.join("state/raw-store.db"), "raw").expect("raw file");
        let file_stats =
            collect_path_file_stats("raw_store_db", &state_root.join("state/raw-store.db"));
        assert!(file_stats.exists);
        assert!(!file_stats.is_dir);
        assert_eq!(file_stats.file_count, 1);
        let missing_stats = collect_path_file_stats("missing", &state_root.join("missing"));
        assert!(!missing_stats.exists);
        let state_stats = collect_state_path_stats(&args, &ctx);
        assert!(
            state_stats
                .iter()
                .any(|s| s.label == "raw_store_db" && s.exists)
        );
        assert!(
            state_stats
                .iter()
                .any(|s| s.label == "work_db" && s.file_count == 2)
        );

        let report_path = td.path().join("report.json");
        fs::write(
            &report_path,
            r#"{"repo_sync_stats": { "nested": {"text": "a\"b"} }, "after": 1}"#,
        )
        .expect("report");
        assert_eq!(
            extract_json_object_field(&report_path, "repo_sync_stats").expect("repo stats")["nested"]
                ["text"],
            "a\"b"
        );
        fs::write(&report_path, r#"{"repo_sync_stats": []}"#).expect("report");
        assert!(extract_json_object_field(&report_path, "repo_sync_stats").is_none());
        assert!(extract_json_object_field(&report_path, "missing").is_none());

        let counts = parse_report_counts_fallback(&serde_json::json!({
            "vrps": [{}, {}],
            "aspas": [{}],
            "publication_points": [{"warnings": [{}, {}]}, {"warnings": [{}]}],
            "tree": {"instances_processed": 2, "instances_failed": 1, "warnings": [{}]}
        }));
        assert_eq!(counts.vrps, 2);
        assert_eq!(counts.aspas, 1);
        assert_eq!(counts.publication_points, 2);
        assert_eq!(counts.warnings, 4);
        assert_eq!(counts.tree_instances_processed, Some(2));
        assert_eq!(counts.tree_instances_failed, Some(1));
    }

    #[test]
    fn retention_removes_oldest_run_directories() {
        let td = tempfile::tempdir().expect("tempdir");
        let runs = td.path().join("runs");
        fs::create_dir_all(&runs).expect("runs dir");
        for name in [
            "000001-20260428T000001Z",
            "000002-20260428T000002Z",
            "000003-20260428T000003Z",
        ] {
            fs::create_dir_all(runs.join(name)).expect("run dir");
        }

        let removed = apply_retention(&runs, 2).expect("retention");
        assert_eq!(removed.len(), 1);
        assert!(!runs.join("000001-20260428T000001Z").exists());
        assert!(runs.join("000002-20260428T000002Z").exists());
        assert!(runs.join("000003-20260428T000003Z").exists());
    }

    #[test]
    fn retention_empty_root_and_parse_metrics_error_paths_are_reported() {
        let td = tempfile::tempdir().expect("tempdir");
        let missing_runs = td.path().join("missing-runs");
        assert!(
            apply_retention(&missing_runs, 2)
                .expect("empty retention")
                .is_empty()
        );

        let disabled = collect_process_metrics(false, &td.path().join("missing-time.txt"));
        assert!(!disabled.time_wrapper_used);
        assert!(disabled.time_output_path.is_none());

        let missing = collect_process_metrics(true, &td.path().join("missing-time.txt"));
        assert!(missing.time_wrapper_used);
        assert!(
            missing
                .parse_error
                .expect("parse error")
                .contains("read process time output failed")
        );
    }

    #[test]
    fn process_metrics_parses_gnu_time_elapsed_line() {
        let td = tempfile::tempdir().expect("tempdir");
        let path = td.path().join("time.txt");
        fs::write(
            &path,
            "User time (seconds): 1.25\nSystem time (seconds): 0.50\nPercent of CPU this job got: 175%\nElapsed (wall clock) time (h:mm:ss or m:ss): 0:01.00\nMaximum resident set size (kbytes): 12345\nExit status: 0\n",
        )
        .expect("write time");

        let metrics = collect_process_metrics(true, &path);
        assert_eq!(metrics.user_seconds, Some(1.25));
        assert_eq!(metrics.system_seconds, Some(0.50));
        assert_eq!(metrics.cpu_percent, Some(175.0));
        assert_eq!(metrics.elapsed_raw.as_deref(), Some("0:01.00"));
        assert_eq!(metrics.max_rss_kb, Some(12345));
        assert_eq!(metrics.exit_status_from_time, Some(0));
    }

    #[test]
    #[cfg(unix)]
    fn child_and_db_stats_error_paths_are_reported() {
        use std::os::unix::fs::PermissionsExt;

        let td = tempfile::tempdir().expect("tempdir");
        let run_dir = td.path().join("run");
        fs::create_dir_all(&run_dir).expect("run dir");
        let missing_db = td.path().join("missing-db");
        let skipped =
            run_db_stats_command(Path::new("/bin/echo"), &missing_db, &run_dir, "estimate");
        assert_eq!(skipped.status, "skipped");
        assert!(skipped.output_path.is_none());

        let db_path = td.path().join("db");
        fs::create_dir_all(&db_path).expect("db");
        let failing_bin = td.path().join("failing_db_stats.sh");
        fs::write(
            &failing_bin,
            "#!/bin/sh\necho partial=1\necho db-stats failed >&2\nexit 7\n",
        )
        .expect("script");
        let mut permissions = fs::metadata(&failing_bin).expect("metadata").permissions();
        permissions.set_mode(0o755);
        fs::set_permissions(&failing_bin, permissions).expect("chmod");
        let failed = run_db_stats_command(&failing_bin, &db_path, &run_dir, "exact");
        assert_eq!(failed.status, "failed");
        assert_eq!(failed.exit_code, Some(7));
        assert_eq!(failed.metrics.get("partial").map(String::as_str), Some("1"));
        assert!(failed.stderr_path.is_some());
        assert!(failed.error.expect("stderr").contains("db-stats failed"));

        let spawn_failed = run_db_stats_command(
            Path::new("/definitely/not/db_stats"),
            &db_path,
            &run_dir,
            "estimate",
        );
        assert_eq!(spawn_failed.status, "spawn_failed");
        assert!(
            spawn_failed
                .error
                .expect("spawn err")
                .contains("spawn db_stats failed")
        );

        let metrics = parse_key_value_metrics("alpha=1\nnot-a-pair\nbeta = two\n");
        assert_eq!(metrics.get("alpha").map(String::as_str), Some("1"));
        assert_eq!(metrics.get("beta").map(String::as_str), Some("two"));

        let mut args = test_args(td.path().join("daemon"));
        args.rpki_bin = PathBuf::from("/bin/sh");
        args.time_bin = Some(PathBuf::from("/usr/bin/time"));
        args.child_args = vec![
            "-c".to_string(),
            "echo child-out; echo child-err >&2; exit 7".to_string(),
        ];
        let ctx = RunContext {
            seq: 1,
            run_id: "000001-20260428T000000Z".to_string(),
            run_dir: args.state_root.join("runs/000001-20260428T000000Z"),
        };
        let summary = run_child_once(&args, &ctx).expect("run failing child");
        assert_eq!(summary.status, RunStatus::Failed);
        assert_eq!(summary.exit_code, Some(7));
        let process_metrics = summary.process_metrics.expect("process metrics");
        assert!(process_metrics.time_wrapper_used);
        assert_eq!(process_metrics.exit_status_from_time, Some(7));

        let mut spawn_args = test_args(td.path().join("spawn"));
        spawn_args.rpki_bin = PathBuf::from("/definitely/not/rpki");
        spawn_args.child_args = vec!["--help".to_string()];
        let spawn_ctx = RunContext {
            seq: 1,
            run_id: "000001-20260428T000001Z".to_string(),
            run_dir: spawn_args.state_root.join("runs/000001-20260428T000001Z"),
        };
        let spawn_summary = run_child_once(&spawn_args, &spawn_ctx).expect("spawn summary");
        assert_eq!(spawn_summary.status, RunStatus::SpawnFailed);
        assert!(
            spawn_summary
                .error
                .expect("spawn error")
                .contains("spawn child failed")
        );
    }

    #[test]
    fn report_metadata_prefers_stdout_summary_and_extracts_repo_sync_stats() {
        let td = tempfile::tempdir().expect("tempdir");
        fs::write(
            td.path().join("stdout.log"),
            "RPKI stage2 serial run summary\npublication_points_processed=7 publication_points_failed=1\nrrdp_repos_unique=3\nvrps=11\naspas=2\naudit_publication_points=7\nwarnings_total=5\n",
        )
        .expect("stdout");
        fs::write(
            td.path().join("report.json"),
            "{\"large\":[{\"ignored\":\"{}\"}],\"repo_sync_stats\":{\"by_phase\":{\"rrdp_ok\":{\"count\":7}},\"by_terminal_state\":{},\"publication_points_total\":7}}",
        )
        .expect("report");

        let (counts, repo_sync_stats) = parse_report_metadata(td.path());
        let counts = counts.expect("counts");
        assert_eq!(counts.vrps, 11);
        assert_eq!(counts.aspas, 2);
        assert_eq!(counts.publication_points, 7);
        assert_eq!(counts.rrdp_repos_unique, Some(3));
        assert_eq!(counts.tree_instances_processed, Some(7));
        assert_eq!(counts.tree_instances_failed, Some(1));
        assert_eq!(counts.warnings, 5);
        assert_eq!(
            repo_sync_stats.expect("repo sync")["by_phase"]["rrdp_ok"]["count"].as_u64(),
            Some(7)
        );
    }

    #[test]
    fn daemon_exits_immediately_when_max_runs_already_reached() {
        let td = tempfile::tempdir().expect("tempdir");
        let mut args = test_args(td.path().join("daemon"));
        args.max_runs = Some(0);

        run_daemon(&args).expect("run daemon");

        let status_text =
            fs::read_to_string(args.state_root.join("daemon-status.json")).expect("status");
        assert!(status_text.contains("\"state\": \"exited\""));
        assert!(status_text.contains("\"runsCompleted\": 0"));
        assert!(args.state_root.join("runs").exists());
    }

    #[test]
    fn daemon_runs_fake_child_twice_and_writes_summaries() {
        let td = tempfile::tempdir().expect("tempdir");
        let args = Args {
            state_root: td.path().join("daemon"),
            rpki_bin: PathBuf::from("/bin/sh"),
            interval_secs: 0,
            max_runs: Some(2),
            retain_runs: 10,
            status_json: None,
            summary_jsonl: None,
            work_db: td.path().join("daemon/state/work-db"),
            repo_bytes_db: Some(td.path().join("daemon/state/repo-bytes.db")),
            raw_store_db: None,
            db_stats_bin: None,
            db_stats_exact_every: None,
            time_bin: None,
            dead_repo_blacklist: None,
            dead_repo_blacklist_fail_threshold: None,
            dead_repo_health_check_interval_secs: 600,
            dead_repo_probe_rsync_bin: PathBuf::from("rsync"),
            child_args: vec![
                "-c".to_string(),
                "echo stdout-{run_seq}; echo stderr-{run_seq} >&2; echo marker > {run_out}/marker.txt".to_string(),
            ],
        };

        run_daemon(&args).expect("run daemon");

        let status_text =
            fs::read_to_string(args.state_root.join("daemon-status.json")).expect("status");
        assert!(status_text.contains("\"state\": \"exited\""));
        assert!(status_text.contains("\"runsCompleted\": 2"));

        let jsonl =
            fs::read_to_string(args.state_root.join("daemon-runs.jsonl")).expect("summary jsonl");
        assert_eq!(jsonl.lines().count(), 2);

        let run_dirs = fs::read_dir(args.state_root.join("runs"))
            .expect("runs dir")
            .collect::<Result<Vec<_>, _>>()
            .expect("entries");
        assert_eq!(run_dirs.len(), 2);
        for entry in run_dirs {
            assert!(entry.path().join("run-summary.json").exists());
            assert!(entry.path().join("marker.txt").exists());
        }
    }

    #[test]
    fn daemon_sleep_and_retention_deletion_are_recorded() {
        let td = tempfile::tempdir().expect("tempdir");
        let mut args = test_args(td.path().join("daemon"));
        args.rpki_bin = PathBuf::from("/bin/sh");
        args.interval_secs = 1;
        args.max_runs = Some(2);
        args.retain_runs = 1;
        args.child_args = vec![
            "-c".to_string(),
            "echo run-{run_seq}; printf marker > {run_out}/marker.txt".to_string(),
        ];

        run_daemon(&args).expect("run daemon");

        let jsonl = fs::read_to_string(args.state_root.join("daemon-runs.jsonl")).expect("jsonl");
        assert_eq!(jsonl.lines().count(), 2);
        let last: serde_json::Value =
            serde_json::from_str(jsonl.lines().last().expect("last line")).expect("summary");
        assert_eq!(
            last["retentionDeletedRuns"]
                .as_array()
                .expect("deleted")
                .len(),
            1
        );
        let run_dirs = fs::read_dir(args.state_root.join("runs"))
            .expect("runs")
            .collect::<Result<Vec<_>, _>>()
            .expect("entries");
        assert_eq!(run_dirs.len(), 1);
    }

    #[test]
    #[cfg(unix)]
    fn daemon_collects_stage_report_db_and_file_metrics() {
        use std::os::unix::fs::PermissionsExt;

        let td = tempfile::tempdir().expect("tempdir");
        let db_stats_bin = td.path().join("fake_db_stats.sh");
        fs::write(
            &db_stats_bin,
            "#!/bin/sh\nif [ \"$3\" = \"--exact\" ]; then echo mode=exact; else echo mode=estimate; fi\necho total=42\necho db.files.total_size_bytes=123\n",
        )
        .expect("fake db_stats");
        let mut permissions = fs::metadata(&db_stats_bin).expect("metadata").permissions();
        permissions.set_mode(0o755);
        fs::set_permissions(&db_stats_bin, permissions).expect("chmod");

        let args = Args {
            state_root: td.path().join("daemon"),
            rpki_bin: PathBuf::from("/bin/sh"),
            interval_secs: 0,
            max_runs: Some(1),
            retain_runs: 10,
            status_json: None,
            summary_jsonl: None,
            work_db: PathBuf::from("{state_root}/state/work-db"),
            repo_bytes_db: Some(PathBuf::from("{state_root}/state/repo-bytes.db")),
            raw_store_db: None,
            db_stats_bin: Some(db_stats_bin),
            db_stats_exact_every: Some(1),
            time_bin: None,
            dead_repo_blacklist: None,
            dead_repo_blacklist_fail_threshold: None,
            dead_repo_health_check_interval_secs: 600,
            dead_repo_probe_rsync_bin: PathBuf::from("rsync"),
            child_args: vec![
                "-c".to_string(),
                "mkdir -p {state_root}/state/work-db {state_root}/state/repo-bytes.db; \
                 printf x > {state_root}/state/work-db/000001.sst; \
                 printf '{\"validation_ms\":7,\"download_event_count\":2}' > {run_out}/stage-timing.json; \
                 printf '{\"tree\":{\"instances_processed\":3,\"instances_failed\":1,\"warnings\":[{}]},\"publication_points\":[{},{}],\"vrps\":[{},{}],\"aspas\":[{}],\"repo_sync_stats\":{\"by_phase\":{\"snapshot\":{\"count\":1}}}}' > {run_out}/report.json"
                    .to_string(),
            ],
        };

        run_daemon(&args).expect("run daemon");

        let run_summary = find_named_file(&args.state_root.join("runs"), "run-summary.json")
            .expect("run summary");
        let summary: serde_json::Value =
            serde_json::from_slice(&fs::read(run_summary).expect("read run summary"))
                .expect("parse run summary");

        assert_eq!(summary["stageTiming"]["validation_ms"].as_u64(), Some(7));
        assert_eq!(summary["reportCounts"]["vrps"].as_u64(), Some(2));
        assert_eq!(summary["reportCounts"]["aspas"].as_u64(), Some(1));
        assert_eq!(
            summary["reportCounts"]["publicationPoints"].as_u64(),
            Some(2)
        );
        assert_eq!(
            summary["repoSyncStats"]["by_phase"]["snapshot"]["count"].as_u64(),
            Some(1)
        );
        assert_eq!(summary["dbStats"].as_array().expect("db stats").len(), 2);
        assert!(
            summary["pathStats"]
                .as_array()
                .expect("path stats")
                .iter()
                .any(|item| item["label"] == "work_db" && item["exists"] == true)
        );
    }

    #[test]
    fn dead_repo_blacklist_flag_is_injected_into_child_args() {
        let argv = vec![
            "rpki_daemon".to_string(),
            "--state-root".to_string(),
            "/tmp/daemon".to_string(),
            "--rpki-bin".to_string(),
            "/bin/echo".to_string(),
            "--dead-repo-blacklist".to_string(),
            "/tmp/daemon/state/dead-repo-blacklist.json".to_string(),
            "--dead-repo-blacklist-fail-threshold".to_string(),
            "2".to_string(),
            "--".to_string(),
            "--db".to_string(),
            "{state_root}/state/work-db".to_string(),
        ];
        let args = parse_args(&argv).expect("parse");
        assert_eq!(
            args.child_args,
            vec![
                "--db".to_string(),
                "{state_root}/state/work-db".to_string(),
                "--dead-repo-blacklist".to_string(),
                "/tmp/daemon/state/dead-repo-blacklist.json".to_string(),
                "--dead-repo-blacklist-fail-threshold".to_string(),
                "2".to_string(),
            ]
        );
    }

    #[test]
    fn dead_repo_blacklist_injection_respects_existing_child_flags() {
        let argv = vec![
            "rpki_daemon".to_string(),
            "--state-root".to_string(),
            "/tmp/daemon".to_string(),
            "--rpki-bin".to_string(),
            "/bin/echo".to_string(),
            "--dead-repo-blacklist".to_string(),
            "/tmp/daemon/state/dead-repo-blacklist.json".to_string(),
            "--".to_string(),
            "--dead-repo-blacklist".to_string(),
            "/custom/path.json".to_string(),
        ];
        let args = parse_args(&argv).expect("parse");
        assert_eq!(
            args.child_args,
            vec![
                "--dead-repo-blacklist".to_string(),
                "/custom/path.json".to_string(),
            ]
        );
    }

    #[test]
    fn dead_repo_threshold_without_blacklist_is_rejected() {
        let argv = vec![
            "rpki_daemon".to_string(),
            "--state-root".to_string(),
            "/tmp/daemon".to_string(),
            "--rpki-bin".to_string(),
            "/bin/echo".to_string(),
            "--dead-repo-blacklist-fail-threshold".to_string(),
            "2".to_string(),
            "--".to_string(),
            "--version".to_string(),
        ];
        let err = parse_args(&argv).expect_err("parse should fail");
        assert!(err.contains("requires --dead-repo-blacklist"), "{err}");
    }

    #[test]
    fn health_check_keeps_dead_entry_and_removes_revived_entry() {
        use std::io::{Read as _, Write as _};
        use std::net::TcpListener;

        // Revived RRDP host: one-shot HTTP server answering 200.
        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
        let port = listener.local_addr().expect("local addr").port();
        std::thread::spawn(move || {
            if let Ok((mut stream, _)) = listener.accept() {
                let mut buf = [0u8; 1024];
                let _ = stream.read(&mut buf);
                let body = b"<notification/>";
                let _ = stream.write_all(
                    format!(
                        "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
                        body.len()
                    )
                    .as_bytes(),
                );
                let _ = stream.write_all(body);
            }
        });

        let td = tempfile::tempdir().expect("tempdir");
        let blacklist_path = td.path().join("dead-repo-blacklist.json");
        let revived_uri = format!("http://127.0.0.1:{port}/notification.xml");
        let dead_uri = "http://127.0.0.1:1/notification.xml".to_string();
        let mut blacklist = DeadRepoBlacklist::new();
        for uri in [&revived_uri, &dead_uri] {
            blacklist.record_transport_failure(RepoTransportMode::Rrdp, uri, 100, 1, 256);
        }
        blacklist
            .store_atomic(&blacklist_path, 100)
            .expect("store blacklist");

        let mut args = test_args(td.path().join("daemon"));
        args.dead_repo_blacklist = Some(blacklist_path.clone());
        args.dead_repo_health_check_interval_secs = 0;
        let mut health = DeadRepoHealthRuntime::default();
        maybe_run_dead_repo_health_check(&args, &mut health);

        let (after, warning) = DeadRepoBlacklist::load(&blacklist_path);
        assert!(warning.is_none());
        assert!(!after.is_blacklisted(RepoTransportMode::Rrdp, &revived_uri));
        assert!(after.is_blacklisted(RepoTransportMode::Rrdp, &dead_uri));
        assert!(health.last_health_check_at.is_some());

        // Status JSON exposes the blacklist section.
        let status = dead_repo_blacklist_status(&args, &health).expect("status");
        assert_eq!(status.entries, 1);
        assert_eq!(status.blacklisted, 1);
        assert!(status.last_health_check_at_rfc3339_utc.is_some());
    }
}
