//! GitHub Discussions shown on the Start page.
//!
//! Native builds read GitHub's public Atom feed and the public Discussions
//! page, which exposes the repository's pinned-discussion section without an
//! access token. The last successful result is cached for offline launches.
//! Web builds read `discussions.json`, generated by the Pages workflow through
//! GitHub's authenticated GraphQL API.

pub const DISCUSSIONS_URL: &str =
    "https://github.com/HakanSeven12/OpenCADStudio/discussions";

#[cfg(not(target_arch = "wasm32"))]
const FEED_URL: &str =
    "https://github.com/HakanSeven12/OpenCADStudio/discussions.atom";

#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct DiscussionEntry {
    pub number: u64,
    pub title: String,
    pub url: String,
    pub author: String,
    pub updated_at: String,
    #[serde(default)]
    pub pinned: bool,
}

fn sort_entries(entries: &mut [DiscussionEntry]) {
    entries.sort_by(|a, b| {
        b.pinned
            .cmp(&a.pinned)
            .then_with(|| b.updated_at.cmp(&a.updated_at))
            .then_with(|| b.number.cmp(&a.number))
    });
}

fn parse_json(body: &str) -> Result<Vec<DiscussionEntry>, String> {
    let mut entries: Vec<DiscussionEntry> =
        serde_json::from_str(body).map_err(|e| e.to_string())?;
    entries.retain(|entry| {
        entry.number > 0
            && !entry.title.trim().is_empty()
            && entry.url.starts_with("https://github.com/")
    });
    sort_entries(&mut entries);
    Ok(entries)
}

#[cfg(not(target_arch = "wasm32"))]
fn cache_path() -> Option<std::path::PathBuf> {
    crate::config::config_dir().map(|dir| dir.join("discussions.json"))
}

#[cfg(not(target_arch = "wasm32"))]
pub fn load_cached() -> Vec<DiscussionEntry> {
    cache_path()
        .and_then(|path| std::fs::read_to_string(path).ok())
        .and_then(|body| parse_json(&body).ok())
        .or_else(|| parse_json(include_str!("../web/discussions.json")).ok())
        .unwrap_or_default()
}

#[cfg(not(target_arch = "wasm32"))]
pub fn fetch_discussions() -> Result<Vec<DiscussionEntry>, String> {
    let agent = crate::network::agent(std::time::Duration::from_secs(15));
    let feed = get_text(&agent, FEED_URL)?;
    let page = get_text(&agent, DISCUSSIONS_URL)?;
    let pinned = pinned_numbers(&page);
    let mut entries = parse_atom(&feed);
    if entries.is_empty() {
        return Err("GitHub Discussions feed returned no entries".into());
    }
    for entry in &mut entries {
        entry.pinned = pinned.contains(&entry.number);
    }
    sort_entries(&mut entries);

    if let Some(path) = cache_path() {
        if let Some(parent) = path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        if let Ok(body) = serde_json::to_string(&entries) {
            let _ = std::fs::write(path, body);
        }
    }
    Ok(entries)
}

#[cfg(not(target_arch = "wasm32"))]
fn get_text(agent: &ureq::Agent, url: &str) -> Result<String, String> {
    agent
        .get(url)
        .header(
            "User-Agent",
            concat!("OpenCADStudio/", env!("CARGO_PKG_VERSION")),
        )
        .call()
        .map_err(|e| e.to_string())?
        .body_mut()
        .with_config()
        .limit(4 * 1024 * 1024)
        .read_to_string()
        .map_err(|e| e.to_string())
}

#[cfg(not(target_arch = "wasm32"))]
fn parse_atom(feed: &str) -> Vec<DiscussionEntry> {
    feed.split("<entry>")
        .skip(1)
        .filter_map(|tail| {
            let entry = tail.split_once("</entry>")?.0;
            let url_start = entry.find(
                "href=\"https://github.com/HakanSeven12/OpenCADStudio/discussions/",
            )? + "href=\"".len();
            let url_tail = &entry[url_start..];
            let url = url_tail.split_once('"')?.0.to_string();
            let number = url.rsplit('/').next()?.parse().ok()?;
            let title = xml_text(entry, "title")?;
            let author_block = entry.split_once("<author>")?.1.split_once("</author>")?.0;
            let author = xml_text(author_block, "name").unwrap_or_default();
            let updated_at = xml_text(entry, "updated").unwrap_or_default();
            Some(DiscussionEntry {
                number,
                title,
                url,
                author,
                updated_at,
                pinned: false,
            })
        })
        .collect()
}

#[cfg(not(target_arch = "wasm32"))]
fn xml_text(block: &str, tag: &str) -> Option<String> {
    let open = format!("<{tag}>");
    let close = format!("</{tag}>");
    let value = block.split_once(&open)?.1.split_once(&close)?.0;
    let compact = value.split_whitespace().collect::<Vec<_>>().join(" ");
    if compact.is_empty() {
        None
    } else {
        Some(
            compact
                .replace("&quot;", "\"")
                .replace("&#39;", "'")
                .replace("&apos;", "'")
                .replace("&lt;", "<")
                .replace("&gt;", ">")
                .replace("&amp;", "&"),
        )
    }
}

#[cfg(not(target_arch = "wasm32"))]
fn pinned_numbers(page: &str) -> std::collections::HashSet<u64> {
    let mut numbers = std::collections::HashSet::new();
    let Some(after_heading) = page.split_once("id=\"pinned-discussions\"").map(|(_, rest)| rest)
    else {
        return numbers;
    };
    let section = after_heading
        .split_once("</ul>")
        .map(|(section, _)| section)
        .unwrap_or(after_heading);
    let marker = "/HakanSeven12/OpenCADStudio/discussions/";
    let mut rest = section;
    while let Some(pos) = rest.find(marker) {
        let tail = &rest[pos + marker.len()..];
        let digits = tail
            .chars()
            .take_while(|ch| ch.is_ascii_digit())
            .collect::<String>();
        if let Ok(number) = digits.parse() {
            numbers.insert(number);
        }
        rest = tail;
    }
    numbers
}

#[cfg(target_arch = "wasm32")]
pub async fn fetch_discussions_web() -> Result<Vec<DiscussionEntry>, String> {
    use wasm_bindgen::JsCast;
    use wasm_bindgen_futures::JsFuture;

    let window = web_sys::window().ok_or("no window")?;
    let response = JsFuture::from(window.fetch_with_str("discussions.json"))
        .await
        .map_err(|_| "fetch failed")?;
    let response: web_sys::Response =
        response.dyn_into().map_err(|_| "not a Response")?;
    if !response.ok() {
        return Err(format!("HTTP {}", response.status()));
    }
    let text = JsFuture::from(response.text().map_err(|_| "text() unavailable")?)
        .await
        .map_err(|_| "body read failed")?;
    let body = text.as_string().ok_or("body is not a string")?;
    parse_json(&body)
}

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

    #[cfg(not(target_arch = "wasm32"))]
    #[test]
    fn pinned_discussions_are_sorted_first() {
        let feed = r#"
            <entry>
              <link type="text/html" rel="alternate" href="https://github.com/HakanSeven12/OpenCADStudio/discussions/2"/>
              <title>New &amp; recent</title>
              <updated>2026-07-28T10:00:00+00:00</updated>
              <author><name>alice</name></author>
            </entry>
            <entry>
              <link type="text/html" rel="alternate" href="https://github.com/HakanSeven12/OpenCADStudio/discussions/1"/>
              <title>Pinned</title>
              <updated>2026-07-20T10:00:00+00:00</updated>
              <author><name>bob</name></author>
            </entry>
        "#;
        let page = r#"
          <h2 id="pinned-discussions">Pinned Discussions</h2>
          <ul><a href="/HakanSeven12/OpenCADStudio/discussions/1">Pinned</a></ul>
        "#;
        let pinned = pinned_numbers(page);
        let mut entries = parse_atom(feed);
        for entry in &mut entries {
            entry.pinned = pinned.contains(&entry.number);
        }
        sort_entries(&mut entries);

        assert_eq!(entries[0].number, 1);
        assert_eq!(entries[1].title, "New & recent");
    }
}
