mod config;

use anyhow::{anyhow, Context, Result};
use axum::body::Body;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post, put};
use axum::{Json, Router};
use clap::Parser;
use config::{MetadataBackend, ServerConfig, ServerMode};
use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path as FsPath, PathBuf};
use std::sync::Arc;
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::sync::Mutex;
use tracing_subscriber::EnvFilter;

#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
    #[arg(short, long, default_value = "coronafs.toml")]
    config: PathBuf,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct VolumeMetadata {
    id: String,
    size_bytes: u64,
    #[serde(default)]
    format: VolumeFileFormat,
    #[serde(default)]
    node_local: bool,
    #[serde(default)]
    materialized_from: Option<String>,
    port: Option<u16>,
    export_pid: Option<u32>,
    #[serde(default)]
    export_read_only: Option<bool>,
    created_at: String,
    updated_at: String,
}

#[derive(Debug, Serialize)]
struct VolumeResponse {
    id: String,
    size_bytes: u64,
    format: String,
    node_local: bool,
    materialized_from: Option<String>,
    path: String,
    export: Option<ExportResponse>,
}

#[derive(Debug, Serialize)]
struct VolumeListResponse {
    items: Vec<VolumeResponse>,
}

#[derive(Debug, Serialize)]
struct ExportResponse {
    uri: String,
    port: u16,
    pid: Option<u32>,
    read_only: bool,
}

#[derive(Debug, Serialize)]
struct CapabilitiesResponse {
    mode: ServerMode,
    supports_controller_api: bool,
    supports_node_api: bool,
}

#[derive(Debug, Deserialize)]
struct CreateVolumeRequest {
    size_bytes: u64,
    #[serde(default)]
    format: Option<VolumeFileFormat>,
    #[serde(default)]
    backing_file: Option<String>,
    #[serde(default)]
    backing_format: Option<VolumeFileFormat>,
}

#[derive(Debug, Deserialize)]
struct ResizeVolumeRequest {
    size_bytes: u64,
}

#[derive(Debug, Deserialize)]
struct ImportQuery {
    size_bytes: Option<u64>,
}

#[derive(Debug, Deserialize)]
struct ExportQuery {
    read_only: Option<bool>,
}

#[derive(Debug, Deserialize)]
struct MaterializeVolumeRequest {
    source_uri: String,
    #[serde(default)]
    size_bytes: Option<u64>,
    #[serde(default)]
    format: Option<VolumeFileFormat>,
    #[serde(default)]
    lazy: bool,
}

#[derive(Clone)]
struct AppState {
    config: Arc<ServerConfig>,
    metadata_store: MetadataStore,
    volume_guards: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
    reserved_ports: Arc<Mutex<HashSet<u16>>>,
}

#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
enum VolumeFileFormat {
    #[default]
    Raw,
    Qcow2,
}

impl VolumeFileFormat {
    fn as_qemu_arg(self) -> &'static str {
        match self {
            Self::Raw => "raw",
            Self::Qcow2 => "qcow2",
        }
    }
}

#[derive(Clone)]
enum MetadataStore {
    Filesystem,
    Chainfire(ChainfireMetadataStore),
}

#[derive(Clone)]
struct ChainfireMetadataStore {
    client: reqwest::Client,
    base_urls: Vec<String>,
    key_prefix: String,
}

#[derive(Debug, Deserialize)]
struct ChainfireGetEnvelope {
    data: ChainfireValueEntry,
}

#[derive(Debug, Deserialize)]
struct ChainfireValueEntry {
    value: String,
}

#[derive(Debug, Deserialize)]
struct ChainfireListEnvelope {
    data: ChainfireListData,
}

#[derive(Debug, Deserialize)]
struct ChainfireListData {
    items: Vec<ChainfireKvItem>,
}

#[derive(Debug, Deserialize)]
struct ChainfireKvItem {
    key: String,
    value: String,
}

impl MetadataStore {
    fn from_config(config: &ServerConfig) -> Result<Self> {
        match config.metadata_backend {
            MetadataBackend::Filesystem => Ok(Self::Filesystem),
            MetadataBackend::Chainfire => {
                let base_url = config.chainfire_api_url.clone().ok_or_else(|| {
                    anyhow!("chainfire_api_url must be set when metadata_backend = chainfire")
                })?;
                Ok(Self::Chainfire(ChainfireMetadataStore {
                    client: reqwest::Client::new(),
                    base_urls: parse_chainfire_base_urls(&base_url)?,
                    key_prefix: config.chainfire_key_prefix.clone(),
                }))
            }
        }
    }

    async fn load(&self, config: &ServerConfig, id: &str) -> Result<Option<VolumeMetadata>> {
        match self {
            Self::Filesystem => load_metadata_file(&metadata_path(config, id)).await,
            Self::Chainfire(store) => store.load(id).await,
        }
    }

    async fn save(&self, config: &ServerConfig, meta: &VolumeMetadata) -> Result<()> {
        match self {
            Self::Filesystem => save_metadata_file(&metadata_path(config, &meta.id), meta).await,
            Self::Chainfire(store) => store.save(meta).await,
        }
    }

    async fn delete(&self, config: &ServerConfig, id: &str) -> Result<()> {
        match self {
            Self::Filesystem => delete_metadata_file(&metadata_path(config, id)).await,
            Self::Chainfire(store) => store.delete(id).await,
        }
    }

    async fn list(&self, config: &ServerConfig) -> Result<Vec<VolumeMetadata>> {
        match self {
            Self::Filesystem => list_metadata_files(config).await,
            Self::Chainfire(store) => store.list().await,
        }
    }
}

impl ChainfireMetadataStore {
    fn key_for(&self, id: &str) -> String {
        format!(
            "{}/{}",
            self.key_prefix.trim_end_matches('/'),
            id.trim_start_matches('/')
        )
    }

    fn kv_url(&self, base_url: &str, key: &str) -> String {
        format!(
            "{}/api/v1/kv/{}",
            base_url.trim_end_matches('/'),
            key.trim_start_matches('/')
        )
    }

    async fn load(&self, id: &str) -> Result<Option<VolumeMetadata>> {
        let key = self.key_for(id);
        let mut last_error: Option<anyhow::Error> = None;
        for base_url in &self.base_urls {
            let response = match self
                .client
                .get(self.kv_url(base_url, &key))
                .query(&[("consistency", "leader")])
                .send()
                .await
            {
                Ok(response) => response,
                Err(error) => {
                    last_error = Some(anyhow!(
                        "failed to read CoronaFS metadata from {}: {error}",
                        base_url
                    ));
                    continue;
                }
            };
            if response.status() == reqwest::StatusCode::NOT_FOUND {
                return Ok(None);
            }
            let response = match response.error_for_status() {
                Ok(response) => response,
                Err(error) if retryable_chainfire_status(error.status()) => {
                    last_error = Some(anyhow!(
                        "failed to read CoronaFS metadata from {}: {}",
                        base_url,
                        error
                    ));
                    continue;
                }
                Err(error) => return Err(error.into()),
            };
            let payload = response.json::<ChainfireGetEnvelope>().await?;
            return Ok(Some(serde_json::from_str(&payload.data.value)?));
        }
        Err(last_error.unwrap_or_else(|| anyhow!("no ChainFire endpoints configured")))
    }

    async fn save(&self, meta: &VolumeMetadata) -> Result<()> {
        let body = serde_json::json!({
            "value": serde_json::to_string(meta)?,
        });
        let key = self.key_for(&meta.id);
        let mut last_error: Option<anyhow::Error> = None;
        for base_url in &self.base_urls {
            let response = match self
                .client
                .put(self.kv_url(base_url, &key))
                .json(&body)
                .send()
                .await
            {
                Ok(response) => response,
                Err(error) => {
                    last_error = Some(anyhow!(
                        "failed to store CoronaFS metadata to {}: {error}",
                        base_url
                    ));
                    continue;
                }
            };
            match response.error_for_status() {
                Ok(_) => return Ok(()),
                Err(error) if retryable_chainfire_status(error.status()) => {
                    last_error = Some(anyhow!(
                        "failed to store CoronaFS metadata to {}: {}",
                        base_url,
                        error
                    ));
                }
                Err(error) => return Err(error.into()),
            }
        }
        Err(last_error.unwrap_or_else(|| anyhow!("no ChainFire endpoints configured")))
    }

    async fn delete(&self, id: &str) -> Result<()> {
        let key = self.key_for(id);
        let mut last_error: Option<anyhow::Error> = None;
        for base_url in &self.base_urls {
            let response = match self.client.delete(self.kv_url(base_url, &key)).send().await {
                Ok(response) => response,
                Err(error) => {
                    last_error = Some(anyhow!(
                        "failed to delete CoronaFS metadata from {}: {error}",
                        base_url
                    ));
                    continue;
                }
            };
            if response.status() == reqwest::StatusCode::NOT_FOUND {
                return Ok(());
            }
            match response.error_for_status() {
                Ok(_) => return Ok(()),
                Err(error) if retryable_chainfire_status(error.status()) => {
                    last_error = Some(anyhow!(
                        "failed to delete CoronaFS metadata from {}: {}",
                        base_url,
                        error
                    ));
                }
                Err(error) => return Err(error.into()),
            }
        }
        Err(last_error.unwrap_or_else(|| anyhow!("no ChainFire endpoints configured")))
    }

    async fn list(&self) -> Result<Vec<VolumeMetadata>> {
        let mut last_error: Option<anyhow::Error> = None;
        for base_url in &self.base_urls {
            let response = match self
                .client
                .get(format!("{}/api/v1/kv", base_url.trim_end_matches('/')))
                .query(&[
                    ("prefix", self.key_prefix.as_str()),
                    ("consistency", "leader"),
                ])
                .send()
                .await
            {
                Ok(response) => response,
                Err(error) => {
                    last_error = Some(anyhow!(
                        "failed to list CoronaFS metadata from {}: {error}",
                        base_url
                    ));
                    continue;
                }
            };
            let response = match response.error_for_status() {
                Ok(response) => response,
                Err(error) if retryable_chainfire_status(error.status()) => {
                    last_error = Some(anyhow!(
                        "failed to list CoronaFS metadata from {}: {}",
                        base_url,
                        error
                    ));
                    continue;
                }
                Err(error) => return Err(error.into()),
            };
            let payload = response.json::<ChainfireListEnvelope>().await?;
            let mut items = Vec::with_capacity(payload.data.items.len());
            for item in payload.data.items {
                items.push(
                    serde_json::from_str::<VolumeMetadata>(&item.value).with_context(|| {
                        format!("failed to decode CoronaFS metadata for key {}", item.key)
                    })?,
                );
            }
            return Ok(items);
        }
        Err(last_error.unwrap_or_else(|| anyhow!("no ChainFire endpoints configured")))
    }
}

fn parse_chainfire_base_urls(value: &str) -> Result<Vec<String>> {
    let urls: Vec<String> = value
        .split(',')
        .map(str::trim)
        .filter(|item| !item.is_empty())
        .map(|item| item.trim_end_matches('/').to_string())
        .collect();
    if urls.is_empty() {
        return Err(anyhow!("at least one ChainFire API URL must be configured"));
    }
    Ok(urls)
}

fn retryable_chainfire_status(status: Option<reqwest::StatusCode>) -> bool {
    matches!(
        status,
        Some(reqwest::StatusCode::BAD_GATEWAY)
            | Some(reqwest::StatusCode::SERVICE_UNAVAILABLE)
            | Some(reqwest::StatusCode::GATEWAY_TIMEOUT)
    )
}

impl AppState {
    async fn new(config: ServerConfig) -> Result<Self> {
        prepare_dirs(&config).await?;
        let metadata_store = MetadataStore::from_config(&config)?;
        let reserved_ports = collect_reserved_ports(&config, &metadata_store).await?;
        Ok(Self {
            config: Arc::new(config),
            metadata_store,
            volume_guards: Arc::new(Mutex::new(HashMap::new())),
            reserved_ports: Arc::new(Mutex::new(reserved_ports)),
        })
    }

    async fn volume_guard(&self, volume_id: &str) -> Arc<Mutex<()>> {
        let mut guards = self.volume_guards.lock().await;
        guards
            .entry(volume_id.to_string())
            .or_insert_with(|| Arc::new(Mutex::new(())))
            .clone()
    }

    async fn load_metadata(&self, id: &str) -> Result<Option<VolumeMetadata>> {
        self.metadata_store.load(&self.config, id).await
    }

    async fn save_metadata(&self, meta: &VolumeMetadata) -> Result<()> {
        self.metadata_store.save(&self.config, meta).await
    }

    async fn delete_metadata(&self, id: &str) -> Result<()> {
        self.metadata_store.delete(&self.config, id).await
    }
}

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

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

    fn not_found(message: impl Into<String>) -> Self {
        Self::new(StatusCode::NOT_FOUND, message)
    }

    fn internal(err: anyhow::Error) -> Self {
        Self::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
    }
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        if self.status.is_server_error() {
            tracing::error!(status = %self.status, error = %self.message, "CoronaFS API request failed");
        } else if self.status == StatusCode::NOT_FOUND {
            tracing::debug!(status = %self.status, error = %self.message, "CoronaFS API request returned not found");
        }
        (
            self.status,
            Json(serde_json::json!({
                "error": self.message,
            })),
        )
            .into_response()
    }
}

type ApiResult<T> = Result<Json<T>, ApiError>;

#[tokio::main]
async fn main() -> Result<()> {
    let args = Args::parse();
    let config = if args.config.exists() {
        let contents = fs::read_to_string(&args.config)
            .await
            .with_context(|| format!("failed to read config {}", args.config.display()))?;
        toml::from_str::<ServerConfig>(&contents)
            .with_context(|| format!("failed to parse config {}", args.config.display()))?
    } else {
        ServerConfig::default()
    };

    tracing_subscriber::fmt()
        .with_env_filter(
            EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&config.log_level)),
        )
        .init();

    let listen_addr = config.listen_addr;
    let state = AppState::new(config).await?;

    let app = Router::new()
        .route("/healthz", get(healthz))
        .route("/v1/capabilities", get(capabilities))
        .route("/v1/volumes", get(list_volumes))
        .route(
            "/v1/volumes/{id}",
            put(create_blank_volume)
                .get(get_volume)
                .delete(delete_volume),
        )
        .route("/v1/volumes/{id}/import", put(import_volume))
        .route("/v1/volumes/{id}/materialize", post(materialize_volume))
        .route("/v1/volumes/{id}/resize", post(resize_volume))
        .route(
            "/v1/volumes/{id}/export",
            post(ensure_export).delete(release_export),
        )
        .with_state(state);

    tracing::info!(%listen_addr, "starting CoronaFS server");
    let listener = tokio::net::TcpListener::bind(listen_addr).await?;
    axum::serve(listener, app).await?;
    Ok(())
}

async fn healthz() -> Json<serde_json::Value> {
    Json(serde_json::json!({"status": "ok"}))
}

async fn capabilities(State(state): State<AppState>) -> Json<CapabilitiesResponse> {
    Json(CapabilitiesResponse {
        mode: state.config.mode,
        supports_controller_api: state.config.supports_controller_api(),
        supports_node_api: state.config.supports_node_api(),
    })
}

fn ensure_controller_api(state: &AppState, operation: &str) -> Result<()> {
    if state.config.supports_controller_api() {
        return Ok(());
    }
    Err(anyhow!(
        "{operation} is not available when CoronaFS runs in {:?} mode",
        state.config.mode
    ))
}

fn ensure_node_api(state: &AppState, operation: &str) -> Result<()> {
    if state.config.supports_node_api() {
        return Ok(());
    }
    Err(anyhow!(
        "{operation} is not available when CoronaFS runs in {:?} mode",
        state.config.mode
    ))
}

fn ensure_volume_create_api(state: &AppState, operation: &str) -> Result<()> {
    if state.config.supports_controller_api() || state.config.supports_node_api() {
        return Ok(());
    }
    Err(anyhow!(
        "{operation} is not available when CoronaFS runs in {:?} mode",
        state.config.mode
    ))
}

async fn create_blank_volume(
    State(state): State<AppState>,
    Path(id): Path<String>,
    Json(req): Json<CreateVolumeRequest>,
) -> ApiResult<VolumeResponse> {
    ensure_volume_create_api(&state, "volume create").map_err(ApiError::internal)?;
    let volume_guard = state.volume_guard(&id).await;
    let _guard = volume_guard.lock().await;
    create_blank_impl(&state, &id, req)
        .await
        .map(Json)
        .map_err(ApiError::internal)
}

async fn import_volume(
    State(state): State<AppState>,
    Path(id): Path<String>,
    Query(query): Query<ImportQuery>,
    body: Body,
) -> ApiResult<VolumeResponse> {
    ensure_controller_api(&state, "volume import").map_err(ApiError::internal)?;
    let volume_guard = state.volume_guard(&id).await;
    let _guard = volume_guard.lock().await;
    import_impl(&state, &id, query.size_bytes, body)
        .await
        .map(Json)
        .map_err(ApiError::internal)
}

async fn get_volume(
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> ApiResult<VolumeResponse> {
    load_response(&state, &id)
        .await
        .ok_or_else(|| ApiError::not_found(format!("volume {id} not found")))
        .map(Json)
}

async fn list_volumes(State(state): State<AppState>) -> ApiResult<VolumeListResponse> {
    let items = list_volume_responses(&state)
        .await
        .map_err(ApiError::internal)?;
    Ok(Json(VolumeListResponse { items }))
}

async fn ensure_export(
    State(state): State<AppState>,
    Path(id): Path<String>,
    Query(query): Query<ExportQuery>,
) -> ApiResult<VolumeResponse> {
    ensure_node_api(&state, "volume export").map_err(ApiError::internal)?;
    let volume_guard = state.volume_guard(&id).await;
    let _guard = volume_guard.lock().await;
    ensure_export_impl(&state, &id, query.read_only.unwrap_or(false))
        .await
        .map(Json)
        .map_err(ApiError::internal)
}

async fn release_export(
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
    ensure_node_api(&state, "volume export release").map_err(ApiError::internal)?;
    let volume_guard = state.volume_guard(&id).await;
    let _guard = volume_guard.lock().await;
    release_export_impl(&state, &id)
        .await
        .map(|_| StatusCode::NO_CONTENT)
        .map_err(ApiError::internal)
}

async fn materialize_volume(
    State(state): State<AppState>,
    Path(id): Path<String>,
    Json(req): Json<MaterializeVolumeRequest>,
) -> ApiResult<VolumeResponse> {
    ensure_node_api(&state, "volume materialize").map_err(ApiError::internal)?;
    let volume_guard = state.volume_guard(&id).await;
    let _guard = volume_guard.lock().await;
    materialize_impl(&state, &id, req)
        .await
        .map(Json)
        .map_err(ApiError::internal)
}

async fn resize_volume(
    State(state): State<AppState>,
    Path(id): Path<String>,
    Json(req): Json<ResizeVolumeRequest>,
) -> ApiResult<VolumeResponse> {
    ensure_controller_api(&state, "volume resize").map_err(ApiError::internal)?;
    let volume_guard = state.volume_guard(&id).await;
    let _guard = volume_guard.lock().await;
    resize_impl(&state, &id, req.size_bytes)
        .await
        .map(Json)
        .map_err(ApiError::internal)
}

async fn delete_volume(
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
    let volume_guard = state.volume_guard(&id).await;
    let _guard = volume_guard.lock().await;
    delete_impl(&state, &id)
        .await
        .map(|_| StatusCode::NO_CONTENT)
        .map_err(ApiError::internal)
}

async fn prepare_dirs(config: &ServerConfig) -> Result<()> {
    fs::create_dir_all(config.volume_dir()).await?;
    fs::create_dir_all(config.metadata_dir()).await?;
    fs::create_dir_all(config.pid_dir()).await?;
    ensure_volume_dir_permissions(&config.volume_dir()).await?;
    Ok(())
}

async fn create_blank_impl(
    state: &AppState,
    id: &str,
    req: CreateVolumeRequest,
) -> Result<VolumeResponse> {
    let path = volume_path(&state.config, id);
    if state.load_metadata(id).await?.is_some() {
        return load_response_required(state, id).await;
    }

    let format = req.format.unwrap_or_default();
    let backing_file = req.backing_file.clone();
    let backing_format = req.backing_format.unwrap_or(VolumeFileFormat::Raw);
    let temp_path = temp_create_path(&state.config, id);
    if fs::try_exists(&temp_path).await.unwrap_or(false) {
        let _ = fs::remove_file(&temp_path).await;
    }

    match format {
        VolumeFileFormat::Raw => {
            if backing_file.is_some() {
                return Err(anyhow!("raw CoronaFS volumes do not support backing files"));
            }
            create_or_preallocate_file(&temp_path, req.size_bytes, state.config.preallocate)
                .await?;
        }
        VolumeFileFormat::Qcow2 => {
            create_qcow2_volume_file(
                &state.config,
                &temp_path,
                req.size_bytes,
                backing_file.as_deref(),
                backing_format,
            )
            .await?;
        }
    }

    let meta = VolumeMetadata {
        id: id.to_string(),
        size_bytes: req.size_bytes,
        format,
        node_local: matches!(state.config.mode, ServerMode::Node) || backing_file.is_some(),
        materialized_from: backing_file,
        port: None,
        export_pid: None,
        export_read_only: None,
        created_at: chrono::Utc::now().to_rfc3339(),
        updated_at: chrono::Utc::now().to_rfc3339(),
    };
    fs::rename(&temp_path, &path).await?;
    ensure_volume_file_permissions(&path).await?;
    state.save_metadata(&meta).await?;
    Ok(response_from_metadata(state, meta))
}

async fn import_impl(
    state: &AppState,
    id: &str,
    size_bytes: Option<u64>,
    body: Body,
) -> Result<VolumeResponse> {
    let path = volume_path(&state.config, id);
    let tmp_path = temp_import_path(&state.config, id);
    if let Some(size_bytes) = size_bytes {
        create_or_preallocate_file(&tmp_path, size_bytes, state.config.preallocate).await?;
    }
    let mut stream = body.into_data_stream();
    let mut file = fs::OpenOptions::new()
        .create(true)
        .write(true)
        .truncate(size_bytes.is_none())
        .open(&tmp_path)
        .await
        .with_context(|| format!("failed to create {}", tmp_path.display()))?;
    let mut bytes_written = 0u64;
    while let Some(chunk) = stream.next().await {
        let chunk = chunk.context("failed to read request body chunk")?;
        bytes_written = bytes_written.saturating_add(chunk.len() as u64);
        file.write_all(&chunk)
            .await
            .with_context(|| format!("failed to write {}", tmp_path.display()))?;
    }
    if let Some(size_bytes) = size_bytes {
        file.set_len(size_bytes).await?;
    }
    if state.config.sync_on_write {
        file.sync_all().await?;
    }
    drop(file);
    fs::rename(&tmp_path, &path).await?;
    ensure_volume_file_permissions(&path).await?;
    let actual_size = fs::metadata(&path).await?.len();
    let meta = VolumeMetadata {
        id: id.to_string(),
        size_bytes: size_bytes.unwrap_or(actual_size),
        format: VolumeFileFormat::Raw,
        node_local: false,
        materialized_from: None,
        port: None,
        export_pid: None,
        export_read_only: None,
        created_at: chrono::Utc::now().to_rfc3339(),
        updated_at: chrono::Utc::now().to_rfc3339(),
    };
    state.save_metadata(&meta).await?;
    tracing::info!(
        volume_id = id,
        bytes_written,
        volume_size = actual_size,
        "Imported raw volume into CoronaFS"
    );
    Ok(response_from_metadata(state, meta))
}

async fn resize_impl(state: &AppState, id: &str, size_bytes: u64) -> Result<VolumeResponse> {
    let path = volume_path(&state.config, id);
    let mut meta = state
        .load_metadata(id)
        .await?
        .ok_or_else(|| anyhow!("volume {id} not found"))?;

    let reserved_port = meta.port;
    stop_export_if_running(&state.config, id, &mut meta).await?;
    release_export_port(state, reserved_port).await;
    meta.port = None;
    let status = Command::new(&state.config.qemu_img_path)
        .args([
            "resize",
            "-f",
            meta.format.as_qemu_arg(),
            path.to_string_lossy().as_ref(),
            &size_bytes.to_string(),
        ])
        .status()
        .await
        .context("failed to spawn qemu-img resize")?;
    if !status.success() {
        return Err(anyhow!("qemu-img resize failed for {}", path.display()));
    }
    meta.size_bytes = size_bytes;
    meta.updated_at = chrono::Utc::now().to_rfc3339();
    state.save_metadata(&meta).await?;
    Ok(response_from_metadata(state, meta))
}

async fn materialize_impl(
    state: &AppState,
    id: &str,
    req: MaterializeVolumeRequest,
) -> Result<VolumeResponse> {
    let path = volume_path(&state.config, id);
    if state.load_metadata(id).await?.is_some() {
        return load_response_required(state, id).await;
    }

    let default_format = if req.lazy {
        VolumeFileFormat::Qcow2
    } else {
        VolumeFileFormat::Raw
    };
    let format = req.format.unwrap_or(default_format);
    let temp_path = temp_create_path(&state.config, id);
    if fs::try_exists(&temp_path).await.unwrap_or(false) {
        let _ = fs::remove_file(&temp_path).await;
    }

    if req.lazy {
        if format != VolumeFileFormat::Qcow2 {
            return Err(anyhow!(
                "lazy CoronaFS materialization for volume {id} requires qcow2 output format"
            ));
        }
        let size_bytes = req.size_bytes.ok_or_else(|| {
            anyhow!("lazy CoronaFS materialization for volume {id} requires size_bytes")
        })?;
        create_qcow2_volume_file(
            &state.config,
            &temp_path,
            size_bytes,
            Some(req.source_uri.as_str()),
            VolumeFileFormat::Raw,
        )
        .await
        .with_context(|| {
            format!(
                "failed to create lazy CoronaFS materialization for volume {id} from {}",
                req.source_uri
            )
        })?;
    } else {
        let status = Command::new(&state.config.qemu_img_path)
            .args([
                "convert",
                "-t",
                "none",
                "-T",
                "none",
                "-O",
                format.as_qemu_arg(),
                req.source_uri.as_str(),
                temp_path.to_string_lossy().as_ref(),
            ])
            .status()
            .await
            .context("failed to spawn qemu-img convert for CoronaFS materialization")?;
        if !status.success() {
            return Err(anyhow!(
                "qemu-img convert failed while materializing volume {id} from {}",
                req.source_uri
            ));
        }
    }

    if let Some(size_bytes) = req.size_bytes {
        let resize = Command::new(&state.config.qemu_img_path)
            .args([
                "resize",
                "-f",
                format.as_qemu_arg(),
                temp_path.to_string_lossy().as_ref(),
                &size_bytes.to_string(),
            ])
            .status()
            .await
            .context("failed to resize materialized CoronaFS volume")?;
        if !resize.success() {
            return Err(anyhow!(
                "qemu-img resize failed while materializing volume {id}"
            ));
        }
    }

    fs::rename(&temp_path, &path).await?;
    ensure_volume_file_permissions(&path).await?;
    let actual_size = fs::metadata(&path).await?.len();
    let meta = VolumeMetadata {
        id: id.to_string(),
        size_bytes: req.size_bytes.unwrap_or(actual_size),
        format,
        node_local: true,
        materialized_from: Some(req.source_uri.clone()),
        port: None,
        export_pid: None,
        export_read_only: None,
        created_at: chrono::Utc::now().to_rfc3339(),
        updated_at: chrono::Utc::now().to_rfc3339(),
    };
    state.save_metadata(&meta).await?;
    tracing::info!(
        volume_id = id,
        source_uri = req.source_uri,
        size_bytes = meta.size_bytes,
        format = meta.format.as_qemu_arg(),
        "Materialized CoronaFS volume from remote export"
    );
    Ok(response_from_metadata(state, meta))
}

async fn ensure_export_impl(state: &AppState, id: &str, read_only: bool) -> Result<VolumeResponse> {
    let mut meta = state
        .load_metadata(id)
        .await?
        .ok_or_else(|| anyhow!("volume {id} not found"))?;
    let mut preferred_port = meta.port;
    if let Some(pid) = meta.export_pid {
        if process_running(pid).await
            && meta.port.is_some()
            && meta.export_read_only == Some(read_only)
        {
            if let Some(port) = meta.port {
                mark_port_reserved(state, port).await;
            }
            return Ok(response_from_metadata(state, meta));
        }
        stop_export_if_running(&state.config, id, &mut meta).await?;
        release_export_port(state, meta.port).await;
        meta.port = None;
    }
    let port = reserve_export_port(state, preferred_port.take()).await?;
    let pid_path = pid_path(&state.config, id);
    let path = volume_path(&state.config, id);
    let effective_aio_mode = export_aio_mode(
        &state.config.export_cache_mode,
        &state.config.export_aio_mode,
    );
    let mut command = Command::new(&state.config.qemu_nbd_path);
    command.args([
        "--fork",
        "--persistent",
        "--pid-file",
        pid_path.to_string_lossy().as_ref(),
        "--shared",
        &state.config.export_shared_clients.to_string(),
        "--cache",
        &state.config.export_cache_mode,
        "--aio",
        effective_aio_mode,
        "--discard",
        &state.config.export_discard_mode,
        "--detect-zeroes",
        &state.config.export_detect_zeroes_mode,
        "--format",
        meta.format.as_qemu_arg(),
        "--bind",
        &state.config.export_bind_addr,
        "--port",
        &port.to_string(),
    ]);
    if read_only {
        command.arg("--read-only");
    }
    command.arg(path.to_string_lossy().as_ref());
    let status = command.status().await.context("failed to spawn qemu-nbd")?;
    if !status.success() {
        release_export_port(state, Some(port)).await;
        return Err(anyhow!(
            "qemu-nbd failed to export volume {id} on port {port}"
        ));
    }
    let pid = match read_pid_file(&pid_path).await {
        Ok(pid) => pid,
        Err(err) => {
            release_export_port(state, Some(port)).await;
            return Err(err);
        }
    };
    meta.port = Some(port);
    meta.export_pid = Some(pid);
    meta.export_read_only = Some(read_only);
    meta.updated_at = chrono::Utc::now().to_rfc3339();
    state.save_metadata(&meta).await?;
    if let Err(err) = wait_for_tcp_listen(export_probe_host(&state.config), port).await {
        let _ = stop_export_if_running(&state.config, id, &mut meta).await;
        release_export_port(state, Some(port)).await;
        return Err(err);
    }
    Ok(response_from_metadata(state, meta))
}

async fn release_export_impl(state: &AppState, id: &str) -> Result<()> {
    let Some(mut meta) = state.load_metadata(id).await? else {
        return Ok(());
    };
    let reserved_port = meta.port;
    stop_export_if_running(&state.config, id, &mut meta).await?;
    release_export_port(state, reserved_port).await;
    meta.updated_at = chrono::Utc::now().to_rfc3339();
    state.save_metadata(&meta).await?;
    Ok(())
}

fn export_aio_mode<'a>(cache_mode: &str, aio_mode: &'a str) -> &'a str {
    if aio_mode == "native" && !matches!(cache_mode, "none" | "directsync") {
        tracing::warn!(
            cache_mode,
            requested_aio_mode = aio_mode,
            effective_aio_mode = "threads",
            "CoronaFS export cache mode is incompatible with qemu-nbd native AIO; falling back to threads",
        );
        "threads"
    } else {
        aio_mode
    }
}

async fn delete_impl(state: &AppState, id: &str) -> Result<()> {
    let pid_path = pid_path(&state.config, id);
    if let Some(mut meta) = state.load_metadata(id).await? {
        let reserved_port = meta.port;
        stop_export_if_running(&state.config, id, &mut meta).await?;
        release_export_port(state, reserved_port).await;
    } else {
        stop_export_for_pid_file(&pid_path).await?;
    }
    let path = volume_path(&state.config, id);
    if fs::try_exists(&path).await.unwrap_or(false) {
        fs::remove_file(&path).await?;
    }
    state.delete_metadata(id).await?;
    if fs::try_exists(&pid_path).await.unwrap_or(false) {
        fs::remove_file(pid_path).await?;
    }
    Ok(())
}

async fn load_response(state: &AppState, id: &str) -> Option<VolumeResponse> {
    load_response_required(state, id).await.ok()
}

async fn list_volume_responses(state: &AppState) -> Result<Vec<VolumeResponse>> {
    let mut items = Vec::new();
    for meta in state.metadata_store.list(&state.config).await? {
        items.push(response_from_metadata(state, meta));
    }
    items.sort_by(|lhs, rhs| lhs.id.cmp(&rhs.id));
    Ok(items)
}

async fn load_response_required(state: &AppState, id: &str) -> Result<VolumeResponse> {
    let meta = state
        .load_metadata(id)
        .await?
        .ok_or_else(|| anyhow!("volume {id} not found"))?;
    Ok(response_from_metadata(state, meta))
}

fn response_from_metadata(state: &AppState, meta: VolumeMetadata) -> VolumeResponse {
    let id = meta.id;
    let export = match (meta.port, meta.export_pid) {
        (Some(port), pid) if pid.map(process_running_sync).unwrap_or(false) => {
            Some(ExportResponse {
                uri: format!("nbd://{}:{}", state.config.advertise_host, port),
                port,
                pid,
                read_only: meta.export_read_only.unwrap_or(false),
            })
        }
        _ => None,
    };
    VolumeResponse {
        id: id.clone(),
        size_bytes: meta.size_bytes,
        format: meta.format.as_qemu_arg().to_string(),
        node_local: meta.node_local,
        materialized_from: meta.materialized_from,
        path: volume_path(&state.config, &id).display().to_string(),
        export,
    }
}

async fn load_metadata_file(path: &FsPath) -> Result<Option<VolumeMetadata>> {
    if !fs::try_exists(path).await.unwrap_or(false) {
        return Ok(None);
    }
    let bytes = fs::read(path).await?;
    Ok(Some(serde_json::from_slice(&bytes)?))
}

async fn save_metadata_file(path: &FsPath, meta: &VolumeMetadata) -> Result<()> {
    let bytes = serde_json::to_vec_pretty(meta)?;
    let tmp_path = path.with_extension("json.tmp");
    fs::write(&tmp_path, bytes).await?;
    fs::rename(&tmp_path, path).await?;
    Ok(())
}

async fn delete_metadata_file(path: &FsPath) -> Result<()> {
    if fs::try_exists(path).await.unwrap_or(false) {
        fs::remove_file(path).await?;
    }
    Ok(())
}

async fn list_metadata_files(config: &ServerConfig) -> Result<Vec<VolumeMetadata>> {
    let mut items = Vec::new();
    let mut entries = fs::read_dir(config.metadata_dir()).await?;
    while let Some(entry) = entries.next_entry().await? {
        let path = entry.path();
        let Some(meta) = load_metadata_file(&path).await? else {
            continue;
        };
        items.push(meta);
    }
    Ok(items)
}

async fn stop_export_if_running(
    config: &ServerConfig,
    id: &str,
    meta: &mut VolumeMetadata,
) -> Result<()> {
    let pid_path = pid_path(config, id);
    if let Some(pid) = meta
        .export_pid
        .or(read_pid_file_if_present(&pid_path).await?)
    {
        stop_export_process(pid).await?;
    }
    meta.export_pid = None;
    meta.export_read_only = None;
    if fs::try_exists(&pid_path).await.unwrap_or(false) {
        fs::remove_file(pid_path).await?;
    }
    Ok(())
}

async fn stop_export_for_pid_file(pid_path: &FsPath) -> Result<()> {
    if let Some(pid) = read_pid_file_if_present(pid_path).await? {
        stop_export_process(pid).await?;
    }
    if fs::try_exists(pid_path).await.unwrap_or(false) {
        fs::remove_file(pid_path).await?;
    }
    Ok(())
}

async fn stop_export_process(pid: u32) -> Result<()> {
    if process_running(pid).await {
        let status = Command::new("kill")
            .args(["-TERM", &pid.to_string()])
            .status()
            .await
            .context("failed to terminate qemu-nbd export")?;
        if !status.success() {
            return Err(anyhow!("failed to stop qemu-nbd export pid {pid}"));
        }
        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
        while process_running(pid).await {
            if std::time::Instant::now() >= deadline {
                let _ = Command::new("kill")
                    .args(["-KILL", &pid.to_string()])
                    .status()
                    .await;
                break;
            }
            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        }
    }
    Ok(())
}

async fn process_running(pid: u32) -> bool {
    fs::try_exists(format!("/proc/{pid}"))
        .await
        .unwrap_or(false)
}

fn process_running_sync(pid: u32) -> bool {
    FsPath::new("/proc").join(pid.to_string()).exists()
}

async fn read_pid_file(path: &FsPath) -> Result<u32> {
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
    loop {
        if fs::try_exists(path).await.unwrap_or(false) {
            let contents = fs::read_to_string(path).await?;
            return contents
                .trim()
                .parse::<u32>()
                .with_context(|| format!("invalid pid file {}", path.display()));
        }
        if std::time::Instant::now() >= deadline {
            return Err(anyhow!("timed out waiting for pid file {}", path.display()));
        }
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
    }
}

async fn read_pid_file_if_present(path: &FsPath) -> Result<Option<u32>> {
    if !fs::try_exists(path).await.unwrap_or(false) {
        return Ok(None);
    }
    match read_pid_file(path).await {
        Ok(pid) => Ok(Some(pid)),
        Err(error) => {
            tracing::warn!(
                pid_file = %path.display(),
                error = %error,
                "failed to read CoronaFS export pid file; removing stale pid file"
            );
            let _ = fs::remove_file(path).await;
            Ok(None)
        }
    }
}

async fn wait_for_tcp_listen(host: &str, port: u16) -> Result<()> {
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
    loop {
        if tokio::net::TcpStream::connect((host, port)).await.is_ok() {
            return Ok(());
        }
        if std::time::Instant::now() >= deadline {
            return Err(anyhow!("timed out waiting for export {}:{}", host, port));
        }
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
    }
}

async fn collect_reserved_ports(
    config: &ServerConfig,
    metadata_store: &MetadataStore,
) -> Result<HashSet<u16>> {
    let mut reserved = HashSet::new();
    for mut meta in metadata_store.list(config).await? {
        match (meta.port, meta.export_pid) {
            (Some(port), Some(pid)) if process_running(pid).await => {
                reserved.insert(port);
            }
            (Some(_), _) | (_, Some(_)) => {
                meta.port = None;
                meta.export_pid = None;
                meta.export_read_only = None;
                meta.updated_at = chrono::Utc::now().to_rfc3339();
                metadata_store.save(config, &meta).await?;
            }
            _ => {}
        }
    }

    let mut pid_entries = fs::read_dir(config.pid_dir()).await?;
    while let Some(entry) = pid_entries.next_entry().await? {
        let path = entry.path();
        if path.extension().and_then(|ext| ext.to_str()) != Some("pid") {
            continue;
        }
        let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
            continue;
        };
        if fs::try_exists(&metadata_path(config, id))
            .await
            .unwrap_or(false)
        {
            continue;
        }
        stop_export_for_pid_file(&path).await?;
    }

    Ok(reserved)
}

async fn reserve_export_port(state: &AppState, preferred_port: Option<u16>) -> Result<u16> {
    let mut reserved = state.reserved_ports.lock().await;
    if let Some(port) = preferred_port {
        if port_is_usable(&state.config, &reserved, port).await {
            reserved.insert(port);
            return Ok(port);
        }
    }

    let start = state.config.export_base_port as u32;
    let end = start + state.config.export_port_count as u32;
    for port in start..end {
        let port_u16 = port as u16;
        if port_is_usable(&state.config, &reserved, port_u16).await {
            reserved.insert(port_u16);
            return Ok(port_u16);
        }
    }

    Err(anyhow!(
        "no free export ports left in range {}..{}",
        state.config.export_base_port,
        state.config.export_base_port + state.config.export_port_count
    ))
}

async fn port_is_usable(config: &ServerConfig, reserved: &HashSet<u16>, port: u16) -> bool {
    let start = config.export_base_port as u32;
    let end = start + config.export_port_count as u32;
    if (port as u32) < start || (port as u32) >= end || reserved.contains(&port) {
        return false;
    }
    tokio::net::TcpListener::bind((config.export_bind_addr.as_str(), port))
        .await
        .is_ok()
}

async fn mark_port_reserved(state: &AppState, port: u16) {
    let mut reserved = state.reserved_ports.lock().await;
    reserved.insert(port);
}

async fn release_export_port(state: &AppState, port: Option<u16>) {
    if let Some(port) = port {
        let mut reserved = state.reserved_ports.lock().await;
        reserved.remove(&port);
    }
}

fn export_probe_host(config: &ServerConfig) -> &str {
    match config.export_bind_addr.as_str() {
        "0.0.0.0" | "::" | "" => "127.0.0.1",
        host => host,
    }
}

async fn create_or_preallocate_file(
    path: &FsPath,
    size_bytes: u64,
    preallocate: bool,
) -> Result<()> {
    if preallocate {
        let status = Command::new("fallocate")
            .args([
                "-l",
                &size_bytes.to_string(),
                path.to_string_lossy().as_ref(),
            ])
            .status()
            .await;
        if matches!(status, Ok(status) if status.success()) {
            return Ok(());
        }
    }

    let file = fs::File::create(path).await?;
    file.set_len(size_bytes).await?;
    ensure_volume_file_permissions(path).await?;
    Ok(())
}

async fn ensure_volume_file_permissions(path: &FsPath) -> Result<()> {
    #[cfg(unix)]
    {
        let permissions = std::fs::Permissions::from_mode(0o660);
        fs::set_permissions(path, permissions).await?;
    }
    Ok(())
}

async fn ensure_volume_dir_permissions(path: &FsPath) -> Result<()> {
    #[cfg(unix)]
    {
        // Keep CoronaFS volumes group-writable and setgid so PlasmaVMC clones
        // inherit the shared `coronafs` group when they populate volumes locally.
        let permissions = std::fs::Permissions::from_mode(0o2770);
        fs::set_permissions(path, permissions).await?;
    }
    Ok(())
}

fn volume_path(config: &ServerConfig, id: &str) -> PathBuf {
    config.volume_dir().join(format!("{id}.raw"))
}

fn metadata_path(config: &ServerConfig, id: &str) -> PathBuf {
    config.metadata_dir().join(format!("{id}.json"))
}

fn pid_path(config: &ServerConfig, id: &str) -> PathBuf {
    config.pid_dir().join(format!("{id}.pid"))
}

fn temp_import_path(config: &ServerConfig, id: &str) -> PathBuf {
    config.data_dir.join(format!("{id}.import.tmp"))
}

fn temp_create_path(config: &ServerConfig, id: &str) -> PathBuf {
    config.data_dir.join(format!("{id}.create.tmp"))
}

async fn create_qcow2_volume_file(
    config: &ServerConfig,
    path: &FsPath,
    size_bytes: u64,
    backing_file: Option<&str>,
    backing_format: VolumeFileFormat,
) -> Result<()> {
    let mut command = Command::new(&config.qemu_img_path);
    command.args(["create", "-f", "qcow2"]);
    if let Some(backing_file) = backing_file {
        command.args(["-F", backing_format.as_qemu_arg(), "-b", backing_file]);
    }
    command.args([path.to_string_lossy().as_ref(), &size_bytes.to_string()]);
    let status = command
        .status()
        .await
        .context("failed to spawn qemu-img create")?;
    if !status.success() {
        return Err(anyhow!(
            "qemu-img create failed for {} with status {status}",
            path.display()
        ));
    }
    ensure_volume_file_permissions(path).await?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        extract::{Path as AxumPath, Query as AxumQuery, State as AxumState},
        routing::get as axum_get,
        Json as AxumJson, Router as AxumRouter,
    };
    use serde::Deserialize;
    use std::sync::{
        atomic::{AtomicU16, Ordering},
        Arc,
    };
    use tempfile::tempdir;
    use tokio::sync::Mutex as TokioMutex;

    static NEXT_TEST_EXPORT_BASE_PORT: AtomicU16 = AtomicU16::new(18000);

    #[test]
    fn export_aio_mode_falls_back_for_cached_exports() {
        assert_eq!(export_aio_mode("writeback", "native"), "threads");
        assert_eq!(export_aio_mode("none", "native"), "native");
        assert_eq!(export_aio_mode("directsync", "native"), "native");
        assert_eq!(export_aio_mode("writeback", "threads"), "threads");
    }

    #[test]
    fn export_probe_host_prefers_loopback_for_wildcard_bind() {
        let mut config = ServerConfig {
            export_bind_addr: "0.0.0.0".to_string(),
            ..ServerConfig::default()
        };
        assert_eq!(export_probe_host(&config), "127.0.0.1");
        config.export_bind_addr = "10.100.0.11".to_string();
        assert_eq!(export_probe_host(&config), "10.100.0.11");
    }

    #[test]
    fn server_mode_capabilities_match_intended_split() {
        let mut config = ServerConfig::default();
        assert!(config.supports_controller_api());
        assert!(config.supports_node_api());

        config.mode = ServerMode::Controller;
        assert!(config.supports_controller_api());
        assert!(!config.supports_node_api());

        config.mode = ServerMode::Node;
        assert!(!config.supports_controller_api());
        assert!(config.supports_node_api());
    }

    fn test_config() -> (tempfile::TempDir, ServerConfig) {
        let tempdir = tempdir().unwrap();
        let mut config = ServerConfig::default();
        config.data_dir = tempdir.path().join("coronafs");
        config.preallocate = false;
        config.export_port_count = 4;
        config.export_base_port = find_free_export_base_port(config.export_port_count);
        (tempdir, config)
    }

    fn find_free_export_base_port(port_count: u16) -> u16 {
        let step = port_count.max(1);
        for _ in 0..4096 {
            let start = NEXT_TEST_EXPORT_BASE_PORT.fetch_add(step, Ordering::Relaxed);
            let Some(end_port) = start.checked_add(step.saturating_sub(1)) else {
                continue;
            };
            if end_port >= 24000 {
                continue;
            }
            let mut listeners = Vec::with_capacity(port_count as usize);
            let mut available = true;
            for offset in 0..port_count {
                match std::net::TcpListener::bind(("127.0.0.1", start + offset)) {
                    Ok(listener) => listeners.push(listener),
                    Err(_) => {
                        available = false;
                        break;
                    }
                }
            }
            if available {
                drop(listeners);
                return start;
            }
        }
        panic!("failed to find a free CoronaFS export port range for tests");
    }

    #[tokio::test]
    async fn collect_reserved_ports_clears_stale_exports() {
        let (_tempdir, config) = test_config();
        prepare_dirs(&config).await.unwrap();

        let meta_path = metadata_path(&config, "stale");
        save_metadata_file(
            &meta_path,
            &VolumeMetadata {
                id: "stale".to_string(),
                size_bytes: 4096,
                format: VolumeFileFormat::Raw,
                node_local: false,
                materialized_from: None,
                port: Some(config.export_base_port),
                export_pid: Some(u32::MAX),
                export_read_only: Some(false),
                created_at: chrono::Utc::now().to_rfc3339(),
                updated_at: chrono::Utc::now().to_rfc3339(),
            },
        )
        .await
        .unwrap();

        let reserved = collect_reserved_ports(&config, &MetadataStore::Filesystem)
            .await
            .unwrap();
        assert!(reserved.is_empty());

        let meta = load_metadata_file(&meta_path).await.unwrap().unwrap();
        assert_eq!(meta.port, None);
        assert_eq!(meta.export_pid, None);
    }

    #[tokio::test]
    async fn collect_reserved_ports_stops_orphan_pid_files_without_metadata() {
        let (_tempdir, config) = test_config();
        prepare_dirs(&config).await.unwrap();

        let mut child = Command::new("sleep").arg("60").spawn().unwrap();
        let pid = child.id().unwrap();
        let orphan_pid_path = pid_path(&config, "orphan");
        fs::write(&orphan_pid_path, format!("{pid}\n"))
            .await
            .unwrap();

        let reserved = collect_reserved_ports(&config, &MetadataStore::Filesystem)
            .await
            .unwrap();
        assert!(reserved.is_empty());
        assert!(!fs::try_exists(&orphan_pid_path).await.unwrap());

        tokio::time::timeout(std::time::Duration::from_secs(5), child.wait())
            .await
            .expect("timed out waiting for orphan qemu-nbd placeholder process to exit")
            .unwrap();
        assert!(!process_running(pid).await);
    }

    #[tokio::test]
    async fn reserve_export_port_prefers_requested_slot() {
        let (_tempdir, config) = test_config();
        let state = AppState::new(config).await.unwrap();

        let preferred = state.config.export_base_port + 1;
        let port = reserve_export_port(&state, Some(preferred)).await.unwrap();
        assert_eq!(port, preferred);

        let next = reserve_export_port(&state, Some(preferred)).await.unwrap();
        assert_ne!(next, preferred);

        release_export_port(&state, Some(port)).await;
        release_export_port(&state, Some(next)).await;
        for _ in 0..10 {
            if port_is_usable(&state.config, &HashSet::new(), preferred).await {
                break;
            }
            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
        }
        let reused = reserve_export_port(&state, Some(preferred)).await.unwrap();
        assert_eq!(reused, preferred);
    }

    #[tokio::test]
    async fn import_impl_writes_volume_and_metadata() {
        let (_tempdir, config) = test_config();
        let state = AppState::new(config).await.unwrap();

        let response = import_impl(&state, "vol-1", None, Body::from("volume-data"))
            .await
            .unwrap();
        assert_eq!(response.size_bytes, 11);
        assert!(!response.node_local);
        assert!(response.materialized_from.is_none());

        let on_disk = fs::read(volume_path(&state.config, "vol-1")).await.unwrap();
        assert_eq!(on_disk, b"volume-data");

        let meta = load_metadata_file(&metadata_path(&state.config, "vol-1"))
            .await
            .unwrap()
            .unwrap();
        assert_eq!(meta.size_bytes, 11);
        assert_eq!(meta.format, VolumeFileFormat::Raw);
        assert!(!meta.node_local);
        assert!(meta.materialized_from.is_none());
        assert_eq!(meta.port, None);
        assert_eq!(meta.export_pid, None);
        assert_eq!(meta.export_read_only, None);
        assert_eq!(response.format, "raw");
    }

    #[tokio::test]
    async fn create_blank_impl_supports_qcow2_backing_files() {
        let (_tempdir, config) = test_config();
        let state = AppState::new(config.clone()).await.unwrap();
        let backing = config.data_dir.join("backing.raw");
        create_or_preallocate_file(&backing, 8 * 1024 * 1024, false)
            .await
            .unwrap();

        let response = create_blank_impl(
            &state,
            "overlay",
            CreateVolumeRequest {
                size_bytes: 16 * 1024 * 1024,
                format: Some(VolumeFileFormat::Qcow2),
                backing_file: Some(backing.display().to_string()),
                backing_format: Some(VolumeFileFormat::Raw),
            },
        )
        .await
        .unwrap();

        assert_eq!(response.format, "qcow2");
        assert!(response.node_local);
        assert_eq!(
            response.materialized_from.as_deref(),
            Some(backing.to_string_lossy().as_ref())
        );
        let meta = load_metadata_file(&metadata_path(&state.config, "overlay"))
            .await
            .unwrap()
            .unwrap();
        assert_eq!(meta.format, VolumeFileFormat::Qcow2);
        assert!(meta.node_local);
        assert_eq!(
            meta.materialized_from.as_deref(),
            Some(backing.to_string_lossy().as_ref())
        );

        let info = Command::new(&config.qemu_img_path)
            .args([
                "info",
                "--output",
                "json",
                volume_path(&state.config, "overlay")
                    .to_string_lossy()
                    .as_ref(),
            ])
            .output()
            .await
            .unwrap();
        assert!(info.status.success());
        let info: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();
        assert_eq!(info["format"], "qcow2");
    }

    #[tokio::test]
    async fn list_volume_responses_returns_sorted_items() {
        let (_tempdir, config) = test_config();
        let state = AppState::new(config).await.unwrap();

        create_blank_impl(
            &state,
            "vol-b",
            CreateVolumeRequest {
                size_bytes: 2 * 1024 * 1024,
                format: Some(VolumeFileFormat::Raw),
                backing_file: None,
                backing_format: None,
            },
        )
        .await
        .unwrap();
        create_blank_impl(
            &state,
            "vol-a",
            CreateVolumeRequest {
                size_bytes: 1024 * 1024,
                format: Some(VolumeFileFormat::Raw),
                backing_file: None,
                backing_format: None,
            },
        )
        .await
        .unwrap();

        let items = list_volume_responses(&state).await.unwrap();
        assert_eq!(items.len(), 2);
        assert_eq!(items[0].id, "vol-a");
        assert_eq!(items[1].id, "vol-b");
        assert_eq!(items[0].format, "raw");
        assert!(items.iter().all(|item| !item.node_local));
        assert!(items.iter().all(|item| item.materialized_from.is_none()));
        assert!(items.iter().all(|item| item.export.is_none()));
    }

    #[tokio::test]
    async fn materialize_impl_marks_node_local_origin() {
        let (_tempdir, config) = test_config();
        let state = AppState::new(config.clone()).await.unwrap();
        let source_path = config.data_dir.join("source.raw");
        create_or_preallocate_file(&source_path, 4 * 1024 * 1024, false)
            .await
            .unwrap();

        let response = materialize_impl(
            &state,
            "materialized",
            MaterializeVolumeRequest {
                source_uri: source_path.display().to_string(),
                size_bytes: Some(4 * 1024 * 1024),
                format: Some(VolumeFileFormat::Raw),
                lazy: false,
            },
        )
        .await
        .unwrap();

        assert!(response.node_local);
        assert_eq!(
            response.materialized_from.as_deref(),
            Some(source_path.to_string_lossy().as_ref())
        );

        let meta = load_metadata_file(&metadata_path(&state.config, "materialized"))
            .await
            .unwrap()
            .unwrap();
        assert!(meta.node_local);
        assert_eq!(
            meta.materialized_from.as_deref(),
            Some(source_path.to_string_lossy().as_ref())
        );
        assert_eq!(meta.export_read_only, None);
    }

    #[tokio::test]
    async fn materialize_impl_supports_lazy_qcow2_overlay() {
        let (_tempdir, config) = test_config();
        let state = AppState::new(config.clone()).await.unwrap();
        let source_path = config.data_dir.join("source.raw");
        create_or_preallocate_file(&source_path, 4 * 1024 * 1024, false)
            .await
            .unwrap();

        let response = materialize_impl(
            &state,
            "lazy-materialized",
            MaterializeVolumeRequest {
                source_uri: source_path.display().to_string(),
                size_bytes: Some(8 * 1024 * 1024),
                format: Some(VolumeFileFormat::Qcow2),
                lazy: true,
            },
        )
        .await
        .unwrap();

        assert_eq!(response.format, "qcow2");
        assert!(response.node_local);
        assert_eq!(
            response.materialized_from.as_deref(),
            Some(source_path.to_string_lossy().as_ref())
        );

        let info = Command::new(&config.qemu_img_path)
            .args([
                "info",
                "--output",
                "json",
                volume_path(&state.config, "lazy-materialized")
                    .to_string_lossy()
                    .as_ref(),
            ])
            .output()
            .await
            .unwrap();
        assert!(info.status.success());
        let info: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();
        assert_eq!(info["format"], "qcow2");
        assert_eq!(info["backing-filename"], source_path.display().to_string());
        assert_eq!(info["virtual-size"], 8 * 1024 * 1024);
    }

    #[tokio::test]
    async fn response_from_metadata_preserves_export_mode() {
        let (_tempdir, config) = test_config();
        let state = AppState::new(config).await.unwrap();
        let mut child = Command::new("sleep").arg("60").spawn().unwrap();
        let pid = child.id().unwrap();

        let response = response_from_metadata(
            &state,
            VolumeMetadata {
                id: "export-mode".to_string(),
                size_bytes: 4096,
                format: VolumeFileFormat::Raw,
                node_local: true,
                materialized_from: Some("nbd://127.0.0.1:11000".to_string()),
                port: Some(state.config.export_base_port),
                export_pid: Some(pid),
                export_read_only: Some(true),
                created_at: chrono::Utc::now().to_rfc3339(),
                updated_at: chrono::Utc::now().to_rfc3339(),
            },
        );

        assert!(response.node_local);
        assert_eq!(
            response.materialized_from.as_deref(),
            Some("nbd://127.0.0.1:11000")
        );
        let export = response.export.expect("expected export");
        assert!(export.read_only);

        let _ = child.kill().await;
        let _ = child.wait().await;
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn prepare_dirs_makes_volume_dir_group_writable_and_setgid() {
        let (_tempdir, config) = test_config();
        prepare_dirs(&config).await.unwrap();

        let mode = std::fs::metadata(config.volume_dir())
            .unwrap()
            .permissions()
            .mode()
            & 0o7777;
        assert_eq!(mode, 0o2770);
    }

    #[tokio::test]
    async fn delete_impl_releases_reserved_port_and_removes_files() {
        let (_tempdir, config) = test_config();
        let state = AppState::new(config).await.unwrap();
        let port = state.config.export_base_port + 2;

        import_impl(&state, "vol-delete", None, Body::from("delete-me"))
            .await
            .unwrap();
        mark_port_reserved(&state, port).await;

        let meta_path = metadata_path(&state.config, "vol-delete");
        save_metadata_file(
            &meta_path,
            &VolumeMetadata {
                id: "vol-delete".to_string(),
                size_bytes: 9,
                format: VolumeFileFormat::Raw,
                node_local: false,
                materialized_from: None,
                port: Some(port),
                export_pid: None,
                export_read_only: None,
                created_at: chrono::Utc::now().to_rfc3339(),
                updated_at: chrono::Utc::now().to_rfc3339(),
            },
        )
        .await
        .unwrap();
        fs::write(pid_path(&state.config, "vol-delete"), b"1234")
            .await
            .unwrap();

        delete_impl(&state, "vol-delete").await.unwrap();

        assert!(!fs::try_exists(volume_path(&state.config, "vol-delete"))
            .await
            .unwrap());
        assert!(!fs::try_exists(meta_path).await.unwrap());
        assert!(!fs::try_exists(pid_path(&state.config, "vol-delete"))
            .await
            .unwrap());

        let reused = reserve_export_port(&state, Some(port)).await.unwrap();
        assert_eq!(reused, port);
    }

    #[tokio::test]
    async fn release_export_impl_stops_export_and_frees_port() {
        let (_tempdir, config) = test_config();
        let state = AppState::new(config).await.unwrap();
        let port = state.config.export_base_port + 2;

        import_impl(&state, "vol-release", None, Body::from("release-me"))
            .await
            .unwrap();
        mark_port_reserved(&state, port).await;

        let mut child = Command::new("sleep").arg("60").spawn().unwrap();
        let pid = child.id().unwrap();
        let meta_path = metadata_path(&state.config, "vol-release");
        save_metadata_file(
            &meta_path,
            &VolumeMetadata {
                id: "vol-release".to_string(),
                size_bytes: 10,
                format: VolumeFileFormat::Raw,
                node_local: true,
                materialized_from: Some("nbd://127.0.0.1:18000".to_string()),
                port: Some(port),
                export_pid: Some(pid),
                export_read_only: Some(false),
                created_at: chrono::Utc::now().to_rfc3339(),
                updated_at: chrono::Utc::now().to_rfc3339(),
            },
        )
        .await
        .unwrap();
        fs::write(pid_path(&state.config, "vol-release"), format!("{pid}\n"))
            .await
            .unwrap();

        release_export_impl(&state, "vol-release").await.unwrap();

        let meta = load_metadata_file(&meta_path).await.unwrap().unwrap();
        assert_eq!(meta.port, Some(port));
        assert_eq!(meta.export_pid, None);
        assert_eq!(meta.export_read_only, None);
        assert!(!fs::try_exists(pid_path(&state.config, "vol-release"))
            .await
            .unwrap());

        tokio::time::timeout(std::time::Duration::from_secs(5), child.wait())
            .await
            .expect("timed out waiting for released export placeholder process to exit")
            .unwrap();
        assert!(!process_running(pid).await);

        let reused = reserve_export_port(&state, Some(port)).await.unwrap();
        assert_eq!(reused, port);
    }

    #[derive(Clone)]
    struct MockChainfireState {
        values: Arc<TokioMutex<HashMap<String, String>>>,
        stale_once_keys: Arc<TokioMutex<HashSet<String>>>,
    }

    #[derive(Deserialize)]
    struct MockPutRequest {
        value: String,
    }

    #[derive(Deserialize)]
    struct MockPrefixQuery {
        prefix: Option<String>,
    }

    async fn mock_chainfire_get(
        AxumState(state): AxumState<MockChainfireState>,
        AxumPath(key): AxumPath<String>,
    ) -> Result<AxumJson<serde_json::Value>, StatusCode> {
        let full_key = if key.contains('/') {
            format!("/{}", key)
        } else {
            key
        };
        let mut stale_once = state.stale_once_keys.lock().await;
        if stale_once.remove(&full_key) {
            return Err(StatusCode::NOT_FOUND);
        }
        drop(stale_once);
        let values = state.values.lock().await;
        let Some(value) = values.get(&full_key).cloned() else {
            return Err(StatusCode::NOT_FOUND);
        };
        Ok(AxumJson(serde_json::json!({
            "data": {
                "key": full_key,
                "value": value,
            },
            "meta": {
                "request_id": "test",
                "timestamp": chrono::Utc::now().to_rfc3339(),
            }
        })))
    }

    async fn mock_chainfire_put(
        AxumState(state): AxumState<MockChainfireState>,
        AxumPath(key): AxumPath<String>,
        AxumJson(req): AxumJson<MockPutRequest>,
    ) -> (StatusCode, AxumJson<serde_json::Value>) {
        let full_key = if key.contains('/') {
            format!("/{}", key)
        } else {
            key
        };
        state
            .values
            .lock()
            .await
            .insert(full_key.clone(), req.value);
        (
            StatusCode::OK,
            AxumJson(serde_json::json!({
                "data": {
                    "key": full_key,
                    "success": true,
                },
                "meta": {
                    "request_id": "test",
                    "timestamp": chrono::Utc::now().to_rfc3339(),
                }
            })),
        )
    }

    async fn mock_chainfire_delete(
        AxumState(state): AxumState<MockChainfireState>,
        AxumPath(key): AxumPath<String>,
    ) -> (StatusCode, AxumJson<serde_json::Value>) {
        let full_key = if key.contains('/') {
            format!("/{}", key)
        } else {
            key
        };
        state.values.lock().await.remove(&full_key);
        (
            StatusCode::OK,
            AxumJson(serde_json::json!({
                "data": {
                    "key": full_key,
                    "success": true,
                },
                "meta": {
                    "request_id": "test",
                    "timestamp": chrono::Utc::now().to_rfc3339(),
                }
            })),
        )
    }

    async fn mock_chainfire_list(
        AxumState(state): AxumState<MockChainfireState>,
        AxumQuery(query): AxumQuery<MockPrefixQuery>,
    ) -> AxumJson<serde_json::Value> {
        let prefix = query.prefix.unwrap_or_default();
        let values = state.values.lock().await;
        let mut items = values
            .iter()
            .filter(|(key, _)| key.starts_with(&prefix))
            .map(|(key, value)| {
                serde_json::json!({
                    "key": key,
                    "value": value,
                })
            })
            .collect::<Vec<_>>();
        items.sort_by(|left, right| left["key"].as_str().cmp(&right["key"].as_str()));
        AxumJson(serde_json::json!({
            "data": {
                "items": items,
            },
            "meta": {
                "request_id": "test",
                "timestamp": chrono::Utc::now().to_rfc3339(),
            }
        }))
    }

    #[tokio::test]
    async fn chainfire_metadata_store_round_trips_volume_metadata() {
        let (_tempdir, mut config) = test_config();
        prepare_dirs(&config).await.unwrap();

        let state = MockChainfireState {
            values: Arc::new(TokioMutex::new(HashMap::new())),
            stale_once_keys: Arc::new(TokioMutex::new(HashSet::new())),
        };
        let app = AxumRouter::new()
            .route(
                "/api/v1/kv/{*key}",
                axum_get(mock_chainfire_get)
                    .put(mock_chainfire_put)
                    .delete(mock_chainfire_delete),
            )
            .route("/api/v1/kv", axum_get(mock_chainfire_list))
            .with_state(state);
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr = listener.local_addr().unwrap();
        let server = tokio::spawn(async move {
            axum::serve(listener, app).await.unwrap();
        });

        config.metadata_backend = MetadataBackend::Chainfire;
        config.chainfire_api_url = Some(format!("http://{}", addr));
        config.chainfire_key_prefix = "/coronafs/test".to_string();
        let store = MetadataStore::from_config(&config).unwrap();

        let meta = VolumeMetadata {
            id: "vol-chainfire".to_string(),
            size_bytes: 1024,
            format: VolumeFileFormat::Raw,
            node_local: false,
            materialized_from: None,
            port: Some(18001),
            export_pid: Some(1234),
            export_read_only: Some(false),
            created_at: chrono::Utc::now().to_rfc3339(),
            updated_at: chrono::Utc::now().to_rfc3339(),
        };

        store.save(&config, &meta).await.unwrap();
        let loaded = store.load(&config, "vol-chainfire").await.unwrap().unwrap();
        assert_eq!(loaded.id, meta.id);
        assert_eq!(loaded.size_bytes, meta.size_bytes);
        assert_eq!(loaded.port, meta.port);

        let listed = store.list(&config).await.unwrap();
        assert_eq!(listed.len(), 1);
        assert_eq!(listed[0].id, meta.id);

        store.delete(&config, "vol-chainfire").await.unwrap();
        assert!(store
            .load(&config, "vol-chainfire")
            .await
            .unwrap()
            .is_none());

        server.abort();
    }

    #[tokio::test]
    async fn create_blank_impl_does_not_require_immediate_chainfire_read_after_write() {
        let (_tempdir, mut config) = test_config();
        prepare_dirs(&config).await.unwrap();

        let state = MockChainfireState {
            values: Arc::new(TokioMutex::new(HashMap::new())),
            stale_once_keys: Arc::new(TokioMutex::new(HashSet::new())),
        };
        let full_key = "/coronafs/test/vol-stale".to_string();
        state.stale_once_keys.lock().await.insert(full_key.clone());
        let app = AxumRouter::new()
            .route(
                "/api/v1/kv/{*key}",
                axum_get(mock_chainfire_get)
                    .put(mock_chainfire_put)
                    .delete(mock_chainfire_delete),
            )
            .route("/api/v1/kv", axum_get(mock_chainfire_list))
            .with_state(state.clone());
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr = listener.local_addr().unwrap();
        let server = tokio::spawn(async move {
            axum::serve(listener, app).await.unwrap();
        });

        config.metadata_backend = MetadataBackend::Chainfire;
        config.chainfire_api_url = Some(format!("http://{}", addr));
        config.chainfire_key_prefix = "/coronafs/test".to_string();
        let app_state = AppState::new(config).await.unwrap();

        let response = create_blank_impl(
            &app_state,
            "vol-stale",
            CreateVolumeRequest {
                size_bytes: 1024 * 1024,
                format: Some(VolumeFileFormat::Raw),
                backing_file: None,
                backing_format: None,
            },
        )
        .await
        .unwrap();

        assert_eq!(response.id, "vol-stale");
        assert_eq!(response.size_bytes, 1024 * 1024);
        assert!(state.values.lock().await.contains_key(&full_key));

        server.abort();
    }

    #[test]
    fn parse_chainfire_base_urls_supports_comma_separated_endpoints() {
        let urls =
            parse_chainfire_base_urls("http://127.0.0.1:2379, http://10.0.0.2:2379/").unwrap();
        assert_eq!(
            urls,
            vec![
                "http://127.0.0.1:2379".to_string(),
                "http://10.0.0.2:2379".to_string()
            ]
        );
    }

    #[test]
    fn volume_create_api_is_available_in_node_mode() {
        let state = AppState {
            config: Arc::new(ServerConfig {
                mode: ServerMode::Node,
                ..ServerConfig::default()
            }),
            metadata_store: MetadataStore::Filesystem,
            volume_guards: Arc::new(Mutex::new(HashMap::new())),
            reserved_ports: Arc::new(Mutex::new(HashSet::new())),
        };
        assert!(ensure_volume_create_api(&state, "volume create").is_ok());
    }
}
