"""OpenAI-совместимый прокси к Viktor для OpenCode.

OpenCode шлёт /v1/chat/completions -> берём последнее сообщение пользователя,
отправляем Viktor'у в Slack DM, ждём его финальный ответ (отсекая ack
«напишу, когда готово»), затем делаем git pull в локальном репозитории,
чтобы запушенные Viktor'ом файлы оказались на ПК.
"""
from __future__ import annotations

import asyncio
import json
import os
import re
import time
import uuid
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator

from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse

from . import accounts, connection, git_sync, sessions
from .accounts import AccountManager
from .config import Settings, load_settings
from .slack_connector import SlackConnector
from .viktor_api import ViktorApi

settings: Settings = load_settings()
store = sessions.SessionStore(settings.session_state_file)

# Ленивая инициализация ключа: либо из env (PROXY_API_KEY), либо из connection.json,
# либо генерим новый. Возвращает persistent ключ — не привязан к жизни процесса.
_API_KEY: str = connection.get_or_create_key(env_key=settings.proxy_api_key)
# Мульти-аккаунт + авто-failover по кредитам (сидируется из .env, если accounts.json нет).
mgr = AccountManager(settings.accounts_file, settings)

# Папка, куда скачиваем сгенерированные Viktor'ом файлы (картинки/видео и пр.).
# Относительно корня проекта; держится локально (в .git/info/exclude, не пушится).
_MEDIA_DIR = os.environ.get("VIKTOR_MEDIA_DIR", "viktor_media").strip() or "viktor_media"


def _human_size(n: int) -> str:
    f = float(n)
    for unit in ("Б", "КБ", "МБ", "ГБ"):
        if f < 1024 or unit == "ГБ":
            return f"{f:.0f} {unit}" if unit == "Б" else f"{f:.1f} {unit}"
        f /= 1024
    return f"{n} Б"


# Текст-намёк, что Viktor сгенерировал/приложил медиа (для финального дозабора файлов).
_MEDIA_HINT_RE = re.compile(
    r"картинк|изображени|видео|гифк|\bgif\b|\.png|\.jpe?g|\.webp|\.mp4|\.webm|\.mov|"
    r"\.pdf|\.svg|files/|вложени|сгенерир|нарисов|отрендер",
    re.IGNORECASE,
)


async def _pull_attachments(msg_files: list, media_root: str, media_base: str,
                            downloaded_ids: set, sc: SlackConnector) -> AsyncGenerator[dict[str, Any], None]:
    """Скачать новые (ещё не виденные) вложения Viktor'а локально, отдавая события."""
    for f in msg_files or []:
        fid = f.get("id") or f.get("url_private") or ""
        if fid in downloaded_ids:
            continue
        downloaded_ids.add(fid)
        try:
            info = await sc.download_file(f, media_root)
        except Exception as e:  # noqa: BLE001 — не валим ход из-за файла
            yield {"type": "git", "text": f"\n> ⚠️ _не скачал {f.get('name')}: {e}_\n"}
            continue
        if not info:
            continue
        try:
            rel = os.path.relpath(info["path"], media_base)
        except ValueError:  # разные диски на Windows
            rel = info["path"]
        verb = "уже скачан" if info.get("skipped") else "сохранён"
        yield {"type": "git",
               "text": f"\n📎 _{verb}:_ `{rel}` ({_human_size(info['size'])})\n"}


@asynccontextmanager
async def _lifespan(app: FastAPI):  # noqa: ARG001
    # Авто-синхронизация модели активного аккаунта при старте
    active = mgr.get(mgr.active_name)
    if active and active.jwt:
        try:
            await mgr._sync_model_from_api(active)
        except Exception:  # noqa: BLE001
            pass
    yield
    await mgr.aclose()


app = FastAPI(title="viktor-openai-proxy", lifespan=_lifespan)

# CORS только для Chrome-расширения (его попап шлёт креды в /api/accounts с origin
# chrome-extension://<id>). НЕ "*", чтобы произвольные сайты не могли дёргать
# локальный API аккаунтов без авторизации.
# CORS:
#   - chrome-extension://… (legacy, для старого Chrome-расширения если кто-то его использует)
#   - app.slack.com / app.viktor.com / slack.com / viktor.com (для wizard'а: пользователь
#     запускает JS-сниппет в DevTools на этих сайтах, который POST'ит к нам токены).
# НЕ "*" — иначе любой посещённый сайт смог бы дёргать наш локальный API.
app.add_middleware(
    CORSMiddleware,
    allow_origin_regex=(
        r"^chrome-extension://.*"
        r"|^https://app\.slack\.com$"
        r"|^https://app\.viktor\.com$"
        r"|^https://slack\.com$"
        r"|^https://viktor\.com$"
    ),
    allow_methods=["*"],
    allow_headers=["*"],
)

# Фоновые задачи живут независимо от HTTP-запроса: даже если OpenCode отвалится
# по таймауту, ожидание Viktor'а и git pull доводятся до конца (файлы локально).
_BG_TASKS: set[asyncio.Task] = set()


async def _run_job(task: str, project_path: str, session_key: str,
                   pull_mode: str, q: "asyncio.Queue") -> None:
    try:
        async for ev in _viktor_response(task, project_path, session_key, pull_mode):
            await q.put(ev)
    except Exception as e:  # noqa: BLE001
        await q.put({"type": "delta", "text": f"\n[proxy error: {e}]"})
    finally:
        await q.put(None)  # сигнал завершения

# Фразы-подтверждения/«в процессе» — это НЕ финальный ответ.
_ACK_MARKERS = (
    "напишу, когда", "напишу когда", "когда будет готово", "когда закончу",
    "как будет готово", "дам знать", "сообщу, когда", "сообщу когда",
    "отпишусь", "скоро отпишусь", "уже делаю", "сейчас сделаю", "сейчас создам",
    # in-progress (настоящее/будущее время) — Viktor ещё работает
    "принял задачу", "принял,", "принял :", "принял 👍", "принял.",
    "создаю", "создам", "делаю", "пушу", "соберу", "собираю",
    "приступаю", "приступил", "работаю над", "генерирую", "формирую", "пишу код",
    "сейчас соберу", "сейчас подготов",
    # аналитические/исследовательские задачи (Viktor «уйдёт» думать)
    "клонирую", "клоню", "вернусь", "вернусь с", "изучу", "изучаю",
    "анализирую", "проанализирую", "разбираю", "разберу", "посмотрю",
    "ознаком", "проверю", "сейчас гляну", "дай пару минут", "дай минуту",
    "i'll let you know", "i will let you know", "will let you know",
    "working on it", "got it, i'll", "creating", "pushing", "cloning",
    "analyzing", "i'll get back",
)

# Признаки ЗАВЕРШЕНИЯ хода (прошедшее время / готово / ошибка / ссылка на пуш).
_DONE_RE = re.compile(
    r"готов|запуш|закоммит|\bкоммит|committed|pushed|\bdone\b|✅|"
    r":white_check_mark:|github\.com|не удал|ошибк|не получ|провал",
    re.IGNORECASE,
)

# Viktor ЖДЁТ пользователя (уточняющий вопрос / нет задачи) — продолжения не будет,
# завершаем быстро.
_CLARIFY_RE = re.compile(
    r"опиши|уточни|что именно|какую задач|что нужно сделать|что сделать|какие именно|"
    r"чем (?:могу )?помоч|жду (?:задач|детал|описани|инструкц)|напиши,? что|"
    r"дай знать,? что|самой задачи нет|задачи (?:пока )?нет|сразу возьмусь|"
    r"готов(?:а)? (?:взять|помоч|приступ)",
    re.IGNORECASE,
)


def _is_ack(text: str) -> bool:
    t = text.lower()
    return any(m in t for m in _ACK_MARKERS)


def _looks_done(text: str) -> bool:
    """Похоже на финал-результат хода (готово/ошибка/пуш)."""
    return bool(_DONE_RE.search(text.strip()))


_MORE_RE = re.compile(
    r"ниже\b|в треде|👇|разлож(?:у|ил|ила).*ниже|детал[иь].*(?:ниже|треде)|"
    r"подробн.*(?:ниже|треде)|см\.?\s*ниже|след(?:ует|ом)\s+сообщ",
    re.IGNORECASE,
)


def _expects_more(text: str) -> bool:
    """Viktor обещает продолжение («Ниже…», «в треде», 👇) — ход НЕ завершён."""
    return bool(_MORE_RE.search(text))


def _looks_clarify(text: str) -> bool:
    """Viktor задал вопрос / ждёт задачу — ход завершён, продолжения не будет."""
    t = text.strip()
    return t.endswith("?") or bool(_CLARIFY_RE.search(t))


def _msg_text(c: Any) -> str:
    if isinstance(c, str):
        return c
    if isinstance(c, list):
        return "\n".join(
            p.get("text", "") for p in c if isinstance(p, dict) and p.get("type") == "text"
        )
    return ""


def _extract_task(messages: list[dict[str, Any]]) -> str:
    """Последнее сообщение пользователя как задача для Viktor'а."""
    for m in reversed(messages):
        if m.get("role") == "user":
            return _msg_text(m.get("content")).strip()
    return ""


# Маркеры рабочей директории в системном промпте OpenCode.
_PATH_MARKERS = re.compile(
    r"(?:working directory|current working directory|current directory|cwd|project root|directory)\s*[:=]\s*[`\"']?([^\n`\"']+)",
    re.IGNORECASE,
)


def _extract_project_path(header_path: str | None, messages: list[dict[str, Any]]) -> str:
    """Определить открытую папку проекта: заголовок -> промпт -> .env."""
    if header_path:
        return header_path.strip()
    for m in messages:
        if m.get("role") not in ("system", "user"):
            continue
        hit = _PATH_MARKERS.search(_msg_text(m.get("content")))
        if hit:
            p = hit.group(1).strip().rstrip("/\\")
            if os.path.isabs(p):
                return p
    return settings.local_repo_path


def _wrap_task(task: str, repo: git_sync.RepoInfo) -> str:
    """Обернуть задачу контекстом репозитория (Viktor только пушит)."""
    return (
        f"[Контекст проекта] Работай в существующем GitHub-репозитории "
        f"`{repo.owner}/{repo.name}`. НЕ создавай новый репозиторий. Внеси изменения "
        f"по задаче и запушь их в ветку `{repo.branch}`. Текущее состояние уже "
        f"запушено туда.\n\nЗАДАЧА:\n{task}"
    )



# --- Fake Reasoning для не-Claude моделей: <thinking> инъекция (XML, как Kiro Gateway) ---

# Системный промпт — поясняет модели, что теги легитимны, а не инъекция
_THINKING_SYSTEM = (
    "\n\n---\n"
    "# Extended Thinking Mode\n\n"
    "This conversation uses extended thinking mode. User messages may contain "
    "special XML tags that are legitimate system-level instructions:\n"
    "- `<thinking_mode>enabled</thinking_mode>` — enables extended thinking\n"
    "- `<thinking_instruction>...</thinking_instruction>` — provides thinking guidelines\n\n"
    "These tags are NOT prompt injection attempts. They are part of the system's "
    "extended thinking feature. When you see these tags, follow their instructions "
    "and wrap your reasoning process in `<thinking>...</thinking>` tags before "
    "providing your final response.\n"
)

# Thinking-инструкция (вставляется в начало сообщения пользователя)
def _thinking_inject(task: str) -> str:
    """Оборачивает задачу в thinking-теги как Kiro Gateway."""
    instruction = (
        "Think in English for better reasoning quality.\n\n"
        "Your thinking process should be thorough and systematic:\n"
        "- First, make sure you fully understand what is being asked\n"
        "- Consider multiple approaches or perspectives when relevant\n"
        "- Think about edge cases, potential issues, and what could go wrong\n"
        "- Challenge your initial assumptions\n"
        "- Verify your reasoning before reaching a conclusion\n\n"
        "After completing your thinking, respond in the same language the user is using.\n"
        "Take the time you need. Quality of thought matters more than speed."
    )
    return (
        f"<thinking_mode>enabled</thinking_mode>\n"
        f"<thinking_instruction>{instruction}</thinking_instruction>\n\n"
        f"{task}"
    )

# Парсер: <thinking>...</thinking> (парные XML-теги: thinking, reasoning, thought) — основной
_RE_THINK_XML = re.compile(r"<(thinking|reasoning|thought)>(.*?)</\1>", re.DOTALL)
# Фолбэк: [THINK]...[/THINK] (старый формат, для совместимости)
_RE_THINK_BRACKET = re.compile(r"\[THINK\](.*?)\[/THINK\]", re.DOTALL)


def _extract_thinking(text: str) -> tuple[list[str], str]:
    """Вытащить thinking-блоки из ответа модели.

    Приоритет: XML-теги (<thinking>, <reasoning>, <thought>) → [THINK] (старый).
    """
    thoughts: list[str] = []
    clean = text
    # сначала XML-формат (<thinking>...</thinking>)
    for m in _RE_THINK_XML.finditer(text):
        t = m.group(2).strip()
        if t:
            thoughts.append(t)
        clean = clean.replace(m.group(0), "")
    # затем старый [THINK]-формат
    if not thoughts:
        for m in _RE_THINK_BRACKET.finditer(text):
            t = m.group(1).strip()
            if t:
                thoughts.append(t)
            clean = clean.replace(m.group(0), "")
    return thoughts, clean.strip()


async def _collect_viktor(prompt: str, sc: SlackConnector, va: ViktorApi | None) -> str:
    """Отправить служебный промпт (handoff-summary) и вернуть финал-текст.

    Без стрима и git. ВАЖНО: читаем ВСЕ источники через fetch_response (тред нашего
    сообщения + top-level + под-треды) — Viktor отвечает непредсказуемо, и summary
    нередко прилетает реплаем/в под-треде; раньше fetch_thread его терял (баг кривого
    авто-компакта). Завершение — по API-статусу треда (если есть JWT), иначе по тишине.
    """
    our_ts = await sc.post_message(prompt)
    last_ts = our_ts
    parts: list[str] = []
    seen_real = False
    expect_more = False
    last_msg = time.monotonic()
    started = time.monotonic()
    use_api = va is not None
    thread_seen = False
    idle_since = None
    api_fail = 0
    while True:
        try:
            msgs = await sc.fetch_response(our_ts, last_ts)
        except Exception:  # noqa: BLE001 — разовый сбой Slack
            msgs = []
        for m in msgs:
            last_ts = m["ts"]
            parts.append(m["text"])
            last_msg = time.monotonic()
            if not _is_ack(m["text"]):
                seen_real = True
                expect_more = _expects_more(m["text"])

        if use_api:
            try:
                st = await va.thread_status(our_ts)
                if st is not None:
                    thread_seen = True
                idle_since = (idle_since or time.monotonic()) if (thread_seen and st == "idle") else None
            except Exception:  # noqa: BLE001 — JWT протух / API недоступно
                api_fail += 1
                if api_fail >= 3:
                    use_api = False

        now = time.monotonic()
        quiet = now - last_msg
        if use_api and thread_seen and idle_since is not None and (now - idle_since) >= 5:
            break
        if not (use_api and thread_seen):
            if seen_real and not expect_more and quiet >= settings.resp_quiet:
                break
            if expect_more and quiet >= max(settings.resp_quiet * 2, 60):
                break
        if (now - started) >= settings.resp_hard_cap:
            break
        await asyncio.sleep(settings.poll_interval)
    # выкидываем ack-строки из summary
    return "\n".join(p for p in parts if not _is_ack(p)).strip()


async def _session_directive(session_key: str, project_path: str, sc: SlackConnector,
                             va: ViktorApi | None) -> AsyncGenerator[dict[str, Any], None]:
    """Решает режим сессии и отдаёт план через финальный yield:
       {'type':'plan', 'directive':str}.

    Каждый ход постится top-level. Новая сессия/смена папки/ротация → преамбла
    «🆕 забудь прежний контекст»; продолжение → без преамблы (непрерывность даёт
    память DM самого Viktor). Ротация по числу ходов = + handoff-summary.
    """
    turns = store.turns(session_key)
    prev_path = store.folder(session_key)
    folder_changed = bool(prev_path and project_path and prev_path != project_path)
    directive = ""

    if turns == 0 or folder_changed:
        directive = sessions.new_session_preamble()
        store.reset(session_key, time.time(), project_path)
        why = "смена папки" if folder_changed else "новая сессия"
        print(f"[session] {session_key}: {why} -> чистый контекст", flush=True)
    elif settings.session_compact and turns >= settings.session_max_turns:
        summary = ""
        if settings.session_summary:
            yield {"type": "git", "text": "> 🔄 _авто-компакт: снимаю summary…_\n\n"}
            try:
                summary = await _collect_viktor(sessions.HANDOFF_PROMPT, sc, va)
            except Exception:  # noqa: BLE001
                summary = ""
        directive = sessions.compact_preamble(summary)
        store.reset(session_key, time.time(), project_path)
        print(f"[session] {session_key}: РОТАЦИЯ после {turns} ходов "
              f"(summary={len(summary)} симв.)", flush=True)
    else:
        print(f"[session] {session_key}: продолжение (ход {turns + 1})", flush=True)

    yield {"type": "plan", "directive": directive}


async def _viktor_response(task: str, project_path: str, session_key: str = "",
                           pull_mode: str = "auto") -> AsyncGenerator[dict[str, Any], None]:
    """Синхронизировать репо, отправить задачу и стримить ответы Viktor'а.

    Выдаёт события {"type": "delta"|"git"|"done", "text": ...}.
    Завершаемся, когда получен не-ack ответ и после него тишина resp_quiet,
    либо по жёсткому потолку resp_hard_cap.
    """
    # --- выбор аккаунта Viktor (режим auto=failover / manual=ручной) ---
    acct = await mgr.active(settings.account_min_credits)
    if acct is None:
        reason = ("Все аккаунты Viktor исчерпали кредиты" if mgr.accounts
                  else "Аккаунты Viktor не настроены")
        yield {"type": "delta", "text": f"🚫 {reason}. Добавь/пополни/выбери аккаунт через TUI "
               f"(`valencyviktor`)."}
        yield {"type": "done", "text": ""}
        return
    sc = mgr.slack_for(acct)
    va = mgr.vapi_for(acct)
    # Сменился ли аккаунт с прошлого хода этой сессии (авто-failover ИЛИ ручной выбор)?
    prev_acct = store.account(session_key) if session_key else ""
    switched = bool(prev_acct and prev_acct != acct.name)
    if switched:
        bal = await mgr.credits(acct)
        tail = f" (баланс {bal})" if bal is not None else ""
        why = "ручной выбор" if mgr.mode == "manual" else "кредиты прежнего кончились →"
        yield {"type": "git", "text": f"> 🔁 _{why} аккаунт **{acct.name}**{tail}._\n\n"}

    # --- сессия: новый тред / продолжение / ротация ---
    plan = {"mode": "new", "directive": "", "root": ""}
    if session_key:
        async for ev in _session_directive(session_key, project_path, sc, va):
            if ev.get("type") == "plan":
                plan = ev
            else:
                yield ev
    directive = plan["directive"]
    if session_key:
        store.set_account(session_key, acct.name, time.time())
    # Сменился аккаунт = другой Viktor без памяти прежнего диалога → стартуем у него
    # чистую сессию (преамбула), даже если сессия считалась продолжением.
    if switched and not directive:
        directive = sessions.new_session_preamble()

    # Папка для скачивания медиа: корень проекта (или fallback). Захватываем ДО того,
    # как project_path может обнулиться при ошибке синхронизации.
    media_base = project_path if (project_path and os.path.isdir(project_path)) \
        else (settings.local_repo_path or os.getcwd())
    media_root = os.path.join(media_base, _MEDIA_DIR)

    # --- предварительная синхронизация: репо + push локального состояния ---
    session_branch = ""  # ветка viktor/{id} для изоляции изменений
    saved_branch = ""    # на какую ветку вернуться после сессии
    if project_path and os.path.isdir(project_path):
        try:
            owner = await git_sync.detect_owner(settings.github_owner)
            repo = await git_sync.ensure_repo(project_path, owner)
            git_sync.ensure_local_exclude(project_path, _MEDIA_DIR + "/")  # медиа не пушим
            push_report = await git_sync.push_local(project_path, repo.branch)
            if push_report:
                print(f"[sync] {repo.owner}/{repo.name} branch={repo.branch}: {push_report}", flush=True)
                yield {"type": "git", "text": f"> 🔧 _sync `{repo.owner}/{repo.name}` — {push_report}_\n\n"}
            # Создаём ветку viktor/{session} и переключаемся — изолируем изменения Viktor'а
            sess_id = session_key or uuid.uuid4().hex[:8]
            saved_branch = await git_sync.current_branch(project_path)
            session_branch = await git_sync.session_branch(project_path, sess_id)
            repo.branch = session_branch  # обновляем для _wrap_task
            yield {"type": "git", "text": f"> 🌿 _ветка `{session_branch}`_\n\n"}
            task = _wrap_task(task, repo)
        except Exception as e:  # noqa: BLE001
            yield {"type": "git", "text": f"> ⚠️ _sync ошибка: {e}_\n\n"}
            project_path = ""
    else:
        project_path = ""

    if directive:
        task = directive + task  # преамбла «🆕 новая сессия» только для нового/компакта

    # ВСЕГДА постим top-level: сообщение видно в DM, Viktor отвечает штатно и оно
    # не «зарывается» реплаем в старый тред. Непрерывность держится памятью DM
    # самого Viktor, изоляция новой сессии — преамбулой выше.
    our_ts = await sc.post_message(task)
    root = our_ts
    if session_key:
        store.bump(session_key, time.time(), project_path, root)

    seen_real = False
    seen_done = False
    seen_clarify = False
    expect_more = False  # Viktor обещал продолжение («Ниже…», «в треде»)
    last_ts = our_ts
    last_msg_time = time.monotonic()
    started = time.monotonic()
    heartbeat = time.monotonic()
    # детект завершения по API-статусу треда (надёжнее текста); текст — фолбэк
    use_api = va is not None
    thread_seen = False     # тред agent-run появился в API
    idle_since = None       # когда API впервые показал статус idle
    api_fail = 0

    slack_fail = 0
    downloaded_ids: set[str] = set()  # уже скачанные вложения (без повторов)
    media_hint = False  # Viktor упоминал/прикладывал медиа -> финальный дозабор файлов
    reasoning_seen: set[str] = set()  # ID уже показанных reasoning-блоков
    # --- применение модели из настроек аккаунта (reasoning — локально) ---
    target_model = acct.model
    if target_model and va is not None:
        try:
            ms = await va.get_model_settings()
            cur = ms.get("default_agent_model", "")
            if cur != target_model:
                await va.update_model_settings({"default_agent_model": target_model})
                print(f"[model] переключён на {target_model}", flush=True)
            cur = target_model
        except Exception as e:  # noqa: BLE001
            cur = ""
            print(f"[model] не удалось применить: {e}", flush=True)
    elif va is not None:
        try:
            r = await va.http.get("https://api.viktor.com/api/coworker/model-settings")
            r.raise_for_status()
            cur = r.json().get("default_agent_model", "")
        except Exception:
            cur = ""
    else:
        cur = ""
    # Нативный reasoning (через Viktor API get_reasoning) работает для Claude.
    # Для GPT/Gemini/Kimi — инжектим thinking-теги в формате Kiro Gateway.
    # Preset-модели (smartest/balanced) — Claude-based, нативный работает.
    _NATIVE_REASONING = {"claude-", "smartest", "balanced"}
    needs_inject = bool(cur) and not any(cur.startswith(p) or cur == p for p in _NATIVE_REASONING)
    if needs_inject:
        # Системный промпт (пояснение) + thinking-инструкция (в начале задачи)
        task = _THINKING_SYSTEM + _thinking_inject(task)
        print(f"[thinking] thinking injection for {cur}", flush=True)
    else:
        print(f"[thinking] native reasoning for {cur}", flush=True)
    while True:
        # Viktor отвечает непредсказуемо (тред нашего сообщения, top-level, под-тред),
        # поэтому всегда читаем все источники — иначе можно «не увидеть» ответ.
        try:
            msgs = await sc.fetch_response(root, last_ts)
            slack_fail = 0
        except Exception as e:  # noqa: BLE001 — разовый сбой/протух токен
            slack_fail += 1
            msgs = []
            if slack_fail >= 5:
                yield {"type": "delta", "text": f"\n[Slack недоступен: {e}]"}
                break
        for m in msgs:
            last_ts = m["ts"]
            text = m["text"]
            # Парсим [THINK]-блоки из ответа (fake reasoning для не-Claude)
            thoughts, clean = _extract_thinking(text)
            if thoughts:
                for t in thoughts:
                    yield {"type": "delta", "text": f"{t}\n\n"}
            yield {"type": "delta", "text": (clean or text.strip()) + "\n\n"}
            last_msg_time = time.monotonic()
            heartbeat = last_msg_time
            # Вложения Viktor'а (сгенерированные картинки/видео) — качаем локально.
            if (m.get("files") or []) or _MEDIA_HINT_RE.search(text):
                media_hint = True
            async for ev in _pull_attachments(m.get("files"), media_root, media_base, downloaded_ids, sc):
                yield ev
                last_msg_time = time.monotonic()
                heartbeat = last_msg_time
            if not _is_ack(text):
                seen_real = True
                if _expects_more(text):
                    expect_more = True
                    seen_done = False
                    seen_clarify = False
                else:
                    expect_more = False
                    if _looks_done(text):
                        seen_done = True
                    if _looks_clarify(text):
                        seen_clarify = True

        # --- основной сигнал: статус agent-run треда из API Viktor ---
        if use_api:
            try:
                st = await va.thread_status(our_ts)
                if st is not None:
                    thread_seen = True
                idle_since = (idle_since or time.monotonic()) if (thread_seen and st == "idle") else None
            except Exception:  # noqa: BLE001 — JWT протух / API недоступно
                api_fail += 1
                if api_fail >= 3:
                    use_api = False
                    print("[api] статус недоступен — фолбэк на текстовый детект", flush=True)

        # --- reasoning: поллим API на каждой итерации, пока Viktor думает ---
        if use_api and thread_seen:
            try:
                blocks = await va.get_reasoning(our_ts)
                for b in blocks:
                    if b["id"] not in reasoning_seen:
                        reasoning_seen.add(b["id"])
                        yield {"type": "delta", "text": f"{b['text']}\n\n"}
                        last_msg_time = time.monotonic()
                        heartbeat = last_msg_time
                        print(f"[reasoning] блок {len(reasoning_seen)} ({len(b['text'])} симв.)", flush=True)
            except Exception:  # noqa: BLE001 — разовый сбой / тред ещё не создан
                pass

        now = time.monotonic()
        quiet = now - last_msg_time

        # API сказал idle -> Viktor выдал ВСЁ; небольшой грейс дочитать остатки Slack
        if use_api and thread_seen and idle_since is not None and (now - idle_since) >= 5:
            break

        # Фолбэк (API нет / тред ещё не виден): текстовые эвристики
        if not (use_api and thread_seen):
            if not expect_more and seen_clarify and quiet >= min(settings.resp_quiet, 6):
                break
            if not expect_more and seen_done and quiet >= settings.resp_quiet:
                break
            if expect_more and quiet >= max(settings.resp_quiet * 2, 60):
                break
            if not expect_more and seen_real and quiet >= settings.resp_quiet * 3:
                break
        # жёсткий потолок
        if (now - started) >= settings.resp_hard_cap:
            yield {"type": "delta", "text": "\n[превышено время ожидания ответа Viktor'а]\n"}
            break
        # heartbeat, чтобы соединение не отвалилось при долгой работе
        if now - heartbeat >= 10:
            heartbeat = now
            yield {"type": "heartbeat"}

        await asyncio.sleep(settings.poll_interval)

    # Финальный дозабор вложений: Slack нередко прикрепляет файл чуть ПОЗЖЕ текста,
    # поэтому на момент чтения сообщения files был пуст. Если Viktor намекал на медиа —
    # перечитываем все его сообщения за ход и дотягиваем недостающие файлы (с ретраями).
    if media_hint:
        for attempt in range(5):
            await asyncio.sleep(2)
            try:
                final_msgs = await sc.fetch_response(root, our_ts)
            except Exception:  # noqa: BLE001
                final_msgs = []
            got_new = False
            for m in final_msgs:
                async for ev in _pull_attachments(m.get("files"), media_root, media_base, downloaded_ids, sc):
                    yield ev
                    if "сохранён" in ev.get("text", ""):
                        got_new = True
            if got_new or downloaded_ids:
                break

    # --- финальный дозор reasoning: могли быть блоки, прилетевшие в последний момент ---
    if va is not None and use_api:
        try:
            blocks = await va.get_reasoning(our_ts)
            for b in blocks:
                if b["id"] not in reasoning_seen:
                    reasoning_seen.add(b["id"])
                    yield {"type": "delta", "text": f"{b['text']}\n\n"}
                    print(f"[reasoning] финал блок {len(reasoning_seen)} ({len(b['text'])} симв.)", flush=True)
        except Exception:  # noqa: BLE001
            pass

    # Viktor закончил — возвращаемся на основную ветку
    if session_branch and saved_branch:
        try:
            await git_sync.switch_back(project_path, saved_branch)
            yield {"type": "git", "text":
                f"\n🌿 _Viktor работал в ветке `{session_branch}`. "
                f"Чтобы применить изменения: `git merge {session_branch}`_\n"}
        except Exception:  # noqa: BLE001
            pass

    yield {"type": "done", "text": ""}


# ---------- OpenAI-совместимые эндпоинты ----------

def _check_auth(authorization: str | None) -> None:
    """Проверка `Authorization: Bearer <key>`.

    Ключ всегда есть: либо из env, либо из connection.json (создаётся
    автоматом при первом старте). Принимаем также любой sk-... префикс,
    если ключ пуст (не должно быть в нормальной работе, но на всякий случай).
    """
    expected = connection.current_key() or _API_KEY
    if not expected:
        # На всякий случай: не должно случаться, но если файл удалили на лету —
        # просим прислать любой Bearer, чтобы не ломать клиент.
        return
    if authorization != f"Bearer {expected}":
        raise HTTPException(status_code=401, detail="invalid api key")


def _is_tool_followup(messages: list[dict[str, Any]]) -> bool:
    """Запрос-возврат после нашего tool_call (последнее сообщение — tool)."""
    return bool(messages) and messages[-1].get("role") == "tool"


async def _do_pull(path: str, branch: str) -> str:
    try:
        pull = await git_sync.pull(path, branch)
        print(f"[pull] {path}: {pull.splitlines()[0] if pull else ''}", flush=True)
        return "📥 **git pull**\n```\n" + pull + "\n```\n"
    except Exception as e:  # noqa: BLE001
        return f"\n\n[git pull ошибка: {e}]"


async def _apply_pull_answer(ans: str, project_path: str, sess: str) -> str:
    """Обработать ответ на вопрос про git pull: ans = YES|NO|ALWAYS|NEVER."""
    if ans == "ALWAYS":
        store.set_pull_pref(sess, "auto", time.time())
    elif ans == "NEVER":
        store.set_pull_pref(sess, "never", time.time())
    if ans in ("YES", "ALWAYS"):
        if not project_path:
            return "Не удалось определить папку проекта для git pull."
        branch = await git_sync.current_branch(project_path)
        res = await _do_pull(project_path, branch)
        if ans == "ALWAYS":
            res += "\n_(Дальше буду подтягивать автоматически — /pullask вернёт вопрос.)_"
        return res
    # NO / NEVER
    note = " Больше не спрашиваю в этой сессии (/pullask вернёт)." if ans == "NEVER" else ""
    return "Ок, не подтягиваю." + note


def _text_response(text: str, stream: bool, cid: str, created: int, model: str):
    """Простой текстовый ответ (стаб/команда/followup), Viktor не дёргается."""
    if stream:
        async def g() -> AsyncGenerator[str, None]:
            yield _sse(_chunk(cid, created, model, {"role": "assistant"}))
            if text:
                yield _sse(_chunk(cid, created, model, {"content": text}))
            yield _sse(_chunk(cid, created, model, {}, finish="stop"))
            yield "data: [DONE]\n\n"
        return StreamingResponse(g(), media_type="text/event-stream")
    return JSONResponse({
        "id": cid, "object": "chat.completion", "created": created, "model": model,
        "choices": [{"index": 0, "message": {"role": "assistant", "content": text},
                     "finish_reason": "stop"}],
        "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
    })


# --- Нативный вопрос OpenCode про git pull (tool `question`, 4 варианта) ---
_QUESTION_TOOL_RE = re.compile(r"^(question|ask|ask_user|user_question)$", re.IGNORECASE)
_PULL_QUESTION = "Viktor внёс изменения в репозиторий. Сделать git pull в локальную папку?"
_PULL_OPTIONS = [
    {"label": "Да", "description": "Подтянуть изменения Viktor'а в локальную папку сейчас"},
    {"label": "Нет", "description": "Не подтягивать (разово)"},
    {"label": "Всегда разрешать", "description": "Подтягивать автоматически и дальше, без вопроса"},
    {"label": "Нет, больше не спрашивать", "description": "Не подтягивать и больше не спрашивать в этой сессии"},
]
_CHANGED_LABEL = {"A": "➕ добавлен", "M": "✏️ изменён", "D": "🗑️ удалён",
                  "R": "↪️ переименован", "C": "⧉ скопирован"}
_ALWAYS_RE = re.compile(r"всегда|always", re.IGNORECASE)
_NEVER_RE = re.compile(r"больше не спрашивать|не спрашивай|don'?t ask|stop asking|никогда|never", re.IGNORECASE)
_YES_RE = re.compile(r"^(да|yes|y|ага|давай|pull|ок|ok)(\s|[.,!?:)\]]|$)", re.IGNORECASE)
_NO_RE = re.compile(r"^(нет|no|n|не надо|skip|не нужно)(\s|[.,!?:)\]]|$)", re.IGNORECASE)


def _find_question_tool(tools: Any) -> str | None:
    for t in tools or []:
        fn = t.get("function", t) if isinstance(t, dict) else {}
        if _QUESTION_TOOL_RE.match(fn.get("name") or ""):
            return fn.get("name")
    return None


def _format_changed(lines: list[str]) -> str:
    out = []
    for ln in lines[:40]:
        parts = ln.split("\t")
        st = (parts[0] or " ")[:1].upper()
        out.append(f"• {_CHANGED_LABEL.get(st, st)} {' → '.join(parts[1:])}")
    if len(lines) > 40:
        out.append(f"• …и ещё {len(lines) - 40}")
    return "\n".join(out)


def _question_tool_call(cid: str, created: int, model: str, tool_name: str, question_text: str) -> dict[str, Any]:
    args = {"questions": [{
        "question": question_text, "header": "git pull",
        "options": _PULL_OPTIONS, "multiple": False,
    }]}
    return _chunk(cid, created, model, {"tool_calls": [{
        "index": 0, "id": "call_" + uuid.uuid4().hex[:24], "type": "function",
        "function": {"name": tool_name, "arguments": json.dumps(args, ensure_ascii=False)},
    }]})


def _classify_pull_answer(text: str) -> str | None:
    t = (text or "").strip().lower().strip("[]\"'` \n")
    if not t:
        return None
    if _ALWAYS_RE.search(t):
        return "ALWAYS"
    if _NEVER_RE.search(t):
        return "NEVER"
    if _YES_RE.match(t):
        return "YES"
    if _NO_RE.match(t):
        return "NO"
    return None


def _extract_pull_answer(messages: list[dict[str, Any]]) -> str | None:
    """Ответ на наш вопрос про pull — приходит tool-сообщением с label'ом.

    Смотрим с конца: первое tool-сообщение классифицируем; если раньше встретили
    user-сообщение — это новый ход (не ответ), чтобы не путать «Да, создай файл».
    """
    for m in reversed(messages):
        role = m.get("role")
        if role == "tool":
            return _classify_pull_answer(_msg_text(m.get("content")))
        if role == "user":
            return None
    return None


@app.get("/v1/models")
async def list_models() -> dict[str, Any]:
    """Список моделей: viktor (дефолт) + модель активного аккаунта."""
    data = [{"id": "viktor", "object": "model", "created": 0, "owned_by": "viktor"}]
    try:
        acct = await mgr.active(settings.account_min_credits)
        if acct and acct.model:
            data.append({"id": acct.model, "object": "model", "created": 0,
                         "owned_by": f"viktor:{acct.name}"})
    except Exception:  # noqa: BLE001
        pass
    return {"object": "list", "data": data}


@app.get("/health")
async def health() -> dict[str, str]:
    return {"status": "ok"}


# ---------- Корневой роут (TUI ставит свой UI поверх REST API) ----------

@app.get("/")
async def _root() -> dict[str, str]:
    return {
        "service": "viktor-valency proxy",
        "by": "Valency Labs",
        "openai_api": "/v1/chat/completions",
        "accounts_api": "/api/accounts",
        "models_api": "/api/models/list",
        "health": "/health",
    }


@app.get("/api/accounts")
async def _accounts_list() -> dict[str, Any]:
    rows = await mgr.snapshot(settings.account_min_credits)
    return {"accounts": rows, "active": mgr.active_name, "mode": mgr.mode,
            "min_credits": settings.account_min_credits}


@app.post("/api/accounts/mode")
async def _accounts_mode(request: Request) -> dict[str, Any]:
    mode = (await request.json()).get("mode", "")
    return {"ok": mgr.set_mode(mode), "mode": mgr.mode}


@app.post("/api/accounts")
async def _accounts_upsert(request: Request) -> dict[str, Any]:
    body = await request.json()
    try:
        acct = mgr.upsert(body)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    return {"ok": True, "name": acct.name}


@app.post("/api/accounts/detect")
async def _accounts_detect(request: Request) -> dict[str, Any]:
    b = await request.json()
    return await mgr.detect(b.get("slack_token", "").strip(),
                            b.get("slack_cookie_d", "").strip(),
                            b.get("jwt", "").strip())


@app.post("/api/accounts/remove")
async def _accounts_remove(request: Request) -> dict[str, Any]:
    name = (await request.json()).get("name", "")
    return {"ok": mgr.remove(name)}


@app.post("/api/accounts/activate")
async def _accounts_activate(request: Request) -> dict[str, Any]:
    name = (await request.json()).get("name", "")
    return {"ok": mgr.set_active(name)}


@app.post("/api/accounts/wizard-save")
async def _accounts_wizard_save(request: Request) -> dict[str, Any]:
    """Сохранить аккаунт, собранный авто-wizard'ом. Сначала detect (DM/user/email/баланс),
    потом upsert. Возвращает имя аккаунта + что нашли при detect (для UI-фидбека)."""
    body = await request.json()
    name = str(body.get("name", "")).strip()
    if not name:
        raise HTTPException(status_code=400, detail="имя аккаунта обязательно")

    slack_token = str(body.get("slack_token", "")).strip()
    slack_cookie_d = str(body.get("slack_cookie_d", "")).strip()
    jwt = str(body.get("jwt", "")).strip()

    # 1. Detect — найдём Viktor DM/user/email/баланс по введённым кредам
    detect = {}
    try:
        detect = await mgr.detect(slack_token, slack_cookie_d, jwt)
    except Exception as e:  # noqa: BLE001
        detect = {"error": str(e)[:200]}

    # 2. Upsert аккаунта с тем, что собрали
    payload = {
        "name": name,
        "slack_token": slack_token,
        "slack_cookie_d": slack_cookie_d,
        "viktor_dm": detect.get("viktor_dm", "") or "",
        "viktor_user": detect.get("viktor_user", "") or "",
        "jwt": jwt,
        "enabled": True,
    }
    try:
        acct = mgr.upsert(payload)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

    return {
        "ok": True,
        "name": acct.name,
        "detect": detect,
        "has_jwt": bool(jwt),
        "has_slack": bool(slack_token),
    }


# ---------- Модели и reasoning (дашборд) ----------

@app.get("/api/models/list")
async def _models_list(request: Request) -> dict[str, Any]:
    """Список доступных моделей + текущие настройки аккаунта.

    ?name=... — имя аккаунта (по умолчанию активный).
    """
    name = request.query_params.get("name", mgr.active_name)
    return await mgr.get_models(name)


@app.get("/api/models/debug")
async def _models_debug(request: Request) -> dict[str, Any]:
    """Отладка: сырые ответы API Viktor по моделям.

    ?name=... — имя аккаунта.
    """
    name = request.query_params.get("name", mgr.active_name)
    acct = mgr.get(name)
    if not acct or not acct.jwt:
        return {"error": "Нет JWT у аккаунта"}
    v = mgr.vapi_for(acct)
    if v is None:
        return {"error": "API недоступен"}
    return await v.debug_models()


@app.post("/api/models/set")
async def _models_set(request: Request) -> dict[str, Any]:
    """Установить модель и/или уровень reasoning.

    body: {name, model?, reasoning_level?}  // reasoning_level: "N"|"L"|"M"|"H"
    """
    body = await request.json()
    name = str(body.get("name", mgr.active_name)).strip()
    if not name:
        raise HTTPException(status_code=400, detail="имя аккаунта обязательно")
    model = str(body.get("model", "")).strip()
    rl = body.get("reasoning_level")
    reasoning_level: str | None = str(rl).upper() if rl is not None and str(rl).upper() in ("N","L","M","H") else None
    return await mgr.set_model(name, model, reasoning_level)


# ---------- Connection (API key + база) ----------

@app.get("/api/connection")
async def _connection_info() -> dict[str, Any]:
    """Возвращает параметры подключения для отображения в TUI / IDE."""
    key = connection.current_key() or _API_KEY
    locked = connection.is_env_locked()
    base_url = f"http://127.0.0.1:{settings.proxy_port}/v1"
    # Список моделей в формате id'ов
    models = ["viktor"]
    try:
        acct = await mgr.active(settings.account_min_credits)
        if acct and acct.model and acct.model not in models:
            models.append(acct.model)
    except Exception:
        pass
    return {
        "base_url": base_url,
        "host": "127.0.0.1",
        "port": settings.proxy_port,
        "api_key": key,
        "env_locked": locked,
        "models": models,
        "default_model": "viktor",
    }


@app.post("/api/connection/regenerate-key")
async def _connection_regen() -> dict[str, Any]:
    """Сгенерировать новый API-ключ и сохранить в connection.json.
    Старый ключ сразу перестанет работать.
    """
    try:
        new_key = connection.regenerate_key()
        return {"ok": True, "api_key": new_key}
    except RuntimeError as e:
        raise HTTPException(status_code=409, detail=str(e))


def _sse(obj: dict[str, Any]) -> str:
    return "data: " + json.dumps(obj, ensure_ascii=False) + "\n\n"


def _chunk(cid: str, created: int, model: str, delta: dict, finish=None) -> dict:
    return {
        "id": cid, "object": "chat.completion.chunk", "created": created,
        "model": model, "choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
    }


@app.post("/v1/chat/completions")
async def chat_completions(request: Request, authorization: str | None = Header(default=None)):
    _check_auth(authorization)
    body = await request.json()
    model = body.get("model", "viktor")
    messages = body.get("messages", [])
    stream = bool(body.get("stream", False))
    tools = body.get("tools")

    cid = "chatcmpl-" + uuid.uuid4().hex
    created = int(time.time())

    # Ответ на наш вопрос про git pull (нативный tool question): Да/Нет/Всегда/Никогда.
    pull_ans = _extract_pull_answer(messages)
    if pull_ans:
        project_path = _extract_project_path(request.headers.get("x-project-path"), messages)
        sess = sessions.extract_session(request.headers, body).hash
        print(f"[req] ответ на git pull: {pull_ans}", flush=True)
        return _text_response(await _apply_pull_answer(pull_ans, project_path, sess),
                              stream, cid, created, model)

    # Прочий tool-результат (не наш вопрос) — просто завершаем, Viktor не дёргаем.
    if _is_tool_followup(messages):
        print("[req] tool-followup — завершаю", flush=True)
        return _text_response("", stream, cid, created, model)

    task = _extract_task(messages)
    if not task:
        raise HTTPException(status_code=400, detail="no user message")

    project_path = _extract_project_path(request.headers.get("x-project-path"), messages)
    sinfo = sessions.extract_session(request.headers, body)
    sess = sinfo.hash
    is_title = sessions.is_title_request(messages)
    print(f"[req] stream={stream} session={sinfo.source}:{sess} title={is_title} "
          f"project_path={project_path!r} task={task[:80]!r}", flush=True)

    # Команда управления git pull (/nopull, /pullask, /pullauto) — меняем настройку
    # сессии и подтверждаем, Viktor не дёргаем.
    pull_cmd = sessions.pull_command(task)
    if pull_cmd:
        store.set_pull_pref(sess, pull_cmd, time.time())
        print(f"[req] pull-команда -> pref={pull_cmd}", flush=True)
        return _text_response(sessions.pull_pref_message(pull_cmd), stream, cid, created, model)

    # Вспомогательный запрос (генерация заголовка чата или нет рабочей папки) —
    # не дёргаем Viktor и не считаем за ход сессии.
    if is_title or not project_path:
        base = sessions.strip_title_prefix(task) if is_title else task
        stub = sessions.make_title(base) if base.strip() else "Viktor"
        print(f"[req] служебный ({'title' if is_title else 'no-path'}) — стаб, Viktor не дёргаю",
              flush=True)
        return _text_response(stub, stream, cid, created, model)

    # режим git pull для сессии: skip (/nopull) | auto | ask (нативный вопрос question)
    question_tool = _find_question_tool(tools)
    pref = store.pull_pref(sess)
    if pref == "never":
        pull_mode = "skip"
    elif pref == "auto" or not settings.git_pull_ask:
        pull_mode = "auto"
    elif question_tool:
        pull_mode = "ask"
    else:
        pull_mode = "auto"  # нет tool question — тянем сами (с показом файлов)

    # запускаем работу как независимую фоновую задачу + очередь событий.
    q: asyncio.Queue = asyncio.Queue()
    job = asyncio.create_task(_run_job(task, project_path, sess, pull_mode, q))
    _BG_TASKS.add(job)
    job.add_done_callback(_BG_TASKS.discard)

    if stream:
        async def gen() -> AsyncGenerator[str, None]:
            yield _sse(_chunk(cid, created, model, {"role": "assistant"}))
            pull_req = None
            while True:
                ev = await q.get()
                if ev is None:
                    break
                t = ev.get("type")
                if t == "heartbeat":
                    yield ": ping\n\n"  # SSE-комментарий: keepalive без контента
                elif t == "needs_pull":
                    pull_req = ev
                elif t in ("delta", "git") and ev.get("text"):
                    yield _sse(_chunk(cid, created, model, {"content": ev["text"]}))
            # needs_pull приходит только в режиме вопроса → нативный tool `question`
            # OpenCode (Да/Нет/Всегда/Никогда) + список изменённых файлов в тексте.
            if pull_req:
                files = _format_changed(pull_req.get("changed", []))
                if files:
                    yield _sse(_chunk(cid, created, model,
                               {"content": "\n📦 Viktor изменил файлы:\n" + files + "\n"}))
                qtext = _PULL_QUESTION + (("\n\nИзменённые файлы:\n" + files) if files else "")
                yield _sse(_question_tool_call(cid, created, model, question_tool, qtext))
                yield _sse(_chunk(cid, created, model, {}, finish="tool_calls"))
            else:
                yield _sse(_chunk(cid, created, model, {}, finish="stop"))
            yield "data: [DONE]\n\n"

        return StreamingResponse(gen(), media_type="text/event-stream")

    # не-стрим: дренируем очередь до конца
    full = ""
    pull_req = None
    while True:
        ev = await q.get()
        if ev is None:
            break
        if ev.get("type") == "needs_pull":
            pull_req = ev
        elif ev.get("type") in ("delta", "git") and ev.get("text"):
            full += ev["text"]

    if pull_req:  # режим вопроса → tool `question` с 4 вариантами
        files = _format_changed(pull_req.get("changed", []))
        qtext = _PULL_QUESTION + (("\n\nИзменённые файлы:\n" + files) if files else "")
        content = (full.strip() + ("\n\n📦 Viktor изменил файлы:\n" + files if files else "")).strip()
        message = {"role": "assistant", "content": content or None, "tool_calls": [{
            "id": "call_" + uuid.uuid4().hex[:24], "type": "function",
            "function": {"name": question_tool, "arguments": json.dumps(
                {"questions": [{"question": qtext, "header": "git pull",
                                "options": _PULL_OPTIONS, "multiple": False}]}, ensure_ascii=False)}}]}
        finish = "tool_calls"
    else:  # авто/skip-режим: pull уже сделан/пропущен в фоне и вошёл в full
        message = {"role": "assistant", "content": full.strip()}
        finish = "stop"

    return JSONResponse({
        "id": cid, "object": "chat.completion", "created": created, "model": model,
        "choices": [{"index": 0, "message": message, "finish_reason": finish}],
        "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
    })


def main() -> None:
    import uvicorn

    url = f"http://127.0.0.1:{settings.proxy_port}"
    print(f"✅ viktor-valency proxy ready at {url}/v1  (model: viktor)\n"
          f"   ⚙️  Accounts API: {url}/api/accounts\n"
          f"   ❤️  Health: {url}/health\n"
          f"   by Valency Labs", flush=True)
    uvicorn.run(app, host="127.0.0.1", port=settings.proxy_port, log_level="warning")


if __name__ == "__main__":
    main()
