use std::sync::Arc;

use serde::{Deserialize, Serialize};
use tauri::{Manager, State};
use tracing::info;

use itsgoin_core::identity::IdentityManager;
use itsgoin_core::node::Node;
use itsgoin_core::types::{NodeId, PeerSlotKind, Post, PostId, PostVisibility, VisibilityIntent};

/// The active Node, swappable on identity switch.
type AppNode = Arc<tokio::sync::RwLock<Arc<Node>>>;
/// The identity manager for multi-identity operations.
type AppIdentity = Arc<tokio::sync::Mutex<IdentityManager>>;

/// Helper: get a clone of the active Node from the RwLock.
async fn get_node(state: &State<'_, AppNode>) -> Arc<Node> {
    state.read().await.clone()
}

/// Helper: same as get_node but also returns a reference-like wrapper for &Node usage.
macro_rules! node {
    ($state:expr) => {{
        let _n = get_node($state).await;
        _n
    }};
}

// --- DTOs for the frontend (all IDs as hex strings) ---

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct AttachmentDto {
    cid: String,
    mime_type: String,
    size_bytes: u64,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct PostDto {
    id: String,
    author: String,
    author_name: Option<String>,
    content: String,
    timestamp_ms: u64,
    is_me: bool,
    /// "public", "encrypted", or "encrypted-for-me"
    visibility: String,
    /// The original intent: "public", "friends", "circle", "direct", or "unknown"
    intent_kind: String,
    /// Decrypted plaintext if we can decrypt; None for public or if we're not a recipient
    decrypted_content: Option<String>,
    attachments: Vec<AttachmentDto>,
    /// Hex node IDs of recipients (non-empty only for Encrypted posts)
    recipients: Vec<String>,
    /// Reaction counts: [(emoji, count, reacted_by_me)]
    reaction_counts: Vec<ReactionCountDto>,
    /// Number of comments on this post
    comment_count: u64,
    /// If the post is authored by one of our held posting identities, the
    /// persona's display_name. None for posts authored by peers (or if the
    /// local persona has no display name).
    as_persona: Option<String>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ReactionCountDto {
    emoji: String,
    count: u64,
    reacted_by_me: bool,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ReactionDto {
    reactor: String,
    emoji: String,
    post_id: String,
    timestamp_ms: u64,
    encrypted_payload: Option<String>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CommentDto {
    author: String,
    author_name: Option<String>,
    post_id: String,
    content: String,
    timestamp_ms: u64,
}

#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommentPolicyDto {
    allow_comments: String,
    allow_reacts: String,
    moderation: String,
    blocklist: Vec<String>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ReceiptSlotDto {
    slot_index: u32,
    node_id: Option<String>,
    state: String,
    timestamp_ms: u64,
    emoji: Option<String>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CommentSlotDto {
    slot_index: u32,
    author: String,
    author_name: Option<String>,
    timestamp_ms: u64,
    content: String,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CircleDto {
    name: String,
    members: Vec<String>,
    created_at: u64,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StatsDto {
    post_count: usize,
    peer_count: usize,
    follow_count: usize,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct BadgeCountsDto {
    new_feed: usize,
    new_engagement: usize,
    unread_messages: usize,
    new_reacts: usize,
    new_comments: usize,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RedundancyDto {
    total: usize,
    zero_replicas: usize,
    one_replica: usize,
    two_plus_replicas: usize,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct NodeInfoDto {
    node_id: String,
    connect_string: String,
    display_name: Option<String>,
    has_profile: bool,
    duplicate_detected: bool,
    anchors: Vec<String>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct PeerDto {
    node_id: String,
    display_name: Option<String>,
    addresses: Vec<String>,
    introduced_by: Option<String>,
    is_anchor: bool,
    last_seen: u64,
    /// Reach level: "mesh", "n1", "n2", "n3", or "known"
    reach: String,
    /// Whether we have a mesh or session connection right now
    is_online: bool,
    /// Timestamp (ms) of last activity (connection, push, or pull)
    last_activity_ms: u64,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProfileDto {
    node_id: String,
    display_name: String,
    bio: String,
    anchors: Vec<String>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SuggestedPeerDto {
    node_id: String,
    display_name: Option<String>,
    addresses: Vec<String>,
    introduced_by_name: Option<String>,
    is_anchor: bool,
}

// --- Helpers ---

async fn post_to_dto(
    id: &PostId,
    post: &Post,
    vis: &PostVisibility,
    decrypted: Option<&str>,
    node: &Node,
) -> PostDto {
    // "is_me" now means: authored by ANY posting identity we hold, not just the
    // network key. Covers the multi-persona case from 0.6.4+.
    let (is_me, as_persona) = {
        let s = node.storage.get().await;
        match s.get_posting_identity(&post.author) {
            Ok(Some(pi)) => {
                let name = if pi.display_name.is_empty() { None } else { Some(pi.display_name) };
                (true, name)
            }
            _ => (false, None),
        }
    };
    let author_name = match node.resolve_display_name(&post.author).await {
        Ok((name, _, _)) if !name.is_empty() => Some(name),
        _ => None,
    };

    // Resolve intent kind from storage
    let intent_kind = {
        let storage = node.storage.get().await;
        match storage.get_post_intent(id) {
            Ok(Some(intent)) => match intent {
                VisibilityIntent::Public => "public".to_string(),
                VisibilityIntent::Friends => "friends".to_string(),
                VisibilityIntent::Circle(_) => "circle".to_string(),
                VisibilityIntent::Direct(_) => "direct".to_string(),
                VisibilityIntent::Control => "control".to_string(),
                VisibilityIntent::Profile => "profile".to_string(),
                VisibilityIntent::GroupKeyDistribute => "group_key_distribute".to_string(),
                VisibilityIntent::Announcement => "announcement".to_string(),
            },
            _ => "unknown".to_string(),
        }
    };

    // For own encrypted posts, show "encrypted" not "encrypted-for-me"
    // since intent_kind handles DM filtering now
    let (visibility, decrypted_content) = match vis {
        PostVisibility::Public => ("public".to_string(), None),
        PostVisibility::Encrypted { .. } | PostVisibility::GroupEncrypted { .. } => match decrypted {
            Some(text) if is_me => ("encrypted".to_string(), Some(text.to_string())),
            Some(text) => ("encrypted-for-me".to_string(), Some(text.to_string())),
            None => ("encrypted".to_string(), None),
        },
        // FoF Layer 3: FoFClosed body. Decrypted is None from the sync
        // feed-pre-decrypt helper; the frontend calls read_fof_closed_body
        // for any post with visibility == "fof-closed" to fill in the body.
        PostVisibility::FoFClosed => ("fof-closed".to_string(), None),
    };
    let recipients = match vis {
        PostVisibility::Encrypted { recipients } => {
            recipients.iter().map(|wk| hex::encode(wk.recipient)).collect()
        }
        _ => vec![],
    };
    let attachments = post
        .attachments
        .iter()
        .map(|a| AttachmentDto {
            cid: hex::encode(a.cid),
            mime_type: a.mime_type.clone(),
            size_bytes: a.size_bytes,
        })
        .collect();
    // Engagement data
    let reaction_counts = {
        let storage = node.storage.get().await;
        storage.get_reaction_counts(id, &node.node_id).unwrap_or_default()
            .into_iter()
            .map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me })
            .collect()
    };
    let comment_count = {
        let storage = node.storage.get().await;
        storage.get_comment_count(id).unwrap_or(0)
    };

    PostDto {
        id: hex::encode(id),
        author: hex::encode(post.author),
        author_name,
        content: post.content.clone(),
        timestamp_ms: post.timestamp_ms,
        is_me,
        visibility,
        intent_kind,
        decrypted_content,
        attachments,
        recipients,
        reaction_counts,
        comment_count,
        as_persona,
    }
}

/// Decrypt a just-created post for immediate display. The post was authored
/// by one of our held posting identities (default or a specific persona);
/// look up that identity's secret to decrypt.
async fn decrypt_just_created(
    node: &Node,
    post: &Post,
    vis: &PostVisibility,
) -> Option<String> {
    match vis {
        PostVisibility::Public => None,
        PostVisibility::Encrypted { recipients } => {
            let author_identity = {
                let s = node.storage.get().await;
                s.get_posting_identity(&post.author).ok().flatten()
            }?;
            itsgoin_core::crypto::decrypt_post(
                &post.content,
                &author_identity.secret_seed,
                &author_identity.node_id,
                &author_identity.node_id,
                recipients,
            )
            .ok()
            .flatten()
        }
        PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => {
            let seed_info = {
                let storage = node.storage.get().await;
                storage.get_all_group_seeds_map().ok()
                    .and_then(|map| map.get(&(*group_id, *epoch)).copied())
            };
            if let Some((seed, pubkey)) = seed_info {
                itsgoin_core::crypto::decrypt_group_post(
                    &post.content,
                    &seed,
                    &pubkey,
                    wrapped_cek,
                )
                .ok()
            } else {
                None
            }
        }
        // FoF Layer 3: FoFClosed body decrypt happens via the dedicated
        // async read_fof_closed_body command. This sync helper returns
        // None and the frontend dispatches the FoF read explicitly.
        PostVisibility::FoFClosed => None,
    }
}

fn parse_node_id(hex_str: &str) -> Result<NodeId, String> {
    itsgoin_core::parse_node_id_hex(hex_str).map_err(|e| e.to_string())
}

// --- Tauri commands ---

#[tauri::command]
async fn get_node_info(state: State<'_, AppNode>) -> Result<NodeInfoDto, String> {
    let node = get_node(&state).await;
    let node_id_hex = hex::encode(node.node_id);
    let addr = node.endpoint_addr();
    // Prefer external address (UPnP, public IPv6, observed) over local bind address
    let external_addr = node.network.http_addr();
    let observed_addr = if external_addr.is_none() {
        let storage = node.storage.get().await;
        storage.get_peer_record(&node.node_id).ok().flatten()
            .and_then(|r| r.addresses.first().map(|a| a.to_string()))
    } else {
        None
    };
    let connect_string = if let Some(ext) = external_addr.or(observed_addr) {
        format!("{}@{}", node_id_hex, ext)
    } else if let Some(sock) = addr.ip_addrs().next() {
        format!("{}@{}", node_id_hex, sock)
    } else {
        node_id_hex.clone()
    };
    let profile = node.my_profile().await.map_err(|e| e.to_string())?;
    let anchors = profile
        .as_ref()
        .map(|p| p.anchors.iter().map(hex::encode).collect())
        .unwrap_or_default();
    Ok(NodeInfoDto {
        node_id: node_id_hex,
        connect_string,
        display_name: profile.as_ref().map(|p| p.display_name.clone()),
        has_profile: profile.is_some(),
        duplicate_detected: node.network.duplicate_detected.load(std::sync::atomic::Ordering::Relaxed),
        anchors,
    })
}

#[tauri::command]
async fn set_display_name(
    state: State<'_, AppNode>,
    name: String,
) -> Result<ProfileDto, String> {
    let node = get_node(&state).await;
    let profile = node
        .set_profile(name, String::new())
        .await
        .map_err(|e| e.to_string())?;
    Ok(ProfileDto {
        node_id: hex::encode(profile.node_id),
        display_name: profile.display_name,
        bio: profile.bio,
        anchors: profile.anchors.iter().map(hex::encode).collect(),
    })
}

#[tauri::command]
async fn set_profile(
    state: State<'_, AppNode>,
    name: String,
    bio: String,
) -> Result<ProfileDto, String> {
    let node = get_node(&state).await;
    let profile = node
        .set_profile(name, bio)
        .await
        .map_err(|e| e.to_string())?;
    Ok(ProfileDto {
        node_id: hex::encode(profile.node_id),
        display_name: profile.display_name,
        bio: profile.bio,
        anchors: profile.anchors.iter().map(hex::encode).collect(),
    })
}

#[tauri::command]
async fn create_post(
    state: State<'_, AppNode>,
    content: String,
    visibility: Option<String>,
    circle_name: Option<String>,
    recipient_hex: Option<String>,
    posting_id_hex: Option<String>,
) -> Result<PostDto, String> {
    let node = get_node(&state).await;
    let intent = match visibility.as_deref() {
        Some("friends") => VisibilityIntent::Friends,
        Some("circle") => {
            let name = circle_name.ok_or("circle_name required for circle visibility")?;
            VisibilityIntent::Circle(name)
        }
        Some("direct") => {
            let hex = recipient_hex.ok_or("recipient_hex required for direct visibility")?;
            let nid = itsgoin_core::parse_node_id_hex(&hex).map_err(|e| e.to_string())?;
            VisibilityIntent::Direct(vec![nid])
        }
        _ => VisibilityIntent::Public,
    };
    let (id, post, vis) = match posting_id_hex {
        Some(pid_hex) => {
            let pid = itsgoin_core::parse_node_id_hex(&pid_hex).map_err(|e| e.to_string())?;
            node.create_post_as(&pid, content, intent, vec![]).await
        }
        None => node.create_post_with_visibility(content, intent, vec![]).await,
    }.map_err(|e| e.to_string())?;
    let decrypted = decrypt_just_created(&node, &post, &vis).await;
    Ok(post_to_dto(&id, &post, &vis, decrypted.as_deref(), &node).await)
}

#[tauri::command]
async fn create_post_with_files(
    state: State<'_, AppNode>,
    content: String,
    visibility: Option<String>,
    circle_name: Option<String>,
    recipient_hex: Option<String>,
    files: Vec<(String, String)>,
    posting_id_hex: Option<String>,
) -> Result<PostDto, String> {
    let node = get_node(&state).await;
    let intent = match visibility.as_deref() {
        Some("friends") => VisibilityIntent::Friends,
        Some("circle") => {
            let name = circle_name.ok_or("circle_name required for circle visibility")?;
            VisibilityIntent::Circle(name)
        }
        Some("direct") => {
            let hex = recipient_hex.ok_or("recipient_hex required for direct visibility")?;
            let nid = itsgoin_core::parse_node_id_hex(&hex).map_err(|e| e.to_string())?;
            VisibilityIntent::Direct(vec![nid])
        }
        _ => VisibilityIntent::Public,
    };

    use base64::Engine;
    let attachment_data: Vec<(Vec<u8>, String)> = files
        .into_iter()
        .map(|(b64, mime)| {
            let data = base64::engine::general_purpose::STANDARD
                .decode(&b64)
                .map_err(|e| format!("invalid base64: {}", e))?;
            Ok((data, mime))
        })
        .collect::<Result<Vec<_>, String>>()?;

    let (id, post, vis) = match posting_id_hex {
        Some(pid_hex) => {
            let pid = itsgoin_core::parse_node_id_hex(&pid_hex).map_err(|e| e.to_string())?;
            node.create_post_as(&pid, content, intent, attachment_data).await
        }
        None => node.create_post_with_visibility(content, intent, attachment_data).await,
    }.map_err(|e| e.to_string())?;
    let decrypted = decrypt_just_created(&node, &post, &vis).await;
    Ok(post_to_dto(&id, &post, &vis, decrypted.as_deref(), &node).await)
}

/// Posting identity DTO for IPC.
#[derive(serde::Serialize)]
struct PostingIdentityDto {
    #[serde(rename = "nodeId")]
    node_id: String,
    #[serde(rename = "displayName")]
    display_name: String,
    #[serde(rename = "createdAt")]
    created_at: u64,
    #[serde(rename = "isDefault")]
    is_default: bool,
}

#[tauri::command]
async fn list_posting_identities(
    state: State<'_, AppNode>,
) -> Result<Vec<PostingIdentityDto>, String> {
    let node = get_node(&state).await;
    let identities = node.list_posting_identities().await.map_err(|e| e.to_string())?;
    let default = {
        let s = node.storage.get().await;
        s.get_default_posting_id().ok().flatten()
    };
    Ok(identities
        .into_iter()
        .map(|id| PostingIdentityDto {
            node_id: hex::encode(id.node_id),
            display_name: id.display_name,
            created_at: id.created_at,
            is_default: Some(id.node_id) == default,
        })
        .collect())
}

#[tauri::command]
async fn create_posting_identity(
    state: State<'_, AppNode>,
    display_name: String,
) -> Result<PostingIdentityDto, String> {
    let node = get_node(&state).await;
    let id = node.create_posting_identity(display_name).await.map_err(|e| e.to_string())?;
    Ok(PostingIdentityDto {
        node_id: hex::encode(id.node_id),
        display_name: id.display_name,
        created_at: id.created_at,
        is_default: false,
    })
}

#[tauri::command]
async fn delete_posting_identity(
    state: State<'_, AppNode>,
    node_id_hex: String,
) -> Result<(), String> {
    let node = get_node(&state).await;
    let nid = itsgoin_core::parse_node_id_hex(&node_id_hex).map_err(|e| e.to_string())?;
    node.delete_posting_identity(&nid).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn set_default_posting_identity(
    state: State<'_, AppNode>,
    node_id_hex: String,
) -> Result<(), String> {
    let node = get_node(&state).await;
    let nid = itsgoin_core::parse_node_id_hex(&node_id_hex).map_err(|e| e.to_string())?;
    node.set_default_posting_identity(&nid).await.map_err(|e| e.to_string())
}

/// Create a post signed by a specific posting identity instead of the default.
#[tauri::command]
async fn create_post_as(
    state: State<'_, AppNode>,
    posting_id_hex: String,
    content: String,
    visibility: Option<String>,
    circle_name: Option<String>,
    recipient_hex: Option<String>,
) -> Result<PostDto, String> {
    let node = get_node(&state).await;
    let posting_id = itsgoin_core::parse_node_id_hex(&posting_id_hex).map_err(|e| e.to_string())?;
    let intent = match visibility.as_deref() {
        Some("friends") => VisibilityIntent::Friends,
        Some("circle") => {
            let name = circle_name.ok_or("circle_name required for circle visibility")?;
            VisibilityIntent::Circle(name)
        }
        Some("direct") => {
            let hex = recipient_hex.ok_or("recipient_hex required for direct visibility")?;
            let nid = itsgoin_core::parse_node_id_hex(&hex).map_err(|e| e.to_string())?;
            VisibilityIntent::Direct(vec![nid])
        }
        _ => VisibilityIntent::Public,
    };
    let (id, post, vis) = node
        .create_post_as(&posting_id, content, intent, vec![])
        .await
        .map_err(|e| e.to_string())?;
    let decrypted = decrypt_just_created(&node, &post, &vis).await;
    Ok(post_to_dto(&id, &post, &vis, decrypted.as_deref(), &node).await)
}

/// Return the filesystem path of a blob if it exists locally (for streaming video/media).
/// For non-public (encrypted) posts, returns None since raw encrypted blobs can't be served
/// via the asset protocol — the frontend must use IPC-based get_blob instead.
#[tauri::command]
async fn get_blob_path(
    state: State<'_, AppNode>,
    cid_hex: String,
    post_id_hex: Option<String>,
) -> Result<Option<String>, String> {
    let node = get_node(&state).await;
    let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?;
    let cid: [u8; 32] = cid_bytes
        .try_into()
        .map_err(|_| "CID must be 32 bytes".to_string())?;

    // If a post_id is provided, check if the post is encrypted — if so, can't serve raw file
    if let Some(ref pid_hex) = post_id_hex {
        if let Ok(pid_bytes) = hex::decode(pid_hex) {
            if let Ok(post_id) = <[u8; 32]>::try_from(pid_bytes.as_slice()) {
                let storage = node.storage.get().await;
                if let Ok(Some((_post, vis))) = storage.get_post_with_visibility(&post_id) {
                    if !matches!(vis, PostVisibility::Public) {
                        return Ok(None);
                    }
                }
            }
        }
    }

    Ok(node.blob_store.file_path(&cid).map(|p| p.to_string_lossy().to_string()))
}

/// Sanitize a filename for safe download: strip path separators and control characters.
fn sanitize_download_filename(filename: &str) -> String {
    filename
        .replace(['/', '\\', '\0'], "_")
        .chars()
        .filter(|c| !c.is_control())
        .collect::<String>()
}

/// Helper: resolve a blob with optional decryption via post context.
/// First tries local (with post-based decryption), then falls back to network fetch + decrypt.
async fn resolve_blob_data(
    node: &Node,
    cid: &[u8; 32],
    post_id_hex: Option<&str>,
) -> Result<Vec<u8>, String> {
    // Parse post_id if provided
    let post_id = if let Some(pid_hex) = post_id_hex {
        let pid_bytes = hex::decode(pid_hex).map_err(|e| e.to_string())?;
        Some(<[u8; 32]>::try_from(pid_bytes.as_slice()).map_err(|_| "bad post_id".to_string())?)
    } else {
        None
    };

    // Try local blob with decryption
    if let Some(ref pid) = post_id {
        if let Some(data) = node.get_blob_for_post(cid, pid).await.map_err(|e| e.to_string())? {
            return Ok(data);
        }
    } else if let Some(data) = node.get_blob(cid).await.map_err(|e| e.to_string())? {
        return Ok(data);
    }

    // Try fetching from network if post_id provided
    if let Some(pid) = post_id {
        let post = {
            let storage = node.storage.get().await;
            storage.get_post(&pid).map_err(|e| e.to_string())?
        };
        if let Some(post) = post {
            let mime = post.attachments.iter()
                .find(|a| a.cid == *cid)
                .map(|a| a.mime_type.as_str())
                .unwrap_or("application/octet-stream");
            if let Some(_fetched) = node
                .fetch_blob_with_fallback(cid, &pid, &post.author, mime, post.timestamp_ms)
                .await
                .map_err(|e| e.to_string())?
            {
                // Re-read with decryption
                if let Some(data) = node.get_blob_for_post(cid, &pid).await.map_err(|e| e.to_string())? {
                    return Ok(data);
                }
            }
        }
    }

    Err("blob not found".to_string())
}

/// Save a blob to the Downloads folder and open it with the system handler.
#[tauri::command]
async fn save_and_open_blob(
    state: State<'_, AppNode>,
    cid_hex: String,
    post_id_hex: Option<String>,
    filename: String,
) -> Result<String, String> {
    let node = get_node(&state).await;
    let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?;
    let cid: [u8; 32] = cid_bytes
        .try_into()
        .map_err(|_| "CID must be 32 bytes".to_string())?;

    let data = resolve_blob_data(&node, &cid, post_id_hex.as_deref()).await?;
    let safe_name = sanitize_download_filename(&filename);

    // Save to Downloads — use app cache dir on Android (no access to shared storage without SAF)
    let downloads = get_writable_download_dir(&node);
    std::fs::create_dir_all(&downloads).map_err(|e| e.to_string())?;
    let dest = downloads.join(&safe_name);
    tokio::fs::write(&dest, &data).await.map_err(|e| e.to_string())?;

    // Open with system handler
    let _ = open::that(&dest);

    Ok(dest.to_string_lossy().to_string())
}

/// Get a writable directory for downloads/exports.
/// On desktop: ~/Downloads. On Android: app data dir + "exports".
fn get_writable_download_dir(node: &Node) -> std::path::PathBuf {
    #[cfg(target_os = "android")]
    {
        node.data_dir.join("exports")
    }
    #[cfg(not(target_os = "android"))]
    {
        let _ = node;
        dirs::download_dir()
            .or_else(|| dirs::home_dir().map(|h| h.join("Downloads")))
            .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
    }
}

/// Save a blob to Downloads without opening it.
#[tauri::command]
async fn save_blob(
    state: State<'_, AppNode>,
    cid_hex: String,
    post_id_hex: Option<String>,
    filename: String,
) -> Result<String, String> {
    let node = get_node(&state).await;
    let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?;
    let cid: [u8; 32] = cid_bytes
        .try_into()
        .map_err(|_| "CID must be 32 bytes".to_string())?;

    let data = resolve_blob_data(&node, &cid, post_id_hex.as_deref()).await?;
    let safe_name = sanitize_download_filename(&filename);

    let downloads = get_writable_download_dir(&node);
    std::fs::create_dir_all(&downloads).map_err(|e| e.to_string())?;
    let dest = downloads.join(&safe_name);
    tokio::fs::write(&dest, &data).await.map_err(|e| e.to_string())?;

    Ok(dest.to_string_lossy().to_string())
}

#[tauri::command]
async fn get_blob(
    state: State<'_, AppNode>,
    cid_hex: String,
    post_id_hex: Option<String>,
) -> Result<String, String> {
    let node = get_node(&state).await;
    let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?;
    let cid: [u8; 32] = cid_bytes
        .try_into()
        .map_err(|_| "CID must be 32 bytes".to_string())?;

    let data = resolve_blob_data(&node, &cid, post_id_hex.as_deref()).await?;
    use base64::Engine;
    Ok(base64::engine::general_purpose::STANDARD.encode(&data))
}

#[tauri::command]
async fn get_feed(state: State<'_, AppNode>) -> Result<Vec<PostDto>, String> {
    let node = get_node(&state).await;
    let posts = node.get_feed().await.map_err(|e| e.to_string())?;
    let mut dtos = Vec::with_capacity(posts.len());
    for (id, p, vis, decrypted) in &posts {
        dtos.push(post_to_dto(id, p, vis, decrypted.as_deref(), &node).await);
    }
    Ok(dtos)
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct FeedPageDto {
    posts: Vec<PostDto>,
    has_more: bool,
    oldest_ms: Option<u64>,
}

#[tauri::command]
async fn get_feed_page(
    state: State<'_, AppNode>,
    before_ms: Option<u64>,
    limit: Option<usize>,
) -> Result<FeedPageDto, String> {
    let node = get_node(&state).await;
    let page_size = limit.unwrap_or(20);
    // Fetch one extra to know if there are more
    let posts = node.get_feed_page(before_ms, page_size + 1).await.map_err(|e| e.to_string())?;
    let has_more = posts.len() > page_size;
    let page: Vec<_> = posts.into_iter().take(page_size).collect();
    let oldest_ms = page.last().map(|(_, p, _, _)| p.timestamp_ms);
    let dtos = post_to_dto_batch(&page, &node).await;
    Ok(FeedPageDto { posts: dtos, has_more, oldest_ms })
}

#[tauri::command]
async fn get_all_posts_page(
    state: State<'_, AppNode>,
    before_ms: Option<u64>,
    limit: Option<usize>,
) -> Result<FeedPageDto, String> {
    let node = get_node(&state).await;
    let page_size = limit.unwrap_or(20);
    let posts = node.get_all_posts_page(before_ms, page_size + 1).await.map_err(|e| e.to_string())?;
    let has_more = posts.len() > page_size;
    let page: Vec<_> = posts.into_iter().take(page_size).collect();
    let oldest_ms = page.last().map(|(_, p, _, _)| p.timestamp_ms);
    let dtos = post_to_dto_batch(&page, &node).await;
    Ok(FeedPageDto { posts: dtos, has_more, oldest_ms })
}

/// Batched DTO assembly: 3 bulk queries instead of 4 per post
async fn post_to_dto_batch(
    posts: &[(itsgoin_core::types::PostId, itsgoin_core::types::Post, itsgoin_core::types::PostVisibility, Option<String>)],
    node: &Node,
) -> Vec<PostDto> {
    use std::collections::HashMap;
    if posts.is_empty() { return vec![]; }

    let post_ids: Vec<itsgoin_core::types::PostId> = posts.iter().map(|(id, _, _, _)| *id).collect();

    // Batch queries — 3 queries total instead of 4 × N
    let (reaction_map, comment_map, intent_map, posting_identities) = {
        let storage = node.storage.get().await;
        let reactions = storage.get_reaction_counts_batch(&post_ids, &node.node_id).unwrap_or_default();
        let comments = storage.get_comment_counts_batch(&post_ids).unwrap_or_default();
        let intents = storage.get_post_intents_batch(&post_ids).unwrap_or_default();
        let identities = storage.list_posting_identities().unwrap_or_default();
        (reactions, comments, intents, identities)
    };
    // Map posting-id -> display-name so we can tag author=persona posts.
    let persona_names: HashMap<itsgoin_core::types::NodeId, Option<String>> = posting_identities
        .into_iter()
        .map(|pi| {
            let name = if pi.display_name.is_empty() { None } else { Some(pi.display_name) };
            (pi.node_id, name)
        })
        .collect();

    // Batch resolve display names
    let mut name_cache: HashMap<itsgoin_core::types::NodeId, Option<String>> = HashMap::new();

    let mut dtos = Vec::with_capacity(posts.len());
    for (id, post, vis, decrypted) in posts {
        let (is_me, as_persona) = match persona_names.get(&post.author) {
            Some(name) => (true, name.clone()),
            None => (false, None),
        };

        let author_name = if let Some(cached) = name_cache.get(&post.author) {
            cached.clone()
        } else {
            let name = match node.resolve_display_name(&post.author).await {
                Ok((name, _, _)) if !name.is_empty() => Some(name),
                _ => None,
            };
            name_cache.insert(post.author, name.clone());
            name
        };

        let intent_kind = if let Some(intent_json) = intent_map.get(id) {
            match serde_json::from_str::<VisibilityIntent>(intent_json) {
                Ok(VisibilityIntent::Public) => "public",
                Ok(VisibilityIntent::Friends) => "friends",
                Ok(VisibilityIntent::Circle(_)) => "circle",
                Ok(VisibilityIntent::Direct(_)) => "direct",
                _ => "unknown",
            }
        } else {
            "unknown"
        }.to_string();

        let (visibility, decrypted_content) = match vis {
            PostVisibility::Public => ("public".to_string(), None),
            PostVisibility::Encrypted { .. } | PostVisibility::GroupEncrypted { .. } => match decrypted {
                Some(text) if is_me => ("encrypted".to_string(), Some(text.clone())),
                Some(text) => ("encrypted-for-me".to_string(), Some(text.clone())),
                None => ("encrypted".to_string(), None),
            },
            PostVisibility::FoFClosed => ("fof-closed".to_string(), None),
        };
        let recipients = match vis {
            PostVisibility::Encrypted { recipients } => {
                recipients.iter().map(|wk| hex::encode(wk.recipient)).collect()
            }
            _ => vec![],
        };
        let attachments = post.attachments.iter().map(|a| AttachmentDto {
            cid: hex::encode(a.cid),
            mime_type: a.mime_type.clone(),
            size_bytes: a.size_bytes,
        }).collect();

        let reaction_counts = reaction_map.get(id).cloned().unwrap_or_default()
            .into_iter()
            .map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me })
            .collect();
        let comment_count = comment_map.get(id).copied().unwrap_or(0);

        dtos.push(PostDto {
            id: hex::encode(id),
            author: hex::encode(post.author),
            author_name,
            content: post.content.clone(),
            timestamp_ms: post.timestamp_ms,
            is_me,
            visibility,
            intent_kind,
            decrypted_content,
            attachments,
            recipients,
            reaction_counts,
            comment_count,
            as_persona: as_persona.clone(),
        });
    }
    dtos
}

#[tauri::command]
async fn get_all_posts(state: State<'_, AppNode>) -> Result<Vec<PostDto>, String> {
    let node = get_node(&state).await;
    let posts = node.get_all_posts().await.map_err(|e| e.to_string())?;
    let mut dtos = Vec::with_capacity(posts.len());
    for (id, p, vis, decrypted) in &posts {
        dtos.push(post_to_dto(id, p, vis, decrypted.as_deref(), &node).await);
    }
    Ok(dtos)
}

#[tauri::command]
async fn get_stats(state: State<'_, AppNode>) -> Result<StatsDto, String> {
    let node = get_node(&state).await;
    let stats = node.stats().await.map_err(|e| e.to_string())?;
    Ok(StatsDto {
        post_count: stats.post_count,
        peer_count: stats.peer_count,
        follow_count: stats.follow_count,
    })
}

#[tauri::command]
async fn connect_peer(
    state: State<'_, AppNode>,
    connect_string: String,
) -> Result<String, String> {
    let node = get_node(&state).await;
    let (nid, addr) =
        itsgoin_core::parse_connect_string(&connect_string).map_err(|e| e.to_string())?;
    // Store peer with addresses
    let ip_addrs: Vec<_> = addr.ip_addrs().copied().collect();
    {
        let storage = node.storage.get().await;
        if ip_addrs.is_empty() {
            storage.add_peer(&nid).map_err(|e| e.to_string())?;
        } else {
            storage
                .upsert_peer(&nid, &ip_addrs, None)
                .map_err(|e| e.to_string())?;
        }
    }
    node.follow(&nid).await.map_err(|e| e.to_string())?;
    node.sync_with_addr(addr).await.map_err(|e| e.to_string())?;
    let name = node.get_display_name(&nid).await.unwrap_or(None);
    let id_hex = hex::encode(nid);
    let label = name.unwrap_or_else(|| id_hex[..12.min(id_hex.len())].to_string());
    Ok(format!("Connected and synced with {}", label))
}

#[tauri::command]
async fn follow_node(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> {
    let node = get_node(&state).await;
    let nid = parse_node_id(&node_id_hex)?;
    node.follow(&nid).await.map_err(|e| e.to_string())?;
    // Auto-sync: pull posts from the followed peer in the background (15s timeout)
    let node_clone = get_node(&state).await;
    tokio::spawn(async move {
        match tokio::time::timeout(
            std::time::Duration::from_secs(15),
            node_clone.sync_with(nid),
        ).await {
            Ok(Ok(())) => {}
            Ok(Err(e)) => tracing::debug!(error = %e, "Auto-sync after follow failed"),
            Err(_) => tracing::debug!("Auto-sync after follow timed out (15s)"),
        }
    });
    Ok(())
}

#[tauri::command]
async fn unfollow_node(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> {
    let node = get_node(&state).await;
    let nid = parse_node_id(&node_id_hex)?;
    node.unfollow(&nid).await.map_err(|e| e.to_string())
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct DiscoverProfileDto {
    node_id: String,
    display_name: String,
    bio: String,
    updated_at_ms: u64,
    has_avatar: bool,
}

/// Named peers we could follow, derived from signed profile posts we've
/// received via the CDN. Filters out self, follows, ignores, and unnamed
/// profiles. Replaces the old network-presence "Discover" path which
/// depended on peer liveness.
#[tauri::command]
async fn list_discover(state: State<'_, AppNode>) -> Result<Vec<DiscoverProfileDto>, String> {
    let node = get_node(&state).await;
    let profiles = node.list_discoverable_profiles().await.map_err(|e| e.to_string())?;
    Ok(profiles.into_iter().map(|p| DiscoverProfileDto {
        node_id: hex::encode(p.node_id),
        display_name: p.display_name,
        bio: p.bio,
        updated_at_ms: p.updated_at,
        has_avatar: p.avatar_cid.is_some(),
    }).collect())
}

#[tauri::command]
async fn ignore_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> {
    let node = get_node(&state).await;
    let nid = parse_node_id(&node_id_hex)?;
    node.ignore_peer(&nid).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn unignore_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> {
    let node = get_node(&state).await;
    let nid = parse_node_id(&node_id_hex)?;
    node.unignore_peer(&nid).await.map_err(|e| e.to_string())
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct IgnoredPeerDto {
    node_id: String,
    display_name: Option<String>,
}

#[tauri::command]
async fn list_ignored_peers(state: State<'_, AppNode>) -> Result<Vec<IgnoredPeerDto>, String> {
    let node = get_node(&state).await;
    let ids = node.list_ignored_peers().await.map_err(|e| e.to_string())?;
    let mut out = Vec::with_capacity(ids.len());
    for nid in &ids {
        let display_name = match node.resolve_display_name(nid).await {
            Ok((name, _, _)) if !name.is_empty() => Some(name),
            _ => None,
        };
        out.push(IgnoredPeerDto { node_id: hex::encode(nid), display_name });
    }
    Ok(out)
}

// --- FoF Layer 1: Vouches ---

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct VouchGivenDto {
    node_id: String,
    display_name: String,
    granted_at_ms: u64,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct VouchReceivedDto {
    node_id: String,
    display_name: String,
    epoch: u32,
    received_at_ms: u64,
}

#[tauri::command]
async fn vouch_for_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> {
    let node = get_node(&state).await;
    let nid = parse_node_id(&node_id_hex)?;
    node.vouch_for_peer(&nid).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn revoke_vouch_for_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> {
    let node = get_node(&state).await;
    let nid = parse_node_id(&node_id_hex)?;
    node.revoke_vouch_and_rotate(&nid).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn list_vouches_given(state: State<'_, AppNode>) -> Result<Vec<VouchGivenDto>, String> {
    let node = get_node(&state).await;
    let rows = node.list_vouches_given().await.map_err(|e| e.to_string())?;
    Ok(rows.into_iter().map(|(nid, name, at)| VouchGivenDto {
        node_id: hex::encode(nid),
        display_name: name,
        granted_at_ms: at,
    }).collect())
}

#[tauri::command]
async fn exit_app(app: tauri::AppHandle) {
    // On Android, the foreground NodeService survives Activity exit by
    // design (keeps network alive when backgrounded). When the user
    // explicitly hits the in-app close button, also stop the service
    // so we actually free the device's network/wakelock.
    #[cfg(target_os = "android")]
    itsgoin_core::android_wifi::stop_node_service();

    app.exit(0);
}

#[tauri::command]
async fn list_vouches_received(state: State<'_, AppNode>) -> Result<Vec<VouchReceivedDto>, String> {
    let node = get_node(&state).await;
    let rows = node.list_vouches_received().await.map_err(|e| e.to_string())?;
    Ok(rows.into_iter().map(|(nid, name, epoch, at)| VouchReceivedDto {
        node_id: hex::encode(nid),
        display_name: name,
        epoch,
        received_at_ms: at,
    }).collect())
}

// --- FoF Layer 2: comment-gated post + commenting ---

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct FoFPostCreatedDto {
    post_id: String,
}

#[tauri::command]
async fn create_post_with_fof_comments(
    state: State<'_, AppNode>,
    content: String,
) -> Result<FoFPostCreatedDto, String> {
    let node = get_node(&state).await;
    let (post_id, _post, _vis, _cek) = node
        .create_post_with_fof_comments(content, vec![])
        .await
        .map_err(|e| e.to_string())?;
    Ok(FoFPostCreatedDto { post_id: hex::encode(post_id) })
}

#[tauri::command]
async fn comment_on_fof_post(
    state: State<'_, AppNode>,
    post_id_hex: String,
    body: String,
) -> Result<(), String> {
    let node = get_node(&state).await;
    let pid = parse_node_id(&post_id_hex)?; // PostId is also [u8; 32]
    node.comment_on_fof_post(pid, body).await
        .map(|_| ())
        .map_err(|e| e.to_string())
}

#[tauri::command]
async fn revoke_fof_commenter(
    state: State<'_, AppNode>,
    post_id_hex: String,
    pub_x_index: u32,
    reason_code: u8,
) -> Result<(), String> {
    let node = get_node(&state).await;
    let pid = parse_node_id(&post_id_hex)?;
    node.revoke_fof_commenter(pid, pub_x_index, reason_code).await
        .map_err(|e| e.to_string())
}

// FoF Layer 3: Mode 1 (FoFClosed) — encrypted body + FoF comments.

#[tauri::command]
async fn create_post_fof_closed(
    state: State<'_, AppNode>,
    content: String,
) -> Result<FoFPostCreatedDto, String> {
    let node = get_node(&state).await;
    let (post_id, _post, _cek) = node
        .create_post_fof_closed(content)
        .await
        .map_err(|e| e.to_string())?;
    Ok(FoFPostCreatedDto { post_id: hex::encode(post_id) })
}

/// Returns the decrypted body of a FoFClosed post if any local persona
/// can unlock it. `None` means "ciphertext only" (not in the FoF set).
#[tauri::command]
async fn read_fof_closed_body(
    state: State<'_, AppNode>,
    post_id_hex: String,
) -> Result<Option<String>, String> {
    let node = get_node(&state).await;
    let pid = parse_node_id(&post_id_hex)?;
    node.read_fof_closed_body(&pid).await.map_err(|e| e.to_string())
}

// FoF Layer 4: V_me lifecycle + cascade + key-burn.

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct VmeRotatedDto {
    new_epoch: u32,
}

#[tauri::command]
async fn rotate_v_me(state: State<'_, AppNode>) -> Result<VmeRotatedDto, String> {
    let node = get_node(&state).await;
    let new_epoch = node.rotate_v_me().await.map_err(|e| e.to_string())?;
    Ok(VmeRotatedDto { new_epoch })
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CascadeRevokeResultDto {
    posts_revoked: usize,
}

#[tauri::command]
async fn cascade_revoke_v_me_epoch(
    state: State<'_, AppNode>,
    retired_epoch: u32,
    reason_code: u8,
) -> Result<CascadeRevokeResultDto, String> {
    let node = get_node(&state).await;
    let n = node.cascade_revoke_v_me_epoch(retired_epoch, reason_code)
        .await
        .map_err(|e| e.to_string())?;
    Ok(CascadeRevokeResultDto { posts_revoked: n })
}

#[tauri::command]
async fn key_burn_post_slot(
    state: State<'_, AppNode>,
    post_id_hex: String,
    slot_index: u32,
    new_v_x_hex: String,
) -> Result<(), String> {
    let node = get_node(&state).await;
    let pid = parse_node_id(&post_id_hex)?;
    let new_v_x_bytes = hex::decode(&new_v_x_hex)
        .map_err(|e| format!("invalid new_v_x hex: {}", e))?;
    let new_v_x: [u8; 32] = new_v_x_bytes.as_slice().try_into()
        .map_err(|_| "new_v_x must be 32 bytes".to_string())?;
    node.key_burn_post_slot(pid, slot_index, &new_v_x).await
        .map_err(|e| e.to_string())
}

#[tauri::command]
async fn list_follows(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
    let node = get_node(&state).await;
    let follows = node.list_follows().await.map_err(|e| e.to_string())?;
    // v0.6.2: since posting identities are anonymized from network
    // presence, "last activity" is now the last post we hold from that
    // author rather than the network peer last-seen timestamp. One batch
    // query for all follows.
    let activity = node.last_activity_for_follows().await.unwrap_or_default();
    let mut dtos = Vec::with_capacity(follows.len());
    for nid in &follows {
        let display_name = match node.resolve_display_name(nid).await {
            Ok((name, _, _)) if !name.is_empty() => Some(name),
            _ => None,
        };
        // Try to get peer record for address info
        let storage = node.storage.get().await;
        let rec = storage.get_peer_record(nid).ok().flatten();
        drop(storage);
        // Online presence is no longer reliable post-v0.6.1 (network id
        // is decoupled from posting id we're showing). Frontend should
        // treat this as a soft hint rather than truth.
        let is_online = node.network.is_connected(nid).await
            || node.network.has_session(nid).await;
        let last_activity_ms = *activity.get(nid).unwrap_or(&0);
        dtos.push(PeerDto {
            node_id: hex::encode(nid),
            display_name,
            addresses: rec
                .as_ref()
                .map(|r| r.addresses.iter().map(|a| a.to_string()).collect())
                .unwrap_or_default(),
            introduced_by: rec
                .as_ref()
                .and_then(|r| r.introduced_by.map(|ib| hex::encode(ib))),
            is_anchor: rec.as_ref().map(|r| r.is_anchor).unwrap_or(false),
            last_seen: rec.as_ref().map(|r| r.last_seen).unwrap_or(0),
            reach: String::new(),
            is_online,
            last_activity_ms,
        });
    }
    Ok(dtos)
}

#[tauri::command]
async fn list_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
    let node = get_node(&state).await;
    let records = node
        .list_peer_records()
        .await
        .map_err(|e| e.to_string())?;

    // Build reach sets for classification
    let mesh_ids: std::collections::HashSet<_> = node
        .list_connections()
        .await
        .into_iter()
        .map(|(nid, _, _)| nid)
        .collect();
    let (social_ids, n2_ids, n3_ids) = {
        let storage = node.storage.get().await;
        let social: std::collections::HashSet<_> = storage
            .list_social_routes()
            .unwrap_or_default()
            .into_iter()
            .map(|r| r.node_id)
            .collect();
        let n2: std::collections::HashSet<_> = storage
            .build_n2_share()
            .unwrap_or_default()
            .into_iter()
            .collect();
        let n3: std::collections::HashSet<_> = storage
            .list_distinct_n3()
            .unwrap_or_default()
            .into_iter()
            .collect();
        (social, n2, n3)
    };

    let mut dtos = Vec::with_capacity(records.len());
    for rec in &records {
        let display_name = match node.resolve_display_name(&rec.node_id).await {
            Ok((name, _, _)) if !name.is_empty() => Some(name),
            _ => None,
        };
        let introduced_by_name = if let Some(ib) = &rec.introduced_by {
            match node.resolve_display_name(ib).await {
                Ok((name, _, _)) if !name.is_empty() => Some(name),
                _ => None,
            }
        } else {
            None
        };
        let reach = if mesh_ids.contains(&rec.node_id) {
            "mesh"
        } else if social_ids.contains(&rec.node_id) {
            "n1"
        } else if n2_ids.contains(&rec.node_id) {
            "n2"
        } else if n3_ids.contains(&rec.node_id) {
            "n3"
        } else {
            "known"
        };
        dtos.push(PeerDto {
            node_id: hex::encode(rec.node_id),
            display_name,
            addresses: rec.addresses.iter().map(|a| a.to_string()).collect(),
            introduced_by: rec.introduced_by.map(|ib| {
                introduced_by_name
                    .clone()
                    .unwrap_or_else(|| hex::encode(ib)[..12].to_string())
            }),
            is_anchor: rec.is_anchor,
            last_seen: rec.last_seen,
            reach: reach.to_string(),
            is_online: mesh_ids.contains(&rec.node_id),
            last_activity_ms: rec.last_seen,
        });
    }
    Ok(dtos)
}

#[tauri::command]
async fn suggested_peers(state: State<'_, AppNode>) -> Result<Vec<SuggestedPeerDto>, String> {
    let node = get_node(&state).await;
    let records = node
        .list_peer_records()
        .await
        .map_err(|e| e.to_string())?;
    let follows = node.list_follows().await.map_err(|e| e.to_string())?;
    let follow_set: std::collections::HashSet<_> = follows.iter().collect();
    let mut dtos = Vec::new();
    for rec in &records {
        // Suggested = gossip-discovered (introduced_by is set) and not yet followed
        if rec.introduced_by.is_some() && !follow_set.contains(&rec.node_id) {
            let display_name = match node.resolve_display_name(&rec.node_id).await {
                Ok((name, _, _)) if !name.is_empty() => Some(name),
                _ => None,
            };
            let introduced_by_name = if let Some(ib) = &rec.introduced_by {
                match node.resolve_display_name(ib).await {
                    Ok((name, _, _)) if !name.is_empty() => Some(name),
                    _ => None,
                }
            } else {
                None
            };
            dtos.push(SuggestedPeerDto {
                node_id: hex::encode(rec.node_id),
                display_name,
                addresses: rec.addresses.iter().map(|a| a.to_string()).collect(),
                introduced_by_name,
                is_anchor: rec.is_anchor,
            });
        }
    }
    Ok(dtos)
}

#[tauri::command]
async fn list_circles(state: State<'_, AppNode>) -> Result<Vec<CircleDto>, String> {
    let node = get_node(&state).await;
    let circles = node.list_circles().await.map_err(|e| e.to_string())?;
    Ok(circles
        .into_iter()
        .map(|c| CircleDto {
            name: c.name,
            members: c.members.iter().map(hex::encode).collect(),
            created_at: c.created_at,
        })
        .collect())
}

#[tauri::command]
async fn create_circle(state: State<'_, AppNode>, name: String) -> Result<CircleDto, String> {
    let node = get_node(&state).await;
    node.create_circle(name.clone())
        .await
        .map_err(|e| e.to_string())?;
    Ok(CircleDto {
        name,
        members: vec![],
        created_at: std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_millis() as u64,
    })
}

#[tauri::command]
async fn delete_circle(state: State<'_, AppNode>, name: String) -> Result<(), String> {
    let node = get_node(&state).await;
    node.delete_circle(name).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn add_circle_member(
    state: State<'_, AppNode>,
    circle_name: String,
    node_id_hex: String,
) -> Result<(), String> {
    let node = get_node(&state).await;
    let nid = parse_node_id(&node_id_hex)?;
    node.add_to_circle(circle_name, nid)
        .await
        .map_err(|e| e.to_string())
}

#[tauri::command]
async fn remove_circle_member(
    state: State<'_, AppNode>,
    circle_name: String,
    node_id_hex: String,
) -> Result<(), String> {
    let node = get_node(&state).await;
    let nid = parse_node_id(&node_id_hex)?;
    node.remove_from_circle(circle_name, nid)
        .await
        .map_err(|e| e.to_string())
}

#[tauri::command]
async fn delete_post(state: State<'_, AppNode>, post_id_hex: String) -> Result<(), String> {
    let node = get_node(&state).await;
    let post_id = parse_node_id(&post_id_hex)?;
    node.delete_post(&post_id).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn revoke_circle_access(
    state: State<'_, AppNode>,
    circle_name: String,
    node_id_hex: String,
    mode: Option<String>,
) -> Result<usize, String> {
    let node = get_node(&state).await;
    let nid = parse_node_id(&node_id_hex)?;
    let rev_mode = match mode.as_deref() {
        Some("reencrypt") => itsgoin_core::types::RevocationMode::ReEncrypt,
        _ => itsgoin_core::types::RevocationMode::SyncAccessList,
    };
    node.revoke_circle_access(&circle_name, &nid, rev_mode)
        .await
        .map_err(|e| e.to_string())
}

#[tauri::command]
async fn get_redundancy_info(state: State<'_, AppNode>) -> Result<RedundancyDto, String> {
    let node = get_node(&state).await;
    let (total, zero, one, two_plus) = node
        .get_redundancy_summary()
        .await
        .map_err(|e| e.to_string())?;
    Ok(RedundancyDto {
        total,
        zero_replicas: zero,
        one_replica: one,
        two_plus_replicas: two_plus,
    })
}

#[tauri::command]
async fn set_anchors(
    state: State<'_, AppNode>,
    anchors: Vec<String>,
) -> Result<ProfileDto, String> {
    let node = get_node(&state).await;
    let anchor_ids: Vec<itsgoin_core::types::NodeId> = anchors
        .iter()
        .map(|h| parse_node_id(h))
        .collect::<Result<Vec<_>, _>>()?;
    let profile = node
        .set_anchors(anchor_ids)
        .await
        .map_err(|e| e.to_string())?;
    Ok(ProfileDto {
        node_id: hex::encode(profile.node_id),
        display_name: profile.display_name,
        bio: profile.bio,
        anchors: profile.anchors.iter().map(hex::encode).collect(),
    })
}

#[tauri::command]
async fn list_anchor_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
    let node = get_node(&state).await;
    let storage = node.storage.get().await;
    let records = storage.list_anchor_peers().map_err(|e| e.to_string())?;
    drop(storage);
    let mut dtos = Vec::with_capacity(records.len());
    for rec in &records {
        let display_name = node.get_display_name(&rec.node_id).await.unwrap_or(None);
        dtos.push(PeerDto {
            node_id: hex::encode(rec.node_id),
            display_name,
            addresses: rec.addresses.iter().map(|a| a.to_string()).collect(),
            introduced_by: rec.introduced_by.map(|ib| hex::encode(ib)),
            is_anchor: rec.is_anchor,
            last_seen: rec.last_seen,
            reach: String::new(),
            is_online: node.network.is_connected(&rec.node_id).await,
            last_activity_ms: rec.last_seen,
        });
    }
    Ok(dtos)
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct KnownAnchorDto {
    node_id: String,
    display_name: Option<String>,
    addresses: Vec<String>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ReleaseAnnouncementDto {
    channel: String,
    version: String,
    title: String,
    body: String,
    download_url: String,
    timestamp_ms: u64,
    is_newer_than_current: bool,
}

/// Check for a newer release on the user's selected update channel.
/// Returns None if no announcement is stored for that channel or the
/// stored announcement isn't actually newer than the running build.
#[tauri::command]
async fn check_release_announcement(
    state: State<'_, AppNode>,
    channel: String,
) -> Result<Option<ReleaseAnnouncementDto>, String> {
    let node = get_node(&state).await;
    let stored = node.latest_release_announcement(&channel).await.map_err(|e| e.to_string())?;
    let Some(stored) = stored else { return Ok(None); };
    let Some(release) = stored.content.release.as_ref() else { return Ok(None); };

    let current = env!("CARGO_PKG_VERSION");
    let is_newer = version_is_newer(&release.version, current);
    if !is_newer { return Ok(None); }

    Ok(Some(ReleaseAnnouncementDto {
        channel: release.channel.clone(),
        version: release.version.clone(),
        title: stored.content.title.clone(),
        body: stored.content.body.clone(),
        download_url: release.download_url.clone(),
        timestamp_ms: stored.content.timestamp_ms,
        is_newer_than_current: true,
    }))
}

/// Simple dotted-semver compare. Returns true if `a` > `b`.
/// Handles "0.6.2" vs "0.6.3" and suffixed labels like "0.6.3-beta"
/// (beta suffix treated as pre-release, so "0.6.3-beta" < "0.6.3").
fn version_is_newer(a: &str, b: &str) -> bool {
    fn parse(v: &str) -> (Vec<u32>, bool) {
        let (core, pre) = match v.split_once('-') {
            Some((c, _)) => (c, true),
            None => (v, false),
        };
        let nums: Vec<u32> = core.split('.').filter_map(|p| p.parse().ok()).collect();
        (nums, pre)
    }
    let (na, pa) = parse(a);
    let (nb, pb) = parse(b);
    let len = na.len().max(nb.len());
    for i in 0..len {
        let ai = *na.get(i).unwrap_or(&0);
        let bi = *nb.get(i).unwrap_or(&0);
        if ai != bi { return ai > bi; }
    }
    // Cores equal — a stable (no pre) outranks a pre-release.
    !pa && pb
}

/// Read the user's preferred update channel from local settings.
/// Defaults to "stable" if never set.
#[tauri::command]
async fn get_update_channel(state: State<'_, AppNode>) -> Result<String, String> {
    let node = get_node(&state).await;
    let storage = node.storage.get().await;
    Ok(storage
        .get_setting("ui_update_channel")
        .map_err(|e| e.to_string())?
        .unwrap_or_else(|| "stable".to_string()))
}

/// Return the hex id of the disposable fresh-install persona (if it still
/// exists and hasn't been claimed). Frontend uses this to filter the blank
/// starter out of the Personas list so the user never sees a "ghost"
/// persona waiting between install and their first-run choice.
/// Returns an empty string when there's no disposable persona to hide.
#[tauri::command]
async fn get_first_run_auto_persona_id(state: State<'_, AppNode>) -> Result<String, String> {
    let node = get_node(&state).await;
    let storage = node.storage.get().await;
    Ok(storage.get_setting("first_run_auto_persona_id")
        .map_err(|e| e.to_string())?
        .unwrap_or_default())
}

/// Persist the user's preferred update channel.
#[tauri::command]
async fn set_update_channel(state: State<'_, AppNode>, channel: String) -> Result<(), String> {
    if channel != "stable" && channel != "beta" {
        return Err(format!("invalid channel: {}", channel));
    }
    let node = get_node(&state).await;
    let storage = node.storage.get().await;
    storage.set_setting("ui_update_channel", &channel).map_err(|e| e.to_string())
}

#[tauri::command]
async fn get_session_relay_enabled(state: State<'_, AppNode>) -> Result<bool, String> {
    let node = get_node(&state).await;
    Ok(node.network.conn_handle().is_session_relay_enabled().await)
}

#[tauri::command]
async fn set_session_relay_enabled(state: State<'_, AppNode>, enabled: bool) -> Result<(), String> {
    let node = get_node(&state).await;
    {
        let storage = node.storage.get().await;
        storage
            .set_setting("relay.session_relay_enabled", if enabled { "true" } else { "false" })
            .map_err(|e| e.to_string())?;
    }
    node.network.conn_handle().set_session_relay_enabled(enabled).await;
    Ok(())
}

/// Open a URL in the user's default system browser.
/// Desktop: spawns the platform opener (xdg-open / open / cmd start).
/// Only https:// URLs are accepted to avoid being a generic command exec.
/// TODO: Android requires an Intent.ACTION_VIEW — add tauri-plugin-opener
/// when we need the banner to work on mobile.
#[tauri::command]
fn open_url_external(url: String) -> Result<(), String> {
    if !url.starts_with("https://") && !url.starts_with("http://") {
        return Err("only http(s) urls are allowed".to_string());
    }
    #[cfg(target_os = "linux")]
    let res = std::process::Command::new("xdg-open").arg(&url).spawn();
    #[cfg(target_os = "macos")]
    let res = std::process::Command::new("open").arg(&url).spawn();
    #[cfg(target_os = "windows")]
    let res = std::process::Command::new("cmd").args(["/c", "start", "", &url]).spawn();
    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
    let res: std::io::Result<std::process::Child> = Err(std::io::Error::new(
        std::io::ErrorKind::Unsupported,
        "open_url_external not supported on this platform",
    ));
    res.map(|_| ()).map_err(|e| e.to_string())
}

#[tauri::command]
async fn list_known_anchors(state: State<'_, AppNode>) -> Result<Vec<KnownAnchorDto>, String> {
    let node = get_node(&state).await;
    let storage = node.storage.get().await;
    let anchors = storage.list_known_anchors().map_err(|e| e.to_string())?;
    drop(storage);
    let mut dtos = Vec::with_capacity(anchors.len());
    for (nid, addrs) in &anchors {
        let display_name = node.get_display_name(nid).await.unwrap_or(None);
        dtos.push(KnownAnchorDto {
            node_id: hex::encode(nid),
            display_name,
            addresses: addrs.iter().map(|a| a.to_string()).collect(),
        });
    }
    Ok(dtos)
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WormResultDto {
    node_id: String,
    addresses: Vec<String>,
    reporter: String,
    freshness_ms: u64,
    /// If resolved through a needle_peer (recent peer of target), this is the needle's hex ID
    found_via: Option<String>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ConnectionDto {
    node_id: String,
    display_name: Option<String>,
    slot_kind: String,
    connected_at: u64,
}

#[tauri::command]
async fn list_connections(state: State<'_, AppNode>) -> Result<Vec<ConnectionDto>, String> {
    let node = get_node(&state).await;
    let conns = node.list_connections().await;
    let mut dtos = Vec::with_capacity(conns.len());
    for (nid, slot_kind, connected_at) in conns {
        let display_name = node.get_display_name(&nid).await.unwrap_or(None);
        dtos.push(ConnectionDto {
            node_id: hex::encode(nid),
            display_name,
            slot_kind: format!("{:?}", slot_kind),
            connected_at,
        });
    }
    Ok(dtos)
}

#[tauri::command]
async fn worm_lookup(
    state: State<'_, AppNode>,
    node_id_hex: String,
) -> Result<Option<WormResultDto>, String> {
    let node = get_node(&state).await;
    let nid = parse_node_id(&node_id_hex)?;
    match node.worm_lookup(&nid).await.map_err(|e| e.to_string())? {
        Some(wr) => {
            let found_via = if wr.node_id != nid {
                Some(hex::encode(wr.node_id))
            } else {
                None
            };
            Ok(Some(WormResultDto {
                node_id: hex::encode(nid),
                addresses: wr.addresses,
                reporter: hex::encode(wr.reporter),
                freshness_ms: wr.freshness_ms,
                found_via,
            }))
        }
        None => Ok(None),
    }
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SocialRouteDto {
    node_id: String,
    display_name: Option<String>,
    addresses: Vec<String>,
    peer_count: usize,
    relation: String,
    status: String,
    last_connected_ms: u64,
    last_seen_ms: u64,
    reach_method: String,
}

#[tauri::command]
async fn list_social_routes(state: State<'_, AppNode>) -> Result<Vec<SocialRouteDto>, String> {
    let node = get_node(&state).await;
    let routes = node.list_social_routes().await.map_err(|e| e.to_string())?;
    let mut dtos = Vec::with_capacity(routes.len());
    for r in &routes {
        let display_name = match node.resolve_display_name(&r.node_id).await {
            Ok((name, _, _)) if !name.is_empty() => Some(name),
            _ => None,
        };
        dtos.push(SocialRouteDto {
            node_id: hex::encode(r.node_id),
            display_name,
            addresses: r.addresses.iter().map(|a| a.to_string()).collect(),
            peer_count: r.peer_addresses.len(),
            relation: r.relation.to_string(),
            status: r.status.to_string(),
            last_connected_ms: r.last_connected_ms,
            last_seen_ms: r.last_seen_ms,
            reach_method: r.reach_method.to_string(),
        });
    }
    Ok(dtos)
}

#[tauri::command]
async fn export_identity(state: State<'_, AppNode>) -> Result<String, String> {
    let node = get_node(&state).await;
    node.export_identity_hex().map_err(|e| e.to_string())
}

#[tauri::command]
async fn set_circle_profile(
    state: State<'_, AppNode>,
    circle_name: String,
    display_name: String,
    bio: String,
    avatar_cid: Option<String>,
) -> Result<serde_json::Value, String> {
    let node = get_node(&state).await;
    let avatar = match avatar_cid {
        Some(hex) => {
            let bytes = hex::decode(&hex).map_err(|e| e.to_string())?;
            Some(<[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| "avatar_cid must be 32 bytes".to_string())?)
        }
        None => None,
    };
    let cp = node
        .set_circle_profile(circle_name, display_name, bio, avatar)
        .await
        .map_err(|e| e.to_string())?;
    Ok(serde_json::json!({
        "circleName": cp.circle_name,
        "displayName": cp.display_name,
        "bio": cp.bio,
        "avatarCid": cp.avatar_cid.map(hex::encode),
        "updatedAt": cp.updated_at,
    }))
}

#[tauri::command]
async fn get_circle_profile(
    state: State<'_, AppNode>,
    circle_name: String,
) -> Result<Option<serde_json::Value>, String> {
    let node = get_node(&state).await;
    let cp = node
        .get_circle_profile(&circle_name)
        .await
        .map_err(|e| e.to_string())?;
    Ok(cp.map(|c| {
        serde_json::json!({
            "circleName": c.circle_name,
            "displayName": c.display_name,
            "bio": c.bio,
            "avatarCid": c.avatar_cid.map(hex::encode),
            "updatedAt": c.updated_at,
        })
    }))
}

#[tauri::command]
async fn delete_circle_profile(
    state: State<'_, AppNode>,
    circle_name: String,
) -> Result<(), String> {
    let node = get_node(&state).await;
    node.delete_circle_profile(circle_name)
        .await
        .map_err(|e| e.to_string())
}

#[tauri::command]
async fn set_public_visible(
    state: State<'_, AppNode>,
    visible: bool,
) -> Result<(), String> {
    let node = get_node(&state).await;
    node.set_public_visible(visible)
        .await
        .map_err(|e| e.to_string())
}

#[tauri::command]
async fn resolve_display(
    state: State<'_, AppNode>,
    node_id_hex: String,
) -> Result<serde_json::Value, String> {
    let node = get_node(&state).await;
    let nid = parse_node_id(&node_id_hex)?;
    let (display_name, bio, avatar_cid) = node
        .resolve_display_name(&nid)
        .await
        .map_err(|e| e.to_string())?;
    Ok(serde_json::json!({
        "displayName": display_name,
        "bio": bio,
        "avatarCid": avatar_cid.map(hex::encode),
    }))
}

#[tauri::command]
async fn get_public_visible(
    state: State<'_, AppNode>,
) -> Result<bool, String> {
    let node = get_node(&state).await;
    node.get_public_visible()
        .await
        .map_err(|e| e.to_string())
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CacheStatsDto {
    used_bytes: u64,
    max_bytes: u64,
    blob_count: u64,
}

#[tauri::command]
async fn send_notification(title: String, body: String) -> Result<(), String> {
    #[cfg(not(target_os = "android"))]
    {
        let _ = notify_rust::Notification::new()
            .summary(&title)
            .body(&body)
            .appname("ItsGoin")
            .show();
    }
    Ok(())
}

#[tauri::command]
async fn get_cache_stats(state: State<'_, AppNode>) -> Result<CacheStatsDto, String> {
    let node = get_node(&state).await;
    let (used, max, count) = node.get_cache_stats().await.map_err(|e| e.to_string())?;
    Ok(CacheStatsDto {
        used_bytes: used,
        max_bytes: max,
        blob_count: count,
    })
}

#[tauri::command]
async fn get_setting(state: State<'_, AppNode>, key: String) -> Result<Option<String>, String> {
    let node = get_node(&state).await;
    node.get_setting(&key).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn set_setting(state: State<'_, AppNode>, key: String, value: String) -> Result<(), String> {
    let node = get_node(&state).await;
    node.set_setting(&key, &value).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn mark_post_seen(
    state: State<'_, AppNode>,
    post_id: String,
    react_count: u32,
    comment_count: u32,
) -> Result<(), String> {
    let node = get_node(&state).await;
    let pid = hex_to_postid(&post_id)?;
    node.set_seen_engagement(&pid, react_count, comment_count).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn mark_conversation_read(
    state: State<'_, AppNode>,
    partner_id: String,
) -> Result<(), String> {
    let node = get_node(&state).await;
    let nid = parse_node_id(&partner_id)?;
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0);
    node.set_last_read_message(&nid, now_ms).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn get_seen_engagement(
    state: State<'_, AppNode>,
    post_id: String,
) -> Result<serde_json::Value, String> {
    let node = get_node(&state).await;
    let pid = hex_to_postid(&post_id)?;
    let (rc, cc) = node.get_seen_engagement(&pid).await.map_err(|e| e.to_string())?;
    Ok(serde_json::json!({
        "seenReactCount": rc,
        "seenCommentCount": cc,
    }))
}

#[tauri::command]
async fn get_badge_counts(
    state: State<'_, AppNode>,
    last_feed_view_ms: u64,
) -> Result<BadgeCountsDto, String> {
    let node = get_node(&state).await;
    let storage = node.storage.get().await;

    // Feed badge: count non-DM posts from others newer than last_feed_view_ms
    let feed_posts = storage.get_feed().map_err(|e| e.to_string())?;
    let new_feed = feed_posts.iter()
        .filter(|(id, p, _vis)| {
            p.author != node.node_id
                && p.timestamp_ms > last_feed_view_ms
                && !matches!(
                    storage.get_post_intent(id).ok().flatten(),
                    Some(VisibilityIntent::Direct(_))
                )
        })
        .count();

    // My Posts badge: count own non-DM posts with unseen engagement
    let all_posts = storage.list_posts_reverse_chron().map_err(|e| e.to_string())?;
    let mut new_engagement = 0usize;
    for (id, post, _vis) in &all_posts {
        if post.author != node.node_id { continue; }
        // Skip DMs
        if matches!(
            storage.get_post_intent(id).ok().flatten(),
            Some(VisibilityIntent::Direct(_))
        ) { continue; }
        let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id)
            .unwrap_or_default()
            .iter()
            .map(|(_, count, _)| *count)
            .sum();
        let total_comments = storage.get_comment_count(id).unwrap_or(0);
        if total_reacts > 0 || total_comments > 0 {
            let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0));
            if total_reacts > seen_r as u64 || total_comments > seen_c as u64 {
                new_engagement += 1;
            }
        }
    }

    // Unread messages: count conversations with messages newer than last_read
    let mut unread_messages = 0usize;
    let dm_posts = all_posts.iter().filter(|(id, p, _)| {
        matches!(
            storage.get_post_intent(id).ok().flatten(),
            Some(VisibilityIntent::Direct(_))
        ) || (p.author != node.node_id && matches!(
            storage.get_post_with_visibility(id).ok().flatten(),
            Some((_, PostVisibility::Encrypted { .. }))
        ))
    });
    let mut seen_partners = std::collections::HashSet::new();
    for (_id, post, _vis) in dm_posts {
        let partner = if post.author == node.node_id {
            // sent DM — skip for unread count
            continue;
        } else {
            post.author
        };
        if seen_partners.contains(&partner) { continue; }
        seen_partners.insert(partner);
        let last_read = storage.get_last_read_message(&partner).unwrap_or(0);
        if post.timestamp_ms > last_read {
            unread_messages += 1;
        }
    }

    // Count new reacts and comments separately
    let mut new_reacts = 0usize;
    let mut new_comments = 0usize;
    for (id, post, _vis) in &all_posts {
        if post.author != node.node_id { continue; }
        let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id)
            .unwrap_or_default().iter().map(|(_, c, _)| *c).sum();
        let total_comments = storage.get_comment_count(id).unwrap_or(0);
        let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0));
        if total_reacts > seen_r as u64 { new_reacts += (total_reacts - seen_r as u64) as usize; }
        if total_comments > seen_c as u64 { new_comments += (total_comments - seen_c as u64) as usize; }
    }

    Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments })
}

#[tauri::command]
async fn get_last_read_message(
    state: State<'_, AppNode>,
    partner_id_hex: String,
) -> Result<u64, String> {
    let node = get_node(&state).await;
    let nid = parse_node_id(&partner_id_hex)?;
    node.get_last_read_message(&nid).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn generate_share_link(state: State<'_, AppNode>, post_id_hex: String) -> Result<Option<String>, String> {
    let node = get_node(&state).await;
    let pid = parse_node_id(&post_id_hex)?;
    node.generate_share_link(&pid).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn sync_all(state: State<'_, AppNode>) -> Result<String, String> {
    let node = get_node(&state).await;
    node.sync_all().await.map_err(|e| e.to_string())?;
    Ok("Sync complete".to_string())
}

#[tauri::command]
async fn sync_from_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<String, String> {
    let node = get_node(&state).await;
    let bytes = hex::decode(&node_id_hex).map_err(|e| e.to_string())?;
    let nid: [u8; 32] = bytes.try_into().map_err(|_| "Invalid node ID length")?;
    node.sync_with(nid).await.map_err(|e| e.to_string())?;
    Ok("Sync complete".to_string())
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct NetworkSummaryDto {
    preferred_count: usize,
    local_count: usize,
    wide_count: usize,
    total_connections: usize,
    n2_distinct: usize,
    n3_distinct: usize,
    has_public_v6: bool,
    has_public_v4: bool,
    has_upnp: bool,
}

#[tauri::command]
async fn get_network_summary(state: State<'_, AppNode>) -> Result<NetworkSummaryDto, String> {
    let node = get_node(&state).await;
    let conns = node.list_connections().await;
    let mut preferred = 0usize;
    let mut local = 0usize;
    let mut wide = 0usize;
    for (_nid, slot_kind, _at) in &conns {
        match slot_kind {
            PeerSlotKind::Preferred => preferred += 1,
            PeerSlotKind::Local => local += 1,
            PeerSlotKind::Wide => wide += 1,
        }
    }
    let (n2, n3) = {
        let storage = node.storage.get().await;
        let n2 = storage.count_distinct_n2().unwrap_or(0);
        let n3 = storage.count_distinct_n3().unwrap_or(0);
        (n2, n3)
    };
    Ok(NetworkSummaryDto {
        preferred_count: preferred,
        local_count: local,
        wide_count: wide,
        total_connections: conns.len(),
        n2_distinct: n2,
        n3_distinct: n3,
        has_public_v6: node.network.has_public_v6(),
        has_public_v4: node.network.is_anchor(),
        has_upnp: node.network.has_upnp(),
    })
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct OurInfoDto {
    node_id: String,
    addresses: Vec<AddressInfoDto>,
    nat_type: String,
    device_role: String,
    upnp: bool,
    http_capable: bool,
    http_addr: Option<String>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct AddressInfoDto {
    addr: String,
    family: String,    // "IPv4" or "IPv6"
    status: String,    // "Public", "NAT (easy)", "NAT (hard)", "UPnP", "LAN", "Server"
}

#[tauri::command]
async fn pick_file(app: tauri::AppHandle, title: String, filter_name: Option<String>, filter_ext: Option<Vec<String>>) -> Result<Option<String>, String> {
    #[cfg(not(any(target_os = "android", target_os = "ios")))]
    {
        use tauri_plugin_dialog::DialogExt;
        let mut builder = app.dialog().file().set_title(&title);
        if let (Some(name), Some(exts)) = (filter_name, filter_ext) {
            let ext_refs: Vec<&str> = exts.iter().map(|s| s.as_str()).collect();
            builder = builder.add_filter(&name, &ext_refs);
        }
        let path = builder.blocking_pick_file();
        Ok(path.map(|p| p.to_string()))
    }
    #[cfg(target_os = "android")]
    {
        // Android: SAF "open document" dialog. The dialog returns a content
        // URI, not a filesystem path, so we read the bytes via the plugin
        // and stage them in the app's private cache so existing import code
        // (which expects a path) can read the file normally.
        let _ = (title,);
        use tauri_plugin_android_fs::{AndroidFsExt, AndroidFs};
        let mime_types: Vec<&str> = match filter_ext.as_deref() {
            Some(exts) if exts.iter().any(|e| e == "zip") => vec!["application/zip", "application/octet-stream"],
            _ => vec!["*/*"],
        };
        let api = app.android_fs();
        let uris = api.show_open_file_dialog(None, &mime_types, false)
            .map_err(|e| format!("Open dialog failed: {}", e))?;
        let uri = match uris.into_iter().next() {
            Some(u) => u,
            None => return Ok(None),
        };
        let data = api.read(&uri).map_err(|e| format!("Read failed: {}", e))?;
        // Stage in private cache so import_* can open it by path.
        let cache_dir = app.path().app_cache_dir()
            .map_err(|e| format!("no cache dir: {}", e))?;
        std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
        // Name includes a timestamp so repeated picks don't clobber.
        let stamp = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_millis())
            .unwrap_or(0);
        let filename = match filter_ext.as_deref() {
            Some(exts) if exts.iter().any(|e| e == "zip") => format!("import-{}.zip", stamp),
            _ => format!("import-{}", stamp),
        };
        let dest = cache_dir.join(filename);
        std::fs::write(&dest, &data).map_err(|e| format!("Stage write failed: {}", e))?;
        Ok(Some(dest.to_string_lossy().to_string()))
    }
    #[cfg(target_os = "ios")]
    {
        let _ = (app, title, filter_name, filter_ext);
        Ok(None)
    }
}

#[tauri::command]
async fn pick_folder(app: tauri::AppHandle, title: String) -> Result<Option<String>, String> {
    #[cfg(not(any(target_os = "android", target_os = "ios")))]
    {
        use tauri_plugin_dialog::DialogExt;
        let path = app.dialog().file().set_title(&title).blocking_pick_folder();
        Ok(path.map(|p| p.to_string()))
    }
    #[cfg(any(target_os = "android", target_os = "ios"))]
    {
        let _ = (app, title);
        Ok(None)
    }
}

#[tauri::command]
async fn get_our_info(state: State<'_, AppNode>) -> Result<OurInfoDto, String> {
    let node = get_node(&state).await;
    let net = &node.network;
    let nat_type = net.conn_handle().nat_type().await;
    let has_upnp = net.has_upnp();
    let _has_public_v6 = net.has_public_v6();
    let bind_addr = net.bind_addr();

    let mut addresses = Vec::new();

    // Collect bound socket addresses (these are our local interfaces)
    let bound: std::collections::HashSet<String> = net.bound_sockets()
        .iter()
        .map(|s| s.to_string())
        .collect();

    // Gather bound (local) addresses with classification
    for sock in net.bound_sockets() {
        if sock.ip().is_loopback() || sock.ip().is_unspecified() { continue; }
        let family = if sock.ip().is_ipv4() { "IPv4" } else { "IPv6" };
        let status = classify_addr(&sock, &nat_type, has_upnp, bind_addr.is_some(), false);
        addresses.push(AddressInfoDto {
            addr: sock.to_string(),
            family: family.to_string(),
            status,
        });
    }

    // Add UPnP external address if different from bound
    if let Some(ref mapping) = net.upnp_mapping() {
        let ext = mapping.external_addr;
        if !addresses.iter().any(|a| a.addr == ext.to_string()) {
            addresses.insert(0, AddressInfoDto {
                addr: ext.to_string(),
                family: if ext.ip().is_ipv4() { "IPv4" } else { "IPv6" }.to_string(),
                status: "UPnP external".to_string(),
            });
        }
    }

    // Add iroh-discovered addresses not already listed
    for sock in net.endpoint_addr().ip_addrs() {
        if sock.ip().is_loopback() || sock.ip().is_unspecified() { continue; }
        let s = sock.to_string();
        if addresses.iter().any(|a| a.addr == s) { continue; }
        let family = if sock.ip().is_ipv4() { "IPv4" } else { "IPv6" };
        let is_observed = !bound.contains(&s);
        let status = classify_addr(sock, &nat_type, has_upnp, bind_addr.is_some(), is_observed);
        addresses.push(AddressInfoDto {
            addr: s,
            family: family.to_string(),
            status,
        });
    }

    // Add peer-observed external address (from anchor's your_observed_addr)
    if let Some(observed) = net.conn_handle().observed_external_addr().await {
        let s = observed.to_string();
        if !addresses.iter().any(|a| a.addr == s) {
            let family = if observed.ip().is_ipv4() { "IPv4" } else { "IPv6" };
            addresses.insert(0, AddressInfoDto {
                addr: s,
                family: family.to_string(),
                status: classify_addr(&observed, &nat_type, has_upnp, bind_addr.is_some(), true),
            });
        }
    }

    Ok(OurInfoDto {
        node_id: hex::encode(net.node_id_bytes()),
        addresses,
        nat_type: nat_type.to_string(),
        device_role: format!("{:?}", net.device_role()),
        upnp: has_upnp,
        http_capable: net.is_http_capable(),
        http_addr: net.http_addr(),
    })
}

fn classify_addr(sock: &std::net::SocketAddr, nat_type: &itsgoin_core::types::NatType, has_upnp: bool, is_server: bool, is_observed: bool) -> String {
    use std::net::IpAddr;
    let ip = sock.ip();
    let is_v6 = ip.is_ipv6();
    let is_pub = match ip {
        IpAddr::V4(v4) => !v4.is_private() && !v4.is_loopback() && !v4.is_link_local(),
        IpAddr::V6(v6) => !v6.is_loopback() && {
            let seg = v6.segments();
            seg[0] & 0xfe00 != 0xfe00 && seg[0] & 0xffc0 != 0xfe80
        },
    };
    if is_server { return "Server".to_string(); }
    if is_pub {
        if is_observed && !is_v6 {
            return match nat_type {
                itsgoin_core::types::NatType::Public |
                itsgoin_core::types::NatType::Easy => "External · easy punch".to_string(),
                itsgoin_core::types::NatType::Hard => "External · hard punch (scan)".to_string(),
                itsgoin_core::types::NatType::Unknown => "External".to_string(),
            };
        }
        return "Public".to_string();
    }
    if is_v6 { return "Link-local".to_string(); }
    "LAN".to_string()
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ActivityEventDto {
    timestamp_ms: u64,
    level: String,
    category: String,
    message: String,
    peer_id: Option<String>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ActivityLogDto {
    events: Vec<ActivityEventDto>,
    rebalance_last_ms: u64,
    rebalance_interval_secs: u64,
    anchor_register_last_ms: u64,
    anchor_register_interval_secs: u64,
}

#[tauri::command]
async fn get_activity_log(state: State<'_, AppNode>) -> Result<ActivityLogDto, String> {
    let node = get_node(&state).await;
    let events = node.get_activity_log(200);
    let (rebalance_last, anchor_last) = node.timer_state();
    let dto_events: Vec<ActivityEventDto> = events.into_iter().map(|e| {
        ActivityEventDto {
            timestamp_ms: e.timestamp_ms,
            level: format!("{:?}", e.level).to_lowercase(),
            category: format!("{:?}", e.category).to_lowercase(),
            message: e.message,
            peer_id: e.peer_id.map(hex::encode),
        }
    }).collect();
    Ok(ActivityLogDto {
        events: dto_events,
        rebalance_last_ms: rebalance_last,
        rebalance_interval_secs: 600,
        anchor_register_last_ms: anchor_last,
        anchor_register_interval_secs: 600,
    })
}

#[tauri::command]
async fn trigger_rebalance(state: State<'_, AppNode>) -> Result<String, String> {
    let node = get_node(&state).await;
    node.network.rebalance().await.map_err(|e| e.to_string())?;
    let conns = node.list_connections().await;
    Ok(format!("Rebalance complete — {} connections", conns.len()))
}

#[tauri::command]
async fn request_referrals(state: State<'_, AppNode>) -> Result<String, String> {
    let node = get_node(&state).await;
    let node_id = node.node_id;
    // Try known_anchors table first (populated by anchor register cycle),
    // fall back to anchor peers from the peers table (is_anchor = true)
    let anchors: Vec<(NodeId, Vec<std::net::SocketAddr>)> = {
        let storage = node.storage.get().await;
        let known = storage.list_known_anchors().unwrap_or_default();
        if !known.is_empty() {
            known
        } else {
            storage.list_anchor_peers().unwrap_or_default()
                .into_iter()
                .map(|r| (r.node_id, r.addresses))
                .collect()
        }
    };
    if anchors.is_empty() {
        return Ok("No known anchors".to_string());
    }
    let mut total = 0usize;
    let mut reachable = 0usize;
    for (anchor_nid, anchor_addrs) in &anchors {
        if *anchor_nid == node_id {
            continue;
        }
        // Connect to anchor if not already connected
        if !node.network.is_peer_connected(anchor_nid).await {
            let endpoint_id = match itsgoin_core::EndpointId::from_bytes(anchor_nid) {
                Ok(eid) => eid,
                Err(_) => continue,
            };
            let mut addr = itsgoin_core::EndpointAddr::from(endpoint_id);
            for sa in anchor_addrs {
                addr = addr.with_ip_addr(*sa);
            }
            if let Err(_) = node.network.connect_to_peer(*anchor_nid, addr).await {
                continue;
            }
        }
        match node.network.request_anchor_referrals(anchor_nid).await {
            Ok(referrals) => {
                reachable += 1;
                for referral in &referrals {
                    if referral.node_id == node_id {
                        continue;
                    }
                    if let Some(addr_str) = referral.addresses.first() {
                        let connect_str = format!(
                            "{}@{}",
                            hex::encode(referral.node_id),
                            addr_str,
                        );
                        if let Ok((rid, raddr)) = itsgoin_core::parse_connect_string(&connect_str) {
                            match node.network.connect_to_peer(rid, raddr).await {
                                Ok(()) => {}
                                Err(_) => {
                                    // Direct connect failed (NAT) — try hole punch via anchor
                                    let _ = node.network.connect_via_introduction(rid, *anchor_nid).await;
                                }
                            }
                            total += 1;
                        }
                    }
                }
            }
            Err(_) => {}
        }
    }
    Ok(format!("Got {} referrals from {} anchors", total, reachable))
}

#[tauri::command]
async fn reset_data(state: State<'_, AppNode>) -> Result<String, String> {
    let node = get_node(&state).await;
    // Write the sentinel at the APP-level data_dir (parent of the active
    // identity's dir). The startup sentinel check runs at the same level.
    // Earlier versions wrote to node.data_dir which is the identity subdir,
    // making the check miss on Android.
    let app_data_dir = node.data_dir.parent()
        .ok_or_else(|| "no parent data dir".to_string())?
        .to_path_buf();
    let sentinel = app_data_dir.join(".reset");
    std::fs::write(&sentinel, b"reset").map_err(|e| e.to_string())?;
    Ok("Reset scheduled. Restart the app to apply.".to_string())
}

// --- Engagement IPC commands ---

#[tauri::command]
async fn react_to_post(
    state: State<'_, AppNode>,
    post_id: String,
    emoji: String,
    private: bool,
) -> Result<ReactionDto, String> {
    let node = get_node(&state).await;
    let pid = hex_to_postid(&post_id)?;
    let reaction = node.react_to_post(pid, emoji, private).await.map_err(|e| e.to_string())?;
    Ok(ReactionDto {
        reactor: hex::encode(reaction.reactor),
        emoji: reaction.emoji,
        post_id: hex::encode(reaction.post_id),
        timestamp_ms: reaction.timestamp_ms,
        encrypted_payload: reaction.encrypted_payload,
    })
}

#[tauri::command]
async fn remove_reaction(
    state: State<'_, AppNode>,
    post_id: String,
    emoji: String,
) -> Result<(), String> {
    let node = get_node(&state).await;
    let pid = hex_to_postid(&post_id)?;
    node.remove_reaction(pid, emoji).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn get_reactions(
    state: State<'_, AppNode>,
    post_id: String,
) -> Result<Vec<ReactionDto>, String> {
    let node = get_node(&state).await;
    let pid = hex_to_postid(&post_id)?;
    let reactions = node.get_reactions(pid).await.map_err(|e| e.to_string())?;
    Ok(reactions.into_iter().map(|r| ReactionDto {
        reactor: hex::encode(r.reactor),
        emoji: r.emoji,
        post_id: hex::encode(r.post_id),
        timestamp_ms: r.timestamp_ms,
        encrypted_payload: r.encrypted_payload,
    }).collect())
}

#[tauri::command]
async fn get_reaction_counts(
    state: State<'_, AppNode>,
    post_id: String,
) -> Result<Vec<ReactionCountDto>, String> {
    let node = get_node(&state).await;
    let pid = hex_to_postid(&post_id)?;
    let counts = node.get_reaction_counts(pid).await.map_err(|e| e.to_string())?;
    Ok(counts.into_iter().map(|(emoji, count, reacted_by_me)| {
        ReactionCountDto { emoji, count, reacted_by_me }
    }).collect())
}

#[tauri::command]
async fn comment_on_post(
    state: State<'_, AppNode>,
    post_id: String,
    content: String,
) -> Result<CommentDto, String> {
    let node = get_node(&state).await;
    let pid = hex_to_postid(&post_id)?;
    let comment = node.comment_on_post(pid, content).await.map_err(|e| e.to_string())?;
    let author_name = match node.resolve_display_name(&comment.author).await {
        Ok((name, _, _)) if !name.is_empty() => Some(name),
        _ => None,
    };
    Ok(CommentDto {
        author: hex::encode(comment.author),
        author_name,
        post_id: hex::encode(comment.post_id),
        content: comment.content,
        timestamp_ms: comment.timestamp_ms,
    })
}

#[tauri::command]
async fn edit_comment(
    state: State<'_, AppNode>,
    post_id: String,
    timestamp_ms: u64,
    new_content: String,
) -> Result<(), String> {
    let node = get_node(&state).await;
    let pid = hex_to_postid(&post_id)?;
    node.edit_comment(pid, timestamp_ms, new_content).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn delete_comment(
    state: State<'_, AppNode>,
    post_id: String,
    timestamp_ms: u64,
) -> Result<(), String> {
    let node = get_node(&state).await;
    let pid = hex_to_postid(&post_id)?;
    node.delete_comment(pid, timestamp_ms).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn get_comments(
    state: State<'_, AppNode>,
    post_id: String,
) -> Result<Vec<CommentDto>, String> {
    let node = get_node(&state).await;
    let pid = hex_to_postid(&post_id)?;
    let comments = node.get_comments(pid).await.map_err(|e| e.to_string())?;
    let mut dtos = Vec::new();
    for c in comments {
        let author_name = match node.resolve_display_name(&c.author).await {
            Ok((name, _, _)) if !name.is_empty() => Some(name),
            _ => None,
        };
        dtos.push(CommentDto {
            author: hex::encode(c.author),
            author_name,
            post_id: hex::encode(c.post_id),
            content: c.content,
            timestamp_ms: c.timestamp_ms,
        });
    }
    Ok(dtos)
}

#[tauri::command]
async fn set_comment_policy(
    state: State<'_, AppNode>,
    post_id: String,
    allow_comments: String,
    allow_reacts: String,
) -> Result<(), String> {
    let node = get_node(&state).await;
    let pid = hex_to_postid(&post_id)?;
    let comment_perm = match allow_comments.as_str() {
        "followers_only" | "audience_only" => itsgoin_core::types::CommentPermission::FollowersOnly,
        "none" => itsgoin_core::types::CommentPermission::None,
        _ => itsgoin_core::types::CommentPermission::Public,
    };
    let react_perm = match allow_reacts.as_str() {
        "public" => itsgoin_core::types::ReactPermission::Public,
        "private" => itsgoin_core::types::ReactPermission::Private,
        "none" => itsgoin_core::types::ReactPermission::None,
        _ => itsgoin_core::types::ReactPermission::Both,
    };
    let policy = itsgoin_core::types::CommentPolicy {
        allow_comments: comment_perm,
        allow_reacts: react_perm,
        ..Default::default()
    };
    node.set_comment_policy(pid, policy).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn get_comment_policy(
    state: State<'_, AppNode>,
    post_id: String,
) -> Result<Option<CommentPolicyDto>, String> {
    let node = get_node(&state).await;
    let pid = hex_to_postid(&post_id)?;
    let policy = node.get_comment_policy(pid).await.map_err(|e| e.to_string())?;
    Ok(policy.map(|p| CommentPolicyDto {
        allow_comments: format!("{:?}", p.allow_comments).to_lowercase(),
        allow_reacts: format!("{:?}", p.allow_reacts).to_lowercase(),
        moderation: format!("{:?}", p.moderation).to_lowercase(),
        blocklist: p.blocklist.iter().map(hex::encode).collect(),
    }))
}

#[tauri::command]
async fn get_comment_thread(
    state: State<'_, AppNode>,
    post_id: String,
) -> Result<Vec<CommentDto>, String> {
    let node = get_node(&state).await;
    let pid = hex_to_postid(&post_id)?;
    let comments = node.get_comment_thread(pid).await.map_err(|e| e.to_string())?;
    let mut dtos = Vec::new();
    for c in comments {
        let author_name = match node.resolve_display_name(&c.author).await {
            Ok((name, _, _)) if !name.is_empty() => Some(name),
            _ => None,
        };
        dtos.push(CommentDto {
            author: hex::encode(c.author),
            author_name,
            post_id: hex::encode(c.post_id),
            content: c.content,
            timestamp_ms: c.timestamp_ms,
        });
    }
    Ok(dtos)
}

#[tauri::command]
async fn write_message_receipt(
    state: State<'_, AppNode>,
    post_id: String,
    receipt_state: String,
    emoji: Option<String>,
) -> Result<(), String> {
    let node = get_node(&state).await;
    let pid = hex_to_postid(&post_id)?;
    let state_val = itsgoin_core::types::ReceiptState::from_str_label(&receipt_state);
    node.write_receipt_slot(pid, state_val, emoji).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn write_message_comment(
    state: State<'_, AppNode>,
    post_id: String,
    content: String,
) -> Result<(), String> {
    let node = get_node(&state).await;
    let pid = hex_to_postid(&post_id)?;
    node.write_comment_slot(pid, content).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn get_message_receipts(
    state: State<'_, AppNode>,
    post_id: String,
) -> Result<Vec<ReceiptSlotDto>, String> {
    let node = get_node(&state).await;
    let pid = hex_to_postid(&post_id)?;
    let slots = node.read_receipt_slots(pid).await.map_err(|e| e.to_string())?;
    Ok(slots.into_iter().map(|s| ReceiptSlotDto {
        slot_index: s.slot_index,
        node_id: s.node_id.map(hex::encode),
        state: s.state.as_str().to_string(),
        timestamp_ms: s.timestamp_ms,
        emoji: s.emoji,
    }).collect())
}

#[tauri::command]
async fn get_message_comments(
    state: State<'_, AppNode>,
    post_id: String,
) -> Result<Vec<CommentSlotDto>, String> {
    let node = get_node(&state).await;
    let pid = hex_to_postid(&post_id)?;
    let slots = node.read_comment_slots(pid).await.map_err(|e| e.to_string())?;
    let mut dtos = Vec::new();
    for s in slots {
        let author_name = match node.resolve_display_name(&s.author).await {
            Ok((name, _, _)) if !name.is_empty() => Some(name),
            _ => None,
        };
        dtos.push(CommentSlotDto {
            slot_index: s.slot_index,
            author: hex::encode(s.author),
            author_name,
            timestamp_ms: s.timestamp_ms,
            content: s.content,
        });
    }
    Ok(dtos)
}

fn hex_to_postid(hex_str: &str) -> Result<itsgoin_core::types::PostId, String> {
    let bytes = hex::decode(hex_str).map_err(|e| format!("invalid hex: {}", e))?;
    if bytes.len() != 32 {
        return Err("post_id must be 32 bytes".to_string());
    }
    let mut id = [0u8; 32];
    id.copy_from_slice(&bytes);
    Ok(id)
}

// --- App setup ---

// --- Identity management IPC commands ---

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct IdentityInfoDto {
    node_id: String,
    display_name: String,
    created_at: u64,
    last_used_at: u64,
    is_active: bool,
}

#[tauri::command]
async fn list_identities(state: State<'_, AppIdentity>) -> Result<Vec<IdentityInfoDto>, String> {
    let mgr = state.lock().await;
    let active_id = mgr.active_id();
    let identities = mgr.list_identities().map_err(|e| e.to_string())?;
    Ok(identities.into_iter().map(|i| IdentityInfoDto {
        is_active: active_id.map_or(false, |a| a == i.node_id),
        node_id: i.node_id_hex,
        display_name: i.display_name,
        created_at: i.created_at,
        last_used_at: i.last_used_at,
    }).collect())
}

#[tauri::command]
async fn create_identity(state: State<'_, AppIdentity>, name: String) -> Result<String, String> {
    let mgr = state.lock().await;
    let node_id = mgr.create_identity(&name).map_err(|e| e.to_string())?;
    Ok(hex::encode(node_id))
}

#[tauri::command]
async fn switch_identity(
    node_state: State<'_, AppNode>,
    id_state: State<'_, AppIdentity>,
    node_id_hex: String,
) -> Result<String, String> {
    let nid_bytes = hex::decode(&node_id_hex).map_err(|e| e.to_string())?;
    let nid: NodeId = nid_bytes.try_into().map_err(|_| "Invalid node ID".to_string())?;

    let mut mgr = id_state.lock().await;
    let new_node = mgr.switch_identity(&nid).await.map_err(|e| e.to_string())?;

    // Start background tasks on the new node
    new_node.start_accept_loop();
    new_node.start_pull_cycle(300);
    new_node.start_diff_cycle(120);
    new_node.start_rebalance_cycle(600);
    new_node.start_growth_loop();
    new_node.start_recovery_loop();
    new_node.start_social_checkin_cycle(3600);
    new_node.start_anchor_register_cycle(600);
    new_node.start_upnp_renewal_cycle();
    new_node.start_upnp_tcp_renewal_cycle();
    new_node.start_http_server();
    new_node.start_bootstrap_connectivity_check();
    new_node.start_replication_cycle(600);

    let cache_max_bytes: u64 = {
        let storage = new_node.storage.get().await;
        storage.get_setting("cache_size_bytes")
            .ok()
            .flatten()
            .and_then(|s| s.parse().ok())
            .unwrap_or(1_073_741_824u64)
    };
    Node::start_eviction_cycle(Arc::clone(&new_node), 300, cache_max_bytes);

    // Hot-swap the active node
    {
        let mut current = node_state.write().await;
        *current = new_node;
    }

    Ok(format!("Switched to {}", &node_id_hex[..12]))
}

#[tauri::command]
async fn delete_identity(state: State<'_, AppIdentity>, node_id_hex: String) -> Result<String, String> {
    let nid_bytes = hex::decode(&node_id_hex).map_err(|e| e.to_string())?;
    let nid: NodeId = nid_bytes.try_into().map_err(|_| "Invalid node ID".to_string())?;
    let mgr = state.lock().await;
    mgr.delete_identity(&nid).map_err(|e| e.to_string())?;
    Ok("Identity deleted".to_string())
}

#[tauri::command]
async fn import_identity_key(state: State<'_, AppIdentity>, key_hex: String, name: String) -> Result<String, String> {
    let mgr = state.lock().await;
    let node_id = mgr.import_identity_from_key(&key_hex, &name).map_err(|e| e.to_string())?;
    Ok(hex::encode(node_id))
}

#[tauri::command]
async fn get_active_identity(state: State<'_, AppIdentity>) -> Result<Option<IdentityInfoDto>, String> {
    let mgr = state.lock().await;
    let active_id = mgr.active_id();
    if active_id.is_none() {
        return Ok(None);
    }
    let identities = mgr.list_identities().map_err(|e| e.to_string())?;
    Ok(identities.into_iter().find(|i| Some(i.node_id) == active_id).map(|i| IdentityInfoDto {
        is_active: true,
        node_id: i.node_id_hex,
        display_name: i.display_name,
        created_at: i.created_at,
        last_used_at: i.last_used_at,
    }))
}

// --- Export/Import IPC ---

#[tauri::command]
async fn export_data(
    state: State<'_, AppNode>,
    scope: String,
    output_dir: String,
) -> Result<String, String> {
    let node = get_node(&state).await;
    let export_scope = match scope.as_str() {
        "identity_only" => itsgoin_core::export::ExportScope::IdentityOnly,
        "posts_only" => itsgoin_core::export::ExportScope::PostsOnly,
        "posts_with_identity" => itsgoin_core::export::ExportScope::PostsWithIdentity,
        "everything" => itsgoin_core::export::ExportScope::Everything,
        _ => return Err("Invalid scope".to_string()),
    };
    // Resolve output directory — on Android use app data dir, on desktop resolve relative to home
    let resolved_dir = {
        #[cfg(target_os = "android")]
        {
            let _ = &output_dir;
            node.data_dir.join("exports")
        }
        #[cfg(not(target_os = "android"))]
        {
            if std::path::Path::new(&output_dir).is_relative() {
                dirs::home_dir()
                    .unwrap_or_else(|| std::path::PathBuf::from("."))
                    .join(&output_dir)
            } else {
                std::path::PathBuf::from(&output_dir)
            }
        }
    };
    let result = itsgoin_core::export::export_data(
        &node.data_dir,
        &node.storage,
        &node.blob_store,
        &node.node_id,
        export_scope,
        &resolved_dir,
    ).await.map_err(|e| e.to_string())?;

    let paths: Vec<String> = result.paths.iter()
        .map(|p| p.to_string_lossy().to_string())
        .collect();
    Ok(format!("Exported {} posts, {} blobs to {} file(s): {}",
        result.post_count, result.blob_count, paths.len(),
        paths.join(", ")))
}

/// Clear the duplicate identity flag and start sync tasks that were skipped.
#[tauri::command]
async fn clear_duplicate_flag(state: State<'_, AppNode>) -> Result<(), String> {
    let node = get_node(&state).await;
    node.network.duplicate_detected.store(false, std::sync::atomic::Ordering::Relaxed);
    // Start the sync tasks that were skipped during bootstrap
    node.start_accept_loop();
    node.start_pull_cycle(300);
    node.start_diff_cycle(120);
    node.start_rebalance_cycle(600);
    node.start_growth_loop();
    node.start_recovery_loop();
    node.start_social_checkin_cycle(3600);
    node.start_anchor_register_cycle(600);
    node.start_upnp_renewal_cycle();
    node.start_upnp_tcp_renewal_cycle();
    node.start_http_server();
    node.start_bootstrap_connectivity_check();
    node.start_replication_cycle(600);
    let cache_max_bytes: u64 = {
        let storage = node.storage.get().await;
        storage.get_setting("cache_size_bytes").ok().flatten()
            .and_then(|s| s.parse().ok()).unwrap_or(1_073_741_824u64)
    };
    Node::start_eviction_cycle(Arc::clone(&node), 300, cache_max_bytes);
    Ok(())
}

/// On Android: save a file from the app's internal storage to a user-chosen location via SAF.
/// On desktop: no-op (files are already in ~/Downloads).
#[tauri::command]
async fn share_file(app: tauri::AppHandle, file_path: String, mime_type: String) -> Result<String, String> {
    #[cfg(target_os = "android")]
    {
        use tauri_plugin_android_fs::{AndroidFsExt, AndroidFs};
        let path = std::path::Path::new(&file_path);
        let filename = path.file_name()
            .map(|n| n.to_string_lossy().to_string())
            .unwrap_or_else(|| "export.zip".to_string());
        let data = std::fs::read(path).map_err(|e| format!("Failed to read file: {}", e))?;
        let api = app.android_fs();
        let uri = api.show_save_file_dialog(None, &filename, Some(&mime_type))
            .map_err(|e| format!("Save dialog failed: {}", e))?;
        match uri {
            Some(uri) => {
                api.write(&uri, &data).map_err(|e| format!("Write failed: {}", e))?;
                Ok(format!("Saved to device"))
            }
            None => Ok("Cancelled".to_string()),
        }
    }
    #[cfg(not(target_os = "android"))]
    {
        let _ = (app, mime_type);
        // Desktop: just return the path — file is already accessible
        Ok(file_path)
    }
}

#[tauri::command]
async fn import_summary(zip_path: String) -> Result<String, String> {
    let summary = itsgoin_core::import::read_import_summary(std::path::Path::new(&zip_path))
        .map_err(|e| e.to_string())?;
    serde_json::to_string(&summary).map_err(|e| e.to_string())
}

#[tauri::command]
async fn import_public_posts(
    state: State<'_, AppNode>,
    zip_path: String,
) -> Result<String, String> {
    let node = get_node(&state).await;
    let result = itsgoin_core::import::import_public_posts(
        std::path::Path::new(&zip_path),
        &node.storage,
        &node.blob_store,
        &node.node_id,
    ).await.map_err(|e| e.to_string())?;
    Ok(result.message)
}

/// Import a bundle as personas on the current identity. The bundle's posting
/// keys become additional personas; imported content keeps its original author
/// and encrypted content becomes decryptable because we now hold those keys.
#[tauri::command]
async fn import_as_personas_cmd(
    state: State<'_, AppNode>,
    zip_path: String,
) -> Result<String, String> {
    let node = get_node(&state).await;
    let result = itsgoin_core::import::import_as_personas(
        std::path::Path::new(&zip_path),
        &node.storage,
        &node.blob_store,
    ).await.map_err(|e| e.to_string())?;
    // Drop the disposable fresh-install persona if it's still pristine.
    // Safe-by-construction: the helper bails unless ALL of [no name, no
    // posts, no engagement, no longer the default] hold.
    let pruned = node.try_prune_first_run_auto_persona().await
        .unwrap_or(false);
    let msg = if pruned {
        format!("{} (cleared blank starter persona)", result.message)
    } else {
        result.message
    };
    Ok(msg)
}

#[tauri::command]
async fn import_as_new_identity(
    state: State<'_, AppIdentity>,
    zip_path: String,
) -> Result<String, String> {
    let mgr = state.lock().await;
    let base_dir = mgr.base_dir().to_path_buf();
    drop(mgr);
    let node_id = itsgoin_core::import::import_as_identity(
        std::path::Path::new(&zip_path),
        &base_dir,
    ).map_err(|e| e.to_string())?;
    Ok(format!("Identity {} imported — switch to it in Settings", &node_id[..12]))
}

#[tauri::command]
async fn import_merge_with_key(
    state: State<'_, AppNode>,
    zip_path: String,
    key_hex: String,
) -> Result<String, String> {
    let node = get_node(&state).await;
    let result = itsgoin_core::import::merge_with_key(
        std::path::Path::new(&zip_path),
        &key_hex,
        &node.storage,
        &node.blob_store,
        &node.node_id,
        &node.secret_seed(),
    ).await.map_err(|e| e.to_string())?;
    Ok(result.message)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "info,iroh=warn,swarm_discovery=warn".parse().unwrap()),
        )
        .init();

    // On desktop, create our own runtime. On mobile, Tauri provides one.
    let _rt_guard = if cfg!(target_os = "android") || cfg!(target_os = "ios") {
        None
    } else {
        let rt = tokio::runtime::Builder::new_multi_thread()
            .enable_all()
            .build()
            .expect("Failed to create tokio runtime");
        tauri::async_runtime::set(rt.handle().clone());
        Some(rt)
    };

    info!("Starting Tauri app");
    tauri::Builder::default()
        .plugin(tauri_plugin_notification::init())
        .plugin(tauri_plugin_dialog::init())
        .plugin({
            #[cfg(target_os = "android")]
            { tauri_plugin_android_fs::init() }
            #[cfg(not(target_os = "android"))]
            { tauri::plugin::Builder::<tauri::Wry>::new("android-fs-stub").build() }
        })
        .setup(move |app| {
            // Desktop: store data next to the AppImage/executable so each copy
            // gets its own identity. Mobile: use the standard app data dir.
            let data_dir = if cfg!(target_os = "android") || cfg!(target_os = "ios") {
                app.path().app_data_dir()?
            } else {
                // APPIMAGE env var points to the .AppImage file itself
                let exe_dir = std::env::var("APPIMAGE")
                    .ok()
                    .and_then(|p| std::path::PathBuf::from(p).parent().map(|d| d.to_path_buf()))
                    .or_else(|| std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())));
                match exe_dir {
                    Some(dir) => dir.join("itsgoin-data"),
                    None => app.path().app_data_dir()?,
                }
            };
            std::fs::create_dir_all(&data_dir)?;

            // Check for reset sentinel from previous session. A "Reset All
            // Data" request wipes EVERYTHING under the app data dir so the
            // next launch starts truly fresh — new network key, new posting
            // key, no posts, no blobs, no identities.
            let sentinel = data_dir.join(".reset");
            if sentinel.exists() {
                info!("Reset sentinel found — wiping all app data");
                if let Ok(entries) = std::fs::read_dir(&data_dir) {
                    for entry in entries.flatten() {
                        let path = entry.path();
                        if path.ends_with(".reset") { continue; }
                        if path.is_dir() {
                            let _ = std::fs::remove_dir_all(&path);
                        } else {
                            let _ = std::fs::remove_file(&path);
                        }
                    }
                }
                let _ = std::fs::remove_file(&sentinel);
            }

            info!(data_dir = %data_dir.display(), "Opening node via IdentityManager");
            let is_mobile = cfg!(target_os = "android") || cfg!(target_os = "ios");
            let profile = if is_mobile {
                itsgoin_core::types::DeviceProfile::Mobile
            } else {
                itsgoin_core::types::DeviceProfile::Desktop
            };
            let (node, identity_mgr) = tauri::async_runtime::block_on(async {
                let mut mgr = IdentityManager::open(&data_dir, None, profile).await?;

                let n = if let Some(node) = mgr.active_node() {
                    Arc::clone(node)
                } else {
                    // No identity — create a temporary one so the app can start.
                    // Frontend detects this via get_node_info and shows the first-run chooser.
                    info!("No identity found — creating initial identity for first-run setup");
                    let node_id = mgr.create_identity("My Identity")?;
                    mgr.switch_identity(&node_id).await?;
                    Arc::clone(mgr.active_node().expect("just created"))
                };

                Ok::<_, anyhow::Error>((n, mgr))
            })?;

            // Start bootstrap + background tasks AFTER setup completes (non-blocking)
            let boot_node = Arc::clone(&node);
            let boot_data_dir = data_dir.clone();
            tauri::async_runtime::spawn(async move {
                // Bootstrap: connect to anchors, NAT probe, referrals (slow — runs in background)
                if let Err(e) = boot_node.run_bootstrap(&boot_data_dir).await {
                    tracing::warn!(error = %e, "Background bootstrap failed");
                }

                // Skip sync if duplicate identity detected
                if boot_node.network.duplicate_detected.load(std::sync::atomic::Ordering::Relaxed) {
                    tracing::warn!("Duplicate identity detected — skipping sync tasks");
                    return;
                }

                // Start all background networking tasks
                boot_node.start_accept_loop();
                boot_node.start_pull_cycle(300);
                boot_node.start_diff_cycle(120);
                boot_node.start_rebalance_cycle(600);
                boot_node.start_growth_loop();
                boot_node.start_recovery_loop();
                boot_node.start_social_checkin_cycle(3600);
                boot_node.start_anchor_register_cycle(600);
                boot_node.start_upnp_renewal_cycle();
                boot_node.start_upnp_tcp_renewal_cycle();
                boot_node.start_http_server();
                boot_node.start_bootstrap_connectivity_check();
                boot_node.start_replication_cycle(600);

                let cache_max_bytes: u64 = {
                    let storage = boot_node.storage.get().await;
                    storage.get_setting("cache_size_bytes")
                        .ok()
                        .flatten()
                        .and_then(|s| s.parse().ok())
                        .unwrap_or(1_073_741_824u64)
                };
                Node::start_eviction_cycle(Arc::clone(&boot_node), 300, cache_max_bytes);
            });

            // Manage both the swappable Node and the IdentityManager
            let app_node: AppNode = Arc::new(tokio::sync::RwLock::new(node));
            let app_identity: AppIdentity = Arc::new(tokio::sync::Mutex::new(identity_mgr));
            app.manage(app_node);
            app.manage(app_identity);
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![
            get_node_info,
            set_display_name,
            set_profile,
            create_post,
            create_post_with_files,
            create_post_as,
            list_posting_identities,
            create_posting_identity,
            delete_posting_identity,
            set_default_posting_identity,
            get_blob,
            get_blob_path,
            save_and_open_blob,
            save_blob,
            get_feed,
            get_feed_page,
            get_all_posts_page,
            get_all_posts,
            get_stats,
            connect_peer,
            follow_node,
            unfollow_node,
            list_follows,
            list_peers,
            suggested_peers,
            list_discover,
            ignore_peer,
            unignore_peer,
            list_ignored_peers,
            vouch_for_peer,
            revoke_vouch_for_peer,
            list_vouches_given,
            list_vouches_received,
            create_post_with_fof_comments,
            comment_on_fof_post,
            revoke_fof_commenter,
            create_post_fof_closed,
            read_fof_closed_body,
            rotate_v_me,
            cascade_revoke_v_me_epoch,
            key_burn_post_slot,
            list_circles,
            create_circle,
            delete_circle,
            add_circle_member,
            remove_circle_member,
            delete_post,
            revoke_circle_access,
            get_redundancy_info,
            set_anchors,
            list_anchor_peers,
            list_known_anchors,
            check_release_announcement,
            get_update_channel,
            set_update_channel,
            open_url_external,
            get_first_run_auto_persona_id,
            list_connections,
            worm_lookup,
            list_social_routes,
            export_identity,
            set_circle_profile,
            get_circle_profile,
            delete_circle_profile,
            set_public_visible,
            resolve_display,
            get_public_visible,
            sync_all,
            sync_from_peer,
            get_network_summary,
            get_our_info,
            pick_file,
            pick_folder,
            get_activity_log,
            trigger_rebalance,
            request_referrals,
            reset_data,
            react_to_post,
            remove_reaction,
            get_reactions,
            get_reaction_counts,
            comment_on_post,
            edit_comment,
            delete_comment,
            get_comments,
            set_comment_policy,
            get_comment_policy,
            get_comment_thread,
            write_message_receipt,
            write_message_comment,
            get_message_receipts,
            get_message_comments,
            get_cache_stats,
            send_notification,
            get_setting,
            set_setting,
            mark_post_seen,
            mark_conversation_read,
            get_seen_engagement,
            get_badge_counts,
            get_last_read_message,
            generate_share_link,
            list_identities,
            create_identity,
            switch_identity,
            delete_identity,
            import_identity_key,
            get_active_identity,
            export_data,
            share_file,
            clear_duplicate_flag,
            import_summary,
            import_public_posts,
            import_as_new_identity,
            import_as_personas_cmd,
            import_merge_with_key,
            exit_app,
            get_session_relay_enabled,
            set_session_relay_enabled,
        ])
        .build(tauri::generate_context!())
        .expect("error while building tauri application")
        .run(|app_handle, event| {
            if let tauri::RunEvent::Resumed = event {
                // App resumed from background (mobile sleep/wake) —
                // probe connections and recover dead ones immediately
                if let Some(app_node) = app_handle.try_state::<AppNode>() {
                    let app_node = app_node.inner().clone();
                    tauri::async_runtime::spawn(async move {
                        let node = app_node.read().await.clone();
                        let removed = node.network.wake_health_check().await;
                        if removed > 0 {
                            tracing::info!(removed, "Wake health check: removed dead connections");
                        }
                    });
                }
            }
        });
}
