//! Turning a pasted or browsed YouTube URL into something the app can store.
//!
//! The app's subscriptions come from a Takeout CSV, which gives a channel id, a
//! title and a URL. A channel added by hand — or picked up from whatever is
//! playing in a browser — has to arrive at the same three things, and a video
//! picked up that way needs enough to sit in the feed like any other.
//!
//! Everything here works on strings so it can be tested without the network.

/// The video id in a YouTube URL, in any of the shapes YouTube uses.
pub fn video_id_from_url(url: &str) -> Option<String> {
    let valid = |s: &str| s.len() == 11 && s.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-');

    // watch?v=ID, with the parameter anywhere in the query.
    if let Some(q) = url.split('?').nth(1) {
        for pair in q.split('&') {
            if let Some(v) = pair.strip_prefix("v=") {
                let v = v.split('#').next().unwrap_or(v);
                if valid(v) {
                    return Some(v.to_string());
                }
            }
        }
    }

    // youtu.be/ID, /shorts/ID, /live/ID, /embed/ID.
    let path = url.split('?').next().unwrap_or(url).trim_end_matches('/');
    for marker in ["youtu.be/", "/shorts/", "/live/", "/embed/", "/v/"] {
        if let Some(rest) = path.split(marker).nth(1) {
            let id = rest.split('/').next().unwrap_or(rest);
            if valid(id) {
                return Some(id.to_string());
            }
        }
    }
    None
}

/// True for any URL on YouTube, which is all the tray needs to decide whether
/// the browser is showing something it can act on.
pub fn is_youtube_url(url: &str) -> bool {
    let u = url.to_ascii_lowercase();
    u.contains("youtube.com/") || u.contains("youtu.be/")
}

/// Undoes the escaping YouTube's embedded JSON uses, so a title reads as it
/// looks on the page rather than as `Bob & Alice`.
pub fn unescape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut chars = s.chars();
    while let Some(c) = chars.next() {
        if c != '\\' {
            out.push(c);
            continue;
        }
        match chars.next() {
            Some('u') => {
                let hex: String = chars.by_ref().take(4).collect();
                match u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
                    Some(ch) => out.push(ch),
                    None => out.push_str(&hex),
                }
            }
            Some('n') => out.push('\n'),
            Some('t') => out.push('\t'),
            Some('"') => out.push('"'),
            Some('\\') => out.push('\\'),
            Some('/') => out.push('/'),
            Some(other) => out.push(other),
            None => break,
        }
    }
    out
}

/// The first value of `"<key>":"<value>"` in a page's embedded JSON.
fn json_string(html: &str, key: &str) -> Option<String> {
    let needle = format!("\"{key}\":\"");
    let start = html.find(&needle)? + needle.len();
    let rest = &html[start..];
    // Stop at the first quote that is not escaped.
    let mut end = 0;
    let bytes = rest.as_bytes();
    while end < bytes.len() {
        if bytes[end] == b'"' && (end == 0 || bytes[end - 1] != b'\\') {
            break;
        }
        end += 1;
    }
    if end == 0 || end >= rest.len() {
        return None;
    }
    Some(unescape(&rest[..end]))
}

/// The content of `<meta ... (property|name|itemprop)="<key>" ... content="...">`,
/// whichever order the attributes happen to be in.
fn meta(html: &str, key: &str) -> Option<String> {
    for tag in html.split("<meta").skip(1) {
        let tag = &tag[..tag.find('>').unwrap_or(tag.len())];
        let names = [
            format!("property=\"{key}\""),
            format!("name=\"{key}\""),
            format!("itemprop=\"{key}\""),
        ];
        if !names.iter().any(|n| tag.contains(n.as_str())) {
            continue;
        }
        if let Some(rest) = tag.split("content=\"").nth(1) {
            let value = &rest[..rest.find('"').unwrap_or(rest.len())];
            if !value.is_empty() {
                return Some(decode_entities(value));
            }
        }
    }
    None
}

/// The handful of XML entities YouTube puts in meta attributes.
fn decode_entities(s: &str) -> String {
    s.replace("&amp;", "&")
        .replace("&quot;", "\"")
        .replace("&#39;", "'")
        .replace("&lt;", "<")
        .replace("&gt;", ">")
}

/// The channel a page belongs to, whether that page is the channel itself or
/// one of its videos.
pub fn parse_channel(html: &str) -> Option<(String, String)> {
    let id = json_string(html, "channelId")
        .or_else(|| json_string(html, "externalId"))
        .or_else(|| meta(html, "channelId"))
        .filter(|id| id.starts_with("UC") && id.len() == 24)?;

    // On a video page og:title is the video, so the channel's own name has to
    // come from the player data.
    let title = json_string(html, "ownerChannelName")
        .or_else(|| json_string(html, "author"))
        .or_else(|| meta(html, "og:title"))
        .unwrap_or_else(|| "Unknown channel".to_string());

    Some((id, title.trim().to_string()))
}

/// What a video page says about itself, beyond its channel.
#[derive(Debug, Clone, PartialEq)]
pub struct VideoMeta {
    pub title: String,
    /// ISO date, `YYYY-MM-DD`, as YouTube writes it.
    pub published: Option<String>,
}

pub fn parse_video(html: &str) -> VideoMeta {
    VideoMeta {
        title: meta(html, "og:title")
            .or_else(|| json_string(html, "title"))
            .unwrap_or_else(|| "Untitled".to_string()),
        published: meta(html, "datePublished")
            .or_else(|| json_string(html, "publishDate"))
            .map(|d| d.chars().take(10).collect()),
    }
}

/// `YYYY-MM-DD` as unix seconds, so a hand-added video sorts into the feed
/// alongside everything that came from an Atom feed.
pub fn iso_date_to_unix(date: &str) -> Option<i64> {
    let mut parts = date.split('-');
    let y: i32 = parts.next()?.parse().ok()?;
    let m: u32 = parts.next()?.parse().ok()?;
    let d: u32 = parts.next()?.parse().ok()?;
    chrono::NaiveDate::from_ymd_opt(y, m, d)?
        .and_hms_opt(12, 0, 0)
        .map(|dt| dt.and_utc().timestamp())
}

/// The canonical page for a channel id.
pub fn channel_url(id: &str) -> String {
    format!("https://www.youtube.com/channel/{id}")
}

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

    #[test]
    fn video_ids_come_out_of_every_url_shape() {
        assert_eq!(
            video_id_from_url("https://www.youtube.com/watch?v=Bb48k48myRI").as_deref(),
            Some("Bb48k48myRI")
        );
        // Real browser URLs carry a pile of other parameters.
        assert_eq!(
            video_id_from_url("https://www.youtube.com/watch?app=desktop&v=Bb48k48myRI&t=42s")
                .as_deref(),
            Some("Bb48k48myRI")
        );
        assert_eq!(
            video_id_from_url("https://youtu.be/Bb48k48myRI?si=xyz").as_deref(),
            Some("Bb48k48myRI")
        );
        assert_eq!(
            video_id_from_url("https://www.youtube.com/shorts/Bb48k48myRI").as_deref(),
            Some("Bb48k48myRI")
        );
        // A channel page is not a video.
        assert_eq!(video_id_from_url("https://www.youtube.com/@mkbhd"), None);
        assert_eq!(video_id_from_url("https://example.com/watch?v=short"), None);
    }

    #[test]
    fn youtube_urls_are_recognised() {
        assert!(is_youtube_url("https://www.youtube.com/watch?v=abc"));
        assert!(is_youtube_url("https://youtu.be/abc"));
        assert!(!is_youtube_url("https://vimeo.com/12345"));
    }

    #[test]
    fn channel_comes_from_a_video_page_with_the_channels_own_name() {
        let html = r#"<meta property="og:title" content="A video title">
            {"channelId":"UCBJycsmduvYEL83R_U4JriQ","ownerChannelName":"Marques Brownlee"}"#;
        let (id, title) = parse_channel(html).unwrap();
        assert_eq!(id, "UCBJycsmduvYEL83R_U4JriQ");
        // Not the video's title, which og:title would have given.
        assert_eq!(title, "Marques Brownlee");
    }

    #[test]
    fn channel_comes_from_a_channel_page_by_its_og_title() {
        let html = r#"<meta property="og:title" content="Lukis3D Studio">
            <script>var x = {"externalId":"UCBJycsmduvYEL83R_U4JriQ"};</script>"#;
        let (id, title) = parse_channel(html).unwrap();
        assert_eq!(id, "UCBJycsmduvYEL83R_U4JriQ");
        assert_eq!(title, "Lukis3D Studio");
    }

    #[test]
    fn a_page_without_a_channel_id_resolves_to_nothing() {
        assert_eq!(parse_channel("<html>nothing here</html>"), None);
        // A plausible-looking id that is not one must not be accepted.
        assert_eq!(parse_channel(r#"{"channelId":"UCshort"}"#), None);
    }

    #[test]
    fn escaped_titles_are_read_back_as_written() {
        let html = r#"{"channelId":"UCBJycsmduvYEL83R_U4JriQ","ownerChannelName":"Bob & Alice \"Live\""}"#;
        assert_eq!(parse_channel(html).unwrap().1, "Bob & Alice \"Live\"");
    }

    #[test]
    fn video_pages_give_a_title_and_a_date() {
        let html = r#"<meta property="og:title" content="Six Months in Thailand">
            <meta itemprop="datePublished" content="2026-08-29T10:00:00-07:00">"#;
        let m = parse_video(html);
        assert_eq!(m.title, "Six Months in Thailand");
        assert_eq!(m.published.as_deref(), Some("2026-08-29"));
    }

    #[test]
    fn meta_entities_are_decoded() {
        let html = r#"<meta property="og:title" content="Tools &amp; Toys">"#;
        assert_eq!(parse_video(html).title, "Tools & Toys");
    }

    #[test]
    fn iso_dates_become_timestamps_that_sort() {
        let older = iso_date_to_unix("2026-08-28").unwrap();
        let newer = iso_date_to_unix("2026-08-29").unwrap();
        assert!(newer > older);
        assert_eq!(iso_date_to_unix("nonsense"), None);
    }
}
