#!/usr/bin/env vpython3
# Copyright 2026 the V8 project authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Install local GitHub Copilot CLI adapters for V8 agent files.

Generated files are ignored by Git. Pass --force to refresh files created by a
previous run without replacing user-managed files.
"""

from __future__ import annotations

import argparse
import os
from pathlib import Path
import re
import stat
import sys
import yaml

FILE_PATH = Path(__file__).resolve()
GENERATED_MARKER = (
    "<!-- Generated by agents/scripts/install_for_copilot_cli.py. -->")
# Copilot only reads instruction files that carry this suffix.
RULE_ADAPTER_SUFFIX = ".instructions.md"
FRONTMATTER_PATTERN = re.compile(
    r"\A(?P<block>---[ \t]*\r?\n(?P<yaml>.*?)\r?\n---[ \t]*)"
    r"(?:\r?\n|\Z)", re.DOTALL)
COPILOT_INCOMPATIBLE_RULES = {
    # Mandates orchestration-only operation through subagent and `gdb-mcp`
    # APIs not mapped by this adapter.
    "debugging.md",
    # Hard-codes Jetski/Gemini CLI behavior and adapter paths.
    "framework.md",
    # Mandates `TAG=agy` and a conversation ID that this adapter cannot
    # supply, which would land in uploaded CL descriptions.
    "git-commit.md",
}
COPILOT_INCOMPATIBLE_SKILLS = {
    # Requires client-specific approval suppression and agent lifecycle controls
    # not mapped by this adapter.
    "agent-evaluation-framework",
    # Mandates a subagent API and model tier not mapped by this adapter.
    "doc-invalidation-checker",
    # Maps only Jetski and Gemini CLI tools.
    "env-abstraction",
    # Mandates scheduling and delegation APIs not mapped by this adapter.
    "orchestrator",
    # Mandates internal Buganizer, session-context, and delegation APIs not
    # provisioned or mapped by this adapter.
    "v8-security-triaging",
    # Mixes client-neutral tool setup with Jetski-specific .agents, MCP, and
    # settings guidance; exclude it until split or adapted.
    "v8-setup",
}


def _path_entry_exists(path: Path) -> bool:
  try:
    path.lstat()
  except FileNotFoundError:
    return False
  return True


def _path_entry_is_reparse_point(path: Path) -> bool:
  path_stat = path.lstat()
  if stat.S_ISLNK(path_stat.st_mode):
    return True
  file_attributes = getattr(path_stat, "st_file_attributes", 0)
  reparse_attribute = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
  return bool(file_attributes & reparse_attribute)


class CopilotInstaller:
  """Installs Copilot CLI adapters for one V8 checkout."""

  def __init__(self, repo_root: Path):
    self.repo_root = repo_root
    self.agents_dir = repo_root / "agents"
    self.skills_dir = self.agents_dir / "skills"
    self.rules_dir = self.agents_dir / "rules"
    self.github_dir = repo_root / ".github"
    self.copilot_instructions = self.github_dir / "copilot-instructions.md"
    self.github_skills_dir = self.github_dir / "skills"
    self.github_instructions_dir = self.github_dir / "instructions"

  def _repo_path(self, path: Path) -> str:
    return path.relative_to(self.repo_root).as_posix()

  def _frontmatter(self, markdown_file: Path) -> tuple[dict[str, str], str]:
    """Return parsed YAML frontmatter and its original Markdown block."""
    match = FRONTMATTER_PATTERN.match(markdown_file.read_text(encoding="utf-8"))
    if not match:
      return {}, ""
    metadata = yaml.safe_load(match.group("yaml")) or {}
    if not isinstance(metadata, dict):
      metadata = {}
    return metadata, match.group("block")

  def _write_generated_file(self, path: Path, content: str) -> bool:
    if path.is_symlink():
      print(f"Skipping {path}: existing path is a symlink")
      return False
    if _path_entry_exists(path):
      try:
        existing = path.read_text(encoding="utf-8")
      except UnicodeDecodeError:
        # Destinations under .github/ are user territory, so skip undecodable
        # files instead of aborting the install part-way through.
        print(f"Skipping {path}: existing file is not UTF-8 text")
        return False
      if GENERATED_MARKER not in existing:
        print(f"Skipping {path}: existing file is not generated")
        return False

    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8", newline="\n")
    print(f"Wrote {path}")
    return True

  def _rule_apply_to(self, metadata: dict[str, str]) -> str | None:
    """Return the Copilot `applyTo` glob for a rule.

    `None` means Copilot has no equivalent trigger and the rule is only
    advertised in the instructions.
    """
    trigger = str(metadata.get("trigger", ""))
    if trigger == "always_on":
      # Copilot has no always-on trigger, but `**` matches every file.
      return "**"
    if trigger == "glob":
      return str(metadata.get("globs", "")) or None
    # Some rules carry the glob in the trigger field instead of `globs`.
    if "*" in trigger:
      return trigger
    return None

  def _compatible_rules(self):
    """Yield `(rule_file, metadata, apply_to)` for each compatible rule."""
    if not self.rules_dir.is_dir():
      return
    for rule_file in sorted(self.rules_dir.glob("*.md")):
      if rule_file.name in COPILOT_INCOMPATIBLE_RULES:
        continue
      metadata, _ = self._frontmatter(rule_file)
      yield rule_file, metadata, self._rule_apply_to(metadata)

  def _rule_source_for(self, adapter_name: str) -> Path:
    stem = adapter_name
    if stem.endswith(RULE_ADAPTER_SUFFIX):
      stem = stem[:-len(RULE_ADAPTER_SUFFIX)]
    return self.rules_dir / f"{stem}.md"

  def _enumerate_rules(self) -> str:
    """List rules that Copilot cannot trigger on its own."""
    entries = []
    for rule_file, metadata, apply_to in self._compatible_rules():
      if apply_to is not None:
        continue
      entry = f"- `{self._repo_path(rule_file)}`"
      description = metadata.get("description", "")
      if description:
        entry += f": {description}"
      entries.append(entry)

    if not entries:
      return ""
    return ("\n## Optional rules\n\n"
            "Copilot cannot trigger these rules automatically. Read one when "
            "its description matches the current task:\n\n" +
            "\n".join(entries) + "\n")

  def _instructions(self) -> str:
    return f"""{GENERATED_MARKER}
# V8 GitHub Copilot Instructions

The canonical V8 agent knowledge lives under `agents/`. Update those checked-in
files rather than this generated adapter.

## Canonical context

Rules install as Copilot instruction files under `.github/instructions/`, which
Copilot applies on its own. Load compatible V8 skills on demand through
`.github/skills/`; their canonical content and supporting resources remain in
`agents/skills/`. When a rule or skill names another agent's tool, use the
Copilot CLI equivalent.
{self._enumerate_rules()}"""

  def _generated_markdown(self, path: Path) -> bool:
    if not path.is_file():
      return False
    try:
      return GENERATED_MARKER in path.read_text(encoding="utf-8")
    except UnicodeDecodeError:
      # A file this script cannot decode is not one it generated.
      return False

  def _generated_link(self, path: Path, source: Path) -> bool:
    if not path.is_symlink():
      return False
    try:
      return os.readlink(path) == os.path.relpath(source, start=path.parent)
    except OSError:
      return False

  def _remove_generated_adapter(self, path: Path, source: Path) -> bool:
    """Remove an adapter this script generated, keeping user files intact."""
    if self._generated_link(path, source):
      path.unlink()
      return True
    if path.is_symlink() or _path_entry_is_reparse_point(path):
      return False
    if path.is_file():
      if not self._generated_markdown(path):
        return False
      path.unlink()
      return True
    if path.is_dir() and self._generated_markdown(path / "SKILL.md"):
      (path / "SKILL.md").unlink()
      if not any(path.iterdir()):
        path.rmdir()
      return True
    return False

  def _skill_wrapper(self, skill_dir: Path) -> str:
    skill_file = skill_dir / "SKILL.md"
    metadata, frontmatter = self._frontmatter(skill_file)
    name = metadata.get("name", skill_dir.name)
    if not frontmatter:
      frontmatter = (
          "---\n"
          f"name: {skill_dir.name}\n"
          f"description: Adapter for the canonical V8 {skill_dir.name} skill.\n"
          "---")
    canonical_path = self._repo_path(skill_file)
    base_path = self._repo_path(skill_dir)
    return f"""{frontmatter}
{GENERATED_MARKER}

# {name}

Read and follow the canonical skill at `{canonical_path}`.
Treat `{base_path}/` as the skill's base directory when resolving relative
paths and supporting resources.
"""

  def _write_skill_wrapper(self, skill_dir: Path, dest: Path) -> None:
    self._write_generated_file(dest / "SKILL.md",
                               self._skill_wrapper(skill_dir))

  def _rule_wrapper(self, rule_file: Path, apply_to: str) -> str:
    """Render a rule as an instruction file Copilot can act on.

    The canonical frontmatter is dropped rather than passed through, because
    its `trigger` vocabulary means nothing to Copilot.
    """
    _, frontmatter = self._frontmatter(rule_file)
    body = rule_file.read_text(encoding="utf-8")[len(frontmatter):]
    return (f"---\napplyTo: \"{apply_to}\"\n---\n"
            f"{GENERATED_MARKER}\n\n{body.lstrip()}")

  def _remove_stale_adapters(self, adapters_dir: Path, current: set[str],
                             source_for) -> None:
    if not adapters_dir.is_dir():
      return
    for dest in adapters_dir.iterdir():
      if dest.name in current:
        continue
      if self._remove_generated_adapter(dest, source_for(dest.name)):
        print(f"Removed stale adapter {dest}")

  def _install_skill(self, skill_dir: Path, force: bool) -> None:
    """Link the skill into `.github/`, falling back to a generated wrapper."""
    dest = self.github_skills_dir / skill_dir.name
    if _path_entry_exists(dest):
      if not force:
        print(f"Skipping {dest}: already exists")
        return
      if not self._remove_generated_adapter(dest, skill_dir):
        print(f"Skipping {dest}: existing path is not generated")
        return
      if _path_entry_exists(dest):
        self._write_skill_wrapper(skill_dir, dest)
        return

    dest.parent.mkdir(parents=True, exist_ok=True)
    target = Path(os.path.relpath(skill_dir, start=dest.parent))
    try:
      dest.symlink_to(target, target_is_directory=True)
      print(f"Symlinked {dest} -> {target}")
    except OSError as exc:
      if _path_entry_exists(dest):
        print(f"Skipping {dest}: failed to create a clean adapter: {exc}")
        return
      print(f"Could not symlink {dest}: {exc}; writing a wrapper instead")
      self._write_skill_wrapper(skill_dir, dest)

  def _install_rule(self, rule_file: Path, apply_to: str) -> None:
    # A rule cannot be linked the way a skill is, because Copilot needs an
    # `applyTo` header that the canonical file does not carry.
    dest = self.github_instructions_dir / (rule_file.stem + RULE_ADAPTER_SUFFIX)
    self._write_generated_file(dest, self._rule_wrapper(rule_file, apply_to))

  def install(self, force: bool) -> int:
    if not self.skills_dir.is_dir():
      print(f"Missing skills directory: {self.skills_dir}", file=sys.stderr)
      return 1

    for destination in (self.github_dir, self.github_skills_dir,
                        self.github_instructions_dir):
      if (_path_entry_exists(destination) and
          _path_entry_is_reparse_point(destination)):
        print(
            f"Unsafe adapter destination: {destination} is a symlink or "
            "reparse point",
            file=sys.stderr)
        return 1

    self._write_generated_file(self.copilot_instructions, self._instructions())

    self.github_skills_dir.mkdir(parents=True, exist_ok=True)

    current_skills = sorted(
        path for path in self.skills_dir.iterdir()
        if (path.is_dir() and (path / "SKILL.md").is_file() and
            path.name not in COPILOT_INCOMPATIBLE_SKILLS))
    current_rules = [(rule_file, apply_to)
                     for rule_file, _, apply_to in self._compatible_rules()
                     if apply_to is not None]
    # Stale adapters are dropped even without `--force`: reclaiming a path
    # this script generated is not the same as overwriting a user's file, and
    # leaving one behind keeps a rule active after it stops being compatible.
    self._remove_stale_adapters(self.github_skills_dir,
                                {path.name for path in current_skills},
                                lambda name: self.skills_dir / name)
    self._remove_stale_adapters(
        self.github_instructions_dir,
        {path.stem + RULE_ADAPTER_SUFFIX for path, _ in current_rules},
        self._rule_source_for)
    for skill_dir in current_skills:
      self._install_skill(skill_dir, force)
    for rule_file, apply_to in current_rules:
      self._install_rule(rule_file, apply_to)

    print("GitHub Copilot CLI adapter installation complete.")
    print("Restart or resume your Copilot CLI session to pick up the changes.")
    return 0


def main(argv: list[str] | None = None) -> int:
  parser = argparse.ArgumentParser(description=__doc__)
  parser.add_argument(
      "--force",
      action="store_true",
      help="replace adapters generated by a previous run")
  args = parser.parse_args(argv)
  return CopilotInstaller(FILE_PATH.parents[2]).install(args.force)


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