ff-rdp-cli 0.2.0

CLI for Firefox Remote Debugging Protocol
use ff_rdp_core::TabInfo;

use crate::error::AppError;
use crate::port_owner;

/// Resolve a `--tab` / `--tab-id` flag pair to a single [`TabInfo`] reference.
///
/// Resolution order:
/// 1. `tab_id` — exact match on `TabInfo::actor`.
/// 2. `tab` as a 1-based integer index into `tabs`.
/// 3. `tab` as a case-insensitive URL substring.
/// 4. Neither flag — first selected tab, falling back to `tabs[0]`.
///
/// Use [`resolve_tab_with_context`] when you can supply the connected host
/// and port — the resulting "no tabs" error will then surface the connected
/// PID and point users at `ff-rdp doctor`.
#[cfg(test)]
pub fn resolve_tab<'a>(
    tabs: &'a [TabInfo],
    tab: Option<&str>,
    tab_id: Option<&str>,
) -> Result<&'a TabInfo, AppError> {
    resolve_tab_with_context(tabs, tab, tab_id, "localhost", 0)
}

/// Like [`resolve_tab`] but the no-tabs error mentions the connected target.
///
/// When the listener is reachable but exposes zero tabs, the error reports
/// the connected PID, port uptime, and points at `ff-rdp doctor` so the
/// user can drill in. When the port is dark, the error suggests `launch`
/// — this is the "no Firefox connected" branch from the spec.
pub fn resolve_tab_with_context<'a>(
    tabs: &'a [TabInfo],
    tab: Option<&str>,
    tab_id: Option<&str>,
    host: &str,
    port: u16,
) -> Result<&'a TabInfo, AppError> {
    if let Some(id) = tab_id {
        return tabs.iter().find(|t| t.actor.as_ref() == id).ok_or_else(|| {
            AppError::User(format!(
                "no tab with actor ID '{id}'; use `ff-rdp tabs` to list available tabs.\n\
                 hint: run `ff-rdp doctor` if the actor ID was issued by a stale connection."
            ))
        });
    }

    if let Some(selector) = tab {
        // Try 1-based integer index first.
        if let Ok(n) = selector.parse::<usize>() {
            let count = tabs.len();
            return if count == 0 {
                Err(AppError::User(no_tabs_message(host, port)))
            } else if n == 0 || n > count {
                Err(AppError::User(format!(
                    "tab index {n} out of range (1–{count} tabs available); use `ff-rdp tabs` to list available tabs.\n\
                     hint: run `ff-rdp doctor` if the tab list looks wrong."
                )))
            } else {
                Ok(&tabs[n - 1])
            };
        }

        // Fall back to case-insensitive URL substring match.
        let lower = selector.to_lowercase();
        return tabs
            .iter()
            .find(|t| t.url.to_lowercase().contains(&lower))
            .ok_or_else(|| {
                AppError::User(format!(
                    "no tab matching URL pattern '{selector}'; use `ff-rdp tabs` to list available tabs.\n\
                     hint: run `ff-rdp doctor` if the tab list looks wrong."
                ))
            });
    }

    // No flag — prefer the selected tab, then the first tab.
    if tabs.is_empty() {
        return Err(AppError::User(no_tabs_message(host, port)));
    }

    Ok(tabs.iter().find(|t| t.selected).unwrap_or(&tabs[0]))
}

/// Compose the "no tabs" error, branching on the apparent root cause:
///
/// * Listener is dark → suggest `launch` and `doctor`.
/// * Listener is up but exposes zero tabs → surface the connected PID/uptime
///   and point at `--temp-profile` plus `doctor`.
fn no_tabs_message(host: &str, port: u16) -> String {
    if port == 0 {
        // Backwards-compatible default for callers that don't supply context.
        return "no tabs available — is a page open in Firefox? Use `ff-rdp launch --headless --temp-profile` to start one.\n\
                hint: run `ff-rdp doctor` for a full diagnostic."
            .to_owned();
    }

    // Local OS port probes only make sense for loopback hosts; on remote
    // targets, fall through to the generic "connected but empty" message.
    if !crate::connection_meta::is_loopback(host) {
        return format!(
            "no tabs available — connected {host}:{port} exposes 0 debuggable tabs. \
             Open a tab in Firefox or relaunch with `ff-rdp launch --temp-profile` for a clean session.\n\
             hint: run `ff-rdp doctor` for a full diagnostic."
        );
    }

    let in_use = port_owner::is_port_in_use(port);
    if !in_use {
        return format!(
            "no tabs available — nothing is listening on {host}:{port}. \
             Use `ff-rdp launch --headless --temp-profile` to start Firefox.\n\
             hint: run `ff-rdp doctor` for a full diagnostic."
        );
    }
    let owner = port_owner::find_listener(port).ok().flatten();
    let detail = match owner {
        Some(o) if !o.process_name.is_empty() => {
            let uptime = match o.uptime_s {
                Some(s) => format!(" (uptime {})", format_uptime_short(s)),
                None => String::new(),
            };
            format!(" ({} PID {}){}", o.process_name, o.pid, uptime)
        }
        Some(o) => format!(" (PID {})", o.pid),
        None => String::new(),
    };
    format!(
        "no tabs available — connected Firefox{detail} exposes 0 debuggable tabs. \
         Open a tab manually or relaunch with `ff-rdp launch --temp-profile` for a clean session.\n\
         hint: run `ff-rdp doctor` to see why this connection has no tabs."
    )
}

pub(crate) fn format_uptime_short(secs: u64) -> String {
    let days = secs / 86400;
    let hours = (secs % 86400) / 3600;
    let mins = (secs % 3600) / 60;
    if days > 0 {
        format!("{days}d{hours}h")
    } else if hours > 0 {
        format!("{hours}h{mins}m")
    } else {
        format!("{mins}m")
    }
}

#[cfg(test)]
mod tests {
    use ff_rdp_core::types::ActorId;

    use super::*;

    fn make_tab(actor: &str, url: &str, selected: bool) -> TabInfo {
        TabInfo {
            actor: ActorId::from(actor),
            title: String::new(),
            url: url.to_owned(),
            selected,
            browsing_context_id: None,
        }
    }

    fn tabs() -> Vec<TabInfo> {
        vec![
            make_tab("server1.conn0.tab1", "https://github.com/rust-lang", false),
            make_tab("server1.conn0.tab2", "https://crates.io", true),
            make_tab("server1.conn0.tab3", "https://docs.rs/tokio", false),
        ]
    }

    // --- --tab-id ---

    #[test]
    fn tab_id_exact_match() {
        let ts = tabs();
        let result = resolve_tab(&ts, None, Some("server1.conn0.tab2")).unwrap();
        assert_eq!(result.actor.as_ref(), "server1.conn0.tab2");
    }

    #[test]
    fn tab_id_not_found_returns_error() {
        let ts = tabs();
        let err = resolve_tab(&ts, None, Some("server1.conn0.tab99")).unwrap_err();
        match err {
            AppError::User(msg) => {
                assert!(msg.contains("server1.conn0.tab99"), "message: {msg}");
            }
            other => panic!("expected AppError::User, got: {other}"),
        }
    }

    // --- --tab as index ---

    #[test]
    fn tab_index_first() {
        let ts = tabs();
        let result = resolve_tab(&ts, Some("1"), None).unwrap();
        assert_eq!(result.actor.as_ref(), "server1.conn0.tab1");
    }

    #[test]
    fn tab_index_last() {
        let ts = tabs();
        let result = resolve_tab(&ts, Some("3"), None).unwrap();
        assert_eq!(result.actor.as_ref(), "server1.conn0.tab3");
    }

    #[test]
    fn tab_index_out_of_range() {
        let ts = tabs();
        let err = resolve_tab(&ts, Some("5"), None).unwrap_err();
        match err {
            AppError::User(msg) => {
                assert!(msg.contains('5'), "message: {msg}");
                assert!(msg.contains('3'), "message: {msg}");
            }
            other => panic!("expected AppError::User, got: {other}"),
        }
    }

    #[test]
    fn tab_index_zero_is_out_of_range() {
        let ts = tabs();
        let err = resolve_tab(&ts, Some("0"), None).unwrap_err();
        match err {
            AppError::User(msg) => assert!(msg.contains('0'), "message: {msg}"),
            other => panic!("expected AppError::User, got: {other}"),
        }
    }

    // --- --tab as URL substring ---

    #[test]
    fn tab_url_substring_match() {
        let ts = tabs();
        let result = resolve_tab(&ts, Some("crates"), None).unwrap();
        assert_eq!(result.actor.as_ref(), "server1.conn0.tab2");
    }

    #[test]
    fn tab_url_substring_case_insensitive() {
        let ts = tabs();
        let result = resolve_tab(&ts, Some("GitHub"), None).unwrap();
        assert_eq!(result.actor.as_ref(), "server1.conn0.tab1");
    }

    #[test]
    fn tab_url_substring_no_match_returns_error() {
        let ts = tabs();
        let err = resolve_tab(&ts, Some("wikipedia"), None).unwrap_err();
        match err {
            AppError::User(msg) => assert!(msg.contains("wikipedia"), "message: {msg}"),
            other => panic!("expected AppError::User, got: {other}"),
        }
    }

    // --- default (no flags) ---

    #[test]
    fn default_returns_selected_tab() {
        let ts = tabs();
        let result = resolve_tab(&ts, None, None).unwrap();
        // tab2 is selected
        assert_eq!(result.actor.as_ref(), "server1.conn0.tab2");
    }

    #[test]
    fn default_falls_back_to_first_when_none_selected() {
        let ts = vec![
            make_tab("server1.conn0.tab1", "https://example.com", false),
            make_tab("server1.conn0.tab2", "https://rust-lang.org", false),
        ];
        let result = resolve_tab(&ts, None, None).unwrap();
        assert_eq!(result.actor.as_ref(), "server1.conn0.tab1");
    }

    #[test]
    fn default_empty_tabs_returns_error() {
        let err = resolve_tab(&[], None, None).unwrap_err();
        match err {
            AppError::User(msg) => assert!(msg.contains("Firefox"), "message: {msg}"),
            other => panic!("expected AppError::User, got: {other}"),
        }
    }

    // --- tab_id takes precedence over tab ---

    #[test]
    fn tab_id_takes_precedence_over_tab() {
        let ts = tabs();
        // --tab 1 would give tab1, but --tab-id for tab3 should win
        let result = resolve_tab(&ts, Some("1"), Some("server1.conn0.tab3")).unwrap();
        assert_eq!(result.actor.as_ref(), "server1.conn0.tab3");
    }

    // --- format_uptime_short ---

    #[test]
    fn format_uptime_short_handles_minutes() {
        assert_eq!(format_uptime_short(120), "2m");
    }

    #[test]
    fn format_uptime_short_handles_hours() {
        assert_eq!(format_uptime_short(3700), "1h1m");
    }

    #[test]
    fn format_uptime_short_handles_days() {
        assert_eq!(format_uptime_short(90_000), "1d1h");
    }
}