#!/usr/bin/env bash
set -euo pipefail

: "${BUNNYLAND_DOMAIN:?set BUNNYLAND_DOMAIN to your public domain, for example sandbox.example.com}"
: "${BUNNYLAND_PLAY_TOKEN:?set BUNNYLAND_PLAY_TOKEN to a world:play bearer token}"
: "${BUNNYLAND_OPERATOR_TOKEN:?set BUNNYLAND_OPERATOR_TOKEN to a world:admin bearer token}"

export BUNNYLAND_DOMAIN
export BUNNYLAND_PLAY_TOKEN
export BUNNYLAND_OPERATOR_TOKEN
export BUNNYLAND_VERIFY_CONNECT_HOST="${BUNNYLAND_VERIFY_CONNECT_HOST:-$BUNNYLAND_DOMAIN}"
export BUNNYLAND_VERIFY_PORT="${BUNNYLAND_VERIFY_PORT:-443}"
export BUNNYLAND_VERIFY_SCHEME="${BUNNYLAND_VERIFY_SCHEME:-https}"
export BUNNYLAND_VERIFY_ADMIN_UNAUTH_STATUS="${BUNNYLAND_VERIFY_ADMIN_UNAUTH_STATUS:-401}"
export BUNNYLAND_VERIFY_ADMIN_PLAY_STATUS="${BUNNYLAND_VERIFY_ADMIN_PLAY_STATUS:-403}"
export BUNNYLAND_VERIFY_ADMIN_AUTH_STATUS="${BUNNYLAND_VERIFY_ADMIN_AUTH_STATUS:-200}"
export BUNNYLAND_VERIFY_ADMIN_DATA="${BUNNYLAND_VERIFY_ADMIN_DATA:-0}"
export BUNNYLAND_HOME_DOMAIN="${BUNNYLAND_HOME_DOMAIN:-}"
export BUNNYLAND_HOME_EXPECT_TEXT="${BUNNYLAND_HOME_EXPECT_TEXT:-}"

python3 - <<'PY'
import base64
import json
import os
import socket
import ssl
import struct
import sys
from contextlib import nullcontext
from dataclasses import asdict, dataclass

domain = os.environ["BUNNYLAND_DOMAIN"]
connect_host = os.environ["BUNNYLAND_VERIFY_CONNECT_HOST"]
port = int(os.environ["BUNNYLAND_VERIFY_PORT"])
scheme = os.environ["BUNNYLAND_VERIFY_SCHEME"]
admin_unauth_status = int(os.environ["BUNNYLAND_VERIFY_ADMIN_UNAUTH_STATUS"])
admin_play_status = int(os.environ["BUNNYLAND_VERIFY_ADMIN_PLAY_STATUS"])
admin_auth_status = int(os.environ["BUNNYLAND_VERIFY_ADMIN_AUTH_STATUS"])
play_token = os.environ["BUNNYLAND_PLAY_TOKEN"]
operator_token = os.environ["BUNNYLAND_OPERATOR_TOKEN"]
verify_admin_data = os.environ["BUNNYLAND_VERIFY_ADMIN_DATA"] == "1"
home_domain = os.environ["BUNNYLAND_HOME_DOMAIN"]
home_expect_text = os.environ["BUNNYLAND_HOME_EXPECT_TEXT"]
base_url = f"{scheme}://{domain}"


@dataclass(frozen=True)
class RuntimePatch:
    paused: bool


def fail(message: str) -> None:
    print(f"FAIL {message}", file=sys.stderr)
    raise SystemExit(1)


def ok(message: str) -> None:
    print(f"OK   {message}")


if scheme not in {"http", "https"}:
    fail(f"BUNNYLAND_VERIFY_SCHEME must be http or https, got {scheme!r}")

def decode_chunked(body: bytes) -> bytes:
    decoded = bytearray()
    offset = 0
    while True:
        line_end = body.find(b"\r\n", offset)
        if line_end < 0:
            fail("HTTP response: malformed chunked body")
        size_line = body[offset:line_end].split(b";", 1)[0]
        size = int(size_line, 16)
        offset = line_end + 2
        if size == 0:
            return bytes(decoded)
        decoded.extend(body[offset : offset + size])
        offset += size + 2


def request(
    method: str,
    path: str,
    *,
    token: str | None = None,
    target_domain: str | None = None,
    body: RuntimePatch | None = None,
) -> tuple[int, bytes]:
    request_domain = target_domain or domain
    headers = {
        "User-Agent": "bunnyland-vps-docker-verify/1",
        "X-Bunnyland-Client-Id": "vps-docker-verify",
    }
    if token is not None:
        headers["Authorization"] = f"Bearer {token}"
    headers["Host"] = request_domain
    headers["Connection"] = "close"
    payload = b"" if body is None else json.dumps(asdict(body)).encode()
    if body is not None:
        headers["Content-Type"] = "application/json"
        headers["Content-Length"] = str(len(payload))
    raw_request = f"{method} {path} HTTP/1.1\r\n" + "".join(
        f"{key}: {value}\r\n" for key, value in headers.items()
    ) + "\r\n"
    with socket.create_connection((connect_host, port), timeout=15) as raw:
        ctx = ssl.create_default_context() if scheme == "https" else None
        stream = ctx.wrap_socket(raw, server_hostname=request_domain) if ctx else nullcontext(raw)
        with stream as sock:
            sock.sendall(raw_request.encode("ascii") + payload)
            data = b""
            while True:
                chunk = sock.recv(65536)
                if not chunk:
                    break
                data += chunk

    header_bytes, body = data.split(b"\r\n\r\n", 1)
    header_lines = header_bytes.decode("iso-8859-1").split("\r\n")
    status = int(header_lines[0].split()[1])
    response_headers = {}
    for line in header_lines[1:]:
        if ":" in line:
            key, value = line.split(":", 1)
            response_headers[key.strip().lower()] = value.strip().lower()
    if response_headers.get("transfer-encoding") == "chunked":
        body = decode_chunked(body)
    return status, body


def expect_status(
    name: str,
    method: str,
    path: str,
    expected: int,
    *,
    token: str | None = None,
    target_domain: str | None = None,
    body: RuntimePatch | None = None,
) -> bytes:
    status, response_body = request(
        method,
        path,
        token=token,
        target_domain=target_domain,
        body=body,
    )
    if status != expected:
        fail(f"{name}: expected HTTP {expected}, got HTTP {status}")
    ok(f"{name}: HTTP {status}")
    return response_body


if home_domain:
    body = expect_status("homepage", "GET", "/", 200, target_domain=home_domain)
    if home_expect_text and home_expect_text.encode() not in body:
        fail(f"homepage: expected text {home_expect_text!r} was not present")
    if home_expect_text:
        ok("homepage: expected text present")

body = expect_status("welcome page", "GET", "/", 200)
if b"Welcome to Bunnyland" not in body:
    fail("welcome page: response did not contain the welcome front door")

body = expect_status("inspector", "GET", "/inspector.html", 200)
if b"Bunnyland Inspector" not in body:
    fail("inspector: response did not contain Bunnyland Inspector")

body = expect_status("world editor", "GET", "/world-editor.html", 200)
if b"Bunnyland World Editor" not in body:
    fail("world editor: response did not contain Bunnyland World Editor")

config = json.loads(expect_status("frontend config", "GET", "/config.json", 200).decode())
if config.get("serverUrl") != "/api/":
    fail(f"frontend config: expected serverUrl '/api/', got {config.get('serverUrl')!r}")
# config.json is rendered from the frontend environment at container start; the discordUrl
# key is always present (empty when no invite is configured), so its absence means the
# render step did not run.
if "discordUrl" not in config:
    fail("frontend config: discordUrl key missing (config.json was not rendered)")
ok("frontend config: same-origin /api/")

expect_status("REST health", "GET", "/api/v1/public/health", 204)

# Health is public, but the player API requires a scoped bearer token.
expect_status("world characters require player auth", "GET", "/api/v1/play/characters", 401)
expect_status(
    "world characters reject bad player credentials",
    "GET",
    "/api/v1/play/characters",
    401,
    token="invalid",
)
lobby = json.loads(expect_status(
    "world characters player", "GET", "/api/v1/play/characters", 200, token=play_token
).decode())
ok(f"world characters player: {len(lobby.get('characters', []))} claimable")

# The raw snapshot is admin-scoped.
expect_status(
    "world snapshot requires admin", "GET", "/api/v1/admin/world/snapshot", admin_unauth_status
)
if verify_admin_data:
    snapshot = json.loads(
        expect_status(
            "world snapshot (admin)",
            "GET",
            "/api/v1/admin/world/snapshot",
            200,
            token=operator_token,
        ).decode()
    )
    metadata = snapshot.get("metadata") or {}
    ok(
        "world snapshot: "
        + f"seed={metadata.get('seed')!r} generator={metadata.get('generator')!r} "
        + f"epoch={snapshot.get('world_epoch')!r}"
    )

expect_status(
    "admin rejects player scope",
    "PATCH",
    "/api/v1/admin/world/runtime",
    admin_play_status,
    token=play_token,
    body=RuntimePatch(paused=True),
)

pause_body = expect_status(
    "admin accepts good credentials",
    "PATCH",
    "/api/v1/admin/world/runtime",
    admin_auth_status,
    token=operator_token,
    body=RuntimePatch(paused=True),
)
if admin_auth_status == 200:
    pause = json.loads(pause_body.decode())
    if pause.get("paused") is not True:
        fail(f"admin pause: unexpected response {pause!r}")

resume_body = expect_status(
    "admin resume",
    "PATCH",
    "/api/v1/admin/world/runtime",
    admin_auth_status,
    token=operator_token,
    body=RuntimePatch(paused=False),
)
if admin_auth_status == 200:
    resume = json.loads(resume_body.decode())
    if resume.get("paused") is not False:
        fail(f"admin resume: unexpected response {resume!r}")

def websocket_handshake(token: str | None = None) -> tuple[str, bytes | None]:
    """Open the updates stream and return (status line, first frame payload). The payload is
    None unless the server completed the upgrade (HTTP 101)."""
    key = base64.b64encode(os.urandom(16)).decode()
    lines = [
        "GET /api/v1/admin/world/stream HTTP/1.1",
        f"Host: {domain}",
        "Upgrade: websocket",
        "Connection: Upgrade",
        f"Sec-WebSocket-Key: {key}",
        "Sec-WebSocket-Version: 13",
    ]
    raw_request = "\r\n".join(lines) + "\r\n\r\n"
    with socket.create_connection((connect_host, port), timeout=15) as raw:
        ctx = ssl.create_default_context() if scheme == "https" else None
        stream = ctx.wrap_socket(raw, server_hostname=domain) if ctx else nullcontext(raw)
        with stream as sock:
            sock.sendall(raw_request.encode("ascii"))
            data = b""
            while b"\r\n\r\n" not in data:
                chunk = sock.recv(4096)
                if not chunk:
                    fail("websocket: connection closed before headers")
                data += chunk
            headers, rest = data.split(b"\r\n\r\n", 1)
            status_line = headers.split(b"\r\n", 1)[0].decode("ascii", "replace")
            if " 101 " not in status_line:
                return status_line, None

            auth_data = {"client_id": "vps-docker-verify"}
            if token is not None:
                auth_data["token"] = token
            payload = json.dumps({"type": "authenticate", "data": auth_data}).encode()
            mask = os.urandom(4)
            header = bytes((0x81, 0x80 | len(payload)))
            masked = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
            sock.sendall(header + mask + masked)

            while len(rest) < 2:
                rest += sock.recv(4096)
            length = rest[1] & 0x7F
            offset = 2
            if length == 126:
                while len(rest) < offset + 2:
                    rest += sock.recv(4096)
                length = struct.unpack("!H", rest[offset : offset + 2])[0]
                offset += 2
            elif length == 127:
                while len(rest) < offset + 8:
                    rest += sock.recv(4096)
                length = struct.unpack("!Q", rest[offset : offset + 8])[0]
                offset += 8
            while len(rest) < offset + length:
                rest += sock.recv(65536)
            return status_line, rest[offset : offset + length]


# WebSocket credentials are validated in the first frame after upgrade.
unauth_status, unauth_payload = websocket_handshake()
if " 101 " not in unauth_status or not unauth_payload:
    fail(f"websocket: expected upgrade followed by authentication rejection, got {unauth_status}")
ok("websocket requires admin bearer in its first frame")

if verify_admin_data:
    status_line, payload = websocket_handshake(token=operator_token)
    if " 101 " not in status_line:
        fail(f"websocket (admin): expected HTTP 101, got {status_line}")
    if not payload or b'"type":"snapshot"' not in payload:
        fail("websocket (admin): first frame was not a snapshot event")
    ok("websocket (admin): HTTP 101 and snapshot frame")

print(f"Verified Bunnyland VPS Docker deployment at {base_url}/")
PY
