"""Telegram bot engine.

Handles messages (text, photo, voice), topic management, and Claude CLI integration.
Uses RetryHTTPXRequest for proxy resilience, progressive message editing for streaming.
"""

import asyncio
import json
import logging
import time
from datetime import datetime, timezone
from pathlib import Path

import yaml

from telegram import BotCommand, Update
from telegram.constants import ChatAction, ParseMode
from telegram.error import BadRequest, NetworkError
from telegram.ext import (
    Application,
    CommandHandler,
    ContextTypes,
    MessageHandler,
    filters,
)
from telegram.request import HTTPXRequest

from core.asr import transcribe
from core.claude_session import send_message as claude_send
from core.config import Config

logger = logging.getLogger(__name__)

# Streaming edit parameters
EDIT_INTERVAL = 1.5  # seconds between message edits
EDIT_MIN_DELTA = 150  # minimum new chars before editing


class RetryHTTPXRequest(HTTPXRequest):
    """HTTPXRequest with retry on ConnectError (SOCKS5 proxy hiccups)."""

    MAX_RETRIES = 3
    RETRY_DELAY = 2

    async def do_request(self, *args, **kwargs):
        last_exc = None
        for attempt in range(self.MAX_RETRIES):
            try:
                return await super().do_request(*args, **kwargs)
            except NetworkError as e:
                if "ConnectError" in str(e):
                    last_exc = e
                    if attempt < self.MAX_RETRIES - 1:
                        logger.warning(
                            "Telegram ConnectError (attempt %d/%d), retrying in %ds...",
                            attempt + 1, self.MAX_RETRIES, self.RETRY_DELAY,
                        )
                        await asyncio.sleep(self.RETRY_DELAY)
                else:
                    raise
        raise last_exc


def build_app(config: Config) -> Application:
    """Build and configure the Telegram Application."""
    builder = Application.builder().token(config.bot_token)

    # Configure HTTP client with proxy and timeouts
    request_kwargs = {
        "connect_timeout": 30.0,
        "read_timeout": 60.0,
        "write_timeout": 60.0,
        "pool_timeout": 10.0,
    }
    if config.proxy:
        request_kwargs["proxy"] = config.proxy

    request = RetryHTTPXRequest(**request_kwargs)
    builder = builder.request(request)
    builder = builder.concurrent_updates(True)

    app = builder.build()

    # Store config in bot_data for handler access
    app.bot_data["config"] = config

    # Register handlers (order matters — more specific first)
    app.add_handler(CommandHandler("start", handle_start))
    app.add_handler(CommandHandler("newtopic", handle_new_topic))
    app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
    app.add_handler(MessageHandler(filters.VOICE | filters.AUDIO, handle_voice))
    app.add_handler(MessageHandler(filters.Document.ALL, handle_document))
    app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))

    # Post-init: set bot commands
    app.post_init = _post_init

    return app


async def _post_init(application: Application) -> None:
    """Set bot commands menu after initialization."""
    commands = [
        BotCommand("newtopic", "Create a new topic"),
        BotCommand("start", "Start / help"),
    ]
    await application.bot.set_my_commands(commands)
    logger.info("Bot initialized: @%s", application.bot.username)


def _get_config(context: ContextTypes.DEFAULT_TYPE) -> Config:
    return context.bot_data["config"]


def _is_owner(update: Update, config: Config) -> bool:
    return update.effective_user and update.effective_user.id == config.owner_id


def _topic_id(update: Update) -> str:
    """Get topic ID from message, or 'general' for the default topic."""
    thread_id = update.effective_message.message_thread_id
    return str(thread_id) if thread_id else "general"


def _topic_dir(config: Config, topic_id: str) -> Path:
    """Get data directory for a topic."""
    d = config.data_dir / "topics" / topic_id
    d.mkdir(parents=True, exist_ok=True)
    return d


def _log_interaction(config: Config, topic_id: str, user_msg: str, bot_msg: str) -> None:
    """Append interaction to topic log."""
    log_file = _topic_dir(config, topic_id) / "log.jsonl"
    entry = {
        "ts": datetime.now(timezone.utc).isoformat(),
        "user": user_msg[:1000],
        "bot": bot_msg[:2000],
    }
    with open(log_file, "a") as f:
        f.write(json.dumps(entry, ensure_ascii=False) + "\n")


def _md_to_html(text: str) -> str:
    """Convert common Markdown to Telegram HTML."""
    import re
    # Escape HTML entities first (but preserve our conversions)
    text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")

    # Code blocks: ```lang\n...\n```
    text = re.sub(
        r"```\w*\n(.*?)```",
        lambda m: f"<pre>{m.group(1)}</pre>",
        text, flags=re.DOTALL,
    )
    # Inline code: `...`
    text = re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
    # Bold: **...**
    text = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", text)
    # Italic: *...*
    text = re.sub(r"\*(.+?)\*", r"<i>\1</i>", text)
    # Headers: ## ... → bold line
    text = re.sub(r"^#{1,6}\s+(.+)$", r"<b>\1</b>", text, flags=re.MULTILINE)
    # Bullet lists: - item → bullet
    text = re.sub(r"^- ", "• ", text, flags=re.MULTILINE)

    return text


async def _edit_text_md(message, text: str) -> None:
    """Edit message with HTML formatting, falling back to plain text."""
    try:
        html = _md_to_html(text)
        await message.edit_text(html, parse_mode=ParseMode.HTML)
    except BadRequest:
        try:
            await message.edit_text(text)
        except BadRequest:
            pass


# Cache of topic labels we've already applied: {topic_id: label}
_applied_labels: dict[str, str] = {}

# Pending questions from Claude: {topic_id: asyncio.Future}
_pending_questions: dict[str, asyncio.Future] = {}


async def _sync_topic_name(update: Update, config: Config, topic_id: str) -> None:
    """Rename Telegram topic if topic-map.yml has a new/changed label."""
    if topic_id == "general":
        return
    topic_map_path = config.data_dir / "topic-map.yml"
    if not topic_map_path.exists():
        return
    try:
        with open(topic_map_path) as f:
            topic_map = yaml.safe_load(f) or {}
        entry = topic_map.get(topic_id) or topic_map.get(int(topic_id))
        if not entry or not isinstance(entry, dict):
            return
        label = entry.get("label")
        if not label or _applied_labels.get(topic_id) == label:
            return
        await update.get_bot().edit_forum_topic(
            chat_id=update.effective_chat.id,
            message_thread_id=int(topic_id),
            name=label[:128],
        )
        _applied_labels[topic_id] = label
        logger.info("Renamed topic %s to: %s", topic_id, label)
    except BadRequest as e:
        if "not modified" not in str(e).lower():
            logger.warning("Failed to rename topic %s: %s", topic_id, e)
        _applied_labels[topic_id] = label  # don't retry
    except Exception as e:
        logger.warning("Error reading topic-map.yml: %s", e)


async def handle_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """Handle /start command."""
    config = _get_config(context)
    if not _is_owner(update, config):
        return
    await update.effective_message.reply_text(
        "Ready. Send me a message or use /newtopic to create a topic."
    )


async def handle_new_topic(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """Handle /newtopic <name> — create a forum topic."""
    config = _get_config(context)
    if not _is_owner(update, config):
        return

    name = " ".join(context.args) if context.args else None
    if not name:
        await update.effective_message.reply_text("Usage: /newtopic Topic Name")
        return

    try:
        topic = await context.bot.create_forum_topic(
            chat_id=update.effective_chat.id,
            name=name,
        )
        tid = str(topic.message_thread_id)
        _topic_dir(config, tid)
        await context.bot.send_message(
            chat_id=update.effective_chat.id,
            message_thread_id=topic.message_thread_id,
            text=f"Topic created. Send me anything here.",
        )
        logger.info("Created topic: %s (id=%s)", name, tid)
    except BadRequest as e:
        logger.error("Failed to create topic: %s", e)
        await update.effective_message.reply_text(f"Failed to create topic: {e}")


async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """Handle text messages — send to Claude CLI."""
    config = _get_config(context)
    if not _is_owner(update, config):
        return

    tid = _topic_id(update)
    user_text = update.effective_message.text

    # If Claude is waiting for an answer in this topic, deliver it
    if tid in _pending_questions:
        future = _pending_questions.pop(tid)
        if not future.done():
            future.set_result(user_text)
            return

    # Send typing indicator and placeholder
    await context.bot.send_chat_action(
        chat_id=update.effective_chat.id,
        action=ChatAction.TYPING,
        message_thread_id=update.effective_message.message_thread_id,
    )
    placeholder = await update.effective_message.reply_text("thinking...")

    # Streaming state
    last_edit_time = 0.0
    last_edit_len = 0

    async def on_chunk(text_so_far: str):
        nonlocal last_edit_time, last_edit_len
        now = time.monotonic()
        delta = len(text_so_far) - last_edit_len

        if delta >= EDIT_MIN_DELTA and (now - last_edit_time) >= EDIT_INTERVAL:
            try:
                display = _truncate_for_telegram(text_so_far)
                await placeholder.edit_text(display)
                last_edit_time = now
                last_edit_len = len(text_so_far)
            except BadRequest:
                pass  # message not modified or too long

    async def on_question(question: str) -> str:
        """Claude asks user a question — send it and wait for reply."""
        await update.effective_message.reply_text(f"❓ {question}")
        loop = asyncio.get_event_loop()
        future = loop.create_future()
        _pending_questions[tid] = future
        return await future

    topic_dir = _topic_dir(config, tid)

    try:
        response = await claude_send(
            config, tid, user_text, on_chunk=on_chunk, on_question=on_question,
        )
        display = _truncate_for_telegram(response)
        await _edit_text_md(placeholder, display)
    except RuntimeError as e:
        logger.error("Claude error for topic %s: %s", tid, e)
        await placeholder.edit_text(f"Error: {e}")
        response = f"[error] {e}"
    finally:
        _pending_questions.pop(tid, None)

    await _send_outbox(update, topic_dir)
    _log_interaction(config, tid, user_text, response)
    await _sync_topic_name(update, config, tid)


async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """Handle photo messages — save image, send path to Claude."""
    config = _get_config(context)
    if not _is_owner(update, config):
        return

    tid = _topic_id(update)
    images_dir = _topic_dir(config, tid) / "images"
    images_dir.mkdir(exist_ok=True)

    # Download the largest photo
    photo = update.effective_message.photo[-1]
    file = await context.bot.get_file(photo.file_id)
    ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
    filename = f"{ts}_{photo.file_unique_id}.jpg"
    filepath = images_dir / filename
    await file.download_to_drive(str(filepath))

    caption = update.effective_message.caption or ""
    message = f"User sent an image: {filepath}"
    if caption:
        message += f"\nCaption: {caption}"

    # Send typing and placeholder
    placeholder = await update.effective_message.reply_text("looking at image...")

    try:
        response = await claude_send(config, tid, message)
        display = _truncate_for_telegram(response)
        await _edit_text_md(placeholder, display)
    except RuntimeError as e:
        logger.error("Claude error for photo in topic %s: %s", tid, e)
        await placeholder.edit_text(f"Error: {e}")
        response = f"[error] {e}"

    _log_interaction(config, tid, f"[photo] {caption}", response)
    await _sync_topic_name(update, config, tid)


async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """Handle document messages — save file, send path to Claude."""
    config = _get_config(context)
    if not _is_owner(update, config):
        return

    tid = _topic_id(update)
    docs_dir = _topic_dir(config, tid) / "documents"
    docs_dir.mkdir(exist_ok=True)

    doc = update.effective_message.document
    file = await context.bot.get_file(doc.file_id)
    # Use original filename if available, otherwise generate one
    orig_name = doc.file_name or f"{doc.file_unique_id}"
    ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
    filename = f"{ts}_{orig_name}"
    filepath = docs_dir / filename
    await file.download_to_drive(str(filepath))

    caption = update.effective_message.caption or ""
    message = f"User sent a document: {filepath} (name: {orig_name}, size: {doc.file_size} bytes)"
    if caption:
        message += f"\nCaption: {caption}"

    topic_dir = _topic_dir(config, tid)
    placeholder = await update.effective_message.reply_text("reading document...")

    try:
        response = await claude_send(config, tid, message)
        display = _truncate_for_telegram(response)
        await _edit_text_md(placeholder, display)
    except RuntimeError as e:
        logger.error("Claude error for document in topic %s: %s", tid, e)
        await placeholder.edit_text(f"Error: {e}")
        response = f"[error] {e}"

    await _send_outbox(update, topic_dir)
    _log_interaction(config, tid, f"[document: {orig_name}] {caption}", response)
    await _sync_topic_name(update, config, tid)


async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """Handle voice/audio messages — save file, send path to Claude."""
    config = _get_config(context)
    if not _is_owner(update, config):
        return

    tid = _topic_id(update)
    voice_dir = _topic_dir(config, tid) / "voice"
    voice_dir.mkdir(exist_ok=True)

    # Download voice file
    voice = update.effective_message.voice or update.effective_message.audio
    file = await context.bot.get_file(voice.file_id)
    ext = "ogg" if update.effective_message.voice else "mp3"
    ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
    filename = f"{ts}_{voice.file_unique_id}.{ext}"
    filepath = voice_dir / filename
    await file.download_to_drive(str(filepath))

    topic_dir = _topic_dir(config, tid)

    # Transcribe via Whisper if available, otherwise send file path
    if config.whisper_url:
        placeholder = await update.effective_message.reply_text("transcribing voice...")
        try:
            text = await transcribe(str(filepath), config.whisper_url)
            message = f"[voice message transcription]: {text}"
            logger.info("Transcribed voice in topic %s: %d chars", tid, len(text))
            # Show transcription to user, then send to Claude
            try:
                await placeholder.edit_text(f"🎤 {text}")
            except BadRequest:
                pass
            placeholder = await update.effective_message.reply_text("thinking...")
        except RuntimeError as e:
            logger.error("ASR failed for topic %s: %s", tid, e)
            message = f"User sent a voice message: {filepath} (duration: {voice.duration}s)\n(transcription failed: {e})"
    else:
        message = f"User sent a voice message: {filepath} (duration: {voice.duration}s)"
        placeholder = await update.effective_message.reply_text("processing voice...")

    try:
        response = await claude_send(config, tid, message)
        display = _truncate_for_telegram(response)
        await _edit_text_md(placeholder, display)
    except RuntimeError as e:
        logger.error("Claude error for voice in topic %s: %s", tid, e)
        await placeholder.edit_text(f"Error: {e}")
        response = f"[error] {e}"

    await _send_outbox(update, topic_dir)
    _log_interaction(config, tid, message, response)
    await _sync_topic_name(update, config, tid)


async def _send_outbox(update: Update, topic_dir: Path) -> None:
    """Send files queued in outbox.jsonl by Claude via send-to-user tool."""
    outbox = topic_dir / "outbox.jsonl"
    if not outbox.exists():
        return

    entries = []
    try:
        with open(outbox) as f:
            for line in f:
                line = line.strip()
                if line:
                    entries.append(json.loads(line))
        # Clear outbox
        outbox.unlink()
    except Exception as e:
        logger.error("Failed to read outbox: %s", e)
        return

    for entry in entries:
        fpath = Path(entry.get("path", ""))
        ftype = entry.get("type", "document")
        caption = entry.get("caption", "") or fpath.name

        if not fpath.is_file():
            logger.warning("Outbox file not found: %s", fpath)
            continue

        try:
            with open(fpath, "rb") as f:
                if ftype == "image":
                    await update.effective_message.reply_photo(photo=f, caption=caption)
                elif ftype == "video":
                    await update.effective_message.reply_video(video=f, caption=caption)
                elif ftype == "audio":
                    await update.effective_message.reply_voice(voice=f, caption=caption)
                else:
                    await update.effective_message.reply_document(document=f, caption=caption)
            logger.info("Sent %s: %s", ftype, fpath.name)
        except Exception as e:
            logger.error("Failed to send %s %s: %s", ftype, fpath.name, e)


def _truncate_for_telegram(text: str, max_len: int = 4096) -> str:
    """Truncate text to Telegram message limit."""
    if len(text) <= max_len:
        return text
    return text[: max_len - 20] + "\n\n[truncated]"
