from __future__ import annotations

import hashlib
import json
import zipfile
from pathlib import Path


ROOT = Path(__file__).resolve().parent
METADATA = json.loads((ROOT / "paper-metadata.json").read_text(encoding="utf-8"))
REPORT_NUMBER = str(METADATA["reportNumber"])
VERSION = str(METADATA["version"])
ARCHIVE = ROOT / f"{REPORT_NUMBER}-v{VERSION}.zip"
MANIFEST = ROOT / "release-manifest.json"
HASH_FILE = ROOT / f"{ARCHIVE.name}.sha256"
REPORT = ROOT / "package-validation-report.json"
FORBIDDEN = (
    "__pycache__", ".pyc", ".pytest_cache", "draft-package", "v0.1-draft", "v1.1-draft",
    "paper-metadata-v1.1-draft", "paper-content-v1.1-draft", "zenodo-request.json",
    "zenodo-reserve-receipt", "zenodo-publish-receipt", "precise_latitude", "precise_longitude",
    "full_html", "raw_html", "page_text", "full_description", "review_text", "screenshot",
    "image_binary", "customer_name", "phone", "email", "address",
)


def digest_bytes(value: bytes) -> str:
    return hashlib.sha256(value).hexdigest()


def digest_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def main() -> None:
    errors: list[str] = []
    for path in (ARCHIVE, MANIFEST, HASH_FILE):
        if not path.is_file():
            errors.append(f"missing package artifact: {path.name}")
    if errors:
        raise FileNotFoundError("; ".join(errors))
    manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
    identity_ok = all((
        manifest.get("reportNumber") == REPORT_NUMBER,
        manifest.get("version") == VERSION == "1.1",
        manifest.get("doi") == METADATA.get("doi") == "10.5281/zenodo.21791470",
        manifest.get("status") == "PUBLIC_RELEASE",
        manifest.get("license") == "CC BY 4.0",
        manifest.get("publicLicenseGranted") is True,
        manifest.get("fieldFlightExecuted") is False,
        manifest.get("realMavic3MFilesIncluded") is False,
        manifest.get("thirdPartyRawContentIncluded") is False,
        manifest.get("thirdPartyResearchDataIncluded") is False,
        manifest.get("thirdPartyDerivedDataIncluded") is True,
        manifest.get("thirdPartyFontSoftwareIncluded") is True,
        manifest.get("thirdPartyFontLicenseIncluded") is True,
        manifest.get("personalDataIncluded") is False,
        manifest.get("syntheticMetricsAreFieldAccuracy") is False,
    ))
    if not identity_ok:
        errors.append("release identity, rights or evidence boundary is inconsistent")
    records = manifest.get("files", [])
    expected = {record["path"]: record for record in records}
    expected_names = set(expected) | {"release-manifest.json"}
    if manifest.get("fileCount") != len(records):
        errors.append("manifest fileCount does not match records")
    font_rights = manifest.get("thirdPartyRedistribution", [])
    if font_rights != [{
        "path": "assets/fonts/NotoSansTC-Regular.ttf",
        "copyright": "Copyright 2014-2021 Adobe, with Reserved Font Name 'Source'",
        "license": "SIL Open Font License 1.1",
        "licensePath": "assets/fonts/OFL.txt",
        "relicensedUnderCcBy": False,
    }]:
        errors.append("third-party font redistribution disclosure is incomplete")
    if "assets/fonts/OFL.txt" not in expected or "assets/fonts/NotoSansTC-Regular.ttf" not in expected:
        errors.append("font software or OFL licence is missing from release manifest")
    focused_required = {
        "run_focused_analysis.py",
        "requirements-traceability-v1.1.csv",
        "source-registry-v1.1-addendum.csv",
        "m3m_twin/envelope.py",
        "m3m_twin/srf.py",
        "tests_v1_1/test_envelope.py",
        "tests_v1_1/test_srf.py",
        "outputs/focused/focused-analysis-receipt.json",
        "outputs/focused/m3m-spectral-response-receipt.json",
        "outputs/focused/m3m-spectral-response-summary.csv",
        "outputs/focused/mission-feasibility-envelope.csv",
        "outputs/figures/figure-5-measured-spectral-response.png",
        "outputs/figures/figure-6-mission-feasibility-envelope.png",
    }
    missing_focused = sorted(focused_required - set(expected))
    if missing_focused:
        errors.append("v1.1 focused assets missing from release manifest: " + ", ".join(missing_focused))
    with zipfile.ZipFile(ARCHIVE) as archive:
        names = archive.namelist()
        if len(names) != len(set(names)):
            errors.append("archive contains duplicate members")
        if set(names) != expected_names:
            errors.append("archive members differ from release manifest")
        forbidden = [name for name in names if any(token.lower() in name.lower() for token in FORBIDDEN)]
        if forbidden:
            errors.append("forbidden archive members: " + ", ".join(forbidden))
        if archive.testzip() is not None:
            errors.append("archive CRC check failed")
        if json.loads(archive.read("release-manifest.json")) != manifest:
            errors.append("embedded release manifest differs")
        for name, record in expected.items():
            value = archive.read(name)
            if len(value) != record["bytes"] or digest_bytes(value) != record["sha256"]:
                errors.append(f"size or SHA-256 mismatch: {name}")
    archive_hash = digest_file(ARCHIVE)
    if HASH_FILE.read_text(encoding="utf-8").strip() != f"{archive_hash}  {ARCHIVE.name}":
        errors.append("archive checksum sidecar is inconsistent")
    report = {
        "schemaVersion": 1,
        "validatedOn": "2026-08-04",
        "status": "PASSED" if not errors else "FAILED",
        "archive": ARCHIVE.name,
        "archiveBytes": ARCHIVE.stat().st_size,
        "archiveSha256": archive_hash,
        "manifestFileCount": len(records),
        "identityAndRightsBoundaryPassed": identity_ok,
        "errors": errors,
    }
    REPORT.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print(json.dumps({"status": report["status"], "errors": len(errors), "sha256": archive_hash}, ensure_ascii=False))
    if errors:
        raise SystemExit(1)


if __name__ == "__main__":
    main()
