#!/usr/bin/env python3
"""Wrapper that brings up Xvfb + a private AT-SPI stack, then runs pytest."""

from __future__ import annotations

import contextlib
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import time
from typing import NoReturn

_SKIP_EXIT = 77
# Prefer this prefix's AT-SPI.
_ATSPI_DIRECTORIES = (
    os.path.join("@prefix@", "libexec"),
    os.path.join("@prefix@", "lib", "at-spi2-core"),
    "/usr/libexec",
    "/usr/lib/at-spi2-core",
    "/usr/local/libexec",
)
_SANDBOX_READY_FLAG = "--sandbox-ready"


class _Terminated(Exception):
    """Raised when the wrapper is signalled to stop, so that cleanup still happens."""


def _raise_terminated(signum: int, _frame: object) -> NoReturn:
    """Turns a termination signal into an exception the cleanup path can see."""

    raise _Terminated(signum)

# The only services our bus may start. Anything else the host has installed, including the
# portals and the shortcuts window they open, stays unreachable from the sandbox.
_ACTIVATABLE_SERVICES = ("org.gnome.GPaste",)

# Mirror orca_bin.py.in so the pytest imports can find Orca's GSettings schemas when Orca is
# installed under a non-system prefix.
_share_dir = os.path.join("@prefix@", "share")
if os.path.isdir(_share_dir):
    _xdg = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
    if _share_dir not in _xdg.split(":"):
        os.environ["XDG_DATA_DIRS"] = f"{_share_dir}:{_xdg}"

# Prefer this prefix's Orca.
_orca_binary = os.path.join("@prefix@", "bin", "orca")
if "ORCA_TEST_BINARY" not in os.environ and os.path.isfile(_orca_binary):
    os.environ["ORCA_TEST_BINARY"] = _orca_binary


def _find_atspi_binary(name: str) -> str | None:
    """Returns the first existing at-spi2 binary path among common install locations."""

    for directory in _ATSPI_DIRECTORIES:
        candidate = os.path.join(directory, name)
        if os.path.exists(candidate):
            return candidate
    return None


_BUS_LAUNCHER = _find_atspi_binary("at-spi-bus-launcher")
_REGISTRYD = _find_atspi_binary("at-spi2-registryd")


def _skip_and_exit(reason: str) -> NoReturn:
    """Prints a skip reason and exits with the test-suite skip code."""

    print(f"SKIP: {reason}", file=sys.stderr)
    sys.exit(_SKIP_EXIT)


def _other_orca_is_running() -> bool:
    """Returns True if another Orca process belonging to this user is active."""

    try:
        result = subprocess.run(
            ["pgrep", "-u", str(os.getuid()), "-x", "orca"],
            check=False,
            capture_output=True,
            text=True,
            timeout=5,
        )
    except (FileNotFoundError, subprocess.TimeoutExpired):
        return False
    return bool(result.stdout.strip())


def _wait_for_bus_name(
    name: str,
    bus_args: list[str] | None = None,
    timeout: float = 5.0,
) -> bool:
    """Polls a D-Bus connection for `name` using NameHasOwner (no auto-activation).

    bus_args selects the bus, e.g. ["--session"] or ["--address", bus_address].
    """

    if bus_args is None:
        bus_args = ["--session"]
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        try:
            result = subprocess.run(
                [
                    "gdbus",
                    "call",
                    *bus_args,
                    "--dest",
                    "org.freedesktop.DBus",
                    "--object-path",
                    "/org/freedesktop/DBus",
                    "--method",
                    "org.freedesktop.DBus.NameHasOwner",
                    name,
                ],
                check=False,
                capture_output=True,
                text=True,
                timeout=2,
            )
        except (FileNotFoundError, subprocess.TimeoutExpired):
            time.sleep(0.2)
            continue
        if result.returncode == 0 and result.stdout.strip() == "(true,)":
            return True
        time.sleep(0.05)
    return False


def _resolve_a11y_bus_address(timeout: float = 5.0) -> str | None:
    """Returns the a11y bus address after our at-spi-bus-launcher has registered."""

    if not _wait_for_bus_name("org.a11y.Bus", timeout=timeout):
        return None
    try:
        result = subprocess.run(
            [
                "gdbus",
                "call",
                "--session",
                "--dest",
                "org.a11y.Bus",
                "--object-path",
                "/org/a11y/bus",
                "--method",
                "org.a11y.Bus.GetAddress",
            ],
            check=False,
            capture_output=True,
            text=True,
            timeout=2,
        )
    except (FileNotFoundError, subprocess.TimeoutExpired):
        return None
    stripped = result.stdout.strip()
    if result.returncode == 0 and stripped.startswith("('") and stripped.endswith("',)"):
        return stripped[2:-3]
    return None


def _start_xvfb(
    children: list[subprocess.Popen],
    env: dict[str, str],
    timeout: float = 10.0,
) -> str:
    """Starts Xvfb and returns the display number it picked."""

    read_fd, write_fd = os.pipe()
    argv = [
        "Xvfb",
        "-displayfd",
        str(write_fd),
        "-screen",
        "0",
        "1024x768x24",
        "-nolisten",
        "tcp",
    ]

    proc = subprocess.Popen(argv, pass_fds=(write_fd,), env=env)
    os.close(write_fd)
    children.append(proc)

    with os.fdopen(read_fd, "r") as handle:
        buffered = ""
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            if proc.poll() is not None:
                raise RuntimeError(f"Xvfb exited early with code {proc.returncode}")
            chunk = handle.readline()
            if chunk:
                buffered += chunk
                if "\n" in buffered:
                    break
            else:
                time.sleep(0.05)

    display_num = buffered.strip()
    if not display_num:
        raise RuntimeError("Xvfb did not report a display number on its -displayfd")
    return display_num


def _write_bus_config(sandbox: str) -> str:
    """Writes a session bus config whose only activatable services are _ACTIVATABLE_SERVICES."""

    services = os.path.join(sandbox, "services")
    os.makedirs(services, exist_ok=True)
    for name in _ACTIVATABLE_SERVICES:
        source = os.path.join("/usr/share/dbus-1/services", f"{name}.service")
        if not os.path.exists(source):
            continue
        with open(source, encoding="utf-8") as handle:
            lines = handle.readlines()
        with open(os.path.join(services, f"{name}.service"), "w", encoding="utf-8") as handle:
            handle.writelines(
                line for line in lines if not line.startswith("SystemdService=")
            )

    config = os.path.join(sandbox, "session.conf")
    with open(config, "w", encoding="utf-8") as handle:
        handle.write(
            '<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"\n'
            ' "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">\n'
            "<busconfig>\n"
            "  <type>session</type>\n"
            "  <keep_umask/>\n"
            "  <listen>unix:tmpdir=/tmp</listen>\n"
            "  <auth>EXTERNAL</auth>\n"
            f"  <servicedir>{services}</servicedir>\n"
            '  <policy context="default">\n'
            '    <allow send_destination="*" eavesdrop="true"/>\n'
            '    <allow eavesdrop="true"/>\n'
            '    <allow own="*"/>\n'
            "  </policy>\n"
            '  <limit name="service_start_timeout">120000</limit>\n'
            "</busconfig>\n"
        )
    return config


def _setup(children: list[subprocess.Popen]) -> dict[str, str] | None:
    """Brings up Xvfb and the private AT-SPI stack. Returns the env pytest should run under."""

    stack_env = os.environ.copy()
    stack_env.pop("AT_SPI_BUS_ADDRESS", None)
    stack_env.pop("DISPLAY", None)
    stack_env.pop("WAYLAND_DISPLAY", None)
    stack_env["GDK_BACKEND"] = "x11"
    # Pin scaling so wrap points in the test app are deterministic.
    stack_env["GDK_SCALE"] = "1"
    stack_env["GDK_DPI_SCALE"] = "1.0"
    stack_env["GSETTINGS_BACKEND"] = "memory"

    # 1. Nested X server for isolation from the caller's desktop.
    display_num = _start_xvfb(children, stack_env)
    stack_env["DISPLAY"] = f":{display_num}"

    # Services the bus activates (GPaste, the portals, and the shortcuts provider they spawn)
    # get the bus environment, not stack_env. Give them our Xvfb; otherwise their windows
    # open on the caller's desktop.
    subprocess.run(
        ["dbus-update-activation-environment", f"DISPLAY=:{display_num}"],
        env=stack_env,
        check=False,
    )

    # 2. at-spi-bus-launcher on our private session bus.
    launcher = subprocess.Popen(
        [_BUS_LAUNCHER, "--launch-immediately", "--a11y=1", "--screen-reader=0"],
        env=stack_env,
    )
    children.append(launcher)
    address = _resolve_a11y_bus_address(timeout=5.0)
    if address is None:
        print("Failed to resolve a11y bus address from at-spi-bus-launcher", file=sys.stderr)
        return None
    stack_env["AT_SPI_BUS_ADDRESS"] = address
    print(f"[wrapper] DISPLAY={stack_env['DISPLAY']}", file=sys.stderr)
    print(f"[wrapper] AT_SPI_BUS_ADDRESS={address}", file=sys.stderr)
    print(
        f"[wrapper] DBUS_SESSION_BUS_ADDRESS={stack_env.get('DBUS_SESSION_BUS_ADDRESS')}",
        file=sys.stderr,
    )

    # 3. at-spi2-registryd pointed at the private bus + Xvfb display. Wait
    # until it has claimed org.a11y.atspi.Registry on the a11y bus so clients
    # (Orca, our test app) don't race against its startup.
    registryd = subprocess.Popen([_REGISTRYD], env=stack_env)
    children.append(registryd)
    if not _wait_for_bus_name(
        "org.a11y.atspi.Registry", bus_args=["--address", address], timeout=5.0,
    ):
        print("at-spi2-registryd did not claim its bus name in time", file=sys.stderr)
        return None

    return stack_env


def _unmount_fuse_under(sandbox: str) -> None:
    """Unmounts any FUSE mountpoints under sandbox so rmtree can remove the tree."""

    # Auto-activated services like gvfsd-fuse mount FUSE filesystems inside our sandboxed
    # XDG_RUNTIME_DIR. SIGKILLing the daemon does not unmount the filesystem, and rmtree
    # silently fails on the mountpoint.

    try:
        with open("/proc/self/mounts", encoding="utf-8") as handle:
            mounts = handle.read()
    except OSError:
        return
    prefix = sandbox.rstrip("/") + os.sep
    for line in mounts.splitlines():
        parts = line.split()
        if len(parts) < 3:
            continue
        mountpoint, fstype = parts[1], parts[2]
        if fstype.startswith("fuse") and mountpoint.startswith(prefix):
            subprocess.run(
                ["fusermount", "-u", mountpoint], check=False, capture_output=True,
            )


def _kill_lingering_sandbox_processes(sandbox: str) -> None:
    """SIGKILLs lingering processes whose cmdline references the sandbox path."""

    # Some D-Bus auto-activated helpers (e.g. p11-kit pkcs11 servers) get re-parented to the user's
    # systemd manager, so the wrapper's process-group SIGKILL doesn't reach them.

    needle = sandbox.encode()
    try:
        pids = [name for name in os.listdir("/proc") if name.isdigit()]
    except OSError:
        return
    own_pid = os.getpid()
    for pid_str in pids:
        pid = int(pid_str)
        if pid == own_pid:
            continue
        try:
            with open(f"/proc/{pid}/cmdline", "rb") as handle:
                if needle not in handle.read():
                    continue
        except OSError:
            continue
        with contextlib.suppress(ProcessLookupError, PermissionError):
            os.kill(pid, signal.SIGKILL)


def _teardown(children: list[subprocess.Popen]) -> None:
    """Terminates the child processes started by _setup, escalating to SIGKILL if needed."""

    for proc in reversed(children):
        if proc.poll() is None:
            proc.send_signal(signal.SIGTERM)
    for proc in reversed(children):
        try:
            proc.wait(timeout=2)
        except subprocess.TimeoutExpired:
            proc.kill()


def _run_test(test_file: str) -> int:
    """Sets up the Xvfb + AT-SPI stack, runs pytest, tears everything down."""

    children: list[subprocess.Popen] = []
    try:
        env = _setup(children)
        if env is None:
            return 1
        # @PYTHON@ is substituted with the Python interpreter path by meson's
        # configure_file(); see tests/meson.build.
        pytest_proc = subprocess.run(
            ["@PYTHON@", "-m", "pytest", test_file, "-v"],
            env=env,
            check=False,
        )
        return pytest_proc.returncode
    finally:
        _teardown(children)


def main() -> int:
    """Runs an integration test inside a private Xvfb + a11y stack."""

    args = [arg for arg in sys.argv[1:] if arg != _SANDBOX_READY_FLAG]
    sandbox_ready = _SANDBOX_READY_FLAG in sys.argv

    if len(args) != 1:
        print("Usage: integration_test_wrapper.py <test_file>", file=sys.stderr)
        return 1

    test_file = args[0]

    searched = ", ".join(_ATSPI_DIRECTORIES)
    if shutil.which("Xvfb") is None:
        _skip_and_exit("Xvfb is not installed")
    if shutil.which("dbus-run-session") is None:
        _skip_and_exit("dbus-run-session is not installed")
    if _BUS_LAUNCHER is None:
        _skip_and_exit(f"at-spi-bus-launcher not found in any of: {searched}")
    if _REGISTRYD is None:
        _skip_and_exit(f"at-spi2-registryd not found in any of: {searched}")
    if not os.environ.get("ORCA_TEST_BINARY") and shutil.which("orca") is None:
        _skip_and_exit("orca is not on PATH and ORCA_TEST_BINARY is not set")
    if _other_orca_is_running():
        _skip_and_exit(
            "another Orca instance is running as this user; stop it before running "
            "this test (it would collide on the org.gnome.Orca.Service bus name)"
        )

    # Run ourselves as a child of dbus-run-session so every subsequent process
    # we spawn (Xvfb, at-spi-bus-launcher, registryd, pytest, Orca) inherits
    # that private session bus. We also pre-populate XDG_RUNTIME_DIR with a
    # sandbox path so dbus-daemon (and anything it may auto-activate, e.g.
    # org.a11y.Bus) writes its sockets into our sandbox rather than into the
    # caller's real /run/user/$UID. The child invocation signals "sandbox is
    # ready, skip this block" by passing --sandbox-ready in argv.
    if not sandbox_ready:
        sandbox = tempfile.mkdtemp(prefix="orca-integration-test-")
        runtime_dir = os.path.join(sandbox, "runtime")
        os.makedirs(runtime_dir, mode=0o700, exist_ok=True)
        tmp_dir = os.path.join(sandbox, "tmp")
        os.makedirs(tmp_dir, mode=0o700, exist_ok=True)
        child_env = os.environ.copy()
        child_env["XDG_RUNTIME_DIR"] = runtime_dir
        # The bus hands this environment to the services it activates, and our Xvfb does not
        # exist yet, so remove the caller's display rather than let them inherit it.
        child_env.pop("DISPLAY", None)
        child_env.pop("WAYLAND_DISPLAY", None)
        child_env["GSETTINGS_BACKEND"] = "memory"
        # Redirect temp files into the sandbox so they are removed with it. Chromium leaves
        # org.chromium.Chromium.* dirs in TMPDIR, which teardown SIGKILLs it before it can
        # clean up; in /tmp those orphan, in the sandbox they go with the rmtree.
        child_env["TMPDIR"] = tmp_dir
        # Put dbus-run-session in its own process group. Auto-activated D-Bus
        # services (portals, GPaste, speech providers) get orphaned when the
        # inner wrapper exits but still hold our stderr fd, which keeps meson's
        # pipe reader blocked. Killing the group breaks that chain.
        proc = subprocess.Popen(
            ["dbus-run-session", f"--config-file={_write_bus_config(sandbox)}", "--",
             sys.executable, sys.argv[0], _SANDBOX_READY_FLAG, test_file],
            env=child_env,
            start_new_session=True,
        )
        # Without this, a timeout kills the wrapper before the cleanup below runs, leaving
        # an Orca behind which makes every subsequent test file skip.
        for signal_number in (signal.SIGTERM, signal.SIGINT):
            signal.signal(signal_number, _raise_terminated)
        try:
            returncode = proc.wait()
        except _Terminated as error:
            returncode = 128 + int(error.args[0])
        finally:
            with contextlib.suppress(ProcessLookupError, PermissionError):
                os.killpg(proc.pid, signal.SIGKILL)
            _kill_lingering_sandbox_processes(sandbox)
            _unmount_fuse_under(sandbox)
            shutil.rmtree(sandbox, ignore_errors=True)
        return returncode

    return _run_test(test_file)


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