#!/usr/bin/env python3
"""Build the HTML5 game and ship it — one command.

    python tools/deploy.py

Which is, in order:

  1. export the Web preset into a clean `build/web/` — index.html and everything
     it loads, and nothing else;
  2. stamp it with the commit it came from (`build.json`, generated, never
     committed) and drop in the header files a static host needs;
  3. check the folder is actually complete and self-contained before anyone
     uploads it;
  4. zip it with `index.html` at the root of the archive — which is what itch.io
     looks for — into `dist/`;
  5. push that zip to itch.io with butler.

Any step that needs a credential it has not been given is skipped cleanly, with
a line saying so and an exit code of 0: a missing itch.io token must never break
a build. Nothing here is destructive outside `build/web` and `dist`.

    python tools/deploy.py --no-itch      # build + zip, keep it local
    python tools/deploy.py --zip-only     # skip the export, re-zip what is there
    python tools/deploy.py --no-threads   # a build that runs on a host that
                                          # cannot set the isolation headers
    python tools/deploy.py --serve        # build, then serve it locally
    python tools/deploy.py --status       # ask butler what it can see, change nothing

Configuration is read from `.env` at the project root (git-ignored) — see
`.env.example`. A real environment variable always wins over the file, which is
what makes this usable from CI.
"""

from __future__ import annotations

import argparse
import json
import os
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
BUILD = ROOT / "build" / "web"
DIST = ROOT / "dist"

# The engine binary. `GODOT` in the environment wins; otherwise the one this
# project is built with, then whatever is on the PATH.
DEFAULT_GODOT = r"C:\Users\gille_ycz9q4f\tools\godot\Godot_v4.4.1-stable_win64.exe"

PRESET_THREADS = "Web"
PRESET_NO_THREADS = "Web (no threads)"

# What a web export must contain to be a game rather than a folder. Checked
# before anything is uploaded, because a missing .pck fails on the player's
# machine and nowhere else.
REQUIRED = ["index.html", "index.js", "index.wasm", "index.pck"]

# Dropped into the export so a static host sends the two headers the threads
# build needs. Netlify and Cloudflare Pages read `_headers`; Apache reads
# `.htaccess`; nginx is a snippet in the README, since it has no drop-in file.
HEADERS_FILE = """/*
  Cross-Origin-Opener-Policy: same-origin
  Cross-Origin-Embedder-Policy: require-corp
  Cross-Origin-Resource-Policy: cross-origin
"""

HTACCESS = """# The threads build needs a cross-origin-isolated document.
<IfModule mod_headers.c>
  Header set Cross-Origin-Opener-Policy "same-origin"
  Header set Cross-Origin-Embedder-Policy "require-corp"
  Header set Cross-Origin-Resource-Policy "cross-origin"
</IfModule>
AddType application/wasm .wasm
"""


def say(step: str, message: str) -> None:
    print("%-9s %s" % (step, message), flush=True)


# The Windows console is cp1252 by default, which cannot print an em dash and
# leaves a "?" in the middle of a sentence.
if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")


def die(message: str) -> None:
    print("\nFAILED: %s" % message, file=sys.stderr)
    raise SystemExit(1)


# --- .env ------------------------------------------------------------------

def load_env() -> dict:
    """`.env` at the project root, with real environment variables on top."""
    values = {}
    env_file = ROOT / ".env"
    if env_file.exists():
        for line in env_file.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            key, _, value = line.partition("=")
            values[key.strip()] = value.strip().strip('"').strip("'")
    for key in list(values) + ["GODOT", "ITCH_PROJECT", "ITCH_CHANNEL",
                               "ITCH_USERVERSION", "BUTLER_API_KEY", "SITE_URL",
                               "CACHE_DOMAIN", "CLOUDFLARE_API_TOKEN",
                               "CLOUDFLARE_ZONE_ID"]:
        if os.environ.get(key):
            values[key] = os.environ[key]
    return values


# --- 1. export -------------------------------------------------------------

def godot_binary(env: dict) -> str:
    candidate = env.get("GODOT") or DEFAULT_GODOT
    if Path(candidate).exists():
        return candidate
    found = shutil.which("godot") or shutil.which("godot4")
    if found:
        return found
    die("no Godot binary. Set GODOT=<path to the editor> in .env, or put it on the PATH.")


def export(env: dict, preset: str) -> None:
    """A clean folder every time: a stale index.wasm from a previous engine
    version next to a fresh index.js is a bug nobody can read."""
    if BUILD.exists():
        shutil.rmtree(BUILD)
    BUILD.mkdir(parents=True)
    say("export", "%s -> %s" % (preset, BUILD.relative_to(ROOT)))
    result = subprocess.run(
        [godot_binary(env), "--headless", "--path", str(ROOT),
         "--export-release", preset, str(BUILD / "index.html")],
        cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8", errors="replace")
    # Godot exits 0 on some export failures, so the check is the files, below.
    if result.returncode != 0:
        sys.stderr.write(result.stdout or "")
        sys.stderr.write(result.stderr or "")
        die("the export returned %d — is the preset named %r, and are the export "
            "templates for this engine version installed?" % (result.returncode, preset))


# --- 2. stamp and headers --------------------------------------------------

def stamp() -> str:
    """The commit this copy came from, with a + if the tree was dirty. Written
    into the export, never into the repo: a committed stamp names the commit
    before the one it ships with."""
    def git(*args: str) -> str:
        out = subprocess.run(["git", *args], cwd=str(ROOT), capture_output=True, text=True)
        return out.stdout.strip() if out.returncode == 0 else ""

    commit = git("rev-parse", "--short", "HEAD") or "unknown"
    if git("status", "--porcelain"):
        commit += "+"
    (BUILD / "build.json").write_text(
        json.dumps({"commit": commit}, indent=1) + "\n", encoding="utf-8")
    return commit


def drop_in_headers() -> None:
    (BUILD / "_headers").write_text(HEADERS_FILE, encoding="utf-8")
    (BUILD / ".htaccess").write_text(HTACCESS, encoding="utf-8")


def rewrite_site_url(site: str) -> None:
    """The social card's og:url and og:image have to be absolute — a scraper does
    not resolve a relative one. The shell carries a default; this points it at
    wherever this build is actually going."""
    if not site:
        return
    site = site.rstrip("/") + "/"
    page = BUILD / "index.html"
    html = page.read_text(encoding="utf-8")
    for old, new in [("https://masterpieceornot.allweb.fun/", site)]:
        html = html.replace(old, new)
    page.write_text(html, encoding="utf-8")
    say("meta", "social card points at %s" % site)


# --- 3. check --------------------------------------------------------------

def check() -> None:
    """The folder has to be a game, and it has to be self-contained: everything
    index.html loads must sit beside it. A build that reaches for a file it does
    not carry works on the machine that made it and nowhere else."""
    missing = [name for name in REQUIRED
               if not (BUILD / name).exists() or (BUILD / name).stat().st_size == 0]
    if missing:
        die("the export is incomplete — missing or empty: %s" % ", ".join(missing))

    html = (BUILD / "index.html").read_text(encoding="utf-8")
    if "$GODOT_" in html:
        die("index.html still has an unreplaced $GODOT_ placeholder — the custom "
            "shell (web/shell.html) is out of step with this engine version.")

    import re
    # Every local thing the page names, and whether it is in the folder.
    referenced = set(re.findall(r'(?:src|href)="([^"$#][^"]*)"', html))
    for target in sorted(referenced):
        if target.startswith(("http://", "https://", "//", "data:", "mailto:")):
            continue
        if not (BUILD / target).exists():
            die("index.html loads %r, which is not in the folder" % target)

    total = sum(f.stat().st_size for f in BUILD.rglob("*") if f.is_file())
    say("check", "%d files, %.1f MB, self-contained" % (
        sum(1 for f in BUILD.rglob("*") if f.is_file()), total / 1e6))


# --- 4. zip ----------------------------------------------------------------

def pack(name: str) -> Path:
    """index.html at the root of the archive — itch.io looks there and nowhere
    else — and everything beside it. Deflated, because a 40 MB wasm is most of
    the upload and it compresses to a third."""
    DIST.mkdir(exist_ok=True)
    archive = DIST / ("%s.zip" % name)
    if archive.exists():
        archive.unlink()
    files = sorted(f for f in BUILD.rglob("*") if f.is_file())
    with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
        for f in files:
            zf.write(f, f.relative_to(BUILD).as_posix())
    with zipfile.ZipFile(archive) as zf:
        if "index.html" not in zf.namelist():
            die("index.html is not at the root of the zip")
    say("pack", "%s (%.1f MB)" % (archive.relative_to(ROOT), archive.stat().st_size / 1e6))
    return archive


# --- 5. itch.io ------------------------------------------------------------

def butler_path() -> str | None:
    return shutil.which("butler")


def itch_status(env: dict) -> None:
    butler = butler_path()
    project = env.get("ITCH_PROJECT", "")
    if not butler:
        say("itch", "skipped: butler is not on the PATH (itch.io/docs/butler/)")
        return
    if not project:
        say("itch", "skipped: no ITCH_PROJECT in .env")
        return
    subprocess.run([butler, "status", project], cwd=str(ROOT))


def push_to_itch(env: dict, archive: Path, version: str) -> None:
    butler = butler_path()
    project = env.get("ITCH_PROJECT", "")
    channel = env.get("ITCH_CHANNEL", "html5")
    if not butler:
        say("itch", "skipped: butler is not on the PATH — install it from "
                    "itch.io/docs/butler/ and re-run, or upload %s by hand"
                    % archive.relative_to(ROOT))
        return
    if not project:
        say("itch", "skipped: set ITCH_PROJECT=user/game in .env (no channel) "
                    "to push automatically")
        return
    target = "%s:%s" % (project, channel)
    say("itch", "pushing to %s as %s" % (target, version))
    environ = dict(os.environ)
    if env.get("BUTLER_API_KEY"):
        environ["BUTLER_API_KEY"] = env["BUTLER_API_KEY"]
    result = subprocess.run(
        [butler, "push", str(archive), target, "--userversion", version],
        cwd=str(ROOT), env=environ)
    if result.returncode != 0:
        die("butler push failed (%d). `butler login` once, or set BUTLER_API_KEY "
            "in .env." % result.returncode)


# --- the run ---------------------------------------------------------------

def main() -> None:
    parser = argparse.ArgumentParser(description="Build the HTML5 game and ship it.")
    parser.add_argument("--no-itch", action="store_true", help="build and zip, do not push")
    parser.add_argument("--zip-only", action="store_true", help="re-zip build/web as it stands")
    parser.add_argument("--no-threads", action="store_true",
                        help="export the build that runs without the isolation headers")
    parser.add_argument("--serve", action="store_true",
                        help="serve build/web locally when the build is done")
    parser.add_argument("--status", action="store_true", help="ask butler what it sees, then stop")
    parser.add_argument("--name", default="masterpiece-or-not-html5", help="zip name")
    args = parser.parse_args()

    env = load_env()

    if args.status:
        itch_status(env)
        return

    if not args.zip_only:
        export(env, PRESET_NO_THREADS if args.no_threads else PRESET_THREADS)

    commit = stamp()
    drop_in_headers()
    rewrite_site_url(env.get("SITE_URL", ""))
    say("stamp", "build %s" % commit)
    check()

    archive = pack(args.name)

    version = env.get("ITCH_USERVERSION") or commit.rstrip("+")
    if args.no_itch:
        say("itch", "skipped: --no-itch")
    else:
        push_to_itch(env, archive, version)

    print()
    say("done", "serve %s from any static host, or upload %s"
        % (BUILD.relative_to(ROOT), archive.relative_to(ROOT)))
    if not args.no_threads:
        say("note", "this build needs COOP/COEP headers — _headers and .htaccess "
                    "are in the folder; on itch.io tick SharedArrayBuffer support.")

    if args.serve:
        subprocess.run([sys.executable, str(ROOT / "web" / "serve.py"), str(BUILD)], cwd=str(ROOT))


if __name__ == "__main__":
    main()
