//! A loopback HTTP server for locally downloaded media.
//!
//! Tauri's built-in `asset` protocol cannot serve a downloaded film to the
//! webview. Its response to a request *without* a `Range` header reads the whole
//! file into a `Vec<u8>`, and it only advertises `Accept-Ranges: bytes` from
//! inside the range branch — so the first request never learns ranges are
//! available and a multi-gigabyte body is attempted instead. Chromium abandoned
//! it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as
//! "downloaded video does not play offline".
//!
//! Serving over real HTTP on 127.0.0.1 rather than a custom URI scheme is
//! deliberate: it makes range support a property of the transport instead of
//! depending on whether a platform's webview forwards `Range` to a custom
//! scheme, which differs between Android and the desktop webviews.
//!
//! Two things confine it, because **loopback is shared between apps on
//! Android** — any other installed app can connect to this port:
//!
//! - it binds `127.0.0.1` only, so nothing off-device can reach it; and
//! - every URL carries a random per-session token, so another app cannot guess a
//!   working URL, and paths are confined to the app data directory even if one
//!   did.
//!
//! Phase 1 serves local files only. The same origin is the intended home for
//! remote passthrough (and download-while-watching) later; see the stage-2 spec.
//!
//! TRACES: UR-071 | DR-137 | UT-127

use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::Arc;

use log::{debug, error, info, warn};
use rand::Rng;
use tiny_http::{Header, Response, Server, StatusCode};

/// Bytes per response. Large enough that a film needs relatively few round
/// trips, small enough that one response is never a memory problem on a phone.
/// Tauri's asset protocol uses 1 MiB; 4 MiB quarters the request count for the
/// multi-gigabyte files this exists to serve.
const CHUNK_LEN: u64 = 4 * 1024 * 1024;

/// Managed state. `None` when the server could not bind — local playback then
/// fails with a clear error instead of the app refusing to start.
pub struct MediaServerWrapper(pub Option<MediaServer>);

/// A running server. Dropping this does not stop the thread; the server lives
/// for the life of the process by design, since playback can start at any time.
pub struct MediaServer {
    port: u16,
    token: String,
}

impl MediaServer {
    /// The base a media URL is built on, e.g. `http://127.0.0.1:53412/<token>`.
    pub fn base_url(&self) -> String {
        format!("http://127.0.0.1:{}/{}", self.port, self.token)
    }

    /// A playable URL for an absolute on-disk path.
    pub fn url_for(&self, path: &str) -> String {
        format!("{}/{}", self.base_url(), urlencoding::encode(path))
    }
}

/// Bind to an ephemeral loopback port and start serving `root` in a background
/// thread.
///
/// TRACES: UR-071 | DR-137
pub fn start(root: PathBuf) -> Result<MediaServer, String> {
    // Port 0 → the OS picks a free one. Binding 127.0.0.1 (not 0.0.0.0) keeps
    // this off the network.
    let server =
        Server::http("127.0.0.1:0").map_err(|e| format!("Failed to bind media server: {e}"))?;

    let port = server
        .server_addr()
        .to_ip()
        .ok_or_else(|| "Media server bound to a non-IP address".to_string())?
        .port();

    let token: String = {
        let mut rng = rand::thread_rng();
        (0..32)
            .map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
            .collect()
    };

    info!(
        "[MediaServer] Serving {} on 127.0.0.1:{}",
        root.display(),
        port
    );

    let server = Arc::new(server);
    let shared = Arc::new((root, token.clone()));

    std::thread::Builder::new()
        .name("media-server".into())
        .spawn(move || loop {
            let request = match server.recv() {
                Ok(r) => r,
                Err(e) => {
                    error!("[MediaServer] accept failed: {e}");
                    continue;
                }
            };
            let shared = Arc::clone(&shared);
            // A thread per request: media clients open several connections at
            // once, and a blocking read of one must not stall the others.
            if let Err(e) = std::thread::Builder::new()
                .name("media-server-req".into())
                .spawn(move || {
                    let (root, token) = &*shared;
                    handle(request, root, token);
                })
            {
                error!("[MediaServer] could not spawn handler: {e}");
            }
        })
        .map_err(|e| format!("Failed to start media server thread: {e}"))?;

    Ok(MediaServer { port, token })
}

fn header(name: &str, value: &str) -> Header {
    Header::from_bytes(name.as_bytes(), value.as_bytes())
        .expect("static header name/value are valid")
}

fn empty(status: u16) -> Response<std::io::Empty> {
    Response::empty(StatusCode(status)).with_header(header("Accept-Ranges", "bytes"))
}

fn handle(request: tiny_http::Request, root: &Path, token: &str) {
    let url = request.url().to_string();
    let method = request.method().as_str().to_string();

    let outcome = match route(&url, root, token) {
        Ok(path) => path,
        Err(status) => {
            let _ = request.respond(empty(status));
            return;
        }
    };

    if method != "GET" && method != "HEAD" {
        let _ = request.respond(empty(405));
        return;
    }

    let mut file = match File::open(&outcome) {
        Ok(f) => f,
        Err(e) => {
            warn!("[MediaServer] {}: {}", outcome.display(), e);
            let _ = request.respond(empty(404));
            return;
        }
    };
    let len = match file.metadata() {
        Ok(m) => m.len(),
        Err(e) => {
            warn!(
                "[MediaServer] metadata failed for {}: {}",
                outcome.display(),
                e
            );
            let _ = request.respond(empty(404));
            return;
        }
    };

    let range = request
        .headers()
        .iter()
        .find(|h| h.field.equiv("Range"))
        .map(|h| h.value.as_str().to_string());

    debug!(
        "[MediaServer] {} {} ({} bytes) range={:?}",
        method,
        outcome.display(),
        len,
        range
    );

    let Some(span) = span_for(range.as_deref(), len) else {
        let _ = request
            .respond(empty(416).with_header(header("Content-Range", &format!("bytes */{len}"))));
        return;
    };

    // Sniff before seeking to the span, for extension-less files.
    let mut head = [0u8; 16];
    let head_len = file.read(&mut head).unwrap_or(0);
    let mime = content_type(&outcome, &head[..head_len]);

    if method == "HEAD" {
        let _ = request.respond(
            empty(200)
                .with_header(header("Content-Type", mime))
                .with_header(header("Content-Length", &len.to_string())),
        );
        return;
    }

    if let Err(e) = file.seek(SeekFrom::Start(span.start)) {
        warn!("[MediaServer] seek failed for {}: {}", outcome.display(), e);
        let _ = request.respond(empty(500));
        return;
    }

    // Streamed straight from the file handle: at no point is more than the
    // span in memory, and the span is capped at CHUNK_LEN.
    let nbytes = span.len();
    let body = file.take(nbytes);
    // tiny_http switches to chunked transfer above a 32 KiB default, which drops
    // Content-Length — and a 206 without one is unusable to Chromium's media
    // loader, which needs the range's size. Raising the threshold past our own
    // cap keeps every response length-delimited.
    let response = Response::new(
        StatusCode(206),
        vec![
            header("Accept-Ranges", "bytes"),
            header("Content-Type", mime),
            header(
                "Content-Range",
                &format!("bytes {}-{}/{}", span.start, span.end, len),
            ),
        ],
        body,
        Some(nbytes as usize),
        None,
    )
    .with_chunked_threshold(usize::MAX);

    if let Err(e) = request.respond(response) {
        // A client that seeks away closes the connection mid-body; that is
        // normal and must not be logged as a failure.
        debug!("[MediaServer] response ended early: {e}");
    }
}

/// Check the token and resolve the path, or return the status to answer with.
fn route(url: &str, root: &Path, token: &str) -> Result<PathBuf, u16> {
    let trimmed = url.trim_start_matches('/');
    let (got_token, rest) = trimmed.split_once('/').ok_or(404u16)?;

    // Constant-time-ish: length check first, then a byte compare. The token is
    // the only thing standing between another app on the device and this server.
    if got_token.len() != token.len() || got_token != token {
        warn!("[MediaServer] Rejected a request with a bad token");
        return Err(403);
    }

    // Strip any query string before decoding.
    let raw = rest.split('?').next().unwrap_or("");
    match resolve_path(raw, root) {
        Resolved::Allow(p) => Ok(p),
        Resolved::Forbidden => {
            warn!("[MediaServer] Refused a path outside the app data directory");
            Err(403)
        }
    }
}

/// What a request path resolved to, before any file is touched.
#[derive(Debug, PartialEq, Eq)]
pub enum Resolved {
    Allow(PathBuf),
    /// Escaped the allowed root.
    Forbidden,
}

/// Resolve a percent-encoded request path to a file inside `root`.
///
/// `..` segments are folded away lexically rather than through `canonicalize`,
/// so a missing file still resolves (and then 404s) instead of being reported as
/// a scope violation.
///
/// TRACES: UR-071 | DR-137 | UT-127
pub fn resolve_path(raw: &str, root: &Path) -> Resolved {
    let decoded = match urlencoding::decode(raw) {
        Ok(d) => d.into_owned(),
        Err(_) => raw.to_string(),
    };

    let mut normalised = PathBuf::new();
    for part in Path::new(&decoded).components() {
        match part {
            std::path::Component::ParentDir => {
                normalised.pop();
            }
            std::path::Component::CurDir => {}
            other => normalised.push(other),
        }
    }

    if normalised.starts_with(root) {
        Resolved::Allow(normalised)
    } else {
        Resolved::Forbidden
    }
}

/// The byte range a response should carry. `end` is inclusive.
#[derive(Debug, PartialEq, Eq)]
pub struct Span {
    pub start: u64,
    pub end: u64,
}

impl Span {
    pub fn len(&self) -> u64 {
        self.end + 1 - self.start
    }
}

/// Decide which span to send for a `Range` header (or its absence).
///
/// `None` means unsatisfiable — answer 416. A missing or unparseable header
/// yields the first chunk, so a client that did not ask for a range still gets a
/// bounded response it can continue from, which is exactly the case the asset
/// protocol answered with the whole file.
///
/// TRACES: UR-071 | DR-137 | UT-127
pub fn span_for(range: Option<&str>, len: u64) -> Option<Span> {
    if len == 0 {
        return Some(Span { start: 0, end: 0 });
    }
    let last = len - 1;
    let first_chunk = Span {
        start: 0,
        end: (CHUNK_LEN - 1).min(last),
    };

    let Some(raw) = range else {
        return Some(first_chunk);
    };
    let Some(spec) = raw.trim().strip_prefix("bytes=") else {
        return Some(first_chunk);
    };
    // Only the first range of a multi-range request is honoured; media clients
    // ask for one, and a single 206 is a valid answer either way.
    let spec = spec.split(',').next().unwrap_or("").trim();
    let Some((from, to)) = spec.split_once('-') else {
        return Some(first_chunk);
    };

    let (start, end) = if from.is_empty() {
        // Suffix form: `-500` is the final 500 bytes.
        let suffix: u64 = match to.parse() {
            Ok(n) => n,
            Err(_) => return Some(first_chunk),
        };
        if suffix == 0 {
            return None;
        }
        (len.saturating_sub(suffix), last)
    } else {
        let start: u64 = match from.parse() {
            Ok(n) => n,
            Err(_) => return Some(first_chunk),
        };
        let end = if to.is_empty() {
            last
        } else {
            match to.parse::<u64>() {
                Ok(n) => n.min(last),
                Err(_) => return Some(first_chunk),
            }
        };
        (start, end)
    };

    if start > last || end < start {
        return None;
    }

    Some(Span {
        start,
        end: end.min(start + CHUNK_LEN - 1),
    })
}

/// Guess a content type.
///
/// **Magic bytes win over the extension.** Downloading at `original` quality
/// asks Jellyfin for a direct static copy, which returns the *source file's*
/// bytes under a `.mp4` name whatever the real container is — a downloaded film
/// named `.mp4` turned out to be an AVI holding XVID. Trusting the extension
/// there labels it `video/mp4` and the player is handed a container that is not
/// what the header claims. The extension is only a fallback for a file whose
/// bytes are unrecognised, and for the extension-less files the offline queue
/// writes under an item id.
///
/// TRACES: UR-071 | DR-137 | UT-127
fn content_type(path: &Path, head: &[u8]) -> &'static str {
    // `ftyp` at offset 4 marks an ISO base media file (mp4 and friends).
    if head.len() > 11 && &head[4..8] == b"ftyp" {
        return "video/mp4";
    }
    if head.len() > 11 && head.starts_with(b"RIFF") && &head[8..11] == b"AVI" {
        return "video/x-msvideo";
    }
    if head.starts_with(b"\x1aE\xdf\xa3") {
        return "video/x-matroska";
    }
    if head.starts_with(b"ID3") || head.starts_with(b"\xff\xfb") {
        return "audio/mpeg";
    }
    if head.starts_with(b"OggS") {
        return "audio/ogg";
    }
    if head.starts_with(b"fLaC") {
        return "audio/flac";
    }

    match path
        .extension()
        .and_then(|e| e.to_str())
        .map(|e| e.to_ascii_lowercase())
        .as_deref()
    {
        Some("mp4" | "m4v" | "mov") => return "video/mp4",
        Some("mkv") => return "video/x-matroska",
        Some("webm") => return "video/webm",
        Some("mp3") => return "audio/mpeg",
        Some("m4a" | "aac") => return "audio/mp4",
        Some("flac") => return "audio/flac",
        Some("ogg" | "opus") => return "audio/ogg",
        Some("wav") => return "audio/wav",
        Some("avi") => return "video/x-msvideo",
        _ => {}
    }

    "application/octet-stream"
}

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

    /// The whole point: a request with no `Range` must still come back bounded.
    /// That is the case Tauri's asset protocol answers with the entire file —
    /// the read Chromium abandoned after 31s.
    ///
    /// TRACES: UR-071 | DR-137 | UT-127
    #[test]
    fn a_rangeless_request_is_answered_with_one_chunk_not_the_file() {
        let huge = 8 * 1024 * 1024 * 1024; // 8 GiB
        let span = span_for(None, huge).unwrap();
        assert_eq!(span.start, 0);
        assert_eq!(span.len(), CHUNK_LEN);
    }

    /// TRACES: UR-071 | DR-137 | UT-127
    #[test]
    fn no_response_ever_exceeds_one_chunk() {
        let len = 8 * 1024 * 1024 * 1024;
        for h in [
            "bytes=0-",
            "bytes=0-99999999999",
            "bytes=1024-",
            "bytes=-99999999",
        ] {
            let span = span_for(Some(h), len).unwrap();
            assert!(span.len() <= CHUNK_LEN, "{h} produced {} bytes", span.len());
        }
    }

    /// TRACES: UR-071 | DR-137 | UT-127
    #[test]
    fn ranges_are_honoured() {
        let len = 1000u64;
        assert_eq!(
            span_for(Some("bytes=100-199"), len).unwrap(),
            Span {
                start: 100,
                end: 199
            }
        );
        // Open-ended runs to the end of a small file.
        assert_eq!(
            span_for(Some("bytes=900-"), len).unwrap(),
            Span {
                start: 900,
                end: 999
            }
        );
        // Suffix form.
        assert_eq!(
            span_for(Some("bytes=-100"), len).unwrap(),
            Span {
                start: 900,
                end: 999
            }
        );
        // Past the end is unsatisfiable, not a clamp — a clamp would make a
        // seek past the end silently replay earlier bytes.
        assert!(span_for(Some("bytes=1000-"), len).is_none());
    }

    /// A malformed header must not fail the request: playing from the start is
    /// strictly better than refusing to open the file.
    ///
    /// TRACES: UR-071 | DR-137 | UT-127
    #[test]
    fn a_malformed_range_falls_back_to_the_first_chunk() {
        assert_eq!(span_for(Some("pages=1-2"), 5000).unwrap().start, 0);
        assert_eq!(span_for(Some("bytes=abc-def"), 5000).unwrap().start, 0);
    }

    /// TRACES: UR-071 | DR-137 | UT-127
    #[test]
    fn reads_are_confined_to_the_app_data_directory() {
        let root = Path::new("/data/user/0/app");

        assert_eq!(
            resolve_path("/data/user/0/app/videos/f.mp4", root),
            Resolved::Allow(PathBuf::from("/data/user/0/app/videos/f.mp4"))
        );
        // Percent-encoded, as the URL builder produces.
        assert_eq!(
            resolve_path("%2Fdata%2Fuser%2F0%2Fapp%2Fa%20b.mp4", root),
            Resolved::Allow(PathBuf::from("/data/user/0/app/a b.mp4"))
        );
        // Traversal out of the root, and an unrelated absolute path, are refused.
        assert_eq!(
            resolve_path("/data/user/0/app/../../../etc/passwd", root),
            Resolved::Forbidden
        );
        assert_eq!(resolve_path("/etc/passwd", root), Resolved::Forbidden);
    }

    /// Loopback is shared between apps on Android, so the token is the only
    /// thing stopping another installed app from reading downloaded media.
    ///
    /// TRACES: UR-071 | DR-137 | UT-127
    #[test]
    fn a_request_without_the_right_token_is_refused() {
        let root = Path::new("/data/user/0/app");
        let good = "0123456789abcdef0123456789abcdef";

        assert_eq!(
            route(
                &format!("/{good}/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4"),
                root,
                good
            ),
            Ok(PathBuf::from("/data/user/0/app/f.mp4"))
        );
        assert_eq!(
            route("/wrong-token/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4", root, good),
            Err(403)
        );
        // No token segment at all.
        assert_eq!(route("/f.mp4", root, good), Err(404));
        // Right token, but a path outside the root is still refused.
        assert_eq!(
            route(&format!("/{good}/%2Fetc%2Fpasswd"), root, good),
            Err(403)
        );
    }

    /// TRACES: UR-071 | DR-137 | UT-127
    #[test]
    fn content_type_uses_the_extension_then_the_magic_bytes() {
        assert_eq!(content_type(Path::new("/a/f.mp4"), &[]), "video/mp4");
        assert_eq!(content_type(Path::new("/a/f.mp3"), &[]), "audio/mpeg");
        // A `.mp4` that is really an AVI: downloading at `original` quality
        // copies the source bytes under an mp4 name, so the extension lies and
        // the magic bytes must win.
        let avi_head = b"RIFF\xcc\xf3\xbc\x2bAVI LIST";
        assert_eq!(
            content_type(Path::new("/a/film.mp4"), avi_head),
            "video/x-msvideo"
        );
        // Extension-less, as the offline queue writes them: sniff instead.
        let mp4_head = b"\x00\x00\x00\x20ftypisom\x00\x00\x02\x00";
        assert_eq!(content_type(Path::new("/a/abc123"), mp4_head), "video/mp4");
        assert_eq!(
            content_type(Path::new("/a/abc123"), b"ID3\x03junk"),
            "audio/mpeg"
        );
    }
}
