"""Telegram command poller + Command dataclass.

Uses httpx (async) for long-polling getUpdates. The sync TelegramNotifier
continues to use requests — this module is the only httpx consumer.
"""
from __future__ import annotations

import asyncio
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal

import httpx

if TYPE_CHECKING:
    from .config import TelegramCfg

logger = logging.getLogger(__name__)

CommandAction = Literal[
    "set_interval", "stop", "status", "ss", "pause", "resume", "rebase", "window", "help"
]

_BASE = "https://api.telegram.org/bot{token}/{method}"

# Slash-command menu shown in the Telegram client (autocomplete on "/").
# Single source of truth: the poller registers these at startup via
# setMyCommands, and scripts/register_telegram_commands.py reuses the list.
# Keep in sync with the `/help` handler in main.py (same commands, same order).
# command must match [a-z0-9_]{1,32}; description is 1-256 chars. Telegram has
# no typed args, so arg formats live in the description text.
TELEGRAM_COMMANDS: list[tuple[str, str]] = [
    ("status", "Stare FSM, uptime, ultima detecție, fereastră open/closed"),
    ("ss", "Screenshot acum (top-3 buline din ROI)"),
    ("pause", "Suspendă detecția (heartbeat-urile continuă)"),
    ("resume", "Reia detecția (șterge user-pause + drift-pause)"),
    ("rebase", "Propune phash nou pentru canary — aplici cu: /rebase confirm"),
    ("interval", "Auto-screenshot la N minute, ex: /interval 3"),
    ("stop", "Oprește scheduler-ul de auto-screenshot"),
    ("window", "Fereastră monitorizare locală, ex: /window 19:40-21:45 (off = dezactivează)"),
    ("help", "Listă scurtă a tuturor comenzilor"),
]

_HHMM = __import__("re").compile(r"^([01]?\d|2[0-3]):[0-5]\d$")


def _norm_hhmm(s: str) -> str | None:
    """Return zero-padded HH:MM if `s` is a valid 24h time, else None."""
    if not _HHMM.match(s):
        return None
    hh, mm = s.split(":")
    return f"{int(hh):02d}:{mm}"


@dataclass
class Command:
    action: CommandAction
    value: int | None = None  # seconds; only for set_interval
    # (start_hhmm, stop_hhmm) local wall-clock; only for action="window".
    # None on a "window" command means "clear the window".
    window: tuple[str, str] | None = None


class TelegramPoller:
    """Long-poll Telegram getUpdates, emit Commands into asyncio.Queue.

    Security: rejects messages from chat_ids not in cfg.allowed_chat_ids.
    Degrades (stops polling) after 3 consecutive 401 responses and warns
    via Discord (caller responsibility — poller only logs + sets degraded flag).
    """

    def __init__(
        self,
        cfg: TelegramCfg,
        cmd_queue: asyncio.Queue[Command],
        audit,  # _AuditLike
    ) -> None:
        self._cfg = cfg
        self._cmd_queue = cmd_queue
        self._audit = audit
        self._offset = 0
        self._consecutive_401 = 0
        self._degraded = False
        # fallback: if allowed_chat_ids is empty, accept only the primary chat
        self._allowed = set(cfg.allowed_chat_ids) or {cfg.chat_id}

    @property
    def degraded(self) -> bool:
        return self._degraded

    async def run(self) -> None:
        async with httpx.AsyncClient() as client:
            await self._drain(client)
            await self._register_commands(client)
            while True:
                if self._degraded:
                    await asyncio.sleep(5)
                    continue
                try:
                    await self._poll_once(client)
                except asyncio.CancelledError:
                    raise
                except (httpx.HTTPError, httpx.TimeoutException) as exc:
                    self._audit.log({"event": "poller_error", "error": str(exc)})
                    await asyncio.sleep(5)
                except Exception as exc:  # json, unexpected
                    self._audit.log({"event": "poller_error", "error": str(exc)})
                    await asyncio.sleep(5)

    async def _register_commands(self, client: httpx.AsyncClient) -> None:
        """Populate the Telegram '/' menu via setMyCommands (TELEGRAM_COMMANDS).

        Best-effort: any failure (bad token, network) is logged and swallowed so
        it never blocks polling. Idempotent — safe to call on every startup.
        """
        try:
            resp = await client.post(
                _BASE.format(token=self._cfg.bot_token, method="setMyCommands"),
                json={"commands": [
                    {"command": c, "description": d} for c, d in TELEGRAM_COMMANDS
                ]},
                timeout=10,
            )
            body = resp.json()
            if body.get("ok"):
                self._audit.log({
                    "event": "telegram_commands_registered",
                    "count": len(TELEGRAM_COMMANDS),
                })
            else:
                logger.warning("setMyCommands failed: %s", body)
        except Exception as exc:
            logger.warning("setMyCommands error: %s", exc)

    async def _drain(self, client: httpx.AsyncClient) -> None:
        """Discard all pending updates at startup so stale commands don't replay."""
        try:
            resp = await client.get(
                _BASE.format(token=self._cfg.bot_token, method="getUpdates"),
                params={"timeout": 0, "offset": self._offset},
                timeout=10,
            )
            body = resp.json()
            if body.get("ok") and body.get("result"):
                self._offset = body["result"][-1]["update_id"] + 1
        except Exception as exc:
            logger.warning("TelegramPoller startup drain failed: %s", exc)

    async def _poll_once(self, client: httpx.AsyncClient) -> None:
        resp = await client.get(
            _BASE.format(token=self._cfg.bot_token, method="getUpdates"),
            params={"timeout": self._cfg.poll_timeout_s, "offset": self._offset},
            timeout=self._cfg.poll_timeout_s + 5,
        )

        if resp.status_code == 401:
            self._consecutive_401 += 1
            if self._consecutive_401 >= 3:
                self._degraded = True
                self._audit.log({"event": "poller_degraded", "reason": "3_consecutive_401"})
            return
        self._consecutive_401 = 0

        body = resp.json()
        if not body.get("ok"):
            return

        for update in body.get("result", []):
            self._offset = update["update_id"] + 1
            await self._process_update(update)

    async def _process_update(self, update: dict) -> None:
        if "callback_query" in update:
            # Inline button pressed — may be expired; reply with fallback
            cbq = update["callback_query"]
            chat_id = str(cbq.get("from", {}).get("id", ""))
            if chat_id not in self._allowed:
                logger.info("Rejected callback_query from chat_id=%s", chat_id)
                return
            # Caller handles answerCallbackQuery; just note in audit
            self._audit.log({"event": "command_received", "action": "callback_query", "chat_id": chat_id})
            return

        msg = update.get("message") or update.get("edited_message")
        if not msg:
            return

        chat_id = str(msg.get("chat", {}).get("id", ""))
        if chat_id not in self._allowed:
            logger.info("Rejected message from chat_id=%s", chat_id)
            return

        text = (msg.get("text") or "").strip().lower()
        cmd = self._parse_command(text)
        if cmd is None:
            return

        self._audit.log({
            "event": "command_received",
            "action": cmd.action,
            "value": cmd.value,
            "chat_id": chat_id,
        })
        await self._cmd_queue.put(cmd)

    def _parse_command(self, text: str) -> Command | None:
        t = text.lstrip("/").strip()
        if not t:
            return None
        if t in ("h", "help"):
            return Command(action="help")
        if t == "stop":
            return Command(action="stop")
        if t == "status":
            return Command(action="status")
        if t in ("ss", "screenshot"):
            return Command(action="ss")
        if t == "pause":
            return Command(action="pause")
        if t == "resume":
            return Command(action="resume")
        if t == "resume force":
            # value=1 signals force: also lift canary drift-pause, not just user pause.
            return Command(action="resume", value=1)
        if t == "rebase":
            return Command(action="rebase")
        if t == "rebase confirm":
            # value=1 applies the pending proposal; plain "rebase" captures+proposes.
            return Command(action="rebase", value=1)
        if t == "window" or t.startswith("window "):
            rest = t[len("window"):].strip()
            if rest in ("", "off", "clear"):
                # window=None signals "clear the session window".
                return Command(action="window", window=None)
            # accept "19:40-21:45" or "19:40 21:45"
            seg = rest.replace("-", " ").split()
            if len(seg) == 2:
                start = _norm_hhmm(seg[0])
                stop = _norm_hhmm(seg[1])
                if start and stop:
                    return Command(action="window", window=(start, stop))
            return None
        # "3" → set_interval 3 minutes → 180s; "interval 3" also accepted
        parts = t.split()
        if len(parts) == 1 and parts[0].isdigit():
            return Command(action="set_interval", value=int(parts[0]) * 60)
        if len(parts) == 2 and parts[0] in ("interval", "set_interval") and parts[1].isdigit():
            return Command(action="set_interval", value=int(parts[1]) * 60)
        return None
