#!/usr/bin/env python3
"""
server.py — serves the static demo files AND proxies /api/* to Pacdora's
REST API, attaching the secret x-pacdora-appid / x-pacdora-appkey headers
server-side so they never reach the browser.

Why this exists: Pacdora's docs are explicit that the App Key used for
authenticated REST calls (as opposed to the public-facing editor appKey)
must never appear in frontend JavaScript. Since this whole project so far
has been static files with no backend, this script is the smallest
possible backend that fixes that — stdlib only, no dependencies to install.

SECURITY MODEL (for local-app deployment — see chat notes for the full
reasoning): this is designed to be launched as a CHILD PROCESS of a
native program, which opens a browser pointed at it afterward. The
native launcher passes credentials via environment variables (not files,
not command-line args — env vars of a spawned child process aren't
visible to other processes the way argv often is). Two protections beyond
"just don't put secrets in frontend JS":
  1. Binds to 127.0.0.1 only — other machines on the network can't reach it.
  2. Every /api/* request must carry a per-session x-session-token header,
     because loopback binding alone doesn't stop OTHER BROWSER TABS on the
     same machine from calling this proxy — browsers don't block
     cross-origin requests to localhost by default. The native launcher
     should generate a fresh random token each run (PACDORA_SESSION_TOKEN)
     so no other page can guess it and ride on this proxy's secret key.

Run:
    cd api-demo/src
    python server.py

Then open:
    http://127.0.0.1:8080/static/index.html

Configure credentials via environment variables (do NOT hardcode them
in this file if you intend to commit it anywhere). This is the ONE place
you need to paste real values — every .html file and main.js reference
{{PACDORA_APP_ID}} / {{PACDORA_EDITOR_APP_KEY}} / {{PACDORA_SESSION_TOKEN}}
placeholder tokens, which this server substitutes with the real values
on every request:

    Windows (cmd):
        set PACDORA_APP_ID=your_app_id
        set PACDORA_APP_KEY=your_app_key
        python server.py

    macOS/Linux:
        export PACDORA_APP_ID=your_app_id
        export PACDORA_APP_KEY=your_app_key
        python server.py

    PACDORA_SESSION_TOKEN is optional for manual testing — if unset, a
    random one is generated at startup and printed. The native launcher
    should set it explicitly, generating a fresh value per run.

    PACDORA_EDITOR_APP_KEY is also optional — if you only have one App
    Key from Pacdora (not a separate editor-specific one), leave it unset
    and it reuses PACDORA_APP_KEY automatically.

Credentials, don't mix up PACDORA_APP_KEY's two possible uses:
    PACDORA_APP_ID          public — identifies your account (script tag,
                             Pacdora.init, and the x-pacdora-appid REST header)
    PACDORA_APP_KEY         used two ways: (a) SECRET, attached server-side
                             to REST calls via x-pacdora-appkey, never sent
                             to the browser; (b) also reused client-side in
                             data-app-key attributes UNLESS you set
                             PACDORA_EDITOR_APP_KEY to something different —
                             worth confirming with Pacdora whether these
                             should really be the same value for your account
    PACDORA_EDITOR_APP_KEY  optional override if Pacdora gives you a
                             separate, distinct editor-facing key
    PACDORA_SESSION_TOKEN   per-run secret — gates access to THIS proxy
                             itself, protecting against other browser tabs

Test the proxy is wired correctly (once you know a real endpoint path
from your Postman collection / apidoc.pacdora.com) — from the browser
console on a page this script served (so SESSION_TOKEN is in scope):
    apiFetch('/api/whatever/path').then(r => r.json()).then(console.log)
"""

import json
import os
import secrets
import sys
import urllib.request
import urllib.error
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer

# ---- configuration ----------------------------------------------------

PORT = int(os.environ.get("PORT", "8080"))

# Loopback only. Binding to 0.0.0.0 would also accept connections from
# other machines on the same network — for a locally-launched tool, only
# the local machine should ever be able to reach this at all.
BIND_HOST = "0.0.0.0"

# The base URL of Pacdora's REST API. Unconfirmed from public docs —
# check your Postman collection / apidoc.pacdora.com for the real host
# if this isn't it, and update here.
PACDORA_API_BASE = os.environ.get("PACDORA_API_BASE", "https://api.pacdora.com")

PACDORA_APP_ID = os.environ.get("PACDORA_APP_ID", "")
PACDORA_APP_KEY = os.environ.get("PACDORA_APP_KEY", "")

# The public-facing "editor" app key — used in data-app-key attributes and
# Pacdora.init's appKey field. Pacdora's docs show this in their own
# example HTML with the literal same "Your app Key" placeholder as the
# Getting Started guide's single App Key — for most accounts this is
# probably the SAME value as PACDORA_APP_KEY, not a separate credential.
# If you only have one App ID + one App Key from Pacdora (no distinct
# "editor key"), leave PACDORA_EDITOR_APP_KEY unset and it defaults to
# PACDORA_APP_KEY below.
#
# Worth confirming with Pacdora directly: if this key can also authenticate
# full REST calls, putting it in client-side HTML (as their own example
# does) means anyone viewing page source has it. Ask whether it's meant to
# be domain-restricted, or scoped differently from the REST-only use.
PACDORA_EDITOR_APP_KEY = os.environ.get("PACDORA_EDITOR_APP_KEY") or PACDORA_APP_KEY

# ---- session token --------------------------------------------------
# Binding to 127.0.0.1 stops OTHER MACHINES from reaching this server, but
# it does NOT stop other tabs/sites open in the SAME browser from making a
# request to http://127.0.0.1:PORT/api/... — browsers don't block
# cross-origin requests to localhost by default. A malicious or merely
# unrelated page open at the same time could silently piggyback on this
# proxy and the secret App Key it holds.
#
# Fix: every /api/* request must carry a per-session token in a custom
# header. The native launcher should generate a fresh random token each
# run and pass it in via PACDORA_SESSION_TOKEN — same mechanism as the
# other credentials. The token gets templated into main.js (like appId)
# so our own page can attach it automatically; a page from any other
# origin has no way to know it.
#
# If PACDORA_SESSION_TOKEN isn't set (e.g. running this standalone for
# manual testing, not yet wired to the native launcher), one is generated
# here and printed at startup so testing still works without extra setup.
PACDORA_SESSION_TOKEN = os.environ.get("PACDORA_SESSION_TOKEN") or secrets.token_urlsafe(24)
_SESSION_TOKEN_WAS_PROVIDED = bool(os.environ.get("PACDORA_SESSION_TOKEN"))

# Serve files from ./static (so URLs match what detail.html/index.html
# already expect: /static/index.html, /static/css/style.css, etc.)
DOCUMENT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".")

# Placeholder tokens in .html/.js files, substituted with the real values
# above on every request. This is the single source of truth this file
# exists to provide — set the env vars once, and every file that
# references these {{...}} tokens picks them up automatically. No more
# hunting through multiple files for a missed paste.
TEMPLATE_VALUES = {
    "{{PACDORA_APP_ID}}": PACDORA_APP_ID or "YOUR_APP_ID",
    "{{PACDORA_EDITOR_APP_KEY}}": PACDORA_EDITOR_APP_KEY or "YOUR_APP_KEY",
    "{{PACDORA_SESSION_TOKEN}}": PACDORA_SESSION_TOKEN,
}

if not PACDORA_APP_ID or not PACDORA_APP_KEY:
    print("WARNING: PACDORA_APP_ID / PACDORA_APP_KEY are not set.", file=sys.stderr)
    print("         /api/* requests will be forwarded without auth headers", file=sys.stderr)
    print("         and will likely fail. Set them as environment variables", file=sys.stderr)
    print("         before starting the server — see the top of server.py.", file=sys.stderr)
elif not os.environ.get("PACDORA_EDITOR_APP_KEY"):
    print("NOTE: PACDORA_EDITOR_APP_KEY not set — reusing PACDORA_APP_KEY for", file=sys.stderr)
    print("      the editor widget too. If Pacdora ever issues you a separate", file=sys.stderr)
    print("      editor-specific key, set PACDORA_EDITOR_APP_KEY explicitly.", file=sys.stderr)
if not _SESSION_TOKEN_WAS_PROVIDED:
    print("NOTE: PACDORA_SESSION_TOKEN not set — generated one for this run.", file=sys.stderr)
    print("      When wired up to the native launcher, it should generate", file=sys.stderr)
    print("      and pass a fresh one via env var on every launch instead.", file=sys.stderr)


# ---- request handler ---------------------------------------------------

class ProxyHandler(SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=DOCUMENT_ROOT, **kwargs)

    def log_message(self, fmt, *args):
        # quieter, timestamped one-liner instead of the default verbose log
        sys.stderr.write("[server] " + (fmt % args) + "\n")

    def do_GET(self):
        if self.path.startswith("/api/"):
            self._proxy("GET")
        elif self._is_templated(self.path):
            self._serve_templated()
        else:
            super().do_GET()

    def do_POST(self):
        if self.path.startswith("/api/"):
            self._proxy("POST")
        elif self.path.startswith("/local/event"):
            self._local_event()
        else:
            self.send_error(405, "POST not supported on static files")

    def do_PUT(self):
        if self.path.startswith("/api/"):
            self._proxy("PUT")
        else:
            self.send_error(405, "PUT not supported on static files")

    def do_DELETE(self):
        if self.path.startswith("/api/"):
            self._proxy("DELETE")
        else:
            self.send_error(405, "DELETE not supported on static files")

    def do_OPTIONS(self):
        # simple same-origin preflight support, harmless if unused
        self.send_response(204)
        self.send_header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "content-type,x-session-token")
        self.end_headers()

    def _is_templated(self, path):
        # Only .html files and main.js carry {{...}} placeholder tokens.
        # Everything else (css, images) is served as-is, untouched.
        path_only = path.split("?", 1)[0]
        return path_only.endswith(".html") or path_only.endswith("main.js")

    def _serve_templated(self):
        path_only = self.path.split("?", 1)[0]
        fs_path = self.translate_path(path_only)
        if not os.path.isfile(fs_path):
            self.send_error(404, "File not found")
            return

        with open(fs_path, "r", encoding="utf-8") as f:
            content = f.read()
        for token, value in TEMPLATE_VALUES.items():
            content = content.replace(token, value)
        body = content.encode("utf-8")

        self.send_response(200)
        content_type = "text/html" if path_only.endswith(".html") else "application/javascript"
        self.send_header("Content-Type", content_type + "; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        # keep browsers from caching a stale credential-substituted copy
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def _local_event(self):
        # Bridge for the native launcher: the page has no direct way to
        # call back into the process that spawned this server (it's a
        # normal browser tab, not an embedded webview with a JS<->native
        # bridge), so it POSTs here instead, and this prints one
        # machine-readable line to STDOUT. Since the native launcher
        # already owns this process (it spawned it), it can just read
        # this process's stdout and watch for lines starting with
        # "PACDORA_EVENT ". Session-token gated like /api/*, for the
        # same reason — otherwise any other tab could spam fake events.
        if self.headers.get("x-session-token") != PACDORA_SESSION_TOKEN:
            self._send_json(403, {"error": "invalid_or_missing_session_token"})
            return
        length = int(self.headers.get("Content-Length", 0) or 0)
        body = self.rfile.read(length) if length else b"{}"
        try:
            payload = json.loads(body)
        except json.JSONDecodeError:
            payload = {"raw": body.decode("utf-8", "replace")}
        # flush=True so the launcher sees it immediately, not buffered
        print("PACDORA_EVENT " + json.dumps(payload), flush=True)
        self._send_json(200, {"ok": True})

    def _proxy(self, method):
        if self.headers.get("x-session-token") != PACDORA_SESSION_TOKEN:
            self._send_json(403, {"error": "invalid_or_missing_session_token"})
            return

        # /api/models/123  ->  {PACDORA_API_BASE}/models/123
        upstream_path = self.path[len("/api"):]
        url = PACDORA_API_BASE.rstrip("/") + upstream_path

        length = int(self.headers.get("Content-Length", 0) or 0)
        body = self.rfile.read(length) if length else None

        headers = {
            "content-type": "application/json",
            "x-pacdora-appid": PACDORA_APP_ID,
            "x-pacdora-appkey": PACDORA_APP_KEY,
        }

        req = urllib.request.Request(url, data=body, headers=headers, method=method)

        try:
            with urllib.request.urlopen(req, timeout=15) as resp:
                self._send_upstream_response(resp.status, resp.read(), dict(resp.headers))
        except urllib.error.HTTPError as e:
            # upstream responded with a non-2xx — forward it as-is so the
            # browser sees Pacdora's real error code/message, not a generic 500
            self._send_upstream_response(e.code, e.read(), dict(e.headers or {}))
        except urllib.error.URLError as e:
            self._send_json(502, {"error": "proxy_failed", "detail": str(e.reason)})

    def _send_upstream_response(self, status, body, upstream_headers):
        self.send_response(status)
        content_type = upstream_headers.get("content-type", "application/json")
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        # forward Pacdora's trace id if present, useful for their support
        if "x-trace-id" in upstream_headers:
            self.send_header("x-trace-id", upstream_headers["x-trace-id"])
        self.end_headers()
        self.wfile.write(body)

    def _send_json(self, status, obj):
        body = json.dumps(obj).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


def main():
    server = ThreadingHTTPServer((BIND_HOST, PORT), ProxyHandler)
    print(f"Serving {DOCUMENT_ROOT} on http://{BIND_HOST}:{PORT}  (loopback only)")
    print(f"Open:    http://{BIND_HOST}:{PORT}/static/index.html")
    print(f"Proxy:   http://{BIND_HOST}:{PORT}/api/*  ->  {PACDORA_API_BASE}/*  (session-token gated)")
    print(f"Credentials templated into .html/main.js: "
          f"appId={'set' if PACDORA_APP_ID else 'MISSING'}, "
          f"editorAppKey={'set' if PACDORA_EDITOR_APP_KEY else 'MISSING'}, "
          f"sessionToken={'from env' if _SESSION_TOKEN_WAS_PROVIDED else 'auto-generated'}")
    print("Ctrl+C to stop.")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nStopping.")
        server.shutdown()


if __name__ == "__main__":
    main()
