"""
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), a users table (profile data and private_mode flag),
and a chats table for speaker/chat resolution in search results.
"""
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

# 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,
    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,
    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,
    updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
"""

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

    Reads TELLMGRAMBOT_DATA_PATH env var (directory path) and appends conversations.db.
    Falls back to data/conversations.db relative to the execution directory if unset.
    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'))
    return os.path.join(data_dir, 'conversations.db')

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.
    """
    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: 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 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")
        # Migration: merge private_mode table into users.private_mode and drop the old table.
        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")
        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,
) -> 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 group context loading.
        reply_to_id: Database id of the message this is a reply to (None if not a reply).

    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) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            (chat_id, user_id, role, content, int(is_private), reply_to_id),
        )
        await db.commit()
        return cursor.lastrowid

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]

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).

    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).
    """
    query = (
        "SELECT role, content 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"
    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 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.

    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.
    """
    query = (
        "SELECT role, content FROM messages "
        "WHERE (chat_id = ? "
        "OR (chat_id = ? AND chat_id != ?) "
        "OR (user_id = ? AND chat_id != ? AND chat_id != ?)) "
        "AND 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 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 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.

    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 role, content, created_at, id FROM messages "
                f"WHERE chat_id IN ({placeholders})"
            )
            if exclude_private:
                query += " AND 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]))
    return [{"role": row[0], "content": row[1]} 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.

    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:
        await db.execute(
            "INSERT INTO users (user_id, username, first_name, last_name) VALUES (?, ?, ?, ?) "
            "ON CONFLICT(user_id) DO UPDATE SET "
            "username = excluded.username, first_name = excluded.first_name, "
            "last_name = excluded.last_name, "
            "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
            (user_id, username, first_name, last_name),
        )
        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.

    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:
        await db.execute(
            "INSERT INTO chats (chat_id, chat_type, chat_title) VALUES (?, ?, ?) "
            "ON CONFLICT(chat_id) DO UPDATE SET "
            "chat_type = excluded.chat_type, chat_title = excluded.chat_title, "
            "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
            (chat_id, chat_type, chat_title),
        )
        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 = 20,
) -> list[dict]:
    """
    Search message history across accessible chats with optional filters.

    Resolves speaker_query against the users table (username, first_name, last_name,
    or computed full_name) with automatic leading `@` stripping (e.g., "@alice" -> "alice").
    Resolves chat_query against the chats table (chat_title) with fallback behavior:
    if chat_query matches no known chat title globally, falls back to all accessible chats
    (handles colloquial terms like "DMs"); if it matches a real chat not in accessible_chat_ids,
    returns empty (access control preserved). Both are matched case-insensitively using LIKE.
    At least one of content_query or speaker_query must be provided; calling with neither
    returns an empty list.

    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).
        speaker_query: Name or @handle to resolve to a set of user_ids (LIKE match
                       against username, first_name, last_name, and full_name).
                       Leading `@` is stripped automatically.
        chat_query: Chat title to resolve to a set of chat_ids (LIKE match).
                    If no matching chat is found globally, falls back to all accessible chats.
                    If a matching chat is found but not in accessible, returns empty.
        date_from: ISO date string (YYYY-MM-DD) for start of range (inclusive).
        date_to: ISO date string (YYYY-MM-DD) for end of range (inclusive).
        limit: Maximum number of results to return (default 20).

    Returns:
        List of result dicts with keys: speaker, chat, content, timestamp.
        Ordered by created_at DESC (most recent first).
    """
    if not content_query and not speaker_query:
        return []
    if not accessible_chat_ids:
        return []

    async with aiosqlite.connect(get_db_path()) as db:
        # Resolve speaker_query -> user_ids
        speaker_ids: Optional[list[int]] = None
        if speaker_query:
            like = f"%{speaker_query.lstrip('@')}%"
            async with db.execute(
                "SELECT user_id FROM users WHERE "
                "username LIKE ? OR first_name LIKE ? OR last_name LIKE ? "
                "OR (first_name || ' ' || last_name) LIKE ?",
                (like, like, like, like),
            ) as cur:
                rows = await cur.fetchall()
            speaker_ids = [r[0] for r in rows]
            if not speaker_ids:
                return []

        # Resolve chat_query -> chat_ids (intersected with accessible_chat_ids)
        resolved_chat_ids: Optional[list[int]] = None
        if chat_query:
            like = f"%{chat_query}%"
            # Check if chat_query matches any known chat globally (not just accessible).
            # If it matches a real chat that is not accessible, return empty (access control).
            # If it matches nothing at all (e.g. "DMs"), fall back to all accessible chats.
            async with db.execute(
                "SELECT COUNT(*) FROM chats WHERE chat_title LIKE ?", (like,)
            ) as cur:
                global_count = (await cur.fetchone())[0]
            if global_count > 0:
                placeholders = ','.join('?' * len(accessible_chat_ids))
                async with db.execute(
                    f"SELECT chat_id FROM chats WHERE chat_title LIKE ? AND chat_id IN ({placeholders})",
                    (like, *accessible_chat_ids),
                ) as cur:
                    rows = await cur.fetchall()
                resolved_chat_ids = [r[0] for r in rows] or []
                if not resolved_chat_ids:
                    return []

        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)
        if date_to:
            conditions.append("m.created_at <= ?")
            params.append(date_to + "T23:59:59Z")

        where_clause = " AND ".join(conditions)
        query = f"""
            SELECT
                u.first_name, u.last_name, u.username,
                c.chat_title,
                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, 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",
            "content": content,
            "timestamp": created_at,
        })
    return results

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 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()
