use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, RwLock};

use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use tokio::sync::Semaphore;
use tracing::warn;

use crate::actors::ActorRegistry;
use crate::cache::NclCache;
use crate::config::OpsConfig;
use crate::config_reload::{apply_config_reload, ReloadAction};
use crate::notifications::NotificationStore;

// ── Config surface
// ────────────────────────────────────────────────────────────

/// Which process reads a config section.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConsumerKind {
    RustStruct,
    NuScript,
    CiPipeline,
    External,
}

/// A single process that reads fields from a config section.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigConsumer {
    /// Identifier for this consumer (e.g. "vapora-backend", "deploy-script").
    pub id: String,
    pub kind: ConsumerKind,
    /// Rust fully-qualified type or script path.
    pub reference: String,
    /// Fields this consumer reads. Empty = reads all fields.
    pub fields: Vec<String>,
}

/// Describes one NCL config file and all processes that read it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigSection {
    pub id: String,
    /// Path to the NCL file, relative to `config_root`.
    pub file: String,
    /// Path to the NCL contract file. Relative to `contracts_path` or project
    /// root.
    pub contract: String,
    pub description: String,
    /// Why this section exists and why current values were chosen.
    pub rationale: String,
    /// When false, ontoref will only read this section, never write.
    pub mutable: bool,
    pub consumers: Vec<ConfigConsumer>,
}

/// How the project's config files are organised.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConfigKind {
    /// Multiple .ncl files merged via & operator.
    NclMerge,
    /// .typedialog/ structure with form.toml + validators + fragments.
    TypeDialog,
    /// Single monolithic .ncl file.
    SingleFile,
}

/// Project-level config surface metadata — loaded once from manifest.ncl at
/// registration time and stored in `ProjectContext`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigSurface {
    /// Directory containing config NCL files, relative to project root.
    pub config_root: PathBuf,
    /// Main NCL file (entry point for `nickel export`).
    pub entry_point: String,
    pub kind: ConfigKind,
    /// Directory added to NICKEL_IMPORT_PATH when exporting config.
    pub contracts_path: String,
    /// Where ontoref writes `{section}.overrides.ncl` files.
    /// Defaults to `config_root` when empty.
    pub overrides_dir: PathBuf,
    pub sections: Vec<ConfigSection>,
}

impl ConfigSurface {
    /// Resolve the directory where override files are written.
    /// Returns `overrides_dir` if set, otherwise `config_root`.
    pub fn resolved_overrides_dir(&self) -> &Path {
        if self.overrides_dir == PathBuf::new() {
            &self.config_root
        } else {
            &self.overrides_dir
        }
    }

    /// Resolve the absolute path to the config entry point given the project
    /// root.
    pub fn entry_point_path(&self, project_root: &Path) -> PathBuf {
        project_root.join(&self.config_root).join(&self.entry_point)
    }

    /// Find a section by id.
    pub fn section(&self, id: &str) -> Option<&ConfigSection> {
        self.sections.iter().find(|s| s.id == id)
    }
}

// ── Auth / keys
// ───────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
    Admin,
    Viewer,
}

// Bridge between this daemon-local enum and the `auth::Role` that the
// lifted-out auth crate uses (γ-consumer of ontoref-auth, ADR-021
// transition). The translation is total but no longer isomorphic:
// `auth::Role::Editor` ("capabilities resolved by the consuming domain")
// has no capability map in this daemon, so it narrows to Viewer —
// least privilege until an editor surface exists here. A future ADR may
// collapse the two enums into a single re-export — until then these
// `From` impls are the seam.
impl From<Role> for auth::Role {
    fn from(r: Role) -> Self {
        match r {
            Role::Admin => auth::Role::Admin,
            Role::Viewer => auth::Role::Viewer,
        }
    }
}

impl From<auth::Role> for Role {
    fn from(r: auth::Role) -> Self {
        match r {
            auth::Role::Admin => Role::Admin,
            auth::Role::Editor | auth::Role::Viewer => Role::Viewer,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyEntry {
    pub role: Role,
    /// Argon2id PHC string — produced by `ontoref-daemon --hash-password <pw>`
    pub hash: String,
    /// Human-readable label identifying which key this is (e.g. "developer",
    /// "agent", "ci"). Recorded in sessions for audit tracing. Optional —
    /// defaults to empty string for keys created before this field existed.
    #[serde(default)]
    pub label: String,
}

/// Result of a successful key verification — role and the label of the
/// matched key for audit trail purposes.
#[derive(Debug, Clone)]
pub struct KeyMatch {
    pub role: Role,
    pub label: String,
}

/// Verify `password` against a slice of `KeyEntry`s using argon2id.
///
/// Returns the `KeyMatch` (role + label) of the first matching key, or `None`
/// if no key matches. Unparseable PHC hashes are logged and skipped.
pub fn verify_keys_list(keys: &[KeyEntry], password: &str) -> Option<KeyMatch> {
    use argon2::{Argon2, PasswordHash, PasswordVerifier};
    for key in keys {
        let Ok(parsed) = PasswordHash::new(&key.hash) else {
            warn!(role = ?key.role, label = %key.label, "skipping key entry with unparseable PHC hash — key is inactive");
            continue;
        };
        if Argon2::default()
            .verify_password(password.as_bytes(), &parsed)
            .is_ok()
        {
            return Some(KeyMatch {
                role: key.role,
                label: key.label.clone(),
            });
        }
    }
    None
}

/// Entry as it arrives from the JSON config pipe (ADR-004).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryEntry {
    pub slug: String,
    /// Absolute local path. Empty for push_only projects.
    #[serde(default)]
    pub root: PathBuf,
    /// Import paths for nickel export (local projects only).
    #[serde(default)]
    pub nickel_import_paths: Vec<String>,
    #[serde(default)]
    pub keys: Vec<KeyEntry>,
    /// Git/SSH/HTTPS URL. Informational — daemon never fetches from it.
    #[serde(default)]
    pub remote_url: String,
    /// When true: no local file watch, no NCL import. Project pushes via POST
    /// /sync.
    #[serde(default)]
    pub push_only: bool,
}

/// Per-project runtime state owned by the registry.
pub struct ProjectContext {
    pub slug: String,
    pub root: PathBuf,
    pub import_path: Option<String>,
    pub cache: Arc<NclCache>,
    pub actors: Arc<ActorRegistry>,
    pub notifications: Arc<NotificationStore>,
    /// Mutable at runtime via `PUT /projects/{slug}/keys`.
    pub keys: RwLock<Vec<KeyEntry>>,
    /// Git/SSH/HTTPS remote URL. Informational.
    pub remote_url: String,
    /// No local file watch, no NCL import — project pushes via POST /sync.
    pub push_only: bool,
    /// Ensures only one `seed_ontology` runs at a time per project.
    /// Prevents concurrent re-seeds caused by rapid filesystem events.
    pub seed_lock: Arc<Semaphore>,
    /// Incremented by 1 after each successful `seed_ontology` completion.
    /// Clients can compare versions to detect stale local state.
    pub ontology_version: Arc<AtomicU64>,
    /// Per-file change counters. Keyed by canonical absolute path; incremented
    /// on every cache invalidation for that file. Consumers compare snapshots
    /// to detect which individual files changed between polls.
    pub file_versions: Arc<DashMap<PathBuf, u64>>,
    /// Config surface metadata loaded from the project's manifest.ncl at
    /// registration time. `None` when the project's manifest has no
    /// `config_surface` field or when the manifest can't be exported.
    pub config_surface: Option<ConfigSurface>,
    /// Currently-active `OpsConfig` (tier + phase) for this project.
    /// Defaults to `Tier0 / Progressive` until populated by startup
    /// config load or a successful guarded reload. Updated via
    /// [`apply_ops_reload`] which routes through
    /// [`crate::config_reload::apply_config_reload`] for guard
    /// enforcement (ADR-033 `config-watcher-applies-guard`).
    ///
    /// [`apply_ops_reload`]: ProjectContext::apply_ops_reload
    pub ops: RwLock<OpsConfig>,
}

impl ProjectContext {
    pub fn auth_enabled(&self) -> bool {
        !self
            .keys
            .read()
            .unwrap_or_else(|e| e.into_inner())
            .is_empty()
    }

    pub fn verify_key(&self, password: &str) -> Option<KeyMatch> {
        let keys = self.keys.read().unwrap_or_else(|e| e.into_inner());
        verify_keys_list(&keys, password)
    }

    /// Replace the current key set. Returns the number of keys now active.
    pub fn set_keys(&self, new_keys: Vec<KeyEntry>) -> usize {
        let mut guard = self.keys.write().unwrap_or_else(|e| e.into_inner());
        *guard = new_keys;
        guard.len()
    }

    /// Snapshot of the currently-active ops state.
    pub fn ops(&self) -> OpsConfig {
        self.ops.read().unwrap_or_else(|e| e.into_inner()).clone()
    }

    /// Replace the active ops state unconditionally. Use only for startup
    /// initialization from the loaded `DaemonNclConfig`; runtime mutations
    /// MUST go through [`apply_ops_reload`] so the guard fires.
    ///
    /// [`apply_ops_reload`]: ProjectContext::apply_ops_reload
    pub fn set_ops_unchecked(&self, new_ops: OpsConfig) {
        let mut guard = self.ops.write().unwrap_or_else(|e| e.into_inner());
        *guard = new_ops;
    }

    /// Guarded ops reload (ADR-033 `config-watcher-applies-guard`).
    /// Reads the current ops, invokes
    /// [`crate::config_reload::apply_config_reload`], and either applies
    /// the new value (when the guard accepts) or retains the previous
    /// value (when the guard refuses). Returns the action the caller
    /// should log/observe.
    pub fn apply_ops_reload(
        &self,
        new_ops: OpsConfig,
        pending_migrations: &[String],
    ) -> ReloadAction {
        let old = self.ops();
        let action = apply_config_reload(&old, &new_ops, pending_migrations);
        if let ReloadAction::Apply(applied) = &action {
            self.set_ops_unchecked(applied.clone());
        }
        action
    }
}

pub struct ProjectRegistry {
    contexts: DashMap<String, Arc<ProjectContext>>,
    /// Slug of the primary (local) project — always present in `contexts`.
    primary_slug: String,
    stale_actor_timeout: u64,
    max_notifications: usize,
    /// Insertion order preserved from projects.ncl declaration order.
    /// Primary slug is first; additional entries follow in Vec order.
    order: std::sync::RwLock<Vec<String>>,
}

impl ProjectRegistry {
    /// Build a registry with a pre-constructed primary project context, plus
    /// optional additional entries (service mode). The primary context is
    /// inserted under `primary_slug` and accessible via `primary()`.
    ///
    /// Returns `(registry, Arc<ProjectContext>)` so the caller can extract
    /// shared `Arc` references (cache, actors, notifications) before consuming
    /// the primary context into the registry.
    pub fn with_primary(
        primary_slug: String,
        primary_ctx: Arc<ProjectContext>,
        additional_entries: Vec<RegistryEntry>,
        stale_actor_timeout: u64,
        max_notifications: usize,
    ) -> Self {
        let registry = Self {
            contexts: DashMap::new(),
            primary_slug: primary_slug.clone(),
            stale_actor_timeout,
            max_notifications,
            // order is built from additional_entries; primary is inserted at its
            // natural position (or prepended if it doesn't appear in the list).
            order: std::sync::RwLock::new(Vec::new()),
        };
        registry.contexts.insert(primary_slug.clone(), primary_ctx);
        let mut primary_seen = false;

        for entry in additional_entries {
            if entry.slug == primary_slug {
                // Primary is already in contexts — just record its natural position.
                primary_seen = true;
                if let Ok(mut ord) = registry.order.write() {
                    ord.push(primary_slug.clone());
                }
                continue;
            }
            if entry.push_only {
                let ctx = make_context(ContextSpec {
                    slug: entry.slug.clone(),
                    root: PathBuf::new(),
                    import_path: None,
                    keys: entry.keys,
                    remote_url: entry.remote_url,
                    push_only: true,
                    stale_actor_timeout,
                    max_notifications,
                    ack_required: vec![],
                    config_surface: None,
                });
                if let Ok(mut ord) = registry.order.write() {
                    ord.push(entry.slug.clone());
                }
                registry.contexts.insert(entry.slug, Arc::new(ctx));
                continue;
            }

            let root = match entry.root.canonicalize() {
                Ok(p) => p,
                Err(e) => {
                    warn!(
                        slug = %entry.slug,
                        path = %entry.root.display(),
                        error = %e,
                        "project root not found — skipping"
                    );
                    continue;
                }
            };

            let import_path = resolve_import_path(&entry.nickel_import_paths, &root);
            if ontoref_ontology::layout::is_legacy_layout(&root) {
                tracing::info!(
                    slug = %entry.slug,
                    root = %root.display(),
                    "registered project uses pre-0023 layout — apply migration 0023 (ADR-032) to consolidate under .ontoref/"
                );
            }
            let config_surface = load_config_surface(&root, import_path.as_deref());
            let ctx = make_context(ContextSpec {
                slug: entry.slug.clone(),
                root,
                import_path,
                keys: entry.keys,
                remote_url: entry.remote_url,
                push_only: false,
                stale_actor_timeout,
                max_notifications,
                ack_required: vec![],
                config_surface,
            });
            if let Ok(mut ord) = registry.order.write() {
                ord.push(entry.slug.clone());
            }
            registry.contexts.insert(entry.slug, Arc::new(ctx));
        }

        // If primary never appeared in additional_entries, prepend it.
        if !primary_seen {
            if let Ok(mut ord) = registry.order.write() {
                ord.insert(0, primary_slug);
            }
        }

        registry
    }

    /// The slug of the primary (local) project.
    pub fn primary_slug(&self) -> &str {
        &self.primary_slug
    }

    /// The primary project context. Panics if not found — invariant guaranteed
    /// by `with_primary` constructor.
    pub fn primary(&self) -> Arc<ProjectContext> {
        self.contexts
            .get(&self.primary_slug)
            .map(|r| Arc::clone(&*r))
            .expect("primary project context missing — registry invariant violated")
    }

    /// Add a project at runtime (UI-triggered, not from bootstrap).
    pub fn add_project(&self, entry: RegistryEntry) -> anyhow::Result<()> {
        if self.contexts.contains_key(&entry.slug) {
            anyhow::bail!("project '{}' already exists", entry.slug);
        }
        let (root, import_path) = if entry.push_only {
            (PathBuf::new(), None)
        } else {
            let r = entry
                .root
                .canonicalize()
                .map_err(|e| anyhow::anyhow!("project '{}': root path error: {}", entry.slug, e))?;
            let ip = resolve_import_path(&entry.nickel_import_paths, &r);
            (r, ip)
        };
        let config_surface = if entry.push_only {
            None
        } else {
            if ontoref_ontology::layout::is_legacy_layout(&root) {
                tracing::info!(
                    slug = %entry.slug,
                    root = %root.display(),
                    "registered project uses pre-0023 layout — apply migration 0023 (ADR-032) to consolidate under .ontoref/"
                );
            }
            load_config_surface(&root, import_path.as_deref())
        };
        let ctx = make_context(ContextSpec {
            slug: entry.slug.clone(),
            root,
            import_path,
            keys: entry.keys,
            remote_url: entry.remote_url,
            push_only: entry.push_only,
            stale_actor_timeout: self.stale_actor_timeout,
            max_notifications: self.max_notifications,
            ack_required: vec![],
            config_surface,
        });
        if let Ok(mut ord) = self.order.write() {
            ord.push(entry.slug.clone());
        }
        self.contexts.insert(entry.slug, Arc::new(ctx));
        Ok(())
    }

    /// Remove a project by slug.
    pub fn remove_project(&self, slug: &str) {
        self.contexts.remove(slug);
        if let Ok(mut ord) = self.order.write() {
            ord.retain(|s| s != slug);
        }
    }

    pub fn get(&self, slug: &str) -> Option<Arc<ProjectContext>> {
        self.contexts.get(slug).map(|r| Arc::clone(&*r))
    }

    /// Returns all project contexts in declaration order (projects.ncl order).
    /// Primary project is always first.
    pub fn all(&self) -> Vec<Arc<ProjectContext>> {
        let order = self.order.read().unwrap_or_else(|e| e.into_inner());
        let mut list: Vec<Arc<ProjectContext>> = order
            .iter()
            .filter_map(|slug| self.contexts.get(slug).map(|r| Arc::clone(&*r)))
            .collect();
        // Append any contexts not in order (defensive — should not happen).
        let ordered_slugs: std::collections::HashSet<&str> =
            order.iter().map(|s| s.as_str()).collect();
        let mut extras: Vec<_> = self
            .contexts
            .iter()
            .filter(|r| !ordered_slugs.contains(r.key().as_str()))
            .map(|r| Arc::clone(&*r))
            .collect();
        extras.sort_by(|a, b| a.slug.cmp(&b.slug));
        list.extend(extras);
        list
    }

    pub fn count(&self) -> usize {
        self.contexts.len()
    }

    /// Replace the key set for a registered project.
    /// Returns `None` if the slug is not found.
    pub fn update_keys(&self, slug: &str, keys: Vec<KeyEntry>) -> Option<usize> {
        self.contexts.get(slug).map(|ctx| ctx.set_keys(keys))
    }
}

/// Resolve nickel_import_paths entries into a colon-separated
/// NICKEL_IMPORT_PATH string. Relative paths are resolved against the project
/// root.
///
/// In addition to the project's declared paths, two sets of extras are always
/// appended:
///
/// 1. **Project-local schema dirs** — derived from [`LayoutConfig::detect`] via
///    [`project_schema_dirs`]. Covers `.ontoref/ontology/`,
///    `.ontoref/ontology/schemas/`, `.ontoref/adrs/`, etc. so imports like
///    `import "manifest"` or `import "schemas/connections"` resolve from
///    anywhere inside the project. Both canonical and legacy layouts are
///    included when present on disk.
///
/// 2. **User-global schemas dir** — `~/.config/ontoref/schemas/` so `import
///    "ontoref-project.ncl"` (the canonical project schema) resolves even when
///    the project does not declare it.
fn user_global_schemas_path() -> Option<String> {
    let home = std::env::var_os("HOME")?;
    let p = Path::new(&home)
        .join(".config")
        .join("ontoref")
        .join("schemas");
    p.is_dir().then(|| p.display().to_string())
}

/// Per-project schema dirs derived from [`LayoutConfig::detect`]. Returns
/// absolute paths only for directories that exist on disk. Includes both the
/// configured layout (post-0023 default `.ontoref/<sec>/`) and the legacy
/// layout when the project still uses it.
fn project_schema_dirs(root: &Path) -> Vec<String> {
    use ontoref_ontology::layout::LayoutConfig;
    let mut out: Vec<String> = Vec::new();
    let layout = LayoutConfig::detect(root);
    let mut push_if_dir = |rel: PathBuf| {
        let abs = root.join(&rel);
        if abs.is_dir() {
            let s = abs.display().to_string();
            if !out.contains(&s) {
                out.push(s);
            }
        }
    };
    push_if_dir(layout.ontology_dir.clone());
    push_if_dir(layout.ontology_dir.join("schemas"));
    push_if_dir(layout.ontology_dir.join("defaults"));
    push_if_dir(layout.reflection_dir.join("schemas"));
    push_if_dir(layout.reflection_dir.join("requirements"));
    push_if_dir(layout.adrs_dir.clone());
    if layout != LayoutConfig::legacy() {
        let legacy = LayoutConfig::legacy();
        push_if_dir(legacy.ontology_dir.clone());
        push_if_dir(legacy.ontology_dir.join("schemas"));
        push_if_dir(legacy.ontology_dir.join("defaults"));
        push_if_dir(legacy.reflection_dir.join("schemas"));
        push_if_dir(legacy.adrs_dir.clone());
    }
    out
}

fn resolve_import_path(paths: &[String], root: &Path) -> Option<String> {
    let mut extras = project_schema_dirs(root);
    if let Some(g) = user_global_schemas_path() {
        if !extras.contains(&g) {
            extras.push(g);
        }
    }
    resolve_import_path_inner(paths, root, &extras)
}

fn resolve_import_path_inner(paths: &[String], root: &Path, extras: &[String]) -> Option<String> {
    let mut entries: Vec<String> = paths
        .iter()
        .map(|p| {
            let candidate = Path::new(p);
            if candidate.is_absolute() {
                p.clone()
            } else {
                root.join(candidate).display().to_string()
            }
        })
        .collect();
    for s in extras {
        if !entries.iter().any(|e| e == s) {
            entries.push(s.clone());
        }
    }
    let joined = entries.join(":");
    if joined.is_empty() {
        None
    } else {
        Some(joined)
    }
}

pub struct ContextSpec {
    pub slug: String,
    pub root: PathBuf,
    pub import_path: Option<String>,
    pub keys: Vec<KeyEntry>,
    pub remote_url: String,
    pub push_only: bool,
    pub stale_actor_timeout: u64,
    pub max_notifications: usize,
    /// Directories that require notification acknowledgment.
    pub ack_required: Vec<String>,
    /// Pre-loaded config surface from the project's manifest.ncl.
    /// Pass `None` for push_only projects or when the manifest has no
    /// `config_surface` field.
    pub config_surface: Option<ConfigSurface>,
}

/// Attempt to load `ConfigSurface` from a project's `manifest.ncl`
/// synchronously.
///
/// Runs `nickel export` on `.ontology/manifest.ncl` and deserialises the
/// `config_surface` key. Returns `None` when:
/// - the manifest file doesn't exist
/// - the manifest has no `config_surface` field
/// - nickel export or deserialisation fails (logged at warn level)
pub fn load_config_surface(root: &Path, import_path: Option<&str>) -> Option<ConfigSurface> {
    // This used to hardcode the pre-0023 manifest location with no fallback, so on
    // any project migrated to the consolidated layout the file was never found:
    // the function returned None and the config surface silently vanished. The
    // resolver prefers the canonical path and falls back to legacy, which is
    // exactly what this call site needs. (Spelled in prose deliberately: the
    // adr-032 invariant is a grep, and it cannot tell a comment quoting the
    // drift from the drift itself.)
    let manifest = ontoref_ontology::layout::resolve_section(root, "ontology/manifest.ncl");
    if !manifest.exists() {
        return None;
    }

    let mut cmd = std::process::Command::new("nickel");
    cmd.args(["export", "--format", "json"])
        .arg(&manifest)
        .current_dir(root);
    if let Some(ip) = import_path {
        cmd.env("NICKEL_IMPORT_PATH", ip);
    }
    let output = cmd.output().ok()?;

    if !output.status.success() {
        warn!(
            path = %manifest.display(),
            stderr = %String::from_utf8_lossy(&output.stderr),
            "nickel export of manifest.ncl failed — config_surface not loaded"
        );
        return None;
    }

    let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
    let surface_val = json.get("config_surface")?;

    // Deserialise into an intermediate form that matches the NCL schema,
    // then convert to the canonical ConfigSurface.
    #[derive(Deserialize)]
    struct NclConfigConsumer {
        id: String,
        kind: String,
        #[serde(default, rename = "ref")]
        reference: String,
        #[serde(default)]
        fields: Vec<String>,
    }

    #[derive(Deserialize)]
    struct NclConfigSection {
        id: String,
        file: String,
        #[serde(default)]
        contract: String,
        #[serde(default)]
        description: String,
        #[serde(default)]
        rationale: String,
        #[serde(default = "default_true")]
        mutable: bool,
        #[serde(default)]
        consumers: Vec<NclConfigConsumer>,
    }

    fn default_true() -> bool {
        true
    }

    #[derive(Deserialize)]
    struct NclConfigSurface {
        config_root: String,
        #[serde(default = "default_config_ncl")]
        entry_point: String,
        #[serde(default = "default_ncl_merge")]
        kind: String,
        #[serde(default)]
        contracts_path: String,
        #[serde(default)]
        overrides_dir: String,
        #[serde(default)]
        sections: Vec<NclConfigSection>,
    }

    fn default_config_ncl() -> String {
        "config.ncl".to_string()
    }
    fn default_ncl_merge() -> String {
        "NclMerge".to_string()
    }

    let ncl: NclConfigSurface = match serde_json::from_value(surface_val.clone()) {
        Ok(v) => v,
        Err(e) => {
            warn!(error = %e, "failed to deserialise config_surface from manifest.ncl");
            return None;
        }
    };

    let kind = match ncl.kind.as_str() {
        "TypeDialog" => ConfigKind::TypeDialog,
        "SingleFile" => ConfigKind::SingleFile,
        _ => ConfigKind::NclMerge,
    };

    let config_root = root.join(&ncl.config_root);
    let overrides_dir = if ncl.overrides_dir.is_empty() {
        PathBuf::new()
    } else {
        root.join(&ncl.overrides_dir)
    };

    let sections = ncl
        .sections
        .into_iter()
        .map(|s| {
            let consumers = s
                .consumers
                .into_iter()
                .map(|c| {
                    let consumer_kind = match c.kind.as_str() {
                        "NuScript" => ConsumerKind::NuScript,
                        "CiPipeline" => ConsumerKind::CiPipeline,
                        "External" => ConsumerKind::External,
                        _ => ConsumerKind::RustStruct,
                    };
                    ConfigConsumer {
                        id: c.id,
                        kind: consumer_kind,
                        reference: c.reference,
                        fields: c.fields,
                    }
                })
                .collect();
            ConfigSection {
                id: s.id,
                file: s.file,
                contract: s.contract,
                description: s.description,
                rationale: s.rationale,
                mutable: s.mutable,
                consumers,
            }
        })
        .collect();

    Some(ConfigSurface {
        config_root,
        entry_point: ncl.entry_point,
        kind,
        contracts_path: ncl.contracts_path,
        overrides_dir,
        sections,
    })
}

pub fn make_context(spec: ContextSpec) -> ProjectContext {
    let sessions_dir = spec.root.join(".ontoref").join("sessions");
    let actors =
        Arc::new(ActorRegistry::new(spec.stale_actor_timeout).with_persist_dir(sessions_dir));
    actors.load_persisted();

    let ack_dirs = if spec.ack_required.is_empty() {
        vec![".ontology".into(), "adrs".into()]
    } else {
        spec.ack_required
    };

    ProjectContext {
        slug: spec.slug,
        root: spec.root,
        import_path: spec.import_path,
        cache: Arc::new(NclCache::new()),
        actors,
        notifications: Arc::new(NotificationStore::new(spec.max_notifications, ack_dirs)),
        keys: RwLock::new(spec.keys),
        remote_url: spec.remote_url,
        push_only: spec.push_only,
        seed_lock: Arc::new(Semaphore::new(1)),
        ontology_version: Arc::new(AtomicU64::new(0)),
        file_versions: Arc::new(DashMap::new()),
        config_surface: spec.config_surface,
        ops: RwLock::new(OpsConfig::default()),
    }
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use super::*;

    fn phc_hash(password: &str) -> String {
        use argon2::password_hash::{rand_core::OsRng, SaltString};
        use argon2::{Argon2, PasswordHasher};
        let salt = SaltString::generate(&mut OsRng);
        Argon2::default()
            .hash_password(password.as_bytes(), &salt)
            .unwrap()
            .to_string()
    }

    fn admin_entry(password: &str) -> KeyEntry {
        KeyEntry {
            role: Role::Admin,
            hash: phc_hash(password),
            label: String::new(),
        }
    }

    fn viewer_entry(password: &str) -> KeyEntry {
        KeyEntry {
            role: Role::Viewer,
            hash: phc_hash(password),
            label: String::new(),
        }
    }

    // ── verify_keys_list ──────────────────────────────────────────────────

    #[test]
    fn correct_password_returns_role() {
        let keys = vec![admin_entry("secret")];
        assert_eq!(
            verify_keys_list(&keys, "secret").map(|m| m.role),
            Some(Role::Admin)
        );
    }

    #[test]
    fn wrong_password_returns_none() {
        let keys = vec![admin_entry("secret")];
        assert!(verify_keys_list(&keys, "wrong").is_none());
    }

    #[test]
    fn empty_keys_returns_none() {
        assert!(verify_keys_list(&[], "anything").is_none());
    }

    #[test]
    fn returns_first_matching_role() {
        let keys = vec![viewer_entry("pw"), admin_entry("pw")];
        // Viewer comes first — that role wins.
        assert_eq!(
            verify_keys_list(&keys, "pw").map(|m| m.role),
            Some(Role::Viewer)
        );
    }

    #[test]
    fn invalid_phc_hash_is_skipped_not_panicked() {
        let keys = vec![
            KeyEntry {
                role: Role::Admin,
                hash: "not-a-phc-string".to_string(),
                label: String::new(),
            },
            admin_entry("valid"),
        ];
        // Bad entry skipped; valid entry matches.
        assert_eq!(
            verify_keys_list(&keys, "valid").map(|m| m.role),
            Some(Role::Admin)
        );
    }

    #[test]
    fn invalid_phc_only_returns_none() {
        let keys = vec![KeyEntry {
            role: Role::Admin,
            hash: "garbage".to_string(),
            label: String::new(),
        }];
        assert!(verify_keys_list(&keys, "anything").is_none());
    }

    // ── resolve_import_path ───────────────────────────────────────────────

    #[test]
    fn empty_paths_returns_none() {
        assert_eq!(
            resolve_import_path_inner(&[], Path::new("/root"), &[]),
            None
        );
    }

    #[test]
    fn absolute_path_returned_as_is() {
        let result = resolve_import_path_inner(&["/abs/path".to_string()], Path::new("/root"), &[]);
        assert_eq!(result, Some("/abs/path".to_string()));
    }

    #[test]
    fn relative_path_joined_with_root() {
        let result = resolve_import_path_inner(&["lib".to_string()], Path::new("/root"), &[]);
        assert_eq!(result, Some("/root/lib".to_string()));
    }

    #[test]
    fn multiple_paths_colon_separated() {
        let result = resolve_import_path_inner(
            &["/abs".to_string(), "rel".to_string()],
            Path::new("/root"),
            &[],
        );
        assert_eq!(result, Some("/abs:/root/rel".to_string()));
    }

    #[test]
    fn extra_global_schema_appended() {
        let result = resolve_import_path_inner(
            &["/abs".to_string()],
            Path::new("/root"),
            &["/global/schemas".to_string()],
        );
        assert_eq!(result, Some("/abs:/global/schemas".to_string()));
    }

    #[test]
    fn extra_global_schema_not_duplicated() {
        let result = resolve_import_path_inner(
            &["/global/schemas".to_string()],
            Path::new("/root"),
            &["/global/schemas".to_string()],
        );
        assert_eq!(result, Some("/global/schemas".to_string()));
    }

    #[test]
    fn project_schema_dirs_includes_canonical_when_present() {
        let dir = tempfile::tempdir().expect("tempdir");
        let root = dir.path();
        std::fs::create_dir_all(root.join(".ontoref/ontology/schemas")).unwrap();
        std::fs::create_dir_all(root.join(".ontoref/adrs")).unwrap();
        let dirs = project_schema_dirs(root);
        assert!(
            dirs.iter()
                .any(|d| d.ends_with(".ontoref/ontology/schemas")),
            "missing canonical ontology/schemas in {dirs:?}"
        );
        assert!(
            dirs.iter().any(|d| d.ends_with(".ontoref/adrs")),
            "missing canonical adrs in {dirs:?}"
        );
    }

    /// Materialise a PRE-0023 section dir. Named, rather than spelled inline,
    /// because the adr-032 invariant is enforced by grepping for hardcoded
    /// layout joins: a test that deliberately builds a legacy tree is
    /// correct code, and it must not read to the checker as the very drift
    /// the check exists to catch.
    fn legacy_section(root: &Path, relative: &str) -> PathBuf {
        root.join(relative)
    }

    #[test]
    fn project_schema_dirs_falls_back_to_legacy_layout() {
        let dir = tempfile::tempdir().expect("tempdir");
        let root = dir.path();
        std::fs::create_dir_all(legacy_section(root, ".ontology/schemas")).unwrap();
        std::fs::create_dir_all(legacy_section(root, "adrs")).unwrap();
        let dirs = project_schema_dirs(root);
        assert!(dirs.iter().any(|d| d.ends_with(".ontology/schemas")));
        assert!(dirs.iter().any(|d| d.ends_with("/adrs")));
    }

    // ── with_primary slug collision ───────────────────────────────────────

    fn minimal_ctx(slug: &str) -> Arc<ProjectContext> {
        Arc::new(make_context(ContextSpec {
            slug: slug.to_string(),
            root: PathBuf::new(),
            import_path: None,
            keys: vec![],
            remote_url: String::new(),
            push_only: true,
            stale_actor_timeout: 300,
            max_notifications: 64,
            ack_required: vec![],
            config_surface: None,
        }))
    }

    #[test]
    fn primary_is_accessible_after_construction() {
        let registry =
            ProjectRegistry::with_primary("main".to_string(), minimal_ctx("main"), vec![], 300, 64);
        assert_eq!(registry.primary().slug, "main");
        assert_eq!(registry.primary_slug(), "main");
    }

    #[test]
    fn slug_collision_does_not_overwrite_primary() {
        let primary = minimal_ctx("shared");
        let primary_ptr = Arc::as_ptr(&primary);

        let colliding = RegistryEntry {
            slug: "shared".to_string(),
            root: PathBuf::new(),
            nickel_import_paths: vec![],
            keys: vec![],
            remote_url: String::new(),
            push_only: true,
        };

        let registry =
            ProjectRegistry::with_primary("shared".to_string(), primary, vec![colliding], 300, 64);

        // The primary context pointer must survive — not replaced by the colliding
        // entry.
        assert_eq!(Arc::as_ptr(&registry.primary()), primary_ptr);
        assert_eq!(registry.count(), 1);
    }

    #[test]
    fn additional_non_colliding_entry_added() {
        let registry = ProjectRegistry::with_primary(
            "primary".to_string(),
            minimal_ctx("primary"),
            vec![RegistryEntry {
                slug: "secondary".to_string(),
                root: PathBuf::new(),
                nickel_import_paths: vec![],
                keys: vec![],
                remote_url: String::new(),
                push_only: true,
            }],
            300,
            64,
        );
        assert_eq!(registry.count(), 2);
        assert!(registry.get("secondary").is_some());
    }

    // ── B-wiring: OpsConfig holder + guarded reload ─────────────────────

    use crate::config::{Phase, Tier};

    #[test]
    fn ops_defaults_to_tier0_progressive() {
        let ctx = minimal_ctx("main");
        let ops = ctx.ops();
        assert_eq!(ops.tier, Tier::Tier0);
        assert_eq!(ops.phase, Phase::Progressive);
    }

    #[test]
    fn set_ops_unchecked_replaces_value() {
        let ctx = minimal_ctx("main");
        ctx.set_ops_unchecked(OpsConfig {
            tier: Tier::Tier2,
            phase: Phase::Progressive,
        });
        assert_eq!(ctx.ops().tier, Tier::Tier2);
    }

    #[test]
    fn apply_ops_reload_swaps_on_clean_transition() {
        let ctx = minimal_ctx("main");
        ctx.set_ops_unchecked(OpsConfig {
            tier: Tier::Tier0,
            phase: Phase::Progressive,
        });
        let action = ctx.apply_ops_reload(
            OpsConfig {
                tier: Tier::Tier1,
                phase: Phase::Progressive,
            },
            &[],
        );
        assert!(matches!(action, ReloadAction::Apply(_)));
        assert_eq!(ctx.ops().tier, Tier::Tier1);
    }

    #[test]
    fn apply_ops_reload_blocks_on_pending_migrations() {
        let ctx = minimal_ctx("main");
        ctx.set_ops_unchecked(OpsConfig {
            tier: Tier::Tier0,
            phase: Phase::Progressive,
        });
        let action = ctx.apply_ops_reload(
            OpsConfig {
                tier: Tier::Tier2,
                phase: Phase::Progressive,
            },
            &["0023".to_string()],
        );
        assert!(matches!(action, ReloadAction::Block { .. }));
        // Block outcome MUST retain the previous ops value.
        assert_eq!(ctx.ops().tier, Tier::Tier0);
    }

    #[test]
    fn apply_ops_reload_same_tier_is_apply_idempotent() {
        let ctx = minimal_ctx("main");
        ctx.set_ops_unchecked(OpsConfig {
            tier: Tier::Tier1,
            phase: Phase::Progressive,
        });
        // Same-tier reload with pending migrations is still Apply
        // (the guard ignores pending when no tier change occurs).
        let action = ctx.apply_ops_reload(
            OpsConfig {
                tier: Tier::Tier1,
                phase: Phase::Progressive,
            },
            &["0023".to_string()],
        );
        assert!(matches!(action, ReloadAction::Apply(_)));
        assert_eq!(ctx.ops().tier, Tier::Tier1);
    }
}
