//! host-wallet — sr25519 key management with platform-native keychain storage.
//!
//! The wallet holds a BIP-39 mnemonic and derives per-app sr25519 keypairs
//! using Substrate-compatible hard derivation (`//app//<app_id>//<index>`).
//!
//! Private key material never leaves this crate. SPAs interact through
//! `get_address(app_id)` and `sign(app_id, payload)`.

pub mod backup;
pub mod crypto;
pub mod derive;
pub mod error;
pub mod keyring;
pub mod keystore;
#[cfg(not(target_arch = "wasm32"))]
pub mod pairing;
pub mod signer;
pub use error::{WalletError, WalletResult};
pub use signer::{AppSigner, HostSigner, RootSigner, SignerError, SsoPathSigner, WalletPathSigner};

use k256::ecdsa::SigningKey;
use schnorrkel::Keypair;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;

#[cfg(not(target_arch = "wasm32"))]
use std::time::Instant;
#[cfg(target_arch = "wasm32")]
use web_time::Instant;

/// Chat material: (account_id, p256_secret_bytes, p256_public_bytes).
///
/// - `account_id`: 32-byte sr25519 public key at `//wallet`.
///   NOTE: iOS uses `//wallet` (not `//wallet//chat`) as the signing identity.
///   host-sdk matches iOS here until iOS is fixed. See host-sdk#36.
/// - `p256_secret_bytes`: 32-byte P-256 scalar derived from `//wallet//chat`, zeroized on drop.
/// - `p256_public_bytes`: 65-byte uncompressed P-256 public key derived from `//wallet//chat`.
pub type ChatMaterial = ([u8; 32], zeroize::Zeroizing<[u8; 32]>, [u8; 65]);

/// Flag set by the macOS sleep notification observer.
/// `WalletManager::check_auto_lock` checks and clears this.
static SYSTEM_SLEEP: AtomicBool = AtomicBool::new(false);

/// Maximum payload size for signing (64 KiB).
pub(crate) const MAX_SIGN_PAYLOAD: usize = 65_536;

/// Maximum allowed length for an app_id string (256 bytes).
///
/// Prevents DoS via unbounded Blake2b hashing of caller-controlled strings
/// and unbounded growth of the `app_keys` cache HashMap keys.
const MAX_APP_ID_LEN: usize = 256;

/// Maximum number of cached per-app derived keypairs.
///
/// Prevents unbounded memory growth from unique (app_id, index) tuples.
/// If the cache is full, the oldest entries are evicted (simple clear).
const MAX_APP_KEYS_CACHE: usize = 64;

/// Substrate signing context (matches what the runtime verifies).
pub(crate) const SIGNING_CTX: &[u8] = b"substrate";

/// Default auto-lock timeout: 15 minutes of inactivity.
const DEFAULT_AUTO_LOCK_SECS: u64 = 15 * 60;

/// The wallet's current state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WalletState {
    /// No mnemonic exists — user needs to create or import.
    NoWallet,
    /// Mnemonic exists but wallet is locked (keypair not in memory).
    Locked,
    /// Wallet is unlocked — keypair loaded, ready to sign.
    Unlocked {
        /// The root SS58 address (for display in Settings).
        root_address: String,
    },
}

/// Describes where signing operations are routed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WalletSource {
    /// Keys are derived locally from the mnemonic.
    Local,
    /// Signing is delegated to a paired mobile wallet.
    Paired {
        /// The SS58 address of the paired mobile account.
        address: String,
        /// Human-readable name shown during pairing (e.g. device name).
        display_name: String,
    },
}

/// Wallet manager — holds in-memory key material when unlocked.
///
/// Create one instance and store it in your application's global state.
///
/// `Debug` is intentionally implemented to redact all key material fields.
pub struct WalletManager {
    /// Root keypair — only present when unlocked. Zeroed on lock.
    root_keypair: Option<Keypair>,
    /// Cached per-app keypairs — cleared on lock.
    app_keys: HashMap<(String, u32), Keypair>,
    /// ETH secp256k1 signing key (BIP-44 m/44'/60'/0'/0/0). ZeroizeOnDrop.
    eth_key: Option<SigningKey>,
    /// BTC secp256k1 signing key (BIP-44 m/44'/0'/0'/0/0). ZeroizeOnDrop.
    btc_key: Option<SigningKey>,
    /// Cached flag: whether a mnemonic exists in the Keychain.
    /// Avoids hitting the Keychain (and triggering macOS auth prompts) on every
    /// render frame. Updated on create/import/delete/unlock.
    has_mnemonic: bool,
    /// Last time the wallet was used (unlock, sign, get address).
    /// Auto-lock fires when `Instant::now() - last_activity > auto_lock_timeout`.
    last_activity: Option<Instant>,
    /// Auto-lock timeout. `None` = never auto-lock.
    auto_lock_timeout: Option<Duration>,
    /// Whether signing is local or delegated to a paired mobile wallet.
    source: WalletSource,
    /// AES-128 session key for paired wallet communication.
    /// Only `Some` when `source == WalletSource::Paired`.
    /// Wrapped in `Zeroizing` so the key bytes are overwritten on drop.
    paired_session_key: Option<zeroize::Zeroizing<[u8; 16]>>,
    /// sr25519 identity pubkey (//wallet//sso) used as HMAC key for channel derivation.
    paired_identity: Option<[u8; 32]>,
    /// P-256 public key used during pairing (for HMAC channel derivation).
    paired_p256_pubkey: Option<[u8; 65]>,
}

impl std::fmt::Debug for WalletManager {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("WalletManager")
            .field("has_mnemonic", &self.has_mnemonic)
            .field("source", &self.source)
            .field("unlocked", &self.root_keypair.is_some())
            .field("cached_app_keys", &self.app_keys.len())
            .finish_non_exhaustive()
    }
}

impl Default for WalletManager {
    fn default() -> Self {
        Self::new()
    }
}

impl WalletManager {
    pub fn new() -> Self {
        // Single Keychain check at construction time.
        let has = keystore::has_mnemonic();

        // Restore paired wallet state if present in Keychain.
        let (source, paired_session_key, paired_identity, paired_p256_pubkey) =
            if let Some((address, session_key, identity_pubkey, p256_pubkey)) =
                keystore::load_paired_data()
            {
                log::info!("Restored paired wallet: {}", address);
                (
                    WalletSource::Paired {
                        address,
                        display_name: String::new(), // display_name not stored separately
                    },
                    Some(zeroize::Zeroizing::new(session_key)),
                    Some(identity_pubkey),
                    Some(p256_pubkey),
                )
            } else {
                (WalletSource::Local, None, None, None)
            };

        Self {
            root_keypair: None,
            app_keys: HashMap::new(),
            eth_key: None,
            btc_key: None,
            has_mnemonic: has,
            last_activity: None,
            auto_lock_timeout: Some(Duration::from_secs(DEFAULT_AUTO_LOCK_SECS)),
            source,
            paired_session_key,
            paired_identity,
            paired_p256_pubkey,
        }
    }

    /// Current wallet state (read-only — does not advance the state machine).
    /// Call `tick()` once per frame to check auto-lock before reading state.
    pub fn state(&self) -> WalletState {
        if self.root_keypair.is_some() {
            WalletState::Unlocked {
                root_address: self.root_address().unwrap_or_default(),
            }
        } else if self.has_mnemonic {
            WalletState::Locked
        } else {
            WalletState::NoWallet
        }
    }

    /// Advance the state machine — check auto-lock timeout and system sleep.
    /// Call once per render frame from the workbench tick loop. Returns `true`
    /// if the wallet was locked (caller should `cx.notify()`).
    pub fn tick(&mut self) -> bool {
        self.check_auto_lock()
    }

    /// Check if the auto-lock timeout has elapsed or system slept, and lock if so.
    /// Returns `true` if the wallet was locked.
    fn check_auto_lock(&mut self) -> bool {
        if self.root_keypair.is_none() {
            // Still clear a stale sleep flag
            SYSTEM_SLEEP.swap(false, Ordering::Relaxed);
            return false;
        }
        if SYSTEM_SLEEP.swap(false, Ordering::Relaxed) {
            log::info!("Wallet locked due to system sleep");
            self.lock();
            return true;
        }
        if let (Some(timeout), Some(last)) = (self.auto_lock_timeout, self.last_activity) {
            if last.elapsed() > timeout {
                log::info!(
                    "Wallet auto-locked after {} seconds of inactivity",
                    timeout.as_secs()
                );
                self.lock();
                return true;
            }
        }
        false
    }

    /// Record wallet activity (resets the auto-lock timer).
    fn touch(&mut self) {
        self.last_activity = Some(Instant::now());
    }

    /// Create a new wallet with a fresh 12-word mnemonic.
    ///
    /// Returns the mnemonic phrase wrapped in `Zeroizing<String>` so the
    /// sensitive text is zeroed in memory when the caller drops it.
    /// The wallet is automatically unlocked after creation.
    pub fn create(&mut self) -> WalletResult<zeroize::Zeroizing<String>> {
        let mnemonic = bip39::Mnemonic::generate(12)
            .map_err(|e| WalletError::MnemonicGenerationFailed(e.to_string()))?;
        // Wrap immediately so the phrase is zeroed when this scope exits or the
        // caller drops the returned value.
        let phrase = zeroize::Zeroizing::new(mnemonic.to_string());

        keystore::store_mnemonic(&phrase).map_err(|e| WalletError::Keystore(e.to_string()))?;
        self.has_mnemonic = true;

        let kp = derive::root_keypair_from_mnemonic(&mnemonic);
        self.root_keypair = Some(kp);
        self.eth_key = Some(derive::secp256k1::eth_key(&mnemonic));
        self.btc_key = Some(derive::secp256k1::btc_key(&mnemonic));
        self.app_keys.clear();
        self.touch();

        log::info!(
            "Wallet created. Root address: {}",
            self.root_address().unwrap_or_default()
        );
        Ok(phrase)
    }

    /// Import an existing mnemonic phrase.
    ///
    /// The wallet is automatically unlocked after import.
    ///
    /// # Security
    /// The `phrase` parameter is a borrowed `&str`, so this function cannot
    /// zeroize the caller's buffer. The caller is responsible for zeroizing
    /// the mnemonic after calling this function, e.g. by storing it in a
    /// `zeroize::Zeroizing<String>` and dropping it promptly.
    pub fn import(&mut self, phrase: &str) -> WalletResult<()> {
        let mnemonic = bip39::Mnemonic::parse(phrase)
            .map_err(|e| WalletError::InvalidMnemonic(e.to_string()))?;

        keystore::store_mnemonic(phrase).map_err(|e| WalletError::Keystore(e.to_string()))?;
        self.has_mnemonic = true;

        let kp = derive::root_keypair_from_mnemonic(&mnemonic);
        self.root_keypair = Some(kp);
        self.eth_key = Some(derive::secp256k1::eth_key(&mnemonic));
        self.btc_key = Some(derive::secp256k1::btc_key(&mnemonic));
        self.app_keys.clear();
        self.touch();

        log::info!(
            "Wallet imported. Root address: {}",
            self.root_address().unwrap_or_default()
        );
        Ok(())
    }

    /// Unlock the wallet by loading the mnemonic from the Keychain.
    ///
    /// On macOS this triggers a Touch ID prompt (or passcode fallback).
    pub fn unlock(&mut self) -> WalletResult<()> {
        let phrase = zeroize::Zeroizing::new(
            keystore::load_mnemonic().map_err(|e| WalletError::Keystore(e.to_string()))?,
        );
        let mnemonic = bip39::Mnemonic::parse(phrase.as_str())
            .map_err(|e| WalletError::InvalidMnemonic(e.to_string()))?;

        let kp = derive::root_keypair_from_mnemonic(&mnemonic);
        self.root_keypair = Some(kp);
        self.eth_key = Some(derive::secp256k1::eth_key(&mnemonic));
        self.btc_key = Some(derive::secp256k1::btc_key(&mnemonic));
        self.app_keys.clear();
        self.touch();
        // Set has_mnemonic after parse succeeded — if parse fails above,
        // the flag stays unchanged so state() doesn't report Locked for a
        // corrupted mnemonic.
        self.has_mnemonic = true;

        log::info!("Wallet unlocked");
        Ok(())
    }

    /// Lock the wallet — clear all in-memory key material.
    /// schnorrkel's `Keypair` and `SecretKey` both implement `ZeroizeOnDrop`,
    /// so dropping them zeros the secret bytes in place.
    /// `paired_session_key` is `Zeroizing<[u8; 16]>` — dropping it (via `None`)
    /// automatically overwrites the key bytes before deallocation.
    /// `Drop` calls `lock()`, so this also covers process exit.
    pub fn lock(&mut self) {
        self.root_keypair = None;
        self.app_keys.clear();
        self.eth_key = None;
        self.btc_key = None;
        self.paired_session_key = None;
        self.last_activity = None;
        log::info!("Wallet locked");
    }

    /// Return the raw 32-byte sr25519 public key, or None if locked.
    pub fn root_public_key(&self) -> Option<[u8; 32]> {
        self.root_keypair.as_ref().map(|kp| kp.public.to_bytes())
    }

    /// Return the root SS58 address (Polkadot format, prefix 0).
    pub fn root_address(&self) -> WalletResult<String> {
        let kp = self.root_keypair.as_ref().ok_or(WalletError::Locked)?;
        Ok(derive::ss58_address_with_prefix(&kp.public.to_bytes(), 0))
    }

    /// Return the checksummed EIP-55 Ethereum address.
    pub fn eth_address(&self) -> WalletResult<String> {
        let key = self.eth_key.as_ref().ok_or(WalletError::Locked)?;
        Ok(derive::secp256k1::eth_address(key))
    }

    /// Return the P2WPKH (bech32) Bitcoin mainnet address.
    pub fn btc_address(&self) -> WalletResult<String> {
        let key = self.btc_key.as_ref().ok_or(WalletError::Locked)?;
        Ok(derive::secp256k1::btc_address_p2wpkh(key))
    }

    /// Sign arbitrary bytes with the ETH private key (EIP-191 personal_sign).
    /// Returns 65-byte signature: `r (32) || s (32) || v (1)`.
    pub fn eth_sign_personal(&mut self, data: &[u8]) -> WalletResult<Vec<u8>> {
        self.touch();
        if data.len() > MAX_SIGN_PAYLOAD {
            return Err(WalletError::PayloadTooLarge {
                size: data.len(),
                max: MAX_SIGN_PAYLOAD,
            });
        }

        let key = self.eth_key.as_ref().ok_or(WalletError::Locked)?;

        // EIP-191 personal_sign prefix
        use sha3::{Digest, Keccak256};
        let prefix = format!("\x19Ethereum Signed Message:\n{}", data.len());
        let mut hasher = Keccak256::new();
        hasher.update(prefix.as_bytes());
        hasher.update(data);
        let digest = hasher.finalize();

        let (sig, recid) = key
            .sign_prehash_recoverable(&digest)
            .map_err(|e| WalletError::EthSigningFailed(e.to_string()))?;
        let mut out = Vec::with_capacity(65);
        out.extend_from_slice(&sig.to_bytes());
        out.push(recid.to_byte() + 27); // Ethereum v = recid + 27
        Ok(out)
    }

    /// Sign a raw 32-byte digest with the BTC private key (ECDSA, no prefix).
    /// The caller is responsible for hashing: BTC transactions use SHA256d
    /// (double-SHA256 of the serialized transaction).
    /// Returns the DER-encoded signature.
    pub fn btc_sign_raw(&mut self, digest: &[u8; 32]) -> WalletResult<Vec<u8>> {
        self.touch();
        let key = self.btc_key.as_ref().ok_or(WalletError::Locked)?;

        let (sig, _recid) = key
            .sign_prehash_recoverable(digest)
            .map_err(|e| WalletError::BtcSigningFailed(e.to_string()))?;
        Ok(sig.to_der().to_bytes().to_vec())
    }

    /// Sign a UTF-8 message using BIP-137 (Bitcoin Signed Message).
    /// Returns a 65-byte recoverable signature encoded as Base64.
    /// Digest: SHA256d of `"\x18Bitcoin Signed Message:\n" + varint(len) + msg`.
    /// Header byte: 39 + recid (native SegWit P2WPKH, Electrum convention).
    pub fn btc_sign_message(&mut self, msg: &[u8]) -> WalletResult<String> {
        use base64::{engine::general_purpose::STANDARD, Engine};
        use sha2::{Digest, Sha256};

        self.touch();
        if msg.len() > MAX_SIGN_PAYLOAD {
            return Err(WalletError::PayloadTooLarge {
                size: msg.len(),
                max: MAX_SIGN_PAYLOAD,
            });
        }
        let key = self.btc_key.as_ref().ok_or(WalletError::Locked)?;

        // BIP-137 message prefix
        let prefix = b"\x18Bitcoin Signed Message:\n";
        let msg_len = encode_varint(msg.len() as u64);
        let mut preimage = Vec::with_capacity(prefix.len() + msg_len.len() + msg.len());
        preimage.extend_from_slice(prefix);
        preimage.extend_from_slice(&msg_len);
        preimage.extend_from_slice(msg);

        // SHA256d (double SHA-256)
        let digest: [u8; 32] = Sha256::digest(Sha256::digest(&preimage)).into();

        let (sig, recid) = key
            .sign_prehash_recoverable(&digest)
            .map_err(|e| WalletError::BtcSigningFailed(e.to_string()))?;

        // Header: 39 + recid for native SegWit P2WPKH (BIP-84)
        let header = 39u8 + recid.to_byte();
        let mut out = Vec::with_capacity(65);
        out.push(header);
        out.extend_from_slice(&sig.to_bytes());
        Ok(STANDARD.encode(&out))
    }

    /// Return the SS58 address for a given app_id (Polkadot format, prefix 0).
    pub fn app_address(&mut self, app_id: &str, index: u32) -> WalletResult<String> {
        self.touch();
        let kp = self.app_keypair(app_id, index)?;
        Ok(derive::ss58_address_with_prefix(&kp.public.to_bytes(), 0))
    }

    /// Return the raw 32-byte sr25519 public key for a derived app account.
    pub fn app_public_key(&mut self, app_id: &str, index: u32) -> WalletResult<[u8; 32]> {
        self.touch();
        let kp = self.app_keypair(app_id, index)?;
        Ok(kp.public.to_bytes())
    }

    /// Sign arbitrary bytes with the root keypair (for dapp wallet use).
    ///
    /// Returns the 64-byte sr25519 signature.
    pub fn sign_root(&mut self, payload: &[u8]) -> WalletResult<Vec<u8>> {
        self.touch();
        if payload.len() > MAX_SIGN_PAYLOAD {
            return Err(WalletError::PayloadTooLarge {
                size: payload.len(),
                max: MAX_SIGN_PAYLOAD,
            });
        }

        let kp = self.root_keypair.as_ref().ok_or(WalletError::Locked)?;
        let ctx = schnorrkel::signing_context(SIGNING_CTX);
        let sig = kp.sign(ctx.bytes(payload));
        Ok(sig.to_bytes().to_vec())
    }

    /// Sign arbitrary bytes as the given app's derived account.
    ///
    /// Returns the 64-byte sr25519 signature.
    pub fn sign(&mut self, app_id: &str, index: u32, payload: &[u8]) -> WalletResult<Vec<u8>> {
        self.touch();
        if payload.len() > MAX_SIGN_PAYLOAD {
            return Err(WalletError::PayloadTooLarge {
                size: payload.len(),
                max: MAX_SIGN_PAYLOAD,
            });
        }

        let kp = self.app_keypair(app_id, index)?;
        let ctx = schnorrkel::signing_context(SIGNING_CTX);
        let sig = kp.sign(ctx.bytes(payload));
        Ok(sig.to_bytes().to_vec())
    }

    /// Return the 32-byte Ed25519 public key at `//ed25519//<app_id>//<index>`.
    pub fn ed25519_public_key(&mut self, app_id: &str, index: u32) -> WalletResult<[u8; 32]> {
        self.touch();
        let root = self.root_keypair.as_ref().ok_or(WalletError::Locked)?;
        let key = derive::ed25519_keypair_from_root(root, app_id, index);
        Ok(key.verifying_key().to_bytes())
    }

    /// Sign a payload with the Ed25519 key at `//ed25519//<app_id>//<index>`.
    ///
    /// Ed25519 handles hashing internally (SHA-512 internally in ed25519-dalek).
    /// Returns a 64-byte signature.
    pub fn sign_ed25519(
        &mut self,
        app_id: &str,
        index: u32,
        payload: &[u8],
    ) -> WalletResult<Vec<u8>> {
        use ed25519_dalek::Signer;
        self.touch();
        if payload.len() > MAX_SIGN_PAYLOAD {
            return Err(WalletError::PayloadTooLarge {
                size: payload.len(),
                max: MAX_SIGN_PAYLOAD,
            });
        }
        let root = self.root_keypair.as_ref().ok_or(WalletError::Locked)?;
        let key = derive::ed25519_keypair_from_root(root, app_id, index);
        let sig = key.sign(payload);
        Ok(sig.to_bytes().to_vec())
    }

    /// Return the 33-byte compressed secp256k1 public key at
    /// `//ecdsa//<app_id>//<index>` (Substrate ECDSA).
    pub fn substrate_ecdsa_public_key(
        &mut self,
        app_id: &str,
        index: u32,
    ) -> WalletResult<Vec<u8>> {
        self.touch();
        let root = self.root_keypair.as_ref().ok_or(WalletError::Locked)?;
        let key = derive::substrate_ecdsa_keypair_from_root(root, app_id, index);
        let vk = k256::ecdsa::VerifyingKey::from(&key);
        Ok(vk.to_encoded_point(true).as_bytes().to_vec())
    }

    /// Sign a payload with the Substrate ECDSA key at `//ecdsa//<app_id>//<index>`.
    ///
    /// Prehashes with `BLAKE2b-256` (Substrate convention), then signs the
    /// 32-byte digest. Returns 65 bytes: `r (32) || s (32) || recovery_id (1)`.
    pub fn sign_substrate_ecdsa(
        &mut self,
        app_id: &str,
        index: u32,
        payload: &[u8],
    ) -> WalletResult<Vec<u8>> {
        use blake2::digest::{consts::U32, Digest};
        self.touch();
        if payload.len() > MAX_SIGN_PAYLOAD {
            return Err(WalletError::PayloadTooLarge {
                size: payload.len(),
                max: MAX_SIGN_PAYLOAD,
            });
        }
        let root = self.root_keypair.as_ref().ok_or(WalletError::Locked)?;
        let key = derive::substrate_ecdsa_keypair_from_root(root, app_id, index);

        // Substrate ECDSA prehash: BLAKE2b-256 of the full payload.
        let mut hasher = blake2::Blake2b::<U32>::new();
        hasher.update(payload);
        let digest: [u8; 32] = hasher.finalize().into();

        let (sig, recid) = key
            .sign_prehash_recoverable(&digest)
            .map_err(|e| WalletError::SubstrateEcdsaFailed(e.to_string()))?;
        let mut out = Vec::with_capacity(65);
        out.extend_from_slice(&sig.to_bytes());
        out.push(recid.to_byte());
        Ok(out)
    }

    /// Delete the wallet entirely — removes the mnemonic from Keychain.
    pub fn delete(&mut self) -> WalletResult<()> {
        self.lock();
        keystore::delete_mnemonic().map_err(|e| WalletError::Keystore(e.to_string()))?;
        self.has_mnemonic = false;
        log::info!("Wallet deleted");
        Ok(())
    }

    // -----------------------------------------------------------------------
    // Pairing API
    // -----------------------------------------------------------------------

    /// The address to use for dapp interactions.
    ///
    /// Returns the paired address if paired, otherwise the root SS58 address.
    pub fn active_address(&self) -> WalletResult<String> {
        match &self.source {
            WalletSource::Paired { address, .. } => Ok(address.clone()),
            WalletSource::Local => self.root_address(),
        }
    }

    /// Get the current wallet source (Local or Paired).
    pub fn source(&self) -> &WalletSource {
        &self.source
    }

    /// Accept a successful pairing result and persist it to the Keychain.
    ///
    /// Sets `source` to `WalletSource::Paired` and stores session data so it
    /// survives app restart.
    pub fn accept_pairing(
        &mut self,
        address: &str,
        display_name: &str,
        session_key: [u8; 16],
        identity_pubkey: [u8; 32],
        p256_pubkey: [u8; 65],
    ) -> WalletResult<()> {
        keystore::store_paired_data(address, &session_key, &identity_pubkey, &p256_pubkey)
            .map_err(|e| WalletError::Keystore(e.to_string()))?;
        self.source = WalletSource::Paired {
            address: address.to_string(),
            display_name: display_name.to_string(),
        };
        self.paired_session_key = Some(zeroize::Zeroizing::new(session_key));
        self.paired_identity = Some(identity_pubkey);
        self.paired_p256_pubkey = Some(p256_pubkey);
        log::info!("Paired with wallet: {} ({})", address, display_name);
        Ok(())
    }

    /// Remove paired wallet data — clears Keychain entry and resets to Local.
    pub fn unpair(&mut self) -> WalletResult<()> {
        keystore::delete_paired_data().map_err(|e| WalletError::Keystore(e.to_string()))?;
        self.source = WalletSource::Local;
        self.paired_session_key = None;
        self.paired_identity = None;
        self.paired_p256_pubkey = None;
        log::info!("Unpaired wallet, reverted to local signing");
        Ok(())
    }

    /// Derive and return the 32-byte sr25519 public key at `//wallet//sso`.
    ///
    /// This is the identity used as the HMAC key for pairing channel derivation.
    /// Requires an unlocked wallet (root keypair in memory).
    pub fn pairing_identity(&mut self) -> WalletResult<[u8; 32]> {
        self.touch();
        let root = self.root_keypair.as_ref().ok_or(WalletError::Locked)?;
        let sso_kp = derive::sso_keypair(root);
        Ok(sso_kp.public.to_bytes())
    }

    /// Derive and return the 32-byte sr25519 public key at `//wallet`.
    ///
    /// This is the identity used as the account ID in Chat Identity Records.
    /// NOTE: Uses `//wallet` (not `//wallet//chat`) to match iOS. See host-sdk#36.
    /// Requires an unlocked wallet (root keypair in memory).
    pub fn chat_identity(&mut self) -> WalletResult<[u8; 32]> {
        self.touch();
        let root = self.root_keypair.as_ref().ok_or(WalletError::Locked)?;
        let wallet_kp = derive::wallet_keypair(root);
        Ok(wallet_kp.public.to_bytes())
    }

    /// Sign arbitrary bytes with the sr25519 keypair at `//wallet`.
    ///
    /// Returns the 64-byte sr25519 signature. Uses the Substrate signing
    /// context (`b"substrate"`) matching the iOS chat protocol.
    /// NOTE: Uses `//wallet` (not `//wallet//chat`) to match iOS. See host-sdk#36.
    pub fn chat_sign(&mut self, payload: &[u8]) -> WalletResult<Vec<u8>> {
        self.touch();
        if payload.len() > MAX_SIGN_PAYLOAD {
            return Err(WalletError::PayloadTooLarge {
                size: payload.len(),
                max: MAX_SIGN_PAYLOAD,
            });
        }
        let root = self.root_keypair.as_ref().ok_or(WalletError::Locked)?;
        let wallet_kp = derive::wallet_keypair(root);
        let ctx = schnorrkel::signing_context(SIGNING_CTX);
        let sig = wallet_kp.sign(ctx.bytes(payload));
        Ok(sig.to_bytes().to_vec())
    }

    /// Derive all key material needed for the chat-v1 protocol.
    ///
    /// Returns `(account_id, p256_secret_bytes, p256_public_bytes)`:
    /// - `account_id`: 32-byte sr25519 public key at `//wallet`.
    ///   NOTE: iOS uses `//wallet` for signing (not `//wallet//chat`).
    ///   This matches iOS behavior. See host-sdk#36.
    /// - `p256_secret_bytes`: 32-byte P-256 scalar derived from the
    ///   `//wallet//chat` sr25519 secret via blake2b256.
    /// - `p256_public_bytes`: 65-byte uncompressed P-256 public key.
    ///
    /// Derives from the in-memory root keypair — no Keychain read required.
    pub fn chat_material(&mut self) -> WalletResult<ChatMaterial> {
        use zeroize::Zeroize;

        self.touch();
        let root = self.root_keypair.as_ref().ok_or(WalletError::Locked)?;
        // account_id uses //wallet (matches iOS signing identity)
        let wallet_kp = derive::wallet_keypair(root);
        let account_id = wallet_kp.public.to_bytes();
        // P-256 uses //wallet//chat (matches iOS encryption key)
        let chat_kp = derive::chat_keypair(root);

        // First 32 bytes of expanded SR25519 secret = scalar half (not nonce).
        // Wrap in Zeroizing so the full 64-byte expanded secret is cleared on drop.
        let secret_bytes_64 = zeroize::Zeroizing::new(chat_kp.secret.to_bytes());
        let mut scalar_bytes = [0u8; 32];
        scalar_bytes.copy_from_slice(&secret_bytes_64[..32]);

        let p256_secret = crypto::p256_from_sr25519_scalar(&scalar_bytes)
            .map_err(|e| WalletError::P256DerivationFailed(e.to_string()))?;
        scalar_bytes.zeroize();

        let p256_public = p256_secret.public_key();

        let mut p256_secret_bytes = zeroize::Zeroizing::new([0u8; 32]);
        p256_secret_bytes.copy_from_slice(&p256_secret.to_bytes());

        let mut p256_public_bytes = [0u8; 65];
        p256_public_bytes.copy_from_slice(&p256_public.to_sec1_bytes());

        Ok((account_id, p256_secret_bytes, p256_public_bytes))
    }

    /// Derive 32-byte Bandersnatch entropy from the wallet's root keypair.
    ///
    /// Convention (matching iOS/Android): blake2b-256 of the first 32 bytes
    /// of the root sr25519 expanded secret. The result is the seed for
    /// `NativeVrfProvider::from_entropy`.
    pub fn bandersnatch_entropy(&mut self) -> WalletResult<zeroize::Zeroizing<[u8; 32]>> {
        use blake2::digest::{consts::U32, Digest};

        self.touch();
        let root = self.root_keypair.as_ref().ok_or(WalletError::Locked)?;

        let secret_bytes = root.secret.to_bytes();
        let mut hasher = blake2::Blake2b::<U32>::new();
        hasher.update(&secret_bytes[..32]);
        let hash: [u8; 32] = hasher.finalize().into();
        Ok(zeroize::Zeroizing::new(hash))
    }

    /// Sign a payload with the `//wallet//sso` keypair.
    ///
    /// Returns the 64-byte sr25519 signature. Uses the Substrate signing context.
    pub fn sso_sign(&mut self, payload: &[u8]) -> WalletResult<Vec<u8>> {
        self.touch();
        if payload.len() > MAX_SIGN_PAYLOAD {
            return Err(WalletError::PayloadTooLarge {
                size: payload.len(),
                max: MAX_SIGN_PAYLOAD,
            });
        }
        let root = self.root_keypair.as_ref().ok_or(WalletError::Locked)?;
        let sso_kp = derive::sso_keypair(root);
        let ctx = schnorrkel::signing_context(SIGNING_CTX);
        let sig = sso_kp.sign(ctx.bytes(payload));
        Ok(sig.to_bytes().to_vec())
    }

    /// Get the (session_key, identity_pubkey, p256_pubkey) triple needed to
    /// call `pairing::sign_via_paired`. Returns `None` if not paired.
    pub fn paired_sign_params(&self) -> Option<([u8; 16], [u8; 32], [u8; 65])> {
        match (
            self.paired_session_key.as_deref().copied(),
            self.paired_identity,
            self.paired_p256_pubkey,
        ) {
            (Some(sk), Some(id), Some(p256)) => Some((sk, id, p256)),
            _ => None,
        }
    }

    /// Load key material from a mnemonic phrase without persisting to keystore.
    ///
    /// Use this on platforms where the keystore is unavailable (WASM) or when
    /// the caller manages mnemonic storage externally. The wallet is unlocked
    /// immediately after loading.
    pub fn load_from_mnemonic(&mut self, phrase: &str) -> WalletResult<()> {
        let mnemonic = bip39::Mnemonic::parse(phrase)
            .map_err(|e| WalletError::InvalidMnemonic(e.to_string()))?;

        let kp = derive::root_keypair_from_mnemonic(&mnemonic);
        self.root_keypair = Some(kp);
        self.eth_key = Some(derive::secp256k1::eth_key(&mnemonic));
        self.btc_key = Some(derive::secp256k1::btc_key(&mnemonic));
        self.app_keys.clear();
        // Mark mnemonic present so state() reports Locked (not NoWallet) after
        // a subsequent lock(). Mirrors the pattern used in unlock().
        self.has_mnemonic = true;
        self.touch();
        Ok(())
    }

    /// Set the root keypair directly (for testing without keychain).
    #[doc(hidden)]
    pub fn root_keypair_for_testing(&mut self, kp: Keypair) {
        self.root_keypair = Some(kp);
        self.app_keys.clear();
        self.touch();
    }

    /// Get or derive the keypair for a given app_id.
    fn app_keypair(&mut self, app_id: &str, index: u32) -> WalletResult<&Keypair> {
        if app_id.len() > MAX_APP_ID_LEN {
            return Err(WalletError::AppIdTooLong {
                len: app_id.len(),
                max: MAX_APP_ID_LEN,
            });
        }

        let root = self.root_keypair.as_ref().ok_or(WalletError::Locked)?;

        let key = (app_id.to_string(), index);
        if !self.app_keys.contains_key(&key) {
            // Evict the entire cache if it exceeds the cap. This is simple and
            // effective — the hot set will be re-derived on next access.
            if self.app_keys.len() >= MAX_APP_KEYS_CACHE {
                self.app_keys.clear();
            }
            let kp = derive::app_keypair(root, app_id, index);
            self.app_keys.insert(key.clone(), kp);
        }

        // SAFETY: key was just inserted if missing, so it is always present.
        Ok(self.app_keys.get(&key).unwrap())
    }
}

/// Bitcoin varint encoding (CompactSize).
fn encode_varint(n: u64) -> Vec<u8> {
    if n < 0xfd {
        vec![n as u8]
    } else if n <= 0xffff {
        let mut v = vec![0xfd];
        v.extend_from_slice(&(n as u16).to_le_bytes());
        v
    } else if n <= 0xffff_ffff {
        let mut v = vec![0xfe];
        v.extend_from_slice(&(n as u32).to_le_bytes());
        v
    } else {
        let mut v = vec![0xff];
        v.extend_from_slice(&n.to_le_bytes());
        v
    }
}

impl Drop for WalletManager {
    fn drop(&mut self) {
        self.lock();
    }
}

/// Register a macOS observer that sets the `SYSTEM_SLEEP` flag when the
/// system sleeps or the screen locks. Call once at app startup.
/// `WalletManager::check_auto_lock` (called from `state()`) reads and
/// clears this flag, triggering an immediate lock.
#[cfg(target_os = "macos")]
pub fn register_sleep_observer() {
    use objc2::msg_send;
    use objc2::runtime::{AnyClass, AnyObject, ClassBuilder};
    use std::sync::OnceLock;

    static REGISTERED: OnceLock<()> = OnceLock::new();
    REGISTERED.get_or_init(|| {
        // SAFETY: Objective-C runtime interop. All class and method names used
        // here (`NSObject`, `NSWorkspace`, `NSString`) are present on every
        // macOS version we target; `unwrap()` on `AnyClass::get` will panic
        // only if Apple removes these classes, which is not a realistic risk.
        // The `on_sleep` callback captures `SYSTEM_SLEEP` (a `&'static
        // AtomicBool`) by reference, which is safe because the static lives
        // for the entire process lifetime. The observer is explicitly retained
        // with `msg_send![observer, retain]` so it is never deallocated; this
        // intentional leak is acceptable because the observer must remain valid
        // for the process lifetime. `OnceLock` guarantees this block runs
        // exactly once, so there is no double-registration.
        unsafe {
            // Build a tiny ObjC class with a callback method
            static CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
            let cls = CLASS.get_or_init(|| {
                if let Some(c) = AnyClass::get(c"HostWalletSleepObserver") {
                    return c;
                }
                let superclass = AnyClass::get(c"NSObject").unwrap();
                let mut builder =
                    ClassBuilder::new(c"HostWalletSleepObserver", superclass).unwrap();

                unsafe extern "C" fn on_sleep(
                    _this: *mut AnyObject,
                    _sel: objc2::runtime::Sel,
                    _note: *mut AnyObject,
                ) {
                    SYSTEM_SLEEP.store(true, Ordering::Relaxed);
                }
                builder.add_method(
                    objc2::sel!(onSleep:),
                    on_sleep as unsafe extern "C" fn(_, _, _),
                );
                builder.register()
            });

            let observer: *mut AnyObject = msg_send![*cls, new];
            // Prevent deallocation — leaked intentionally for process lifetime.
            let _: *mut AnyObject = msg_send![observer, retain];

            // NSWorkspace.sharedWorkspace
            let ws_cls = AnyClass::get(c"NSWorkspace").unwrap();
            let shared_ws: *mut AnyObject = msg_send![ws_cls, sharedWorkspace];
            let nc: *mut AnyObject = msg_send![shared_ws, notificationCenter];

            // Register for NSWorkspaceWillSleepNotification
            let sleep_name: *mut AnyObject = msg_send![
                AnyClass::get(c"NSString").unwrap(),
                stringWithUTF8String: c"NSWorkspaceWillSleepNotification".as_ptr()
            ];
            let _: () = msg_send![
                nc,
                addObserver: observer,
                selector: objc2::sel!(onSleep:),
                name: sleep_name,
                object: std::ptr::null::<AnyObject>()
            ];

            // Register for NSWorkspaceScreensDidSleepNotification (screen lock)
            let screen_sleep_name: *mut AnyObject = msg_send![
                AnyClass::get(c"NSString").unwrap(),
                stringWithUTF8String: c"NSWorkspaceScreensDidSleepNotification".as_ptr()
            ];
            let _: () = msg_send![
                nc,
                addObserver: observer,
                selector: objc2::sel!(onSleep:),
                name: screen_sleep_name,
                object: std::ptr::null::<AnyObject>()
            ];
        }
    });
}

#[cfg(not(target_os = "macos"))]
pub fn register_sleep_observer() {}

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

    #[test]
    fn create_and_address() {
        let mut wm = WalletManager::new();
        // Import a known mnemonic (can't test create() without Keychain)
        let mnemonic = bip39::Mnemonic::parse(
            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
        ).unwrap();
        let kp = derive::root_keypair_from_mnemonic(&mnemonic);
        wm.root_keypair = Some(kp);

        let root_addr = wm.root_address().unwrap();
        // Polkadot addresses start with '1' (prefix 0)
        assert!(
            root_addr.starts_with('1'),
            "expected Polkadot prefix, got: {root_addr}"
        );

        let app_addr = wm.app_address("com.test.app", 0).unwrap();
        assert!(
            app_addr.starts_with('1'),
            "expected Polkadot prefix, got: {app_addr}"
        );
        assert_ne!(root_addr, app_addr);
    }

    #[test]
    fn sign_and_verify() {
        let mut wm = WalletManager::new();
        let mnemonic = bip39::Mnemonic::parse(
            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
        ).unwrap();
        wm.root_keypair = Some(derive::root_keypair_from_mnemonic(&mnemonic));

        let sig_bytes = wm.sign("com.test.app", 0, b"hello world").unwrap();
        assert_eq!(sig_bytes.len(), 64);

        // Verify the signature
        let kp = derive::app_keypair(wm.root_keypair.as_ref().unwrap(), "com.test.app", 0);
        let sig = schnorrkel::Signature::from_bytes(&sig_bytes).unwrap();
        let ctx = schnorrkel::signing_context(SIGNING_CTX);
        assert!(kp.public.verify(ctx.bytes(b"hello world"), &sig).is_ok());
    }

    #[test]
    fn payload_too_large() {
        let mut wm = WalletManager::new();
        let mnemonic = bip39::Mnemonic::parse(
            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
        ).unwrap();
        wm.root_keypair = Some(derive::root_keypair_from_mnemonic(&mnemonic));

        let big = vec![0u8; MAX_SIGN_PAYLOAD + 1];
        assert!(wm.sign("com.test.app", 0, &big).is_err());
    }

    #[test]
    fn lock_clears_keys() {
        let mut wm = WalletManager::new();
        let mnemonic = bip39::Mnemonic::parse(
            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
        ).unwrap();
        wm.root_keypair = Some(derive::root_keypair_from_mnemonic(&mnemonic));
        wm.eth_key = Some(derive::secp256k1::eth_key(&mnemonic));
        wm.btc_key = Some(derive::secp256k1::btc_key(&mnemonic));
        let _ = wm.app_address("com.test.app", 0).unwrap();
        assert!(!wm.app_keys.is_empty());

        wm.lock();
        assert!(wm.root_keypair.is_none());
        assert!(wm.app_keys.is_empty());
        assert!(wm.eth_key.is_none());
        assert!(wm.btc_key.is_none());
        // After lock, state is either NoWallet (no keychain) or Locked (keychain present)
        assert!(!matches!(wm.state(), WalletState::Unlocked { .. }));
    }

    #[test]
    fn app_cache_distinguishes_indices() {
        let mut wm = WalletManager::new();
        let mnemonic = bip39::Mnemonic::parse(
            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
        ).unwrap();
        wm.root_keypair = Some(derive::root_keypair_from_mnemonic(&mnemonic));

        let addr0 = wm.app_address("com.test.app", 0).unwrap();
        let addr1 = wm.app_address("com.test.app", 1).unwrap();
        assert_ne!(
            addr0, addr1,
            "Different indices must produce different addresses"
        );
        assert_eq!(
            wm.app_keys.len(),
            2,
            "Cache must hold two entries for different indices"
        );
    }

    #[test]
    fn sign_and_verify_nonzero_index() {
        let mut wm = WalletManager::new();
        let mnemonic = bip39::Mnemonic::parse(
            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
        ).unwrap();
        wm.root_keypair = Some(derive::root_keypair_from_mnemonic(&mnemonic));

        let sig_bytes = wm.sign("com.test.app", 3, b"hello index 3").unwrap();
        assert_eq!(sig_bytes.len(), 64);

        // Verify the signature against the correct derived key
        let kp = derive::app_keypair(wm.root_keypair.as_ref().unwrap(), "com.test.app", 3);
        let sig = schnorrkel::Signature::from_bytes(&sig_bytes).unwrap();
        let ctx = schnorrkel::signing_context(SIGNING_CTX);
        assert!(kp.public.verify(ctx.bytes(b"hello index 3"), &sig).is_ok());
    }

    #[test]
    fn eth_address_when_unlocked() {
        let mut wm = WalletManager::new();
        let mnemonic = bip39::Mnemonic::parse(
            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
        ).unwrap();
        wm.root_keypair = Some(derive::root_keypair_from_mnemonic(&mnemonic));
        wm.eth_key = Some(derive::secp256k1::eth_key(&mnemonic));
        wm.btc_key = Some(derive::secp256k1::btc_key(&mnemonic));

        let eth = wm.eth_address().unwrap();
        assert!(eth.starts_with("0x"));
        assert_eq!(eth.len(), 42);

        let btc = wm.btc_address().unwrap();
        assert!(btc.starts_with("bc1q"));
    }

    #[test]
    fn eth_address_fails_when_locked() {
        let wm = WalletManager::new();
        assert!(wm.eth_address().is_err());
        assert!(wm.btc_address().is_err());
    }

    #[test]
    fn eth_sign_personal_produces_65_bytes() {
        let mut wm = WalletManager::new();
        let mnemonic = bip39::Mnemonic::parse(
            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
        ).unwrap();
        wm.root_keypair = Some(derive::root_keypair_from_mnemonic(&mnemonic));
        wm.eth_key = Some(derive::secp256k1::eth_key(&mnemonic));

        let sig = wm.eth_sign_personal(b"hello ethereum").unwrap();
        assert_eq!(sig.len(), 65, "ETH personal_sign must be 65 bytes (r+s+v)");
        // v should be 27 or 28
        assert!(
            sig[64] == 27 || sig[64] == 28,
            "v must be 27 or 28, got {}",
            sig[64]
        );
    }

    #[test]
    fn eth_sign_personal_recovers_to_correct_address() {
        use k256::ecdsa::{RecoveryId, Signature, VerifyingKey};
        use sha3::{Digest, Keccak256};

        let mut wm = WalletManager::new();
        let mnemonic = bip39::Mnemonic::parse(
            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
        ).unwrap();
        wm.root_keypair = Some(derive::root_keypair_from_mnemonic(&mnemonic));
        wm.eth_key = Some(derive::secp256k1::eth_key(&mnemonic));

        let data = b"hello ethereum";
        let sig_bytes = wm.eth_sign_personal(data).unwrap();

        // Re-hash with EIP-191 prefix
        let prefix = format!("\x19Ethereum Signed Message:\n{}", data.len());
        let digest = Keccak256::new()
            .chain_update(prefix.as_bytes())
            .chain_update(data)
            .finalize();

        let sig = Signature::from_slice(&sig_bytes[..64]).unwrap();
        let recid = RecoveryId::from_byte(sig_bytes[64] - 27).unwrap();
        let recovered = VerifyingKey::recover_from_prehash(&digest, &sig, recid).unwrap();

        let expected_addr = wm.eth_address().unwrap();
        let recovered_addr = derive::secp256k1::eth_address_from_verifying_key(&recovered);
        assert_eq!(
            recovered_addr, expected_addr,
            "Recovered signer must match wallet ETH address"
        );
    }

    #[test]
    fn encode_varint_boundary_values() {
        assert_eq!(encode_varint(0), vec![0]);
        assert_eq!(encode_varint(1), vec![1]);
        assert_eq!(encode_varint(0xfc), vec![0xfc]);
        assert_eq!(encode_varint(0xfd), vec![0xfd, 0xfd, 0x00]);
        assert_eq!(encode_varint(0xffff), vec![0xfd, 0xff, 0xff]);
        assert_eq!(encode_varint(0x10000), vec![0xfe, 0x00, 0x00, 0x01, 0x00]);
        assert_eq!(
            encode_varint(0x1_0000_0000),
            vec![0xff, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]
        );
    }

    #[test]
    fn btc_sign_message_produces_base64_65_bytes() {
        use base64::{engine::general_purpose::STANDARD, Engine};

        let mut wm = WalletManager::new();
        let mnemonic = bip39::Mnemonic::parse(
            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
        ).unwrap();
        wm.root_keypair = Some(derive::root_keypair_from_mnemonic(&mnemonic));
        wm.btc_key = Some(derive::secp256k1::btc_key(&mnemonic));

        let b64 = wm.btc_sign_message(b"Hello Bitcoin!").unwrap();
        let raw = STANDARD.decode(&b64).unwrap();
        assert_eq!(raw.len(), 65, "BIP-137 sig must be 65 bytes");
        // Header byte for native SegWit P2WPKH: 39..42
        assert!(
            (39..=42).contains(&raw[0]),
            "header must be 39+recid, got {}",
            raw[0]
        );
    }

    #[test]
    fn btc_sign_message_recovers_to_correct_key() {
        use base64::{engine::general_purpose::STANDARD, Engine};
        use k256::ecdsa::{RecoveryId, Signature, VerifyingKey};
        use sha2::{Digest, Sha256};

        let mut wm = WalletManager::new();
        let mnemonic = bip39::Mnemonic::parse(
            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
        ).unwrap();
        wm.root_keypair = Some(derive::root_keypair_from_mnemonic(&mnemonic));
        wm.btc_key = Some(derive::secp256k1::btc_key(&mnemonic));

        let msg = b"test message for recovery";
        let b64 = wm.btc_sign_message(msg).unwrap();
        let raw = STANDARD.decode(&b64).unwrap();

        // Reconstruct the digest
        let prefix = b"\x18Bitcoin Signed Message:\n";
        let msg_len = encode_varint(msg.len() as u64);
        let mut preimage = Vec::new();
        preimage.extend_from_slice(prefix);
        preimage.extend_from_slice(&msg_len);
        preimage.extend_from_slice(msg);
        let digest: [u8; 32] = Sha256::digest(Sha256::digest(&preimage)).into();

        let recid = RecoveryId::from_byte(raw[0] - 39).unwrap();
        let sig = Signature::from_slice(&raw[1..]).unwrap();
        let recovered = VerifyingKey::recover_from_prehash(&digest, &sig, recid).unwrap();

        let expected_vk = VerifyingKey::from(wm.btc_key.as_ref().unwrap());
        assert_eq!(recovered, expected_vk, "Recovered key must match BTC key");
    }

    #[test]
    fn btc_sign_message_fails_when_locked() {
        let mut wm = WalletManager::new();
        assert!(wm.btc_sign_message(b"test").is_err());
    }

    #[test]
    fn btc_sign_message_rejects_oversized() {
        let mut wm = WalletManager::new();
        let mnemonic = bip39::Mnemonic::parse(
            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
        ).unwrap();
        wm.root_keypair = Some(derive::root_keypair_from_mnemonic(&mnemonic));
        wm.btc_key = Some(derive::secp256k1::btc_key(&mnemonic));

        let big = vec![0u8; MAX_SIGN_PAYLOAD + 1];
        assert!(wm.btc_sign_message(&big).is_err());
    }

    #[test]
    fn btc_sign_message_deterministic() {
        let mut wm = WalletManager::new();
        let mnemonic = bip39::Mnemonic::parse(
            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
        ).unwrap();
        wm.root_keypair = Some(derive::root_keypair_from_mnemonic(&mnemonic));
        wm.btc_key = Some(derive::secp256k1::btc_key(&mnemonic));

        let sig1 = wm.btc_sign_message(b"deterministic test").unwrap();
        let sig2 = wm.btc_sign_message(b"deterministic test").unwrap();
        assert_eq!(
            sig1, sig2,
            "RFC 6979 nonces must produce identical signatures"
        );
    }

    #[test]
    fn btc_sign_message_empty_string() {
        use base64::{engine::general_purpose::STANDARD, Engine};

        let mut wm = WalletManager::new();
        let mnemonic = bip39::Mnemonic::parse(
            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
        ).unwrap();
        wm.root_keypair = Some(derive::root_keypair_from_mnemonic(&mnemonic));
        wm.btc_key = Some(derive::secp256k1::btc_key(&mnemonic));

        let b64 = wm.btc_sign_message(b"").unwrap();
        let raw = STANDARD.decode(&b64).unwrap();
        assert_eq!(raw.len(), 65);
    }

    #[test]
    fn btc_sign_raw_produces_der() {
        let mut wm = WalletManager::new();
        let mnemonic = bip39::Mnemonic::parse(
            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
        ).unwrap();
        wm.root_keypair = Some(derive::root_keypair_from_mnemonic(&mnemonic));
        wm.btc_key = Some(derive::secp256k1::btc_key(&mnemonic));

        let digest = [0x42u8; 32];
        let sig = wm.btc_sign_raw(&digest).unwrap();
        // DER-encoded ECDSA signatures are 70-72 bytes typically
        assert!(
            sig.len() >= 68 && sig.len() <= 73,
            "DER sig len: {}",
            sig.len()
        );
        // DER starts with 0x30
        assert_eq!(sig[0], 0x30, "DER encoding must start with 0x30");
    }

    // -----------------------------------------------------------------------
    // chat_material() tests
    // -----------------------------------------------------------------------

    fn unlocked_wallet() -> WalletManager {
        let mut wm = WalletManager::new();
        let mnemonic = bip39::Mnemonic::parse(
            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
        ).unwrap();
        wm.root_keypair = Some(derive::root_keypair_from_mnemonic(&mnemonic));
        wm
    }

    #[test]
    fn test_chat_material_returns_valid_key_triple() {
        let mut wm = unlocked_wallet();
        let (account_id, p256_secret, p256_public) = wm.chat_material().unwrap();

        // account_id is a 32-byte sr25519 public key
        assert_eq!(account_id.len(), 32, "account_id must be 32 bytes");

        // p256_secret is the scalar wrapped in Zeroizing — check it is 32 bytes
        assert_eq!(p256_secret.len(), 32, "p256_secret must be 32 bytes");

        // p256_public is uncompressed (0x04 || X || Y = 65 bytes)
        assert_eq!(p256_public.len(), 65, "p256_public must be 65 bytes");
        assert_eq!(
            p256_public[0], 0x04,
            "uncompressed P-256 key must start with 0x04, got 0x{:02x}",
            p256_public[0]
        );
    }

    #[test]
    fn test_chat_material_is_deterministic() {
        let mut wm1 = unlocked_wallet();
        let mut wm2 = unlocked_wallet();

        let (id1, secret1, pub1) = wm1.chat_material().unwrap();
        let (id2, secret2, pub2) = wm2.chat_material().unwrap();

        // The same mnemonic must produce identical key material on every call.
        assert_eq!(id1, id2, "account_id must be deterministic");
        assert_eq!(*secret1, *secret2, "p256_secret must be deterministic");
        assert_eq!(pub1, pub2, "p256_public must be deterministic");
    }

    #[test]
    fn test_chat_material_requires_unlocked_wallet() {
        // WalletManager::new() leaves root_keypair == None (locked / no wallet).
        let mut wm = WalletManager::new();
        let result = wm.chat_material();
        assert!(
            result.is_err(),
            "chat_material() on a locked wallet must return Err"
        );
    }

    // -----------------------------------------------------------------------
    // chat_identity() / chat_sign() tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_chat_identity_matches_material_account_id() {
        let mut wm = unlocked_wallet();
        let identity = wm.chat_identity().unwrap();
        let (account_id, _, _) = wm.chat_material().unwrap();
        assert_eq!(
            identity, account_id,
            "chat_identity() must return the same account_id as chat_material()"
        );
    }

    #[test]
    fn test_chat_sign_produces_valid_signature() {
        const PAYLOAD: &[u8] = b"chat protocol test payload";

        let mut wm = unlocked_wallet();

        let pubkey_bytes = wm.chat_identity().unwrap();
        let sig_bytes = wm.chat_sign(PAYLOAD).unwrap();

        assert_eq!(sig_bytes.len(), 64, "sr25519 signature must be 64 bytes");

        // Verify the signature using schnorrkel directly.
        let pubkey = schnorrkel::PublicKey::from_bytes(&pubkey_bytes)
            .expect("chat_identity() must produce a valid sr25519 public key");
        let sig = schnorrkel::Signature::from_bytes(&sig_bytes)
            .expect("chat_sign() must produce a valid schnorrkel signature");
        let ctx = schnorrkel::signing_context(SIGNING_CTX);
        assert!(
            pubkey.verify(ctx.bytes(PAYLOAD), &sig).is_ok(),
            "sr25519 chat signature must verify against chat_identity() pubkey"
        );
    }

    // -----------------------------------------------------------------------
    // Ed25519 tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_ed25519_public_key_returns_32_bytes() {
        let mut wm = unlocked_wallet();
        let pk = wm.ed25519_public_key("com.test.app", 0).unwrap();
        assert_eq!(pk.len(), 32, "Ed25519 public key must be 32 bytes");
    }

    #[test]
    fn test_ed25519_sign_and_verify() {
        use ed25519_dalek::{Signature, VerifyingKey};

        const PAYLOAD: &[u8] = b"ed25519 sign test payload";

        let mut wm = unlocked_wallet();
        let pk_bytes = wm.ed25519_public_key("com.test.app", 0).unwrap();
        let sig_bytes = wm.sign_ed25519("com.test.app", 0, PAYLOAD).unwrap();

        assert_eq!(sig_bytes.len(), 64, "Ed25519 signature must be 64 bytes");

        let vk =
            VerifyingKey::from_bytes(pk_bytes.as_slice().try_into().expect("pk must be 32 bytes"))
                .expect("valid Ed25519 verifying key");
        let sig = Signature::from_slice(&sig_bytes).expect("valid Ed25519 signature");
        assert!(
            vk.verify_strict(PAYLOAD, &sig).is_ok(),
            "Ed25519 signature must verify against the derived public key"
        );
    }

    #[test]
    fn test_ed25519_path_differs_from_sr25519() {
        let mut wm = unlocked_wallet();
        let ed25519_pk = wm.ed25519_public_key("com.test.app", 0).unwrap();
        let sr25519_pk = wm.app_public_key("com.test.app", 0).unwrap();
        // Both are 32 bytes but must differ — different derivation trees.
        assert_ne!(
            ed25519_pk.as_slice(),
            sr25519_pk.as_slice(),
            "Ed25519 and sr25519 paths must produce different public keys"
        );
    }

    #[test]
    fn test_ed25519_locked_returns_error() {
        let mut wm = WalletManager::new();
        assert!(
            wm.ed25519_public_key("com.test.app", 0).is_err(),
            "ed25519_public_key() must fail when wallet is locked"
        );
        assert!(
            wm.sign_ed25519("com.test.app", 0, b"payload").is_err(),
            "sign_ed25519() must fail when wallet is locked"
        );
    }

    // -----------------------------------------------------------------------
    // Substrate ECDSA tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_substrate_ecdsa_public_key_returns_33_bytes() {
        let mut wm = unlocked_wallet();
        let pk = wm.substrate_ecdsa_public_key("com.test.app", 0).unwrap();
        assert_eq!(
            pk.len(),
            33,
            "Compressed secp256k1 public key must be 33 bytes"
        );
        // Compressed points start with 0x02 or 0x03.
        assert!(
            pk[0] == 0x02 || pk[0] == 0x03,
            "Compressed public key must start with 0x02 or 0x03, got 0x{:02x}",
            pk[0]
        );
    }

    #[test]
    fn test_substrate_ecdsa_sign_and_verify() {
        use blake2::digest::{consts::U32, Digest};
        use k256::ecdsa::{RecoveryId, Signature, VerifyingKey};

        const PAYLOAD: &[u8] = b"substrate ecdsa sign test payload";

        let mut wm = unlocked_wallet();
        let pk_bytes = wm.substrate_ecdsa_public_key("com.test.app", 0).unwrap();
        let sig_bytes = wm.sign_substrate_ecdsa("com.test.app", 0, PAYLOAD).unwrap();

        assert_eq!(
            sig_bytes.len(),
            65,
            "Substrate ECDSA signature must be 65 bytes"
        );

        // Prehash with BLAKE2b-256 (matches the signing path).
        let mut hasher = blake2::Blake2b::<U32>::new();
        hasher.update(PAYLOAD);
        let digest: [u8; 32] = hasher.finalize().into();

        let sig = Signature::from_slice(&sig_bytes[..64]).expect("valid ECDSA signature");
        let recid = RecoveryId::from_byte(sig_bytes[64]).expect("valid recovery id");
        let recovered = VerifyingKey::recover_from_prehash(&digest, &sig, recid)
            .expect("key recovery must succeed");

        // The recovered key must match the public key returned by the wallet.
        let expected_vk =
            VerifyingKey::from_sec1_bytes(&pk_bytes).expect("valid compressed public key");
        assert_eq!(
            recovered, expected_vk,
            "Recovered key must match substrate_ecdsa_public_key()"
        );
    }

    #[test]
    fn test_substrate_ecdsa_locked_returns_error() {
        let mut wm = WalletManager::new();
        assert!(
            wm.substrate_ecdsa_public_key("com.test.app", 0).is_err(),
            "substrate_ecdsa_public_key() must fail when wallet is locked"
        );
        assert!(
            wm.sign_substrate_ecdsa("com.test.app", 0, b"payload")
                .is_err(),
            "sign_substrate_ecdsa() must fail when wallet is locked"
        );
    }
}
