// YT Player — Tauri (Windows / WebView2) backend.
//
// Mirrors the zero-native Zig bridge, but as Tauri commands. The same frontend
// in ../frontend calls these via window.__TAURI__.core.invoke(...). Returns the
// exact JSON shapes the UI expects:
//   yt_search  { query }            -> { ok, results:[…] }
//   yt_channel { channel }          -> { ok, channel, channelUrl, results:[…] }
//   yt_streams { videoId }          -> { ok, data:{ meta, audioUrl, qualities[] } }
//   store_load {}                   -> playlists / history / settings
//   store_save { data }             -> { ok }
//
// Unlike the size-limited zero-native bridge, Tauri's IPC has no fixed buffer,
// but we still return only the slim fields the UI needs.
//
// Threading: the yt-dlp calls and the offline downloads are blocking and can
// take seconds. They are dispatched onto a background thread pool with
// `tauri::async_runtime::spawn_blocking` so the WebView/main thread stays
// responsive and multiple downloads can run concurrently.

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use serde_json::{json, Value};
use std::collections::HashSet;
use std::path::PathBuf;
use std::process::Command;
use tauri::Manager;

const SEARCH_LIMIT: u32 = 25;
const CHANNEL_LIMIT: u32 = 60;

// yt-dlp.exe is baked straight into this binary at compile time, so the standalone
// `ytplayer.exe` is fully self-contained — no sibling file and nothing on PATH
// required. On first run we materialize it into the app cache dir and run from there.
// (Windows only; other platforms fall back to a resource/dev/PATH copy.)
// Disable with --no-default-features for faster debug builds.
#[cfg(all(windows, feature = "embed-ytdlp"))]
static YTDLP_EMBEDDED: &[u8] = include_bytes!("../../bin/yt-dlp.exe");

/// Write the embedded yt-dlp to the cache dir if it isn't already there (size-checked),
/// and return its path.
#[cfg(all(windows, feature = "embed-ytdlp"))]
fn embedded_ytdlp(app: &tauri::AppHandle) -> Option<PathBuf> {
    let dir = app.path().app_cache_dir().ok()?;
    std::fs::create_dir_all(&dir).ok()?;
    let dest = dir.join("yt-dlp.exe");
    let fresh = std::fs::metadata(&dest)
        .map(|m| m.len() == YTDLP_EMBEDDED.len() as u64)
        .unwrap_or(false);
    if !fresh {
        std::fs::write(&dest, YTDLP_EMBEDDED).ok()?;
    }
    Some(dest)
}

/// Locate yt-dlp: prefer a bundled resource copy, then a dev ./bin, then the
/// embedded self-contained copy (Windows), then finally PATH.
fn ytdlp_path(app: &tauri::AppHandle) -> PathBuf {
    let name = if cfg!(windows) { "yt-dlp.exe" } else { "yt-dlp" };
    if let Ok(res) = app.path().resource_dir() {
        let p = res.join("bin").join(name);
        if p.exists() {
            return p;
        }
    }
    let dev = PathBuf::from("bin").join(name);
    if dev.exists() {
        return dev;
    }
    #[cfg(all(windows, feature = "embed-ytdlp"))]
    {
        if let Some(p) = embedded_ytdlp(app) {
            return p;
        }
    }
    PathBuf::from(name)
}

fn run_ytdlp(app: &tauri::AppHandle, args: &[&str]) -> Result<String, String> {
    let exe = ytdlp_path(app);
    let mut cmd = Command::new(&exe);
    cmd.args(args);

    // Don't flash a console window when spawning yt-dlp on Windows.
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
        cmd.creation_flags(CREATE_NO_WINDOW);
    }

    let output = cmd
        .output()
        .map_err(|e| format!("failed to launch yt-dlp ({}): {e}", exe.display()))?;

    if !output.status.success() {
        let err = String::from_utf8_lossy(&output.stderr);
        return Err(if err.trim().is_empty() {
            "yt-dlp failed".to_string()
        } else {
            err.trim().to_string()
        });
    }
    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

fn str_or<'a>(v: &'a Value, key: &str, fallback: &'a str) -> &'a str {
    v.get(key).and_then(|x| x.as_str()).unwrap_or(fallback)
}

/// Pick the channel display name from a yt-dlp record (channel, then uploader).
fn pick_channel(v: &Value) -> &str {
    let c = str_or(v, "channel", "");
    if c.is_empty() {
        str_or(v, "uploader", "")
    } else {
        c
    }
}

/// Pick a usable channel URL (channel_url, then uploader_url).
fn pick_channel_url(v: &Value) -> &str {
    let u = str_or(v, "channel_url", "");
    if u.is_empty() {
        str_or(v, "uploader_url", "")
    } else {
        u
    }
}

/// Pick a channel id (channel_id, then uploader_id).
fn pick_channel_id(v: &Value) -> &str {
    let id = str_or(v, "channel_id", "");
    if id.is_empty() {
        str_or(v, "uploader_id", "")
    } else {
        id
    }
}

/// Turn one yt-dlp flat-playlist record into the slim card the UI expects.
fn slim_entry(j: &Value) -> Option<Value> {
    let id = str_or(j, "id", "");
    if id.is_empty() {
        return None;
    }
    Some(json!({
        "id": id,
        "title": str_or(j, "title", "(untitled)"),
        "channel": pick_channel(j),
        "channelId": pick_channel_id(j),
        "channelUrl": pick_channel_url(j),
        "duration": j.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0),
        "thumbnail": format!("https://i.ytimg.com/vi/{}/mqdefault.jpg", id),
    }))
}

// ============================================================================
// Search
// ============================================================================
#[tauri::command]
async fn yt_search(app: tauri::AppHandle, query: String) -> Value {
    let q = query.trim().to_string();
    if q.is_empty() {
        return json!({ "ok": false, "error": "empty query" });
    }
    tauri::async_runtime::spawn_blocking(move || yt_search_blocking(&app, &q))
        .await
        .unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() }))
}

fn yt_search_blocking(app: &tauri::AppHandle, q: &str) -> Value {
    let search = format!("ytsearch{}:{}", SEARCH_LIMIT, q);
    let out = match run_ytdlp(
        app,
        &[
            &search,
            "--dump-json",
            "--flat-playlist",
            "--no-warnings",
            "--ignore-errors",
        ],
    ) {
        Ok(o) => o,
        Err(e) => return json!({ "ok": false, "error": e }),
    };

    let mut results = Vec::new();
    for line in out.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let j: Value = match serde_json::from_str(line) {
            Ok(v) => v,
            Err(_) => continue,
        };
        if let Some(card) = slim_entry(&j) {
            results.push(card);
        }
    }

    json!({ "ok": true, "results": results })
}

// ============================================================================
// Channel — list a channel's recent uploads
// ============================================================================
#[tauri::command]
async fn yt_channel(app: tauri::AppHandle, channel: String) -> Value {
    let c = channel.trim().to_string();
    if c.is_empty() {
        return json!({ "ok": false, "error": "missing channel" });
    }
    tauri::async_runtime::spawn_blocking(move || yt_channel_blocking(&app, &c))
        .await
        .unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() }))
}

fn yt_channel_blocking(app: &tauri::AppHandle, channel: &str) -> Value {
    // Accept either a full channel/uploader URL or a bare channel id. Always
    // resolve to the "/videos" tab so we list uploads, not the channel home.
    let base = if channel.starts_with("http") {
        channel.trim_end_matches('/').to_string()
    } else if channel.starts_with('@') {
        format!("https://www.youtube.com/{}", channel)
    } else if channel.starts_with("UC") {
        format!("https://www.youtube.com/channel/{}", channel)
    } else {
        format!("https://www.youtube.com/@{}", channel)
    };
    let url = if base.ends_with("/videos") {
        base
    } else {
        format!("{}/videos", base)
    };

    let end = CHANNEL_LIMIT.to_string();
    let out = match run_ytdlp(
        app,
        &[
            &url,
            "--dump-json",
            "--flat-playlist",
            "--no-warnings",
            "--ignore-errors",
            "--playlist-end",
            &end,
        ],
    ) {
        Ok(o) => o,
        Err(e) => return json!({ "ok": false, "error": e }),
    };

    let mut results = Vec::new();
    let mut name = String::new();
    let mut chan_url = String::new();
    for line in out.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let j: Value = match serde_json::from_str(line) {
            Ok(v) => v,
            Err(_) => continue,
        };
        if name.is_empty() {
            let n = pick_channel(&j);
            if !n.is_empty() {
                name = n.to_string();
            }
        }
        if chan_url.is_empty() {
            let u = pick_channel_url(&j);
            if !u.is_empty() {
                chan_url = u.to_string();
            }
        }
        if let Some(card) = slim_entry(&j) {
            results.push(card);
        }
    }

    json!({
        "ok": true,
        "channel": name,
        "channelUrl": chan_url,
        "results": results,
    })
}

// ============================================================================
// Streams
// ============================================================================
#[tauri::command]
async fn yt_streams(app: tauri::AppHandle, video_id: String) -> Value {
    let id = video_id.trim().to_string();
    if id.is_empty() {
        return json!({ "ok": false, "error": "missing videoId" });
    }
    tauri::async_runtime::spawn_blocking(move || yt_streams_blocking(&app, &id))
        .await
        .unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() }))
}

fn yt_streams_blocking(app: &tauri::AppHandle, video_id: &str) -> Value {
    let url = format!("https://www.youtube.com/watch?v={}", video_id);
    let out = match run_ytdlp(app, &["-J", "--no-warnings", &url]) {
        Ok(o) => o,
        Err(e) => return json!({ "ok": false, "error": e }),
    };
    let info: Value = match serde_json::from_str(&out) {
        Ok(v) => v,
        Err(e) => return json!({ "ok": false, "error": format!("parse error: {e}") }),
    };

    let empty: Vec<Value> = Vec::new();
    let formats = info.get("formats").and_then(|f| f.as_array()).unwrap_or(&empty);

    // Best audio-only stream (prefer m4a/mp4a, then by bitrate).
    let mut best_audio_url = String::new();
    let mut best_audio_score = -1.0_f64;
    for f in formats {
        let vcodec = str_or(f, "vcodec", "none");
        let acodec = str_or(f, "acodec", "none");
        let furl = str_or(f, "url", "");
        if furl.is_empty() {
            continue;
        }
        let has_video = vcodec != "none" && !vcodec.is_empty();
        let has_audio = acodec != "none" && !acodec.is_empty();
        if !has_video && has_audio {
            let mut score = f.get("abr").and_then(|x| x.as_f64()).unwrap_or(0.0);
            if acodec.contains("mp4a") {
                score += 1000.0;
            }
            if score > best_audio_score {
                best_audio_score = score;
                best_audio_url = furl.to_string();
            }
        }
    }

    // Quality list: progressive (single-file, has audio) first so it survives the
    // per-height dedupe — it's the most reliable fallback when the dual adaptive
    // path fails. Adaptive video-only fills in the heights progressive doesn't cover.
    let mut qualities = Vec::new();
    let mut seen: HashSet<i64> = HashSet::new();
    for want_progressive in [true, false] {
        for f in formats {
            let vcodec = str_or(f, "vcodec", "none");
            let acodec = str_or(f, "acodec", "none");
            let furl = str_or(f, "url", "");
            if furl.is_empty() {
                continue;
            }
            let has_video = vcodec != "none" && !vcodec.is_empty();
            let has_audio = acodec != "none" && !acodec.is_empty();
            if !has_video {
                continue;
            }
            let is_progressive = has_audio;
            if is_progressive != want_progressive {
                continue;
            }
            let height = f.get("height").and_then(|x| x.as_i64()).unwrap_or(0);
            if height <= 0 || seen.contains(&height) {
                continue;
            }
            seen.insert(height);
            qualities.push(json!({
                "label": format!("{}p", height),
                "height": height,
                "hasAudio": is_progressive,
                "url": furl,
                "ext": str_or(f, "ext", ""),
            }));
        }
    }
    qualities.sort_by(|a, b| {
        b["height"]
            .as_i64()
            .unwrap_or(0)
            .cmp(&a["height"].as_i64().unwrap_or(0))
    });

    json!({
        "ok": true,
        "data": {
            "meta": {
                "id": video_id,
                "title": str_or(&info, "title", "(untitled)"),
                "channel": pick_channel(&info),
                "channelId": pick_channel_id(&info),
                "channelUrl": pick_channel_url(&info),
                "duration": info.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0),
                "thumbnail": format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", video_id),
            },
            "audioUrl": if best_audio_url.is_empty() { Value::Null } else { Value::String(best_audio_url) },
            "qualities": qualities,
        }
    })
}

fn data_file(app: &tauri::AppHandle) -> Result<PathBuf, String> {
    let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
    std::fs::create_dir_all(&dir).ok();
    Ok(dir.join("ytplayer-data.json"))
}

// ============================================================================
// Offline cache — download a single self-contained file per video so playlist
// items play instantly and work offline. Files live in <app_cache_dir>/videos
// and are named "<videoId>.<ext>". They persist until explicitly deleted.
// ============================================================================
fn cache_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
    let dir = app
        .path()
        .app_cache_dir()
        .map_err(|e| e.to_string())?
        .join("videos");
    std::fs::create_dir_all(&dir).ok();
    Ok(dir)
}

/// Find an already-cached file for a video id (any extension), if present.
fn cached_file(dir: &PathBuf, video_id: &str) -> Option<PathBuf> {
    let entries = std::fs::read_dir(dir).ok()?;
    for e in entries.flatten() {
        let p = e.path();
        if p.file_stem().and_then(|s| s.to_str()) == Some(video_id) {
            // Ignore partial yt-dlp downloads.
            let ext = p.extension().and_then(|s| s.to_str()).unwrap_or("");
            if ext != "part" && ext != "ytdl" {
                return Some(p);
            }
        }
    }
    None
}

fn file_size(p: &PathBuf) -> u64 {
    std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)
}

/// Download a video into the cache as a single progressive mp4 (audio+video in
/// one file, so it needs no ffmpeg muxing and plays offline). Idempotent.
///
/// Async + spawn_blocking so several saves can run in parallel without blocking
/// the UI thread.
#[tauri::command]
async fn cache_download(app: tauri::AppHandle, video_id: String) -> Value {
    let id = video_id.trim().to_string();
    if id.is_empty() {
        return json!({ "ok": false, "error": "missing videoId" });
    }
    tauri::async_runtime::spawn_blocking(move || cache_download_blocking(&app, &id))
        .await
        .unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() }))
}

fn cache_download_blocking(app: &tauri::AppHandle, video_id: &str) -> Value {
    let dir = match cache_dir(app) {
        Ok(d) => d,
        Err(e) => return json!({ "ok": false, "error": e }),
    };
    if let Some(p) = cached_file(&dir, video_id) {
        return json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) });
    }
    let url = format!("https://www.youtube.com/watch?v={}", video_id);
    let out_tmpl = dir.join(format!("{}.%(ext)s", video_id));
    let out_tmpl = out_tmpl.to_string_lossy().to_string();
    // Prefer a progressive mp4 (single file with audio); fall back to best single
    // file. Avoids ffmpeg by not requesting separate streams that need merging.
    let res = run_ytdlp(
        app,
        &[
            "--no-playlist",
            "--no-warnings",
            "-f",
            "best[ext=mp4][acodec!=none][vcodec!=none]/best[acodec!=none][vcodec!=none]/best",
            "-o",
            &out_tmpl,
            &url,
        ],
    );
    if let Err(e) = res {
        return json!({ "ok": false, "error": e });
    }
    match cached_file(&dir, video_id) {
        Some(p) => json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) }),
        None => json!({ "ok": false, "error": "download finished but no file was produced" }),
    }
}

#[tauri::command]
fn cache_status(app: tauri::AppHandle, video_id: String) -> Value {
    let dir = match cache_dir(&app) {
        Ok(d) => d,
        Err(e) => return json!({ "ok": false, "error": e }),
    };
    match cached_file(&dir, &video_id) {
        Some(p) => json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) }),
        None => json!({ "ok": true, "cached": false }),
    }
}

#[tauri::command]
fn cache_list(app: tauri::AppHandle) -> Value {
    let dir = match cache_dir(&app) {
        Ok(d) => d,
        Err(e) => return json!({ "ok": false, "error": e }),
    };
    let mut items = Vec::new();
    let mut total: u64 = 0;
    if let Ok(entries) = std::fs::read_dir(&dir) {
        for e in entries.flatten() {
            let p = e.path();
            if !p.is_file() {
                continue;
            }
            let ext = p.extension().and_then(|s| s.to_str()).unwrap_or("");
            if ext == "part" || ext == "ytdl" {
                continue;
            }
            if let Some(id) = p.file_stem().and_then(|s| s.to_str()) {
                let size = file_size(&p);
                total += size;
                items.push(json!({ "id": id, "size": size, "path": p.to_string_lossy() }));
            }
        }
    }
    json!({ "ok": true, "items": items, "total": total })
}

#[tauri::command]
fn cache_delete(app: tauri::AppHandle, video_id: String) -> Value {
    let dir = match cache_dir(&app) {
        Ok(d) => d,
        Err(e) => return json!({ "ok": false, "error": e }),
    };
    if let Some(p) = cached_file(&dir, &video_id) {
        match std::fs::remove_file(&p) {
            Ok(_) => json!({ "ok": true }),
            Err(e) => json!({ "ok": false, "error": e.to_string() }),
        }
    } else {
        json!({ "ok": true })
    }
}

#[tauri::command]
fn cache_clear(app: tauri::AppHandle) -> Value {
    let dir = match cache_dir(&app) {
        Ok(d) => d,
        Err(e) => return json!({ "ok": false, "error": e }),
    };
    let mut removed = 0u32;
    if let Ok(entries) = std::fs::read_dir(&dir) {
        for e in entries.flatten() {
            let p = e.path();
            if p.is_file() && std::fs::remove_file(&p).is_ok() {
                removed += 1;
            }
        }
    }
    json!({ "ok": true, "removed": removed })
}

#[tauri::command]
fn store_load(app: tauri::AppHandle) -> Value {
    let default = json!({
        "playlists": [],
        "history": [],
        "settings": { "quality": "auto", "volume": 1, "audioOnly": false }
    });
    let path = match data_file(&app) {
        Ok(p) => p,
        Err(_) => return default,
    };
    match std::fs::read_to_string(&path) {
        Ok(s) => serde_json::from_str::<Value>(&s).unwrap_or(default),
        Err(_) => default,
    }
}

#[tauri::command]
fn store_save(app: tauri::AppHandle, data: String) -> Value {
    let path = match data_file(&app) {
        Ok(p) => p,
        Err(e) => return json!({ "ok": false, "error": e }),
    };
    match std::fs::write(&path, data) {
        Ok(_) => json!({ "ok": true }),
        Err(e) => json!({ "ok": false, "error": e.to_string() }),
    }
}

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            yt_search,
            yt_channel,
            yt_streams,
            store_load,
            store_save,
            cache_download,
            cache_status,
            cache_list,
            cache_delete,
            cache_clear
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
