"""Build a complete desktop bundle with the shared Python payload tools."""
from __future__ import annotations

import argparse
import json
import os
import shutil
import subprocess
import sys
from contextlib import contextmanager
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))


def run(argv: list[str], *, cwd: Path, env: dict[str, str]) -> None:
    print("bundle: " + subprocess.list2cmdline(argv), flush=True)
    subprocess.run(argv, cwd=cwd, env=env, check=True)


def capture(argv: list[str], repo: Path, env: dict[str, str]) -> str:
    return subprocess.check_output(argv, cwd=repo, env=env, text=True, encoding="utf-8").strip()


@contextmanager
def flavored_assets(icons: Path, assets: Path):
    """Hold the flavored icon set at its workspace path for the packaging window.

    electron-builder resolves every piece of packaging artwork from the
    workspace — the app icon, the icon.ico extraResource, the MSIX logo set
    before-build stages into build/appx, and the exe identity stamp — so a
    flavored (canary/commit) build cannot package from the rendered product
    directory alone. Restore the admitted files once packaging ends: the source
    custody check refuses a dirty checkout, and a build that leaves its own
    render behind breaks every later build in that tree and `hermes update`.
    """
    admitted = {path.relative_to(assets): path.read_bytes()
                for path in assets.rglob("*") if path.is_file()}
    try:
        shutil.copytree(icons, assets, dirs_exist_ok=True)
        yield
    finally:
        for path in assets.rglob("*"):
            if path.is_file() and path.relative_to(assets) not in admitted:
                path.unlink()
        for relative, data in admitted.items():
            path = assets / relative
            path.parent.mkdir(parents=True, exist_ok=True)
            path.write_bytes(data)


def release_version(_repo: Path, tag: str) -> str:
    from scripts.termux.deb_version import channel_for_tag

    channel_for_tag(tag)  # shared release tag grammar, not a second version parser
    return tag[1:]


def npm_command(node: str) -> list[str]:
    # Dependency preparation and packaging must resolve the same npm identity.
    return [node, str(ROOT / "scripts/build/node-deps.mjs"), "--npm"]


def build(repo: Path, tag: str | None, variant: str, builder_args: list[str],
          commit_build: str | None = None) -> None:
    from scripts.bundles.desktop_prepare import BuildRequest, prepare
    from scripts.releases.bundle_env import decode
    request = BuildRequest.create(repo, tag=tag, commit=commit_build, variant=variant,
                                  work=repo / ".build/desktop-job", cache=repo / ".cache/desktop-inputs",
                                  bundle_env=decode(os.environ.get("HERMES_BUNDLE_ENV_JSON", "")))
    build_prepared(prepare(request), builder_args)


def build_prepared(path: Path, builder_args: list[str], variant: str | None = None) -> None:
    from scripts.bundles.desktop_prepare import PreparedDesktop
    from scripts.bundles.desktop_inputs import build_lock
    prepared = PreparedDesktop.load(path)
    with build_lock(prepared.request.source):
        _build_prepared(prepared, builder_args, variant)


def _build_prepared(prepared, builder_args: list[str], variant: str | None) -> None:
    prepared.validate()
    from scripts.bundles.desktop_inputs import build_environment, packaging_environment, select_variant, validate_builder_identity
    from scripts.bundles.native import finish_native

    request = prepared.request
    validate_builder_identity(request, builder_args)
    if request.channel_request is not None and not request.target.startswith(("darwin-", "win32-")):
        raise ValueError("channel builds require a supported native macOS or Windows target")
    variant = select_variant(prepared, variant)
    repo, node = request.source, str(prepared.node)
    env = build_environment(prepared, variant, os.environ)
    if request.release_epoch is not None:
        env["HERMES_RELEASE_EPOCH"] = str(request.release_epoch)
    desktop = repo / "apps/desktop"
    targets = {"win32": ["--win", "msix"], "darwin": ["--mac", "dmg", "zip"], "linux": ["--linux", "AppImage"]}[sys.platform]
    package_args = ["--prepared", str(prepared.packager), "--native-deps", str(prepared.native),
                    *targets, f"-c.extraMetadata.version={request.version}"]
    run([node, "scripts/run-electron-builder.mjs", "--validate-only", *package_args, *builder_args],
        cwd=desktop, env=env)
    run([node, "scripts/build/node-deps.mjs", "--source", str(repo), "--reuse", "--no-install",
         "--native-toolchain", prepared.native_toolchain,
         *[arg for name in request.workspaces() for arg in ("--workspace", name)]], cwd=repo, env=env)
    products = repo / "apps/desktop/build/products"
    icons = products / "icons"
    run([str(prepared.icon_python), "-I", str(repo / "scripts/generate_icons.py"),
         "--source", str(repo), "--out", str(icons)], cwd=repo, env=env)
    if variant != "light":
        run([node, "scripts/build/tui.mjs", "--source", str(repo), "--out", str(products / "tui")], cwd=repo, env=env)
        run([node, "scripts/build/web.mjs", "--source", str(repo), "--icons", str(products / "icons"),
             "--out", str(products / "web")], cwd=repo, env=env)
        if prepared.payload is None:
            raise ValueError("payload dependencies were not prepared")
        if finish_native(prepared.payload, {"tui": products / "tui", "web": products / "web"}):
            raise RuntimeError("prepared payload assembly failed")
    from scripts.bundles.desktop_prepare import require_source
    require_source(repo, request.commit)
    # The admitted checkout stays clean across the whole build: packaging reads
    # its artwork from the workspace, so the render lives there only while
    # electron-builder holds it, and the final check proves it was handed back.
    # Stamp before the render lands: the stamp's git check would call the
    # checkout dirty while the flavored icons sit over committed files.
    run([node, "scripts/write-build-stamp.mjs"], cwd=desktop, env=env)
    with flavored_assets(icons / "apps/desktop/assets", desktop / "assets"):
        run([node, "scripts/build/desktop.mjs", "--source", str(repo), "--icons", str(icons),
             "--stamp", str(desktop / "build/install-stamp.json"), "--native-deps", str(prepared.native),
             "--out", str(desktop / "dist")], cwd=repo, env=env)
        version_args = []
        if sys.platform == "win32" and request.channel_request is None and request.tag is not None:
            script = "const m=require('./scripts/msix-shared.mjs');console.log(m.nativeQuad(process.argv[1], Number(process.env.HERMES_RELEASE_EPOCH)))"
            quad = capture([node, "-e", script, request.tag], repo, env)
            version_args = [f'-c.extraMetadata.shortVersion={quad}', f'-c.extraMetadata.shortVersionWindows={quad}']
        run([node, "scripts/run-electron-builder.mjs", *package_args, *version_args, *builder_args], cwd=desktop,
            env=packaging_environment(env, os.environ, request.target))
    require_source(repo, request.commit)


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--tag", required=False,
                        help="Release tag (vX.Y.Z / canary), exclusive with --commit/--channel-request")
    parser.add_argument("--commit", dest="commit_build", default=None,
                        help="Commit-only build: exact full 40-char SHA the checkout is at; "
                             "version comes from the target pyproject, no tag is referenced")
    parser.add_argument("--release-commit", help="Admitted commit for a stable tag not created until green")
    parser.add_argument("--channel-request", type=Path, help="Immutable admitted channel request JSON")
    parser.add_argument("--variant", choices=["bundled", "store", "light"])
    parser.add_argument("--repo", type=Path, default=ROOT)
    parser.add_argument("--work", type=Path)
    parser.add_argument("--cache", type=Path)
    parser.add_argument("--prepare-only", action="store_true")
    parser.add_argument("--prepared", type=Path)
    parser.add_argument("builder_args", nargs=argparse.REMAINDER)
    args = parser.parse_args()
    builder_args = [v for v in args.builder_args if v != "--"]
    try:
        if args.prepared:
            if (args.tag or args.commit_build or args.release_commit or args.channel_request
                    or args.prepare_only or args.work or args.cache):
                parser.error("--prepared supplies the complete build request")
            build_prepared(args.prepared, builder_args, args.variant)
        else:
            from scripts.bundles.desktop_prepare import BuildRequest, prepare
            from scripts.releases.bundle_env import decode
            request = BuildRequest.create(args.repo, tag=args.tag, commit=args.commit_build,
                                          variant=args.variant or "bundled",
                                          work=args.work or args.repo / ".build/desktop-job",
                                          cache=args.cache or args.repo / ".cache/desktop-inputs",
                                          bundle_env=decode(os.environ.get("HERMES_BUNDLE_ENV_JSON", "")),
                                          channel_request=json.loads(args.channel_request.read_text(encoding="utf-8-sig"))
                                          if args.channel_request else None,
                                          release_commit=args.release_commit)
            if args.prepare_only and builder_args:
                parser.error("builder arguments belong to the build phase")
            result = prepare(request)
            if args.prepare_only:
                print(result)
            else:
                build_prepared(result, builder_args)
    except (ValueError, OSError, RuntimeError, subprocess.CalledProcessError) as exc:
        parser.exit(1, f"desktop build: {exc}\n")


if __name__ == "__main__":
    main()
