#!/usr/bin/env python3
"""Build pinned, minimal, LGPL-only static ffmpeg.exe + ffprobe.exe for win64.

Why this exists: packaging/build_windows_exe.py previously vendored whatever
``ffprobe.exe`` the build machine had -- typically a gyan.dev/BtbN full build,
which is GPL (``--enable-gpl --enable-libx264 --enable-libx265``) and must not
be redistributed inside the installer -- and never bundled ffmpeg at all. This
is the win64 sibling of ``build_portable_ffmpeg.py``: same pinned FFmpeg
release, same sha256, same LGPL component set -- all imported from that module
so the two platforms cannot drift apart. See
docs/legal/third-party-licenses.md.

Toolchains (either produces dist/win32-amd64/{ffmpeg.exe,ffprobe.exe}):

* Cross-compile from macOS/Linux with mingw-w64
  (``brew install mingw-w64 nasm`` / ``apt install gcc-mingw-w64-x86-64``)::

      python3 packaging/ffmpeg_portable/build_ffmpeg_windows.py

* Native build on Windows inside an MSYS2 MINGW64/UCRT64 shell
  (``pacman -S make tar mingw-w64-x86_64-toolchain mingw-w64-x86_64-nasm``)::

      python3 packaging/ffmpeg_portable/build_ffmpeg_windows.py --native

zlib is required for PNG/EXR decode and compressed MOV atoms; macOS links the
/usr/lib copy but Windows has no system zlib, so a pinned zlib release is built
statically into the exes (sha256 cross-checked against Homebrew's independent
pin for the same tarball). No other external library is permitted.

Verification: the license blurb and configuration line are compile-time string
literals, so each binary is classified WITHOUT executing it (LGPL blurb
present, no GPL/nonfree/external-lib configure markers, our --disable flags
present) and the PE import table may name Windows system DLLs only (objdump
-p). When the exes can actually run (a native Windows build, or
``--verify-only`` on a Windows machine after a cross build) the shared
recipe's behavioral gauntlet also runs: ``-L`` must report the GNU Lesser GPL,
the probe output of synthesized YUV4MPEG/WAV samples plus
fixtures/sample_prores_pcm.mov must carry the exact codec/fps/resolution
fields the app reads (ProRes DECODE, still supported), and the product's two
ffmpeg pipelines (mjpeg stills, amerge PCM mux) must produce verifiable output.
Cross builds install with ``behavioral_verified: false`` in the buildinfo --
run ``--verify-only`` on a Windows machine before shipping.

Output:
    packaging/ffmpeg_portable/dist/win32-amd64/ffmpeg.exe
    packaging/ffmpeg_portable/dist/win32-amd64/ffprobe.exe
    packaging/ffmpeg_portable/dist/win32-amd64/BUILDINFO.json

packaging/build_windows_exe.py prefers these over any system copy when they
exist (override with KONFORMISTA_FFPROBE_PORTABLE / KONFORMISTA_FFMPEG_PORTABLE).
"""

from __future__ import annotations

import argparse
import importlib.util
import json
import os
import shutil
import subprocess
import sys
import urllib.request
from pathlib import Path

HERE = Path(__file__).resolve().parent
ROOT = HERE.parent.parent
BUILD_ROOT = ROOT / "build" / "ffmpeg-portable-win64"
DIST_DIR = HERE / "dist"
TARGET_KEY = "win32-amd64"  # what platform_key() computes on a Windows x64 box


def _load_shared_recipe():
    """The native recipe module: the single source of truth for the FFmpeg pin,
    the LGPL component set and the download/extract/verify helpers."""
    module_path = HERE / "build_portable_ffmpeg.py"
    spec = importlib.util.spec_from_file_location("konformista_ffmpeg_recipe_shared", module_path)
    if spec is None or spec.loader is None:
        raise SystemExit(f"Cannot load the shared ffmpeg recipe: {module_path}")
    module = importlib.util.module_from_spec(spec)
    sys.modules[spec.name] = module
    spec.loader.exec_module(module)
    return module


_shared = _load_shared_recipe()

# Re-exported so tests and callers read one module for the whole win64 story.
FFMPEG_VERSION = _shared.FFMPEG_VERSION
FFMPEG_SHA256 = _shared.FFMPEG_SHA256
FFMPEG_URL = _shared.FFMPEG_URL
VIDEO_ENCODERS = _shared.VIDEO_ENCODERS
AUDIO_ENCODERS = _shared.AUDIO_ENCODERS
MUXERS = _shared.MUXERS
PROTOCOLS = _shared.PROTOCOLS
FILTERS = _shared.FILTERS
GPL_CONFIGURE_MARKERS = _shared.GPL_CONFIGURE_MARKERS
LGPL_LICENSE_BLURB = _shared.LGPL_LICENSE_BLURB

run = _shared.run
require_tool = _shared.require_tool
_sha256 = _shared._sha256

# Pinned zlib, statically linked into the exes. The sha256 was computed from the
# downloaded tarball and cross-checked against Homebrew's independently pinned
# checksum for the same release (homebrew-core Formula/z/zlib.rb @ 1.3.2). Bump
# both together. zlib.net deletes superseded tarballs, so the GitHub release
# mirror is listed too -- the hash pins the content wherever it comes from.
ZLIB_VERSION = "1.3.2"
ZLIB_SHA256 = "bb329a0a2cd0274d05519d61c667c062e06990d72e125ee2dfa8de64f0119d16"
ZLIB_URLS = (
    f"https://zlib.net/zlib-{ZLIB_VERSION}.tar.gz",
    f"https://github.com/madler/zlib/releases/download/v{ZLIB_VERSION}/zlib-{ZLIB_VERSION}.tar.gz",
)

DEFAULT_CROSS_PREFIX = "x86_64-w64-mingw32-"

# A static exe may import Windows system DLLs only. Anything else
# (libwinpthread-1.dll, libgcc_s_seh-1.dll, zlib1.dll, av*/sw* runtimes) means
# the -static link failed and the exe is not self-contained. UCRT toolchains
# import the api-ms-win-* forwarder set instead of msvcrt; both are system.
ALLOWED_IMPORT_DLLS = {
    "advapi32.dll",
    "bcrypt.dll",
    "crypt32.dll",
    "gdi32.dll",
    "kernel32.dll",
    "msvcrt.dll",
    "ncrypt.dll",
    "ole32.dll",
    "oleaut32.dll",
    "psapi.dll",
    "shell32.dll",
    "shlwapi.dll",
    "ucrtbase.dll",
    "user32.dll",
    "userenv.dll",
    "ws2_32.dll",
}
ALLOWED_IMPORT_DLL_PREFIXES = ("api-ms-win-",)

# Byte-scan identity markers: the embedded configuration string must be THIS
# recipe's (mirrors verify_license's execution-based required list).
REQUIRED_CONFIGURE_MARKERS = ("--disable-autodetect", "--disable-network")


def configure_flags_windows(*, cross_prefix: str | None = None, zlib_prefix: Path | None = None) -> list[str]:
    """The full ./configure argument list for the LGPL static win64 build.

    The component set IS the native recipe's -- ``configure_args`` is imported
    and only toolchain flags are appended: ``--target-os/--arch``, ``-static``
    (folds libgcc/libwinpthread into the exes), the cross prefix, and the
    pinned zlib prefix replacing /usr/lib's copy. Nothing here may ever add
    --enable-gpl, --enable-nonfree or an external library.
    """
    flags = _shared.configure_args(
        for_platform="win32",
        for_machine="amd64",
        have_nasm=bool(shutil.which("nasm") or shutil.which("yasm")),
    )
    flags += [
        "--target-os=mingw32",
        "--arch=x86_64",
        "--extra-ldflags=-static",
    ]
    if cross_prefix:
        flags += ["--enable-cross-compile", f"--cross-prefix={cross_prefix}"]
    if zlib_prefix is not None:
        flags += [
            f"--extra-cflags=-I{zlib_prefix / 'include'}",
            f"--extra-ldflags=-L{zlib_prefix / 'lib'}",
        ]
    return flags


def download_zlib(build_root: Path) -> Path:
    """Fetch the pinned zlib tarball (cached) and verify its sha256 before use."""
    build_root.mkdir(parents=True, exist_ok=True)
    tarball = build_root / f"zlib-{ZLIB_VERSION}.tar.gz"
    if tarball.exists() and _sha256(tarball) == ZLIB_SHA256:
        print(f"Using cached {tarball.name} (sha256 verified).")
        return tarball
    last_error: Exception | None = None
    for url in ZLIB_URLS:
        print(f"Downloading {url} ...")
        try:
            with urllib.request.urlopen(url, timeout=120) as response, tarball.open("wb") as out:
                shutil.copyfileobj(response, out)
        except OSError as exc:
            last_error = exc
            continue
        actual = _sha256(tarball)
        if actual == ZLIB_SHA256:
            print(f"Downloaded {tarball.name} (sha256 verified).")
            return tarball
        last_error = SystemExit(
            f"zlib tarball checksum mismatch from {url}:\n"
            f"  expected {ZLIB_SHA256}\n  actual   {actual}"
        )
    tarball.unlink(missing_ok=True)
    raise SystemExit(f"Could not fetch a verified zlib {ZLIB_VERSION} tarball: {last_error}")


def build_zlib(build_root: Path, *, cross_prefix: str) -> Path:
    """Build the pinned static libz.a with the target toolchain; returns a
    prefix dir holding include/zlib.h+zconf.h and lib/libz.a for configure."""
    tarball = download_zlib(build_root)
    source_dir = build_root / f"zlib-{ZLIB_VERSION}"
    if source_dir.exists():
        shutil.rmtree(source_dir)
    run(["tar", "-xf", str(tarball), "-C", str(build_root)])
    if not (source_dir / "win32" / "Makefile.gcc").exists():
        raise SystemExit(f"Extracted zlib tree has no win32/Makefile.gcc: {source_dir}")
    run(["make", "-f", "win32/Makefile.gcc", f"PREFIX={cross_prefix}", "libz.a"], cwd=source_dir)

    prefix = build_root / "zlib-prefix"
    include_dir = prefix / "include"
    lib_dir = prefix / "lib"
    include_dir.mkdir(parents=True, exist_ok=True)
    lib_dir.mkdir(parents=True, exist_ok=True)
    for header in ("zlib.h", "zconf.h"):
        shutil.copy2(source_dir / header, include_dir / header)
    shutil.copy2(source_dir / "libz.a", lib_dir / "libz.a")
    return prefix


def build_tools_windows(source_dir: Path, *, jobs: int, cross_prefix: str, zlib_prefix: Path) -> dict[str, Path]:
    flags = configure_flags_windows(cross_prefix=cross_prefix or None, zlib_prefix=zlib_prefix)
    # `sh configure` (not ./configure): ffmpeg's configure is a POSIX sh script,
    # and native Windows cannot exec a shebang file -- MSYS2's sh runs it there.
    run(["sh", "./configure", *flags], cwd=source_dir)
    run(["make", f"-j{jobs}"], cwd=source_dir)
    built = {name: source_dir / name for name in ("ffmpeg.exe", "ffprobe.exe")}
    missing = [name for name, path in built.items() if not path.is_file()]
    if missing:
        raise SystemExit(f"make finished but did not produce: {', '.join(missing)}")
    return built


# --------------------------------------------------------------------------- #
# Verification: nothing leaves this script unproven. The byte-scan and import
# checks always run (they need no Windows machine); the behavioral gauntlet
# from the shared recipe runs whenever the exes can execute here.

def verify_embedded_license_markers(data: bytes, *, name: str = "binary") -> None:
    """Classify an exe from its bytes: LGPL blurb present, no GPL/nonfree/
    external-lib configure markers, and our own --disable flags present (which
    proves the embedded configuration string is this recipe's)."""
    if LGPL_LICENSE_BLURB not in data:
        raise SystemExit(
            f"{name} does not embed the GNU Lesser GPL license text; "
            "this is not the LGPL build this recipe promises."
        )
    for marker in GPL_CONFIGURE_MARKERS:
        if marker.encode("ascii") in data:
            raise SystemExit(f"{name} embeds the configure marker {marker!r}; not an LGPL-only build.")
    for required in REQUIRED_CONFIGURE_MARKERS:
        if required.encode("ascii") not in data:
            raise SystemExit(f"{name} configuration is missing {required!r}; not this recipe's build.")


def parse_import_dlls(objdump_output: str) -> list[str]:
    """The 'DLL Name:' entries of `objdump -p` on a PE binary."""
    dlls = []
    for line in objdump_output.splitlines():
        stripped = line.strip()
        if stripped.startswith("DLL Name:"):
            dlls.append(stripped.split(":", 1)[1].strip())
    return dlls


def _is_allowed_import(dll: str) -> bool:
    name = dll.lower()
    return name in ALLOWED_IMPORT_DLLS or name.startswith(ALLOWED_IMPORT_DLL_PREFIXES)


def verify_pe_imports(binary: Path, *, objdump: str) -> None:
    """The import table may name Windows system DLLs only: static means no
    vendored DLL closure, which is the whole point of this build."""
    result = subprocess.run([objdump, "-p", str(binary)], check=False, capture_output=True, text=True, timeout=120)
    dlls = parse_import_dlls(result.stdout or "")
    if result.returncode != 0 or not dlls:
        raise SystemExit(
            f"Could not read the PE import table with {objdump!r}:\n{result.stderr or result.stdout}"
        )
    foreign = sorted(dll for dll in dlls if not _is_allowed_import(dll))
    if foreign:
        raise SystemExit(f"{binary.name} is not self-contained; foreign imports:\n  " + "\n  ".join(foreign))


def verify_on_windows(ffmpeg: Path, ffprobe: Path) -> str:
    """The shared recipe's execution-based gauntlet (license banners, component
    inventory, probe output of synthesized samples and the ProRes fixture, the
    product's three pipelines); Windows-only because the exes must actually run."""
    for binary in (ffmpeg, ffprobe):
        _shared.verify_license(binary)
    _shared.verify_component_inventory(ffmpeg)
    _shared.verify_probe_output(ffprobe)
    _shared.verify_product_pipelines(ffmpeg, ffprobe)
    version_line = _shared.tool_output(ffmpeg, ["-version"]).splitlines()[0]
    return version_line


def _configuration_from_bytes(data: bytes) -> str:
    """The embedded 'configuration:' compile-time literal, for BUILDINFO on
    cross builds (where -version cannot be executed)."""
    marker = b"--disable-shared"
    start = data.find(marker)
    if start == -1:
        return ""
    end = data.find(b"\x00", start)
    return data[start : end if end != -1 else start + 4096].decode("ascii", "replace")


def install(built: dict[str, Path], *, version_line: str, cross_prefix: str, behavioral_verified: bool) -> Path:
    out_dir = DIST_DIR / TARGET_KEY
    out_dir.mkdir(parents=True, exist_ok=True)
    staged: dict[str, Path] = {}
    for name, source in sorted(built.items()):
        target = out_dir / name
        shutil.copy2(source, target)
        staged[name] = target
    payload = _shared.buildinfo_payload(
        configuration=_configuration_from_bytes(staged["ffmpeg.exe"].read_bytes()),
        version_line=version_line,
        binaries=staged,
        target_key=TARGET_KEY,
        behavioral_verified=behavioral_verified,
        recipe="packaging/ffmpeg_portable/build_ffmpeg_windows.py",
        extra={
            "toolchain": cross_prefix.rstrip("-") if cross_prefix else "native-mingw",
            "zlib_version": ZLIB_VERSION,
            "zlib_sha256": ZLIB_SHA256,
        },
    )
    (out_dir / "BUILDINFO.json").write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
    return out_dir


def _find_objdump(cross_prefix: str) -> str | None:
    for candidate in (f"{cross_prefix}objdump" if cross_prefix else "", "objdump"):
        if candidate and shutil.which(candidate):
            return candidate
    return None


def verify_only(out_dir: Path, *, cross_prefix: str) -> int:
    """Re-verify existing dist exes (e.g. a cross build copied to a Windows
    machine before shipping)."""
    ffmpeg = out_dir / "ffmpeg.exe"
    ffprobe = out_dir / "ffprobe.exe"
    for binary in (ffmpeg, ffprobe):
        if not binary.is_file():
            raise SystemExit(f"Nothing to verify: {binary} does not exist.")
        verify_embedded_license_markers(binary.read_bytes(), name=binary.name)
    objdump = _find_objdump(cross_prefix)
    if objdump is not None:
        for binary in (ffmpeg, ffprobe):
            verify_pe_imports(binary, objdump=objdump)
    else:
        print("NOTE: no objdump on PATH; skipped the PE import-table check.")
    if os.name == "nt":
        version_line = verify_on_windows(ffmpeg, ffprobe)
        print(f"Verified on Windows: {version_line}")
    else:
        print("Static checks passed. Run --verify-only on a Windows machine for the behavioral gauntlet.")
    return 0


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--jobs", type=int, default=os.cpu_count() or 4)
    parser.add_argument("--clean", action="store_true", help="wipe the build dir first")
    parser.add_argument(
        "--cross-prefix",
        default=None,
        help=f"mingw-w64 toolchain prefix (default: {DEFAULT_CROSS_PREFIX!r} off-Windows, native on Windows)",
    )
    parser.add_argument(
        "--native",
        action="store_true",
        help="build with the ambient gcc toolchain (MSYS2 MINGW64/UCRT64 shell) instead of a cross prefix",
    )
    parser.add_argument(
        "--verify-only",
        action="store_true",
        help="re-run verification against dist/win32-amd64/ without building",
    )
    args = parser.parse_args(argv)

    if args.native and args.cross_prefix:
        parser.error("--native and --cross-prefix cannot be used together")
    if args.native or os.name == "nt":
        cross_prefix = args.cross_prefix or ""
    else:
        cross_prefix = args.cross_prefix if args.cross_prefix is not None else DEFAULT_CROSS_PREFIX

    if args.verify_only:
        return verify_only(DIST_DIR / TARGET_KEY, cross_prefix=cross_prefix)

    compiler = f"{cross_prefix}gcc" if cross_prefix else "gcc"
    for tool in (compiler, "make", "tar", "sh"):
        require_tool(tool)
    if not cross_prefix and os.name != "nt":
        raise SystemExit(
            "A native (no cross prefix) build only makes sense on Windows/MSYS2; "
            f"install mingw-w64 and use the default {DEFAULT_CROSS_PREFIX!r} cross prefix instead."
        )
    objdump = _find_objdump(cross_prefix)
    if objdump is None:
        raise SystemExit(f"Required tool not found: {cross_prefix}objdump (or objdump)")

    if args.clean and BUILD_ROOT.exists():
        shutil.rmtree(BUILD_ROOT)

    zlib_prefix = build_zlib(BUILD_ROOT, cross_prefix=cross_prefix)
    tarball = _shared.download_source(BUILD_ROOT)
    source_dir = _shared.extract_source(tarball, BUILD_ROOT)
    built = build_tools_windows(source_dir, jobs=args.jobs, cross_prefix=cross_prefix, zlib_prefix=zlib_prefix)

    for name, path in sorted(built.items()):
        verify_embedded_license_markers(path.read_bytes(), name=name)
        verify_pe_imports(path, objdump=objdump)
    behavioral_verified = os.name == "nt"
    version_line = verify_on_windows(built["ffmpeg.exe"], built["ffprobe.exe"]) if behavioral_verified else ""

    out_dir = install(
        built,
        version_line=version_line,
        cross_prefix=cross_prefix,
        behavioral_verified=behavioral_verified,
    )
    for name in sorted(built):
        target = out_dir / name
        size_mb = target.stat().st_size / (1024 * 1024)
        print(f"staged {target} ({size_mb:.1f} MB)")
    if not behavioral_verified:
        print(
            "Cross build: static license/linkage checks passed, but the exes were not run. "
            "Copy the repo to a Windows machine and run this script with --verify-only before shipping."
        )
    print("packaging/build_windows_exe.py will now bundle these instead of system copies.")
    return 0


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