#!/usr/bin/env python
import asyncio
import datetime
import logging
import json
import os
import re
from math import floor

from telegram import Bot, Update, Message, Chat, User, ReactionTypeEmoji, MessageEntity
from telegram.constants import MessageLimit
from telegram.error import TelegramError
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes

from .conversation import Conversation
from .utils import format_dt
from .database import (
    get_private_mode,
    set_private_mode,
    delete_messages_for_user,
    delete_messages_for_chat,
    delete_private_messages_for_user,
    delete_bot_replies_for_user,
    get_shared_group_chat_ids,
    search_messages,
)
from .initialize import (
    INIT_BOT_CONFIG,
    ApiKeyStatus,
    init_logging,
    init_structure,
)
from .message_handlers import handle_greetings, handle_common_queries, handle_url_ask
from .models import TokenLimits
from .providers.factory import get_provider
from .providers.base import ContextLengthExceededError, ProviderAuthError, ProviderConnectionError, ToolCall
from .utils import exact_word_match, log_error

logger = logging.getLogger(__name__)

_SEARCH_TOOL = {
    "name": "search_messages",
    "description": (
        "Search the full message history across the user's private chat and shared group chats. "
        "Use whenever the user asks who said something, what someone said, or what was discussed. "
        "At least one of content_query or speaker_query must be provided."
    ),
    "parameters": {
        "type": "object",
        "properties": {
            "content_query": {"type": "string", "description": "Text to search for in message content"},
            "speaker_query": {"type": "string", "description": "Name or @username of the person who sent the message. Use the exact name or @username if known. If multiple users match, the search will return an ambiguity error asking you to clarify."},
            "chat_query": {"type": "string", "description": "Name of the group chat to search within. Use the exact chat title if known. If multiple chats match, the search will return an ambiguity error asking you to clarify."},
            "date_from": {"type": "string", "description": "ISO date string (YYYY-MM-DD) for start of range"},
            "date_to": {"type": "string", "description": "ISO date string (YYYY-MM-DD) for end of range"},
        },
        "required": [],
    },
}

class TelegramBot:
    """
    The bridge between the Telegram interface and a Large Language Model (LLM).
    - Tokens help retain conversation messages between the Telegram bot assistant and the user.
    - URLs in [square brackets] can be supplied on how the bot should interpret it.
    - For more information, see README or: https://github.com/Digital-Heresy/TeLLMgramBot
    """
    async def _tele_info(self):
        """Fetch the Telegram bot's information before running application."""
        try:
            bot = await Bot(token=os.environ['TELLMGRAMBOT_TELEGRAM_API_KEY']).get_me()
            self.telegram['bot_id'] = bot.id
            self.telegram['username'] = bot.username
            self.telegram['first_name'] = bot.first_name
            self.telegram['last_name'] = bot.last_name
        except Exception as e:
            # Fail fast if the bot's identity cannot be fetched to avoid
            # continuing with an uninitialized self.telegram['username'].
            log_error(e, 'Telegram')
            raise

    async def tele_commands(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Show all the commands available when the user sends `/help` to the bot."""
        await update.message.reply_text("TeLLMgramBot commands:\n"
            "/nick - Set your nickname (e.g. \"/nick B0b #2\").\n"
            "/forget - Clear all of your conversations.\n"
            "/private - Toggle private mode (private chats only).\n"
            "/help - Display this usage information.\n\n"
            "Chat history will still show in your Telegram client.\n\n"
            "Administrator-only commands:\n"
            "/start - Go online to receive new responses (default).\n"
            "/stop - Go offline to prevent new responses.\n"
        )

    async def tele_start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """A bot_owner-only command to go online and handle any new messages."""
        uname = update.message.from_user.username
        if uname == self.telegram['owner']:
            self._online = True
            greeting_text = f"Oh, hello {update.message.from_user.first_name}! Let me get to work!"
            await update.message.reply_text(greeting_text)
        else:
            await update.message.reply_text("Sorry, I can't do that for you.")

    async def tele_stop_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """A bot_owner-only command to go offline and not handle any new messages."""
        uname = update.message.from_user.username
        if uname == self.telegram['owner']:
            self._online = False
            await update.message.reply_text("Sure thing boss, cutting out!")
        else:
            await update.message.reply_text("Sorry, I can't do that for you.")

    async def tele_nick_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """
        Let bot assistant know to call the user by a different name other than the Telegram username.
        Must follow the format of "/nick(@<bot name>)? <nickname>", checked by a regular expression.
        """
        validated = await self.tele_validate(update)
        if not validated:
            return
        (msg, chat, user) = validated

        # If it's a group conversation, need to handle commands directly to bot with @<bot name> also
        pattern = re.compile(fr"^\s*/nick(?:@{re.escape(self.telegram['username'])})?\s+(.+?)\s*$", re.IGNORECASE)
        result = pattern.search(msg.text or "")
        if result:
            prompt = f"Please refer to me by my nickname, {result.group(1).strip()}, rather than my user name."
            await msg.reply_text(await self.tele_handle_response(prompt, msg))
        else:
            await msg.reply_text("Please provide a valid nickname after the command like \"/nick B0b #2\".")

    async def tele_forget_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """
        Remove conversation history from the database (behavior depends on chat type).

        In private chats: deletes all bot replies whose reply_to_id points to this user's messages,
        then deletes the user's rows across all chats (full wipe including group messages),
        then deletes any remaining messages in the private chat (pre-migration rows with NULL reply_to_id).
        In group chats: deletes bot replies linked to this user's messages, then deletes the user's
        rows across all chats, leaving other users' messages intact.

        In both cases, evicts only the in-memory Conversation objects that contain this
        user's data - scoped to the current chat, the user's private chat, and any other
        chats where their context was previously merged. Other users' active sessions are
        not affected.
        """
        validated = await self.tele_validate(update)
        if not validated:
            return
        (msg, chat, user) = validated
        user_id = user.id
        chat_id = chat.id
        chat_type = chat.type

        # Find all chat_ids where this user's context has been merged in memory,
        # identified by user_id being present in the Conversation's _context_cursor.
        user_chat_ids = {
            gid for gid, conv in self.conversations.items() if user_id in conv._context_cursor
        }
        # Always include the private chat (chat_id == user_id in Telegram) and current chat.
        user_chat_ids.update({chat_id, user_id})

        if chat_type == 'private':
            # Wipe bot replies linked to this user, then user's own rows across all chats,
            # then any remaining bot replies in the private chat (pre-migration rows).
            await delete_bot_replies_for_user(user_id)
            await delete_messages_for_user(user_id)
            await delete_messages_for_chat(chat_id)
        else:
            await delete_bot_replies_for_user(user_id)
            await delete_messages_for_user(user_id)

        # Evict only the Conversations that contain this user's data, not all sessions.
        for evict_id in user_chat_ids:
            self.conversations.pop(evict_id, None)
            self.token_warning.pop(evict_id, None)

        await msg.reply_text("My memories of our conversations are wiped!")

    async def tele_private_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """
        Enable or disable private mode for the requesting user (private chats only).

        When private mode is ON, messages sent in private chats are flagged as is_private=1
        in the database. These rows are excluded from all context loads - both group context
        (cross-pollination) and private chat reloads. On /private off, they are permanently
        deleted from the DB and purged from in-session memory (neuralyzer semantics).

        Usage:
            /private       - enables private mode (idempotent if already on)
            /private off   - disables private mode and purges in-session private messages

        Only available in private chats; sending the command in a group returns an error.
        """
        validated = await self.tele_validate(update)
        if not validated:
            return
        (msg, chat, user) = validated

        if chat.type != 'private':
            await msg.reply_text("Private mode is only available in private chats.")
            return

        user_id = user.id
        chat_id = chat.id
        args = (msg.text or "").strip().split()
        force_off = len(args) > 1 and args[1].lower() == 'off'

        if force_off:
            # Neuralyzer: delete before flipping the mode flag so that if the delete
            # fails, private mode stays ON and the user can safely retry /private off.
            await delete_private_messages_for_user(user_id)
            await set_private_mode(user_id, False)
            # Also purge from in-memory conversation so the bot cannot reference them now.
            if chat_id in self.conversations:
                self.conversations[chat_id].purge_private_messages()
            await msg.reply_text(
                "Private mode is OFF. Future messages in this private chat will be included in group conversation contexts."
            )
        else:
            await set_private_mode(user_id, True)
            await msg.reply_text(
                "Private mode is ON. Future messages in this private chat will be excluded "
                "from group conversation contexts. Use /private off to disable."
            )

    async def tele_handle_response(self, text: str, msg: Message) -> str:
        """
        Primary function for handling any response including Generative AI, ensuring:
        - The owner started up the bot assistant for user interactions.
        - User has conversed with the bot assistant before.
        - Depending on regular interaction or URL request, formulate message based
          on the Generative AI model (e.g. OpenAI GPT) and token count checks.

        Conversations are keyed by chat_id (Telegram chat ID) and track both user_id
        and bot_id to support multi-participant group conversations. Respects per-user
        private mode settings when loading past context.

        Feature gating: If chat_enabled or url_analysis_enabled are False (per key_status),
        those features return a generic "couldn't process" error rather than attempting
        to call disabled LLM providers.

        Args:
            text: The user's message text.
            msg: The Telegram Message object containing chat/user context.

        Returns:
            The bot's response string. If no handler matches or features are disabled,
            returns a generic error message.

        Side Effects:
            - Adds user message to conversation history (keyed by chat_id).
            - Upserts user (first_name, last_name, username) and chat metadata.
            - Loads past interactions if this is the first message in this chat_id.
            - May invoke LLM tool calling (search_messages) and perform a second LLM round-trip.
            - Generates a token warning if conversation nears the limit.
            - Prunes conversation if token count exceeds the threshold.
            - Stores bot's response with bot identity and reply_to_id for tracking.
        """
        # Starting ensures we get some kind of user account details for logging
        if not self._online:
            return "I'd love to chat, but I am offline at the moment!"

        # Extract identity and context from the message
        user_id    = msg.from_user.id
        username   = msg.from_user.username
        first_name = msg.from_user.first_name
        last_name  = msg.from_user.last_name
        chat_id    = msg.chat.id
        chat_type  = msg.chat.type
        chat_title = msg.chat.title
        identity = f"@{username}" if username else ' '.join(filter(None, [first_name, last_name]))
        logger.info(f"User {user_id} ({identity}) in {chat_type} Chat {chat_id}{f' ({chat_title})' if chat_title else ''}")

        # For a new session, create a Conversation keyed by chat_id
        if chat_id not in self.conversations:
            self.conversations[chat_id] = Conversation(
                chat_id,
                chat_type,
                self.llm['prompt'],
                self.llm['chat_model'],
                chat_title,
            )

        conv = self.conversations[chat_id]

        # Refresh datetime on every message so timestamps stay accurate across long sessions
        conv.update_datetime()

        token_budget = floor(self.llm['prune_threshold'] / 2)

        if user_id not in conv._context_cursor:
            # First appearance of this user in this session - load their cross-chat history.
            # Pass bot_id so private chats can also load shared group context.
            # If no history exists yet, the cursor stays unset so the next message retries.
            await conv.get_past_interaction(token_budget, user_id, self.telegram['bot_id'])
        else:
            # Already loaded - check for new cross-chat messages since last load.
            await conv.refresh_user_context(user_id, token_budget)

        # Add the user's message to our conversation, respecting private mode
        # Private mode only applies in private chats - group messages are never flagged private
        user_private_mode = await get_private_mode(user_id)
        is_private = (chat_type == 'private') and user_private_mode
        user_msg_id = await conv.add_user_message(
            text, user_id, username, first_name, last_name, is_private
        )

        # Check if the user is asking about a [URL]
        url_match = re.search(r'\[http(s)?://\S+]', text)

        # Form the assistant's message based on low level easy stuff or send to the LLM
        reply = "Sorry, I couldn't process your message! Please contact my creator."
        if handle_greetings(text):
            reply = handle_greetings(text)
        elif handle_common_queries(text):
            reply = handle_common_queries(text)
        elif url_match and self.key_status.url_analysis_enabled:
            await msg.reply_text("Sure, give me a moment to look at that URL...")
            reply = await handle_url_ask(text, self.llm['url_model'])
        elif self._online and self.key_status.chat_enabled:
            # This is the transition point between quick Telegram replies and the LLM.
            tools = [_SEARCH_TOOL]
            result = await self.llm_completion(chat_id, tools)
            if isinstance(result, ToolCall):
                reply = await self._resolve_tool_call(result, conv, user_id)
            else:
                reply = result

        # Check token count before storing to determine if warning should be appended
        token_count = await conv.get_message_token_count()
        if token_count > self.llm['prune_back_to'] and chat_id not in self.token_warning:
            reply += ("\n\n"
                "By the way, our conversation will soon reach my token limit, so I may"
                " start forgetting some of our older exchanges. Would you like me to"
                " summarize our conversation so far to keep the main points alive?"
            )
            self.token_warning[chat_id] = True

        # Add the full reply (including any warning) to the conversation.
        # Propagate is_private so bot replies in private-mode exchanges are also excluded
        # from group context loading - otherwise the assistant's half of the conversation leaks.
        await conv.add_assistant_message(
            reply,
            self.telegram['bot_id'],
            self.telegram['username'],
            self.telegram['first_name'],
            self.telegram['last_name'],
            is_private,
            user_msg_id,
        )

        # Recompute token count after full exchange is stored, then prune if needed
        token_count = await conv.get_message_token_count()
        if token_count > self.llm['prune_threshold']:
            await conv.prune_conversation(self.llm['prune_back_to'])

        return reply

    def _foreign_bot_mention(self, msg: Message) -> str | None:
        """
        Return the @username of the first explicitly @mentioned account that is not us, or None.

        Inspects MessageEntity.MENTION entities in msg.entities. Used to detect when a
        reply-to-bot message explicitly @mentions another account (bot or user), so we can
        defer politely instead of responding as if the message was meant for us. Note that
        Telegram does not distinguish bots from regular users in MENTION entities; this
        method detects any foreign @mention. Deflections skip the read receipt -- the 👀
        reaction is only sent when we are going to generate a full response.

        Uses msg.parse_entity() for correct UTF-16 offset handling (Telegram offsets are
        UTF-16 code units, which can differ from Python str indices for non-BMP characters
        such as emoji appearing before the @mention).

        Args:
            msg: The incoming Telegram Message object.

        Returns:
            The @username string (with leading @) of the first foreign mention, or None if
            the message contains no @mentions of other accounts.
        """
        if not msg.entities:
            return None
        our_username = self.telegram['username'].lower()
        for entity in msg.entities:
            if entity.type == MessageEntity.MENTION:
                mentioned = msg.parse_entity(entity)
                if mentioned.lstrip('@').lower() != our_username:
                    return mentioned
        return None

    async def _send_read_receipt(self, msg: Message, context: ContextTypes.DEFAULT_TYPE):
        """
        Send a read receipt acknowledgement via message reaction.

        Attempts to add a 👀 emoji reaction to the user's message to confirm the bot
        read it. If the message reaction API is unavailable (e.g., older Telegram clients),
        falls back to a short "Got it!" text reply. Called unconditionally on every group
        mention before the normal LLM response.

        Args:
            msg: The Telegram Message object to react to.
            context: The Telegram context for sending the reply if reaction fails.
        """
        try:
            await context.bot.set_message_reaction(
                chat_id=msg.chat.id,
                message_id=msg.message_id,
                reaction=[ReactionTypeEmoji(emoji="👀")],
            )
        except (TelegramError, AttributeError):
            await msg.reply_text("Got it!")

    async def tele_handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """
        Route incoming Telegram messages to appropriate handlers based on chat type and trigger conditions.

        In group/supergroup chats, the bot responds when any of the following conditions are met:
        - User mentions the bot by @username
        - User mentions the bot by nickname (set via config)
        - User mentions the bot by initials (set via config)
        - User directly replies to one of the bot's messages (Telegram reply-to feature)

        In all matching group scenarios, a read receipt acknowledgement (👀 reaction or "Got it!" fallback)
        is sent immediately via _send_read_receipt() before generating the full LLM response.

        In private chats, the bot responds to all messages.

        Args:
            update: The Telegram Update object containing the incoming message.
            context: The Telegram context for sending replies.
        """
        validated = await self.tele_validate(update)
        if not validated:
            return
        (msg, chat, user) = validated

        # If it's a group text, only reply if the bot is named
        # The real magic of how the bot behaves is in tele_handle_response()
        response = "Sorry, I couldn't process your message! Please contact my creator."
        if chat.type == 'supergroup' or chat.type == 'group':
            is_reply_to_bot = (
                msg.reply_to_message is not None and
                msg.reply_to_message.from_user is not None and
                msg.reply_to_message.from_user.id == self.telegram['bot_id']
            )
            if exact_word_match(self.telegram['username'], msg.text):
                pattern = r'@?\b' + re.escape(self.telegram['username']) + r'\b'
                new_text = re.sub(pattern, '', msg.text).strip()
                await self._send_read_receipt(msg, context)
                response = await self.tele_handle_response(new_text, msg)
            elif (
                exact_word_match(self.telegram['nickname'], msg.text) or
                exact_word_match(self.telegram['initials'], msg.text)
            ):
                await self._send_read_receipt(msg, context)
                response = await self.tele_handle_response(msg.text, msg)
            elif is_reply_to_bot:
                foreign = self._foreign_bot_mention(msg)
                if foreign:
                    # The reply is directed at a different account - deflect without
                    # sending a read receipt, since we are not handling this message.
                    await msg.reply_text(f"Looks like that message is for {foreign}!")
                    return
                await self._send_read_receipt(msg, context)
                response = await self.tele_handle_response(msg.text, msg)
            else:
                return
        elif chat.type == 'private':
            response = await self.tele_handle_response(msg.text, msg)
        else:
            return

        # Split into smaller chunks since Telegram messages have a maximum text length (likely 4096)
        chunk_length = MessageLimit.MAX_TEXT_LENGTH - 1
        for chunk in [response[i:i+chunk_length] for i in range(0, len(response), chunk_length)]:
            await msg.reply_text(chunk)
            await asyncio.sleep(0.12)  # small pause to reduce risk of rate limiting

    async def tele_validate(self, update: Update) -> tuple[Message, Chat, User] | None:
        """
        Check if any update through Telegram has a complete message, chat, and user.
        Requires a valid message and chat; user.id must be present (username is optional).
        Uses python-telegram-bot's Update `effective` methods for all three.
        """
        if update is None:
            return None

        # Any python-telegram-bot 'effective' method helps ensure there are no "None" types
        # Non-user updates like Inline queries or chat member updates makes either one undefined
        msg  = update.effective_message
        chat = update.effective_chat
        user = update.effective_user

        # If there is no chat or valid message of the unique conversation, no reason to reply
        if chat is None or msg is None:
            return None
        # chat.type => 'private', 'group', 'supergroup', 'channel'
        # chat.id = user.id if 'private'

        # Handle the user sending the message; user.id is always present
        if not user:
            return None

        return (msg, chat, user)

    async def tele_error(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle any conversation error caused on the Telegram side."""
        log_error(context.error, 'Telegram')
        msg = update.effective_message if update else None
        if msg:
            await msg.reply_text("Sorry, I ran into an error! Please contact my creator.")

    async def _handle_tool_call(self, tool_call: ToolCall, user_id: int, chat_id: int) -> str:
        """
        Execute a tool call requested by the LLM and return the result as a JSON string.

        Supports the 'search_messages' tool. Search results are scoped to chats accessible
        to the requesting user: their private chat, the current chat (always included so the
        current group is always searchable), and shared groups where the user and bot have
        both sent messages.

        Args:
            tool_call: The ToolCall returned by the provider.
            user_id: Telegram user ID of the requesting user (for access control).
            chat_id: Telegram chat ID of the current conversation (always included
                     in accessible_chat_ids for search, ensuring current chat is searchable).

        Returns:
            JSON-encoded list of result dicts with keys: speaker, chat, content, timestamp.
            JSON-encoded ambiguity sentinel dict (with '_ambiguous': True) when speaker_query
            or chat_query matched multiple entities; the LLM uses this to ask the user to clarify.
            Returns a JSON error object on failure.
        """
        if tool_call.name == 'search_messages':
            args = tool_call.arguments
            shared_groups = await get_shared_group_chat_ids(user_id, self.telegram['bot_id'])
            accessible = list({user_id, chat_id, *shared_groups})
            results = await search_messages(
                accessible,
                args.get('content_query'),
                args.get('speaker_query'),
                args.get('chat_query'),
                args.get('date_from'),
                args.get('date_to'),
            )
            if isinstance(results, list):
                for r in results:
                    ts = r.get('timestamp', '')
                    if ts:
                        try:
                            dt = datetime.datetime.fromisoformat(ts.rstrip('Z')).replace(tzinfo=datetime.timezone.utc)
                            r['timestamp'] = format_dt(dt)
                        except ValueError:
                            pass
            return json.dumps(results)
        return json.dumps({"error": f"Unknown tool: {tool_call.name}"})

    async def _resolve_tool_call(self, tool_call: ToolCall, conv: Conversation, user_id: int) -> str:
        """
        Execute a tool call and perform the second LLM round-trip with results injected.

        Builds an ephemeral message slice that appends a placeholder assistant acknowledgement
        and a user message containing the tool results to conv.messages, then calls the
        provider for a final natural-language answer. The ephemeral messages are not stored
        in conv.messages -- only the final answer is persisted by the caller.

        Args:
            tool_call: The ToolCall returned by the first LLM call.
            conv: The active Conversation for this chat.
            user_id: Telegram user ID of the requesting user (for search access control).

        Returns:
            The final LLM response string, or a fallback error message on failure.
        """
        tool_result = await self._handle_tool_call(tool_call, user_id, conv.chat_id)
        ephemeral = list(conv.messages) + [
            {"role": "assistant", "content": "Let me handle that."},
            {"role": "user", "content": f"Tool result:\n{tool_result}\n\nPlease respond to my request based on this result."},
        ]
        provider = get_provider(self.llm['chat_model'])
        try:
            result = await provider.complete(self.llm['chat_model'], ephemeral)
            return result if isinstance(result, str) else "Sorry, I couldn't process the tool result."
        except Exception as e:
            log_error(e, 'ToolCall')
            return "Sorry, I had trouble handling that request."

    async def llm_completion(self, chat_id: int, tools: list[dict] | None = None, _retry: bool = True) -> "str | ToolCall":
        """
        Get the LLM response for the conversation in the given chat.

        Args:
            chat_id: Telegram chat ID (negative for groups, positive for private).
            tools: Optional list of tool definitions in unified format. When provided,
                   the LLM may return a ToolCall instead of a string.
            _retry: Internal flag to prevent infinite retry loops on context overflow.

        Returns:
            The LLM's response string, a ToolCall if the model wants to invoke a tool,
            or a user-facing error message string on failure.

        Selects the provider (OpenAI or Anthropic) automatically based on the configured model.
        On context length errors, prunes the conversation and retries once. Other errors are
        logged and return a user-facing fallback string.
        """
        provider = get_provider(self.llm['chat_model'])
        try:
            return await provider.complete(self.llm['chat_model'], self.conversations[chat_id].messages, tools)
        except ContextLengthExceededError:
            if not _retry:
                return "Sorry, your conversation is too long for me to respond to. Try /forget to start fresh."
            logger.warning(f"Chat {chat_id} reached maximum context length, pruning conversation")
            await self.conversations[chat_id].prune_conversation(self.llm['prune_back_to'])
            return await self.llm_completion(chat_id, tools, False)
        except ProviderAuthError as e:
            log_error(e, f"{self.llm['chat_model']} Authentication")
            return "Sorry, I'm having trouble authenticating with the AI provider. Please contact my owner!"
        except ProviderConnectionError as e:
            log_error(e, f"{self.llm['chat_model']} Connection")
            return "Sorry, I couldn't reach the AI provider. Please try again in a moment!"
        except Exception as e:
            log_error(e, f"{self.llm['chat_model']} Other")
            return "Sorry, something went wrong on my end. Please try again!"

    def start_polling(self):
        """The main polling "loop" the user interacts with via Telegram."""
        logger.info(f"TeLLMgramBot {self.telegram['username']} polling...")
        self.telegram['app'].run_polling(poll_interval=self.telegram['pollinterval'])
        logger.info(f"TeLLMgramBot {self.telegram['username']} polling ended.")

    # Initialization
    def __init__(self,
        bot_owner      = INIT_BOT_CONFIG['bot_owner'],
        bot_nickname   = INIT_BOT_CONFIG['bot_nickname'],
        bot_initials   = INIT_BOT_CONFIG['bot_initials'],
        chat_model     = INIT_BOT_CONFIG['chat_model'],
        url_model      = INIT_BOT_CONFIG['url_model'],
        token_limit    = INIT_BOT_CONFIG['token_limit'],
        persona_temp   = INIT_BOT_CONFIG['persona_temp'],
        persona_prompt = INIT_BOT_CONFIG['persona_prompt'],
        key_status: ApiKeyStatus | None = None,
        log_name: str = 'tellmgrambot',
    ):
        """
        Initialize the Telegram bot with LLM configuration and API keys.

        Args:
            bot_owner: Telegram username of the bot owner (required, no `@`).
            bot_nickname: Nickname the bot responds to in group chats.
            bot_initials: Initials the bot responds to in group chats.
            chat_model: LLM model for conversation (e.g., 'gpt-5-mini', 'claude-sonnet-4-6').
            url_model: LLM model for URL analysis (can differ from chat_model).
            token_limit: Maximum tokens for conversations. If None, uses the chat_model default.
            persona_temp: LLM temperature (0.0-2.0). If None, defaults to 1.0.
            persona_prompt: System prompt defining the bot's behavior and personality.
            key_status: ApiKeyStatus object indicating available features. If None, calls init_structure().

        Side Effects:
            - Fetches bot metadata (username, first_name, last_name, bot_id) from Telegram API via _tele_info().
            - Initializes command and message handlers for the Telegram application.
            - Sets self._online to True once initialization completes (ready for polling).
        """
        # Starting to initialize, not online yet
        self._online = False

        # Bootstrap structure and logging; init_logging() is a no-op if init_structure already ran it.
        self.key_status = key_status if key_status is not None else init_structure(log_name=log_name)[0]
        init_logging(log_name)

        # Initialize some variables
        self.token_warning = {}  # Determines whether user has reached token limit by AI model
        self.conversations = {}  # Provides Conversation class per user based on bot response
        self.telegram = {
            'bot_id'       : 0,  # overwritten by _tele_info(); 0 is a safe sentinel
            'owner'        : bot_owner,
            'nickname'     : bot_nickname,
            'initials'     : bot_initials,
            'username'     : None,
            'first_name'   : None,
            'last_name'    : None,
            'pollinterval' : 3,
        }
        # Check for event running loops before getting the bot's information
        try:
            loop = asyncio.get_running_loop()
        except RuntimeError:
            # No running event loop; safe to run synchronously
            asyncio.run(self._tele_info())
        else:
            # Already in an event loop; schedule as a background task
            loop.create_task(self._tele_info())

        # Build our application with handlers for Commands, Messages, and Errors
        self.telegram['app'] = Application.builder().token(os.environ['TELLMGRAMBOT_TELEGRAM_API_KEY']).build()
        self.telegram['app'].add_handler(CommandHandler('help', self.tele_commands))
        self.telegram['app'].add_handler(CommandHandler('start', self.tele_start_command))
        self.telegram['app'].add_handler(CommandHandler('stop', self.tele_stop_command))
        self.telegram['app'].add_handler(CommandHandler('nick', self.tele_nick_command))
        self.telegram['app'].add_handler(CommandHandler('forget', self.tele_forget_command))
        self.telegram['app'].add_handler(CommandHandler('private', self.tele_private_command))
        self.telegram['app'].add_handler(MessageHandler(filters.TEXT & ~filters.UpdateType.EDITED_MESSAGE, self.tele_handle_message))
        self.telegram['app'].add_error_handler(self.tele_error)

        # Get our LLM spun up with defaults if not defined by user input
        # Tokens as integers measure the length of conversation messages
        self.llm = {
            'prompt'      : persona_prompt,
            'chat_model'  : chat_model,
            'url_model'   : url_model,
            'token_limit' : token_limit or TokenLimits(chat_model).max_tokens(),
            'temperature' : persona_temp or 1.0,
            'top_p'       : 0.9
        }
        # Set a rounded-down integer to prune a lengthy conversation by 500 tokens
        # Note if the upper limit is below 500, the lower limit is set to 0
        self.llm['prune_threshold'] = floor(0.95 * self.llm['token_limit'])
        self.llm['prune_back_to'] = max(0, self.llm['prune_threshold'] - 500)

        # Bot is now ready and active by default
        self._online = True

    def set(config_file: str = 'config.yaml', prompt_file: str = 'test_personality.prmpt', log_name: str = 'tellmgrambot'):
        """
        Instantiate a TelegramBot from configuration and prompt files.

        Calls init_structure() to bootstrap directories, API keys, and configuration files,
        unpacking a three-tuple (ApiKeyStatus, config dict, persona prompt str with system
        appendix already appended). Applies defaults for any missing values and returns a
        fully initialized TelegramBot.

        Args:
            config_file: Filename of the bot configuration YAML (default: 'config.yaml').
                         Resolved relative to TELLMGRAMBOT_CONFIGS_PATH env var, not as a literal path.
            prompt_file: Filename of the bot persona prompt (default: 'test_personality.prmpt').
                         Resolved relative to TELLMGRAMBOT_PROMPTS_PATH env var, not as a literal path.
            log_name: Base name for the log file (default: 'tellmgrambot').
                      Produces e.g. 'mybot_2026-03-29_10-30-45.log' when set to 'mybot'.

        Returns:
            A fully initialized TelegramBot instance.

        Side Effects:
            - Calls init_structure() which creates directories, config/prompt files, and checks API keys.
            - Prints API key status summary and optional warnings if config values are missing or prompt is empty.
        """
        # Bootstrap directories, logging, config, prompt (with appendix), and API keys in one call.
        key_status, config, prompt = init_structure(config_file, prompt_file, log_name)

        # Apply parameters to bot:
        return TelegramBot(
            bot_owner      = config['bot_owner'],
            bot_nickname   = config['bot_nickname'],
            bot_initials   = config['bot_initials'],
            chat_model     = config['chat_model'],
            url_model      = config['url_model'],
            token_limit    = config['token_limit'],
            persona_temp   = config['persona_temp'],
            persona_prompt = prompt,
            key_status     = key_status,
            log_name       = log_name,
        )
