"""
SQLite-backed conversation storage for TeLLMgramBot.

Provides async helpers for persisting and loading messages, managing per-user private mode,
searching message history, and retrieving conversation context. The schema includes a messages
table (indexed by chat_id and user_id, with is_private flag), a users table (profile data
and private_mode flag), and a chats table for speaker/chat resolution in search results.
"""
import json
import os
from typing import Optional

import aiosqlite
from .utils import execution_dir

# Allows tests to override the DB path without touching env vars
_DB_PATH: Optional[str] = None

# Per-bot DB filename (e.g. 'MyBot_conversations.db'). Set via set_db_filename() during
# init_structure() after config is loaded. Falls back to 'conversations.db' if unset.
_DB_FILENAME: Optional[str] = None

# Maximum bound parameters per SQLite statement (SQLite default limit is 999).
# Defined at module level so tests can patch it to force batching with small inputs.
_SQLITE_MAX_PARAMS = 999

_DDL = """
PRAGMA journal_mode=WAL;

CREATE TABLE IF NOT EXISTS messages (
    id             INTEGER PRIMARY KEY AUTOINCREMENT,
    tg_message_id  INTEGER,
    chat_id        INTEGER NOT NULL,
    user_id        INTEGER NOT NULL,
    role           TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
    content        TEXT NOT NULL,
    is_private     INTEGER NOT NULL DEFAULT 0,
    reply_to_id    INTEGER,
    created_at     TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);

CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_user_id ON messages(user_id, created_at);

CREATE TABLE IF NOT EXISTS users (
    user_id        INTEGER PRIMARY KEY,
    username       TEXT,
    first_name     TEXT,
    last_name      TEXT,
    private_mode   INTEGER NOT NULL DEFAULT 0,
    previous_names TEXT,
    updated_at     TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);

CREATE TABLE IF NOT EXISTS chats (
    chat_id         INTEGER PRIMARY KEY,
    chat_type       TEXT,
    chat_title      TEXT,
    previous_titles TEXT,
    updated_at      TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
"""


def set_db_filename(filename: str) -> None:
    """
    Set the database filename used by get_db_path().

    Called during init_structure() after the bot config is loaded. Allows per-bot
    DB isolation when multiple bots share the same data directory. The filename is
    stored in the module-level _DB_FILENAME variable; _DB_PATH (test override) still
    takes precedence over everything.

    Args:
        filename: DB filename, e.g. 'MyBot_conversations.db'. A '.db' suffix is
                  appended automatically if not already present. Must be a plain
                  basename - path separators and absolute paths are rejected.

    Raises:
        ValueError: If filename contains path components or is absolute.
    """
    if os.path.isabs(filename) or os.path.basename(filename) != filename:
        raise ValueError(f"DB filename must be a plain basename, not a path: {filename!r}")
    global _DB_FILENAME
    _DB_FILENAME = filename if filename.endswith('.db') else f"{filename}.db"


def get_db_path() -> str:
    """
    Return the SQLite database file path.

    Reads TELLMGRAMBOT_DATA_PATH env var (directory path) and appends the
    configured filename (_DB_FILENAME if set, else 'conversations.db').
    Module-level _DB_PATH overrides everything - used by tests.
    """
    if _DB_PATH:
        return _DB_PATH
    data_dir = os.environ.get('TELLMGRAMBOT_DATA_PATH', os.path.join(execution_dir(), 'data'))
    filename = _DB_FILENAME or 'conversations.db'
    return os.path.join(data_dir, filename)


async def init_db() -> None:
    """
    Create the messages, users, and chats tables if they do not exist.
    Enables WAL journal mode for safe concurrent reads and writes.
    Safe to call repeatedly - all statements use IF NOT EXISTS.

    Migrates existing databases:
    - Adds reply_to_id column to messages table for explicit bot reply tracking.
    - Drops username column from messages table (now in normalized users table).
    - Adds chat_type column to chats table.
    - Adds private_mode column to users table and merges legacy private_mode table into it.
    - Adds tg_message_id column to messages table for Telegram message ID tracking.
    - Drops is_foreign_bot column: deletes legacy foreign bot rows first, then drops the column.
    """
    async with aiosqlite.connect(get_db_path()) as db:
        await db.executescript(_DDL)
        existing = {row[1] async for row in await db.execute("PRAGMA table_info(messages)")}
        # Migration: add reply_to_id to existing databases that predate this column.
        if 'reply_to_id' not in existing:
            await db.execute("ALTER TABLE messages ADD COLUMN reply_to_id INTEGER")
        # Migration: add tg_message_id to existing databases that predate this column.
        if 'tg_message_id' not in existing:
            await db.execute("ALTER TABLE messages ADD COLUMN tg_message_id INTEGER")
        # Index on tg_message_id is created here (not in DDL) because the column may not
        # exist in the initial executescript pass on legacy databases.
        await db.execute(
            "CREATE INDEX IF NOT EXISTS idx_messages_tg_message_id ON messages(chat_id, tg_message_id)"
        )
        # Migration: drop is_foreign_bot column. Delete legacy rows first (referential
        # dependency), then drop. Foreign bot capture was removed in v3.10.2.
        if 'is_foreign_bot' in existing:
            await db.execute("DELETE FROM messages WHERE is_foreign_bot = 1")
            await db.execute("ALTER TABLE messages DROP COLUMN is_foreign_bot")
        # Migration: drop username column now stored in the normalized users table.
        if 'username' in existing:
            await db.execute("ALTER TABLE messages DROP COLUMN username")
        # Migration: add chat_type and previous_titles to chats table if absent.
        chats_cols = {row[1] async for row in await db.execute("PRAGMA table_info(chats)")}
        if 'chat_type' not in chats_cols:
            await db.execute("ALTER TABLE chats ADD COLUMN chat_type TEXT")
        if 'previous_titles' not in chats_cols:
            await db.execute("ALTER TABLE chats ADD COLUMN previous_titles TEXT")
        # Migration: merge private_mode table into users.private_mode and drop the old table.
        # Also add previous_names column for name history tracking.
        users_cols = {row[1] async for row in await db.execute("PRAGMA table_info(users)")}
        if 'private_mode' not in users_cols:
            await db.execute("ALTER TABLE users ADD COLUMN private_mode INTEGER NOT NULL DEFAULT 0")
        if 'previous_names' not in users_cols:
            await db.execute("ALTER TABLE users ADD COLUMN previous_names TEXT")
        tables = {row[0] async for row in await db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
        if 'private_mode' in tables:
            await db.execute(
                "INSERT OR IGNORE INTO users (user_id, private_mode) "
                "SELECT user_id, enabled FROM private_mode"
            )
            await db.execute(
                "UPDATE users SET private_mode = 1 "
                "WHERE user_id IN (SELECT user_id FROM private_mode WHERE enabled = 1)"
            )
            await db.execute("DROP TABLE private_mode")
        await db.commit()


async def insert_message(
    chat_id: int,
    user_id: int,
    role: str,
    content: str,
    is_private: bool = False,
    reply_to_id: Optional[int] = None,
    tg_message_id: Optional[int] = None,
) -> int:
    """
    Persist a single message row and return its database id.

    Args:
        chat_id: Telegram chat ID (negative for groups, positive/==user_id for private).
        user_id: Telegram user ID of the sender.
        role: 'user', 'assistant', or 'system'.
        content: Message text.
        is_private: If True, this message is excluded from all context loading.
        reply_to_id: Database id of the message this is a reply to (None if not a reply).
        tg_message_id: Telegram message ID (unique per chat). Used to match
                       reply_to_message.message_id against stored messages.

    Returns:
        The integer id of the newly inserted row.
    """
    async with aiosqlite.connect(get_db_path()) as db:
        cursor = await db.execute(
            "INSERT INTO messages "
            "(chat_id, user_id, role, content, is_private, reply_to_id, tg_message_id) "
            "VALUES (?, ?, ?, ?, ?, ?, ?)",
            (chat_id, user_id, role, content, int(is_private), reply_to_id, tg_message_id),
        )
        await db.commit()
        return cursor.lastrowid


async def update_message_tg_id(db_id: int, tg_message_id: int) -> None:
    """
    Store the Telegram message ID on an assistant message row after send.

    Called after the bot's Telegram send completes successfully to persist the message ID.
    This enables tier 2 dedup in _build_reply_context() for cross-session replies: if the
    user replies to an older bot message in a future session, the tier 2 DB lookup will
    find it via tg_message_id even if it's outside the loaded token window.

    Args:
        db_id: Database id (primary key) of the assistant message row.
        tg_message_id: Telegram message ID returned by the send call.
    """
    async with aiosqlite.connect(get_db_path()) as db:
        await db.execute(
            "UPDATE messages SET tg_message_id = ? WHERE id = ?",
            (tg_message_id, db_id),
        )
        await db.commit()


async def load_messages_for_chat(
    chat_id: int,
    exclude_private: bool = False,
) -> list[dict]:
    """
    Load all messages for a given chat in chronological order.

    Args:
        chat_id: Telegram chat ID to query.
        exclude_private: If True, rows with is_private=1 are excluded.
            Used when loading group conversation context.

    Returns:
        List of {"role": ..., "content": ...} dicts.
    """
    query = "SELECT role, content FROM messages WHERE chat_id = ?"
    params: tuple = (chat_id,)
    if exclude_private:
        query += " AND is_private = 0"
    query += " ORDER BY created_at ASC, id ASC"
    async with aiosqlite.connect(get_db_path()) as db:
        async with db.execute(query, params) as cursor:
            rows = await cursor.fetchall()
    return [{"role": row[0], "content": row[1]} for row in rows]


async def load_messages_for_user(
    user_id: int,
    exclude_private: bool = False,
) -> list[dict]:
    """
    Load all messages from a user's private chat history.

    In Telegram, a user's private chat_id equals their user_id. This function loads messages
    from that private chat, optionally filtering out private-mode messages.

    Args:
        user_id: Telegram user ID; also serves as the private chat_id.
        exclude_private: If True, rows with is_private=1 are excluded.

    Returns:
        List of {"role": ..., "content": ...} dicts in chronological order.
    """
    query = "SELECT role, content FROM messages WHERE chat_id = ?"
    params: tuple = (user_id,)
    if exclude_private:
        query += " AND is_private = 0"
    query += " ORDER BY created_at ASC, id ASC"
    async with aiosqlite.connect(get_db_path()) as db:
        async with db.execute(query, params) as cursor:
            rows = await cursor.fetchall()
    return [{"role": row[0], "content": row[1]} for row in rows]


def _build_speaker_prefix(
    role: str,
    chat_id: int,
    current_chat_id: int,
    first_name: Optional[str],
    last_name: Optional[str],
    username: Optional[str],
    chat_title: Optional[str],
    created_at: Optional[str] = None,
) -> str:
    """
    Build a speaker attribution prefix for a context message.

    Returns empty string for assistant messages, or user messages where no
    identity information is available.

    For user messages (including foreign bots, whose identity is resolved from
    the users table just like any other sender):
    - Same chat:  "[Name, YYYY-MM-DD HH:MM UTC]: "
    - Cross-chat: "[Name (ChatTitle), YYYY-MM-DD HH:MM UTC]: " where ChatTitle falls back
                  to "private chat" for positive chat_ids or "group {id}" for negative.

    Args:
        role: Message role ('user', 'assistant', 'system').
        chat_id: Telegram chat ID where the message originated.
        current_chat_id: Telegram chat ID of the current conversation (for same/cross-chat detection).
        first_name: User's first name, may be None.
        last_name: User's last name, may be None.
        username: User's Telegram @handle (without @), may be None.
        chat_title: Display name of the group/channel, or None for private chats.
        created_at: ISO-format timestamp (e.g. '2026-03-31T23:28:00Z') from the message.
                    Date portion is extracted and included in the prefix; omitted if absent.

    Returns:
        Empty string for assistant/system messages or users with no identity.
        Prefix string like "[Name, YYYY-MM-DD HH:MM UTC]: " for same-chat user messages,
        or "[Name (ChatTitle), YYYY-MM-DD HH:MM UTC]: " for cross-chat messages.

    Notes:
        Pass current_chat_id=0 as a sentinel when all rows are cross-chat (e.g.,
        load_shared_group_context), since 0 is never a real Telegram chat ID.
    """
    if role != 'user':
        return ''
    name_parts = [p for p in [first_name, last_name] if p]
    name = ' '.join(name_parts) if name_parts else username
    if not name:
        return ''
    date = (created_at[:16].replace('T', ' ') + ' UTC') if created_at else None
    if chat_id != current_chat_id:
        if chat_title:
            label = chat_title
        elif chat_id > 0:
            label = 'private chat'
        else:
            label = f'group {chat_id}'
        suffix = f', {date}' if date else ''
        return f'[{name} ({label}){suffix}]: '
    suffix = f', {date}' if date else ''
    return f'[{name}{suffix}]: '


async def load_full_user_context(
    user_id: int,
    current_chat_id: int,
    exclude_private: bool = False,
) -> list[dict]:
    """
    Load bidirectional conversation context for a user across all chats.

    Implements bidirectional cross-pollination: returns all messages from the current
    chat (all participants) plus all messages from the user's private chat (both user
    and assistant turns), merged in chronological order. This eliminates amnesia when
    a user switches between private and group contexts.

    In Telegram, a user's private chat_id always equals their user_id. Three arms cover
    all cross-context cases:
      Arm 1 - chat_id = current_chat_id:
          All messages (user and bot) in the current chat. Always included.
      Arm 2 - chat_id = user_id AND chat_id != current_chat_id:
          All messages (both sides) from the user's private chat, included when the
          current chat is a group. Pulling both sides restores proper role alternation
          that was broken when only the user's own rows were fetched.
      Arm 3 - user_id = user_id AND chat_id != current_chat_id AND chat_id != user_id:
          The requesting user's own messages from any other group chats (not the private
          chat, not the current chat). Covers group-to-private and group-to-group context.
          Only the user's own rows are fetched from other groups (not bot replies).

    User messages are annotated with a speaker prefix built by _build_speaker_prefix:
    "[Name, YYYY-MM-DD HH:MM UTC]: " for same-chat messages, "[Name (ChatTitle), YYYY-MM-DD HH:MM UTC]: " for cross-chat.
    This applies uniformly to all senders including foreign bots, whose identity is
    resolved from the users table. Assistant messages receive no prefix. Messages with
    no resolvable user identity are unannotated, ensuring backward compatibility with
    test fixtures that skip upsert_user.

    The exclude_private flag is controlled by the caller and applies regardless of chat type.
    Conversation.get_past_interaction always passes exclude_private=True so that is_private=1
    rows are never rehydrated into any session context.

    Args:
        user_id: Telegram user ID of the requesting user.
        current_chat_id: The chat_id of the current conversation (negative for groups, positive for private).
        exclude_private: If True, rows with is_private=1 are excluded from all arms of the query.
            Always passed as True by Conversation.get_past_interaction so that private-mode messages
            don't surface in shared contexts.

    Returns:
        List of {"role": ..., "content": ...} dicts in ascending chronological order (oldest first).
        User message content is prefixed with "[Name, YYYY-MM-DD HH:MM UTC]: " or
        "[Name (ChatTitle), YYYY-MM-DD HH:MM UTC]: " when user identity is available.
    """
    query = (
        "SELECT m.role, m.content, m.user_id, m.chat_id, "
        "u.first_name, u.last_name, u.username, c.chat_title, m.created_at, m.tg_message_id "
        "FROM messages m "
        "LEFT JOIN users u ON m.user_id = u.user_id "
        "LEFT JOIN chats c ON m.chat_id = c.chat_id "
        "WHERE (m.chat_id = ? "
        "OR (m.chat_id = ? AND m.chat_id != ?) "
        "OR (m.user_id = ? AND m.chat_id != ? AND m.chat_id != ?))"
    )
    params: tuple = (current_chat_id, user_id, current_chat_id, user_id, current_chat_id, user_id)
    if exclude_private:
        query += " AND m.is_private = 0"
    query += " ORDER BY m.created_at ASC, m.id ASC"
    async with aiosqlite.connect(get_db_path()) as db:
        async with db.execute(query, params) as cursor:
            rows = await cursor.fetchall()
    return [
        {
            "role": row[0],
            "content": _build_speaker_prefix(
                row[0], row[3], current_chat_id, row[4], row[5], row[6], row[7], row[8]
            ) + row[1],
            "_tg_id": row[9],
        }
        for row in rows
    ]


async def get_max_cross_chat_message_id(
    user_id: int,
    current_chat_id: int,
    exclude_private: bool = False,
) -> int:
    """
    Return the maximum message id from the cross-chat context for this user.

    Uses the same three-arm query as load_full_user_context, returning only the
    MAX(id) scalar. Used to establish a cursor for mid-session refresh detection.

    Returns:
        Maximum message id as an integer, or 0 if no rows exist.
    """
    query = (
        "SELECT MAX(id) FROM messages "
        "WHERE (chat_id = ? "
        "OR (chat_id = ? AND chat_id != ?) "
        "OR (user_id = ? AND chat_id != ? AND chat_id != ?))"
    )
    params: tuple = (current_chat_id, user_id, current_chat_id, user_id, current_chat_id, user_id)
    if exclude_private:
        query += " AND is_private = 0"
    async with aiosqlite.connect(get_db_path()) as db:
        async with db.execute(query, params) as cursor:
            row = await cursor.fetchone()
    return row[0] if row and row[0] is not None else 0


async def load_new_cross_chat_context(
    user_id: int,
    current_chat_id: int,
    since_id: int,
    exclude_private: bool = False,
) -> list[dict]:
    """
    Load cross-chat context rows with id strictly greater than since_id.

    Uses the same three-arm query as load_full_user_context but restricted to rows
    newer than the given cursor. Returns only the delta since the last context load.
    Applies the same speaker prefix annotation as load_full_user_context.

    Args:
        user_id: Telegram user ID of the requesting user.
        current_chat_id: The chat_id of the current conversation.
        since_id: Exclusive lower bound - only rows with id > since_id are returned.
        exclude_private: If True, rows with is_private=1 are excluded.

    Returns:
        List of {"role": ..., "content": ...} dicts in ascending chronological order.
        User message content is prefixed with "[Name, YYYY-MM-DD HH:MM UTC]: " or
        "[Name (ChatTitle), YYYY-MM-DD HH:MM UTC]: " when user identity is available.
    """
    query = (
        "SELECT m.role, m.content, m.user_id, m.chat_id, "
        "u.first_name, u.last_name, u.username, c.chat_title, m.created_at, m.tg_message_id "
        "FROM messages m "
        "LEFT JOIN users u ON m.user_id = u.user_id "
        "LEFT JOIN chats c ON m.chat_id = c.chat_id "
        "WHERE (m.chat_id = ? "
        "OR (m.chat_id = ? AND m.chat_id != ?) "
        "OR (m.user_id = ? AND m.chat_id != ? AND m.chat_id != ?)) "
        "AND m.id > ?"
    )
    params: tuple = (current_chat_id, user_id, current_chat_id, user_id, current_chat_id, user_id, since_id)
    if exclude_private:
        query += " AND m.is_private = 0"
    query += " ORDER BY m.created_at ASC, m.id ASC"
    async with aiosqlite.connect(get_db_path()) as db:
        async with db.execute(query, params) as cursor:
            rows = await cursor.fetchall()
    return [
        {
            "role": row[0],
            "content": _build_speaker_prefix(
                row[0], row[3], current_chat_id, row[4], row[5], row[6], row[7], row[8]
            ) + row[1],
            "_tg_id": row[9],
        }
        for row in rows
    ]


async def get_shared_group_chat_ids(user_id: int, bot_id: int) -> list[int]:
    """
    Return chat_ids of groups where both the user and the bot have sent messages.

    Eligibility conditions:
    1. The requesting user has sent at least one message in the group.
    2. The bot has also sent at least one message in the group (bot is active there).

    Condition 2 prevents surfacing history from groups the bot was removed from
    or never properly joined.

    Args:
        user_id: Telegram user ID of the requesting user.
        bot_id: Telegram user ID of the bot account.

    Returns:
        List of negative chat_ids (Telegram group IDs).
    """
    query = """
        SELECT DISTINCT chat_id
        FROM messages
        WHERE user_id = ? AND chat_id < 0
        AND EXISTS (
            SELECT 1 FROM messages m2
            WHERE m2.chat_id = messages.chat_id AND m2.user_id = ?
        )
    """
    async with aiosqlite.connect(get_db_path()) as db:
        async with db.execute(query, (user_id, bot_id)) as cursor:
            rows = await cursor.fetchall()
    return [row[0] for row in rows]


async def load_shared_group_context(
    group_chat_ids: list[int],
    exclude_private: bool = True,
) -> list[dict]:
    """
    Load all messages from multiple group chats, merged in chronological order.

    Used to fill remaining token budget in a private chat with context from
    shared groups (full group context in private chat).

    Automatically batches the query if group_chat_ids exceeds _SQLITE_MAX_PARAMS
    (999, SQLite's bound parameter limit) to handle users who share many groups
    with the bot. Results are merged and sorted chronologically across batches.

    All loaded messages are from groups (cross-chat relative to the caller's private chat),
    so all user messages receive a "[Name (ChatTitle), YYYY-MM-DD HH:MM UTC]: " prefix via _build_speaker_prefix,
    including foreign bots whose identity is resolved from the users table.
    Assistant messages receive no prefix.

    Args:
        group_chat_ids: List of group chat_ids (negative integers) to include.
        exclude_private: If True, rows with is_private=1 are excluded (default True).

    Returns:
        List of {"role": ..., "content": ...} dicts in ascending chronological order.
        Empty list if group_chat_ids is empty.
    """
    if not group_chat_ids:
        return []
    # SQLite has a max of 999 bound parameters per statement. Batch group_chat_ids to
    # avoid exceeding this limit for users who share many groups with the bot.
    all_rows: list[tuple] = []
    async with aiosqlite.connect(get_db_path()) as db:
        for i in range(0, len(group_chat_ids), _SQLITE_MAX_PARAMS):
            chunk = group_chat_ids[i:i + _SQLITE_MAX_PARAMS]
            placeholders = ','.join('?' * len(chunk))
            query = (
                f"SELECT m.role, m.content, m.created_at, m.id, "
                f"m.chat_id, u.first_name, u.last_name, u.username, c.chat_title, m.tg_message_id "
                f"FROM messages m "
                f"LEFT JOIN users u ON m.user_id = u.user_id "
                f"LEFT JOIN chats c ON m.chat_id = c.chat_id "
                f"WHERE m.chat_id IN ({placeholders})"
            )
            if exclude_private:
                query += " AND m.is_private = 0"
            async with db.execute(query, tuple(chunk)) as cursor:
                all_rows.extend(await cursor.fetchall())
    all_rows.sort(key=lambda r: (r[2], r[3]))
    # current_chat_id=0 is a sentinel (never a real Telegram ID) so all rows are treated
    # as cross-chat, ensuring every user message receives a "[Name (ChatTitle), date]: " prefix.
    return [
        {
            "role": row[0],
            "content": _build_speaker_prefix(
                row[0], row[4], 0, row[5], row[6], row[7], row[8], row[2]
            ) + row[1],
            "_tg_id": row[9],
        }
        for row in all_rows
    ]


async def upsert_user(
    user_id: int,
    username: Optional[str],
    first_name: Optional[str],
    last_name: Optional[str],
) -> None:
    """
    Insert or update a user record in the users table.

    Always reflects the latest Telegram profile data. Called on every interaction
    so that name changes are picked up automatically. When first/last name changes,
    the old display name (first_name + space + last_name) is appended to the
    previous_names column (JSON array, deduplicated) to enable search by
    historical identities.

    Args:
        user_id: Telegram user ID.
        username: Telegram @handle (without @), may be None.
        first_name: Telegram first name, may be None.
        last_name: Telegram last name, may be None.
    """
    async with aiosqlite.connect(get_db_path()) as db:
        async with db.execute(
            "SELECT first_name, last_name, previous_names FROM users WHERE user_id = ?",
            (user_id,),
        ) as cur:
            existing = await cur.fetchone()

        updated_prev = None
        if existing is not None:
            old_first, old_last, prev = existing
            old_display = f"{old_first or ''} {old_last or ''}".strip()
            new_display = f"{first_name or ''} {last_name or ''}".strip()
            if old_display and old_display != new_display:
                if prev:
                    try:
                        seen = json.loads(prev)
                        if not isinstance(seen, list):
                            seen = [seen] if seen else []
                    except json.JSONDecodeError:
                        seen = [n.strip() for n in prev.split(",") if n.strip()]
                else:
                    seen = []
                if old_display not in seen:
                    seen.append(old_display)
                updated_prev = json.dumps(seen)
            else:
                updated_prev = prev

        await db.execute(
            "INSERT INTO users (user_id, username, first_name, last_name, previous_names) VALUES (?, ?, ?, ?, ?) "
            "ON CONFLICT(user_id) DO UPDATE SET "
            "username = excluded.username, first_name = excluded.first_name, "
            "last_name = excluded.last_name, previous_names = excluded.previous_names, "
            "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
            (user_id, username, first_name, last_name, updated_prev),
        )
        await db.commit()


async def upsert_chat(
    chat_id: int,
    chat_type: str,
    chat_title: Optional[str],
) -> None:
    """
    Insert or update a chat record in the chats table.

    Always reflects the latest Telegram chat metadata. Called on every interaction
    so that chat type and title changes are picked up automatically. When the
    chat_title changes, the old title is appended to the previous_titles column
    (JSON array, deduplicated) to enable search by historical identities.

    Args:
        chat_id: Telegram chat ID.
        chat_type: 'private', 'group', 'supergroup', or 'channel'.
        chat_title: Display name of the group/channel, or None for private chats.
    """
    async with aiosqlite.connect(get_db_path()) as db:
        async with db.execute(
            "SELECT chat_title, previous_titles FROM chats WHERE chat_id = ?",
            (chat_id,),
        ) as cur:
            existing = await cur.fetchone()

        updated_prev = None
        if existing is not None:
            old_title, prev = existing
            if old_title and old_title != chat_title:
                if prev:
                    try:
                        seen = json.loads(prev)
                        if not isinstance(seen, list):
                            seen = [seen] if seen else []
                    except json.JSONDecodeError:
                        seen = [t.strip() for t in prev.split(",") if t.strip()]
                else:
                    seen = []
                if old_title not in seen:
                    seen.append(old_title)
                updated_prev = json.dumps(seen)
            else:
                updated_prev = prev

        await db.execute(
            "INSERT INTO chats (chat_id, chat_type, chat_title, previous_titles) VALUES (?, ?, ?, ?) "
            "ON CONFLICT(chat_id) DO UPDATE SET "
            "chat_type = excluded.chat_type, chat_title = excluded.chat_title, "
            "previous_titles = excluded.previous_titles, "
            "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
            (chat_id, chat_type, chat_title, updated_prev),
        )
        await db.commit()


async def get_chat_type(chat_id: int) -> Optional[str]:
    """
    Return the chat_type for a given chat_id from the chats table.

    Args:
        chat_id: Telegram chat ID.

    Returns:
        The chat_type string ('private', 'group', 'supergroup', 'channel'),
        or None if the chat has not yet been upserted.
    """
    async with aiosqlite.connect(get_db_path()) as db:
        async with db.execute(
            "SELECT chat_type FROM chats WHERE chat_id = ?", (chat_id,)
        ) as cursor:
            row = await cursor.fetchone()
    return row[0] if row else None


async def search_messages(
    accessible_chat_ids: list[int],
    content_query: Optional[str] = None,
    speaker_query: Optional[str] = None,
    chat_query: Optional[str] = None,
    date_from: Optional[str] = None,
    date_to: Optional[str] = None,
    limit: int = 30,
) -> list[dict] | dict:
    """
    Search message history across accessible chats with optional filters.

    All filters (content_query, speaker_query, chat_query, date_from, date_to) are
    optional. If all filters are omitted, returns recent messages from all accessible
    chats, ordered most-recent-first. Filters are combined as AND conditions.

    Resolves speaker_query and chat_query using exact-match-first logic:
    1. Case-insensitive exact match - if exactly one entity matches, proceed.
    2. Partial LIKE match (including historical names/titles) - if exactly one accessible
       entity matches, proceed. Searches current username/first_name/last_name for speakers
       and current chat_title for chats; falls back to previous_names and previous_titles
       columns to find entities by their past identities. Zero matches: colloquial fallback
       (chat: all accessible; speaker: return empty). Two or more matches: return an
       ambiguity sentinel dict instead of results.

    Leading `@` is stripped from speaker_query automatically.
    Access control: results are always scoped to accessible_chat_ids (the caller's
    private chat plus shared group chats), preventing information leakage.

    Args:
        accessible_chat_ids: Chat IDs the caller is allowed to search within.
        content_query: Text to search for in message content (LIKE match). Optional.
        speaker_query: Name or @handle to resolve to a set of user_ids. Exact match
                       is tried first; falls back to LIKE. Leading `@` stripped. Optional.
        chat_query: Chat title to resolve to a set of chat_ids. Exact match tried first;
                    falls back to LIKE. Zero global matches fall back to all accessible.
                    If a matching chat is found but not accessible, returns empty. Optional.
        date_from: ISO datetime string (YYYY-MM-DDTHH:MM) for start of range (inclusive). Optional.
        date_to: ISO datetime string (YYYY-MM-DDTHH:MM) for end of range (inclusive). Optional.
        limit: Maximum number of results to return (default 30).

    Returns:
        List of result dicts with keys: speaker, chat, chat_id, content, timestamp (normal case).
        Dict with '_ambiguous': True when speaker_query or chat_query matched multiple
        entities; contains 'chat_matches' (list of dicts: {"title": str, "chat_id": int})
        and/or 'speaker_matches' (list of dicts: {"label": str, "username": str|None})
        and a 'message' string for the LLM to surface as a disambiguation prompt.
        Ordered by created_at DESC (most recent first).
    """
    if not accessible_chat_ids:
        return []

    accessible_set = set(accessible_chat_ids)

    async with aiosqlite.connect(get_db_path()) as db:
        # Resolve speaker_query -> user_ids (exact match first, then LIKE)
        # Scoped to users who have at least one message in accessible_chat_ids (s3kp).
        # Batched over accessible_chat_ids to stay within SQLite's 999-parameter limit;
        # each batch leaves 5 slots for name-matching params (exact: 3, partial: 5).
        speaker_ids: Optional[list[int]] = None
        speaker_amb: Optional[dict] = None
        if speaker_query:
            q = speaker_query.lstrip('@')
            exact_seen: dict[int, tuple] = {}
            for i in range(0, len(accessible_chat_ids), _SQLITE_MAX_PARAMS - 3):
                chunk = accessible_chat_ids[i:i + _SQLITE_MAX_PARAMS - 3]
                chunk_ph = ','.join('?' * len(chunk))
                async with db.execute(
                    f"SELECT DISTINCT u.user_id, "
                    f"COALESCE(u.first_name || ' ' || u.last_name, u.first_name, u.username, 'Unknown') AS label, "
                    f"u.username "
                    f"FROM users u JOIN messages m ON m.user_id = u.user_id "
                    f"WHERE m.chat_id IN ({chunk_ph}) "
                    f"AND (LOWER(u.username) = LOWER(?) "
                    f"OR LOWER(u.first_name) = LOWER(?) "
                    f"OR LOWER(u.first_name || ' ' || u.last_name) = LOWER(?))",
                    (*chunk, q, q, q),
                ) as cur:
                    for row in await cur.fetchall():
                        exact_seen.setdefault(row[0], row)
            exact_rows = list(exact_seen.values())
            if len(exact_rows) == 1:
                speaker_ids = [exact_rows[0][0]]
            elif len(exact_rows) > 1:
                speaker_amb = {'rows': exact_rows}
            else:
                like = f"%{q}%"
                partial_seen: dict[int, tuple] = {}
                for i in range(0, len(accessible_chat_ids), _SQLITE_MAX_PARAMS - 5):
                    chunk = accessible_chat_ids[i:i + _SQLITE_MAX_PARAMS - 5]
                    chunk_ph = ','.join('?' * len(chunk))
                    async with db.execute(
                        f"SELECT DISTINCT u.user_id, "
                        f"COALESCE(u.first_name || ' ' || u.last_name, u.first_name, u.username, 'Unknown') AS label, "
                        f"u.username "
                        f"FROM users u JOIN messages m ON m.user_id = u.user_id "
                        f"WHERE m.chat_id IN ({chunk_ph}) "
                        f"AND (u.username LIKE ? OR u.first_name LIKE ? OR u.last_name LIKE ? "
                        f"OR (u.first_name || ' ' || u.last_name) LIKE ? OR u.previous_names LIKE ?)",
                        (*chunk, like, like, like, like, like),
                    ) as cur:
                        for row in await cur.fetchall():
                            partial_seen.setdefault(row[0], row)
                partial_rows = list(partial_seen.values())
                if len(partial_rows) == 0:
                    return []
                elif len(partial_rows) == 1:
                    speaker_ids = [partial_rows[0][0]]
                else:
                    speaker_amb = {'rows': partial_rows}

        # Resolve chat_query -> chat_ids (exact match first, then LIKE)
        resolved_chat_ids: Optional[list[int]] = None
        chat_amb: Optional[dict] = None
        if chat_query:
            async with db.execute(
                "SELECT chat_id, chat_title FROM chats WHERE LOWER(chat_title) = LOWER(?)",
                (chat_query,),
            ) as cur:
                exact_rows = await cur.fetchall()
            exact_accessible = [r for r in exact_rows if r[0] in accessible_set]
            if len(exact_accessible) == 1:
                resolved_chat_ids = [exact_accessible[0][0]]
            elif len(exact_accessible) > 1:
                chat_amb = {'rows': exact_accessible}
            elif exact_rows and not exact_accessible:
                # Exact global match found but not accessible - access control
                return []
            else:
                # No exact match - partial LIKE
                like = f"%{chat_query}%"
                # Check for any global match first to distinguish colloquial fallback
                # (zero global matches) from access-control rejection (global match, not accessible).
                async with db.execute(
                    "SELECT 1 FROM chats WHERE chat_title LIKE ? OR previous_titles LIKE ? LIMIT 1",
                    (like, like),
                ) as cur:
                    has_global = await cur.fetchone() is not None
                if not has_global:
                    pass  # Zero global matches: colloquial fallback (resolved_chat_ids stays None)
                else:
                    # Batch accessible_chat_ids to stay within SQLite's 999-parameter limit;
                    # reserve 2 slots for the LIKE parameters (chat_title + previous_titles).
                    partial_accessible_seen: dict[int, tuple] = {}
                    for i in range(0, len(accessible_chat_ids), _SQLITE_MAX_PARAMS - 2):
                        chunk = accessible_chat_ids[i:i + _SQLITE_MAX_PARAMS - 2]
                        chunk_ph = ','.join('?' * len(chunk))
                        async with db.execute(
                            f"SELECT chat_id, chat_title FROM chats "
                            f"WHERE (chat_title LIKE ? OR previous_titles LIKE ?) AND chat_id IN ({chunk_ph})",
                            (like, like, *chunk),
                        ) as cur:
                            for row in await cur.fetchall():
                                partial_accessible_seen.setdefault(row[0], row)
                    partial_accessible = list(partial_accessible_seen.values())
                    if not partial_accessible:
                        # Found globally but none accessible - access control
                        return []
                    elif len(partial_accessible) == 1:
                        resolved_chat_ids = [partial_accessible[0][0]]
                    else:
                        chat_amb = {'rows': partial_accessible}

        # Return ambiguity sentinel if either query matched multiple entities
        if speaker_amb or chat_amb:
            sentinel: dict = {'_ambiguous': True}
            msgs = []
            if chat_amb:
                sentinel['chat_matches'] = [
                    {'title': r[1], 'chat_id': r[0]} for r in chat_amb['rows']
                ]
                msgs.append(f"Multiple chats match '{chat_query}'. Please be more specific.")
            if speaker_amb:
                sentinel['speaker_matches'] = [
                    {'label': r[1], 'username': r[2]} for r in speaker_amb['rows']
                ]
                msgs.append(f"Multiple users match '{speaker_query}'. Please be more specific.")
            sentinel['message'] = ' '.join(msgs)
            return sentinel

        search_chat_ids = resolved_chat_ids if resolved_chat_ids is not None else accessible_chat_ids

        # Build main query
        chat_placeholders = ','.join('?' * len(search_chat_ids))
        conditions = [f"m.chat_id IN ({chat_placeholders})", "m.is_private = 0"]
        params: list = list(search_chat_ids)

        if content_query:
            conditions.append("m.content LIKE ?")
            params.append(f"%{content_query}%")
        if speaker_ids:
            id_placeholders = ','.join('?' * len(speaker_ids))
            conditions.append(f"m.user_id IN ({id_placeholders})")
            params.extend(speaker_ids)
        if date_from:
            conditions.append("m.created_at >= ?")
            params.append(date_from + ":00.000Z")
        if date_to:
            conditions.append("m.created_at <= ?")
            params.append(date_to + ":59Z")

        where_clause = " AND ".join(conditions)
        query = f"""
            SELECT
                u.first_name, u.last_name, u.username,
                c.chat_title, m.chat_id,
                m.content, m.created_at
            FROM messages m
            LEFT JOIN users u ON u.user_id = m.user_id
            LEFT JOIN chats c ON c.chat_id = m.chat_id
            WHERE {where_clause}
            ORDER BY m.created_at DESC, m.id DESC
            LIMIT ?
        """
        params.append(limit)

        async with db.execute(query, params) as cur:
            rows = await cur.fetchall()

    results = []
    for first_name, last_name, username, chat_title, chat_id, content, created_at in rows:
        if first_name and last_name:
            speaker_label = f"{first_name} {last_name}"
        elif first_name:
            speaker_label = first_name
        elif username:
            speaker_label = f"@{username}"
        else:
            speaker_label = "Unknown"
        if username and f"@{username}" not in speaker_label:
            speaker_label += f" (@{username})"
        results.append({
            "speaker": speaker_label,
            "chat": chat_title or "Private Chat",
            "chat_id": chat_id,
            "content": content,
            "timestamp": created_at,
        })
    return results


async def message_id_exists(chat_id: int, tg_message_id: int) -> bool:
    """
    Return True if a message with the given Telegram message ID exists in the database
    for the specified chat.

    Used as the DB-tier fallback in the reply-to-thread dedup check: if a message is
    not in the in-memory _loaded_message_ids set, this confirms whether it was stored
    in the DB but outside the current token budget window.

    Args:
        chat_id: Telegram chat ID to scope the lookup.
        tg_message_id: Telegram message ID to look up (reply_to_message.message_id).

    Returns:
        True if a matching row exists, False otherwise.
    """
    async with aiosqlite.connect(get_db_path()) as db:
        async with db.execute(
            "SELECT 1 FROM messages WHERE chat_id = ? AND tg_message_id = ? LIMIT 1",
            (chat_id, tg_message_id),
        ) as cursor:
            return await cursor.fetchone() is not None


async def delete_bot_replies_for_user(user_id: int) -> None:
    """
    Delete assistant rows whose reply_to_id references any message from this user.

    Must be called before delete_messages_for_user so that the user's message ids
    still exist for the subquery to match against. Rows with reply_to_id = NULL
    (pre-migration messages) are unaffected.

    Args:
        user_id: Telegram user ID whose bot replies should be removed.
    """
    async with aiosqlite.connect(get_db_path()) as db:
        await db.execute(
            "DELETE FROM messages WHERE role = 'assistant' "
            "AND reply_to_id IN (SELECT id FROM messages WHERE user_id = ?)",
            (user_id,),
        )
        await db.commit()


async def delete_messages_for_user(user_id: int) -> None:
    """
    Delete all message rows for a given user_id across all chats.
    Used by /forget - call delete_bot_replies_for_user first to clean up linked bot replies.
    """
    async with aiosqlite.connect(get_db_path()) as db:
        await db.execute("DELETE FROM messages WHERE user_id = ?", (user_id,))
        await db.commit()


async def delete_messages_for_chat(chat_id: int) -> None:
    """
    Delete all message rows for a given chat_id, including all participants and bot replies.
    Used by /forget in private chats to fully clear the conversation.
    """
    async with aiosqlite.connect(get_db_path()) as db:
        await db.execute("DELETE FROM messages WHERE chat_id = ?", (chat_id,))
        await db.commit()


async def delete_private_messages_for_user(user_id: int) -> None:
    """
    Delete all private-mode message rows for a user from their private chat.

    Removes rows where chat_id = user_id (the user's Telegram private chat) AND
    is_private = 1. This covers both the user's own messages and the bot's replies
    stored in the same private chat, since assistant turns share the same chat_id.

    Called when the user turns private mode off (/private off) to ensure that
    private-mode messages do not resurface on the next session reload.

    Args:
        user_id: Telegram user ID; also serves as the private chat_id.
    """
    async with aiosqlite.connect(get_db_path()) as db:
        await db.execute(
            "DELETE FROM messages WHERE chat_id = ? AND is_private = 1",
            (user_id,)
        )
        await db.commit()


async def wipe_all_data() -> None:
    """
    Delete all rows from the messages, users, and chats tables.

    Irreversible. Used by the /wipe admin command to reset the bot's
    conversation database entirely. Schema and indexes are preserved.
    """
    async with aiosqlite.connect(get_db_path()) as db:
        await db.execute("DELETE FROM messages")
        await db.execute("DELETE FROM users")
        await db.execute("DELETE FROM chats")
        await db.commit()


async def get_private_mode(user_id: int) -> bool:
    """
    Return whether private mode is currently enabled for this user.

    Queries the private_mode column in the users table. Defaults to False if
    no row exists in the users table.

    Args:
        user_id: Telegram user ID.

    Returns:
        True if private mode is enabled, False otherwise.
    """
    async with aiosqlite.connect(get_db_path()) as db:
        async with db.execute(
            "SELECT private_mode FROM users WHERE user_id = ?", (user_id,)
        ) as cursor:
            row = await cursor.fetchone()
    return bool(row[0]) if row else False


async def set_private_mode(user_id: int, enabled: bool) -> None:
    """
    Enable or disable private mode for a user.

    Updates the private_mode column in the users table. Uses upsert semantics to
    handle the case where the users row may not yet exist, though in normal operation
    upsert_user is always invoked first via add_user_message on every interaction.

    Args:
        user_id: Telegram user ID.
        enabled: True to enable private mode, False to disable.
    """
    async with aiosqlite.connect(get_db_path()) as db:
        await db.execute(
            "INSERT INTO users (user_id, private_mode) VALUES (?, ?) "
            "ON CONFLICT(user_id) DO UPDATE SET private_mode = excluded.private_mode, "
            "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
            (user_id, int(enabled)),
        )
        await db.commit()
