//! Import data from ZIP archives exported by the export module.
//!
//! Import actions:
//! - AddAsIdentity: create a new identity from the export's key + data
//! - ImportPublicPosts: import only public posts into the current identity (new PostIds)
//! - MergeWithKey: decrypt encrypted posts using provided key, re-encrypt for current identity

use std::io::Read;
use std::path::Path;

use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};

use crate::blob::BlobStore;
use crate::content::compute_post_id;
use crate::export::{ExportManifest, ExportedPost};
use crate::storage::StoragePool;
use crate::types::{Attachment, NodeId, Post, PostVisibility, PostingIdentity, VisibilityIntent};

/// Parse the Debug-formatted intent string that the export writes
/// (`format!("{:?}", visibility_intent)`) back into a `VisibilityIntent`.
///
/// Falls back to a visibility-shape heuristic when the export carries no
/// intent (pre-v0.6.1 source DBs never populated the intent column, so the
/// export writes `None`). Without this fallback, old exports imported as a
/// persona would bucket every encrypted post under `Friends` and DMs would
/// silently disappear from the Messages tab.
fn parse_exported_intent(raw: Option<&str>, vis: &PostVisibility) -> VisibilityIntent {
    // Try a clean prefix match on the Debug form first.
    if let Some(s) = raw {
        let s = s.trim();
        if s == "Public" { return VisibilityIntent::Public; }
        if s == "Friends" { return VisibilityIntent::Friends; }
        if s.starts_with("Circle(") {
            // `Circle("name")` — pull the quoted string.
            if let Some(q) = s.find('"') {
                if let Some(end) = s.rfind('"') {
                    if end > q {
                        let name = &s[q + 1..end];
                        return VisibilityIntent::Circle(name.to_string());
                    }
                }
            }
            return VisibilityIntent::Circle(String::new());
        }
        if s.starts_with("Direct(") {
            // Debug form of Vec<NodeId> is messy to parse reliably. Recover
            // the recipient list from the PostVisibility if available; the
            // set is semantically equivalent (both derive from the same
            // wrapping list).
            if let PostVisibility::Encrypted { recipients } = vis {
                let ids: Vec<NodeId> = recipients.iter().map(|wk| wk.recipient).collect();
                return VisibilityIntent::Direct(ids);
            }
            return VisibilityIntent::Direct(vec![]);
        }
        if s == "Control" { return VisibilityIntent::Control; }
        if s == "Profile" { return VisibilityIntent::Profile; }
        if s == "Announcement" { return VisibilityIntent::Announcement; }
        if s == "GroupKeyDistribute" { return VisibilityIntent::GroupKeyDistribute; }
    }
    // No intent recorded — infer from the visibility shape.
    match vis {
        PostVisibility::Public => VisibilityIntent::Public,
        // FoF Layer 3: FoFClosed pairs with VisibilityIntent::Public.
        // The FoF gating handles audience; intent is the structural tag.
        PostVisibility::FoFClosed => VisibilityIntent::Public,
        PostVisibility::Encrypted { recipients } => {
            // Heuristic: DMs typically wrap to 1-2 people (recipient + self);
            // Friends posts wrap to every public follow (usually many).
            // Use 3 as a threshold that treats small-group conversations as
            // Direct while letting broadcast-to-friends stay Friends.
            //
            // Rationale: the pre-intent export was built before we could
            // distinguish these cases, so we prefer a best-effort partition
            // that gets DMs into the Messages tab (where the user will look
            // for them) rather than filing them under Friends.
            if recipients.len() <= 3 {
                VisibilityIntent::Direct(recipients.iter().map(|wk| wk.recipient).collect())
            } else {
                VisibilityIntent::Friends
            }
        }
        PostVisibility::GroupEncrypted { .. } => {
            // No way to recover the circle name from wire visibility alone.
            VisibilityIntent::Circle(String::new())
        }
    }
}

/// Extract posting_identities.json from an export ZIP and upsert each entry
/// into storage. Called during import so multi-persona users restore all
/// their posting keys. Idempotent — INSERT OR IGNORE on conflict. No-op if
/// the file is missing (pre-0.6.3 bundle).
pub async fn restore_posting_identities(
    zip_path: &Path,
    storage: &StoragePool,
) -> anyhow::Result<usize> {
    let zip_path = zip_path.to_path_buf();
    let identities: Vec<PostingIdentity> = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<PostingIdentity>> {
        let file = std::fs::File::open(&zip_path)?;
        let mut archive = zip::ZipArchive::new(file)?;
        let buf = {
            let mut entry = match archive.by_name("itsgoin-export/posting_identities.json") {
                Ok(e) => e,
                Err(_) => return Ok(Vec::new()),
            };
            let mut s = String::new();
            entry.read_to_string(&mut s)?;
            s
        };
        Ok(serde_json::from_str(&buf).unwrap_or_default())
    }).await??;

    let s = storage.get().await;
    let mut restored = 0usize;
    for id in &identities {
        s.upsert_posting_identity(id)?;
        restored += 1;
    }
    Ok(restored)
}

/// What to do with the imported data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ImportAction {
    /// Create a new identity from the export's key and restore all data.
    AddAsIdentity,
    /// Import public posts into the current identity with new PostIds.
    ImportPublicPosts,
    /// Decrypt with the provided key, re-create posts under current identity.
    MergeWithKey { key_hex: String },
}

/// Summary of what an import ZIP contains (shown to user before importing).
#[derive(Debug, Serialize, Deserialize)]
pub struct ImportSummary {
    pub node_id: String,
    pub scope: String,
    pub export_date: u64,
    pub post_count: usize,
    pub blob_count: usize,
    pub has_identity_key: bool,
    pub has_follows: bool,
    pub has_settings: bool,
}

/// Result of an import operation.
#[derive(Debug, Serialize, Deserialize)]
pub struct ImportResult {
    pub posts_imported: usize,
    pub posts_skipped: usize,
    pub blobs_imported: usize,
    pub message: String,
}

/// Read a ZIP and return a summary of its contents (without importing).
pub fn read_import_summary(zip_path: &Path) -> anyhow::Result<ImportSummary> {
    let file = std::fs::File::open(zip_path)?;
    let mut archive = zip::ZipArchive::new(file)?;

    // Read manifest
    let manifest: ExportManifest = {
        let mut entry = archive.by_name("itsgoin-export/manifest.json")?;
        let mut buf = String::new();
        entry.read_to_string(&mut buf)?;
        serde_json::from_str(&buf)?
    };

    let has_key = archive.by_name("itsgoin-export/identity.key").is_ok();
    let has_follows = archive.by_name("itsgoin-export/follows.json").is_ok();
    let has_settings = archive.by_name("itsgoin-export/settings.json").is_ok();

    Ok(ImportSummary {
        node_id: manifest.node_id,
        scope: format!("{:?}", manifest.scope),
        export_date: manifest.export_date,
        post_count: manifest.post_count,
        blob_count: manifest.blob_count,
        has_identity_key: has_key,
        has_follows,
        has_settings,
    })
}

/// Parsed data from a ZIP ready for async import.
struct ParsedImport {
    posts: Vec<(Post, PostVisibility, Vec<(Attachment, Vec<u8>)>)>,
    skipped: usize,
}

/// Staged content from a bundle, ready to write into the current identity
/// without reparenting. Posts keep their original author/post_id/signatures;
/// the bundle's posting keys become personas on this device.
struct StagedImport {
    /// Posting identities to register (includes the bundle's identity.key and
    /// any entries from posting_identities.json). Deduped by node_id.
    posting_identities: Vec<PostingIdentity>,
    /// Posts in the form (post_id, Post, PostVisibility, intent, blobs).
    posts: Vec<(crate::types::PostId, Post, PostVisibility, crate::types::VisibilityIntent, Vec<(Attachment, Vec<u8>)>)>,
    /// Follows to add to current identity's follow list.
    follows: Vec<NodeId>,
    /// Profiles to upsert (keyed by their own node_id, which becomes one of
    /// our personas or a remote peer).
    profiles: Vec<crate::types::PublicProfile>,
}

/// Import a bundle as personas: add the source's posting keys to our
/// `posting_identities`, and insert their posts AS-AUTHORED (no reparenting).
/// Content encrypted to any of the imported keys becomes decryptable because
/// we now hold those secrets. Idempotent — duplicate post ids / posting keys
/// are skipped via ON CONFLICT handling.
pub async fn import_as_personas(
    zip_path: &Path,
    storage: &StoragePool,
    blob_store: &BlobStore,
) -> anyhow::Result<ImportResult> {
    let staged = {
        let zip_path = zip_path.to_path_buf();
        tokio::task::spawn_blocking(move || -> anyhow::Result<StagedImport> {
            let file = std::fs::File::open(&zip_path)?;
            let mut archive = zip::ZipArchive::new(file)?;

            // identity.key — the source device's primary key, which now
            // becomes a posting persona on our device.
            let mut posting_identities: Vec<PostingIdentity> = Vec::new();
            if let Ok(mut entry) = archive.by_name("itsgoin-export/identity.key") {
                let mut key_bytes = Vec::new();
                entry.read_to_end(&mut key_bytes)?;
                if key_bytes.len() == 32 {
                    let seed: [u8; 32] = key_bytes.as_slice().try_into().unwrap();
                    let sk = iroh::SecretKey::from_bytes(&seed);
                    let nid: NodeId = *sk.public().as_bytes();
                    let now = std::time::SystemTime::now()
                        .duration_since(std::time::UNIX_EPOCH)
                        .map(|d| d.as_millis() as u64)
                        .unwrap_or(0);
                    posting_identities.push(PostingIdentity {
                        node_id: nid,
                        secret_seed: seed,
                        display_name: String::new(),
                        created_at: now,
                    });
                }
            }

            // posting_identities.json (v0.6+ bundles) — additional personas.
            if let Ok(mut entry) = archive.by_name("itsgoin-export/posting_identities.json") {
                let mut buf = String::new();
                entry.read_to_string(&mut buf)?;
                if let Ok(ids) = serde_json::from_str::<Vec<PostingIdentity>>(&buf) {
                    for id in ids {
                        if !posting_identities.iter().any(|p| p.node_id == id.node_id) {
                            posting_identities.push(id);
                        }
                    }
                }
            }

            // posts.json
            let posts_raw: Vec<ExportedPost> = match archive.by_name("itsgoin-export/posts.json") {
                Ok(mut entry) => {
                    let mut buf = String::new();
                    entry.read_to_string(&mut buf)?;
                    serde_json::from_str(&buf).unwrap_or_default()
                }
                Err(_) => Vec::new(),
            };

            let mut staged_posts = Vec::new();
            for ep in &posts_raw {
                let id_bytes = hex::decode(&ep.id).unwrap_or_default();
                let post_id: crate::types::PostId = match id_bytes.as_slice().try_into() {
                    Ok(id) => id,
                    Err(_) => continue,
                };
                let author_bytes = hex::decode(&ep.author).unwrap_or_default();
                let author: NodeId = match author_bytes.as_slice().try_into() {
                    Ok(a) => a,
                    Err(_) => continue,
                };
                let attachments: Vec<Attachment> = serde_json::from_str(&ep.attachments_json)
                    .unwrap_or_default();
                let vis: PostVisibility = serde_json::from_str(&ep.visibility_json)
                    .unwrap_or(PostVisibility::Public);
                let post = Post {
                    author,
                    content: ep.content.clone(),
                    attachments: attachments.clone(),
                    timestamp_ms: ep.timestamp_ms,
                fof_gating: None,
                supersedes_post_id: None,
                };

                // Preserve the original visibility intent from the export.
                // Export stored it as a Debug-format string
                // (`format!("{:?}", intent)`), so we parse a prefix match.
                // When the export predates intent storage (source DB never
                // populated `visibility_intent`), fall back to a heuristic
                // based on the visibility shape.
                let intent = parse_exported_intent(ep.intent.as_deref(), &vis);

                // Read attached blobs
                let mut blobs = Vec::new();
                for att in &attachments {
                    let path = format!("itsgoin-export/blobs/{}", hex::encode(att.cid));
                    if let Ok(mut blob_entry) = archive.by_name(&path) {
                        let mut data = Vec::new();
                        blob_entry.read_to_end(&mut data)?;
                        blobs.push((att.clone(), data));
                    }
                }
                staged_posts.push((post_id, post, vis, intent, blobs));
            }

            // follows.json (optional)
            let follows: Vec<NodeId> = match archive.by_name("itsgoin-export/follows.json") {
                Ok(mut entry) => {
                    let mut buf = String::new();
                    entry.read_to_string(&mut buf)?;
                    serde_json::from_str::<Vec<String>>(&buf)
                        .unwrap_or_default()
                        .into_iter()
                        .filter_map(|s| {
                            let b = hex::decode(&s).ok()?;
                            <[u8; 32]>::try_from(b.as_slice()).ok()
                        })
                        .collect()
                }
                Err(_) => Vec::new(),
            };

            // profiles.json (optional)
            let profiles: Vec<crate::types::PublicProfile> = match archive.by_name("itsgoin-export/profiles.json") {
                Ok(mut entry) => {
                    let mut buf = String::new();
                    entry.read_to_string(&mut buf)?;
                    serde_json::from_str(&buf).unwrap_or_default()
                }
                Err(_) => Vec::new(),
            };

            Ok(StagedImport {
                posting_identities,
                posts: staged_posts,
                follows,
                profiles,
            })
        }).await??
    };

    // Phase 2: write into storage.
    let mut imported_posts = 0usize;
    let mut imported_blobs = 0usize;
    let mut imported_personas = 0usize;
    let mut skipped_posts = 0usize;

    // Posting identities first so the decrypt-any-persona path (feed render)
    // can find them immediately after this import call returns. If the
    // current device has exactly one posting identity (typically the one
    // auto-created on first launch) and we're importing additional ones,
    // switch the default to the first imported persona — the user's intent is
    // to pick up where they left off, not to post under a fresh throwaway.
    {
        let s = storage.get().await;
        let prior_count = s.count_posting_identities().unwrap_or(0);
        for pi in &staged.posting_identities {
            s.upsert_posting_identity(pi)?;
            imported_personas += 1;
        }
        if prior_count <= 1 {
            if let Some(first) = staged.posting_identities.first() {
                let _ = s.set_default_posting_id(&first.node_id);
            }
        }
    }

    // Posts + blobs. Content keeps its original post_id, author, signatures.
    for (post_id, post, vis, intent, blobs) in &staged.posts {
        let s = storage.get().await;
        if s.get_post(post_id).ok().flatten().is_some() {
            skipped_posts += 1;
            continue;
        }
        if crate::content::verify_post_id(post_id, post) {
            // Intent was parsed from the export (or heuristic-inferred when
            // the export predates intent storage). See `parse_exported_intent`.
            s.store_post_with_intent(post_id, post, vis, intent)?;
            imported_posts += 1;
        } else {
            warn!(post_id = hex::encode(post_id), "Skipping post with invalid signature during import");
            skipped_posts += 1;
            continue;
        }
        for (att, data) in blobs {
            if !blob_store.has(&att.cid) {
                blob_store.store(&att.cid, data)?;
            }
            s.record_blob(&att.cid, post_id, &post.author, data.len() as u64, &att.mime_type, post.timestamp_ms)?;
            let _ = s.pin_blob(&att.cid);
            imported_blobs += 1;
        }
    }

    // Follows + profiles.
    {
        let s = storage.get().await;
        for f in &staged.follows {
            let _ = s.add_follow(f);
        }
        for p in &staged.profiles {
            let _ = s.store_profile(p);
        }
    }

    Ok(ImportResult {
        posts_imported: imported_posts,
        posts_skipped: skipped_posts,
        blobs_imported: imported_blobs,
        message: format!(
            "Imported {} personas, {} posts ({} skipped), {} blobs",
            imported_personas, imported_posts, skipped_posts, imported_blobs
        ),
    })
}

/// Import public posts from a ZIP into the current identity.
/// Creates new posts with the current node_id as author, preserving original timestamps.
pub async fn import_public_posts(
    zip_path: &Path,
    storage: &StoragePool,
    blob_store: &BlobStore,
    our_node_id: &NodeId,
) -> anyhow::Result<ImportResult> {
    // Phase 1: Read everything from ZIP synchronously (no Send requirement)
    let parsed = {
        let zip_path = zip_path.to_path_buf();
        let our_node_id = *our_node_id;
        tokio::task::spawn_blocking(move || -> anyhow::Result<ParsedImport> {
            let file = std::fs::File::open(&zip_path)?;
            let mut archive = zip::ZipArchive::new(file)?;

            let posts: Vec<ExportedPost> = {
                let mut entry = archive.by_name("itsgoin-export/posts.json")?;
                let mut buf = String::new();
                entry.read_to_string(&mut buf)?;
                serde_json::from_str(&buf)?
            };

            let mut result_posts = Vec::new();
            let mut skipped = 0usize;

            for ep in &posts {
                let vis: PostVisibility = serde_json::from_str(&ep.visibility_json).unwrap_or(PostVisibility::Public);
                if !matches!(vis, PostVisibility::Public) {
                    skipped += 1;
                    continue;
                }

                let attachments: Vec<Attachment> = serde_json::from_str(&ep.attachments_json).unwrap_or_default();
                let new_post = Post {
                    author: our_node_id,
                    content: ep.content.clone(),
                    attachments: attachments.clone(),
                    timestamp_ms: ep.timestamp_ms,
                fof_gating: None,
                supersedes_post_id: None,
                };

                // Read blob data from archive
                let mut blob_data = Vec::new();
                for att in &attachments {
                    let cid_hex = hex::encode(att.cid);
                    let blob_path = format!("itsgoin-export/blobs/{}", cid_hex);
                    if let Ok(mut blob_entry) = archive.by_name(&blob_path) {
                        let mut data = Vec::new();
                        blob_entry.read_to_end(&mut data)?;
                        blob_data.push((att.clone(), data));
                    }
                }

                result_posts.push((new_post, vis, blob_data));
            }

            Ok(ParsedImport { posts: result_posts, skipped })
        }).await??
    };

    // Phase 2: Store to DB + blob store (async — needs storage.get().await)
    let mut imported = 0usize;
    let mut blobs_imported = 0usize;

    info!(post_count = parsed.posts.len(), skipped = parsed.skipped, "Import phase 2: storing to DB");

    // Ensure we follow ourselves so imported posts appear in feed
    {
        let s = storage.get().await;
        let _ = s.add_follow(our_node_id);
    }

    let now = now_ms();

    for (new_post, _vis, blob_data) in &parsed.posts {
        let new_id = compute_post_id(new_post);

        let s = storage.get().await;
        if s.get_post(&new_id).ok().flatten().is_some() {
            drop(s);
            debug!(post = hex::encode(new_id), "Import: skipping duplicate post");
            continue;
        }
        // Store post with intent (matches create_post_with_visibility behavior)
        s.store_post_with_intent(&new_id, new_post, &PostVisibility::Public, &crate::types::VisibilityIntent::Public)?;

        // Store blobs + record them, matching normal post creation
        for (att, data) in blob_data {
            if !blob_store.has(&att.cid) {
                blob_store.store(&att.cid, data)?;
            }
            s.record_blob(&att.cid, &new_id, our_node_id, data.len() as u64, &att.mime_type, att.size_bytes)?;
            let _ = s.pin_blob(&att.cid);
            blobs_imported += 1;
        }

        // Create BlobHeader (matches what engagement/sync expects)
        let header = crate::types::BlobHeader {
            post_id: new_id,
            author: *our_node_id,
            reactions: vec![],
            comments: vec![],
            policy: crate::types::CommentPolicy::default(),
            updated_at: now,
            thread_splits: vec![],
            receipt_slots: vec![],
            comment_slots: vec![],
            prior_author: None,
        };
        let header_json = serde_json::to_string(&header).unwrap_or_default();
        let _ = s.store_blob_header(&new_id, our_node_id, &header_json, now);
        drop(s);

        imported += 1;
        debug!(imported, post = hex::encode(new_id), "Import: stored post");
    }

    info!(imported, skipped = parsed.skipped, blobs = blobs_imported, "Public post import complete");

    Ok(ImportResult {
        posts_imported: imported,
        posts_skipped: parsed.skipped,
        blobs_imported,
        message: format!("Imported {} posts ({} skipped), {} blobs", imported, parsed.skipped, blobs_imported),
    })
}

/// Import a ZIP as a new identity (create identity subdir, extract everything).
pub fn import_as_identity(
    zip_path: &Path,
    base_dir: &Path,
) -> anyhow::Result<String> {
    let file = std::fs::File::open(zip_path)?;
    let mut archive = zip::ZipArchive::new(file)?;

    // Read manifest
    let manifest: ExportManifest = {
        let mut entry = archive.by_name("itsgoin-export/manifest.json")?;
        let mut buf = String::new();
        entry.read_to_string(&mut buf)?;
        serde_json::from_str(&buf)?
    };

    // Read identity key
    let key_data = {
        let mut entry = archive.by_name("itsgoin-export/identity.key")
            .map_err(|_| anyhow::anyhow!("Export doesn't contain an identity key"))?;
        let mut buf = Vec::new();
        entry.read_to_end(&mut buf)?;
        buf
    };

    // Create identity directory
    let id_dir = base_dir.join("identities").join(&manifest.node_id);
    if id_dir.exists() {
        anyhow::bail!("Identity {} already exists", &manifest.node_id[..12]);
    }
    std::fs::create_dir_all(&id_dir)?;

    // Write identity key
    let key_path = id_dir.join("identity.key");
    std::fs::write(&key_path, &key_data)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600));
    }

    // Write metadata
    let now = now_ms();
    let meta = serde_json::json!({
        "display_name": format!("Imported {}", &manifest.node_id[..12]),
        "created_at": now,
        "last_used_at": now,
    });
    std::fs::write(id_dir.join("meta.json"), serde_json::to_string_pretty(&meta)?)?;

    info!(identity = manifest.node_id, "Imported identity from ZIP — switch to it to restore data");

    // Note: posts, blobs, follows, settings will be restored when the user switches to this
    // identity and opens the node. The full DB restore could be done here, but it's simpler
    // to let the user switch and then import posts via the import wizard.

    Ok(manifest.node_id)
}

/// Merge posts from another identity into the current one using the original key for decryption.
/// Decrypts encrypted posts, creates new posts under the current identity, preserves timestamps.
/// BlobHeader gets `prior_author` set for provenance.
pub async fn merge_with_key(
    zip_path: &Path,
    original_key_hex: &str,
    storage: &StoragePool,
    blob_store: &BlobStore,
    our_node_id: &NodeId,
    _our_seed: &[u8; 32],
) -> anyhow::Result<ImportResult> {
    // Derive the original identity from the provided key
    let original_seed_bytes = hex::decode(original_key_hex)?;
    let original_seed: [u8; 32] = original_seed_bytes.try_into()
        .map_err(|_| anyhow::anyhow!("key must be 32 bytes (64 hex chars)"))?;
    let original_secret_key = iroh::SecretKey::from_bytes(&original_seed);
    let original_node_id: NodeId = *original_secret_key.public().as_bytes();

    // Phase 1: Read and decrypt everything from ZIP synchronously
    let parsed = {
        let zip_path = zip_path.to_path_buf();
        let our_nid = *our_node_id;
        let orig_seed = original_seed;
        let orig_nid = original_node_id;

        tokio::task::spawn_blocking(move || -> anyhow::Result<ParsedImport> {
            let file = std::fs::File::open(&zip_path)?;
            let mut archive = zip::ZipArchive::new(file)?;

            let posts: Vec<ExportedPost> = {
                let mut entry = archive.by_name("itsgoin-export/posts.json")?;
                let mut buf = String::new();
                entry.read_to_string(&mut buf)?;
                serde_json::from_str(&buf)?
            };

            let mut result_posts = Vec::new();
            let mut skipped = 0usize;

            for ep in &posts {
                let vis: PostVisibility = serde_json::from_str(&ep.visibility_json)
                    .unwrap_or(PostVisibility::Public);
                let attachments: Vec<Attachment> = serde_json::from_str(&ep.attachments_json)
                    .unwrap_or_default();

                // Decrypt content if encrypted
                let plaintext = match &vis {
                    PostVisibility::Public => ep.content.clone(),
                    PostVisibility::Encrypted { recipients } => {
                        match crate::crypto::decrypt_post(
                            &ep.content, &orig_seed, &orig_nid, &orig_nid, recipients,
                        ) {
                            Ok(Some(text)) => text,
                            Ok(None) => {
                                debug!(post = ep.id, "Not a recipient of this post — skipping");
                                skipped += 1;
                                continue;
                            }
                            Err(e) => {
                                warn!(post = ep.id, error = %e, "Failed to decrypt post — skipping");
                                skipped += 1;
                                continue;
                            }
                        }
                    }
                    PostVisibility::GroupEncrypted { .. } => {
                        // Group decryption needs the group seed — skip for now
                        debug!(post = ep.id, "Group-encrypted post — skipping (group merge not yet supported)");
                        skipped += 1;
                        continue;
                    }
                    PostVisibility::FoFClosed => {
                        // FoF Layer 3 import: skip for now. The recovered
                        // post would need its fof_gating + CEK to decrypt,
                        // and the receiving persona's keyring may not
                        // include the right V_x. Re-issue via the author's
                        // device is the supported path.
                        debug!(post = ep.id, "FoFClosed post — skipping (import not yet supported)");
                        skipped += 1;
                        continue;
                    }
                };

                // Create new post under our identity
                let new_post = Post {
                    author: our_nid,
                    content: plaintext,
                    attachments: attachments.clone(),
                    timestamp_ms: ep.timestamp_ms,
                fof_gating: None,
                supersedes_post_id: None,
                };

                // Read blob data from archive (may need decryption for encrypted posts)
                let mut blob_data = Vec::new();
                for att in &attachments {
                    let cid_hex = hex::encode(att.cid);
                    let blob_path = format!("itsgoin-export/blobs/{}", cid_hex);
                    if let Ok(mut blob_entry) = archive.by_name(&blob_path) {
                        let mut data = Vec::new();
                        blob_entry.read_to_end(&mut data)?;

                        // If the post was encrypted, blobs are also encrypted with the same CEK
                        if matches!(vis, PostVisibility::Encrypted { .. }) {
                            if let PostVisibility::Encrypted { ref recipients } = vis {
                                if let Ok(Some(cek)) = crate::crypto::unwrap_cek_for_recipient(
                                    &orig_seed, &orig_nid, &orig_nid, recipients,
                                ) {
                                    if let Ok(decrypted) = crate::crypto::decrypt_bytes_with_cek(&data, &cek) {
                                        data = decrypted;
                                    }
                                }
                            }
                        }

                        blob_data.push((att.clone(), data));
                    }
                }

                // Merged posts go in as public (decrypted content, new author)
                result_posts.push((new_post, PostVisibility::Public, blob_data));
            }

            Ok(ParsedImport { posts: result_posts, skipped })
        }).await??
    };

    // Phase 2: Store with prior_author provenance
    let mut imported = 0usize;
    let mut blobs_imported = 0usize;

    for (new_post, _vis, blob_data) in &parsed.posts {
        let new_id = compute_post_id(new_post);

        let s = storage.get().await;
        if s.get_post(&new_id).ok().flatten().is_some() {
            continue;
        }
        s.store_post_with_visibility(&new_id, new_post, &PostVisibility::Public)?;

        // Create BlobHeader with prior_author
        let now = now_ms();
        let header = crate::types::BlobHeader {
            post_id: new_id,
            author: *our_node_id,
            reactions: vec![],
            comments: vec![],
            policy: crate::types::CommentPolicy::default(),
            updated_at: now,
            thread_splits: vec![],
            receipt_slots: vec![],
            comment_slots: vec![],
            prior_author: Some(original_node_id),
        };
        let header_json = serde_json::to_string(&header).unwrap_or_default();
        let _ = s.store_blob_header(&new_id, our_node_id, &header_json, now);
        drop(s);

        for (att, data) in blob_data {
            if !blob_store.has(&att.cid) {
                blob_store.store(&att.cid, data)?;
                let s = storage.get().await;
                let _ = s.record_blob(&att.cid, &new_id, our_node_id, data.len() as u64, &att.mime_type, att.size_bytes);
                blobs_imported += 1;
            }
        }

        imported += 1;
    }

    info!(
        imported, skipped = parsed.skipped, blobs = blobs_imported,
        original = hex::encode(original_node_id),
        "Merge with key complete"
    );

    Ok(ImportResult {
        posts_imported: imported,
        posts_skipped: parsed.skipped,
        blobs_imported,
        message: format!(
            "Merged {} posts from {} ({} skipped), {} blobs",
            imported, &hex::encode(original_node_id)[..12], parsed.skipped, blobs_imported
        ),
    })
}

fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as u64
}
