#!/usr/bin/env python3
"""
Telegram MCP Daemon — HTTP API + единый Telegram polling.
==========================================================
Один фоновый процесс. Каждая сессия Claude Code регистрируется через API
и получает свой тред (топик) в Telegram-группе. Входящие сообщения
от пользователя копятся в очереди сессии и отдаются при запросе.

Запуск:
  python -m telegram_mcp_bridge.daemon

Конфиг: ~/.claude/channels/telegram-daemon/.env
  TELEGRAM_BOT_TOKEN=...
  TELEGRAM_CHAT_ID=...
  DAEMON_PORT=8765

API:
  POST /api/register          {name: str}        -> {session_id, thread_id}
  POST /api/send              {sid, text}         -> {message_id}
  POST /api/send_file         multipart(sid, file, caption?) -> {message_id}
  POST /api/send_with_options {sid, text, options} -> {message_id}
  GET  /api/poll/{sid}?timeout=30                 -> {messages: [...]}
  POST /api/unregister        {sid}               -> ok
  GET  /api/sessions                              -> [{sid, name, thread_id}]
  GET  /api/health                                -> ok
"""

import mimetypes
import os
import sys
import asyncio
import time
import uuid
import httpx
from pathlib import Path
from aiohttp import web

# --- Конфигурация ---
CONFIG_DIR = Path.home() / ".claude" / "channels" / "telegram-daemon"
ENV_FILE = CONFIG_DIR / ".env"

if ENV_FILE.exists():
    for line in ENV_FILE.read_text().splitlines():
        if "=" in line and not line.startswith("#"):
            key, _, val = line.partition("=")
            key, val = key.strip(), val.strip()
            if key and key not in os.environ:
                os.environ[key] = val

BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
DAEMON_PORT = int(os.environ.get("DAEMON_PORT", "8765"))

if not BOT_TOKEN or not CHAT_ID:
    print(f"[daemon] TELEGRAM_BOT_TOKEN и TELEGRAM_CHAT_ID обязательны", file=sys.stderr)
    print(f"[daemon] Задайте в {ENV_FILE}", file=sys.stderr)
    raise SystemExit(1)

API_BASE = f"https://api.telegram.org/bot{BOT_TOKEN}"

# --- Состояние ---
# sid (str) -> { thread_id: int, queue: asyncio.Queue, name: str, created: float, last_active: float }
sessions: dict[str, dict] = {}
last_update_id = 0
SESSION_TTL = 3600 * 2  # 2 часа без активности — удалить сессию

_tg_client: httpx.AsyncClient | None = None


def _get_tg_client() -> httpx.AsyncClient:
    global _tg_client
    if _tg_client is None:
        _tg_client = httpx.AsyncClient(timeout=60)
    return _tg_client


async def tg_request(method: str, params: dict | None = None) -> dict:
    client = _get_tg_client()
    resp = await client.post(f"{API_BASE}/{method}", json=params or {})
    resp.raise_for_status()
    return resp.json()


async def create_thread(name: str) -> int:
    result = await tg_request("createForumTopic", {
        "chat_id": CHAT_ID,
        "name": name,
    })
    return result["result"]["message_thread_id"]


async def send_tg_message(text: str, thread_id: int | None) -> int:
    params = {
        "chat_id": CHAT_ID,
        "text": text,
        "parse_mode": "Markdown",
    }
    if thread_id is not None:
        params["message_thread_id"] = thread_id
    try:
        result = await tg_request("sendMessage", params)
        return result["result"]["message_id"]
    except httpx.HTTPStatusError as e:
        # Markdown parse error — retry without parse_mode
        if e.response.status_code == 400 and "can't parse" in e.response.text.lower():
            params.pop("parse_mode", None)
            result = await tg_request("sendMessage", params)
            return result["result"]["message_id"]
        raise


PHOTO_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}


async def send_tg_file(file_bytes: bytes, filename: str, thread_id: int | None, caption: str = "") -> int:
    """Отправляет файл в Telegram: sendPhoto для изображений, sendDocument для остальных."""
    ext = os.path.splitext(filename)[1].lower()
    is_photo = ext in PHOTO_EXTENSIONS

    method = "sendPhoto" if is_photo else "sendDocument"
    field_name = "photo" if is_photo else "document"

    content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"

    data = {"chat_id": str(CHAT_ID)}
    if thread_id is not None:
        data["message_thread_id"] = str(thread_id)
    if caption:
        data["caption"] = caption

    files = {field_name: (filename, file_bytes, content_type)}

    client = _get_tg_client()
    resp = await client.post(f"{API_BASE}/{method}", data=data, files=files)
    resp.raise_for_status()
    result = resp.json()
    return result["result"]["message_id"]


DOWNLOAD_DIR = Path("/tmp/tg_mcp_files")
DOWNLOAD_DIR.mkdir(exist_ok=True)


async def _download_tg_file(file_id: str, filename: str) -> str | None:
    """Скачивает файл из Telegram по file_id, возвращает локальный путь."""
    try:
        result = await tg_request("getFile", {"file_id": file_id})
        file_path = result["result"]["file_path"]
        url = f"https://api.telegram.org/file/bot{BOT_TOKEN}/{file_path}"
        client = _get_tg_client()
        resp = await client.get(url)
        resp.raise_for_status()
        # Уникальное имя чтобы не перезаписать
        local_path = DOWNLOAD_DIR / f"{file_id}_{filename}"
        local_path.write_bytes(resp.content)
        print(f"[daemon] Файл скачан: {local_path}", file=sys.stderr)
        return str(local_path)
    except Exception as e:
        print(f"[daemon] Ошибка скачивания файла {file_id}: {e}", file=sys.stderr)
        return None


# --- Telegram Polling ---
async def poll_telegram():
    global last_update_id

    try:
        flush = await tg_request("getUpdates", {"offset": -1, "limit": 1})
        if flush.get("result"):
            last_update_id = flush["result"][-1]["update_id"] + 1
    except Exception as e:
        print(f"[daemon] Ошибка сброса апдейтов: {e}", file=sys.stderr)

    print(f"[daemon] Polling запущен для чата {CHAT_ID}", file=sys.stderr)

    while True:
        try:
            updates = await tg_request("getUpdates", {
                "offset": last_update_id,
                "timeout": 30,
                "allowed_updates": ["message", "callback_query"],
            })

            for upd in updates.get("result", []):
                last_update_id = upd["update_id"] + 1

                # Callback query (inline-кнопки)
                cb = upd.get("callback_query")
                if cb:
                    cb_msg = cb.get("message", {})
                    if str(cb_msg.get("chat", {}).get("id")) != str(CHAT_ID):
                        continue
                    thread_id = cb_msg.get("message_thread_id")
                    await tg_request("answerCallbackQuery", {
                        "callback_query_id": cb["id"]
                    })
                    for s in sessions.values():
                        if s["thread_id"] == thread_id:
                            await s["queue"].put({
                                "type": "callback",
                                "text": cb.get("data", ""),
                                "from": cb.get("from", {}).get("username", ""),
                                "ts": time.time(),
                            })
                            break
                    continue

                # Обычное сообщение
                msg = upd.get("message", {})
                if not msg:
                    continue
                if str(msg.get("chat", {}).get("id")) != str(CHAT_ID):
                    continue
                if msg.get("from", {}).get("is_bot", False):
                    continue

                thread_id = msg.get("message_thread_id")
                text = msg.get("text", "")
                file_path = None

                if msg.get("document"):
                    doc = msg["document"]
                    fname = doc.get("file_name", "unknown")
                    file_path = await _download_tg_file(doc["file_id"], fname)
                    text = f"[Файл: {fname}]"
                    if file_path:
                        text += f" saved:{file_path}"
                elif msg.get("photo"):
                    # Берём самое большое фото (последний элемент)
                    photo = msg["photo"][-1]
                    file_path = await _download_tg_file(photo["file_id"], "photo.jpg")
                    caption = msg.get("caption", "")
                    text = f"[Фото]{': ' + caption if caption else ''}"
                    if file_path:
                        text += f" saved:{file_path}"
                elif not text:
                    text = "[Сообщение без текста]"

                username = msg.get("from", {}).get("username", str(msg.get("from", {}).get("id", "")))

                delivered = False
                for s in sessions.values():
                    if s["thread_id"] == thread_id:
                        await s["queue"].put({
                            "type": "message",
                            "text": text,
                            "from": username,
                            "message_id": msg.get("message_id"),
                            "ts": time.time(),
                        })
                        delivered = True
                        break

                if not delivered and thread_id:
                    print(f"[daemon] Сообщение в неизвестном треде {thread_id}, пропущено", file=sys.stderr)

        except asyncio.CancelledError:
            break
        except Exception as e:
            print(f"[daemon] Ошибка polling: {e}", file=sys.stderr)
            await asyncio.sleep(5)


# --- HTTP API ---

async def handle_register(request: web.Request) -> web.Response:
    data = await request.json()
    name = data.get("name", f"Session {time.strftime('%H:%M %d.%m')}")
    sid = str(uuid.uuid4())[:8]
    while sid in sessions:
        sid = str(uuid.uuid4())[:8]

    try:
        thread_id = await create_thread(name)
    except Exception as e:
        return web.json_response({"error": str(e)}, status=500)

    sessions[sid] = {
        "thread_id": thread_id,
        "queue": asyncio.Queue(),
        "name": name,
        "created": time.time(),
        "last_active": time.time(),
    }
    print(f"[daemon] Зарегистрирована сессия {sid}: '{name}' (тред={thread_id})", file=sys.stderr)
    return web.json_response({"session_id": sid, "thread_id": thread_id})


async def _recover_thread(sid: str) -> int:
    """Пересоздаёт тред если старый удалён."""
    name = sessions[sid]["name"]
    new_thread = await create_thread(name)
    sessions[sid]["thread_id"] = new_thread
    print(f"[daemon] Тред пересоздан для {sid}: {new_thread}", file=sys.stderr)
    return new_thread


async def _send_with_recovery(sid: str, text: str) -> int:
    """Отправляет сообщение, пересоздавая тред при 'thread not found'."""
    try:
        return await send_tg_message(text, sessions[sid]["thread_id"])
    except httpx.HTTPStatusError as e:
        if e.response.status_code == 400 and "thread not found" in e.response.text.lower():
            await _recover_thread(sid)
            return await send_tg_message(text, sessions[sid]["thread_id"])
        raise


async def handle_send(request: web.Request) -> web.Response:
    data = await request.json()
    sid = data.get("sid", "")
    text = data.get("text", "")

    if sid not in sessions:
        return web.json_response({"error": "session not found"}, status=404)

    sessions[sid]["last_active"] = time.time()
    try:
        msg_id = await _send_with_recovery(sid, text)
    except Exception as e:
        return web.json_response({"error": str(e)}, status=500)

    return web.json_response({"message_id": msg_id})


async def handle_send_with_options(request: web.Request) -> web.Response:
    data = await request.json()
    sid = data.get("sid", "")
    text = data.get("text", "")
    options = data.get("options", [])

    if sid not in sessions:
        return web.json_response({"error": "session not found"}, status=404)

    async def _do_send():
        thread_id = sessions[sid]["thread_id"]
        keyboard = {
            "inline_keyboard": [
                [{"text": opt, "callback_data": opt}] for opt in options
            ]
        }
        params = {
            "chat_id": CHAT_ID,
            "text": text,
            "reply_markup": keyboard,
            "parse_mode": "Markdown",
        }
        if thread_id is not None:
            params["message_thread_id"] = thread_id
        result = await tg_request("sendMessage", params)
        return result["result"]["message_id"]

    try:
        msg_id = await _do_send()
    except httpx.HTTPStatusError as e:
        if e.response.status_code == 400 and "thread not found" in e.response.text.lower():
            await _recover_thread(sid)
            try:
                msg_id = await _do_send()
            except Exception as e2:
                return web.json_response({"error": str(e2)}, status=500)
        else:
            return web.json_response({"error": str(e)}, status=500)
    except Exception as e:
        return web.json_response({"error": str(e)}, status=500)

    return web.json_response({"message_id": msg_id})


async def handle_send_file(request: web.Request) -> web.Response:
    reader = await request.multipart()

    sid = ""
    caption = ""
    file_bytes = b""
    filename = "file"

    async for part in reader:
        if part.name == "sid":
            sid = (await part.read(decode=True)).decode()
        elif part.name == "caption":
            caption = (await part.read(decode=True)).decode()
        elif part.name == "file":
            filename = part.filename or "file"
            file_bytes = await part.read(decode=False)

    if sid not in sessions:
        return web.json_response({"error": "session not found"}, status=404)
    if not file_bytes:
        return web.json_response({"error": "no file provided"}, status=400)

    try:
        thread_id = sessions[sid]["thread_id"]
        msg_id = await send_tg_file(file_bytes, filename, thread_id, caption)
    except httpx.HTTPStatusError as e:
        if e.response.status_code == 400 and "thread not found" in e.response.text.lower():
            await _recover_thread(sid)
            thread_id = sessions[sid]["thread_id"]
            msg_id = await send_tg_file(file_bytes, filename, thread_id, caption)
        else:
            return web.json_response({"error": str(e)}, status=500)
    except Exception as e:
        return web.json_response({"error": str(e)}, status=500)

    return web.json_response({"message_id": msg_id})


async def handle_poll(request: web.Request) -> web.Response:
    sid = request.match_info["sid"]
    timeout = int(request.query.get("timeout", "30"))

    if sid not in sessions:
        return web.json_response({"error": "session not found"}, status=404)

    sessions[sid]["last_active"] = time.time()
    queue = sessions[sid]["queue"]
    messages = []

    # Сначала забираем все уже накопленные
    while not queue.empty():
        try:
            messages.append(queue.get_nowait())
        except asyncio.QueueEmpty:
            break

    # Если пусто — ждём первое сообщение с таймаутом
    if not messages and timeout > 0:
        try:
            msg = await asyncio.wait_for(queue.get(), timeout=timeout)
            messages.append(msg)
            # Забираем остальные, если пришли одновременно
            while not queue.empty():
                try:
                    messages.append(queue.get_nowait())
                except asyncio.QueueEmpty:
                    break
        except asyncio.TimeoutError:
            pass

    return web.json_response({"messages": messages})


async def handle_unregister(request: web.Request) -> web.Response:
    data = await request.json()
    sid = data.get("sid", "")
    if sid in sessions:
        name = sessions[sid]["name"]
        del sessions[sid]
        print(f"[daemon] Сессия {sid} ('{name}') отключена", file=sys.stderr)
    return web.json_response({"ok": True})


async def handle_sessions(request: web.Request) -> web.Response:
    result = []
    for sid, s in sessions.items():
        result.append({
            "session_id": sid,
            "name": s["name"],
            "thread_id": s["thread_id"],
            "created": s["created"],
            "pending_messages": s["queue"].qsize(),
        })
    return web.json_response(result)


async def handle_health(request: web.Request) -> web.Response:
    return web.json_response({
        "status": "ok",
        "sessions": len(sessions),
        "uptime": time.time(),
    })


async def cleanup_stale_sessions():
    """Периодически удаляет сессии без активности дольше SESSION_TTL."""
    while True:
        await asyncio.sleep(300)  # проверяем каждые 5 минут
        now = time.time()
        stale = [
            sid for sid, s in sessions.items()
            if now - s["last_active"] > SESSION_TTL
        ]
        for sid in stale:
            name = sessions[sid]["name"]
            del sessions[sid]
            print(f"[daemon] Сессия {sid} ('{name}') удалена по TTL", file=sys.stderr)


async def on_startup(app: web.Application):
    app["polling_task"] = asyncio.create_task(poll_telegram())
    app["cleanup_task"] = asyncio.create_task(cleanup_stale_sessions())
    print(f"[daemon] HTTP API на http://127.0.0.1:{DAEMON_PORT}", file=sys.stderr)


async def on_cleanup(app: web.Application):
    app["polling_task"].cancel()
    app["cleanup_task"].cancel()
    for task_name in ("polling_task", "cleanup_task"):
        try:
            await app[task_name]
        except asyncio.CancelledError:
            pass
    if _tg_client:
        await _tg_client.aclose()
    sessions.clear()
    print("[daemon] Остановлен", file=sys.stderr)


def create_app() -> web.Application:
    app = web.Application()
    app.router.add_post("/api/register", handle_register)
    app.router.add_post("/api/send", handle_send)
    app.router.add_post("/api/send_with_options", handle_send_with_options)
    app.router.add_post("/api/send_file", handle_send_file)
    app.router.add_get("/api/poll/{sid}", handle_poll)
    app.router.add_post("/api/unregister", handle_unregister)
    app.router.add_get("/api/sessions", handle_sessions)
    app.router.add_get("/api/health", handle_health)
    app.on_startup.append(on_startup)
    app.on_cleanup.append(on_cleanup)
    return app


def main():
    app = create_app()
    web.run_app(app, host="127.0.0.1", port=DAEMON_PORT, print=None)


if __name__ == "__main__":
    main()
