# /// script
# requires-python = ">=3.9"
# dependencies = []
# ///
"""
archiXML Live — local bridge.

Proxies the archiXML web app to Archicad's JSON server, which the browser cannot reach directly:
Archicad sends no CORS headers, and an application/json POST forces a preflight it will reject.

The app itself comes from https://archixml.architools.net, so a user always runs the current
version and cannot end up debugging a stale local copy. This file is then the ONLY local artifact —
one self-contained stdlib script with no folder around it. When the archiXML tree happens to sit
next to the script (the full-repo checkout), the same file also serves it locally, which is the
fallback for sites with no outbound access.

Run it from the Tapir Palette (Add -> pick this file -> Run). Tapir passes --port for the Archicad
instance that launched it, installs `uv` if needed, and streams this output into its log.

    python bridge/archixml.py --port 19723            # standalone
    python bridge/archixml.py --port 19723 --no-dock  # print the URL, don't open a palette
    python bridge/archixml.py --port 19723 --app local    # force the local copy
    python bridge/archixml.py --port 19723 --app hosted   # force architools.net

Deliberately dumb: a proxy and a static file server with no Tapir domain knowledge. Every command
name, field map and commit ordering lives in js/backends/live/. Updating a locally-installed script
is the highest-friction operation in this system, so the bridge should almost never need a new
version.
"""

import argparse
import json
import mimetypes
import os
import posixpath
import re
import secrets
import socket
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

PROTOCOL_VERSION = 1
BRIDGE_VERSION = "0.2.0"

# Where the app lives. This deployment is the archiXML repo's own; www.architools.net/archixml
# embeds it in an iframe. Hosted mode targets the app's OWN origin rather than the framed page:
# a cross-origin iframe cannot be handed query parameters without the parent forwarding them, and
# Chrome's local-network gate is delegated to frames through Permissions-Policy the parent does not
# currently grant. Going straight to the subdomain needs neither.
HOSTED_APP_URL = "https://archixml.architools.net"

AC_PORT_FIRST = 19723
AC_PORT_LAST = 19743          # Archicad allocates 19723+N; scan the documented range
AC_DISCOVERY_TIMEOUT = 1.5
AC_COMMAND_TIMEOUT = 30.0     # a profile change can force a rebuild of every element using it

# An unauthenticated localhost bridge with CORS lets any site the user visits drive their Archicad.
# Origins are allowlisted, every /ac/* and /fs/* call needs the launch token, and the Host header is
# validated against DNS rebinding.
ALLOWED_ORIGIN_PATTERNS = (
    re.compile(r"^http://127\.0\.0\.1(:\d+)?$"),
    re.compile(r"^http://localhost(:\d+)?$"),
    re.compile(r"^https://www\.architools\.net$"),
    re.compile(r"^https://architools\.net$"),
    # The hosted app is its own deployment at archixml.architools.net, embedded in an iframe on
    # www.architools.net/archixml. A cross-origin iframe keeps ITS OWN origin, so this subdomain
    # is what the browser sends -- not the parent's. Listed explicitly rather than by wildcard:
    # allowing *.architools.net would hand the bridge to anything ever hosted on a subdomain.
    re.compile(r"^https://archixml\.architools\.net$"),
)
ALLOWED_HOST_PATTERN = re.compile(r"^(127\.0\.0\.1|localhost)(:\d+)?$")


def allow_origin(origin):
    """Add one exact origin to the allowlist for this run. Only --hosted-url uses this."""
    global ALLOWED_ORIGIN_PATTERNS
    ALLOWED_ORIGIN_PATTERNS = ALLOWED_ORIGIN_PATTERNS + (
        re.compile("^" + re.escape(origin.rstrip("/")) + "$"),)

# Hosts the page may ask the bridge to open in the user's real browser. Deliberately a short
# allowlist: this endpoint reaches outside the sandbox, so it must not become a way for any page
# served here to launch arbitrary URLs.
EXTERNAL_URL_ALLOWLIST = (
    re.compile(r"^https://(www\.)?architools\.net(/.*)?$"),
    re.compile(r"^https://(www\.)?linkedin\.com/.*$"),
    re.compile(r"^https://github\.com/giza611(/.*)?$"),
)

STATE_DIR = os.path.join(os.path.expanduser("~"), ".archixml")
SNAPSHOT_DIR = os.path.join(STATE_DIR, "snapshots")
SETTINGS_PATH = os.path.join(STATE_DIR, "settings.json")

if getattr(sys, "frozen", False):
    # PyInstaller: __file__ points inside the unpacked bundle, which is not where the user put
    # the executable and never contains the app tree.
    REPO_ROOT = os.path.dirname(os.path.abspath(sys.executable))
else:
    REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

STATIC_ALLOWED_DIRS = ("js", "css", "assets", "tests")

# Inter is vendored under css/fonts so the page reaches no third party.
FONT_TYPES = {".woff2": "font/woff2", ".woff": "font/woff", ".ttf": "font/ttf"}
# "sample layers.xml" is served because the golden harness fetches it as a fixture. It already
# ships publicly with the Vercel build, so this exposes nothing new.
STATIC_ALLOWED_ROOT_FILES = ("index.html", "favicon.ico", "sample layers.xml")

# The three files that together mean "the archiXML tree really is here".
LOCAL_APP_SIGNATURE = ("index.html", os.path.join("js", "app.js"), os.path.join("css", "styles.css"))


def detect_local_app(root):
    """Is the archiXML tree sitting next to this script?

    This is not a nicety. REPO_ROOT is two levels up from the script, so a standalone archixml.py
    dropped in C:\\Tools\\ computes REPO_ROOT = C:\\ — and without this check the static server would
    happily hand out any js/, css/, assets/ or tests/ directory that happened to live there. A
    single file must serve nothing off disk. Three files rather than one, because a lone index.html
    is a common thing to find in a directory.
    """
    return all(os.path.isfile(os.path.join(root, *part.split(os.sep)))
               for part in LOCAL_APP_SIGNATURE)


# Read at import so tests can flip it; every static route consults it first.
HAS_LOCAL_APP = detect_local_app(REPO_ROOT)


# ---------------------------------------------------------------------------
# Archicad transport
# ---------------------------------------------------------------------------

def ac_post(port, payload, timeout=AC_COMMAND_TIMEOUT):
    """POST to an Archicad JSON server. Returns (ok, parsedBodyOrErrorDict)."""
    request = urllib.request.Request(
        "http://127.0.0.1:{}".format(port),
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
    )
    try:
        with urllib.request.urlopen(request, timeout=timeout) as response:
            return True, json.loads(response.read().decode("utf-8"))
    except urllib.error.HTTPError as exc:
        return False, {"error": "archicad returned HTTP {}".format(exc.code), "port": port}
    except urllib.error.URLError as exc:
        return False, {"error": "cannot reach Archicad on port {}: {}".format(port, exc.reason),
                       "port": port}
    except socket.timeout:
        return False, {"error": "Archicad did not answer within {}s on port {}. Commands run on "
                                "its main thread — a long batch or a modal dialog will block."
                                .format(timeout, port), "port": port}
    except json.JSONDecodeError as exc:
        return False, {"error": "Archicad returned malformed JSON: {}".format(exc), "port": port}


def tapir_payload(command, parameters=None):
    """Tapir commands are tunnelled through the official ExecuteAddOnCommand."""
    return {
        "command": "API.ExecuteAddOnCommand",
        "parameters": {
            "addOnCommandId": {"commandNamespace": "TapirCommand", "commandName": command},
            "addOnCommandParameters": parameters or {},
        },
    }


def probe_instance(port):
    """Identify one Archicad instance, or return None if nothing useful is listening.

    Ports are probed by identity, never by position: an instance's port depends on launch order and
    is reused when an instance closes, so 19723 is not reliably "the first project".
    """
    ok, body = ac_post(port, {"command": "API.GetProductInfo", "parameters": {}},
                       timeout=AC_DISCOVERY_TIMEOUT)
    if not ok or not body.get("succeeded"):
        return None

    product = body.get("result", {})
    instance = {
        "port": port,
        "acVersion": product.get("version"),
        "buildNumber": product.get("buildNumber"),
        "projectName": None,
        "projectPath": None,
        "isTeamwork": None,
        "hasTapir": False,
        "tapirVersion": None,
    }

    ok, body = ac_post(port, tapir_payload("GetAddOnVersion"), timeout=AC_DISCOVERY_TIMEOUT)
    if ok and body.get("succeeded"):
        response = body.get("result", {}).get("addOnCommandResponse", {})
        if isinstance(response, dict) and "error" not in response:
            instance["hasTapir"] = True
            instance["tapirVersion"] = response.get("version")

    if instance["hasTapir"]:
        ok, body = ac_post(port, tapir_payload("GetProjectInfo"), timeout=AC_DISCOVERY_TIMEOUT)
        if ok and body.get("succeeded"):
            project = body.get("result", {}).get("addOnCommandResponse", {})
            if isinstance(project, dict) and "error" not in project:
                instance["projectName"] = project.get("projectName")
                instance["projectPath"] = project.get("projectPath")
                # Attribute writes fail in Teamwork unless the Attributes workspace is already
                # reserved, and there is no API to reserve it — so live mode must degrade to
                # read-only rather than fail halfway through a commit.
                for key in ("isTeamwork", "isTeamworkProject", "isShared"):
                    if key in project:
                        instance["isTeamwork"] = bool(project[key])
                        break
    return instance


def discover_instances():
    """Scan the documented port range concurrently — serially this would take ~30s."""
    ports = range(AC_PORT_FIRST, AC_PORT_LAST + 1)
    with ThreadPoolExecutor(max_workers=len(list(ports))) as pool:
        found = pool.map(probe_instance, range(AC_PORT_FIRST, AC_PORT_LAST + 1))
    return [item for item in found if item]


# ---------------------------------------------------------------------------
# Disk state
# ---------------------------------------------------------------------------

def ensure_state_dirs():
    os.makedirs(SNAPSHOT_DIR, exist_ok=True)


def read_settings():
    try:
        with open(SETTINGS_PATH, "r", encoding="utf-8") as handle:
            return json.load(handle)
    except (OSError, json.JSONDecodeError):
        return {}


def write_settings(settings):
    ensure_state_dirs()
    with open(SETTINGS_PATH, "w", encoding="utf-8") as handle:
        json.dump(settings, handle, indent=2)


def save_snapshot(body):
    """Persist a rollback artifact.

    Archicad has NO undo for attribute writes — Tapir calls ACAPI_Attribute_* unwrapped, matching
    Attribute Manager's own behaviour. The synthesized <AttributeFile> XML the app sends here is
    therefore the only honest recovery path: Attribute Manager -> Open -> Apply.
    """
    ensure_state_dirs()
    stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
    scope = re.sub(r"[^A-Za-z0-9_-]", "", str(body.get("scope", "scope")))[:40] or "scope"
    name = "{}-{}".format(stamp, scope)

    written = []
    if body.get("json") is not None:
        path = os.path.join(SNAPSHOT_DIR, name + ".json")
        with open(path, "w", encoding="utf-8") as handle:
            json.dump(body["json"], handle, indent=2)
        written.append(path)
    if body.get("xml"):
        path = os.path.join(SNAPSHOT_DIR, name + ".xml")
        with open(path, "w", encoding="utf-8") as handle:
            handle.write(body["xml"])
        written.append(path)
    return {"id": name, "files": written, "directory": SNAPSHOT_DIR}


def list_snapshots():
    ensure_state_dirs()
    items = []
    for entry in sorted(os.listdir(SNAPSHOT_DIR), reverse=True):
        path = os.path.join(SNAPSHOT_DIR, entry)
        if os.path.isfile(path):
            items.append({
                "name": entry,
                "bytes": os.path.getsize(path),
                "modified": os.path.getmtime(path),
            })
    return items


# ---------------------------------------------------------------------------
# Static assets
# ---------------------------------------------------------------------------

CDN_TAG_PATTERN = re.compile(
    r"[ \t]*<(?:link|script)\b[^>]*?(?:fonts\.googleapis\.com|fonts\.gstatic\.com|"
    r"vercel-scripts\.com)[^>]*?>(?:</script>)?[ \t]*\r?\n?",
    re.IGNORECASE,
)


def transform_index_html(source):
    """Make index.html safe to serve inside Archicad's embedded browser.

    Two problems, both invisible on the public Vercel build:

    1. The Google Fonts link and the two Vercel analytics scripts hang on a locked-down office
       network. css/styles.css already declares `'Inter', system-ui, -apple-system, 'Segoe UI'`,
       so dropping the webfont degrades to a system font rather than breaking layout.
    2. The architools.net logo link uses target="_top". In a docked palette that navigates the
       whole palette away from the app with no way back. _blank at worst does nothing.
    """
    html, removed = CDN_TAG_PATTERN.subn("", source)
    html = html.replace(
        '<a class="toolbar-logo" href="https://www.architools.net" target="_top"',
        '<a class="toolbar-logo" href="https://www.architools.net" target="_blank" rel="noopener"',
    )
    html = html.replace(
        "</head>",
        '    <meta name="archixml-runtime" content="bridge">\n</head>',
    )
    return html, removed


LAUNCHER_HTML = """<!doctype html>
<meta charset="utf-8">
<title>Starting archiXML</title>
<body style="margin:0;font:14px/1.5 system-ui,-apple-system,'Segoe UI',sans-serif;padding:32px;
             color:#444;min-height:{min_height}px;box-sizing:border-box">
<h1 style="font:600 20px system-ui;margin:0 0 12px">archiXML</h1>
<p id="msg">Loading the app&hellip;</p>
<div id="choices" hidden style="margin-top:20px">
  <p><a id="go-hosted" href="#">Load from architools.net</a></p>
  <p id="go-local-row" hidden><a id="go-local" href="#">Use the copy on this computer</a></p>
</div>
<script>
const CONFIG = {config};
const hosted = CONFIG.hostedUrl.replace(/\\/$/, '')
             + '/?bridge=' + CONFIG.uiPort + '&t=' + encodeURIComponent(CONFIG.token);
const local = '/?t=' + encodeURIComponent(CONFIG.token);
const msg = document.getElementById('msg');

document.getElementById('go-hosted').href = hosted;
document.getElementById('go-local').href = local;
if (CONFIG.hasLocal) document.getElementById('go-local-row').hidden = false;

// An opaque no-cors GET is enough to tell "the network answered" from "it did not", and it warms
// the cache for the navigation that follows. A captive portal can still answer for anything, which
// is why the manual links below stay on the page rather than being a dead end.
async function reachable(url) {{
    const stop = new AbortController();
    const timer = setTimeout(() => stop.abort(), 5000);
    try {{
        await fetch(url, {{ mode: 'no-cors', cache: 'no-store', signal: stop.signal }});
        return true;
    }} catch {{
        return false;
    }} finally {{
        clearTimeout(timer);
    }}
}}

(async () => {{
    if (CONFIG.mode === 'local') return location.replace(local);
    msg.textContent = 'Loading the app from architools.net\\u2026';
    if (await reachable(CONFIG.hostedUrl)) return location.replace(hosted);
    if (CONFIG.hasLocal && CONFIG.mode !== 'hosted') {{
        msg.textContent = 'architools.net could not be reached \\u2014 using the copy on this '
                        + 'computer.';
        return location.replace(local);
    }}
    msg.innerHTML = '<strong>architools.net could not be reached.</strong><br>'
                  + 'archiXML is served from there, and this bridge is a single file with no '
                  + 'local copy of the app. Check outbound access, or run the bridge from a full '
                  + 'archiXML checkout to serve the app locally.';
    document.getElementById('choices').hidden = false;
}})();
</script>
</body>"""


def render_launcher(server):
    """The page the palette lands on. Its whole job is deciding where the app comes from.

    It exists as a Python string rather than a file because the single-file tier has no files, and
    because a decision made in JS on a REAL origin is debuggable — the palette bootstrap runs in an
    opaque origin where console output is awkward to reach and a failure shows as a blank frame.
    """
    config = {
        "hostedUrl": server.hosted_url,
        "uiPort": server.server_address[1],
        "token": server.token,
        "hasLocal": HAS_LOCAL_APP,
        "mode": server.app_mode,
    }
    return LAUNCHER_HTML.format(config=json.dumps(config), min_height=PALETTE_HEIGHT)


def resolve_static(path):
    """Map a URL path to a file on disk, refusing anything outside the served allowlist."""
    if not HAS_LOCAL_APP:
        return None   # single-file tier: the app comes from architools.net, nothing is on disk
    clean = posixpath.normpath(urllib.parse.unquote(path.split("?", 1)[0]))
    if clean in ("/", "", "."):
        clean = "/index.html"
    relative = clean.lstrip("/")

    # normpath collapses ../ but a leading .. survives; reject it and anything absolute.
    if relative.startswith("..") or os.path.isabs(relative):
        return None

    top = relative.split("/", 1)[0]
    if "/" in relative:
        if top not in STATIC_ALLOWED_DIRS:
            return None
    elif relative not in STATIC_ALLOWED_ROOT_FILES:
        return None

    candidate = os.path.join(REPO_ROOT, *relative.split("/"))
    # Belt and braces: confirm the resolved path really is under the repo root.
    if os.path.commonpath([os.path.realpath(candidate), REPO_ROOT]) != REPO_ROOT:
        return None
    return candidate if os.path.isfile(candidate) else None


# ---------------------------------------------------------------------------
# HTTP handler
# ---------------------------------------------------------------------------

class BridgeHandler(BaseHTTPRequestHandler):
    server_version = "archixml-bridge/" + BRIDGE_VERSION
    protocol_version = "HTTP/1.1"

    def log_message(self, fmt, *args):
        if self.server.verbose:
            sys.stdout.write("  [http] {}\n".format(fmt % args))
            sys.stdout.flush()

    # --- plumbing ------------------------------------------------------

    def _origin_allowed(self):
        origin = self.headers.get("Origin")
        if not origin:
            return True, None  # same-origin or a non-browser client
        for pattern in ALLOWED_ORIGIN_PATTERNS:
            if pattern.match(origin):
                return True, origin
        return False, origin

    def _host_allowed(self):
        return bool(ALLOWED_HOST_PATTERN.match(self.headers.get("Host", "")))

    def _token_ok(self):
        supplied = self.headers.get("X-ArchiXML-Token")
        if not supplied:
            query = urllib.parse.urlparse(self.path).query
            supplied = urllib.parse.parse_qs(query).get("t", [None])[0]
        return bool(supplied) and secrets.compare_digest(supplied, self.server.token)

    def _send(self, code, payload, content_type="application/json", raw=False):
        # raw=True means payload is already a body (str or bytes). Without it a str payload would
        # be JSON-encoded, which silently turns served HTML into a quoted string.
        body = payload if raw else json.dumps(payload)
        if isinstance(body, str):
            body = body.encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        allowed, origin = self._origin_allowed()
        if allowed and origin:
            self.send_header("Access-Control-Allow-Origin", origin)
            self.send_header("Vary", "Origin")
            self.send_header("Access-Control-Allow-Headers", "Content-Type, X-ArchiXML-Token")
            self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
            # Needed only when the page is the public https build reaching into 127.0.0.1.
            if self.headers.get("Access-Control-Request-Private-Network") == "true":
                self.send_header("Access-Control-Allow-Private-Network", "true")
        self.end_headers()
        if self.command != "HEAD":
            self.wfile.write(body)

    def _read_json(self):
        length = int(self.headers.get("Content-Length", 0))
        if length <= 0:
            return {}
        try:
            return json.loads(self.rfile.read(length).decode("utf-8"))
        except json.JSONDecodeError as exc:
            raise ValueError("malformed JSON body: {}".format(exc))

    def _guard(self, needs_token=True):
        """Returns True when the request may proceed; otherwise responds and returns False."""
        if not self._host_allowed():
            self._send(403, {"error": "host not allowed"})
            return False
        allowed, origin = self._origin_allowed()
        if not allowed:
            self._send(403, {"error": "origin not allowed", "origin": origin})
            return False
        if needs_token and not self._token_ok():
            self._send(401, {"error": "missing or invalid token",
                             "hint": "open the URL printed in the Tapir Palette log — it carries "
                                     "?t=<token>"})
            return False
        return True

    # --- verbs ---------------------------------------------------------

    def do_OPTIONS(self):
        if not self._guard(needs_token=False):
            return
        self._send(204, b"", raw=True)

    def do_GET(self):
        route = urllib.parse.urlparse(self.path).path

        if route == "/health":
            if not self._guard(needs_token=False):
                return
            self._send(200, {
                "app": "archixml-bridge",
                "version": BRIDGE_VERSION,
                "protocol": PROTOCOL_VERSION,
                "acPort": self.server.ac_port,
            })
            return

        if route == "/ac/instances":
            if not self._guard():
                return
            self._send(200, {"instances": discover_instances(),
                             "launchedFrom": self.server.ac_port})
            return

        if route == "/fs/settings":
            if not self._guard():
                return
            self._send(200, read_settings())
            return

        if route == "/fs/snapshots":
            if not self._guard():
                return
            self._send(200, {"snapshots": list_snapshots(), "directory": SNAPSHOT_DIR})
            return

        if route == "/launch":
            # Gated like any other token route. It is handed the token by construction — the
            # palette bootstrap and the printed URL both carry it — and it passes that token on to
            # whichever copy of the app it chooses, so an ungated launcher would be a token oracle.
            if not self._guard():
                return
            self._send(200, render_launcher(self.server),
                       content_type="text/html; charset=utf-8", raw=True)
            return

        if route in ("/", "") and not HAS_LOCAL_APP:
            # Single-file tier: there is no index.html to serve, so / is the launcher.
            self.send_response(302)
            self.send_header("Location", "/launch" + (
                "?" + urllib.parse.urlparse(self.path).query
                if urllib.parse.urlparse(self.path).query else ""))
            self.send_header("Content-Length", "0")
            self.end_headers()
            return

        self._serve_static(route)

    def do_POST(self):
        route = urllib.parse.urlparse(self.path).path
        if not self._guard():
            return
        try:
            body = self._read_json()
        except ValueError as exc:
            self._send(400, {"error": str(exc)})
            return

        if route == "/ac/command":
            self._handle_command(body)
        elif route == "/ac/batch":
            self._handle_batch(body)
        elif route == "/open-external":
            self._handle_open_external(body)
        elif route == "/fs/snapshots":
            self._send(200, save_snapshot(body))
        else:
            self._send(404, {"error": "unknown route", "route": route})

    def do_PUT(self):
        route = urllib.parse.urlparse(self.path).path
        if not self._guard():
            return
        try:
            body = self._read_json()
        except ValueError as exc:
            self._send(400, {"error": str(exc)})
            return
        if route == "/fs/settings":
            write_settings(body)
            self._send(200, {"saved": True})
        else:
            self._send(404, {"error": "unknown route", "route": route})

    # --- handlers ------------------------------------------------------

    def _build_payload(self, call):
        """Accepts either an official command or a Tapir one.

        {"command": "API.GetProductInfo", "parameters": {...}}   official
        {"tapir": "GetLayers", "parameters": {...}}              add-on

        Both are needed: attribute FOLDERS exist only in the official API
        (GetAttributeFolderStructure, CreateAttributeFolders, ...) while every attribute WRITE
        exists only in Tapir. They share this socket, so it is a routing detail, not two transports.
        """
        if call.get("tapir"):
            return tapir_payload(call["tapir"], call.get("parameters"))
        if call.get("command"):
            return {"command": call["command"], "parameters": call.get("parameters") or {}}
        return None

    def _handle_command(self, body):
        port = int(body.get("port") or self.server.ac_port)
        payload = self._build_payload(body)
        if payload is None:
            self._send(400, {"error": "specify either 'command' (official) or 'tapir' (add-on)"})
            return
        timeout = float(body.get("timeout") or AC_COMMAND_TIMEOUT)
        started = time.time()
        ok, result = ac_post(port, payload, timeout=timeout)
        # The response is returned verbatim, both error layers intact. Unwrapping is
        # js/backends/live/tapir-client.js's job — the bridge must not swallow per-item failures.
        self._send(200 if ok else 502, {
            "ok": ok,
            "elapsedSeconds": round(time.time() - started, 3),
            "response": result,
        })

    def _handle_batch(self, body):
        """Run several commands in one round trip.

        Load-bearing for commit: a 300-item chunked write becomes ~6 round trips instead of 300,
        and stopOnError aborts the remainder without a browser round trip per chunk. Calls run
        sequentially — Archicad serializes them on its main thread regardless, and firing them
        concurrently only risks interleaving a partially-applied change set.
        """
        port = int(body.get("port") or self.server.ac_port)
        calls = body.get("calls") or []
        stop_on_error = bool(body.get("stopOnError", True))
        timeout = float(body.get("timeout") or AC_COMMAND_TIMEOUT)

        results = []
        aborted_after = None
        for position, call in enumerate(calls):
            payload = self._build_payload(call)
            if payload is None:
                results.append({"ok": False, "response": {"error": "malformed call"},
                                "label": call.get("label")})
                if stop_on_error:
                    aborted_after = position
                    break
                continue

            started = time.time()
            ok, result = ac_post(port, payload, timeout=timeout)
            results.append({
                "ok": ok,
                "label": call.get("label"),
                "elapsedSeconds": round(time.time() - started, 3),
                "response": result,
            })
            if not ok and stop_on_error:
                aborted_after = position
                break

        self._send(200, {
            "results": results,
            "requested": len(calls),
            "executed": len(results),
            "abortedAfter": aborted_after,
        })

    def _handle_open_external(self, body):
        """Open a URL in the user's real browser.

        Archicad's embedded browser has nowhere to send a normal link — target="_blank" simply
        does nothing there — so a click has to come back out to the host to be useful. Only
        allowlisted hosts are accepted; this is the one endpoint that acts outside the sandbox.
        """
        url = str(body.get("url", ""))
        if not any(pattern.match(url) for pattern in EXTERNAL_URL_ALLOWLIST):
            self._send(403, {"error": "URL not allowed", "url": url})
            return
        try:
            webbrowser.open(url)
        except Exception as exc:  # noqa: BLE001 - never take the bridge down over a link
            self._send(500, {"error": "could not open a browser: {}".format(exc)})
            return
        self._send(200, {"opened": url})

    def _serve_static(self, route):
        path = resolve_static(route)
        if not path:
            self._send(404, {"error": "not found", "route": route})
            return

        if os.path.basename(path) == "index.html":
            with open(path, "r", encoding="utf-8") as handle:
                html, removed = transform_index_html(handle.read())
            if removed and not self.server.reported_cdn_strip:
                self.server.reported_cdn_strip = True
                print("  stripped {} CDN tag(s) from index.html "
                      "(webfont + analytics; system font fallback applies)".format(removed))
            self._send(200, html, content_type="text/html; charset=utf-8", raw=True)
            return

        # Python's table predates woff2 on some installs. Browsers accept a font regardless of
        # its media type, but a wrong one trips strict corporate proxies and CSP setups.
        content_type = (FONT_TYPES.get(os.path.splitext(path)[1].lower())
                        or mimetypes.guess_type(path)[0] or "application/octet-stream")
        with open(path, "rb") as handle:
            self._send(200, handle.read(), content_type=content_type, raw=True)


# ---------------------------------------------------------------------------
# Docking
# ---------------------------------------------------------------------------

PALETTE_WIDTH = 1280
PALETTE_HEIGHT = 820


def dock_palette(ac_port, url):
    """Show the app in a native Archicad palette.

    ShowScriptUI's JS bridge is one-shot (SubmitResult / ClosePalette / ReportContentHeight, no
    RunCommand), so it cannot host a stateful app directly. Instead the palette loads a throwaway
    bootstrap whose only job is to hand control to /launch, which picks between the hosted app and
    a local copy and then hands that page the bridge's port and token. The palette is just a
    chrome-less frame.

    `autoHeight` is what makes the app's collapse/expand control possible: ReportContentHeight is
    ignored unless it is set (ScriptUIPalette.cpp gates the call on autoHeightEnabled). Setting it
    also makes Tapir inject a ResizeObserver into the bootstrap that reports body.scrollHeight
    after 100ms — which is why the bootstrap body carries an explicit min-height. Without it, a
    slow `location.replace` loses the race and the palette shrinks to the height of a two-line
    "Loading" message and stays there. The app's own document has no such observer, so once the
    bootstrap is gone the height changes only when the app asks for one.
    """
    bootstrap = (
        "<!doctype html><meta charset='utf-8'>"
        "<body style=\"margin:0;font:14px system-ui;padding:24px;color:#666;"
        "min-height:{height}px;box-sizing:border-box\">"
        "<p>Loading archiXML&hellip;</p>"
        "<p style=\"font-size:12px\">If this message stays, open "
        "<a href='{url}'>{url}</a> in a browser instead.</p>"
        "<script>location.replace({url_json});</script>"
        "</body>"
    ).format(url=url, url_json=json.dumps(url), height=PALETTE_HEIGHT)

    ok, body = ac_post(ac_port, tapir_payload("ShowScriptUI", {
        "htmlContent": bootstrap,
        "title": "archiXML",
        "width": PALETTE_WIDTH,
        "height": PALETTE_HEIGHT,
        "resizable": True,
        "scrollBarsVisible": True,
        "contextMenuEnabled": True,   # keeps DevTools available for debugging
        "navigationDisabled": False,  # required — the bootstrap navigates
        "autoHeight": True,           # required for the app's collapse control; see docstring
    }), timeout=15.0)

    if not ok:
        return False, body.get("error", "unknown error")
    if not body.get("succeeded"):
        return False, body.get("error", "transport rejected ShowScriptUI")
    response = body.get("result", {}).get("addOnCommandResponse", {})
    if isinstance(response, dict) and "error" in response:
        return False, response["error"]
    return True, None


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(description="archiXML Live — local bridge")
    parser.add_argument("--port", type=int, default=AC_PORT_FIRST,
                        help="Archicad JSON port (the Tapir Palette passes this automatically)")
    parser.add_argument("--ui-port", type=int, default=0,
                        help="port to serve the app on (0 = pick a free one)")
    parser.add_argument("--no-dock", action="store_true",
                        help="print the URL instead of opening an Archicad palette")
    parser.add_argument("--verbose", action="store_true", help="log every HTTP request")
    parser.add_argument("--token", default=None, help="override the generated launch token")
    parser.add_argument("--app", choices=("auto", "hosted", "local"), default="auto",
                        help="where the app comes from. auto = architools.net, falling back to a "
                             "local copy if one is present and the network is not")
    parser.add_argument("--hosted-url", default=HOSTED_APP_URL,
                        help="override the hosted app URL (for testing a preview deployment)")
    args = parser.parse_args()

    if args.app == "local" and not HAS_LOCAL_APP:
        print("  --app local, but no archiXML tree next to this script.")
        print("  A standalone archixml.py has no copy of the app; run it from a full checkout.")
        return 2

    hosted_url = args.hosted_url.rstrip("/")
    if hosted_url != HOSTED_APP_URL:
        if not hosted_url.startswith("https://"):
            print("  --hosted-url must be https — the page reaches into 127.0.0.1 and browsers "
                  "only trust loopback from a secure context.")
            return 2
        # The override is worthless without a matching origin allowlist entry: every /ac/* call
        # from that deployment would be a 403. This is an operator flag typed on the operator's own
        # machine, so it is honoured — loudly, and only for this run.
        allow_origin(hosted_url)

    ensure_state_dirs()
    token = args.token or secrets.token_urlsafe(24)

    server = ThreadingHTTPServer(("127.0.0.1", args.ui_port), BridgeHandler)
    server.token = token
    server.ac_port = args.port
    server.verbose = args.verbose
    server.reported_cdn_strip = False
    server.hosted_url = hosted_url
    server.app_mode = args.app
    ui_port = server.server_address[1]
    launch_url = "http://127.0.0.1:{}/launch?t={}".format(ui_port, token)
    hosted_app_url = "{}/?bridge={}&t={}".format(hosted_url, ui_port, token)
    local_app_url = "http://127.0.0.1:{}/?t={}".format(ui_port, token)

    print("archiXML bridge {} (protocol {})".format(BRIDGE_VERSION, PROTOCOL_VERSION))
    print("  app       {}{}".format(
        hosted_url,
        "" if args.app == "hosted" else
        (" (local copy available as a fallback)" if HAS_LOCAL_APP else " (no local copy: this is "
         "a single-file bridge)")))
    if args.app == "local":
        print("            forced to the local copy in {}".format(REPO_ROOT))
    if hosted_url != HOSTED_APP_URL:
        print("  WARNING: --hosted-url overridden; {} has been added to the origin allowlist for "
              "this run.".format(hosted_url))
    print("  archicad  port {}".format(args.port))
    print("  state     {}".format(STATE_DIR))

    instance = probe_instance(args.port)
    if not instance:
        print("\n  WARNING: nothing answered on port {}. Is a project open?".format(args.port))
    else:
        print("  project   {} (Archicad {})".format(
            instance.get("projectName") or "<unnamed>", instance.get("acVersion")))
        if not instance.get("hasTapir"):
            print("  WARNING: the Tapir add-on did not respond. Live mode needs Tapir 1.5.4+;")
            print("           without it only the official read-only commands are available.")
        if instance.get("isTeamwork"):
            print("  NOTE: Teamwork project. There is no API to reserve the Attributes workspace,")
            print("        so attribute writes will fail unless it is already reserved by you.")

    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()

    # The token is what the whole security model rests on, so these URLs are the only way in.
    # Nothing scans, nothing guesses: the page is TOLD which port to talk to and with what token,
    # and both come from this log on this machine.
    if args.app != "local":
        print("\n  URL      {}".format(hosted_app_url))
        print("           (the app, from architools.net, paired with this bridge)")
    if HAS_LOCAL_APP and args.app != "hosted":
        print("\n  local    {}".format(local_app_url))
        print("           (the copy in {}, for when architools.net is unreachable)"
              .format(REPO_ROOT))
        # Printed because the harness is otherwise unfindable: opening tests/run.html off disk
        # gives a file:// origin where ES modules will not load at all.
        print("  harness  http://127.0.0.1:{}/tests/run.html".format(ui_port))
        print("           (no token; static files are not gated)")
    print("\n           The token is required and is new on every launch. Bookmarking a URL above")
    print("           is fine; sharing one hands over your open project.")

    if args.no_dock:
        print("\n  --no-dock: open a URL above in Chrome, Edge or Firefox.")
        print("             Safari blocks an https page from reaching 127.0.0.1, so the hosted")
        print("             URL cannot work there. Use the local one, or another browser.")
    else:
        docked, error = dock_palette(args.port, launch_url)
        if docked:
            print("\n  Docked in an Archicad palette. Close the palette to hide it;")
            print("  the bridge keeps running until you stop this script.")
        else:
            print("\n  Could not dock the palette: {}".format(error))
            print("  Open a URL above in a browser instead — nothing else changes.")

    print("\n  Press Ctrl+C to stop.\n")
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        print("\n  Stopping.")
        server.shutdown()
        server.server_close()
    return 0


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