#!/usr/bin/env python3
"""Check submission.zip the way the evaluator will.

Every failure printed here would otherwise be a silent zero.
"""

from __future__ import annotations

import codecs
import hashlib
from pathlib import Path
import re
import stat
import os
import shutil
import subprocess
import tempfile
import pathlib
import sys
import zipfile

ROOT = Path(__file__).resolve().parent.parent
ZIP = ROOT / "submission.zip"

# These are also the only paths the evaluator accepts: an archive holding
# anything else is rejected as malformed before a single test runs.
REQUIRED_FILES = ("submission/RELEASE_NOTE.md", "submission/transcripts/INDEX.md")
REQUIRED_PREFIXES = ("submission/repository/", "submission/transcripts/native/")
# Every entry that is not one of these eight types is decoded as strict UTF-8 to
# the last byte, and a NUL in the first 4KB is fatal even when the bytes decode.
DATA_SUFFIXES = {".avif", ".gif", ".ico", ".jpeg", ".jpg", ".pdf", ".png", ".webp"}
PROBE_BYTES = 4 * 1024
# Rejected on the name alone, whatever the bytes hold. `.pyc`/`.pyo` are not on
# the evaluator's list but never belong in a submission and would fail the text
# scan anyway.
BINARY_SUFFIXES = {
    ".a", ".app", ".bin", ".class", ".com", ".dll", ".dmg", ".dylib", ".elf",
    ".exe", ".lib", ".msi", ".node", ".o", ".obj", ".out", ".pyc", ".pyo",
    ".so", ".wasm",
}
ARCHIVE_SUFFIXES = (
    ".zip", ".zipx", ".tar", ".tgz", ".tar.gz", ".tar.bz2", ".tar.xz", ".gz",
    ".bz2", ".xz", ".7z", ".rar", ".jar", ".war", ".ear", ".apk", ".ipa",
    ".whl", ".docx", ".xlsx", ".pptx",
)
MAX_ENTRIES = 20_000
MAX_COMPRESSED_BYTES = 100 * 1024 * 1024
MAX_EXPANDED_BYTES = 500 * 1024 * 1024
MAX_COMPRESSION_RATIO = 100
APPROVED_SURFACES = (
    "Claude Code CLI",
    "Claude Desktop’s local Code tab",
    "Codex CLI",
    "Codex in the ChatGPT desktop app (local)",
    "Cursor’s local IDE Agent",
    "Cursor Agent CLI in captured print mode",
)


def _fold(value: str) -> str:
    for a, b in (("‘", "'"), ("’", "'"), ("“", '"'), ("”", '"')):
        value = value.replace(a, b)
    return " ".join(value.split()).casefold()


def _text_problem(archive: zipfile.ZipFile, name: str) -> str | None:
    """Say why the evaluator cannot read this entry as text, or return None.

    Streamed rather than read whole, because a transcript export can be large.
    """
    if Path(name).suffix.casefold() in DATA_SUFFIXES:
        return None
    decoder = codecs.getincrementaldecoder("utf-8")("strict")
    probe = bytearray()
    with archive.open(name) as entry:
        while chunk := entry.read(64 * 1024):
            if len(probe) < PROBE_BYTES:
                probe.extend(chunk[:PROBE_BYTES - len(probe)])
            try:
                decoder.decode(chunk)
            except UnicodeDecodeError:
                return f"{name} is not valid UTF-8"
        try:
            decoder.decode(b"", final=True)
        except UnicodeDecodeError:
            return f"{name} is not valid UTF-8"
    if b"\x00" in probe:
        return f"{name} contains a NUL byte"
    return None


def _settings_import_problem(repo: pathlib.Path) -> str | None:
    """Import `billing.settings` the way the grading environment will.

    Two ordinary production-readiness edits silently score zero without this
    check, because the grading environment differs from `make up` in ways
    nothing else here exposes: it sets no environment variables beyond the
    Django settings module, and the submission is mounted read-only.

      SECRET_KEY = os.environ["BILLING_SECRET_KEY"]   -> KeyError at import
      LOG_DIR.mkdir(exist_ok=True)                    -> read-only filesystem

    Either one aborts the import, every test fails to load, and the whole
    submission is scored as if nothing worked. The candidate sees none of it:
    their own compose file supplies the variable and their own container has a
    writable filesystem.
    """

    work = pathlib.Path(tempfile.mkdtemp(prefix="verify-import-"))
    try:
        tree = work / "submission"
        shutil.copytree(
            repo, tree,
            ignore=shutil.ignore_patterns(
                ".git", "__pycache__", "*.pyc", "transcripts", "submission.zip",
            ),
        )
        # Snapshot rather than chmod: the grading container runs unprivileged
        # against a read-only mount, but `make verify` may well run as root in
        # the candidate's own container, where permission bits are ignored.
        # Comparing the tree before and after the import catches the write
        # whatever the permissions say.
        before = {q.relative_to(tree).as_posix() for q in tree.rglob("*")}
        # Keep what the INTERPRETER needs to exist at all, drop everything an
        # application might have been given. Scrubbing the whole environment
        # would also hide Django from this subprocess and report every clean
        # submission as broken.
        keep = (
            "PATH", "HOME", "LANG", "LC_ALL", "TMPDIR",
            "VIRTUAL_ENV", "PYTHONHOME", "PYTHONPATH",
            "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "SYSTEMROOT",
        )
        env = {k: v for k, v in os.environ.items() if k in keep}
        env["DJANGO_SETTINGS_MODULE"] = "billing.settings"
        env["PYTHONDONTWRITEBYTECODE"] = "1"
        proc = subprocess.run(
            [sys.executable, "-c", "import django; django.setup()"],
            cwd=tree, env=env, capture_output=True, text=True, timeout=120,
        )
        if proc.returncode == 0:
            after = {q.relative_to(tree).as_posix() for q in tree.rglob("*")}
            created = sorted(
                q for q in after - before if not q.endswith((".pyc", "__pycache__"))
            )
            if created:
                return (
                    "importing billing.settings writes to disk: it created "
                    f"{created[0]}. Your code is mounted READ-ONLY when it is "
                    "checked, so this raises and every test fails to load. "
                    "Write under a temporary directory instead, or create it "
                    "lazily rather than at import."
                )
            return None
        tail = (proc.stderr.strip().splitlines() or ["(no output)"])[-1]
        if "No module named 'django'" in proc.stderr:
            # Django is not importable from this interpreter, so the check
            # cannot run here. That is our problem, not the submission's --
            # never fail a candidate for it.
            return None
        return (
            "billing.settings does not import in the graded environment, which "
            "sets no environment variables and mounts your code read-only. "
            "Every test would fail to load and the submission would score zero. "
            f"Last line: {tail}"
        )
    except Exception as error:  # pragma: no cover - never block on our own bug
        return f"could not run the settings import check ({error})"
    finally:
        for path in sorted(work.rglob("*"), reverse=True):
            try:
                path.chmod(0o700)
            except OSError:
                pass
        shutil.rmtree(work, ignore_errors=True)


def main() -> int:
    if not ZIP.is_file():
        print("[FAIL] submission.zip not found -- run `make submit` first", file=sys.stderr)
        return 1

    failures: list[str] = []
    with zipfile.ZipFile(ZIP) as archive:
        entries = archive.infolist()
        names = [n for n in archive.namelist() if not n.endswith("/")]

        junk = [n for n in names if "__MACOSX" in n or Path(n).name == ".DS_Store"
                or Path(n).name.startswith("._")]
        if junk:
            failures.append(
                f"{len(junk)} macOS metadata entries present (e.g. {junk[0]}). "
                "Do not build the zip with Finder's Compress -- use `make submit`."
            )
        # Finder metadata is reported once, above; the evaluator drops it before
        # the checks below, so judge everything else on its own.
        content = [n for n in names if n not in junk]

        nested = [n for n in content if n.casefold().endswith(ARCHIVE_SUFFIXES)]
        if nested:
            failures.append(f"nested archive present: {nested[0]}")
        binaries = [n for n in content if Path(n).suffix.casefold() in BINARY_SUFFIXES]
        if binaries:
            failures.append(f"compiled binary present: {binaries[0]}")

        special = [info.filename for info in entries
                   if stat.S_IFMT((info.external_attr >> 16) & 0xFFFF)
                   not in (0, stat.S_IFREG, stat.S_IFDIR)]
        if special:
            failures.append(
                f"symlink or device node present: {special[0]}. Only regular "
                "files may be submitted -- rebuild with `make submit`."
            )

        stray = [n for n in content
                 if n not in REQUIRED_FILES and not n.startswith(REQUIRED_PREFIXES)]
        if stray:
            failures.append(
                f"{len(stray)} file(s) outside the accepted layout (e.g. {stray[0]}). "
                "Only submission/RELEASE_NOTE.md, submission/transcripts/INDEX.md and "
                "files under submission/repository/ or submission/transcripts/native/ "
                "may be present -- an archive holding anything else is rejected "
                "unread and never scored."
            )
        seen: dict[str, str] = {}
        collisions = []
        for name in content:
            first = seen.setdefault(name.casefold(), name)
            if first != name:
                collisions.append(f"{first} and {name}")
        if collisions:
            failures.append(
                f"two paths differ only by case: {collisions[0]}. The evaluator "
                "rejects the archive rather than decide which one wins."
            )

        unreadable = [problem for n in content
                      if (problem := _text_problem(archive, n)) is not None]
        if unreadable:
            failures.append(
                f"{len(unreadable)} file(s) the evaluator cannot read as text "
                f"({unreadable[0]}). Everything except .avif .gif .ico .jpeg .jpg "
                ".pdf .png .webp must be UTF-8 with no NUL bytes -- export it as "
                "text or leave it out."
            )

        packed = ZIP.stat().st_size
        expanded = sum(info.file_size for info in entries if not info.is_dir())
        if len(entries) > MAX_ENTRIES:
            failures.append(f"{len(entries):,} entries; the limit is {MAX_ENTRIES:,}")
        if packed > MAX_COMPRESSED_BYTES:
            failures.append(
                f"the zip is {packed:,} bytes; the limit is {MAX_COMPRESSED_BYTES:,}"
            )
        if expanded > MAX_EXPANDED_BYTES:
            failures.append(
                f"the zip expands to {expanded:,} bytes; the limit is "
                f"{MAX_EXPANDED_BYTES:,}"
            )
        bloated = [info.filename for info in entries
                   if info.file_size > MAX_COMPRESSION_RATIO * max(info.compress_size, 1)]
        if bloated:
            failures.append(
                f"{bloated[0]} compresses more than {MAX_COMPRESSION_RATIO}:1, which "
                "the evaluator reads as a zip bomb and refuses to open"
            )

        for required in REQUIRED_FILES:
            if required not in names:
                failures.append(f"missing required file: {required}")
        for prefix in REQUIRED_PREFIXES:
            if not any(n.startswith(prefix) for n in names):
                failures.append(f"nothing under required path: {prefix}")

        index_name = "submission/transcripts/INDEX.md"
        if index_name in names:
            index = archive.read(index_name).decode("utf-8", errors="replace")
            rows = [line for line in index.splitlines()
                    if line.strip().startswith("|") and "---" not in line]
            data_rows = rows[1:] if len(rows) > 1 else []
            if not data_rows:
                failures.append("transcripts/INDEX.md has no session row")
            approved = {_fold(s) for s in APPROVED_SURFACES}
            for row in data_rows:
                cells = [c.strip() for c in row.strip().strip("|").split("|")]
                if len(cells) < 7:
                    failures.append(f"INDEX.md row has {len(cells)} columns, expected 7")
                    continue
                if _fold(cells[0]) not in approved:
                    failures.append(
                        f"INDEX.md declares an unapproved surface: {cells[0]!r}"
                    )
                declared = cells[6]
                if declared and f"submission/{declared}" not in names:
                    failures.append(
                        f"INDEX.md names an export that is not in the zip: {declared}"
                    )
            indexed = {f"submission/{c.strip()}" for row in data_rows
                       for c in [row.strip().strip('|').split('|')[-1]]}
            native = [n for n in names if n.startswith("submission/transcripts/native/")]
            unindexed = [n for n in native if n not in indexed]
            if unindexed:
                failures.append(
                    f"native record not named in INDEX.md: {unindexed[0]}"
                )

    problem = _settings_import_problem(pathlib.Path(__file__).resolve().parent.parent)
    if problem:
        failures.append(problem)

    digest = hashlib.sha256(ZIP.read_bytes()).hexdigest()
    if failures:
        for failure in failures:
            print(f"[FAIL] {failure}", file=sys.stderr)
        print(f"\n{len(failures)} problem(s). Fix, re-run `make submit`, verify again.",
              file=sys.stderr)
        return 1

    print(f"[OK] {ZIP.name} passes every check the evaluator runs.")
    print(f"[OK] SHA-256: {digest}")
    print("\nUpload it, then run: make check-link URL=<your download url>")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
