use crate::api::compute_contract_key;
use anyhow::{anyhow, Result};
use directories::ProjectDirs;
use ed25519_dalek::{SigningKey, VerifyingKey};
use freenet_stdlib::prelude::ContractKey;
use river_core::chat_delegate::OutboundDmStore;
use river_core::room_state::member::AuthorizedMember;
use river_core::room_state::ChatRoomStateV1;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use tracing::info;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredRoomInfo {
    pub signing_key_bytes: [u8; 32],
    pub state: ChatRoomStateV1,
    pub contract_key: String, // Store as string for serialization
    /// The user's own AuthorizedMember, stored so they can re-add themselves
    /// after being pruned for inactivity (no recent messages).
    #[serde(default)]
    pub self_authorized_member: Option<AuthorizedMember>,
    /// The invite chain members needed to validate self_authorized_member.
    #[serde(default)]
    pub invite_chain: Vec<AuthorizedMember>,
    /// The previous contract key before WASM update, used for migration fallback.
    /// When the bundled WASM changes, this stores the old contract key so
    /// any client can GET state from the old contract and PUT it to the new one.
    #[serde(default)]
    pub previous_contract_key: Option<String>,
    /// Room secrets received via the `Invitation` artifact (issue freenet/river#302).
    /// Maps `secret_version` → 32-byte symmetric key. Empty for public rooms
    /// and for rooms joined before this field existed (`#[serde(default)]`
    /// keeps old `rooms.json` files readable).
    ///
    /// **Current consumers** (CLI):
    /// - `create_invitation` — folds these into `Invitation::room_secrets`
    ///   so the next invitee can decrypt the room immediately on join.
    /// - `accept_invitation` — seeds this map from a freshly-accepted
    ///   `Invitation::room_secrets` so the CLI persists the secret across
    ///   invocations.
    ///
    /// **Not yet consumed** for message/nickname decryption — the CLI has no
    /// private-room-decrypt path today, so this map is currently used only
    /// to forward secrets onward via new invitations. A CLI message-decrypt
    /// counterpart is the natural follow-up after freenet/river#304 (the
    /// CLI heal path); until then, do not assume reading from this map
    /// gates anything other than invitation forwarding.
    ///
    /// **Authority.** The owner-signed contract blob in
    /// `state.secrets.encrypted_secrets` is authoritative and supersedes an
    /// invitation-carried entry at the same version — mirrors the UI's
    /// `RoomData::invitation_secrets` semantics. NOTE: unlike the UI's
    /// `repopulate_secrets_from_state` (which prunes the
    /// invitation-carried entry once the owner blob arrives), the CLI does
    /// NOT prune. Storage waste only — see freenet/river#304 for the heal
    /// path that would naturally hook the prune.
    ///
    /// **Threat model.** Plaintext on disk, consistent with `signing_key_bytes`
    /// and the outbound-DM cache. Protected by filesystem permissions and
    /// whatever full-disk encryption the user has configured.
    #[serde(default)]
    pub invitation_secrets: HashMap<u32, [u8; 32]>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RoomStorage {
    /// Map from room owner verifying key (as base58) to room info
    pub rooms: HashMap<String, StoredRoomInfo>,
}

pub struct Storage {
    storage_path: PathBuf,
    /// Outbound-DM plaintext cache file (issue freenet/river#256).
    /// Side file so the larger `rooms.json` blob stays untouched on
    /// each DM send. JSON-serialized [`OutboundDmStore`].
    outbound_dms_path: PathBuf,
    /// In-memory signing-key override (from `--signing-key-file` flag or
    /// `RIVER_SIGNING_KEY_FILE` env var). When set, every call to
    /// [`Storage::get_room`] returns this key in place of the room's
    /// stored `signing_key_bytes`. Never written back to disk — the
    /// override is a per-command-invocation thing.
    ///
    /// Motivates: this machine often has multiple identities for the
    /// same room (room owner, invite bot, test alts), but `rooms.json`
    /// only stores ONE `signing_key_bytes` per room. The UI's chat-
    /// delegate sync can silently rewrite that field — leaving owner
    /// ops broken without a manual swap. The override lets owner ops
    /// nominate the right identity at command time without touching
    /// `rooms.json`. See discussion on river#281.
    signing_key_override: Option<SigningKey>,
}

impl Storage {
    pub fn new(config_dir: Option<&str>) -> Result<Self> {
        Self::new_with_override(config_dir, None)
    }

    /// Construct a [`Storage`] with an optional in-memory signing-key
    /// override. See the field doc on [`Storage::signing_key_override`].
    pub fn new_with_override(
        config_dir: Option<&str>,
        signing_key_override: Option<SigningKey>,
    ) -> Result<Self> {
        // Use provided config_dir, then check environment variable, then use default
        let data_dir = if let Some(dir) = config_dir {
            PathBuf::from(dir)
        } else if let Ok(config_dir) = std::env::var("RIVER_CONFIG_DIR") {
            PathBuf::from(config_dir)
        } else {
            // Fall back to default project directories
            let proj_dirs = ProjectDirs::from("", "Freenet", "River")
                .ok_or_else(|| anyhow!("Failed to determine project directories"))?;
            proj_dirs.data_dir().to_path_buf()
        };

        fs::create_dir_all(&data_dir)?;

        let storage_path = data_dir.join("rooms.json");
        let outbound_dms_path = data_dir.join("outbound_dms.json");

        Ok(Self {
            storage_path,
            outbound_dms_path,
            signing_key_override,
        })
    }

    /// Resolve the signing key to use for the current command: prefer
    /// the in-memory override if set, otherwise reconstruct from the
    /// per-room `signing_key_bytes`. Used by both [`Storage::get_room`]
    /// and [`crate::api::ApiClient::ensure_room_migrated`] (which has
    /// its own load_rooms snapshot for migration purposes).
    pub fn resolve_signing_key(&self, stored_bytes: &[u8; 32]) -> SigningKey {
        if let Some(override_key) = &self.signing_key_override {
            override_key.clone()
        } else {
            SigningKey::from_bytes(stored_bytes)
        }
    }

    pub fn load_rooms(&self) -> Result<RoomStorage> {
        if !self.storage_path.exists() {
            return Ok(RoomStorage::default());
        }

        let contents = fs::read_to_string(&self.storage_path)?;
        let mut storage: RoomStorage = serde_json::from_str(&contents)?;

        // Regenerate contract keys to ensure they match the current bundled WASM
        // This handles the case where rooms were stored with an older WASM version
        let mut updated = false;
        for (owner_key_str, room_info) in storage.rooms.iter_mut() {
            let owner_key_bytes = match bs58::decode(owner_key_str).into_vec() {
                Ok(bytes) if bytes.len() == 32 => {
                    let mut arr = [0u8; 32];
                    arr.copy_from_slice(&bytes);
                    arr
                }
                _ => continue,
            };
            let owner_vk = match VerifyingKey::from_bytes(&owner_key_bytes) {
                Ok(vk) => vk,
                Err(_) => continue,
            };
            let new_key = compute_contract_key(&owner_vk);
            let new_key_str = new_key.id().to_string();
            if room_info.contract_key != new_key_str {
                info!(
                    "Updating contract key for room: {} -> {}",
                    room_info.contract_key, new_key_str
                );
                // Save old key for migration fallback before overwriting
                room_info.previous_contract_key = Some(room_info.contract_key.clone());
                room_info.contract_key = new_key_str;
                updated = true;
            }
        }

        // Save the updated storage if any keys changed
        if updated {
            self.save_rooms(&storage)?;
        }

        Ok(storage)
    }

    pub fn save_rooms(&self, storage: &RoomStorage) -> Result<()> {
        let contents = serde_json::to_string_pretty(storage)?;
        fs::write(&self.storage_path, contents)?;
        Ok(())
    }

    /// Load the local outbound-DM plaintext cache (issue
    /// freenet/river#256). Returns an empty store if the file does
    /// not exist; surfaces any other I/O or parse error.
    pub fn load_outbound_dms(&self) -> Result<OutboundDmStore> {
        if !self.outbound_dms_path.exists() {
            return Ok(OutboundDmStore::default());
        }
        let contents = fs::read_to_string(&self.outbound_dms_path)?;
        let store: OutboundDmStore = serde_json::from_str(&contents)?;
        Ok(store)
    }

    /// Persist the outbound-DM plaintext cache to disk.
    ///
    /// **Threat model note (#256 / #259 review).** This file is
    /// plaintext on disk — consistent with `rooms.json`, which also
    /// stores room signing keys and member state unencrypted. Both
    /// are protected by filesystem permissions on the user's data
    /// directory and by whatever full-disk encryption the user has
    /// configured. The UI path uses the chat delegate, whose secret
    /// store IS encrypted at rest; the CLI does NOT have an
    /// equivalent yet.
    pub fn save_outbound_dms(&self, store: &OutboundDmStore) -> Result<()> {
        let contents = serde_json::to_string_pretty(store)?;
        fs::write(&self.outbound_dms_path, contents)?;
        Ok(())
    }

    pub fn add_room(
        &self,
        owner_vk: &VerifyingKey,
        signing_key: &SigningKey,
        state: ChatRoomStateV1,
        contract_key: &ContractKey,
    ) -> Result<()> {
        self.add_room_with_invitation_secrets(
            owner_vk,
            signing_key,
            state,
            contract_key,
            HashMap::new(),
        )
    }

    /// Like [`Self::add_room`] but seeds the room's persisted
    /// `invitation_secrets` from an `Invitation` artifact (issue
    /// freenet/river#302). Used by `accept_invitation` so a CLI invitee can
    /// decrypt a private room on first read without waiting for the owner's
    /// chat-delegate to back-fill an `encrypted_secrets` blob.
    pub fn add_room_with_invitation_secrets(
        &self,
        owner_vk: &VerifyingKey,
        signing_key: &SigningKey,
        state: ChatRoomStateV1,
        contract_key: &ContractKey,
        invitation_secrets: HashMap<u32, [u8; 32]>,
    ) -> Result<()> {
        let mut storage = self.load_rooms()?;

        let owner_key_str = bs58::encode(owner_vk.as_bytes()).into_string();
        let room_info = StoredRoomInfo {
            signing_key_bytes: signing_key.to_bytes(),
            state,
            contract_key: contract_key.id().to_string(),
            self_authorized_member: None,
            invite_chain: Vec::new(),
            previous_contract_key: None,
            invitation_secrets,
        };

        storage.rooms.insert(owner_key_str, room_info);
        self.save_rooms(&storage)?;

        Ok(())
    }

    /// Return the persisted invitation-carried secrets for a room, keyed by
    /// `secret_version`. Returns an empty map for rooms that predate
    /// freenet/river#302 (the `#[serde(default)]` keeps loading safe) and for
    /// public rooms, where no secrets are carried. Used by `create_invitation`
    /// to populate the outgoing invitation's `room_secrets`.
    pub fn get_invitation_secrets(
        &self,
        owner_vk: &VerifyingKey,
    ) -> Result<HashMap<u32, [u8; 32]>> {
        let storage = self.load_rooms()?;
        let owner_key_str = bs58::encode(owner_vk.as_bytes()).into_string();
        Ok(storage
            .rooms
            .get(&owner_key_str)
            .map(|r| r.invitation_secrets.clone())
            .unwrap_or_default())
    }

    pub fn get_room(
        &self,
        owner_vk: &VerifyingKey,
    ) -> Result<Option<(SigningKey, ChatRoomStateV1, String)>> {
        let storage = self.load_rooms()?;
        let owner_key_str = bs58::encode(owner_vk.as_bytes()).into_string();

        if let Some(room_info) = storage.rooms.get(&owner_key_str) {
            let signing_key = self.resolve_signing_key(&room_info.signing_key_bytes);
            Ok(Some((
                signing_key,
                room_info.state.clone(),
                room_info.contract_key.clone(),
            )))
        } else {
            Ok(None)
        }
    }

    pub fn update_room_state(&self, owner_vk: &VerifyingKey, state: ChatRoomStateV1) -> Result<()> {
        let mut storage = self.load_rooms()?;
        let owner_key_str = bs58::encode(owner_vk.as_bytes()).into_string();

        if let Some(room_info) = storage.rooms.get_mut(&owner_key_str) {
            room_info.state = state;
            self.save_rooms(&storage)?;
            Ok(())
        } else {
            Err(anyhow!("Room not found"))
        }
    }

    /// Update the contract key for a room (used during migration to new contract version)
    pub fn update_contract_key(
        &self,
        owner_vk: &VerifyingKey,
        new_key: &ContractKey,
    ) -> Result<()> {
        let mut storage = self.load_rooms()?;
        let owner_key_str = bs58::encode(owner_vk.as_bytes()).into_string();

        if let Some(room_info) = storage.rooms.get_mut(&owner_key_str) {
            room_info.contract_key = new_key.id().to_string();
            self.save_rooms(&storage)?;
            Ok(())
        } else {
            Err(anyhow!("Room not found"))
        }
    }

    pub fn store_authorized_member(
        &self,
        owner_vk: &VerifyingKey,
        authorized_member: &AuthorizedMember,
        invite_chain: &[AuthorizedMember],
    ) -> Result<()> {
        let mut storage = self.load_rooms()?;
        let owner_key_str = bs58::encode(owner_vk.as_bytes()).into_string();
        if let Some(room_info) = storage.rooms.get_mut(&owner_key_str) {
            room_info.self_authorized_member = Some(authorized_member.clone());
            room_info.invite_chain = invite_chain.to_vec();
            self.save_rooms(&storage)?;
        }
        Ok(())
    }

    pub fn list_rooms(&self) -> Result<Vec<(VerifyingKey, String, String)>> {
        let storage = self.load_rooms()?;
        let mut rooms = Vec::new();

        for (owner_key_str, room_info) in storage.rooms.iter() {
            let owner_key_bytes = bs58::decode(owner_key_str).into_vec()?;
            if owner_key_bytes.len() == 32 {
                let mut key_array = [0u8; 32];
                key_array.copy_from_slice(&owner_key_bytes);
                if let Ok(owner_vk) = VerifyingKey::from_bytes(&key_array) {
                    let room_name = room_info
                        .state
                        .configuration
                        .configuration
                        .display
                        .name
                        .to_string_lossy();
                    rooms.push((owner_vk, room_name, room_info.contract_key.clone()));
                }
            }
        }

        Ok(rooms)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use ed25519_dalek::SigningKey;
    use river_core::room_state::configuration::{AuthorizedConfigurationV1, Configuration};
    use tempfile::TempDir;

    fn create_test_storage() -> (Storage, TempDir) {
        let temp_dir = TempDir::new().unwrap();
        let storage = Storage::new(Some(temp_dir.path().to_str().unwrap())).unwrap();
        (storage, temp_dir)
    }

    fn create_test_signing_key() -> SigningKey {
        SigningKey::from_bytes(&rand::Rng::gen::<[u8; 32]>(&mut rand::thread_rng()))
    }

    /// Compute the expected contract key for a given owner verifying key.
    /// This matches what load_rooms will regenerate.
    fn expected_contract_key(owner_vk: &VerifyingKey) -> ContractKey {
        compute_contract_key(owner_vk)
    }

    fn create_test_state(owner_sk: &SigningKey) -> ChatRoomStateV1 {
        let owner_vk = owner_sk.verifying_key();
        let mut state = ChatRoomStateV1::default();
        let config = Configuration {
            owner_member_id: owner_vk.into(),
            ..Default::default()
        };
        state.configuration = AuthorizedConfigurationV1::new(config, owner_sk);
        state
    }

    #[test]
    fn test_update_contract_key_success() {
        let (storage, _temp_dir) = create_test_storage();
        let owner_sk = create_test_signing_key();
        let owner_vk = owner_sk.verifying_key();
        let state = create_test_state(&owner_sk);
        let initial_key = expected_contract_key(&owner_vk);

        // Add room with the computed contract key
        storage
            .add_room(&owner_vk, &owner_sk, state, &initial_key)
            .unwrap();

        // Verify the key is stored correctly (will be regenerated on load)
        let (_, _, stored_key) = storage.get_room(&owner_vk).unwrap().unwrap();
        assert_eq!(stored_key, initial_key.id().to_string());

        // Create a different key for testing update
        let different_key = {
            let code = freenet_stdlib::prelude::ContractCode::from(vec![42u8; 100]);
            let params = freenet_stdlib::prelude::Parameters::from(vec![42u8]);
            ContractKey::from_params_and_code(params, &code)
        };

        // Update to different key
        storage
            .update_contract_key(&owner_vk, &different_key)
            .unwrap();

        // After reload, key will be regenerated to match current WASM, not the updated key
        // This tests that update_contract_key persists, but load_rooms regenerates
        let (_, _, stored_key) = storage.get_room(&owner_vk).unwrap().unwrap();
        // The key gets regenerated on load, so it will be the expected key
        assert_eq!(stored_key, initial_key.id().to_string());
    }

    #[test]
    fn test_update_contract_key_room_not_found() {
        let (storage, _temp_dir) = create_test_storage();
        let owner_sk = create_test_signing_key();
        let owner_vk = owner_sk.verifying_key();
        let new_key = expected_contract_key(&owner_vk);

        // Attempt to update non-existent room
        let result = storage.update_contract_key(&owner_vk, &new_key);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("Room not found"));
    }

    #[test]
    fn test_update_contract_key_preserves_state() {
        let (storage, _temp_dir) = create_test_storage();
        let owner_sk = create_test_signing_key();
        let owner_vk = owner_sk.verifying_key();
        let state = create_test_state(&owner_sk);
        let initial_key = expected_contract_key(&owner_vk);

        // Add room
        storage
            .add_room(&owner_vk, &owner_sk, state.clone(), &initial_key)
            .unwrap();

        // Create a different key for testing update
        let different_key = {
            let code = freenet_stdlib::prelude::ContractCode::from(vec![99u8; 100]);
            let params = freenet_stdlib::prelude::Parameters::from(vec![99u8]);
            ContractKey::from_params_and_code(params, &code)
        };

        // Update contract key
        storage
            .update_contract_key(&owner_vk, &different_key)
            .unwrap();

        // Verify state is preserved (key will be regenerated but state should remain)
        let (retrieved_sk, retrieved_state, _) = storage.get_room(&owner_vk).unwrap().unwrap();
        assert_eq!(retrieved_sk.to_bytes(), owner_sk.to_bytes());
        assert_eq!(
            retrieved_state.configuration.configuration.max_members,
            state.configuration.configuration.max_members
        );
    }

    #[test]
    fn test_load_rooms_sets_previous_contract_key_on_mismatch() {
        let (storage, _temp_dir) = create_test_storage();
        let owner_sk = create_test_signing_key();
        let owner_vk = owner_sk.verifying_key();
        let state = create_test_state(&owner_sk);

        // Store with a fake old contract key (simulating a WASM change)
        let fake_old_key = {
            let code = freenet_stdlib::prelude::ContractCode::from(vec![1u8; 100]);
            let params = freenet_stdlib::prelude::Parameters::from(vec![1u8]);
            ContractKey::from_params_and_code(params, &code)
        };
        storage
            .add_room(&owner_vk, &owner_sk, state, &fake_old_key)
            .unwrap();

        // Verify initial state: add_room stores the fake_old_key verbatim (the file
        // doesn't exist yet when load_rooms runs inside add_room, so no regeneration).
        let raw_storage: RoomStorage =
            serde_json::from_str(&std::fs::read_to_string(&storage.storage_path).unwrap()).unwrap();
        let owner_key_str = bs58::encode(owner_vk.as_bytes()).into_string();
        assert_eq!(
            raw_storage.rooms[&owner_key_str].previous_contract_key,
            None
        );

        // Now load_rooms should detect the mismatch and set previous_contract_key
        let loaded = storage.load_rooms().unwrap();
        let room_info = loaded.rooms.get(&owner_key_str).unwrap();

        // The contract key should now be the current WASM-derived key
        let expected = expected_contract_key(&owner_vk);
        assert_eq!(room_info.contract_key, expected.id().to_string());

        // previous_contract_key should be set to the fake old key
        assert_eq!(
            room_info.previous_contract_key.as_deref(),
            Some(fake_old_key.id().to_string().as_str())
        );

        // Verify the update was persisted to disk
        let persisted: RoomStorage =
            serde_json::from_str(&std::fs::read_to_string(&storage.storage_path).unwrap()).unwrap();
        assert_eq!(
            persisted.rooms[&owner_key_str]
                .previous_contract_key
                .as_deref(),
            Some(fake_old_key.id().to_string().as_str())
        );
    }

    #[test]
    fn test_storage_roundtrip() {
        let (storage, _temp_dir) = create_test_storage();
        let owner_sk = create_test_signing_key();
        let owner_vk = owner_sk.verifying_key();
        let state = create_test_state(&owner_sk);
        let contract_key = expected_contract_key(&owner_vk);

        // Add room
        storage
            .add_room(&owner_vk, &owner_sk, state.clone(), &contract_key)
            .unwrap();

        // Retrieve and verify
        let (retrieved_sk, retrieved_state, retrieved_key) =
            storage.get_room(&owner_vk).unwrap().unwrap();

        assert_eq!(retrieved_sk.to_bytes(), owner_sk.to_bytes());
        // The contract key should match the expected key (computed from owner_vk + current WASM)
        assert_eq!(retrieved_key, contract_key.id().to_string());
        assert_eq!(
            retrieved_state.configuration.configuration.max_members,
            state.configuration.configuration.max_members
        );

        // When WASM hasn't changed, previous_contract_key must be None
        // (ensures ensure_room_migrated returns early without network calls)
        let loaded = storage.load_rooms().unwrap();
        let owner_key_str = bs58::encode(owner_vk.as_bytes()).into_string();
        assert_eq!(
            loaded.rooms[&owner_key_str].previous_contract_key, None,
            "previous_contract_key should be None when WASM hasn't changed"
        );
    }

    /// `--signing-key-file` / `RIVER_SIGNING_KEY_FILE` override: `Storage::get_room`
    /// must return the override key in place of the room's stored
    /// `signing_key_bytes`, AND the override must NOT be written back to
    /// `rooms.json`. This pins the "in-memory only" contract documented
    /// on `Storage::signing_key_override` — the persistent on-disk
    /// identity must be untouched, so subsequent invocations without
    /// the override revert to the stored identity.
    #[test]
    fn signing_key_override_is_returned_and_not_persisted() {
        let temp_dir = TempDir::new().unwrap();
        let stored_sk = create_test_signing_key();
        let override_sk = create_test_signing_key();
        assert_ne!(
            stored_sk.to_bytes(),
            override_sk.to_bytes(),
            "test invariant: stored and override keys must differ"
        );
        let owner_vk = stored_sk.verifying_key();

        // Set up storage with the stored identity (no override).
        let storage_no_override = Storage::new(Some(temp_dir.path().to_str().unwrap())).unwrap();
        let state = create_test_state(&stored_sk);
        let initial_key = expected_contract_key(&owner_vk);
        storage_no_override
            .add_room(&owner_vk, &stored_sk, state, &initial_key)
            .unwrap();

        // Sanity: without override, get_room returns the stored key.
        let (sk_no_override, _, _) = storage_no_override
            .get_room(&owner_vk)
            .unwrap()
            .expect("room present");
        assert_eq!(
            sk_no_override.to_bytes(),
            stored_sk.to_bytes(),
            "no override → stored key"
        );

        // With override, get_room returns the override.
        let storage_with_override = Storage::new_with_override(
            Some(temp_dir.path().to_str().unwrap()),
            Some(override_sk.clone()),
        )
        .unwrap();
        let (sk_with_override, _, _) = storage_with_override
            .get_room(&owner_vk)
            .unwrap()
            .expect("room present");
        assert_eq!(
            sk_with_override.to_bytes(),
            override_sk.to_bytes(),
            "override → override key returned"
        );

        // Critical: rooms.json on disk is untouched. A fresh Storage
        // without the override must see the ORIGINAL stored bytes.
        let storage_fresh = Storage::new(Some(temp_dir.path().to_str().unwrap())).unwrap();
        let (sk_fresh, _, _) = storage_fresh
            .get_room(&owner_vk)
            .unwrap()
            .expect("room present");
        assert_eq!(
            sk_fresh.to_bytes(),
            stored_sk.to_bytes(),
            "override must NOT be written back to rooms.json"
        );
    }

    /// `add_room_with_invitation_secrets` + `get_invitation_secrets` round
    /// trips the persisted map through disk. Pinning this here means a
    /// future serde-attr change on `StoredRoomInfo` cannot silently break
    /// invitation-secret persistence. (Issue freenet/river#302 PR #303
    /// testing-reviewer finding #6.)
    #[test]
    fn invitation_secrets_round_trip_via_disk() {
        let (storage, _temp_dir) = create_test_storage();
        let owner_sk = create_test_signing_key();
        let owner_vk = owner_sk.verifying_key();
        let state = create_test_state(&owner_sk);
        let key = expected_contract_key(&owner_vk);

        let mut invitation_secrets: HashMap<u32, [u8; 32]> = HashMap::new();
        invitation_secrets.insert(0, [0x11u8; 32]);
        invitation_secrets.insert(1, [0x22u8; 32]);
        storage
            .add_room_with_invitation_secrets(
                &owner_vk,
                &owner_sk,
                state,
                &key,
                invitation_secrets.clone(),
            )
            .unwrap();

        // Reload through a FRESH Storage so the assertion exercises the
        // disk path, not an in-memory carry-over.
        let storage_fresh = Storage::new(Some(_temp_dir.path().to_str().unwrap())).unwrap();
        let retrieved = storage_fresh.get_invitation_secrets(&owner_vk).unwrap();
        assert_eq!(
            retrieved, invitation_secrets,
            "invitation_secrets must survive disk round-trip byte-for-byte"
        );
    }

    /// Pre-#302 `rooms.json` shape: no `invitation_secrets` key. The
    /// `#[serde(default)]` attribute MUST keep loading clean, with the
    /// new field defaulting to an empty map. Mirrors the UI's
    /// `roomdata_decodes_from_minimal_legacy_blob`. (Testing-reviewer
    /// finding #5.)
    #[test]
    fn rooms_json_decodes_legacy_blob_without_invitation_secrets_field() {
        let temp_dir = TempDir::new().unwrap();
        let owner_sk = create_test_signing_key();
        let owner_vk = owner_sk.verifying_key();
        let state = create_test_state(&owner_sk);
        let owner_key_str = bs58::encode(owner_vk.as_bytes()).into_string();
        let key = expected_contract_key(&owner_vk);

        // Hand-build a JSON blob in the PRE-#302 shape — six fields, no
        // `invitation_secrets` key. Anything else would mean we're only
        // testing the current serialization round-tripping, not legacy
        // forward-compat.
        let legacy_blob = serde_json::json!({
            "rooms": {
                &owner_key_str: {
                    "signing_key_bytes": owner_sk.to_bytes().to_vec(),
                    "state": state,
                    "contract_key": key.id().to_string(),
                    "self_authorized_member": null,
                    "invite_chain": [],
                    "previous_contract_key": null,
                }
            }
        });
        let storage_path = temp_dir.path().join("rooms.json");
        std::fs::write(&storage_path, legacy_blob.to_string()).unwrap();

        let storage = Storage::new(Some(temp_dir.path().to_str().unwrap())).unwrap();
        let loaded = storage.load_rooms().unwrap();
        let room = loaded
            .rooms
            .get(&owner_key_str)
            .expect("legacy rooms.json must still load");
        assert!(
            room.invitation_secrets.is_empty(),
            "legacy rooms.json with no `invitation_secrets` field must deserialize \
             with an empty map (the `#[serde(default)]` invariant)"
        );
        // Sanity: get_invitation_secrets agrees.
        assert!(storage
            .get_invitation_secrets(&owner_vk)
            .unwrap()
            .is_empty());
    }
}
