//! WASM bindings for relicario.
//!
//! The bridge exposes an opaque `SessionHandle` API: the master key is held
//! entirely in WASM linear memory, wrapped in `Zeroizing<[u8; 32]>`, and
//! looked up per call via a u32 handle. JS cannot read key bytes.

mod session;
mod device;

use wasm_bindgen::prelude::*;
use zeroize::Zeroizing;

use relicario_core::{derive_master_key, imgsecret, KdfParams};

/// Handle returned from `unlock`. Backed by a `u32`; opaque to JS.
///
/// Dropping the handle (or invoking `.free()` from JS) removes the entry from
/// the session registry, zeroizing the wrapped master key and image_secret.
/// `lock(handle)` remains available as the explicit early-cleanup path; the
/// `Drop` impl is the safety net that catches code paths which forget to call
/// `lock` before letting the handle go out of scope.
#[wasm_bindgen]
pub struct SessionHandle(u32);

#[wasm_bindgen]
impl SessionHandle {
    #[wasm_bindgen(getter)]
    pub fn value(&self) -> u32 { self.0 }
}

impl Drop for SessionHandle {
    fn drop(&mut self) { let _ = session::remove(self.0); }
}

#[doc(hidden)]
pub fn __test_make_handle() -> SessionHandle {
    SessionHandle(session::insert(
        Zeroizing::new([0x77u8; 32]),
        Zeroizing::new([0u8; 32]),
    ))
}

#[doc(hidden)]
pub fn __test_session_exists(handle: u32) -> bool {
    session::with(handle, |_| ()).is_some()
}

#[wasm_bindgen]
pub fn unlock(
    passphrase: &str,
    image_bytes: &[u8],
    salt: &[u8],
    params_json: &str,
) -> Result<SessionHandle, JsError> {
    let params: KdfParams = serde_json::from_str(params_json)
        .map_err(|e| JsError::new(&format!("params: {e}")))?;
    let image_secret = imgsecret::extract(image_bytes)
        .map_err(|e| JsError::new(&e.to_string()))?;
    let salt_arr: &[u8; 32] = salt.try_into()
        .map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
    let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, &params)
        .map_err(|e| JsError::new(&e.to_string()))?;
    let stored_secret = Zeroizing::new(image_secret);
    let handle = session::insert(master_key, stored_secret);
    Ok(SessionHandle(handle))
}

#[wasm_bindgen]
pub fn lock(handle: &SessionHandle) -> bool {
    session::remove(handle.0)
}

// ── Pluggable second factor: key-file bindings (Task 3) ──────────────────────

/// Unlock using a raw 32-byte secret directly, bypassing steganographic
/// extraction. Mirrors `unlock` exactly, except the caller supplies the
/// secret bytes instead of a carrier image.
#[wasm_bindgen]
pub fn unlock_with_secret(
    passphrase: &str,
    secret: &[u8],
    salt: &[u8],
    params_json: &str,
) -> Result<SessionHandle, JsError> {
    let params: KdfParams = serde_json::from_str(params_json)
        .map_err(|e| JsError::new(&format!("params: {e}")))?;
    let secret_arr: &[u8; 32] = secret.try_into()
        .map_err(|_| JsError::new("secret must be exactly 32 bytes"))?;
    let salt_arr: &[u8; 32] = salt.try_into()
        .map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
    let master_key = derive_master_key(passphrase.as_bytes(), secret_arr, salt_arr, &params)
        .map_err(|e| JsError::new(&e.to_string()))?;
    let handle = session::insert(master_key, Zeroizing::new(*secret_arr));
    Ok(SessionHandle(handle))
}

/// Encode a 32-byte secret into the `relicario-keyfile-v1` armor format.
/// Returns the UTF-8 bytes suitable for writing to a `.relkey` file.
#[wasm_bindgen]
pub fn keyfile_encode(secret: &[u8]) -> Result<Vec<u8>, JsError> {
    let arr: &[u8; 32] = secret.try_into()
        .map_err(|_| JsError::new("secret must be exactly 32 bytes"))?;
    Ok(relicario_core::keyfile::keyfile_encode(arr))
}

/// Decode a `relicario-keyfile-v1` armored file, returning the raw 32-byte secret.
#[wasm_bindgen]
pub fn keyfile_decode(bytes: &[u8]) -> Result<Vec<u8>, JsError> {
    let s = relicario_core::keyfile::keyfile_decode(bytes)
        .map_err(|e| JsError::new(&e.to_string()))?;
    Ok(s.to_vec())
}

use serde_wasm_bindgen::Serializer;
use relicario_core::{
    decrypt_item, decrypt_manifest, decrypt_settings,
    encrypt_item, encrypt_manifest, encrypt_settings,
    Item, Manifest, VaultSettings,
};

fn need_key(handle: &SessionHandle) -> Result<(), JsError> {
    if session::with(handle.0, |_| ()).is_some() { Ok(()) }
    else { Err(JsError::new("invalid or locked session handle")) }
}

fn js_value_for<T: serde::Serialize>(v: &T) -> Result<JsValue, JsError> {
    let ser = Serializer::new().serialize_maps_as_objects(true);
    v.serialize(&ser).map_err(|e| JsError::new(&e.to_string()))
}

#[wasm_bindgen]
pub fn manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
    need_key(handle)?;
    let out = session::with(handle.0, |k| decrypt_manifest(encrypted, k))
        .unwrap()
        .map_err(|e| JsError::new(&e.to_string()))?;
    js_value_for(&out)
}

#[wasm_bindgen]
pub fn manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result<Vec<u8>, JsError> {
    need_key(handle)?;
    let m: Manifest = serde_json::from_str(manifest_json)
        .map_err(|e| JsError::new(&format!("manifest json: {e}")))?;
    session::with(handle.0, |k| encrypt_manifest(&m, k))
        .unwrap()
        .map_err(|e| JsError::new(&e.to_string()))
}

#[wasm_bindgen]
pub fn item_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
    need_key(handle)?;
    let out = session::with(handle.0, |k| decrypt_item(encrypted, k))
        .unwrap()
        .map_err(|e| JsError::new(&e.to_string()))?;
    js_value_for(&out)
}

#[wasm_bindgen]
pub fn item_encrypt(handle: &SessionHandle, item_json: &str) -> Result<Vec<u8>, JsError> {
    need_key(handle)?;
    let item: Item = serde_json::from_str(item_json)
        .map_err(|e| JsError::new(&format!("item json: {e}")))?;
    session::with(handle.0, |k| encrypt_item(&item, k))
        .unwrap()
        .map_err(|e| JsError::new(&e.to_string()))
}

#[wasm_bindgen]
pub fn settings_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
    need_key(handle)?;
    let out = session::with(handle.0, |k| decrypt_settings(encrypted, k))
        .unwrap()
        .map_err(|e| JsError::new(&e.to_string()))?;
    js_value_for(&out)
}

#[wasm_bindgen]
pub fn settings_encrypt(handle: &SessionHandle, settings_json: &str) -> Result<Vec<u8>, JsError> {
    need_key(handle)?;
    let s: VaultSettings = serde_json::from_str(settings_json)
        .map_err(|e| JsError::new(&format!("settings json: {e}")))?;
    session::with(handle.0, |k| encrypt_settings(&s, k))
        .unwrap()
        .map_err(|e| JsError::new(&e.to_string()))
}

/// Returns the JSON for `VaultSettings::default()`. Used by the setup
/// wizard to encrypt and write a default settings.enc on new-vault setup.
/// Keeping this in WASM (instead of hand-encoding in TS) prevents drift
/// when the default VaultSettings shape changes in Rust.
#[wasm_bindgen]
pub fn default_vault_settings_json() -> Result<String, JsError> {
    let s = VaultSettings::default();
    serde_json::to_string(&s).map_err(|e| JsError::new(&e.to_string()))
}

// ── Task 20: attachment / generator / imgsecret / ID / TOTP bridges ─────────

use relicario_core::{decrypt_attachment, encrypt_attachment, FieldId, ItemId};

#[wasm_bindgen]
pub struct EncryptedAttachment {
    aid: String,
    bytes: Vec<u8>,
}

#[wasm_bindgen]
impl EncryptedAttachment {
    #[wasm_bindgen(getter)] pub fn aid(&self) -> String { self.aid.clone() }
    #[wasm_bindgen(getter)] pub fn bytes(&self) -> Vec<u8> { self.bytes.clone() }
}

#[wasm_bindgen]
pub fn attachment_encrypt(
    handle: &SessionHandle,
    plaintext: &[u8],
    max_bytes: u64,
) -> Result<EncryptedAttachment, JsError> {
    need_key(handle)?;
    let enc = session::with(handle.0, |k| encrypt_attachment(plaintext, k, max_bytes))
        .unwrap()
        .map_err(|e| JsError::new(&e.to_string()))?;
    Ok(EncryptedAttachment { aid: enc.id.as_str().to_owned(), bytes: enc.bytes })
}

#[wasm_bindgen]
pub fn attachment_decrypt(
    handle: &SessionHandle,
    encrypted: &[u8],
) -> Result<Vec<u8>, JsError> {
    need_key(handle)?;
    let plain = session::with(handle.0, |k| decrypt_attachment(encrypted, k))
        .unwrap()
        .map_err(|e| JsError::new(&e.to_string()))?;
    Ok(plain.to_vec())
}

#[wasm_bindgen] pub fn new_item_id() -> String { ItemId::new().as_str().to_owned() }
#[wasm_bindgen] pub fn new_field_id() -> String { FieldId::new().as_str().to_owned() }

use relicario_core::{
    generate_passphrase as core_generate_passphrase,
    generate_password as core_generate_password,
    rate_passphrase as core_rate_passphrase,
    GeneratorRequest,
};

#[wasm_bindgen]
pub fn generate_password(request_json: &str) -> Result<String, JsError> {
    let req: GeneratorRequest = serde_json::from_str(request_json)
        .map_err(|e| JsError::new(&format!("generator request: {e}")))?;
    let out = core_generate_password(&req).map_err(|e| JsError::new(&e.to_string()))?;
    Ok(out.as_str().to_owned())
}

#[wasm_bindgen]
pub fn generate_passphrase(request_json: &str) -> Result<String, JsError> {
    let req: GeneratorRequest = serde_json::from_str(request_json)
        .map_err(|e| JsError::new(&format!("generator request: {e}")))?;
    let out = core_generate_passphrase(&req).map_err(|e| JsError::new(&e.to_string()))?;
    Ok(out.as_str().to_owned())
}

#[wasm_bindgen]
pub fn rate_passphrase(p: &str) -> Result<JsValue, JsError> {
    let est = core_rate_passphrase(p);
    js_value_for(&serde_json::json!({
        "score": est.score,
        "guesses_log10": est.guesses_log10,
    }))
}

/// Register a new device, generating ed25519 keypairs for signing and deploy.
/// Returns JSON: { "signing_public_key": "ssh-ed25519 ...", "deploy_public_key": "ssh-ed25519 ..." }
/// Private keys are kept internal to WASM and never cross to JS.
#[wasm_bindgen]
pub fn register_device(name: &str) -> Result<JsValue, JsError> {
    let (signing_pub, deploy_pub) =
        device::register_device(name).map_err(|e| JsError::new(&e))?;

    js_value_for(&serde_json::json!({
        "signing_public_key": signing_pub,
        "deploy_public_key": deploy_pub,
    }))
}

/// Sign `data` using the registered device's signing key.
/// Returns JSON: { "signature": "<base64>" }
/// Errors if no device has been registered via register_device().
#[wasm_bindgen]
pub fn sign_for_git(data: &[u8]) -> Result<JsValue, JsError> {
    let signature = device::sign_for_git(data).map_err(|e| JsError::new(&e))?;

    js_value_for(&serde_json::json!({
        "signature": signature,
    }))
}

/// Get the current device's name and public keys.
/// Returns JSON: { "name": "...", "signing_public_key": "...", "deploy_public_key": "..." }
/// Returns null if no device is registered in this session.
#[wasm_bindgen]
pub fn get_device_info() -> Result<JsValue, JsError> {
    match device::get_device_info() {
        Some((name, signing_pub, deploy_pub)) => js_value_for(&serde_json::json!({
            "name": name,
            "signing_public_key": signing_pub,
            "deploy_public_key": deploy_pub,
        })),
        None => Ok(JsValue::NULL),
    }
}

/// Clear the in-memory device state (call on logout or before re-registration).
#[wasm_bindgen]
pub fn clear_device() {
    device::clear_device();
}

/// Extract field history from a decrypted item JSON.
/// Returns JSON array of { field_id, field_name, current_value, entries: [{ value, changed_at }] }
#[wasm_bindgen]
pub fn get_field_history(item_json: &str) -> Result<JsValue, JsError> {
    let item: Item = serde_json::from_str(item_json)
        .map_err(|e| JsError::new(&format!("item json: {e}")))?;

    let mut results = Vec::new();

    // Only section fields are tracked in field_history (set_field_value operates on sections).
    for section in &item.sections {
        for field in &section.fields {
            if field.value.is_history_tracked() {
                if let Some(entries) = item.field_history.get(&field.id) {
                    if !entries.is_empty() {
                        let current = match &field.value {
                            relicario_core::FieldValue::Password(v) => v.as_str().to_owned(),
                            relicario_core::FieldValue::Concealed(v) => v.as_str().to_owned(),
                            _ => String::new(),
                        };
                        results.push(serde_json::json!({
                            "field_id": field.id.as_str(),
                            "field_name": &field.label,
                            "current_value": current,
                            "entries": entries.iter().map(|e| serde_json::json!({
                                "value": e.value.as_str(),
                                "changed_at": e.replaced_at,
                            })).collect::<Vec<_>>(),
                        }));
                    }
                }
            }
        }
    }

    js_value_for(&results)
}

#[wasm_bindgen]
pub fn extract_image_secret(image_bytes: &[u8]) -> Result<Vec<u8>, JsError> {
    let s = imgsecret::extract(image_bytes).map_err(|e| JsError::new(&e.to_string()))?;
    Ok(s.to_vec())
}

#[wasm_bindgen]
pub fn embed_image_secret(carrier: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsError> {
    let s: &[u8; 32] = secret.try_into()
        .map_err(|_| JsError::new("secret must be exactly 32 bytes"))?;
    imgsecret::embed(carrier, s).map_err(|e| JsError::new(&e.to_string()))
}

// ── Pure parsers (no session needed) ────────────────────────────────────────

use relicario_core::{base32 as core_base32, mime as core_mime, MonthYear};

/// Parse a card-expiry string (`MM/YYYY` / `MM-YYYY` / `MM/YY`).
/// Returns a plain `{ month, year }` object on success.
#[wasm_bindgen]
pub fn parse_month_year(s: &str) -> Result<JsValue, JsError> {
    let my = MonthYear::parse(s).map_err(|e| JsError::new(&e.to_string()))?;
    js_value_for(&my)
}

/// Decode an RFC 4648 base32 string (case-insensitive, optional padding,
/// whitespace-stripped). Returned as `Uint8Array` on the JS side.
#[wasm_bindgen]
pub fn base32_decode_lenient(s: &str) -> Result<Vec<u8>, JsError> {
    core_base32::decode_rfc4648_lenient(s).map_err(|e| JsError::new(&e.to_string()))
}

/// Guess a MIME type from a filename's extension. Returns
/// `application/octet-stream` for unknown or missing extensions.
#[wasm_bindgen]
pub fn guess_mime(filename: &str) -> String {
    core_mime::guess_for_extension(filename).to_string()
}

use relicario_core::item_types::{TotpConfig, compute_totp_code};

#[wasm_bindgen]
pub struct TotpCode {
    code: String,
    expires_at: u64,
}

#[wasm_bindgen]
impl TotpCode {
    #[wasm_bindgen(getter)] pub fn code(&self) -> String { self.code.clone() }
    #[wasm_bindgen(getter)] pub fn expires_at(&self) -> u64 { self.expires_at }
}

#[wasm_bindgen]
pub fn totp_compute(
    config_json: &str,
    now_unix_seconds: u64,
) -> Result<TotpCode, JsError> {
    let cfg: TotpConfig = serde_json::from_str(config_json)
        .map_err(|e| JsError::new(&format!("totp config: {e}")))?;
    let code = compute_totp_code(&cfg, now_unix_seconds)
        .map_err(|e| JsError::new(&e.to_string()))?;
    let period = cfg.period_seconds as u64;
    let expires_at = ((now_unix_seconds / period) + 1) * period;
    Ok(TotpCode { code, expires_at })
}

// ── Backup container bridge ─────────────────────────────────────────────────

use base64::Engine;

use relicario_core::backup::{
    pack_backup as core_pack_backup,
    unpack_backup as core_unpack_backup,
    BackupInput, BackupItem, BackupAttachment,
};

/// Pack a vault into a `.relbak` byte vector.
///
/// `input_json` shape:
/// ```json
/// {
///   "salt": "<base64>",
///   "params_json": "...",
///   "devices_json": "...",
///   "manifest_enc": "<base64>",
///   "settings_enc": "<base64>",
///   "items": [{"id": "<hex>", "ciphertext": "<base64>"}, ...],
///   "attachments": [{"item_id": "<hex>", "attachment_id": "<hex>", "ciphertext": "<base64>"}, ...],
///   "reference_jpg": "<base64>" | null,
///   "git_archive": "<base64>" | null
/// }
/// ```
#[wasm_bindgen]
pub fn pack_backup_json(input_json: &str, passphrase: &str) -> Result<Vec<u8>, JsError> {
    #[derive(serde::Deserialize)]
    struct InJson {
        salt: String,
        params_json: String,
        devices_json: String,
        manifest_enc: String,
        settings_enc: String,
        items: Vec<InItem>,
        attachments: Vec<InAttachment>,
        reference_jpg: Option<String>,
        git_archive: Option<String>,
    }
    #[derive(serde::Deserialize)]
    struct InItem { id: String, ciphertext: String }
    #[derive(serde::Deserialize)]
    struct InAttachment { item_id: String, attachment_id: String, ciphertext: String }

    let parsed: InJson = serde_json::from_str(input_json)
        .map_err(|e| JsError::new(&format!("backup input: {e}")))?;

    let b64 = base64::engine::general_purpose::STANDARD;
    let salt = b64.decode(&parsed.salt).map_err(|e| JsError::new(&e.to_string()))?;
    let manifest = b64.decode(&parsed.manifest_enc).map_err(|e| JsError::new(&e.to_string()))?;
    let settings = b64.decode(&parsed.settings_enc).map_err(|e| JsError::new(&e.to_string()))?;
    let items_bytes: Vec<(String, Vec<u8>)> = parsed.items.iter()
        .map(|i| {
            let ct = b64.decode(&i.ciphertext).map_err(|e| JsError::new(&e.to_string()))?;
            Ok((i.id.clone(), ct))
        })
        .collect::<Result<Vec<_>, JsError>>()?;
    let attach_bytes: Vec<(String, String, Vec<u8>)> = parsed.attachments.iter()
        .map(|a| {
            let ct = b64.decode(&a.ciphertext).map_err(|e| JsError::new(&e.to_string()))?;
            Ok((a.item_id.clone(), a.attachment_id.clone(), ct))
        })
        .collect::<Result<Vec<_>, JsError>>()?;

    let ref_bytes = parsed.reference_jpg.as_deref()
        .map(|s| b64.decode(s))
        .transpose()
        .map_err(|e| JsError::new(&e.to_string()))?;
    let git_bytes = parsed.git_archive.as_deref()
        .map(|s| b64.decode(s))
        .transpose()
        .map_err(|e| JsError::new(&e.to_string()))?;

    let items_refs: Vec<BackupItem> = items_bytes.iter()
        .map(|(id, ct)| BackupItem { id: id.clone(), ciphertext: ct })
        .collect();
    let attach_refs: Vec<BackupAttachment> = attach_bytes.iter()
        .map(|(iid, aid, ct)| BackupAttachment {
            item_id: iid.clone(),
            attachment_id: aid.clone(),
            ciphertext: ct,
        })
        .collect();

    let input = BackupInput {
        salt: &salt,
        params_json: &parsed.params_json,
        devices_json: &parsed.devices_json,
        manifest_enc: &manifest,
        settings_enc: &settings,
        items: items_refs,
        attachments: attach_refs,
        reference_jpg: ref_bytes.as_deref(),
        git_archive: git_bytes.as_deref(),
    };
    core_pack_backup(input, passphrase).map_err(|e| JsError::new(&e.to_string()))
}

/// Unpack `.relbak` bytes; returns the JSON shape that mirrors `BackupOutput`,
/// with binary fields base64-encoded.
#[wasm_bindgen]
pub fn unpack_backup_json(bytes: &[u8], passphrase: &str) -> Result<String, JsError> {
    let out = core_unpack_backup(bytes, passphrase)
        .map_err(|e| JsError::new(&e.to_string()))?;

    let b64 = base64::engine::general_purpose::STANDARD;
    let json = serde_json::json!({
        "salt": b64.encode(out.salt),
        "params_json": out.params_json,
        "devices_json": out.devices_json,
        "manifest_enc": b64.encode(&out.manifest_enc),
        "settings_enc": b64.encode(&out.settings_enc),
        "items": out.items.iter().map(|i| serde_json::json!({
            "id": i.id,
            "ciphertext": b64.encode(&i.ciphertext),
        })).collect::<Vec<_>>(),
        "attachments": out.attachments.iter().map(|a| serde_json::json!({
            "item_id": a.item_id,
            "attachment_id": a.attachment_id,
            "ciphertext": b64.encode(&a.ciphertext),
        })).collect::<Vec<_>>(),
        "reference_jpg": out.reference_jpg.as_ref().map(|b| b64.encode(b)),
        "git_archive": out.git_archive.as_ref().map(|b| b64.encode(b)),
        "created_at": out.created_at,
    });
    Ok(json.to_string())
}

// ── LastPass CSV importer bridge ────────────────────────────────────────────

use relicario_core::import_lastpass::parse_lastpass_csv as core_parse_lastpass_csv;

/// Parse a LastPass CSV into `{ items: [Item], warnings: [ImportWarning] }`.
///
/// Items are returned as full `Item` JSON objects with freshly-minted IDs
/// and timestamps already populated. The SW caller is responsible for
/// encrypting + writing them; this bridge stays pure so the preview UI
/// can render counts without committing anything.
#[wasm_bindgen]
pub fn parse_lastpass_csv_json(csv_bytes: &[u8]) -> Result<String, JsError> {
    let (items, warnings) = core_parse_lastpass_csv(csv_bytes)
        .map_err(|e| JsError::new(&e.to_string()))?;

    let json = serde_json::json!({
        "items": items,
        "warnings": warnings,
    });
    Ok(json.to_string())
}

// ── Recovery QR bindings ─────────────────────────────────────────────────────

use relicario_core::{generate_recovery_qr, recovery_qr_to_svg, unwrap_recovery_qr};

/// Generate a recovery QR SVG for the current session.
/// Returns the SVG string. The passphrase wraps the image_secret under a
/// separate key (domain-separated from the master key derivation).
#[wasm_bindgen]
pub fn wasm_generate_recovery_qr(
    handle: &SessionHandle,
    passphrase: &str,
) -> Result<String, JsError> {
    let payload = session::with_image_secret(handle.0, |s| generate_recovery_qr(passphrase, s))
        .ok_or_else(|| JsError::new("invalid or locked session handle"))?
        .map_err(|e| JsError::new(&e.to_string()))?;
    Ok(recovery_qr_to_svg(&payload))
}

/// Unwrap a recovery QR payload (base64-encoded 109-byte blob) using the passphrase.
/// Returns the raw image_secret bytes (32 bytes).
#[wasm_bindgen]
pub fn wasm_unwrap_recovery_qr(
    payload_b64: &str,
    passphrase: &str,
) -> Result<Vec<u8>, JsError> {
    use base64::{engine::general_purpose::STANDARD, Engine};
    let bytes = STANDARD.decode(payload_b64)
        .map_err(|e| JsError::new(&format!("base64: {e}")))?;
    let recovered = unwrap_recovery_qr(&bytes, passphrase)
        .map_err(|e| JsError::new(&e.to_string()))?;
    Ok(recovered.to_vec())
}

#[cfg(test)]
mod session_tests {
    use super::*;
    use zeroize::Zeroizing;

    /// Synthesize a carrier JPEG for embedding tests (mirrors core's private helper).
    fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
        use image::codecs::jpeg::JpegEncoder;
        use image::{ImageBuffer, ImageEncoder, Rgb};
        let img = ImageBuffer::from_fn(width, height, |x, y| {
            Rgb([
                ((x * 7 + y * 13) % 256) as u8,
                ((x * 11 + y * 3) % 256) as u8,
                ((x * 5 + y * 17) % 256) as u8,
            ])
        });
        let mut buf = Vec::new();
        JpegEncoder::new_with_quality(&mut buf, 92)
            .write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8)
            .unwrap();
        buf
    }

    /// SECURITY PROOF: both unlock paths derive the identical master key.
    ///
    /// The key-file path (`unlock_with_secret`) must derive the same Argon2id
    /// master key as the stego-image path (`unlock`) when given the same secret,
    /// passphrase, salt, and KDF params. This is the core security argument:
    /// the key file is simply an alternative transport for the same 32-byte
    /// secret that the stego-image carries.
    #[test]
    fn unlock_with_secret_matches_unlock_from_jpeg() {
        session::clear();
        let secret = [3u8; 32];
        let salt = [1u8; 32];
        let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#;

        // Image transport: embed the secret into a carrier JPEG, then unlock from it.
        let carrier = make_test_jpeg(256, 256);
        let stego = imgsecret::embed(&carrier, &secret).expect("embed secret into carrier");
        let h_img = unlock("correct horse", &stego, &salt, params).expect("unlock from jpeg");

        // Key-file transport: unlock from the raw secret directly.
        let h_key = unlock_with_secret("correct horse", &secret, &salt, params)
            .expect("unlock_with_secret");

        // PROOF: both sessions hold a byte-identical master key — the key-file
        // path derives the SAME master key as the stego-image path for the
        // same secret. We compare stored master keys directly (avoids js-sys,
        // which is unavailable natively; stronger than a cross-decrypt anyway).
        let extract = |h: u32| {
            session::with(h, |k| {
                let mut a = [0u8; 32];
                a.copy_from_slice(&k[..]);
                a
            })
            .expect("session present")
        };
        assert_eq!(
            extract(h_img.value()),
            extract(h_key.value()),
            "key-file path must derive the identical master key as the stego-image path"
        );
    }

    #[test]
    fn insert_then_remove_clears_entry() {
        session::clear();
        let h = session::insert(Zeroizing::new([0x11u8; 32]), Zeroizing::new([0u8; 32]));
        assert_ne!(h, 0);
        assert!(session::remove(h));
        assert!(!session::remove(h));   // second remove false
    }

    #[test]
    fn dropping_session_handle_clears_registry_entry() {
        session::clear();
        let handle = SessionHandle(session::insert(
            Zeroizing::new([0x33u8; 32]),
            Zeroizing::new([0u8; 32]),
        ));
        let id = handle.value();
        assert!(session::with(id, |_| ()).is_some());
        drop(handle);
        assert!(session::with(id, |_| ()).is_none());
    }

    #[test]
    fn with_yields_key_only_while_session_lives() {
        session::clear();
        let h = session::insert(Zeroizing::new([0x22u8; 32]), Zeroizing::new([0u8; 32]));
        let byte = session::with(h, |k| k[0]);
        assert_eq!(byte, Some(0x22));
        session::remove(h);
        let byte = session::with(h, |k| k[0]);
        assert_eq!(byte, None);
    }

    #[test]
    fn manifest_round_trip_via_handle() {
        use relicario_core::{Manifest, decrypt_manifest};
        session::clear();
        let h = session::insert(Zeroizing::new([0x55u8; 32]), Zeroizing::new([0u8; 32]));
        let handle = SessionHandle(h);
        let key = Zeroizing::new([0x55u8; 32]);
        let empty = Manifest::new();
        let bytes = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
        assert!(!bytes.is_empty());
        // Decrypt via core directly (avoids js-sys on native).
        let parsed: Manifest = decrypt_manifest(&bytes, &key).unwrap();
        assert_eq!(parsed.items.len(), 0);
        // Random nonces mean two encryptions of the same plaintext differ.
        let bytes2 = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
        assert_ne!(bytes, bytes2, "nonces must differ");
    }

    #[test]
    fn parse_lastpass_csv_json_returns_items_and_warnings() {
        // Row 1 imports cleanly; row 2 has an empty `name` and is skipped
        // with a warning.
        let csv = "url,username,password,totp,extra,name,grouping,fav\n\
                   https://x,alice,hunter2,,,GitHub,Work,1\n\
                   https://y,bob,hunter2,,,,,";
        let json = super::parse_lastpass_csv_json(csv.as_bytes()).unwrap();
        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert_eq!(v["items"].as_array().unwrap().len(), 1);
        assert_eq!(v["warnings"].as_array().unwrap().len(), 1);
        assert!(v["warnings"][0]["message"].as_str().unwrap().contains("name"));
        // The item's title round-trips as a plain JSON string.
        assert_eq!(v["items"][0]["title"].as_str().unwrap(), "GitHub");
    }

    #[test]
    fn parse_lastpass_csv_json_propagates_header_errors() {
        // Test the underlying core function directly since native tests
        // can't call wasm_bindgen functions.
        use relicario_core::import_lastpass::parse_lastpass_csv;
        let bad = "name,user,pass\nA,u,p\n";
        let err = parse_lastpass_csv(bad.as_bytes());
        // Should fail with a header validation error.
        assert!(err.is_err());
    }

    #[test]
    fn base32_decode_lenient_round_trips_known_vector() {
        let bytes = super::base32_decode_lenient("MZXW6YTBOI").unwrap();
        assert_eq!(bytes, b"foobar");
    }

    #[test]
    fn guess_mime_known_and_unknown_extensions() {
        assert_eq!(super::guess_mime("doc.pdf"), "application/pdf");
        assert_eq!(super::guess_mime("photo.JPEG"), "image/jpeg");
        assert_eq!(super::guess_mime("file.xyz"), "application/octet-stream");
    }

    // Error paths and JsValue serialization can't be exercised natively —
    // JsError::new and serde_wasm_bindgen::Serializer call wasm-bindgen
    // imports that panic off-wasm (same constraint as
    // `parse_lastpass_csv_json_propagates_header_errors` above). Those
    // paths are covered in core: `time::tests::parse_rejects_malformed`
    // and `base32::tests::decode_rfc4648_lenient_rejects_non_alphabet_chars`.
}
