from __future__ import annotations

import hashlib
import json
import re
import stat
import sys
import zipfile
from datetime import datetime
from pathlib import Path, PurePosixPath
from zoneinfo import ZoneInfo


ROOT = Path(__file__).resolve().parent
META = json.loads((ROOT / "paper-metadata.json").read_text(encoding="utf-8"))
VERSION = META["version"]
REPORT_NUMBER = META["reportNumber"]
ZIP = ROOT / f"{REPORT_NUMBER}-v{VERSION}.zip"
MANIFEST = ROOT / "release-manifest.json"
CHECKSUM = ROOT / f"{ZIP.name}.sha256"
REPORT = ROOT / "package-validation-report.json"
SENSITIVE_PATH = re.compile(r"(^|[._-])(\.env|password|secret|token|cookie|credential|private[._-]?key)([._-]|$)", re.I)
ZENODO_PATTERN = re.compile(rb"10\.5281/zenodo\.\d+")


def now_taipei() -> str:
    return datetime.now(ZoneInfo("Asia/Taipei")).replace(microsecond=0).isoformat()


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


def sha256(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 unsafe_name(name: str) -> str | None:
    if not name or "\\" in name or name.startswith("/") or re.match(r"^[A-Za-z]:", name):
        return "absolute, empty, or backslash path"
    path = PurePosixPath(name)
    if any(part in {"", ".", ".."} for part in path.parts):
        return "path traversal or ambiguous path"
    if any(SENSITIVE_PATH.search(part) for part in path.parts):
        return "sensitive-looking path"
    return None


def main() -> None:
    errors: list[str] = []
    checks: dict[str, object] = {}

    if not ZIP.is_file() or not MANIFEST.is_file():
        errors.append("Archive or release-manifest.json is missing")
    else:
        manifest_bytes = MANIFEST.read_bytes()
        try:
            manifest = json.loads(manifest_bytes.decode("utf-8"))
        except (UnicodeDecodeError, json.JSONDecodeError) as error:
            manifest = {}
            errors.append(f"Manifest is not valid UTF-8 JSON: {error}")

        records = manifest.get("files", []) if isinstance(manifest, dict) else []
        record_names = [record.get("path") for record in records if isinstance(record, dict)]
        if len(record_names) != len(records) or any(not isinstance(name, str) for name in record_names):
            errors.append("Manifest file records are malformed")
        if len(set(record_names)) != len(record_names):
            errors.append("Manifest contains duplicate paths")
        for name in record_names:
            issue = unsafe_name(name)
            if issue:
                errors.append(f"Unsafe manifest path {name!r}: {issue}")
        if "assets/schemas/cff-1.2.0-schema.json" in record_names:
            errors.append("CFF schema must stay local and outside the public ZIP because it contains unrelated example identifiers")

        try:
            with zipfile.ZipFile(ZIP) as archive:
                infos = archive.infolist()
                names = [info.filename for info in infos]
                if len(names) != len(set(names)):
                    errors.append("ZIP contains duplicate entry names")
                if len(infos) > 500:
                    errors.append(f"ZIP contains too many entries: {len(infos)}")
                total_expanded = sum(info.file_size for info in infos)
                if total_expanded > 500 * 1024 * 1024:
                    errors.append(f"ZIP expanded size exceeds limit: {total_expanded}")
                for info in infos:
                    issue = unsafe_name(info.filename)
                    if issue:
                        errors.append(f"Unsafe ZIP entry {info.filename!r}: {issue}")
                    if info.flag_bits & 0x1:
                        errors.append(f"Encrypted ZIP entry is not allowed: {info.filename}")
                    mode = (info.external_attr >> 16) & 0xFFFF
                    if stat.S_ISLNK(mode):
                        errors.append(f"Symlink ZIP entry is not allowed: {info.filename}")
                    if info.compress_size and info.file_size / info.compress_size > 200:
                        errors.append(f"ZIP compression ratio exceeds limit: {info.filename}")

                expected_names = sorted([*record_names, "release-manifest.json"])
                if sorted(names) != expected_names:
                    missing = sorted(set(expected_names) - set(names))
                    extra = sorted(set(names) - set(expected_names))
                    errors.append(f"ZIP entry set differs from manifest (missing={missing}, extra={extra})")
                if "release-manifest.json" in names and archive.read("release-manifest.json") != manifest_bytes:
                    errors.append("Embedded manifest differs from external manifest")

                records_by_name = {record["path"]: record for record in records if isinstance(record, dict) and isinstance(record.get("path"), str)}
                doi_hits: dict[str, list[str]] = {}
                for name, record in records_by_name.items():
                    if name not in names:
                        continue
                    data = archive.read(name)
                    if record.get("bytes") != len(data):
                        errors.append(f"ZIP byte count mismatch: {name}")
                    if record.get("sha256") != sha256_bytes(data):
                        errors.append(f"ZIP SHA-256 mismatch: {name}")
                    local = ROOT / name
                    if not local.is_file() or local.read_bytes() != data:
                        errors.append(f"ZIP entry differs from frozen local payload: {name}")
                    matches = sorted({item.decode("ascii") for item in ZENODO_PATTERN.findall(data)})
                    if matches:
                        doi_hits[name] = matches

                expected_doi = META.get("doi")
                all_dois = sorted({doi for matches in doi_hits.values() for doi in matches})
                if expected_doi:
                    if all_dois != [expected_doi]:
                        errors.append(f"ZIP contains DOI set {all_dois}, expected only {expected_doi}")
                elif all_dois:
                    errors.append(f"Pre-reservation ZIP contains unexpected Zenodo DOI(s): {all_dois}")
                checks["doiOccurrencesByFile"] = doi_hits

                bad_crc = archive.testzip()
                if bad_crc:
                    errors.append(f"ZIP CRC failure: {bad_crc}")
                checks["entries"] = len(infos)
                checks["expandedBytes"] = total_expanded
        except (zipfile.BadZipFile, OSError) as error:
            errors.append(f"Invalid ZIP archive: {error}")

        if not errors:
            zip_hash = sha256(ZIP)
            CHECKSUM.write_text(f"{zip_hash}  {ZIP.name}\n", encoding="ascii")
            checks.update({"zipBytes": ZIP.stat().st_size, "zipSha256": zip_hash, "manifestSha256": sha256(MANIFEST), "checksumFile": CHECKSUM.name})

    result = {
        "schemaVersion": 2,
        "reportNumber": REPORT_NUMBER,
        "version": VERSION,
        "validatedAt": now_taipei(),
        "status": "PASSED" if not errors else "FAILED",
        "scope": "Post-package archive integrity, sensitive-path, exact-manifest and single-candidate-DOI validation; report and checksum remain outside the immutable ZIP.",
        "checks": checks,
        "errors": errors,
    }
    REPORT.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print(json.dumps({"status": result["status"], "errors": len(errors), "report": str(REPORT)}, ensure_ascii=False))
    if errors:
        sys.exit(1)


if __name__ == "__main__":
    main()
