#!/usr/bin/env python3
# Copyright (C) 2026 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Packages the Perfetto UI, as deployed on ui.perfetto.dev, for self-hosting.

For a given release tag, this downloads the exact UI build that the
stable-channel Cloud Build deploy publishes to the public
gs://ui.perfetto.dev bucket, and zips it up into a tree that can be dropped
onto any static file server to self-host that release's UI.

Usage: ./tools/release/package-ui-github-release-artifact v57.1

This will generate perfetto-ui.zip in /tmp/perfetto-v57.1-github-release/
(the same staging dir package-github-release-artifacts uses, so a single
`gh release upload $STAGING/*.zip` picks up both).
"""

import argparse
import base64
import concurrent.futures
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
import zipfile

_GCS_ROOT = 'https://commondatastorage.googleapis.com/ui.perfetto.dev'
_REPO_ROOT = os.path.abspath(
    os.path.join(os.path.dirname(__file__), '..', '..'))
_NUM_WORKERS = 16


def _fetch(url):
  with urllib.request.urlopen(url) as resp:
    return resp.read()


def _resolve_ui_version(version):
  try:
    sha = subprocess.check_output(['git', 'rev-parse', f'{version}^{{commit}}'],
                                  cwd=_REPO_ROOT,
                                  stderr=subprocess.PIPE,
                                  text=True).strip()
  except subprocess.CalledProcessError as e:
    raise SystemExit(
        f'ERROR: tag {version} not found in this checkout. Fetch tags '
        f'(git fetch --tags) and re-run.\n{e.stderr}')
  return f'{version}-{sha[:9]}'


def _download_manifest(ui_version):
  url = f'{_GCS_ROOT}/{ui_version}/manifest.json'
  try:
    return json.loads(_fetch(url))
  except urllib.error.HTTPError as e:
    if e.code == 404:
      raise SystemExit(
          f'ERROR: {url} not found (404). The UI for {ui_version} has not '
          'been deployed to ui.perfetto.dev yet: the stable-channel Cloud '
          'Build deploy runs on push to the stable branch. Wait for it to '
          'complete and re-run this script.')
    raise


def _download_resource(ui_dir, ui_version, relpath, digest):
  url = f'{_GCS_ROOT}/{ui_version}/{relpath}'
  data = _fetch(url)
  actual = 'sha256-' + base64.b64encode(hashlib.sha256(data).digest()).decode()
  if actual != digest:
    raise SystemExit(
        f'ERROR: checksum mismatch for {relpath}: manifest says {digest}, '
        f'downloaded content hashes to {actual}')
  dst = os.path.join(ui_dir, ui_version, relpath)
  os.makedirs(os.path.dirname(dst), exist_ok=True)
  with open(dst, 'wb') as f:
    f.write(data)


def _download_ui_tree(ui_dir, ui_version, manifest):
  resources = manifest['resources']
  print(f'--- Downloading {len(resources)} resources for {ui_version} ---')
  with concurrent.futures.ThreadPoolExecutor(max_workers=_NUM_WORKERS) as pool:
    futures = [
        pool.submit(_download_resource, ui_dir, ui_version, relpath, digest)
        for relpath, digest in resources.items()
    ]
    for future in concurrent.futures.as_completed(futures):
      future.result()  # Re-raises on failure.

  version_dir = os.path.join(ui_dir, ui_version)
  os.makedirs(version_dir, exist_ok=True)
  for extra in ('index.html', 'manifest.json'):
    data = _fetch(f'{_GCS_ROOT}/{ui_version}/{extra}')
    with open(os.path.join(version_dir, extra), 'wb') as f:
      f.write(data)


def _download_root_files(ui_dir):
  sw_path = os.path.join(ui_dir, 'service_worker.js')
  with open(sw_path, 'wb') as f:
    f.write(_fetch(f'{_GCS_ROOT}/service_worker.js'))

  try:
    data = _fetch(f'{_GCS_ROOT}/service_worker.js.map')
  except urllib.error.HTTPError as e:
    if e.code != 404:
      raise
    print('NOTE: service_worker.js.map not found on the bucket; skipping.')
    return
  with open(os.path.join(ui_dir, 'service_worker.js.map'), 'wb') as f:
    f.write(data)


def _write_root_index_html(ui_dir, ui_version):
  version_index = os.path.join(ui_dir, ui_version, 'index.html')
  with open(version_index) as f:
    html = f.read()

  version_map = json.dumps({'stable': ui_version})
  patched, count = re.subn(
      r"data-perfetto_version='[^']*'",
      f"data-perfetto_version='{version_map}'",
      html,
      count=1)
  if count == 0:
    raise SystemExit(
        "ERROR: data-perfetto_version attribute not found in the downloaded "
        f'{ui_version}/index.html; the archival copy format may have '
        'changed.')

  with open(os.path.join(ui_dir, 'index.html'), 'w') as f:
    f.write(patched)


def _zip_ui_dir(ui_dir, zip_path):
  file_count = 0
  with zipfile.ZipFile(
      zip_path, 'w', zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
    for root, _, files in os.walk(ui_dir):
      for name in sorted(files):
        abs_path = os.path.join(root, name)
        arcname = os.path.relpath(abs_path, ui_dir)
        zf.write(abs_path, arcname)
        file_count += 1
  return file_count


def main():
  parser = argparse.ArgumentParser(epilog='Example: %s v57.1' % __file__)
  parser.add_argument('version', help='Release version tag (e.g. v57.1)')
  parser.add_argument(
      '--out-dir',
      help='Staging dir (default: /tmp/perfetto-<version>-github-release, '
      'matching package-github-release-artifacts).')
  args = parser.parse_args()

  if not re.match(r'^v\d+\.\d+$', args.version):
    print(
        f'ERROR: version must match vX.Y (got: {args.version})',
        file=sys.stderr)
    return 1

  out_dir = args.out_dir or f'/tmp/perfetto-{args.version}-github-release'
  os.makedirs(out_dir, exist_ok=True)

  ui_version = _resolve_ui_version(args.version)
  print(f'Resolved {args.version} -> ui_version {ui_version}')

  manifest = _download_manifest(ui_version)

  # Stage the tree outside out_dir: package-github-release-artifacts'
  # verify_downloads() hard-fails on unexpected directories in there.
  with tempfile.TemporaryDirectory(prefix='perfetto-ui-') as ui_dir:
    _download_ui_tree(ui_dir, ui_version, manifest)
    _download_root_files(ui_dir)
    _write_root_index_html(ui_dir, ui_version)

    zip_path = os.path.join(out_dir, 'perfetto-ui.zip')
    file_count = _zip_ui_dir(ui_dir, zip_path)

  print('')
  print('=' * 70)
  print(f'Packaged {file_count} files into {zip_path}')
  print(f'Zip size: {os.path.getsize(zip_path)} bytes')
  print('=' * 70)
  return 0


if __name__ == '__main__':
  sys.exit(main())
