//! Reading the subscription list out of a signed-in browser.
//!
//! The Takeout CSV is a snapshot you have to go and fetch. This reads the live
//! list from <https://www.youtube.com/feed/channels> in a browser that is
//! already signed in, which is both current and no trouble to repeat.
//!
//! It runs JavaScript in the page rather than fetching it here, for two
//! reasons. The page is signed-in-only, and the session cookie cannot be
//! borrowed: Chromium encrypts its cookie store with a per-app Keychain key, so
//! Arc's cookies read with Chrome's key come back undecryptable — measured at
//! 1346 of 2196, the session cookies among them. And the list is lazily loaded,
//! so it has to be scrolled to the end, which only the page itself can do.

/// The channels a page carries, as (handle, name).
pub type Scraped = Vec<(String, String)>;

/// Collects what is currently rendered. Handles rather than channel ids: the
/// page carries no ids at all — no `ytInitialData`, and every link is a
/// `/@handle`.
///
/// The name comes from `#text`. `#title` does not exist on this page and
/// `#channel-title` wraps a second copy of the name, which is worth knowing
/// because a wrong name is not a visible failure — it just means nothing
/// matches what is already stored, and every channel gets looked up over the
/// network instead.
pub const EXTRACT_JS: &str = r#"(function(){
  var els = document.querySelectorAll('ytd-channel-renderer');
  var rows = [];
  for (var i = 0; i < els.length; i++) {
    var a = els[i].querySelector('a[href^="/@"], a[href^="/channel/"]');
    if (!a) continue;
    var t = els[i].querySelector('#text, #channel-title');
    var name = t ? t.textContent.replace(/\s+/g, ' ').trim() : '';
    rows.push(a.getAttribute('href') + '\t' + name);
  }
  return rows.join('\n');
})()"#;

/// One scroll to the end, so the next batch loads.
pub const SCROLL_JS: &str = "window.scrollTo(0, document.documentElement.scrollHeight); \
                             document.querySelectorAll('ytd-channel-renderer').length";

/// True for the page this can read.
pub fn is_subscriptions_page(url: &str) -> bool {
    let u = url.to_ascii_lowercase();
    u.contains("youtube.com/feed/channels")
}

/// Parses what the page handed back.
///
/// A name is allowed to be empty — the handle is what identifies the channel,
/// and a nameless row is still a subscription.
pub fn parse_rows(raw: &str) -> Scraped {
    let mut out: Scraped = Vec::new();
    for line in raw.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let (href, name) = match line.split_once('\t') {
            Some((h, n)) => (h.trim(), n.trim()),
            None => (line, ""),
        };
        if !href.starts_with("/@") && !href.starts_with("/channel/") {
            continue;
        }
        // The same channel can be rendered twice while the list re-flows.
        if out.iter().any(|(h, _)| h == href) {
            continue;
        }
        out.push((href.to_string(), collapse_doubled(name)));
    }
    out
}

/// Collapses a name that arrived twice over.
///
/// YouTube's markup nests the channel name inside an element that also holds
/// it, so a slightly wrong selector yields "3D OCD 3D OCD". That reads fine to
/// a person and matches nothing at all.
pub fn collapse_doubled(name: &str) -> String {
    let n = name.split_whitespace().collect::<Vec<_>>().join(" ");
    if n.is_empty() {
        return n;
    }
    if n.len() % 2 == 1 {
        let mid = n.len() / 2;
        if n.is_char_boundary(mid) && n.is_char_boundary(mid + 1) && &n[mid..mid + 1] == " " {
            let (a, b) = (&n[..mid], &n[mid + 1..]);
            if a == b {
                return a.to_string();
            }
        }
    }
    n
}

/// The full address for a scraped handle.
pub fn handle_url(href: &str) -> String {
    format!("https://www.youtube.com{href}")
}

/// The channel id, when the page gave one outright rather than a handle.
pub fn id_from_href(href: &str) -> Option<String> {
    let id = href.strip_prefix("/channel/")?;
    let id = id.split('/').next().unwrap_or(id);
    (id.starts_with("UC") && id.len() == 24).then(|| id.to_string())
}

/// Names as YouTube renders them and as a CSV stored them differ in case,
/// spacing and stray whitespace; this is what they are compared on.
pub fn name_key(name: &str) -> String {
    name.split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
        .to_lowercase()
}

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

    #[test]
    fn only_the_subscriptions_page_qualifies() {
        assert!(is_subscriptions_page("https://www.youtube.com/feed/channels"));
        assert!(is_subscriptions_page("https://youtube.com/feed/channels?flow=grid"));
        assert!(!is_subscriptions_page("https://www.youtube.com/feed/subscriptions"));
        assert!(!is_subscriptions_page("https://www.youtube.com/watch?v=abc"));
    }

    #[test]
    fn rows_come_back_as_handle_and_name() {
        let raw = "/@3DOCD\t3D OCD\n/@mkbhd\tMarques Brownlee\n";
        assert_eq!(
            parse_rows(raw),
            vec![
                ("/@3DOCD".to_string(), "3D OCD".to_string()),
                ("/@mkbhd".to_string(), "Marques Brownlee".to_string()),
            ]
        );
    }

    #[test]
    fn junk_rows_are_dropped_and_repeats_collapse() {
        // A re-flowing list can render the same channel twice, and anything
        // that is not a channel link is not a subscription.
        let raw = "/@a\tA\n\n/watch?v=x\tNot a channel\n/@a\tA\n/@b\t\n";
        assert_eq!(
            parse_rows(raw),
            vec![
                ("/@a".to_string(), "A".to_string()),
                // A nameless row is still a subscription; the handle identifies it.
                ("/@b".to_string(), String::new()),
            ]
        );
    }

    #[test]
    fn a_channel_link_gives_its_id_without_a_lookup() {
        assert_eq!(
            id_from_href("/channel/UCBJycsmduvYEL83R_U4JriQ").as_deref(),
            Some("UCBJycsmduvYEL83R_U4JriQ")
        );
        assert_eq!(id_from_href("/@mkbhd"), None);
        assert_eq!(id_from_href("/channel/UCshort"), None);
    }

    #[test]
    fn a_name_that_arrived_twice_is_collapsed() {
        assert_eq!(collapse_doubled("3D OCD 3D OCD"), "3D OCD");
        assert_eq!(collapse_doubled("  Linus Tech Tips  "), "Linus Tech Tips");
        // Not everything that repeats is doubled.
        assert_eq!(collapse_doubled("Spanian 2"), "Spanian 2");
        assert_eq!(collapse_doubled("Corridor Crew"), "Corridor Crew");
        assert_eq!(collapse_doubled(""), "");
    }

    #[test]
    fn doubled_names_survive_parsing() {
        assert_eq!(
            parse_rows("/@3DOCD\t3D OCD 3D OCD\n"),
            vec![("/@3DOCD".to_string(), "3D OCD".to_string())]
        );
    }

    #[test]
    fn handles_become_addresses() {
        assert_eq!(handle_url("/@mkbhd"), "https://www.youtube.com/@mkbhd");
    }

    #[test]
    fn names_match_across_spacing_and_case() {
        // What the page renders vs what a Takeout CSV stored.
        assert_eq!(name_key("  Linus   Tech Tips "), name_key("linus tech tips"));
        assert_ne!(name_key("Corridor Crew"), name_key("Corridor Digital"));
    }

    #[test]
    fn the_extraction_script_asks_for_what_the_page_has() {
        // Handles and names, since the page carries no channel ids.
        assert!(EXTRACT_JS.contains("ytd-channel-renderer"));
        assert!(EXTRACT_JS.contains(r#"a[href^="/@"]"#));
        // #text holds the name once; #title does not exist on this page.
        assert!(EXTRACT_JS.contains("'#text, #channel-title'"));
        assert!(SCROLL_JS.contains("scrollTo"));
    }
}
