use std::collections::BTreeSet;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{Context, Result, bail};
use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use chrono::{SecondsFormat, Utc};
use clap::{Parser, Subcommand};
use ed25519_dalek::{Signer, SigningKey};
use flate2::write::GzEncoder;
use flate2::{Compression, GzBuilder};
use rand_core::{OsRng, RngCore};
use reqwest::blocking::Client;
use reqwest::blocking::multipart::{Form, Part};
use serde::Serialize;
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
use tar::{Builder, EntryType, Header};
use tempfile::tempdir;

const DEFAULT_REGISTRY: &str = "https://registry.regesta.dev";
const DEFAULT_CHANNEL: &str = "latest";
const REGESTA_CONFIG_FILE: &str = "regesta.json";
const SSH_SIGNATURE_NAMESPACE: &str = "regesta";

const DEFAULT_SOURCE_ENTRIES: &[&str] = &[
    REGESTA_CONFIG_FILE,
    "package.json",
    "README.md",
    "LICENSE",
    "src",
];

#[derive(Parser)]
#[command(name = "regesta", version)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    #[command(about = "Generate Ed25519 publish key files")]
    Keygen {
        #[arg(default_value = ".")]
        output_dir: PathBuf,
        #[arg(long)]
        domain: Option<String>,
        #[arg(long)]
        force: bool,
        #[arg(long)]
        kid: Option<String>,
    },
    #[command(about = "Publish a tarball-backed package")]
    Publish {
        cwd: Option<PathBuf>,
        #[arg(long)]
        auth_key: Option<PathBuf>,
        #[arg(long)]
        kid: Option<String>,
        #[arg(long, default_value = DEFAULT_REGISTRY)]
        registry: String,
        #[arg(long)]
        signing_format: Option<String>,
        #[arg(long)]
        ssh_signing_key: Option<String>,
        #[arg(long)]
        ssh_signing_program: Option<String>,
    },
    #[command(about = "Prepare publish payload without uploading")]
    Pack { cwd: Option<PathBuf> },
    #[command(about = "Verify a published package release")]
    Verify {
        spec: String,
        #[arg(long, default_value = DEFAULT_REGISTRY)]
        registry: String,
    },
    #[command(about = "Verify the public registry event log")]
    VerifyLog {
        #[arg(long, default_value = DEFAULT_REGISTRY)]
        registry: String,
        #[arg(long)]
        limit: Option<u16>,
        #[arg(long)]
        max_pages: Option<usize>,
    },
    #[command(about = "Verify public package state")]
    VerifyPackage {
        package_id: String,
        #[arg(long, default_value = DEFAULT_REGISTRY)]
        registry: String,
        #[arg(long)]
        limit: Option<u16>,
        #[arg(long)]
        max_pages: Option<usize>,
    },
    #[command(about = "Compare two public registry event-log views")]
    CompareLogs {
        left_registry: String,
        right_registry: String,
        #[arg(long)]
        limit: Option<u16>,
        #[arg(long)]
        max_pages: Option<usize>,
    },
    #[command(about = "Mirror public registry facts to a directory")]
    Mirror {
        output_dir: PathBuf,
        #[arg(long, default_value = DEFAULT_REGISTRY)]
        registry: String,
        #[arg(long)]
        limit: Option<u16>,
        #[arg(long)]
        max_pages: Option<usize>,
    },
    #[command(about = "Compare two local mirror directories")]
    CompareMirrors {
        left_dir: PathBuf,
        right_dir: PathBuf,
    },
}

#[derive(Debug, Clone)]
struct ParsedPackageId {
    ecosystem: String,
    id: String,
    owner_domain: String,
}

#[derive(Debug, Clone)]
struct PackageVersion {
    id: String,
    version: String,
}

#[derive(Debug)]
struct PreparedPublish {
    artifacts: Vec<PublishArtifact>,
    config: Value,
    id: String,
    source: Vec<u8>,
    version: String,
}

#[derive(Debug)]
struct PublishArtifact {
    bytes: Vec<u8>,
    compatibility: Option<Value>,
    filename: Option<String>,
    format: Option<String>,
    media_type: String,
    role: String,
}

#[derive(Debug)]
struct SourceArchive {
    bytes: Vec<u8>,
}

#[derive(Debug)]
struct PackageJsonDefaults {
    description: Option<String>,
    exports: Option<Value>,
    name: Option<String>,
    repository: Option<String>,
    version: Option<String>,
}

#[derive(Serialize)]
struct EventLogResult {
    #[serde(rename = "checkedEvents")]
    checked_events: usize,
    #[serde(skip_serializing_if = "Option::is_none", rename = "lastEventId")]
    last_event_id: Option<String>,
    ok: bool,
    packages: usize,
    problems: Vec<String>,
}

fn main() -> Result<()> {
    let cli = Cli::parse();

    match cli.command {
        Commands::Keygen {
            output_dir,
            domain,
            force,
            kid,
        } => print_json(&keygen(&output_dir, domain, force, kid)?),
        Commands::Publish {
            cwd,
            auth_key,
            kid,
            registry,
            signing_format,
            ssh_signing_key,
            ssh_signing_program,
        } => {
            let project_dir = cwd.unwrap_or(std::env::current_dir()?);
            let response = publish(PublishOptions {
                auth_key,
                kid,
                project_dir,
                registry,
                signing_format,
                ssh_signing_key,
                ssh_signing_program,
            })?;
            print_json(&response);
        }
        Commands::Pack { cwd } => {
            let project_dir = cwd.unwrap_or(std::env::current_dir()?);
            let prepared = prepare_npm_publish(&project_dir)?;
            print_json(&json!({
                "config": prepared.config,
                "artifactBytes": prepared.artifacts.iter().map(|artifact| {
                    json!({
                        "bytes": artifact.bytes.len(),
                        "role": artifact.role,
                    })
                }).collect::<Vec<_>>(),
                "sourceBytes": prepared.source.len(),
            }));
        }
        Commands::Verify { spec, registry } => {
            let parsed = parse_package_version(&spec)?;
            let value = fetch_json(&release_verification_url(
                &registry,
                &parsed.id,
                &parsed.version,
            )?)?;
            let ok = json_bool(&value, "ok").unwrap_or(false);
            print_json(&value);
            if !ok {
                std::process::exit(1);
            }
        }
        Commands::VerifyLog {
            registry,
            limit,
            max_pages,
        } => {
            let result = verify_log(&registry, limit, max_pages)?;
            let ok = result.ok;
            print_json(&result);
            if !ok {
                std::process::exit(1);
            }
        }
        Commands::VerifyPackage {
            package_id,
            registry,
            limit,
            max_pages,
        } => {
            let parsed = parse_package_id(&package_id)?;
            let log = verify_package_log(&registry, &parsed.id, limit, max_pages)?;
            let state_url = package_url(&registry, &parsed.id)?;
            let state = fetch_json(&state_url)?;
            let ok = log.ok;
            print_json(&json!({
                "checkedEvents": log.checked_events,
                "lastEventId": log.last_event_id,
                "ok": ok,
                "problems": log.problems,
                "state": state,
            }));
            if !ok {
                std::process::exit(1);
            }
        }
        Commands::CompareLogs {
            left_registry,
            right_registry,
            limit,
            max_pages,
        } => {
            let left = fetch_event_log(&left_registry, limit, max_pages)?;
            let right = fetch_event_log(&right_registry, limit, max_pages)?;
            let left_events = left.events;
            let right_events = right.events;
            let checked_events = left_events.len().min(right_events.len());
            let mut problems = Vec::new();
            if left_events.len() != right_events.len() {
                problems.push("Event logs have different lengths".to_string());
            }
            for (index, (left_event, right_event)) in
                left_events.iter().zip(right_events.iter()).enumerate()
            {
                if canonical_json(left_event)? != canonical_json(right_event)? {
                    problems.push(format!("Event logs differ at index {index}"));
                    break;
                }
            }
            let ok = problems.is_empty();
            print_json(&json!({
                "checkedEvents": checked_events,
                "left": {
                    "checkedEvents": left_events.len(),
                    "lastEventId": left.last_event_id,
                    "packages": packages_from_events(&left_events).len(),
                    "registry": left_registry,
                },
                "ok": ok,
                "problems": problems,
                "right": {
                    "checkedEvents": right_events.len(),
                    "lastEventId": right.last_event_id,
                    "packages": packages_from_events(&right_events).len(),
                    "registry": right_registry,
                },
            }));
            if !ok {
                std::process::exit(1);
            }
        }
        Commands::Mirror {
            output_dir,
            registry,
            limit,
            max_pages,
        } => {
            let result = mirror_registry(&registry, &output_dir, limit, max_pages)?;
            let ok = json_bool(&result, "ok").unwrap_or(false);
            print_json(&result);
            if !ok {
                std::process::exit(1);
            }
        }
        Commands::CompareMirrors {
            left_dir,
            right_dir,
        } => {
            let left = read_json_file(&left_dir.join("inventory.json"))?;
            let right = read_json_file(&right_dir.join("inventory.json"))?;
            let ok = canonical_json(&left)? == canonical_json(&right)?;
            print_json(&json!({
                "checkedEvents": array_len_at(&left, "events").min(array_len_at(&right, "events")),
                "checkedObjects": array_len_at(&left, "objects").min(array_len_at(&right, "objects")),
                "checkedReleases": array_len_at(&left, "releases").min(array_len_at(&right, "releases")),
                "left": left,
                "ok": ok,
                "problems": if ok { Vec::<String>::new() } else { vec!["Mirror inventories differ".to_string()] },
                "right": right,
            }));
            if !ok {
                std::process::exit(1);
            }
        }
    }

    Ok(())
}

struct PublishOptions {
    auth_key: Option<PathBuf>,
    kid: Option<String>,
    project_dir: PathBuf,
    registry: String,
    signing_format: Option<String>,
    ssh_signing_key: Option<String>,
    ssh_signing_program: Option<String>,
}

fn keygen(
    output_dir: &Path,
    domain: Option<String>,
    force: bool,
    kid: Option<String>,
) -> Result<Value> {
    fs::create_dir_all(output_dir)
        .with_context(|| format!("failed to create {}", output_dir.display()))?;

    let signing_key = SigningKey::generate(&mut OsRng);
    let verifying_key = signing_key.verifying_key();
    let kid = kid.unwrap_or_else(random_ed25519_key_id);
    let private_key_jwk = json!({
        "crv": "Ed25519",
        "d": URL_SAFE_NO_PAD.encode(signing_key.to_bytes()),
        "kty": "OKP",
        "x": URL_SAFE_NO_PAD.encode(verifying_key.to_bytes()),
    });
    let private_key_file = json!({
        "kid": kid,
        "privateKeyJwk": private_key_jwk,
    });
    let public_key_file = json!({
        "alg": "EdDSA",
        "kid": kid,
        "publicKeyJwk": {
            "crv": "Ed25519",
            "kty": "OKP",
            "x": URL_SAFE_NO_PAD.encode(verifying_key.to_bytes()),
        },
        "use": "regesta-write",
    });

    let private_key_path = output_dir.join("private-key.json");
    let public_key_path = output_dir.join("public-key.json");
    write_json_file(&private_key_path, &private_key_file, force, true)?;
    write_json_file(&public_key_path, &public_key_file, force, false)?;

    let mut result = Map::new();
    result.insert(
        "privateKey".to_string(),
        Value::String(private_key_path.to_string_lossy().into_owned()),
    );
    result.insert(
        "publicKey".to_string(),
        Value::String(public_key_path.to_string_lossy().into_owned()),
    );

    if let Some(domain) = domain {
        let domain_binding = json!({
            "domain": domain,
            "keys": [public_key_file],
            "object": "regesta.domain-binding",
        });
        let domain_binding_path = output_dir.join("domain-binding.json");
        write_json_file(&domain_binding_path, &domain_binding, force, false)?;
        result.insert(
            "domainBinding".to_string(),
            Value::String(domain_binding_path.to_string_lossy().into_owned()),
        );
    }

    Ok(Value::Object(result))
}

fn publish(options: PublishOptions) -> Result<Value> {
    let registry = normalize_registry_url(&options.registry);
    let prepared = prepare_npm_publish(&options.project_dir)?;
    let artifact_descriptors = release_publish_artifact_descriptors(&prepared.artifacts);
    let intent = create_release_publish_intent(
        &prepared.id,
        &prepared.version,
        sha256_digest(canonical_json(&prepared.config)?.as_bytes()),
        sha256_digest(&prepared.source),
        prepared
            .artifacts
            .iter()
            .map(|artifact| sha256_digest(&artifact.bytes))
            .collect(),
        release_publish_artifact_descriptor_digest(&artifact_descriptors)?,
    )?;
    let authorization = create_authorization(&intent, &options)?;
    let artifact_parts = prepared
        .artifacts
        .iter()
        .enumerate()
        .map(|(index, artifact)| {
            let mut item = Map::new();
            item.insert(
                "mediaType".to_string(),
                Value::String(artifact.media_type.clone()),
            );
            item.insert(
                "part".to_string(),
                Value::String(format!("artifact.{index}")),
            );
            item.insert("role".to_string(), Value::String(artifact.role.clone()));
            if let Some(filename) = &artifact.filename {
                item.insert("filename".to_string(), Value::String(filename.clone()));
            }
            if let Some(format) = &artifact.format {
                item.insert("format".to_string(), Value::String(format.clone()));
            }
            if let Some(compatibility) = &artifact.compatibility {
                item.insert("compatibility".to_string(), compatibility.clone());
            }
            Value::Object(item)
        })
        .collect::<Vec<_>>();

    let mut form = Form::new()
        .text("config", serde_json::to_string(&prepared.config)?)
        .text("authorization", serde_json::to_string(&authorization)?)
        .text("artifacts", serde_json::to_string(&artifact_parts)?)
        .part(
            "source",
            Part::bytes(prepared.source)
                .file_name("source.tgz")
                .mime_str("application/vnd.regesta.source-archive+tgz")?,
        );

    for (index, artifact) in prepared.artifacts.into_iter().enumerate() {
        let filename = artifact
            .filename
            .clone()
            .unwrap_or_else(|| format!("artifact-{index}"));
        form = form.part(
            format!("artifact.{index}"),
            Part::bytes(artifact.bytes)
                .file_name(filename)
                .mime_str(&artifact.media_type)?,
        );
    }

    let response = Client::new()
        .post(format!("{registry}/releases"))
        .multipart(form)
        .send()
        .context("publish request failed")?;
    let status = response.status();
    let text = response.text().context("failed to read publish response")?;
    let value = parse_json_text(&text).unwrap_or_else(|_| json!({ "text": text }));

    if !status.is_success() {
        bail!("{}", serde_json::to_string_pretty(&value)?);
    }

    Ok(value)
}

fn prepare_npm_publish(project_dir: &Path) -> Result<PreparedPublish> {
    let config = read_npm_regesta_config(project_dir)?;
    let id = string_field(&config, "id")?.to_string();
    let version = string_field(&config, "version")?.to_string();
    let source_archive = create_source_archive(project_dir, &config)?;
    let tarball = create_npm_package_tarball(project_dir)?;

    Ok(PreparedPublish {
        artifacts: vec![PublishArtifact {
            bytes: tarball.0,
            compatibility: None,
            filename: tarball.1,
            format: Some("npm-tarball".to_string()),
            media_type: "application/gzip".to_string(),
            role: "install".to_string(),
        }],
        config,
        id,
        source: source_archive.bytes,
        version,
    })
}

fn read_npm_regesta_config(project_dir: &Path) -> Result<Value> {
    let raw = fs::read_to_string(project_dir.join(REGESTA_CONFIG_FILE))
        .with_context(|| format!("failed to read {REGESTA_CONFIG_FILE}"))?;
    let config_input: Value = json5::from_str(&raw).context("failed to parse regesta.json")?;
    let package_json = read_npm_package_json_defaults(project_dir)?;
    let normalized_input =
        normalize_npm_regesta_config_input(config_input, package_json.name.as_deref())?;
    let config = normalize_regesta_config(normalized_input, package_json)?;
    let parsed = parse_package_id(string_field(&config, "id")?)?;
    if parsed.ecosystem != "npm" {
        bail!("npm publish config must use npm ecosystem: {}", parsed.id);
    }

    Ok(config)
}

fn read_npm_package_json_defaults(project_dir: &Path) -> Result<PackageJsonDefaults> {
    let path = project_dir.join("package.json");
    let text = match fs::read_to_string(&path) {
        Ok(text) => text,
        Err(error) if error.kind() == io::ErrorKind::NotFound => {
            return Ok(PackageJsonDefaults {
                description: None,
                exports: None,
                name: None,
                repository: None,
                version: None,
            });
        }
        Err(error) => {
            return Err(error).with_context(|| format!("failed to read {}", path.display()));
        }
    };
    let value: Value = serde_json::from_str(&text).context("failed to parse package.json")?;
    let Some(record) = value.as_object() else {
        return Ok(PackageJsonDefaults {
            description: None,
            exports: None,
            name: None,
            repository: None,
            version: None,
        });
    };

    Ok(PackageJsonDefaults {
        description: record
            .get("description")
            .and_then(Value::as_str)
            .map(str::to_string),
        exports: record.get("exports").cloned().filter(is_package_export),
        name: record
            .get("name")
            .and_then(Value::as_str)
            .map(str::to_string),
        repository: normalize_repository(record.get("repository")),
        version: record
            .get("version")
            .and_then(Value::as_str)
            .map(str::to_string),
    })
}

fn normalize_npm_regesta_config_input(
    value: Value,
    package_json_name: Option<&str>,
) -> Result<Value> {
    let Some(record) = value.as_object() else {
        return Ok(value);
    };
    let mut output = record.clone();

    match output.get("id") {
        Some(Value::String(id)) if !id.contains(':') => {
            output.insert(
                "id".to_string(),
                Value::String(npm_package_id_from_name(id)?),
            );
        }
        None => {
            if let Some(name) = package_json_name {
                output.insert(
                    "id".to_string(),
                    Value::String(npm_package_id_from_name(name)?),
                );
            }
        }
        _ => {}
    }

    Ok(Value::Object(output))
}

fn normalize_regesta_config(value: Value, defaults: PackageJsonDefaults) -> Result<Value> {
    let record = value
        .as_object()
        .ok_or_else(|| anyhow::anyhow!("regesta.json must be an object"))?;

    if record.contains_key("$schema") || record.contains_key("schema") {
        bail!("regesta.json schema fields are not supported");
    }
    if record.contains_key("compatibility") {
        bail!(
            "regesta.json compatibility is not supported; attach compatibility to publish artifacts"
        );
    }
    if record.contains_key("dependencies") {
        bail!("regesta.json dependencies are not supported; use ecosystem-native manifests");
    }
    assert_known_fields(
        record,
        &[
            "description",
            "exports",
            "family",
            "id",
            "languages",
            "provenance",
            "repository",
            "source",
            "version",
        ],
        "regesta.json",
    )?;

    let id_value = record
        .get("id")
        .cloned()
        .or_else(|| {
            defaults
                .name
                .as_ref()
                .and_then(|name| npm_package_id_from_name(name).ok())
                .map(Value::String)
        })
        .ok_or_else(|| anyhow::anyhow!("regesta.json id must be a package id string"))?;
    let id = parse_package_id(
        id_value
            .as_str()
            .ok_or_else(|| anyhow::anyhow!("regesta.json id must be a package id string"))?,
    )?
    .id;
    let version = record
        .get("version")
        .and_then(Value::as_str)
        .or(defaults.version.as_deref())
        .ok_or_else(|| anyhow::anyhow!("regesta.json version must be a non-empty string"))?;
    assert_non_empty_no_control(version, "regesta.json version")?;

    let mut config = Map::new();
    config.insert("id".to_string(), Value::String(id));
    config.insert(
        "provenance".to_string(),
        normalize_provenance(record.get("provenance"))?,
    );
    config.insert("source".to_string(), normalize_source(record)?);
    config.insert("version".to_string(), Value::String(version.to_string()));

    let description = record
        .get("description")
        .and_then(Value::as_str)
        .or(defaults.description.as_deref());
    if let Some(description) = description {
        config.insert(
            "description".to_string(),
            Value::String(description.to_string()),
        );
    }

    let repository = record
        .get("repository")
        .and_then(Value::as_str)
        .or(defaults.repository.as_deref());
    if let Some(repository) = repository {
        config.insert(
            "repository".to_string(),
            Value::String(repository.to_string()),
        );
    }

    if let Some(languages) = record.get("languages") {
        config.insert(
            "languages".to_string(),
            Value::Array(normalize_string_array(languages, "regesta.json languages")?),
        );
    }

    let exports_value = record.get("exports").cloned().or(defaults.exports);
    if let Some(exports) = exports_value {
        if !is_package_export(&exports) {
            bail!("package exports must be JSON string, null, array, or object values");
        }
        config.insert("exports".to_string(), exports);
    }

    if let Some(family) = record.get("family") {
        let family = family
            .as_str()
            .ok_or_else(|| anyhow::anyhow!("regesta.json family must be a string"))?;
        config.insert("family".to_string(), Value::String(family.to_string()));
    }

    Ok(Value::Object(config))
}

fn normalize_provenance(value: Option<&Value>) -> Result<Value> {
    let Some(value) = value else {
        return Ok(json!({ "level": "source-attached" }));
    };
    let record = value
        .as_object()
        .ok_or_else(|| anyhow::anyhow!("regesta.json provenance must be an object"))?;
    assert_known_fields(record, &["level"], "regesta.json provenance")?;
    let level = record
        .get("level")
        .and_then(Value::as_str)
        .unwrap_or("source-attached");
    if level != "source-attached" {
        bail!("regesta.json provenance.level must be source-attached");
    }
    Ok(json!({ "level": level }))
}

fn normalize_source(record: &Map<String, Value>) -> Result<Value> {
    let source = record
        .get("source")
        .ok_or_else(|| anyhow::anyhow!("regesta.json source is required"))?
        .as_object()
        .ok_or_else(|| anyhow::anyhow!("regesta.json source must be an object"))?;
    assert_known_fields(source, &["exclude", "include"], "regesta.json source")?;
    let mut output = Map::new();

    if let Some(exclude) = source.get("exclude") {
        let items = normalize_source_path_array(exclude, "regesta.json source.exclude")?;
        if items
            .iter()
            .any(|item| trim_trailing_slashes(item) == REGESTA_CONFIG_FILE)
        {
            bail!("regesta.json source.exclude must not exclude regesta.json");
        }
        output.insert(
            "exclude".to_string(),
            Value::Array(items.into_iter().map(Value::String).collect()),
        );
    }

    if let Some(include) = source.get("include") {
        let items = normalize_source_path_array(include, "regesta.json source.include")?;
        output.insert(
            "include".to_string(),
            Value::Array(items.into_iter().map(Value::String).collect()),
        );
    }

    Ok(Value::Object(output))
}

fn create_source_archive(project_dir: &Path, config: &Value) -> Result<SourceArchive> {
    let source = config
        .get("source")
        .and_then(Value::as_object)
        .ok_or_else(|| anyhow::anyhow!("regesta config source is required"))?;
    let include = source.get("include").and_then(Value::as_array);
    let exclude = source
        .get("exclude")
        .and_then(Value::as_array)
        .map(|items| {
            items
                .iter()
                .filter_map(Value::as_str)
                .map(str::to_string)
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    let requested_entries = if let Some(include) = include {
        include
            .iter()
            .map(|item| {
                item.as_str()
                    .map(str::to_string)
                    .ok_or_else(|| anyhow::anyhow!("regesta source include paths must be a string"))
            })
            .collect::<Result<Vec<_>>>()?
    } else {
        DEFAULT_SOURCE_ENTRIES
            .iter()
            .map(|item| item.to_string())
            .collect()
    };
    let mut entries = Vec::new();
    let require_entries = include.is_some();

    for entry in requested_entries {
        let normalized = normalize_archive_path(&entry, "regesta source include")?;
        if is_excluded(&normalized, &exclude) {
            continue;
        }
        let full_path = project_dir.join(&normalized);
        if require_entries {
            fs::metadata(&full_path)
                .with_context(|| format!("missing source archive entry {}", normalized))?;
            entries.push(normalized);
        } else if full_path.exists() {
            entries.push(normalized);
        }
    }

    if !entries
        .iter()
        .any(|entry| trim_trailing_slashes(entry) == REGESTA_CONFIG_FILE)
    {
        entries.push(REGESTA_CONFIG_FILE.to_string());
    }
    entries.sort();
    entries.dedup();

    let encoder = GzBuilder::new()
        .mtime(0)
        .write(Vec::new(), Compression::default());
    let mut builder = Builder::new(encoder);
    for entry in &entries {
        append_archive_entry(&mut builder, project_dir, entry, &exclude)?;
    }
    let encoder: GzEncoder<Vec<u8>> = builder.into_inner()?;
    let bytes = encoder.finish()?;

    Ok(SourceArchive { bytes })
}

fn append_archive_entry(
    builder: &mut Builder<GzEncoder<Vec<u8>>>,
    project_dir: &Path,
    entry: &str,
    exclude: &[String],
) -> Result<()> {
    if is_excluded(entry, exclude) {
        return Ok(());
    }
    let full_path = project_dir.join(entry);
    let metadata = fs::metadata(&full_path)
        .with_context(|| format!("failed to stat source archive entry {entry}"))?;
    if metadata.is_dir() {
        append_directory(builder, entry)?;
        let mut children = fs::read_dir(&full_path)?
            .map(|item| item.map(|entry| entry.file_name().to_string_lossy().into_owned()))
            .collect::<io::Result<Vec<_>>>()?;
        children.sort();
        for child in children {
            let child_entry = format!("{}/{}", trim_trailing_slashes(entry), child);
            append_archive_entry(builder, project_dir, &child_entry, exclude)?;
        }
        return Ok(());
    }
    if metadata.is_file() {
        let mut file = File::open(&full_path)?;
        let mut bytes = Vec::new();
        file.read_to_end(&mut bytes)?;
        let mut header = Header::new_gnu();
        header.set_entry_type(EntryType::Regular);
        header.set_size(bytes.len() as u64);
        header.set_mode(0o644);
        header.set_mtime(0);
        header.set_cksum();
        builder.append_data(&mut header, entry, bytes.as_slice())?;
    }
    Ok(())
}

fn append_directory(builder: &mut Builder<GzEncoder<Vec<u8>>>, entry: &str) -> Result<()> {
    let mut path = trim_trailing_slashes(entry).to_string();
    path.push('/');
    let mut header = Header::new_gnu();
    header.set_entry_type(EntryType::Directory);
    header.set_size(0);
    header.set_mode(0o755);
    header.set_mtime(0);
    header.set_cksum();
    builder.append_data(&mut header, path, io::empty())?;
    Ok(())
}

fn create_npm_package_tarball(project_dir: &Path) -> Result<(Vec<u8>, Option<String>)> {
    let output_dir = tempdir()?;
    let package_manager = detect_package_manager(project_dir)?;
    let mut command = Command::new(&package_manager);

    match package_manager.as_str() {
        "pnpm" => {
            command.args(["pack", "--pack-destination"]);
            command.arg(output_dir.path());
        }
        "npm" => {
            command.args(["pack", "--pack-destination"]);
            command.arg(output_dir.path());
        }
        _ => bail!(
            "Unsupported package manager for npm tarball generation: {}",
            package_manager
        ),
    }

    let output = command
        .current_dir(project_dir)
        .output()
        .with_context(|| format!("failed to run {package_manager} pack"))?;
    if !output.status.success() {
        bail!(
            "{} pack failed with exit code {:?}: {}",
            package_manager,
            output.status.code(),
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }

    let tarballs = fs::read_dir(output_dir.path())?
        .filter_map(|entry| entry.ok())
        .map(|entry| entry.path())
        .filter(|path| path.extension().and_then(|item| item.to_str()) == Some("tgz"))
        .collect::<Vec<_>>();
    if tarballs.len() != 1 {
        bail!(
            "Package manager pack must produce exactly one .tgz file, found {}",
            tarballs.len()
        );
    }
    let path = &tarballs[0];
    let filename = path
        .file_name()
        .and_then(|item| item.to_str())
        .map(str::to_string);
    Ok((fs::read(path)?, filename))
}

fn detect_package_manager(project_dir: &Path) -> Result<String> {
    if let Some(package_manager) = find_package_manager_field(project_dir)? {
        return Ok(package_manager);
    }
    if project_dir.join("pnpm-lock.yaml").exists() {
        return Ok("pnpm".to_string());
    }
    if project_dir.join("package-lock.json").exists() {
        return Ok("npm".to_string());
    }
    Ok("npm".to_string())
}

fn find_package_manager_field(project_dir: &Path) -> Result<Option<String>> {
    let mut current = project_dir.to_path_buf();
    loop {
        let package_json_path = current.join("package.json");
        match fs::read_to_string(&package_json_path) {
            Ok(text) => {
                let value: Value = serde_json::from_str(&text)
                    .with_context(|| format!("failed to parse {}", package_json_path.display()))?;
                if let Some(package_manager) = value
                    .get("packageManager")
                    .and_then(Value::as_str)
                    .and_then(|value| value.split('@').next())
                {
                    return Ok(Some(package_manager.to_string()));
                }
            }
            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
            Err(error) => return Err(error.into()),
        }
        if !current.pop() {
            return Ok(None);
        }
    }
}

fn create_authorization(intent: &Value, options: &PublishOptions) -> Result<Value> {
    let format = options.signing_format.clone().unwrap_or_else(|| {
        if options.auth_key.is_some() {
            "ed25519"
        } else {
            "ssh"
        }
        .to_string()
    });
    match format.as_str() {
        "ed25519" => create_ed25519_authorization(intent, options),
        "ssh" => create_ssh_authorization(intent, options),
        _ => bail!("Unsupported signing format: {}", format),
    }
}

fn create_ed25519_authorization(intent: &Value, options: &PublishOptions) -> Result<Value> {
    let auth_key = options
        .auth_key
        .as_ref()
        .ok_or_else(|| anyhow::anyhow!("Missing --auth-key for Ed25519 signed publish"))?;
    let key_file = read_json_file(auth_key)?;
    let private_key_jwk = key_file
        .get("privateKeyJwk")
        .unwrap_or(&key_file)
        .as_object()
        .ok_or_else(|| anyhow::anyhow!("privateKeyJwk must be an object"))?;
    let kid = options
        .kid
        .clone()
        .or_else(|| {
            key_file
                .get("kid")
                .and_then(Value::as_str)
                .map(str::to_string)
        })
        .ok_or_else(|| anyhow::anyhow!("Missing --kid or kid in auth key file"))?;
    let d = private_key_jwk
        .get("d")
        .and_then(Value::as_str)
        .ok_or_else(|| anyhow::anyhow!("privateKeyJwk must include x and d"))?;
    let x = private_key_jwk
        .get("x")
        .and_then(Value::as_str)
        .ok_or_else(|| anyhow::anyhow!("privateKeyJwk must include x and d"))?;
    if private_key_jwk.get("kty").and_then(Value::as_str) != Some("OKP")
        || private_key_jwk.get("crv").and_then(Value::as_str) != Some("Ed25519")
    {
        bail!("privateKeyJwk must be an Ed25519 OKP JWK");
    }
    let secret = URL_SAFE_NO_PAD
        .decode(d)
        .context("privateKeyJwk d must be base64url")?;
    let secret: [u8; 32] = secret
        .try_into()
        .map_err(|_| anyhow::anyhow!("privateKeyJwk d must decode to 32 bytes"))?;
    let signing_key = SigningKey::from_bytes(&secret);
    let public = URL_SAFE_NO_PAD
        .decode(x)
        .context("privateKeyJwk x must be base64url")?;
    if signing_key.verifying_key().to_bytes().as_slice() != public.as_slice() {
        bail!("privateKeyJwk public component does not match private key");
    }
    let signature = signing_key.sign(canonical_json(intent)?.as_bytes());
    Ok(json!({
        "alg": "EdDSA",
        "kid": kid,
        "payload": intent,
        "signature": URL_SAFE_NO_PAD.encode(signature.to_bytes()),
    }))
}

fn create_ssh_authorization(intent: &Value, options: &PublishOptions) -> Result<Value> {
    let signing_key = options
        .ssh_signing_key
        .as_ref()
        .ok_or_else(|| anyhow::anyhow!("Missing --ssh-signing-key for ssh signed publish"))?;
    let public_key = resolve_ssh_public_key(signing_key, &options.project_dir)?;
    let kid = options.kid.clone().unwrap_or_else(|| {
        ssh_ed25519_public_key_id(&public_key).unwrap_or_else(|_| "ssh-ed25519:invalid".to_string())
    });
    let signature = sign_with_ssh_program(
        options
            .ssh_signing_program
            .as_deref()
            .unwrap_or("ssh-keygen"),
        signing_key,
        canonical_json(intent)?.as_bytes(),
        &options.project_dir,
    )?;
    Ok(json!({
        "alg": "ssh-ed25519",
        "kid": kid,
        "payload": intent,
        "signature": signature,
    }))
}

fn sign_with_ssh_program(
    program: &str,
    signing_key: &str,
    payload: &[u8],
    cwd: &Path,
) -> Result<String> {
    let temp = tempdir()?;
    let payload_path = temp.path().join("payload");
    let signature_path = temp.path().join("payload.sig");
    fs::write(&payload_path, payload)?;
    let output = Command::new(program)
        .args([
            "-Y",
            "sign",
            "-f",
            signing_key,
            "-n",
            SSH_SIGNATURE_NAMESPACE,
        ])
        .arg(&payload_path)
        .current_dir(cwd)
        .output()
        .with_context(|| format!("failed to run SSH signing program {program}"))?;
    if !output.status.success() {
        bail!(
            "{} -Y sign failed with exit code {:?}: {}",
            program,
            output.status.code(),
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }
    if let Ok(signature) = fs::read_to_string(&signature_path)
        && !signature.trim().is_empty()
    {
        return Ok(signature);
    }
    let stdout = String::from_utf8_lossy(&output.stdout);
    extract_ssh_signature(&stdout)
        .map(str::to_string)
        .ok_or_else(|| anyhow::anyhow!("SSH signing program did not produce a signature"))
}

fn resolve_ssh_public_key(signing_key: &str, cwd: &Path) -> Result<String> {
    let trimmed = signing_key.trim();
    if trimmed.starts_with("ssh-ed25519 ") {
        return normalize_ssh_ed25519_public_key(trimmed);
    }
    let key_path = resolve_path(cwd, trimmed);
    if let Some(public_key) = read_ssh_public_key_file(&key_path)? {
        return Ok(public_key);
    }
    if let Some(public_key) =
        read_ssh_public_key_file(&PathBuf::from(format!("{}.pub", key_path.display())))?
    {
        return Ok(public_key);
    }
    bail!(
        "SSH signing key must be an ssh-ed25519 public key, public key file, or private key path with a .pub file"
    );
}

fn read_ssh_public_key_file(path: &Path) -> Result<Option<String>> {
    let text = match fs::read_to_string(path) {
        Ok(text) => text,
        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
        Err(error) => return Err(error.into()),
    };
    for line in text.lines().map(str::trim) {
        if line.starts_with("ssh-ed25519 ") {
            return Ok(Some(normalize_ssh_ed25519_public_key(line)?));
        }
    }
    Ok(None)
}

fn extract_ssh_signature(value: &str) -> Option<&str> {
    let start = value.find("-----BEGIN SSH SIGNATURE-----")?;
    let end_marker = "-----END SSH SIGNATURE-----";
    let end = value[start..].find(end_marker)? + start + end_marker.len();
    Some(&value[start..end])
}

fn release_publish_artifact_descriptors(artifacts: &[PublishArtifact]) -> Vec<Value> {
    artifacts
        .iter()
        .map(|artifact| {
            let mut item = Map::new();
            if let Some(compatibility) = &artifact.compatibility {
                item.insert("compatibility".to_string(), compatibility.clone());
            }
            item.insert(
                "digest".to_string(),
                Value::String(sha256_digest(&artifact.bytes)),
            );
            if let Some(filename) = &artifact.filename {
                item.insert("filename".to_string(), Value::String(filename.clone()));
            }
            if let Some(format) = &artifact.format {
                item.insert("format".to_string(), Value::String(format.clone()));
            }
            item.insert(
                "mediaType".to_string(),
                Value::String(artifact.media_type.clone()),
            );
            item.insert("role".to_string(), Value::String(artifact.role.clone()));
            Value::Object(item)
        })
        .collect()
}

fn release_publish_artifact_descriptor_digest(artifacts: &[Value]) -> Result<String> {
    if artifacts.is_empty() {
        bail!("artifactDescriptors must not be empty");
    }
    Ok(sha256_digest(
        canonical_json(&Value::Array(artifacts.to_vec()))?.as_bytes(),
    ))
}

fn create_release_publish_intent(
    package_id: &str,
    version: &str,
    config_digest: String,
    source_digest: String,
    artifact_digests: Vec<String>,
    artifact_descriptor_digest: String,
) -> Result<Value> {
    let parsed = parse_package_id(package_id)?;
    Ok(json!({
        "artifactDescriptorDigest": artifact_descriptor_digest,
        "artifactDigests": artifact_digests,
        "channel": DEFAULT_CHANNEL,
        "configDigest": config_digest,
        "domain": parsed.owner_domain,
        "nonce": random_nonce(),
        "object": "regesta.write-intent",
        "operation": "release.publish",
        "package": parsed.id,
        "sourceDigest": source_digest,
        "timestamp": canonical_now(),
        "version": version,
    }))
}

fn verify_log(
    registry: &str,
    limit: Option<u16>,
    max_pages: Option<usize>,
) -> Result<EventLogResult> {
    let events = fetch_event_log(registry, limit, max_pages)?;
    Ok(EventLogResult {
        checked_events: events.events.len(),
        last_event_id: events.last_event_id,
        ok: events.problems.is_empty(),
        packages: packages_from_events(&events.events).len(),
        problems: events.problems,
    })
}

fn verify_package_log(
    registry: &str,
    package_id: &str,
    limit: Option<u16>,
    max_pages: Option<usize>,
) -> Result<EventLogResult> {
    let events = fetch_event_log(registry, limit, max_pages)?;
    let package_events = events
        .events
        .into_iter()
        .filter(|event| registry_event_package_id(event).as_deref() == Some(package_id))
        .collect::<Vec<_>>();
    Ok(EventLogResult {
        checked_events: package_events.len(),
        last_event_id: package_events
            .last()
            .and_then(|event| event.get("id"))
            .and_then(Value::as_str)
            .map(str::to_string),
        ok: events.problems.is_empty(),
        packages: usize::from(!package_events.is_empty()),
        problems: events.problems,
    })
}

#[derive(Debug)]
struct EventLog {
    events: Vec<Value>,
    last_event_id: Option<String>,
    problems: Vec<String>,
}

fn fetch_event_log(
    registry: &str,
    limit: Option<u16>,
    max_pages: Option<usize>,
) -> Result<EventLog> {
    let limit = usize::from(limit.unwrap_or(999));
    if limit == 0 || limit > 999 {
        bail!("Event log page size must be an integer from 1 to 999");
    }
    let max_pages = max_pages.unwrap_or(1000);
    let mut events = Vec::new();
    let mut problems = Vec::new();
    let mut after: Option<String> = None;

    for _ in 0..max_pages {
        let page = fetch_json(&event_log_url(registry, after.as_deref(), limit))?;
        let Some(page_events) = page.get("events").and_then(Value::as_array) else {
            problems.push("Event log response must include events array".to_string());
            break;
        };
        if page_events.is_empty() {
            if page.get("nextAfter").is_some() {
                problems.push("Event log empty page must not include nextAfter".to_string());
            }
            return Ok(EventLog {
                events,
                last_event_id: after,
                problems,
            });
        }
        if page_events.len() > limit {
            problems.push("Event log page returned more events than requested".to_string());
            break;
        }
        let last_id = page_events
            .last()
            .and_then(|event| event.get("id"))
            .and_then(Value::as_str)
            .map(str::to_string);
        if page
            .get("nextAfter")
            .and_then(Value::as_str)
            .map(str::to_string)
            != last_id
        {
            problems.push("Event log nextAfter must match last event id".to_string());
            break;
        }
        events.extend(page_events.iter().cloned());
        after = last_id;
    }

    if problems.is_empty() {
        problems.push("Event log stopped before reaching tail".to_string());
    }
    Ok(EventLog {
        events,
        last_event_id: after,
        problems,
    })
}

fn mirror_registry(
    registry: &str,
    output_dir: &Path,
    limit: Option<u16>,
    max_pages: Option<usize>,
) -> Result<Value> {
    fs::create_dir_all(output_dir)?;
    let event_log = fetch_event_log(registry, limit, max_pages)?;
    let mirrored_at = canonical_now();
    let event_ids = event_log
        .events
        .iter()
        .filter_map(|event| event.get("id").and_then(Value::as_str).map(str::to_string))
        .collect::<Vec<_>>();
    let packages = packages_from_events(&event_log.events);
    fs::create_dir_all(output_dir.join("events"))?;
    for event in &event_log.events {
        if let Some(id) = event.get("id").and_then(Value::as_str) {
            fs::write(
                output_dir
                    .join("events")
                    .join(digest_file_name(id, "json")?),
                format!("{}\n", canonical_json(event)?),
            )?;
        }
    }

    let object_digests = mirror_objects(registry, output_dir, limit, max_pages)?;
    let inventory = json!({
        "events": event_ids,
        "kind": "regesta.local-mirror.inventory",
        "lastEventId": event_log.last_event_id,
        "mirroredAt": mirrored_at,
        "objects": object_digests,
        "ok": event_log.problems.is_empty(),
        "packages": packages.iter().cloned().collect::<Vec<_>>(),
        "problems": event_log.problems,
        "registry": normalize_registry_url(registry),
        "releases": releases_from_events(&event_log.events),
    });
    fs::write(
        output_dir.join("inventory.json"),
        format!("{}\n", canonical_json(&inventory)?),
    )?;

    Ok(json!({
        "events": array_len_at(&inventory, "events"),
        "lastEventId": inventory.get("lastEventId").cloned().unwrap_or(Value::Null),
        "mirroredAt": mirrored_at,
        "objects": array_len_at(&inventory, "objects"),
        "ok": json_bool(&inventory, "ok").unwrap_or(false),
        "outputDir": output_dir,
        "packages": array_len_at(&inventory, "packages"),
        "problems": inventory.get("problems").cloned().unwrap_or_else(|| json!([])),
        "registry": normalize_registry_url(registry),
        "releases": array_len_at(&inventory, "releases"),
    }))
}

fn mirror_objects(
    registry: &str,
    output_dir: &Path,
    limit: Option<u16>,
    max_pages: Option<usize>,
) -> Result<Vec<String>> {
    let limit = usize::from(limit.unwrap_or(999));
    let max_pages = max_pages.unwrap_or(1000);
    let mut after: Option<String> = None;
    let mut digests = Vec::new();
    fs::create_dir_all(output_dir.join("objects"))?;
    for _ in 0..max_pages {
        let page = fetch_json(&object_inventory_url(registry, after.as_deref(), limit))?;
        let Some(objects) = page.get("objects").and_then(Value::as_array) else {
            break;
        };
        if objects.is_empty() {
            break;
        }
        for object in objects {
            let Some(digest) = object.get("digest").and_then(Value::as_str) else {
                continue;
            };
            let bytes = fetch_bytes(&object_url(registry, digest)?)
                .with_context(|| format!("failed to mirror object {digest}"))?;
            fs::write(
                output_dir
                    .join("objects")
                    .join(digest_file_name(digest, "bin")?),
                bytes,
            )?;
            digests.push(digest.to_string());
        }
        after = page
            .get("nextAfter")
            .and_then(Value::as_str)
            .map(str::to_string);
        if after.is_none() {
            break;
        }
    }
    digests.sort();
    digests.dedup();
    Ok(digests)
}

fn fetch_json(url: &str) -> Result<Value> {
    let response = Client::new().get(url).send()?;
    let status = response.status();
    let text = response.text()?;
    if !status.is_success() {
        bail!("GET {url} failed with status {status}: {text}");
    }
    parse_json_text(&text).with_context(|| format!("GET {url} did not return JSON"))
}

fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
    let response = Client::new().get(url).send()?;
    let status = response.status();
    if !status.is_success() {
        bail!("GET {url} failed with status {status}");
    }
    Ok(response.bytes()?.to_vec())
}

fn parse_package_id(value: &str) -> Result<ParsedPackageId> {
    let Some((ecosystem, name)) = value.split_once(':') else {
        bail!("Invalid package id: {}", value);
    };
    if ecosystem.is_empty()
        || name.is_empty()
        || !ecosystem
            .chars()
            .all(|item| item.is_ascii_lowercase() || item.is_ascii_digit() || item == '-')
    {
        bail!("Invalid package id: {}", value);
    }
    if name.starts_with('@') {
        bail!(
            "Package id must not include native package syntax: {}",
            value
        );
    }
    if has_control_character(name) {
        bail!("Invalid package name: {}", value);
    }
    let Some((owner_domain, package_name)) = name.split_once('/') else {
        bail!("Package id must include an owner domain: {}", value);
    };
    if !is_canonical_owner_domain(owner_domain) {
        bail!("Package id owner must be a domain: {}", value);
    }
    if package_name.split('/').any(str::is_empty) {
        bail!("Package id name must not contain empty segments: {}", value);
    }
    Ok(ParsedPackageId {
        ecosystem: ecosystem.to_string(),
        id: format!("{ecosystem}:{name}"),
        owner_domain: owner_domain.to_string(),
    })
}

fn parse_package_version(value: &str) -> Result<PackageVersion> {
    let ecosystem_separator_index = value
        .find(':')
        .ok_or_else(|| anyhow::anyhow!("Package version must include a package id: {value}"))?;
    let separator_index = value
        .rfind('@')
        .ok_or_else(|| anyhow::anyhow!("Package version must include a version: {value}"))?;
    if separator_index <= ecosystem_separator_index {
        bail!("Package version must include a version: {}", value);
    }
    let id = parse_package_id(&value[..separator_index])?.id;
    let version = &value[separator_index + 1..];
    assert_non_empty_no_control(version, "Package version")?;
    Ok(PackageVersion {
        id,
        version: version.to_string(),
    })
}

fn is_canonical_owner_domain(value: &str) -> bool {
    value.len() <= 253
        && value.contains('.')
        && value.split('.').all(|label| {
            !label.is_empty()
                && label.len() <= 63
                && label
                    .chars()
                    .all(|item| item.is_ascii_lowercase() || item.is_ascii_digit() || item == '-')
                && label
                    .chars()
                    .next()
                    .is_some_and(|item| item.is_ascii_lowercase() || item.is_ascii_digit())
                && label
                    .chars()
                    .last()
                    .is_some_and(|item| item.is_ascii_lowercase() || item.is_ascii_digit())
        })
}

fn npm_package_id_from_name(package_name: &str) -> Result<String> {
    let Some(name) = package_name.strip_prefix('@') else {
        bail!("npm package name must be domain-scoped: {}", package_name);
    };
    let segments = name.split('/').collect::<Vec<_>>();
    if segments.len() != 2 || segments.iter().any(|segment| segment.is_empty()) {
        bail!("npm package name must be domain-scoped: {}", package_name);
    }
    Ok(parse_package_id(&format!("npm:{name}"))?.id)
}

fn normalize_archive_path(entry: &str, field: &str) -> Result<String> {
    assert_source_archive_path(entry, &format!("{field} paths"))?;
    Ok(entry.to_string())
}

fn assert_source_archive_path(value: &str, label: &str) -> Result<()> {
    if value.is_empty() {
        bail!("{label} must be non-empty");
    }
    if has_control_character(value) {
        bail!("{label} must not contain control characters");
    }
    if value.contains('\\') {
        bail!("{label} must use forward slashes");
    }
    if value.starts_with('/') || looks_like_windows_absolute_path(value) {
        bail!("{label} must be relative");
    }
    let segments = value.split('/').collect::<Vec<_>>();
    if value == "." || value.starts_with("./") || value.contains("//") || segments.contains(&".") {
        bail!("{label} must be normalized");
    }
    if segments.contains(&"..") {
        bail!("{label} must not contain parent directory segments");
    }
    Ok(())
}

fn normalize_source_path_array(value: &Value, field: &str) -> Result<Vec<String>> {
    let array = value
        .as_array()
        .ok_or_else(|| anyhow::anyhow!("{field} must be an array"))?;
    array
        .iter()
        .map(|item| {
            let path = item
                .as_str()
                .ok_or_else(|| anyhow::anyhow!("{field} must contain strings"))?;
            assert_source_archive_path(path, &format!("{field} paths"))?;
            Ok(path.to_string())
        })
        .collect()
}

fn is_excluded(entry: &str, excluded_entries: &[String]) -> bool {
    let normalized_entry = trim_trailing_slashes(entry);
    excluded_entries.iter().any(|excluded| {
        let excluded = trim_trailing_slashes(excluded);
        normalized_entry == excluded || normalized_entry.starts_with(&format!("{excluded}/"))
    })
}

fn canonical_json(value: &Value) -> Result<String> {
    match value {
        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
            Ok(serde_json::to_string(value)?)
        }
        Value::Array(items) => {
            let mut output = String::from("[");
            for (index, item) in items.iter().enumerate() {
                if index > 0 {
                    output.push(',');
                }
                output.push_str(&canonical_json(item)?);
            }
            output.push(']');
            Ok(output)
        }
        Value::Object(record) => {
            let mut keys = record.keys().collect::<Vec<_>>();
            keys.sort();
            let mut output = String::from("{");
            for (index, key) in keys.iter().enumerate() {
                if index > 0 {
                    output.push(',');
                }
                output.push_str(&serde_json::to_string(key)?);
                output.push(':');
                output.push_str(&canonical_json(&record[*key])?);
            }
            output.push('}');
            Ok(output)
        }
    }
}

fn sha256_digest(bytes: impl AsRef<[u8]>) -> String {
    let digest = Sha256::digest(bytes.as_ref());
    format!("sha256:{}", hex::encode(digest))
}

fn normalize_ssh_ed25519_public_key(public_key: &str) -> Result<String> {
    let parsed = parse_ssh_ed25519_public_key(public_key)?;
    Ok(format!("ssh-ed25519 {}", STANDARD.encode(parsed.blob)))
}

struct SshEd25519PublicKey {
    blob: Vec<u8>,
}

fn ssh_ed25519_public_key_id(public_key: &str) -> Result<String> {
    let parsed = parse_ssh_ed25519_public_key(public_key)?;
    let digest = Sha256::digest(&parsed.blob);
    Ok(format!("ssh-ed25519:{}", URL_SAFE_NO_PAD.encode(digest)))
}

fn parse_ssh_ed25519_public_key(value: &str) -> Result<SshEd25519PublicKey> {
    let mut parts = value.split_whitespace();
    if parts.next() != Some("ssh-ed25519") {
        bail!("publicKey must start with ssh-ed25519");
    }
    let encoded = parts
        .next()
        .ok_or_else(|| anyhow::anyhow!("publicKey must include key data"))?;
    let blob = STANDARD
        .decode(encoded)
        .context("invalid SSH public key base64")?;
    let mut reader = SshReader::new(&blob);
    let algorithm = reader.string_utf8()?;
    if algorithm != "ssh-ed25519" {
        bail!("publicKey algorithm must be ssh-ed25519");
    }
    let key = reader.string_bytes()?;
    if key.len() != 32 {
        bail!("publicKey Ed25519 key must be 32 bytes");
    }
    if !reader.is_empty() {
        bail!("publicKey must not include trailing data");
    }
    Ok(SshEd25519PublicKey { blob })
}

struct SshReader<'a> {
    bytes: &'a [u8],
    offset: usize,
}

impl<'a> SshReader<'a> {
    fn new(bytes: &'a [u8]) -> Self {
        Self { bytes, offset: 0 }
    }

    fn string_bytes(&mut self) -> Result<&'a [u8]> {
        if self.offset + 4 > self.bytes.len() {
            bail!("SSH string is truncated");
        }
        let length = u32::from_be_bytes(
            self.bytes[self.offset..self.offset + 4]
                .try_into()
                .expect("slice length checked"),
        ) as usize;
        self.offset += 4;
        if self.offset + length > self.bytes.len() {
            bail!("SSH string is truncated");
        }
        let value = &self.bytes[self.offset..self.offset + length];
        self.offset += length;
        Ok(value)
    }

    fn string_utf8(&mut self) -> Result<String> {
        Ok(std::str::from_utf8(self.string_bytes()?)?.to_string())
    }

    fn is_empty(&self) -> bool {
        self.offset == self.bytes.len()
    }
}

fn write_json_file(path: &Path, value: &Value, force: bool, private: bool) -> Result<()> {
    let mut options = OpenOptions::new();
    options.write(true);
    if force {
        options.create(true).truncate(true);
    } else {
        options.create_new(true);
    }
    #[cfg(unix)]
    if private {
        options.mode(0o600);
    }
    let mut file = options
        .open(path)
        .with_context(|| format!("failed to write {}", path.display()))?;
    serde_json::to_writer_pretty(&mut file, value)?;
    use std::io::Write;
    file.write_all(b"\n")?;
    Ok(())
}

fn read_json_file(path: &Path) -> Result<Value> {
    let text =
        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
    parse_json_text(&text).with_context(|| format!("failed to parse {}", path.display()))
}

fn parse_json_text(text: &str) -> Result<Value> {
    Ok(serde_json::from_str(text)?)
}

fn normalize_registry_url(registry: &str) -> String {
    registry.trim_end_matches('/').to_string()
}

fn release_verification_url(registry: &str, package_id: &str, version: &str) -> Result<String> {
    Ok(format!(
        "{}/packages/{}/releases/{}/verification",
        normalize_registry_url(registry),
        encoded_package_id(package_id)?,
        urlencoding::encode(version)
    ))
}

fn package_url(registry: &str, package_id: &str) -> Result<String> {
    Ok(format!(
        "{}/packages/{}",
        normalize_registry_url(registry),
        encoded_package_id(package_id)?
    ))
}

fn event_log_url(registry: &str, after: Option<&str>, limit: usize) -> String {
    let mut url = format!("{}/events?limit={limit}", normalize_registry_url(registry));
    if let Some(after) = after {
        url.push_str("&after=");
        url.push_str(&urlencoding::encode(after));
    }
    url
}

fn object_inventory_url(registry: &str, after: Option<&str>, limit: usize) -> String {
    let mut url = format!("{}/objects?limit={limit}", normalize_registry_url(registry));
    if let Some(after) = after {
        url.push_str("&after=");
        url.push_str(&urlencoding::encode(after));
    }
    url
}

fn object_url(registry: &str, digest: &str) -> Result<String> {
    Ok(format!(
        "{}/objects/{}",
        normalize_registry_url(registry),
        encoded_digest(digest)?
    ))
}

fn encoded_package_id(package_id: &str) -> Result<String> {
    Ok(urlencoding::encode(&parse_package_id(package_id)?.id).into_owned())
}

fn encoded_digest(digest: &str) -> Result<String> {
    let Some((algorithm, hex)) = digest.split_once(':') else {
        bail!("Invalid digest: {}", digest);
    };
    Ok(format!("{algorithm}/{hex}"))
}

fn digest_file_name(digest: &str, extension: &str) -> Result<String> {
    let Some((algorithm, hex)) = digest.split_once(':') else {
        bail!("Invalid digest: {}", digest);
    };
    Ok(format!("{algorithm}-{hex}.{extension}"))
}

fn print_json<T: Serialize>(value: &T) {
    println!(
        "{}",
        serde_json::to_string_pretty(value).expect("JSON output must serialize")
    );
}

fn packages_from_events(events: &[Value]) -> BTreeSet<String> {
    events
        .iter()
        .filter_map(registry_event_package_id)
        .collect()
}

fn releases_from_events(events: &[Value]) -> Vec<Value> {
    events
        .iter()
        .filter(|event| event.get("eventType").and_then(Value::as_str) == Some("release.published"))
        .filter_map(|event| {
            let package = event.get("release")?.get("id")?.as_str()?;
            let version = event.get("release")?.get("version")?.as_str()?;
            Some(json!({ "id": package, "version": version }))
        })
        .collect()
}

fn registry_event_package_id(event: &Value) -> Option<String> {
    if event.get("eventType").and_then(Value::as_str) == Some("release.published") {
        return event
            .get("release")?
            .get("id")?
            .as_str()
            .map(str::to_string);
    }

    event
        .get("package")
        .and_then(Value::as_str)
        .map(str::to_string)
}

fn array_len_at(value: &Value, field: &str) -> usize {
    value
        .get(field)
        .and_then(Value::as_array)
        .map_or(0, Vec::len)
}

fn json_bool(value: &Value, field: &str) -> Option<bool> {
    value.get(field).and_then(Value::as_bool)
}

fn random_ed25519_key_id() -> String {
    let mut bytes = [0_u8; 8];
    OsRng.fill_bytes(&mut bytes);
    format!("ed25519:{}", URL_SAFE_NO_PAD.encode(bytes))
}

fn random_nonce() -> String {
    let mut bytes = [0_u8; 16];
    OsRng.fill_bytes(&mut bytes);
    URL_SAFE_NO_PAD.encode(bytes)
}

fn canonical_now() -> String {
    Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}

fn resolve_path(cwd: &Path, path: &str) -> PathBuf {
    let path = PathBuf::from(path);
    if path.is_absolute() {
        path
    } else {
        cwd.join(path)
    }
}

fn normalize_repository(value: Option<&Value>) -> Option<String> {
    match value {
        Some(Value::String(value)) => Some(value.clone()),
        Some(Value::Object(record)) => record
            .get("url")
            .and_then(Value::as_str)
            .map(str::to_string),
        _ => None,
    }
}

fn is_package_export(value: &Value) -> bool {
    match value {
        Value::Null | Value::String(_) => true,
        Value::Array(items) => items.iter().all(is_package_export),
        Value::Object(record) => record.values().all(is_package_export),
        _ => false,
    }
}

fn normalize_string_array(value: &Value, field: &str) -> Result<Vec<Value>> {
    let array = value
        .as_array()
        .ok_or_else(|| anyhow::anyhow!("{field} must be an array"))?;
    array
        .iter()
        .map(|item| {
            let text = item
                .as_str()
                .ok_or_else(|| anyhow::anyhow!("{field} must contain non-empty strings"))?;
            if text.is_empty() {
                bail!("{field} must contain non-empty strings");
            }
            Ok(Value::String(text.to_string()))
        })
        .collect()
}

fn assert_known_fields(
    record: &Map<String, Value>,
    known_fields: &[&str],
    label: &str,
) -> Result<()> {
    let known = known_fields.iter().copied().collect::<BTreeSet<_>>();
    if let Some(unknown) = record.keys().find(|key| !known.contains(key.as_str())) {
        bail!("{label} must not include unknown field: {unknown}");
    }
    Ok(())
}

fn string_field<'a>(value: &'a Value, field: &str) -> Result<&'a str> {
    value
        .get(field)
        .and_then(Value::as_str)
        .ok_or_else(|| anyhow::anyhow!("{field} must be a string"))
}

fn assert_non_empty_no_control(value: &str, label: &str) -> Result<()> {
    if value.is_empty() {
        bail!("{label} must be a non-empty string");
    }
    if has_control_character(value) {
        bail!("{label} must not include control characters");
    }
    Ok(())
}

fn has_control_character(value: &str) -> bool {
    value.chars().any(|character| {
        let code = character as u32;
        code <= 0x1f || code == 0x7f
    })
}

fn looks_like_windows_absolute_path(value: &str) -> bool {
    let bytes = value.as_bytes();
    bytes.len() >= 3 && bytes[1] == b':' && bytes[2] == b'/' && bytes[0].is_ascii_alphabetic()
}

fn trim_trailing_slashes(value: &str) -> &str {
    value.trim_end_matches('/')
}
