use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};

use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use tera::Context;
use tokio::sync::RwLock;

use super::super::auth::AuthUser;
use crate::api::AppState;

// ── Error ────────────────────────────────────────────────────────────────────

#[derive(Debug, thiserror::Error)]
pub enum UiError {
    #[error("template render error: {0}")]
    Render(#[from] tera::Error),
    #[error("ncl export failed for {path}: {reason}")]
    NclExport { path: String, reason: String },
    #[error("ui is not configured — start the daemon with --templates-dir")]
    NotConfigured,
    #[error("bad request: {0}")]
    BadRequest(String),
    #[error("forbidden: {0}")]
    Forbidden(String),
}

impl IntoResponse for UiError {
    fn into_response(self) -> Response {
        let status = match &self {
            UiError::BadRequest(_) => StatusCode::BAD_REQUEST,
            UiError::Forbidden(_) => StatusCode::FORBIDDEN,
            _ => StatusCode::INTERNAL_SERVER_ERROR,
        };
        let detail = if let UiError::Render(ref e) = self {
            use std::error::Error;
            let mut chain = format!("{e}");
            let mut src = e.source();
            while let Some(s) = src {
                chain.push_str(&format!("\nCaused by: {s}"));
                src = s.source();
            }
            chain
        } else {
            format!("{self}")
        };
        let html = format!(
            r#"<!DOCTYPE html><html data-theme="dark"><body class="p-8">
<h1 class="text-2xl font-bold text-error mb-4">UI Error</h1>
<pre class="bg-base-200 p-4 rounded">{detail}</pre>
</body></html>"#
        );
        (status, Html(html)).into_response()
    }
}

// ── Helpers ──────────────────────────────────────────────────────────────────

/// Acquire read lock on Tera and render a template.
/// The lock is held only for the duration of `render()` — not across the
/// async NCL export — so reloads are never blocked by slow nickel subprocess
/// calls.
pub(crate) async fn render(
    tera: &Arc<RwLock<ui::TeraEnv>>,
    template: &str,
    ctx: &Context,
) -> Result<Html<String>, UiError> {
    let guard = tera.read().await;
    guard.render(template, ctx).map(Html).map_err(|e| {
        use std::error::Error;
        let mut chain = format!("{e}");
        let mut src = e.source();
        while let Some(s) = src {
            chain.push_str(&format!(" → {s}"));
            src = s.source();
        }
        tracing::error!(template, error = %chain, "tera render failed");
        UiError::Render(e)
    })
}

pub(crate) fn tera_ref(state: &AppState) -> Result<&Arc<RwLock<ui::TeraEnv>>, UiError> {
    state.tera.as_ref().ok_or(UiError::NotConfigured)
}

pub(crate) async fn ncl_export(
    state: &AppState,
    relative_path: &str,
) -> Result<serde_json::Value, UiError> {
    let path = state.project_root.join(relative_path);
    let (value, _hit) = state
        .cache
        .export(&path, state.nickel_import_path.as_deref())
        .await
        .map_err(|e| UiError::NclExport {
            path: relative_path.to_string(),
            reason: e.to_string(),
        })?;
    Ok(value)
}

pub(crate) fn epoch_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

/// Format current UTC time as "HH:MM:SS UTC" for display in templates.
pub(crate) fn now_hms() -> String {
    let secs = epoch_secs();
    let h = (secs % 86400) / 3600;
    let m = (secs % 3600) / 60;
    let s = secs % 60;
    format!("{h:02}:{m:02}:{s:02} UTC")
}

pub(crate) fn now_iso() -> String {
    let total_secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    let secs_in_day = total_secs % 86400;
    let days = total_secs / 86400;
    let h = secs_in_day / 3600;
    let m = (secs_in_day % 3600) / 60;
    let s = secs_in_day % 60;
    let z = days + 719_468;
    let era = z / 146_097;
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
    let y0 = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let mo = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if mo <= 2 { y0 + 1 } else { y0 };
    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}

pub(crate) fn today_iso() -> String {
    // No chrono dep — derive from SystemTime epoch arithmetic.
    let secs = epoch_secs();
    let days_since_epoch = secs / 86400;
    // Gregorian calendar from Julian Day Number (JDN 2440588 = 1970-01-01)
    let jdn = days_since_epoch as i64 + 2440588;
    let (y, m, d) = jdn_to_ymd(jdn);
    format!("{y:04}-{m:02}-{d:02}")
}

fn jdn_to_ymd(jdn: i64) -> (i64, i64, i64) {
    let a = jdn + 32044;
    let b = (4 * a + 3) / 146097;
    let c = a - (146097 * b) / 4;
    let dd = (4 * c + 3) / 1461;
    let e = c - (1461 * dd) / 4;
    let mm = (5 * e + 2) / 153;
    let d = e - (153 * mm + 2) / 5 + 1;
    let m = mm + 3 - 12 * (mm / 10);
    let y = 100 * b + dd - 4800 + mm / 10;
    (y, m, d)
}

// ── Auth helpers ─────────────────────────────────────────────────────────────

/// Map the HTTP auth role to a lowercase string for Tera context.
/// Templates gate admin-only sections on `current_role == "admin"`.
pub(crate) fn auth_role_str(auth: &AuthUser) -> &'static str {
    use crate::registry::Role;
    match auth.0.role {
        Role::Admin => "admin",
        Role::Viewer => "viewer",
    }
}

// ── Brand context ────────────────────────────────────────────────────────────

/// Resolve a logo config value to a URL.
/// Bare paths (no `/` or scheme prefix) are served from `{base_url}/assets/`.
fn resolve_logo_url(raw: &str, base_url: &str) -> String {
    if raw.starts_with('/') || raw.starts_with("http://") || raw.starts_with("https://") {
        raw.to_string()
    } else {
        format!("{base_url}/assets/{raw}")
    }
}

/// Load and export `.ontoref/config.ncl`, returning the full JSON value.
/// Returns `None` if the file doesn't exist or Nickel export fails.
pub(crate) async fn load_config_json(
    root: &std::path::Path,
    cache: &Arc<crate::cache::NclCache>,
    import_path: Option<&str>,
) -> Option<serde_json::Value> {
    let config_path = root.join(".ontoref").join("config.ncl");
    if !config_path.exists() {
        return None;
    }
    match cache.export(&config_path, import_path).await {
        Ok((json, _)) => {
            tracing::info!(
                path = %config_path.display(),
                has_card = json.get("card").is_some(),
                card_tagline = json.get("card").and_then(|c| c.get("tagline")).and_then(|v| v.as_str()).unwrap_or(""),
                "config.ncl loaded"
            );
            Some(json)
        }
        Err(e) => {
            tracing::warn!(
                path = %config_path.display(),
                import_path = ?import_path,
                error = %e,
                "config.ncl export failed"
            );
            None
        }
    }
}

/// Load logo URLs from `.ontoref/config.ncl` ui section.
/// Returns `(logo_light_url, logo_dark_url)` — either may be `None`.
async fn load_logos(
    root: &std::path::Path,
    cache: &Arc<crate::cache::NclCache>,
    import_path: Option<&str>,
    base_url: &str,
) -> (Option<String>, Option<String>) {
    let Some(json) = load_config_json(root, cache, import_path).await else {
        return (None, None);
    };
    let ui = json.get("ui");
    let logo = ui
        .and_then(|u| u.get("logo"))
        .and_then(|v| v.as_str())
        .map(|raw| resolve_logo_url(raw, base_url));
    let logo_dark = ui
        .and_then(|u| u.get("logo_dark"))
        .and_then(|v| v.as_str())
        .map(|raw| resolve_logo_url(raw, base_url));
    (logo, logo_dark)
}

/// Extract card data from a config JSON (from `.ontoref/config.ncl` `card`
/// field).
pub(crate) fn extract_card_from_config(json: &serde_json::Value) -> serde_json::Value {
    let Some(card) = json.get("card") else {
        return serde_json::Value::Null;
    };
    serde_json::json!({
        "tagline":     card.get("tagline").and_then(|v| v.as_str()).unwrap_or(""),
        "description": card.get("description").and_then(|v| v.as_str()).unwrap_or(""),
        "version":     card.get("version").and_then(|v| v.as_str()).unwrap_or(""),
        "status":      card.get("status").and_then(|v| v.as_str()).unwrap_or(""),
        "url":         card.get("url").and_then(|v| v.as_str()).unwrap_or(""),
        "repo":        card.get("repo").and_then(|v| v.as_str()).unwrap_or(""),
        "docs":        card.get("docs").and_then(|v| v.as_str()).unwrap_or(""),
        "tags":        card.get("tags").and_then(|v| v.as_array()).cloned().unwrap_or_default(),
        "features":    card.get("features").and_then(|v| v.as_array()).cloned().unwrap_or_default(),
    })
}

pub(crate) struct DomainCtx {
    pub has_origin: bool,
    pub origin_id: String,
    pub origin_name: String,
    pub origin_path: String,
    pub origin_integrated: bool,
    pub origin_slug: Option<String>,
    pub has_provides: bool,
    pub provides_id: String,
    pub provides_name: String,
    pub provides_kind: String,
    pub impl_projects: Vec<serde_json::Value>,
}

impl Default for DomainCtx {
    fn default() -> Self {
        Self {
            has_origin: false,
            origin_id: String::new(),
            origin_name: String::new(),
            origin_path: String::new(),
            origin_integrated: true,
            origin_slug: None,
            has_provides: false,
            provides_id: String::new(),
            provides_name: String::new(),
            provides_kind: String::new(),
            impl_projects: Vec::new(),
        }
    }
}

/// Extract registry participant, namespaces, and endpoint from manifest JSON.
/// Returns empty strings/vec when `registry_provides` is absent.
pub(crate) fn extract_registry_ctx(
    manifest_json: Option<&serde_json::Value>,
) -> (String, Vec<String>, String) {
    let Some(mj) = manifest_json else {
        return (String::new(), Vec::new(), String::new());
    };
    let rp = mj.get("registry_provides");
    let participant = rp
        .and_then(|v| v.get("participant"))
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_owned();
    if participant.is_empty() {
        return (String::new(), Vec::new(), String::new());
    }
    let registries = rp.and_then(|v| v.get("registries"));
    let default_id = registries
        .and_then(|v| v.get("default"))
        .and_then(|v| v.as_str())
        .unwrap_or("primary");
    let entry = registries
        .and_then(|v| v.get("registries"))
        .and_then(|v| v.as_array())
        .and_then(|arr| {
            arr.iter()
                .find(|e| e.get("id").and_then(|i| i.as_str()) == Some(default_id))
        });
    let namespaces = entry
        .and_then(|e| e.get("namespaces"))
        .and_then(|n| n.get("own"))
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(str::to_owned))
                .collect()
        })
        .unwrap_or_default();
    let endpoint = entry
        .and_then(|e| e.get("endpoint"))
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_owned();
    (participant, namespaces, endpoint)
}

/// Pure projection of `.ontoref/project.ncl::sops` into vault basics.
/// Returns `Some((vault_id, vault_mode))` when sops is enabled, `None`
/// otherwise. vault_mode: "declarative" if `recipient_rules` is a non-empty
/// array, "legacy" otherwise.
pub(crate) fn vault_basics_from_json(
    project_json: Option<&serde_json::Value>,
) -> Option<(String, String)> {
    let pj = project_json?;
    let sops = pj.get("sops")?;
    let enabled = sops
        .get("enabled")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    if !enabled {
        return None;
    }
    let slug = pj.get("slug").and_then(|v| v.as_str()).unwrap_or("");
    let vault_id = sops
        .get("vault_id")
        .and_then(|v| v.as_str())
        .filter(|s| !s.is_empty())
        .unwrap_or(slug)
        .to_string();
    let has_rules = sops
        .get("recipient_rules")
        .and_then(|v| v.as_array())
        .map(|a| !a.is_empty())
        .unwrap_or(false);
    let mode = if has_rules { "declarative" } else { "legacy" }.to_string();
    Some((vault_id, mode))
}

/// Extract vault state for the UI from a project.ncl JSON
/// (.ontoref/project.ncl::sops). Returns (has_vault, vault_id, vault_mode,
/// recipient_count, last_op_summary).
pub(crate) fn extract_vault_ctx(
    project_json: Option<&serde_json::Value>,
) -> (bool, String, String, i32, String) {
    let Some((vault_id, mode)) = vault_basics_from_json(project_json) else {
        return (false, String::new(), String::new(), 0, String::new());
    };

    // Local files — best-effort, no failure on absence.
    let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
    let (recipient_count, last_op_summary) = if let Some(home) = home {
        let vault_dir = home
            .join(".config")
            .join("ontoref")
            .join("vaults")
            .join(&vault_id);
        let access_sops = vault_dir.join("access.sops.yaml");
        let count: i32 = if access_sops.exists() {
            std::fs::read_to_string(&access_sops)
                .map(|s| {
                    s.lines()
                        .filter(|l| l.contains("recipient") && l.contains("age1"))
                        .count() as i32
                })
                .unwrap_or(0)
        } else {
            0
        };
        let logs = vault_dir.join("logs").join("access.jsonl");
        let last = if logs.exists() {
            std::fs::read_to_string(&logs)
                .ok()
                .and_then(|s| s.lines().last().map(String::from))
                .and_then(|line| serde_json::from_str::<serde_json::Value>(&line).ok())
                .map(|v| {
                    let op = v.get("op").and_then(|x| x.as_str()).unwrap_or("?");
                    let actor = v.get("actor").and_then(|x| x.as_str()).unwrap_or("?");
                    let ts = v.get("ts").and_then(|x| x.as_str()).unwrap_or("");
                    format!("{op} by {actor} @ {ts}")
                })
                .unwrap_or_default()
        } else {
            String::new()
        };
        (count, last)
    } else {
        (0, String::new())
    };
    (true, vault_id, mode, recipient_count, last_op_summary)
}

/// Resolve domain_origin and domain_provides context from a manifest JSON
/// value.
pub(crate) async fn resolve_domain_ctx(
    manifest_json: &serde_json::Value,
    registry: &Arc<crate::registry::ProjectRegistry>,
) -> DomainCtx {
    let mut dc = DomainCtx::default();
    if let Some(origin) = manifest_json.get("domain_origin") {
        dc.has_origin = true;
        dc.origin_id = origin
            .get("id")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        dc.origin_name = origin
            .get("name")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        dc.origin_path = origin
            .get("path")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        dc.origin_integrated = origin
            .get("integrated")
            .and_then(|v| v.as_bool())
            .unwrap_or(true);
        if !dc.origin_path.is_empty() {
            let target = std::path::PathBuf::from(&dc.origin_path);
            dc.origin_slug = registry
                .all()
                .into_iter()
                .find(|pc| pc.root == target)
                .map(|pc| pc.slug.clone());
        }
    }
    if let Some(provides) = manifest_json.get("domain_provides") {
        dc.has_provides = true;
        dc.provides_id = provides
            .get("id")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        dc.provides_name = provides
            .get("name")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        dc.provides_kind = provides
            .get("kind")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        for proj_ctx in registry.all() {
            let mpath =
                ontoref_ontology::layout::resolve_section(&proj_ctx.root, "ontology/manifest.ncl");
            if !mpath.exists() {
                continue;
            }
            let Ok((mj, _)) = proj_ctx
                .cache
                .export(&mpath, proj_ctx.import_path.as_deref())
                .await
            else {
                continue;
            };
            let matches = mj
                .get("domain_origin")
                .and_then(|o| o.get("id"))
                .and_then(|v| v.as_str())
                == Some(dc.provides_id.as_str());
            if matches {
                dc.impl_projects
                    .push(serde_json::json!({ "slug": proj_ctx.slug }));
            }
        }
    }
    dc
}

/// Insert logo and MCP metadata into a Tera context.
/// Logos are loaded from `.ontoref/config.ncl`; service flags reflect runtime
/// state from `AppState::service_flags`.
pub(crate) async fn insert_brand_ctx(
    ctx: &mut Context,
    root: &std::path::Path,
    cache: &Arc<crate::cache::NclCache>,
    import_path: Option<&str>,
    base_url: &str,
    registry: &Arc<crate::registry::ProjectRegistry>,
    flags: &crate::api::ServiceFlags,
) {
    let (logo, logo_dark) = load_logos(root, cache, import_path, base_url).await;
    ctx.insert("logo", &logo);
    ctx.insert("logo_dark", &logo_dark);
    insert_mcp_ctx(ctx, flags);
    // Expose repo + docs URLs from card for file link generation in templates.
    let (repo_url, docs_url) = if let Some(json) = load_config_json(root, cache, import_path).await
    {
        let card = json.get("card");
        let repo = card
            .and_then(|c| c.get("repo"))
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        let docs = card
            .and_then(|c| c.get("docs"))
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        (repo, docs)
    } else {
        (String::new(), String::new())
    };
    ctx.insert("card_repo", &repo_url);
    ctx.insert("card_docs", &docs_url);
    ctx.insert(
        "has_personal",
        &ontoref_ontology::layout::resolve_section(root, "ontology/personal.ncl").exists(),
    );
    ctx.insert(
        "has_career",
        &ontoref_ontology::layout::resolve_section(root, "ontology/career.ncl").exists(),
    );
    let is_provisioning = load_config_json(root, cache, import_path)
        .await
        .and_then(|c| c.get("domain_provisioning").and_then(|v| v.as_bool()))
        .unwrap_or(false);
    ctx.insert("has_provisioning", &is_provisioning);

    // Domain ontology layer + registry topology — both read from manifest.ncl.
    let manifest_path = ontoref_ontology::layout::resolve_section(root, "ontology/manifest.ncl");
    let manifest_json: Option<serde_json::Value> = if manifest_path.exists() {
        match cache.export(&manifest_path, import_path).await {
            Ok((mj, _)) => Some(mj),
            Err(_) => None,
        }
    } else {
        None
    };

    let domain = match &manifest_json {
        Some(mj) => resolve_domain_ctx(mj, registry).await,
        None => DomainCtx::default(),
    };
    ctx.insert("has_domain_origin", &domain.has_origin);
    ctx.insert("domain_origin_id", &domain.origin_id);
    ctx.insert("domain_origin_name", &domain.origin_name);
    ctx.insert("domain_origin_path", &domain.origin_path);
    ctx.insert("domain_origin_integrated", &domain.origin_integrated);
    ctx.insert("domain_origin_slug", &domain.origin_slug);
    ctx.insert("has_domain_provides", &domain.has_provides);
    ctx.insert("domain_provides_id", &domain.provides_id);
    ctx.insert("domain_provides_name", &domain.provides_name);
    ctx.insert("domain_provides_kind", &domain.provides_kind);
    ctx.insert("domain_impl_projects", &domain.impl_projects);

    // Registry topology — manifest.registry_provides (default registry entry)
    let (registry_participant, registry_namespaces, registry_endpoint) =
        extract_registry_ctx(manifest_json.as_ref());
    ctx.insert("has_registry", &!registry_participant.is_empty());
    ctx.insert("registry_participant", &registry_participant);
    ctx.insert("registry_namespaces", &registry_namespaces);
    ctx.insert("registry_endpoint", &registry_endpoint);

    // Vault state — project.ncl::sops (ADR-017 + ADR-019)
    let project_ncl_path = root.join(".ontoref").join("project.ncl");
    let project_json: Option<serde_json::Value> = if project_ncl_path.exists() {
        match cache.export(&project_ncl_path, import_path).await {
            Ok((pj, _)) => Some(pj),
            Err(_) => None,
        }
    } else {
        None
    };
    let (has_vault, vault_id, vault_mode, vault_recipients, vault_last_op) =
        extract_vault_ctx(project_json.as_ref());
    ctx.insert("has_vault", &has_vault);
    ctx.insert("vault_id", &vault_id);
    ctx.insert("vault_mode", &vault_mode);
    ctx.insert("vault_recipients", &vault_recipients);
    ctx.insert("vault_last_op", &vault_last_op);
}

/// Extract the first meaningful prose line from README.md.
/// Skips HTML tags, Markdown headings, image/badge lines, blockquotes, and
/// blank lines.
pub(crate) fn readme_description(root: &std::path::Path) -> String {
    let readme = root.join("README.md");
    let Ok(content) = std::fs::read_to_string(&readme) else {
        return String::new();
    };
    for raw in content.lines() {
        let line = raw.trim();
        if line.is_empty() {
            continue;
        }
        if line.starts_with('<')
            || line.starts_with('#')
            || line.starts_with('!')
            || line.starts_with("---")
            || line.starts_with("===")
            || line.contains("badge")
            || line.contains("img.shields")
            || line.contains("<img")
            || line.starts_with('>')
        {
            continue;
        }
        let text = line.trim_start_matches(['*', '_', '`', '>', ' ']);
        if text.len() > 10 {
            return if text.len() > 160 {
                format!("{}…", &text[..text.floor_char_boundary(160)])
            } else {
                text.to_string()
            };
        }
    }
    String::new()
}

/// Insert MCP and GraphQL runtime state and daemon version into a Tera context.
pub(crate) fn insert_mcp_ctx(ctx: &mut Context, flags: &crate::api::ServiceFlags) {
    ctx.insert("daemon_version", env!("CARGO_PKG_VERSION"));
    #[cfg(feature = "graphql")]
    ctx.insert(
        "graphql_active",
        &flags.graphql.load(std::sync::atomic::Ordering::Relaxed),
    );
    #[cfg(not(feature = "graphql"))]
    ctx.insert("graphql_active", &false);
    #[cfg(feature = "mcp")]
    {
        ctx.insert(
            "mcp_active",
            &flags.mcp.load(std::sync::atomic::Ordering::Relaxed),
        );
        ctx.insert(
            "mcp_tools",
            &[
                // discovery
                "ontoref_help",
                "ontoref_list_projects",
                "ontoref_set_project",
                // search + retrieval
                "ontoref_search",
                "ontoref_get",
                "ontoref_get_node",
                "ontoref_get_adr",
                "ontoref_get_mode",
                // project state
                "ontoref_status",
                "ontoref_describe",
                "ontoref_guides",
                // ontology
                "ontoref_list_adrs",
                "ontoref_list_modes",
                "ontoref_list_ontology_extensions",
                "ontoref_get_ontology_extension",
                "ontoref_constraints",
                // backlog + actions
                "ontoref_backlog_list",
                "ontoref_backlog",
                "ontoref_action_list",
                "ontoref_action_add",
                // validation
                "ontoref_validate_adrs",
                "ontoref_validate",
                "ontoref_impact",
                // qa + bookmarks
                "ontoref_qa_list",
                "ontoref_qa_add",
                "ontoref_bookmark_list",
                "ontoref_bookmark_add",
            ],
        );
    }
    #[cfg(not(feature = "mcp"))]
    {
        ctx.insert("mcp_active", &false);
        ctx.insert("mcp_tools", &Vec::<&str>::new());
    }
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::*;

    #[test]
    fn vault_basics_returns_none_when_no_project_json() {
        assert!(vault_basics_from_json(None).is_none());
    }

    #[test]
    fn vault_basics_returns_none_when_sops_absent() {
        let j = json!({ "slug": "demo" });
        assert!(vault_basics_from_json(Some(&j)).is_none());
    }

    #[test]
    fn vault_basics_returns_none_when_sops_disabled() {
        let j = json!({ "slug": "demo", "sops": { "enabled": false } });
        assert!(vault_basics_from_json(Some(&j)).is_none());
    }

    #[test]
    fn vault_basics_legacy_mode_no_rules() {
        let j = json!({
            "slug": "demo",
            "sops": { "enabled": true, "vault_id": "demo-vault" }
        });
        let (id, mode) = vault_basics_from_json(Some(&j)).expect("vault");
        assert_eq!(id, "demo-vault");
        assert_eq!(mode, "legacy");
    }

    #[test]
    fn vault_basics_legacy_mode_empty_rules_array() {
        let j = json!({
            "slug": "demo",
            "sops": { "enabled": true, "recipient_rules": [] }
        });
        let (id, mode) = vault_basics_from_json(Some(&j)).expect("vault");
        assert_eq!(id, "demo");
        assert_eq!(mode, "legacy");
    }

    #[test]
    fn vault_basics_declarative_mode_with_rules() {
        let j = json!({
            "slug": "libre-wuji",
            "sops": {
                "enabled": true,
                "vault_id": "libre-wuji",
                "recipient_rules": [
                    { "path": "access\\.sops\\.yaml$", "groups": ["admin"] }
                ]
            }
        });
        let (id, mode) = vault_basics_from_json(Some(&j)).expect("vault");
        assert_eq!(id, "libre-wuji");
        assert_eq!(mode, "declarative");
    }

    #[test]
    fn vault_basics_falls_back_to_slug_when_vault_id_missing() {
        let j = json!({
            "slug": "fallback-slug",
            "sops": { "enabled": true }
        });
        let (id, _mode) = vault_basics_from_json(Some(&j)).expect("vault");
        assert_eq!(id, "fallback-slug");
    }

    #[test]
    fn vault_basics_falls_back_to_slug_when_vault_id_empty_string() {
        let j = json!({
            "slug": "fallback-slug",
            "sops": { "enabled": true, "vault_id": "" }
        });
        let (id, _mode) = vault_basics_from_json(Some(&j)).expect("vault");
        assert_eq!(id, "fallback-slug");
    }
}
