#!/usr/bin/env python3
"""pharosiraptor-local-bridge — expose local GraphDoc via Cloudflare Tunnel and register with intrikata.

Automatic path for https://intrikata.com console to use a machine-local Pharosiraptor
without mixed-content (HTTPS page cannot call http://localhost). Flow:

  1. Verify GraphDoc at http://127.0.0.1:8080/api/health
  2. Start `cloudflared tunnel --url http://127.0.0.1:8080` (quick tunnel)
  3. POST tunnel URL to https://intrikata.com/api/local-bridge/register
  4. Open console with ?lb_session=… so the browser auto-connects via edge proxy

Stdlib only (+ cloudflared on PATH). Python 3.9+.

  py pharosiraptor-local-bridge.py
  py pharosiraptor-local-bridge.py --session <id-from-console>
  py pharosiraptor-local-bridge.py --port 8080 --base https://intrikata.com
  py pharosiraptor-local-bridge.py --upstream-only   # print tunnel URL only (no register)
"""
from __future__ import annotations

import argparse
import json
import os
import re
import secrets
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
import webbrowser

DEFAULT_ORIGIN = "http://127.0.0.1:8080"
DEFAULT_BASE = os.environ.get("INTRIKATA_BASE", "https://intrikata.com").rstrip("/")
TUNNEL_URL_RE = re.compile(
    r"https://[a-z0-9-]+\.trycloudflare\.com",
    re.I,
)


def http_json(url: str, method: str = "GET", body: dict | None = None, timeout: float = 8.0):
    data = None
    headers = {"User-Agent": "intrikata-pharosiraptor-local-bridge/1.0", "Accept": "application/json"}
    if body is not None:
        data = json.dumps(body).encode("utf-8")
        headers["Content-Type"] = "application/json"
    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read().decode("utf-8"))


def check_graphdoc(origin: str) -> dict:
    url = origin.rstrip("/") + "/api/health"
    try:
        j = http_json(url, timeout=3.0)
    except Exception as e:
        sys.exit(f"intrikata-bridge: GraphDoc not healthy at {url}: {e}")
    if j.get("status") not in ("healthy", "ok"):
        sys.exit(f"intrikata-bridge: unexpected health: {j}")
    print(f"graphdoc     ok  {url}  version={j.get('version', '?')}")
    return j


def find_cloudflared() -> str:
    path = shutil.which("cloudflared")
    if path:
        return path
    # Windows default install path
    candidates = [
        r"C:\Program Files (x86)\cloudflared\cloudflared.exe",
        r"C:\Program Files\cloudflared\cloudflared.exe",
    ]
    for c in candidates:
        if os.path.isfile(c):
            return c
    sys.exit(
        "intrikata-bridge: cloudflared not found on PATH. "
        "Install: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/"
    )


def start_tunnel(cloudflared: str, origin: str) -> tuple[subprocess.Popen, str]:
    # Quick tunnel — no account login required for trycloudflare.com
    cmd = [cloudflared, "tunnel", "--url", origin, "--no-autoupdate"]
    print(f"cloudflared  starting  {' '.join(cmd)}")
    proc = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        bufsize=1,
        encoding="utf-8",
        errors="replace",
    )
    tunnel_url = None
    deadline = time.time() + 45
    assert proc.stdout is not None
    while time.time() < deadline:
        line = proc.stdout.readline()
        if not line and proc.poll() is not None:
            break
        if line:
            sys.stdout.write("  | " + line)
            sys.stdout.flush()
            m = TUNNEL_URL_RE.search(line)
            if m:
                tunnel_url = m.group(0)
                break
    if not tunnel_url:
        proc.terminate()
        sys.exit("intrikata-bridge: timed out waiting for trycloudflare.com URL")
    print(f"tunnel       {tunnel_url}")
    return proc, tunnel_url


def negotiate_transport_mtls(tunnel_url: str, args: argparse.Namespace) -> dict:
    """Attempt full transport mTLS; return capability hint for edge register.

    Ladder (first success wins; else degrade to app-layer):
      1. Detect quick tunnel (trycloudflare.com) → cannot Access-mTLS
      2. Named tunnel + CF Access mTLS env hints
      3. Explicit --mtls-cert/--mtls-key (local client cert material for future Access)
      4. Degrade to app-layer challenge-response device cert factor
    """
    attempts: list[dict] = []
    host = ""
    try:
        from urllib.parse import urlparse

        host = urlparse(tunnel_url).hostname or ""
    except Exception:
        host = ""

    is_quick = host.endswith(".trycloudflare.com")
    attempts.append(
        {
            "step": "classify-upstream",
            "host": host,
            "kind": "quick-tunnel" if is_quick else "named-or-custom",
            "ok": not is_quick,
            "residual": (
                "trycloudflare.com quick tunnels do not support Cloudflare Access mTLS "
                "or custom client-certificate policies"
                if is_quick
                else None
            ),
        }
    )

    # Explicit cert paths from env / flags (material for Access or local tooling)
    cert = getattr(args, "mtls_cert", None) or os.environ.get("INTRIKATA_MTLS_CERT", "")
    key = getattr(args, "mtls_key", None) or os.environ.get("INTRIKATA_MTLS_KEY", "")
    access_enabled = bool(
        os.environ.get("CF_ACCESS_MTLS")
        or os.environ.get("INTRIKATA_ACCESS_MTLS")
        or getattr(args, "access_mtls", False)
    )
    has_material = bool(cert and key and os.path.isfile(cert) and os.path.isfile(key))
    attempts.append(
        {
            "step": "local-client-cert-material",
            "ok": has_material,
            "residual": None if has_material else "no --mtls-cert/--mtls-key (or INTRIKATA_MTLS_*) on disk",
            "cert": cert if has_material else None,
        }
    )
    attempts.append(
        {
            "step": "access-mtls-flag",
            "ok": access_enabled,
            "residual": None
            if access_enabled
            else "CF_ACCESS_MTLS / --access-mtls not set (named tunnel + Zero Trust Access mTLS)",
        }
    )

    # Probe: can we speak plain HTTPS to the tunnel public URL? (no client cert)
    plain_ok = False
    try:
        hj = http_json(tunnel_url.rstrip("/") + "/api/health", timeout=6.0)
        plain_ok = hj.get("status") in ("healthy", "ok")
    except Exception as e:
        attempts.append(
            {
                "step": "public-health-without-client-cert",
                "ok": False,
                "residual": f"plain HTTPS probe failed: {e}",
            }
        )
    else:
        attempts.append(
            {
                "step": "public-health-without-client-cert",
                "ok": plain_ok,
                "residual": (
                    "public tunnel accepts requests without client certificate "
                    "(origin not enforcing mTLS on the public hostname)"
                    if plain_ok
                    else "public health not ok without client cert"
                ),
            }
        )

    # Decision
    if is_quick:
        mode, transport, residual = (
            "app-layer",
            False,
            "degraded: quick tunnel — app-layer ECDSA device cert factor only",
        )
    elif access_enabled and not is_quick:
        mode, transport, residual = (
            "transport-bridge-declared",
            True,
            "bridge declared Access mTLS; edge honors Cf-Client-Cert-* when present, else app-layer",
        )
    elif has_material and not is_quick:
        mode, transport, residual = (
            "transport-bridge-declared",
            True,
            "client cert material present for Access/named path; edge degrades if headers absent",
        )
    else:
        mode, transport, residual = (
            "app-layer",
            False,
            "degraded: no Access mTLS / cert material — app-layer device cert factor available via link-mtls",
        )

    result = {
        "mode": mode,
        "transport_mtls": transport,
        "transport": transport,
        "access_mtls": access_enabled,
        "residual": residual,
        "attempts": attempts,
        "fallback": None if transport else "app-layer-challenge-response",
        "quick_tunnel": is_quick,
        "host": host,
    }
    print(f"mtls         mode={mode}  transport={transport}")
    if residual:
        print(f"mtls         residual: {residual}")
    return result


def register(base: str, session_id: str, upstream: str, mtls: dict | None = None) -> dict:
    url = base + "/api/local-bridge/register"
    body = {"session_id": session_id, "upstream": upstream}
    if mtls:
        body["mtls"] = mtls
    try:
        return http_json(url, method="POST", body=body, timeout=20.0)
    except urllib.error.HTTPError as e:
        err = e.read().decode("utf-8", errors="replace")
        sys.exit(f"intrikata-bridge: register failed HTTP {e.code}: {err}")
    except Exception as e:
        sys.exit(f"intrikata-bridge: register failed: {e}")


def main() -> None:
    ap = argparse.ArgumentParser(description="Bridge local GraphDoc to intrikata.com via Cloudflare Tunnel")
    ap.add_argument("--origin", default=DEFAULT_ORIGIN, help="local GraphDoc origin (default http://127.0.0.1:8080)")
    ap.add_argument("--base", default=DEFAULT_BASE, help="intrikata site base URL")
    ap.add_argument("--session", default=os.environ.get("INTRIKATA_BRIDGE_SESSION", ""), help="browser session_id")
    ap.add_argument("--no-open", action="store_true", help="do not open browser")
    ap.add_argument("--upstream-only", action="store_true", help="print tunnel URL and keep tunnel; skip register")
    ap.add_argument("--keep", action="store_true", help="keep tunnel running (default: run until Ctrl+C)")
    ap.add_argument(
        "--mtls-cert",
        default=os.environ.get("INTRIKATA_MTLS_CERT", ""),
        help="optional client cert PEM for Access/transport mTLS negotiation",
    )
    ap.add_argument(
        "--mtls-key",
        default=os.environ.get("INTRIKATA_MTLS_KEY", ""),
        help="optional client key PEM for Access/transport mTLS negotiation",
    )
    ap.add_argument(
        "--access-mtls",
        action="store_true",
        help="declare Cloudflare Access mTLS enabled for this named tunnel hostname",
    )
    args = ap.parse_args()

    check_graphdoc(args.origin)
    cf = find_cloudflared()
    print(f"cloudflared  {cf}")

    session_id = args.session.strip() or secrets.token_urlsafe(18)
    print(f"session      {session_id}")

    proc, tunnel_url = start_tunnel(cf, args.origin.rstrip("/"))

    try:
        # Wait until the public tunnel hostname actually reaches GraphDoc
        # (register's edge health-check fails if we POST too early → CF 502).
        print("tunnel       waiting for public health…")
        ready = False
        for _ in range(30):
            try:
                hj = http_json(tunnel_url.rstrip("/") + "/api/health", timeout=6.0)
                if hj.get("status") in ("healthy", "ok"):
                    ready = True
                    print(f"tunnel       public health ok  version={hj.get('version', '?')}")
                    break
            except Exception:
                pass
            time.sleep(1.5)
        if not ready:
            print("tunnel       WARN public health not confirmed; register may 502 — retry script if so")

        mtls_hint = negotiate_transport_mtls(tunnel_url, args)

        if args.upstream_only:
            print(f"\nupstream-only mode — set console proxy:\n  proxy set {tunnel_url}")
            print(f"or open:\n  {args.base}/?proxy={tunnel_url}")
            print(f"mtls hint    {json.dumps({k: mtls_hint[k] for k in ('mode', 'transport', 'residual')})}")
        else:
            reg = None
            last_err = None
            for attempt in range(1, 6):
                try:
                    reg = register(args.base, session_id, tunnel_url, mtls=mtls_hint)
                    break
                except SystemExit as e:
                    last_err = e
                    print(f"registered   retry {attempt}/5 after: {e}")
                    time.sleep(3 * attempt)
            if reg is None:
                raise last_err if last_err else SystemExit("register failed")
            print(f"registered   ok  expires_in={reg.get('expires_in_seconds')}s")
            edge_mtls = reg.get("mtls") or {}
            print(
                f"mtls edge    mode={edge_mtls.get('mode')}  transport={edge_mtls.get('transport')}  "
                f"fallback={edge_mtls.get('fallback')}"
            )
            if edge_mtls.get("residual"):
                print(f"mtls edge    residual: {edge_mtls.get('residual')}")
            print(f"edge health  {args.base}/api/local-bridge/health?session_id={session_id}")
            open_url = f"{args.base}/?lb_session={session_id}&proxy={tunnel_url}"
            print(f"console      {open_url}")
            if not args.no_open:
                webbrowser.open(open_url)

        print("\nbridge live — leave this process running; Ctrl+C to stop tunnel")
        # Drain remaining cloudflared logs
        assert proc.stdout is not None
        while True:
            line = proc.stdout.readline()
            if not line and proc.poll() is not None:
                break
            if line:
                sys.stdout.write("  | " + line)
                sys.stdout.flush()
            if proc.poll() is not None:
                break
            time.sleep(0.05)
    except KeyboardInterrupt:
        print("\nstopping tunnel…")
    finally:
        proc.terminate()
        try:
            proc.wait(timeout=5)
        except subprocess.TimeoutExpired:
            proc.kill()


if __name__ == "__main__":
    main()
