//! Config handlers.

use serde::Serialize;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};

use super::super::*;

use crate::sessions::file_lock::FileLock;

static CONFIG_FILE_WRITE_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));

/// Bounded budget for cross-process flock acquisition on the config
/// path. The flock guards the read-modify-write window; in normal
/// operation peers don't contend on config writes (operator-triggered
/// actions are rare events). If a peer holds the lock longer than
/// this budget, surface `ERROR_UNAVAILABLE` to the caller so the
/// tokio worker that picked up the request can yield instead of
/// parking indefinitely on a hung peer (NFS-stuck CLI, slow remote
/// sync inside a CLI hook). Caller is expected to retry.
const CONFIG_FLOCK_TIMEOUT_MS: u64 = 10_000;
const CONFIG_FLOCK_RETRY_INITIAL_BACKOFF_MS: u64 = 25;
const CONFIG_FLOCK_RETRY_MAX_BACKOFF_MS: u64 = 250;

/// Acquire the in-process mutex AND the cross-process flock at
/// `<config_path>.lock` to serialize the read-modify-write cycle
/// against every other writer regardless of process. The bare
/// in-process `CONFIG_FILE_WRITE_LOCK` is necessary but not
/// sufficient — daemon and CLI are separate processes, so two
/// concurrent `cara config set` invocations (or daemon
/// `persist_matrix_session` racing a CLI `cara config set`) would
/// both pass the in-process gate, both read the same pre-write
/// snapshot, both rename their merged result over the destination,
/// and whichever rename runs second silently overwrites the
/// other's commit. The atomic rename prevents torn writes but does
/// not prevent LOST UPDATES.
///
/// SECURITY/AVAILABILITY (B128): use `FileLock::try_acquire` in a
/// bounded-backoff loop rather than `FileLock::acquire`. The
/// original B124 fix called the blocking flock acquire from sync
/// fns invoked on tokio worker threads (the WS dispatcher calls
/// `handle_wizard_*` / `handle_config_*` inline); a peer process
/// holding the lock indefinitely (NFS mount hung, CLI stuck on a
/// slow hook) would park the worker thread without yielding,
/// starving every other handler sharing that worker. With the
/// bounded loop the worker thread sleeps in short increments and
/// the function returns `ERROR_UNAVAILABLE` after the total budget
/// elapses; callers can retry, and the WS reactor stays responsive.
///
/// Returns a 2-tuple guard so callers hold both locks for the same
/// scope; Rust's local-binding drop order releases bindings in
/// declaration order (mutex first, flock second), which is the
/// correct release order — releasing the flock LAST means peer
/// writers can re-snapshot the new contents only after the
/// rename has committed AND the in-process mutex is also free.
fn acquire_config_write_locks(
    config_path: &Path,
) -> Result<(std::sync::MutexGuard<'static, ()>, FileLock), String> {
    // Round-7 concurrency-MEDIUM: the initial `CONFIG_FILE_WRITE_LOCK.lock()`
    // is a `std::sync::Mutex` acquisition. B143 wrapped the inner
    // flock-retry sleep in `cooperative_blocking_sleep`, but the mutex
    // hop itself could still park up to N-1 tokio workers under
    // multi-writer contention while writer #1 was inside the retry
    // loop holding the mutex. Route the lock acquisition through
    // `runtime_bridge::cooperative_blocking_call` so the worker
    // migrates its queued tasks to a sibling worker before potentially
    // parking. The bridge helper centralises the `block_in_place` use
    // site under the runtime-bridge audit boundary
    // (`scripts/check-runtime-bridge-usage.sh`).
    let mutex_guard =
        crate::runtime_bridge::cooperative_blocking_call(|| CONFIG_FILE_WRITE_LOCK.lock())
            .map_err(|_| "config write lock poisoned".to_string())?;
    let start = std::time::Instant::now();
    let budget = std::time::Duration::from_millis(CONFIG_FLOCK_TIMEOUT_MS);
    let mut backoff = std::time::Duration::from_millis(CONFIG_FLOCK_RETRY_INITIAL_BACKOFF_MS);
    let max_backoff = std::time::Duration::from_millis(CONFIG_FLOCK_RETRY_MAX_BACKOFF_MS);
    loop {
        match FileLock::try_acquire(config_path) {
            Ok(Some(file_lock)) => return Ok((mutex_guard, file_lock)),
            Ok(None) => {
                if start.elapsed() >= budget {
                    return Err(format!(
                        "cross-process config write lock at {}.lock is held by another \
                         writer for more than {}ms; retry once the peer releases the lock",
                        config_path.display(),
                        CONFIG_FLOCK_TIMEOUT_MS
                    ));
                }
                crate::runtime_bridge::cooperative_blocking_sleep(backoff);
                backoff = std::cmp::min(backoff * 2, max_backoff);
            }
            Err(err) => {
                return Err(format!(
                    "failed to acquire cross-process config write lock at {}.lock: {err}",
                    config_path.display()
                ));
            }
        }
    }
}

/// Did the closure mutate the config? `Changed` triggers persistence;
/// `NoOp` skips the write so a wizard or merge-style caller reporting
/// "nothing to apply" doesn't recreate or overwrite the on-disk file.
/// The previous `bool` shape allowed `.map(|()| false)` to silently
/// drop persistence on every call — the named variants force callers
/// to think about the outcome.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConfigUpdateOutcome {
    Changed,
    NoOp,
}

impl ConfigUpdateOutcome {
    pub(crate) fn is_changed(self) -> bool {
        matches!(self, Self::Changed)
    }
}

#[derive(Debug, Serialize)]
pub(crate) struct ConfigIssue {
    #[serde(skip)]
    pub(crate) severity: config::schema::Severity,
    pub(crate) path: String,
    pub(crate) message: String,
}

impl ConfigIssue {
    pub(crate) fn is_error(&self) -> bool {
        matches!(self.severity, config::schema::Severity::Error)
    }
}

pub(crate) fn has_config_errors(issues: &[ConfigIssue]) -> bool {
    issues.iter().any(ConfigIssue::is_error)
}

#[derive(Debug)]
pub(crate) struct ConfigSnapshot {
    pub(crate) path: String,
    pub(crate) exists: bool,
    pub(crate) parsed: Value,
    pub(crate) raw_config: Value,
    pub(crate) valid: bool,
    pub(crate) config: Value,
    pub(crate) hash: Option<String>,
    pub(crate) issues: Vec<ConfigIssue>,
}

pub(crate) fn map_validation_issues(issues: Vec<config::ValidationIssue>) -> Vec<ConfigIssue> {
    issues
        .into_iter()
        .map(|issue| ConfigIssue {
            severity: issue.severity,
            path: issue.path,
            message: issue.message,
        })
        .collect()
}

pub(crate) fn sha256_hex(value: &str) -> String {
    let digest = Sha256::digest(value.as_bytes());
    hex::encode(digest)
}

pub(crate) fn read_config_snapshot() -> ConfigSnapshot {
    let path = config::get_config_path();
    let path_str = path.display().to_string();
    if !path.exists() {
        return ConfigSnapshot {
            path: path_str,
            exists: false,
            parsed: Value::Object(serde_json::Map::new()),
            raw_config: Value::Object(serde_json::Map::new()),
            valid: true,
            config: Value::Object(serde_json::Map::new()),
            hash: None,
            issues: Vec::new(),
        };
    }

    let raw = match fs::read_to_string(&path) {
        Ok(raw) => raw,
        Err(err) => {
            return ConfigSnapshot {
                path: path_str,
                exists: true,
                parsed: Value::Object(serde_json::Map::new()),
                raw_config: Value::Object(serde_json::Map::new()),
                valid: false,
                config: Value::Object(serde_json::Map::new()),
                hash: None,
                issues: vec![ConfigIssue {
                    severity: config::schema::Severity::Error,
                    path: "".to_string(),
                    message: format!("read failed: {}", err),
                }],
            }
        }
    };

    let hash = Some(sha256_hex(&raw));
    let parsed = json5::from_str::<Value>(&raw).unwrap_or(Value::Null);
    let (raw_config, config_value, mut issues, valid) =
        match config::load_config_pair_uncached(&path) {
            Ok((raw_cfg, cfg)) => {
                let issues = map_validation_issues(config::validate_config(&cfg));
                let valid = !has_config_errors(&issues);
                (raw_cfg, cfg, issues, valid)
            }
            Err(err) => {
                let issues = vec![ConfigIssue {
                    severity: config::schema::Severity::Error,
                    path: "".to_string(),
                    message: err.to_string(),
                }];
                (parsed.clone(), parsed.clone(), issues, false)
            }
        };

    if !valid && issues.is_empty() {
        issues.push(ConfigIssue {
            severity: config::schema::Severity::Error,
            path: "".to_string(),
            message: "invalid config".to_string(),
        });
    }

    ConfigSnapshot {
        path: path_str,
        exists: true,
        parsed,
        raw_config,
        valid,
        config: config_value,
        hash,
        issues,
    }
}

fn require_config_base_hash(
    params: Option<&Value>,
    snapshot: &ConfigSnapshot,
) -> Result<(), ErrorShape> {
    if !snapshot.exists {
        return Ok(());
    }
    let base_hash = params
        .and_then(|v| v.get("baseHash"))
        .and_then(|v| v.as_str())
        .map(|s| s.trim())
        .filter(|s| !s.is_empty());
    let expected = snapshot.hash.as_deref();
    if expected.is_none() {
        return Err(error_shape(
            ERROR_INVALID_REQUEST,
            "config base hash unavailable; re-run config.get and retry",
            None,
        ));
    }
    let Some(base_hash) = base_hash else {
        return Err(error_shape(
            ERROR_INVALID_REQUEST,
            "config base hash required; re-run config.get and retry",
            None,
        ));
    };
    if Some(base_hash) != expected {
        return Err(error_shape(
            ERROR_INVALID_REQUEST,
            "config changed since last load; re-run config.get and retry",
            None,
        ));
    }
    Ok(())
}

/// Write a config value to disk atomically. Returns `Err(message)` on failure.
/// This is the `pub(crate)` helper so non-WS code (e.g. the control HTTP
/// endpoint) can persist config without depending on `ErrorShape`.
pub(crate) fn persist_config_file(path: &PathBuf, config_value: &Value) -> Result<(), String> {
    let _guards = acquire_config_write_locks(path)?;
    let (existing_text, existing_raw) = read_existing_config_for_write(path)?;
    // Reject corrupt-base writes here too: a `(Some(raw), None)`
    // means the file exists on disk but failed to parse, so
    // `validate_locked_secret_preservation(None, ...)` would
    // short-circuit to Ok and the encrypted-secret preservation
    // check would be silently bypassed. Force the operator either
    // to fix the file or to use a corrupt-tolerant replacement
    // path (which doesn't exist on this code path — full
    // replacement still goes through `update_config_file_inner`'s
    // sibling guard, and that one rejects too).
    if existing_text.is_some() && existing_raw.is_none() {
        return Err(format!(
            "config file at {} failed to parse; refuse to write into an unparseable base — \
             fix the file on disk first or remove it",
            path.display()
        ));
    }
    persist_config_file_locked(path, config_value, existing_raw.as_ref())
}

/// Persistence outcomes for `persist_config_file_with_base_hash`.
///
/// The conflict variant is the optimistic-concurrency loser case
/// (the on-disk hash drifted between snapshot and lock). Callers
/// surface it as 409 Conflict / `INVALID_REQUEST` so clients can
/// re-read and retry; previously this was distinguished by string
/// match on the `Err(String)` payload, which was brittle (a typo in
/// the message at the producer silently demoted both consumers from
/// 409 to 500).
#[derive(Debug)]
pub(crate) enum PersistConfigError {
    /// On-disk hash drifted between the caller's snapshot and the
    /// write-lock acquisition — caller must re-read and retry.
    ConflictingBaseHash(String),
    /// Anything else (I/O failure, validation, lock poison, etc.).
    Other(String),
}

impl PersistConfigError {
    pub(crate) fn into_message(self) -> String {
        match self {
            Self::ConflictingBaseHash(s) | Self::Other(s) => s,
        }
    }

    /// HTTP status mapping for the REST control surface. The
    /// optimistic-concurrency conflict is a client-side race, not a
    /// server fault — `409 Conflict` so callers can re-read and
    /// retry. Everything else is `500`.
    pub(crate) fn http_status(&self) -> axum::http::StatusCode {
        match self {
            Self::ConflictingBaseHash(_) => axum::http::StatusCode::CONFLICT,
            Self::Other(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

pub(crate) fn persist_config_file_with_base_hash(
    path: &PathBuf,
    config_value: &Value,
    expected_hash: Option<&str>,
) -> Result<(), PersistConfigError> {
    let _guards = acquire_config_write_locks(path).map_err(PersistConfigError::Other)?;
    let (existing_text, existing_raw) =
        read_existing_config_for_write(path).map_err(PersistConfigError::Other)?;
    if path.exists() {
        let actual_hash = existing_text.as_deref().map(sha256_hex);
        if actual_hash.as_deref() != expected_hash {
            return Err(PersistConfigError::ConflictingBaseHash(
                "config changed since last load; re-read config and retry".to_string(),
            ));
        }
    }
    // Reject corrupt-base: `existing_text=Some, existing_raw=None`
    // means the on-disk file exists but failed to parse. Without this
    // guard, `persist_config_file_locked`'s
    // `validate_locked_secret_preservation(None, ...)` early-returns Ok
    // and the encrypted-secret preservation check is silently bypassed
    // — operator with a corrupt config + secret encryption disabled
    // could overwrite encrypted accessToken/storePassphrase in-place.
    if existing_text.is_some() && existing_raw.is_none() {
        return Err(PersistConfigError::Other(format!(
            "config file at {} failed to parse; refuse to write into an unparseable base — \
             fix the file on disk first or remove it",
            path.display()
        )));
    }
    persist_config_file_locked(path, config_value, existing_raw.as_ref())
        .map_err(PersistConfigError::Other)
}

pub(crate) fn update_config_file<F>(path: &PathBuf, update: F) -> Result<(), String>
where
    F: FnOnce(&mut Value) -> Result<(), String>,
{
    update_config_file_inner(path, |value| {
        update(value).map(|()| ConfigUpdateOutcome::Changed)
    })
}

/// Closure returns `ConfigUpdateOutcome` to express "changed vs no-op".
/// `NoOp` skips the persist step entirely so callers can express a
/// no-op without rewriting (or creating) the on-disk file.
pub(crate) fn try_update_config_file<F>(
    path: &PathBuf,
    update: F,
) -> Result<ConfigUpdateOutcome, String>
where
    F: FnOnce(&mut Value) -> Result<ConfigUpdateOutcome, String>,
{
    let mut applied = ConfigUpdateOutcome::NoOp;
    update_config_file_inner(path, |value| {
        let result = update(value);
        if let Ok(ConfigUpdateOutcome::Changed) = result {
            applied = ConfigUpdateOutcome::Changed;
        }
        result
    })?;
    Ok(applied)
}

fn update_config_file_inner<F>(path: &PathBuf, update: F) -> Result<(), String>
where
    F: FnOnce(&mut Value) -> Result<ConfigUpdateOutcome, String>,
{
    let _guards = acquire_config_write_locks(path)?;
    let (existing_text, existing_raw) = read_existing_config_for_write(path)?;
    // Merge-style callers cannot operate on a corrupted base: a parse
    // failure means `existing_raw` is `None` while the file is
    // non-empty on disk. Treating that as "no existing config" and
    // building a fresh `{}` would silently clobber the
    // broken-but-recoverable original. Reject loudly so the operator
    // fixes or removes the on-disk file. The persist sinks
    // (`persist_config_file`, `persist_config_file_with_base_hash`,
    // and this driver's own corrupt-base guard above) all refuse
    // unparseable bases, so suggesting `config.set` would loop the
    // caller back into the same error. There is no corrupt-tolerant
    // replace path; the operator's only recovery is filesystem-level.
    if existing_text.is_some() && existing_raw.is_none() {
        return Err(format!(
            "config file at {} failed to parse; refuse to write into an unparseable base — \
             fix the file on disk first or remove it, then retry",
            path.display()
        ));
    }
    let mut next_config = existing_raw
        .clone()
        .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
    let outcome = update(&mut next_config)?;
    if !outcome.is_changed() {
        // Closure reported a no-op; preserve the on-disk file
        // verbatim. This avoids rewriting (or creating) the config
        // file with an unchanged value, matching the operator
        // expectation that a no-op wizard returning `applied=false`
        // touches no state.
        return Ok(());
    }
    persist_config_file_locked(path, &next_config, existing_raw.as_ref())
}

pub(super) fn update_config_file_with_error_shape<F>(
    path: &PathBuf,
    update: F,
) -> Result<(), ErrorShape>
where
    F: FnOnce(&mut Value) -> Result<(), ErrorShape>,
{
    try_update_config_file_with_error_shape(path, |value| {
        update(value).map(|()| ConfigUpdateOutcome::Changed)
    })
    .map(|_| ())
}

/// `ErrorShape`-flavoured no-op-aware variant of
/// `try_update_config_file`. Used by `persist_wizard_config` so an
/// `applied=false` outcome doesn't rewrite the config file.
pub(super) fn try_update_config_file_with_error_shape<F>(
    path: &PathBuf,
    update: F,
) -> Result<ConfigUpdateOutcome, ErrorShape>
where
    F: FnOnce(&mut Value) -> Result<ConfigUpdateOutcome, ErrorShape>,
{
    let mut handler_error = None;
    let result = try_update_config_file(path, |value| match update(value) {
        Ok(outcome) => Ok(outcome),
        Err(err) => {
            handler_error = Some(err);
            Err("handler rejected config update".to_string())
        }
    });
    match result {
        Ok(outcome) => Ok(outcome),
        Err(_) if handler_error.is_some() => Err(handler_error.expect("checked above")),
        Err(msg) => Err(error_shape(ERROR_UNAVAILABLE, &msg, None)),
    }
}

fn read_existing_config_for_write(path: &Path) -> Result<(Option<String>, Option<Value>), String> {
    if !path.exists() {
        return Ok((None, None));
    }
    let raw = fs::read_to_string(path)
        .map_err(|err| format!("failed to read existing config before write: {}", err))?;
    // Return the raw text alongside `None` when JSON5 parse fails so
    // the caller has the bytes for hash-comparison and lock-window
    // reasoning. Note: every persist sink in this module
    // (`persist_config_file`, `persist_config_file_with_base_hash`,
    // and `update_config_file_inner`) explicitly refuses to write
    // when `existing_text=Some, existing_raw=None` — there is no
    // corrupt-tolerant replace path. Operators recovering from a
    // hand-corrupted config file must fix or remove the file at the
    // filesystem level before any of the WS / HTTP config paths will
    // accept a new value. This signature is therefore "fetch + parse
    // best-effort"; the corrupt-base policy lives at the call sites.
    let parsed = json5::from_str::<Value>(&raw).ok();
    Ok((Some(raw), parsed))
}

fn persist_config_file_locked(
    path: &PathBuf,
    config_value: &Value,
    existing_raw: Option<&Value>,
) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .map_err(|err| format!("failed to create config dir: {}", err))?;
    }

    let mut config_value = config_value.clone();
    config::validate_locked_secret_preservation(existing_raw, &config_value)?;
    validate_persisted_runtime_config_candidate(&config_value)?;
    config::seal_config_secrets(&mut config_value, existing_raw)?;
    let content = serde_json::to_string_pretty(&config_value)
        .map_err(|err| format!("failed to serialize config: {}", err))?;
    let tmp_path = config_write_temp_path(path);
    {
        // SECURITY: when CARAPACE_CONFIG_PASSWORD is unset, seal_config_secrets
        // is a no-op and the tmp file holds plaintext credentials (matrix.*,
        // gateway tokens, provider api keys) for the brief window between
        // create/sync_all and rename. Set 0o600 at create time on Unix so
        // other local users cannot snapshot the parent directory and read
        // partial-write contents. Windows relies on the user-profile ACL.
        //
        // SECURITY: `create_new(true)` guarantees we don't reopen a
        // stale tmp from a previous crashed write — but PID reuse +
        // the monotonic counter could in theory collide with a
        // leftover sibling, in which case the create fails with
        // AlreadyExists. Sweep the stale tmp once and retry; if the
        // retry still fails, surface the error.
        //
        // Accepted TOCTOU: between `remove_file(&tmp_path)` and the
        // second `open_config_tmp_owner_only(&tmp_path)`, another
        // process could create a new file at that path. The next
        // open then either (a) succeeds because we won the race —
        // safe; or (b) fails with AlreadyExists — surfaced via the
        // "(after stale-tmp sweep)" message. We do NOT retry in a
        // loop because that masks operator-misconfiguration (e.g.
        // permission errors) as transient; one sweep + one retry is
        // the bound. The race is bounded to the brief window inside
        // `CONFIG_FILE_WRITE_LOCK`, so it can only fire when an
        // external (non-carapace) actor is touching tmp paths
        // — operator-error territory, not a security boundary.
        let mut file = match open_config_tmp_owner_only(&tmp_path) {
            Ok(file) => file,
            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
                // Best-effort: a leftover tmp is operator-recoverable
                // by `rm`, but doing it ourselves keeps `config.set`
                // resilient to crashed-write detritus.
                let _ = fs::remove_file(&tmp_path);
                open_config_tmp_owner_only(&tmp_path).map_err(|err| {
                    format!("failed to write config (after stale-tmp sweep): {err}")
                })?
            }
            Err(err) => return Err(format!("failed to write config: {}", err)),
        };
        file.write_all(content.as_bytes())
            .map_err(|err| format!("failed to write config: {}", err))?;
        file.write_all(b"\n")
            .map_err(|err| format!("failed to write config: {}", err))?;
        file.sync_all()
            .map_err(|err| format!("failed to sync config: {}", err))?;
    }
    if let Err(err) = fs::rename(&tmp_path, path) {
        let _ = fs::remove_file(&tmp_path);
        return Err(format!("failed to replace config: {}", err));
    }
    // The dirent change from the rename is not durable until the parent
    // directory is fsynced; without this, a power loss after `config.set`
    // returns success can revert the config to its pre-rename contents.
    // Propagate the fsync error rather than swallowing it — a failed
    // dirent flush invalidates the success contract this function
    // advertises.
    sync_parent_dir_for_config(path)?;

    config::clear_cache();
    Ok(())
}

fn validate_persisted_runtime_config_candidate(config_value: &Value) -> Result<(), String> {
    let (_, issues) = config::validate_runtime_config_candidate(config_value)
        .map_err(|err| format!("invalid config: {err}"))?;
    let errors = issues
        .into_iter()
        .filter(config::ValidationIssue::is_error)
        .map(|issue| format!("{}: {}", issue.path, issue.message))
        .collect::<Vec<_>>();
    if errors.is_empty() {
        Ok(())
    } else {
        Err(format!("invalid config: {}", errors.join("; ")))
    }
}

fn sync_parent_dir_for_config(path: &Path) -> Result<(), String> {
    crate::paths::sync_parent_dir_blocking(path)
        .map_err(|err| format!("failed to fsync config dir: {}", err))
}

#[cfg(unix)]
fn open_config_tmp_owner_only(path: &Path) -> std::io::Result<fs::File> {
    use std::os::unix::fs::OpenOptionsExt;
    // O_NOFOLLOW defense-in-depth — see `paths::create_atomic_tmp_owner_only`.
    // `create_new`'s O_EXCL refuses a planted symlink today; O_NOFOLLOW
    // guards against a future refactor that weakens create_new.
    fs::OpenOptions::new()
        .create_new(true)
        .write(true)
        .mode(0o600)
        .custom_flags(libc::O_NOFOLLOW)
        .open(path)
}

#[cfg(not(unix))]
fn open_config_tmp_owner_only(path: &Path) -> std::io::Result<fs::File> {
    fs::OpenOptions::new()
        .create_new(true)
        .write(true)
        .open(path)
}

fn config_write_temp_path(path: &Path) -> PathBuf {
    crate::paths::atomic_tmp_path(path, "cfg")
}

#[cfg(test)]
pub(super) fn write_config_file(path: &PathBuf, config_value: &Value) -> Result<(), ErrorShape> {
    persist_config_file(path, config_value)
        .map_err(|msg| error_shape(ERROR_UNAVAILABLE, &msg, None))
}

/// Atomic config write with optimistic-concurrency check inside the file
/// lock. The pre-lock `require_config_base_hash` check at handler entry
/// guards against the operator's stale snapshot, but a write that races
/// another writer between the check and the lock acquisition would
/// otherwise still proceed. Re-checking against the on-disk hash inside
/// the lock closes that window.
pub(super) fn write_config_file_with_base_hash(
    path: &PathBuf,
    config_value: &Value,
    expected_hash: Option<&str>,
) -> Result<(), ErrorShape> {
    persist_config_file_with_base_hash(path, config_value, expected_hash).map_err(|err| match err {
        // Optimistic-concurrency conflict (the loser of a write/write
        // race) — surface it with the same code the pre-lock baseHash
        // check uses so callers can re-read and retry deterministically.
        PersistConfigError::ConflictingBaseHash(msg) => {
            error_shape(ERROR_INVALID_REQUEST, &msg, None)
        }
        PersistConfigError::Other(msg) => error_shape(ERROR_UNAVAILABLE, &msg, None),
    })
}

fn merge_patch(base: Value, patch: Value) -> Value {
    match (base, patch) {
        (_, Value::Null) => Value::Null,
        (Value::Object(mut base_map), Value::Object(patch_map)) => {
            for (key, patch_value) in patch_map {
                if patch_value.is_null() {
                    base_map.remove(&key);
                } else {
                    let base_value = base_map.remove(&key).unwrap_or(Value::Null);
                    let merged = merge_patch(base_value, patch_value);
                    base_map.insert(key, merged);
                }
            }
            Value::Object(base_map)
        }
        (_, patch_value) => patch_value,
    }
}

fn reject_protected_config_changes(before: &Value, after: &Value) -> Result<(), ErrorShape> {
    let changed = config::changed_protected_config_prefixes(before, after);
    if changed.is_empty() {
        return Ok(());
    }
    Err(error_shape(
        ERROR_INVALID_REQUEST,
        &format!(
            "Cannot modify protected configuration through WebSocket config methods: {}",
            changed.join(", ")
        ),
        Some(json!({ "protected": changed })),
    ))
}

fn validate_runtime_config_candidate(config_value: &Value) -> Result<Vec<ConfigIssue>, ErrorShape> {
    config::validate_runtime_config_candidate(config_value)
        .map(|(_, issues)| map_validation_issues(issues))
        .map_err(|err| error_shape(ERROR_INVALID_REQUEST, &err.to_string(), None))
}

pub(super) fn handle_config_get(params: Option<&Value>) -> Result<Value, ErrorShape> {
    let snapshot = read_config_snapshot();
    let key = params
        .and_then(|v| v.get("key"))
        .and_then(|v| v.as_str())
        .map(|s| s.trim())
        .filter(|s| !s.is_empty());

    if let Some(key) = key {
        // Serve the resolved view (`raw_config` is what
        // `load_config_pair_uncached` returns after env-var
        // substitution and `resolve_config_secrets` decryption — its
        // name is historical, NOT a "raw on-disk" view) and apply
        // key-name redaction so plaintext values for known secret
        // paths are scrubbed before leaving the gateway. The
        // redactor is the load-bearing protection here, not the
        // source field's structure: even if the on-disk file held
        // an `enc:v2:` ciphertext, this call site receives the
        // decrypted value and redaction is what prevents the
        // plaintext from reaching the response. `redact_value_at_key`
        // handles leaf strings directly using the trailing path
        // segment as the secret-name hint — no wrap-in-temp-object
        // dance needed.
        let mut value = get_value_at_path(&snapshot.raw_config, key).unwrap_or(Value::Null);
        let leaf_name = key.rsplit('.').next().unwrap_or(key);
        crate::logging::redact::redact_value_at_key(&mut value, leaf_name);
        return Ok(json!({
            "key": key,
            "value": value
        }));
    }

    let mut redacted_config = snapshot.raw_config.clone();
    crate::logging::redact::redact_json_value(&mut redacted_config);
    let mut redacted_parsed = snapshot.parsed.clone();
    crate::logging::redact::redact_json_value(&mut redacted_parsed);

    // `raw` (literal file text) is intentionally omitted. It can contain
    // plaintext secrets when CARAPACE_CONFIG_PASSWORD is unset, and regex
    // scrubbing of JSON5 text is unreliable. Clients should use the
    // structured `parsed` / `config` views.
    Ok(json!({
        "path": snapshot.path,
        "exists": snapshot.exists,
        "raw": Value::Null,
        "parsed": redacted_parsed,
        "valid": snapshot.valid,
        "config": redacted_config,
        "hash": snapshot.hash,
        "issues": snapshot.issues,
        "warnings": []
    }))
}

pub(super) fn handle_config_set(params: Option<&Value>) -> Result<Value, ErrorShape> {
    let snapshot = read_config_snapshot();
    require_config_base_hash(params, &snapshot)?;

    let raw = params
        .and_then(|v| v.get("raw"))
        .and_then(|v| v.as_str())
        .ok_or_else(|| error_shape(ERROR_INVALID_REQUEST, "raw is required", None))?;
    let mut parsed = json5::from_str::<Value>(raw)
        .map_err(|err| error_shape(ERROR_INVALID_REQUEST, &err.to_string(), None))?;
    if !parsed.is_object() {
        return Err(error_shape(
            ERROR_INVALID_REQUEST,
            "config.set raw must be an object",
            None,
        ));
    }
    // Compare against `snapshot.parsed` — the pure JSON5 parse of
    // the on-disk file, with `${VAR}` placeholders preserved.
    // `snapshot.raw_config` is the env-substituted + secret-decrypted
    // view; comparing it against the caller's submitted (still
    // placeholder-bearing) `parsed` would resolve-vs-placeholder and
    // trip a false-positive protected-change rejection when the
    // caller passes the same `${VAR}` they had on disk.
    reject_protected_config_changes(&snapshot.parsed, &parsed)?;
    let issues = validate_runtime_config_candidate(&parsed)?;
    if has_config_errors(&issues) {
        return Err(error_shape(
            ERROR_INVALID_REQUEST,
            "invalid config",
            Some(json!({ "issues": issues })),
        ));
    }
    // SECURITY (B136): pre-encrypt plaintext secrets at known
    // CONFIG_SECRET_PATHS before persist. Closes the B127-class
    // TOCTOU bypass at the WS direct config-write path:
    // `seal_config_secrets` inside the persist sink does its own
    // independent `config_password()` read; if the env var
    // transiently unsets between this handler and the seal-layer
    // read, the seal silently no-ops and leaves plaintext on disk
    // while the handler reports success. Pre-encrypting makes the
    // seal-layer `is_encrypted()` guard skip these slots.
    config::encrypt_known_secret_paths_inline(&mut parsed)
        .map_err(|err| error_shape(ERROR_UNAVAILABLE, &err, None))?;
    write_config_file_with_base_hash(
        &config::get_config_path(),
        &parsed,
        snapshot.hash.as_deref(),
    )?;
    // Echoing back `parsed` directly would expose any plaintext secret the
    // caller did NOT supply (e.g., they patched a benign field; the
    // response would still carry the existing matrix.accessToken etc.
    // when the on-disk file holds raw values). Always run the same
    // redactor used by config.get before returning.
    let mut redacted_response = parsed.clone();
    crate::logging::redact::redact_json_value(&mut redacted_response);
    Ok(json!({
        "ok": true,
        "path": config::get_config_path().display().to_string(),
        "config": redacted_response
    }))
}

pub(super) fn handle_config_apply(params: Option<&Value>) -> Result<Value, ErrorShape> {
    let snapshot = read_config_snapshot();
    require_config_base_hash(params, &snapshot)?;

    let raw = params
        .and_then(|v| v.get("raw"))
        .and_then(|v| v.as_str())
        .ok_or_else(|| error_shape(ERROR_INVALID_REQUEST, "raw is required", None))?;
    let mut parsed = json5::from_str::<Value>(raw)
        .map_err(|err| error_shape(ERROR_INVALID_REQUEST, &err.to_string(), None))?;
    if !parsed.is_object() {
        return Err(error_shape(
            ERROR_INVALID_REQUEST,
            "config.apply raw must be an object",
            None,
        ));
    }
    // Compare against `snapshot.parsed` for the same reason as
    // `handle_config_set` — placeholder vs placeholder, otherwise the
    // resolve-vs-placeholder mismatch is a false-positive reject.
    reject_protected_config_changes(&snapshot.parsed, &parsed)?;
    let issues = validate_runtime_config_candidate(&parsed)?;
    if has_config_errors(&issues) {
        return Err(error_shape(
            ERROR_INVALID_REQUEST,
            "invalid config",
            Some(json!({ "issues": issues })),
        ));
    }
    // SECURITY (B136): see handle_config_set for the bypass class
    // this closes. Symmetric encrypt-inline pre-pass.
    config::encrypt_known_secret_paths_inline(&mut parsed)
        .map_err(|err| error_shape(ERROR_UNAVAILABLE, &err, None))?;
    write_config_file_with_base_hash(
        &config::get_config_path(),
        &parsed,
        snapshot.hash.as_deref(),
    )?;
    let mut redacted_response = parsed.clone();
    crate::logging::redact::redact_json_value(&mut redacted_response);
    Ok(json!({
        "ok": true,
        "path": config::get_config_path().display().to_string(),
        "config": redacted_response
    }))
}

pub(super) fn handle_config_patch(params: Option<&Value>) -> Result<Value, ErrorShape> {
    let snapshot = read_config_snapshot();
    require_config_base_hash(params, &snapshot)?;

    // Refuse to patch a corrupted base. Without this guard, `merge_patch`
    // applied to a `Value::Null` parsed view returns just the patch
    // contents, silently rewriting an unparseable config file with only
    // the patch — destroying the operator's broken-but-recoverable
    // original. The persist sinks (`config.set` via
    // `persist_config_file`, `config.apply` via
    // `persist_config_file_with_base_hash`, and `config.patch` via the
    // inner `update_config_file_inner`) all refuse to write into an
    // unparseable base, so suggesting `config.set` would loop the
    // operator back into the same error. Tell them to fix or remove
    // the on-disk file directly.
    if snapshot.exists && snapshot.parsed.is_null() {
        return Err(error_shape(
            ERROR_INVALID_REQUEST,
            &format!(
                "config file at {} failed to parse; refuse to write into an unparseable base — \
                 fix the file on disk first or remove it, then retry",
                snapshot.path
            ),
            None,
        ));
    }

    let raw = params
        .and_then(|v| v.get("raw"))
        .and_then(|v| v.as_str())
        .ok_or_else(|| error_shape(ERROR_INVALID_REQUEST, "raw is required", None))?;
    let patch_value = json5::from_str::<Value>(raw)
        .map_err(|err| error_shape(ERROR_INVALID_REQUEST, &err.to_string(), None))?;
    if !patch_value.is_object() {
        return Err(error_shape(
            ERROR_INVALID_REQUEST,
            "config.patch raw must be an object",
            None,
        ));
    }

    // Merge against `snapshot.parsed` — the pure JSON5 parse of the
    // on-disk file with `${VAR}` placeholders and `enc:v2:`
    // ciphertexts preserved. `snapshot.raw_config` is the
    // env-substituted + secret-decrypted view, so persisting a merge
    // built from it would silently materialize operator secrets into
    // the config file (the `seal_config_secrets` re-encrypt pass is a
    // no-op when `CARAPACE_CONFIG_PASSWORD` is unset). The
    // protected-change check uses the same base for consistency:
    // placeholder vs placeholder-derived merge keeps no-op submissions
    // out of the false-positive bucket.
    let mut merged = merge_patch(snapshot.parsed.clone(), patch_value);
    reject_protected_config_changes(&snapshot.parsed, &merged)?;
    let issues = validate_runtime_config_candidate(&merged)?;
    if has_config_errors(&issues) {
        return Err(error_shape(
            ERROR_INVALID_REQUEST,
            "invalid config",
            Some(json!({ "issues": issues })),
        ));
    }

    // SECURITY (B136): see handle_config_set. Patch path additionally
    // protects against an operator patching a secret-bearing path
    // with plaintext (e.g., `{"matrix": {"accessToken": "raw"}}`)
    // while the env var is transiently unset.
    config::encrypt_known_secret_paths_inline(&mut merged)
        .map_err(|err| error_shape(ERROR_UNAVAILABLE, &err, None))?;
    write_config_file_with_base_hash(
        &config::get_config_path(),
        &merged,
        snapshot.hash.as_deref(),
    )?;
    // The merged result inherits every existing field from snapshot.parsed
    // — including any plaintext secrets the on-disk file already holds.
    // A caller patching a benign field could otherwise read secrets they
    // never sent. Apply the same redactor used by config.get.
    let mut redacted_response = merged.clone();
    crate::logging::redact::redact_json_value(&mut redacted_response);
    Ok(json!({
        "ok": true,
        "path": config::get_config_path().display().to_string(),
        "config": redacted_response
    }))
}

pub(super) fn handle_config_validate(params: Option<&Value>) -> Result<Value, ErrorShape> {
    let raw = params.and_then(|v| v.get("raw")).and_then(|v| v.as_str());

    let parsed = if let Some(raw) = raw {
        json5::from_str::<Value>(raw)
            .map_err(|err| error_shape(ERROR_INVALID_REQUEST, &err.to_string(), None))?
    } else {
        params
            .and_then(|v| v.get("config"))
            .cloned()
            .ok_or_else(|| error_shape(ERROR_INVALID_REQUEST, "raw or config is required", None))?
    };

    if !parsed.is_object() {
        return Err(error_shape(
            ERROR_INVALID_REQUEST,
            "config.validate value must be an object",
            None,
        ));
    }

    let issues = validate_runtime_config_candidate(&parsed)?;
    if has_config_errors(&issues) {
        return Err(error_shape(
            ERROR_INVALID_REQUEST,
            "invalid config",
            Some(json!({ "issues": issues })),
        ));
    }

    Ok(json!({
        "ok": true,
        "valid": true,
        "issues": [],
        "warnings": issues
    }))
}

pub(super) fn handle_config_schema() -> Result<Value, ErrorShape> {
    let keys = config::schema::known_top_level_keys();
    let mut properties = serde_json::Map::new();
    for key in keys {
        properties.insert(key.to_string(), json!({ "type": "object" }));
    }

    Ok(json!({
        "$schema": "http://json-schema.org/draft-07/schema#",
        "type": "object",
        "additionalProperties": false,
        "properties": properties,
        "knownKeys": keys
    }))
}

/// Handle the `config.reload` WS method (admin-only).
///
/// Routes through the hot-reload bridge so the WS reload exercises the
/// same provider-validation pipeline as the file-watcher and SIGHUP paths.
/// A reload that drops the LLM provider is rejected before the cache is
/// touched; the client gets an error response.
pub(super) async fn handle_config_reload(state: &WsServerState) -> Result<Value, ErrorShape> {
    use crate::config::watcher::{manual_reload_mode, mode_label};
    use crate::server::startup::{ReloadCommand, ReloadCommandResult};

    let mode = manual_reload_mode();

    let Some(command_tx) = state.reload_command_tx() else {
        return Err(error_shape(
            ERROR_UNAVAILABLE,
            "config-reload bridge is not running; reload requests cannot be processed",
            None,
        ));
    };
    let (respond_to, response_rx) = tokio::sync::oneshot::channel();
    if command_tx
        .send(ReloadCommand {
            mode: mode.clone(),
            respond_to,
        })
        .await
        .is_err()
    {
        return Err(error_shape(
            ERROR_UNAVAILABLE,
            "config-reload bridge has shut down; reload not delivered",
            None,
        ));
    }
    // The bridge always replies; an `Err(_)` here would mean the bridge task
    // panicked between command receipt and respond_to.send — fold it into a
    // generic LoadError so the WS handler never silently no-ops.
    let result = response_rx.await.unwrap_or_else(|_| {
        ReloadCommandResult::LoadError(
            "config-reload bridge dropped the response without replying".into(),
        )
    });
    match result {
        ReloadCommandResult::Applied { warnings } => Ok(json!({
            "ok": true,
            "mode": mode_label(&mode),
            "warnings": warnings,
        })),
        ReloadCommandResult::Reverted => Err(error_shape(
            ERROR_UNAVAILABLE,
            "reload rejected: the new config has no LLM provider configured (or build_providers \
             failed). The previous config is still active.",
            None,
        )),
        ReloadCommandResult::LoadError(message) => {
            // Log the full diagnostic server-side and surface a generic
            // summary to the WS client. The full message can include
            // absolute file paths, OS errnos, and JSON5 parser positions —
            // useful for the operator reading server logs but a leak vector
            // when WS error responses get forwarded to logging aggregators
            // or dashboards.
            tracing::error!("config.reload failed: {}", message);
            Err(error_shape(
                ERROR_UNAVAILABLE,
                "config reload failed; see server logs for details",
                None,
            ))
        }
    }
}

/// Broadcast a `config.changed` event to all connected WS clients.
///
/// This is called after a successful config reload (from file watcher, SIGHUP,
/// or the `config.reload` WS method).
pub fn broadcast_config_changed(state: &WsServerState, mode: &str) {
    let payload = json!({
        "mode": mode,
        "ts": now_ms()
    });
    broadcast_event(state, "config.changed", payload);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_config_write_temp_path_is_unique_in_target_dir() {
        let path = PathBuf::from("config-dir").join("carapace.json");
        let first = config_write_temp_path(&path);
        let second = config_write_temp_path(&path);

        assert_ne!(first, second);
        assert_eq!(first.parent(), path.parent());
        // After the round-28 consolidation, config writes go through
        // `crate::paths::atomic_tmp_path(path, "cfg")`, producing
        // `{file_name}.cfg.tmp.{pid}.{counter}` — the `cfg` infix
        // makes journal globs unambiguous (vs session/secret tmps).
        assert!(first
            .file_name()
            .and_then(|name| name.to_str())
            .is_some_and(|name| name.starts_with("carapace.json.cfg.tmp.")));
    }

    #[test]
    fn test_persist_config_file_rejects_locked_secret_overwrite_without_password() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env = crate::test_support::env::ScopedEnv::new();

        // Build a real enc:v2 envelope under one password; then drop
        // the password so the downgrade guard runs against the
        // unlocked-on-disk view. Using a fake fixed string like
        // "enc:v2:nonce:ciphertext:salt" doesn't pass the strict
        // envelope validation in `is_encrypted` (it isn't valid
        // base64-encoded data of the right segment lengths) and would
        // be treated as plaintext, silently disabling the downgrade
        // check this test exists to verify. `generate_hex_secret`
        // (random bytes via `getrandom`) avoids CodeQL's
        // `rust/hard-coded-cryptographic-value` rule, which flags
        // any literal prefix (e.g. `format!("fixture-{uuid}")`)
        // that flows into a crypto sink.
        let passphrase = crate::crypto::generate_hex_secret(32).expect("getrandom passphrase");
        env.set("CARAPACE_CONFIG_PASSWORD", &passphrase);
        let store = crate::config::secrets::SecretStore::new(passphrase.as_bytes()).expect("store");
        let real_envelope = store.encrypt("encrypted-token").expect("encrypt");

        env.unset("CARAPACE_CONFIG_PASSWORD");
        let temp = tempfile::tempdir().expect("tempdir");
        let path = temp.path().join("carapace.json5");
        let on_disk = json!({
            "matrix": {
                "accessToken": real_envelope.clone(),
                "deviceId": "DEVICE",
                "encrypted": false
            }
        });
        fs::write(&path, serde_json::to_string_pretty(&on_disk).unwrap())
            .expect("write existing config");

        let candidate = json!({
            "matrix": {
                "accessToken": null,
                "deviceId": "DEVICE",
                "encrypted": false
            }
        });
        let err = persist_config_file(&path, &candidate)
            .expect_err("locked encrypted secret must not be overwritten");

        assert!(err.contains("CARAPACE_CONFIG_PASSWORD is required"));
        let current = fs::read_to_string(&path).expect("read config");
        assert!(current.contains(&real_envelope));
    }

    #[test]
    fn test_persist_config_file_rejects_plaintext_overwriting_ciphertext_without_password() {
        // The primary threat scenario: an existing enc:v2 ciphertext
        // on disk gets replaced by a non-empty plaintext string while
        // CARAPACE_CONFIG_PASSWORD is unset. The previous test used a
        // `null` candidate which fell into the "encrypted-paths-must-
        // not-change" branch instead of the downgrade guard. This
        // test exercises the downgrade guard's primary code path.
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env = crate::test_support::env::ScopedEnv::new();

        let passphrase = crate::crypto::generate_hex_secret(32).expect("getrandom passphrase");
        env.set("CARAPACE_CONFIG_PASSWORD", &passphrase);
        let store = crate::config::secrets::SecretStore::new(passphrase.as_bytes()).expect("store");
        let real_envelope = store.encrypt("orig-token").expect("encrypt");

        env.unset("CARAPACE_CONFIG_PASSWORD");
        let temp = tempfile::tempdir().expect("tempdir");
        let path = temp.path().join("carapace.json5");
        let on_disk = json!({
            "matrix": {
                "accessToken": real_envelope.clone(),
                "deviceId": "DEVICE",
                "encrypted": false
            }
        });
        fs::write(&path, serde_json::to_string_pretty(&on_disk).unwrap())
            .expect("write existing config");

        // Operator submits a PLAINTEXT replacement for the encrypted
        // path while the password is unset. The downgrade guard MUST
        // reject this — it's the case the comment at
        // `validate_locked_secret_preservation` calls out as the
        // primary threat (config write, no password, plaintext
        // replaces enc:v2:).
        let candidate = json!({
            "matrix": {
                "accessToken": "sk-attacker-supplied-plaintext",
                "deviceId": "DEVICE",
                "encrypted": false
            }
        });
        let err = persist_config_file(&path, &candidate)
            .expect_err("plaintext overwrite of enc:v2: must be rejected");

        assert!(
            err.contains("CARAPACE_CONFIG_PASSWORD is unset"),
            "error must name the unset password: {err}"
        );
        let current = fs::read_to_string(&path).expect("read config");
        assert!(
            current.contains(&real_envelope),
            "on-disk ciphertext must be preserved verbatim after rejection"
        );
        assert!(
            !current.contains("sk-attacker-supplied-plaintext"),
            "candidate plaintext must NOT have leaked to disk"
        );
    }

    #[test]
    fn test_persist_config_file_rejects_plaintext_overwriting_corrupt_ciphertext() {
        // The R26-4 finding I just landed: the downgrade guard now uses
        // looks_like_encrypted_value (prefix-only) rather than
        // is_encrypted (strict). A truncated / corrupt enc:v2: envelope
        // still LOOKS encrypted by prefix; it must still be preserved.
        // Without this protection a partial-write or hand-corruption
        // turns into a free pass for plaintext to land on disk.
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env = crate::test_support::env::ScopedEnv::new();
        env.unset("CARAPACE_CONFIG_PASSWORD");

        // Truncated enc:v2 envelope — recognizable by prefix but fails
        // strict parse_encrypted because segments are too short.
        let corrupt_envelope = "enc:v2:abc:def:ghi";
        let temp = tempfile::tempdir().expect("tempdir");
        let path = temp.path().join("carapace.json5");
        let on_disk = json!({
            "matrix": {
                "accessToken": corrupt_envelope,
                "deviceId": "DEVICE"
            }
        });
        fs::write(&path, serde_json::to_string_pretty(&on_disk).unwrap())
            .expect("write existing config");

        let candidate = json!({
            "matrix": {
                "accessToken": "sk-plaintext-replacement",
                "deviceId": "DEVICE"
            }
        });
        let err = persist_config_file(&path, &candidate)
            .expect_err("malformed enc:v2 ciphertext must still be preserved");
        assert!(
            err.contains("CARAPACE_CONFIG_PASSWORD"),
            "error must name the password: {err}"
        );
        let current = fs::read_to_string(&path).expect("read config");
        assert!(current.contains(corrupt_envelope));
    }

    #[test]
    fn test_persist_config_file_with_base_hash_rejects_stale_write() {
        let temp = tempfile::tempdir().expect("tempdir");
        let path = temp.path().join("carapace.json5");
        let original = r#"{ gateway: { controlUi: { enabled: false } } }"#;
        fs::write(&path, original).expect("write original config");
        let stale_hash = sha256_hex(original);
        fs::write(&path, r#"{ gateway: { controlUi: { enabled: true } } }"#)
            .expect("write concurrent config");

        let err = persist_config_file_with_base_hash(
            &path,
            &json!({"gateway": {"controlUi": {"enabled": false}}}),
            Some(&stale_hash),
        )
        .expect_err("stale base hash must be rejected");

        assert!(matches!(err, PersistConfigError::ConflictingBaseHash(_)));
    }

    #[test]
    fn test_handle_config_validate_accepts_object() {
        let params = json!({ "config": {} });
        let result = handle_config_validate(Some(&params));
        assert!(result.is_ok());
        let value = result.unwrap();
        assert_eq!(value["ok"], true);
        assert_eq!(value["valid"], true);
    }

    /// `config.get` must never return plaintext secrets — neither in the
    /// full snapshot nor in per-key lookups. The previous WS handler shape
    /// returned `snapshot.config` (the *decrypted* config view), which
    /// leaked any sealed secret to operators with control-API access.
    #[test]
    fn test_handle_config_get_redacts_secret_paths() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env = crate::test_support::env::ScopedEnv::new();
        env.unset("CARAPACE_CONFIG_PASSWORD");
        let temp = tempfile::tempdir().expect("tempdir");
        let path = temp.path().join("carapace.json5");
        fs::write(
            &path,
            r#"{
                matrix: {
                    accessToken: "plaintext-token-value-must-not-leak",
                    password: "plaintext-password-value-must-not-leak",
                    storePassphrase: "plaintext-passphrase-must-not-leak",
                    deviceId: "DEVICE",
                    homeserverUrl: "https://matrix.example.com"
                }
            }"#,
        )
        .expect("write config");
        env.set("CARAPACE_CONFIG_PATH", path.display().to_string());
        crate::config::clear_cache();

        let response = handle_config_get(None).expect("config.get full snapshot");
        let matrix = response
            .get("config")
            .and_then(|c| c.get("matrix"))
            .expect("matrix subtree");
        assert_eq!(
            matrix.get("accessToken").and_then(|v| v.as_str()),
            Some("[REDACTED]"),
            "accessToken must be redacted in full snapshot"
        );
        assert_eq!(
            matrix.get("password").and_then(|v| v.as_str()),
            Some("[REDACTED]"),
            "password must be redacted in full snapshot"
        );
        assert_eq!(
            matrix.get("storePassphrase").and_then(|v| v.as_str()),
            Some("[REDACTED]"),
            "storePassphrase must be redacted in full snapshot"
        );
        assert_eq!(
            matrix.get("deviceId").and_then(|v| v.as_str()),
            Some("DEVICE"),
            "deviceId is identity-linked, not secret — preserve"
        );
        assert_eq!(
            matrix.get("homeserverUrl").and_then(|v| v.as_str()),
            Some("https://matrix.example.com"),
            "non-secret fields preserved"
        );

        // raw text intentionally not served via WS — clients use parsed/config.
        assert!(
            response.get("raw").is_some_and(|v| v.is_null()),
            "raw text must not be served via config.get"
        );

        // parsed view must also be redacted
        let parsed_matrix = response
            .get("parsed")
            .and_then(|c| c.get("matrix"))
            .expect("parsed matrix subtree");
        assert_eq!(
            parsed_matrix.get("accessToken").and_then(|v| v.as_str()),
            Some("[REDACTED]"),
            "parsed view must redact accessToken"
        );

        // Per-key query must redact too
        let by_key = handle_config_get(Some(&json!({"key": "matrix.accessToken"})))
            .expect("config.get with key");
        assert_eq!(
            by_key.get("value").and_then(|v| v.as_str()),
            Some("[REDACTED]"),
            "per-key lookup must redact secrets"
        );

        // Non-secret per-key lookup unchanged
        let by_key = handle_config_get(Some(&json!({"key": "matrix.deviceId"})))
            .expect("config.get with non-secret key");
        assert_eq!(by_key.get("value").and_then(|v| v.as_str()), Some("DEVICE"));

        crate::config::clear_cache();
    }

    /// `config.set` / `config.apply` / `config.patch` echo the resulting
    /// config in the response. Without redaction, a caller patching a
    /// benign field could read existing plaintext secrets via the
    /// returned `config` object — even though `config.get` is now
    /// scrubbed. This test pins the contract: the response config must
    /// be redacted on all three write paths.
    #[test]
    fn test_handle_config_write_redacts_response_payload() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env = crate::test_support::env::ScopedEnv::new();
        env.unset("CARAPACE_CONFIG_PASSWORD");
        let temp = tempfile::tempdir().expect("tempdir");
        let path = temp.path().join("carapace.json5");
        fs::write(
            &path,
            r#"{
                matrix: {
                    accessToken: "existing-token-must-not-leak",
                    password: "existing-password-must-not-leak",
                    deviceId: "DEVICE",
                    homeserverUrl: "https://matrix.example.com",
                    enabled: false
                }
            }"#,
        )
        .expect("write config");
        env.set("CARAPACE_CONFIG_PATH", path.display().to_string());
        crate::config::clear_cache();

        let snapshot = read_config_snapshot();
        let base_hash = snapshot.hash.clone().expect("hash present");

        // config.patch with an unrelated field should NOT echo back the
        // existing plaintext secrets in the response.
        let patch_raw = r#"{matrix:{encrypted:false}}"#;
        let response = handle_config_patch(Some(&json!({
            "raw": patch_raw,
            "baseHash": base_hash,
        })))
        .expect("config.patch should succeed");
        let response_matrix = response
            .get("config")
            .and_then(|c| c.get("matrix"))
            .expect("response config.matrix");
        assert_eq!(
            response_matrix.get("accessToken").and_then(|v| v.as_str()),
            Some("[REDACTED]"),
            "config.patch response must not echo existing accessToken"
        );
        assert_eq!(
            response_matrix.get("password").and_then(|v| v.as_str()),
            Some("[REDACTED]"),
            "config.patch response must not echo existing password"
        );
        assert_eq!(
            response_matrix.get("deviceId").and_then(|v| v.as_str()),
            Some("DEVICE"),
            "protected non-secret identity field is unchanged"
        );
        assert_eq!(
            response_matrix.get("encrypted").and_then(|v| v.as_bool()),
            Some(false),
            "non-secret field reflects the patch"
        );

        crate::config::clear_cache();

        // Also exercise config.set with an explicit object containing a
        // secret-named field — it must come back redacted.
        let snapshot = read_config_snapshot();
        let base_hash = snapshot.hash.clone().expect("hash present");
        let set_raw = r#"{
            matrix: {
                accessToken: "existing-token-must-not-leak",
                password: "existing-password-must-not-leak",
                deviceId: "DEVICE",
                homeserverUrl: "https://matrix.example.com",
                encrypted: false,
                enabled: false
            }
        }"#;
        let response = handle_config_set(Some(&json!({
            "raw": set_raw,
            "baseHash": base_hash,
        })))
        .expect("config.set should succeed");
        let response_matrix = response
            .get("config")
            .and_then(|c| c.get("matrix"))
            .expect("response config.matrix");
        assert_eq!(
            response_matrix.get("accessToken").and_then(|v| v.as_str()),
            Some("[REDACTED]"),
            "config.set response must not echo accessToken even when the caller supplied it"
        );
        assert_eq!(
            response_matrix.get("password").and_then(|v| v.as_str()),
            Some("[REDACTED]"),
            "config.set response must not echo password even when the caller supplied it"
        );

        crate::config::clear_cache();
    }

    #[test]
    fn test_handle_config_write_rejects_protected_matrix_paths() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env = crate::test_support::env::ScopedEnv::new();
        env.unset("CARAPACE_CONFIG_PASSWORD");
        let temp = tempfile::tempdir().expect("tempdir");
        let path = temp.path().join("carapace.json5");
        fs::write(
            &path,
            r#"{
                matrix: {
                    accessToken: "token",
                    password: "password",
                    storePassphrase: "passphrase",
                    deviceId: "DEVICE",
                    homeserverUrl: "https://matrix.example.com",
                    userId: "@cara:example.com"
                }
            }"#,
        )
        .expect("write config");
        env.set("CARAPACE_CONFIG_PATH", path.display().to_string());
        crate::config::clear_cache();

        let replacement = r#"{
            matrix: {
                accessToken: "changed",
                password: "password",
                storePassphrase: "passphrase",
                deviceId: "DEVICE",
                homeserverUrl: "https://matrix.example.com",
                userId: "@cara:example.com"
            }
        }"#;
        for (method, call) in [
            (
                "config.set",
                handle_config_set
                    as fn(Option<&serde_json::Value>) -> Result<serde_json::Value, ErrorShape>,
            ),
            (
                "config.apply",
                handle_config_apply
                    as fn(Option<&serde_json::Value>) -> Result<serde_json::Value, ErrorShape>,
            ),
        ] {
            let snapshot = read_config_snapshot();
            let base_hash = snapshot.hash.clone().expect("hash present");
            let err = call(Some(&json!({
                "raw": replacement,
                "baseHash": base_hash,
            })))
            .expect_err("protected Matrix replacement must be rejected");
            assert_eq!(
                err.code, ERROR_INVALID_REQUEST,
                "{method} must protect Matrix"
            );
        }

        for (label, update) in [
            ("accessToken", r#"{matrix:{accessToken:"changed"}}"#),
            ("password", r#"{matrix:{password:"changed"}}"#),
            ("storePassphrase", r#"{matrix:{storePassphrase:"changed"}}"#),
            ("deviceId", r#"{matrix:{deviceId:"CHANGED"}}"#),
            (
                "homeserverUrl",
                r#"{matrix:{homeserverUrl:"https://evil.example.com"}}"#,
            ),
            ("userId", r#"{matrix:{userId:"@evil:example.com"}}"#),
            (
                "env.MATRIX_ACCESS_TOKEN",
                r#"{env:{MATRIX_ACCESS_TOKEN:"changed"}}"#,
            ),
            (
                "env.vars.MATRIX_STORE_PASSPHRASE",
                r#"{env:{vars:{MATRIX_STORE_PASSPHRASE:"changed"}}}"#,
            ),
        ] {
            let snapshot = read_config_snapshot();
            let base_hash = snapshot.hash.clone().expect("hash present");
            let err = handle_config_patch(Some(&json!({
                "raw": update,
                "baseHash": base_hash,
            })))
            .expect_err("protected Matrix patch must be rejected");
            assert_eq!(err.code, ERROR_INVALID_REQUEST, "{label} must be protected");
            assert!(
                err.message.contains("protected configuration"),
                "{label} should report protected path"
            );
        }

        crate::config::clear_cache();
    }

    /// `config.patch` against a corrupted on-disk file would
    /// otherwise merge `Null` with the patch and silently rewrite
    /// the file with only the patch contents — destroying the
    /// operator's broken-but-recoverable original. The handler
    /// explicitly refuses to patch a parsed-as-Null base and tells
    /// the operator to fix the file or use config.set.
    #[test]
    fn test_handle_config_patch_refuses_unparseable_base() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env = crate::test_support::env::ScopedEnv::new();
        env.unset("CARAPACE_CONFIG_PASSWORD");
        let temp = tempfile::tempdir().expect("tempdir");
        let path = temp.path().join("carapace.json5");
        // Intentionally invalid JSON5 — opening brace + unbalanced.
        let original = "{ this is not valid json5";
        fs::write(&path, original).expect("write corrupt config");
        env.set("CARAPACE_CONFIG_PATH", path.display().to_string());
        crate::config::clear_cache();

        // base hash matches the actual file contents; the corruption
        // alone should be enough to refuse.
        let base_hash = sha256_hex(original);
        let result = handle_config_patch(Some(&json!({
            "raw": "{matrix:{deviceId:'D'}}",
            "baseHash": base_hash,
        })));
        let err = result.expect_err("patch on unparseable base must be refused");
        assert!(
            err.message.contains("unparseable base"),
            "expected refusal message, got: {}",
            err.message
        );

        // Verify the on-disk file is unchanged.
        let after = fs::read_to_string(&path).expect("read");
        assert_eq!(after, original, "patch must not touch unparseable file");

        crate::config::clear_cache();
    }

    /// Direct test for `update_config_file` against an unparseable
    /// base. The merge variant must reject loudly rather than build
    /// a fresh `{}` and silently clobber the operator's broken file.
    /// This pins the inner guard from below the WS handlers — a
    /// future refactor that bypasses `handle_config_patch` (e.g.
    /// `cara config set` going direct via `update_config_file`)
    /// must not regress to "treat parse failure as no existing
    /// config".
    #[test]
    fn test_update_config_file_refuses_unparseable_base() {
        let temp = tempfile::tempdir().expect("tempdir");
        let path = temp.path().join("carapace.json5");
        let original = "{ this is not valid json5";
        fs::write(&path, original).expect("write corrupt base");

        let result = update_config_file(&path, |value| {
            value
                .as_object_mut()
                .expect("merge sees object")
                .insert("foo".to_string(), json!("bar"));
            Ok(())
        });
        let err = result.expect_err("update_config_file must refuse corrupt base");
        assert!(
            err.contains("unparseable base"),
            "expected refusal message, got: {err}"
        );

        let after = fs::read_to_string(&path).expect("read");
        assert_eq!(
            after, original,
            "update_config_file must leave a corrupt file untouched"
        );
    }

    /// Same guard via `try_update_config_file`. `try_` callers also
    /// must not silently clobber an unparseable base on a no-op
    /// update.
    #[test]
    fn test_try_update_config_file_refuses_unparseable_base() {
        let temp = tempfile::tempdir().expect("tempdir");
        let path = temp.path().join("carapace.json5");
        let original = "{ this is not valid json5";
        fs::write(&path, original).expect("write corrupt base");

        let result =
            try_update_config_file(&path, |_value| Ok::<_, String>(ConfigUpdateOutcome::NoOp));
        let err = result.expect_err("try_update_config_file must refuse corrupt base");
        assert!(
            err.contains("unparseable base"),
            "expected refusal message, got: {err}"
        );

        let after = fs::read_to_string(&path).expect("read");
        assert_eq!(
            after, original,
            "try_update_config_file must leave a corrupt file untouched"
        );
    }

    /// Pin the REST `PersistConfigError → StatusCode` mapping. The
    /// `ConflictingBaseHash` arm must be 409 (client-side race);
    /// `Other` must be 500 (server fault). A regression that
    /// collapses both into 500 would lose the
    /// "re-read-and-retry" affordance for callers; collapsing both
    /// into 409 would mask real I/O failures as conflicts.
    #[test]
    fn test_persist_config_error_http_status() {
        use axum::http::StatusCode;
        assert_eq!(
            PersistConfigError::ConflictingBaseHash("drift".to_string()).http_status(),
            StatusCode::CONFLICT
        );
        assert_eq!(
            PersistConfigError::Other("io error".to_string()).http_status(),
            StatusCode::INTERNAL_SERVER_ERROR
        );
    }

    /// Round-trip pin for the `snapshot.parsed` vs `snapshot.raw_config`
    /// choice at the persist sites. Past iterations of this branch
    /// inverted the two values and silently materialized resolved
    /// `${VAR}` placeholders into the on-disk config file when
    /// `CARAPACE_CONFIG_PASSWORD` was unset (`seal_config_secrets` is
    /// then a no-op). This test seeds a placeholder, exports the env
    /// var, runs `handle_config_patch`, and asserts the on-disk file
    /// still contains the placeholder (not the resolved value). Any
    /// regression that wires `snapshot.raw_config` back into
    /// `merge_patch` will fail here loudly.
    #[test]
    fn test_handle_config_patch_persists_placeholder_not_resolved_value() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env = crate::test_support::env::ScopedEnv::new();
        env.unset("CARAPACE_CONFIG_PASSWORD");
        env.set("CARAPACE_TEST_SECRET", "the-resolved-secret");
        let temp = tempfile::tempdir().expect("tempdir");
        let path = temp.path().join("carapace.json5");
        let original = r#"{
                "telegram": {
                    "botToken": "${CARAPACE_TEST_SECRET}"
                }
            }"#;
        fs::write(&path, original).expect("write base config");
        env.set("CARAPACE_CONFIG_PATH", path.display().to_string());
        crate::config::clear_cache();

        let snapshot_for_hash = read_config_snapshot();
        let base_hash = snapshot_for_hash
            .hash
            .clone()
            .expect("snapshot.hash present for existing file");

        // Patch a benign field — must not touch the placeholder.
        let result = handle_config_patch(Some(&json!({
            "raw": "{ messages: { defaultExpiryDays: 14 } }",
            "baseHash": base_hash,
        })));
        result.expect("patch must succeed against placeholder-bearing config");

        let after = fs::read_to_string(&path).expect("read after patch");
        assert!(
            after.contains("${CARAPACE_TEST_SECRET}"),
            "placeholder must be preserved verbatim, got: {after}"
        );
        assert!(
            !after.contains("the-resolved-secret"),
            "resolved env value must NOT appear on disk, got: {after}"
        );

        crate::config::clear_cache();
    }

    /// B136 regression: `config.set` and `config.patch` encrypt
    /// plaintext secrets inline at known CONFIG_SECRET_PATHS when
    /// CARAPACE_CONFIG_PASSWORD is set. Closes the B127-class
    /// TOCTOU bypass at the WS-direct config-write path — a
    /// transient env-var unset between the handler's parse and
    /// `seal_config_secrets`'s independent read would otherwise
    /// leave plaintext on disk.
    ///
    /// The bypass surface is narrow because
    /// `reject_protected_config_changes` blocks DIRECT writes to
    /// protected/secret paths. But submitting the SAME plaintext
    /// value that already exists in the snapshot does NOT count as
    /// a change, so it passes the protected-check; then the seal
    /// layer's silent-no-op leaves the plaintext on disk. The
    /// inline encrypt makes the seal a no-op regardless.
    #[test]
    fn test_handle_config_set_encrypts_unchanged_plaintext_secret_inline_when_password_set() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env = crate::test_support::env::ScopedEnv::new();
        env.set("CARAPACE_CONFIG_PASSWORD", "test-config-password");
        let temp = tempfile::tempdir().expect("tempdir");
        let path = temp.path().join("carapace.json5");
        // Snapshot already contains plaintext bot token (operator
        // could land in this state via prior unencrypted-first-run
        // setup that wrote plaintext, then added CARAPACE_CONFIG_PASSWORD).
        fs::write(
            &path,
            "{ \"discord\": { \"botToken\": \"discord-plaintext-token\" } }",
        )
        .expect("write base");
        env.set("CARAPACE_CONFIG_PATH", path.display().to_string());
        crate::config::clear_cache();

        let snapshot = read_config_snapshot();
        let base_hash = snapshot
            .hash
            .clone()
            .expect("snapshot.hash present for existing file");

        // Submit `config.set` with the SAME plaintext at the
        // protected path — `reject_protected_config_changes` sees
        // no change and allows. Without B136's encrypt-inline
        // pass, the plaintext would stay on disk under transient
        // env-var unset.
        let result = handle_config_set(Some(&json!({
            "raw": "{ \"discord\": { \"botToken\": \"discord-plaintext-token\" } }",
            "baseHash": base_hash,
        })));
        result.expect("config.set must succeed when value unchanged");

        let after = fs::read_to_string(&path).expect("read after set");
        assert!(
            !after.contains("discord-plaintext-token"),
            "plaintext bot token must NOT remain on disk after the set; got: {after}"
        );
        assert!(
            after.contains("enc:v2:"),
            "discord.botToken must be encrypted to enc:v2: envelope, got: {after}"
        );

        crate::config::clear_cache();
    }

    /// `config.reload` returns ERROR_UNAVAILABLE when the hot-reload bridge
    /// is not running. The bridge sets `reload_command_tx` on
    /// `WsServerState` once it spawns; without it, the handler refuses to
    /// process the request rather than installing a payload directly (which
    /// would skip provider validation).
    #[tokio::test]
    async fn test_handle_config_reload_errors_when_bridge_not_running() {
        let state = WsServerState::new(WsServerConfig::default());
        // No `set_reload_command_tx(Some(...))` here — bridge never spawned.

        let result = handle_config_reload(&state).await;

        let err = result.expect_err("must fail without a bridge");
        assert!(
            err.message.contains("config-reload bridge is not running"),
            "got: {}",
            err.message
        );
    }

    /// `config.reload` reports a generic ERROR_UNAVAILABLE when the bridge's
    /// load fails. The bridge's raw error message (potentially containing
    /// file paths, OS errnos, and parser positions) is logged server-side
    /// but kept out of the WS response, so error-aggregation pipelines
    /// don't unintentionally surface filesystem layout to less-privileged
    /// log consumers.
    #[tokio::test]
    async fn test_handle_config_reload_surfaces_bridge_load_error() {
        use crate::server::startup::{ReloadCommand, ReloadCommandResult};

        let state = WsServerState::new(WsServerConfig::default());
        let (command_tx, mut command_rx) = tokio::sync::mpsc::channel::<ReloadCommand>(1);
        state.set_reload_command_tx(Some(command_tx));

        // Stand-in bridge: respond with a synthetic LoadError that includes
        // a path the WS response must NOT leak.
        let bridge = tokio::spawn(async move {
            let cmd = command_rx.recv().await.expect("bridge receives command");
            let _ = cmd.respond_to.send(ReloadCommandResult::LoadError(
                "/etc/carapace/config.json5: parse failed at line 1".to_string(),
            ));
        });

        let result = handle_config_reload(&state).await;
        bridge.await.expect("bridge task joins");

        let err = result.expect_err("LoadError must surface as Err");
        assert!(
            !err.message.contains("config.json5") && !err.message.contains("/etc/carapace"),
            "WS response must not leak the raw load-error path: {}",
            err.message
        );
        assert!(
            err.message.contains("config reload failed") && err.message.contains("server logs"),
            "WS response must point operators at server logs: {}",
            err.message
        );
    }

    /// `config.reload` returns ok+ERROR_UNAVAILABLE based on the bridge's
    /// outcome. `Reverted` (no provider in new config) maps to an error
    /// response that names the rejection reason; `Applied` maps to ok with
    /// the mode field populated from whatever the handler resolved.
    #[tokio::test]
    async fn test_handle_config_reload_maps_bridge_outcomes_to_responses() {
        use crate::server::startup::{ReloadCommand, ReloadCommandResult};

        // Applied path → Ok response with mode field set.
        {
            let state = WsServerState::new(WsServerConfig::default());
            let (command_tx, mut command_rx) = tokio::sync::mpsc::channel::<ReloadCommand>(1);
            state.set_reload_command_tx(Some(command_tx));
            let bridge = tokio::spawn(async move {
                let cmd = command_rx.recv().await.expect("command received");
                // Echo the mode label the handler resolved so the assertion
                // pins the round-trip without depending on whatever
                // gateway.reload.mode the ambient on-disk config carries.
                let label = crate::config::watcher::mode_label(&cmd.mode).to_string();
                let _ = cmd.respond_to.send(ReloadCommandResult::Applied {
                    warnings: vec!["a: warn-one".to_string()],
                });
                label
            });
            let result = handle_config_reload(&state).await;
            let resolved_label = bridge.await.unwrap();
            let value = result.expect("Applied → Ok");
            assert_eq!(value["ok"], true);
            assert_eq!(value["mode"], serde_json::Value::String(resolved_label));
            // Warnings from the bridge must round-trip into the response so
            // clients can surface non-fatal validation issues to the operator.
            assert_eq!(
                value["warnings"],
                serde_json::json!(["a: warn-one"]),
                "Applied warnings must be forwarded to the WS response"
            );
        }

        // Reverted path → Err with provider-rejection message.
        {
            let state = WsServerState::new(WsServerConfig::default());
            let (command_tx, mut command_rx) = tokio::sync::mpsc::channel::<ReloadCommand>(1);
            state.set_reload_command_tx(Some(command_tx));
            let bridge = tokio::spawn(async move {
                let cmd = command_rx.recv().await.expect("command received");
                let _ = cmd.respond_to.send(ReloadCommandResult::Reverted);
            });
            let result = handle_config_reload(&state).await;
            bridge.await.unwrap();
            let err = result.expect_err("Reverted → Err");
            assert!(
                err.message.contains("no LLM provider"),
                "Reverted reason must surface: {}",
                err.message
            );
        }
    }
}
