# retoor <retoor@molodetz.nl>

from __future__ import annotations

import asyncio
import json
import logging
import os
import signal
import sys
from collections import deque
from datetime import datetime, timezone

from devplacepy.config import BASE_DIR
from devplacepy.database import get_setting
from devplacepy.services.base import BaseService, ConfigField

from .bridge import TelegramBridge
from .format import markdown_to_telegram_html, split_for_telegram

logger = logging.getLogger("telegram.service")

WORKER_MODULE = "devplacepy.services.telegram.worker"
STREAM_LIMIT = 16 * 1024 * 1024
RESULT_TIMEOUT_SECONDS = 30.0
STOP_GRACE_SECONDS = 5.0
LIVE_LOG_SIZE = 200
LATENCY_SAMPLES = 200

FIELD_TOKEN = "telegram_bot_token"
FIELD_POLL_TIMEOUT = "telegram_poll_timeout"
FIELD_CODE_TTL = "telegram_code_ttl_minutes"
FIELD_MAX_CONCURRENT = "telegram_max_concurrent_turns"


class TelegramService(BaseService):
    default_enabled = False
    min_interval = 15
    title = "Telegram Bot"
    description = (
        "Runs a Telegram bot that bridges Devii into Telegram. A user pairs their Telegram "
        "account with a four digit code from their profile, then chats with their own Devii "
        "with markdown, typing status, live message editing and image understanding. The "
        "long-poller runs as a supervised subprocess and is off by default."
    )
    config_fields = [
        ConfigField(
            FIELD_TOKEN,
            "Bot token",
            type="str",
            default="",
            secret=True,
            help="Telegram bot token from @BotFather. Required for the service to start.",
            group="Telegram",
        ),
        ConfigField(
            FIELD_POLL_TIMEOUT,
            "Long-poll timeout (seconds)",
            type="int",
            default=25,
            minimum=1,
            maximum=50,
            help="getUpdates long-poll hold time. 25-30 is recommended.",
            group="Telegram",
        ),
        ConfigField(
            FIELD_CODE_TTL,
            "Pairing code lifetime (minutes)",
            type="int",
            default=60,
            minimum=1,
            maximum=1440,
            help="How long a pairing code requested from a profile stays valid.",
            group="Telegram",
        ),
        ConfigField(
            FIELD_MAX_CONCURRENT,
            "Max concurrent turns",
            type="int",
            default=8,
            minimum=1,
            maximum=64,
            help="Upper bound on Devii turns processed at once across all chats.",
            group="Telegram",
        ),
    ]

    def __init__(self) -> None:
        super().__init__(name="telegram", interval_seconds=30)
        self._proc: asyncio.subprocess.Process | None = None
        self._tasks: list[asyncio.Task] = []
        self._pending: dict[int, asyncio.Future] = {}
        self._req_seq = 0
        self._stdin_lock = asyncio.Lock()
        self._live_logs: deque[str] = deque(maxlen=LIVE_LOG_SIZE)
        self._bridge: TelegramBridge | None = None
        self._stats = self._fresh_stats()

    def _fresh_stats(self) -> dict:
        return {"in": 0, "out": 0, "edits": 0, "errors": 0, "latencies": deque(maxlen=LATENCY_SAMPLES)}

    def _token(self) -> str:
        return get_setting(FIELD_TOKEN, "").strip()

    def _alive(self) -> bool:
        return self._proc is not None and self._proc.returncode is None

    async def on_enable(self) -> None:
        if not self._token():
            self.log("No bot token configured; waiting for configuration")
            return
        await self._spawn()

    async def on_disable(self) -> None:
        await self._terminate()

    async def run_once(self) -> None:
        if not self.is_enabled():
            return
        if not self._token():
            return
        if not self._alive():
            self.log("Worker not running; starting it")
            await self._spawn()

    async def _spawn(self) -> None:
        if self._alive():
            return
        self._bridge = TelegramBridge(self, max_concurrent=self._max_concurrent())
        self._stats = self._fresh_stats()
        env = {
            **os.environ,
            "TELEGRAM_BOT_TOKEN": self._token(),
            "TELEGRAM_POLL_TIMEOUT": str(self._poll_timeout()),
        }
        self._proc = await asyncio.create_subprocess_exec(
            sys.executable,
            "-m",
            WORKER_MODULE,
            cwd=str(BASE_DIR),
            stdin=asyncio.subprocess.PIPE,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            env=env,
            start_new_session=True,
            limit=STREAM_LIMIT,
        )
        self._tasks = [
            asyncio.create_task(self._read_stdout()),
            asyncio.create_task(self._read_stderr()),
        ]
        self.log(f"Telegram worker started (pid {self._proc.pid})")

    async def _terminate(self) -> None:
        proc = self._proc
        self._proc = None
        for task in self._tasks:
            task.cancel()
        self._tasks = []
        for future in self._pending.values():
            if not future.done():
                future.cancel()
        self._pending = {}
        if proc is None:
            return
        try:
            if proc.stdin is not None and not proc.stdin.is_closing():
                proc.stdin.close()
        except Exception:  # noqa: BLE001 - closing a dead pipe is fine
            pass
        try:
            await asyncio.wait_for(proc.wait(), timeout=STOP_GRACE_SECONDS)
        except asyncio.TimeoutError:
            self._kill_group(proc)
            try:
                await asyncio.wait_for(proc.wait(), timeout=STOP_GRACE_SECONDS)
            except asyncio.TimeoutError:
                pass
        self.log("Telegram worker stopped")

    def _kill_group(self, proc: asyncio.subprocess.Process) -> None:
        try:
            os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
        except (ProcessLookupError, PermissionError):
            try:
                proc.kill()
            except ProcessLookupError:
                pass

    async def _read_stdout(self) -> None:
        proc = self._proc
        if proc is None or proc.stdout is None:
            return
        try:
            while True:
                line = await proc.stdout.readline()
                if not line:
                    break
                try:
                    frame = json.loads(line.decode("utf-8", "replace"))
                except (ValueError, TypeError):
                    continue
                await self._handle_frame(frame)
        except asyncio.CancelledError:
            pass
        except Exception:  # noqa: BLE001 - reader must not crash the supervisor
            logger.exception("Telegram stdout reader failed")

    async def _read_stderr(self) -> None:
        proc = self._proc
        if proc is None or proc.stderr is None:
            return
        try:
            while True:
                line = await proc.stderr.readline()
                if not line:
                    break
                self._live_log("[stderr] " + line.decode("utf-8", "replace").rstrip())
        except asyncio.CancelledError:
            pass
        except Exception:  # noqa: BLE001
            pass

    async def _handle_frame(self, frame: dict) -> None:
        kind = frame.get("type")
        if kind == "log":
            self._live_log(str(frame.get("line", "")))
        elif kind == "ready":
            self._live_log("worker ready")
        elif kind == "result":
            self._resolve(frame)
        elif kind in ("message", "callback"):
            self._stats["in"] += 1
            self._live_log(f"in <- chat {frame.get('chat_id')} ({kind})")
            if self._bridge is not None:
                asyncio.create_task(self._dispatch_inbound(frame))

    async def _dispatch_inbound(self, frame: dict) -> None:
        try:
            await self._bridge.handle_inbound(frame)
        except Exception:  # noqa: BLE001 - surfaced as a stat, never crashes the reader
            self._stats["errors"] += 1
            logger.exception("Telegram inbound handling failed")

    def _resolve(self, frame: dict) -> None:
        future = self._pending.pop(frame.get("req_id"), None)
        if future is not None and not future.done():
            future.set_result(frame)

    def _live_log(self, line: str) -> None:
        stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
        entry = f"[{stamp}] {line}"
        self._live_logs.append(entry)
        asyncio.create_task(self._publish_log(entry))

    async def _publish_log(self, entry: str) -> None:
        try:
            from devplacepy.services.pubsub import publish

            await publish(f"admin.services.{self.name}.logs", {"line": entry})
        except Exception:  # noqa: BLE001 - live logs are best-effort
            pass

    async def _write(self, command: dict) -> None:
        proc = self._proc
        if proc is None or proc.stdin is None or proc.stdin.is_closing():
            raise RuntimeError("Telegram worker is not running")
        payload = (json.dumps(command, ensure_ascii=False) + "\n").encode("utf-8")
        async with self._stdin_lock:
            proc.stdin.write(payload)
            await proc.stdin.drain()

    def _next_req(self) -> int:
        self._req_seq += 1
        return self._req_seq

    async def send(
        self,
        chat_id: int,
        text: str,
        parse_mode: str = "HTML",
        reply_markup: dict | None = None,
    ) -> int | None:
        req_id = self._next_req()
        loop = asyncio.get_event_loop()
        future: asyncio.Future = loop.create_future()
        self._pending[req_id] = future
        command = {
            "cmd": "send",
            "req_id": req_id,
            "chat_id": chat_id,
            "text": text,
            "parse_mode": parse_mode,
        }
        if reply_markup is not None:
            command["reply_markup"] = reply_markup
        try:
            await self._write(command)
        except RuntimeError:
            self._pending.pop(req_id, None)
            self._stats["errors"] += 1
            return None
        self._stats["out"] += 1
        try:
            result = await asyncio.wait_for(future, timeout=RESULT_TIMEOUT_SECONDS)
        except asyncio.TimeoutError:
            self._pending.pop(req_id, None)
            self._stats["errors"] += 1
            return None
        if not result.get("ok"):
            self._stats["errors"] += 1
            return None
        return result.get("message_id")

    async def edit(
        self,
        chat_id: int,
        message_id: int,
        text: str,
        parse_mode: str = "HTML",
        reply_markup: dict | None = None,
    ) -> bool:
        req_id = self._next_req()
        loop = asyncio.get_event_loop()
        future: asyncio.Future = loop.create_future()
        self._pending[req_id] = future
        command = {
            "cmd": "edit",
            "req_id": req_id,
            "chat_id": chat_id,
            "message_id": message_id,
            "text": text,
            "parse_mode": parse_mode,
        }
        if reply_markup is not None:
            command["reply_markup"] = reply_markup
        try:
            await self._write(command)
        except RuntimeError:
            self._pending.pop(req_id, None)
            return False
        self._stats["edits"] += 1
        try:
            result = await asyncio.wait_for(future, timeout=RESULT_TIMEOUT_SECONDS)
        except asyncio.TimeoutError:
            self._pending.pop(req_id, None)
            return False
        return bool(result.get("ok"))

    async def answer_callback(
        self, callback_query_id: str, text: str | None = None
    ) -> bool:
        req_id = self._next_req()
        loop = asyncio.get_event_loop()
        future: asyncio.Future = loop.create_future()
        self._pending[req_id] = future
        try:
            await self._write(
                {
                    "cmd": "answer_callback",
                    "req_id": req_id,
                    "callback_query_id": callback_query_id,
                    "text": text,
                }
            )
        except RuntimeError:
            self._pending.pop(req_id, None)
            return False
        try:
            result = await asyncio.wait_for(future, timeout=RESULT_TIMEOUT_SECONDS)
        except asyncio.TimeoutError:
            self._pending.pop(req_id, None)
            return False
        return bool(result.get("ok"))

    async def chat_action(self, chat_id: int, action: str = "typing") -> None:
        try:
            await self._write({"cmd": "chat_action", "chat_id": chat_id, "action": action})
        except RuntimeError:
            pass

    async def send_markdown(self, chat_id: int, text: str) -> bool:
        chunks = split_for_telegram(markdown_to_telegram_html(text))
        delivered = False
        for chunk in chunks:
            if await self.send(chat_id, chunk) is not None:
                delivered = True
        return delivered

    def record_latency(self, seconds: float) -> None:
        self._stats["latencies"].append(seconds)

    def _poll_timeout(self) -> int:
        return max(1, int(self.get_config().get(FIELD_POLL_TIMEOUT, 25)))

    def _max_concurrent(self) -> int:
        return max(1, int(self.get_config().get(FIELD_MAX_CONCURRENT, 8)))

    def worker_alive(self) -> bool:
        return self._alive()

    def collect_metrics(self) -> dict:
        latencies = list(self._stats["latencies"])
        avg_ms = int(sum(latencies) / len(latencies) * 1000) if latencies else 0
        return {
            "stats": [
                {"label": "Worker", "value": "running" if self._alive() else "stopped"},
                {"label": "Messages in", "value": self._stats["in"]},
                {"label": "Messages out", "value": self._stats["out"]},
                {"label": "Edits", "value": self._stats["edits"]},
                {"label": "Errors", "value": self._stats["errors"]},
                {"label": "Avg response", "value": f"{avg_ms} ms"},
                {"label": "Token", "value": "set" if self._token() else "missing"},
            ]
        }
