// server/src/paste.rs
#![forbid(unsafe_code)]

use anyhow::{Context, Result, ensure};
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use std::{path::PathBuf, sync::Arc};
use tokio::sync::Mutex;

use crate::runtime_support;
use lesavka_common::hid::{append_char_reports, char_to_usage};
use lesavka_common::lesavka::PasteRequest;
use lesavka_common::paste::decode_shared_key;

/// Decrypt a `PasteRequest` sent by the desktop client.
///
/// Inputs: the protobuf request carrying the encrypted payload and nonce.
/// Outputs: the decoded UTF-8 clipboard text, or an error when validation or
/// decryption fails.
/// Why: the server must reject plaintext payloads so clipboard injection is
/// never silently downgraded to an unencrypted transport.
pub fn decrypt(req: &PasteRequest) -> Result<String> {
    if !req.encrypted {
        anyhow::bail!("paste request must be encrypted");
    }
    ensure!(req.nonce.len() == 12, "paste nonce must be 12 bytes");
    let key = load_key()?;
    let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
    let nonce = Nonce::from_slice(&req.nonce);
    let plaintext = cipher
        .decrypt(nonce, req.data.as_ref())
        .map_err(|e| anyhow::anyhow!("paste decrypt failed: {e}"))?;
    String::from_utf8(plaintext).context("paste plaintext not UTF-8")
}

/// Type clipboard text into the HID keyboard gadget.
///
/// Inputs: the HID keyboard file handle plus the plaintext that should be
/// injected into the remote machine.
/// Outputs: `Ok(())` after emitting key press/release reports for every
/// supported character up to the configured maximum.
/// Why: paste injection must rate-limit itself so slower hosts do not drop
/// HID reports under bursty clipboard loads.
pub async fn type_text(
    kb: &Arc<Mutex<Option<tokio::fs::File>>>,
    hid_path: &str,
    text: &str,
) -> Result<()> {
    let max = std::env::var("LESAVKA_PASTE_MAX")
        .ok()
        .and_then(|v| v.parse::<usize>().ok())
        .unwrap_or(4096);
    let delay_ms = std::env::var("LESAVKA_PASTE_DELAY_MS")
        .ok()
        .and_then(|v| v.parse::<u64>().ok())
        .unwrap_or(8);
    let delay = std::time::Duration::from_millis(delay_ms);

    let (unsupported_count, unsupported_preview) = unsupported_chars(text.chars().take(max));
    if unsupported_count > 0 {
        anyhow::bail!(
            "paste contains {unsupported_count} unsupported character(s): {unsupported_preview}"
        );
    }

    for c in text.chars().take(max) {
        let mut reports = Vec::with_capacity(4);
        if append_char_reports(&mut reports, c) {
            for report in reports {
                runtime_support::write_hid_report(kb, hid_path, &report).await?;
                if delay_ms > 0 {
                    tokio::time::sleep(delay).await;
                }
            }
        }
    }
    Ok(())
}

/// Summarize unsupported clipboard characters for explicit operator feedback.
fn unsupported_chars(chars: impl Iterator<Item = char>) -> (usize, String) {
    let mut count = 0usize;
    let mut preview = Vec::new();
    for ch in chars {
        if char_to_usage(ch).is_some() {
            continue;
        }
        count += 1;
        if preview.len() < 6 {
            preview.push(format!("{:?}", ch));
        }
    }
    if count == 0 {
        (0, String::new())
    } else {
        (count, preview.join(", "))
    }
}

fn load_key() -> Result<[u8; 32]> {
    decode_shared_key(&load_key_material()?)
}

fn load_key_material() -> Result<String> {
    if let Some(raw) = std::env::var("LESAVKA_PASTE_KEY")
        .ok()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
    {
        return Ok(raw);
    }

    let path = std::env::var("LESAVKA_PASTE_KEY_FILE")
        .ok()
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("/etc/lesavka/paste-key"));
    let raw = std::fs::read_to_string(&path)
        .with_context(|| format!("reading paste key file {}", path.display()))?;
    let trimmed = raw.trim().to_string();
    if trimmed.is_empty() {
        anyhow::bail!("paste key file {} is empty", path.display());
    }
    Ok(trimmed)
}

#[cfg(test)]
mod tests {
    use super::{decrypt, type_text, unsupported_chars};
    use chacha20poly1305::aead::{Aead, KeyInit};
    use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
    use lesavka_common::lesavka::PasteRequest;
    use serial_test::serial;
    use std::sync::Arc;
    use temp_env::with_var;
    use tempfile::tempdir;
    use tokio::fs::{File, OpenOptions};
    use tokio::io::AsyncReadExt;
    use tokio::runtime::Runtime;
    use tokio::sync::Mutex;

    #[test]
    #[serial]
    fn decrypt_rejects_plaintext_requests() {
        let req = PasteRequest {
            nonce: vec![],
            data: vec![],
            encrypted: false,
        };
        let err = decrypt(&req).expect_err("plaintext must fail");
        assert!(err.to_string().contains("encrypted"));
    }

    #[test]
    #[serial]
    fn decrypt_round_trips_encrypted_payload() {
        let key = "hex:00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
        with_var("LESAVKA_PASTE_KEY", Some(key), || {
            let raw_key = lesavka_common::paste::decode_shared_key(key).expect("decode key");
            let cipher = ChaCha20Poly1305::new(Key::from_slice(&raw_key));
            let nonce_bytes = [0x11u8; 12];
            let nonce = Nonce::from_slice(&nonce_bytes);
            let data = cipher
                .encrypt(nonce, b"secret paste".as_ref())
                .expect("encrypt");
            let req = PasteRequest {
                nonce: nonce_bytes.to_vec(),
                data,
                encrypted: true,
            };
            assert_eq!(decrypt(&req).expect("decrypt"), "secret paste");
        });
    }

    #[test]
    #[serial]
    fn type_text_writes_reports_for_supported_chars() {
        let rt = Runtime::new().expect("runtime");
        with_var("LESAVKA_PASTE_DELAY_MS", Some("0"), || {
            rt.block_on(async {
                let dir = tempdir().expect("tempdir");
                let path = dir.path().join("hidg0.bin");
                let file = OpenOptions::new()
                    .create(true)
                    .truncate(true)
                    .write(true)
                    .open(&path)
                    .await
                    .expect("open temp file");
                let kb = Arc::new(Mutex::new(Some(file)));
                type_text(&kb, path.to_str().unwrap(), "A!?")
                    .await
                    .expect("type text");

                let mut bytes = Vec::new();
                let mut file = File::open(&path).await.expect("reopen temp file");
                file.read_to_end(&mut bytes).await.expect("read reports");
                assert_eq!(bytes.len(), 96);
            });
        });
    }

    #[test]
    fn unsupported_chars_reports_count_and_preview() {
        let (count, preview) = unsupported_chars("ok🙂éx".chars());
        assert_eq!(count, 2);
        assert!(preview.contains("'🙂'"));
        assert!(preview.contains("'é'"));
    }

    #[test]
    #[serial]
    /// Reject unsupported clipboard characters instead of silently dropping them.
    fn type_text_rejects_unsupported_chars() {
        let rt = Runtime::new().expect("runtime");
        with_var("LESAVKA_PASTE_DELAY_MS", Some("0"), || {
            rt.block_on(async {
                let dir = tempdir().expect("tempdir");
                let path = dir.path().join("hidg0.bin");
                let file = OpenOptions::new()
                    .create(true)
                    .truncate(true)
                    .write(true)
                    .open(&path)
                    .await
                    .expect("open temp file");
                let kb = Arc::new(Mutex::new(Some(file)));
                let err = type_text(&kb, path.to_str().unwrap(), "pw🙂")
                    .await
                    .expect_err("unsupported char should fail");
                assert!(err.to_string().contains("unsupported character"));
            });
        });
    }

    #[test]
    #[serial]
    fn decrypt_loads_key_from_file() {
        let dir = tempdir().expect("tempdir");
        let path = dir.path().join("paste-key");
        let key = "hex:00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
        std::fs::write(&path, key).expect("write key file");
        with_var("LESAVKA_PASTE_KEY", None::<&str>, || {
            with_var("LESAVKA_PASTE_KEY_FILE", Some(path.as_os_str()), || {
                let raw_key = lesavka_common::paste::decode_shared_key(key).expect("decode key");
                let cipher = ChaCha20Poly1305::new(Key::from_slice(&raw_key));
                let nonce_bytes = [0x22u8; 12];
                let nonce = Nonce::from_slice(&nonce_bytes);
                let data = cipher
                    .encrypt(nonce, b"file backed secret".as_ref())
                    .expect("encrypt");
                let req = PasteRequest {
                    nonce: nonce_bytes.to_vec(),
                    data,
                    encrypted: true,
                };
                assert_eq!(decrypt(&req).expect("decrypt"), "file backed secret");
            });
        });
    }
}
