use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{Read, Seek, SeekFrom, Write};
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::process::Command;
use std::sync::{Arc, Mutex};

use rpki::blob_store::ExternalRepoBytesDb;
use rpki::query::report_stream::{ObjectFilter, ObjectScope};
use rpki::query::vrp::VrpLookup;
use rpki::query_db::{
    ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, ObjectUriIndexRecord, QueryDb,
    QueryDbError, RepoSortOrder, ValidationExplainRecord,
};
use serde::Serialize;
use serde_json::{Value, json};

const EXPORT_PAGE_LIMIT: usize = 1000;
const EXPORT_OBJECT_SET_MAX: usize = 10_000;
type ExportJobStore = Arc<Mutex<BTreeMap<String, ExportJobRecord>>>;

#[derive(Clone, Debug, PartialEq, Eq)]
struct Args {
    query_db: PathBuf,
    repo_bytes_db: Option<PathBuf>,
    export_root: PathBuf,
    listen: String,
    watch_run_root: Option<PathBuf>,
    watch_interval_secs: u64,
    projection_entry_limit: usize,
    watch_min_run_seq: Option<u64>,
    watch_backfill: bool,
    indexer_bin: Option<PathBuf>,
    retain_indexed_runs: usize,
}

fn usage() -> &'static str {
    "Usage: rpki_query_service --query-db <path> [--repo-bytes-db <path>] [--export-root <path>] [--listen <addr:port>] [--watch-run-root <path>] [--watch-interval-secs <n>] [--watch-min-run-seq <n>] [--watch-backfill] [--projection-entry-limit <n>] [--indexer-bin <path>] [--retain-indexed-runs <n>]"
}

fn parse_args(argv: &[String]) -> Result<Args, String> {
    let mut query_db = None;
    let mut repo_bytes_db = None;
    let mut export_root = None;
    let mut listen = "127.0.0.1:9560".to_string();
    let mut watch_run_root = None;
    let mut watch_interval_secs = 10u64;
    let mut projection_entry_limit = 50usize;
    let mut watch_min_run_seq = None;
    let mut watch_backfill = false;
    let mut indexer_bin = None;
    let mut retain_indexed_runs = 10usize;
    let mut index = 1usize;
    while index < argv.len() {
        match argv[index].as_str() {
            "--query-db" => {
                index += 1;
                query_db = Some(PathBuf::from(value_at(argv, index, "--query-db")?));
            }
            "--repo-bytes-db" => {
                index += 1;
                repo_bytes_db = Some(PathBuf::from(value_at(argv, index, "--repo-bytes-db")?));
            }
            "--export-root" => {
                index += 1;
                export_root = Some(PathBuf::from(value_at(argv, index, "--export-root")?));
            }
            "--listen" => {
                index += 1;
                listen = value_at(argv, index, "--listen")?.to_string();
            }
            "--watch-run-root" => {
                index += 1;
                watch_run_root = Some(PathBuf::from(value_at(argv, index, "--watch-run-root")?));
            }
            "--watch-interval-secs" => {
                index += 1;
                let raw = value_at(argv, index, "--watch-interval-secs")?;
                watch_interval_secs = raw
                    .parse::<u64>()
                    .map_err(|_| format!("invalid --watch-interval-secs: {raw}"))?;
            }
            "--projection-entry-limit" => {
                index += 1;
                let raw = value_at(argv, index, "--projection-entry-limit")?;
                projection_entry_limit = raw
                    .parse::<usize>()
                    .map_err(|_| format!("invalid --projection-entry-limit: {raw}"))?;
            }
            "--watch-min-run-seq" => {
                index += 1;
                let raw = value_at(argv, index, "--watch-min-run-seq")?;
                watch_min_run_seq = Some(
                    raw.parse::<u64>()
                        .map_err(|_| format!("invalid --watch-min-run-seq: {raw}"))?,
                );
            }
            "--indexer-bin" => {
                index += 1;
                indexer_bin = Some(PathBuf::from(value_at(argv, index, "--indexer-bin")?));
            }
            "--watch-backfill" => {
                watch_backfill = true;
            }
            "--retain-indexed-runs" => {
                index += 1;
                let raw = value_at(argv, index, "--retain-indexed-runs")?;
                retain_indexed_runs = raw
                    .parse::<usize>()
                    .map_err(|_| format!("invalid --retain-indexed-runs: {raw}"))?;
            }
            "-h" | "--help" => return Err(usage().to_string()),
            other => return Err(format!("unknown argument: {other}\n{}", usage())),
        }
        index += 1;
    }
    let query_db = query_db.ok_or_else(|| format!("--query-db is required\n{}", usage()))?;
    let default_export_root = query_db.with_extension("exports");
    Ok(Args {
        query_db,
        repo_bytes_db,
        export_root: export_root.unwrap_or(default_export_root),
        listen,
        watch_run_root,
        watch_interval_secs,
        projection_entry_limit,
        watch_min_run_seq,
        watch_backfill,
        indexer_bin,
        retain_indexed_runs,
    })
}

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 main() {
    if let Err(err) = real_main() {
        eprintln!("{err}");
        std::process::exit(1);
    }
}

fn real_main() -> Result<(), String> {
    let args = parse_args(&std::env::args().collect::<Vec<_>>())?;
    let db = Arc::new(open_query_db_for_service(&args).map_err(|e| e.to_string())?);
    let export_jobs = Arc::new(Mutex::new(BTreeMap::new()));
    let repo_bytes = match args.repo_bytes_db.as_ref() {
        Some(path) => {
            let opened = if args.watch_run_root.is_some() {
                // Watch mode tracks a live soak that keeps writing new object
                // bytes. A plain read-only open is frozen at open time (new
                // manifests/CRLs/objects become unparsable until restart), so
                // use a secondary instance and catch up in the watcher loop.
                let secondary_path = args.query_db.with_extension("repo-bytes.secondary");
                ExternalRepoBytesDb::open_as_secondary(path, &secondary_path)
                    .map_err(|e| e.to_string())?
            } else {
                ExternalRepoBytesDb::open_read_only(path).map_err(|e| e.to_string())?
            };
            Some(Arc::new(opened))
        }
        None => None,
    };
    if let Some(run_root) = args.watch_run_root.clone() {
        let effective_watch_min_run_seq = resolve_effective_watch_min_run_seq(
            &run_root,
            args.watch_min_run_seq,
            args.watch_backfill,
        )?;
        spawn_indexer_watcher(
            Arc::clone(&db),
            repo_bytes.clone(),
            args.query_db.clone(),
            resolve_indexer_bin(args.indexer_bin.clone())?,
            run_root,
            args.projection_entry_limit,
            args.watch_interval_secs,
            effective_watch_min_run_seq,
            args.retain_indexed_runs,
        );
    }
    let listener = TcpListener::bind(&args.listen)
        .map_err(|e| format!("bind failed: {}: {e}", args.listen))?;
    eprintln!("rpki_query_service listening on {}", args.listen);
    for stream in listener.incoming() {
        match stream {
            Ok(stream) => {
                let db = Arc::clone(&db);
                let repo_bytes = repo_bytes.clone();
                let export_jobs = Arc::clone(&export_jobs);
                let export_root = args.export_root.clone();
                std::thread::spawn(move || {
                    let _ = handle_client(stream, db, repo_bytes, export_jobs, export_root);
                });
            }
            Err(err) => eprintln!("accept failed: {err}"),
        }
    }
    Ok(())
}

fn open_query_db_for_service(args: &Args) -> Result<QueryDb, QueryDbError> {
    if args.watch_run_root.is_some() {
        drop(QueryDb::open(&args.query_db)?);
        let secondary_path = args.query_db.with_extension("secondary");
        if let Some(parent) = secondary_path.parent() {
            fs::create_dir_all(parent)?;
        }
        QueryDb::open_secondary(&args.query_db, &secondary_path)
    } else {
        QueryDb::open(&args.query_db)
    }
}

fn resolve_indexer_bin(configured: Option<PathBuf>) -> Result<PathBuf, String> {
    if let Some(path) = configured {
        return Ok(path);
    }
    let current_exe = std::env::current_exe().map_err(|e| e.to_string())?;
    let Some(parent) = current_exe.parent() else {
        return Err("cannot resolve current executable directory".to_string());
    };
    Ok(parent.join("rpki_query_indexer"))
}

fn resolve_effective_watch_min_run_seq(
    run_root: &std::path::Path,
    configured_min_run_seq: Option<u64>,
    watch_backfill: bool,
) -> Result<Option<u64>, String> {
    if configured_min_run_seq.is_some() || watch_backfill {
        return Ok(configured_min_run_seq);
    }
    latest_completed_run_seq(run_root).map(|seq| Some(seq.saturating_add(1)))
}

fn spawn_indexer_watcher(
    db: Arc<QueryDb>,
    repo_bytes: Option<Arc<ExternalRepoBytesDb>>,
    query_db_path: PathBuf,
    indexer_bin: PathBuf,
    run_root: PathBuf,
    projection_entry_limit: usize,
    interval_secs: u64,
    min_run_seq: Option<u64>,
    retain_indexed_runs: usize,
) {
    std::thread::spawn(move || {
        loop {
            match discover_next_unindexed_run(&db, &run_root, min_run_seq) {
                Ok(Some(run_dir)) => {
                    match run_indexer_child(
                        &indexer_bin,
                        &query_db_path,
                        &run_dir,
                        projection_entry_limit,
                        retain_indexed_runs,
                    ) {
                        Ok(message) => {
                            let _ = db.try_catch_up_with_primary();
                            if let Some(repo_bytes) = repo_bytes.as_ref() {
                                let _ = repo_bytes.try_catch_up_with_primary();
                            }
                            eprintln!("query watcher indexed {}: {message}", run_dir.display());
                        }
                        Err(err) => eprintln!(
                            "query watcher index failed for {}: {err}",
                            run_dir.display()
                        ),
                    }
                }
                Ok(None) => {
                    let _ = db.try_catch_up_with_primary();
                    if let Some(repo_bytes) = repo_bytes.as_ref() {
                        let _ = repo_bytes.try_catch_up_with_primary();
                    }
                }
                Err(err) => eprintln!("query watcher index failed: {err}"),
            }
            std::thread::sleep(std::time::Duration::from_secs(interval_secs.max(1)));
        }
    });
}

fn latest_completed_run_seq(run_root: &std::path::Path) -> Result<u64, String> {
    let runs_root = if run_root.join("runs").is_dir() {
        run_root.join("runs")
    } else {
        run_root.to_path_buf()
    };
    let mut latest = 0u64;
    if !runs_root.exists() {
        return Ok(latest);
    }
    for entry in fs::read_dir(&runs_root).map_err(|e| e.to_string())? {
        let entry = entry.map_err(|e| e.to_string())?;
        let path = entry.path();
        if !path.is_dir() || !path.join("report.json").exists() {
            continue;
        }
        let Some(seq) = run_seq_from_path(&path) else {
            continue;
        };
        if !run_status_is_success_or_unknown(&path)? {
            continue;
        }
        latest = latest.max(seq);
    }
    Ok(latest)
}

fn discover_next_unindexed_run(
    db: &QueryDb,
    run_root: &std::path::Path,
    min_run_seq: Option<u64>,
) -> Result<Option<PathBuf>, String> {
    db.try_catch_up_with_primary().map_err(|e| e.to_string())?;
    let runs_root = if run_root.join("runs").is_dir() {
        run_root.join("runs")
    } else {
        run_root.to_path_buf()
    };
    let mut candidates = Vec::new();
    let latest_ready_seq = match db.latest_ready_run().map_err(|e| e.to_string())? {
        Some(run_id) => db
            .get_run(&run_id)
            .map_err(|e| e.to_string())?
            .and_then(|run| run.run_seq),
        None => None,
    };
    let min_candidate_seq = min_run_seq.unwrap_or(0).max(
        latest_ready_seq
            .map(|seq| seq.saturating_add(1))
            .unwrap_or(0),
    );
    for entry in fs::read_dir(&runs_root).map_err(|e| e.to_string())? {
        let entry = entry.map_err(|e| e.to_string())?;
        let path = entry.path();
        if !path.is_dir() || !path.join("report.json").exists() {
            continue;
        }
        let Some(seq) = run_seq_from_path(&path) else {
            continue;
        };
        if seq < min_candidate_seq {
            continue;
        }
        let run_id = path
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or_default()
            .to_string();
        if db.get_run(&run_id).map_err(|e| e.to_string())?.is_some() {
            continue;
        }
        if !run_status_is_success_or_unknown(&path)? {
            continue;
        }
        candidates.push((seq, path));
    }
    candidates.sort_by_key(|(seq, _)| *seq);
    Ok(candidates.into_iter().map(|(_, path)| path).next())
}

fn run_indexer_child(
    indexer_bin: &std::path::Path,
    query_db_path: &std::path::Path,
    run_dir: &std::path::Path,
    projection_entry_limit: usize,
    retain_indexed_runs: usize,
) -> Result<String, String> {
    let output = Command::new(indexer_bin)
        .arg("--query-db")
        .arg(query_db_path)
        .arg("--run-dir")
        .arg(run_dir)
        .arg("--projection-entry-limit")
        .arg(projection_entry_limit.to_string())
        .arg("--retain-indexed-runs")
        .arg(retain_indexed_runs.to_string())
        .arg("--dump-summary")
        .output()
        .map_err(|e| format!("spawn {} failed: {e}", indexer_bin.display()))?;
    if !output.status.success() {
        return Err(format!(
            "exit={} stdout={} stderr={}",
            output.status,
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        ));
    }
    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

fn run_seq_from_path(path: &std::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 run_status_is_success_or_unknown(run_dir: &std::path::Path) -> Result<bool, String> {
    let summary_path = run_dir.join("run-summary.json");
    if !summary_path.exists() {
        return Ok(true);
    }
    let summary: Value = serde_json::from_slice(
        &fs::read(&summary_path)
            .map_err(|e| format!("read run summary failed: {}: {e}", summary_path.display()))?,
    )
    .map_err(|e| format!("parse run summary failed: {}: {e}", summary_path.display()))?;
    Ok(summary
        .get("status")
        .and_then(Value::as_str)
        .map_or(true, |status| status == "success"))
}

fn handle_client(
    mut stream: TcpStream,
    db: Arc<QueryDb>,
    repo_bytes: Option<Arc<ExternalRepoBytesDb>>,
    export_jobs: ExportJobStore,
    export_root: PathBuf,
) -> Result<(), String> {
    let Some(request) = read_http_request(&mut stream)? else {
        return Ok(());
    };
    if let Err(err) = db.try_catch_up_with_primary() {
        eprintln!("query db catch-up failed: {err}");
    }
    let response = if request.method != "GET" {
        if request.method == "POST" {
            match route_post_request(
                Arc::clone(&db),
                repo_bytes.clone(),
                Arc::clone(&export_jobs),
                export_root,
                &request.target,
                &request.body,
            ) {
                Ok(value) => json_response(200, &value),
                Err(ApiError { status, message }) => {
                    json_response(status, &json!({"error": message}))
                }
            }
        } else {
            json_response(405, &json!({"error":"method_not_allowed"}))
        }
    } else if let Some(raw_result) =
        route_raw_request(&db, repo_bytes.as_deref(), &export_jobs, &request.target)
    {
        match raw_result {
            Ok(response) => response,
            Err(ApiError { status, message }) => json_response(status, &json!({"error": message})),
        }
    } else {
        match route_request(&db, repo_bytes.as_deref(), &export_jobs, &request.target) {
            Ok(value) => json_response(200, &value),
            Err(ApiError { status, message }) => json_response(status, &json!({"error": message})),
        }
    };
    stream.write_all(&response).map_err(|e| e.to_string())?;
    Ok(())
}

struct HttpRequest {
    method: String,
    target: String,
    body: Vec<u8>,
}

fn read_http_request(stream: &mut TcpStream) -> Result<Option<HttpRequest>, String> {
    let mut buffer = Vec::new();
    let mut chunk = [0u8; 8192];
    let read = stream.read(&mut chunk).map_err(|e| e.to_string())?;
    if read == 0 {
        return Ok(None);
    }
    buffer.extend_from_slice(&chunk[..read]);
    let header_end = loop {
        if let Some(pos) = find_header_end(&buffer) {
            break pos;
        }
        let read = stream.read(&mut chunk).map_err(|e| e.to_string())?;
        if read == 0 {
            return Err("incomplete HTTP headers".to_string());
        }
        buffer.extend_from_slice(&chunk[..read]);
    };
    let headers = String::from_utf8_lossy(&buffer[..header_end]);
    let Some(first_line) = headers.lines().next() else {
        return Ok(None);
    };
    let mut parts = first_line.split_whitespace();
    let method = parts.next().unwrap_or("").to_string();
    let target = parts.next().unwrap_or("").to_string();
    let content_length = headers
        .lines()
        .filter_map(|line| line.split_once(':'))
        .find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
        .and_then(|(_, value)| value.trim().parse::<usize>().ok())
        .unwrap_or(0);
    let body_start = header_end + 4;
    while buffer.len().saturating_sub(body_start) < content_length {
        let read = stream.read(&mut chunk).map_err(|e| e.to_string())?;
        if read == 0 {
            break;
        }
        buffer.extend_from_slice(&chunk[..read]);
    }
    let body_end = body_start + content_length.min(buffer.len().saturating_sub(body_start));
    Ok(Some(HttpRequest {
        method,
        target,
        body: buffer[body_start..body_end].to_vec(),
    }))
}

fn find_header_end(bytes: &[u8]) -> Option<usize> {
    bytes.windows(4).position(|window| window == b"\r\n\r\n")
}

#[derive(Debug)]
struct ApiError {
    status: u16,
    message: String,
}

impl ApiError {
    fn new(status: u16, message: impl Into<String>) -> Self {
        Self {
            status,
            message: message.into(),
        }
    }
}

impl From<QueryDbError> for ApiError {
    fn from(value: QueryDbError) -> Self {
        match value {
            // The run is still indexed but its report.json was removed by
            // retention cleanup: this is a client-visible "gone", not a
            // server fault.
            QueryDbError::RunDataGone(run_id) => Self::new(
                404,
                format!(
                    "report data for run {run_id} has expired and was cleaned up by retention"
                ),
            ),
            other => Self::new(500, other.to_string()),
        }
    }
}

fn route_request(
    db: &QueryDb,
    repo_bytes: Option<&ExternalRepoBytesDb>,
    export_jobs: &ExportJobStore,
    target: &str,
) -> Result<Value, ApiError> {
    let (path, query) = split_target(target);
    let query = parse_query(query);
    let path = path.trim_end_matches('/');
    let decoded_segments = decode_path_segments(path);
    let segments = decoded_segments
        .iter()
        .map(String::as_str)
        .collect::<Vec<_>>();
    if segments.is_empty() {
        return Ok(json!({"data":{"service":"rpki_query_service","version":1}}));
    }
    match segments.as_slice() {
        ["healthz"] => {
            let latest_ready_run = db.latest_ready_run()?;
            data_response(
                &json!({
                    "status": "ok",
                    "service": "rpki_query_service",
                    "version": 1,
                    "latestReadyRun": latest_ready_run,
                }),
                None,
            )
        }
        ["runs"] => page_response(db.list_runs(limit(&query), cursor(&query))?, None),
        ["latest_run"] => {
            let run_id = db
                .latest_ready_run()?
                .ok_or_else(|| ApiError::new(404, "latest run not found"))?;
            let run = db
                .get_run(&run_id)?
                .ok_or_else(|| ApiError::new(404, "latest run record not found"))?;
            data_response(&run, Some(run_id))
        }
        ["runs", "latest"] => {
            let run_id = db
                .latest_ready_run()?
                .ok_or_else(|| ApiError::new(404, "latest run not found"))?;
            let run = db
                .get_run(&run_id)?
                .ok_or_else(|| ApiError::new(404, "latest run record not found"))?;
            data_response(&run, Some(run_id))
        }
        ["runs", raw_run_id] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let run = db
                .get_run(&run_id)?
                .ok_or_else(|| ApiError::new(404, "run not found"))?;
            data_response(&run, Some(run_id))
        }
        ["runs", raw_run_id, "artifacts"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let run = db
                .get_run(&run_id)?
                .ok_or_else(|| ApiError::new(404, "run not found"))?;
            data_response(&run.artifact_paths, Some(run_id))
        }
        ["runs", raw_run_id, "summary"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let stat = db.get_stat(&run_id, "overview", "counts")?;
            data_response(
                &stat.map(|s| s.value).unwrap_or_else(|| json!({})),
                Some(run_id),
            )
        }
        ["runs", raw_run_id, "vrps", "lookup"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let lookup = vrp_lookup_from_query(&query)?;
            let asn = query
                .get("asn")
                .map(|value| {
                    value.parse::<u32>().map_err(|_| {
                        ApiError::new(400, format!("invalid ASN query parameter: {value}"))
                    })
                })
                .transpose()?;
            page_response(
                db.lookup_vrps(&run_id, &lookup, asn, limit(&query), cursor(&query))?,
                Some(run_id),
            )
        }
        ["runs", raw_run_id, "repos"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let page = match repo_sort_from_query(&query)? {
                Some(order) => db.list_repos_sorted(&run_id, limit(&query), cursor(&query), order)?,
                None => db.list_repos(&run_id, limit(&query), cursor(&query))?,
            };
            page_response(page, Some(run_id))
        }
        ["runs", raw_run_id, "repos", repo_id] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let repo = db
                .get_repo(&run_id, repo_id)?
                .ok_or_else(|| ApiError::new(404, "repo not found"))?;
            data_response(&repo, Some(run_id))
        }
        ["runs", raw_run_id, "repos", repo_id, "stats"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let repo = db
                .get_repo(&run_id, repo_id)?
                .ok_or_else(|| ApiError::new(404, "repo not found"))?;
            data_response(
                &json!({
                    "publicationPoints": repo.publication_points,
                    "objects": repo.objects,
                    "rejectedObjects": repo.rejected_objects,
                    "syncDurationMsTotal": repo.sync_duration_ms_total,
                    "phases": repo.phases,
                    "terminalStates": repo.terminal_states,
                }),
                Some(run_id),
            )
        }
        ["runs", raw_run_id, "repos", repo_id, "publication-points"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            page_response(
                db.list_publication_points_for_repo(
                    &run_id,
                    repo_id,
                    limit(&query),
                    cursor(&query),
                )?,
                Some(run_id),
            )
        }
        ["runs", raw_run_id, "repos", repo_id, "objects"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let filter = object_filter_from_query(&query)?;
            page_response(
                db.list_objects_filtered(
                    &run_id,
                    ObjectScope::Repo(repo_id.to_string()),
                    &filter,
                    limit(&query),
                    cursor(&query),
                )?,
                Some(run_id),
            )
        }
        ["runs", raw_run_id, "publication-points"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            page_response(
                db.list_publication_points(&run_id, limit(&query), cursor(&query))?,
                Some(run_id),
            )
        }
        ["runs", raw_run_id, "publication-points", pp_id] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let pp = db
                .get_publication_point(&run_id, pp_id)?
                .ok_or_else(|| ApiError::new(404, "publication point not found"))?;
            data_response(&pp, Some(run_id))
        }
        ["runs", raw_run_id, "publication-points", pp_id, "stats"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let pp = db
                .get_publication_point(&run_id, pp_id)?
                .ok_or_else(|| ApiError::new(404, "publication point not found"))?;
            data_response(
                &json!({
                    "objects": pp.objects,
                    "rejectedObjects": pp.rejected_objects,
                    "warnings": pp.warnings,
                    "repoSyncSource": pp.repo_sync_source,
                    "repoSyncPhase": pp.repo_sync_phase,
                    "repoSyncDurationMs": pp.repo_sync_duration_ms,
                    "repoTerminalState": pp.repo_terminal_state,
                }),
                Some(run_id),
            )
        }
        ["runs", raw_run_id, "publication-points", pp_id, "objects"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let filter = object_filter_from_query(&query)?;
            page_response(
                db.list_objects_filtered(
                    &run_id,
                    ObjectScope::PublicationPoint(pp_id.to_string()),
                    &filter,
                    limit(&query),
                    cursor(&query),
                )?,
                Some(run_id),
            )
        }
        ["runs", raw_run_id, "objects"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let filter = object_filter_from_query(&query)?;
            page_response(
                db.list_objects_filtered(
                    &run_id,
                    ObjectScope::All,
                    &filter,
                    limit(&query),
                    cursor(&query),
                )?,
                Some(run_id),
            )
        }
        ["runs", raw_run_id, "issues"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let mut filter = object_filter_from_query(&query)?;
            filter.issues_only = true;
            filter.repo_id = query.get("repoId").cloned();
            filter.pp_id = query.get("ppId").cloned();
            page_response(
                db.list_objects_filtered(
                    &run_id,
                    ObjectScope::All,
                    &filter,
                    limit(&query),
                    cursor(&query),
                )?,
                Some(run_id),
            )
        }
        ["runs", raw_run_id, "search"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let q = query
                .get("q")
                .map(String::as_str)
                .unwrap_or("")
                .trim()
                .to_string();
            if q.is_empty() {
                return Err(ApiError::new(400, "q query parameter is required"));
            }
            let limit = search_limit(&query);
            data_response(&search_run(db, &run_id, &q, limit)?, Some(run_id))
        }
        ["runs", raw_run_id, "objects", "by-uri"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let uri = query
                .get("uri")
                .ok_or_else(|| ApiError::new(400, "uri query parameter is required"))?;
            let object = db
                .get_object_by_uri(&run_id, uri)?
                .ok_or_else(|| ApiError::new(404, "object uri not found"))?;
            data_response(&object, Some(run_id))
        }
        ["runs", raw_run_id, "objects", object_instance_id] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let object = db
                .get_object_by_instance_id(&run_id, object_instance_id)?
                .ok_or_else(|| ApiError::new(404, "object not found"))?;
            data_response(&object, Some(run_id))
        }
        ["runs", raw_run_id, "objects", object_instance_id, "parsed"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let object = db
                .get_object_by_instance_id(&run_id, object_instance_id)?
                .ok_or_else(|| ApiError::new(404, "object not found"))?;
            let projection = projection_for_explain(db, repo_bytes, &object)?;
            data_response(&projection, Some(run_id))
        }
        [
            "runs",
            raw_run_id,
            "objects",
            object_instance_id,
            "validation",
        ] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let object = db
                .get_object_by_instance_id(&run_id, object_instance_id)?
                .ok_or_else(|| ApiError::new(404, "object not found"))?;
            data_response(&validation_summary(&object), Some(run_id))
        }
        [
            "runs",
            raw_run_id,
            "objects",
            object_instance_id,
            "validation",
            "file",
        ] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let object = db
                .get_object_by_instance_id(&run_id, object_instance_id)?
                .ok_or_else(|| ApiError::new(404, "object not found"))?;
            data_response(&file_validation_summary(&object), Some(run_id))
        }
        [
            "runs",
            raw_run_id,
            "objects",
            object_instance_id,
            "validation",
            "chain",
        ] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let object = db
                .get_object_by_instance_id(&run_id, object_instance_id)?
                .ok_or_else(|| ApiError::new(404, "object not found"))?;
            data_response(&chain_validation_summary(&object), Some(run_id))
        }
        ["runs", raw_run_id, "objects", object_instance_id, "chain"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let chain_edges = chain_edges_for_object(db, repo_bytes, &run_id, object_instance_id)?;
            data_response(&chain_edges, Some(run_id))
        }
        [
            "runs",
            raw_run_id,
            "objects",
            object_instance_id,
            "parsed",
            "manifest-files",
        ] => {
            let run_id = resolve_run(db, raw_run_id)?;
            projection_array_page(
                db,
                repo_bytes,
                &run_id,
                object_instance_id,
                &["object", "manifest", "fileList", "entries"],
                "manifest-files",
                limit(&query),
                offset_cursor(&query),
            )
        }
        [
            "runs",
            raw_run_id,
            "objects",
            object_instance_id,
            "parsed",
            "revoked-certs",
        ] => {
            let run_id = resolve_run(db, raw_run_id)?;
            projection_array_page(
                db,
                repo_bytes,
                &run_id,
                object_instance_id,
                &["object", "revokedCertificates", "entries"],
                "revoked-certs",
                limit(&query),
                offset_cursor(&query),
            )
        }
        ["runs", raw_run_id, "exports"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let limit = exports_limit(&query);
            page_response(
                rpki::query_db::QueryPage {
                    data: list_export_jobs(export_jobs, &run_id, limit)?,
                    next_cursor: None,
                    limit,
                },
                Some(run_id),
            )
        }
        ["runs", raw_run_id, "exports", job_id] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let job = get_export_job(db, export_jobs, &run_id, job_id)?
                .ok_or_else(|| ApiError::new(404, "export job not found"))?;
            data_response(&job, Some(run_id))
        }
        ["objects", sha256] => {
            let projection = global_projection_for_hash(db, repo_bytes, sha256)?
                .ok_or_else(|| ApiError::new(404, "object projection not found"))?;
            data_response(&projection, None)
        }
        ["runs", raw_run_id, "stats", "overview"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let stat = db.get_stat(&run_id, "overview", "counts")?;
            data_response(
                &stat.map(|s| s.value).unwrap_or_else(|| json!({})),
                Some(run_id),
            )
        }
        ["runs", raw_run_id, "stats", "repos"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let page = match repo_sort_from_query(&query)? {
                Some(order) => db.list_repos_sorted(&run_id, limit(&query), cursor(&query), order)?,
                None => db.list_repos(&run_id, limit(&query), cursor(&query))?,
            };
            page_response(page, Some(run_id))
        }
        ["runs", raw_run_id, "stats", "publication-points"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            page_response(
                db.list_publication_points(&run_id, limit(&query), cursor(&query))?,
                Some(run_id),
            )
        }
        ["runs", raw_run_id, "stats", "object-types"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let stat = db.get_stat(&run_id, "objects", "by_type")?;
            data_response(
                &stat.map(|s| s.value).unwrap_or_else(|| json!({})),
                Some(run_id),
            )
        }
        ["runs", raw_run_id, "stats", "validation"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let stat = db.get_stat(&run_id, "validation", "by_result")?;
            data_response(
                &stat.map(|s| s.value).unwrap_or_else(|| json!({})),
                Some(run_id),
            )
        }
        ["runs", raw_run_id, "stats", "reasons"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let stat = db.get_stat(&run_id, "validation", "reasons")?;
            data_response(
                &stat.map(|s| s.value).unwrap_or_else(|| json!({})),
                Some(run_id),
            )
        }
        ["runs", raw_run_id, "stats", "downloads"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let stat = db.get_stat(&run_id, "downloads", "summary")?;
            data_response(
                &stat.map(|s| s.value).unwrap_or_else(|| json!({})),
                Some(run_id),
            )
        }
        ["runs", raw_run_id, "stats", "validation-events"] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let name = query.get("name").map(String::as_str).unwrap_or("by_type");
            let stat = db.get_stat(&run_id, "validation_events", name)?;
            data_response(
                &stat.map(|s| s.value).unwrap_or_else(|| json!({})),
                Some(run_id),
            )
        }
        ["runs", raw_run_id, "stats", scope] => {
            let run_id = resolve_run(db, raw_run_id)?;
            let name = query.get("name").map(String::as_str).unwrap_or("counts");
            let stat = db.get_stat(&run_id, scope, name)?;
            data_response(
                &stat.map(|s| s.value).unwrap_or_else(|| json!({})),
                Some(run_id),
            )
        }
        _ => Err(ApiError::new(404, "not found")),
    }
}

fn route_post_request(
    db: Arc<QueryDb>,
    repo_bytes: Option<Arc<ExternalRepoBytesDb>>,
    export_jobs: ExportJobStore,
    export_root: PathBuf,
    target: &str,
    body: &[u8],
) -> Result<Value, ApiError> {
    let (path, _) = split_target(target);
    let path = path.trim_end_matches('/');
    let decoded_segments = decode_path_segments(path);
    let segments = decoded_segments
        .iter()
        .map(String::as_str)
        .collect::<Vec<_>>();
    match segments.as_slice() {
        ["runs", raw_run_id, "exports"] => {
            let repo_bytes = repo_bytes
                .ok_or_else(|| ApiError::new(400, "repo-bytes db is required for export jobs"))?;
            let run_id = resolve_run(&db, raw_run_id)?;
            let request: Value = if body.is_empty() {
                json!({})
            } else {
                serde_json::from_slice(body)
                    .map_err(|err| ApiError::new(400, format!("invalid JSON body: {err}")))?
            };
            let scope = request
                .get("scope")
                .and_then(Value::as_str)
                .unwrap_or("repo")
                .to_string();
            let repo_id = request
                .get("repo_id")
                .or_else(|| request.get("repoId"))
                .and_then(Value::as_str)
                .map(str::to_string);
            let pp_id = request
                .get("pp_id")
                .or_else(|| request.get("ppId"))
                .and_then(Value::as_str)
                .map(str::to_string);
            let object_instance_ids = request
                .get("object_instance_ids")
                .or_else(|| request.get("objectInstanceIds"))
                .and_then(Value::as_array)
                .map(|items| {
                    items
                        .iter()
                        .filter_map(Value::as_str)
                        .map(str::to_string)
                        .collect::<Vec<_>>()
                })
                .unwrap_or_default();
            validate_export_request(&scope, repo_id.as_deref(), pp_id.as_deref())?;
            let job_id = uuid::Uuid::new_v4().to_string();
            let output_path = export_root.join(&run_id).join(format!("{job_id}.tar"));
            let job = ExportJobRecord {
                schema_version: 1,
                job_id: job_id.clone(),
                run_id: run_id.clone(),
                scope,
                repo_id,
                pp_id,
                status: "running".to_string(),
                created_at: now_rfc3339(),
                finished_at: None,
                output_path: Some(output_path.display().to_string()),
                object_count: 0,
                bytes_written: 0,
                error: None,
            };
            put_export_job(db.as_ref(), &export_jobs, &job)?;
            let worker_db = Arc::clone(&db);
            let worker_export_jobs = Arc::clone(&export_jobs);
            let failed_job_template = job.clone();
            let response_run_id = run_id.clone();
            let response_job = job.clone();
            std::thread::spawn(move || {
                let final_job = run_export_job(
                    &worker_db,
                    repo_bytes.as_ref(),
                    job,
                    output_path,
                    object_instance_ids,
                )
                .unwrap_or_else(|err| ExportJobRecord {
                    schema_version: 1,
                    job_id: failed_job_template.job_id.clone(),
                    run_id: failed_job_template.run_id.clone(),
                    scope: failed_job_template.scope.clone(),
                    repo_id: failed_job_template.repo_id.clone(),
                    pp_id: failed_job_template.pp_id.clone(),
                    status: "failed".to_string(),
                    created_at: failed_job_template.created_at.clone(),
                    finished_at: Some(now_rfc3339()),
                    output_path: failed_job_template.output_path.clone(),
                    object_count: 0,
                    bytes_written: 0,
                    error: Some(err.message),
                });
                let _ = put_export_job(worker_db.as_ref(), &worker_export_jobs, &final_job);
            });
            data_response(&response_job, Some(response_run_id))
        }
        [
            "runs",
            raw_run_id,
            "objects",
            object_instance_id,
            "validation",
            "explain",
        ] => {
            let run_id = resolve_run(&db, raw_run_id)?;
            let force_refresh = if body.is_empty() {
                false
            } else {
                let request: Value = serde_json::from_slice(body)
                    .map_err(|err| ApiError::new(400, format!("invalid JSON body: {err}")))?;
                request
                    .get("refresh")
                    .or_else(|| request.get("forceRefresh"))
                    .and_then(Value::as_bool)
                    .unwrap_or(false)
            };
            let explain = validation_explain(
                db.as_ref(),
                repo_bytes.as_deref(),
                &run_id,
                object_instance_id,
                force_refresh,
            )?;
            data_response(&explain, Some(run_id))
        }
        _ => Err(ApiError::new(404, "not found")),
    }
}

fn validate_export_request(
    scope: &str,
    repo_id: Option<&str>,
    pp_id: Option<&str>,
) -> Result<(), ApiError> {
    match scope {
        "repo" if repo_id.is_some() => Ok(()),
        "publication_point" | "publicationPoint" if pp_id.is_some() => Ok(()),
        "object_set" | "objectSet" => Ok(()),
        "repo" => Err(ApiError::new(400, "repo export requires repo_id")),
        "publication_point" | "publicationPoint" => Err(ApiError::new(
            400,
            "publication point export requires pp_id",
        )),
        other => Err(ApiError::new(
            400,
            format!("unsupported export scope: {other}"),
        )),
    }
}

fn route_raw_request(
    db: &QueryDb,
    repo_bytes: Option<&ExternalRepoBytesDb>,
    export_jobs: &ExportJobStore,
    target: &str,
) -> Option<Result<Vec<u8>, ApiError>> {
    let (path, _) = split_target(target);
    let path = path.trim_end_matches('/');
    let decoded_segments = decode_path_segments(path);
    let segments = decoded_segments
        .iter()
        .map(String::as_str)
        .collect::<Vec<_>>();
    match segments.as_slice() {
        ["runs", raw_run_id, "objects", object_instance_id, "raw"] => Some(raw_object_response(
            db,
            repo_bytes,
            raw_run_id,
            object_instance_id,
        )),
        ["runs", raw_run_id, "exports", job_id, "download"] => Some(export_download_response(
            db,
            export_jobs,
            raw_run_id,
            job_id,
        )),
        _ => None,
    }
}

fn raw_object_response(
    db: &QueryDb,
    repo_bytes: Option<&ExternalRepoBytesDb>,
    raw_run_id: &str,
    object_instance_id: &str,
) -> Result<Vec<u8>, ApiError> {
    let repo_bytes = repo_bytes
        .ok_or_else(|| ApiError::new(400, "repo-bytes db is not configured for raw download"))?;
    let run_id = resolve_run(db, raw_run_id)?;
    let object = db
        .get_object_by_instance_id(&run_id, object_instance_id)?
        .ok_or_else(|| ApiError::new(404, "object not found"))?;
    if !is_sha256_hex(&object.sha256) {
        return Err(ApiError::new(404, "object raw hash is unavailable"));
    }
    let bytes = repo_bytes
        .get_blob_bytes(&object.sha256)
        .map_err(|err| ApiError::new(500, err.to_string()))?
        .ok_or_else(|| ApiError::new(404, "object raw bytes not found"))?;
    Ok(binary_response(200, "application/octet-stream", bytes))
}

fn export_download_response(
    db: &QueryDb,
    export_jobs: &ExportJobStore,
    raw_run_id: &str,
    job_id: &str,
) -> Result<Vec<u8>, ApiError> {
    let run_id = resolve_run(db, raw_run_id)?;
    let job = get_export_job(db, export_jobs, &run_id, job_id)?
        .ok_or_else(|| ApiError::new(404, "export job not found"))?;
    if job.status != "complete" {
        return Err(ApiError::new(
            409,
            format!("export job status is {}", job.status),
        ));
    }
    let path = job
        .output_path
        .as_ref()
        .ok_or_else(|| ApiError::new(404, "export output path not found"))?;
    let bytes = fs::read(path).map_err(|err| ApiError::new(404, err.to_string()))?;
    let filename = format!(
        "rpki-export-{}-{}.tar",
        sanitize_filename_token(&run_id),
        sanitize_filename_token(job_id)
    );
    Ok(binary_response_with_headers(
        200,
        "application/x-tar",
        &[(
            "Content-Disposition".to_string(),
            format!("attachment; filename=\"{filename}\""),
        )],
        bytes,
    ))
}

fn export_job_store_key(run_id: &str, job_id: &str) -> String {
    format!("{run_id}/{job_id}")
}

fn put_export_job(
    db: &QueryDb,
    export_jobs: &ExportJobStore,
    job: &ExportJobRecord,
) -> Result<(), ApiError> {
    if !db.is_secondary() {
        db.put_export_job(job)?;
    }
    let mut jobs = export_jobs
        .lock()
        .map_err(|_| ApiError::new(500, "export job store lock poisoned"))?;
    jobs.insert(export_job_store_key(&job.run_id, &job.job_id), job.clone());
    Ok(())
}

fn get_export_job(
    db: &QueryDb,
    export_jobs: &ExportJobStore,
    run_id: &str,
    job_id: &str,
) -> Result<Option<ExportJobRecord>, ApiError> {
    if let Some(job) = export_jobs
        .lock()
        .map_err(|_| ApiError::new(500, "export job store lock poisoned"))?
        .get(&export_job_store_key(run_id, job_id))
        .cloned()
    {
        return Ok(Some(job));
    }
    Ok(db.get_export_job(run_id, job_id)?)
}

fn list_export_jobs(
    export_jobs: &ExportJobStore,
    run_id: &str,
    limit: usize,
) -> Result<Vec<ExportJobRecord>, ApiError> {
    let jobs = export_jobs
        .lock()
        .map_err(|_| ApiError::new(500, "export job store lock poisoned"))?;
    let mut out = jobs
        .values()
        .filter(|job| job.run_id == run_id)
        .cloned()
        .collect::<Vec<_>>();
    out.sort_by(|left, right| right.created_at.cmp(&left.created_at));
    out.truncate(limit);
    Ok(out)
}

fn run_export_job(
    db: &QueryDb,
    repo_bytes: &ExternalRepoBytesDb,
    mut job: ExportJobRecord,
    output_path: PathBuf,
    object_instance_ids: Vec<String>,
) -> Result<ExportJobRecord, ApiError> {
    if let Some(parent) = output_path.parent() {
        fs::create_dir_all(parent).map_err(|err| ApiError::new(500, err.to_string()))?;
    }
    let manifest_path = output_path.with_extension("manifest.json.tmp");
    let mut file = File::create(&output_path).map_err(|err| ApiError::new(500, err.to_string()))?;
    let mut manifest =
        File::create(&manifest_path).map_err(|err| ApiError::new(500, err.to_string()))?;
    write_manifest_prefix(&mut manifest, &job)?;
    let mut object_count = 0u64;

    match job.scope.as_str() {
        "repo" => {
            let repo_id = job.repo_id.as_deref().unwrap_or_default();
            let mut cursor = None;
            loop {
                let page = db.list_objects_for_repo(
                    &job.run_id,
                    repo_id,
                    EXPORT_PAGE_LIMIT,
                    cursor.as_deref(),
                )?;
                for object in page.data {
                    write_export_object(
                        repo_bytes,
                        &mut file,
                        &mut manifest,
                        &mut object_count,
                        &object,
                    )?;
                }
                let Some(next_cursor) = page.next_cursor else {
                    break;
                };
                cursor = Some(next_cursor);
            }
        }
        "publication_point" | "publicationPoint" => {
            let pp_id = job.pp_id.as_deref().unwrap_or_default();
            let mut cursor = None;
            loop {
                let page = db.list_objects_for_pp(
                    &job.run_id,
                    pp_id,
                    EXPORT_PAGE_LIMIT,
                    cursor.as_deref(),
                )?;
                for object in page.data {
                    write_export_object(
                        repo_bytes,
                        &mut file,
                        &mut manifest,
                        &mut object_count,
                        &object,
                    )?;
                }
                let Some(next_cursor) = page.next_cursor else {
                    break;
                };
                cursor = Some(next_cursor);
            }
        }
        "object_set" | "objectSet" => {
            if object_instance_ids.len() > EXPORT_OBJECT_SET_MAX {
                return Err(ApiError::new(
                    400,
                    format!("objectSet export accepts at most {EXPORT_OBJECT_SET_MAX} objects"),
                ));
            }
            for object_instance_id in object_instance_ids {
                let object = db
                    .get_object_by_instance_id(&job.run_id, &object_instance_id)?
                    .ok_or_else(|| {
                        ApiError::new(404, format!("object not found: {object_instance_id}"))
                    })?;
                write_export_object(
                    repo_bytes,
                    &mut file,
                    &mut manifest,
                    &mut object_count,
                    &object,
                )?;
            }
        }
        other => {
            return Err(ApiError::new(
                400,
                format!("unsupported export scope: {other}"),
            ));
        }
    }
    write_manifest_suffix(&mut manifest, object_count)?;
    manifest
        .flush()
        .map_err(|err| ApiError::new(500, err.to_string()))?;
    drop(manifest);
    write_tar_file_entry(&mut file, "manifest.json", &manifest_path)?;
    let _ = fs::remove_file(&manifest_path);
    file.write_all(&[0u8; 1024])
        .map_err(|err| ApiError::new(500, err.to_string()))?;
    file.flush()
        .map_err(|err| ApiError::new(500, err.to_string()))?;
    let bytes_written = file
        .seek(SeekFrom::End(0))
        .map_err(|err| ApiError::new(500, err.to_string()))?;
    job.status = "complete".to_string();
    job.finished_at = Some(now_rfc3339());
    job.object_count = object_count;
    job.bytes_written = bytes_written;
    Ok(job)
}

fn write_manifest_prefix(file: &mut File, job: &ExportJobRecord) -> Result<(), ApiError> {
    write!(file, "{{\"schemaVersion\":1,\"jobId\":")
        .map_err(|err| ApiError::new(500, err.to_string()))?;
    serde_json::to_writer(&mut *file, &job.job_id)
        .map_err(|err| ApiError::new(500, err.to_string()))?;
    write!(file, ",\"runId\":").map_err(|err| ApiError::new(500, err.to_string()))?;
    serde_json::to_writer(&mut *file, &job.run_id)
        .map_err(|err| ApiError::new(500, err.to_string()))?;
    write!(file, ",\"scope\":").map_err(|err| ApiError::new(500, err.to_string()))?;
    serde_json::to_writer(&mut *file, &job.scope)
        .map_err(|err| ApiError::new(500, err.to_string()))?;
    write!(file, ",\"repoId\":").map_err(|err| ApiError::new(500, err.to_string()))?;
    serde_json::to_writer(&mut *file, &job.repo_id)
        .map_err(|err| ApiError::new(500, err.to_string()))?;
    write!(file, ",\"ppId\":").map_err(|err| ApiError::new(500, err.to_string()))?;
    serde_json::to_writer(&mut *file, &job.pp_id)
        .map_err(|err| ApiError::new(500, err.to_string()))?;
    write!(file, ",\"objects\":[").map_err(|err| ApiError::new(500, err.to_string()))?;
    Ok(())
}

fn write_manifest_suffix(file: &mut File, object_count: u64) -> Result<(), ApiError> {
    write!(file, "],\"objectCount\":{object_count}}}")
        .map_err(|err| ApiError::new(500, err.to_string()))
}

fn write_export_object(
    repo_bytes: &ExternalRepoBytesDb,
    tar_file: &mut File,
    manifest_file: &mut File,
    object_count: &mut u64,
    object: &ObjectInstanceRecord,
) -> Result<(), ApiError> {
    if !is_sha256_hex(&object.sha256) {
        return Err(ApiError::new(
            500,
            format!("object has invalid sha256: {}", object.uri),
        ));
    }
    let bytes = repo_bytes
        .get_blob_bytes(&object.sha256)
        .map_err(|err| ApiError::new(500, err.to_string()))?
        .ok_or_else(|| ApiError::new(500, format!("bytes missing for {}", object.uri)))?;
    write_tar_entry(tar_file, &export_object_path(object), &bytes)?;
    if *object_count > 0 {
        write!(manifest_file, ",").map_err(|err| ApiError::new(500, err.to_string()))?;
    }
    serde_json::to_writer(
        manifest_file,
        &json!({
            "uri": object.uri,
            "sha256": object.sha256,
            "objectType": object.object_type,
            "result": object.result,
            "sourceSection": object.source_section,
        }),
    )
    .map_err(|err| ApiError::new(500, err.to_string()))?;
    *object_count += 1;
    Ok(())
}

fn write_tar_entry(file: &mut File, path: &str, bytes: &[u8]) -> Result<(), ApiError> {
    let name = normalize_tar_path(path);
    let mut header = [0u8; 512];
    let name_bytes = name.as_bytes();
    let name_len = name_bytes.len().min(100);
    header[..name_len].copy_from_slice(&name_bytes[..name_len]);
    write_octal(&mut header[100..108], 0o644);
    write_octal(&mut header[108..116], 0);
    write_octal(&mut header[116..124], 0);
    write_octal(&mut header[124..136], bytes.len() as u64);
    write_octal(&mut header[136..148], 0);
    for byte in &mut header[148..156] {
        *byte = b' ';
    }
    header[156] = b'0';
    header[257..263].copy_from_slice(b"ustar\0");
    header[263..265].copy_from_slice(b"00");
    let checksum: u64 = header.iter().map(|byte| *byte as u64).sum();
    write_checksum(&mut header[148..156], checksum);
    file.write_all(&header)
        .map_err(|err| ApiError::new(500, err.to_string()))?;
    file.write_all(bytes)
        .map_err(|err| ApiError::new(500, err.to_string()))?;
    let padding = (512 - (bytes.len() % 512)) % 512;
    if padding > 0 {
        file.write_all(&vec![0u8; padding])
            .map_err(|err| ApiError::new(500, err.to_string()))?;
    }
    Ok(())
}

fn write_tar_file_entry(
    file: &mut File,
    path: &str,
    source_path: &PathBuf,
) -> Result<(), ApiError> {
    let name = normalize_tar_path(path);
    let size = fs::metadata(source_path)
        .map_err(|err| ApiError::new(500, err.to_string()))?
        .len();
    let mut header = [0u8; 512];
    let name_bytes = name.as_bytes();
    let name_len = name_bytes.len().min(100);
    header[..name_len].copy_from_slice(&name_bytes[..name_len]);
    write_octal(&mut header[100..108], 0o644);
    write_octal(&mut header[108..116], 0);
    write_octal(&mut header[116..124], 0);
    write_octal(&mut header[124..136], size);
    write_octal(&mut header[136..148], 0);
    for byte in &mut header[148..156] {
        *byte = b' ';
    }
    header[156] = b'0';
    header[257..263].copy_from_slice(b"ustar\0");
    header[263..265].copy_from_slice(b"00");
    let checksum: u64 = header.iter().map(|byte| *byte as u64).sum();
    write_checksum(&mut header[148..156], checksum);
    file.write_all(&header)
        .map_err(|err| ApiError::new(500, err.to_string()))?;
    let mut source = File::open(source_path).map_err(|err| ApiError::new(500, err.to_string()))?;
    std::io::copy(&mut source, file).map_err(|err| ApiError::new(500, err.to_string()))?;
    let padding = (512 - (size as usize % 512)) % 512;
    if padding > 0 {
        file.write_all(&vec![0u8; padding])
            .map_err(|err| ApiError::new(500, err.to_string()))?;
    }
    Ok(())
}

fn write_octal(slot: &mut [u8], value: u64) {
    let width = slot.len();
    let text = format!("{value:0width$o}\0", width = width - 1);
    slot.copy_from_slice(&text.as_bytes()[..width]);
}

fn write_checksum(slot: &mut [u8], value: u64) {
    let text = format!("{value:06o}\0 ",);
    slot.copy_from_slice(&text.as_bytes()[..slot.len()]);
}

fn normalize_tar_path(path: &str) -> String {
    let mut out = path
        .replace("://", "/")
        .chars()
        .map(|ch| match ch {
            'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '_' | '-' | '/' => ch,
            _ => '_',
        })
        .collect::<String>();
    while out.contains("//") {
        out = out.replace("//", "/");
    }
    out.trim_start_matches('/').chars().take(100).collect()
}

fn export_object_path(object: &ObjectInstanceRecord) -> String {
    let ext = object
        .uri
        .rsplit_once('.')
        .map(|(_, ext)| ext)
        .filter(|ext| ext.len() <= 5)
        .unwrap_or("bin");
    format!(
        "objects/{}_{}.{}",
        object.object_instance_id,
        object.sha256.chars().take(16).collect::<String>(),
        ext
    )
}

fn projection_array_page(
    db: &QueryDb,
    repo_bytes: Option<&ExternalRepoBytesDb>,
    run_id: &str,
    object_instance_id: &str,
    path: &[&str],
    list_kind: &str,
    limit: usize,
    offset: usize,
) -> Result<Value, ApiError> {
    let object = db
        .get_object_by_instance_id(run_id, object_instance_id)?
        .ok_or_else(|| ApiError::new(404, "object not found"))?;
    let (total, data) =
        match projection_list_from_repo_bytes(repo_bytes, &object, list_kind, offset, limit)? {
            Some(page) => page,
            None => {
                let projection = projection_for_explain(db, repo_bytes, &object)?
                    .ok_or_else(|| ApiError::new(404, "object projection not found"))?;
                let entries = json_path(&projection.projection, path)
                    .and_then(Value::as_array)
                    .ok_or_else(|| ApiError::new(404, "projection list not found"))?;
                let end = (offset + limit).min(entries.len());
                (
                    entries.len(),
                    entries[offset.min(entries.len())..end].to_vec(),
                )
            }
        };
    let end = (offset + data.len()).min(total);
    let next_cursor = if end < total {
        Some(end.to_string())
    } else {
        None
    };
    Ok(json!({
        "data": data,
        "page": {"nextCursor": next_cursor, "limit": limit},
        "meta": meta(Some(run_id.to_string())),
    }))
}

const EXPLAIN_VERSION: u32 = 1;

fn validation_explain(
    db: &QueryDb,
    repo_bytes: Option<&ExternalRepoBytesDb>,
    run_id: &str,
    object_instance_id: &str,
    force_refresh: bool,
) -> Result<ValidationExplainRecord, ApiError> {
    if !force_refresh
        && let Some(cached) =
            db.get_validation_explain(run_id, object_instance_id, EXPLAIN_VERSION)?
    {
        return Ok(cached);
    }
    let object = db
        .get_object_by_instance_id(run_id, object_instance_id)?
        .ok_or_else(|| ApiError::new(404, "object not found"))?;
    let projection = projection_for_explain(db, repo_bytes, &object)?;
    let parsevalidate = parsevalidate_summary(&object, projection.as_ref());
    let chain_edges = build_chain_edges(db, run_id, &object, projection.as_ref())?;
    let chainvalidate = json!({
        "status": final_status(&object),
        "mode": "audit-context",
        "issues": issues_for_object(&object),
        "edgesCount": chain_edges.len(),
        "note": "M7 explain uses run audit state and parse projection only; full chain revalidation is deferred."
    });
    let explain = ValidationExplainRecord {
        schema_version: 1,
        explain_version: EXPLAIN_VERSION,
        run_id: run_id.to_string(),
        object_instance_id: object.object_instance_id.clone(),
        uri: object.uri.clone(),
        sha256: object.sha256.clone(),
        object_type: object.object_type.clone(),
        final_status: final_status(&object).to_string(),
        audit_result: object.result.clone(),
        detail_summary: object.detail_summary.clone(),
        authoritative: false,
        explain_mode: "audit_projection".to_string(),
        generated_at: now_rfc3339(),
        parsevalidate,
        chainvalidate,
        chain_edges,
    };
    db.put_validation_explain(&explain)?;
    Ok(explain)
}

fn chain_edges_for_object(
    db: &QueryDb,
    repo_bytes: Option<&ExternalRepoBytesDb>,
    run_id: &str,
    object_instance_id: &str,
) -> Result<Vec<ChainEdgeRecord>, ApiError> {
    let object = db
        .get_object_by_instance_id(run_id, object_instance_id)?
        .ok_or_else(|| ApiError::new(404, "object not found"))?;
    let projection = projection_for_explain(db, repo_bytes, &object)?;
    build_chain_edges(db, run_id, &object, projection.as_ref())
}

fn projection_for_explain(
    db: &QueryDb,
    repo_bytes: Option<&ExternalRepoBytesDb>,
    object: &ObjectInstanceRecord,
) -> Result<Option<rpki::object_projection::ObjectProjectionRecord>, ApiError> {
    if !is_sha256_hex(&object.sha256) {
        return Ok(None);
    }
    if let Some(projection) = db.get_object_projection(&object.sha256)? {
        return Ok(Some(projection));
    }
    let Some(repo_bytes) = repo_bytes else {
        return Ok(None);
    };
    let Some(bytes) = repo_bytes
        .get_blob_bytes(&object.sha256)
        .map_err(|err| ApiError::new(500, err.to_string()))?
    else {
        return Ok(None);
    };
    let projection = rpki::object_projection::build_object_projection(
        object_type_for_projection(&object.object_type),
        std::path::Path::new(&object.uri),
        &bytes,
        100,
    );
    db.put_object_projection(&projection)?;
    Ok(Some(projection))
}

fn search_run(db: &QueryDb, run_id: &str, q: &str, limit: usize) -> Result<Value, ApiError> {
    let objects = search_run_objects(db, run_id, q, limit)?;
    let lower = q.to_lowercase();
    let repos = db.search_repos(run_id, &lower, limit)?;
    let publication_points = db.search_publication_points(run_id, &lower, limit)?;
    Ok(json!({
        "objects": objects,
        "repos": repos,
        "publicationPoints": publication_points,
    }))
}

fn search_run_objects(
    db: &QueryDb,
    run_id: &str,
    q: &str,
    limit: usize,
) -> Result<Vec<ObjectInstanceRecord>, ApiError> {
    if q.starts_with("rsync://") || q.starts_with("http://") || q.starts_with("https://") {
        let Some(index) = db.get_object_by_uri(run_id, q)? else {
            return Ok(Vec::new());
        };
        let object = match db.get_object_by_instance_id(run_id, &index.object_instance_id)? {
            Some(full) => full,
            None => object_instance_from_uri_index(&index),
        };
        return Ok(vec![object]);
    }
    let lower = q.to_lowercase();
    if is_sha256_hex(q) {
        if let Some(projection) = db.get_object_projection(&lower)? {
            if let Some(full) = db.get_cached_object_by_sha256(run_id, &projection.sha256)? {
                return Ok(vec![full]);
            }
            return Ok(vec![object_instance_from_projection(run_id, &projection)]);
        }
        if let Some(object) = db.get_object_by_sha256(run_id, &lower)? {
            return Ok(vec![object]);
        }
        return Ok(Vec::new());
    }
    let is_hex = lower.len() >= 8 && lower.bytes().all(|byte| byte.is_ascii_hexdigit());
    if is_hex {
        let mut objects = Vec::new();
        for projection in db.search_object_projections_by_hash_prefix(&lower, limit)? {
            let object = match db.get_cached_object_by_sha256(run_id, &projection.sha256)? {
                Some(full) => full,
                None => object_instance_from_projection(run_id, &projection),
            };
            objects.push(object);
        }
        return Ok(objects);
    }
    Ok(Vec::new())
}

fn object_instance_from_uri_index(index: &ObjectUriIndexRecord) -> ObjectInstanceRecord {
    ObjectInstanceRecord {
        schema_version: 1,
        run_id: index.run_id.clone(),
        object_instance_id: index.object_instance_id.clone(),
        uri: index.uri.clone(),
        uri_hash: String::new(),
        sha256: index.sha256.clone(),
        object_type: String::new(),
        result: String::new(),
        detail_summary: None,
        repo_id: index.repo_id.clone(),
        pp_id: index.pp_id.clone(),
        source_section: "uri_index".to_string(),
        rejected: false,
        reject_reason: None,
    }
}

fn object_instance_from_projection(
    run_id: &str,
    projection: &rpki::object_projection::ObjectProjectionRecord,
) -> ObjectInstanceRecord {
    ObjectInstanceRecord {
        schema_version: 1,
        run_id: run_id.to_string(),
        object_instance_id: String::new(),
        uri: String::new(),
        uri_hash: String::new(),
        sha256: projection.sha256.clone(),
        object_type: projection.object_type.clone(),
        result: projection.parse_status.clone(),
        detail_summary: projection.error_summary.clone(),
        repo_id: String::new(),
        pp_id: String::new(),
        source_section: "hash_index".to_string(),
        rejected: false,
        reject_reason: None,
    }
}

fn global_projection_for_hash(
    db: &QueryDb,
    repo_bytes: Option<&ExternalRepoBytesDb>,
    sha256: &str,
) -> Result<Option<rpki::object_projection::ObjectProjectionRecord>, ApiError> {
    if let Some(projection) = db.get_object_projection(sha256)? {
        return Ok(Some(projection));
    }
    let Some(run_id) = db.latest_ready_run()? else {
        return Ok(None);
    };
    let Some(object) = db.get_object_by_sha256(&run_id, sha256)? else {
        return Ok(None);
    };
    projection_for_explain(db, repo_bytes, &object)
}

fn validation_summary(object: &ObjectInstanceRecord) -> Value {
    json!({
        "objectInstanceId": object.object_instance_id,
        "uri": object.uri,
        "sha256": object.sha256,
        "objectType": object.object_type,
        "finalStatus": final_status(object),
        "auditResult": object.result,
        "detailSummary": object.detail_summary,
        "rejected": object.rejected,
        "rejectReason": object.reject_reason,
        "sourceSection": object.source_section,
        "fileValidation": file_validation_summary(object),
        "chainValidation": chain_validation_summary(object),
        "explainAvailable": true,
        "authoritative": false
    })
}

fn file_validation_summary(object: &ObjectInstanceRecord) -> Value {
    json!({
        "status": final_status(object),
        "stage": "audit_result",
        "issues": issues_for_object(object),
        "detailSummary": object.detail_summary,
    })
}

fn chain_validation_summary(object: &ObjectInstanceRecord) -> Value {
    json!({
        "status": final_status(object),
        "stage": "audit_context",
        "issues": issues_for_object(object),
        "edgesPage": {"nextCursor": null, "limit": 100},
        "note": "Use POST validation/explain for cached audit projection details."
    })
}

fn parsevalidate_summary(
    object: &ObjectInstanceRecord,
    projection: Option<&rpki::object_projection::ObjectProjectionRecord>,
) -> Value {
    let projection_status = projection
        .map(|record| record.parse_status.as_str())
        .unwrap_or("unavailable");
    let projection_error = projection.and_then(|record| record.error_summary.as_deref());
    let status = match projection_status {
        "ok" if !object.rejected && object.result == "ok" => "valid",
        "ok" if object.rejected || object.result == "error" => "warning",
        "error" => "invalid",
        _ if object.result == "error" || object.rejected => "invalid",
        _ => "unknown",
    };
    json!({
        "status": status,
        "projectionStatus": projection_status,
        "issues": issues_for_object(object),
        "projectionError": projection_error,
        "projection": projection.map(|record| record.projection.clone()),
    })
}

fn build_chain_edges(
    db: &QueryDb,
    run_id: &str,
    object: &ObjectInstanceRecord,
    projection: Option<&rpki::object_projection::ObjectProjectionRecord>,
) -> Result<Vec<ChainEdgeRecord>, ApiError> {
    let mut edges = Vec::new();
    if let Some(pp) = db.get_publication_point(run_id, &object.pp_id)?
        && let Some(manifest_uri) = pp.manifest_rsync_uri
        && manifest_uri != object.uri
    {
        let manifest = db.get_object_by_uri(run_id, &manifest_uri)?;
        edges.push(ChainEdgeRecord {
            relation: "publication_point_manifest".to_string(),
            from_uri: object.uri.clone(),
            to_uri: manifest_uri,
            to_object_instance_id: manifest
                .as_ref()
                .map(|item| item.object_instance_id.clone()),
            to_sha256: manifest.as_ref().map(|item| item.sha256.clone()),
            status: if manifest.is_some() {
                "linked".to_string()
            } else {
                "missing".to_string()
            },
            evidence: json!({"ppId": object.pp_id}),
        });
    }
    if let Some(uri) = first_signed_object_uri(projection) {
        let target = db.get_object_by_uri(run_id, &uri)?;
        edges.push(ChainEdgeRecord {
            relation: "embedded_ee_sia_signed_object".to_string(),
            from_uri: object.uri.clone(),
            to_uri: uri,
            to_object_instance_id: target.as_ref().map(|item| item.object_instance_id.clone()),
            to_sha256: target.as_ref().map(|item| item.sha256.clone()),
            status: if target.is_some() {
                "linked".to_string()
            } else {
                "missing".to_string()
            },
            evidence: json!({"source": "projection.signedObject.certificates[0].resourceCertificate.extensions.subjectInfoAccess"}),
        });
    }
    Ok(edges)
}

fn first_signed_object_uri(
    projection: Option<&rpki::object_projection::ObjectProjectionRecord>,
) -> Option<String> {
    projection
        .and_then(|record| {
            record
                .projection
                .get("object")?
                .get("signedObject")?
                .get("signedData")?
                .get("certificates")?
                .as_array()?
                .first()?
                .get("resourceCertificate")?
                .get("extensions")?
                .get("subjectInfoAccess")?
                .get("signedObjectUris")?
                .as_array()?
                .first()?
                .as_str()
        })
        .map(str::to_string)
}

fn final_status(object: &ObjectInstanceRecord) -> &'static str {
    if object.rejected || object.result == "error" {
        "invalid"
    } else if object.result == "skipped" {
        "skipped"
    } else if object.result == "ok" {
        "valid"
    } else {
        "unknown"
    }
}

fn issues_for_object(object: &ObjectInstanceRecord) -> Vec<Value> {
    let reason = object
        .reject_reason
        .as_ref()
        .or(object.detail_summary.as_ref())
        .filter(|reason| !reason.is_empty());
    reason
        .map(|reason| {
            vec![json!({
                "stage": "audit_result",
                "severity": if object.rejected || object.result == "error" { "error" } else { "warning" },
                "reasonCode": stable_reason_code(reason),
                "summary": reason,
            })]
        })
        .unwrap_or_default()
}

fn stable_reason_code(reason: &str) -> String {
    let lower = reason.to_ascii_lowercase();
    let code = lower
        .chars()
        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
        .collect::<String>();
    let code = code
        .split('_')
        .filter(|part| !part.is_empty())
        .collect::<Vec<_>>()
        .join("_");
    if code.is_empty() {
        "unknown".to_string()
    } else {
        code.chars().take(96).collect()
    }
}

fn object_type_for_projection(label: &str) -> rpki::object_projection::ObjectType {
    match label {
        "cer" | "certificate" | "router_certificate" => rpki::object_projection::ObjectType::Cer,
        "mft" | "manifest" => rpki::object_projection::ObjectType::Mft,
        "crl" => rpki::object_projection::ObjectType::Crl,
        "roa" => rpki::object_projection::ObjectType::Roa,
        "asa" | "aspa" => rpki::object_projection::ObjectType::Aspa,
        _ => rpki::object_projection::ObjectType::Auto,
    }
}

fn projection_list_from_repo_bytes(
    repo_bytes: Option<&ExternalRepoBytesDb>,
    object: &ObjectInstanceRecord,
    list_kind: &str,
    offset: usize,
    limit: usize,
) -> Result<Option<(usize, Vec<Value>)>, ApiError> {
    let Some(repo_bytes) = repo_bytes else {
        return Ok(None);
    };
    if !is_sha256_hex(&object.sha256) {
        return Ok(None);
    }
    let Some(bytes) = repo_bytes
        .get_blob_bytes(&object.sha256)
        .map_err(|err| ApiError::new(500, err.to_string()))?
    else {
        return Ok(None);
    };
    let page = match list_kind {
        "manifest-files" => {
            rpki::object_projection::manifest_file_entries_page(&bytes, offset, limit)
        }
        "revoked-certs" => rpki::object_projection::crl_revoked_entries_page(&bytes, offset, limit),
        _ => return Ok(None),
    }
    .map_err(|err| ApiError::new(500, err))?;
    Ok(Some(page))
}

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

fn resolve_run(db: &QueryDb, raw: &str) -> Result<String, ApiError> {
    db.resolve_run_id(raw)?
        .ok_or_else(|| ApiError::new(404, "run not found"))
}

fn split_target(target: &str) -> (&str, &str) {
    target.split_once('?').unwrap_or((target, ""))
}

fn parse_query(raw: &str) -> BTreeMap<String, String> {
    raw.split('&')
        .filter(|part| !part.is_empty())
        .filter_map(|part| {
            let (key, value) = part.split_once('=').unwrap_or((part, ""));
            Some((percent_decode(key)?, percent_decode(value)?))
        })
        .collect()
}

fn percent_decode(value: &str) -> Option<String> {
    let bytes = value.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0usize;
    while i < bytes.len() {
        match bytes[i] {
            b'+' => out.push(b' '),
            b'%' if i + 2 < bytes.len() => {
                let hi = hex_value(bytes[i + 1])?;
                let lo = hex_value(bytes[i + 2])?;
                out.push((hi << 4) | lo);
                i += 2;
            }
            byte => out.push(byte),
        }
        i += 1;
    }
    String::from_utf8(out).ok()
}

fn hex_value(byte: u8) -> Option<u8> {
    match byte {
        b'0'..=b'9' => Some(byte - b'0'),
        b'a'..=b'f' => Some(byte - b'a' + 10),
        b'A'..=b'F' => Some(byte - b'A' + 10),
        _ => None,
    }
}

/// Split an API path into percent-decoded segments. Real-world ids are plain
/// hex, so decoding is a no-op for them; it only matters for hand-built URLs
/// (e.g. an id containing a literal `%2F`).
fn decode_path_segments(path: &str) -> Vec<String> {
    path.trim_start_matches("/api/v1")
        .trim_start_matches('/')
        .split('/')
        .filter(|s| !s.is_empty())
        .map(|segment| percent_decode(segment).unwrap_or_else(|| segment.to_string()))
        .collect()
}

fn limit(query: &BTreeMap<String, String>) -> usize {
    query
        .get("limit")
        .and_then(|value| value.parse::<usize>().ok())
        .unwrap_or(100)
        .clamp(1, 1000)
}

fn search_limit(query: &BTreeMap<String, String>) -> usize {
    query
        .get("limit")
        .and_then(|value| value.parse::<usize>().ok())
        .unwrap_or(10)
        .clamp(1, 50)
}

fn exports_limit(query: &BTreeMap<String, String>) -> usize {
    query
        .get("limit")
        .and_then(|value| value.parse::<usize>().ok())
        .unwrap_or(50)
        .clamp(1, 200)
}

fn repo_sort_from_query(query: &BTreeMap<String, String>) -> Result<Option<RepoSortOrder>, ApiError> {
    match query.get("sort").map(|value| value.as_str()) {
        None => Ok(None),
        Some("objects") => match query.get("order").map(|value| value.as_str()) {
            None | Some("desc") => Ok(Some(RepoSortOrder::ObjectsDesc)),
            Some("asc") => Ok(Some(RepoSortOrder::ObjectsAsc)),
            Some(other) => Err(ApiError::new(400, format!("invalid repos order: {other}"))),
        },
        Some(other) => Err(ApiError::new(400, format!("invalid repos sort: {other}"))),
    }
}

fn object_filter_from_query(query: &BTreeMap<String, String>) -> Result<ObjectFilter, ApiError> {
    let mut filter = ObjectFilter::default();
    if let Some(value) = query.get("type") {
        if value.is_empty() {
            return Err(ApiError::new(400, "type filter must not be empty"));
        }
        // Accept projection-vocabulary aliases so both `mft` and `manifest`
        // (etc.) match the URI-derived report stream types.
        let lower = value.to_ascii_lowercase();
        let normalized = match lower.as_str() {
            "mft" => "manifest",
            "cer" => "certificate",
            "asa" => "aspa",
            _ => lower.as_str(),
        };
        filter.object_type = Some(normalized.to_string());
    }
    if let Some(value) = query.get("result") {
        if !matches!(
            value.to_ascii_lowercase().as_str(),
            "ok" | "error" | "skipped"
        ) {
            return Err(ApiError::new(
                400,
                format!("invalid result filter: {value}"),
            ));
        }
        filter.result = Some(value.clone());
    }
    if let Some(value) = query.get("rejected") {
        filter.rejected = Some(match value.as_str() {
            "true" => true,
            "false" => false,
            _ => {
                return Err(ApiError::new(
                    400,
                    format!("invalid rejected filter: {value}"),
                ));
            }
        });
    }
    if let Some(value) = query.get("reason") {
        filter.reason = Some(value.to_lowercase());
    }
    if let Some(value) = query.get("q") {
        filter.query = Some(value.to_lowercase());
    }
    Ok(filter)
}

fn vrp_lookup_from_query(query: &BTreeMap<String, String>) -> Result<VrpLookup, ApiError> {
    let ip = query.get("ip");
    let prefix = query.get("prefix");
    match (ip, prefix) {
        (Some(_), Some(_)) => Err(ApiError::new(
            400,
            "ip and prefix query parameters are mutually exclusive",
        )),
        (None, None) => Err(ApiError::new(
            400,
            "one of ip or prefix query parameters is required",
        )),
        (Some(value), None) => {
            VrpLookup::parse_ip(value).map_err(|error| ApiError::new(400, error))
        }
        (None, Some(value)) => {
            VrpLookup::parse_prefix(value).map_err(|error| ApiError::new(400, error))
        }
    }
}

fn cursor(query: &BTreeMap<String, String>) -> Option<&str> {
    query.get("cursor").map(String::as_str)
}

fn offset_cursor(query: &BTreeMap<String, String>) -> usize {
    query
        .get("cursor")
        .and_then(|value| value.parse::<usize>().ok())
        .unwrap_or(0)
}

fn now_rfc3339() -> String {
    time::OffsetDateTime::now_utc()
        .format(&time::format_description::well_known::Rfc3339)
        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
}

fn data_response<T: Serialize>(data: &T, run_id: Option<String>) -> Result<Value, ApiError> {
    Ok(json!({"data": data, "page": null, "meta": meta(run_id)}))
}

fn page_response<T: Serialize>(
    page: rpki::query_db::QueryPage<T>,
    run_id: Option<String>,
) -> Result<Value, ApiError> {
    Ok(json!({
        "data": page.data,
        "page": {"nextCursor": page.next_cursor, "limit": page.limit},
        "meta": meta(run_id),
    }))
}

fn meta(run_id: Option<String>) -> Value {
    json!({"runId": run_id, "schemaVersion": 1})
}

fn json_response(status: u16, value: &Value) -> Vec<u8> {
    let body = serde_json::to_vec(value).unwrap_or_else(|_| b"{}".to_vec());
    let reason = match status {
        200 => "OK",
        400 => "Bad Request",
        404 => "Not Found",
        405 => "Method Not Allowed",
        409 => "Conflict",
        _ => "Internal Server Error",
    };
    format!(
        "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
        body.len(),
        String::from_utf8_lossy(&body)
    )
    .into_bytes()
}

fn binary_response(status: u16, content_type: &str, body: Vec<u8>) -> Vec<u8> {
    binary_response_with_headers(status, content_type, &[], body)
}

fn binary_response_with_headers(
    status: u16,
    content_type: &str,
    extra_headers: &[(String, String)],
    body: Vec<u8>,
) -> Vec<u8> {
    let reason = match status {
        200 => "OK",
        404 => "Not Found",
        _ => "Internal Server Error",
    };
    let mut head = format!("HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n");
    for (name, value) in extra_headers {
        head.push_str(&format!("{name}: {value}\r\n"));
    }
    let mut response = format!(
        "{head}Content-Length: {}\r\nConnection: close\r\n\r\n",
        body.len()
    )
    .into_bytes();
    response.extend(body);
    response
}

/// Header-safe token for use inside a `Content-Disposition` filename: strips
/// anything that could smuggle CRLF or quotes into the response head.
fn sanitize_filename_token(value: &str) -> String {
    value
        .chars()
        .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
        .collect()
}

fn is_sha256_hex(value: &str) -> bool {
    value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}

#[cfg(test)]
mod tests {
    use super::*;
    use rpki::query_db::{ArtifactIndexerConfig, index_artifacts};
    use sha2::{Digest, Sha256};
    use std::fs;

    fn test_export_jobs() -> ExportJobStore {
        Arc::new(Mutex::new(BTreeMap::new()))
    }

    fn test_route_request(
        db: &QueryDb,
        repo_bytes: Option<&ExternalRepoBytesDb>,
        target: &str,
    ) -> Result<Value, ApiError> {
        route_request(db, repo_bytes, &test_export_jobs(), target)
    }

    fn test_route_raw_request(
        db: &QueryDb,
        repo_bytes: Option<&ExternalRepoBytesDb>,
        target: &str,
    ) -> Option<Result<Vec<u8>, ApiError>> {
        route_raw_request(db, repo_bytes, &test_export_jobs(), target)
    }

    #[test]
    fn parse_query_decodes_limit_and_cursor() {
        let query = parse_query("limit=10&cursor=objinst%2Frun_1%2Fabc");
        assert_eq!(limit(&query), 10);
        assert_eq!(cursor(&query), Some("objinst/run_1/abc"));
    }

    #[test]
    fn parse_args_accepts_watcher_and_rejects_invalid_input() {
        let argv = [
            "rpki_query_service",
            "--query-db",
            "query.db",
            "--repo-bytes-db",
            "repo-bytes.db",
            "--export-root",
            "exports",
            "--listen",
            "127.0.0.1:19560",
            "--watch-run-root",
            "runs",
            "--watch-interval-secs",
            "2",
            "--watch-min-run-seq",
            "42",
            "--watch-backfill",
            "--projection-entry-limit",
            "9",
            "--indexer-bin",
            "bin/rpki_query_indexer",
            "--retain-indexed-runs",
            "3",
        ]
        .iter()
        .map(|value| value.to_string())
        .collect::<Vec<_>>();
        let args = parse_args(&argv).expect("args");
        assert_eq!(args.query_db, PathBuf::from("query.db"));
        assert_eq!(args.repo_bytes_db, Some(PathBuf::from("repo-bytes.db")));
        assert_eq!(args.export_root, PathBuf::from("exports"));
        assert_eq!(args.listen, "127.0.0.1:19560");
        assert_eq!(args.watch_run_root, Some(PathBuf::from("runs")));
        assert_eq!(args.watch_interval_secs, 2);
        assert_eq!(args.watch_min_run_seq, Some(42));
        assert!(args.watch_backfill);
        assert_eq!(args.projection_entry_limit, 9);
        assert_eq!(
            args.indexer_bin,
            Some(PathBuf::from("bin/rpki_query_indexer"))
        );
        assert_eq!(args.retain_indexed_runs, 3);

        let default_args = parse_args(&[
            "rpki_query_service".to_string(),
            "--query-db".to_string(),
            "query.db".to_string(),
        ])
        .expect("default args");
        assert_eq!(default_args.export_root, PathBuf::from("query.exports"));
        assert_eq!(default_args.listen, "127.0.0.1:9560");
        assert_eq!(default_args.watch_interval_secs, 10);
        assert_eq!(default_args.watch_min_run_seq, None);
        assert!(!default_args.watch_backfill);
        assert_eq!(default_args.projection_entry_limit, 50);
        assert_eq!(default_args.indexer_bin, None);
        assert_eq!(default_args.retain_indexed_runs, 10);

        assert!(
            parse_args(&["rpki_query_service".to_string()])
                .unwrap_err()
                .contains("--query-db")
        );
        assert!(
            parse_args(&[
                "rpki_query_service".to_string(),
                "--query-db".to_string(),
                "query.db".to_string(),
                "--watch-interval-secs".to_string(),
                "bad".to_string(),
            ])
            .unwrap_err()
            .contains("invalid --watch-interval-secs")
        );
        assert!(
            parse_args(&[
                "rpki_query_service".to_string(),
                "--query-db".to_string(),
                "query.db".to_string(),
                "--projection-entry-limit".to_string(),
                "bad".to_string(),
            ])
            .unwrap_err()
            .contains("invalid --projection-entry-limit")
        );
        assert!(
            parse_args(&[
                "rpki_query_service".to_string(),
                "--query-db".to_string(),
                "query.db".to_string(),
                "--retain-indexed-runs".to_string(),
                "bad".to_string(),
            ])
            .unwrap_err()
            .contains("invalid --retain-indexed-runs")
        );
        assert!(
            parse_args(&[
                "rpki_query_service".to_string(),
                "--query-db".to_string(),
                "query.db".to_string(),
                "--unknown".to_string(),
            ])
            .unwrap_err()
            .contains("unknown argument")
        );
    }

    #[test]
    fn resolve_effective_watch_min_run_seq_defaults_to_next_run_without_backfill() {
        let temp = tempfile::tempdir().expect("tempdir");
        let runs_root = temp.path().join("runs");
        fs::create_dir_all(runs_root.join("run_0003")).expect("run_0003");
        fs::write(runs_root.join("run_0003/report.json"), "{}").expect("report");
        fs::write(
            runs_root.join("run_0003/run-summary.json"),
            r#"{"status":"success"}"#,
        )
        .expect("summary");
        fs::create_dir_all(runs_root.join("run_0004")).expect("run_0004");
        fs::write(runs_root.join("run_0004/report.json"), "{}").expect("report");
        fs::write(
            runs_root.join("run_0004/run-summary.json"),
            r#"{"status":"failed"}"#,
        )
        .expect("summary");

        assert_eq!(
            resolve_effective_watch_min_run_seq(temp.path(), None, false).expect("min seq"),
            Some(4)
        );
        assert_eq!(
            resolve_effective_watch_min_run_seq(temp.path(), Some(2), false).expect("min seq"),
            Some(2)
        );
        assert_eq!(
            resolve_effective_watch_min_run_seq(temp.path(), None, true).expect("min seq"),
            None
        );
    }

    #[test]
    fn route_request_exposes_run_navigation_stats_and_object_details() {
        let temp = tempfile::tempdir().expect("tempdir");
        let run_dir = temp.path().join("runs/run_0001");
        fs::create_dir_all(&run_dir).expect("run dir");
        let object_bytes =
            fs::read("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa")
                .expect("fixture roa");
        let object_sha = hex::encode(Sha256::digest(&object_bytes));
        write_sample_run(&run_dir, &object_sha);

        let repo_bytes_path = temp.path().join("repo-bytes.db");
        let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes");
        repo_bytes
            .put_blob_bytes_batch(&[(object_sha.clone(), object_bytes)])
            .expect("put bytes");
        drop(repo_bytes);

        let query_db_path = temp.path().join("query-db");
        index_artifacts(&ArtifactIndexerConfig {
            query_db_path: query_db_path.clone(),
            run_root: Some(temp.path().to_path_buf()),
            run_dir: None,
            repo_bytes_db_path: Some(repo_bytes_path.clone()),
            projection_entry_limit: 5,
            min_run_seq: None,
            retain_indexed_runs: None,
        })
        .expect("index artifacts");
        let db = QueryDb::open(&query_db_path).expect("query db");
        let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes");
        let repo = db
            .list_repos("run_0001", 1, None)
            .expect("repos")
            .data
            .into_iter()
            .next()
            .expect("repo");
        let pp = db
            .list_publication_points("run_0001", 1, None)
            .expect("pps")
            .data
            .into_iter()
            .next()
            .expect("pp");
        let object = db
            .list_objects("run_0001", 1, None)
            .expect("objects")
            .data
            .into_iter()
            .next()
            .expect("object");

        assert_eq!(
            test_route_request(&db, Some(&repo_bytes), "/api/v1").expect("root")["data"]["service"]
                .as_str(),
            Some("rpki_query_service")
        );
        assert_eq!(
            test_route_request(&db, Some(&repo_bytes), "/api/v1/runs").expect("runs")["data"]
                .as_array()
                .expect("runs")
                .len(),
            1
        );
        assert_eq!(
            test_route_request(&db, Some(&repo_bytes), "/api/v1/runs/latest")
                .expect("latest")["data"]["runId"]
                .as_str(),
            Some("run_0001")
        );
        assert_eq!(
            test_route_request(&db, Some(&repo_bytes), "/api/v1/latest_run")
                .expect("latest_run")["data"]["runId"]
                .as_str(),
            Some("run_0001")
        );
        assert_eq!(
            test_route_request(&db, Some(&repo_bytes), "/api/v1/runs/run_0001")
                .expect("run")["data"]["counts"]["objects"]
                .as_u64(),
            Some(1)
        );
        assert!(
            test_route_request(&db, Some(&repo_bytes), "/api/v1/runs/latest/artifacts")
                .expect("artifacts")["data"]["report.json"]
                .as_str()
                .is_some()
        );
        assert_eq!(
            test_route_request(&db, Some(&repo_bytes), "/api/v1/runs/latest/summary")
                .expect("summary")["data"]["objects"]
                .as_u64(),
            Some(1)
        );
        let vrp_ip = test_route_request(
            &db,
            Some(&repo_bytes),
            "/api/v1/runs/latest/vrps/lookup?ip=2001:da8::1",
        )
        .expect("VRP IP lookup");
        assert_eq!(vrp_ip["data"][0]["asn"].as_u64(), Some(4538));
        assert_eq!(vrp_ip["meta"]["runId"].as_str(), Some("run_0001"));
        let vrp_prefix = test_route_request(
            &db,
            Some(&repo_bytes),
            "/api/v1/runs/latest/vrps/lookup?prefix=2001%3Ada8%3A%3A%2F32&asn=4538",
        )
        .expect("VRP prefix lookup");
        assert_eq!(vrp_prefix["data"].as_array().unwrap().len(), 1);
        let invalid = test_route_request(
            &db,
            Some(&repo_bytes),
            "/api/v1/runs/latest/vrps/lookup?ip=192.0.2.1&prefix=192.0.2.0%2F24",
        )
        .expect_err("conflicting VRP lookup parameters");
        assert_eq!(invalid.status, 400);

        for target in [
            "/api/v1/runs/latest/repos",
            &format!("/api/v1/runs/latest/repos/{}", repo.repo_id),
            &format!("/api/v1/runs/latest/repos/{}/stats", repo.repo_id),
            &format!(
                "/api/v1/runs/latest/repos/{}/publication-points",
                repo.repo_id
            ),
            &format!("/api/v1/runs/latest/repos/{}/objects", repo.repo_id),
            "/api/v1/runs/latest/publication-points",
            &format!("/api/v1/runs/latest/publication-points/{}", pp.pp_id),
            &format!("/api/v1/runs/latest/publication-points/{}/stats", pp.pp_id),
            &format!(
                "/api/v1/runs/latest/publication-points/{}/objects",
                pp.pp_id
            ),
            "/api/v1/runs/latest/objects",
            &format!(
                "/api/v1/runs/latest/objects/by-uri?uri={}",
                percent_encode_for_test(&object.uri)
            ),
            &format!("/api/v1/runs/latest/objects/{}", object.object_instance_id),
            &format!(
                "/api/v1/runs/latest/objects/{}/parsed",
                object.object_instance_id
            ),
            &format!(
                "/api/v1/runs/latest/objects/{}/validation/file",
                object.object_instance_id
            ),
            &format!(
                "/api/v1/runs/latest/objects/{}/validation/chain",
                object.object_instance_id
            ),
            "/api/v1/runs/latest/stats/overview",
            "/api/v1/runs/latest/stats/repos",
            "/api/v1/runs/latest/stats/publication-points",
            "/api/v1/runs/latest/stats/object-types",
            "/api/v1/runs/latest/stats/validation",
            "/api/v1/runs/latest/stats/reasons",
            "/api/v1/runs/latest/stats/downloads",
            "/api/v1/runs/latest/stats/validation-events?name=manifest",
            "/api/v1/runs/latest/stats/objects?name=by_source",
            &format!("/api/v1/objects/{object_sha}"),
        ] {
            let value = test_route_request(&db, Some(&repo_bytes), target).unwrap_or_else(|err| {
                panic!("route failed for {target}: {} {}", err.status, err.message)
            });
            assert!(value.get("meta").is_some(), "{target}");
        }

        assert_eq!(
            test_route_request(&db, Some(&repo_bytes), "/api/v1/runs/latest/objects/by-uri")
                .unwrap_err()
                .status,
            400
        );
        assert_eq!(
            test_route_request(&db, Some(&repo_bytes), "/api/v1/runs/latest/repos/nope")
                .unwrap_err()
                .status,
            404
        );
    }

    fn index_sample_run(temp: &tempfile::TempDir) -> QueryDb {
        let run_dir = temp.path().join("runs/run_0001");
        fs::create_dir_all(&run_dir).expect("run dir");
        let object_bytes =
            fs::read("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa")
                .expect("fixture roa");
        let object_sha = hex::encode(Sha256::digest(&object_bytes));
        write_sample_run(&run_dir, &object_sha);
        let query_db_path = temp.path().join("query-db");
        index_artifacts(&ArtifactIndexerConfig {
            query_db_path: query_db_path.clone(),
            run_root: Some(temp.path().to_path_buf()),
            run_dir: None,
            repo_bytes_db_path: None,
            projection_entry_limit: 5,
            min_run_seq: None,
            retain_indexed_runs: None,
        })
        .expect("index artifacts");
        QueryDb::open(&query_db_path).expect("query db")
    }

    #[test]
    fn unknown_run_id_returns_404_instead_of_empty_payload() {
        let temp = tempfile::tempdir().expect("tempdir");
        let db = index_sample_run(&temp);

        for target in [
            "/api/v1/runs/run_9999",
            "/api/v1/runs/run_9999/summary",
            "/api/v1/runs/run_9999/objects",
            "/api/v1/runs/run_9999/issues",
            "/api/v1/runs/run_9999/stats/overview",
            "/api/v1/runs/run_9999/stats/validation-events?name=manifest",
        ] {
            let err = match test_route_request(&db, None, target) {
                Ok(_) => panic!("expected 404 for {target}"),
                Err(err) => err,
            };
            assert_eq!(err.status, 404, "{target}");
        }
        // Existing runs and "latest" keep working.
        assert!(
            test_route_request(&db, None, "/api/v1/runs/run_0001/stats/overview").is_ok()
        );
        assert!(test_route_request(&db, None, "/api/v1/runs/latest/objects").is_ok());
    }

    #[test]
    fn expired_run_report_returns_404_run_data_gone() {
        let temp = tempfile::tempdir().expect("tempdir");
        let db = index_sample_run(&temp);
        assert!(test_route_request(&db, None, "/api/v1/runs/run_0001/objects").is_ok());

        // Retention cleanup removes the run directory while the index record
        // survives: object routes must report a distinguishable 404.
        fs::remove_dir_all(temp.path().join("runs/run_0001")).expect("remove run dir");
        let err = test_route_request(&db, None, "/api/v1/runs/run_0001/objects")
            .expect_err("run data gone");
        assert_eq!(err.status, 404);
        assert!(
            err.message.contains("expired"),
            "unexpected message: {}",
            err.message
        );
        let err = test_route_request(&db, None, "/api/v1/runs/run_0001/issues")
            .expect_err("run data gone");
        assert_eq!(err.status, 404);
        // Stats served from the index keep working.
        assert!(
            test_route_request(&db, None, "/api/v1/runs/run_0001/stats/overview").is_ok()
        );
    }

    #[test]
    fn decode_path_segments_percent_decodes_ids() {
        let segments = decode_path_segments("/api/v1/runs/run_0001/objects/abc%2Fdef");
        assert_eq!(segments, vec!["runs", "run_0001", "objects", "abc/def"]);
        assert!(decode_path_segments("/api/v1").is_empty());
        assert!(decode_path_segments("/api/v1/").is_empty());
    }

    #[test]
    fn sanitize_filename_token_strips_crlf_and_quotes() {
        assert_eq!(sanitize_filename_token("run_0001"), "run_0001");
        assert_eq!(sanitize_filename_token("a\r\nb\"c d/e"), "abcde");
    }

    #[test]
    fn raw_route_downloads_object_bytes_from_repo_bytes_db() {
        let temp = tempfile::tempdir().expect("tempdir");
        let run_dir = temp.path().join("runs/run_0001");
        fs::create_dir_all(&run_dir).expect("run dir");
        let object_bytes =
            fs::read("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa")
                .expect("fixture roa");
        let object_sha = hex::encode(Sha256::digest(&object_bytes));
        write_sample_run(&run_dir, &object_sha);

        let repo_bytes_path = temp.path().join("repo-bytes.db");
        let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes");
        repo_bytes
            .put_blob_bytes_batch(&[(object_sha.clone(), object_bytes.clone())])
            .expect("put bytes");
        drop(repo_bytes);

        let query_db_path = temp.path().join("query-db");
        index_artifacts(&ArtifactIndexerConfig {
            query_db_path: query_db_path.clone(),
            run_root: Some(temp.path().to_path_buf()),
            run_dir: None,
            repo_bytes_db_path: Some(repo_bytes_path.clone()),
            projection_entry_limit: 5,
            min_run_seq: None,
            retain_indexed_runs: None,
        })
        .expect("index artifacts");
        let db = QueryDb::open(&query_db_path).expect("query db");
        let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes");
        let object = db
            .list_objects("run_0001", 1, None)
            .expect("objects")
            .data
            .into_iter()
            .next()
            .expect("object");

        let response = test_route_raw_request(
            &db,
            Some(&repo_bytes),
            &format!(
                "/api/v1/runs/latest/objects/{}/raw",
                object.object_instance_id
            ),
        )
        .expect("raw route")
        .expect("raw response");
        let separator = response
            .windows(4)
            .position(|window| window == b"\r\n\r\n")
            .expect("header separator");
        assert_eq!(&response[separator + 4..], object_bytes.as_slice());
    }

    #[test]
    fn export_job_writes_tar_with_manifest_and_object() {
        let temp = tempfile::tempdir().expect("tempdir");
        let run_dir = temp.path().join("runs/run_0001");
        fs::create_dir_all(&run_dir).expect("run dir");
        let object_bytes =
            fs::read("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa")
                .expect("fixture roa");
        let object_sha = hex::encode(Sha256::digest(&object_bytes));
        write_sample_run(&run_dir, &object_sha);
        let repo_bytes_path = temp.path().join("repo-bytes.db");
        let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes");
        repo_bytes
            .put_blob_bytes_batch(&[(object_sha, object_bytes)])
            .expect("put bytes");
        drop(repo_bytes);
        let query_db_path = temp.path().join("query-db");
        index_artifacts(&ArtifactIndexerConfig {
            query_db_path: query_db_path.clone(),
            run_root: Some(temp.path().to_path_buf()),
            run_dir: None,
            repo_bytes_db_path: Some(repo_bytes_path.clone()),
            projection_entry_limit: 5,
            min_run_seq: None,
            retain_indexed_runs: None,
        })
        .expect("index artifacts");
        let db = QueryDb::open(&query_db_path).expect("query db");
        let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes");
        let repo = db
            .list_repos("run_0001", 1, None)
            .expect("repos")
            .data
            .into_iter()
            .next()
            .expect("repo");
        let job = ExportJobRecord {
            schema_version: 1,
            job_id: "job1".to_string(),
            run_id: "run_0001".to_string(),
            scope: "repo".to_string(),
            repo_id: Some(repo.repo_id),
            pp_id: None,
            status: "running".to_string(),
            created_at: now_rfc3339(),
            finished_at: None,
            output_path: Some(temp.path().join("export.tar").display().to_string()),
            object_count: 0,
            bytes_written: 0,
            error: None,
        };
        let output_path = temp.path().join("export.tar");
        let final_job =
            run_export_job(&db, &repo_bytes, job, output_path.clone(), Vec::new()).expect("export");
        assert_eq!(final_job.status, "complete");
        assert_eq!(final_job.object_count, 1);
        let tar = fs::read(output_path).expect("tar");
        assert!(
            tar.windows("manifest.json".len())
                .any(|window| window == b"manifest.json")
        );
        assert!(
            tar.windows("AS4538.roa".len())
                .any(|window| window == b"AS4538.roa")
                || tar.windows(".roa".len()).any(|window| window == b".roa")
        );
    }

    #[test]
    fn post_export_route_creates_and_completes_repo_job() {
        let temp = tempfile::tempdir().expect("tempdir");
        let run_dir = temp.path().join("runs/run_0001");
        fs::create_dir_all(&run_dir).expect("run dir");
        let object_bytes =
            fs::read("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa")
                .expect("fixture roa");
        let object_sha = hex::encode(Sha256::digest(&object_bytes));
        write_sample_run(&run_dir, &object_sha);
        let repo_bytes_path = temp.path().join("repo-bytes.db");
        let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes");
        repo_bytes
            .put_blob_bytes_batch(&[(object_sha, object_bytes)])
            .expect("put bytes");
        drop(repo_bytes);
        let query_db_path = temp.path().join("query-db");
        index_artifacts(&ArtifactIndexerConfig {
            query_db_path: query_db_path.clone(),
            run_root: Some(temp.path().to_path_buf()),
            run_dir: None,
            repo_bytes_db_path: Some(repo_bytes_path.clone()),
            projection_entry_limit: 5,
            min_run_seq: None,
            retain_indexed_runs: None,
        })
        .expect("index artifacts");
        let db = Arc::new(QueryDb::open(&query_db_path).expect("query db"));
        let repo_bytes = Arc::new(ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes"));
        let repo = db
            .list_repos("run_0001", 1, None)
            .expect("repos")
            .data
            .into_iter()
            .next()
            .expect("repo");
        let body =
            serde_json::to_vec(&json!({"scope":"repo","repo_id": repo.repo_id})).expect("body");
        let response = route_post_request(
            Arc::clone(&db),
            Some(repo_bytes),
            test_export_jobs(),
            temp.path().join("exports"),
            "/api/v1/runs/latest/exports",
            &body,
        )
        .expect("post export");
        let job_id = response["data"]["jobId"]
            .as_str()
            .expect("job id")
            .to_string();
        let mut job = None;
        for _ in 0..50 {
            job = db.get_export_job("run_0001", &job_id).expect("job");
            if job.as_ref().is_some_and(|job| job.status == "complete") {
                break;
            }
            std::thread::sleep(std::time::Duration::from_millis(20));
        }
        let job = job.expect("completed job");
        assert_eq!(job.status, "complete");
        assert_eq!(job.object_count, 1);
        assert!(job.bytes_written > 0);

        let download = test_route_raw_request(
            &db,
            None,
            &format!("/api/v1/runs/latest/exports/{job_id}/download"),
        )
        .expect("download route")
        .expect("download response");
        let head_end = download
            .windows(4)
            .position(|window| window == b"\r\n\r\n")
            .expect("header separator");
        let head = String::from_utf8_lossy(&download[..head_end]);
        assert!(head.starts_with("HTTP/1.1 200 OK"), "head: {head}");
        assert!(
            head.contains("Content-Type: application/x-tar\r\n"),
            "head: {head}"
        );
        let expected_disposition = format!(
            "Content-Disposition: attachment; filename=\"rpki-export-run_0001-{job_id}.tar\""
        );
        assert!(
            head.contains(&expected_disposition),
            "head: {head}"
        );
    }

    #[test]
    fn projection_large_lists_page_from_repo_bytes() {
        let temp = tempfile::tempdir().expect("tempdir");
        let run_dir = temp.path().join("runs/run_0001");
        fs::create_dir_all(&run_dir).expect("run dir");
        let mft_bytes = fs::read(
            "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft",
        )
        .expect("fixture mft");
        let mft_sha = hex::encode(Sha256::digest(&mft_bytes));
        write_manifest_sample_run(&run_dir, &mft_sha);
        let repo_bytes_path = temp.path().join("repo-bytes.db");
        let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes");
        repo_bytes
            .put_blob_bytes_batch(&[(mft_sha, mft_bytes)])
            .expect("put bytes");
        drop(repo_bytes);
        let query_db_path = temp.path().join("query-db");
        index_artifacts(&ArtifactIndexerConfig {
            query_db_path: query_db_path.clone(),
            run_root: Some(temp.path().to_path_buf()),
            run_dir: None,
            repo_bytes_db_path: Some(repo_bytes_path.clone()),
            projection_entry_limit: 2,
            min_run_seq: None,
            retain_indexed_runs: None,
        })
        .expect("index artifacts");
        let db = QueryDb::open(&query_db_path).expect("query db");
        let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes");
        let object = db
            .list_objects("run_0001", 1, None)
            .expect("objects")
            .data
            .into_iter()
            .next()
            .expect("object");
        let page = projection_array_page(
            &db,
            Some(&repo_bytes),
            "run_0001",
            &object.object_instance_id,
            &["object", "manifest", "fileList", "entries"],
            "manifest-files",
            2,
            2,
        )
        .expect("page");
        assert_eq!(page["data"].as_array().expect("data").len(), 2);
        assert!(page["page"]["nextCursor"].as_str().is_some());
    }

    #[test]
    fn projection_large_lists_can_use_stored_projection_without_repo_bytes() {
        let temp = tempfile::tempdir().expect("tempdir");
        let run_dir = temp.path().join("runs/run_0001");
        fs::create_dir_all(&run_dir).expect("run dir");
        let mft_bytes = fs::read(
            "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft",
        )
        .expect("fixture mft");
        let mft_sha = hex::encode(Sha256::digest(&mft_bytes));
        write_manifest_sample_run(&run_dir, &mft_sha);
        let repo_bytes_path = temp.path().join("repo-bytes.db");
        let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes");
        repo_bytes
            .put_blob_bytes_batch(&[(mft_sha, mft_bytes)])
            .expect("put bytes");
        drop(repo_bytes);
        let query_db_path = temp.path().join("query-db");
        index_artifacts(&ArtifactIndexerConfig {
            query_db_path: query_db_path.clone(),
            run_root: Some(temp.path().to_path_buf()),
            run_dir: None,
            repo_bytes_db_path: Some(repo_bytes_path.clone()),
            projection_entry_limit: 4,
            min_run_seq: None,
            retain_indexed_runs: None,
        })
        .expect("index artifacts");
        let db = QueryDb::open(&query_db_path).expect("query db");
        let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes");
        let object = db
            .list_objects("run_0001", 1, None)
            .expect("objects")
            .data
            .into_iter()
            .next()
            .expect("object");
        let projection = projection_for_explain(&db, Some(&repo_bytes), &object)
            .expect("prime lazy projection cache")
            .expect("projection");
        assert_eq!(projection.sha256, object.sha256);
        drop(repo_bytes);
        let page = projection_array_page(
            &db,
            None,
            "run_0001",
            &object.object_instance_id,
            &["object", "manifest", "fileList", "entries"],
            "manifest-files",
            2,
            0,
        )
        .expect("page");
        assert_eq!(page["data"].as_array().expect("data").len(), 2);
    }

    #[test]
    fn post_and_raw_routes_return_expected_error_statuses() {
        let temp = tempfile::tempdir().expect("tempdir");
        let run_dir = temp.path().join("runs/run_0001");
        fs::create_dir_all(&run_dir).expect("run dir");
        write_sample_run(&run_dir, &"1".repeat(64));
        let query_db_path = temp.path().join("query-db");
        index_artifacts(&ArtifactIndexerConfig {
            query_db_path: query_db_path.clone(),
            run_root: Some(temp.path().to_path_buf()),
            run_dir: None,
            repo_bytes_db_path: None,
            projection_entry_limit: 5,
            min_run_seq: None,
            retain_indexed_runs: None,
        })
        .expect("index artifacts");
        let db = Arc::new(QueryDb::open(&query_db_path).expect("query db"));
        let object = db
            .list_objects("run_0001", 1, None)
            .expect("objects")
            .data
            .into_iter()
            .next()
            .expect("object");

        assert_eq!(
            route_post_request(
                Arc::clone(&db),
                None,
                test_export_jobs(),
                temp.path().join("exports"),
                "/api/v1/runs/latest/exports",
                b"{}",
            )
            .unwrap_err()
            .status,
            400
        );
        assert_eq!(
            route_post_request(
                Arc::clone(&db),
                None,
                test_export_jobs(),
                temp.path().join("exports"),
                &format!(
                    "/api/v1/runs/latest/objects/{}/validation/explain",
                    object.object_instance_id
                ),
                b"{bad json",
            )
            .unwrap_err()
            .status,
            400
        );
        assert_eq!(
            validate_export_request("repo", None, None)
                .unwrap_err()
                .status,
            400
        );
        assert_eq!(
            validate_export_request("publication_point", None, None)
                .unwrap_err()
                .status,
            400
        );
        assert_eq!(
            validate_export_request("bad", None, None)
                .unwrap_err()
                .status,
            400
        );
        validate_export_request("repo", Some("repo-id"), None).expect("repo export");
        validate_export_request("publicationPoint", None, Some("pp-id")).expect("pp export");
        validate_export_request("objectSet", None, None).expect("object set export");

        assert_eq!(
            test_route_raw_request(
                &db,
                None,
                &format!(
                    "/api/v1/runs/latest/objects/{}/raw",
                    object.object_instance_id
                ),
            )
            .expect("raw route")
            .unwrap_err()
            .status,
            400
        );
        let running_job = ExportJobRecord {
            schema_version: 1,
            job_id: "job1".to_string(),
            run_id: "run_0001".to_string(),
            scope: "object_set".to_string(),
            repo_id: None,
            pp_id: None,
            status: "running".to_string(),
            created_at: now_rfc3339(),
            finished_at: None,
            output_path: None,
            object_count: 0,
            bytes_written: 0,
            error: None,
        };
        db.put_export_job(&running_job).expect("job");
        assert_eq!(
            test_route_raw_request(&db, None, "/api/v1/runs/latest/exports/job1/download")
                .expect("download route")
                .unwrap_err()
                .status,
            409
        );
        assert!(test_route_raw_request(&db, None, "/api/v1/not-raw").is_none());
    }

    #[test]
    fn validation_explain_uses_projection_and_cache() {
        let temp = tempfile::tempdir().expect("tempdir");
        let run_dir = temp.path().join("runs/run_0001");
        fs::create_dir_all(&run_dir).expect("run dir");
        let object_bytes =
            fs::read("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa")
                .expect("fixture roa");
        let object_sha = hex::encode(Sha256::digest(&object_bytes));
        write_sample_run(&run_dir, &object_sha);
        let repo_bytes_path = temp.path().join("repo-bytes.db");
        let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes");
        repo_bytes
            .put_blob_bytes_batch(&[(object_sha, object_bytes)])
            .expect("put bytes");
        drop(repo_bytes);
        let query_db_path = temp.path().join("query-db");
        index_artifacts(&ArtifactIndexerConfig {
            query_db_path: query_db_path.clone(),
            run_root: Some(temp.path().to_path_buf()),
            run_dir: None,
            repo_bytes_db_path: Some(repo_bytes_path.clone()),
            projection_entry_limit: 5,
            min_run_seq: None,
            retain_indexed_runs: None,
        })
        .expect("index artifacts");
        let db = QueryDb::open(&query_db_path).expect("query db");
        let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes");
        let object = db
            .list_objects("run_0001", 1, None)
            .expect("objects")
            .data
            .into_iter()
            .next()
            .expect("object");

        let explain = validation_explain(
            &db,
            Some(&repo_bytes),
            "run_0001",
            &object.object_instance_id,
            false,
        )
        .expect("explain");
        assert_eq!(explain.final_status, "valid");
        assert_eq!(
            explain.parsevalidate["projectionStatus"].as_str(),
            Some("ok")
        );
        assert!(!explain.authoritative);
        assert!(
            db.get_validation_explain("run_0001", &object.object_instance_id, EXPLAIN_VERSION)
                .expect("cached")
                .is_some()
        );

        let validation = test_route_request(
            &db,
            Some(&repo_bytes),
            &format!(
                "/api/v1/runs/latest/objects/{}/validation",
                object.object_instance_id
            ),
        )
        .expect("validation route");
        assert_eq!(validation["data"]["finalStatus"].as_str(), Some("valid"));

        let chain = test_route_request(
            &db,
            Some(&repo_bytes),
            &format!(
                "/api/v1/runs/latest/objects/{}/chain",
                object.object_instance_id
            ),
        )
        .expect("chain route");
        assert!(chain["data"].as_array().is_some());
    }

    fn write_sample_run(run_dir: &std::path::Path, object_sha: &str) {
        let report = json!({
            "format_version": 2,
            "meta": {"validation_time_rfc3339_utc": "2026-06-15T00:00:00Z"},
            "tree": {"warnings": []},
            "publication_points": [
                {
                    "node_id": 10,
                    "rsync_base_uri": "rsync://repo.example/rpki/",
                    "manifest_rsync_uri": "rsync://repo.example/rpki/m.mft",
                    "publication_point_rsync_uri": "rsync://repo.example/rpki/",
                    "rrdp_notification_uri": "https://repo.example/rrdp/notification.xml",
                    "source": "rrdp",
                    "repo_sync_source": "rrdp",
                    "repo_sync_phase": "rrdp_delta",
                    "repo_sync_duration_ms": 123,
                    "repo_terminal_state": "fresh",
                    "warnings": [],
                    "objects": [
                        {"rsync_uri":"rsync://repo.example/rpki/a.roa","sha256_hex": object_sha,"kind":"roa","result":"ok"}
                    ]
                }
            ],
            "vrps": [{"asn": 4538, "prefix": "2001:da8::/32", "max_length": 32}],
            "aspas": [],
            "downloads": [],
            "download_stats": {}
        });
        fs::write(
            run_dir.join("report.json"),
            serde_json::to_vec(&report).unwrap(),
        )
        .expect("report");
        let summary = json!({
            "status": "success",
            "runId": "run_0001",
            "runSeq": 1,
            "startedAtRfc3339Utc": "2026-06-15T00:00:00Z",
            "finishedAtRfc3339Utc": "2026-06-15T00:01:00Z",
            "wallMs": 60000,
            "reportCounts": {"vrps": 1, "aspas": 0, "publicationPoints": 1, "warnings": 0}
        });
        fs::write(
            run_dir.join("run-summary.json"),
            serde_json::to_vec(&summary).unwrap(),
        )
        .expect("summary");
        fs::write(run_dir.join("stage-timing.json"), b"{}").expect("stage");
    }

    fn write_manifest_sample_run(run_dir: &std::path::Path, object_sha: &str) {
        let report = json!({
            "format_version": 2,
            "meta": {"validation_time_rfc3339_utc": "2026-06-15T00:00:00Z"},
            "tree": {"warnings": []},
            "publication_points": [
                {
                    "node_id": 11,
                    "rsync_base_uri": "rsync://repo.example/rpki/",
                    "manifest_rsync_uri": "rsync://repo.example/rpki/m.mft",
                    "publication_point_rsync_uri": "rsync://repo.example/rpki/",
                    "rrdp_notification_uri": "https://repo.example/rrdp/notification.xml",
                    "source": "rrdp",
                    "repo_sync_source": "rrdp",
                    "repo_sync_phase": "rrdp_delta",
                    "repo_sync_duration_ms": 123,
                    "repo_terminal_state": "fresh",
                    "warnings": [],
                    "objects": [
                        {"rsync_uri":"rsync://repo.example/rpki/m.mft","sha256_hex": object_sha,"kind":"manifest","result":"ok"}
                    ]
                }
            ],
            "vrps": [],
            "aspas": [],
            "downloads": [],
            "download_stats": {}
        });
        fs::write(
            run_dir.join("report.json"),
            serde_json::to_vec(&report).unwrap(),
        )
        .expect("report");
        let summary = json!({
            "status": "success",
            "runId": "run_0001",
            "runSeq": 1,
            "startedAtRfc3339Utc": "2026-06-15T00:00:00Z",
            "finishedAtRfc3339Utc": "2026-06-15T00:01:00Z",
            "wallMs": 60000,
            "reportCounts": {"vrps": 0, "aspas": 0, "publicationPoints": 1, "warnings": 0}
        });
        fs::write(
            run_dir.join("run-summary.json"),
            serde_json::to_vec(&summary).unwrap(),
        )
        .expect("summary");
        fs::write(run_dir.join("stage-timing.json"), b"{}").expect("stage");
    }

    fn percent_encode_for_test(value: &str) -> String {
        value
            .bytes()
            .flat_map(|byte| match byte {
                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                    vec![byte as char]
                }
                _ => format!("%{byte:02X}").chars().collect(),
            })
            .collect()
    }
}
