use std::error::Error;
use std::fs;
use std::io::{self, BufReader, Read, Write};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::time::{Duration, Instant};

const MAX_IPC_MSG: usize = 64 * 1024;
const IPC_DEADLINE: Duration = Duration::from_millis(50);

use forge_ipc::{AppIdMatcher, CliToggle, IpcMessage, Keysym, WindowScale};
use serde_json::Value;
use smithay::input::keyboard::XkbConfig;
use smithay::reexports::calloop::LoopHandle;
use tracing::{info, warn};

use crate::config::{GestureAction, GestureBinding, KeyBinding};
use crate::forge::Forge;
use crate::socket::SocketSource;

pub fn spawn_ipc_socket(
    event_loop: &LoopHandle<'static, Forge>,
    socket_name: &str,
) -> Result<PathBuf, Box<dyn Error>> {
    let socket_path = forge_ipc::socket_path(socket_name);

    if socket_path.exists() {
        fs::remove_file(&socket_path)?;
    }

    // Bind on a temporary path, restrict it, then rename into place so the
    // socket is never reachable with default permissions, even briefly.
    let tmp_path = socket_path.with_file_name(format!(
        "{}.tmp",
        socket_path.file_name().and_then(|n| n.to_str()).unwrap_or("forge.sock"),
    ));
    if tmp_path.exists() {
        fs::remove_file(&tmp_path)?;
    }
    let listener = UnixListener::bind(&tmp_path)?;
    // Restrict socket to the session owner — crustace and forge-msg run as
    // the same user, so 0o600 is sufficient and prevents other local users
    // from sending lock/screenshot/quit commands.
    fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o600))?;
    fs::rename(&tmp_path, &socket_path)?;
    let socket = SocketSource::new(listener)?;

    event_loop.insert_source(socket, move |stream, _, forge| {
        handle_message(stream, forge);
    })?;

    Ok(socket_path)
}

/// Read one newline-terminated request, bounded in both size and total time so
/// a local process cannot force a large allocation or hold the render loop.
fn read_request(stream: &UnixStream) -> Option<String> {
    stream.set_read_timeout(Some(Duration::from_millis(5))).ok();
    let deadline = Instant::now() + IPC_DEADLINE;
    let mut reader = BufReader::new(stream);
    let mut buf: Vec<u8> = Vec::with_capacity(256);

    loop {
        if Instant::now() >= deadline {
            return None;
        }
        let mut byte = [0u8; 1];
        match reader.read(&mut byte) {
            // EOF terminates a message just like '\n' does. The `forge msg`
            // CLI frames by shutdown(Write) with NO trailing newline, so
            // removing this arm silently drops every forge msg command.
            // Guarded by tests::eof_framed_message_is_accepted.
            Ok(0) => break,
            Ok(_) => {
                if byte[0] == b'\n' {
                    break;
                }
                if buf.len() >= MAX_IPC_MSG {
                    return None;
                }
                buf.push(byte[0]);
            }
            Err(e) if e.kind() == io::ErrorKind::WouldBlock
                || e.kind() == io::ErrorKind::TimedOut =>
            {
                continue;
            }
            Err(_) => return None,
        }
    }

    if buf.is_empty() {
        return None;
    }
    String::from_utf8(buf).ok()
}

fn handle_message(mut stream: UnixStream, forge: &mut Forge) {
    let Some(buffer) = read_request(&stream) else {
        return;
    };

    // Try Pedro's action-based JSON protocol first
    if let Ok(val) = serde_json::from_str::<Value>(&buffer) {
        if let Some(action) = val.get("action").and_then(|a| a.as_str()) {
            handle_action(action, &val, &mut stream, forge);
            return;
        }
    }

    // Fall back to forge_ipc::IpcMessage enum format
    let message: IpcMessage = match serde_json::from_str(&buffer) {
        Ok(m) => m,
        Err(_) => return,
    };
    handle_ipc_message(message, &mut stream, forge);
}

// ── Pedro's action-based protocol ──────────────────────────────────────

fn handle_action(action: &str, val: &Value, stream: &mut UnixStream, forge: &mut Forge) {
    match action {
        "toggle_launcher" => {
            info!("IPC: toggle_launcher");
            // Forge does nothing — crustace handles this directly.
            // The keybinding sends this to crustace's socket.
            send_json(stream, r#"{"success":true}"#);
            forge.unstall();
        }


        "show_homescreen" => {
            info!("IPC: show_homescreen");
            // Minimize all windows, give focus to crustace layer
            forge.windows.minimize_all();
            forge.request_redraw();
            send_json(stream, r#"{"success":true}"#);
        }

        "list_windows" => {
            let windows = forge.windows.list_toplevel_windows();
            let json = serde_json::to_string(&windows).unwrap_or_else(|_| "[]".into());
            send_json(stream, &json);
        }

        "activate_window" => {
            if let Some(id) = val.get("window_id").and_then(|v| v.as_u64()) {
                forge.windows.activate_window_by_id(id);
                forge.unstall();
                send_json(stream, r#"{"success":true}"#);
            } else {
                send_json(stream, r#"{"error":"missing_window_id"}"#);
            }
        }

        "activate_app_id" => {
            if let Some(app_id) = val.get("app_id").and_then(|v| v.as_str()) {
                forge.windows.activate_window_by_app_id(app_id);
                forge.unstall();
                send_json(stream, r#"{"success":true}"#);
            } else {
                send_json(stream, r#"{"error":"missing_app_id"}"#);
            }
        }

        "close_window" => {
            if let Some(id) = val.get("window_id").and_then(|v| v.as_u64()) {
                forge.windows.close_window_by_id(id);
                forge.unstall();
                send_json(stream, r#"{"success":true}"#);
            } else {
                send_json(stream, r#"{"error":"missing_window_id"}"#);
            }
        }

        "close_app_id" => {
            if let Some(app_id) = val.get("app_id").and_then(|v| v.as_str()) {
                forge.windows.close_windows_by_app_id(app_id);
                forge.unstall();
                send_json(stream, r#"{"success":true}"#);
            } else {
                send_json(stream, r#"{"error":"missing_app_id"}"#);
            }
        }

        "resize_app_id" => {
            let app_id = val.get("app_id").and_then(|v| v.as_str());
            let w = val.get("w").and_then(|v| v.as_i64()).map(|v| v as i32);
            let h = val.get("h").and_then(|v| v.as_i64()).map(|v| v as i32);
            match (app_id, w, h) {
                (Some(app_id), Some(w), Some(h)) if w > 0 && h > 0 => {
                    forge.windows.resize_window_by_app_id(app_id, w, h);
                    forge.unstall();
                    send_json(stream, r#"{"success":true}"#);
                }
                _ => send_json(stream, r#"{"error":"missing_or_invalid_fields"}"#),
            }
        }

        "get_window_thumbnail" => {
            let id = val.get("window_id").and_then(|v| v.as_u64()).unwrap_or(0);
            let width = val.get("width").and_then(|v| v.as_u64()).unwrap_or(300);
            let height = val.get("height").and_then(|v| v.as_u64()).unwrap_or(200);

            if !(1..=1920).contains(&width) || !(1..=1080).contains(&height) {
                send_json(stream, r#"{"error":"invalid_dimensions"}"#);
                return;
            }
            let width = width as u32;
            let height = height as u32;

            match forge.capture_window_thumbnail(id, width, height) {
                Ok(thumb) => {
                    let json = serde_json::to_string(&thumb).unwrap_or_else(|_| {
                        r#"{"error":"serialize_failed"}"#.into()
                    });
                    send_json(stream, &json);
                }
                Err(e) => {
                    let err = serde_json::json!({
                        "error": "capture_failed",
                        "message": e
                    });
                    send_json(stream, &err.to_string());
                }
            }
        }

        "screenshot" | "screenshot_region" => {
            forge.take_screenshot();
            send_json(stream, r#"{"success":true}"#);
        }

        "screenshot_full" => {
            forge.screenshot_full_to_clipboard();
            send_json(stream, r#"{"success":true}"#);
        }

        "screenshot_window" => {
            let rect = match val.get("window_id").and_then(|v| v.as_u64()) {
                Some(id) => forge.windows.window_rect_by_id(id),
                None => forge.windows.focused_window_capture_rect(),
            };
            match rect {
                Some(region) => {
                    forge.screenshot_region_to_clipboard(region);
                    send_json(stream, r#"{"success":true}"#);
                }
                None => send_json(stream, r#"{"error":"no_window"}"#),
            }
        }

        "lock" => {
            forge.lock_session();
            forge.request_redraw();
            send_json(stream, r#"{"success":true}"#);
        }

        "switch_workspace" => {
            if let Some(idx) = val.get("index").and_then(|v| v.as_u64()) {
                forge.windows.switch_workspace(idx as usize);
                forge.request_redraw();
                send_json(stream, r#"{"success":true}"#);
            } else if let Some(dir) = val.get("direction").and_then(|v| v.as_str()) {
                let current = forge.windows.active_workspace().unwrap_or(0);
                let count = forge.windows.workspace_count();
                if count > 0 {
                    let target = match dir {
                        "next" => (current + 1) % count,
                        "prev" => if current == 0 { count - 1 } else { current - 1 },
                        _ => current,
                    };
                    forge.windows.switch_workspace(target);
                    forge.request_redraw();
                }
                send_json(stream, r#"{"success":true}"#);
            }
        }

        "get_workspaces" => {
            let current = forge.windows.active_workspace().unwrap_or(0);
            let count = forge.windows.workspace_count();
            let json = serde_json::json!({
                "current": current,
                "count": count,
            });
            send_json(stream, &json.to_string());
        }

        "configure_topbar" => {
            if let Some(height) = val.get("height").and_then(|v| v.as_i64()) {
                info!("IPC: configure_topbar height={height}");
                forge.windows.configure_topbar(height as i32);
                forge.unstall();
                send_json(stream, r#"{"success":true}"#);
            } else {
                send_json(stream, r#"{"error":"missing_height"}"#);
            }
        }

        "set_notification_count" => {
            let count = val.get("count").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
            forge.shell.topbar.set_notification_count(count);
            forge.request_redraw();
            send_json(stream, r#"{"success":true}"#);
        }

        "set_dock_zone" => {
            let height_phys = val.get("height").and_then(|v| v.as_u64()).unwrap_or(0) as f64;
            let height = height_phys / forge.windows.output_scale().max(1.0);
            forge.input.edge_recognizer.set_leave_band(height);
            // Alt+Tab needs the same value to keep its panel out of the
            // dock strip (eydos #6 spec'd bottom margin = dock height
            // + 2% screen).
            forge.shell.app_switcher.set_dock_height(height as i32);
            send_json(stream, r#"{"success":true}"#);
        }

        // Interface scale picked in Settings. `{"scale":1.25}` pins it and
        // persists it in `forge.toml`; `{"scale":0}` clears the pick and
        // hands the decision back to the automatic path.
        "set_output_scale" => {
            let requested = val
                .get("scale")
                .and_then(|v| v.as_f64())
                .filter(|s| (1.0..=3.0).contains(s));
            info!("IPC: set_output_scale {requested:?}");
            crate::output::set_user_scale_override(requested);

            // This is the session-wide control (Settings → Accessibility) and
            // it names the primary screen. Route it through the same
            // per-connector pin the Monitors panel writes, or the two would
            // hold different values for the same screen and whichever
            // re-derivation ran last would win.
            let primary = forge.windows.output_name();
            let primary_identity = forge.windows.primary_output().identity().map(str::to_string);
            forge.windows.set_output_scale_pick(&primary, requested);
            let mut config_outputs = crate::config::ForgeConfig::load();
            if let Some(entry) = config_outputs
                .display
                .output_for_mut(&primary, primary_identity.as_deref())
            {
                entry.scale = requested;
                let _ = config_outputs.save();
            }
            forge.windows.apply_output_scale();
            forge.update_display_state();
            forge.force_redraw(true);

            let mut config = crate::config::ForgeConfig::load();
            config.display.scale = requested;
            if let Err(e) = config.save() {
                warn!("set_output_scale: could not persist: {e}");
            }
            send_json(stream, r#"{"success":true}"#);
        }

        // Connector inventory for the Settings display panel: every connected
        // screen with its EDID identity, mode list, per-panel automatic scale,
        // and the session display mode. Per-connector user overrides come from
        // the persisted config so Settings can show pick vs automatic.
        "list_outputs" => {
            let infos = forge.backend.output_infos();
            let layout = forge.windows.output_layout();
            let picks = forge.windows.output_scale_picks();
            let config = crate::config::ForgeConfig::load();
            let mode = match config.display.mode {
                crate::config::DisplayMode::Mirror => "mirror",
                crate::config::DisplayMode::Extend => "extend",
            };
            let outputs: Vec<serde_json::Value> = infos
                .iter()
                .map(|info| {
                    let user = config
                        .display
                        .output_for(&info.connector, info.identity.as_deref());
                    let mut v = serde_json::to_value(info).unwrap_or_default();
                    if let Some(obj) = v.as_object_mut() {
                        // Live pin when the connector is lit, else the
                        // persisted one for a screen that is not connected.
                        let pick = picks
                            .iter()
                            .find(|(n, _)| *n == info.connector)
                            .map(|(_, s)| *s)
                            .unwrap_or_else(|| user.and_then(|o| o.scale).unwrap_or(0.0));
                        obj.insert("scale".into(), pick.into());
                        obj.insert(
                            "position".into(),
                            match user.and_then(|o| o.position) {
                                Some((x, y)) => serde_json::json!([x, y]),
                                None => serde_json::Value::Null,
                            },
                        );
                        obj.insert(
                            "enabled".into(),
                            user.map(|o| o.enabled).unwrap_or(true).into(),
                        );
                        // Live layout, when the connector is lit right now:
                        // what Settings draws in the arrangement canvas.
                        if let Some((_, rect, applied)) =
                            layout.iter().find(|(n, _, _)| n == &info.connector)
                        {
                            obj.insert(
                                "position".into(),
                                serde_json::json!([rect.loc.x, rect.loc.y]),
                            );
                            obj.insert(
                                "logical_size".into(),
                                serde_json::json!([rect.size.w, rect.size.h]),
                            );
                            obj.insert("scale_applied".into(), (*applied).into());
                        }
                    }
                    v
                })
                .collect();
            let reply = serde_json::json!({
                "success": true,
                "mode": mode,
                "session_scale": forge.windows.output_scale(),
                "outputs": outputs,
            });
            send_json(stream, &reply.to_string());
        }

        // Switch how multiple screens are used, live: mirror keeps every
        // screen on the primary's image, extend gives each its own region.
        // Secondary heads are rebuilt under the new mode; the primary never
        // blanks. Persisted so the choice survives restarts.
        "set_display_mode" => {
            let mode = match val.get("mode").and_then(|v| v.as_str()) {
                Some("mirror") => crate::config::DisplayMode::Mirror,
                Some("extend") => crate::config::DisplayMode::Extend,
                other => {
                    warn!("set_display_mode: invalid mode {other:?}");
                    send_json(stream, r#"{"success":false,"error":"invalid mode"}"#);
                    return;
                }
            };
            info!("IPC: set_display_mode {mode:?}");
            let mut config = crate::config::ForgeConfig::load();
            config.display.mode = mode;
            if let Err(e) = config.save() {
                warn!("set_display_mode: could not persist: {e}");
            }
            if let crate::backend::Backend::Udev(udev) = &mut forge.backend {
                udev.reconfigure_outputs(&forge.display_handle, &mut forge.windows);
            }
            forge.update_display_state();
            forge.force_redraw(true);
            send_json(stream, r#"{"success":true}"#);
        }

        // Per-connector display settings from the Settings panel. Only the
        // fields present are touched; everything persists to forge.toml.
        // scale 0 returns the screen to its own panel-DPI automatic; mode
        // and enabled rebuild the secondary heads; primary applies on the
        // next session start.
        "set_output_config" => {
            let Some(connector) = val.get("connector").and_then(|v| v.as_str()) else {
                send_json(stream, r#"{"success":false,"error":"missing connector"}"#);
                return;
            };
            let scale = val.get("scale").and_then(|v| v.as_f64());
            let position = val.get("position").and_then(|v| {
                let arr = v.as_array()?;
                Some((arr.first()?.as_i64()? as i32, arr.get(1)?.as_i64()? as i32))
            });
            let mode = val.get("mode").and_then(|v| v.as_str()).map(str::to_string);
            let enabled = val.get("enabled").and_then(|v| v.as_bool());
            let primary = val.get("primary").and_then(|v| v.as_bool());
            info!(
                "IPC: set_output_config {connector}: scale={scale:?} position={position:?} \
                 mode={mode:?} enabled={enabled:?} primary={primary:?}"
            );

            let identity = forge
                .backend
                .output_infos()
                .into_iter()
                .find(|i| i.connector == connector)
                .and_then(|i| i.identity);
            let mut config = crate::config::ForgeConfig::load();
            if config.display.output_for(connector, identity.as_deref()).is_none() {
                config.display.outputs.push(crate::config::OutputConfig {
                    connector: connector.to_string(),
                    identity: None,
                    scale: None,
                    mode: None,
                    position: None,
                    enabled: true,
                    primary: false,
                });
            }
            config.display.learn_identity(connector, identity.as_deref());
            let entry = config
                .display
                .output_for_mut(connector, identity.as_deref())
                .expect("entry just ensured");
            let scale_pick = scale.map(|s| (s > 0.0).then_some(s));
            if let Some(pick) = scale_pick {
                entry.scale = pick.filter(|s| (1.0..=3.0).contains(s));
            }
            if let Some(pos) = position {
                entry.position = Some(pos);
            }
            let mode_changed = mode.is_some() && mode != entry.mode;
            if let Some(m) = mode {
                entry.mode = Some(m);
            }
            let enabled_changed = enabled.is_some_and(|e| e != entry.enabled);
            if let Some(e) = enabled {
                entry.enabled = e;
            }
            if let Some(p) = primary {
                if p {
                    for o in config.display.outputs.iter_mut() {
                        o.primary = false;
                    }
                }
                let entry = config
                    .display
                    .output_for_mut(connector, identity.as_deref())
                    .expect("entry just ensured");
                entry.primary = p;
            }
            let applied_scale = config
                .display
                .output_for(connector, identity.as_deref())
                .and_then(|o| o.scale);
            if let Err(e) = config.save() {
                warn!("set_output_config: could not persist: {e}");
            }

            // Live apply. Scale and position are soft; a mode or enabled
            // change rebuilds the secondary heads.
            if mode_changed || enabled_changed {
                if let crate::backend::Backend::Udev(udev) = &mut forge.backend {
                    udev.reconfigure_outputs(&forge.display_handle, &mut forge.windows);
                }
            } else {
                if scale_pick.is_some() {
                    if let Some(applied) =
                        forge.windows.set_output_scale_pick(connector, applied_scale)
                    {
                        if let crate::backend::Backend::Udev(udev) = &mut forge.backend {
                            udev.set_head_scale(connector, applied);
                        }
                    }
                }
                if let Some((x, y)) = position {
                    forge.windows.set_extra_output_position(connector, (x, y).into());
                }
            }
            forge.update_display_state();
            forge.force_redraw(true);
            send_json(stream, r#"{"success":true}"#);
        }

        // `scale` is the user's pick (0 = automatic); `automatic` is what the
        // automatic path would choose right now, whether or not it is in
        // force, so Settings can name that number while another is pinned.
        "get_output_scale" => {
            // The primary's own pin is what is in force there; the global
            // override is only the fallback for a screen without one.
            let picked = forge
                .windows
                .output_scale_pick()
                .or_else(crate::output::user_scale_override)
                .unwrap_or(0.0);
            let automatic = forge.windows.automatic_output_scale();
            send_json(
                stream,
                &format!(r#"{{"scale":{picked},"automatic":{automatic}}}"#),
            );
        }

        // Single-app sessions (the installer) drive the whole screen and have
        // nothing to switch to, so the active corners only get in the way: a
        // stray flick to a corner would raise the app switcher or the launcher
        // over the wizard. The session announces itself rather than forge
        // recognising an app id, so any other kiosk gets the same behaviour.
        "set_kiosk" => {
            let enabled = val.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
            info!("IPC: set_kiosk enabled={enabled}");
            forge.set_kiosk(enabled);
            send_json(stream, r#"{"success":true}"#);
        }

        "set_mobile" => {
            let enabled = val.get("enabled").and_then(|v| v.as_bool());
            info!("IPC: set_mobile enabled={enabled:?}");
            forge.windows.set_mobile_override(enabled);
            forge.report_session_state();
            forge.unstall();
            send_json(stream, r#"{"success":true}"#);
        }

        // Cross-process theme sync. Crustace resolves its theme preference
        // (config + CLI --dark / --light) in its own process and calls this
        // action so forge's topbar / SSD render against the same variant,
        // without having to wait for the next sysmon poll to pick up the
        // gsettings value (or, in the --dark / --light case, without the
        // user ever having set gsettings at all).
        //
        // Payload: `{"mode":"dark"}` or `{"mode":"light"}`.
        "set_theme" => {
            let mode = val.get("mode").and_then(|v| v.as_str()).unwrap_or("");
            let is_dark = match mode {
                "dark"  => true,
                "light" => false,
                other   => {
                    warn!("IPC: set_theme got unknown mode {other:?}");
                    send_json(stream, r#"{"error":"invalid_mode"}"#);
                    return;
                }
            };
            info!("IPC: set_theme mode={mode}");
            forge.shell.sys_state.dark_mode = is_dark;
            // Topbar reconciles `sys.dark_mode` against `ltk::active_mode()`
            // on the next render and flushes its own caches when it flips,
            // so a single `set_dirty` is enough to make this visible.
            forge.request_redraw();
            send_json(stream, r#"{"success":true}"#);
        }

        _ => {
            warn!("Unknown IPC action: {action}");
            send_json(stream, r#"{"error":"unknown_action"}"#);
        }
    }
}

// ── Legacy forge_ipc::IpcMessage protocol ──────────────────────────────

fn handle_ipc_message(message: IpcMessage, stream: &mut UnixStream, forge: &mut Forge) {
    match message {
        IpcMessage::Orientation { unlock: true, .. } => forge.unlock_orientation(),
        IpcMessage::Orientation { lock: orientation, .. } => forge.lock_orientation(orientation),

        IpcMessage::Scale { scale, app_id: Some(app_id) } => {
            let Ok(matcher) = AppIdMatcher::try_from(app_id).inspect_err(|e| {
                warn!("Invalid App ID regex in scale message: {e}");
            }) else {
                return;
            };
            forge.windows.add_window_scale(matcher, scale);
        },

        IpcMessage::Scale { scale, app_id: None } => {
            let WindowScale::Fixed(scale) = scale else {
                warn!("Expected fixed scale, got {scale:?}");
                return;
            };
            forge.windows.set_scale(scale);
            forge.unstall();
        },

        IpcMessage::BindGesture { app_id, start, end, program, arguments } => {
            let Ok(app_id) = AppIdMatcher::try_from(app_id).inspect_err(|e| {
                warn!("Invalid App ID regex in gesture binding: {e}");
            }) else {
                return;
            };
            forge.input.touch_state.user_gestures.push(GestureBinding {
                app_id, start, end,
                action: GestureAction::Exec { program, args: arguments },
            });
        },

        IpcMessage::BindGestureKey { app_id, start, end, mods, key } => {
            let Ok(app_id) = AppIdMatcher::try_from(app_id).inspect_err(|e| {
                warn!("Invalid App ID regex in gesture binding: {e}");
            }) else {
                return;
            };
            let Keysym::Xkb(keysym) = key else {
                warn!("Gestures must be bound to real keysyms");
                return;
            };
            forge.input.touch_state.user_gestures.push(GestureBinding {
                app_id, start, end,
                action: GestureAction::Key { keysym, mods: mods.unwrap_or_default() },
            });
        },

        IpcMessage::UnbindGesture { app_id, start, end } => {
            forge.input.touch_state.user_gestures
                .retain(|g| g.app_id.base() != app_id || g.start != start || g.end != end);
        },

        IpcMessage::BindKey { app_id, mods, trigger, keys, program, arguments } => {
            let Ok(app_id) = AppIdMatcher::try_from(app_id).inspect_err(|e| {
                warn!("Invalid App ID regex in key binding: {e}");
            }) else {
                return;
            };
            forge.input.key_bindings.push(KeyBinding {
                app_id, mods: mods.unwrap_or_default(), keys, trigger, program, arguments,
            });
        },

        IpcMessage::UnbindKey { app_id, mods, keys } => {
            let mods = mods.unwrap_or_default();
            forge.input.key_bindings
                .retain(|b| b.app_id.base() != app_id || b.keys != keys || b.mods != mods);
        },

        IpcMessage::KeyboardConfig { model, layout, variant, options } => {
            forge.set_xkb_config(XkbConfig {
                model: model.as_deref().unwrap_or_default(),
                layout: layout.as_deref().unwrap_or_default(),
                variant: variant.as_deref().unwrap_or_default(),
                options,
                rules: "",
            });
        },

        IpcMessage::Dpms { state: Some(state) } => {
            info!("ipc dpms request: {state:?}");
            forge.set_display_status(state == CliToggle::On);
        },
        IpcMessage::Dpms { state: None } => {
            // Report what the CRTC is doing, not what was asked for: a
            // request made while the session was paused is still queued.
            let on = forge.backend.applied_display_status().unwrap_or(forge.session.display_on);
            let state = if on { CliToggle::On } else { CliToggle::Off };
            send_reply(stream, &IpcMessage::DpmsReply { state });
        },

        IpcMessage::Cursor { state } => {
            forge.input.draw_cursor = state == CliToggle::On;
        },
        IpcMessage::Lock => {
            forge.lock_session();
            forge.request_redraw();
        },
        IpcMessage::Unlock => {
            forge.unstall();
        },
        IpcMessage::Launcher => forge.unstall(),
        IpcMessage::PowerMenu => {
            Forge::send_to_crustace(r#"{"action":"show_power_menu"}"#);
        },
        IpcMessage::Wallpaper { .. } => {
            forge.request_redraw();
        },
        IpcMessage::Workspace { target } => {
            if target == "+1" {
                let current = forge.windows.active_workspace().unwrap_or(0);
                let count = forge.windows.workspace_count();
                if count > 0 { forge.windows.switch_workspace((current + 1) % count); }
            } else if target == "-1" {
                let current = forge.windows.active_workspace().unwrap_or(0);
                let count = forge.windows.workspace_count();
                if count > 0 { forge.windows.switch_workspace(if current == 0 { count - 1 } else { current - 1 }); }
            } else if let Ok(idx) = target.parse::<usize>() {
                forge.windows.switch_workspace(idx);
            }
            forge.request_redraw();
        },
        IpcMessage::ShowQuickSettings | IpcMessage::HideQuickSettings |
        IpcMessage::ShowNotifications | IpcMessage::HideNotifications => forge.unstall(),
        IpcMessage::Screenshot => forge.take_screenshot(),
        // Overview was removed; the app switcher replaces it. Kept as a no-op
        // so the typed IPC and the hot-corner that emits it don't error.
        IpcMessage::ToggleOverview => forge.unstall(),
        IpcMessage::Quit => {
            forge.terminated = true;
        },
        IpcMessage::SystemStateUpdate { .. } | IpcMessage::DpmsReply { .. } => (),
    }
}

fn send_json(stream: &mut UnixStream, json: &str) {
    let _ = writeln!(stream, "{json}");
    let _ = stream.flush();
}

fn send_reply(stream: &mut UnixStream, message: &IpcMessage) {
    if let Ok(json) = serde_json::to_string(message) {
        let _ = writeln!(stream, "{json}");
        let _ = stream.flush();
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::net::Shutdown;

    /// The `forge msg` CLI terminates its message by shutting down the write
    /// side WITHOUT a trailing '\n'. A past hardening that required '\n'
    /// silently dropped every CLI command; this guards against regressing.
    #[test]
    fn eof_framed_message_is_accepted() {
        let (mut client, server) = UnixStream::pair().unwrap();
        client.write_all(b"{\"orientation\":{\"lock\":\"landscape\",\"unlock\":false}}").unwrap();
        client.flush().unwrap();
        client.shutdown(Shutdown::Write).unwrap();

        let msg = read_request(&server).expect("EOF-framed message must be accepted");
        assert!(msg.contains("orientation"));
    }

    #[test]
    fn newline_framed_message_is_accepted() {
        let (mut client, server) = UnixStream::pair().unwrap();
        client.write_all(b"{\"action\":\"ping\"}\n").unwrap();
        client.flush().unwrap();

        let msg = read_request(&server).expect("newline-framed message must be accepted");
        assert_eq!(msg, "{\"action\":\"ping\"}");
    }

    #[test]
    fn empty_connection_is_dropped() {
        let (client, server) = UnixStream::pair().unwrap();
        client.shutdown(Shutdown::Write).unwrap();
        assert!(read_request(&server).is_none());
    }
}
