//! M4 — a complete issue EPUB, built offline (spec §3.10, §4 M4).
//!
//! Everything here is offline: the synthetic issue's images are never
//! downloaded, so the chapters exercise the placeholder path.

use daily_epub::config::{self, Config};
use daily_epub::epub::build::fixtures;
use daily_epub::epub::{self, build};
use daily_epub::types::{Edition, Issue, Vote};
use daily_epub::{comments, world};

/// Local file headers store entry names verbatim, so a byte search over the
/// archive is enough to assert its contents without a zip reader.
fn contains_entry(zip: &[u8], name: &str) -> bool {
    zip.windows(name.len()).any(|w| w == name.as_bytes())
}

/// Read one entry out of the archive, inflating it.
fn read_entry_bytes(zip: &[u8], name: &str) -> Vec<u8> {
    use std::io::Read as _;
    let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip)).expect("zip opens");
    let mut file = archive.by_name(name).expect("entry exists");
    let mut out = Vec::new();
    file.read_to_end(&mut out).expect("entry reads");
    out
}

fn read_entry(zip: &[u8], name: &str) -> String {
    use std::io::Read as _;
    let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip)).expect("zip opens");
    let mut file = archive.by_name(name).expect("entry exists");
    let mut out = String::new();
    file.read_to_string(&mut out).expect("entry is text");
    out
}

/// Builds one edition into a temporary directory; the directory is cleaned up
/// when the returned `TempDir` is dropped.
fn build_edition_to_bytes(
    issue: &Issue,
    edition: Edition,
) -> (tempfile::TempDir, std::path::PathBuf, Vec<u8>) {
    let cfg = Config {
        server: config::ServerConfig {
            public_url: "https://daily.hallada.net".into(),
            hmac_secret: Some("integration-secret".into()),
            ..config::ServerConfig::default()
        },
        ..Config::default()
    };
    let dir = tempfile::tempdir().expect("tempdir");
    let artifact = epub::build_edition_with_images(issue, edition, &cfg, dir.path(), &[])
        .expect("edition builds");
    let bytes = std::fs::read(&artifact.path).expect("read epub");
    assert_eq!(artifact.bytes as usize, bytes.len());
    (dir, artifact.path, bytes)
}

#[test]
fn standard_edition_is_a_well_formed_epub3_archive() {
    let issue = fixtures::issue();
    let (_dir, path, zip) = build_edition_to_bytes(&issue, Edition::Standard);

    assert_eq!(
        path.file_name().and_then(|n| n.to_str()),
        Some("The Daily EPUB - 2026-08-15.epub")
    );
    assert_eq!(&zip[0..4], b"PK\x03\x04", "starts with a zip local header");
    assert_eq!(
        &zip[30..38],
        b"mimetype",
        "`mimetype` must be the first entry"
    );
    assert_eq!(&zip[38..58], b"application/epub+zip");

    for entry in [
        "META-INF/container.xml",
        "OEBPS/content.opf",
        "OEBPS/toc.ncx",
        "OEBPS/nav.xhtml",
        "OEBPS/stylesheet.css",
        "OEBPS/cover.png",
        "OEBPS/cover.xhtml",
        "OEBPS/front.xhtml",
        "OEBPS/in-this-issue.xhtml",
        "OEBPS/sec-top-stories.xhtml",
        "OEBPS/art-1001.xhtml",
        "OEBPS/disc-1001.xhtml",
        "OEBPS/sec-niche-corner.xhtml",
        "OEBPS/art-1002.xhtml",
        "OEBPS/world.xhtml",
        "OEBPS/behind.xhtml",
        "OEBPS/colophon.xhtml",
    ] {
        assert!(contains_entry(&zip, entry), "missing {entry}");
    }
}

#[test]
fn x4_edition_is_built_alongside_the_standard_one() {
    let issue = fixtures::issue();
    let (_dir, path, zip) = build_edition_to_bytes(&issue, Edition::X4);
    assert_eq!(
        path.file_name().and_then(|n| n.to_str()),
        Some("The Daily EPUB - 2026-08-15 (X4).epub")
    );
    assert_eq!(&zip[30..38], b"mimetype");
    assert!(contains_entry(&zip, "OEBPS/art-1001.xhtml"));
    assert!(contains_entry(&zip, "OEBPS/cover.jpg"));
    assert!(!contains_entry(&zip, "OEBPS/cover.png"));
    let jpeg = read_entry_bytes(&zip, "OEBPS/cover.jpg");
    let decoded = image::load_from_memory(&jpeg).expect("X4 cover decodes");
    assert_eq!((decoded.width(), decoded.height()), (480, 800));
    assert_eq!(decoded.color(), image::ColorType::Rgb8);
    assert!(jpeg.windows(2).any(|marker| marker == [0xff, 0xc0]));
    assert!(!jpeg.windows(2).any(|marker| marker == [0xff, 0xc2]));
    let opf = read_entry(&zip, "OEBPS/content.opf");
    let cover_page = read_entry(&zip, "OEBPS/cover.xhtml");
    assert!(opf.contains("href=\"cover.jpg\""), "{opf}");
    assert!(opf.contains("media-type=\"image/jpeg\""), "{opf}");
    assert!(opf.contains("properties=\"cover-image\""), "{opf}");
    assert!(
        opf.contains("<meta name=\"cover\" content=\"cover-image\"/>"),
        "{opf}"
    );
    assert!(cover_page.contains("src=\"cover.jpg\""), "{cover_page}");
}

/// Both editions land in the same BookOrbit library, which lists books by
/// `dc:title` — so the edition has to be in the title, not just the filename
/// (§3.10). Without this the two are indistinguishable in the library UI and
/// over OPDS.
#[test]
fn the_two_editions_have_distinct_titles_in_the_opf() {
    let issue = fixtures::issue();
    let (_d1, _, standard) = build_edition_to_bytes(&issue, Edition::Standard);
    let (_d2, _, x4) = build_edition_to_bytes(&issue, Edition::X4);

    let standard_opf = read_entry(&standard, "OEBPS/content.opf");
    let x4_opf = read_entry(&x4, "OEBPS/content.opf");

    assert!(
        standard_opf.contains("<dc:title>The Daily EPUB \u{2014} 2026-08-15</dc:title>"),
        "{standard_opf}"
    );
    assert!(
        x4_opf.contains("<dc:title>The Daily EPUB \u{2014} 2026-08-15 (X4)</dc:title>"),
        "{x4_opf}"
    );

    // The series metadata still groups them: same collection, same position, so
    // they sort together rather than as two unrelated books.
    for opf in [&standard_opf, &x4_opf] {
        assert!(opf.contains("belongs-to-collection"), "{opf}");
        assert!(opf.contains("<dc:date>2026-08-15</dc:date>"), "{opf}");
        assert!(opf.contains("<dc:language>en</dc:language>"), "{opf}");
        assert_eq!(opf.matches("id=\"epub-creator-0\"").count(), 1, "{opf}");
    }
}

#[test]
fn chapter_ids_hrefs_and_toc_levels_are_stable() {
    let issue = fixtures::issue();
    let first =
        build::render_all(&issue, Edition::Standard, &[], "https://x.test", None).expect("render");
    let second =
        build::render_all(&issue, Edition::Standard, &[], "https://x.test", None).expect("render");
    assert_eq!(first, second, "rendering is deterministic");

    let map: Vec<(String, String, u8)> = first
        .iter()
        .map(|c| (c.id.clone(), c.href.clone(), c.toc_level))
        .collect();
    assert_eq!(
        map,
        vec![
            ("cover".into(), "cover.xhtml".into(), 1),
            ("front".into(), "front.xhtml".into(), 1),
            ("in-this-issue".into(), "in-this-issue.xhtml".into(), 1),
            ("sec-Top Stories".into(), "sec-top-stories.xhtml".into(), 1),
            ("art-1001".into(), "art-1001.xhtml".into(), 2),
            ("disc-1001".into(), "disc-1001.xhtml".into(), 3),
            (
                "sec-Niche Corner".into(),
                "sec-niche-corner.xhtml".into(),
                1
            ),
            ("art-1002".into(), "art-1002.xhtml".into(), 2),
            ("world".into(), "world.xhtml".into(), 1),
            ("behind".into(), "behind.xhtml".into(), 1),
            ("colophon".into(), "colophon.xhtml".into(), 1),
        ]
    );
}

/// "Behind the paper" sits between the World Briefing and the colophon in both
/// editions, carries the counts, the admission mix, the learned-signal state,
/// the near misses and the models, and has no links (§15.1).
#[test]
fn behind_the_paper_renders_counts_and_near_misses_in_both_editions() {
    let issue = fixtures::issue();
    for edition in [Edition::Standard, Edition::X4] {
        let (_dir, _, zip) = build_edition_to_bytes(&issue, edition);
        let behind = read_entry(&zip, "OEBPS/behind.xhtml");
        assert!(behind.contains("<h1>Behind the paper</h1>"), "{behind}");
        assert!(
            behind.contains(
                "Considered 412 articles from 1,465 feeds \u{00b7} 398 eligible \u{00b7} 398 triaged \u{00b7} 120 read closely \u{00b7} 60 shortlisted \u{00b7} 2 selected."
            ),
            "{behind}"
        );
        assert!(
            behind.contains(
                "Admitted via: triage 60 \u{00b7} interests 20 \u{00b7} your ratings 12 \u{00b7} exploration 5 \u{00b7} blend 23."
            ),
            "{behind}"
        );
        assert!(
            behind.contains(
                "Learned signals: 14 rated articles with embeddings (neighbour signal at 35%); feed affinity off."
            ),
            "{behind}"
        );
        assert!(behind.contains("<h2>Near misses</h2>"), "{behind}");
        assert!(
            behind.contains(
                "<li>The One That Got Away \u{2014} Example Feed \u{00b7} quality 8.0 \u{00b7} fit 6.5 \u{00b7} shortlisted, not selected</li>"
            ),
            "{behind}"
        );
        assert!(
            behind.contains(
                "<li>Never Read Closely \u{2014} Other Feed \u{00b7} triaged, not admitted</li>"
            ),
            "{behind}"
        );
        assert!(
            behind.contains(
                "Models: triage and assessment deepseek-v4-flash \u{00b7} editor and summaries claude-opus-5 \u{00b7} embeddings voyage-4-lite. Cost $0.81. Generation 23 min."
            ),
            "{behind}"
        );
        assert!(!behind.contains("<a "), "no links in {edition:?}: {behind}");
        assert!(!behind.contains("&nbsp;"));
        for tag in ["html", "head", "body", "div", "p", "ul", "li", "h1", "h2"] {
            // `<li` would also match `<link`; count real opening tags only.
            let opens = behind.matches(&format!("<{tag}>")).count()
                + behind.matches(&format!("<{tag} ")).count();
            let closes = behind.matches(&format!("</{tag}>")).count();
            assert_eq!(opens, closes, "unbalanced <{tag}> in {edition:?}");
        }

        // Ordering: world → behind → colophon in the spine.
        let opf = read_entry(&zip, "OEBPS/content.opf");
        let position = |href: &str| opf.find(&format!("href=\"{href}\"")).expect(href);
        assert!(position("world.xhtml") < position("behind.xhtml"));
        assert!(position("behind.xhtml") < position("colophon.xhtml"));
    }
}

#[test]
fn every_chapter_is_parseable_xhtml() {
    let issue = fixtures::issue();
    let chapters = build::render_all(
        &issue,
        Edition::Standard,
        &[],
        "https://daily.hallada.net",
        Some("integration-secret"),
    )
    .expect("render");

    for chapter in &chapters {
        let xhtml = &chapter.xhtml;
        assert!(
            xhtml.starts_with("<?xml version=\"1.0\" encoding=\"utf-8\"?>"),
            "{} lacks an XML prologue",
            chapter.id
        );
        assert!(xhtml.contains("xmlns=\"http://www.w3.org/1999/xhtml\""));
        assert!(xhtml.trim_end().ends_with("</html>"));
        // Undefined XML entities (html5ever's `&nbsp;`) would break XML parsers.
        assert!(!xhtml.contains("&nbsp;"), "{} has &nbsp;", chapter.id);
        for tag in ["html", "head", "body", "div", "p", "a", "blockquote"] {
            let opens = xhtml.matches(&format!("<{tag}")).count();
            let closes = xhtml.matches(&format!("</{tag}>")).count();
            assert_eq!(opens, closes, "unbalanced <{tag}> in {}", chapter.id);
        }
        // Void elements are self-closed.
        for void in ["<br>", "<hr>", "<link ", "<meta charset=\"utf-8\">"] {
            assert!(!xhtml.contains(void) || xhtml.contains("/>"), "{void}");
        }
        // Every `&` opens an entity XML actually defines.
        for (i, _) in xhtml.match_indices('&') {
            let tail = &xhtml[i + 1..];
            assert!(
                is_defined_entity(tail),
                "bare `&` at byte {i} in {}: {:?}",
                chapter.id,
                &xhtml[i..(i + 24).min(xhtml.len())]
            );
        }
    }
}

/// XML predefines only these five names; everything else must be numeric.
fn is_defined_entity(after_ampersand: &str) -> bool {
    let Some(end) = after_ampersand.find(';') else {
        return false;
    };
    let name = &after_ampersand[..end];
    if matches!(name, "amp" | "lt" | "gt" | "quot" | "apos") {
        return true;
    }
    match name.strip_prefix('#') {
        Some(rest) => match rest.strip_prefix('x').or_else(|| rest.strip_prefix('X')) {
            Some(hex) => !hex.is_empty() && hex.chars().all(|c| c.is_ascii_hexdigit()),
            None => !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()),
        },
        None => false,
    }
}

#[test]
fn rating_links_carry_the_spec_token() {
    let issue = fixtures::issue();
    let chapter = build::render_all(
        &issue,
        Edition::Standard,
        &[],
        "https://daily.hallada.net",
        Some("integration-secret"),
    )
    .expect("render")
    .into_iter()
    .find(|c| c.id == "art-1001")
    .expect("article chapter");

    let date = issue.meta.date;
    let loved = build::rating_token("integration-secret", date, 1, Vote::Loved);
    let good = build::rating_token("integration-secret", date, 1, Vote::Good);
    let down = build::rating_token("integration-secret", date, 1, Vote::NotForMe);
    assert_eq!(loved.len(), 16);
    assert_ne!(loved, good);
    assert_ne!(good, down);
    for (segment, token) in [("loved", loved), ("good", good), ("down", down)] {
        assert!(chapter.xhtml.contains(&format!(
            "https://daily.hallada.net/r/2026-08-15/1/{segment}?t={token}"
        )));
    }
    assert!(chapter.xhtml.contains("[ Loved it ]"));
    assert!(chapter.xhtml.contains("[ Good ]"));
    assert!(chapter.xhtml.contains("[ Not for me ]"));
}

#[test]
fn comment_and_world_fixtures_feed_real_chapters() {
    let hn: serde_json::Value = serde_json::from_str(
        &std::fs::read_to_string(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixtures/hn_item.json"
        ))
        .expect("fixture"),
    )
    .expect("json");
    let thread = comments::parse_hn(&hn).expect("thread");
    assert_eq!(thread.total_comments, 4);

    let html = std::fs::read_to_string(concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/tests/fixtures/wikipedia_current_events.html"
    ))
    .expect("fixture");
    let sections = world::extract_events(
        &html,
        "https://en.wikipedia.org/wiki/Portal:Current_events/2026_August_14",
    )
    .expect("events");
    assert_eq!(sections.len(), 3);
    assert_eq!(sections[0].events[0].children.len(), 1);
    assert!(!sections[0].events[0].links.is_empty());
}

#[test]
fn colophon_facts_are_x4_safe_distinct_paragraphs() {
    let issue = fixtures::issue();
    for edition in [Edition::Standard, Edition::X4] {
        let (_dir, _, zip) = build_edition_to_bytes(&issue, edition);
        let colophon = read_entry(&zip, "OEBPS/colophon.xhtml");
        // Issue, generated, three model lines, entries, candidates, articles,
        // words, two per-provider cost lines, the total, generator (§15.1).
        assert_eq!(colophon.matches("<p class=\"fact-line\">").count(), 13);
        assert!(colophon.contains("<strong>Editor model:</strong> claude-opus-5"));
        assert!(colophon.contains("<strong>Bulk model:</strong> deepseek-v4-flash"));
        assert!(colophon.contains("<strong>anthropic cost:</strong> $0.0500"));
        assert!(colophon.contains("<strong>deepseek cost:</strong> $0.0231"));
        assert!(colophon.contains("<strong>Total token cost:</strong>"));
        assert!(!colophon.contains("<dl"));
        assert!(!colophon.contains("<dt"));
        assert!(!colophon.contains("<dd"));
        assert!(colophon.contains("<strong>Issue:</strong>"));
        assert!(colophon.contains("<strong>Generator:</strong>"));
    }
}
