#!/usr/bin/env python3
"""Превратить текст в аудиофайл через HTTP API ChatGPT или OpenAI Platform.

Скрипт воспроизводит HTTP-запрос самостоятельно: для ChatGPT читает текущий
access token из auth.json, для Platform — ключ из файла или окружения.
Описание схемы, примеры и карта кода: docs/chatgpt-audio-http.md.
"""
# argparse разбирает режим, входной текст, выходной файл и параметры запроса.
import argparse
# json читает сохранённый вход и кодирует тело запроса, но не печатает токен.
import json
# os нужен только для альтернативного источника Platform key — OPENAI_API_KEY.
import os
# sys позволяет вывести диагностическую ошибку в stderr и вернуть код 1.
import sys
# HTTPError и URLError отделяют отказ сервера от ошибки соединения.
import urllib.error
# urlencode и quote безопасно помещают ID сообщений и чатов в URL.
import urllib.parse
# Стандартный HTTP-клиент отправляет запрос без SDK и без запуска Desktop.
import urllib.request
# Path работает с входным текстом, auth.json, файлом ключа и результатом.
from pathlib import Path

# Базовый адрес приватного ChatGPT API. Это отдельный адрес от api.openai.com.
CHATGPT = "https://chatgpt.com/backend-api"
# User-Agent взят из наблюдавшегося успешного Desktop-запроса.
# В предыдущих пробах сервер по-разному отвечал разным клиентам; этот заголовок
# воспроизводит рабочую форму, но не гарантирует обход любого HTTP 403.
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):
    # Не переходим по 3xx: Authorization должен остаться на исходном адресе.
    def redirect_request(self, request, fp, code, msg, headers, newurl):
        # None превращает перенаправление в HTTP-ошибку вместо второго запроса.
        return None


def chatgpt_headers(auth_file):
    # Читаем файл заново при запуске. Токен остаётся в памяти и в HTTP-заголовке;
    # скрипт не копирует его в исходники, отдельный файл, URL или вывод.
    auth = json.loads(auth_file.read_text(encoding="utf-8"))
    # Для ChatGPT-входа credentials находятся в объекте tokens.
    tokens = auth.get("tokens") or {}
    token = tokens.get("access_token")
    # API-key вход, включая acodex-маркер 0000, не подходит для этого endpoint.
    if auth.get("auth_mode") != "chatgpt" or not isinstance(token, str) or not token:
        raise ValueError("Нужен ChatGPT-вход с access_token в выбранном auth.json")
    # Cookies из Desktop не читаются. Для чтения существующего ChatGPT-чата
    # 30.09.2026 потребовалось воспроизвести ещё и набор заголовков Desktop:
    # без него был 403 challenge, с ним GET conversation дал 200. По этой
    # проверке нельзя установить, какой отдельный заголовок оказался решающим.
    headers = {"Authorization": "Bearer " + token, "User-Agent": USER_AGENT,
               "originator": "Codex Desktop", "Origin": "https://chatgpt.com", "Accept": "*/*"}
    # ID аккаунта передаём только если он присутствует в сохранённом входе.
    account = tokens.get("account_id")
    if account:
        headers["ChatGPT-Account-ID"] = account
    return headers


def build_request(args, text):
    # Возвращаем URL, HTTP-метод и JSON-тело отдельно от credentials.
    # Благодаря этому --dry-run показывает контракт, не читая auth.json.
    if args.mode == "chatgpt-text":
        # Произвольный текст: Desktop использует эту форму в language widgets.
        # pronunciation_language задаёт язык, source повторяет контекст вызова.
        # В этой ветке нет параметра модели и нет ID существующего сообщения.
        return (CHATGPT + "/pronunciation/synthesize?format=mp3", "POST",
                {"text": text, "pronunciation_language": args.language,
                 "source": "translation"})
    if args.mode == "chatgpt-message":
        # Здесь сервер сам берёт текст из уже существующего ChatGPT-сообщения.
        # urlencode экранирует все значения query; тело GET-запроса отсутствует.
        query = urllib.parse.urlencode({"conversation_id": args.conversation_id,
                                       "message_id": args.message_id,
                                       "voice": args.voice or "juniper", "format": "mp3"})
        return CHATGPT + "/synthesize?" + query, "GET", None
    if args.mode == "chatgpt-summary":
        # Эта операция озвучивает резюме чата. Входной текст служит инструкцией
        # для резюме; его дословное чтение этим режимом не обещается.
        conversation = urllib.parse.quote(args.conversation_id, safe="")
        return (CHATGPT + f"/conversation/{conversation}/audio_summary/synthesize", "POST",
                {"format": args.summary_format, "custom_instructions": text or None})
    # Оставшийся CLI-режим openai вызывает публичный Speech API.
    # Лимит 4096 символов — ограничение этой демонстрации для данной ветки.
    if len(text) > 4096:
        raise ValueError("Демонстрация /audio/speech ограничена 4096 символами")
    return ("https://api.openai.com/v1/audio/speech", "POST",
            {"model": args.model, "input": text,
             "voice": args.voice or "coral", "response_format": "wav"})


def download_audio(url, method, body, headers, output, timeout):
    # Для POST кодируем JSON в UTF-8. GET оставляет тело равным None.
    if body is not None:
        headers = {**headers, "Content-Type": "application/json"}
    request = urllib.request.Request(url, method=method, headers=headers,
                                    data=None if body is None else json.dumps(body).encode())
    # NoRedirect отключает переходы, а обычный ProxyHandler urllib остаётся:
    # этот TTS-клиент учитывает стандартные proxy-настройки своего окружения.
    # Это сетевой HTTP proxy, а не app-server и не acodex model gateway.
    try:
        with urllib.request.build_opener(NoRedirect()).open(request, timeout=timeout) as response:
            # Отбрасываем необязательные параметры Content-Type после точки с запятой.
            mime = response.headers.get("Content-Type", "").split(";", 1)[0]
            # Читаем максимум 30 МБ + 1 байт: лишний байт обнаруживает превышение.
            # Это локальное ограничение клиента, не заявленный лимит сервиса.
            data = response.read(30_000_001)
            status = response.status
    except urllib.error.HTTPError as error:
        # Не печатаем тело отказа. Оно может содержать пользовательские данные.
        # Признак challenge берём только из заголовка, не угадываем причину 403.
        challenge = error.headers.get("cf-mitigated") == "challenge"
        message = (f"HTTP {error.code}; content-type={error.headers.get('Content-Type')}; "
                   f"Cloudflare challenge={challenge}; тело ответа не выводится")
        # HTTPError владеет потоком ответа; явно освобождаем его перед исключением.
        error.close()
        raise RuntimeError(message) from None
    # Не сохраняем JSON/HTML-ответ как якобы готовое аудио.
    if not mime.startswith("audio/") and mime != "application/octet-stream":
        raise RuntimeError(f"HTTP {status}, но content-type={mime!r} не является аудио")
    if not 0 < len(data) <= 30_000_000:
        raise RuntimeError("Получено пустое или слишком большое аудио")
    # MIME недостаточно: сервер мог подписать JSON/HTML как audio/mpeg.
    # Полное декодирование после сохранения отдельно проверяет ffmpeg/ffprobe.
    if data.lstrip().startswith((b"{", b"<")):
        raise RuntimeError("Вместо аудио получен JSON/HTML")
    # Режим xb создаёт файл исключительно: существующий результат не затирается.
    # Ответ загружается в память целиком и лишь затем сохраняется в этот файл.
    with output.open("xb") as target:
        target.write(data)
    # В полезном выводе только статус, тип, размер и путь; credentials отсутствуют.
    return {"http_status": status, "content_type": mime, "bytes": len(data), "output": str(output)}


def main():
    # mode выбирает endpoint, output задаёт имя нового аудиофайла.
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("mode", choices=["chatgpt-text", "chatgpt-message", "chatgpt-summary", "openai"])
    parser.add_argument("output", type=Path)
    # Текст читается из UTF-8 файла; аргумент не содержит сам текст.
    parser.add_argument("--text-file", type=Path)
    # Этот клиент по умолчанию использует ~/.codex/auth.json. CODEX_HOME здесь
    # автоматически не учитывается; для другого хранилища нужен --auth-file.
    parser.add_argument("--auth-file", type=Path, default=Path.home() / ".codex/auth.json")
    # Файл Platform key используется только для режима openai.
    parser.add_argument("--api-key-file", type=Path, help="Файл, содержащий только Platform API key")
    # Эти ID нужны только режимам чтения сообщения и резюме ChatGPT-чата.
    parser.add_argument("--conversation-id")
    parser.add_argument("--message-id")
    # format здесь означает вид резюме, а НЕ формат аудиофайла. Сервер отвергает
    # «mp3» с HTTP 422: штатный Desktop передаёт одно из этих трёх значений.
    parser.add_argument("--summary-format", default="podcast",
                        choices=["podcast", "executive_briefing", "study_guide"])
    # Язык влияет на pronunciation; voice используется message/openai режимами.
    parser.add_argument("--language", default="ru")
    parser.add_argument("--voice")
    # Имя модели относится только к публичному Speech API.
    parser.add_argument("--model", default="gpt-4o-mini-tts")
    parser.add_argument("--timeout", type=float, default=90)
    parser.add_argument("--dry-run", action="store_true")
    # После разбора аргументов сначала проверяем их, затем читаем credentials.
    args = parser.parse_args()
    if args.timeout <= 0:
        parser.error("timeout должен быть положительным")
    if args.output.exists():
        parser.error("Файл результата уже существует")
    if args.mode in {"chatgpt-message", "chatgpt-summary"} and not args.conversation_id:
        parser.error("Нужен conversation-id существующего ChatGPT-чата")
    if args.mode == "chatgpt-message" and not args.message_id:
        parser.error("Нужен message-id ответа ассистента")
    # strip убирает внешние пробелы и переводы строк; внутренний текст сохраняется.
    text = args.text_file.read_text(encoding="utf-8").strip() if args.text_file else ""
    if args.mode in {"chatgpt-text", "openai"} and not text:
        parser.error("Нужен непустой text-file")
    url, method, body = build_request(args, text)
    # В этом режиме не выполняется сеть и не открывается ни файл токена, ни ключ.
    # Вывод содержит пользовательский текст, поэтому dry-run не является журналом
    # для публичной публикации, если текст конфиденциален.
    if args.dry_run:
        print(json.dumps({"method": method, "url": url, "body": body,
                          "output": str(args.output), "credentials_read": False}, ensure_ascii=False, indent=2))
        return
    # Для Platform приоритет имеет --api-key-file, затем OPENAI_API_KEY.
    # ChatGPT access token не подставляется вместо этого ключа.
    if args.mode == "openai":
        key = args.api_key_file.read_text().strip() if args.api_key_file else os.getenv("OPENAI_API_KEY", "")
        if not key or key == "0000":
            raise ValueError("Нужен действующий OpenAI Platform API key; ChatGPT-вход/0000 не подходят")
        headers = {"Authorization": "Bearer " + key}
    else:
        # Три приватных ChatGPT-режима используют один формат локального входа.
        headers = chatgpt_headers(args.auth_file)
    # Единственный сетевой запрос: готовим headers, скачиваем и сохраняем аудио.
    print(json.dumps(download_audio(url, method, body, headers, args.output, args.timeout), ensure_ascii=False))


if __name__ == "__main__":
    # При импорте тестами main не запускается; при вызове python3 — запускается.
    try:
        main()
    except (OSError, ValueError, RuntimeError, urllib.error.URLError):
        # Не показываем содержимое исключений парсинга auth.json, способное раскрыть credentials.
        exc = sys.exc_info()[1]
        message = str(exc) if isinstance(exc, RuntimeError) else "Проверьте вход, аргументы, файлы и доступ к сети"
        print("TTS: " + message, file=sys.stderr)
        raise SystemExit(1)
