use std::sync::Arc;

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

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

type AppState = Arc<Node>;

// --- 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,
    /// 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,
}

#[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 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 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,
    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 {
    let author_name = match node.resolve_display_name(&post.author).await {
        Ok((name, _, _)) if !name.is_empty() => Some(name),
        _ => None,
    };
    let (visibility, decrypted_content) = match vis {
        PostVisibility::Public => ("public".to_string(), None),
        PostVisibility::Encrypted { .. } | PostVisibility::GroupEncrypted { .. } => match decrypted {
            Some(text) => ("encrypted-for-me".to_string(), Some(text.to_string())),
            None => ("encrypted".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.lock().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.lock().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: &post.author == &node.node_id,
        visibility,
        decrypted_content,
        attachments,
        recipients,
        reaction_counts,
        comment_count,
    }
}

/// Decrypt a just-created post for immediate display.
async fn decrypt_just_created(
    node: &Node,
    post: &Post,
    vis: &PostVisibility,
) -> Option<String> {
    match vis {
        PostVisibility::Public => None,
        PostVisibility::Encrypted { recipients } => {
            itsgoin_core::crypto::decrypt_post(
                &post.content,
                &node.secret_seed_bytes(),
                &node.node_id,
                &node.node_id,
                recipients,
            )
            .ok()
            .flatten()
        }
        PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => {
            let seed_info = {
                let storage = node.storage.lock().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
            }
        }
    }
}

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<'_, AppState>) -> Result<NodeInfoDto, String> {
    let node = state.inner();
    let node_id_hex = hex::encode(node.node_id);
    let addr = node.endpoint_addr();
    let connect_string = 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(),
        anchors,
    })
}

#[tauri::command]
async fn set_display_name(
    state: State<'_, AppState>,
    name: String,
) -> Result<ProfileDto, String> {
    let node = state.inner();
    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<'_, AppState>,
    name: String,
    bio: String,
) -> Result<ProfileDto, String> {
    let node = state.inner();
    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<'_, AppState>,
    content: String,
    visibility: Option<String>,
    circle_name: Option<String>,
    recipient_hex: Option<String>,
) -> Result<PostDto, String> {
    let node = state.inner();
    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_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<'_, AppState>,
    content: String,
    visibility: Option<String>,
    circle_name: Option<String>,
    recipient_hex: Option<String>,
    files: Vec<(String, String)>,
) -> Result<PostDto, String> {
    let node = state.inner();
    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) = 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)
}

/// Return the filesystem path of a blob if it exists locally (for streaming video/media).
#[tauri::command]
async fn get_blob_path(
    state: State<'_, AppState>,
    cid_hex: String,
) -> Result<Option<String>, String> {
    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())?;
    Ok(state.blob_store.file_path(&cid).map(|p| p.to_string_lossy().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<'_, AppState>,
    cid_hex: String,
    post_id_hex: Option<String>,
    filename: String,
) -> Result<String, String> {
    let node = state.inner();
    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())?;

    // Get blob data (local or fetch from network)
    let data = if let Some(d) = node.get_blob(&cid).await.map_err(|e| e.to_string())? {
        d
    } else if let Some(pid_hex) = post_id_hex {
        let pid_bytes = hex::decode(&pid_hex).map_err(|e| e.to_string())?;
        let post_id: [u8; 32] = pid_bytes.try_into().map_err(|_| "bad post_id".to_string())?;
        let post = {
            let storage = node.storage.lock().await;
            storage.get_post(&post_id).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");
            node.fetch_blob_with_fallback(&cid, &post_id, &post.author, mime, post.timestamp_ms)
                .await.map_err(|e| e.to_string())?
                .ok_or_else(|| "blob not found".to_string())?
        } else {
            return Err("post not found".to_string());
        }
    } else {
        return Err("blob not found".to_string());
    };

    // Save to Downloads
    let downloads = dirs::download_dir()
        .or_else(|| dirs::home_dir().map(|h| h.join("Downloads")))
        .unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
    let dest = downloads.join(&filename);
    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())
}

/// Save a blob to Downloads without opening it.
#[tauri::command]
async fn save_blob(
    state: State<'_, AppState>,
    cid_hex: String,
    post_id_hex: Option<String>,
    filename: String,
) -> Result<String, String> {
    let node = state.inner();
    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 = if let Some(d) = node.get_blob(&cid).await.map_err(|e| e.to_string())? {
        d
    } else if let Some(pid_hex) = post_id_hex {
        let pid_bytes = hex::decode(&pid_hex).map_err(|e| e.to_string())?;
        let post_id: [u8; 32] = pid_bytes.try_into().map_err(|_| "bad post_id".to_string())?;
        let post = {
            let storage = node.storage.lock().await;
            storage.get_post(&post_id).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");
            node.fetch_blob_with_fallback(&cid, &post_id, &post.author, mime, post.timestamp_ms)
                .await.map_err(|e| e.to_string())?
                .ok_or_else(|| "blob not found".to_string())?
        } else {
            return Err("post not found".to_string());
        }
    } else {
        return Err("blob not found".to_string());
    };

    let downloads = dirs::download_dir()
        .or_else(|| dirs::home_dir().map(|h| h.join("Downloads")))
        .unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
    let dest = downloads.join(&filename);
    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<'_, AppState>,
    cid_hex: String,
    post_id_hex: Option<String>,
) -> Result<String, String> {
    let node = state.inner();
    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())?;

    // Check local first (also touches last_accessed_at)
    if let Some(data) = node.get_blob(&cid).await.map_err(|e| e.to_string())? {
        use base64::Engine;
        return Ok(base64::engine::general_purpose::STANDARD.encode(&data));
    }

    // Try fetching from author → replica peers if post_id provided
    if let Some(pid_hex) = post_id_hex {
        let pid_bytes = hex::decode(&pid_hex).map_err(|e| e.to_string())?;
        let post_id: [u8; 32] = pid_bytes
            .try_into()
            .map_err(|_| "post_id must be 32 bytes".to_string())?;
        let post = {
            let storage = node.storage.lock().await;
            storage.get_post(&post_id).map_err(|e| e.to_string())?
        };
        if let Some(post) = post {
            // Find the mime type from the post's attachments
            let mime_type = post.attachments.iter()
                .find(|a| a.cid == cid)
                .map(|a| a.mime_type.as_str())
                .unwrap_or("application/octet-stream");

            if let Some(data) = node
                .fetch_blob_with_fallback(&cid, &post_id, &post.author, mime_type, post.timestamp_ms)
                .await
                .map_err(|e| e.to_string())?
            {
                use base64::Engine;
                return Ok(base64::engine::general_purpose::STANDARD.encode(&data));
            }
        }
    }

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

#[tauri::command]
async fn get_feed(state: State<'_, AppState>) -> Result<Vec<PostDto>, String> {
    let node = state.inner();
    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)
}

#[tauri::command]
async fn get_all_posts(state: State<'_, AppState>) -> Result<Vec<PostDto>, String> {
    let node = state.inner();
    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<'_, AppState>) -> Result<StatsDto, String> {
    let node = state.inner();
    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<'_, AppState>,
    connect_string: String,
) -> Result<String, String> {
    let node = state.inner();
    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.lock().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<'_, AppState>, node_id_hex: String) -> Result<(), String> {
    let node = state.inner();
    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
    let node_clone = state.inner().clone();
    tokio::spawn(async move {
        if let Err(e) = node_clone.sync_with(nid).await {
            tracing::debug!(error = %e, "Auto-sync after follow failed (peer may not be connected)");
        }
    });
    Ok(())
}

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

#[tauri::command]
async fn list_follows(state: State<'_, AppState>) -> Result<Vec<PeerDto>, String> {
    let node = state.inner();
    let follows = node.list_follows().await.map_err(|e| e.to_string())?;
    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.lock().await;
        let rec = storage.get_peer_record(nid).ok().flatten();
        drop(storage);
        let is_online = node.network.is_connected(nid).await
            || node.network.has_session(nid).await;
        let last_activity_ms = if let Some(arc) = node.network.conn_handle().get_peer_last_activity(nid).await {
            arc.load(std::sync::atomic::Ordering::Relaxed)
        } else {
            rec.as_ref().map(|r| r.last_seen).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<'_, AppState>) -> Result<Vec<PeerDto>, String> {
    let node = state.inner();
    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.lock().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<'_, AppState>) -> Result<Vec<SuggestedPeerDto>, String> {
    let node = state.inner();
    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<'_, AppState>) -> Result<Vec<CircleDto>, String> {
    let node = state.inner();
    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<'_, AppState>, name: String) -> Result<CircleDto, String> {
    let node = state.inner();
    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<'_, AppState>, name: String) -> Result<(), String> {
    let node = state.inner();
    node.delete_circle(name).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn add_circle_member(
    state: State<'_, AppState>,
    circle_name: String,
    node_id_hex: String,
) -> Result<(), String> {
    let node = state.inner();
    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<'_, AppState>,
    circle_name: String,
    node_id_hex: String,
) -> Result<(), String> {
    let node = state.inner();
    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<'_, AppState>, post_id_hex: String) -> Result<(), String> {
    let node = state.inner();
    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<'_, AppState>,
    circle_name: String,
    node_id_hex: String,
    mode: Option<String>,
) -> Result<usize, String> {
    let node = state.inner();
    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<'_, AppState>) -> Result<RedundancyDto, String> {
    let node = state.inner();
    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<'_, AppState>,
    anchors: Vec<String>,
) -> Result<ProfileDto, String> {
    let node = state.inner();
    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<'_, AppState>) -> Result<Vec<PeerDto>, String> {
    let node = state.inner();
    let storage = node.storage.lock().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>,
}

#[tauri::command]
async fn list_known_anchors(state: State<'_, AppState>) -> Result<Vec<KnownAnchorDto>, String> {
    let node = state.inner();
    let storage = node.storage.lock().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 AudienceDto {
    node_id: String,
    display_name: Option<String>,
    direction: String,
    status: String,
    requested_at: u64,
    approved_at: Option<u64>,
}

#[tauri::command]
async fn list_audience(state: State<'_, AppState>) -> Result<Vec<AudienceDto>, String> {
    let node = state.inner();
    let records = node
        .list_audience(
            itsgoin_core::types::AudienceDirection::Inbound,
            None,
        )
        .await
        .map_err(|e| e.to_string())?;
    let mut dtos = Vec::with_capacity(records.len());
    for r in &records {
        let display_name = node.get_display_name(&r.node_id).await.unwrap_or(None);
        let direction = match r.direction {
            itsgoin_core::types::AudienceDirection::Inbound => "inbound",
            itsgoin_core::types::AudienceDirection::Outbound => "outbound",
        };
        let status = match r.status {
            itsgoin_core::types::AudienceStatus::Pending => "pending",
            itsgoin_core::types::AudienceStatus::Approved => "approved",
            itsgoin_core::types::AudienceStatus::Denied => "denied",
        };
        dtos.push(AudienceDto {
            node_id: hex::encode(r.node_id),
            display_name,
            direction: direction.to_string(),
            status: status.to_string(),
            requested_at: r.requested_at,
            approved_at: r.approved_at,
        });
    }
    Ok(dtos)
}

#[tauri::command]
async fn list_audience_outbound(state: State<'_, AppState>) -> Result<Vec<AudienceDto>, String> {
    let node = state.inner();
    let records = node
        .list_audience(
            itsgoin_core::types::AudienceDirection::Outbound,
            None,
        )
        .await
        .map_err(|e| e.to_string())?;
    let mut dtos = Vec::with_capacity(records.len());
    for r in &records {
        let display_name = node.get_display_name(&r.node_id).await.unwrap_or(None);
        let status = match r.status {
            itsgoin_core::types::AudienceStatus::Pending => "pending",
            itsgoin_core::types::AudienceStatus::Approved => "approved",
            itsgoin_core::types::AudienceStatus::Denied => "denied",
        };
        dtos.push(AudienceDto {
            node_id: hex::encode(r.node_id),
            display_name,
            direction: "outbound".to_string(),
            status: status.to_string(),
            requested_at: r.requested_at,
            approved_at: r.approved_at,
        });
    }
    Ok(dtos)
}

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

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

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

#[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<'_, AppState>) -> Result<Vec<ConnectionDto>, String> {
    let node = state.inner();
    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<'_, AppState>,
    node_id_hex: String,
) -> Result<Option<WormResultDto>, String> {
    let node = state.inner();
    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<'_, AppState>) -> Result<Vec<SocialRouteDto>, String> {
    let node = state.inner();
    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<'_, AppState>) -> Result<String, String> {
    let node = state.inner();
    node.export_identity_hex().map_err(|e| e.to_string())
}

#[tauri::command]
async fn set_circle_profile(
    state: State<'_, AppState>,
    circle_name: String,
    display_name: String,
    bio: String,
    avatar_cid: Option<String>,
) -> Result<serde_json::Value, String> {
    let node = state.inner();
    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<'_, AppState>,
    circle_name: String,
) -> Result<Option<serde_json::Value>, String> {
    let node = state.inner();
    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<'_, AppState>,
    circle_name: String,
) -> Result<(), String> {
    let node = state.inner();
    node.delete_circle_profile(circle_name)
        .await
        .map_err(|e| e.to_string())
}

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

#[tauri::command]
async fn resolve_display(
    state: State<'_, AppState>,
    node_id_hex: String,
) -> Result<serde_json::Value, String> {
    let node = state.inner();
    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<'_, AppState>,
) -> Result<bool, String> {
    let node = state.inner();
    node.get_public_visible()
        .await
        .map_err(|e| e.to_string())
}

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

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

#[tauri::command]
async fn generate_share_link(state: State<'_, AppState>, post_id_hex: String) -> Result<Option<String>, String> {
    let node = state.inner();
    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<'_, AppState>) -> Result<String, String> {
    let node = state.inner();
    node.sync_all().await.map_err(|e| e.to_string())?;
    Ok("Sync complete".to_string())
}

#[tauri::command]
async fn sync_from_peer(state: State<'_, AppState>, node_id_hex: String) -> Result<String, String> {
    let node = state.inner();
    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,
}

#[tauri::command]
async fn get_network_summary(state: State<'_, AppState>) -> Result<NetworkSummaryDto, String> {
    let node = state.inner();
    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.lock().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,
    })
}

#[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<'_, AppState>) -> Result<ActivityLogDto, String> {
    let node = state.inner();
    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<'_, AppState>) -> Result<String, String> {
    let node = state.inner();
    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<'_, AppState>) -> Result<String, String> {
    let node = state.inner();
    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.lock().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<'_, AppState>) -> Result<String, String> {
    let node = state.inner();
    let sentinel = node.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<'_, AppState>,
    post_id: String,
    emoji: String,
    private: bool,
) -> Result<ReactionDto, String> {
    let node = state.inner();
    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<'_, AppState>,
    post_id: String,
    emoji: String,
) -> Result<(), String> {
    let node = state.inner();
    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<'_, AppState>,
    post_id: String,
) -> Result<Vec<ReactionDto>, String> {
    let node = state.inner();
    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<'_, AppState>,
    post_id: String,
) -> Result<Vec<ReactionCountDto>, String> {
    let node = state.inner();
    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<'_, AppState>,
    post_id: String,
    content: String,
) -> Result<CommentDto, String> {
    let node = state.inner();
    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 get_comments(
    state: State<'_, AppState>,
    post_id: String,
) -> Result<Vec<CommentDto>, String> {
    let node = state.inner();
    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<'_, AppState>,
    post_id: String,
    allow_comments: String,
    allow_reacts: String,
) -> Result<(), String> {
    let node = state.inner();
    let pid = hex_to_postid(&post_id)?;
    let comment_perm = match allow_comments.as_str() {
        "audience_only" => itsgoin_core::types::CommentPermission::AudienceOnly,
        "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<'_, AppState>,
    post_id: String,
) -> Result<Option<CommentPolicyDto>, String> {
    let node = state.inner();
    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<'_, AppState>,
    post_id: String,
) -> Result<Vec<CommentDto>, String> {
    let node = state.inner();
    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)
}

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 ---

#[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()
        .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
            let sentinel = data_dir.join(".reset");
            if sentinel.exists() {
                info!("Reset sentinel found — clearing data");
                let _ = std::fs::remove_file(data_dir.join("itsgoin.db"));
                let _ = std::fs::remove_dir_all(data_dir.join("blobs"));
                let _ = std::fs::remove_file(&sentinel);
            }

            info!(data_dir = %data_dir.display(), "Opening node");
            let is_mobile = cfg!(target_os = "android") || cfg!(target_os = "ios");
            let node = tauri::async_runtime::block_on(async {
                let n = if is_mobile {
                    Node::open_mobile(&data_dir).await
                } else {
                    Node::open(&data_dir).await
                }?;
                let n = Arc::new(n);

                // Start background networking (v2: mesh connections)
                // Must be inside block_on so tokio::spawn has a runtime context
                n.start_accept_loop();
                n.start_pull_cycle(300); // 5 min pull cycle
                n.start_diff_cycle(120); // 2 min routing diff
                n.start_rebalance_cycle(600); // 10 min rebalance
                n.start_growth_loop(); // reactive mesh growth
                n.start_recovery_loop(); // reactive anchor reconnect on mesh loss
                n.start_social_checkin_cycle(3600); // 1 hour social checkin
                n.start_anchor_register_cycle(600); // 10 min anchor register
                n.start_upnp_renewal_cycle(); // UPnP lease renewal (if mapped)
                n.start_upnp_tcp_renewal_cycle(); // UPnP TCP lease renewal (for HTTP serving)
                n.start_http_server(); // HTTP post delivery (if publicly reachable)
                n.start_bootstrap_connectivity_check(); // 24h isolation check

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

            app.manage(node);
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![
            get_node_info,
            set_display_name,
            set_profile,
            create_post,
            create_post_with_files,
            get_blob,
            get_blob_path,
            save_and_open_blob,
            save_blob,
            get_feed,
            get_all_posts,
            get_stats,
            connect_peer,
            follow_node,
            unfollow_node,
            list_follows,
            list_peers,
            suggested_peers,
            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,
            list_audience,
            list_audience_outbound,
            request_audience,
            approve_audience,
            remove_audience,
            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_activity_log,
            trigger_rebalance,
            request_referrals,
            reset_data,
            react_to_post,
            remove_reaction,
            get_reactions,
            get_reaction_counts,
            comment_on_post,
            get_comments,
            set_comment_policy,
            get_comment_policy,
            get_comment_thread,
            get_setting,
            set_setting,
            generate_share_link,
        ])
        .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(node) = app_handle.try_state::<AppState>() {
                    let node = node.inner().clone();
                    tauri::async_runtime::spawn(async move {
                        let removed = node.network.wake_health_check().await;
                        if removed > 0 {
                            tracing::info!(removed, "Wake health check: removed dead connections");
                        }
                    });
                }
            }
        });
}
