"""Telegram channel — Bot API with long-polling or webhook.

When no webhook URL is provided, the channel starts a background polling
loop (``getUpdates`` with 30s long-poll timeout) so it works behind NAT
and firewalls with zero setup.  When a webhook URL IS provided, polling
is skipped and the server receives pushes from Telegram instead.

Photos sent to the bot are intercepted before the chat agent: the
highest-resolution copy is downloaded via the Bot API ``getFile`` +
``file/bot<TOKEN>/<file_path>`` endpoints, saved into
``agntspace-finance/inbox/`` with a deterministic filename, and handed
to ``ReceiptIngest.ingest()``. The reply that goes back into the same
Telegram thread is the draft summary (merchant / total / item count /
audit flags). The chat agent never sees the photo — that path is for
text + caption-bearing messages. This is the canonical "drop a receipt
in Telegram → see a draft at /finance" flow.
"""

from __future__ import annotations

import asyncio
import logging
from collections.abc import Callable
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any

import httpx

from agntspace.channels.base import Channel, ChannelMessage

if TYPE_CHECKING:
    from agntspace.finance.ingest import IngestResult, ReceiptIngest
    from agntspace.memory.conversation import ConversationMemory
    from agntspace.vault.paths import VaultPaths

log = logging.getLogger(__name__)

_MAX_MSG_LEN = 4096
_POLL_TIMEOUT = 30  # Telegram long-poll timeout (seconds)
_FILE_DOWNLOAD_TIMEOUT = 30  # seconds — Telegram file fetch
_MAX_PHOTO_BYTES = 15 * 1024 * 1024  # 15 MB cap; mirrors receipt-image file-type spec

# Short timeout for the dormant probe — we don't want a 30s long-poll
# while we're already in conflict; just check if we can reclaim the bot.
_DORMANT_PROBE_TIMEOUT = 5.0


class TelegramChannel(Channel):
    """Telegram Bot API channel with automatic polling fallback.

    The cooperative-conflict state machine (consecutive-409 tracking,
    DORMANT transition, recovery probe, ActivityLogger emission) lives
    on the ``Channel`` base class. This class only provides the
    Telegram-specific bits: classify a 409 as a conflict signal, run
    one short ``getUpdates`` as the recovery probe.
    """

    name = "telegram"

    # Telegram's 409 is unambiguous — three consecutive ones reliably
    # mean another instance owns the bot. Inherits base class
    # _DORMANT_PROBE_INTERVAL of 5 min, no need to override.
    _CONFLICT_THRESHOLD = 3  # arch:deterministic — Telegram 409 conflict count before dormancy; structural reliability, not user strategy

    def __init__(self, bot_token: str, webhook_url: str = "") -> None:
        super().__init__()  # initializes the base-class health state
        self._token = bot_token
        self._webhook_url = webhook_url
        self._base_url = f"https://api.telegram.org/bot{bot_token}"
        self._file_url_base = f"https://api.telegram.org/file/bot{bot_token}"
        self._connected = False
        self._message_callback: Callable | None = None
        self._chat_id: str | None = None
        self._bot_username: str | None = None
        self._bot_name: str | None = None
        self._poll_task: asyncio.Task | None = None
        self._poll_offset: int = 0
        # Optional injected services — main.py wires these post-init so
        # photo-bearing messages can route directly to the finance pipeline.
        # When either is None, photos fall through to the chat agent (or
        # are dropped if there's no caption).
        self._receipt_ingest: ReceiptIngest | None = None
        self._vault_paths: VaultPaths | None = None
        # Conversation memory — when wired, the photo intercept also writes
        # user + assistant turns to ``channel-telegram-<chat_id>`` so the
        # AgntSpace web chat mirrors the Telegram thread (image inline +
        # bot reply). Without this, the photo intercept still works but
        # the web chat shows nothing.
        self._conversation_memory: ConversationMemory | None = None

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    async def connect(self) -> None:
        """Validate bot token, set webhook or start polling."""
        async with httpx.AsyncClient(timeout=15) as client:
            resp = await client.get(f"{self._base_url}/getMe")
            data = resp.json()
            if not data.get("ok"):
                raise ConnectionError(f"Telegram bot token invalid: {data}")

            bot_info = data["result"]
            self._bot_username = bot_info.get("username")
            self._bot_name = bot_info.get("first_name")
            log.info("Telegram connected: @%s", self._bot_username)

            if self._webhook_url:
                # Webhook mode — Telegram pushes to us
                wh_resp = await client.post(
                    f"{self._base_url}/setWebhook",
                    json={"url": self._webhook_url},
                )
                wh_data = wh_resp.json()
                if not wh_data.get("ok"):
                    log.warning("Telegram webhook set failed: %s", wh_data)
                else:
                    log.info("Telegram webhook set: %s", self._webhook_url)
            else:
                # Polling mode — we pull from Telegram
                # Clear any stale webhook first
                await client.post(f"{self._base_url}/deleteWebhook")
                log.info("Telegram polling mode — no webhook, starting getUpdates loop")

        self._connected = True
        self._mark_active()  # base class — health=ACTIVE, conflict counter=0

        # Start polling if no webhook
        if not self._webhook_url:
            self._poll_task = asyncio.create_task(self._poll_loop())

    async def disconnect(self) -> None:
        """Stop polling, remove webhook, mark disconnected."""
        # Stop polling first
        if self._poll_task and not self._poll_task.done():
            self._poll_task.cancel()
            try:
                await self._poll_task
            except (asyncio.CancelledError, Exception):
                pass
            self._poll_task = None

        try:
            async with httpx.AsyncClient(timeout=10) as client:
                await client.post(f"{self._base_url}/deleteWebhook")
        except Exception:
            log.debug("Telegram deleteWebhook failed (ignoring)", exc_info=True)

        self._connected = False
        self._bot_username = None
        self._bot_name = None
        self._mark_disconnected()  # base class — health=DISCONNECTED
        log.info("Telegram disconnected")

    @property
    def is_connected(self) -> bool:
        return self._connected

    def get_info(self) -> dict[str, Any]:
        """Return bot metadata for status endpoints."""
        return {
            "bot_username": self._bot_username,
            "bot_name": self._bot_name,
            "webhook_url": self._webhook_url or None,
            "chat_id": self._chat_id,
            "mode": "webhook" if self._webhook_url else "polling",
        }

    # ------------------------------------------------------------------
    # Polling loop
    # ------------------------------------------------------------------

    async def _poll_loop(self) -> None:
        """Background loop: long-poll getUpdates and dispatch messages.

        Telegram's ``getUpdates`` returns HTTP 200 with
        ``{"ok": false, "error_code": 409, ...}`` when a SECOND instance
        polls the same bot token. We classify that as a conflict signal
        and hand it to the base class's cooperative-shutdown machinery
        (``_record_conflict``). After ``_CONFLICT_THRESHOLD`` consecutive
        409s the channel transitions to DORMANT; this loop notices via
        ``self.is_dormant`` and runs ``_run_dormant_probe_cycle()`` (also
        on the base class) instead of the normal long-poll.

        Recovery is automatic — when the probe (``_attempt_dormant_probe``
        below) successfully reclaims the bot, the base class transitions
        back to ACTIVE and we resume normal polling.

        See ``channels/base.py`` for the full state-machine and the
        2026-04-25 / 2026-04-26 architectural reasoning behind it.
        """
        log.debug("Telegram poll loop started (offset=%d)", self._poll_offset)
        while self._connected:
            try:
                if self.is_dormant:
                    await self._run_dormant_probe_cycle()
                    continue

                async with httpx.AsyncClient(timeout=_POLL_TIMEOUT + 10) as client:
                    resp = await client.get(
                        f"{self._base_url}/getUpdates",
                        params={
                            "offset": self._poll_offset,
                            "timeout": _POLL_TIMEOUT,
                            "allowed_updates": '["message"]',
                        },
                    )
                    data = resp.json()
                    if not data.get("ok"):
                        if data.get("error_code") == 409:
                            # Hand off to the base-class state machine.
                            self._record_conflict("telegram_409", {
                                "description": data.get("description", ""),
                                "bot_username": self._bot_username or "",
                            })
                            await asyncio.sleep(5)
                            continue
                        # Some other API error — log and back off, but
                        # don't count it toward the conflict threshold.
                        log.warning("Telegram getUpdates error: %s", data)
                        await asyncio.sleep(5)
                        continue

                    # Successful response — base class clears the
                    # consecutive-conflict counter.
                    self._mark_active()

                    for update in data.get("result", []):
                        self._poll_offset = update["update_id"] + 1
                        # Dispatch via the same handler as webhook
                        try:
                            await self.handle_webhook(update)
                        except Exception:
                            log.exception("Telegram poll handler error")

            except asyncio.CancelledError:
                break
            except Exception:
                log.debug("Telegram poll error, retrying in 5s", exc_info=True)
                await asyncio.sleep(5)

        log.debug("Telegram poll loop stopped")

    async def _attempt_dormant_probe(self) -> bool:
        """One short ``getUpdates`` probe to test bot ownership.

        Called by the base class's ``_run_dormant_probe_cycle()`` after
        the probe-interval sleep. Returns True iff we reclaimed the bot
        (in which case the base class transitions us back to ACTIVE);
        False to stay dormant.

        On reclaim, also drains whatever updates the probe returned so
        the next poll-loop iteration doesn't re-fetch stale offsets.
        """
        try:
            async with httpx.AsyncClient(timeout=_DORMANT_PROBE_TIMEOUT) as client:
                resp = await client.get(
                    f"{self._base_url}/getUpdates",
                    params={
                        # timeout=0 => non-blocking probe
                        "offset": self._poll_offset,
                        "timeout": 0,
                        "allowed_updates": '["message"]',
                    },
                )
                data = resp.json()
        except Exception:
            log.debug("Telegram dormant probe failed (network/timeout)", exc_info=True)
            return False

        if data.get("ok"):
            # Drain whatever the probe returned so we don't lose messages.
            for update in data.get("result", []):
                self._poll_offset = update["update_id"] + 1
                try:
                    await self.handle_webhook(update)
                except Exception:
                    log.exception("Telegram dormant-probe handler error")
            return True

        if data.get("error_code") == 409:
            log.debug("Telegram dormant probe: still in conflict")
        else:
            log.debug("Telegram dormant probe: non-409 error %s", data)
        return False

    # ------------------------------------------------------------------
    # Sending
    # ------------------------------------------------------------------

    async def send_message(self, chat_id: str, text: str) -> None:
        """Send a text message, splitting if it exceeds Telegram's 4096-char limit."""
        if not text:
            return
        chunks = _split_message(text)
        async with httpx.AsyncClient(timeout=15) as client:
            for chunk in chunks:
                try:
                    resp = await client.post(
                        f"{self._base_url}/sendMessage",
                        json={
                            "chat_id": chat_id,
                            "text": chunk,
                            "parse_mode": "Markdown",
                        },
                    )
                    data = resp.json()
                    # Markdown parse failure — retry as plain text
                    if not data.get("ok") and "parse" in str(data.get("description", "")).lower():
                        await client.post(
                            f"{self._base_url}/sendMessage",
                            json={"chat_id": chat_id, "text": chunk},
                        )
                except Exception:
                    log.exception("Telegram send_message failed for chat %s", chat_id)

    async def send_notification(self, chat_id: str, text: str) -> None:
        """Send a notification (bell-prefixed message)."""
        await self.send_message(chat_id, f"\U0001f514 {text}")

    # ------------------------------------------------------------------
    # Receiving
    # ------------------------------------------------------------------

    async def handle_webhook(self, payload: dict[str, Any]) -> str | None:
        """Process an incoming Telegram update (from webhook or poll).

        Returns the response text that was sent back into the chat thread,
        or None if no response was produced.

        Routing:
          1. Photo + finance subsystem wired → run receipt pipeline,
             reply with draft summary, return without invoking the chat agent.
          2. Text (or photo+caption when finance is unwired) → forward
             to the chat agent via the registered ``_message_callback``.
        """
        message = payload.get("message", {})
        text = message.get("text", "") or message.get("caption", "")
        chat_id = str(message.get("chat", {}).get("id", ""))
        sender_first = message.get("from", {}).get("first_name", "User")
        sender_last = message.get("from", {}).get("last_name", "")
        sender = f"{sender_first} {sender_last}".strip() if sender_last else sender_first
        sender_id = str(message.get("from", {}).get("id", ""))
        photo = message.get("photo") or []

        if not chat_id:
            return None

        self._chat_id = chat_id

        # ── Photo intercept: receipt pipeline ──
        # Run the finance pipeline when (a) a photo is attached and (b) the
        # finance services are wired. Reply with the draft summary directly
        # in the same chat thread; do NOT invoke the chat agent — the
        # finance pipeline IS the response for receipt-shaped messages.
        if photo:
            log.info(
                "Telegram photo received from chat=%s sender=%s sizes=%d (intercept_wired=%s)",
                chat_id,
                sender_id,
                len(photo),
                self._receipt_ingest is not None and self._vault_paths is not None,
            )
        if photo and self._receipt_ingest is not None and self._vault_paths is not None:
            try:
                reply = await self._handle_photo(
                    photo=photo,
                    sender_id=sender_id,
                    chat_id=chat_id,
                    caption=text,
                )
                if reply:
                    log.info("Telegram photo intercept replied (len=%d)", len(reply))
            except Exception:
                log.exception("Telegram photo intercept failed")
                reply = (
                    "I received a photo but couldn't process it. "
                    "Please try again or send the receipt to /finance manually."
                )
            if reply:
                await self.send_message(chat_id, reply)
            return reply

        # ── Text (and photos when finance not wired) flow to the chat agent ──
        if not text:
            return None

        channel_msg = ChannelMessage(
            channel="telegram",
            sender=sender,
            text=text,
            metadata={
                "chat_id": chat_id,
                "sender_id": sender_id,
                "message_id": str(message.get("message_id", "")),
            },
        )

        if self._message_callback:
            response = await self._message_callback(channel_msg)
            if response:
                await self.send_message(chat_id, response)
            return response

        return None

    # ------------------------------------------------------------------
    # Photo intercept helpers (Phase 1.5 — finance receipt pipeline)
    # ------------------------------------------------------------------

    async def _handle_photo(
        self,
        *,
        photo: list[dict[str, Any]],
        sender_id: str,
        chat_id: str,
        caption: str,
    ) -> str | None:
        """Download the highest-resolution photo, save to finance/inbox/,
        run ReceiptIngest, mirror to AgntSpace chat, return a
        Telegram-formatted reply. Never raises; any error becomes a
        graceful user-facing message.
        """
        if self._receipt_ingest is None or self._vault_paths is None:
            return None

        # Pick the highest-resolution variant — Telegram returns photos as
        # an array of resolutions, with the largest typically last but we
        # don't trust ordering.
        best = max(
            photo,
            key=lambda p: (p.get("width") or 0) * (p.get("height") or 0),
            default=None,
        )
        if not best:
            return None
        file_id = best.get("file_id")
        if not file_id:
            return None
        if (best.get("file_size") or 0) > _MAX_PHOTO_BYTES:
            return (
                "That image is too large to process as a receipt "
                f"(over {_MAX_PHOTO_BYTES // (1024 * 1024)} MB). "
                "Try cropping or compressing it first."
            )

        # Download bytes
        bytes_data = await self._download_telegram_file(file_id)
        if bytes_data is None:
            return "I couldn't download that image from Telegram. Please try again."

        # Save to finance/inbox/
        from pathlib import Path
        inbox_path: Path = self._vault_paths.resolve("finance/inbox")
        inbox_path.mkdir(parents=True, exist_ok=True)
        timestamp = datetime.now(UTC).strftime("%Y-%m-%d_%H%M%S")
        filename = f"telegram-{sender_id or 'user'}-{timestamp}.jpg"
        target = inbox_path / filename
        try:
            target.write_bytes(bytes_data)
        except OSError as exc:
            log.exception("Failed to write Telegram photo to %s", target)
            return f"Couldn't save the receipt: {exc}"

        # Mirror the photo into the AgntSpace chat conversation BEFORE
        # ingest runs — that way the user message shows up immediately even
        # if the LLM call is slow. The image renders inline via the
        # finance photo-serving endpoint.
        await self._mirror_photo_to_chat(
            chat_id=chat_id,
            filename=filename,
            caption=caption,
        )

        # Run ingest
        result = await self._receipt_ingest.ingest(
            target,
            source_logical_path=f"finance/inbox/{filename}",
        )
        reply = self._format_receipt_reply(result, caption)

        # Mirror the assistant reply too so the AgntSpace chat shows the
        # full exchange end-to-end.
        await self._mirror_reply_to_chat(chat_id=chat_id, reply=reply)

        return reply

    async def _mirror_photo_to_chat(
        self,
        *,
        chat_id: str,
        filename: str,
        caption: str,
    ) -> None:
        """Write the user's photo as a chat message in the channel-telegram
        conversation. The chat UI's react-markdown renders ``![…](…)``
        inline, so the photo appears in the same place the bot's reply
        will land.

        No-op when ConversationMemory is unwired or the write fails — the
        Telegram-side reply still happens; we just don't have a web mirror.
        """
        if self._conversation_memory is None or not chat_id:
            return
        conv_id = f"channel-telegram-{chat_id}"
        body = f"![Receipt](/api/finance/photo/{filename})"
        if caption:
            body = f"{body}\n\n{caption}"
        try:
            await self._conversation_memory.add_message(conv_id, "user", body)
        except Exception:
            log.exception("Failed to mirror Telegram photo to chat %s", conv_id)

    async def _mirror_reply_to_chat(
        self,
        *,
        chat_id: str,
        reply: str | None,
    ) -> None:
        """Write the assistant reply (draft summary) into the same chat."""
        if self._conversation_memory is None or not chat_id or not reply:
            return
        conv_id = f"channel-telegram-{chat_id}"
        try:
            await self._conversation_memory.add_message(conv_id, "assistant", reply)
        except Exception:
            log.exception("Failed to mirror Telegram reply to chat %s", conv_id)

    async def _download_telegram_file(self, file_id: str) -> bytes | None:
        """Two-step Telegram file download: getFile → file URL → bytes.

        Returns None on any failure (network, 4xx, oversized response,
        invalid file_id). Logs the failure for diagnostics; never raises.
        """
        try:
            async with httpx.AsyncClient(timeout=_FILE_DOWNLOAD_TIMEOUT) as client:
                # Step 1: getFile to resolve file_path
                resp = await client.post(
                    f"{self._base_url}/getFile",
                    json={"file_id": file_id},
                )
                data = resp.json()
                if not data.get("ok"):
                    log.warning("Telegram getFile failed: %s", data.get("description"))
                    return None
                file_path = data.get("result", {}).get("file_path")
                if not file_path:
                    return None
                # Step 2: download bytes
                file_url = f"{self._file_url_base}/{file_path}"
                file_resp = await client.get(file_url)
                if file_resp.status_code != 200:
                    log.warning(
                        "Telegram file download returned %s for %s",
                        file_resp.status_code,
                        file_id,
                    )
                    return None
                payload = file_resp.content
                if len(payload) > _MAX_PHOTO_BYTES:
                    log.warning(
                        "Telegram file %s is %d bytes — over cap, dropping",
                        file_id,
                        len(payload),
                    )
                    return None
                return payload
        except Exception:
            log.exception("Telegram file download crashed for file_id=%s", file_id)
            return None

    @staticmethod
    def _format_receipt_reply(result: IngestResult, caption: str) -> str:
        """Render the IngestResult as a Telegram-friendly summary.

        Markdown-ish; ``send_message`` retries plain-text on parse failure.
        Always honest about what the pipeline saw — audit flags surface
        verbatim so the user knows which fields to verify at /finance.
        """
        # Engine error path
        if result.error:
            return (
                f"I received your photo but couldn't process it as a receipt: "
                f"`{result.error}`."
            )
        draft = result.draft
        if draft is None:
            return "I received your photo but the pipeline returned no draft."

        # Idempotent dedup path — same image, already on file. The ingest
        # engine populates ``draft.draft_id`` with the existing
        # transaction's id (see ReceiptIngest._find_existing_by_hash).
        if result.deduplicated:
            return (
                f"ℹ️ I've seen this receipt before — it's already in the ledger "
                f"as `{draft.draft_id}`. No new draft created."
            )

        merchant = draft.merchant_raw or "(unreadable merchant)"
        total = (
            f"{draft.total} {draft.currency}".strip()
            if draft.total != "0.00" or draft.currency
            else "(unreadable total)"
        )
        flags = list(draft.audit_flags or ())
        lines = [
            f"✅ **Drafted** {merchant}",
            f"• Date: {draft.txn_date or '(unreadable)'}",
            f"• Total: {total}",
            f"• Items: {len(draft.items)}",
        ]
        if draft.payment_method:
            lines.append(f"• Payment: {draft.payment_method}")
        if flags:
            lines.append(f"• Audit flags: `{', '.join(flags)}`")
        if caption:
            lines.append(f"• Your note: {caption}")
        lines.append("")
        lines.append(f"Review and approve at /finance (draft id: `{draft.draft_id}`).")
        return "\n".join(lines)


# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------


def _split_message(text: str) -> list[str]:
    """Split long text into chunks that fit Telegram's per-message limit."""
    if len(text) <= _MAX_MSG_LEN:
        return [text]

    chunks: list[str] = []
    remaining = text
    while remaining:
        if len(remaining) <= _MAX_MSG_LEN:
            chunks.append(remaining)
            break

        split_at = _MAX_MSG_LEN
        for sep in ("\n\n", "\n", " "):
            idx = remaining.rfind(sep, 0, _MAX_MSG_LEN)
            if idx > _MAX_MSG_LEN // 2:
                split_at = idx + len(sep)
                break

        chunks.append(remaining[:split_at])
        remaining = remaining[split_at:]

    return chunks
