//! Per-channel approval-transport config + pairing store (Unit 2).
//!
//! A daemon-side durable JSON store under `~/.car/` (`messaging.json`) holding
//! the approval transport's trust state, now keyed **per channel** (iMessage,
//! Slack). One file, one atomic write, one fail-closed `load()` — the
//! `store-model` technical call: a single store with per-channel sections, NOT
//! a per-channel registry. Each channel carries today's three iMessage fields:
//!
//! - `enabled` — master opt-in flag, **default `false`** (per channel).
//! - `allowlisted_handles` — the approver handle(s) that may resolve approvals
//!   over that channel.
//! - `active_pairing_code` — a freshly minted, high-entropy code shown ONLY in
//!   local UI; an inbound text echoing it (constant-time compared) proves
//!   control of the sending handle and binds it into that channel's allowlist.
//!
//! The per-channel format is the **FIRST format ever shipped** (verified
//! 2026-06-23: `v0.29.0` is tagged but contains no messaging files). There is
//! no prior on-disk flat shape to migrate from, so `load()` carries NO
//! migration reader and NO tolerant fallback — it keeps the
//! empty→default / NotFound→default / parse-or-error shape (MC-4).
//!
//! Anti-injection invariant (SC-6, per channel): there is **no setter reachable
//! from an inbound message**. Mutation happens only through (1) the
//! host/local-auth-gated `messaging.config.*` / `messaging.pairing.*` WS
//! surface, or (2) `validate_and_consume_pairing_code` — the *only*
//! inbound-reachable mutation, binding a handle ONLY on a constant-time match of
//! the locally-minted code.
//!
//! Identity (`identity-shape` call): [`normalize_handle`] is channel-dispatched
//! — the iMessage branch is byte-for-byte unchanged (phone-vs-Apple-ID
//! heuristic); the Slack branch normalizes member/user IDs. Reusing the
//! iMessage normalizer for Slack would silently mis-normalize Slack IDs on the
//! allowlist trust gate, so each channel owns its normalization.

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

pub use car_server_types::channel::{ChannelConfig, ChannelId, SlackTokenRef};

/// Minted pairing-code length in bytes of entropy. 32 random bytes
/// base64url-no-pad encode to 43 ASCII chars — identical entropy and
/// shape to the daemon's per-launch / per-agent tokens
/// (`car_registry::supervisor::mint_agent_token`), so audit/diff tooling
/// treats them uniformly.
const PAIRING_CODE_ENTROPY_BYTES: usize = 32;

/// Durable, serialized form of the messaging transport config — now a map of
/// **per-channel** sections. One `messaging.json` holds every channel's trust
/// state under a single, atomically-written object. The map is a `BTreeMap`
/// keyed by [`ChannelId`] so serialization order is deterministic regardless of
/// insert order.
///
/// `Default` is the empty map (no channel configured yet); a read for an
/// unconfigured channel resolves to a fail-closed [`ChannelConfig::default`]
/// (`enabled = false`, empty allowlist) — both channels are off until the host
/// UI flips one on (MC-5).
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct MessagingConfig {
    /// Per-channel trust state. Absent channels resolve to the fail-closed
    /// default on read.
    #[serde(default)]
    pub channels: BTreeMap<ChannelId, ChannelConfig>,
}

impl MessagingConfig {
    /// The config for `channel`, or the fail-closed default if the channel has
    /// no section yet (read-only — does NOT insert).
    pub fn channel(&self, channel: ChannelId) -> ChannelConfig {
        self.channels.get(&channel).cloned().unwrap_or_default()
    }

    /// Mutable access to `channel`'s section, inserting a fail-closed default if
    /// absent. Used only by the privileged mutators.
    fn channel_mut(&mut self, channel: ChannelId) -> &mut ChannelConfig {
        self.channels.entry(channel).or_default()
    }
}

/// Length-checked constant-time byte compare. Mirrors the in-tree
/// `car_registry::supervisor::constant_time_eq` / `handler.rs`
/// `constant_time_eq` pattern — avoids leaking match position (and thus
/// the secret) via timing. Used for the pairing-code echo check; NEVER
/// use plain `==` on the secret.
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut diff: u8 = 0;
    for (x, y) in a.iter().zip(b.iter()) {
        diff |= x ^ y;
    }
    diff == 0
}

/// Mint a fresh high-entropy, single-use pairing code, encoded as
/// base64url-no-pad (43 chars). The 32 bytes are the concatenation of TWO
/// `Uuid::new_v4()` values. A v4 UUID is CSPRNG-backed but spends 6 of its 128
/// bits on the version/variant tag, so two of them carry ~244 effective bits of
/// entropy (not a full 256). That is far above any brute-force concern for a
/// single-use code that is cleared on the first successful bind — bearer-
/// equivalent strength, same approach as
/// `car_registry::supervisor::mint_agent_token` and the daemon's per-launch
/// token.
fn mint_pairing_code() -> String {
    use base64::Engine as _;
    let a = uuid::Uuid::new_v4();
    let b = uuid::Uuid::new_v4();
    let mut bytes = [0u8; PAIRING_CODE_ENTROPY_BYTES];
    bytes[..16].copy_from_slice(a.as_bytes());
    bytes[16..].copy_from_slice(b.as_bytes());
    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}

/// Crash- and concurrency-safe write: serialize to a UNIQUE temp in the
/// same directory, then atomically `rename(2)` over the target. Same
/// discipline as `handler.rs:atomic_write_sync` (per-call temp name =
/// pid + monotonic seq; rename is atomic on the same filesystem) so two
/// writers never collide and a crash mid-write can't leave a partial
/// file. Kept local to avoid making `handler::atomic_write_sync` `pub`.
fn atomic_write_sync(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
    use std::sync::atomic::{AtomicU64, Ordering};
    static SEQ: AtomicU64 = AtomicU64::new(0);
    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
    let mut tmp_os = path.as_os_str().to_owned();
    tmp_os.push(format!(".tmp.{}.{}", std::process::id(), seq));
    let tmp = PathBuf::from(tmp_os);
    std::fs::write(&tmp, bytes)?;
    std::fs::rename(&tmp, path)
}

/// In-process config/pairing store for the approval transport, now per-channel.
///
/// Reads/writes a single `messaging.json` under an injectable base dir. Every
/// mutating method persists synchronously via `atomic_write_sync`, so the
/// on-disk state is always the source of truth — a fresh
/// `MessagingConfigStore::with_base_dir` over the same dir reloads it.
///
/// Method surface is channel-parameterized (`is_enabled(channel)`,
/// `is_allowlisted(channel, handle)`, …). Back-compat shims without a channel
/// argument default to [`ChannelId::IMessage`] so the #403 callers and the WS
/// surface (which has no `channel` field until Unit 6) keep working.
#[derive(Debug, Clone)]
pub struct MessagingConfigStore {
    base_dir: PathBuf,
}

/// Outcome of an inbound pairing-code echo. Closed set — the inbound
/// parser maps a candidate `(handle, code)` to exactly one of these.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PairingOutcome {
    /// The echoed code matched the active pairing code (constant-time).
    /// The candidate handle was bound into that channel's allowlist and the
    /// code was cleared/rotated.
    Bound,
    /// No active pairing code, or the echoed code did not match.
    /// Nothing was mutated.
    Rejected,
}

impl MessagingConfigStore {
    /// Open the store rooted at `~/.car/`. `HOME` (or `USERPROFILE` on
    /// Windows) resolves the base; falls back to `.` if neither is set
    /// (only happens in degenerate environments — tests inject a temp
    /// dir via `with_base_dir`).
    pub fn from_home() -> Self {
        let base = std::env::var_os("HOME")
            .or_else(|| std::env::var_os("USERPROFILE"))
            .map(PathBuf::from)
            .unwrap_or_else(|| PathBuf::from("."))
            .join(".car");
        Self::with_base_dir(base)
    }

    /// Open the store rooted at an explicit base dir (the `~/.car/`
    /// equivalent). Tests pass a `TempDir` path here so they never touch
    /// the developer's real `~/.car/`.
    pub fn with_base_dir(base_dir: impl Into<PathBuf>) -> Self {
        Self {
            base_dir: base_dir.into(),
        }
    }

    fn config_path(&self) -> PathBuf {
        self.base_dir.join("messaging.json")
    }

    /// Load the persisted config from disk. A missing or empty file yields the
    /// default (no channels configured ⇒ every channel reads fail-closed). A
    /// malformed file surfaces as an error rather than silently resetting the
    /// trust state (which would be a silent security downgrade).
    ///
    /// **No migration branch (MC-4):** the per-channel format is the first
    /// format ever written to disk, so there is no legacy flat shape to be
    /// tolerant toward. `load()` is read-only — it never calls `save()` (a
    /// corrupt file fails closed to `Err`, it does not silently rewrite).
    pub fn load(&self) -> Result<MessagingConfig, String> {
        let path = self.config_path();
        match std::fs::read_to_string(&path) {
            Ok(text) if text.trim().is_empty() => Ok(MessagingConfig::default()),
            Ok(text) => {
                serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(MessagingConfig::default()),
            Err(e) => Err(format!("read {}: {e}", path.display())),
        }
    }

    /// Persist a full config atomically. Internal — all public mutators
    /// route through this so every write is atomic + durable.
    fn save(&self, cfg: &MessagingConfig) -> Result<(), String> {
        std::fs::create_dir_all(&self.base_dir)
            .map_err(|e| format!("create {}: {e}", self.base_dir.display()))?;
        let bytes = serde_json::to_vec_pretty(cfg).map_err(|e| e.to_string())?;
        atomic_write_sync(&self.config_path(), &bytes)
            .map_err(|e| format!("write {}: {e}", self.config_path().display()))
    }

    // ---- Read API (channel-parameterized; the orchestrator/adapters consume) ----

    /// Whether `channel` is enabled (its master opt-in flag).
    pub fn is_enabled_for(&self, channel: ChannelId) -> Result<bool, String> {
        Ok(self.load()?.channel(channel).enabled)
    }

    /// Back-compat: `is_enabled()` defaults to iMessage.
    pub fn is_enabled(&self) -> Result<bool, String> {
        self.is_enabled_for(ChannelId::IMessage)
    }

    /// `channel`'s current allowlist of approver handles.
    pub fn allowlist_for(&self, channel: ChannelId) -> Result<Vec<String>, String> {
        Ok(self.load()?.channel(channel).allowlisted_handles)
    }

    /// Back-compat: `allowlist()` defaults to iMessage.
    pub fn allowlist(&self) -> Result<Vec<String>, String> {
        self.allowlist_for(ChannelId::IMessage)
    }

    /// Whether `handle` is on `channel`'s allowlist. Compares the
    /// channel-NORMALIZED forms (iMessage: phone punctuation stripped, emails
    /// intact; Slack: member-ID form) so a hand-typed value matches the stored
    /// canonical form. The adapter calls this to drop non-paired senders before
    /// any parse (SC-7).
    pub fn is_allowlisted_for(&self, channel: ChannelId, handle: &str) -> Result<bool, String> {
        let want = normalize_handle(channel, handle);
        Ok(self
            .load()?
            .channel(channel)
            .allowlisted_handles
            .iter()
            .any(|h| normalize_handle(channel, h) == want))
    }

    /// Back-compat: `is_allowlisted(handle)` defaults to iMessage.
    pub fn is_allowlisted(&self, handle: &str) -> Result<bool, String> {
        self.is_allowlisted_for(ChannelId::IMessage, handle)
    }

    // ---- Privileged mutators (reachable ONLY from the gated WS surface
    //      or host-local code — NEVER from an inbound message) ----

    /// Set `channel`'s enabled flag. Host-gated path only.
    pub fn set_enabled_for(&self, channel: ChannelId, enabled: bool) -> Result<(), String> {
        let mut cfg = self.load()?;
        cfg.channel_mut(channel).enabled = enabled;
        self.save(&cfg)
    }

    /// Back-compat: `set_enabled(enabled)` defaults to iMessage.
    pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
        self.set_enabled_for(ChannelId::IMessage, enabled)
    }

    /// Replace `channel`'s entire allowlist. Host-gated path only. Handles are
    /// channel-normalized before storage.
    ///
    /// **v1 single-handle cardinality guard (per channel):** v1 supports exactly
    /// ONE paired/allowlisted user per channel (the orchestrator's "sole pending
    /// approval" logic is correct-by-invariant only under that constraint). A
    /// request to set MORE THAN ONE distinct handle is rejected rather than
    /// silently widening the trust set.
    pub fn set_allowlist_for(
        &self,
        channel: ChannelId,
        handles: Vec<String>,
    ) -> Result<(), String> {
        let normalized: Vec<String> =
            handles.iter().map(|h| normalize_handle(channel, h)).collect();
        let deduped = dedup_preserve_order(normalized);
        if deduped.len() > 1 {
            return Err(format!(
                "v1 supports a single allowlisted handle; refusing to set {} handles",
                deduped.len()
            ));
        }
        let mut cfg = self.load()?;
        cfg.channel_mut(channel).allowlisted_handles = deduped;
        self.save(&cfg)
    }

    /// Back-compat: `set_allowlist(handles)` defaults to iMessage.
    pub fn set_allowlist(&self, handles: Vec<String>) -> Result<(), String> {
        self.set_allowlist_for(ChannelId::IMessage, handles)
    }

    /// Add one handle to `channel`'s allowlist (idempotent). Host-gated path
    /// only. The handle is channel-normalized before storage and comparison.
    /// Returns `true` if newly added.
    ///
    /// **v1 single-handle cardinality guard (per channel):** if a DIFFERENT
    /// handle is already allowlisted on this channel, this is rejected — v1 binds
    /// exactly one paired user per channel. Re-adding the SAME handle is still
    /// the idempotent `Ok(false)` no-op.
    pub fn add_handle_for(&self, channel: ChannelId, handle: &str) -> Result<bool, String> {
        let normalized = normalize_handle(channel, handle);
        let mut cfg = self.load()?;
        let section = cfg.channel_mut(channel);
        if section
            .allowlisted_handles
            .iter()
            .any(|h| normalize_handle(channel, h) == normalized)
        {
            return Ok(false);
        }
        if !section.allowlisted_handles.is_empty() {
            return Err(
                "v1 supports a single allowlisted handle; remove the existing handle first"
                    .to_string(),
            );
        }
        section.allowlisted_handles.push(normalized);
        self.save(&cfg)?;
        Ok(true)
    }

    /// Back-compat: `add_handle(handle)` defaults to iMessage.
    pub fn add_handle(&self, handle: &str) -> Result<bool, String> {
        self.add_handle_for(ChannelId::IMessage, handle)
    }

    /// Remove one handle from `channel`'s allowlist (idempotent). Host-gated
    /// path only. Compares channel-normalized forms. Returns `true` if removed.
    pub fn remove_handle_for(&self, channel: ChannelId, handle: &str) -> Result<bool, String> {
        let target = normalize_handle(channel, handle);
        let mut cfg = self.load()?;
        let section = cfg.channel_mut(channel);
        let before = section.allowlisted_handles.len();
        section
            .allowlisted_handles
            .retain(|h| normalize_handle(channel, h) != target);
        let removed = section.allowlisted_handles.len() != before;
        if removed {
            self.save(&cfg)?;
        }
        Ok(removed)
    }

    /// Back-compat: `remove_handle(handle)` defaults to iMessage.
    pub fn remove_handle(&self, handle: &str) -> Result<bool, String> {
        self.remove_handle_for(ChannelId::IMessage, handle)
    }

    /// Mint a fresh pairing code for `channel`, persist it as that channel's
    /// active code, and return it for display in the local UI. Rotates any prior
    /// active code on that channel. Host-gated path only — shown ONLY in local
    /// UI, never produced from any inbound-derived value.
    pub fn mint_pairing_code_for(&self, channel: ChannelId) -> Result<String, String> {
        let mut cfg = self.load()?;
        let code = mint_pairing_code();
        cfg.channel_mut(channel).active_pairing_code = Some(code.clone());
        self.save(&cfg)?;
        Ok(code)
    }

    /// Back-compat: `mint_pairing_code()` defaults to iMessage.
    pub fn mint_pairing_code(&self) -> Result<String, String> {
        self.mint_pairing_code_for(ChannelId::IMessage)
    }

    /// `channel`'s active pairing code, if a pairing is in flight. Host-gated
    /// read (status surface).
    pub fn active_pairing_code_for(&self, channel: ChannelId) -> Result<Option<String>, String> {
        Ok(self.load()?.channel(channel).active_pairing_code)
    }

    /// Back-compat: `active_pairing_code()` defaults to iMessage.
    pub fn active_pairing_code(&self) -> Result<Option<String>, String> {
        self.active_pairing_code_for(ChannelId::IMessage)
    }

    /// Persist `channel`'s keychain token REFERENCE (MC-9). Host-gated path
    /// only — the bearer values themselves are written to the OS keychain by
    /// the provisioning write path (`slack_adapter::provision_slack_tokens`);
    /// THIS stores only the key NAMES (a reference) into `messaging.json`, and
    /// its presence is the "tokens provisioned" marker. Never accepts a bearer
    /// value, so no `xoxb-`/`xapp-` can ever land on disk through this method.
    pub fn set_slack_token_ref_for(
        &self,
        channel: ChannelId,
        token_ref: SlackTokenRef,
    ) -> Result<(), String> {
        let mut cfg = self.load()?;
        cfg.channel_mut(channel).slack_token_ref = Some(token_ref);
        self.save(&cfg)
    }

    /// `channel`'s persisted keychain token reference, if its tokens have been
    /// provisioned. Host-gated read (the boot path reads this to construct the
    /// live transport from the persisted refs). `None` ⇒ not yet provisioned.
    pub fn slack_token_ref_for(
        &self,
        channel: ChannelId,
    ) -> Result<Option<SlackTokenRef>, String> {
        Ok(self.load()?.channel(channel).slack_token_ref)
    }

    /// Persist `channel`'s Slack post-channel id (the conversation/channel id
    /// the outbound prompt posts into, e.g. `C0123…`). Host-gated path only.
    /// This is CONFIG, not a secret — it lands in `messaging.json`, never the
    /// keychain. Set on the same host-gated `messaging.config.set` call as the
    /// tokens.
    pub fn set_slack_channel_id_for(
        &self,
        channel: ChannelId,
        channel_id: &str,
    ) -> Result<(), String> {
        let mut cfg = self.load()?;
        cfg.channel_mut(channel).slack_channel_id = Some(channel_id.to_string());
        self.save(&cfg)
    }

    /// `channel`'s persisted Slack post-channel id, if set. The boot path reads
    /// this to construct the adapter with the channel to post into (NOT the
    /// never-written keychain key). `None` ⇒ no post-channel configured (the
    /// adapter is built but cannot post).
    pub fn slack_channel_id_for(
        &self,
        channel: ChannelId,
    ) -> Result<Option<String>, String> {
        Ok(self.load()?.channel(channel).slack_channel_id)
    }

    // ---- Inbound-reachable mutation: pairing only ----

    /// Validate an inbound-echoed pairing `code` from `candidate_handle` on
    /// `channel`, using a CONSTANT-TIME compare against that channel's active
    /// code. On a match, bind `candidate_handle` into the channel's allowlist
    /// and clear/rotate the code; on a miss (or no active code) mutate NOTHING.
    ///
    /// This is the ONLY mutation an inbound message can ever cause, and it can
    /// only ever *add a handle* to one channel — never set the enabled flag,
    /// never set the allowlist wholesale, never read config. A bare
    /// "approve"/"deny" or arbitrary text never reaches here.
    pub fn validate_and_consume_pairing_code_for(
        &self,
        channel: ChannelId,
        candidate_handle: &str,
        code: &str,
    ) -> Result<PairingOutcome, String> {
        let mut cfg = self.load()?;
        let section = cfg.channel_mut(channel);
        let Some(active) = section.active_pairing_code.as_deref() else {
            return Ok(PairingOutcome::Rejected);
        };
        if !constant_time_eq(active.as_bytes(), code.as_bytes()) {
            return Ok(PairingOutcome::Rejected);
        }
        // Match: bind the channel-NORMALIZED handle (idempotent) and clear the
        // code. The v1 single-handle invariant holds here too — pairing binds the
        // first (and only) paired user; if a different handle is somehow already
        // present we leave the allowlist as-is rather than widening it, but still
        // consume the code (the match proved control of the handle).
        let normalized = normalize_handle(channel, candidate_handle);
        let already_present = section
            .allowlisted_handles
            .iter()
            .any(|h| normalize_handle(channel, h) == normalized);
        if !already_present && section.allowlisted_handles.is_empty() {
            section.allowlisted_handles.push(normalized);
        }
        section.active_pairing_code = None;
        self.save(&cfg)?;
        Ok(PairingOutcome::Bound)
    }

    /// Back-compat: `validate_and_consume_pairing_code(handle, code)` defaults
    /// to iMessage.
    pub fn validate_and_consume_pairing_code(
        &self,
        candidate_handle: &str,
        code: &str,
    ) -> Result<PairingOutcome, String> {
        self.validate_and_consume_pairing_code_for(ChannelId::IMessage, candidate_handle, code)
    }
}

/// De-duplicate handles while preserving first-seen order. Keeps the
/// allowlist stable for display and avoids a HashSet reorder surprise.
fn dedup_preserve_order(handles: Vec<String>) -> Vec<String> {
    let mut seen = std::collections::HashSet::new();
    handles
        .into_iter()
        .filter(|h| seen.insert(h.clone()))
        .collect()
}

/// Normalize a handle for storage and comparison, **dispatched by channel**
/// (`identity-shape` call). Each channel's allowlist trust gate normalizes its
/// own identity form; reusing one channel's normalizer for another would
/// silently mis-normalize IDs at store and compare time — a security-relevant
/// allowlist-matching bug, not a style nit.
///
/// - **iMessage** (byte-for-byte unchanged from #403): a hand-typed phone number
///   like `+1 555-123-4567` or `(555) 123-4567` must match chat.db's canonical
///   `+15551234567`. Only phone-SHAPED handles are touched: a handle containing
///   `@` is an Apple-ID email, returned UNCHANGED (emails are case/format
///   sensitive). Everything else has ASCII whitespace and the common phone
///   punctuation (`-`, `(`, `)`, `.`) stripped, leaving a leading `+` and digits.
/// - **Slack**: a Slack member/user ID (`U…`/`W…`). Trim surrounding whitespace
///   and a possible `@` mention prefix; otherwise leave the ID intact (Slack IDs
///   are opaque, case-sensitive tokens — stripping punctuation would corrupt
///   them). The Slack adapter (Unit 4) feeds member IDs through this branch.
pub fn normalize_handle(channel: ChannelId, handle: &str) -> String {
    match channel {
        ChannelId::IMessage => normalize_imessage_handle(handle),
        ChannelId::Slack => normalize_slack_handle(handle),
    }
}

/// iMessage handle normalization — byte-for-byte the #403 `normalize_handle`.
fn normalize_imessage_handle(handle: &str) -> String {
    // Apple-ID emails: leave fully intact.
    if handle.contains('@') {
        return handle.trim().to_string();
    }
    handle
        .chars()
        .filter(|c| !c.is_whitespace() && !matches!(c, '-' | '(' | ')' | '.'))
        .collect()
}

/// Slack handle normalization — a member/user ID is an opaque, case-sensitive
/// token. Trim whitespace and a leading `@` mention sigil; do NOT strip interior
/// characters (that would corrupt the ID). Keep it simple — the Slack adapter
/// lands in Unit 4, but the dispatch + branch must exist now.
fn normalize_slack_handle(handle: &str) -> String {
    handle.trim().trim_start_matches('@').to_string()
}

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

    fn store() -> (TempDir, MessagingConfigStore) {
        let dir = TempDir::new().expect("tempdir");
        let store = MessagingConfigStore::with_base_dir(dir.path());
        (dir, store)
    }

    /// SC-8: a minted code binds EXACTLY the sending handle; a WRONG
    /// code binds nothing; the validator uses the constant-time compare.
    /// Also proves SC-2-style durability: the bound handle survives a
    /// reload from disk. (Drives the iMessage-default back-compat surface.)
    #[test]
    fn pairing_code_proven_constant_time() {
        let (dir, store) = store();

        // Empty to start.
        assert!(store.allowlist().unwrap().is_empty());

        // Mint a code (daemon-side, local UI only).
        let code = store.mint_pairing_code().unwrap();
        assert_eq!(code.len(), 43, "base64url-no-pad of 32 bytes is 43 chars");
        assert_eq!(
            store.active_pairing_code().unwrap().as_deref(),
            Some(code.as_str())
        );

        // A WRONG code binds nothing — allowlist unchanged, code still active.
        let wrong = "not-the-code-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
        assert_eq!(wrong.len(), code.len(), "exercise the equal-length miss path");
        let outcome = store
            .validate_and_consume_pairing_code("+15550000000", wrong)
            .unwrap();
        assert_eq!(outcome, PairingOutcome::Rejected);
        assert!(
            store.allowlist().unwrap().is_empty(),
            "wrong code must bind nothing"
        );
        assert!(
            store.active_pairing_code().unwrap().is_some(),
            "wrong code must not consume the active code"
        );

        // A different-length wrong code also rejects (constant_time_eq
        // length guard) and binds nothing.
        let outcome = store
            .validate_and_consume_pairing_code("+15550000000", "short")
            .unwrap();
        assert_eq!(outcome, PairingOutcome::Rejected);
        assert!(store.allowlist().unwrap().is_empty());

        // The EXACT minted code binds EXACTLY the sending handle.
        let outcome = store
            .validate_and_consume_pairing_code("+15551234567", &code)
            .unwrap();
        assert_eq!(outcome, PairingOutcome::Bound);
        assert_eq!(
            store.allowlist().unwrap(),
            vec!["+15551234567".to_string()],
            "exactly the sending handle is bound — no others"
        );
        // Code is cleared/rotated after a successful bind.
        assert!(store.active_pairing_code().unwrap().is_none());

        // Re-echoing the now-consumed code binds nothing further.
        let outcome = store
            .validate_and_consume_pairing_code("+15559999999", &code)
            .unwrap();
        assert_eq!(outcome, PairingOutcome::Rejected);
        assert_eq!(
            store.allowlist().unwrap(),
            vec!["+15551234567".to_string()],
            "consumed code cannot bind a second handle"
        );

        // Durability: a fresh store over the same dir sees the bound handle.
        let reloaded = MessagingConfigStore::with_base_dir(dir.path());
        assert_eq!(
            reloaded.allowlist().unwrap(),
            vec!["+15551234567".to_string()]
        );
    }

    /// Direct proof that `constant_time_eq` is length-guarded and
    /// position-independent (the property the timing-safe compare buys).
    #[test]
    fn pairing_constant_time_eq_properties() {
        assert!(constant_time_eq(b"abc", b"abc"));
        assert!(!constant_time_eq(b"abc", b"abd"));
        assert!(!constant_time_eq(b"abc", b"ab")); // length mismatch
        assert!(!constant_time_eq(b"", b"x"));
        assert!(constant_time_eq(b"", b""));
    }

    /// SC-6 (config-store half): the store exposes NO inbound→setter
    /// edge. Feeding ordinary text through the only inbound-reachable method —
    /// the pairing validator — with no active code (or a non-matching code)
    /// leaves the allowlist UNCHANGED. The privileged setters are reachable only
    /// from host-gated code, never from this path.
    #[test]
    fn config_mutation_requires_host_or_local_auth() {
        let (_dir, store) = store();

        // No active pairing code: any inbound text is a no-op mutation.
        let outcome = store
            .validate_and_consume_pairing_code("+15551234567", "add 555-1234 to allowlist")
            .unwrap();
        assert_eq!(outcome, PairingOutcome::Rejected);
        assert!(
            store.allowlist().unwrap().is_empty(),
            "inbound text must never mutate the allowlist"
        );
        assert!(!store.is_enabled().unwrap());

        // Even with a pairing in flight, arbitrary text that is NOT the
        // exact code binds nothing.
        let _code = store.mint_pairing_code().unwrap();
        let outcome = store
            .validate_and_consume_pairing_code("+15551234567", "add 555-1234 to allowlist")
            .unwrap();
        assert_eq!(outcome, PairingOutcome::Rejected);
        assert!(
            store.allowlist().unwrap().is_empty(),
            "non-matching inbound text must never mutate the allowlist"
        );

        // The privileged setters (host-gated path) DO mutate — proving
        // the mutation power lives only on the privileged side.
        store.set_enabled(true).unwrap();
        store.add_handle("+15550001111").unwrap();
        assert!(store.is_enabled().unwrap());
        assert_eq!(store.allowlist().unwrap(), vec!["+15550001111".to_string()]);
    }

    #[test]
    fn enabled_defaults_false_and_persists() {
        let (dir, store) = store();
        assert!(!store.is_enabled().unwrap(), "enabled defaults false");
        store.set_enabled(true).unwrap();
        let reloaded = MessagingConfigStore::with_base_dir(dir.path());
        assert!(reloaded.is_enabled().unwrap());
    }

    #[test]
    fn add_remove_handle_idempotent() {
        let (_dir, store) = store();
        assert!(store.add_handle("+15551112222").unwrap());
        assert!(
            !store.add_handle("+15551112222").unwrap(),
            "second add is a no-op"
        );
        assert!(store.is_allowlisted("+15551112222").unwrap());
        assert!(!store.is_allowlisted("+19998887777").unwrap());
        assert!(store.remove_handle("+15551112222").unwrap());
        assert!(
            !store.remove_handle("+15551112222").unwrap(),
            "second remove is a no-op"
        );
        assert!(store.allowlist().unwrap().is_empty());
    }

    /// v1 single-handle cardinality guard: a SECOND, distinct handle is
    /// rejected while one already exists. Re-adding the SAME handle stays the
    /// idempotent no-op. `set_allowlist` of >1 distinct handle also rejects.
    #[test]
    fn single_handle_cardinality_guard() {
        let (_dir, store) = store();

        // First handle binds.
        assert!(store.add_handle("+15551112222").unwrap());

        // A DIFFERENT second handle is REJECTED (error, not a silent widen).
        let err = store.add_handle("+19998887777").unwrap_err();
        assert!(
            err.contains("single allowlisted handle"),
            "second distinct add must be rejected, got: {err}"
        );
        // The allowlist still holds exactly the first handle.
        assert_eq!(store.allowlist().unwrap(), vec!["+15551112222".to_string()]);

        // Re-adding the SAME handle is still the idempotent no-op (Ok(false)).
        assert!(!store.add_handle("+15551112222").unwrap());

        // set_allowlist of >1 distinct handle rejects.
        let err = store
            .set_allowlist(vec!["+15551112222".into(), "+19998887777".into()])
            .unwrap_err();
        assert!(
            err.contains("single allowlisted handle"),
            "set_allowlist of 2 handles must reject, got: {err}"
        );

        // After removing the existing handle, a new one can bind.
        assert!(store.remove_handle("+15551112222").unwrap());
        assert!(store.add_handle("+19998887777").unwrap());
        assert_eq!(store.allowlist().unwrap(), vec!["+19998887777".to_string()]);
    }

    /// Handle normalization: a hand-typed punctuated phone number stored via
    /// `add_handle` matches chat.db's canonical digits-only form in
    /// `is_allowlisted`, and vice versa. Apple-ID emails are left intact.
    /// (iMessage branch — byte-for-byte unchanged after channel dispatch.)
    #[test]
    fn handle_normalization_phone_matches_email_intact() {
        let (dir, phone_store) = store();
        let _dir = dir;

        // Store a hand-typed, punctuated number.
        assert!(phone_store.add_handle("+1 555-123-4567").unwrap());
        // It is stored in canonical (stripped) form.
        assert_eq!(
            phone_store.allowlist().unwrap(),
            vec!["+15551234567".to_string()]
        );
        // chat.db's canonical form matches.
        assert!(phone_store.is_allowlisted("+15551234567").unwrap());
        // Differently-punctuated styles of the same number also match.
        assert!(
            phone_store.is_allowlisted("+1 (555) 123.4567").unwrap(),
            "differently-punctuated same number must match"
        );
        // A different number does not match.
        assert!(!phone_store.is_allowlisted("+15559998888").unwrap());

        // Apple-ID emails are NOT stripped — stored and matched verbatim.
        let (_dir2, email_store) = store();
        assert!(email_store.add_handle("alice@icloud.com").unwrap());
        assert_eq!(
            email_store.allowlist().unwrap(),
            vec!["alice@icloud.com".to_string()]
        );
        assert!(email_store.is_allowlisted("alice@icloud.com").unwrap());
        assert!(!email_store.is_allowlisted("aliceicloud.com").unwrap());
    }

    /// MC-5 (store level): a fresh store shows BOTH channels disabled, and
    /// enabling one channel does NOT flip the other (channels are independent).
    #[test]
    fn both_channels_default_off_and_independent() {
        let (_dir, store) = store();
        // Fresh store: both channels off.
        assert!(!store.is_enabled_for(ChannelId::IMessage).unwrap());
        assert!(!store.is_enabled_for(ChannelId::Slack).unwrap());

        // Enable Slack only — iMessage stays off.
        store.set_enabled_for(ChannelId::Slack, true).unwrap();
        assert!(store.is_enabled_for(ChannelId::Slack).unwrap());
        assert!(
            !store.is_enabled_for(ChannelId::IMessage).unwrap(),
            "enabling Slack must not flip iMessage"
        );

        // Enable iMessage too — both on now (the "both at once" multi-select).
        store.set_enabled_for(ChannelId::IMessage, true).unwrap();
        assert!(store.is_enabled_for(ChannelId::IMessage).unwrap());
        assert!(store.is_enabled_for(ChannelId::Slack).unwrap());
    }

    /// Per-channel allowlists are independent: a handle paired on one channel
    /// is NOT allowlisted on the other, and the Slack branch normalizes IDs
    /// without phone-stripping.
    #[test]
    fn per_channel_allowlists_independent_and_slack_normalizes() {
        let (_dir, store) = store();
        store
            .add_handle_for(ChannelId::IMessage, "+15551112222")
            .unwrap();
        store
            .add_handle_for(ChannelId::Slack, "U012ABCDEF")
            .unwrap();

        // Each handle is allowlisted only on its own channel.
        assert!(store
            .is_allowlisted_for(ChannelId::IMessage, "+15551112222")
            .unwrap());
        assert!(!store
            .is_allowlisted_for(ChannelId::Slack, "+15551112222")
            .unwrap());
        assert!(store
            .is_allowlisted_for(ChannelId::Slack, "U012ABCDEF")
            .unwrap());
        assert!(!store
            .is_allowlisted_for(ChannelId::IMessage, "U012ABCDEF")
            .unwrap());

        // Slack normalization trims a leading @ mention but keeps the ID intact
        // (no phone punctuation stripping that would corrupt an opaque ID).
        assert!(store
            .is_allowlisted_for(ChannelId::Slack, "@U012ABCDEF")
            .unwrap());
        assert_eq!(
            store.allowlist_for(ChannelId::Slack).unwrap(),
            vec!["U012ABCDEF".to_string()]
        );
    }

    /// MC-4 / MC-5 durability: a `messaging.json` carrying BOTH channels
    /// populated (each enabled + a paired handle) round-trips intact through a
    /// drop-and-reload — neither channel's state is lost or cross-contaminated.
    /// Proves the single-file, per-channel `BTreeMap` persistence holds for the
    /// multi-channel case, not just the iMessage-only default.
    #[test]
    fn multi_channel_state_survives_reload() {
        let dir = TempDir::new().expect("tempdir");

        // Populate BOTH channels via the host-gated mutators, then drop the store.
        {
            let store = MessagingConfigStore::with_base_dir(dir.path());
            store.set_enabled_for(ChannelId::IMessage, true).unwrap();
            store
                .add_handle_for(ChannelId::IMessage, "+15551112222")
                .unwrap();
            store.set_enabled_for(ChannelId::Slack, true).unwrap();
            store
                .add_handle_for(ChannelId::Slack, "U012ABCDEF")
                .unwrap();
        } // store dropped here — only the on-disk messaging.json remains.

        // A fresh store loaded from the same path sees BOTH channels intact.
        let reloaded = MessagingConfigStore::with_base_dir(dir.path());
        assert!(
            reloaded.is_enabled_for(ChannelId::IMessage).unwrap(),
            "iMessage enabled flag must survive reload"
        );
        assert_eq!(
            reloaded.allowlist_for(ChannelId::IMessage).unwrap(),
            vec!["+15551112222".to_string()],
            "iMessage handle must survive reload"
        );
        assert!(
            reloaded.is_enabled_for(ChannelId::Slack).unwrap(),
            "Slack enabled flag must survive reload"
        );
        assert_eq!(
            reloaded.allowlist_for(ChannelId::Slack).unwrap(),
            vec!["U012ABCDEF".to_string()],
            "Slack handle must survive reload"
        );
    }

    /// MC-4 / MC-5: the v1 single-handle cardinality guard fires INDEPENDENTLY
    /// per channel. iMessage holding its one handle does NOT consume Slack's
    /// slot — Slack can still bind its first handle — and the guard still
    /// rejects a SECOND distinct handle on each channel separately.
    #[test]
    fn single_handle_cardinality_guard_is_per_channel() {
        let (_dir, store) = store();

        // iMessage binds its one handle.
        assert!(store
            .add_handle_for(ChannelId::IMessage, "+15551112222")
            .unwrap());

        // A SECOND distinct iMessage handle is rejected — guard fires on iMessage.
        let err = store
            .add_handle_for(ChannelId::IMessage, "+19998887777")
            .unwrap_err();
        assert!(
            err.contains("single allowlisted handle"),
            "second iMessage handle must be rejected, got: {err}"
        );

        // Slack's slot is UNAFFECTED by iMessage being full: Slack binds its first.
        assert!(
            store.add_handle_for(ChannelId::Slack, "U012ABCDEF").unwrap(),
            "iMessage being full must not consume Slack's slot"
        );

        // The guard ALSO fires independently on Slack: a second Slack handle rejects.
        let err = store
            .add_handle_for(ChannelId::Slack, "U999ZZZZZZ")
            .unwrap_err();
        assert!(
            err.contains("single allowlisted handle"),
            "second Slack handle must be rejected, got: {err}"
        );

        // Each channel still holds exactly its own one handle.
        assert_eq!(
            store.allowlist_for(ChannelId::IMessage).unwrap(),
            vec!["+15551112222".to_string()]
        );
        assert_eq!(
            store.allowlist_for(ChannelId::Slack).unwrap(),
            vec!["U012ABCDEF".to_string()]
        );
    }

    /// Security-critical fail-closed load: a corrupt/garbage `messaging.json`
    /// makes `load()` return `Err` — it does NOT silently reset to a default
    /// (which would be a trust-store downgrade, dropping the operator's
    /// allowlist on the floor). The empty-file and absent-file cases still
    /// resolve to the fail-closed default; only malformed CONTENT errors.
    #[test]
    fn load_fails_closed_on_malformed_json() {
        let dir = TempDir::new().expect("tempdir");
        let store = MessagingConfigStore::with_base_dir(dir.path());

        // Write garbage to the config path the store reads from.
        let path = store.config_path();
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(&path, b"{ this is not valid json at all ]]]").unwrap();

        // load() must surface an Err, NOT a silent default/reset.
        let result = store.load();
        assert!(
            result.is_err(),
            "malformed messaging.json must fail closed (Err), not reset to default; got {result:?}"
        );

        // Sanity: the empty-file path still resolves to the fail-closed default
        // (so this is specifically a malformed-CONTENT guard, not a blanket
        // "any read errors" — empty/absent are legitimately the first-boot case).
        std::fs::write(&path, b"   \n").unwrap();
        assert!(
            !store.load().unwrap().channel(ChannelId::IMessage).enabled,
            "empty file resolves to fail-closed default, not an error"
        );
    }

    /// MC-13 (store level): cross-channel pairing isolation. A code minted for
    /// ONE channel does NOT validate/consume on the OTHER channel — a Slack code
    /// echoed as an iMessage pairing binds nothing on iMessage, and vice-versa.
    /// Each channel's active code is its own slot.
    #[test]
    fn pairing_code_does_not_cross_channels() {
        let (_dir, store) = store();

        // Mint a code on Slack only.
        let slack_code = store.mint_pairing_code_for(ChannelId::Slack).unwrap();
        // iMessage has no active code.
        assert!(store
            .active_pairing_code_for(ChannelId::IMessage)
            .unwrap()
            .is_none());

        // Echoing the SLACK code on the IMESSAGE channel binds NOTHING (iMessage
        // has no active code, and the Slack code is not its secret).
        let outcome = store
            .validate_and_consume_pairing_code_for(
                ChannelId::IMessage,
                "+15551234567",
                &slack_code,
            )
            .unwrap();
        assert_eq!(outcome, PairingOutcome::Rejected);
        assert!(
            store.allowlist_for(ChannelId::IMessage).unwrap().is_empty(),
            "a Slack-minted code must bind nothing on iMessage"
        );
        // The Slack code is untouched — it was never the iMessage channel's code.
        assert_eq!(
            store
                .active_pairing_code_for(ChannelId::Slack)
                .unwrap()
                .as_deref(),
            Some(slack_code.as_str()),
            "the Slack code must not be consumed by an iMessage validate attempt"
        );

        // The SAME Slack code on the SLACK channel DOES bind (it is Slack's code).
        let outcome = store
            .validate_and_consume_pairing_code_for(ChannelId::Slack, "U012ABCDEF", &slack_code)
            .unwrap();
        assert_eq!(outcome, PairingOutcome::Bound);
        assert_eq!(
            store.allowlist_for(ChannelId::Slack).unwrap(),
            vec!["U012ABCDEF".to_string()]
        );

        // Symmetric direction: mint on iMessage, echo on Slack → nothing bound.
        let imsg_code = store.mint_pairing_code_for(ChannelId::IMessage).unwrap();
        let outcome = store
            .validate_and_consume_pairing_code_for(ChannelId::Slack, "U999ZZZZZZ", &imsg_code)
            .unwrap();
        assert_eq!(outcome, PairingOutcome::Rejected);
        // Slack's allowlist still holds only its first handle; no second bound.
        assert_eq!(
            store.allowlist_for(ChannelId::Slack).unwrap(),
            vec!["U012ABCDEF".to_string()],
            "an iMessage-minted code must bind nothing on Slack"
        );
    }
}
