from __future__ import annotations

import hashlib
import json
from pathlib import Path

import pytest
from bs4 import BeautifulSoup

import build_explorer_site as site


def test_rendered_site_has_bilingual_science_boundaries_and_valid_links(
    tmp_path: Path,
) -> None:
    target = tmp_path / "rendered"
    site.render_site(target)
    site.validate_site(target)
    text = (target / "index.html").read_text(encoding="utf-8")
    for phrase in (
        "观测视线总和",
        "observed line-of-sight sum",
        "条件模板",
        "conditional template",
        "固定NH的几何曲线",
        "fixed-NH geometry curve",
        "模型张力残差",
        "model-tension residual",
        "±3.403 kpc",
        "pointing center",
    ):
        assert phrase in text
    soup = BeautifulSoup(text, "html.parser")
    assert len(soup.select("[data-zh]")) >= 50
    assert len(soup.select("[data-en]")) >= 50
    assert all(image.get("alt") for image in soup.find_all("img"))
    first_zh_header = soup.select_one("table.data th [data-zh]")
    first_en_header = soup.select_one("table.data th [data-en]")
    assert (
        first_zh_header is not None and first_zh_header.get_text(strip=True) == "分区"
    )
    assert (
        first_en_header is not None and first_en_header.get_text(strip=True) == "Side"
    )
    assert any(
        node.get_text(strip=True) == "Observed CGMsum"
        for node in soup.select("table.data th [data-en]")
    )
    assert len(soup.select('table.data th[scope="col"]')) == 8
    assert len(soup.select('table.symbol-table th[scope="col"]')) == 7
    assert len(soup.select(".reading-steps > li")) == 5
    assert soup.select_one('a[data-lang-button="en"][href="index-en.html"]')
    english = BeautifulSoup(
        (target / "index-en.html").read_text(encoding="utf-8"), "html.parser"
    )
    assert english.html is not None
    assert english.title is not None
    assert english.html["lang"] == "en"
    assert english.html["data-lang"] == "en"
    assert "How the MW reference enters M31 Figure 3" in english.title.get_text()
    assert (
        "M31 surface-brightness profile" in text
    )  # appears only in an explicit forbidden-reading warning


def test_manifest_matches_all_deployed_files(tmp_path: Path) -> None:
    target = tmp_path / "rendered"
    site.render_site(target)
    manifest = json.loads((target / "report_manifest.json").read_text(encoding="utf-8"))
    listed = {item["path"] for item in manifest["files"]}
    actual = {
        path.relative_to(target).as_posix()
        for path in target.rglob("*")
        if path.is_file()
        and path.name not in {"report_manifest.json", "_headers", "_redirects"}
    }
    assert listed == actual
    assert manifest["file_count"] == len(actual)
    assert manifest["total_bytes"] == sum(
        (target / path).stat().st_size for path in actual
    )
    for item in manifest["files"]:
        path = target / item["path"]
        assert item["bytes"] == path.stat().st_size
        assert item["sha256"] == hashlib.sha256(path.read_bytes()).hexdigest()
    assert (
        manifest["builder_sha256"]
        == hashlib.sha256(
            (target / "assets/source/build_explorer_site.py").read_bytes()
        ).hexdigest()
    )


def test_validation_rejects_forbidden_scientific_claim(tmp_path: Path) -> None:
    target = tmp_path / "rendered"
    site.render_site(target)
    index = target / "index.html"
    page = index.read_text(encoding="utf-8").replace(
        "</main>", "<p>The 14 observed points are an M31 profile.</p></main>"
    )
    index.write_text(page, encoding="utf-8")
    with pytest.raises(ValueError, match="forbidden scientific wording"):
        site.validate_site(target)


def test_validation_rejects_missing_fragment(tmp_path: Path) -> None:
    target = tmp_path / "rendered"
    site.render_site(target)
    index = target / "index.html"
    page = index.read_text(encoding="utf-8").replace(
        "</main>", '<a href="#missing-audit-anchor">bad anchor</a></main>'
    )
    index.write_text(page, encoding="utf-8")
    with pytest.raises(FileNotFoundError, match="missing fragment"):
        site.validate_site(target)


def test_validation_rejects_unlisted_file_and_duplicate_manifest_path(
    tmp_path: Path,
) -> None:
    target = tmp_path / "rendered"
    site.render_site(target)
    (target / "unexpected.txt").write_text("not manifested", encoding="utf-8")
    with pytest.raises(ValueError, match="manifest inventory mismatch"):
        site.validate_site(target)

    (target / "unexpected.txt").unlink()
    manifest_path = target / "report_manifest.json"
    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    manifest["files"].append(dict(manifest["files"][0]))
    manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
    with pytest.raises(ValueError, match="unique strings"):
        site.validate_site(target)


def test_atomic_exchange_never_removes_final_path(tmp_path: Path) -> None:
    final = tmp_path / "site"
    staging = tmp_path / "staging"
    final.mkdir()
    staging.mkdir()
    (final / "generation.txt").write_text("old", encoding="utf-8")
    (staging / "generation.txt").write_text("new", encoding="utf-8")
    site.atomic_exchange(staging, final)
    assert final.is_dir()
    assert (final / "generation.txt").read_text(encoding="utf-8") == "new"
    assert (staging / "generation.txt").read_text(encoding="utf-8") == "old"


def test_validation_rejects_unmanifested_deployment_config(tmp_path: Path) -> None:
    target = tmp_path / "rendered"
    site.render_site(target)
    manifest_path = target / "report_manifest.json"
    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    manifest["deployment_config"] = []
    manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
    with pytest.raises(ValueError, match="deployment config inventory mismatch"):
        site.validate_site(target)


def test_loglog_contribution_plane_inventory_is_complete() -> None:
    inventory = site.source_inventory()
    expected = {
        f"assets/figures/draft_figure3_contribution_plane_loglog_{column}.{suffix}"
        for column in ("1col", "2col")
        for suffix in ("png", "pdf")
    }
    assert expected <= inventory.keys()
    assert all(inventory[path].is_file() for path in expected)


def test_failed_build_preserves_previous_site_and_parent_files(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    parent = tmp_path / "cloudflare"
    final = parent / "site"
    final.mkdir(parents=True)
    (final / "known-good.txt").write_text("old validated site", encoding="utf-8")
    (parent / "keep-me.txt").write_text("parent sentinel", encoding="utf-8")
    (parent / ".site-staging").write_text("reserved sentinel", encoding="utf-8")
    (parent / ".site-backup").write_text("reserved sentinel", encoding="utf-8")

    def fail_render(target: Path, **_: object) -> None:
        target.mkdir(parents=True)
        (target / "partial.txt").write_text("incomplete", encoding="utf-8")
        raise RuntimeError("simulated generation failure")

    monkeypatch.setattr(site, "render_site", fail_render)
    with pytest.raises(RuntimeError, match="simulated generation failure"):
        site.build(final)
    assert (final / "known-good.txt").read_text(
        encoding="utf-8"
    ) == "old validated site"
    assert (parent / "keep-me.txt").read_text(encoding="utf-8") == "parent sentinel"
    assert (parent / ".site-staging").read_text(encoding="utf-8") == "reserved sentinel"
    assert (parent / ".site-backup").read_text(encoding="utf-8") == "reserved sentinel"
    assert not list(parent.glob(".site-build-*"))


def test_successful_build_promotes_staging_and_preserves_parent(tmp_path: Path) -> None:
    parent = tmp_path / "cloudflare"
    final = parent / "site"
    final.mkdir(parents=True)
    (final / "obsolete.txt").write_text("old", encoding="utf-8")
    (parent / "keep-me.txt").write_text("parent sentinel", encoding="utf-8")
    (parent / ".site-staging").write_text("reserved sentinel", encoding="utf-8")
    (parent / ".site-backup").write_text("reserved sentinel", encoding="utf-8")
    site.build(final)
    assert (final / "index.html").is_file()
    assert (final / "report_manifest.json").is_file()
    assert not (final / "obsolete.txt").exists()
    assert (parent / "keep-me.txt").read_text(encoding="utf-8") == "parent sentinel"
    assert (parent / ".site-staging").read_text(encoding="utf-8") == "reserved sentinel"
    assert (parent / ".site-backup").read_text(encoding="utf-8") == "reserved sentinel"
    assert not list(parent.glob(".site-build-*"))


def test_pages_headers_are_hashed_as_deployment_config_not_public_asset(
    tmp_path: Path,
) -> None:
    target = tmp_path / "rendered"
    site.render_site(target)
    headers = (target / "_headers").read_text(encoding="utf-8")
    manifest = json.loads((target / "report_manifest.json").read_text(encoding="utf-8"))
    assert "Content-Security-Policy" in headers
    assert "_headers" not in {item["path"] for item in manifest["files"]}
    assert manifest["deployment_config"] == [
        {
            "path": "_headers",
            "bytes": (target / "_headers").stat().st_size,
            "sha256": hashlib.sha256((target / "_headers").read_bytes()).hexdigest(),
        }
    ]


def test_mobile_and_accessibility_contract() -> None:
    css = (site.TEMPLATE_ROOT / "styles.css").read_text(encoding="utf-8")
    js = (site.TEMPLATE_ROOT / "app.js").read_text(encoding="utf-8")
    template = (site.TEMPLATE_ROOT / "index.html").read_text(encoding="utf-8")
    assert "@media (max-width: 560px)" in css
    assert "scroll-behavior: smooth" not in css
    assert "overflow-x: auto" in css
    assert ".table-scroll" in css
    assert "min-height: 2.75rem" in css
    assert ".download span:first-child" in css
    assert "overflow-wrap: anywhere" in css
    assert "prefers-reduced-motion" in css
    assert "focus-visible" in css
    assert "event.key === 'Escape'" in js
    assert "element.inert = value" in js
    assert "event.key !== 'Tab'" in js
    assert "window.history.replaceState" in js
    assert "aria-pressed" in js
    assert '<html lang="zh-CN" data-lang="zh">' in template
    assert 'loading="lazy"' not in template  # deterministic primary-image browser QA
