#!/usr/bin/env python3
"""One-command PC-side installer for Daneshyar classroom infrastructure.

This script is intentionally run from the developer PC. It can:

1. optionally build and atomically deploy the current application;
2. SSH to the main application server;
3. install/repair Laravel Reverb, Redis, the queue worker, coturn, Apache's
   WebSocket proxy, migrations, and the media health timer;
4. provision any number of additional coturn relay servers over SSH; and
5. register those relays in the application database from the main server.

Passwords are prompted by default. They may instead come from environment
variables or an ignored scripts/classroom_servers.json file.
"""

from __future__ import annotations

import argparse
import getpass
import json
import os
import re
import secrets
import shlex
import shutil
import subprocess
import sys
from pathlib import Path, PurePosixPath
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
DEFAULT_CONFIG = SCRIPTS / "classroom_servers.json"
EXAMPLE_CONFIG = SCRIPTS / "classroom_servers.example.json"

DEFAULT_MAIN: dict[str, Any] = {
    "host": "91.107.131.171",
    "ssh_port": 2435,
    "ssh_user": "root",
    "password_env": "DANESHYAR_SSH_PASSWORD",
    "app_dir": "/var/www/daneshyar",
    "domain": "mydaneshyar.ir",
    "php": "php8.3",
    "reverb_port": 8081,
    "turn_port": 3478,
    "public_ip": None,
}


class InstallerError(RuntimeError):
    pass


def q(value: str | int) -> str:
    return shlex.quote(str(value))


def prompt(label: str, default: str = "") -> str:
    suffix = f" [{default}]" if default else ""
    value = input(f"{label}{suffix}: ").strip()
    return value or default


def yes_no(label: str, default: bool = False) -> bool:
    marker = "Y/n" if default else "y/N"
    value = input(f"{label} [{marker}]: ").strip().lower()
    if not value:
        return default
    return value in {"y", "yes", "1", "true"}


def int_value(value: Any, field: str, minimum: int = 1, maximum: int = 65535) -> int:
    try:
        parsed = int(value)
    except (TypeError, ValueError) as exc:
        raise InstallerError(f"{field} must be a number.") from exc
    if parsed < minimum or parsed > maximum:
        raise InstallerError(f"{field} must be between {minimum} and {maximum}.")
    return parsed


def load_config(path: Path, explicitly_requested: bool) -> tuple[dict[str, Any], bool]:
    if not path.exists():
        if explicitly_requested:
            raise InstallerError(f"Config file not found: {path}")
        return {}, False
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        raise InstallerError(f"Cannot read {path}: {exc}") from exc
    if not isinstance(data, dict):
        raise InstallerError("The classroom server config must contain a JSON object.")
    return data, True


def collect_main(config: dict[str, Any], interactive: bool, had_config: bool) -> dict[str, Any]:
    main = {**DEFAULT_MAIN, **(config.get("main_server") or {})}
    if interactive and not had_config:
        print("\nMain application server (press Enter to keep each default):")
        main["host"] = prompt("  SSH host", str(main["host"]))
        main["ssh_port"] = int_value(prompt("  SSH port", str(main["ssh_port"])), "SSH port")
        main["ssh_user"] = prompt("  SSH user", str(main["ssh_user"]))
        main["app_dir"] = prompt("  Live application directory", str(main["app_dir"]))
        main["domain"] = prompt("  Public domain", str(main["domain"]))
        main["php"] = prompt("  PHP binary", str(main["php"]))
        main["reverb_port"] = int_value(prompt("  Internal Reverb port", str(main["reverb_port"])), "Reverb port")
        main["turn_port"] = int_value(prompt("  Primary TURN port", str(main["turn_port"])), "TURN port")

    main["ssh_port"] = int_value(main.get("ssh_port"), "main_server.ssh_port")
    main["reverb_port"] = int_value(main.get("reverb_port"), "main_server.reverb_port")
    main["turn_port"] = int_value(main.get("turn_port"), "main_server.turn_port")
    for field in ("host", "ssh_user", "app_dir", "domain", "php"):
        if not str(main.get(field, "")).strip():
            raise InstallerError(f"main_server.{field} is required.")
    if not re.fullmatch(r"[A-Za-z0-9._+-]+", str(main["php"])):
        raise InstallerError("main_server.php must be a binary name such as php8.3.")
    if "password" in main:
        print("WARNING: main_server.password is stored as plain text. An environment variable is safer.")
    return main


def collect_relays(config: dict[str, Any], interactive: bool, disabled: bool, domain: str) -> list[dict[str, Any]]:
    if disabled:
        return []
    configured = config.get("extra_relays") or []
    if not isinstance(configured, list):
        raise InstallerError("extra_relays must be a JSON array.")
    relays = [dict(item) for item in configured if isinstance(item, dict) and str(item.get("host", "")).strip()]

    if interactive and not configured:
        index = 2
        while yes_no("\nProvision an additional TURN relay now?", False):
            host = prompt("  Relay public IP or DNS name")
            if not host:
                raise InstallerError("Relay host cannot be empty.")
            relay = {
                "name": prompt("  Display name", f"TURN relay {index}"),
                "host": host,
                "ssh_port": int_value(prompt("  SSH port", "22"), "relay SSH port"),
                "ssh_user": prompt("  SSH user", "root"),
                "turn_port": int_value(prompt("  TURN port", "3478"), "relay TURN port"),
                "realm": prompt("  TURN realm", domain),
                "region": prompt("  Region label (optional)", ""),
                "capacity": int_value(prompt("  Participant capacity", "80"), "relay capacity", 1, 1_000_000),
                "priority": int_value(prompt("  Priority (lower wins ties)", str(index * 10)), "relay priority", 1, 1_000_000),
                "password_env": f"DANESHYAR_RELAY_{index}_PASSWORD",
            }
            relays.append(relay)
            index += 1

    for index, relay in enumerate(relays, start=1):
        relay.setdefault("name", f"TURN relay {index + 1}")
        relay.setdefault("ssh_port", 22)
        relay.setdefault("ssh_user", "root")
        relay.setdefault("turn_port", 3478)
        relay.setdefault("realm", domain)
        relay.setdefault("region", None)
        relay.setdefault("capacity", 80)
        relay.setdefault("priority", (index + 1) * 10)
        relay["ssh_port"] = int_value(relay["ssh_port"], f"extra_relays[{index}].ssh_port")
        relay["turn_port"] = int_value(relay["turn_port"], f"extra_relays[{index}].turn_port")
        relay["capacity"] = int_value(relay["capacity"], f"extra_relays[{index}].capacity", 1, 1_000_000)
        relay["priority"] = int_value(relay["priority"], f"extra_relays[{index}].priority", 1, 1_000_000)
        if "password" in relay:
            print(f"WARNING: password for relay {relay['host']} is stored as plain text.")
    return relays


def resolve_password(server: dict[str, Any], label: str, interactive: bool) -> str | None:
    direct = server.get("password")
    if direct:
        return str(direct)
    env_name = str(server.get("password_env") or "").strip()
    if env_name and os.environ.get(env_name):
        return os.environ[env_name]
    if interactive:
        value = getpass.getpass(f"SSH password for {label} (blank = SSH key/agent): ")
        return value or None
    return None


def resolve_sudo_password(server: dict[str, Any], ssh_password: str | None, interactive: bool) -> str | None:
    if str(server.get("ssh_user")) == "root":
        return None
    direct = server.get("sudo_password")
    if direct:
        return str(direct)
    env_name = str(server.get("sudo_password_env") or "").strip()
    if env_name and os.environ.get(env_name):
        return os.environ[env_name]
    if ssh_password:
        return ssh_password
    if interactive:
        value = getpass.getpass(f"sudo password for {server['ssh_user']}@{server['host']}: ")
        return value or None
    # A large number of cloud images grant the SSH user passwordless sudo.
    # run_remote uses `sudo -n` when no password is supplied and will fail
    # clearly if that permission is not actually configured.
    return None


def _paramiko():
    try:
        import paramiko
    except ImportError as exc:
        raise InstallerError("Paramiko is required. Run: pip install paramiko") from exc
    return paramiko


def connect(server: dict[str, Any], password: str | None, accept_new: bool, interactive: bool):
    paramiko = _paramiko()

    def make_client(allow_new: bool):
        client = paramiko.SSHClient()
        client.load_system_host_keys()
        known_hosts = Path.home() / ".ssh" / "known_hosts"
        if known_hosts.exists():
            client.load_host_keys(str(known_hosts))
        client.set_missing_host_key_policy(paramiko.AutoAddPolicy() if allow_new else paramiko.RejectPolicy())
        return client

    def do_connect(client):
        key_file = server.get("key_file")
        client.connect(
            str(server["host"]),
            port=int(server["ssh_port"]),
            username=str(server["ssh_user"]),
            password=password,
            passphrase=password,
            key_filename=str(key_file) if key_file else None,
            look_for_keys=True,
            allow_agent=True,
            timeout=30,
            banner_timeout=30,
            auth_timeout=30,
        )
        transport = client.get_transport()
        if transport:
            transport.set_keepalive(20)
        return client

    print(f"Connecting to {server['ssh_user']}@{server['host']}:{server['ssh_port']} ...")
    client = make_client(accept_new)
    try:
        return do_connect(client)
    except paramiko.BadHostKeyException:
        client.close()
        raise
    except paramiko.SSHException as exc:
        client.close()
        missing = "known_hosts" in str(exc) or "not found" in str(exc).lower()
        if missing and interactive and yes_no(f"Host key for {server['host']} is new. Trust it for this installation?", False):
            return do_connect(make_client(True))
        if missing:
            raise InstallerError("Unknown SSH host key. Re-run with --accept-new-host-key after verifying the server IP.") from exc
        raise InstallerError(f"SSH connection failed for {server['host']}: {exc}") from exc


def run_remote(
    client,
    server: dict[str, Any],
    command: str,
    *,
    sudo_password: str | None = None,
    sudo: bool = False,
    timeout: int = 1800,
    title: str | None = None,
    show_command: bool = True,
    show_output: bool = True,
) -> str:
    if title:
        print(f"\n[{server['host']}] {title}")
    wrapped = f"bash -lc {q(command)}"
    if sudo and str(server["ssh_user"]) != "root":
        if sudo_password:
            wrapped = f"sudo -S -p '' bash -lc {q(command)}"
        else:
            wrapped = f"sudo -n bash -lc {q(command)}"
    if show_command:
        print("  ->", command)

    # Keep sudo on a non-PTY channel so the terminal cannot echo a password.
    # Ubuntu/Debian sudo accepts -S without a TTY by default.
    stdin, stdout, stderr = client.exec_command(wrapped, get_pty=False, timeout=timeout)
    if sudo and str(server["ssh_user"]) != "root":
        stdin.write(sudo_password + "\n")
        stdin.flush()
    lines: list[str] = []
    for line in iter(stdout.readline, ""):
        if show_output:
            print("     " + line, end="")
        lines.append(line)
    code = stdout.channel.recv_exit_status()
    error = stderr.read().decode("utf-8", "replace").strip()
    if code != 0:
        raise InstallerError(
            f"Remote command failed on {server['host']} (exit {code})."
            + (f"\n{error}" if error else "")
        )
    return "".join(lines)


def upload_runtime_scripts(client, main: dict[str, Any], sudo_password: str | None) -> None:
    local_files = [SCRIPTS / "classroom_server.py", SCRIPTS / "media_servers.py"]
    for path in local_files:
        if not path.exists():
            raise InstallerError(f"Missing local installer file: {path}")

    remote_tmp = f"/tmp/daneshyar-classroom-tools-{os.getpid()}"
    run_remote(client, main, f"mkdir -p {q(remote_tmp)}", title="Preparing installer upload")
    sftp = client.open_sftp()
    try:
        for local in local_files:
            sftp.put(str(local), str(PurePosixPath(remote_tmp) / local.name))
    finally:
        sftp.close()

    app_scripts = str(PurePosixPath(str(main["app_dir"])) / "scripts")
    install_command = (
        f"install -d -m 755 {q(app_scripts)} && "
        f"install -m 755 {q(remote_tmp + '/classroom_server.py')} {q(app_scripts + '/classroom_server.py')} && "
        f"install -m 755 {q(remote_tmp + '/media_servers.py')} {q(app_scripts + '/media_servers.py')} && "
        f"rm -rf {q(remote_tmp)}"
    )
    run_remote(client, main, install_command, sudo_password=sudo_password, sudo=True, title="Installing current classroom tools")


def build_and_deploy(main: dict[str, Any], password: str | None, skip_build: bool) -> None:
    if not password:
        raise InstallerError("--deploy-app currently requires a password so update.py can run non-interactively.")
    try:
        import update as updater
    except ImportError as exc:
        raise InstallerError("Cannot import scripts/update.py.") from exc

    expected = (str(main["host"]), int(main["ssh_port"]), str(main["ssh_user"]), str(main["app_dir"]))
    actual = (str(updater.SERVER_HOST), int(updater.SERVER_PORT), str(updater.SSH_USER), str(updater.LIVE_LINK))
    if expected != actual:
        raise InstallerError(
            "--deploy-app target does not match scripts/update.py. "
            f"Installer={expected}; update.py={actual}. Update the configs first."
        )

    if not skip_build:
        npm = shutil.which("npm.cmd") or shutil.which("npm")
        if not npm:
            raise InstallerError("npm is not available; install Node.js or use --skip-build.")
        print("\nBuilding production frontend assets ...")
        result = subprocess.run([npm, "run", "build"], cwd=ROOT, check=False)
        if result.returncode != 0:
            raise InstallerError("Frontend production build failed; deployment was not started.")

    print("\nDeploying the current workspace with scripts/update.py ...")
    environment = os.environ.copy()
    environment["DANESHYAR_SSH_PASSWORD"] = password
    result = subprocess.run([sys.executable, str(SCRIPTS / "update.py")], cwd=ROOT, env=environment, check=False)
    if result.returncode != 0:
        raise InstallerError("Application deployment failed; classroom provisioning was not started.")


def install_main(client, main: dict[str, Any], sudo_password: str | None) -> None:
    app = str(main["app_dir"])
    php = str(main["php"])
    preflight = (
        f"test -f {q(app + '/artisan')} && test -f {q(app + '/.env')} && "
        f"command -v {q(php)} >/dev/null && command -v composer >/dev/null && command -v python3 >/dev/null"
    )
    run_remote(client, main, preflight, title="Checking deployed Laravel application")
    upload_runtime_scripts(client, main, sudo_password)

    composer = (
        f"cd {q(app)} && "
        "if [ ! -f vendor/laravel/reverb/composer.json ]; then "
        "COMPOSER_ALLOW_SUPERUSER=1 composer install "
        "--no-dev --prefer-dist --optimize-autoloader --no-interaction; fi && "
        "test -f vendor/laravel/reverb/composer.json"
    )
    run_remote(
        client,
        main,
        composer,
        sudo_password=sudo_password,
        sudo=True,
        timeout=1800,
        title="Installing PHP dependencies (including Laravel Reverb)",
    )

    command = (
        f"python3 {q(app + '/scripts/classroom_server.py')} install "
        f"--app-dir {q(app)} --domain {q(main['domain'])} --php {q(php)} "
        f"--reverb-port {int(main['reverb_port'])} --turn-port {int(main['turn_port'])}"
    )
    if main.get("public_ip"):
        command += f" --public-ip {q(main['public_ip'])}"
    run_remote(
        client,
        main,
        command,
        sudo_password=sudo_password,
        sudo=True,
        timeout=2400,
        title="Installing Reverb, Redis, queue, coturn, migrations, and health monitoring",
    )


def prepare_relay_registration(client, main: dict[str, Any], sudo_password: str | None) -> None:
    """Put the DB manager on the main server and ensure its schema exists."""
    app = str(main["app_dir"])
    preflight = f"test -f {q(app + '/artisan')} && test -f {q(app + '/.env')} && command -v python3 >/dev/null"
    run_remote(client, main, preflight, title="Checking the main application server")
    upload_runtime_scripts(client, main, sudo_password)
    command = (
        "export DEBIAN_FRONTEND=noninteractive; "
        "if ! python3 -c 'import pymysql' >/dev/null 2>&1; then "
        "apt-get update -qq && apt-get install -y -qq python3-pymysql; fi; "
        f"cd {q(app)} && python3 scripts/media_servers.py migrate"
    )
    run_remote(
        client,
        main,
        command,
        sudo_password=sudo_password,
        sudo=True,
        timeout=1200,
        title="Preparing media-server database registration",
    )


def install_relay(
    relay: dict[str, Any],
    password: str | None,
    sudo_password: str | None,
    accept_new: bool,
    interactive: bool,
) -> str:
    client = connect(relay, password, accept_new, interactive)
    remote_config = f"/tmp/daneshyar-turn-{os.getpid()}.conf"
    try:
        # Preserve an existing server's HMAC secret so a repair/re-run does not
        # invalidate credentials already issued to live classroom clients.
        secret = str(relay.get("turn_secret") or "").strip()
        if not secret:
            existing = run_remote(
                client,
                relay,
                "if [ -f /etc/turnserver.conf ]; then sed -n 's/^static-auth-secret=//p' /etc/turnserver.conf | head -n 1; fi",
                sudo_password=sudo_password,
                sudo=True,
                title=f"Checking existing configuration for {relay['name']}",
                show_command=False,
                show_output=False,
            )
            secret = existing.strip() or secrets.token_hex(32)

        external = f"external-ip={relay['public_ip']}\n" if relay.get("public_ip") else ""
        config = f"""# Generated by scripts/setup_classroom_remote.py
listening-port={int(relay['turn_port'])}
fingerprint
use-auth-secret
static-auth-secret={secret}
realm={relay.get('realm') or relay['host']}
total-quota={int(relay['capacity'])}
stale-nonce=600
min-port=49152
max-port=65535
no-multicast-peers
no-cli
{external}denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
"""

        sftp = client.open_sftp()
        try:
            with sftp.file(remote_config, "w") as handle:
                handle.write(config.encode("utf-8"))
        finally:
            sftp.close()

        turn_port = int(relay["turn_port"])
        command = f"""set -eu
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq coturn
turn_group="$(id -gn turnserver 2>/dev/null || echo root)"
install -o root -g "$turn_group" -m 640 {q(remote_config)} /etc/turnserver.conf
rm -f {q(remote_config)}
if [ -f /etc/default/coturn ]; then
  sed -i 's/^#TURNSERVER_ENABLED=1/TURNSERVER_ENABLED=1/' /etc/default/coturn
  grep -q '^TURNSERVER_ENABLED=1' /etc/default/coturn || echo 'TURNSERVER_ENABLED=1' >> /etc/default/coturn
fi
if command -v ufw >/dev/null 2>&1; then
  ufw allow {turn_port}/udp >/dev/null || true
  ufw allow {turn_port}/tcp >/dev/null || true
  ufw allow 49152:65535/udp >/dev/null || true
fi
systemctl enable coturn >/dev/null 2>&1
systemctl restart coturn
systemctl is-active --quiet coturn
"""
        run_remote(
            client,
            relay,
            command,
            sudo_password=sudo_password,
            sudo=True,
            timeout=1800,
            title=f"Provisioning {relay['name']}",
        )
    finally:
        client.close()
    return secret


def register_relay(
    main_client,
    main: dict[str, Any],
    relay: dict[str, Any],
    secret: str,
    sudo_password: str | None,
) -> None:
    app = str(main["app_dir"])
    parts = [
        "python3", app + "/scripts/media_servers.py", "add",
        "--name", str(relay["name"]),
        "--host", str(relay["host"]),
        "--port", str(relay["turn_port"]),
        "--kind", "turn",
        "--scheme", "turn",
        "--secret", secret,
        "--capacity", str(relay["capacity"]),
        "--priority", str(relay["priority"]),
    ]
    if relay.get("region"):
        parts.extend(["--region", str(relay["region"])])
    command = "cd " + q(app) + " && " + " ".join(q(part) for part in parts)
    run_remote(
        main_client,
        main,
        command,
        sudo_password=sudo_password,
        sudo=True,
        title=f"Registering {relay['name']} in the application database",
        show_command=False,
    )


def health_and_status(main_client, main: dict[str, Any], sudo_password: str | None) -> None:
    app = str(main["app_dir"])
    run_remote(
        main_client,
        main,
        f"cd {q(app)} && python3 scripts/media_servers.py health && python3 scripts/media_servers.py list",
        sudo_password=sudo_password,
        sudo=True,
        title="Checking relay connectivity and current capacity",
    )
    run_remote(
        main_client,
        main,
        f"python3 {q(app + '/scripts/classroom_server.py')} status --app-dir {q(app)}",
        sudo_password=sudo_password,
        sudo=True,
        title="Checking classroom services",
    )


def print_dry_run(main: dict[str, Any], relays: list[dict[str, Any]], deploy_app: bool) -> None:
    print("\nDry run - no connections or changes were made.")
    print(f"  Deploy application first: {'yes' if deploy_app else 'no'}")
    print(f"  Main: {main['ssh_user']}@{main['host']}:{main['ssh_port']} -> {main['app_dir']}")
    print(f"  Domain: {main['domain']} | Reverb {main['reverb_port']} | TURN {main['turn_port']}")
    for relay in relays:
        print(
            f"  Relay: {relay['name']} at {relay['ssh_user']}@{relay['host']}:{relay['ssh_port']} "
            f"TURN={relay['turn_port']} capacity={relay['capacity']} priority={relay['priority']}"
        )


def main() -> None:
    parser = argparse.ArgumentParser(description="Install Daneshyar classroom servers remotely from this PC")
    parser.add_argument(
        "command",
        nargs="?",
        choices=["install", "add-relays", "status"],
        default="install",
        help="install everything, add one or more TURN relays, or check status",
    )
    parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG, help=f"JSON config (default: {DEFAULT_CONFIG})")
    parser.add_argument("--deploy-app", action="store_true", help="build assets and run the existing atomic update before provisioning")
    parser.add_argument("--skip-build", action="store_true", help="with --deploy-app, reuse the existing public/build output")
    parser.add_argument("--no-relays", action="store_true", help="do not prompt for or provision extra TURN relays")
    parser.add_argument("--non-interactive", action="store_true", help="never prompt; use config, environment variables, or SSH keys")
    parser.add_argument("--accept-new-host-key", action="store_true", help="accept a new SSH host key (verify the IP first)")
    parser.add_argument("--dry-run", action="store_true", help="validate and show targets without connecting")
    args = parser.parse_args()

    explicit_config = any(arg == "--config" or arg.startswith("--config=") for arg in sys.argv[1:])
    config, had_config = load_config(args.config.resolve(), explicit_config)
    interactive = not args.non_interactive
    main_server = collect_main(config, interactive, had_config)
    relays = collect_relays(
        config,
        interactive and args.command in {"install", "add-relays"},
        args.no_relays or args.command == "status",
        str(main_server["domain"]),
    )
    if args.command == "add-relays" and not relays:
        raise InstallerError("No extra relay was supplied. Add it to the config or run interactively.")

    if args.dry_run:
        print_dry_run(main_server, relays, args.deploy_app)
        return

    main_password = resolve_password(
        main_server,
        f"{main_server['ssh_user']}@{main_server['host']}:{main_server['ssh_port']}",
        interactive,
    )
    main_sudo = resolve_sudo_password(main_server, main_password, interactive)

    if args.command == "install" and args.deploy_app:
        build_and_deploy(main_server, main_password, args.skip_build)

    main_client = connect(main_server, main_password, args.accept_new_host_key, interactive)
    try:
        if args.command == "status":
            health_and_status(main_client, main_server, main_sudo)
            return

        if args.command == "install":
            install_main(main_client, main_server, main_sudo)
        else:
            prepare_relay_registration(main_client, main_server, main_sudo)

        for relay in relays:
            relay_password = resolve_password(
                relay,
                f"{relay['ssh_user']}@{relay['host']}:{relay['ssh_port']}",
                interactive,
            )
            relay_sudo = resolve_sudo_password(relay, relay_password, interactive)
            secret = install_relay(relay, relay_password, relay_sudo, args.accept_new_host_key, interactive)
            register_relay(main_client, main_server, relay, secret, main_sudo)

        health_and_status(main_client, main_server, main_sudo)
    finally:
        main_client.close()

    print("\nClassroom infrastructure installation completed successfully.")
    print("New classes now choose the least-loaded healthy registered TURN server automatically.")


if __name__ == "__main__":
    try:
        main()
    except (InstallerError, OSError, KeyboardInterrupt) as exc:
        if isinstance(exc, KeyboardInterrupt):
            print("\nCancelled.")
        else:
            print(f"\nFAILED: {exc}", file=sys.stderr)
        raise SystemExit(1)
