use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;

use anyhow::{Context, Result};
use base64::Engine;
use serde_json::Value;
use spacesh_proto::codec::{read_frame, write_frame};
use spacesh_proto::message::{SplitDir, Edge, PresetSlot};
use spacesh_proto::ids::{GroupId, WorkspaceId};
use spacesh_proto::{Cmd, Envelope, Evt, SurfaceId};
use tauri::ipc::Channel;
use tauri::{AppHandle, Emitter};
use tokio::net::UnixStream;
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
use tokio::sync::{mpsc, oneshot, Mutex};

pub struct Bridge {
    next_id: AtomicU64,
    /// For respawning/reconnecting the daemon connection after it drops.
    app: AppHandle,
    sock: PathBuf,
    /// Bumped on every successful reconnect; lets concurrent failing requests
    /// collapse into a single reconnect (single-flight).
    gen: AtomicU64,
    /// Outbound frames to the daemon. Swapped on reconnect.
    tx: Mutex<mpsc::Sender<Envelope>>,
    /// Serializes reconnect attempts.
    reconnect_lock: Mutex<()>,
    /// The current reader task; aborted on reconnect so a stale connection can't
    /// keep delivering duplicate output (which doubled keystroke echo).
    reader: Mutex<tokio::task::JoinHandle<()>>,
    /// Pending request id → reply slot.
    pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
    /// surface id → output channel into the webview.
    out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>>,
}

fn socket_path() -> Result<PathBuf> {
    // Honor SPACESH_SOCK so the GUI matches a daemon/CLI started with the same
    // override (mirrors the daemon's lifecycle::socket_path and the CLI client).
    if let Ok(p) = std::env::var("SPACESH_SOCK") {
        if !p.is_empty() {
            return Ok(PathBuf::from(p));
        }
    }
    Ok(dirs::home_dir().context("no home")?.join(".spacesh").join("sock"))
}

/// Locate the `spaceshd` binary. The Tauri app is its own cargo workspace, so in
/// `tauri dev` the app binary lives in `app/src-tauri/target/debug/` while the
/// daemon is built into the repo-root `target/debug/` — they are NOT siblings.
/// Try, in order: SPACESHD_BIN override, a sibling (release/bundled layout),
/// the repo-root dev/release target relative to the app binary, then PATH.
fn find_daemon() -> PathBuf {
    if let Ok(p) = std::env::var("SPACESHD_BIN") {
        if !p.is_empty() {
            return PathBuf::from(p);
        }
    }
    if let Ok(exe) = std::env::current_exe() {
        let sibling = exe.with_file_name("spaceshd");
        if sibling.exists() {
            return sibling;
        }
        if let Some(dir) = exe.parent() {
            // app/src-tauri/target/{debug,release} → repo-root target/{debug,release}
            for rel in ["../../../../target/debug/spaceshd", "../../../../target/release/spaceshd"] {
                let cand = dir.join(rel);
                if cand.exists() {
                    return cand;
                }
            }
        }
    }
    PathBuf::from("spaceshd") // last resort: rely on PATH
}

/// The installed `spaceshd` binary's mtime as ms since the epoch (for staleness check).
fn daemon_bin_mtime_ms() -> Option<u64> {
    let meta = std::fs::metadata(find_daemon()).ok()?;
    let mtime = meta.modified().ok()?;
    Some(mtime.duration_since(std::time::UNIX_EPOCH).ok()?.as_millis() as u64)
}

async fn ensure_daemon(sock: &PathBuf) -> Result<UnixStream> {
    if let Ok(s) = UnixStream::connect(sock).await {
        return Ok(s);
    }
    // Lazy start: spawn the daemon binary, then poll for the socket.
    let daemon = find_daemon();
    match std::process::Command::new(&daemon).spawn() {
        Ok(_) => {}
        Err(e) => anyhow::bail!("could not spawn daemon at {}: {e}", daemon.display()),
    }
    for _ in 0..100 {
        if let Ok(s) = UnixStream::connect(sock).await {
            return Ok(s);
        }
        tokio::time::sleep(tokio::time::Duration::from_millis(30)).await;
    }
    anyhow::bail!("daemon spawned ({}) but did not bind {} in time", daemon.display(), sock.display())
}

/// Connect (spawning the daemon if needed) and start the reader/writer tasks,
/// returning the outbound sender. Shared `pending`/`out_channels` are reused so
/// replies and live output keep routing across reconnects.
async fn spawn_connection(
    sock: &PathBuf,
    app: &AppHandle,
    pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
    out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>>,
) -> Result<(mpsc::Sender<Envelope>, tokio::task::JoinHandle<()>)> {
    let stream = ensure_daemon(sock).await?;
    let (read_half, write_half) = stream.into_split();
    let (tx, rx) = mpsc::channel::<Envelope>(256);
    spawn_writer(write_half, rx);
    let reader = spawn_reader(read_half, app.clone(), pending, out_channels);
    Ok((tx, reader))
}

impl Bridge {
    pub async fn connect(app: AppHandle) -> Result<Self> {
        let sock = socket_path()?;
        let pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>> = Arc::default();
        let out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>> = Arc::default();
        let (tx, reader) = spawn_connection(&sock, &app, pending.clone(), out_channels.clone()).await?;
        let bridge = Self {
            next_id: AtomicU64::new(1),
            app,
            sock,
            gen: AtomicU64::new(0),
            tx: Mutex::new(tx),
            reconnect_lock: Mutex::new(()),
            reader: Mutex::new(reader),
            pending,
            out_channels,
        };
        // The daemon outlives the GUI by design, so after an update the GUI may
        // attach to a stale daemon — new features that need new daemon code then
        // silently don't work. Restart it if it's out of date.
        bridge.ensure_matching_daemon().await;
        Ok(bridge)
    }

    /// Restart the running daemon if it predates the installed `spaceshd` binary
    /// (or was built from a different commit). The bundled binary's mtime vs the
    /// daemon's `started_at_ms` is the reliable signal: it catches every reinstall
    /// even while developing dirty, where the git build id doesn't change.
    async fn ensure_matching_daemon(&self) {
        let Ok(reply) = self.request(Cmd::Health).await else { return };
        let (daemon_build, started_at_ms) = match &reply {
            Envelope::Res { data, .. } => (
                data.get("build").and_then(|v| v.as_str()).map(str::to_string),
                data.get("started_at_ms").and_then(|v| v.as_u64()),
            ),
            _ => (None, None),
        };
        let gui_build = option_env!("SPACESH_BUILD").unwrap_or("dev");
        let build_mismatch = gui_build != "dev"
            && daemon_build.as_deref().map(|b| b != gui_build).unwrap_or(false);
        let binary_newer = match (daemon_bin_mtime_ms(), started_at_ms) {
            (Some(bin_ms), Some(start_ms)) => bin_ms > start_ms,
            _ => false,
        };
        if !build_mismatch && !binary_newer {
            return;
        }
        // Ask the stale daemon to exit, wait for its socket to clear, then reconnect
        // — which lazily spawns the fresh bundled daemon.
        self.fire(Cmd::Shutdown).await;
        for _ in 0..100 {
            if UnixStream::connect(&self.sock).await.is_err() {
                break;
            }
            tokio::time::sleep(Duration::from_millis(30)).await;
        }
        let seen = self.gen.load(Ordering::Acquire);
        let _ = self.reconnect(seen).await;
    }

    /// Send a command without awaiting a reply or retrying. Used for Shutdown:
    /// the daemon exits before its reply is flushed, so a normal request() would
    /// time out and the reconnect-retry would respawn-and-reshutdown in a loop.
    async fn fire(&self, cmd: Cmd) {
        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
        let tx = self.tx.lock().await.clone();
        let _ = tx.send(Envelope::Req { id, cmd }).await;
    }

    /// Re-establish the daemon connection. Single-flight: callers pass the `gen`
    /// they observed; if another caller already reconnected (gen advanced), this
    /// is a no-op so we never open duplicate connections.
    async fn reconnect(&self, seen_gen: u64) -> Result<()> {
        let _guard = self.reconnect_lock.lock().await;
        if self.gen.load(Ordering::Acquire) != seen_gen {
            return Ok(());
        }
        // Drop in-flight reply slots — their connection is gone; they'll error out.
        self.pending.lock().await.clear();
        // Kill the old reader FIRST so it can't keep delivering output on a stale
        // connection alongside the new one (the cause of doubled keystroke echo).
        self.reader.lock().await.abort();
        let (new_tx, new_reader) = spawn_connection(&self.sock, &self.app, self.pending.clone(), self.out_channels.clone()).await?;
        *self.tx.lock().await = new_tx;
        *self.reader.lock().await = new_reader;
        self.gen.fetch_add(1, Ordering::Release);
        let _ = self.app.emit("spacesh:reconnected", ());
        Ok(())
    }

    /// Send one request and await its reply with a timeout. Errors if the writer
    /// is gone, the reply slot is dropped, or no reply arrives in time.
    async fn send_once(&self, id: u64, env: Envelope) -> Result<Envelope> {
        let (reply_tx, reply_rx) = oneshot::channel();
        self.pending.lock().await.insert(id, reply_tx);
        let tx = self.tx.lock().await.clone();
        if tx.send(env).await.is_err() {
            self.pending.lock().await.remove(&id);
            anyhow::bail!("daemon writer closed");
        }
        match tokio::time::timeout(Duration::from_secs(5), reply_rx).await {
            Ok(Ok(env)) => Ok(env),
            Ok(Err(_)) => anyhow::bail!("reply slot dropped"),
            Err(_) => {
                self.pending.lock().await.remove(&id);
                anyhow::bail!("request timed out")
            }
        }
    }

    pub async fn request(&self, cmd: Cmd) -> Result<Envelope> {
        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
        let seen_gen = self.gen.load(Ordering::Acquire);
        let env = Envelope::Req { id, cmd };
        match self.send_once(id, env.clone()).await {
            Ok(reply) => Ok(reply),
            Err(_) => {
                // Connection likely dropped — reconnect (respawns the daemon if
                // it exited) and retry once.
                self.reconnect(seen_gen).await?;
                self.send_once(id, env).await
            }
        }
    }

    pub async fn register_output(&self, surface_id: String, channel: Channel<Vec<u8>>) {
        self.out_channels.lock().await.insert(surface_id, channel);
    }

    pub async fn unregister_output(&self, surface_id: &str) {
        self.out_channels.lock().await.remove(surface_id);
    }
}

fn spawn_writer(mut write_half: OwnedWriteHalf, mut rx: mpsc::Receiver<Envelope>) {
    tokio::spawn(async move {
        while let Some(env) = rx.recv().await {
            if write_frame(&mut write_half, &env).await.is_err() {
                break;
            }
        }
    });
}

fn spawn_reader(
    mut read_half: OwnedReadHalf,
    app: AppHandle,
    pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
    out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>>,
) -> tokio::task::JoinHandle<()> {
    tokio::spawn(async move {
        loop {
            match read_frame(&mut read_half).await {
                Ok(Some(env)) => match env {
                    Envelope::Res { id, .. } => {
                        if let Some(slot) = pending.lock().await.remove(&id) {
                            let _ = slot.send(env);
                        }
                    }
                    Envelope::Evt(Evt::Output { surface_id, bytes }) => {
                        if let Some(ch) = out_channels.lock().await.get(&surface_id.0) {
                            let _ = ch.send(bytes);
                        }
                    }
                    Envelope::Evt(other) => {
                        // exit / surface_created / surface_closed → emit to webview.
                        let _ = app.emit("spacesh:evt", &other);
                    }
                    Envelope::Req { .. } => {}
                },
                Ok(None) | Err(_) => {
                    let _ = app.emit("spacesh:disconnected", ());
                    break;
                }
            }
        }
    })
}

// ---- Tauri commands ----

type BridgeState<'a> = tauri::State<'a, Bridge>;

fn data_of(env: Envelope) -> Result<Value, String> {
    match env {
        Envelope::Res { ok: true, data, .. } => Ok(data),
        Envelope::Res { ok: false, error, .. } => {
            Err(error.map(|e| format!("{}: {}", e.code, e.msg)).unwrap_or_else(|| "error".into()))
        }
        _ => Err("unexpected reply".into()),
    }
}

#[tauri::command]
pub async fn open(state: BridgeState<'_>, path: String) -> Result<Value, String> {
    data_of(state.request(Cmd::Open { path }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn new_surface(
    state: BridgeState<'_>,
    workspace_id: String,
    command: Option<String>,
    args: Vec<String>,
    cols: u16,
    rows: u16,
) -> Result<Value, String> {
    let cmd = Cmd::NewSurface {
        workspace_id: spacesh_proto::WorkspaceId(workspace_id),
        command,
        args,
        cols,
        rows,
    };
    data_of(state.request(cmd).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn input(state: BridgeState<'_>, surface_id: String, data: Vec<u8>) -> Result<Value, String> {
    let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
    data_of(state.request(Cmd::Input { surface_id: SurfaceId(surface_id), bytes: b64 }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn resize(state: BridgeState<'_>, surface_id: String, cols: u16, rows: u16) -> Result<Value, String> {
    data_of(state.request(Cmd::Resize { surface_id: SurfaceId(surface_id), cols, rows }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn attach(state: BridgeState<'_>, surface_id: String, on_output: Channel<Vec<u8>>) -> Result<Value, String> {
    state.register_output(surface_id.clone(), on_output).await;
    data_of(state.request(Cmd::Attach { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn detach(state: BridgeState<'_>, surface_id: String) -> Result<Value, String> {
    state.unregister_output(&surface_id).await;
    data_of(state.request(Cmd::Detach { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn status(state: BridgeState<'_>) -> Result<Value, String> {
    data_of(state.request(Cmd::Status).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn close_surface(state: BridgeState<'_>, surface_id: String) -> Result<Value, String> {
    data_of(state.request(Cmd::Close { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
}

// ---- M2 commands ----

#[tauri::command]
pub async fn split_surface(state: BridgeState<'_>, surface_id: String, dir: String, command: Option<String>, args: Vec<String>) -> Result<Value, String> {
    let dir = if dir == "down" { SplitDir::Down } else { SplitDir::Right };
    data_of(state.request(Cmd::SplitSurface { surface_id: SurfaceId(surface_id), dir, command, args }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn set_ratios(state: BridgeState<'_>, workspace_id: String, node_path: Vec<u32>, ratios: Vec<f32>) -> Result<Value, String> {
    data_of(state.request(Cmd::SetRatios { workspace_id: WorkspaceId(workspace_id), node_path, ratios }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn move_surface(state: BridgeState<'_>, surface_id: String, target_surface_id: String, edge: String) -> Result<Value, String> {
    let edge = match edge.as_str() { "left" => Edge::Left, "top" => Edge::Top, "bottom" => Edge::Bottom, _ => Edge::Right };
    data_of(state.request(Cmd::MoveSurface { surface_id: SurfaceId(surface_id), target_surface_id: SurfaceId(target_surface_id), edge }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn apply_preset(state: BridgeState<'_>, workspace_id: String, preset_id: String, slots: Vec<PresetSlot>) -> Result<Value, String> {
    data_of(state.request(Cmd::ApplyPreset { workspace_id: WorkspaceId(workspace_id), preset_id, slots }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn restart_surface(state: BridgeState<'_>, surface_id: String, resume: bool) -> Result<Value, String> {
    data_of(state.request(Cmd::RestartSurface { surface_id: SurfaceId(surface_id), resume }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn close_workspace(state: BridgeState<'_>, workspace_id: String) -> Result<Value, String> {
    data_of(state.request(Cmd::CloseWorkspace { workspace_id: WorkspaceId(workspace_id) }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn set_workspace_meta(state: BridgeState<'_>, workspace_id: String, name: Option<String>, group_id: Option<String>, unread: Option<bool>, order: Option<u32>, pinned: Option<bool>) -> Result<Value, String> {
    // group_id: None from JS means "no change"; an explicit null is sent as Some("") to mean "ungroup".
    let gid = match group_id {
        None => None,
        Some(s) if s.is_empty() => Some(None),
        Some(s) => Some(Some(GroupId(s))),
    };
    data_of(state.request(Cmd::SetWorkspaceMeta { workspace_id: WorkspaceId(workspace_id), name, group_id: gid, unread, order, pinned }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn create_group(state: BridgeState<'_>, name: String, color: String) -> Result<Value, String> {
    data_of(state.request(Cmd::CreateGroup { name, color }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn set_group(state: BridgeState<'_>, group_id: String, name: Option<String>, color: Option<String>, order: Option<u32>) -> Result<Value, String> {
    data_of(state.request(Cmd::SetGroup { group_id: GroupId(group_id), name, color, order }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn delete_group(state: BridgeState<'_>, group_id: String) -> Result<Value, String> {
    data_of(state.request(Cmd::DeleteGroup { group_id: GroupId(group_id) }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn focus(state: BridgeState<'_>, surface_id: String) -> Result<Value, String> {
    data_of(state.request(Cmd::Focus { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
}

// ---- SP4 zoom command ----

#[tauri::command]
pub async fn set_zoom(state: BridgeState<'_>, workspace_id: String, surface_id: Option<String>) -> Result<Value, String> {
    let cmd = Cmd::SetZoom {
        workspace_id: spacesh_proto::WorkspaceId(workspace_id),
        surface_id: surface_id.map(spacesh_proto::SurfaceId),
    };
    data_of(state.request(cmd).await.map_err(|e| e.to_string())?)
}

// ---- M3 event log commands ----

#[tauri::command]
pub async fn event_log(state: BridgeState<'_>, limit: Option<u32>) -> Result<Value, String> {
    data_of(state.request(Cmd::EventLog { limit }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn mark_read(state: BridgeState<'_>, target: Value) -> Result<Value, String> {
    let target: spacesh_proto::MarkReadTarget = serde_json::from_value(target).map_err(|e| format!("invalid mark_read target: {e}"))?;
    data_of(state.request(Cmd::MarkRead { target }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn clear_events(state: BridgeState<'_>) -> Result<Value, String> {
    data_of(state.request(Cmd::ClearEvents).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn health(state: BridgeState<'_>) -> Result<Value, String> {
    data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn which_agents(state: BridgeState<'_>, candidates: Vec<String>) -> Result<Value, String> {
    data_of(state.request(Cmd::WhichAgents { candidates }).await.map_err(|e| e.to_string())?)
}

// ---- Update check ----

/// Where the GUI looks for the published app version. Overridable via
/// SPACESH_UPDATE_URL for local testing against a staging server.
const DEFAULT_UPDATE_URL: &str = "https://spaceshell.ru/download/latest.json";

#[derive(serde::Serialize)]
pub struct UpdateInfo {
    current: String,
    latest: String,
    has_update: bool,
    url: String,
}

/// Parse a `major.minor.patch` string (tolerating a leading `v` and a
/// `-prerelease` suffix) into a comparable tuple; missing parts are 0.
fn parse_ver(v: &str) -> (u64, u64, u64) {
    let core = v.trim().trim_start_matches('v').split('-').next().unwrap_or("");
    let mut it = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
    (it.next().unwrap_or(0), it.next().unwrap_or(0), it.next().unwrap_or(0))
}

/// Fetch the server manifest and compare against the bundled app version.
/// `current` is the Tauri package version (single source of truth: tauri.conf.json),
/// which `make dmg` bumps on every build.
#[tauri::command]
pub async fn check_update(app: AppHandle) -> Result<UpdateInfo, String> {
    // The local version is always known; the server may be unreachable (no manifest
    // yet, offline). Never fail the command for that — return the current version with
    // an empty `latest` so the UI can show "couldn't check" instead of blanking out.
    let current = app.package_info().version.to_string();
    let fallback_url = "https://spaceshell.ru/download/spacesh.dmg".to_string();
    let manifest_url =
        std::env::var("SPACESH_UPDATE_URL").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| DEFAULT_UPDATE_URL.to_string());

    let fetch = || async {
        let client = reqwest::Client::builder().timeout(Duration::from_secs(10)).build()?;
        client.get(&manifest_url).send().await?.error_for_status()?.json::<Value>().await
    };

    match fetch().await {
        Ok(manifest) => {
            let latest = manifest.get("version").and_then(|v| v.as_str()).unwrap_or("").to_string();
            let url = manifest.get("url").and_then(|v| v.as_str()).unwrap_or(&fallback_url).to_string();
            let has_update = !latest.is_empty() && parse_ver(&latest) > parse_ver(&current);
            Ok(UpdateInfo { current, latest, has_update, url })
        }
        Err(_) => Ok(UpdateInfo { current, latest: String::new(), has_update: false, url: fallback_url }),
    }
}

/// Open a URL in the default browser (macOS `open`). Used by the update popover's
/// download action — the GUI itself never streams the .dmg.
#[tauri::command]
pub fn open_external(url: String) -> Result<(), String> {
    std::process::Command::new("open").arg(&url).spawn().map_err(|e| e.to_string())?;
    Ok(())
}

/// Whether spaceshell.app has Full Disk Access. Terminals spawned inside the app
/// inherit its TCC grants (the app is their "responsible process"), so without FDA
/// commands like `tmutil` fail. Probe: reading the user's TCC database requires FDA —
/// a permission error means we lack it; success (or the file being absent) means we
/// have it.
#[tauri::command]
pub fn has_full_disk_access() -> bool {
    let Some(home) = std::env::var_os("HOME") else { return false };
    let probe = std::path::Path::new(&home).join("Library/Application Support/com.apple.TCC/TCC.db");
    match std::fs::File::open(&probe) {
        Ok(_) => true,
        Err(e) => e.kind() == std::io::ErrorKind::NotFound,
    }
}

/// Open System Settings → Privacy & Security → Full Disk Access so the user can add
/// spaceshell. macOS cannot grant this programmatically; we only deep-link the pane.
#[tauri::command]
pub fn open_full_disk_access_settings() -> Result<(), String> {
    std::process::Command::new("open")
        .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles")
        .spawn()
        .map_err(|e| e.to_string())?;
    Ok(())
}

/// List the user's installed font families (CoreText) so Settings can offer any of
/// them for the terminal. Hidden system families (".SF NS" etc.) are dropped; the
/// result is de-duplicated and sorted case-insensitively.
#[tauri::command]
pub fn list_fonts() -> Vec<String> {
    use std::collections::BTreeSet;
    let names = core_text::font_collection::get_family_names();
    let mut set: BTreeSet<String> = BTreeSet::new();
    for name in names.iter() {
        let s = name.to_string();
        if !s.is_empty() && !s.starts_with('.') {
            set.insert(s);
        }
    }
    let mut v: Vec<String> = set.into_iter().collect();
    v.sort_by_key(|s| s.to_lowercase());
    v
}

// ---- Settings commands ----

#[tauri::command]
pub async fn get_config(state: BridgeState<'_>) -> Result<Value, String> {
    data_of(state.request(Cmd::GetConfig).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn set_config(
    state: BridgeState<'_>,
    default_shell: Option<String>,
    font_family: Option<String>,
    font_size: Option<u16>,
    theme: Option<String>,
    accent: Option<String>,
    background: Option<String>,
    background_image: Option<String>,
    log_shell_commands: Option<bool>,
) -> Result<Value, String> {
    data_of(state.request(Cmd::SetConfig { default_shell, font_family, font_size, theme, accent, background, background_image, log_shell_commands }).await.map_err(|e| e.to_string())?)
}

/// Read a local image file and return it as a `data:` URL for use as a CSS
/// background. Kept in the GUI bridge (not the daemon) so the bytes load straight
/// into the webview without crossing the socket.
#[tauri::command]
pub async fn read_image_data_url(path: String) -> Result<String, String> {
    use base64::Engine as _;
    let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
    let mime = match std::path::Path::new(&path).extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
        Some("png") => "image/png",
        Some("jpg") | Some("jpeg") => "image/jpeg",
        Some("webp") => "image/webp",
        Some("gif") => "image/gif",
        _ => "application/octet-stream",
    };
    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
    Ok(format!("data:{mime};base64,{b64}"))
}

#[tauri::command]
pub async fn shutdown_daemon(state: BridgeState<'_>) -> Result<Value, String> {
    // Fire-and-forget: the daemon exits without a flushed reply, so awaiting one
    // would time out and trigger a respawn-then-reshutdown loop.
    state.fire(Cmd::Shutdown).await;
    Ok(Value::Null)
}
