use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use iris_chat_core::{
    AppAction, AppReconciler, AppState, AppUpdate, ChatKind, ChatMessageSnapshot,
    ChatThreadSnapshot, CurrentChatSnapshot, DeliveryState, DesktopNearbyObserver,
    DesktopNearbySnapshot, DeviceAuthorizationState, FfiApp, FfiDesktopNearby,
    GroupDetailsSnapshot,
};
use rusqlite::Connection;
use serde::Serialize;
use serde_json::{json, Value};

#[derive(Parser)]
#[command(name = "iris")]
#[command(version)]
#[command(about = "Iris Chat command line client")]
struct Cli {
    #[arg(short, long, global = true)]
    json: bool,

    #[arg(long, global = true, env = "IRIS_DATA_DIR")]
    data_dir: Option<PathBuf>,

    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    Login {
        secret_key: String,
    },
    Restore {
        secret_key: String,
    },
    Logout,
    Whoami,
    State,
    Sync {
        #[arg(long, default_value_t = 1500)]
        wait_ms: u64,
    },
    Search {
        query: String,
        #[arg(short, long, default_value_t = 50)]
        limit: usize,
    },
    Tail {
        #[arg(short, long, default_value_t = 20)]
        limit: usize,
        #[arg(short, long)]
        follow: bool,
        #[arg(short, long)]
        chat: Option<String>,
        #[arg(long, default_value_t = 1000)]
        interval_ms: u64,
    },
    Listen {
        #[arg(short, long)]
        chat: Option<String>,
        #[arg(long, default_value_t = 1000)]
        interval_ms: u64,
        #[arg(long)]
        nearby_lan: bool,
    },
    #[command(subcommand)]
    Account(AccountCommands),
    #[command(subcommand)]
    Chat(ChatCommands),
    Send {
        chat: String,
        message: String,
        #[arg(long)]
        ttl: Option<u64>,
        #[arg(long, value_name = "UNIX_SECONDS")]
        expires_at: Option<u64>,
    },
    React {
        chat: String,
        message_id: String,
        emoji: String,
    },
    Typing {
        chat: String,
        #[arg(long)]
        stop: bool,
    },
    Receipt {
        chat: String,
        receipt_type: String,
        message_ids: Vec<String>,
    },
    Read {
        chat: String,
        #[arg(short, long, default_value_t = 50)]
        limit: usize,
    },
    Seen {
        chat: String,
        message_ids: Vec<String>,
    },
    #[command(subcommand)]
    Invite(InviteCommands),
    #[command(subcommand)]
    Link(LinkCommands),
    #[command(subcommand)]
    Group(GroupCommands),
    #[command(subcommand)]
    Relay(RelayCommands),
}

#[derive(Subcommand)]
enum AccountCommands {
    Create {
        #[arg(short, long, default_value = "")]
        name: String,
    },
    Restore {
        secret_key: String,
    },
    Bundle,
}

#[derive(Subcommand)]
enum ChatCommands {
    List,
    Create {
        user_id: String,
    },
    Open {
        chat: String,
    },
    Read {
        chat: String,
        #[arg(short, long, default_value_t = 50)]
        limit: usize,
    },
    Send {
        chat: String,
        message: String,
        #[arg(long)]
        ttl: Option<u64>,
        #[arg(long, value_name = "UNIX_SECONDS")]
        expires_at: Option<u64>,
    },
    Seen {
        chat: String,
        message_ids: Vec<String>,
    },
    Delete {
        chat: String,
    },
    Ttl {
        chat: String,
        seconds: Option<u64>,
    },
    Mute {
        chat: String,
        muted: bool,
    },
}

#[derive(Subcommand)]
enum InviteCommands {
    Create,
    Accept { invite: String },
}

#[derive(Subcommand)]
enum LinkCommands {
    Create,
    Accept { invite: String },
}

#[derive(Subcommand)]
enum GroupCommands {
    Create {
        name: String,
        members: Vec<String>,
    },
    List,
    Show {
        group: String,
    },
    Send {
        group: String,
        message: String,
    },
    Read {
        group: String,
        #[arg(short, long, default_value_t = 50)]
        limit: usize,
    },
    Add {
        group: String,
        members: Vec<String>,
    },
    Remove {
        group: String,
        members: Vec<String>,
    },
    AddAdmin {
        group: String,
        member: String,
    },
    RemoveAdmin {
        group: String,
        member: String,
    },
    Rename {
        group: String,
        name: String,
    },
    React {
        group: String,
        message_id: String,
        emoji: String,
    },
    Delete {
        group: String,
    },
}

#[derive(Subcommand)]
enum RelayCommands {
    List,
    Add { url: String },
    Remove { url: String },
    Set { urls: Vec<String> },
    Reset,
}

#[derive(Clone, Debug, Serialize, serde::Deserialize)]
struct AccountBundle {
    owner_nsec: Option<String>,
    owner_pubkey_hex: String,
    device_nsec: String,
}

#[derive(Default)]
struct CliReconciler {
    data_dir: PathBuf,
    updates: Mutex<Vec<AppUpdate>>,
}

struct ReconcilerHandle(Arc<CliReconciler>);

impl AppReconciler for ReconcilerHandle {
    fn reconcile(&self, update: AppUpdate) {
        if let AppUpdate::PersistAccountBundle {
            owner_nsec,
            owner_pubkey_hex,
            device_nsec,
            ..
        } = &update
        {
            let bundle = AccountBundle {
                owner_nsec: owner_nsec.clone(),
                owner_pubkey_hex: owner_pubkey_hex.clone(),
                device_nsec: device_nsec.clone(),
            };
            let _ = write_account_bundle(&self.0.data_dir, &bundle);
        }
        if let Ok(mut updates) = self.0.updates.lock() {
            updates.push(update);
        }
    }
}

struct CliApp {
    app: Arc<FfiApp>,
    reconciler: Arc<CliReconciler>,
}

struct CliNearbyObserver;

impl DesktopNearbyObserver for CliNearbyObserver {
    fn desktop_nearby_changed(&self, _snapshot: DesktopNearbySnapshot) {}
}

#[derive(Serialize)]
struct Envelope<T: Serialize> {
    status: &'static str,
    command: String,
    data: T,
}

#[derive(Serialize)]
struct ErrorEnvelope {
    status: &'static str,
    error: String,
}

fn main() {
    let cli = Cli::parse();
    let json_output = cli.json;
    if let Err(error) = run(cli) {
        if json_output {
            println!(
                "{}",
                serde_json::to_string(&ErrorEnvelope {
                    status: "error",
                    error: error.to_string(),
                })
                .unwrap()
            );
        } else {
            eprintln!("{}", error);
        }
        std::process::exit(1);
    }
}

fn run(cli: Cli) -> Result<()> {
    let data_dir = cli.data_dir.unwrap_or_else(default_data_dir);
    std::fs::create_dir_all(&data_dir)
        .with_context(|| format!("create data dir {}", data_dir.display()))?;
    let command_name = command_name(&cli.command).to_string();
    let data = match cli.command {
        Commands::Search { query, limit } => search_messages(&data_dir, &query, limit)?,
        Commands::Tail {
            limit,
            follow,
            chat,
            interval_ms,
        } => {
            if follow {
                follow_messages(&data_dir, chat.as_deref(), interval_ms, "tail")?;
                return Ok(());
            }
            tail_messages(&data_dir, limit, chat.as_deref())?
        }
        Commands::Listen {
            chat,
            interval_ms,
            nearby_lan,
        } => {
            listen(&data_dir, chat.as_deref(), interval_ms, nearby_lan)?;
            return Ok(());
        }
        command => {
            let cli_app = CliApp::open(&data_dir)?;
            let data = handle_command(&cli_app, &data_dir, command)?;
            cli_app.app.shutdown();
            data
        }
    };
    print_output(cli.json, &command_name, data)
}

impl CliApp {
    fn open(data_dir: &Path) -> Result<Self> {
        let app = FfiApp::new(
            data_dir.to_string_lossy().to_string(),
            String::new(),
            env!("CARGO_PKG_VERSION").to_string(),
        );
        let reconciler = Arc::new(CliReconciler {
            data_dir: data_dir.to_path_buf(),
            updates: Mutex::new(Vec::new()),
        });
        app.listen_for_updates(Box::new(ReconcilerHandle(reconciler.clone())));
        fail_on_toast(&app.state())?;
        let cli_app = Self { app, reconciler };
        if let Some(bundle) = read_account_bundle(data_dir)? {
            cli_app.dispatch_and_wait(
                AppAction::RestoreAccountBundle {
                    owner_nsec: bundle.owner_nsec,
                    owner_pubkey_hex: bundle.owner_pubkey_hex,
                    device_nsec: bundle.device_nsec,
                },
                Duration::from_secs(3),
            )?;
        }
        Ok(cli_app)
    }

    fn dispatch_and_wait(&self, action: AppAction, timeout: Duration) -> Result<AppState> {
        let before = self.app.state().rev;
        self.app.dispatch(action);
        self.wait_for_quiet_after(before, timeout)
    }

    fn wait_for_quiet_after(&self, before_rev: u64, timeout: Duration) -> Result<AppState> {
        let started = Instant::now();
        let mut saw_change = false;
        let mut last_rev = before_rev;
        let mut stable_since = Instant::now();
        while started.elapsed() < timeout {
            let state = self.app.state();
            if state.rev != last_rev {
                saw_change = state.rev != before_rev || saw_change;
                last_rev = state.rev;
                stable_since = Instant::now();
            }
            if saw_change && !is_busy(&state) && stable_since.elapsed() >= Duration::from_millis(80)
            {
                return Ok(state);
            }
            thread::sleep(Duration::from_millis(20));
        }
        Ok(self.app.state())
    }

    fn wait_for_update_count(&self, before_count: usize, timeout: Duration) {
        let started = Instant::now();
        while started.elapsed() < timeout {
            let count = self
                .reconciler
                .updates
                .lock()
                .map(|updates| updates.len())
                .unwrap_or(before_count);
            if count > before_count {
                return;
            }
            thread::sleep(Duration::from_millis(20));
        }
    }
}

fn handle_command(cli: &CliApp, data_dir: &Path, command: Commands) -> Result<Value> {
    match command {
        Commands::Login { secret_key } | Commands::Restore { secret_key } => {
            restore_account(cli, &secret_key)
        }
        Commands::Logout => {
            cli.dispatch_and_wait(AppAction::Logout, Duration::from_secs(2))?;
            let _ = std::fs::remove_file(account_bundle_path(data_dir));
            Ok(json!({ "logged_out": true }))
        }
        Commands::Whoami => Ok(account_json(&require_account(&cli.app.state())?)),
        Commands::State => Ok(state_json(&cli.app.state())),
        Commands::Sync { wait_ms } => {
            let state = cli.dispatch_and_wait(
                AppAction::AppForegrounded,
                Duration::from_millis(wait_ms.max(100)),
            )?;
            Ok(state_json(&state))
        }
        Commands::Search { .. } | Commands::Tail { .. } | Commands::Listen { .. } => {
            unreachable!("streaming and read-only commands are handled before regular dispatch")
        }
        Commands::Account(AccountCommands::Create { name }) => {
            cli.dispatch_and_wait(AppAction::CreateAccount { name }, Duration::from_secs(4))?;
            let state = cli.app.state();
            fail_on_toast(&state)?;
            Ok(account_json(&require_account(&state)?))
        }
        Commands::Account(AccountCommands::Restore { secret_key }) => {
            restore_account(cli, &secret_key)
        }
        Commands::Account(AccountCommands::Bundle) => {
            let bundle = read_account_bundle(data_dir)?.context("No saved account bundle.")?;
            Ok(json!({
                "owner_pubkey_hex": bundle.owner_pubkey_hex,
                "has_owner_secret": bundle.owner_nsec.is_some(),
                "has_device_secret": !bundle.device_nsec.is_empty(),
            }))
        }
        Commands::Chat(ChatCommands::List) => {
            Ok(json!({ "chats": chat_list_json(&cli.app.state()) }))
        }
        Commands::Chat(ChatCommands::Create { user_id }) => create_chat(cli, &user_id),
        Commands::Chat(ChatCommands::Open { chat }) => {
            open_chat(cli, &chat).map(|chat| chat_json(&chat, usize::MAX))
        }
        Commands::Chat(ChatCommands::Read { chat, limit }) | Commands::Read { chat, limit } => {
            open_chat(cli, &chat).map(|chat| chat_json(&chat, limit))
        }
        Commands::Chat(ChatCommands::Send {
            chat,
            message,
            ttl,
            expires_at,
        })
        | Commands::Send {
            chat,
            message,
            ttl,
            expires_at,
        } => {
            let expires_at = message_expiration(ttl, expires_at)?;
            send_message(cli, &chat, &message, expires_at)
        }
        Commands::React {
            chat,
            message_id,
            emoji,
        } => react(cli, &chat, &message_id, &emoji),
        Commands::Typing { chat, stop } => typing(cli, &chat, stop),
        Commands::Receipt {
            chat,
            receipt_type,
            message_ids,
        } => receipt(cli, &chat, &receipt_type, message_ids),
        Commands::Chat(ChatCommands::Seen { chat, message_ids })
        | Commands::Seen { chat, message_ids } => mark_seen(cli, &chat, message_ids),
        Commands::Chat(ChatCommands::Delete { chat }) => {
            let chat_id = resolve_chat_id(&cli.app.state(), &chat)?;
            cli.dispatch_and_wait(
                AppAction::DeleteChat {
                    chat_id: chat_id.clone(),
                },
                Duration::from_secs(2),
            )?;
            Ok(json!({ "chat_id": chat_id, "deleted": true }))
        }
        Commands::Chat(ChatCommands::Ttl { chat, seconds }) => {
            let chat_id = resolve_chat_id(&cli.app.state(), &chat)?;
            cli.dispatch_and_wait(
                AppAction::SetChatMessageTtl {
                    chat_id: chat_id.clone(),
                    ttl_seconds: seconds,
                },
                Duration::from_secs(2),
            )?;
            Ok(json!({ "chat_id": chat_id, "message_ttl_seconds": seconds }))
        }
        Commands::Chat(ChatCommands::Mute { chat, muted }) => {
            let chat_id = resolve_chat_id(&cli.app.state(), &chat)?;
            cli.dispatch_and_wait(
                AppAction::SetChatMuted {
                    chat_id: chat_id.clone(),
                    muted,
                },
                Duration::from_secs(2),
            )?;
            Ok(json!({ "chat_id": chat_id, "muted": muted }))
        }
        Commands::Invite(InviteCommands::Create) => {
            cli.dispatch_and_wait(AppAction::CreatePublicInvite, Duration::from_secs(3))?;
            let state = cli.app.state();
            fail_on_toast(&state)?;
            let invite = state.public_invite.context("No invite was created.")?;
            Ok(json!({ "url": invite.url }))
        }
        Commands::Invite(InviteCommands::Accept { invite }) => {
            cli.dispatch_and_wait(
                AppAction::AcceptInvite {
                    invite_input: invite,
                },
                Duration::from_secs(4),
            )?;
            let state = cli.app.state();
            fail_on_toast(&state)?;
            Ok(json!({
                "chats": chat_list_json(&state),
                "current_chat": state.current_chat.as_ref().map(|chat| chat_summary_json(chat)),
            }))
        }
        Commands::Link(LinkCommands::Create) => {
            cli.dispatch_and_wait(
                AppAction::StartLinkedDevice {
                    owner_input: String::new(),
                },
                Duration::from_secs(3),
            )?;
            let state = cli.app.state();
            fail_on_toast(&state)?;
            let link = state.link_device.context("No link code was created.")?;
            Ok(json!({
                "url": link.url,
                "device_input": link.device_input,
            }))
        }
        Commands::Link(LinkCommands::Accept { invite }) => {
            cli.dispatch_and_wait(
                AppAction::AddAuthorizedDevice {
                    device_input: invite,
                },
                Duration::from_secs(4),
            )?;
            let state = cli.app.state();
            fail_on_toast(&state)?;
            Ok(json!({
                "accepted": true,
                "device_roster": state.device_roster.map(|roster| {
                    json!({
                        "device_count": roster.devices.len(),
                        "devices": roster.devices.iter().map(|device| {
                            json!({
                                "device_id": device.device_pubkey_hex,
                                "device_npub": device.device_npub,
                                "current": device.is_current_device,
                                "authorized": device.is_authorized,
                                "stale": device.is_stale,
                            })
                        }).collect::<Vec<_>>(),
                    })
                }),
            }))
        }
        Commands::Group(GroupCommands::Create { name, members }) => {
            cli.dispatch_and_wait(
                AppAction::CreateGroup {
                    name,
                    member_inputs: members,
                },
                Duration::from_secs(4),
            )?;
            let state = cli.app.state();
            fail_on_toast(&state)?;
            Ok(json!({
                "groups": group_list_json(&state),
                "current_chat": state.current_chat.as_ref().map(|chat| chat_summary_json(chat)),
            }))
        }
        Commands::Group(GroupCommands::List) => {
            Ok(json!({ "groups": group_list_json(&cli.app.state()) }))
        }
        Commands::Group(GroupCommands::Show { group }) => show_group(cli, &group),
        Commands::Group(GroupCommands::Send { group, message }) => {
            send_message(cli, &normalize_group_chat(&group), &message, None)
        }
        Commands::Group(GroupCommands::Read { group, limit }) => {
            open_chat(cli, &normalize_group_chat(&group)).map(|chat| chat_json(&chat, limit))
        }
        Commands::Group(GroupCommands::Add { group, members }) => {
            let group_id = resolve_group_id(&cli.app.state(), &group)?;
            cli.dispatch_and_wait(
                AppAction::AddGroupMembers {
                    group_id: group_id.clone(),
                    member_inputs: members,
                },
                Duration::from_secs(3),
            )?;
            show_group(cli, &group_id)
        }
        Commands::Group(GroupCommands::Remove { group, members }) => {
            let group_id = resolve_group_id(&cli.app.state(), &group)?;
            if members.is_empty() {
                anyhow::bail!("At least one member is required.");
            }
            for member in members {
                let owner_pubkey_hex = owner_input_to_hex(&member)?;
                cli.dispatch_and_wait(
                    AppAction::RemoveGroupMember {
                        group_id: group_id.clone(),
                        owner_pubkey_hex,
                    },
                    Duration::from_secs(3),
                )?;
            }
            show_group(cli, &group_id)
        }
        Commands::Group(GroupCommands::AddAdmin { group, member }) => {
            set_group_admin(cli, &group, &member, true)
        }
        Commands::Group(GroupCommands::RemoveAdmin { group, member }) => {
            set_group_admin(cli, &group, &member, false)
        }
        Commands::Group(GroupCommands::Rename { group, name }) => {
            let group_id = resolve_group_id(&cli.app.state(), &group)?;
            cli.dispatch_and_wait(
                AppAction::UpdateGroupName {
                    group_id: group_id.clone(),
                    name,
                },
                Duration::from_secs(3),
            )?;
            show_group(cli, &group_id)
        }
        Commands::Group(GroupCommands::React {
            group,
            message_id,
            emoji,
        }) => react(cli, &normalize_group_chat(&group), &message_id, &emoji),
        Commands::Group(GroupCommands::Delete { group }) => {
            let group_id = resolve_group_id(&cli.app.state(), &group)?;
            let chat_id = normalize_group_chat(&group_id);
            cli.dispatch_and_wait(
                AppAction::DeleteChat {
                    chat_id: chat_id.clone(),
                },
                Duration::from_secs(2),
            )?;
            Ok(json!({ "chat_id": chat_id, "group_id": group_id, "deleted": true }))
        }
        Commands::Relay(RelayCommands::List) => {
            Ok(json!({ "message_servers": cli.app.state().preferences.nostr_relay_urls }))
        }
        Commands::Relay(RelayCommands::Add { url }) => {
            cli.dispatch_and_wait(
                AppAction::AddNostrRelay { relay_url: url },
                Duration::from_secs(2),
            )?;
            Ok(json!({ "message_servers": cli.app.state().preferences.nostr_relay_urls }))
        }
        Commands::Relay(RelayCommands::Remove { url }) => {
            cli.dispatch_and_wait(
                AppAction::RemoveNostrRelay { relay_url: url },
                Duration::from_secs(2),
            )?;
            Ok(json!({ "message_servers": cli.app.state().preferences.nostr_relay_urls }))
        }
        Commands::Relay(RelayCommands::Reset) => {
            cli.dispatch_and_wait(AppAction::ResetNostrRelays, Duration::from_secs(2))?;
            Ok(json!({ "message_servers": cli.app.state().preferences.nostr_relay_urls }))
        }
        Commands::Relay(RelayCommands::Set { urls }) => {
            cli.dispatch_and_wait(
                AppAction::SetNostrRelays { relay_urls: urls },
                Duration::from_secs(3),
            )?;
            Ok(json!({ "message_servers": cli.app.state().preferences.nostr_relay_urls }))
        }
    }
}

fn restore_account(cli: &CliApp, secret_key: &str) -> Result<Value> {
    let before = cli
        .reconciler
        .updates
        .lock()
        .map(|updates| updates.len())
        .unwrap_or(0);
    cli.dispatch_and_wait(
        AppAction::RestoreSession {
            owner_nsec: secret_key.to_string(),
        },
        Duration::from_secs(4),
    )?;
    cli.wait_for_update_count(before, Duration::from_secs(2));
    let state = cli.app.state();
    fail_on_toast(&state)?;
    Ok(account_json(&require_account(&state)?))
}

fn create_chat(cli: &CliApp, user_id: &str) -> Result<Value> {
    cli.dispatch_and_wait(
        AppAction::CreateChat {
            peer_input: user_id.to_string(),
        },
        Duration::from_secs(3),
    )?;
    let state = cli.app.state();
    fail_on_toast(&state)?;
    let chat = state.current_chat.context("No chat was opened.")?;
    Ok(chat_json(&chat, usize::MAX))
}

fn open_chat(cli: &CliApp, chat: &str) -> Result<CurrentChatSnapshot> {
    let chat_id = chat_action_input(&cli.app.state(), chat);
    cli.dispatch_and_wait(AppAction::OpenChat { chat_id }, Duration::from_secs(2))?;
    let state = cli.app.state();
    fail_on_toast(&state)?;
    state.current_chat.context("No chat is open.")
}

fn send_message(
    cli: &CliApp,
    chat: &str,
    message: &str,
    expires_at_secs: Option<u64>,
) -> Result<Value> {
    let chat_id = chat_action_input(&cli.app.state(), chat);
    let action = if let Some(expires_at_secs) = expires_at_secs {
        AppAction::SendDisappearingMessage {
            chat_id: chat_id.clone(),
            text: message.to_string(),
            expires_at_secs,
        }
    } else {
        AppAction::SendMessage {
            chat_id: chat_id.clone(),
            text: message.to_string(),
        }
    };
    cli.dispatch_and_wait(action, Duration::from_secs(3))?;
    let state = cli.app.state();
    fail_on_toast(&state)?;
    let current = state.current_chat.context("No chat is open.")?;
    let sent = current
        .messages
        .iter()
        .rev()
        .find(|item| item.is_outgoing && item.body == message)
        .cloned()
        .context("Message was not added to the chat.")?;
    Ok(message_json(&sent))
}

fn react(cli: &CliApp, chat: &str, message_id: &str, emoji: &str) -> Result<Value> {
    let chat_id = chat_action_input(&cli.app.state(), chat);
    cli.dispatch_and_wait(
        AppAction::ToggleReaction {
            chat_id: chat_id.clone(),
            message_id: message_id.to_string(),
            emoji: emoji.to_string(),
        },
        Duration::from_secs(2),
    )?;
    let current = open_chat(cli, &chat_id)?;
    let message = current
        .messages
        .iter()
        .find(|message| message.id == message_id)
        .context("Message not found.")?;
    Ok(message_json(message))
}

fn typing(cli: &CliApp, chat: &str, stop: bool) -> Result<Value> {
    let chat_id = chat_action_input(&cli.app.state(), chat);
    cli.dispatch_and_wait(
        AppAction::SetTypingIndicatorsEnabled { enabled: true },
        Duration::from_secs(1),
    )?;
    let action = if stop {
        AppAction::StopTyping {
            chat_id: chat_id.clone(),
        }
    } else {
        AppAction::SendTyping {
            chat_id: chat_id.clone(),
        }
    };
    cli.dispatch_and_wait(action, Duration::from_secs(2))?;
    Ok(json!({ "chat_id": chat_id, "typing": !stop }))
}

fn receipt(
    cli: &CliApp,
    chat: &str,
    receipt_type: &str,
    message_ids: Vec<String>,
) -> Result<Value> {
    let receipt_type = receipt_type.trim().to_ascii_lowercase();
    if receipt_type != "delivered" && receipt_type != "seen" {
        anyhow::bail!("Receipt type must be delivered or seen.");
    }
    if receipt_type == "seen" {
        return mark_seen(cli, chat, message_ids);
    }
    let chat_id = chat_action_input(&cli.app.state(), chat);
    cli.dispatch_and_wait(
        AppAction::SendReceipt {
            chat_id: chat_id.clone(),
            receipt_type: receipt_type.clone(),
            message_ids: message_ids.clone(),
        },
        Duration::from_secs(2),
    )?;
    Ok(json!({
        "chat_id": chat_id,
        "receipt_type": receipt_type,
        "message_ids": message_ids,
    }))
}

fn mark_seen(cli: &CliApp, chat: &str, message_ids: Vec<String>) -> Result<Value> {
    let current = open_chat(cli, chat)?;
    let ids = if message_ids.is_empty() {
        current
            .messages
            .iter()
            .filter(|message| !message.is_outgoing)
            .map(|message| message.id.clone())
            .collect()
    } else {
        message_ids
    };
    cli.dispatch_and_wait(
        AppAction::MarkMessagesSeen {
            chat_id: current.chat_id.clone(),
            message_ids: ids.clone(),
        },
        Duration::from_secs(2),
    )?;
    Ok(json!({ "chat_id": current.chat_id, "message_ids": ids }))
}

fn message_expiration(ttl: Option<u64>, expires_at: Option<u64>) -> Result<Option<u64>> {
    match (ttl, expires_at) {
        (Some(_), Some(_)) => anyhow::bail!("Use either --ttl or --expires-at, not both."),
        (Some(ttl), None) if ttl > 0 => Ok(Some(now_secs()?.saturating_add(ttl))),
        (Some(_), None) => Ok(None),
        (None, Some(expires_at)) if expires_at > 0 => Ok(Some(expires_at)),
        (None, Some(_)) | (None, None) => Ok(None),
    }
}

fn now_secs() -> Result<u64> {
    Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
}

fn show_group(cli: &CliApp, group: &str) -> Result<Value> {
    let group_id = resolve_group_id(&cli.app.state(), group)?;
    cli.dispatch_and_wait(
        AppAction::PushScreen {
            screen: iris_chat_core::Screen::GroupDetails {
                group_id: group_id.clone(),
            },
        },
        Duration::from_secs(2),
    )?;
    let state = cli.app.state();
    let details = state.group_details.context("No group details available.")?;
    Ok(group_json(&details))
}

fn set_group_admin(cli: &CliApp, group: &str, member: &str, is_admin: bool) -> Result<Value> {
    let group_id = resolve_group_id(&cli.app.state(), group)?;
    let owner_pubkey_hex = owner_input_to_hex(member)?;
    cli.dispatch_and_wait(
        AppAction::SetGroupAdmin {
            group_id: group_id.clone(),
            owner_pubkey_hex,
            is_admin,
        },
        Duration::from_secs(3),
    )?;
    show_group(cli, &group_id)
}

fn owner_input_to_hex(input: &str) -> Result<String> {
    let hex = iris_chat_core::peer_input_to_hex(input.to_string());
    if hex.is_empty() {
        anyhow::bail!("Invalid user ID: {input}");
    }
    Ok(hex)
}

fn search_messages(data_dir: &Path, query: &str, limit: usize) -> Result<Value> {
    let conn = open_existing_db(data_dir)?;
    let pattern = format!("%{query}%");
    let mut stmt = conn.prepare(
        "SELECT chat_id, id, body, is_outgoing, created_at_secs, delivery
         FROM messages
         WHERE body LIKE ?1
         ORDER BY created_at_secs DESC, id DESC
         LIMIT ?2",
    )?;
    let rows = stmt.query_map((&pattern, limit as i64), |row| {
        Ok(json!({
            "chat_id": row.get::<_, String>(0)?,
            "id": row.get::<_, String>(1)?,
            "body": row.get::<_, String>(2)?,
            "is_outgoing": row.get::<_, i64>(3)? != 0,
            "created_at_secs": row.get::<_, i64>(4)?,
            "delivery": row.get::<_, String>(5)?,
        }))
    })?;
    let mut messages = Vec::new();
    for row in rows {
        messages.push(row?);
    }
    Ok(json!({ "messages": messages }))
}

fn tail_messages(data_dir: &Path, limit: usize, chat: Option<&str>) -> Result<Value> {
    let conn = open_existing_db(data_dir)?;
    let sql = match chat {
        Some(_) => {
            "SELECT chat_id, id, body, is_outgoing, created_at_secs, delivery
             FROM messages
             WHERE chat_id = ?1
             ORDER BY created_at_secs DESC, id DESC
             LIMIT ?2"
        }
        None => {
            "SELECT chat_id, id, body, is_outgoing, created_at_secs, delivery
             FROM messages
             ORDER BY created_at_secs DESC, id DESC
             LIMIT ?1"
        }
    };
    let mut stmt = conn.prepare(sql)?;
    let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result<Value> {
        Ok(json!({
            "chat_id": row.get::<_, String>(0)?,
            "id": row.get::<_, String>(1)?,
            "body": row.get::<_, String>(2)?,
            "is_outgoing": row.get::<_, i64>(3)? != 0,
            "created_at_secs": row.get::<_, i64>(4)?,
            "delivery": row.get::<_, String>(5)?,
        }))
    };
    let mut messages = Vec::new();
    match chat {
        Some(chat_id) => {
            let rows = stmt.query_map((chat_id, limit as i64), map_row)?;
            for row in rows {
                messages.push(row?);
            }
        }
        None => {
            let rows = stmt.query_map([limit as i64], map_row)?;
            for row in rows {
                messages.push(row?);
            }
        }
    }
    messages.reverse();
    Ok(json!({ "messages": messages }))
}

fn listen(data_dir: &Path, chat: Option<&str>, interval_ms: u64, nearby_lan: bool) -> Result<()> {
    let interval = Duration::from_millis(interval_ms.max(100));
    let cli = CliApp::open(data_dir)?;
    let state = cli.app.state();
    fail_on_toast(&state)?;
    require_account(&state)?;
    let chat_filter = normalize_chat_filter(&state, chat);
    if let Some(chat_id) = chat_filter.as_ref() {
        cli.dispatch_and_wait(
            AppAction::OpenChat {
                chat_id: chat_id.clone(),
            },
            Duration::from_secs(2),
        )?;
        fail_on_toast(&cli.app.state())?;
    }
    let mut seen = latest_message_keys(data_dir, chat_filter.as_deref())?;
    let _nearby = if nearby_lan {
        let service = FfiDesktopNearby::new(cli.app.clone(), Box::new(CliNearbyObserver));
        let name = state
            .account
            .as_ref()
            .map(|account| account.display_name.trim())
            .filter(|name| !name.is_empty())
            .unwrap_or("Iris")
            .to_string();
        service.start(name);
        Some(service)
    } else {
        None
    };
    let state = cli.dispatch_and_wait(AppAction::AppForegrounded, Duration::from_secs(5))?;
    fail_on_toast(&state)?;

    print_stream_envelope(
        "listen",
        json!({
            "ready": true,
            "chat": chat_filter.clone(),
            "network": true,
            "nearby_lan": nearby_lan,
        }),
    )?;
    let mut last_foreground = Instant::now();

    loop {
        thread::sleep(interval);
        if last_foreground.elapsed() >= Duration::from_secs(60) {
            cli.app.dispatch(AppAction::AppForegrounded);
            last_foreground = Instant::now();
        }
        stream_new_messages(data_dir, chat_filter.as_deref(), &mut seen)?;
    }
}

fn follow_messages(
    data_dir: &Path,
    chat: Option<&str>,
    interval_ms: u64,
    command: &str,
) -> Result<()> {
    let interval = Duration::from_millis(interval_ms.max(100));
    let mut seen = latest_message_keys(data_dir, chat)?;
    print_stream_envelope(
        command,
        json!({ "ready": true, "chat": chat, "network": false }),
    )?;
    loop {
        thread::sleep(interval);
        stream_new_messages(data_dir, chat, &mut seen)?;
    }
}

fn stream_new_messages(
    data_dir: &Path,
    chat: Option<&str>,
    seen: &mut std::collections::HashSet<String>,
) -> Result<()> {
    let messages = new_message_rows(data_dir, chat, seen)?;
    for message in messages {
        if let (Some(chat_id), Some(id)) = (
            message.get("chat_id").and_then(Value::as_str),
            message.get("id").and_then(Value::as_str),
        ) {
            seen.insert(format!("{chat_id}\0{id}"));
        }
        print_stream_envelope("message", message)?;
    }
    Ok(())
}

fn print_stream_envelope(command: &str, data: Value) -> Result<()> {
    println!(
        "{}",
        serde_json::to_string(&Envelope {
            status: "ok",
            command: command.to_string(),
            data,
        })?
    );
    std::io::stdout().flush()?;
    Ok(())
}

fn normalize_chat_filter(state: &AppState, chat: Option<&str>) -> Option<String> {
    chat.map(|input| chat_action_input(state, input))
}

fn latest_message_keys(
    data_dir: &Path,
    chat: Option<&str>,
) -> Result<std::collections::HashSet<String>> {
    let conn = open_existing_db(data_dir)?;
    let mut seen = std::collections::HashSet::new();
    match chat {
        Some(chat_id) => {
            let mut stmt = conn.prepare("SELECT chat_id, id FROM messages WHERE chat_id = ?1")?;
            let rows = stmt.query_map([chat_id], |row| {
                Ok(format!(
                    "{}\0{}",
                    row.get::<_, String>(0)?,
                    row.get::<_, String>(1)?
                ))
            })?;
            for row in rows {
                seen.insert(row?);
            }
        }
        None => {
            let mut stmt = conn.prepare("SELECT chat_id, id FROM messages")?;
            let rows = stmt.query_map([], |row| {
                Ok(format!(
                    "{}\0{}",
                    row.get::<_, String>(0)?,
                    row.get::<_, String>(1)?
                ))
            })?;
            for row in rows {
                seen.insert(row?);
            }
        }
    }
    Ok(seen)
}

fn new_message_rows(
    data_dir: &Path,
    chat: Option<&str>,
    seen: &std::collections::HashSet<String>,
) -> Result<Vec<Value>> {
    let conn = open_existing_db(data_dir)?;
    let sql = match chat {
        Some(_) => {
            "SELECT chat_id, id, body, is_outgoing, created_at_secs, delivery
             FROM messages
             WHERE chat_id = ?1
             ORDER BY created_at_secs ASC, id ASC"
        }
        None => {
            "SELECT chat_id, id, body, is_outgoing, created_at_secs, delivery
             FROM messages
             ORDER BY created_at_secs ASC, id ASC"
        }
    };
    let mut stmt = conn.prepare(sql)?;
    let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result<Value> {
        Ok(json!({
            "chat_id": row.get::<_, String>(0)?,
            "id": row.get::<_, String>(1)?,
            "body": row.get::<_, String>(2)?,
            "is_outgoing": row.get::<_, i64>(3)? != 0,
            "created_at_secs": row.get::<_, i64>(4)?,
            "delivery": row.get::<_, String>(5)?,
        }))
    };
    let mut messages = Vec::new();
    match chat {
        Some(chat_id) => {
            let rows = stmt.query_map([chat_id], map_row)?;
            for row in rows {
                let message = row?;
                let key = format!(
                    "{}\0{}",
                    message["chat_id"].as_str().unwrap_or_default(),
                    message["id"].as_str().unwrap_or_default()
                );
                if !seen.contains(&key) {
                    messages.push(message);
                }
            }
        }
        None => {
            let rows = stmt.query_map([], map_row)?;
            for row in rows {
                let message = row?;
                let key = format!(
                    "{}\0{}",
                    message["chat_id"].as_str().unwrap_or_default(),
                    message["id"].as_str().unwrap_or_default()
                );
                if !seen.contains(&key) {
                    messages.push(message);
                }
            }
        }
    }
    Ok(messages)
}

fn print_output(json_output: bool, command: &str, data: Value) -> Result<()> {
    if json_output {
        println!(
            "{}",
            serde_json::to_string(&Envelope {
                status: "ok",
                command: command.to_string(),
                data,
            })?
        );
    } else if data.is_object() || data.is_array() {
        println!("{}", serde_json::to_string_pretty(&data)?);
    } else {
        println!("{data}");
    }
    Ok(())
}

fn command_name(command: &Commands) -> &'static str {
    match command {
        Commands::Login { .. } => "login",
        Commands::Restore { .. } => "restore",
        Commands::Logout => "logout",
        Commands::Whoami => "whoami",
        Commands::State => "state",
        Commands::Sync { .. } => "sync",
        Commands::Search { .. } => "search",
        Commands::Tail { .. } => "tail",
        Commands::Listen { .. } => "listen",
        Commands::Account(_) => "account",
        Commands::Chat(_) => "chat",
        Commands::Send { .. } => "send",
        Commands::React { .. } => "react",
        Commands::Typing { .. } => "typing",
        Commands::Receipt { .. } => "receipt",
        Commands::Read { .. } => "read",
        Commands::Seen { .. } => "seen",
        Commands::Invite(_) => "invite",
        Commands::Link(_) => "link",
        Commands::Group(_) => "group",
        Commands::Relay(_) => "relay",
    }
}

fn account_json(account: &iris_chat_core::AccountSnapshot) -> Value {
    json!({
        "user_id": account.public_key_hex,
        "npub": account.npub,
        "name": account.display_name,
        "device_id": account.device_public_key_hex,
        "device_npub": account.device_npub,
        "has_secret_key": account.has_owner_signing_authority,
        "device_state": authorization_state(&account.authorization_state),
    })
}

fn state_json(state: &AppState) -> Value {
    json!({
        "account": state.account.as_ref().map(account_json),
        "chats": chat_list_json(state),
        "groups": group_list_json(state),
        "current_chat": state.current_chat.as_ref().map(|chat| chat_json(chat, usize::MAX)),
        "message_servers": state.preferences.nostr_relay_urls,
        "toast": state.toast,
    })
}

fn chat_list_json(state: &AppState) -> Vec<Value> {
    state.chat_list.iter().map(thread_json).collect()
}

fn group_list_json(state: &AppState) -> Vec<Value> {
    state
        .chat_list
        .iter()
        .filter(|thread| matches!(thread.kind, ChatKind::Group))
        .map(thread_json)
        .collect()
}

fn thread_json(thread: &ChatThreadSnapshot) -> Value {
    json!({
        "chat_id": thread.chat_id,
        "kind": chat_kind(&thread.kind),
        "name": thread.display_name,
        "subtitle": thread.subtitle,
        "member_count": thread.member_count,
        "last_message": thread.last_message_preview,
        "last_message_at_secs": thread.last_message_at_secs,
        "unread_count": thread.unread_count,
        "muted": thread.is_muted,
    })
}

fn chat_summary_json(chat: &CurrentChatSnapshot) -> Value {
    json!({
        "chat_id": chat.chat_id,
        "kind": chat_kind(&chat.kind),
        "name": chat.display_name,
        "group_id": chat.group_id,
        "member_count": chat.member_count,
        "message_count": chat.messages.len(),
        "message_ttl_seconds": chat.message_ttl_seconds,
        "muted": chat.is_muted,
    })
}

fn chat_json(chat: &CurrentChatSnapshot, limit: usize) -> Value {
    let skip = chat.messages.len().saturating_sub(limit);
    json!({
        "chat": chat_summary_json(chat),
        "messages": chat.messages.iter().skip(skip).map(message_json).collect::<Vec<_>>(),
    })
}

fn message_json(message: &ChatMessageSnapshot) -> Value {
    json!({
        "id": message.id,
        "chat_id": message.chat_id,
        "author": message.author,
        "body": message.body,
        "is_outgoing": message.is_outgoing,
        "created_at_secs": message.created_at_secs,
        "expires_at_secs": message.expires_at_secs,
        "delivery": delivery(&message.delivery),
        "source_event_id": message.source_event_id,
        "attachments": message.attachments.iter().map(|item| {
            json!({
                "filename": item.filename,
                "nhash": item.nhash,
                "url": item.htree_url,
            })
        }).collect::<Vec<_>>(),
        "reactions": message.reactions.iter().map(|item| {
            json!({
                "emoji": item.emoji,
                "count": item.count,
                "reacted_by_me": item.reacted_by_me,
            })
        }).collect::<Vec<_>>(),
    })
}

fn group_json(group: &GroupDetailsSnapshot) -> Value {
    json!({
        "group_id": group.group_id,
        "name": group.name,
        "picture_url": group.picture_url,
        "can_manage": group.can_manage,
        "muted": group.is_muted,
        "revision": group.revision,
        "members": group.members.iter().map(|member| {
            json!({
                "user_id": member.owner_pubkey_hex,
                "name": member.display_name,
                "npub": member.npub,
                "admin": member.is_admin,
                "creator": member.is_creator,
                "me": member.is_local_owner,
            })
        }).collect::<Vec<_>>(),
    })
}

fn require_account(state: &AppState) -> Result<iris_chat_core::AccountSnapshot> {
    state
        .account
        .clone()
        .context("Create or restore a profile first.")
}

fn fail_on_toast(state: &AppState) -> Result<()> {
    if let Some(toast) = &state.toast {
        anyhow::bail!(toast.clone());
    }
    Ok(())
}

fn chat_action_input(state: &AppState, input: &str) -> String {
    resolve_chat_id(state, input).unwrap_or_else(|_| input.to_string())
}

fn resolve_chat_id(state: &AppState, input: &str) -> Result<String> {
    if let Some(chat) = &state.current_chat {
        if chat.chat_id == input || chat.group_id.as_deref() == Some(input) {
            return Ok(chat.chat_id.clone());
        }
    }
    state
        .chat_list
        .iter()
        .find(|thread| {
            thread.chat_id == input
                || thread.display_name.eq_ignore_ascii_case(input)
                || thread.subtitle.as_deref() == Some(input)
        })
        .map(|thread| thread.chat_id.clone())
        .with_context(|| format!("Chat not found: {input}"))
}

fn resolve_group_id(state: &AppState, input: &str) -> Result<String> {
    if let Some(group) = &state.group_details {
        if group.group_id == input || group.name.eq_ignore_ascii_case(input) {
            return Ok(group.group_id.clone());
        }
    }
    state
        .chat_list
        .iter()
        .find(|thread| {
            matches!(thread.kind, ChatKind::Group)
                && (thread.chat_id == input
                    || thread.chat_id == normalize_group_chat(input)
                    || thread.display_name.eq_ignore_ascii_case(input))
        })
        .map(|thread| {
            thread
                .chat_id
                .strip_prefix("group:")
                .unwrap_or(&thread.chat_id)
                .to_string()
        })
        .with_context(|| format!("Group not found: {input}"))
}

fn normalize_group_chat(group: &str) -> String {
    if group.starts_with("group:") {
        group.to_string()
    } else {
        format!("group:{group}")
    }
}

fn is_busy(state: &AppState) -> bool {
    let busy = &state.busy;
    busy.creating_account
        || busy.restoring_session
        || busy.linking_device
        || busy.creating_chat
        || busy.creating_group
        || busy.sending_message
        || busy.updating_roster
        || busy.updating_group
        || busy.creating_invite
        || busy.accepting_invite
        || busy.uploading_attachment
}

fn chat_kind(kind: &ChatKind) -> &'static str {
    match kind {
        ChatKind::Direct => "direct",
        ChatKind::Group => "group",
    }
}

fn delivery(delivery: &DeliveryState) -> &'static str {
    match delivery {
        DeliveryState::Queued => "queued",
        DeliveryState::Pending => "pending",
        DeliveryState::Sent => "sent",
        DeliveryState::Received => "received",
        DeliveryState::Seen => "seen",
        DeliveryState::Failed => "failed",
    }
}

fn authorization_state(state: &DeviceAuthorizationState) -> &'static str {
    match state {
        DeviceAuthorizationState::Authorized => "authorized",
        DeviceAuthorizationState::AwaitingApproval => "awaiting_approval",
        DeviceAuthorizationState::Revoked => "revoked",
    }
}

fn open_existing_db(data_dir: &Path) -> Result<Connection> {
    let path = data_dir.join("core.sqlite3");
    Connection::open(path).context("Open Iris chat database")
}

fn account_bundle_path(data_dir: &Path) -> PathBuf {
    data_dir.join("cli-account.json")
}

fn read_account_bundle(data_dir: &Path) -> Result<Option<AccountBundle>> {
    let path = account_bundle_path(data_dir);
    if !path.exists() {
        return Ok(None);
    }
    let raw = std::fs::read_to_string(&path)
        .with_context(|| format!("read account bundle {}", path.display()))?;
    Ok(Some(serde_json::from_str(&raw)?))
}

fn write_account_bundle(data_dir: &Path, bundle: &AccountBundle) -> Result<()> {
    std::fs::create_dir_all(data_dir)?;
    let path = account_bundle_path(data_dir);
    std::fs::write(&path, serde_json::to_vec_pretty(bundle)?)
        .with_context(|| format!("write account bundle {}", path.display()))
}

fn default_data_dir() -> PathBuf {
    if let Some(home) = std::env::var_os("HOME") {
        return PathBuf::from(home)
            .join("Library")
            .join("Application Support")
            .join("Iris Chat CLI");
    }
    PathBuf::from(".iris-chat")
}
