"""Telegram Bot long-polling listener — runs inside the daemon."""

import asyncio
import os
import re
import tempfile
from contextlib import asynccontextmanager
from pathlib import Path

import httpx
import structlog

from fliiq.runtime.agent.config import AgentConfig
from fliiq.runtime.agent.loop import agent_loop
from fliiq.runtime.agent.prompt import assemble_agent_prompt
from fliiq.runtime.agent.setup import setup_agent_resources
from fliiq.runtime.config import global_fliiq_dir
from fliiq.runtime.llm.providers import LLMConfig, create_llm, resolve_model_alias
from fliiq.runtime.telegram.session_store import TelegramSessionStore

log = structlog.get_logger()

TELEGRAM_API_BASE = "https://api.telegram.org"
OPENAI_TTS_URL = "https://api.openai.com/v1/audio/speech"
POLL_TIMEOUT = 30  # seconds — Telegram holds connection open
MAX_MESSAGE_LEN = 4096  # Telegram's per-message limit
DEFAULT_FLIIQ_VOICE = "onyx"  # deep English male
BACKOFF_BASE = 2
BACKOFF_MAX = 60


_RE_MODEL_RESET = re.compile(r"^/model\s+(reset|default)\b", re.IGNORECASE)
_RE_MODEL_SLASH = re.compile(r"^/model\s+(\S+)\s*(.*)", re.IGNORECASE | re.DOTALL)
_RE_MODEL_NATURAL = re.compile(
    r"^use\s+(?:the\s+model\s+)?(\S+)\s+(.+)", re.IGNORECASE | re.DOTALL,
)

_MODEL_RESET_SENTINEL = "__reset__"


def _parse_model_directive(text: str) -> tuple[str | None, str]:
    """Parse model override directive from message text.

    Returns (model_name_or_reset_sentinel, remaining_text).
    If no directive found, returns (None, original_text).
    """
    m = _RE_MODEL_RESET.match(text)
    if m:
        return _MODEL_RESET_SENTINEL, ""

    m = _RE_MODEL_SLASH.match(text)
    if m:
        return m.group(1), m.group(2).strip()

    m = _RE_MODEL_NATURAL.match(text)
    if m:
        return m.group(1), m.group(2).strip()

    return None, text


def _agent_sent_telegram(messages: list[dict], chat_id: int) -> bool:
    """Check if agent already sent a message to this chat via send_telegram tool."""
    for msg in messages:
        if msg.get("role") != "assistant":
            continue
        content = msg.get("content")
        if not isinstance(content, list):
            continue
        for block in content:
            if (
                block.get("type") == "tool_use"
                and block.get("name") == "send_telegram"
                and block.get("input", {}).get("chat_id") == chat_id
            ):
                return True
    return False


class TelegramListener:
    """Long-polls Telegram getUpdates and routes messages through agent_loop."""

    def __init__(
        self,
        bot_token: str,
        llm_config: LLMConfig,
        project_root: Path,
        allowed_chat_ids: set[int] | None = None,
        fliiq_dir: Path | None = None,
    ):
        self._token = bot_token
        self._llm_config = llm_config
        self._project_root = project_root
        self._allowed_chat_ids = allowed_chat_ids
        self._offset = 0
        self._running = False
        self._sessions: dict[tuple[int, int | None], dict] = {}  # (chat_id, thread_id) → session
        self._session_store = TelegramSessionStore(fliiq_dir or global_fliiq_dir())

    @property
    def api_base(self) -> str:
        return f"{TELEGRAM_API_BASE}/bot{self._token}"

    async def send_message(
        self, chat_id: int, text: str, message_thread_id: int | None = None,
    ) -> None:
        """Send a text message. Splits if >4096 chars."""
        chunks = _split_message(text)
        payload_base: dict = {"chat_id": chat_id, "parse_mode": "Markdown"}
        if message_thread_id is not None:
            payload_base["message_thread_id"] = message_thread_id
        async with httpx.AsyncClient(timeout=15) as client:
            for chunk in chunks:
                resp = await client.post(
                    f"{self.api_base}/sendMessage",
                    json={**payload_base, "text": chunk},
                )
                if resp.status_code != 200:
                    # Retry without Markdown if parse error
                    if resp.status_code == 400 and "parse" in resp.text.lower():
                        plain = {k: v for k, v in payload_base.items() if k != "parse_mode"}
                        await client.post(
                            f"{self.api_base}/sendMessage",
                            json={**plain, "text": chunk},
                        )
                    else:
                        log.warning(
                            "telegram_send_failed",
                            chat_id=chat_id,
                            status=resp.status_code,
                            error=resp.text[:200],
                        )

    async def _poll_updates(self, client: httpx.AsyncClient) -> list[dict]:
        """Long-poll getUpdates. Returns list of Update objects."""
        resp = await client.get(
            f"{self.api_base}/getUpdates",
            params={"offset": self._offset, "timeout": POLL_TIMEOUT},
            timeout=POLL_TIMEOUT + 10,  # httpx timeout > Telegram timeout
        )
        if resp.status_code != 200:
            log.warning("telegram_poll_error", status=resp.status_code)
            return []

        data = resp.json()
        updates = data.get("result", [])
        if updates:
            self._offset = updates[-1]["update_id"] + 1
        return updates

    async def _get_or_create_session(
        self, chat_id: int, message_thread_id: int | None = None,
    ) -> dict:
        """Get or create a per-chat/topic session with agent resources."""
        key = (chat_id, message_thread_id)
        if key not in self._sessions:
            def _noop_ask_user(question: str) -> str:
                return "Not available — this is a Telegram bot session."

            def _noop_ask_user_choice(question: str, options: list[dict], recommended: int) -> str:
                if options and recommended and 1 <= recommended <= len(options):
                    picked = options[recommended - 1]
                    return f"Auto-selected: {picked['label']}"
                return "Auto-selected first option"

            resources = await setup_agent_resources(
                mode="autonomous",
                llm_config=self._llm_config,
                project_root=self._project_root,
                ask_user_handler=_noop_ask_user,
                ask_user_choice_handler=_noop_ask_user_choice,
            )

            # Seed with recent persistent history (survives restarts, bridges job→listener)
            recovered = self._session_store.load_recent(chat_id, message_thread_id)

            self._sessions[key] = {
                "messages": recovered,
                "resources": resources,
                "model_override": None,
                "model_override_display": None,
            }

        return self._sessions[key]

    async def _download_voice(self, file_id: str) -> Path:
        """Download a Telegram voice message to a temp OGG file."""
        async with httpx.AsyncClient(timeout=30) as client:
            resp = await client.get(
                f"{self.api_base}/getFile",
                params={"file_id": file_id},
            )
            if resp.status_code != 200:
                raise ValueError(f"Telegram getFile failed ({resp.status_code}): {resp.text[:200]}")

            file_path = resp.json().get("result", {}).get("file_path")
            if not file_path:
                raise ValueError("Telegram getFile returned no file_path.")

            download_url = f"https://api.telegram.org/file/bot{self._token}/{file_path}"
            file_resp = await client.get(download_url)
            if file_resp.status_code != 200:
                raise ValueError(f"Telegram file download failed ({file_resp.status_code})")

        tmp = tempfile.NamedTemporaryFile(suffix=".ogg", delete=False)
        tmp.write(file_resp.content)
        tmp.close()
        return Path(tmp.name)

    async def _transcribe_voice(self, ogg_path: Path) -> str:
        """Transcribe an OGG voice file to text using the speech_to_text skill."""
        from fliiq.data.skills.core.speech_to_text.main import handler as stt_handler

        result = await stt_handler({"audio_file": str(ogg_path)})
        return result["text"]

    async def _synthesize_voice(self, text: str) -> Path:
        """Generate speech from text using OpenAI TTS API. Returns path to opus file."""
        api_key = os.environ.get("OPENAI_API_KEY")
        if not api_key:
            raise ValueError("OPENAI_API_KEY not set — cannot generate voice reply.")

        voice = os.environ.get("FLIIQ_VOICE_ID", DEFAULT_FLIIQ_VOICE)
        headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
        payload = {
            "model": "tts-1",
            "input": text,
            "voice": voice,
            "response_format": "opus",
        }

        async with httpx.AsyncClient(timeout=60) as client:
            resp = await client.post(OPENAI_TTS_URL, headers=headers, json=payload)

        if resp.status_code != 200:
            raise ValueError(f"OpenAI TTS error ({resp.status_code}): {resp.text[:300]}")

        tmp = tempfile.NamedTemporaryFile(suffix=".ogg", delete=False)
        tmp.write(resp.content)
        tmp.close()
        return Path(tmp.name)

    async def _send_voice_reply(
        self, chat_id: int, text: str, message_thread_id: int | None = None,
    ) -> None:
        """Generate TTS and send as a Telegram voice bubble. Best-effort — text reply already sent."""
        try:
            audio_path = await self._synthesize_voice(text)
            try:
                data: dict = {"chat_id": chat_id}
                if message_thread_id is not None:
                    data["message_thread_id"] = message_thread_id
                async with httpx.AsyncClient(timeout=30) as client:
                    with open(audio_path, "rb") as f:
                        resp = await client.post(
                            f"{self.api_base}/sendVoice",
                            data=data,
                            files={"voice": (audio_path.name, f, "audio/ogg")},
                        )
                    if resp.status_code != 200:
                        log.warning("telegram_voice_reply_send_failed", status=resp.status_code)
            finally:
                try:
                    audio_path.unlink()
                except OSError:
                    pass
        except Exception as e:
            log.warning("telegram_voice_reply_error", error=str(e))

    async def _send_chat_action(
        self, chat_id: int, action: str = "typing", message_thread_id: int | None = None,
    ) -> None:
        """Send a chat action (e.g. 'typing...' indicator). Best-effort."""
        try:
            payload: dict = {"chat_id": chat_id, "action": action}
            if message_thread_id is not None:
                payload["message_thread_id"] = message_thread_id
            async with httpx.AsyncClient(timeout=5) as client:
                await client.post(
                    f"{self.api_base}/sendChatAction",
                    json=payload,
                )
        except Exception:
            pass  # Non-critical — don't let indicator failure block processing

    @asynccontextmanager
    async def _typing_while(
        self, chat_id: int, message_thread_id: int | None = None,
    ):
        """Keep 'typing...' indicator alive for the duration of the block."""
        async def _loop() -> None:
            while True:
                await self._send_chat_action(chat_id, "typing", message_thread_id)
                await asyncio.sleep(4)

        task = asyncio.create_task(_loop())
        try:
            yield
        finally:
            task.cancel()
            try:
                await task
            except asyncio.CancelledError:
                pass

    async def _handle_message(
        self, chat_id: int, text: str, is_voice: bool = False,
        message_thread_id: int | None = None,
    ) -> None:
        """Process a single inbound message through agent_loop and reply."""
        session = await self._get_or_create_session(chat_id, message_thread_id)
        resources = session["resources"]
        messages = session["messages"]

        # Parse model override directive before tagging
        model_name, remaining = _parse_model_directive(text)

        if model_name == _MODEL_RESET_SENTINEL:
            session["model_override"] = None
            session["model_override_display"] = None
            await self.send_message(chat_id, "Model reset to default.", message_thread_id)
            return

        if model_name is not None:
            resolved = resolve_model_alias(model_name)
            session["model_override"] = resolved
            session["model_override_display"] = model_name
            if not remaining:
                await self.send_message(
                    chat_id,
                    f"Model set to {resolved}. Send your prompt next.",
                    message_thread_id,
                )
                return
            text = remaining

        voice_attr = ' type="voice"' if is_voice else ""
        thread_attr = f' message_thread_id="{message_thread_id}"' if message_thread_id else ""
        tagged = (
            f'<external_message source="telegram" chat_id="{chat_id}"{thread_attr}{voice_attr}>'
            f"\n{text}\n</external_message>"
        )
        messages.append({"role": "user", "content": tagged})

        # Persist inbound message
        self._session_store.log_message(chat_id, "user", text, message_thread_id)

        # Enrich memory with prompt-relevant entities
        from fliiq.runtime.agent.setup import (
            _load_knowledge_context,
            enrich_knowledge_context,
            enrich_memory_context,
        )

        enriched_memory = enrich_memory_context(resources.memory_context, text, self._project_root)
        knowledge_context = _load_knowledge_context()
        enriched_knowledge = enrich_knowledge_context(knowledge_context, text)

        system = assemble_agent_prompt(
            soul=resources.soul,
            user_soul=resources.user_soul,
            skills=resources.skill_info if resources.skill_info else None,
            mode="autonomous",
            memory_context=enriched_memory,
            user_profile=resources.user_profile,
            fliiq_email=resources.fliiq_email,
            knowledge_context=enriched_knowledge,
        )

        config = AgentConfig(mode="autonomous")

        # Resolve LLM — use override if active, otherwise default
        override = session.get("model_override")
        if override:
            override_config = self._llm_config.model_copy(update={"model": override})
            llm = create_llm(override_config)
        else:
            llm = resources.llm

        result_messages: list[dict] = []
        try:
            async with self._typing_while(chat_id, message_thread_id):
                result = await agent_loop(
                    llm=llm,
                    messages=messages,
                    tools=resources.tools,
                    config=config,
                    system=system,
                )
            session["messages"] = result.messages
            result_messages = result.messages
            response = result.final_text or "(No response)"

            # Post-conversation memory extraction (non-fatal)
            try:
                if getattr(result, "iterations", 0) > 0:
                    from fliiq.runtime.memory.extractor import extract_and_save_memories

                    await extract_and_save_memories(resources.llm, result.messages, self._project_root)
            except Exception:
                pass
        except Exception as e:
            log.error("telegram_agent_error", chat_id=chat_id, error=str(e))
            response = f"Error processing your message: {e}"

        # Skip auto-reply if agent already sent to this chat via send_telegram tool
        already_sent = _agent_sent_telegram(result_messages, chat_id)
        if not already_sent:
            if override:
                display = session.get("model_override_display") or override
                response = f"[{display}] {response}"

            await self.send_message(chat_id, response, message_thread_id)

            # Persist outbound (tool-sent messages are logged by send_telegram handler)
            self._session_store.log_message(chat_id, "assistant", response, message_thread_id)

            if is_voice:
                await self._send_voice_reply(chat_id, response, message_thread_id)

    async def run(self) -> None:
        """Main loop: long-poll → process → reply. Runs until stop() is called."""
        self._running = True
        backoff = 0
        log.info("telegram_listener_running")

        async with httpx.AsyncClient() as client:
            while self._running:
                try:
                    updates = await self._poll_updates(client)
                    backoff = 0  # Reset on success

                    for update in updates:
                        msg = update.get("message", {})
                        chat_id = msg.get("chat", {}).get("id")
                        text = msg.get("text")
                        voice = msg.get("voice")
                        message_thread_id = msg.get("message_thread_id")

                        # Voice message: download and transcribe
                        is_voice = False
                        if voice and chat_id:
                            file_id = voice.get("file_id")
                            if not file_id:
                                continue
                            ogg_path = None
                            try:
                                ogg_path = await self._download_voice(file_id)
                                text = await self._transcribe_voice(ogg_path)
                                is_voice = True
                            except Exception as e:
                                log.error("telegram_voice_transcribe_error", chat_id=chat_id, error=str(e))
                                await self.send_message(
                                    chat_id, f"Could not process voice message: {e}",
                                    message_thread_id,
                                )
                                continue
                            finally:
                                if ogg_path:
                                    try:
                                        ogg_path.unlink()
                                    except OSError:
                                        pass

                        if not chat_id or not text:
                            continue

                        if self._allowed_chat_ids and chat_id not in self._allowed_chat_ids:
                            log.debug("telegram_chat_blocked", chat_id=chat_id)
                            await self.send_message(
                                chat_id,
                                "This Fliiq instance is not configured to respond to this chat.",
                            )
                            continue

                        try:
                            await self._handle_message(
                                chat_id, text, is_voice=is_voice,
                                message_thread_id=message_thread_id,
                            )
                        except Exception as e:
                            log.error("telegram_message_handler_error", chat_id=chat_id, error=str(e))
                            try:
                                await self.send_message(
                                    chat_id, f"Something went wrong: {e}", message_thread_id,
                                )
                            except Exception:
                                pass

                except httpx.TimeoutException:
                    continue  # Normal — long poll timeout, retry immediately
                except Exception as e:
                    backoff = min(BACKOFF_BASE ** (backoff + 1), BACKOFF_MAX)
                    log.warning("telegram_poll_exception", error=str(e), backoff=backoff)
                    await asyncio.sleep(backoff)

        log.info("telegram_listener_stopped")

    async def stop(self) -> None:
        """Signal the run loop to exit."""
        self._running = False


def _split_message(text: str) -> list[str]:
    """Split text into chunks of MAX_MESSAGE_LEN or fewer."""
    if len(text) <= MAX_MESSAGE_LEN:
        return [text]

    chunks = []
    while text:
        if len(text) <= MAX_MESSAGE_LEN:
            chunks.append(text)
            break
        # Try to split at newline
        split_at = text.rfind("\n", 0, MAX_MESSAGE_LEN)
        if split_at == -1:
            split_at = MAX_MESSAGE_LEN
        chunks.append(text[:split_at])
        text = text[split_at:].lstrip("\n")
    return chunks


def parse_allowed_chat_ids() -> set[int] | None:
    """Parse TELEGRAM_ALLOWED_CHAT_IDS from env. Returns None if not set."""
    raw = os.environ.get("TELEGRAM_ALLOWED_CHAT_IDS", "").strip()
    if not raw:
        return None
    try:
        return {int(cid.strip()) for cid in raw.split(",") if cid.strip()}
    except ValueError:
        log.warning("telegram_invalid_allowed_chat_ids", raw=raw)
        return None
