#!/usr/bin/env python3
# ============================================================
# Cadence — tiny local backend
#
# Gives the standalone Cadence HTML a REAL "Sign in with ChatGPT
# (Codex)" login and proxies model calls to OpenAI, so the browser
# never sees CORS and the tokens stay on your machine.
#
# Pure Python standard library — nothing to pip install.
#   python server.py
# then open http://localhost:1455/  (it opens automatically).
#
# Two ways your account can drive a model, tried in this order:
#   1. API-key mode  — after login we token-exchange your id_token
#      for a real OpenAI API key and call api.openai.com. Stable and
#      documented. Bills your OpenAI *API* account (platform credits).
#   2. ChatGPT mode  — if no API key is available (e.g. a Plus/Pro
#      account with no API credits) we fall back to the same backend
#      Codex itself uses: chatgpt.com/backend-api/codex/responses,
#      billed to your ChatGPT subscription. Best-effort: OpenAI does
#      not document this endpoint and may change it.
# ============================================================

import base64
import hashlib
import io
import json
import os
import re
import secrets
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

# ---------- OAuth / Codex constants (from openai/codex source) ----------
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
AUTH_BASE = "https://auth.openai.com"
AUTHORIZE_URL = AUTH_BASE + "/oauth/authorize"
TOKEN_URL = AUTH_BASE + "/oauth/token"
PORT = 1455
REDIRECT_URI = f"http://localhost:{PORT}/auth/callback"
SCOPE = "openid profile email offline_access"
ORIGINATOR = "codex_cli_rs"

OPENAI_API_BASE = "https://api.openai.com/v1"
CHATGPT_BACKEND = "https://chatgpt.com/backend-api/codex"

def _resource_dir():
    """Where bundled read-only assets (cadence.html) live."""
    if getattr(sys, "frozen", False):  # running from a PyInstaller build
        return getattr(sys, "_MEIPASS", os.path.dirname(sys.executable))
    return os.path.dirname(os.path.abspath(__file__))


def _data_dir():
    """Per-user, writable directory for the saved login. This is PER MACHINE /
    PER USER and is never part of the shipped package, so every recipient signs
    in with their own ChatGPT account and no tokens travel with the app."""
    if os.name == "nt":
        base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
    elif sys.platform == "darwin":
        base = os.path.join(os.path.expanduser("~"), "Library", "Application Support")
    else:
        base = os.environ.get("XDG_DATA_HOME") or os.path.join(os.path.expanduser("~"), ".local", "share")
    d = os.path.join(base, "Cadence")
    try:
        os.makedirs(d, exist_ok=True)
    except Exception:
        d = os.path.dirname(os.path.abspath(__file__))
    return d


HERE = _resource_dir()
HTML_PATH = os.path.join(HERE, "cadence.html")
AUTH_FILE = os.path.join(_data_dir(), "auth.json")

# Models a ChatGPT / Codex sign-in account can select (June 2026 lineup).
# Used for ChatGPT-subscription mode and as a fallback; in API-key mode the
# live list from /v1/models is used instead so it never goes stale.
# Best models for this rewrite/translate task, fastest first. The *-codex models
# are coding-tuned (worse for prose, slower) and gpt-5.4-nano is rejected on a
# ChatGPT subscription, so they are intentionally excluded.
CURATED_MODELS = ["gpt-5.4-mini", "gpt-5.4", "gpt-5.5"]
# Default: gpt-5.4-mini — fastest with near-flagship quality for this task.
DEFAULT_PREFERRED = ["gpt-5.4-mini", "gpt-5.4", "gpt-4o-mini", "gpt-4o", "gpt-5.5"]
TRANSCRIBE_MODELS = ["gpt-4o-transcribe", "whisper-1"]

# ---------- in-memory + on-disk session ----------
# Single local user, so module-level state is fine.
PENDING = {}          # state -> code_verifier  (in-flight OAuth attempts)
SESSION = {
    "access_token": None,
    "refresh_token": None,
    "id_token": None,
    "api_key": None,         # minted via token-exchange, if the account allows it
    "account_id": None,
    "email": None,
    "expires_at": 0,         # unix seconds for access_token
    "mode": None,            # "apikey" | "chatgpt" | None
}


def log(*a):
    print("[cadence]", *a, file=sys.stderr, flush=True)


# ---------- small helpers ----------
def b64url(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")


def make_pkce():
    verifier = b64url(secrets.token_bytes(64))
    challenge = b64url(hashlib.sha256(verifier.encode("ascii")).digest())
    return verifier, challenge


def jwt_claims(token: str) -> dict:
    """Decode (without verifying) the payload of a JWT."""
    try:
        payload = token.split(".")[1]
        payload += "=" * (-len(payload) % 4)
        return json.loads(base64.urlsafe_b64decode(payload))
    except Exception:
        return {}


def http_post_form(url: str, fields: dict) -> dict:
    data = urllib.parse.urlencode(fields).encode("utf-8")
    req = urllib.request.Request(
        url, data=data,
        headers={"Content-Type": "application/x-www-form-urlencoded",
                 "Accept": "application/json"},
        method="POST")
    with urllib.request.urlopen(req, timeout=60) as resp:
        return json.loads(resp.read().decode("utf-8"))


def save_session():
    try:
        with open(AUTH_FILE, "w", encoding="utf-8") as f:
            json.dump(SESSION, f)
        if os.name != "nt":
            os.chmod(AUTH_FILE, 0o600)
    except Exception as e:
        log("could not persist auth:", e)


def load_session():
    try:
        with open(AUTH_FILE, "r", encoding="utf-8") as f:
            SESSION.update(json.load(f))
        if SESSION.get("mode"):
            log("restored session for", SESSION.get("email") or "your account")
    except FileNotFoundError:
        pass
    except Exception as e:
        log("could not read auth file:", e)


# ---------- OAuth ----------
def build_authorize_url(state: str, challenge: str) -> str:
    params = {
        "response_type": "code",
        "client_id": CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "scope": SCOPE,
        "code_challenge": challenge,
        "code_challenge_method": "S256",
        "id_token_add_organizations": "true",
        "codex_cli_simplified_flow": "true",
        "state": state,
        "originator": ORIGINATOR,
    }
    return AUTHORIZE_URL + "?" + urllib.parse.urlencode(params)


def exchange_code(code: str, verifier: str) -> dict:
    return http_post_form(TOKEN_URL, {
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": REDIRECT_URI,
        "client_id": CLIENT_ID,
        "code_verifier": verifier,
    })


def exchange_for_api_key(id_token: str):
    """Token-exchange the id_token for a real OpenAI API key. Returns the key
    or None if the account has no API access (common for Plus/Pro-only)."""
    try:
        res = http_post_form(TOKEN_URL, {
            "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
            "client_id": CLIENT_ID,
            "requested_token": "openai-api-key",
            "subject_token": id_token,
            "subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
        })
        return res.get("access_token")
    except urllib.error.HTTPError as e:
        log("api-key exchange not available:", e.code)
        return None
    except Exception as e:
        log("api-key exchange failed:", e)
        return None


def refresh_tokens():
    if not SESSION.get("refresh_token"):
        return False
    try:
        res = http_post_form(TOKEN_URL, {
            "grant_type": "refresh_token",
            "client_id": CLIENT_ID,
            "refresh_token": SESSION["refresh_token"],
            "scope": SCOPE,
        })
        SESSION["access_token"] = res.get("access_token", SESSION["access_token"])
        if res.get("refresh_token"):
            SESSION["refresh_token"] = res["refresh_token"]
        if res.get("id_token"):
            SESSION["id_token"] = res["id_token"]
        SESSION["expires_at"] = time.time() + int(res.get("expires_in", 3600))
        save_session()
        return True
    except Exception as e:
        log("refresh failed:", e)
        return False


def ensure_fresh():
    if SESSION.get("expires_at", 0) - 60 < time.time():
        refresh_tokens()


def finalize_login(tokens: dict):
    SESSION["access_token"] = tokens.get("access_token")
    SESSION["refresh_token"] = tokens.get("refresh_token")
    SESSION["id_token"] = tokens.get("id_token")
    SESSION["expires_at"] = time.time() + int(tokens.get("expires_in", 3600))

    claims = jwt_claims(SESSION["id_token"] or "")
    SESSION["email"] = claims.get("email")
    auth_claim = claims.get("https://api.openai.com/auth", {}) or {}
    SESSION["account_id"] = (auth_claim.get("chatgpt_account_id")
                             or auth_claim.get("user_id"))

    api_key = exchange_for_api_key(SESSION["id_token"] or "")
    if api_key:
        SESSION["api_key"] = api_key
        SESSION["mode"] = "apikey"
    else:
        SESSION["api_key"] = None
        SESSION["mode"] = "chatgpt"
    save_session()
    log("signed in:", SESSION.get("email"), "| mode:", SESSION["mode"])


# ---------- model listing ----------
def list_models():
    if SESSION.get("mode") == "apikey" and SESSION.get("api_key"):
        try:
            req = urllib.request.Request(
                OPENAI_API_BASE + "/models",
                headers={"Authorization": "Bearer " + SESSION["api_key"]})
            with urllib.request.urlopen(req, timeout=30) as resp:
                data = json.loads(resp.read().decode("utf-8"))
            ids = [m["id"] for m in data.get("data", [])]
            chat = sorted({m for m in ids if _is_chat_model(m)}, key=_model_rank)
            return chat or CURATED_MODELS
        except Exception as e:
            log("model list failed, using curated:", e)
            return CURATED_MODELS
    return CURATED_MODELS


def _is_chat_model(mid: str) -> bool:
    if not (mid.startswith("gpt-") or mid.startswith("o1") or mid.startswith("o3")
            or mid.startswith("o4") or mid.startswith("chatgpt")):
        return False
    bad = ("audio", "realtime", "transcribe", "tts", "image", "embedding",
           "moderation", "search", "instruct", "dall-e", "-pro")  # -pro: slow/expensive for this
    return not any(b in mid for b in bad)


def _model_rank(mid: str):
    """Sort key so the newest, most capable models appear first."""
    base = 0
    m = re.match(r"gpt-(\d+)(?:\.(\d+))?", mid)
    if m:
        base = 1000 + int(m.group(1)) * 100 + (int(m.group(2)) if m.group(2) else 0)
    elif mid.startswith(("o4", "o3", "o1")):
        base = 600 + int(mid[1])
    size = 0
    if "nano" in mid:
        size -= 3
    elif "mini" in mid:
        size -= 2
    if "codex" in mid:
        size += 1
    return (-(base * 10 + size), mid)


def pick_default(models):
    for p in DEFAULT_PREFERRED:
        if p in models:
            return p
    return models[0] if models else "gpt-4o-mini"


# ---------- generation ----------
def build_messages(mode: str, text: str):
    if mode == "paragraph":
        system = (
            "You are an expert editor and translator. The text below was dictated by "
            "voice and may be in English, Chinese, or Arabic, so the transcript is raw: "
            "it may contain speech-recognition errors, missing or wrong punctuation, "
            "run-on sentences, filler words, false starts, self-corrections, repetition, "
            "and ideas that arrive out of order. The speaker may have said a LOT.\n\n"
            "Your task: understand the speaker's FULL intent, silently correct likely "
            "transcription errors, and rewrite everything into clean, natural, "
            "well-structured prose. Keep EVERY distinct piece of information and every "
            "point the speaker made — omit nothing meaningful — while removing fillers "
            "and redundancy. Reorder ideas into a logical flow and use several short "
            "paragraphs when the content is long. Do not add facts or opinions the "
            "speaker did not express. Preserve their meaning, intent and tone.\n\n"
            "ALWAYS write the result in clear, natural ENGLISH. If the speaker used "
            "Chinese or Arabic (or any non-English language), translate their meaning "
            "faithfully into idiomatic English. Output ONLY the finished English text — "
            "no preamble, no notes, no surrounding quotes.")
        user = 'Voice transcript:\n"""\n' + text + '\n"""'
    else:
        system = (
            "You are an expert prompt engineer. The notes below were dictated by voice "
            "and may be in English, Chinese, or Arabic, so the transcript may contain "
            "recognition errors, no punctuation, repetition, and out-of-order ideas. "
            "The speaker may have said a LOT.\n\n"
            "Do NOT simply restate, wrap, transcribe or lightly reword what the user "
            "said. ANALYZE their underlying goal and ENGINEER a markedly better, more "
            "complete and more specific prompt than what they literally said: turn vague "
            "spoken intentions into concrete, actionable instructions, expand implied "
            "requirements, and add the structure a strong prompt needs — while staying "
            "faithful to their intent and never inventing unrelated facts.\n\n"
            "Produce ONE polished prompt the user can paste straight into an AI "
            "assistant. Use clean Markdown with EXACTLY these four sections, with these "
            "headings, in this order:\n"
            "## Role — assign a specific expert persona and state the core objective in "
            "one or two precise sentences.\n"
            "## Context — the relevant background, audience, goals, tools and current "
            "state implied by the notes.\n"
            "## Components — an exhaustive bulleted breakdown of every concrete "
            "requirement, deliverable, feature or step the work must include. This is the "
            "heart of the prompt: be specific and complete; omit nothing the speaker "
            "asked for, and make reasonable additions a good prompt would include.\n"
            "## Criteria — the success criteria and constraints: what 'done and good' "
            "looks like, the expected output format/length/tone, and anything to avoid.\n\n"
            "Make conservative inferences to fill obvious gaps, but instruct the assistant "
            "to ask clarifying questions first if essential information is missing.\n\n"
            "ALWAYS write the entire prompt in ENGLISH, even if the spoken notes are in "
            "Chinese or Arabic (translate their intent into English). Output ONLY the "
            "finished prompt, ready to paste — no preamble, no commentary, no surrounding quotes.")
        user = 'Raw spoken notes to engineer into a prompt:\n"""\n' + text + '\n"""'
    return system, user


# ---------- transcription (high-accuracy, API-key mode only) ----------
def _multipart_body(fields: dict, file_field: str, filename: str,
                    file_bytes: bytes, file_ctype: str):
    boundary = "----cadence" + uuid.uuid4().hex
    crlf = b"\r\n"
    buf = io.BytesIO()
    for k, v in fields.items():
        buf.write(b"--" + boundary.encode() + crlf)
        buf.write(('Content-Disposition: form-data; name="%s"' % k).encode() + crlf + crlf)
        buf.write(str(v).encode() + crlf)
    buf.write(b"--" + boundary.encode() + crlf)
    buf.write(('Content-Disposition: form-data; name="%s"; filename="%s"'
               % (file_field, filename)).encode() + crlf)
    buf.write(("Content-Type: %s" % file_ctype).encode() + crlf + crlf)
    buf.write(file_bytes + crlf)
    buf.write(b"--" + boundary.encode() + b"--" + crlf)
    return buf.getvalue(), "multipart/form-data; boundary=" + boundary


def transcribe(audio_bytes: bytes, ctype: str, lang: str = "") -> str:
    if not (SESSION.get("mode") == "apikey" and SESSION.get("api_key")):
        raise RuntimeError("high-accuracy transcription needs API-key mode")
    base = (ctype or "audio/webm").split(";")[0].strip()
    ext = {"audio/webm": "webm", "audio/mp4": "mp4", "audio/mpeg": "mp3",
           "audio/ogg": "ogg", "audio/wav": "wav", "audio/x-wav": "wav"}.get(base, "webm")
    last = None
    for model in TRANSCRIBE_MODELS:
        try:
            # Bias toward the three expected scripts; helps code-switched speech.
            fields = {"model": model, "response_format": "json",
                      "prompt": "The speaker may mix English, Mandarin Chinese, "
                                "and Arabic; transcribe each language in its native script."}
            # Only pin a language when explicitly forced; "auto"/"" lets the model
            # auto-detect (the accurate choice for mixed input).
            if lang in ("en", "zh", "ar"):
                fields["language"] = lang
            data, content_type = _multipart_body(
                fields, "file", "audio." + ext, audio_bytes, base)
            req = urllib.request.Request(
                OPENAI_API_BASE + "/audio/transcriptions", data=data,
                headers={"Authorization": "Bearer " + SESSION["api_key"],
                         "Content-Type": content_type}, method="POST")
            with urllib.request.urlopen(req, timeout=180) as resp:
                return json.loads(resp.read().decode("utf-8")).get("text", "")
        except urllib.error.HTTPError as e:
            last = e
            continue
    if last:
        raise last
    return ""


def _max_out(mode):
    return 1400 if mode == "prompt" else 700


def open_apikey(model, system, user, max_out):
    """Open a streaming chat/completions request (raises on HTTP error)."""
    body = json.dumps({
        "model": model,
        "messages": [{"role": "system", "content": system},
                     {"role": "user", "content": user}],
        "stream": True,
        "max_completion_tokens": max_out,
    }).encode("utf-8")
    req = urllib.request.Request(
        OPENAI_API_BASE + "/chat/completions", data=body,
        headers={"Authorization": "Bearer " + SESSION["api_key"],
                 "Content-Type": "application/json",
                 "Accept": "text/event-stream"},
        method="POST")
    return urllib.request.urlopen(req, timeout=120)


def iter_apikey(resp):
    for raw in resp:
        line = raw.decode("utf-8", "replace").strip()
        if not line.startswith("data:"):
            continue
        chunk = line[5:].strip()
        if chunk == "[DONE]":
            break
        try:
            ev = json.loads(chunk)
            delta = ev["choices"][0]["delta"].get("content")
        except Exception:
            delta = None
        if delta:
            yield delta


def open_chatgpt(model, system, user, max_out):
    """Open a streaming Codex-backend (ChatGPT subscription) request. No reasoning
    field — omitting it is the lowest-latency setting (benchmarked ~2x faster than
    effort:medium)."""
    payload = {
        "model": model,
        "instructions": system,
        "input": [{"type": "message", "role": "user",
                   "content": [{"type": "input_text", "text": user}]}],
        "tools": [],
        "tool_choice": "auto",
        "parallel_tool_calls": False,
        "store": False,
        "stream": True,
    }   # NB: the Codex backend rejects max_output_tokens, so it is omitted here
    body = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(
        CHATGPT_BACKEND + "/responses", data=body,
        headers={
            "Authorization": "Bearer " + SESSION["access_token"],
            "chatgpt-account-id": SESSION.get("account_id") or "",
            "Content-Type": "application/json",
            "Accept": "text/event-stream",
            "OpenAI-Beta": "responses=experimental",
            "originator": ORIGINATOR,
            "session_id": str(uuid.uuid4()),
        },
        method="POST")
    return urllib.request.urlopen(req, timeout=120)


def iter_chatgpt(resp):
    saw_delta = False
    for raw in resp:
        line = raw.decode("utf-8", "replace").strip()
        if not line.startswith("data:"):
            continue
        chunk = line[5:].strip()
        if chunk == "[DONE]":
            break
        try:
            ev = json.loads(chunk)
        except Exception:
            continue
        if ev.get("type") == "response.output_text.delta":
            saw_delta = True
            d = ev.get("delta", "")
            if d:
                yield d
        elif ev.get("type") == "response.completed" and not saw_delta:
            try:  # some variants only deliver the whole text at the end
                for item in ev["response"]["output"]:
                    for c in item.get("content", []):
                        if c.get("type") == "output_text":
                            yield c.get("text", "")
            except Exception:
                pass


def open_generation(mode, text, model):
    """Returns (response, delta_iterator). Raises on upstream HTTP error so the
    caller can return a proper error status before streaming begins."""
    ensure_fresh()
    system, user = build_messages(mode, text)   # output is always English
    mx = _max_out(mode)
    if SESSION.get("mode") == "apikey" and SESSION.get("api_key"):
        resp = open_apikey(model, system, user, mx)
        return resp, iter_apikey(resp)
    resp = open_chatgpt(model, system, user, mx)
    return resp, iter_chatgpt(resp)


# ---------- HTTP handler ----------
class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def log_message(self, *a):
        pass  # quiet

    def _send(self, code, body=b"", ctype="application/json"):
        if isinstance(body, str):
            body = body.encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        if self.command != "HEAD":
            self.wfile.write(body)

    def _json(self, obj, code=200):
        self._send(code, json.dumps(obj), "application/json")

    # ----- GET -----
    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        path = parsed.path

        if path == "/" or path == "/index.html":
            return self._serve_html()

        if path == "/login":
            return self._start_login()

        if path == "/auth/callback":
            return self._auth_callback(parsed)

        if path == "/api/status":
            return self._status()

        if path == "/logout":
            for k in SESSION:
                SESSION[k] = 0 if k == "expires_at" else None
            try:
                os.remove(AUTH_FILE)
            except OSError:
                pass
            return self._json({"ok": True})

        return self._send(404, b'{"error":"not found"}')

    # ----- POST -----
    def do_POST(self):
        path = urllib.parse.urlparse(self.path).path
        if path == "/api/transcribe":
            return self._transcribe()
        if path == "/api/set-key":
            return self._set_key()
        if path != "/api/generate":
            return self._send(404, b'{"error":"not found"}')
        try:
            length = int(self.headers.get("Content-Length", 0))
            req = json.loads(self.rfile.read(length) or b"{}")
        except Exception:
            return self._json({"error": "bad request"}, 400)

        if not SESSION.get("mode"):
            return self._json({"error": "not_connected"}, 401)
        text = (req.get("text") or "").strip()
        if not text:
            return self._json({"error": "empty"}, 400)
        mode = req.get("mode") or "paragraph"
        model = req.get("model") or pick_default(list_models())

        # Open the upstream first so pre-stream failures return a real error
        # status (the client then falls back to built-in cleanup).
        try:
            resp, deltas = open_generation(mode, text, model)
        except urllib.error.HTTPError as e:
            detail = ""
            try:
                detail = e.read().decode("utf-8", "replace")[:600]
            except Exception:
                pass
            log("generate HTTPError", e.code, detail)
            return self._json({"error": "upstream", "status": e.code, "detail": detail}, 502)
        except Exception as e:
            log("generate error:", e)
            return self._json({"error": "server", "detail": str(e)}, 500)

        # Stream the model's text straight to the browser as it is generated.
        self.send_response(200)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.send_header("Cache-Control", "no-cache")
        self.send_header("X-Accel-Buffering", "no")
        self.send_header("X-Cadence-Model", model)
        self.send_header("X-Cadence-Mode", SESSION.get("mode") or "")
        self.send_header("Connection", "close")
        self.end_headers()
        try:
            for delta in deltas:
                if delta:
                    self.wfile.write(delta.encode("utf-8"))
                    self.wfile.flush()
        except Exception as e:
            log("stream interrupted:", e)
        finally:
            try:
                resp.close()
            except Exception:
                pass

    # ----- handlers -----
    def _serve_html(self):
        try:
            with open(HTML_PATH, "rb") as f:
                self._send(200, f.read(), "text/html; charset=utf-8")
        except FileNotFoundError:
            self._send(500, b"cadence.html not found (expected next to server.py, or bundled via --add-data in a PyInstaller build)")

    def _set_key(self):
        """Bring-your-own OpenAI API key. Unlocks gpt-4o-transcribe auto-detect
        (no manual language needed) and api.openai.com generation."""
        try:
            length = int(self.headers.get("Content-Length", 0))
            req = json.loads(self.rfile.read(length) or b"{}")
        except Exception:
            return self._json({"error": "bad request"}, 400)
        key = (req.get("key") or "").strip()
        if not key.startswith("sk-"):
            return self._json({"error": "invalid_format"}, 400)
        try:  # validate against the live API
            r = urllib.request.Request(OPENAI_API_BASE + "/models",
                                       headers={"Authorization": "Bearer " + key})
            with urllib.request.urlopen(r, timeout=20) as resp:
                json.loads(resp.read().decode("utf-8"))
        except urllib.error.HTTPError as e:
            return self._json({"error": "invalid_key", "status": e.code}, 401)
        except Exception as e:
            return self._json({"error": "validate_failed", "detail": str(e)[:120]}, 502)
        SESSION["api_key"] = key
        SESSION["mode"] = "apikey"
        save_session()
        log("API key connected — Whisper auto-detect + api.openai.com enabled")
        return self._json({"ok": True})

    def _transcribe(self):
        if not (SESSION.get("mode") == "apikey" and SESSION.get("api_key")):
            return self._json({"error": "unavailable"}, 409)
        try:
            length = int(self.headers.get("Content-Length", 0))
        except ValueError:
            length = 0
        if length <= 0:
            return self._json({"error": "empty"}, 400)
        audio = self.rfile.read(length)
        ctype = self.headers.get("Content-Type", "audio/webm")
        lang = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("lang", [""])[0]
        try:
            ensure_fresh()
            text = transcribe(audio, ctype, lang)
            return self._json({"text": text})
        except urllib.error.HTTPError as e:
            detail = ""
            try:
                detail = e.read().decode("utf-8", "replace")[:400]
            except Exception:
                pass
            log("transcribe HTTPError", e.code, detail)
            return self._json({"error": "upstream", "status": e.code, "detail": detail}, 502)
        except Exception as e:
            log("transcribe error:", e)
            return self._json({"error": "server", "detail": str(e)}, 500)

    def _start_login(self):
        state = b64url(secrets.token_bytes(24))
        verifier, challenge = make_pkce()
        PENDING[state] = verifier
        url = build_authorize_url(state, challenge)
        self.send_response(302)
        self.send_header("Location", url)
        self.send_header("Content-Length", "0")
        self.end_headers()

    def _auth_callback(self, parsed):
        q = urllib.parse.parse_qs(parsed.query)
        if "error" in q:
            return self._send(400, "Login error: " + q.get("error", [""])[0], "text/plain")
        code = q.get("code", [None])[0]
        state = q.get("state", [None])[0]
        verifier = PENDING.pop(state, None)
        if not code or not verifier:
            return self._send(400, "Invalid OAuth callback (state mismatch).", "text/plain")
        try:
            tokens = exchange_code(code, verifier)
            finalize_login(tokens)
        except urllib.error.HTTPError as e:
            detail = e.read().decode("utf-8", "replace")[:400] if e.fp else ""
            return self._send(502, "Token exchange failed: %s %s" % (e.code, detail), "text/plain")
        except Exception as e:
            return self._send(502, "Token exchange failed: " + str(e), "text/plain")
        # back to the app
        self.send_response(302)
        self.send_header("Location", "/?connected=1")
        self.send_header("Content-Length", "0")
        self.end_headers()

    def _status(self):
        connected = bool(SESSION.get("mode"))
        models = list_models() if connected else CURATED_MODELS
        self._json({
            "connected": connected,
            "email": SESSION.get("email"),
            "mode": SESSION.get("mode"),
            "models": models,
            "default_model": pick_default(models),
            "transcribe": SESSION.get("mode") == "apikey",
        })


def _banner(url):
    print("", flush=True)
    print("   Cadence is running.", flush=True)
    print("   Open this in your browser:  " + url, flush=True)
    print("   (it should open automatically)", flush=True)
    print("", flush=True)
    print("   Keep this window open while you use Cadence.", flush=True)
    print("   Close this window to quit.", flush=True)
    print("", flush=True)


def main():
    # Console may be a legacy codepage (cp1252/cp936); make logging non-ASCII-safe.
    for stream in (sys.stdout, sys.stderr):
        try:
            stream.reconfigure(encoding="utf-8", errors="replace")
        except Exception:
            pass
    url = f"http://localhost:{PORT}/"
    if not os.path.exists(HTML_PATH):
        log("WARNING: cadence.html not found — the page won't load.")
    load_session()
    try:
        server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
    except OSError:
        # Port busy — most likely Cadence is already running. Just open it.
        log("Cadence is already running on port %d — opening it." % PORT)
        try:
            webbrowser.open(url)
        except Exception:
            pass
        return
    _banner(url)
    try:
        webbrowser.open(url)
    except Exception:
        pass
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        log("bye")
        server.shutdown()


if __name__ == "__main__":
    try:
        main()
    except Exception as e:  # keep a double-clicked window open to show the error
        import traceback
        traceback.print_exc()
        try:
            input("\nSomething went wrong (see above). Press Enter to close…")
        except Exception:
            pass
