#!/usr/bin/env python3

__package__ = "abxpkg"

import os
import re
import sys
import shlex
import shutil
import subprocess
from pathlib import Path
from typing import ClassVar, Self
from collections.abc import Iterable

from pydantic import Field, TypeAdapter, computed_field, model_validator

from .base_types import (
    BinName,
    BinProviderName,
    HostBinPath,
    InstallArgs,
    PATHStr,
    abxpkg_install_root_default,
)
from .binary import Binary
from .binprovider import (
    BinProvider,
    BinProviderUnavailableError,
    EnvProvider,
    env_flag_is_true,
    log_method_call,
    remap_kwargs,
)
from .binprovider_pnpm import PnpmProvider
from .logging import (
    format_command,
    format_subprocess_output,
    get_logger,
    log_subprocess_output,
)
from .semver import SemVer

logger = get_logger(__name__)

CLAUDE_SANDBOX_NO_PROXY = (
    "localhost,127.0.0.1,169.254.169.254,metadata.google.internal,"
    ".svc.cluster.local,.local"
)


class PuppeteerProvider(BinProvider):
    name: BinProviderName = "puppeteer"
    _log_emoji = "🎭"
    INSTALLER_BIN: BinName = "browsers"
    INSTALLER_BINPROVIDERS: ClassVar[tuple[BinProviderName, ...] | None] = (
        "env",
        "pnpm",
    )

    PATH: PATHStr = ""  # Starts empty; setup_PATH() fills it with bin_dir and any install_root/pnpm helper bins.
    postinstall_scripts: bool | None = Field(
        default_factory=lambda: env_flag_is_true("ABXPKG_POSTINSTALL_SCRIPTS"),
        repr=False,
    )
    min_release_age: float | None = Field(default=None, repr=False)

    # Default: ABXPKG_PUPPETEER_ROOT > ABXPKG_LIB_DIR/puppeteer > None.
    install_root: Path | None = Field(
        default_factory=lambda: abxpkg_install_root_default("puppeteer"),
        validation_alias="puppeteer_root",
    )
    # Only set in managed mode: setup()/default_abspath_handler() use it to expose stable
    # browser launch shims under ``<install_root>/bin``; global mode leaves it unset.
    bin_dir: Path | None = None

    @computed_field
    @property
    def ENV(self) -> "dict[str, str]":
        # In managed mode we pin ``PUPPETEER_CACHE_DIR`` to
        # ``<install_root>/cache``. In unmanaged mode we leave the
        # ambient env (or @puppeteer/browsers' own ``~/.cache/puppeteer``
        # default) untouched.
        env: dict[str, str] = {}
        if self.install_root is not None:
            env["PUPPETEER_CACHE_DIR"] = str(self.install_root / "cache")
        # @puppeteer/browsers downloads browsers from
        # storage.googleapis.com. In sandboxed environments the egress
        # proxy's NO_PROXY often includes ``.googleapis.com`` / ``.google.com``,
        # which forces the direct connection — which then fails DNS
        # resolution or times out. Override NO_PROXY / no_proxy to a
        # safe sandbox allowlist so the download goes through the proxy
        # instead. Callers that need their own NO_PROXY can still set it
        # via the CLI override flags; our value only fills in the default.
        ambient_no_proxy = os.environ.get("NO_PROXY") or os.environ.get("no_proxy")
        if not ambient_no_proxy or (
            ".googleapis.com" in ambient_no_proxy.lower()
            or ".google.com" in ambient_no_proxy.lower()
        ):
            env["NO_PROXY"] = CLAUDE_SANDBOX_NO_PROXY
            env["no_proxy"] = CLAUDE_SANDBOX_NO_PROXY
        return env

    def supports_postinstall_disable(self, action, no_cache: bool = False) -> bool:
        return action in ("install", "update")

    def supports_min_release_age(self, action, no_cache: bool = False) -> bool:
        """Report the capability of the pnpm install used to bootstrap our CLI."""
        if action not in ("install", "update"):
            return False
        return PnpmProvider().supports_min_release_age(action, no_cache=no_cache)

    @model_validator(mode="after")
    def detect_euid_to_use(self) -> Self:
        if self.bin_dir is None and self.install_root is not None:
            self.bin_dir = self.install_root / "bin"
        return self

    def setup_PATH(self, no_cache: bool = False) -> None:
        """Populate PATH on first use with bin_dir and any install_root/pnpm helper bin dirs."""
        lib_dir = os.environ.get("ABXPKG_LIB_DIR")
        hermetic = self.install_root is not None and (
            not lib_dir
            or not str(self.install_root).startswith(lib_dir.rstrip("/") + "/")
        )
        path_entries: list[Path] = []
        if self.bin_dir is not None:
            path_entries.append(self.bin_dir)
        if hermetic and self.install_root is not None:
            path_entries.append(self.install_root / "pnpm" / "node_modules" / ".bin")
        if path_entries:
            self.PATH = self._merge_PATH(
                *path_entries,
                PATH=self.PATH,
                prepend=True,
            )
        super().setup_PATH(no_cache=no_cache)

    def INSTALLER_BINARY(self, no_cache: bool = False):
        # Prefer the @puppeteer/browsers CLI bootstrapped by an earlier install
        # under ``<install_root>/pnpm/node_modules/.bin``. Without this, a
        # fresh provider copy (e.g. the one Binary.load() builds via
        # get_provider_with_overrides) can't locate the browsers CLI and
        # ``_list_installed_browsers()`` silently returns empty.
        lib_dir = os.environ.get("ABXPKG_LIB_DIR")
        if (
            self.install_root is not None
            and lib_dir
            and str(self.install_root).startswith(lib_dir.rstrip("/") + "/")
        ):
            local_cli = (
                Path(lib_dir)
                / "pnpm"
                / "packages"
                / "puppeteer"
                / "node_modules"
                / ".bin"
                / self.INSTALLER_BIN
            )
        elif self.install_root is not None:
            local_cli = (
                self.install_root
                / "pnpm"
                / "node_modules"
                / ".bin"
                / self.INSTALLER_BIN
            )
        else:
            local_cli = None

        if (
            local_cli is not None
            and local_cli.is_file()
            and os.access(local_cli, os.X_OK)
        ):
            if (
                not no_cache
                and self._INSTALLER_BINARY
                and self._INSTALLER_BINARY.loaded_abspath == local_cli
                and self._INSTALLER_BINARY.is_valid
            ):
                return self._INSTALLER_BINARY
            if not no_cache:
                cached = self.load_cached_binary(self.INSTALLER_BIN, local_cli)
                if cached and cached.loaded_abspath:
                    self._INSTALLER_BINARY = cached
                    return cached
            env_provider = EnvProvider(
                PATH=str(local_cli.parent),
                install_root=None,
                bin_dir=None,
            )
            env_provider.set_projection_providers(
                [PnpmProvider(install_root=local_cli.parents[2])],
            )
            loaded_local = env_provider.load(
                bin_name=self.INSTALLER_BIN,
                no_cache=no_cache,
            )
            if loaded_local and loaded_local.loaded_abspath:
                if loaded_local.loaded_version and loaded_local.loaded_sha256:
                    self.write_cached_binary(
                        self.INSTALLER_BIN,
                        loaded_local.loaded_abspath,
                        loaded_local.loaded_version,
                        loaded_local.loaded_sha256,
                        resolved_provider_name=(
                            loaded_local.loaded_binprovider.name
                            if loaded_local.loaded_binprovider is not None
                            else self.name
                        ),
                        resolved_provider=loaded_local.loaded_binprovider,
                        cache_kind="dependency",
                    )
                self._INSTALLER_BINARY = loaded_local
                return self._INSTALLER_BINARY

        env_provider = EnvProvider(install_root=None, bin_dir=None)
        if self.install_root is None:
            try:
                loaded = env_provider.load(
                    bin_name=self.INSTALLER_BIN,
                    no_cache=no_cache,
                )
            except Exception:
                loaded = None
            if loaded and loaded.loaded_abspath:
                if loaded.loaded_version and loaded.loaded_sha256:
                    self.write_cached_binary(
                        self.INSTALLER_BIN,
                        loaded.loaded_abspath,
                        loaded.loaded_version,
                        loaded.loaded_sha256,
                        resolved_provider_name=(
                            loaded.loaded_binprovider.name
                            if loaded.loaded_binprovider is not None
                            else self.name
                        ),
                        resolved_provider=loaded.loaded_binprovider,
                        cache_kind="dependency",
                    )
                self._INSTALLER_BINARY = loaded
                return self._INSTALLER_BINARY
        node_loaded = Binary(
            name="node",
            binproviders=[env_provider],
        ).load(no_cache=no_cache)
        if (
            node_loaded
            and node_loaded.loaded_abspath
            and node_loaded.loaded_version
            and node_loaded.loaded_sha256
        ):
            self.write_cached_binary(
                "node",
                node_loaded.loaded_abspath,
                node_loaded.loaded_version,
                node_loaded.loaded_sha256,
                resolved_provider_name=(
                    node_loaded.loaded_binprovider.name
                    if node_loaded.loaded_binprovider is not None
                    else self.name
                ),
                resolved_provider=node_loaded.loaded_binprovider,
                cache_kind="dependency",
            )
        raise BinProviderUnavailableError(
            self.__class__.__name__,
            self.INSTALLER_BIN,
        )

    def _cli_binary(
        self,
        *,
        postinstall_scripts: bool,
        min_release_age: float,
        no_cache: bool = False,
    ) -> Binary:
        lib_dir = os.environ.get("ABXPKG_LIB_DIR")
        if (
            self.install_root is not None
            and lib_dir
            and str(self.install_root).startswith(lib_dir.rstrip("/") + "/")
        ):
            pnpm_install_root = Path(lib_dir) / "pnpm" / "packages" / "puppeteer"
        elif self.install_root is not None:
            pnpm_install_root = self.install_root / "pnpm"
        else:
            pnpm_install_root = None
        cli_provider = PnpmProvider(
            install_root=pnpm_install_root,
            postinstall_scripts=postinstall_scripts,
            min_release_age=min_release_age,
        )
        return Binary(
            name=self.INSTALLER_BIN,
            binproviders=[cli_provider],
            overrides={"pnpm": {"install_args": ["@puppeteer/browsers"]}},
            postinstall_scripts=postinstall_scripts,
            min_release_age=min_release_age,
        ).install(no_cache=no_cache)

    def unzip_binary(self) -> Binary:
        """Resolve Puppeteer's archive extractor through native system providers."""
        from . import PROVIDER_CLASS_BY_NAME

        return Binary(
            name="unzip",
            binproviders=[
                PROVIDER_CLASS_BY_NAME[provider_name]()
                for provider_name in ("env", "brew", "apt")
            ],
        )

    @log_method_call()
    def setup(
        self,
        *,
        postinstall_scripts: bool | None = None,
        min_release_age: float | None = None,
        min_version=None,
        no_cache: bool = False,
    ) -> None:
        if self.euid is None:
            self.euid = self.detect_euid(
                owner_paths=(
                    self.install_root,
                    self.bin_dir,
                    self.install_root / "cache"
                    if self.install_root is not None
                    else None,
                    self.install_root / "pnpm"
                    if self.install_root is not None
                    else None,
                ),
                preserve_root=True,
            )
        try:
            cached = self.INSTALLER_BINARY(no_cache=no_cache)
        except Exception:
            cached = None
        if cached and cached.loaded_abspath:
            lib_dir = os.environ.get("ABXPKG_LIB_DIR")
            hermetic = self.install_root is not None and (
                not lib_dir
                or not str(self.install_root).startswith(lib_dir.rstrip("/") + "/")
            )
            path_entries: list[Path] = []
            if self.bin_dir is not None:
                path_entries.append(self.bin_dir)
            if hermetic and self.install_root is not None:
                path_entries.append(
                    self.install_root / "pnpm" / "node_modules" / ".bin",
                )
            if path_entries:
                self.PATH = self._merge_PATH(
                    *path_entries,
                    PATH="",
                    prepend=True,
                )
            return
        postinstall_scripts = (
            False if postinstall_scripts is None else postinstall_scripts
        )
        min_release_age = 0.0 if min_release_age is None else min_release_age

        if self.install_root is not None:
            self.install_root.mkdir(parents=True, exist_ok=True)
            (self.install_root / "cache").mkdir(parents=True, exist_ok=True)
        if self.bin_dir is not None:
            self.bin_dir.mkdir(parents=True, exist_ok=True)

        cli_binary = self._cli_binary(
            postinstall_scripts=postinstall_scripts,
            min_release_age=min_release_age,
            no_cache=no_cache,
        )
        self._INSTALLER_BINARY = cli_binary  # bootstrap: seed cache after pnpm install
        lib_dir = os.environ.get("ABXPKG_LIB_DIR")
        hermetic = self.install_root is not None and (
            not lib_dir
            or not str(self.install_root).startswith(lib_dir.rstrip("/") + "/")
        )
        path_entries: list[Path] = []
        if self.bin_dir is not None:
            path_entries.append(self.bin_dir)
        if hermetic and self.install_root is not None:
            path_entries.append(self.install_root / "pnpm" / "node_modules" / ".bin")
        if path_entries:
            self.PATH = self._merge_PATH(
                *path_entries,
                PATH="",
                prepend=True,
            )

    def _browser_name(
        self,
        bin_name: str,
        install_args: Iterable[str],
    ) -> str:
        for arg in install_args:
            arg_str = str(arg)
            if arg_str.startswith("-"):
                continue
            return arg_str.split("@", 1)[0]
        return bin_name

    def default_install_args_handler(self, bin_name: BinName, **context) -> InstallArgs:
        if str(bin_name) in ("chrome", "chromium"):
            install_args = ["chromium@latest"]
            if (
                sys.platform.startswith("linux")
                and os.name == "posix"
                and os.geteuid() == 0
            ):
                install_args.append("--install-deps")
            return tuple(install_args)
        return TypeAdapter(InstallArgs).validate_python(
            super().default_install_args_handler(bin_name, **context)
            or [str(bin_name)],
        )

    def _normalize_install_args(self, install_args: Iterable[str]) -> list[str]:
        normalized: list[str] = []
        skip_next = False
        for arg in install_args:
            arg_str = str(arg)
            if skip_next:
                skip_next = False
                continue
            if arg_str == "--path":
                skip_next = True
                continue
            if arg_str.startswith("--path="):
                continue
            normalized.append(arg_str)
        if self.install_root is not None:
            normalized.append(f"--path={self.install_root / 'cache'}")
        return normalized

    def _list_installed_browsers(
        self,
        no_cache: bool = False,
    ) -> list[tuple[str, str, Path]]:
        try:
            installer_bin = self.INSTALLER_BINARY(no_cache=no_cache).loaded_abspath
        except Exception:
            return []
        if not installer_bin:
            return []
        cmd = ["list"]
        if self.install_root is not None:
            cmd.append(f"--path={self.install_root / 'cache'}")
        proc = self.exec(
            bin_name=installer_bin,
            cmd=cmd,
            cwd=self.install_root or ".",
            quiet=True,
            timeout=self.version_timeout,
        )
        if proc.returncode != 0:
            return []

        matches: list[tuple[str, str, Path]] = []
        pattern = re.compile(
            r"^(?P<browser>[^@\s]+)@(?P<version>\S+)(?:\s+\([^)]+\))?\s+(?P<path>.+)$",
        )
        for line in proc.stdout.splitlines():
            match = pattern.match(line.strip())
            if not match:
                continue
            matches.append(
                (
                    match.group("browser"),
                    match.group("version"),
                    Path(match.group("path")),
                ),
            )
        return matches

    def _parse_installed_browser_path(
        self,
        output: str,
        browser_name: str,
    ) -> Path | None:
        pattern = re.compile(
            r"^(?P<browser>[^@\s]+)@(?P<version>\S+)(?:\s+\([^)]+\))?\s+(?P<path>.+)$",
            re.MULTILINE,
        )
        matches = [
            (
                match.group("version"),
                Path(match.group("path")),
            )
            for match in pattern.finditer(output or "")
            if match.group("browser") == browser_name
        ]
        parsed_matches = [
            (parsed_version, path)
            for version, path in matches
            if (parsed_version := SemVer.parse(version)) is not None
        ]
        if parsed_matches:
            return max(parsed_matches, key=lambda item: item[0])[1]
        if len(matches) == 1:
            return matches[0][1]
        return None

    def _resolve_installed_browser_path(
        self,
        bin_name: str,
        install_args: Iterable[str] | None = None,
        no_cache: bool = False,
    ) -> Path | None:
        # Pick up the caller's configured install_args so
        # ``bin_name=chrome`` + ``install_args=["chromium@latest"]``
        # resolves to Puppeteer's real ``browser_name="chromium"`` (matching what
        # ``puppeteer-browsers list`` reports), instead of falling
        # back to ``[bin_name]`` which would look for the alias name.
        if install_args is None:
            install_args = self.get_install_args(bin_name, quiet=True) or [bin_name]
        browser_name = self._browser_name(
            bin_name,
            self._normalize_install_args(install_args),
        )
        candidates = [
            (version, path)
            for candidate_browser, version, path in self._list_installed_browsers(
                no_cache=no_cache,
            )
            if candidate_browser == browser_name
        ]
        if not candidates and str(bin_name) == "chrome":
            candidates = [
                (version, path)
                for candidate_browser, version, path in self._list_installed_browsers(
                    no_cache=no_cache,
                )
                if candidate_browser in ("chromium", "chrome", "chrome-headless-shell")
            ]
        parsed_candidates = [
            (parsed_version, path)
            for version, path in candidates
            if (parsed_version := SemVer.parse(version)) is not None
        ]
        if parsed_candidates:
            return max(parsed_candidates, key=lambda item: item[0])[1]
        if not candidates:
            return None
        if len(candidates) == 1:
            return candidates[0][1]
        # Multiple cached builds but none parse as ``SemVer`` (e.g. chromium's
        # integer build IDs like ``1618539``). Fall back to the newest one
        # by file mtime so post-install lookups land on the freshly-
        # downloaded version rather than ``None``.
        candidates.sort(
            key=lambda item: (
                item[1].stat().st_mtime if item[1].exists() else 0,
                item[0],
            ),
        )
        return candidates[-1][1]

    def _refresh_symlink(self, bin_name: str, target: Path) -> Path:
        bin_dir = self.bin_dir
        assert bin_dir is not None
        link_path = bin_dir / bin_name
        link_path.parent.mkdir(parents=True, exist_ok=True)
        use_shell_shim = os.name == "posix" and (
            ".app/Contents/MacOS/" in str(target)
            or target.name
            in {
                "chrome",
                "chromium",
                "chrome-headless-shell",
            }
        )
        desired_script = (
            f'#!/bin/sh\nexec {shlex.quote(str(target))} "$@"\n'
            if use_shell_shim
            else None
        )
        # Idempotent refresh: leave the shim untouched when it already
        # points at ``target``. Rewriting on every ``load()`` would bump
        # the shim's mtime (shell-script case), which breaks callers
        # that stat the shim to validate a freshly-installed binary.
        if use_shell_shim and link_path.is_file() and not link_path.is_symlink():
            try:
                if link_path.read_text(encoding="utf-8") == desired_script:
                    return link_path
            except OSError:
                pass
        elif link_path.is_symlink():
            try:
                if os.readlink(link_path) == str(target):
                    return link_path
            except OSError:
                pass
        if link_path.exists() or link_path.is_symlink():
            link_path.unlink(missing_ok=True)
        if use_shell_shim:
            assert desired_script is not None
            link_path.write_text(desired_script, encoding="utf-8")
            link_path.chmod(0o755)
            return link_path
        link_path.symlink_to(target)
        return link_path

    def default_abspath_handler(
        self,
        bin_name: BinName | HostBinPath,
        no_cache: bool = False,
        **context,
    ) -> HostBinPath | None:
        if str(bin_name) == self.INSTALLER_BIN:
            try:
                installer_binary = self.INSTALLER_BINARY(no_cache=no_cache)
                abspath = installer_binary.loaded_abspath if installer_binary else None
                if abspath:
                    return TypeAdapter(HostBinPath).validate_python(abspath)
            except Exception:
                return None
            return None

        # Authoritative lookup: ask puppeteer-browsers where the browser
        # actually lives — never trust the managed ``bin_dir`` shim as a
        # source of truth, it may point at a browser that was removed
        # out-of-band. When the CLI reports nothing, we report nothing.
        resolved = self._resolve_installed_browser_path(str(bin_name))
        if not resolved or not resolved.exists():
            return None

        # Refresh the convenience shim under ``bin_dir`` so ``PATH`` users
        # get a stable entry pointing at the freshly-resolved executable.
        # In global/unmanaged mode (``install_root=None``) we have no
        # managed shim dir, so just return the resolved path directly.
        # When the shim refresh fails (read-only FS etc.) we also fall
        # back to the resolved path.
        if self.bin_dir is None:
            return resolved
        try:
            return self._refresh_symlink(str(bin_name), resolved)
        except OSError:
            return resolved

    def default_version_handler(
        self,
        bin_name: BinName,
        abspath: HostBinPath | None = None,
        timeout: int | None = None,
        no_cache: bool = False,
        **context,
    ) -> SemVer | None:
        abspath = abspath or self.get_abspath(bin_name, quiet=True, no_cache=no_cache)
        if not abspath:
            return None

        version_cmd = ["--version"]
        if (
            sys.platform.startswith("linux")
            and os.name == "posix"
            and os.geteuid() == 0
        ):
            version_cmd.append("--no-sandbox")

        proc = self.exec(
            bin_name=abspath,
            cmd=version_cmd,
            timeout=self.version_timeout if timeout is None else timeout,
            quiet=True,
        )
        version_output = proc.stdout.strip() or proc.stderr.strip()
        if proc.returncode == 0 and version_output:
            return SemVer.parse(version_output)

        return self._version_from_exec(
            bin_name,
            abspath=abspath,
            timeout=timeout,
        )

    def _cleanup_partial_browser_cache(
        self,
        install_output: str,
        browser_name: str,
    ) -> bool:
        if self.install_root is None:
            return False
        cache_dir = self.install_root / "cache"
        targets: set[Path] = set()
        browser_cache_dir = cache_dir / browser_name

        missing_dir_match = re.search(
            r"browser folder \(([^)]+)\) exists but the executable",
            install_output,
        )
        if missing_dir_match:
            targets.add(Path(missing_dir_match.group(1)))

        missing_zip_match = re.search(r"open '([^']+\.zip)'", install_output)
        if missing_zip_match:
            targets.add(Path(missing_zip_match.group(1)))

        build_id_match = re.search(
            rf"All providers failed for {re.escape(browser_name)} (\S+)",
            install_output,
        )
        if build_id_match and browser_cache_dir.exists():
            build_id = build_id_match.group(1)
            targets.update(browser_cache_dir.glob(f"*{build_id}*"))

        removed_any = False
        resolved_cache = cache_dir.resolve(strict=False)
        for target in targets:
            resolved_target = target.resolve(strict=False)
            if not (
                resolved_target == resolved_cache
                or resolved_cache in resolved_target.parents
            ):
                continue
            if target.is_dir():
                logger.info("$ %s", format_command(["rm", "-rf", str(target)]))
                shutil.rmtree(target, ignore_errors=True)
                removed_any = True
            elif target.exists():
                logger.info("$ %s", format_command(["rm", "-f", str(target)]))
                target.unlink(missing_ok=True)
                removed_any = True
        return removed_any

    def _should_repair_cli_install(self, output: str) -> bool:
        lowered = (output or "").lower()
        return (
            "this.shim.parser.camelcase is not a function" in lowered
            or "yargs/build/lib/command.js" in lowered
        )

    def _get_install_failure_hint(self, install_output: str) -> str | None:
        lowered = (install_output or "").lower()
        if (
            "storage.googleapis.com" in lowered
            and "getaddrinfo" in lowered
            and "eai_again" in lowered
        ):
            return (
                "Puppeteer failed to download a browser from storage.googleapis.com. "
                "Override NO_PROXY/no_proxy to remove .googleapis.com and .google.com. "
                f'Example NO_PROXY="{CLAUDE_SANDBOX_NO_PROXY}"'
            )
        return None

    def _has_sudo(self) -> bool:
        try:
            return self._sudo_binary() is not None
        except Exception:
            return False

    def _sudo_binary(self, *, no_cache: bool = False) -> Binary | None:
        return Binary(
            name="sudo",
            binproviders=[
                EnvProvider(postinstall_scripts=True, min_release_age=0),
            ],
            postinstall_scripts=True,
            min_release_age=0,
        ).load(no_cache=no_cache)

    def _run_install_with_sudo(
        self,
        install_args: list[str],
        no_cache: bool = False,
    ) -> subprocess.CompletedProcess[str] | None:
        try:
            installer_binary = self._INSTALLER_BINARY
            if installer_binary is None or installer_binary.loaded_abspath is None:
                installer_binary = self.INSTALLER_BINARY(no_cache=no_cache)
            installer_bin = installer_binary.loaded_abspath
            assert installer_bin
        except Exception:
            return None
        sudo_binary = self._sudo_binary()
        if sudo_binary is None or sudo_binary.loaded_abspath is None:
            return None

        proc = self.exec(
            bin_name=sudo_binary.loaded_abspath,
            cmd=["-E", str(installer_bin), "install", *install_args],
            cwd=self.install_root or ".",
            timeout=self.install_timeout,
        )
        if proc.returncode == 0 and self.install_root is not None:
            cache_dir = self.install_root / "cache"
            if cache_dir.exists():
                # ArchiveBox can retain ruid=0 while running as its service user.
                uid = os.geteuid()
                gid = os.getegid()
                chown_proc = self.exec(
                    bin_name=sudo_binary.loaded_abspath,
                    cmd=["chown", "-R", f"{uid}:{gid}", str(cache_dir)],
                    cwd=self.install_root or ".",
                    timeout=30,
                    quiet=True,
                )
                if chown_proc.returncode != 0:
                    log_subprocess_output(
                        logger,
                        f"{self.__class__.__name__} sudo chown",
                        chown_proc.stdout,
                        chown_proc.stderr,
                    )
        return proc

    def _install_chromium_system_deps(
        self,
        installer_bin: HostBinPath,
        normalized_install_args: list[str],
        timeout: int,
    ) -> None:
        if (
            not sys.platform.startswith("linux")
            or os.name != "posix"
            or os.geteuid() != 0
            or "--install-deps" not in normalized_install_args
            or self._browser_name("", normalized_install_args) != "chromium"
        ):
            return

        # Puppeteer's Chromium snapshots do not ship deb.deps, so
        # `chromium@latest --install-deps` downloads Chromium but has no
        # package manifest to satisfy. The Chrome headless shell artifact
        # does ship the same Linux dependency manifest; use Puppeteer's own
        # install-deps path, then discard the helper browser cache.
        deps_args = self._normalize_install_args(
            ["chrome-headless-shell@latest", "--install-deps"],
        )
        proc = self.exec(
            bin_name=installer_bin,
            cmd=["install", *deps_args],
            cwd=self.install_root or ".",
            timeout=timeout,
        )
        if proc.returncode != 0:
            self._raise_proc_error("install", "chrome-headless-shell", proc)

        if self.install_root is not None:
            shutil.rmtree(
                self.install_root / "cache" / "chrome-headless-shell",
                ignore_errors=True,
            )

    def default_search_handler(
        self,
        bin_name: BinName,
        min_version: SemVer | None = None,
        min_release_age: float | None = None,
        timeout: int | None = None,
        **context,
    ) -> list:
        """Match bin_name against @puppeteer/browsers's fixed list of supported browsers.

        Like playwright, puppeteer-browsers exposes a closed enum of browser
        targets rather than a real package index, so we match the query as a
        substring against that enum.
        """
        from .binary import Binary

        supported = (
            "chrome",
            "chrome-beta",
            "chrome-canary",
            "chrome-dev",
            "chrome-headless-shell",
            "chromedriver",
            "chromium",
            "firefox",
            "firefox-beta",
            "firefox-nightly",
        )
        return [
            Binary(
                name=browser,
                description=f"puppeteer browser ({browser})",
                binproviders=[self],
                overrides={self.name: {"install_args": [browser]}},
            )
            for browser in supported
            if str(bin_name) in browser
        ]

    @remap_kwargs({"packages": "install_args"})
    def default_install_handler(
        self,
        bin_name: str,
        install_args: InstallArgs | None = None,
        timeout: int | None = None,
        no_cache: bool = False,
        **context,
    ) -> str:
        self.setup(no_cache=no_cache)
        if str(bin_name) == self.INSTALLER_BIN:
            return f"Bootstrapped {self.INSTALLER_BIN} via pnpm"
        install_args = list(install_args or self.get_install_args(bin_name))
        normalized_install_args = self._normalize_install_args(install_args)
        browser_name = self._browser_name(bin_name, normalized_install_args)

        if self.dry_run:
            return f"DRY_RUN would install {browser_name} via @puppeteer/browsers"

        unzip = self.unzip_binary().install(no_cache=no_cache)
        if not unzip.loaded_abspath:
            raise RuntimeError("abxpkg could not resolve or install unzip")

        installer_binary = self._INSTALLER_BINARY
        if installer_binary is None or installer_binary.loaded_abspath is None:
            installer_binary = self.INSTALLER_BINARY(no_cache=no_cache)
        installer_bin = installer_binary.loaded_abspath
        assert installer_bin
        self._install_chromium_system_deps(
            installer_bin,
            normalized_install_args,
            timeout if timeout is not None else self.install_timeout,
        )
        proc = self.exec(
            bin_name=installer_bin,
            cmd=["install", *normalized_install_args],
            cwd=self.install_root or ".",
            timeout=timeout if timeout is not None else self.install_timeout,
        )

        install_output = f"{proc.stdout}\n{proc.stderr}"
        if (
            proc.returncode != 0
            and "--install-deps" in normalized_install_args
            and "requires root privileges" in install_output
            and os.geteuid() != 0
            and self._has_sudo()
        ):
            sudo_proc = self._run_install_with_sudo(
                normalized_install_args,
                no_cache=no_cache,
            )
            if sudo_proc is not None:
                proc = sudo_proc
                install_output = f"{proc.stdout}\n{proc.stderr}"

        if proc.returncode != 0 and self._should_repair_cli_install(install_output):
            cli_binary = self._cli_binary(postinstall_scripts=True, min_release_age=0)
            self._INSTALLER_BINARY = (
                cli_binary  # bootstrap: seed cache after pnpm install
            )
            installer_binary = self._INSTALLER_BINARY
            if installer_binary is None or installer_binary.loaded_abspath is None:
                installer_binary = self.INSTALLER_BINARY(no_cache=no_cache)
            installer_bin = installer_binary.loaded_abspath
            assert installer_bin
            proc = self.exec(
                bin_name=installer_bin,
                cmd=["install", *normalized_install_args],
                cwd=self.install_root or ".",
                timeout=timeout if timeout is not None else self.install_timeout,
            )
            install_output = f"{proc.stdout}\n{proc.stderr}"

        if proc.returncode != 0 and self._cleanup_partial_browser_cache(
            install_output,
            browser_name,
        ):
            proc = self.exec(
                bin_name=installer_bin,
                cmd=["install", *normalized_install_args],
                cwd=self.install_root or ".",
                timeout=timeout if timeout is not None else self.install_timeout,
            )
            install_output = f"{proc.stdout}\n{proc.stderr}"

        if proc.returncode != 0:
            install_hint = self._get_install_failure_hint(install_output)
            if install_hint:
                raise RuntimeError(install_hint) from None
            self._raise_proc_error("install", bin_name, proc)

        installed_path = self._parse_installed_browser_path(
            install_output,
            browser_name,
        )
        installed_path = installed_path or self._resolve_installed_browser_path(
            bin_name,
            install_args,
            no_cache=no_cache,
        )
        if not installed_path or not installed_path.exists():
            raise FileNotFoundError(
                f"{self.__class__.__name__} could not resolve installed browser path for {bin_name}",
            )

        if self.bin_dir is not None:
            self._refresh_symlink(bin_name, installed_path)
        return format_subprocess_output(proc.stdout, proc.stderr)

    @remap_kwargs({"packages": "install_args"})
    def default_update_handler(
        self,
        bin_name: str,
        install_args: InstallArgs | None = None,
        timeout: int | None = None,
        no_cache: bool = False,
        **context,
    ) -> str:
        return self.default_install_handler(
            bin_name,
            install_args=install_args,
            timeout=timeout,
            no_cache=no_cache,
            **context,
        )

    @remap_kwargs({"packages": "install_args"})
    def default_uninstall_handler(
        self,
        bin_name: str,
        install_args: InstallArgs | None = None,
        **context,
    ) -> bool:
        # Resolve the real browser directory via the CLI directly
        # (``_resolve_installed_browser_path`` shells out to
        # ``puppeteer-browsers list``) so we don't round-trip through
        # ``load()`` → ``default_abspath_handler`` and have it refresh
        # the managed shim right as we're about to delete it. Honours
        # managed ``install_root``, ambient ``PUPPETEER_CACHE_DIR``,
        # and puppeteer-browsers' own default uniformly.
        install_args = list(install_args or self.get_install_args(bin_name))
        normalized_install_args = self._normalize_install_args(install_args)
        browser_name = self._browser_name(bin_name, normalized_install_args)
        # Forward install_args so ``_resolve_installed_browser_path`` uses
        # the same ``browser_name`` we just derived; otherwise it re-runs
        # ``get_install_args`` and can pick up a different alias (e.g.
        # caller-passed browser channel vs provider default alias),
        # which would leave the parent.name match below unsatisfied and
        # silently skip the rmtree.
        resolved = self._resolve_installed_browser_path(
            str(bin_name),
            install_args=normalized_install_args,
        )
        if resolved is not None:
            for parent in Path(resolved).resolve().parents:
                if parent.name == browser_name:
                    logger.info("$ %s", format_command(["rm", "-rf", str(parent)]))
                    shutil.rmtree(parent, ignore_errors=True)
                    break

        # Finally, drop the convenience shim under ``bin_dir``. Doing
        # this last avoids the "unlink → load() refresh → rmtree →
        # dangling shim" ordering bug.
        if self.bin_dir is not None:
            bin_path = self.bin_dir / bin_name
            if bin_path.exists() or bin_path.is_symlink():
                logger.info("$ %s", format_command(["rm", "-f", str(bin_path)]))
            bin_path.unlink(missing_ok=True)
        return True
