use anyhow::{Result, anyhow};
use lesavka_common::{hid::append_char_reports, lesavka::KeyboardReport};
use std::time::Duration;

#[cfg(not(coverage))]
use {
    crate::paste, crate::relay_transport, async_stream::stream,
    lesavka_common::lesavka::relay_client::RelayClient, tokio::runtime::Builder as RuntimeBuilder,
    tonic::Request,
};

#[cfg(not(coverage))]
/// Deliver already-captured clipboard text to the remote side, preferring the
/// encrypted paste RPC and falling back to direct HID keyboard reports.
pub fn send_clipboard_text_to_remote(server_addr: &str, text: &str) -> Result<String> {
    match send_clipboard_via_rpc(server_addr, text) {
        Ok(()) => Ok("Clipboard delivered to remote".to_string()),
        Err(rpc_err) => match send_clipboard_via_hid(server_addr, text) {
            Ok(()) => Ok(format!("Clipboard delivered via HID fallback ({rpc_err})")),
            Err(hid_err) => Err(anyhow!(
                "rpc failed: {rpc_err}; hid fallback failed: {hid_err}"
            )),
        },
    }
}

#[cfg(not(coverage))]
/// Use the shared-key paste RPC when both the launcher and relay host are
/// configured for encrypted clipboard delivery.
fn send_clipboard_via_rpc(server_addr: &str, text: &str) -> Result<()> {
    let req = paste::build_paste_request(text)?;
    let timeout = clipboard_transport_timeout();
    let rt = RuntimeBuilder::new_current_thread().enable_all().build()?;
    rt.block_on(async {
        let channel = tokio::time::timeout(timeout, relay_transport::connect(server_addr))
            .await
            .map_err(|_| anyhow!("timed out connecting paste RPC after {:?}", timeout))??;
        let mut cli = RelayClient::new(channel);
        let reply = tokio::time::timeout(timeout, cli.paste_text(Request::new(req)))
            .await
            .map_err(|_| anyhow!("timed out waiting for paste RPC reply after {:?}", timeout))??;
        if reply.get_ref().ok {
            Ok(())
        } else {
            Err(anyhow!("server rejected paste: {}", reply.get_ref().error))
        }
    })
}

#[cfg(not(coverage))]
/// Fall back to keyboard HID reports so launcher-driven paste still works
/// against relay hosts that have not been given `LESAVKA_PASTE_KEY`.
fn send_clipboard_via_hid(server_addr: &str, text: &str) -> Result<()> {
    let reports = build_hid_paste_reports(text)?;
    let delay = clipboard_hid_delay();
    let report_count = reports.len();
    let timeout = clipboard_transport_timeout();
    let ack_timeout = clipboard_hid_ack_timeout(report_count, delay);
    let rt = RuntimeBuilder::new_current_thread().enable_all().build()?;
    rt.block_on(async {
        let channel = tokio::time::timeout(timeout, relay_transport::connect(server_addr))
            .await
            .map_err(|_| anyhow!("timed out connecting keyboard fallback stream after {:?}", timeout))??;
        let mut cli = RelayClient::new(channel);
        let outbound = stream! {
            for report in reports {
                yield report;
                if !delay.is_zero() {
                    tokio::time::sleep(delay).await;
                }
            }
        };
        let mut resp = tokio::time::timeout(timeout, cli.stream_keyboard(Request::new(outbound)))
            .await
            .map_err(|_| anyhow!("timed out opening keyboard fallback stream after {:?}", timeout))??;
        let mut echoed = 0usize;
        let deadline = tokio::time::Instant::now() + ack_timeout;
        while echoed < report_count {
            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
            if remaining.is_zero() {
                anyhow::bail!(
                    "timed out waiting for keyboard fallback acknowledgement after {echoed}/{report_count} reports"
                );
            }
            match tokio::time::timeout(remaining, resp.get_mut().message()).await {
                Ok(Ok(Some(item))) => {
                    let _ = item;
                    echoed += 1;
                }
                Ok(Ok(None)) => break,
                Ok(Err(err)) => return Err(err.into()),
                Err(_) => {
                    anyhow::bail!(
                        "timed out waiting for keyboard fallback acknowledgement after {echoed}/{report_count} reports"
                    );
                }
            }
        }
        Ok(())
    })
}

/// Convert clipboard text into press/release HID reports that match the
/// server-side keyboard gadget mapping.
fn build_hid_paste_reports(text: &str) -> Result<Vec<KeyboardReport>> {
    let mut raw_reports = Vec::with_capacity(text.len() * 4);
    let mut unsupported = 0usize;
    for ch in text.chars() {
        if !append_char_reports(&mut raw_reports, ch) {
            unsupported += 1;
        }
    }
    if unsupported > 0 {
        return Err(anyhow!(
            "clipboard contains {unsupported} unsupported character(s) for HID fallback"
        ));
    }
    Ok(raw_reports
        .into_iter()
        .map(|data| KeyboardReport {
            data: data.to_vec(),
        })
        .collect())
}

/// Keep launcher-driven HID paste slightly slower than live typing so login
/// prompts have more time to digest each character.
fn clipboard_hid_delay() -> Duration {
    let delay_ms = std::env::var("LESAVKA_CLIPBOARD_DELAY_MS")
        .ok()
        .and_then(|value| value.parse::<u64>().ok())
        .unwrap_or(18);
    Duration::from_millis(delay_ms)
}

fn clipboard_hid_ack_timeout(report_count: usize, delay: Duration) -> Duration {
    let base = clipboard_transport_timeout();
    let per_report_ms = delay.as_millis().saturating_mul(report_count as u128);
    let total_ms = base
        .as_millis()
        .saturating_add(per_report_ms)
        .saturating_add(1_000);
    Duration::from_millis(total_ms.min(u64::MAX as u128) as u64)
}

fn clipboard_transport_timeout() -> Duration {
    let timeout_ms = std::env::var("LESAVKA_CLIPBOARD_TIMEOUT_MS")
        .ok()
        .and_then(|value| value.parse::<u64>().ok())
        .unwrap_or(6_000);
    Duration::from_millis(timeout_ms)
}

#[cfg(test)]
mod tests {
    use super::{
        build_hid_paste_reports, clipboard_hid_ack_timeout, clipboard_hid_delay,
        clipboard_transport_timeout,
    };
    use std::time::Duration;

    #[test]
    fn build_hid_paste_reports_emits_press_and_release_pairs() {
        let reports = build_hid_paste_reports("Az").expect("hid reports");
        assert_eq!(reports.len(), 6);
        assert_eq!(reports[0].data.len(), 8);
        assert_eq!(reports[0].data, vec![0x02, 0, 0, 0, 0, 0, 0, 0]);
        assert_eq!(reports[3].data, vec![0; 8]);
        assert_eq!(reports[5].data, vec![0; 8]);
    }

    #[test]
    fn build_hid_paste_reports_rejects_unsupported_chars() {
        let err = build_hid_paste_reports("snowman \u{2603}").expect_err("unsupported");
        assert!(format!("{err:#}").contains("unsupported character"));
    }

    #[test]
    fn clipboard_hid_delay_has_stable_default() {
        assert_eq!(clipboard_hid_delay(), Duration::from_millis(18));
    }

    #[test]
    fn clipboard_transport_timeout_has_stable_default() {
        assert_eq!(clipboard_transport_timeout(), Duration::from_millis(6_000));
    }

    #[test]
    fn clipboard_hid_ack_timeout_scales_with_report_count() {
        assert_eq!(
            clipboard_hid_ack_timeout(1_962, Duration::from_millis(18)),
            Duration::from_millis(42_316)
        );
    }
}
