#!/usr/bin/env python3
"""Самостоятельное демо: существующий ответ ChatGPT → удалённый /synthesize.

Только стандартный Python. Не создаёт чат и не отправляет новое сообщение.
Без ID чата использует первый закреплённый ChatGPT-чат текущего аккаунта.
"""
import argparse
import json
import os
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

BASE = "https://chatgpt.com/backend-api"
USER_AGENT = ("CodexBrowser Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
              "(KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36")


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, request, fp, code, msg, headers, newurl):
        # Не передаём Authorization на адрес из перенаправления.
        return None


def read_auth(path):
    # Токен и account ID читаются из файла, их значения не выводятся.
    auth = json.loads(path.read_text(encoding="utf-8"))
    tokens = auth.get("tokens") or {}
    token = tokens.get("access_token")
    account = tokens.get("account_id")
    if auth.get("auth_mode") != "chatgpt" or not isinstance(token, str) or not token or any(c in token for c in "\r\n"):
        raise RuntimeError("В выбранном файле нет корректного ChatGPT access token")
    headers = {"Authorization": "Bearer " + token, "User-Agent": USER_AGENT,
               "originator": "Codex Desktop", "Origin": "https://chatgpt.com", "Accept": "*/*"}
    if account:
        if not isinstance(account, str) or any(c in account for c in "\r\n"):
            raise RuntimeError("Некорректный account ID в auth.json")
        headers["ChatGPT-Account-ID"] = account
    return headers, account


def pinned_conversation(state_file, account):
    # Выбираем только ветку текущего аккаунта. Не подставляем чужой закреплённый
    # чат, если нужного аккаунта в состоянии нет. Можно явно передать свой ID.
    state = json.loads(state_file.read_text(encoding="utf-8"))
    branches = (state.get("electron-persisted-atom-state") or {}).get("chatgpt-sidebar-state-v1") or {}
    items = (branches.get(account) or {}).get("pinnedConversations") or []
    for item in items:
        conversation = (item.get("conversation") or {}).get("id")
        if isinstance(conversation, str) and conversation:
            return conversation
    raise RuntimeError("Для текущего аккаунта нет закреплённого ChatGPT-чата; передайте --conversation-id")


def assistant_message(conversation):
    # Идём только по активной ветке current_node → parent, не выбираем случайный
    # старый ответ. Текст ответа не нужен локально: сервер читает его по ID.
    mapping = conversation.get("mapping") or {}
    current = conversation.get("current_node")
    visited = set()
    while current and current not in visited:
        visited.add(current)
        node = mapping.get(current) or {}
        message = node.get("message") or {}
        if ((message.get("author") or {}).get("role") == "assistant"
                and (message.get("content") or {}).get("content_type") == "text"
                and message.get("channel") in (None, "final")
                and message.get("recipient", "all") == "all"
                and not (message.get("metadata") or {}).get("is_visually_hidden_from_conversation")):
            if isinstance(message.get("id"), str) and message["id"]:
                return message["id"]
        current = node.get("parent")
    raise RuntimeError("В активной ветке не найден видимый текстовый ответ assistant")


def get(opener, url, headers, timeout, limit):
    try:
        with opener.open(urllib.request.Request(url, headers=headers), timeout=timeout) as response:
            data = response.read(limit + 1)
            if not data or len(data) > limit:
                raise RuntimeError("Пустой ответ либо превышен локальный лимит размера")
            return data, response.headers.get("Content-Type", "").split(";", 1)[0]
    except urllib.error.HTTPError as error:
        # Не выводим тело с данными аккаунта и заголовки с cookies.
        challenge = error.headers.get("cf-mitigated") == "challenge"
        code = error.code
        error.close()
        raise RuntimeError(f"HTTP {code}; Cloudflare challenge={challenge}; тело ответа скрыто") from None


def save_audio(output, data):
    # Приватный файл 0600; сначала запись временного файла, потом публикация
    # готового результата без перезаписи. Очищается только наш временный файл.
    temporary = tempfile.NamedTemporaryFile(dir=output.parent, prefix=".remote-tts-", delete=False)
    try:
        with temporary:
            temporary.write(data)
        os.link(temporary.name, output)
    finally:
        Path(temporary.name).unlink(missing_ok=True)


def voice_catalog(opener, headers, language, timeout):
    # Это отдельный read-only GET. Настройку выбранного голоса аккаунта
    # НЕ меняем: никакого PATCH /settings/account_user_setting здесь нет.
    query = urllib.parse.urlencode({"spoken_language": language, "voice_mode": "advanced"})
    data, mime = get(opener, BASE + "/settings/voices?" + query, headers, timeout, 1_000_000)
    if mime != "application/json":
        raise RuntimeError("Каталог голосов не является JSON")
    result = []
    for item in json.loads(data).get("voices", []):
        if isinstance(item.get("voice"), str) and item["voice"]:
            result.append({"id": item["voice"], "name": item.get("name") or item["voice"]})
    if not result:
        raise RuntimeError("Сервис не вернул голоса")
    return result


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("output", type=Path, nargs="?")
    parser.add_argument("--auth-file", type=Path, default=Path(os.getenv("CODEX_HOME", str(Path.home() / ".codex"))) / "auth.json")
    parser.add_argument("--state-file", type=Path)
    parser.add_argument("--conversation-id")
    parser.add_argument("--message-id")
    parser.add_argument("--voice", default="juniper")
    parser.add_argument("--list-voices", action="store_true")
    parser.add_argument("--language", default="ru", help="Язык описаний в каталоге, не перевод текста")
    parser.add_argument("--format", choices=["mp3", "aac"], default="mp3")
    parser.add_argument("--timeout", type=float, default=90)
    parser.add_argument("--dry-run", action="store_true")
    args = parser.parse_args()
    if args.timeout <= 0:
        parser.error("Нужен положительный timeout")
    if not args.list_voices and (args.output is None or args.output.exists() or not args.output.parent.is_dir()):
        parser.error("Нужны положительный timeout и новый output в существующей папке")
    if args.message_id and not args.conversation_id:
        parser.error("Для --message-id нужен --conversation-id")
    if args.dry_run:
        print(json.dumps({"endpoint": BASE + ("/settings/voices" if args.list_voices else "/synthesize"), "format": args.format,
                          "auto_message": not bool(args.message_id), "credentials_read": False}))
        return
    headers, account = read_auth(args.auth_file)
    opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
    voices = voice_catalog(opener, headers, args.language, args.timeout)
    if args.list_voices:
        print(json.dumps({"source": BASE + "/settings/voices", "voices": voices,
                          "note": "Каталог сервиса; возможность синтеза каждого голоса проверяется отдельно"}, ensure_ascii=False, indent=2))
        return
    # Принимаем ID либо отображаемое имя без учёта регистра. Например, Sol
    # соответствует backend ID glimmer; Arbor — fathom. В запрос идёт ID.
    matches = [v["id"] for v in voices if args.voice.casefold() in (v["id"].casefold(), v["name"].casefold())]
    if not matches:
        raise RuntimeError("Голоса нет в каталоге; запустите --list-voices")
    conversation = args.conversation_id or pinned_conversation(
        args.state_file or args.auth_file.with_name(".codex-global-state.json"), account)
    # Прямой HTTP: не подключаем cookies Desktop или proxy из окружения.
    message = args.message_id
    if not message:
        data, mime = get(opener, BASE + "/conversation/" + urllib.parse.quote(conversation, safe=""), headers, args.timeout, 10_000_000)
        if mime != "application/json":
            raise RuntimeError("Вместо JSON чата получен другой тип ответа")
        message = assistant_message(json.loads(data))
    query = urllib.parse.urlencode({"conversation_id": conversation, "message_id": message,
                                   "voice": matches[0], "format": args.format})
    data, mime = get(opener, BASE + "/synthesize?" + query, headers, args.timeout, 30_000_000)
    if not mime.startswith("audio/") or data.lstrip().startswith((b"{", b"<")):
        raise RuntimeError("Сервис не вернул аудиофайл")
    save_audio(args.output, data)
    # Не печатаем текст чата, conversation/message ID или credential.
    print(json.dumps({"output": str(args.output), "voice": matches[0], "bytes": len(data), "content_type": mime}))


if __name__ == "__main__":
    try:
        main()
    except (OSError, ValueError, RuntimeError, KeyError, TypeError):
        error = sys.exc_info()[1]
        print("ChatGPT Read Aloud: " + (str(error) if isinstance(error, RuntimeError) else "Проверьте файлы, вход и сеть"), file=sys.stderr)
        raise SystemExit(1)
