#!/usr/bin/env python3
"""Create deterministic framework, source, audit and podspec release candidates."""

from __future__ import annotations

import argparse
import hashlib
import json
import lzma
import os
import shutil
import subprocess
import tarfile
import tempfile
from pathlib import Path, PurePosixPath

PLATFORM_PRODUCTS = {
    "ios": "MobileVLCKit",
    "tvos": "TVVLCKit",
}
PLATFORM_PIN_PREFIXES = {
    "ios": "BEAM_IPHONE_MODULE_",
    "tvos": "BEAM_APPLETV_MODULE_",
}


def parse_platforms(value: str) -> tuple[str, ...]:
    if value == "ios":
        return ("ios",)
    if value == "ios,tvos":
        return ("ios", "tvos")
    raise ValueError("platforms must be exactly ios or ios,tvos")


def artifact_scope(platforms: tuple[str, ...]) -> str:
    return "ios" if platforms == ("ios",) else "apple"


def require_reviewed_pins(pins: dict[str, str], platforms: tuple[str, ...]) -> None:
    required = sorted(
        key
        for platform in platforms
        for key in pins
        if key.startswith(PLATFORM_PIN_PREFIXES[platform]) and key.endswith("SHA256")
    )
    expected_count = 3 * len(platforms)
    if len(required) != expected_count:
        raise ValueError(
            f"expected {expected_count} selected module inventory pins; found {required}"
        )
    pending = [key for key in required if pins[key].startswith("PENDING_")]
    if pending:
        raise ValueError(f"cannot package with unreviewed selected-platform pins: {pending}")


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


def normalized(info: tarfile.TarInfo, epoch: int) -> tarfile.TarInfo:
    info.uid = info.gid = 0
    info.uname = info.gname = "root"
    info.mtime = epoch
    if info.isdir():
        info.mode = 0o755
    elif info.isfile():
        info.mode = 0o755 if info.mode & 0o111 else 0o644
    return info


def safe_arcname(value: str) -> str:
    path = PurePosixPath(value)
    if path.is_absolute() or ".." in path.parts or not path.parts:
        raise ValueError(f"unsafe archive path: {value}")
    return path.as_posix()


def write_tar_xz(destination: Path, entries: list[tuple[Path, str]], epoch: int) -> None:
    destination.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile(dir=destination.parent, delete=False) as raw:
        temporary = Path(raw.name)
    try:
        with temporary.open("wb") as target, lzma.LZMAFile(
            target, "wb", format=lzma.FORMAT_XZ, preset=9
        ) as compressed, tarfile.open(fileobj=compressed, mode="w|") as archive:
            for source, arcname in sorted(entries, key=lambda item: item[1]):
                archive.add(
                    source,
                    arcname=safe_arcname(arcname),
                    recursive=False,
                    filter=lambda info: normalized(info, epoch),
                )
        os.replace(temporary, destination)
    finally:
        temporary.unlink(missing_ok=True)


def tree_entries(source: Path, prefix: str) -> list[tuple[Path, str]]:
    entries: list[tuple[Path, str]] = [(source, prefix)]
    entries.extend(
        (path, f"{prefix}/{path.relative_to(source).as_posix()}")
        for path in sorted(source.rglob("*"))
    )
    return entries


def git_files(repository: Path) -> list[Path]:
    output = subprocess.check_output(
        ("git", "-C", str(repository), "ls-files", "-z"), text=False
    )
    return [repository / os.fsdecode(value) for value in output.split(b"\0") if value]


def source_entries(vlckit: Path, tool_dir: Path, staging: Path) -> list[tuple[Path, str]]:
    vlc = vlckit / "libvlc/vlc"
    entries: list[tuple[Path, str]] = []
    for path in git_files(vlckit):
        entries.append((path, f"source/VLCKit/{path.relative_to(vlckit).as_posix()}"))
    for path in git_files(vlc):
        entries.append((path, f"source/VLCKit/libvlc/vlc/{path.relative_to(vlc).as_posix()}"))
    for base in (vlc / "extras/tools", vlc / "contrib/tarballs"):
        for path in sorted(base.iterdir()):
            if path.is_file() and any(
                path.name.endswith(suffix)
                for suffix in (".tar.gz", ".tar.bz2", ".tar.xz", ".tgz", ".zip")
            ):
                entries.append((path, f"source-downloads/{path.relative_to(vlc).as_posix()}"))
    for path in sorted(tool_dir.rglob("*")):
        if path.is_file() and "__pycache__" not in path.parts and path.suffix != ".pyc":
            entries.append((path, f"beam-tool/{path.relative_to(tool_dir).as_posix()}"))
    for name in ("SOURCE-MANIFEST.txt", "audit/source-inputs-sha512.tsv"):
        path = staging / name
        entries.append((path, name))
    return entries


def render_podspec(template: Path, destination: Path, version: str, url: str, digest: str) -> None:
    placeholders = ("@VERSION@", "@ASSET_URL@", "@ASSET_SHA256@")
    content = (
        template.read_text(encoding="utf-8")
        .replace(placeholders[0], version)
        .replace(placeholders[1], url)
        .replace(placeholders[2], digest)
    )
    parsed = json.loads(content)
    if any(placeholder in content for placeholder in placeholders) or parsed["source"][
        "sha256"
    ] != digest:
        raise ValueError(f"podspec template was not fully rendered: {template}")
    destination.write_text(json.dumps(parsed, indent=2, sort_keys=True) + "\n", encoding="utf-8")


def package(
    staging: Path,
    vlckit: Path,
    tool_dir: Path,
    pins: dict[str, str],
    asset_base: str,
    platforms: tuple[str, ...],
) -> None:
    require_reviewed_pins(pins, platforms)
    epoch = int(pins["BEAM_SOURCE_DATE_EPOCH"])
    source_manifest = read_pins(staging / "SOURCE-MANIFEST.txt")
    version = f"{pins['BEAM_VLCKIT_VERSION']}-{pins['BEAM_CANDIDATE_REVISION']}"
    scope = artifact_scope(platforms)
    artifacts = staging / "artifacts"
    if artifacts.exists():
        shutil.rmtree(artifacts)
    artifacts.mkdir()
    copying = vlckit / "COPYING"

    products = tuple((platform, PLATFORM_PRODUCTS[platform]) for platform in platforms)
    asset_records: list[tuple[str, str]] = []
    for platform, product in products:
        consumer = staging / platform
        if consumer.exists():
            shutil.rmtree(consumer)
        consumer.mkdir()
        framework = staging / "frameworks" / f"{product}.xcframework"
        if framework.is_symlink() or not framework.is_dir():
            raise ValueError(f"missing or symlinked selected framework: {framework}")
        shutil.copytree(framework, consumer / framework.name, symlinks=True)
        shutil.copy2(copying, consumer / "COPYING.txt")
        asset_name = f"beam-{product.lower()}-{platform}-{version}.tar.xz"
        asset = artifacts / asset_name
        write_tar_xz(
            asset,
            [
                *tree_entries(framework, framework.name),
                (copying, "COPYING.txt"),
            ],
            epoch,
        )
        digest = sha256(asset)
        render_podspec(
            tool_dir / f"templates/{product}.podspec.json.in",
            artifacts / f"{product}.podspec.json",
            version,
            f"{asset_base.rstrip('/')}/{asset_name}",
            digest,
        )
        asset_records.append((asset_name, digest))

    source_name = f"beam-vlckit-{scope}-corresponding-source-{version}.tar.xz"
    write_tar_xz(
        artifacts / source_name,
        source_entries(vlckit, tool_dir, staging),
        epoch,
    )
    asset_records.append((source_name, sha256(artifacts / source_name)))

    manifest_lines = [
        f"vlckit_version={pins['BEAM_VLCKIT_VERSION']}",
        f"candidate_revision={pins['BEAM_CANDIDATE_REVISION']}",
        f"platforms={','.join(platforms)}",
        f"artifact_scope={scope}",
        f"vlckit_revision={pins['BEAM_VLCKIT_REVISION']}",
        f"vlc_revision={pins['BEAM_VLC_REVISION']}",
        f"xcode_version={pins['BEAM_XCODE_VERSION']}",
        f"xcode_build={pins['BEAM_XCODE_BUILD']}",
        *(
            [
                f"ios_sdk={pins['BEAM_IOS_SDK_VERSION']}",
                f"ios_minimum={pins['BEAM_IOS_DEPLOYMENT_TARGET']}",
            ]
            if "ios" in platforms
            else []
        ),
        *(
            [
                f"tvos_sdk={pins['BEAM_TVOS_SDK_VERSION']}",
                f"tvos_minimum={pins['BEAM_TVOS_DEPLOYMENT_TARGET']}",
            ]
            if "tvos" in platforms
            else []
        ),
        f"meson_profile={source_manifest['meson_profile']}",
        *[
            f"{key.lower()}={value}"
            for key, value in sorted(pins.items())
            if key.endswith("SHA256")
            and any(key.startswith(PLATFORM_PIN_PREFIXES[platform]) for platform in platforms)
        ],
        *[f"asset_sha256={digest}  {name}" for name, digest in sorted(asset_records)],
    ]
    (staging / "BUILD-MANIFEST.txt").write_text("\n".join(manifest_lines) + "\n")

    audit_name = f"beam-vlckit-{scope}-audit-{version}.tar.xz"
    audit_entries = [
        *tree_entries(staging / "audit", "audit"),
        *tree_entries(staging / "logs", "logs"),
        (staging / "SOURCE-MANIFEST.txt", "SOURCE-MANIFEST.txt"),
        (staging / "BUILD-MANIFEST.txt", "BUILD-MANIFEST.txt"),
        (tool_dir / "pins.env", "beam-tool/pins.env"),
        (tool_dir / "baseline-modules.txt", "beam-tool/baseline-modules.txt"),
    ]
    write_tar_xz(artifacts / audit_name, audit_entries, epoch)


def read_pins(path: Path) -> dict[str, str]:
    values: dict[str, str] = {}
    for raw in path.read_text().splitlines():
        if raw and not raw.startswith("#"):
            key, separator, value = raw.partition("=")
            if separator:
                values[key] = value
    return values


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--staging", required=True, type=Path)
    parser.add_argument("--vlckit", required=True, type=Path)
    parser.add_argument("--tool-dir", required=True, type=Path)
    parser.add_argument("--pins", required=True, type=Path)
    parser.add_argument("--asset-base", required=True)
    parser.add_argument("--platforms", required=True)
    args = parser.parse_args()
    package(
        args.staging.resolve(),
        args.vlckit.resolve(),
        args.tool_dir.resolve(),
        read_pins(args.pins),
        args.asset_base,
        parse_platforms(args.platforms),
    )
    return 0


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