#!/usr/bin/env python3
# Copyright 2026 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""
Helper script to run the dev CRD multi-process host. Typical usage is:

  $ remoting/tools/run_multi_process_host.py out/debug

The script will elevate itself and ask for your password.
"""

import argparse
import atexit
import hashlib
import os
import shutil
import signal
import socket
import subprocess
import sys
import time


def run_command(command):
    try:
        subprocess.run(command, check=True)
    except subprocess.CalledProcessError as e:
        print(
            "Command failed with exit code "
            f"{e.returncode}: {' '.join(command)}",
            file=sys.stderr)
        raise


def get_gdm_version():
    try:
        result = subprocess.run(["gdm3", "--version"],
                                capture_output=True,
                                text=True,
                                check=True)
        # result.stdout: "GDM 38.0\n"
        output = result.stdout.strip()
        if output.startswith("GDM "):
            return output[4:]
        return output
    except (subprocess.CalledProcessError, FileNotFoundError):
        return None


def is_gdm_version_ge_49():
    version_str = get_gdm_version()
    if not version_str:
        return False
    try:
        # Version might be "49.0" or just "49"
        major_version = int(version_str.split(".")[0])
        return major_version >= 49
    except (ValueError, IndexError):
        return False


def terminate_remote_sessions():
    print("Cleaning up remote sessions...")
    try:
        result = subprocess.run(["loginctl", "list-sessions", "--no-legend"],
                                capture_output=True,
                                text=True,
                                check=True)
        for line in result.stdout.splitlines():
            parts = line.split()
            if not parts:
                continue
            session_id = parts[0]
            show_session_command = [
                "loginctl", "show-session", session_id, "-p", "Remote", "-p",
                "Type"
            ]
            show_result = subprocess.run(show_session_command,
                                         capture_output=True,
                                         text=True,
                                         check=False)
            if show_result.returncode != 0:
                continue

            props = {}
            for show_line in show_result.stdout.splitlines():
                if "=" in show_line:
                    k, v = show_line.split("=", 1)
                    props[k] = v

            if (props.get("Remote") == "yes"
                    and props.get("Type") in ["wayland", "x11"]):
                print(f"Terminating remote session {session_id} "
                      f"(Type={props.get('Type')})...")
                subprocess.run(["loginctl", "terminate-session", session_id],
                               check=False)
    except subprocess.CalledProcessError as e:
        print(f"Failed to list sessions: {e}", file=sys.stderr)


def handle_signal(signum, frame):
    del signum, frame # Unused
    # This will trigger atexit handlers.
    sys.exit(0)


def get_file_trigger_state(path):
    try:
        st = os.stat(path)
        return (st.st_mtime, st.st_ino)
    except FileNotFoundError:
        return None


def ensure_permissions(abs_out_dir, user_home, force=False):
    remoting_host_path = os.path.join(abs_out_dir, "remoting_me2me_host")
    remoting_core_path = os.path.join(abs_out_dir, "libremoting_core.so")

    def check_permissions():
        for user in [
                "_crd_network", "_crd_peer_connection", "_crd_crashpad"
        ]:
            try:
                subprocess.run([
                    "sudo", "-u", user, "test", "-x", remoting_host_path
                ],
                               check=True,
                               capture_output=True)
                subprocess.run([
                    "sudo", "-u", user, "test", "-r", remoting_core_path
                ],
                               check=True,
                               capture_output=True)
            except subprocess.CalledProcessError:
                return False
        return True

    print("Checking permissions...")
    if not force and check_permissions():
        print("System users have the right permissions.")
        return

    if force:
        print("Forcing permission update...")
    else:
        print("System users do not have execute permissions. Setting ACLs...")

    run_command([
        "setfacl", "-R", "-m", "u:_crd_network:rx", "-m",
        "u:_crd_peer_connection:rx", "-m", "u:_crd_crashpad:rx", "-m",
        "g:Debian-gdm:rx", abs_out_dir
    ])

    # Accounts also need to be granted read and executable permissions to
    # all the parent directories.
    current_dir = abs_out_dir
    user_home_abs = os.path.abspath(user_home)
    while not check_permissions():
        parent_dir = os.path.dirname(current_dir)
        if parent_dir == current_dir:
            break

        print(f"Setting ACLs on {parent_dir}...")
        run_command([
            "setfacl", "-m", "u:_crd_network:rx", "-m",
            "u:_crd_peer_connection:rx", "-m", "u:_crd_crashpad:rx", "-m",
            "g:Debian-gdm:rx", parent_dir
        ])

        if parent_dir == user_home_abs:
            break
        current_dir = parent_dir

    if check_permissions():
        print("ACLs updated successfully.")
    else:
        print("ACLs updated but permissions still failing.", file=sys.stderr)


def user_main(out_dir, keep_sessions=False, set_permissions=False):
    try:
        run_command(["systemctl", "is-active", "--quiet", "gdm3"])
    except subprocess.CalledProcessError:
        print("gdm3 service is not running.")
        print("Please make sure gdm3.service is unmasked and started:")
        print("    sudo systemctl unmask gdm3.service")
        print("    sudo systemctl start gdm3.service")
        sys.exit(1)

    print("Re-running script with sudo...")
    script_path = os.path.abspath(__file__)
    home_dir = os.path.expanduser("~")

    cmd = [
        "sudo", sys.executable, script_path, "--elevated", out_dir,
        "--user-home", home_dir
    ]
    if keep_sessions:
        cmd.append("--keep-sessions")
    if set_permissions:
        cmd.append("--set-permissions")
    run_command(cmd)


def root_main(out_dir,
              user_home,
              keep_sessions=False,
              set_permissions=False):
    abs_out_dir = os.path.abspath(out_dir)
    host_config_path = "/etc/chrome-remote-desktop/host.json"

    if not os.path.exists(host_config_path):
        print(f"{host_config_path} not found. "
              "Looking for the current user config...")
        host_hash = hashlib.md5(socket.gethostname().encode()).hexdigest()
        config_file = os.path.join(
            user_home, f".config/chrome-remote-desktop/host#{host_hash}.json")

        if os.path.exists(config_file):
            os.makedirs(os.path.dirname(host_config_path), exist_ok=True)
            shutil.copy2(config_file, host_config_path)
            print(f"Copied {config_file} to {host_config_path}")
        else:
            print("No suitable user config found")
            sys.exit(1)

    print("Adding CRD system users...")
    run_command(["adduser", "--system", "_crd_network"])
    run_command(["adduser", "--system", "_crd_peer_connection"])
    run_command(["adduser", "--system", "_crd_crashpad"])

    remoting_host_path = os.path.join(abs_out_dir, "remoting_me2me_host")
    remoting_core_path = os.path.join(abs_out_dir, "libremoting_core.so")

    daemon_command = [remoting_host_path, "--type=daemon"]

    # Initial state for monitoring
    host_state = get_file_trigger_state(remoting_host_path)
    core_state = get_file_trigger_state(remoting_core_path)

    proc = None

    def start_daemon():
        nonlocal proc
        print(f"Starting CRD host daemon: {' '.join(daemon_command)}")
        proc = subprocess.Popen(daemon_command)

    def stop_daemon():
        nonlocal proc
        if proc and proc.poll() is None:
            print("Stopping CRD host daemon...")
            proc.terminate()
            try:
                proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                print("Daemon did not terminate, killing...")
                proc.kill()
                proc.wait()
            print("CRD host daemon stopped.")
        proc = None

    signal.signal(signal.SIGTERM, handle_signal)
    signal.signal(signal.SIGINT, handle_signal)
    signal.signal(signal.SIGHUP, handle_signal)

    if not keep_sessions:
        atexit.register(terminate_remote_sessions)

    if host_state is not None and core_state is not None:
        ensure_permissions(abs_out_dir, user_home, force=set_permissions)
        start_daemon()
    else:
        print("Binaries are missing. Waiting for them to be built...")

    try:
        while True:
            if proc and proc.poll() is not None:
                print(f"Host daemon exited with code {proc.returncode}")
                break

            current_host_state = get_file_trigger_state(remoting_host_path)
            current_core_state = get_file_trigger_state(remoting_core_path)

            changed = False
            if current_host_state != host_state:
                print(f"Detected change in {remoting_host_path}")
                host_state = current_host_state
                changed = True
            if current_core_state != core_state:
                print(f"Detected change in {remoting_core_path}")
                core_state = current_core_state
                changed = True

            if changed:
                stop_daemon()
                if host_state is not None and core_state is not None:
                    print("Binaries updated/restored. Starting daemon...")
                    ensure_permissions(abs_out_dir,
                                       user_home,
                                       force=set_permissions)
                    start_daemon()
                else:
                    print(
                        "Binaries are missing (likely rebuilding). Waiting for "
                        "restore...")

            time.sleep(1)
    except KeyboardInterrupt:
        print("\nCaught KeyboardInterrupt. Stopping host daemon...")
    finally:
        stop_daemon()


def main():
    parser = argparse.ArgumentParser(
        description="Run dev CRD multi-process host.")

    parser.add_argument("out_dir",
                        nargs="?",
                        help="Build output directory (e.g., out/debug)")

    parser.add_argument(
        "--keep-sessions",
        action="store_true",
        help=
        "Skip terminating remote sessions on exit. Graphical sessions created "
        "by CRD will be kept even after the host has exited. This allows for "
        "testing session recovery behavior. Run this script as root with the "
        "--terminate-sessions flag to terminate these sessions.")
    parser.add_argument(
        "--set-permissions",
        action="store_true",
        help=
        "Forcibly run the setfacl command to set permissions, bypassing the "
        "permission check. Useful if you modified dependencies (e.g. WebRTC) "
        "that aren't checked by default.")
    parser.add_argument(
        "--terminate-sessions",
        action="store_true",
        help="Only terminate remote sessions and exit. "
        "Must be run with sudo.")

    # These should be set by the script itself.
    parser.add_argument("--elevated",
        action="store_true",
        help=argparse.SUPPRESS)
    parser.add_argument("--user-home", help=argparse.SUPPRESS)

    args = parser.parse_args()

    if args.terminate_sessions:
        if os.geteuid() != 0:
            print("Error: --terminate-sessions must be run with sudo.")
            sys.exit(1)
        terminate_remote_sessions()
        sys.exit(0)

    if not args.out_dir:
        parser.error("out_dir is required when not using --terminate-sessions")

    if not os.path.isdir(args.out_dir):
        print(f"Error: Output directory not found: {args.out_dir}")
        sys.exit(1)

    if args.elevated:
        if os.geteuid() != 0:
            print("Error: Must be run as root in elevated mode.")
            sys.exit(1)
        if not args.user_home:
            print("Error: --user-home is required in elevated mode.")
            sys.exit(1)
        root_main(args.out_dir, args.user_home, args.keep_sessions,
                  args.set_permissions)
    else:
        if os.geteuid() == 0:
            print("Error: Do not run this script directly with sudo. "
                  "It will re-invoke itself.")
            sys.exit(1)
        user_main(args.out_dir, args.keep_sessions, args.set_permissions)


if __name__ == "__main__":
    main()
