//! The menu bar item.
//!
//! Its point is to act on what a browser is showing without leaving it: while a
//! video plays in Safari or Chrome, one click saves it — and one reads the
//! whole subscription list off youtube.com/feed/channels — with playback
//! carrying on undisturbed.
//!
//! Reading the browser's address needs Apple events, which macOS gates behind a
//! permission the user grants once. Without it the menu still opens and says
//! plainly what is missing.

use crate::commands::{self, AppState};
use crate::resolve;
use crate::subscriptions;
use std::io::Write;
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
use tauri_plugin_notification::NotificationExt;

/// The panel window's label.
pub const PANEL: &str = "tray";

/// A running account of what the menu bar did, next to the database.
///
/// The menu bar acts with no window open and its answers arrive as
/// notifications, which macOS can quietly withhold. When someone reports that
/// nothing happened, this is the difference between guessing and knowing.
fn log(app: &AppHandle, line: &str) {
    let Ok(dir) = app.path().app_data_dir() else { return };
    let _ = std::fs::create_dir_all(&dir);
    if let Ok(mut f) = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(dir.join("tray.log"))
    {
        let secs = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        let _ = writeln!(f, "{secs} {line}");
    }
}

/// The tray icon itself, held for as long as the app runs. Dropping it takes
/// the icon out of the menu bar.
pub struct Tray(#[allow(dead_code)] pub tauri::tray::TrayIcon<tauri::Wry>);

/// Browsers worth asking, in the order they are asked. All but Safari answer
/// the same Chromium-flavoured AppleScript.
const CHROMIUM: [&str; 6] = [
    "Arc",
    "Google Chrome",
    "Brave Browser",
    "Microsoft Edge",
    "Vivaldi",
    "Chromium",
];

/// One browser's script. Chromium-derived browsers share a dictionary; Safari
/// speaks of documents rather than tabs.
fn url_script(browser: &str, safari_style: bool) -> String {
    let getter = if safari_style {
        "URL of front document"
    } else {
        "URL of active tab of front window"
    };
    format!(
        r#"try
    if running of application "{browser}" then
        tell application "{browser}" to return {getter}
    end if
end try
return ""
"#
    )
}

/// The YouTube address showing in whichever browser has one.
///
/// Each browser gets its own script, and that is the point rather than a
/// tidiness choice. AppleScript resolves an application's terminology when it
/// compiles, so naming a browser that is not installed is a *compile* error —
/// which no `try` can catch, and which kills the whole script. One script
/// covering six browsers therefore failed outright on a Mac missing any one of
/// them, and the first, working browser was never asked.
///
/// `running of application` needs no dictionary, so it answers even for a
/// browser that is not installed, and launches nothing.
async fn browser_youtube_url(app: &AppHandle) -> Result<String, String> {
    let mut refused = false;

    for (browser, safari_style) in CHROMIUM
        .iter()
        .map(|b| (*b, false))
        .chain(std::iter::once(("Safari", true)))
    {
        let Ok(out) = tokio::process::Command::new("/usr/bin/osascript")
            .arg("-e")
            .arg(url_script(browser, safari_style))
            .output()
            .await
        else {
            continue;
        };

        let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
        if !stdout.is_empty() || !stderr.is_empty() {
            log(app, &format!("{browser}: out={stdout:?} err={stderr:?}"));
        }
        // -1743 is macOS refusing the Apple event for want of permission.
        if stderr.contains("-1743") || stderr.contains("Not authorized") {
            refused = true;
        }
        if resolve::is_youtube_url(&stdout) {
            return Ok(stdout);
        }
    }

    if refused {
        return Err("FlightTube needs permission to read your browser's address. \
                    Allow it under System Settings › Privacy & Security › Automation."
            .into());
    }
    Err("No YouTube page open in a browser.".into())
}

/// Runs JavaScript in a browser's active tab.
///
/// Arc allows this out of the box. Chrome and its relatives ship with it off,
/// and say so in the error, which is passed straight back rather than being
/// flattened into "something went wrong".
pub async fn run_js(browser: &str, js: &str) -> Result<String, String> {
    let safari = browser == "Safari";
    let script = if safari {
        format!("tell application \"Safari\" to return (do JavaScript {} in front document)", quote(js))
    } else {
        format!(
            "tell application {} to return (execute front window's active tab javascript {})",
            quote(browser),
            quote(js)
        )
    };
    let out = tokio::process::Command::new("/usr/bin/osascript")
        .arg("-e")
        .arg(script)
        .output()
        .await
        .map_err(|e| format!("Could not reach {browser}: {e}"))?;

    let stderr = String::from_utf8_lossy(&out.stderr);
    if stderr.contains("Executing JavaScript through AppleScript is turned off") {
        return Err(format!(
            "{browser} will not run JavaScript for another app. Turn it on in {browser} under \
             View › Developer › Allow JavaScript from Apple Events, then try again. Arc allows \
             it already."
        ));
    }
    if !out.status.success() {
        return Err(format!("{browser} refused: {}", stderr.trim()));
    }
    Ok(applescript_unquote(&String::from_utf8_lossy(&out.stdout)))
}

/// AppleScript string literal.
fn quote(s: &str) -> String {
    applescript_string(s)
}

/// The browser showing the subscriptions page, if one is.
async fn browser_on_subscriptions(app: &AppHandle) -> Result<String, String> {
    for (browser, safari_style) in CHROMIUM
        .iter()
        .map(|b| (*b, false))
        .chain(std::iter::once(("Safari", true)))
    {
        let Ok(out) = tokio::process::Command::new("/usr/bin/osascript")
            .arg("-e")
            .arg(url_script(browser, safari_style))
            .output()
            .await
        else {
            continue;
        };
        let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
        if subscriptions::is_subscriptions_page(&url) {
            log(app, &format!("subscriptions page open in {browser}"));
            return Ok(browser.to_string());
        }
    }
    Err("Open https://www.youtube.com/feed/channels in your browser first, \
         then try again."
        .into())
}

/// Scrolls the subscriptions page to its end and reads the list off it.
///
/// The list loads a batch at a time, so this scrolls until the count stops
/// growing rather than trusting one read — a first look sees about half.
pub async fn scrape_subscriptions(app: &AppHandle) -> Result<subscriptions::Scraped, String> {
    let browser = browser_on_subscriptions(app).await?;

    // Bring it to the front first. Chromium browsers discard a background
    // tab's DOM to save memory, so the page keeps reporting its address while
    // having nothing on it — which reads exactly like an empty subscription
    // list rather than like a sleeping tab.
    let _ = tokio::process::Command::new("/usr/bin/osascript")
        .arg("-e")
        .arg(format!("tell application {} to activate", quote(&browser)))
        .output()
        .await;
    tokio::time::sleep(std::time::Duration::from_millis(1200)).await;

    let mut last = 0usize;
    let mut settled = 0;
    for round in 0..40 {
        let n = run_js(&browser, subscriptions::SCROLL_JS)
            .await?
            .parse::<usize>()
            .unwrap_or(0);
        // Nothing there yet only means the tab is still waking up.
        if n == 0 && round < 8 {
            tokio::time::sleep(std::time::Duration::from_millis(700)).await;
            continue;
        }
        if n == last {
            settled += 1;
            if settled >= 3 {
                break;
            }
        } else {
            settled = 0;
        }
        last = n;
        tokio::time::sleep(std::time::Duration::from_millis(900)).await;
    }

    let raw = run_js(&browser, subscriptions::EXTRACT_JS).await?;
    let rows = subscriptions::parse_rows(&raw);
    log(app, &format!("scraped {} channels from {browser}", rows.len()));
    if rows.is_empty() {
        return Err("That page has no channels on it. Is it the subscriptions page, \
                    and are you signed in?"
            .into());
    }
    Ok(rows)
}

/// The answer, delivered without the window coming forward.
///
/// Two ways, because either can fail silently: a notification from the app
/// itself, and the same through AppleScript if the plugin is unavailable.
/// Both are also written to the log beside the database.
async fn notify(app: &AppHandle, text: &str) {
    log(app, text);

    if app
        .notification()
        .builder()
        .title("FlightTube")
        .body(text)
        .show()
        .is_ok()
    {
        return;
    }
    let script = format!(
        "display notification {} with title \"FlightTube\"",
        applescript_string(text)
    );
    let _ = tokio::process::Command::new("/usr/bin/osascript")
        .arg("-e")
        .arg(script)
        .output()
        .await;
}

/// Undoes how osascript prints a string result.
///
/// It prints in source form — wrapped in quotes with the newlines and tabs
/// escaped — so a list of rows arrives as one line that looks nothing like
/// rows. Numbers and bare words come back untouched and pass straight through.
fn applescript_unquote(out: &str) -> String {
    let out = out.trim();
    let Some(inner) = out.strip_prefix('"').and_then(|s| s.strip_suffix('"')) else {
        return out.to_string();
    };
    let mut s = String::with_capacity(inner.len());
    let mut chars = inner.chars();
    while let Some(c) = chars.next() {
        if c != '\\' {
            s.push(c);
            continue;
        }
        match chars.next() {
            Some('n') => s.push('\n'),
            Some('t') => s.push('\t'),
            Some('r') => s.push('\r'),
            Some(other) => s.push(other),
            None => break,
        }
    }
    s
}

/// Quotes a string for AppleScript. Backslashes first, or the escaping of the
/// quotes gets undone.
fn applescript_string(s: &str) -> String {
    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}

#[tauri::command]
pub async fn tray_save_video(app: AppHandle) {
    let app = &app;
    log(app, "menu: save video");
    let url = match browser_youtube_url(app).await {
        Ok(u) => u,
        Err(e) => return notify(app, &e).await,
    };
    if resolve::video_id_from_url(&url).is_none() {
        return notify(app, "That page is not a video.").await;
    }

    let state = app.state::<AppState>();
    let (video_id, title) = match commands::save_video(&state, &url).await {
        Ok(v) => v,
        Err(e) => return notify(app, &e).await,
    };
    let (quality, sub_lang) = state.download_defaults.lock().await.clone();
    let _ = app.emit("feed:changed", ());
    notify(app, &format!("Downloading {title}")).await;

    // The download outlives this handler; its progress shows in the window.
    let handle = app.clone();
    tauri::async_runtime::spawn(async move {
        let state = handle.state::<AppState>();
        match commands::download_video(video_id, quality, sub_lang, handle.clone(), state).await {
            Ok(()) => notify(&handle, &format!("Saved {title}")).await,
            Err(e) => notify(&handle, &format!("{title} failed: {e}")).await,
        }
    });
}

/// Brings the window forward, and closes the panel behind it.
#[tauri::command]
pub fn show_main_window(app: AppHandle) {
    if let Some(p) = app.get_webview_window(PANEL) {
        let _ = p.hide();
    }
    if let Some(w) = app.get_webview_window("main") {
        let _ = w.show();
        let _ = w.unminimize();
        let _ = w.set_focus();
    }
}

/// Closes the panel without doing anything else.
#[tauri::command]
pub fn hide_panel(app: AppHandle) {
    if let Some(p) = app.get_webview_window(PANEL) {
        let _ = p.hide();
    }
}

#[tauri::command]
pub fn quit_app(app: AppHandle) {
    app.exit(0);
}

/// The panel's own size. Fixed, because it is a menu: it does not resize.
const PANEL_W: f64 = 304.0;
const PANEL_H: f64 = 252.0;

/// Opens the panel under the menu bar icon.
///
/// A native menu would look like every other menu bar item; this is the app's
/// own window, styled like the rest of it, positioned to hang from the icon.
fn show_panel(app: &AppHandle, icon: tauri::Rect) {
    let Some(win) = app.get_webview_window(PANEL) else { return };
    if win.is_visible().unwrap_or(false) {
        let _ = win.hide();
        return;
    }

    // The icon's rect is in physical pixels; the window is placed in the same
    // space, centred under the icon and just below the menu bar.
    if let (tauri::Position::Physical(pos), tauri::Size::Physical(size)) =
        (icon.position, icon.size)
    {
        let scale = win.scale_factor().unwrap_or(1.0);
        let w = PANEL_W * scale;
        let x = pos.x as f64 + size.width as f64 / 2.0 - w / 2.0;
        let y = pos.y as f64 + size.height as f64 + 6.0 * scale;
        let _ = win.set_position(tauri::PhysicalPosition::new(x.max(8.0), y));
    }

    let _ = win.show();
    let _ = win.set_focus();
}

pub fn build(app: &AppHandle) -> tauri::Result<()> {
    // Hidden until the icon is clicked. Undecorated and transparent so the
    // page can draw its own rounded, shadowed card.
    let panel = WebviewWindowBuilder::new(app, PANEL, WebviewUrl::App("index.html#tray".into()))
        .title("FlightTube")
        .inner_size(PANEL_W, PANEL_H)
        .decorations(false)
        .transparent(true)
        .resizable(false)
        .always_on_top(true)
        .skip_taskbar(true)
        .visible(false)
        .focused(false)
        .build()?;

    // Clicking away closes it, as a menu does.
    {
        let handle = panel.clone();
        panel.on_window_event(move |e| {
            if let tauri::WindowEvent::Focused(false) = e {
                let _ = handle.hide();
            }
        });
    }

    let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?;

    let tray = TrayIconBuilder::with_id("flighttube")
        .icon(icon)
        // A template image takes the menu bar's own colour, light or dark.
        .icon_as_template(true)
        .tooltip("FlightTube")
        // No menu at all: the click opens the panel instead.
        .show_menu_on_left_click(false)
        .on_tray_icon_event(|tray, event| {
            if let TrayIconEvent::Click { button: MouseButton::Left, button_state, rect, .. } = event
            {
                if button_state == MouseButtonState::Up {
                    show_panel(tray.app_handle(), rect);
                }
            }
        })
        .build(app)?;

    // TrayIcon is reference-counted and "the icon is removed when the last
    // instance is dropped", so letting the handle fall out of scope here would
    // create the item and destroy it in the same breath.
    app.manage(Tray(tray));
    Ok(())
}

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

    #[test]
    fn each_browser_gets_its_own_script() {
        let arc = url_script("Arc", false);
        // Naming a browser that is not installed is a compile error, so a
        // script must never mention more than the one it is asking.
        for other in CHROMIUM.iter().filter(|b| **b != "Arc") {
            assert!(!arc.contains(*other), "Arc's script mentions {other}");
        }
        assert!(!arc.contains("Safari"));
        // Behind a running check, so asking cannot start a browser, and inside
        // a try, so a browser with no window is not an error.
        assert!(arc.contains(r#"running of application "Arc""#));
        assert!(arc.contains("end try"));
        // Nothing needs System Events, which is a separate permission.
        assert!(!arc.contains("System Events"));
    }

    #[test]
    fn safari_is_asked_in_its_own_dialect() {
        assert!(url_script("Safari", true).contains("URL of front document"));
        assert!(url_script("Arc", false).contains("URL of active tab of front window"));
    }

    #[test]
    fn a_printed_string_result_is_unwrapped_into_real_rows() {
        // What osascript actually prints for a multi-line string result: one
        // quoted line with the newlines and tabs escaped.
        let printed = r#""/@a\tA\n/@b\tB""#;
        assert_eq!(applescript_unquote(printed), "/@a\tA\n/@b\tB");
        assert_eq!(applescript_unquote(printed).lines().count(), 2);
    }

    #[test]
    fn a_number_result_passes_through_untouched() {
        assert_eq!(applescript_unquote("197\n"), "197");
        assert_eq!(applescript_unquote(""), "");
    }

    #[test]
    fn notification_text_survives_quotes_and_backslashes() {
        assert_eq!(applescript_string(r#"a "b" c"#), r#""a \"b\" c""#);
        assert_eq!(applescript_string(r"back\slash"), r#""back\\slash""#);
    }
}
