//! Host-side shared-clipboard backend.
//!
//! The wire protocol and the client half live in `punktfunk-core`
//! (`punktfunk_core::quic` + `punktfunk_core::clipboard`); this module drives the **host's** real
//! session clipboard so it can offer what a host app copied and paste what the remote client
//! offered (`design/clipboard-and-file-transfer.md` §4).
//!
//! Concrete backends, selected at session start ([`HostClipboard::open`]) and presented as one
//! [`HostClipboard`] to the [`session`] coordinator:
//! * [`wayland`] (Linux) — `ext-data-control-v1` (KWin, wlroots / Sway, Hyprland). Preferred when present.
//! * [`mutter`] (Linux) — GNOME. Mutter implements **no** wlr/ext data-control, but its *direct*
//!   `org.gnome.Mutter.RemoteDesktop.Session` D-Bus API carries the same clipboard operations (the
//!   xdg `org.freedesktop.portal.Clipboard` would need an interactive grant a headless host can't
//!   answer — so we skip it and talk to Mutter directly, as the input injector already does).
//! * [`windows`] — the Win32 clipboard: a hidden message-only window watches `WM_CLIPBOARDUPDATE`
//!   and serves client content via OLE delayed rendering (`WM_RENDERFORMAT`).
//!
//! The `zwlr-data-control-unstable-v1` fallback (older wlroots/KWin) is a follow-up. The module
//! compiles on Linux and Windows; the [`session`] coordinator is backend-agnostic.

#[cfg(target_os = "linux")]
mod mutter;
#[cfg(target_os = "linux")]
mod wayland;
#[cfg(target_os = "windows")]
mod windows;
/// Pure Win32-clipboard ↔ wire byte conversions (CF_HTML offset math, UTF-16 text, RTF NUL
/// trimming). Free of any Win32 dependency, so it compiles — and its unit tests run — on any host
/// (`cfg(test)`); the Windows backend is the only production consumer.
#[cfg(any(target_os = "windows", test))]
mod winfmt;

pub mod session;

#[cfg(target_os = "linux")]
use std::io::Write as _;
#[cfg(target_os = "linux")]
use std::os::fd::OwnedFd;
use std::sync::Arc;

/// A clipboard event surfaced by a host backend to the [`session`] coordinator. Both the
/// data-control and Mutter backends emit this identical shape.
pub enum ClipEvent {
    /// The host selection changed (a host app copied). `mimes` are the **wire** MIMEs offered (empty
    /// = the clipboard was cleared). The coordinator forwards these as a `ClipOffer` to the client;
    /// bytes cross only if the client later fetches.
    Selection { mimes: Vec<String> },
    /// A host app is pasting content the client offered. The coordinator fetches the wire-`mime`
    /// bytes from the client and hands them to `responder`.
    Paste {
        mime: String,
        responder: PasteResponder,
    },
    /// The backend ended (compositor / session gone).
    Closed,
}

/// How a backend receives the bytes answering a [`ClipEvent::Paste`]. The two host clipboard
/// mechanisms complete a paste differently, so the coordinator stays agnostic by handing bytes to
/// whichever responder the backend attached.
pub enum PasteResponder {
    /// data-control: the compositor handed us the destination pipe on the `send` event — write the
    /// bytes and close it (EOF completes the paste).
    #[cfg(target_os = "linux")]
    Fd(OwnedFd),
    /// Mutter: hand the bytes back to the backend actor, which owns the `SelectionWrite` fd and the
    /// trailing `SelectionWriteDone` call that Mutter's transfer requires.
    #[cfg(target_os = "linux")]
    Channel(tokio::sync::oneshot::Sender<Vec<u8>>),
    /// Windows: hand the bytes to the `WM_RENDERFORMAT` handler blocking the clipboard message-loop
    /// thread, which then `SetClipboardData`s them for the pasting app (`std::sync::mpsc`, since that
    /// thread waits synchronously — see [`windows`]).
    #[cfg(target_os = "windows")]
    Sync(std::sync::mpsc::Sender<Vec<u8>>),
}

impl PasteResponder {
    /// Deliver the fetched bytes (empty on a failed fetch → an empty paste, never a hang).
    pub async fn respond(self, bytes: Vec<u8>) {
        match self {
            #[cfg(target_os = "linux")]
            PasteResponder::Fd(fd) => {
                let _ = tokio::task::spawn_blocking(move || fulfill_paste(fd, &bytes)).await;
            }
            #[cfg(target_os = "linux")]
            PasteResponder::Channel(tx) => {
                let _ = tx.send(bytes);
            }
            #[cfg(target_os = "windows")]
            PasteResponder::Sync(tx) => {
                let _ = tx.send(bytes);
            }
        }
    }
}

/// Write `bytes` into a paste pipe `fd` and close it (EOF signals the reader). Blocking — run off the
/// reactor for large payloads.
#[cfg(target_os = "linux")]
fn fulfill_paste(fd: OwnedFd, bytes: &[u8]) -> std::io::Result<()> {
    let mut file = std::fs::File::from(fd);
    file.write_all(bytes)?;
    Ok(())
}

/// The active host clipboard backend, chosen per session: `ext-data-control`
/// (KWin/wlroots/Hyprland/Sway) or Mutter's direct RemoteDesktop clipboard (GNOME) on Linux, or the
/// Win32 clipboard on Windows. Presented as one type so the [`session`] coordinator is
/// backend-agnostic.
pub enum HostClipboard {
    #[cfg(target_os = "linux")]
    DataControl(wayland::ClipboardBackend),
    #[cfg(target_os = "linux")]
    Mutter(mutter::MutterClipboard),
    #[cfg(target_os = "windows")]
    Windows(windows::WindowsClipboard),
}

impl HostClipboard {
    /// Open whichever backend this session supports. Linux tries data-control first
    /// (KWin/wlroots/Hyprland/Sway) then Mutter's direct clipboard (GNOME); Windows opens the Win32
    /// clipboard. Errors when none is available (gamescope, no live compositor) — the caller then
    /// reports `BACKEND_UNAVAILABLE`.
    pub async fn open() -> anyhow::Result<(
        HostClipboard,
        tokio::sync::mpsc::UnboundedReceiver<ClipEvent>,
    )> {
        #[cfg(target_os = "linux")]
        {
            // data-control's bind does blocking Wayland roundtrips — keep them off the reactor.
            let dc = tokio::task::spawn_blocking(wayland::ClipboardBackend::open)
                .await
                .map_err(|e| anyhow::anyhow!("data-control open join: {e}"))?;
            match dc {
                Ok((b, rx)) => return Ok((HostClipboard::DataControl(b), rx)),
                Err(e) => tracing::debug!(
                    error = format!("{e:#}"),
                    "no ext-data-control — trying Mutter direct clipboard"
                ),
            }
            let (m, rx) = mutter::MutterClipboard::open().await.map_err(|e| {
                e.context("no clipboard backend (neither ext-data-control nor Mutter)")
            })?;
            Ok((HostClipboard::Mutter(m), rx))
        }
        #[cfg(target_os = "windows")]
        {
            let (b, rx) = windows::WindowsClipboard::open().await?;
            Ok((HostClipboard::Windows(b), rx))
        }
    }

    /// The current host selection's wire MIMEs (empty = nothing to offer).
    pub fn current_wire_mimes(&self) -> Vec<String> {
        match self {
            #[cfg(target_os = "linux")]
            HostClipboard::DataControl(b) => b.current_wire_mimes(),
            #[cfg(target_os = "linux")]
            HostClipboard::Mutter(m) => m.current_wire_mimes(),
            #[cfg(target_os = "windows")]
            HostClipboard::Windows(w) => w.current_wire_mimes(),
        }
    }

    /// Install a client's offered formats as the host selection.
    pub fn set_offer(&self, wire_mimes: &[String]) -> anyhow::Result<()> {
        match self {
            #[cfg(target_os = "linux")]
            HostClipboard::DataControl(b) => b.set_offer(wire_mimes),
            #[cfg(target_os = "linux")]
            HostClipboard::Mutter(m) => {
                m.set_offer(wire_mimes);
                Ok(())
            }
            #[cfg(target_os = "windows")]
            HostClipboard::Windows(w) => {
                w.set_offer(wire_mimes);
                Ok(())
            }
        }
    }

    /// Drop the host selection we own.
    pub fn clear_offer(&self) -> anyhow::Result<()> {
        match self {
            #[cfg(target_os = "linux")]
            HostClipboard::DataControl(b) => b.clear_offer(),
            #[cfg(target_os = "linux")]
            HostClipboard::Mutter(m) => {
                m.clear_offer();
                Ok(())
            }
            #[cfg(target_os = "windows")]
            HostClipboard::Windows(w) => {
                w.clear_offer();
                Ok(())
            }
        }
    }

    /// Read one wire format of the current host selection (a client's fetch). Async: data-control
    /// blocks on a pipe (offloaded), Mutter round-trips D-Bus + reads a pipe, Windows reads the
    /// clipboard on a blocking thread.
    pub async fn read_current(self: &Arc<Self>, wire_mime: &str) -> anyhow::Result<Vec<u8>> {
        match &**self {
            #[cfg(target_os = "linux")]
            HostClipboard::DataControl(_) => {
                let me = Arc::clone(self);
                let wire = wire_mime.to_string();
                tokio::task::spawn_blocking(move || match &*me {
                    HostClipboard::DataControl(b) => b.read_current(&wire),
                    _ => unreachable!("variant checked above"),
                })
                .await
                .map_err(|e| anyhow::anyhow!("data-control read join: {e}"))?
            }
            #[cfg(target_os = "linux")]
            HostClipboard::Mutter(m) => m.read_current(wire_mime).await,
            #[cfg(target_os = "windows")]
            HostClipboard::Windows(w) => w.read_current(wire_mime).await,
        }
    }
}

// ---- Format normalization (design/clipboard-and-file-transfer.md §3.5) ------------------------
//
// One portable vocabulary crosses the wire; each end maps to platform types at fetch time. Phase 1
// covers text / RTF / HTML / PNG (files are Phase 2). The wire MIMEs match the core's table.

/// Wire MIME for UTF-8 plain text.
pub const WIRE_TEXT: &str = "text/plain;charset=utf-8";
/// Wire MIME for HTML.
pub const WIRE_HTML: &str = "text/html";
/// Wire MIME for rich text.
pub const WIRE_RTF: &str = "text/rtf";
/// Wire MIME for a PNG image.
pub const WIRE_PNG: &str = "image/png";
/// Wire MIME for a JPEG image — passed through VERBATIM when the source clipboard carries one
/// (no PNG transcode: a lossy original re-encoded lossless is pure bloat). [`WIRE_PNG`] remains
/// the universal fallback every peer must accept; JPEG/GIF are richer options beside it.
pub const WIRE_JPEG: &str = "image/jpeg";
/// Wire MIME for a GIF image — verbatim pass-through preserves animation end to end.
pub const WIRE_GIF: &str = "image/gif";

/// Map a Wayland selection MIME to its canonical wire MIME, or `None` to drop it (internal targets
/// like `TARGETS`/`TIMESTAMP`/`SAVE_TARGETS`, and formats we don't sync in Phase 1). Aliases
/// collapse onto one canonical wire name so the offered list dedups cleanly.
#[cfg(target_os = "linux")]
pub fn wayland_to_wire(wl: &str) -> Option<&'static str> {
    // Strip any parameter noise for the plain-text aliases (some apps send `text/plain;charset=...`
    // with odd charsets, or bare `text/plain`).
    let base = wl.split(';').next().unwrap_or(wl).trim();
    match wl {
        "text/html" => Some(WIRE_HTML),
        "text/rtf" | "application/rtf" | "text/richtext" => Some(WIRE_RTF),
        "image/png" => Some(WIRE_PNG),
        "image/jpeg" => Some(WIRE_JPEG),
        "image/gif" => Some(WIRE_GIF),
        _ => match base {
            "text/plain" | "UTF8_STRING" | "STRING" | "TEXT" => Some(WIRE_TEXT),
            _ => None,
        },
    }
}

/// The Wayland MIME candidates to request, in preference order, when a client fetches `wire` from
/// the host clipboard. The first one present in the current offer is used.
#[cfg(target_os = "linux")]
pub fn wayland_candidates(wire: &str) -> &'static [&'static str] {
    match wire {
        WIRE_TEXT => &[
            "text/plain;charset=utf-8",
            "text/plain",
            "UTF8_STRING",
            "STRING",
            "TEXT",
        ],
        WIRE_HTML => &["text/html"],
        WIRE_RTF => &["text/rtf", "application/rtf", "text/richtext"],
        WIRE_PNG => &["image/png"],
        WIRE_JPEG => &["image/jpeg"],
        WIRE_GIF => &["image/gif"],
        _ => &[],
    }
}

/// Pick the Wayland MIME to `receive()` for a wire fetch: the first [`wayland_candidates`] entry the
/// current selection actually advertises.
#[cfg(target_os = "linux")]
pub fn pick_wayland_mime(wire: &str, available: &[String]) -> Option<String> {
    wayland_candidates(wire)
        .iter()
        .find(|c| available.iter().any(|a| a == *c))
        .map(|c| c.to_string())
}

/// Normalize a raw Wayland offer's MIME list into the deduplicated wire MIME list announced to the
/// client (drops internal targets; collapses aliases; preserves a stable order).
#[cfg(target_os = "linux")]
pub fn offer_wire_mimes(raw: &[String]) -> Vec<&'static str> {
    let mut out: Vec<&'static str> = Vec::new();
    for m in raw {
        if let Some(wire) = wayland_to_wire(m) {
            if !out.contains(&wire) {
                out.push(wire);
            }
        }
    }
    out
}

/// Whether a non-canonical, client-supplied MIME is safe to hand to Wayland as a string argument.
///
/// Deliberately strict: printable ASCII only (so no NUL and no other control byte can reach the
/// `CString` in the generated encoder), bounded length, and it must actually look like a MIME type.
/// A real `type/subtype[;params]` passes; nothing that could crash or confuse the compositor does.
#[cfg(target_os = "linux")]
fn valid_passthrough_mime(m: &str) -> bool {
    let Some((ty, rest)) = m.split_once('/') else {
        return false;
    };
    !ty.is_empty()
        && !rest.is_empty()
        && m.len() <= 255
        // 0x21..=0x7E: printable ASCII without space. Excludes NUL, every other control byte, and
        // any non-ASCII byte.
        && m.bytes().all(|b| (0x21..=0x7E).contains(&b))
}

/// The Wayland MIMEs to advertise when installing a source for a client's offer. Each wire MIME
/// expands to its canonical Wayland name(s); a rich-text-only offer also advertises `text/plain`
/// so plain-text targets always paste (§3.5 synthesis — destination-side, one direction only).
#[cfg(target_os = "linux")]
pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    let mut push = |s: &str| {
        if !out.iter().any(|o| o == s) {
            out.push(s.to_string());
        }
    };
    let mut has_plain = false;
    let mut has_rich = false;
    for w in wire_mimes {
        match w.as_str() {
            WIRE_TEXT => {
                has_plain = true;
                push("text/plain;charset=utf-8");
                push("text/plain");
                push("UTF8_STRING");
                push("STRING");
            }
            WIRE_HTML => {
                has_rich = true;
                push("text/html");
            }
            WIRE_RTF => {
                has_rich = true;
                push("text/rtf");
            }
            WIRE_PNG => push("image/png"),
            WIRE_JPEG => push("image/jpeg"),
            WIRE_GIF => push("image/gif"),
            // A MIME we don't canonicalize is passed through verbatim — so it is the one value on
            // this path the CLIENT fully controls, and it ends up as a Wayland string argument.
            // The wayland-scanner-generated request encoder builds a `CString` and `unwrap()`s it,
            // so a single interior NUL turns one control message into a host clipboard panic
            // (2026-08-05 review L-8). `String::from_utf8_lossy` on the wire preserves `\0`, so
            // nothing upstream removes it. Validate here, at the boundary where the value stops
            // being ours and becomes libwayland's.
            other if valid_passthrough_mime(other) => push(other),
            other => {
                tracing::debug!(mime = %other.escape_debug(), "clipboard: dropping a malformed client MIME");
            }
        }
    }
    // Synthesis: rich text without plain text → also advertise plain (the source derives it lazily).
    if has_rich && !has_plain {
        push("text/plain;charset=utf-8");
        push("text/plain");
        push("UTF8_STRING");
        push("STRING");
    }
    out
}

#[cfg(all(test, target_os = "linux"))]
mod tests {
    use super::*;

    #[test]
    fn wayland_to_wire_canonicalizes_and_drops_targets() {
        assert_eq!(wayland_to_wire("text/plain"), Some(WIRE_TEXT));
        assert_eq!(wayland_to_wire("UTF8_STRING"), Some(WIRE_TEXT));
        assert_eq!(wayland_to_wire("text/plain;charset=utf-8"), Some(WIRE_TEXT));
        assert_eq!(wayland_to_wire("text/html"), Some(WIRE_HTML));
        assert_eq!(wayland_to_wire("application/rtf"), Some(WIRE_RTF));
        assert_eq!(wayland_to_wire("image/png"), Some(WIRE_PNG));
        // Original image formats now map to their own wire kinds (verbatim pass-through).
        assert_eq!(wayland_to_wire("image/jpeg"), Some(WIRE_JPEG));
        assert_eq!(wayland_to_wire("image/gif"), Some(WIRE_GIF));
        // Internal targets and unsupported formats are dropped.
        assert_eq!(wayland_to_wire("TARGETS"), None);
        assert_eq!(wayland_to_wire("TIMESTAMP"), None);
        assert_eq!(wayland_to_wire("image/webp"), None);
    }

    #[test]
    fn offer_wire_mimes_dedups_aliases() {
        let raw = vec![
            "TARGETS".to_string(),
            "UTF8_STRING".to_string(),
            "text/plain;charset=utf-8".to_string(),
            "text/plain".to_string(),
            "text/html".to_string(),
        ];
        // text aliases collapse to one WIRE_TEXT; TARGETS dropped; html kept.
        assert_eq!(offer_wire_mimes(&raw), vec![WIRE_TEXT, WIRE_HTML]);
    }

    /// One control message must not be able to panic the host clipboard coordinator
    /// (2026-08-05 review L-8). The passthrough branch is the only place a client string becomes a
    /// Wayland argument, and the generated encoder `unwrap()`s a `CString` built from it.
    #[test]
    fn passthrough_mimes_cannot_carry_a_nul_or_control_byte() {
        // The crash payload: an interior NUL survives `String::from_utf8_lossy` on the wire.
        assert!(!valid_passthrough_mime("image/webp\0"));
        assert!(!valid_passthrough_mime("\0"));
        assert!(!valid_passthrough_mime("image/\0webp"));
        // Other control bytes and whitespace are refused for the same reason.
        assert!(!valid_passthrough_mime("image/web\np"));
        assert!(!valid_passthrough_mime("image/web p"));
        assert!(!valid_passthrough_mime("image/web\tp"));
        // Shapes that are not a MIME type at all.
        assert!(!valid_passthrough_mime(""));
        assert!(!valid_passthrough_mime("noslash"));
        assert!(!valid_passthrough_mime("/nosubtype"));
        assert!(!valid_passthrough_mime("notype/"));
        assert!(!valid_passthrough_mime(&format!(
            "image/{}",
            "x".repeat(300)
        )));
        // Legitimate uncanonicalized MIMEs still pass through.
        assert!(valid_passthrough_mime("image/webp"));
        assert!(valid_passthrough_mime("application/x-custom+json"));
        assert!(valid_passthrough_mime("text/plain;charset=utf-8"));

        // End to end: the offer list is built without the malformed entry, and does not panic.
        let offers = wayland_offers_for(&["image/webp\0".to_string(), WIRE_PNG.to_string()]);
        assert_eq!(offers, vec!["image/png".to_string()]);
    }

    #[test]
    fn pick_wayland_mime_prefers_canonical() {
        let avail = vec!["text/plain".to_string(), "UTF8_STRING".to_string()];
        // Canonical charset form isn't present, so it falls to the next candidate.
        assert_eq!(
            pick_wayland_mime(WIRE_TEXT, &avail),
            Some("text/plain".to_string())
        );
        let avail2 = vec![
            "text/plain;charset=utf-8".to_string(),
            "text/plain".to_string(),
        ];
        assert_eq!(
            pick_wayland_mime(WIRE_TEXT, &avail2),
            Some("text/plain;charset=utf-8".to_string())
        );
        assert_eq!(pick_wayland_mime(WIRE_PNG, &avail2), None);
    }

    #[test]
    fn wayland_offers_synthesizes_plain_for_rich_only() {
        let offers = wayland_offers_for(&[WIRE_HTML.to_string()]);
        assert!(offers.iter().any(|m| m == "text/html"));
        assert!(
            offers.iter().any(|m| m == "text/plain;charset=utf-8"),
            "rich-only offer must synthesize plain text: {offers:?}"
        );
        // Plain already present → no duplicate synthesis, and text aliases included.
        let offers2 = wayland_offers_for(&[WIRE_TEXT.to_string()]);
        assert!(offers2.iter().any(|m| m == "UTF8_STRING"));
        assert_eq!(offers2.iter().filter(|m| *m == "text/plain").count(), 1);
    }
}
