#!/usr/bin/env python3


# ============================================================
# HOSTISH ACCOUNT
# ============================================================

USERNAME = "YOURUSERNAME"
PASSWORD = "YOURPASSWORD"


# ============================================================
# LOCAL MINECRAFT SERVER
# ============================================================

MINECRAFT_HOST = "127.0.0.1"
MINECRAFT_PORT = 25565


# ============================================================
# SIMPLE VOICE CHAT MOD
# ============================================================
#
# False:
#     The agent behaves exactly like the normal Minecraft agent.
#     No UDP service is requested from Hostish.
#
# True:
#     Hostish dynamically opens a public UDP port and tunnels it
#     to the local Simple Voice Chat UDP server.
#
# Simple Voice Chat uses UDP port 24454 by default.
#
SIMPLE_VOICE_CHAT_ENABLED = False

SIMPLE_VOICE_CHAT_HOST = "127.0.0.1"
SIMPLE_VOICE_CHAT_PORT = 24454

# 0 = let Hostish dynamically choose the public UDP port.
# Leave this at 0 for normal use.
SIMPLE_VOICE_CHAT_PUBLIC_PORT = 0

# Hostish service name. Normally there is no reason to change this.
SIMPLE_VOICE_CHAT_SERVICE_ID = "simple-voice-chat"

# Full path to Simple Voice Chat's server config.
#
# IMPORTANT:
# This is only used when SIMPLE_VOICE_CHAT_ENABLED = True.
# The agent does NOT ask for this path when it starts.
#
# Example:
# SIMPLE_VOICE_CHAT_CONFIG_FILE = (
#     r"E:\Minecraft\server\config\voicechat\voicechat-server.properties"
# )
#
SIMPLE_VOICE_CHAT_CONFIG_FILE = (
    r"path\to\config\voicechat\voicechat-server.properties"
)


# ============================================================
# OPTIONAL AUTOMATIC SERVER START
# ============================================================
#
# Leave blank to start Minecraft manually:
#
# START_BAT = r""
#
# Or:
#
# START_BAT = r"C:\path\to\server\start.bat"
#
START_BAT = r""


AUTO_START_SERVER = False

SERVER_START_TIMEOUT = 120
SERVER_START_CHECK_INTERVAL = 2


# ============================================================
# OPTIONAL WEB HOMEPAGE
# ============================================================
#
# Example:
#
# HOMEPAGE_FILE = r"C:\path\to\home.html"
#
# Leave blank to disable.
#
HOMEPAGE_FILE = r""


# ============================================================
# INTERNAL SETTINGS
# ============================================================

import asyncio
import json
import os
import socket
import ssl
import subprocess
import sys
import time

from datetime import datetime


HOSTISH_URL = (
    "wss://hostish.site/ws/minecraft"
)


AGENT_VERSION = 6


RECONNECT_DELAY = 3
MAX_RECONNECT_DELAY = 15


TCP_CHUNK_SIZE = (
    64 * 1024
)


MAX_WEBSOCKET_MESSAGE = (
    4 * 1024 * 1024
)


MAX_HOMEPAGE_BYTES = (
    512 * 1024
)


MAGIC = b"MC"
HEADER_SIZE = 18


# Simple Voice Chat / Hostish UDP framing.
UDP_MAGIC = b"UD"
UDP_HEADER_SIZE = 34

# Simple Voice Chat audio packets are normally well below this.
UDP_MAX_DATAGRAM_BYTES = 65535

# Maximum time to wait for Hostish to allocate the UDP port
# before Minecraft is started.
VOICE_CHAT_UDP_OPEN_TIMEOUT = 20

# Remember the dynamically assigned port for reconnects in this
# process. Re-requesting the same port helps keep voice_host stable.
_VOICE_CHAT_LAST_PUBLIC_PORT = 0


# ============================================================
# DEPENDENCIES
# ============================================================

_missing = []


try:

    import websockets

except ImportError:

    websockets = None

    _missing.append(
        "websockets"
    )


try:

    import certifi

except ImportError:

    certifi = None

    _missing.append(
        "certifi"
    )


if _missing:

    print()
    print(
        "Hostish Minecraft Agent"
    )
    print()

    print(
        "Missing Python package(s): "
        + ", ".join(
            _missing
        )
    )

    print()
    print(
        "Install them with:"
    )
    print()

    print(
        "    python -m pip install "
        "websockets certifi"
    )

    print()

    sys.exit(
        1
    )


# ============================================================
# LOGGING
# ============================================================

def log(
    message: str,
) -> None:

    timestamp = (
        datetime.now().strftime(
            "%H:%M:%S"
        )
    )


    print(
        f"[{timestamp}] {message}",
        flush=True,
    )


# ============================================================
# TLS
# ============================================================

def create_ssl_context(
) -> ssl.SSLContext:

    context = (
        ssl.create_default_context(
            cafile=
                certifi.where()
        )
    )


    context.check_hostname = (
        True
    )


    context.verify_mode = (
        ssl.CERT_REQUIRED
    )


    return context


# ============================================================
# START.BAT PATH
# ============================================================

def normalized_start_bat(
) -> str:

    configured = str(
        START_BAT or ""
    ).strip()


    if not configured:
        return ""


    return os.path.abspath(
        os.path.expanduser(
            configured
        )
    )


# ============================================================
# SIMPLE VOICE CHAT CONFIG FILE
# ============================================================

def normalized_voice_chat_config_file(
) -> str:

    configured = str(
        SIMPLE_VOICE_CHAT_CONFIG_FILE or ""
    ).strip()

    if not configured:
        return ""

    return os.path.abspath(
        os.path.expanduser(
            configured
        )
    )


def update_voice_chat_public_address(
    public_host: str,
    public_port: int,
) -> bool:
    """
    Update voice_host= in voicechat-server.properties.

    Returns True when the file was changed and False when the
    requested value was already present.
    """

    path = (
        normalized_voice_chat_config_file()
    )

    if not path:

        raise RuntimeError(
            "SIMPLE_VOICE_CHAT_CONFIG_FILE is blank."
        )

    if not os.path.isfile(
        path
    ):

        raise RuntimeError(
            "Simple Voice Chat config file was not found: "
            f"{path}"
        )

    host = str(
        public_host or ""
    ).strip()

    port = int(
        public_port
    )

    if not host:
        raise RuntimeError(
            "Hostish returned a blank UDP hostname."
        )

    if (
        port < 1
        or port > 65535
    ):
        raise RuntimeError(
            "Hostish returned an invalid UDP port."
        )

    wanted = (
        f"{host}:{port}"
    )

    try:

        with open(
            path,
            "r",
            encoding="utf-8-sig",
            newline="",
        ) as file:

            original = (
                file.read()
            )

    except UnicodeError as error:

        raise RuntimeError(
            "voicechat-server.properties must be UTF-8 text."
        ) from error

    except OSError as error:

        raise RuntimeError(
            "Could not read Simple Voice Chat config: "
            f"{error}"
        ) from error


    # Preserve the file's existing newline style.
    newline = (
        "\r\n"
        if "\r\n" in original
        else "\n"
    )

    lines = (
        original
        .replace(
            "\r\n",
            "\n",
        )
        .replace(
            "\r",
            "\n",
        )
        .split(
            "\n"
        )
    )


    found = False
    changed = False
    new_lines = []


    for line in lines:

        stripped = (
            line.lstrip()
        )

        if (
            not stripped.startswith(
                "#"
            )
            and "=" in stripped
        ):

            key = (
                stripped
                .split(
                    "=",
                    1,
                )[0]
                .strip()
            )

            if key == "voice_host":

                found = True

                new_line = (
                    f"voice_host={wanted}"
                )

                if line != new_line:
                    changed = True

                new_lines.append(
                    new_line
                )

                continue


        new_lines.append(
            line
        )


    if not found:

        # Avoid creating an extra blank line if the source already
        # ended with one.
        if (
            new_lines
            and new_lines[-1] != ""
        ):

            new_lines.append(
                ""
            )

        new_lines.append(
            f"voice_host={wanted}"
        )

        changed = True


    if not changed:

        log(
            "Simple Voice Chat voice_host is already correct: "
            f"{wanted}"
        )

        return False


    rewritten = (
        newline.join(
            new_lines
        )
    )


    # Atomic replace in the same directory so a partial write
    # cannot corrupt the properties file.
    temporary = (
        path + ".hostish.tmp"
    )

    try:

        with open(
            temporary,
            "w",
            encoding="utf-8",
            newline="",
        ) as file:

            file.write(
                rewritten
            )

            file.flush()

            try:
                os.fsync(
                    file.fileno()
                )
            except OSError:
                pass


        os.replace(
            temporary,
            path,
        )


    except OSError as error:

        try:

            if os.path.exists(
                temporary
            ):

                os.remove(
                    temporary
                )

        except OSError:
            pass


        raise RuntimeError(
            "Could not update Simple Voice Chat config: "
            f"{error}"
        ) from error


    log(
        "Updated Simple Voice Chat config:"
    )

    log(
        f"voice_host={wanted}"
    )

    log(
        f"Config file: {path}"
    )

    return True


# ============================================================
# VALIDATION
# ============================================================

def validate_settings(
) -> bool:

    problems = []


    username = str(
        USERNAME or ""
    ).strip()


    password = str(
        PASSWORD or ""
    )


    if (
        not username
        or username
        == "your_username"
    ):

        problems.append(
            'Set USERNAME = '
            '"your_username" '
            "at the top of the file."
        )


    if (
        not password
        or password
        == "your_password"
    ):

        problems.append(
            'Set PASSWORD = '
            '"your_password" '
            "at the top of the file."
        )


    try:

        port = int(
            MINECRAFT_PORT
        )


        if (
            port < 1
            or port > 65535
        ):
            raise ValueError


    except Exception:

        problems.append(
            "MINECRAFT_PORT must be "
            "a number from 1 to 65535."
        )


    if SIMPLE_VOICE_CHAT_ENABLED:

        voice_config = (
            normalized_voice_chat_config_file()
        )

        if not voice_config:

            problems.append(
                "Set SIMPLE_VOICE_CHAT_CONFIG_FILE "
                "to voicechat-server.properties."
            )

        elif not os.path.isfile(
            voice_config
        ):

            problems.append(
                "SIMPLE_VOICE_CHAT_CONFIG_FILE does "
                "not exist:\n"
                f"   {voice_config}"
            )


        try:

            voice_port = int(
                SIMPLE_VOICE_CHAT_PORT
            )

            if (
                voice_port < 1
                or voice_port > 65535
            ):
                raise ValueError

        except Exception:

            problems.append(
                "SIMPLE_VOICE_CHAT_PORT must be "
                "a number from 1 to 65535."
            )


        try:

            public_voice_port = int(
                SIMPLE_VOICE_CHAT_PUBLIC_PORT
            )

            if (
                public_voice_port < 0
                or public_voice_port > 65535
            ):
                raise ValueError

        except Exception:

            problems.append(
                "SIMPLE_VOICE_CHAT_PUBLIC_PORT must "
                "be 0 or a number from 1 to 65535."
            )


    start_bat = (
        normalized_start_bat()
    )


    if (
        AUTO_START_SERVER
        and start_bat
        and not os.path.isfile(
            start_bat
        )
    ):

        problems.append(
            "START_BAT does not exist:\n"
            f"   {start_bat}"
        )


    if problems:

        print()

        print(
            "Hostish Minecraft Agent "
            "is not configured correctly."
        )

        print()


        for problem in problems:

            print(
                f" - {problem}"
            )


        print()

        print(
            "Edit this Python file, "
            "save it, and run it again."
        )

        print()

        return False


    return True


# ============================================================
# HOMEPAGE
# ============================================================

def load_homepage(
) -> tuple[
    bool,
    str,
    str,
]:

    configured = str(
        HOMEPAGE_FILE or ""
    ).strip()


    if not configured:

        return (
            False,
            "",
            "",
        )


    path = os.path.abspath(
        os.path.expanduser(
            configured
        )
    )


    try:

        size = os.path.getsize(
            path
        )


    except OSError as error:

        raise RuntimeError(
            "HOMEPAGE_FILE could not "
            f"be opened: {path} ({error})"
        ) from error


    if (
        size > MAX_HOMEPAGE_BYTES
    ):

        raise RuntimeError(
            "HOMEPAGE_FILE is too large. "
            "Maximum size is "
            f"{MAX_HOMEPAGE_BYTES // 1024} KB."
        )


    try:

        with open(
            path,
            "r",
            encoding="utf-8-sig",
        ) as file:

            html = (
                file.read()
            )


    except UnicodeError as error:

        raise RuntimeError(
            "HOMEPAGE_FILE must be UTF-8 HTML."
        ) from error


    except OSError as error:

        raise RuntimeError(
            "Could not read HOMEPAGE_FILE: "
            f"{error}"
        ) from error


    if not html.strip():

        raise RuntimeError(
            "HOMEPAGE_FILE is empty."
        )


    return (
        True,
        html,
        path,
    )


# ============================================================
# LOCAL SERVER CHECK
# ============================================================

async def test_local_server(
    timeout: float = 3,
) -> bool:

    try:

        (
            _reader,
            writer,
        ) = await asyncio.wait_for(
            asyncio.open_connection(
                MINECRAFT_HOST,
                int(
                    MINECRAFT_PORT
                ),
            ),

            timeout=
                timeout,
        )


        writer.close()


        try:

            await writer.wait_closed()

        except Exception:
            pass


        return True


    except Exception:

        return False


# ============================================================
# START.BAT
# ============================================================

def start_minecraft_server(
) -> bool:

    start_bat = (
        normalized_start_bat()
    )


    if not start_bat:

        log(
            "START_BAT is blank; "
            "Minecraft will not be "
            "started automatically."
        )

        return False


    if not os.path.isfile(
        start_bat
    ):

        log(
            "Could not start Minecraft "
            "server: START_BAT was not "
            f"found: {start_bat}"
        )

        return False


    working_directory = (
        os.path.dirname(
            start_bat
        )
    )


    log(
        "Starting Minecraft server..."
    )


    log(
        f"Start script: {start_bat}"
    )


    try:

        if os.name == "nt":

            subprocess.Popen(
                [
                    "cmd.exe",
                    "/c",
                    start_bat,
                ],

                cwd=
                    working_directory,

                creationflags=
                    subprocess.CREATE_NEW_CONSOLE,
            )


        else:

            subprocess.Popen(
                [
                    "/bin/sh",
                    start_bat,
                ],

                cwd=
                    working_directory,

                start_new_session=True,
            )


        return True


    except Exception as error:

        log(
            "Could not start Minecraft "
            "server: "
            f"{type(error).__name__}: "
            f"{error}"
        )

        return False


async def wait_for_minecraft_server(
) -> bool:

    timeout = max(
        1,
        int(
            SERVER_START_TIMEOUT
        ),
    )


    interval = max(
        0.5,
        float(
            SERVER_START_CHECK_INTERVAL
        ),
    )


    started_at = (
        time.monotonic()
    )


    log(
        "Waiting for Minecraft server "
        f"{MINECRAFT_HOST}:"
        f"{MINECRAFT_PORT}..."
    )


    while True:

        if await test_local_server(
            timeout=2
        ):

            log(
                "Minecraft server is online "
                f"after "
                f"{time.monotonic() - started_at:.1f} "
                "seconds."
            )

            return True


        if (
            time.monotonic()
            - started_at
            >= timeout
        ):

            log(
                "Minecraft server did not "
                "become available within "
                f"{timeout} seconds."
            )

            return False


        await asyncio.sleep(
            interval
        )


async def ensure_minecraft_server(
) -> bool:

    log(
        "Checking local Minecraft server "
        f"{MINECRAFT_HOST}:"
        f"{MINECRAFT_PORT}..."
    )


    if await test_local_server():

        log(
            "Local Minecraft server detected."
        )

        log(
            "Server is already running; "
            "START_BAT will not be launched."
        )

        return True


    log(
        "No Minecraft server is currently "
        "responding on "
        f"{MINECRAFT_HOST}:"
        f"{MINECRAFT_PORT}."
    )


    if not AUTO_START_SERVER:

        log(
            "Automatic server start is disabled."
        )

        return False


    if not normalized_start_bat():

        log(
            "START_BAT is blank. "
            "Start Minecraft manually "
            "before players join."
        )

        return False


    if not start_minecraft_server():

        return False


    return await wait_for_minecraft_server()


# ============================================================
# PLAYER CONNECTION
# ============================================================

class MinecraftConnection:

    def __init__(
        self,
        connection_id: str,
        websocket,
        send_lock: asyncio.Lock,
    ):

        self.id = (
            connection_id
        )


        self.websocket = (
            websocket
        )


        self.send_lock = (
            send_lock
        )


        self.reader = None
        self.writer = None

        self.read_task = None

        self.closed = False


    async def send_json(
        self,
        data: dict,
    ) -> None:

        async with self.send_lock:

            await self.websocket.send(
                json.dumps(
                    data,
                    separators=(
                        ",",
                        ":",
                    ),
                )
            )


    async def send_binary(
        self,
        data: bytes,
    ) -> None:

        async with self.send_lock:

            await self.websocket.send(
                data
            )


    async def open(
        self,
    ) -> bool:

        try:

            (
                self.reader,
                self.writer,
            ) = await asyncio.wait_for(
                asyncio.open_connection(
                    MINECRAFT_HOST,
                    int(
                        MINECRAFT_PORT
                    ),
                ),

                timeout=8,
            )


            await self.send_json(
                {
                    "type":
                        "tcp_open_result",

                    "id":
                        self.id,

                    "ok":
                        True,
                }
            )


            log(
                "Player connected | "
                f"{self.id[:8]}"
            )


            self.read_task = (
                asyncio.create_task(
                    self.minecraft_to_hostish()
                )
            )


            return True


        except Exception as error:

            try:

                await self.send_json(
                    {
                        "type":
                            "tcp_open_result",

                        "id":
                            self.id,

                        "ok":
                            False,

                        "error":
                            str(
                                error
                            ),
                    }
                )

            except Exception:
                pass


            log(
                "Could not connect player "
                "to local Minecraft server: "
                f"{error}"
            )


            return False


    async def minecraft_to_hostish(
        self,
    ) -> None:

        try:

            connection_bytes = (
                bytes.fromhex(
                    self.id
                )
            )


            if (
                len(
                    connection_bytes
                )
                != 16
            ):

                raise ValueError(
                    "Hostish returned an "
                    "invalid connection ID"
                )


            prefix = (
                MAGIC
                + connection_bytes
            )


            while not self.closed:

                data = (
                    await self.reader.read(
                        TCP_CHUNK_SIZE
                    )
                )


                if not data:
                    break


                await self.send_binary(
                    prefix + data
                )


        except asyncio.CancelledError:

            return


        except Exception as error:

            if not self.closed:

                log(
                    "Player connection error | "
                    f"{self.id[:8]} | "
                    f"{error}"
                )


        finally:

            await self.close(
                notify_hostish=True
            )


    async def hostish_to_minecraft(
        self,
        data: bytes,
    ) -> None:

        if (
            self.closed
            or self.writer is None
            or not data
        ):

            return


        try:

            self.writer.write(
                data
            )

            await self.writer.drain()


        except Exception:

            await self.close(
                notify_hostish=True
            )


    async def close(
        self,
        notify_hostish: bool = False,
    ) -> None:

        if self.closed:
            return


        self.closed = True


        if (
            self.read_task
            is not None
            and self.read_task
            is not asyncio.current_task()
        ):

            self.read_task.cancel()


        if self.writer is not None:

            try:

                self.writer.close()

                await self.writer.wait_closed()

            except Exception:
                pass


        if notify_hostish:

            try:

                await self.send_json(
                    {
                        "type":
                            "tcp_close",

                        "id":
                            self.id,
                    }
                )

            except Exception:
                pass


        log(
            "Player disconnected | "
            f"{self.id[:8]}"
        )


# ============================================================
# SIMPLE VOICE CHAT UDP BRIDGE
# ============================================================

class VoiceChatPeer:

    def __init__(
        self,
        peer_id: str,
        service_token: str,
        websocket,
        send_lock: asyncio.Lock,
    ):

        self.peer_id = peer_id
        self.service_token = service_token

        self.websocket = websocket
        self.send_lock = send_lock

        self.socket = None
        self.read_task = None
        self.closed = False


    async def send_binary(
        self,
        data: bytes,
    ) -> None:

        async with self.send_lock:

            await self.websocket.send(
                data
            )


    async def open(
        self,
    ) -> bool:

        if self.closed:
            return False

        if self.socket is not None:
            return True

        try:

            loop = asyncio.get_running_loop()

            family = socket.AF_INET6 if ":" in str(
                SIMPLE_VOICE_CHAT_HOST
            ) else socket.AF_INET

            sock = socket.socket(
                family,
                socket.SOCK_DGRAM,
            )

            sock.setblocking(
                False
            )

            await loop.sock_connect(
                sock,
                (
                    str(
                        SIMPLE_VOICE_CHAT_HOST
                    ),
                    int(
                        SIMPLE_VOICE_CHAT_PORT
                    ),
                ),
            )

            self.socket = sock

            self.read_task = (
                asyncio.create_task(
                    self.local_to_hostish()
                )
            )

            return True


        except Exception as error:

            log(
                "Simple Voice Chat UDP peer "
                f"{self.peer_id[:8]} could not "
                "connect to local UDP server: "
                f"{error}"
            )

            await self.close()

            return False


    async def hostish_to_local(
        self,
        payload: bytes,
    ) -> None:

        if (
            self.closed
            or not payload
        ):
            return

        if self.socket is None:

            if not await self.open():
                return

        try:

            loop = asyncio.get_running_loop()

            await loop.sock_sendall(
                self.socket,
                payload,
            )


        except Exception as error:

            if not self.closed:

                log(
                    "Simple Voice Chat UDP send "
                    f"error | {self.peer_id[:8]} | "
                    f"{error}"
                )

            await self.close()


    async def local_to_hostish(
        self,
    ) -> None:

        try:

            loop = asyncio.get_running_loop()

            prefix = (
                UDP_MAGIC
                + bytes.fromhex(
                    self.service_token
                )
                + bytes.fromhex(
                    self.peer_id
                )
            )

            while not self.closed:

                payload = (
                    await loop.sock_recv(
                        self.socket,
                        UDP_MAX_DATAGRAM_BYTES,
                    )
                )

                if not payload:
                    continue

                await self.send_binary(
                    prefix + payload
                )


        except asyncio.CancelledError:

            return


        except Exception as error:

            if not self.closed:

                log(
                    "Simple Voice Chat UDP receive "
                    f"error | {self.peer_id[:8]} | "
                    f"{error}"
                )


        finally:

            await self.close()


    async def close(
        self,
    ) -> None:

        if self.closed:
            return

        self.closed = True

        if (
            self.read_task is not None
            and self.read_task
            is not asyncio.current_task()
        ):

            self.read_task.cancel()

        if self.socket is not None:

            try:
                self.socket.close()
            except Exception:
                pass

            self.socket = None


class VoiceChatTunnel:

    def __init__(
        self,
        websocket,
        send_lock: asyncio.Lock,
    ):

        self.websocket = websocket
        self.send_lock = send_lock

        self.enabled = bool(
            SIMPLE_VOICE_CHAT_ENABLED
        )

        self.service_id = str(
            SIMPLE_VOICE_CHAT_SERVICE_ID
        )

        self.service_token = ""
        self.public_host = ""
        self.public_port = 0

        self.peers = {}


    async def send_json(
        self,
        data: dict,
    ) -> None:

        async with self.send_lock:

            await self.websocket.send(
                json.dumps(
                    data,
                    separators=(
                        ",",
                        ":",
                    ),
                )
            )


    async def request_open(
        self,
        requested_public_port: int | None = None,
    ) -> None:

        if not self.enabled:
            return

        if requested_public_port is None:

            requested_public_port = int(
                SIMPLE_VOICE_CHAT_PUBLIC_PORT
            )


        log(
            "Simple Voice Chat is enabled."
        )


        if requested_public_port:

            log(
                "Requesting Hostish UDP port "
                f"{requested_public_port}..."
            )

        else:

            log(
                "Requesting dynamic Hostish UDP "
                "service..."
            )


        await self.send_json(
            {
                "type":
                    "udp_open",

                "service_id":
                    self.service_id,

                "requested_public_port":
                    int(
                        requested_public_port
                    ),
            }
        )


    async def handle_open_result(
        self,
        event: dict,
    ) -> bool:

        if not self.enabled:
            return False

        if (
            str(
                event.get(
                    "service_id",
                    "",
                )
            )
            != self.service_id
        ):
            return False

        if not event.get(
            "ok"
        ):

            error = str(
                event.get(
                    "error",
                    "unknown error",
                )
            )

            log(
                "Simple Voice Chat UDP could "
                "not be opened: "
                f"{error}"
            )

            return False

        service_token = str(
            event.get(
                "service_token",
                "",
            )
        )

        try:

            token_bytes = bytes.fromhex(
                service_token
            )

        except Exception:

            token_bytes = b""

        if len(token_bytes) != 16:

            log(
                "Simple Voice Chat UDP returned "
                "an invalid service token."
            )

            return False

        self.service_token = service_token

        self.public_host = str(
            event.get(
                "public_host",
                "",
            )
        )

        self.public_port = int(
            event.get(
                "public_port",
                0,
            )
            or 0
        )

        log(
            "Simple Voice Chat UDP tunnel "
            "is online."
        )

        log(
            "Voice chat public address: "
            f"{self.public_host}:"
            f"{self.public_port}"
        )

        log(
            "Voice chat local address: "
            f"{SIMPLE_VOICE_CHAT_HOST}:"
            f"{SIMPLE_VOICE_CHAT_PORT}"
        )

        log(
            "Simple Voice Chat server config "
            "should advertise: "
            f"voice_host="
            f"{self.public_host}:"
            f"{self.public_port}"
        )


        return True


    async def handle_peer_open(
        self,
        event: dict,
    ) -> None:

        if (
            not self.enabled
            or not self.service_token
        ):
            return

        if (
            str(
                event.get(
                    "service_id",
                    "",
                )
            )
            != self.service_id
        ):
            return

        if (
            str(
                event.get(
                    "service_token",
                    "",
                )
            )
            != self.service_token
        ):
            return

        peer_id = str(
            event.get(
                "peer_id",
                "",
            )
        )

        try:

            peer_bytes = bytes.fromhex(
                peer_id
            )

        except Exception:

            peer_bytes = b""

        if len(peer_bytes) != 16:
            return

        old = self.peers.pop(
            peer_id,
            None,
        )

        if old is not None:
            await old.close()

        peer = VoiceChatPeer(
            peer_id,
            self.service_token,
            self.websocket,
            self.send_lock,
        )

        self.peers[
            peer_id
        ] = peer

        # Open immediately so each public UDP peer gets a unique
        # local UDP source port. This is important for a UDP server
        # such as Simple Voice Chat to distinguish clients.
        await peer.open()


    async def handle_peer_close(
        self,
        event: dict,
    ) -> None:

        peer_id = str(
            event.get(
                "peer_id",
                "",
            )
        )

        peer = self.peers.pop(
            peer_id,
            None,
        )

        if peer is not None:

            await peer.close()


    async def handle_udp_closed(
        self,
        event: dict,
    ) -> None:

        if (
            str(
                event.get(
                    "service_id",
                    "",
                )
            )
            != self.service_id
        ):
            return

        reason = str(
            event.get(
                "reason",
                "closed",
            )
        )

        await self.close_peers()

        self.service_token = ""
        self.public_host = ""
        self.public_port = 0

        if self.enabled:

            log(
                "Simple Voice Chat UDP tunnel "
                f"closed: {reason}"
            )


    async def handle_binary(
        self,
        message: bytes,
    ) -> bool:

        if (
            not self.enabled
            or not self.service_token
        ):
            return False

        if (
            len(message)
            < UDP_HEADER_SIZE
            or message[:2]
            != UDP_MAGIC
        ):
            return False

        service_token = (
            message[
                2:18
            ].hex()
        )

        if (
            service_token
            != self.service_token
        ):
            return False

        peer_id = (
            message[
                18:34
            ].hex()
        )

        payload = (
            message[
                UDP_HEADER_SIZE:
            ]
        )

        if not payload:
            return True

        peer = self.peers.get(
            peer_id
        )

        if peer is None:

            # Binary traffic can theoretically arrive directly after
            # udp_peer_open. Be defensive and create the local mapping
            # if the control message was missed/delayed.
            peer = VoiceChatPeer(
                peer_id,
                self.service_token,
                self.websocket,
                self.send_lock,
            )

            self.peers[
                peer_id
            ] = peer

        await peer.hostish_to_local(
            payload
        )

        return True


    async def close_peers(
        self,
    ) -> None:

        if not self.peers:
            return

        peers = list(
            self.peers.values()
        )

        self.peers.clear()

        await asyncio.gather(
            *(
                peer.close()
                for peer in peers
            ),
            return_exceptions=True,
        )


    async def close(
        self,
    ) -> None:

        await self.close_peers()

        self.service_token = ""
        self.public_host = ""
        self.public_port = 0


# ============================================================
# SIMPLE VOICE CHAT PRE-SERVER SETUP
# ============================================================

async def prepare_voice_chat_before_server(
    websocket,
    send_lock: asyncio.Lock,
    voice_chat: VoiceChatTunnel,
) -> tuple[str, int]:
    """
    Allocate the public Hostish UDP endpoint before Minecraft starts,
    write voice_host= to voicechat-server.properties, and only then
    return control so the Minecraft server can be started.
    """

    global _VOICE_CHAT_LAST_PUBLIC_PORT


    if not SIMPLE_VOICE_CHAT_ENABLED:

        return (
            "",
            0,
        )


    configured_port = int(
        SIMPLE_VOICE_CHAT_PUBLIC_PORT
    )


    # If the user explicitly configured a public port, always request it.
    # Otherwise try to reclaim the port assigned earlier in this same
    # agent process after a reconnect. On first launch this is 0/dynamic.
    requested_port = (
        configured_port
        if configured_port
        else int(
            _VOICE_CHAT_LAST_PUBLIC_PORT
            or 0
        )
    )


    await voice_chat.request_open(
        requested_public_port=
            requested_port
    )


    deadline = (
        asyncio.get_running_loop().time()
        + float(
            VOICE_CHAT_UDP_OPEN_TIMEOUT
        )
    )


    tried_dynamic_fallback = False


    while True:

        remaining = (
            deadline
            - asyncio.get_running_loop().time()
        )

        if remaining <= 0:

            raise RuntimeError(
                "Timed out waiting for Hostish to "
                "allocate the Simple Voice Chat UDP port."
            )


        message = await asyncio.wait_for(
            websocket.recv(),
            timeout=
                remaining,
        )


        if isinstance(
            message,
            bytes,
        ):

            # No UDP peers should exist before the server is started,
            # but ignore unexpected binary traffic safely.
            continue


        try:

            event = (
                json.loads(
                    message
                )
            )

        except Exception:
            continue


        event_type = (
            event.get(
                "type"
            )
        )


        if (
            event_type
            == "ping"
        ):

            async with send_lock:

                await websocket.send(
                    json.dumps(
                        {
                            "type":
                                "pong"
                        },
                        separators=(
                            ",",
                            ":",
                        ),
                    )
                )

            continue


        if (
            event_type
            == "homepage_update_result"
        ):

            # The normal message loop will not see this because we are
            # intentionally negotiating UDP before server startup.
            if event.get(
                "ok"
            ):

                if (
                    event.get(
                        "state"
                    )
                    == "enabled"
                ):

                    log(
                        "Browser homepage accepted "
                        "by Hostish "
                        f"({event.get('bytes', 0)} bytes)."
                    )

                    if event.get(
                        "url"
                    ):

                        log(
                            "Browser URL: "
                            f"{event['url']}"
                        )

                else:

                    log(
                        "Browser homepage disabled "
                        "on Hostish."
                    )

            else:

                log(
                    "Browser homepage rejected by "
                    "Hostish: "
                    f"{event.get('state', 'unknown error')}"
                )

            continue


        if (
            event_type
            != "udp_open_result"
        ):

            continue


        if (
            str(
                event.get(
                    "service_id",
                    "",
                )
            )
            != str(
                SIMPLE_VOICE_CHAT_SERVICE_ID
            )
        ):

            continue


        opened = (
            await voice_chat.handle_open_result(
                event
            )
        )


        if not opened:

            # If this was an attempt to reclaim a previous dynamic port,
            # fall back to a fresh dynamic allocation once.
            if (
                requested_port
                and not configured_port
                and not tried_dynamic_fallback
            ):

                tried_dynamic_fallback = True
                requested_port = 0

                log(
                    "Previous UDP port could not be "
                    "reclaimed; requesting a new "
                    "dynamic port..."
                )

                await voice_chat.request_open(
                    requested_public_port=0
                )

                # Give the fallback its own full timeout.
                deadline = (
                    asyncio.get_running_loop().time()
                    + float(
                        VOICE_CHAT_UDP_OPEN_TIMEOUT
                    )
                )

                continue


            raise RuntimeError(
                "Hostish could not open the Simple "
                "Voice Chat UDP service."
            )


        _VOICE_CHAT_LAST_PUBLIC_PORT = (
            int(
                voice_chat.public_port
            )
        )


        changed = (
            update_voice_chat_public_address(
                voice_chat.public_host,
                voice_chat.public_port,
            )
        )


        if changed:

            log(
                "Simple Voice Chat public address "
                "was written before server startup."
            )


        return (
            voice_chat.public_host,
            voice_chat.public_port,
        )


# ============================================================
# HOMEPAGE SEND
# ============================================================

async def send_homepage_state(
    websocket,
    send_lock: asyncio.Lock,
) -> None:

    (
        enabled,
        html,
        path,
    ) = load_homepage()


    async with send_lock:

        await websocket.send(
            json.dumps(
                {
                    "type":
                        "homepage_update",

                    "enabled":
                        enabled,

                    "html":
                        html
                        if enabled
                        else "",
                },
                separators=(
                    ",",
                    ":",
                ),
            )
        )


    if enabled:

        log(
            "Browser homepage sent "
            f"to Hostish: {path}"
        )

    else:

        log(
            "Browser homepage disabled; "
            "state sent to Hostish."
        )


# ============================================================
# HOSTISH TUNNEL
# ============================================================

async def run_tunnel(
) -> None:

    ssl_context = (
        create_ssl_context()
    )


    log(
        "Connecting to Hostish..."
    )


    async with websockets.connect(
        HOSTISH_URL,

        ssl=
            ssl_context,

        max_size=
            MAX_WEBSOCKET_MESSAGE,

        ping_interval=
            20,

        ping_timeout=
            20,

        open_timeout=
            15,

        close_timeout=
            5,
    ) as websocket:

        send_lock = (
            asyncio.Lock()
        )


        connections = {}


        voice_chat = (
            VoiceChatTunnel(
                websocket,
                send_lock,
            )
        )


        # ----------------------------------------------------
        # Authenticate
        # ----------------------------------------------------

        await websocket.send(
            json.dumps(
                {
                    "username":
                        str(
                            USERNAME
                        ).strip(),

                    "password":
                        str(
                            PASSWORD
                        ),

                    "agent":
                        "minecraft",

                    "agent_version":
                        AGENT_VERSION,
                },
                separators=(
                    ",",
                    ":",
                ),
            )
        )


        response_raw = (
            await websocket.recv()
        )


        if not isinstance(
            response_raw,
            str,
        ):

            raise RuntimeError(
                "Invalid authentication response"
            )


        response = (
            json.loads(
                response_raw
            )
        )


        if (
            response.get(
                "type"
            )
            != "auth"
            or not response.get(
                "ok"
            )
        ):

            raise RuntimeError(
                response.get(
                    "error",
                    "Authentication failed",
                )
            )


        hostname = (
            response.get(
                "hostname",
                f"{USERNAME}.hostish.site",
            )
        )


        port = int(
            response.get(
                "port",
                25565,
            )
            or 25565
        )


        log(
            f"Authenticated as {USERNAME}"
        )


        if port == 25565:

            log(
                "Minecraft address: "
                f"{hostname}"
            )

        else:

            log(
                "Minecraft address: "
                f"{hostname}:"
                f"{port}"
            )


        log(
            "Local server: "
            f"{MINECRAFT_HOST}:"
            f"{MINECRAFT_PORT}"
        )


        # ----------------------------------------------------
        # Homepage update
        # ----------------------------------------------------

        await send_homepage_state(
            websocket,
            send_lock,
        )


        # ----------------------------------------------------
        # Voice Chat must know its public UDP address BEFORE
        # Minecraft / Simple Voice Chat starts.
        # ----------------------------------------------------

        if SIMPLE_VOICE_CHAT_ENABLED:

            await prepare_voice_chat_before_server(
                websocket,
                send_lock,
                voice_chat,
            )


        # ----------------------------------------------------
        # Start/check Minecraft only after voice_host is ready.
        # ----------------------------------------------------

        if SIMPLE_VOICE_CHAT_ENABLED:

            server_online = (
                await ensure_minecraft_server()
            )

        else:

            server_online = (
                await test_local_server()
            )


        if not server_online:

            log(
                "WARNING: Minecraft is not "
                "currently responding."
            )

            log(
                "Hostish will stay connected, "
                "but players cannot join until "
                "the Minecraft server is running."
            )


        log(
            "Minecraft tunnel is online."
        )


        # ----------------------------------------------------
        # Main message loop
        # ----------------------------------------------------

        try:

            async for message in websocket:


                # ============================================
                # Raw TCP
                # ============================================

                if isinstance(
                    message,
                    bytes,
                ):

                    # ----------------------------------------
                    # Dynamic UDP / Simple Voice Chat
                    # ----------------------------------------

                    if (
                        await voice_chat.handle_binary(
                            message
                        )
                    ):

                        continue


                    # ----------------------------------------
                    # Existing Minecraft TCP tunnel
                    # ----------------------------------------

                    if (
                        len(
                            message
                        )
                        < HEADER_SIZE
                    ):
                        continue


                    if (
                        message[:2]
                        != MAGIC
                    ):
                        continue


                    connection_id = (
                        message[
                            2:18
                        ].hex()
                    )


                    payload = (
                        message[
                            HEADER_SIZE:
                        ]
                    )


                    connection = (
                        connections.get(
                            connection_id
                        )
                    )


                    if (
                        connection
                        is not None
                    ):

                        await (
                            connection
                            .hostish_to_minecraft(
                                payload
                            )
                        )


                    continue


                # ============================================
                # JSON message
                # ============================================

                try:

                    event = (
                        json.loads(
                            message
                        )
                    )

                except Exception:
                    continue


                event_type = (
                    event.get(
                        "type"
                    )
                )


                # --------------------------------------------
                # Simple Voice Chat / dynamic UDP
                # --------------------------------------------

                if (
                    event_type
                    == "udp_open_result"
                ):

                    await voice_chat.handle_open_result(
                        event
                    )

                    continue


                if (
                    event_type
                    == "udp_peer_open"
                ):

                    await voice_chat.handle_peer_open(
                        event
                    )

                    continue


                if (
                    event_type
                    == "udp_peer_close"
                ):

                    await voice_chat.handle_peer_close(
                        event
                    )

                    continue


                if (
                    event_type
                    == "udp_closed"
                ):

                    await voice_chat.handle_udp_closed(
                        event
                    )

                    continue


                if (
                    event_type
                    == "udp_close_result"
                ):

                    continue


                # --------------------------------------------
                # Homepage acknowledgement
                # --------------------------------------------

                if (
                    event_type
                    == "homepage_update_result"
                ):

                    if event.get(
                        "ok"
                    ):

                        if (
                            event.get(
                                "state"
                            )
                            == "enabled"
                        ):

                            log(
                                "Browser homepage "
                                "accepted by Hostish "
                                f"({event.get('bytes', 0)} bytes)."
                            )


                            if event.get(
                                "url"
                            ):

                                log(
                                    "Browser URL: "
                                    f"{event['url']}"
                                )


                        else:

                            log(
                                "Browser homepage "
                                "disabled on Hostish."
                            )


                    else:

                        log(
                            "Browser homepage rejected "
                            "by Hostish: "
                            f"{event.get('state', 'unknown error')}"
                        )


                    continue


                # --------------------------------------------
                # New Minecraft connection
                # --------------------------------------------

                if (
                    event_type
                    == "tcp_open"
                ):

                    connection_id = str(
                        event.get(
                            "id",
                            "",
                        )
                    )


                    if not connection_id:
                        continue


                    old = (
                        connections.pop(
                            connection_id,
                            None,
                        )
                    )


                    if old is not None:

                        await old.close()


                    connection = (
                        MinecraftConnection(
                            connection_id,
                            websocket,
                            send_lock,
                        )
                    )


                    connections[
                        connection_id
                    ] = connection


                    opened = (
                        await connection.open()
                    )


                    if not opened:

                        connections.pop(
                            connection_id,
                            None,
                        )


                    continue


                # --------------------------------------------
                # Connection closed
                # --------------------------------------------

                if (
                    event_type
                    == "tcp_close"
                ):

                    connection_id = str(
                        event.get(
                            "id",
                            "",
                        )
                    )


                    connection = (
                        connections.pop(
                            connection_id,
                            None,
                        )
                    )


                    if (
                        connection
                        is not None
                    ):

                        await connection.close()


                    continue


                # --------------------------------------------
                # Keepalive
                # --------------------------------------------

                if (
                    event_type
                    == "ping"
                ):

                    async with send_lock:

                        await websocket.send(
                            json.dumps(
                                {
                                    "type":
                                        "pong"
                                },
                                separators=(
                                    ",",
                                    ":",
                                ),
                            )
                        )


                    continue


        finally:

            await voice_chat.close()


            if connections:

                await asyncio.gather(
                    *(
                        connection.close()

                        for connection
                        in connections.values()
                    ),

                    return_exceptions=True,
                )


# ============================================================
# MAIN
# ============================================================

async def main(
) -> None:

    if not validate_settings():
        return


    try:

        (
            homepage_enabled,
            _html,
            _path,
        ) = load_homepage()


    except RuntimeError as error:

        print()

        log(
            "Homepage configuration error: "
            f"{error}"
        )

        print()

        return


    print()


    log(
        "Hostish Minecraft Agent"
    )


    if SIMPLE_VOICE_CHAT_ENABLED:

        log(
            "Simple Voice Chat UDP support: ON"
        )

        log(
            "Voice chat config: "
            f"{normalized_voice_chat_config_file()}"
        )

    else:

        log(
            "Simple Voice Chat UDP support: OFF"
        )


    if SIMPLE_VOICE_CHAT_ENABLED:

        log(
            "Minecraft startup will wait until "
            "Hostish allocates the voice-chat "
            "UDP port and voice_host is updated."
        )


    else:

        server_online = (
            await ensure_minecraft_server()
        )


        if not server_online:

            log(
                "WARNING: Minecraft is not "
                "currently responding."
            )

            log(
                "Hostish will still connect, "
                "but players cannot join until "
                "the Minecraft server is running."
            )


    if homepage_enabled:

        log(
            "Optional browser homepage "
            "is enabled."
        )


    delay = (
        RECONNECT_DELAY
    )


    while True:

        try:

            await run_tunnel()

            delay = (
                RECONNECT_DELAY
            )


        except asyncio.CancelledError:

            return


        except KeyboardInterrupt:

            return


        except ssl.SSLCertVerificationError as error:

            log(
                "TLS certificate verification "
                f"failed: {error}"
            )

            log(
                "Make sure this computer's "
                "date/time is correct and "
                "update certifi with: "
                "python -m pip install -U certifi"
            )


        except Exception as error:

            log(
                "Disconnected: "
                f"{type(error).__name__}: "
                f"{error}"
            )


        log(
            "Reconnecting in "
            f"{delay} seconds..."
        )


        await asyncio.sleep(
            delay
        )


        delay = min(
            delay + 2,
            MAX_RECONNECT_DELAY,
        )


# ============================================================
# ENTRY
# ============================================================

if __name__ == "__main__":

    try:

        asyncio.run(
            main()
        )

    except KeyboardInterrupt:

        print()

        log(
            "Stopped."
        )
