#!/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.
"""Fallback installer for the Perfetto AI agents skills bundle.

Downloads the `ai-agents` release branch and copies the Perfetto skill it
ships (which carries the `trace_processor` wrapper at `bin/` inside the
skill) into a target directory. Used by fallback consumers (OpenCode,
Antigravity, Pi) and any agent without a native plugin marketplace.

The destination skills/ dir may be shared with skills the user installed
from elsewhere (e.g. pi's ~/.agents/skills), so the installer touches only
the Perfetto skill and prompts before replacing an existing one. Pass
--yes to replace without prompting.

Python, not a shell script, on purpose: the bundled `trace_processor`
wrapper already requires Python 3, so this adds no new dependency and runs
unmodified on Linux, macOS and Windows.

  macOS/Linux:  curl -fsSL https://get.perfetto.dev/agents-install | \
                    python3 - --target <path>
  Windows:      curl.exe -fsSL https://get.perfetto.dev/agents-install | \
                    python - --target <path>
"""

import argparse
import json
import os
import shutil
import sys
import tarfile
import tempfile
import urllib.request
from typing import Optional

REPO = 'google/perfetto'
DEFAULT_BRANCH = 'ai-agents'

# tools/release/roll-prebuilts stamps the rolled release version here so the
# installer served from get.perfetto.dev defaults to that release (and a saved
# copy keeps reinstalling it). At the sentinel, installs track the live
# `ai-agents` branch tip; held there for now so default installs serve the
# rebuilt single-skill bundle until the next release re-stamps a pinned
# version.
DEFAULT_VERSION = 'v58.2'

# Custom ref namespace mapping a release version to its ai-agents bundle commit,
# e.g. refs/ai-agent-tag/v56.0 (created by the release pipeline). Kept out of
# refs/tags so it does not show up as a normal tag or in the releases UI.
VERSION_REF_NS = 'ai-agent-tag'

# --agent name -> default --target (relative to the user's home dir). Best
# effort; --target always overrides. Antigravity's location is a guess pending
# confirmation against a real install.
AGENT_TARGETS = {
    'claude': '.claude',
    'codex': '.codex',
    'opencode': os.path.join('.config', 'opencode'),
    'antigravity': '.antigravity',
    'pi': '.agents',
}

# The single skill the bundle ships and the only thing this installer manages.
SKILL_NAME = 'perfetto'

# Agents that discover skills through skills/index.json. We only write that file
# for them; other agents load skills/<name>/SKILL.md directly and never need it.
INDEX_AGENTS = {'opencode'}


def err(msg: str) -> None:
  sys.stderr.write(f'error: {msg}\n')
  sys.exit(1)


def _http_get(url: str) -> bytes:
  with urllib.request.urlopen(url) as resp:  # noqa: S310 (trusted hosts)
    return resp.read()


def resolve_version_to_sha(version: str) -> Optional[str]:
  """Resolve a release version to its pinned ai-agents bundle commit SHA.

  Reads refs/<VERSION_REF_NS>/<version> via the GitHub API. Custom ref
  namespaces are not served by raw.githubusercontent or codeload-by-ref, but
  the git/ref API resolves them in one call. Returns None (with a warning) on
  any failure, so the caller can fall back to the branch tip.
  """
  ref = f'{VERSION_REF_NS}/{version}'
  try:
    data = json.loads(
        _http_get(f'https://api.github.com/repos/{REPO}/git/ref/{ref}'))
  except Exception as e:  # noqa: BLE001 (best-effort network path)
    sys.stderr.write(f'warning: could not resolve version {version} ({e}); '
                     f'falling back to {DEFAULT_BRANCH} tip\n')
    return None
  sha = data.get('object', {}).get('sha')
  if sha:
    print(f'Resolved {version} to ai-agents commit {sha}')
  return sha


def resolve_ref(ref_arg: str, version_arg: str) -> str:
  if ref_arg:
    return ref_arg
  # An explicit --version wins; otherwise fall back to the stamped default. The
  # unstamped sentinel means "track the branch tip" (dev checkout).
  version = version_arg or (DEFAULT_VERSION
                            if DEFAULT_VERSION != '__VERSION__' else '')
  if version:
    return resolve_version_to_sha(version) or DEFAULT_BRANCH
  return DEFAULT_BRANCH


def resolve_target(target_arg: str, agent_arg: str) -> str:
  if target_arg:
    return os.path.abspath(os.path.expanduser(target_arg))
  if not agent_arg:
    err('--target is required (or pass --agent for a default)')
  if agent_arg not in AGENT_TARGETS:
    err(f'unknown --agent: {agent_arg} '
        f'(choose from {", ".join(sorted(AGENT_TARGETS))})')
  return os.path.join(os.path.expanduser('~'), AGENT_TARGETS[agent_arg])


def download_and_extract(ref: str, workdir: str) -> str:
  """Download the branch tarball and return the extracted root dir."""
  url = f'https://codeload.github.com/{REPO}/tar.gz/{ref}'
  print(f'Downloading {url}')
  tarball = os.path.join(workdir, 'bundle.tar.gz')
  try:
    urllib.request.urlretrieve(url, tarball)  # noqa: S310 (trusted host)
  except Exception as e:  # noqa: BLE001
    err(f'failed to download {url}: {e}')
  with tarfile.open(tarball, 'r:gz') as tf:
    tf.extractall(workdir)  # noqa: S202 (trusted archive from GitHub)
  # The tarball expands into a single top-level "perfetto-<ref>" directory.
  roots = [
      d for d in os.listdir(workdir)
      if d.startswith('perfetto-') and os.path.isdir(os.path.join(workdir, d))
  ]
  if not roots:
    err('unexpected tarball layout (no perfetto-* directory)')
  return os.path.join(workdir, roots[0])


def _confirm(question: str) -> Optional[bool]:
  """Ask a yes/no question on the controlling terminal.

  The installer is normally run as `curl ... | python3 -`, so sys.stdin is the
  piped script, not the user — input() would hit EOF. Read from the terminal
  directly instead. Returns None when there is no terminal to ask (e.g. CI), so
  the caller can decide how to proceed.
  """
  tty_name = 'CON' if os.name == 'nt' else '/dev/tty'
  try:
    with open(tty_name, 'r') as tty_in, open(tty_name, 'w') as tty_out:
      tty_out.write(f'{question} [y/N] ')
      tty_out.flush()
      answer = tty_in.readline().strip().lower()
  except OSError:
    return None
  return answer in ('y', 'yes')


def _refresh_index(dst_skills: str) -> None:
  """Point skills/index.json at the just-installed Perfetto skill.

  OpenCode discovers skills via this file. The destination skills/ dir may be
  shared with skills the user installed elsewhere, so we replace only the
  Perfetto entry and leave any others in place (creating the file if absent).
  """
  index_path = os.path.join(dst_skills, 'index.json')
  entries = []
  if os.path.isfile(index_path):
    try:
      with open(index_path) as f:
        loaded = json.load(f).get('skills', [])
      if isinstance(loaded, list):
        entries = [e for e in loaded if isinstance(e, dict)]
    except (OSError, ValueError):
      entries = []

  files = []
  skill_dir = os.path.join(dst_skills, SKILL_NAME)
  for root, _, fnames in os.walk(skill_dir):
    for f in fnames:
      files.append(os.path.relpath(os.path.join(root, f), skill_dir))

  merged = [e for e in entries if e.get('name') != SKILL_NAME]
  merged.append({'name': SKILL_NAME, 'files': sorted(files)})
  merged.sort(key=lambda e: e.get('name', ''))
  with open(index_path, 'w') as f:
    f.write(json.dumps({'skills': merged}, indent=2) + '\n')


def install(extracted: str, target: str, agent: Optional[str],
            assume_yes: bool) -> None:
  src_skill = os.path.join(extracted, 'plugins', 'perfetto', 'skills',
                           SKILL_NAME)
  if not os.path.isdir(src_skill):
    err(f'branch is missing plugins/perfetto/skills/{SKILL_NAME} '
        '(wrong ref?)')

  os.makedirs(os.path.join(target, 'bin'), exist_ok=True)
  dst_skills = os.path.join(target, 'skills')
  os.makedirs(dst_skills, exist_ok=True)

  # Touch only the Perfetto skill: the destination skills/ dir is often shared
  # with skills the user installed elsewhere (e.g. pi's ~/.agents/skills), so we
  # never remove anything but our own.
  dst_skill = os.path.join(dst_skills, SKILL_NAME)
  if os.path.exists(dst_skill):
    if not assume_yes:
      confirmed = _confirm(f'Replace the existing Perfetto skill at '
                           f'{dst_skill}?')
      if confirmed is None:
        err(f'{dst_skill} already exists and there is no terminal to confirm '
            f'the replacement; re-run with --yes to replace it.')
      if not confirmed:
        print('Aborted: left the existing Perfetto skill untouched.')
        return
    shutil.rmtree(dst_skill)
  shutil.copytree(src_skill, dst_skill)

  # index.json is only meaningful for agents that discover skills through it.
  # Refresh it for those, and for any target that already has one, but never
  # fabricate it for agents that load SKILL.md directly.
  if agent in INDEX_AGENTS or os.path.isfile(
      os.path.join(dst_skills, 'index.json')):
    _refresh_index(dst_skills)

  # Convenience copy of the wrapper at <target>/bin so users can put it on
  # PATH. The canonical copy lives inside the skill (bin/trace_processor),
  # which is what the skill's $SKILL_ROOT-based instructions use.
  src_tp = os.path.join(dst_skill, 'bin', 'trace_processor')
  if not os.path.isfile(src_tp):
    err(f'branch is missing skills/{SKILL_NAME}/bin/trace_processor '
        '(wrong ref?)')
  dst_tp = os.path.join(target, 'bin', 'trace_processor')
  shutil.copy(src_tp, dst_tp)
  os.chmod(dst_tp, 0o755)


def print_path_hint(target: str) -> None:
  bin_dir = os.path.join(target, 'bin')
  print()
  print(f'Installed Perfetto AI agents skills into: {target}')
  print(f'  skills:          {os.path.join(target, "skills")}')
  print(f'  trace_processor: {os.path.join(bin_dir, "trace_processor")}')
  print()
  print('Add the bundled trace_processor to your PATH:')
  if os.name == 'nt':
    print(f'  $env:PATH = "{bin_dir};$env:PATH"')
  else:
    print(f'  export PATH="{bin_dir}:$PATH"')


def main() -> int:
  ap = argparse.ArgumentParser(
      description='Install the Perfetto AI agents skills bundle.')
  ap.add_argument(
      '--target',
      help='Destination directory. Required unless --agent supplies a default.')
  ap.add_argument(
      '--agent',
      help='One of: claude, codex, opencode, antigravity, pi. Fills a default '
      '--target when --target is not given.')
  ap.add_argument(
      '--version',
      help='Release version to install (e.g. v56.0); resolves to that '
      "release's pinned bundle. Defaults to the version the installer was "
      'published with, else the latest ai-agents tip.')
  ap.add_argument(
      '--ref',
      help='Raw git ref to download (branch, tag or SHA). Overrides --version.')
  ap.add_argument(
      '-y',
      '--yes',
      action='store_true',
      help='Replace an existing Perfetto skill without prompting (for '
      'non-interactive installs).')
  args = ap.parse_args()

  target = resolve_target(args.target, args.agent)
  ref = resolve_ref(args.ref, args.version)

  with tempfile.TemporaryDirectory() as workdir:
    extracted = download_and_extract(ref, workdir)
    install(extracted, target, args.agent, args.yes)

  print_path_hint(target)
  return 0


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