import hashlib
import json
import os
import secrets
import sqlite3
import threading
from datetime import datetime, timezone, timedelta
from functools import wraps
from pathlib import Path

from flask import Flask, jsonify, request, session, send_from_directory, abort
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import secure_filename

try:
    from flask_sock import Sock
except Exception:
    Sock = None

BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "webiv.db"
UPLOAD_DIR = BASE_DIR / "uploads"
UPLOAD_DIR.mkdir(exist_ok=True)

MAX_UPLOAD_MB = int(os.environ.get("WEBIV_MAX_UPLOAD_MB", "50"))
STUN_URL = os.environ.get("WEBIV_STUN_URL", "stun:stun.l.google.com:19302")

app = Flask(__name__, static_folder="static", static_url_path="/static")
app.secret_key = os.environ.get("WEBIV_SECRET") or secrets.token_hex(32)
app.config.update(
    MAX_CONTENT_LENGTH=MAX_UPLOAD_MB * 1024 * 1024,
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE="Lax",
    SESSION_COOKIE_SECURE=True,
    PERMANENT_SESSION_LIFETIME=timedelta(days=30),
)

ALLOWED_EXT = {
    "png", "jpg", "jpeg", "gif", "webp", "svg", "mp4", "webm", "mov", "m4v",
    "mp3", "wav", "ogg", "m4a", "aac", "pdf", "zip", "rar", "7z", "txt",
    "doc", "docx", "xls", "xlsx", "ppt", "pptx", "csv", "json"
}
IMAGE_EXT = {"png", "jpg", "jpeg", "gif", "webp"}
REACTIONS = ["like", "heart", "laugh", "wow", "sad", "angry"]
LANGUAGES = {"fa": "فارسی", "en": "English", "ar": "العربية", "tr": "Türkçe", "ru": "Русский"}

WS_CLIENTS = {}
WS_LOCK = threading.RLock()


def now_iso():
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def get_db():
    conn = sqlite3.connect(DB_PATH, timeout=20)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA foreign_keys = ON")
    conn.execute("PRAGMA journal_mode = WAL")
    conn.execute("PRAGMA busy_timeout = 5000")
    return conn


def column_exists(conn, table, column):
    return any(r[1] == column for r in conn.execute(f"PRAGMA table_info({table})").fetchall())


def add_column(conn, table, column, definition):
    if not column_exists(conn, table, column):
        conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")


def init_db():
    conn = get_db()
    conn.executescript("""
    CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        username TEXT UNIQUE NOT NULL,
        password_hash TEXT NOT NULL,
        display_name TEXT NOT NULL,
        avatar TEXT DEFAULT '',
        bio TEXT DEFAULT '',
        created_at TEXT NOT NULL,
        last_seen TEXT NOT NULL,
        theme TEXT DEFAULT 'system',
        notify_messages INTEGER DEFAULT 1,
        read_receipts INTEGER DEFAULT 1,
        online_status INTEGER DEFAULT 1
    );
    CREATE TABLE IF NOT EXISTS chats (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        kind TEXT NOT NULL DEFAULT 'private',
        title TEXT NOT NULL,
        description TEXT DEFAULT '',
        owner_id INTEGER,
        username TEXT DEFAULT '',
        is_public INTEGER DEFAULT 0,
        created_at TEXT NOT NULL,
        updated_at TEXT NOT NULL,
        FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE SET NULL
    );
    CREATE TABLE IF NOT EXISTS chat_members (
        chat_id INTEGER NOT NULL,
        user_id INTEGER NOT NULL,
        role TEXT NOT NULL DEFAULT 'member',
        joined_at TEXT NOT NULL,
        muted_until TEXT DEFAULT '',
        PRIMARY KEY(chat_id, user_id),
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS messages (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        chat_id INTEGER NOT NULL,
        sender_id INTEGER NOT NULL,
        body TEXT DEFAULT '',
        attachment TEXT DEFAULT '',
        attachment_name TEXT DEFAULT '',
        attachment_mime TEXT DEFAULT '',
        created_at TEXT NOT NULL,
        edited_at TEXT DEFAULT '',
        deleted_at TEXT DEFAULT '',
        reply_to_id INTEGER,
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE,
        FOREIGN KEY(sender_id) REFERENCES users(id) ON DELETE CASCADE,
        FOREIGN KEY(reply_to_id) REFERENCES messages(id) ON DELETE SET NULL
    );
    CREATE TABLE IF NOT EXISTS message_reactions (
        message_id INTEGER NOT NULL,
        user_id INTEGER NOT NULL,
        reaction TEXT NOT NULL,
        created_at TEXT NOT NULL,
        PRIMARY KEY(message_id, user_id, reaction),
        FOREIGN KEY(message_id) REFERENCES messages(id) ON DELETE CASCADE,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS read_state (
        chat_id INTEGER NOT NULL,
        user_id INTEGER NOT NULL,
        last_read_message_id INTEGER DEFAULT 0,
        updated_at TEXT NOT NULL,
        PRIMARY KEY(chat_id, user_id),
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS contacts (
        owner_id INTEGER NOT NULL,
        contact_id INTEGER NOT NULL,
        created_at TEXT NOT NULL,
        PRIMARY KEY(owner_id, contact_id),
        FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE,
        FOREIGN KEY(contact_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS blocks (
        blocker_id INTEGER NOT NULL,
        blocked_id INTEGER NOT NULL,
        created_at TEXT NOT NULL,
        PRIMARY KEY(blocker_id, blocked_id),
        FOREIGN KEY(blocker_id) REFERENCES users(id) ON DELETE CASCADE,
        FOREIGN KEY(blocked_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS pinned_messages (
        chat_id INTEGER NOT NULL,
        message_id INTEGER NOT NULL,
        pinned_by INTEGER NOT NULL,
        pinned_at TEXT NOT NULL,
        PRIMARY KEY(chat_id, message_id),
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE,
        FOREIGN KEY(message_id) REFERENCES messages(id) ON DELETE CASCADE,
        FOREIGN KEY(pinned_by) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS join_requests (
        chat_id INTEGER NOT NULL,
        user_id INTEGER NOT NULL,
        created_at TEXT NOT NULL,
        PRIMARY KEY(chat_id, user_id),
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS notifications (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id INTEGER NOT NULL,
        type TEXT NOT NULL,
        chat_id INTEGER,
        message_id INTEGER,
        payload TEXT DEFAULT '',
        is_read INTEGER DEFAULT 0,
        created_at TEXT NOT NULL,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS sessions_meta (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id INTEGER NOT NULL,
        session_id TEXT NOT NULL,
        user_agent TEXT DEFAULT '',
        ip TEXT DEFAULT '',
        created_at TEXT NOT NULL,
        last_seen TEXT NOT NULL,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS bots (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id INTEGER UNIQUE NOT NULL,
        owner_id INTEGER NOT NULL,
        token_hash TEXT UNIQUE NOT NULL,
        token_prefix TEXT NOT NULL,
        description TEXT DEFAULT '',
        enabled INTEGER DEFAULT 1,
        created_at TEXT NOT NULL,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
        FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS bot_commands (
        bot_id INTEGER NOT NULL,
        command TEXT NOT NULL,
        description TEXT DEFAULT '',
        PRIMARY KEY(bot_id, command),
        FOREIGN KEY(bot_id) REFERENCES bots(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS bot_bans (
        bot_id INTEGER NOT NULL,
        user_id INTEGER NOT NULL,
        created_at TEXT NOT NULL,
        reason TEXT DEFAULT '',
        PRIMARY KEY(bot_id, user_id),
        FOREIGN KEY(bot_id) REFERENCES bots(id) ON DELETE CASCADE,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS bot_chat_bans (
        bot_id INTEGER NOT NULL,
        chat_id INTEGER NOT NULL,
        user_id INTEGER NOT NULL,
        created_at TEXT NOT NULL,
        reason TEXT DEFAULT '',
        PRIMARY KEY(bot_id, chat_id, user_id),
        FOREIGN KEY(bot_id) REFERENCES bots(id) ON DELETE CASCADE,
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS bot_updates (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        bot_id INTEGER NOT NULL,
        chat_id INTEGER,
        user_id INTEGER,
        message_id INTEGER,
        update_json TEXT NOT NULL,
        created_at TEXT NOT NULL,
        FOREIGN KEY(bot_id) REFERENCES bots(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS rtc_signals (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        room_id TEXT NOT NULL,
        sender_id INTEGER NOT NULL,
        recipient_id INTEGER NOT NULL,
        type TEXT NOT NULL,
        payload TEXT NOT NULL,
        created_at TEXT NOT NULL,
        FOREIGN KEY(sender_id) REFERENCES users(id) ON DELETE CASCADE,
        FOREIGN KEY(recipient_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id, id);
    CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id, id);
    CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
    CREATE INDEX IF NOT EXISTS idx_bot_updates_bot_id ON bot_updates(bot_id, id);
    CREATE INDEX IF NOT EXISTS idx_rtc_recipient ON rtc_signals(recipient_id, id);
    """)

    add_column(conn, "chats", "description", "TEXT DEFAULT ''")
    add_column(conn, "chats", "username", "TEXT DEFAULT ''")
    add_column(conn, "chats", "is_public", "INTEGER DEFAULT 0")
    add_column(conn, "chats", "updated_at", "TEXT DEFAULT ''")
    conn.execute("UPDATE chats SET updated_at=created_at WHERE updated_at IS NULL OR updated_at='' ")
    add_column(conn, "messages", "attachment_mime", "TEXT DEFAULT ''")
    add_column(conn, "messages", "deleted_at", "TEXT DEFAULT ''")
    add_column(conn, "messages", "reply_to_id", "INTEGER")
    add_column(conn, "chat_members", "joined_at", "TEXT DEFAULT ''")
    add_column(conn, "chat_members", "muted_until", "TEXT DEFAULT ''")
    conn.execute("UPDATE chat_members SET joined_at=? WHERE joined_at IS NULL OR joined_at=''", (now_iso(),))
    add_column(conn, "users", "theme", "TEXT DEFAULT 'system'")
    add_column(conn, "users", "notify_messages", "INTEGER DEFAULT 1")
    add_column(conn, "users", "read_receipts", "INTEGER DEFAULT 1")
    add_column(conn, "users", "online_status", "INTEGER DEFAULT 1")
    add_column(conn, "users", "language", "TEXT DEFAULT 'fa'")
    add_column(conn, "users", "is_bot", "INTEGER DEFAULT 0")
    add_column(conn, "users", "bot_owner_id", "INTEGER")
    conn.execute("UPDATE users SET language='fa' WHERE language IS NULL OR language='' ")

    # Seed the built-in BotFather as an internal service identity once.
    bf = conn.execute("SELECT id FROM users WHERE username='botfather'").fetchone()
    if not bf:
        ts = now_iso()
        conn.execute(
            "INSERT INTO users(username,password_hash,display_name,bio,created_at,last_seen,is_bot,language) VALUES(?,?,?,?,?,?,1,'fa')",
            ("botfather", generate_password_hash(secrets.token_urlsafe(32)), "Web IV BotFather", "مدیریت ربات‌های Web IV", ts, ts),
        )
    # v4 platform tables: settings, folders, stories, topics, polls, scheduled messages,
    # disappearing messages, saved messages, reactions metadata, admin log, communities,
    # gifts/stars, and bot/mini-app configuration. All tables are SQLite-friendly for shared hosting.
    conn.executescript("""
    CREATE TABLE IF NOT EXISTS user_settings (
        user_id INTEGER PRIMARY KEY,
        language TEXT DEFAULT 'fa',
        theme_mode TEXT DEFAULT 'system',
        accent TEXT DEFAULT 'blue',
        animation INTEGER DEFAULT 1,
        power_saving INTEGER DEFAULT 0,
        autoplay INTEGER DEFAULT 1,
        auto_download INTEGER DEFAULT 1,
        read_receipts INTEGER DEFAULT 1,
        last_seen_privacy TEXT DEFAULT 'everyone',
        profile_photo_privacy TEXT DEFAULT 'everyone',
        phone_privacy TEXT DEFAULT 'nobody',
        calls_privacy TEXT DEFAULT 'everyone',
        groups_privacy TEXT DEFAULT 'everyone',
        forwards_privacy TEXT DEFAULT 'everyone',
        noforwards_default INTEGER DEFAULT 0,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS chat_folders (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        owner_id INTEGER NOT NULL,
        title TEXT NOT NULL,
        icon TEXT DEFAULT 'folder',
        include_private INTEGER DEFAULT 1,
        include_groups INTEGER DEFAULT 1,
        include_channels INTEGER DEFAULT 1,
        include_bots INTEGER DEFAULT 1,
        include_unread INTEGER DEFAULT 0,
        exclude_muted INTEGER DEFAULT 0,
        created_at TEXT NOT NULL,
        updated_at TEXT NOT NULL,
        FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS folder_chats (
        folder_id INTEGER NOT NULL,
        chat_id INTEGER NOT NULL,
        pinned INTEGER DEFAULT 0,
        PRIMARY KEY(folder_id,chat_id),
        FOREIGN KEY(folder_id) REFERENCES chat_folders(id) ON DELETE CASCADE,
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS chat_settings (
        chat_id INTEGER NOT NULL,
        user_id INTEGER NOT NULL,
        muted INTEGER DEFAULT 0,
        wallpaper TEXT DEFAULT '',
        theme TEXT DEFAULT 'default',
        ttl_seconds INTEGER DEFAULT 0,
        no_forwards INTEGER DEFAULT 0,
        reactions_json TEXT DEFAULT '[]',
        PRIMARY KEY(chat_id,user_id),
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS message_forwards (
        message_id INTEGER NOT NULL,
        from_message_id INTEGER NOT NULL,
        from_chat_id INTEGER NOT NULL,
        attribution INTEGER DEFAULT 1,
        PRIMARY KEY(message_id),
        FOREIGN KEY(message_id) REFERENCES messages(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS scheduled_messages (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        chat_id INTEGER NOT NULL,
        sender_id INTEGER NOT NULL,
        body TEXT DEFAULT '',
        attachment TEXT DEFAULT '',
        attachment_name TEXT DEFAULT '',
        attachment_mime TEXT DEFAULT '',
        reply_to_id INTEGER,
        send_at TEXT NOT NULL,
        repeat_seconds INTEGER DEFAULT 0,
        silent INTEGER DEFAULT 0,
        created_at TEXT NOT NULL,
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE,
        FOREIGN KEY(sender_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS drafts (
        user_id INTEGER NOT NULL,
        chat_id INTEGER NOT NULL,
        body TEXT DEFAULT '',
        updated_at TEXT NOT NULL,
        PRIMARY KEY(user_id,chat_id),
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS stories (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id INTEGER NOT NULL,
        media_url TEXT NOT NULL,
        media_type TEXT DEFAULT 'image',
        caption TEXT DEFAULT '',
        privacy TEXT DEFAULT 'contacts',
        created_at TEXT NOT NULL,
        expires_at TEXT NOT NULL,
        archived INTEGER DEFAULT 0,
        pinned INTEGER DEFAULT 0,
        views INTEGER DEFAULT 0,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS story_views (
        story_id INTEGER NOT NULL,
        user_id INTEGER NOT NULL,
        viewed_at TEXT NOT NULL,
        PRIMARY KEY(story_id,user_id),
        FOREIGN KEY(story_id) REFERENCES stories(id) ON DELETE CASCADE,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS story_reactions (
        story_id INTEGER NOT NULL,
        user_id INTEGER NOT NULL,
        reaction TEXT NOT NULL,
        created_at TEXT NOT NULL,
        PRIMARY KEY(story_id,user_id),
        FOREIGN KEY(story_id) REFERENCES stories(id) ON DELETE CASCADE,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS topics (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        chat_id INTEGER NOT NULL,
        title TEXT NOT NULL,
        icon TEXT DEFAULT '💬',
        pinned INTEGER DEFAULT 0,
        closed INTEGER DEFAULT 0,
        created_by INTEGER NOT NULL,
        created_at TEXT NOT NULL,
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE,
        FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS topic_members (
        topic_id INTEGER NOT NULL,
        user_id INTEGER NOT NULL,
        PRIMARY KEY(topic_id,user_id),
        FOREIGN KEY(topic_id) REFERENCES topics(id) ON DELETE CASCADE,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS polls (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        chat_id INTEGER NOT NULL,
        message_id INTEGER NOT NULL,
        question TEXT NOT NULL,
        options_json TEXT NOT NULL,
        counts_json TEXT NOT NULL,
        allows_multiple INTEGER DEFAULT 0,
        quiz INTEGER DEFAULT 0,
        correct_index INTEGER DEFAULT -1,
        closes_at TEXT DEFAULT '',
        anonymous INTEGER DEFAULT 1,
        created_at TEXT NOT NULL,
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE,
        FOREIGN KEY(message_id) REFERENCES messages(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS poll_votes (
        poll_id INTEGER NOT NULL,
        user_id INTEGER NOT NULL,
        option_index INTEGER NOT NULL,
        created_at TEXT NOT NULL,
        PRIMARY KEY(poll_id,user_id,option_index),
        FOREIGN KEY(poll_id) REFERENCES polls(id) ON DELETE CASCADE,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS saved_messages (
        user_id INTEGER NOT NULL,
        message_id INTEGER NOT NULL,
        tag TEXT DEFAULT '',
        saved_at TEXT NOT NULL,
        PRIMARY KEY(user_id,message_id),
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
        FOREIGN KEY(message_id) REFERENCES messages(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS admin_actions (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        chat_id INTEGER NOT NULL,
        actor_id INTEGER NOT NULL,
        action TEXT NOT NULL,
        target_id INTEGER DEFAULT 0,
        payload TEXT DEFAULT '',
        created_at TEXT NOT NULL,
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE,
        FOREIGN KEY(actor_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS communities (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        owner_id INTEGER NOT NULL,
        title TEXT NOT NULL,
        description TEXT DEFAULT '',
        created_at TEXT NOT NULL,
        FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS community_chats (
        community_id INTEGER NOT NULL,
        chat_id INTEGER NOT NULL,
        visible INTEGER DEFAULT 1,
        suggested INTEGER DEFAULT 0,
        PRIMARY KEY(community_id,chat_id),
        FOREIGN KEY(community_id) REFERENCES communities(id) ON DELETE CASCADE,
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS wallets (
        user_id INTEGER PRIMARY KEY,
        stars INTEGER DEFAULT 0,
        boosts INTEGER DEFAULT 0,
        updated_at TEXT NOT NULL,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS gifts (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        sender_id INTEGER NOT NULL,
        recipient_id INTEGER NOT NULL,
        title TEXT NOT NULL,
        value INTEGER DEFAULT 0,
        kind TEXT DEFAULT 'gift',
        message TEXT DEFAULT '',
        created_at TEXT NOT NULL,
        FOREIGN KEY(sender_id) REFERENCES users(id) ON DELETE CASCADE,
        FOREIGN KEY(recipient_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS bot_settings (
        bot_id INTEGER PRIMARY KEY,
        about TEXT DEFAULT '',
        menu_button TEXT DEFAULT 'commands',
        inline_enabled INTEGER DEFAULT 0,
        web_app_url TEXT DEFAULT '',
        reply_keyboard_json TEXT DEFAULT '[]',
        inline_keyboard_json TEXT DEFAULT '[]',
        FOREIGN KEY(bot_id) REFERENCES bots(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS mini_apps (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        bot_id INTEGER NOT NULL,
        name TEXT NOT NULL,
        short_name TEXT NOT NULL,
        url TEXT NOT NULL,
        description TEXT DEFAULT '',
        created_at TEXT NOT NULL,
        FOREIGN KEY(bot_id) REFERENCES bots(id) ON DELETE CASCADE
    );
    CREATE INDEX IF NOT EXISTS idx_scheduled_send_at ON scheduled_messages(send_at);
    CREATE INDEX IF NOT EXISTS idx_stories_active ON stories(user_id,expires_at);
    CREATE INDEX IF NOT EXISTS idx_admin_actions_chat ON admin_actions(chat_id,id);
    CREATE INDEX IF NOT EXISTS idx_topics_chat ON topics(chat_id,id);
    CREATE INDEX IF NOT EXISTS idx_poll_votes_poll ON poll_votes(poll_id);
    """)
    conn.executescript("""
    CREATE TABLE IF NOT EXISTS member_tags (
        chat_id INTEGER NOT NULL, user_id INTEGER NOT NULL, tag TEXT NOT NULL, updated_at TEXT NOT NULL,
        PRIMARY KEY(chat_id,user_id),
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS profile_music (
        user_id INTEGER PRIMARY KEY, title TEXT DEFAULT '', artist TEXT DEFAULT '', url TEXT DEFAULT '', updated_at TEXT NOT NULL,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS emoji_status (
        user_id INTEGER PRIMARY KEY, value TEXT DEFAULT '', updated_at TEXT NOT NULL,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS welcome_messages (
        chat_id INTEGER PRIMARY KEY, enabled INTEGER DEFAULT 1, body TEXT DEFAULT '', created_at TEXT NOT NULL,
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS message_meta (
        message_id INTEGER PRIMARY KEY, rich_json TEXT DEFAULT '{}', updated_at TEXT NOT NULL,
        FOREIGN KEY(message_id) REFERENCES messages(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS contact_notes (
        owner_id INTEGER NOT NULL, contact_id INTEGER NOT NULL, note TEXT DEFAULT '', updated_at TEXT NOT NULL,
        PRIMARY KEY(owner_id,contact_id),
        FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE,
        FOREIGN KEY(contact_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS premium_accounts (
        user_id INTEGER PRIMARY KEY, active INTEGER DEFAULT 0, expires_at TEXT DEFAULT '', plan TEXT DEFAULT 'free', updated_at TEXT NOT NULL,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS chat_boosts (
        chat_id INTEGER PRIMARY KEY, boosts INTEGER DEFAULT 0, updated_at TEXT NOT NULL,
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS giveaways (
        id INTEGER PRIMARY KEY AUTOINCREMENT, chat_id INTEGER NOT NULL, title TEXT NOT NULL, prize TEXT DEFAULT '', winners_count INTEGER DEFAULT 1, ends_at TEXT NOT NULL, created_at TEXT NOT NULL,
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS giveaway_entries (
        giveaway_id INTEGER NOT NULL, user_id INTEGER NOT NULL, created_at TEXT NOT NULL,
        PRIMARY KEY(giveaway_id,user_id),
        FOREIGN KEY(giveaway_id) REFERENCES giveaways(id) ON DELETE CASCADE,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS checklists (
        id INTEGER PRIMARY KEY AUTOINCREMENT, chat_id INTEGER NOT NULL, message_id INTEGER, title TEXT NOT NULL, created_by INTEGER NOT NULL, created_at TEXT NOT NULL,
        FOREIGN KEY(chat_id) REFERENCES chats(id) ON DELETE CASCADE,
        FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS checklist_items (
        id INTEGER PRIMARY KEY AUTOINCREMENT, checklist_id INTEGER NOT NULL, title TEXT NOT NULL, done INTEGER DEFAULT 0, position INTEGER DEFAULT 0,
        FOREIGN KEY(checklist_id) REFERENCES checklists(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS business_settings (
        user_id INTEGER PRIMARY KEY, work_start TEXT DEFAULT '09:00', work_end TEXT DEFAULT '18:00', location TEXT DEFAULT '', greeting TEXT DEFAULT '', away TEXT DEFAULT '', intro TEXT DEFAULT '', updated_at TEXT NOT NULL,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS business_quick_replies (
        id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, shortcut TEXT NOT NULL, text TEXT NOT NULL, updated_at TEXT NOT NULL,
        UNIQUE(user_id,shortcut), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE TABLE IF NOT EXISTS message_translations (
        message_id INTEGER NOT NULL, language TEXT NOT NULL, translated TEXT NOT NULL, created_at TEXT NOT NULL,
        PRIMARY KEY(message_id,language), FOREIGN KEY(message_id) REFERENCES messages(id) ON DELETE CASCADE
    );
    """)
    conn.commit()
    conn.close()


def json_user(row, include_private=False):
    if not row:
        return None
    data = {
        "id": row["id"], "username": row["username"], "display_name": row["display_name"],
        "avatar": row["avatar"] or "", "bio": row["bio"] or "", "last_seen": row["last_seen"],
        "is_bot": bool(row["is_bot"]) if "is_bot" in row.keys() else False,
    }
    if include_private:
        for key in ("theme", "notify_messages", "read_receipts", "online_status", "created_at", "language"):
            data[key] = row[key]
    return data


def current_user():
    uid = session.get("user_id")
    if not uid:
        return None
    conn = get_db()
    user = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
    if user:
        conn.execute("UPDATE users SET last_seen=? WHERE id=?", (now_iso(), uid))
        conn.commit()
    conn.close()
    return user


def login_required(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        if not current_user():
            return jsonify({"ok": False, "error": "AUTH_REQUIRED"}), 401
        return fn(*args, **kwargs)
    return wrapper


def csrf_required(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        expected = session.get("csrf_token")
        supplied = request.headers.get("X-CSRF-Token") or request.form.get("csrf_token")
        if expected and supplied != expected:
            return jsonify({"ok": False, "error": "CSRF_INVALID"}), 403
        return fn(*args, **kwargs)
    return wrapper


def ensure_csrf():
    if "csrf_token" not in session:
        session["csrf_token"] = secrets.token_urlsafe(24)
    return session["csrf_token"]


def user_id():
    u = current_user()
    return u["id"] if u else None


def is_member(conn, chat_id, uid):
    return conn.execute("SELECT * FROM chat_members WHERE chat_id=? AND user_id=?", (chat_id, uid)).fetchone()


def chat_row(conn, chat_id):
    return conn.execute("SELECT * FROM chats WHERE id=?", (chat_id,)).fetchone()


def other_user(conn, chat_id, uid):
    return conn.execute(
        "SELECT u.* FROM users u JOIN chat_members cm ON cm.user_id=u.id WHERE cm.chat_id=? AND u.id<>? LIMIT 1",
        (chat_id, uid),
    ).fetchone()


def is_blocked(conn, a, b):
    return bool(
        conn.execute("SELECT 1 FROM blocks WHERE blocker_id=? AND blocked_id=?", (a, b)).fetchone()
        or conn.execute("SELECT 1 FROM blocks WHERE blocker_id=? AND blocked_id=?", (b, a)).fetchone()
    )


def can_manage_chat(conn, chat, uid):
    if not chat:
        return False
    if chat["owner_id"] == uid:
        return True
    m = is_member(conn, chat["id"], uid)
    return bool(m and m["role"] in ("owner", "admin"))


def message_json(conn, row, uid):
    if not row:
        return None
    reactions = conn.execute(
        "SELECT reaction,COUNT(*) c,SUM(CASE WHEN user_id=? THEN 1 ELSE 0 END) mine "
        "FROM message_reactions WHERE message_id=? GROUP BY reaction", (uid, row["id"])
    ).fetchall()
    reply = None
    if row["reply_to_id"]:
        rr = conn.execute(
            "SELECT m.id,m.body,u.display_name FROM messages m JOIN users u ON u.id=m.sender_id WHERE m.id=?",
            (row["reply_to_id"],)
        ).fetchone()
        if rr:
            reply = dict(rr)
    return {
        **dict(row),
        "reactions": [{"reaction": r["reaction"], "count": r["c"], "mine": bool(r["mine"])} for r in reactions],
        "reply": reply,
        "is_deleted": bool(row["deleted_at"]),
    }


def notify_chat_members(conn, chat_id, sender_id, kind, message_id=None, payload=None):
    rows = conn.execute("SELECT user_id FROM chat_members WHERE chat_id=? AND user_id<>?", (chat_id, sender_id)).fetchall()
    for r in rows:
        conn.execute(
            "INSERT INTO notifications(user_id,type,chat_id,message_id,payload,created_at) VALUES(?,?,?,?,?,?)",
            (r["user_id"], kind, chat_id, message_id, json.dumps(payload or {}, ensure_ascii=False), now_iso())
        )


def ws_send_to_user(uid, payload):
    if Sock is None:
        return
    dead = []
    raw = json.dumps(payload, ensure_ascii=False)
    with WS_LOCK:
        targets = list(WS_CLIENTS.get(uid, []))
    for ws in targets:
        try:
            ws.send(raw)
        except Exception:
            dead.append(ws)
    if dead:
        with WS_LOCK:
            if uid in WS_CLIENTS:
                for ws in dead:
                    WS_CLIENTS[uid].remove(ws) if ws in WS_CLIENTS[uid] else None
                if not WS_CLIENTS[uid]:
                    WS_CLIENTS.pop(uid, None)


def ws_broadcast_users(user_ids, payload):
    for uid in set(int(x) for x in user_ids if x):
        ws_send_to_user(uid, payload)


def notify_bot_update(conn, chat_id, sender_id, message_id, body, attachment_name):
    bots = conn.execute(
        "SELECT b.id,b.user_id FROM bots b WHERE b.enabled=1 AND b.user_id<>? AND EXISTS "
        "(SELECT 1 FROM chat_members cm WHERE cm.chat_id=? AND cm.user_id=b.user_id)", (sender_id, chat_id)
    ).fetchall()
    for b in bots:
        if conn.execute("SELECT 1 FROM bot_bans WHERE bot_id=? AND user_id=?", (b["id"], sender_id)).fetchone():
            continue
        payload = {
            "update_id": None,
            "message": {
                "message_id": message_id,
                "chat": {"id": chat_id},
                "from": {"id": sender_id},
                "text": body,
                "attachment_name": attachment_name,
            }
        }
        conn.execute(
            "INSERT INTO bot_updates(bot_id,chat_id,user_id,message_id,update_json,created_at) VALUES(?,?,?,?,?,?)",
            (b["id"], chat_id, sender_id, message_id, json.dumps(payload, ensure_ascii=False), now_iso())
        )


def hash_token(token):
    return hashlib.sha256(token.encode("utf-8")).hexdigest()


def generate_bot_token(username):
    return f"{secrets.randbelow(900000000)+100000000}:{secrets.token_urlsafe(32)}"


def validate_bot_token(token):
    if not token or len(token) < 20:
        return None
    conn = get_db()
    bot = conn.execute("SELECT * FROM bots WHERE token_hash=? AND enabled=1", (hash_token(token),)).fetchone()
    conn.close()
    return bot


def bot_owner_ok(conn, bot_id, uid):
    b = conn.execute("SELECT * FROM bots WHERE id=?", (bot_id,)).fetchone()
    return b if b and b["owner_id"] == uid else None


def bot_is_chat_admin(conn, bot_user_id, chat_id):
    m = is_member(conn, chat_id, bot_user_id)
    return bool(m and m["role"] in ("owner", "admin"))


def botfather_reply(uid, text):
    """Web IV BotFather: Telegram-inspired command flow, using Web IV bot accounts."""
    text = (text or '').strip()
    parts = text.split(maxsplit=2)
    command = parts[0].lower() if parts else '/help'
    conn = get_db()
    try:
        help_text = (
            "Web IV BotFather\n\n"
            "/newbot — ساخت ربات\n"
            "/mybots — ربات‌های من\n"
            "/selectbot @username — انتخاب ربات\n"
            "/setdescription متن — توضیحات\n"
            "/setabouttext متن — متن درباره\n"
            "/setcommands cmd - desc, cmd2 - desc\n"
            "/setwebapp https://... — Mini App\n"
            "/setinline on|off — حالت inline\n"
            "/setmenubutton commands|web_app|default\n"
            "/regenerate — توکن جدید\n"
            "/ban @username — بن کاربر برای ربات\n"
            "/unban @username — آن‌بن\n"
            "/deletebot — حذف ربات"
        )
        if command == '/help': return help_text
        if command == '/mybots':
            rows=conn.execute("SELECT b.id,u.username,u.display_name,b.description,b.enabled FROM bots b JOIN users u ON u.id=b.user_id WHERE b.owner_id=? ORDER BY b.id DESC",(uid,)).fetchall()
            if not rows:return 'هنوز رباتی نساخته‌ای. /newbot'
            return '\n'.join(['ربات‌های شما:']+[f"• {r['display_name']} — @{r['username']} — {'فعال' if r['enabled'] else 'خاموش'}" for r in rows])
        if command == '/newbot':
            raw=text[len('/newbot'):].strip()
            if ' ' not in raw:return 'فرمت: /newbot نام ربات usernamebot'
            name,username=raw.rsplit(' ',1); username=username.lower().lstrip('@')
            if not 3<=len(username)<=32 or not username.endswith('bot') or not username.replace('_','').isalnum():return 'username باید ۳ تا ۳۲ کاراکتر باشد و با bot تمام شود.'
            if conn.execute('SELECT 1 FROM users WHERE username=?',(username,)).fetchone():return 'این username قبلاً استفاده شده است.'
            ts=now_iso(); token=generate_bot_token(username)
            cur=conn.execute("INSERT INTO users(username,password_hash,display_name,bio,created_at,last_seen,is_bot,bot_owner_id) VALUES(?,?,?,?,?,?,1,?)",(username,generate_password_hash(secrets.token_urlsafe(24)),name[:64],'ربات Web IV',ts,ts,uid)); buid=cur.lastrowid
            cur=conn.execute("INSERT INTO bots(user_id,owner_id,token_hash,token_prefix,description,created_at) VALUES(?,?,?,?,?,?)",(buid,uid,hash_token(token),token[:10],'',ts)); bid=cur.lastrowid
            conn.execute("INSERT INTO bot_commands(bot_id,command,description) VALUES(?,?,?)",(bid,'start','شروع کار ربات'))
            conn.execute('INSERT OR IGNORE INTO bot_settings(bot_id) VALUES(?)',(bid,)); conn.commit(); session['botfather_bot_id']=bid
            return f'ربات ساخته شد.\nنام: {name}\nUsername: @{username}\n\nTOKEN:\n{token}\n\nتوکن را امن نگه دار.'
        selected=session.get('botfather_bot_id')
        if command == '/selectbot':
            if len(parts)<2:return 'فرمت: /selectbot @username'
            username=parts[1].lstrip('@').lower(); b=conn.execute('SELECT b.id FROM bots b JOIN users u ON u.id=b.user_id WHERE b.owner_id=? AND u.username=?',(uid,username)).fetchone()
            if not b:return 'ربات پیدا نشد.'
            session['botfather_bot_id']=b['id']; return f'ربات @{username} انتخاب شد.'
        if not selected:return 'ابتدا یک ربات را با /selectbot انتخاب کن.'
        b=conn.execute('SELECT * FROM bots WHERE id=? AND owner_id=?',(selected,uid)).fetchone()
        if not b:return 'ربات انتخاب‌شده پیدا نشد.'
        if command=='/setdescription':
            desc=text.split(maxsplit=1)[1] if len(text.split(maxsplit=1))==2 else ''
            conn.execute('UPDATE bots SET description=? WHERE id=?',(desc[:512],selected)); conn.commit(); return 'توضیحات ربات به‌روز شد.'
        if command=='/setabouttext':
            val=text.split(maxsplit=1)[1] if len(text.split(maxsplit=1))==2 else ''
            conn.execute('INSERT OR IGNORE INTO bot_settings(bot_id) VALUES(?)',(selected,)); conn.execute('UPDATE bot_settings SET about=? WHERE bot_id=?',(val[:512],selected)); conn.commit(); return 'متن About به‌روز شد.'
        if command=='/setwebapp':
            val=parts[1] if len(parts)>1 else ''; 
            if val and not (val.startswith('https://') or val.startswith('http://')):return 'آدرس Mini App باید http/https باشد.'
            conn.execute('INSERT OR IGNORE INTO bot_settings(bot_id) VALUES(?)',(selected,)); conn.execute('UPDATE bot_settings SET web_app_url=? WHERE bot_id=?',(val[:500],selected)); conn.commit(); return 'Mini App تنظیم شد.'
        if command=='/setinline':
            val=(parts[1].lower() if len(parts)>1 else 'on'); enabled=1 if val in ('on','true','1') else 0
            conn.execute('INSERT OR IGNORE INTO bot_settings(bot_id) VALUES(?)',(selected,)); conn.execute('UPDATE bot_settings SET inline_enabled=? WHERE bot_id=?',(enabled,selected)); conn.commit(); return 'Inline '+('فعال شد.' if enabled else 'خاموش شد.')
        if command=='/setmenubutton':
            val=(parts[1] if len(parts)>1 else 'commands')
            if val not in ('commands','web_app','default'):return 'مقدار: commands | web_app | default'
            conn.execute('INSERT OR IGNORE INTO bot_settings(bot_id) VALUES(?)',(selected,)); conn.execute('UPDATE bot_settings SET menu_button=? WHERE bot_id=?',(val,selected)); conn.commit(); return 'Menu Button ذخیره شد.'
        if command=='/regenerate':
            token=generate_bot_token('bot'); conn.execute('UPDATE bots SET token_hash=?,token_prefix=? WHERE id=?',(hash_token(token),token[:10],selected)); conn.commit(); return 'توکن جدید (فقط همین بار):\n'+token
        if command in ('/ban','/unban'):
            if len(parts)<2:return f'فرمت: {command} @username'
            tu=parts[1].lstrip('@').lower(); target=conn.execute('SELECT id FROM users WHERE username=?',(tu,)).fetchone()
            if not target:return 'کاربر پیدا نشد.'
            if command=='/ban':conn.execute('INSERT OR REPLACE INTO bot_bans(bot_id,user_id,created_at,reason) VALUES(?,?,?,?)',(selected,target['id'],now_iso(),'BotFather')); result='کاربر برای این ربات بن شد.'
            else:conn.execute('DELETE FROM bot_bans WHERE bot_id=? AND user_id=?',(selected,target['id'])); result='کاربر آن‌بن شد.'
            conn.commit(); return result
        if command=='/setcommands':
            raw=text.split(maxsplit=1)[1] if len(text.split(maxsplit=1))==2 else ''
            conn.execute('DELETE FROM bot_commands WHERE bot_id=?',(selected,))
            for item in raw.split(','):
                if '-' not in item:continue
                cmd,desc=item.split('-',1); cmd=cmd.strip().lstrip('/').lower(); desc=desc.strip()[:160]
                if cmd and cmd.replace('_','').isalnum():conn.execute('INSERT OR REPLACE INTO bot_commands(bot_id,command,description) VALUES(?,?,?)',(selected,cmd,desc))
            conn.commit(); return 'دستورات ربات ذخیره شد.'
        if command=='/deletebot':
            conn.execute('DELETE FROM bots WHERE id=?',(selected,)); conn.execute('DELETE FROM users WHERE id=?',(b['user_id'],)); session.pop('botfather_bot_id',None); conn.commit(); return 'ربات حذف شد.'
        return 'دستور ناشناخته است. /help'
    finally:
        conn.close()

@app.route("/")
def home():
    return send_from_directory(app.static_folder, "index.html")


@app.get("/manifest.webmanifest")
def manifest():
    return send_from_directory(app.static_folder, "manifest.webmanifest")


@app.get("/sw.js")
def sw():
    return send_from_directory(app.static_folder, "sw.js")


@app.route("/uploads/<path:name>")
def uploads(name):
    return send_from_directory(UPLOAD_DIR, name, conditional=True)


@app.get("/api/csrf")
def csrf():
    return jsonify({"ok": True, "csrf": ensure_csrf()})


@app.post("/api/register")
@csrf_required
def register():
    data = request.get_json(silent=True) or {}
    username = (data.get("username") or "").strip().lower().replace(" ", "")
    password = data.get("password") or ""
    display_name = (data.get("display_name") or username).strip()[:80]
    language = data.get("language") if data.get("language") in LANGUAGES else "fa"
    if not 4 <= len(username) <= 32:
        return jsonify({"ok": False, "error": "نام کاربری باید بین ۴ تا ۳۲ کاراکتر باشد."}), 400
    if len(password) < 6:
        return jsonify({"ok": False, "error": "رمز عبور باید حداقل ۶ کاراکتر باشد."}), 400
    if not username.replace("_", "").isalnum():
        return jsonify({"ok": False, "error": "نام کاربری فقط از حروف، عدد و _ تشکیل شود."}), 400
    conn = get_db()
    try:
        cur = conn.execute(
            "INSERT INTO users(username,password_hash,display_name,created_at,last_seen,language) VALUES(?,?,?,?,?,?)",
            (username, generate_password_hash(password), display_name or username, now_iso(), now_iso(), language),
        )
        conn.commit()
        uid = cur.lastrowid
    except sqlite3.IntegrityError:
        conn.close()
        return jsonify({"ok": False, "error": "این نام کاربری قبلاً ثبت شده است."}), 409
    conn.close()
    session.clear(); session["user_id"] = uid; session["csrf_token"] = secrets.token_urlsafe(24); session.permanent = True
    return jsonify({"ok": True, "user": json_user(current_user(), True), "csrf": session["csrf_token"]})


@app.post("/api/login")
@csrf_required
def login():
    data = request.get_json(silent=True) or {}
    username = (data.get("username") or "").strip().lower()
    password = data.get("password") or ""
    conn = get_db(); user = conn.execute("SELECT * FROM users WHERE username=? AND is_bot=0", (username,)).fetchone()
    if not user or not check_password_hash(user["password_hash"], password):
        conn.close(); return jsonify({"ok": False, "error": "نام کاربری یا رمز عبور نادرست است."}), 401
    conn.close()
    session.clear(); session["user_id"] = user["id"]; session["csrf_token"] = secrets.token_urlsafe(24); session.permanent = True
    return jsonify({"ok": True, "user": json_user(current_user(), True), "csrf": session["csrf_token"]})


@app.post("/api/logout")
@csrf_required
def logout():
    session.clear(); return jsonify({"ok": True})


@app.get("/api/me")
@login_required
def me():
    ensure_csrf(); return jsonify({"ok": True, "user": json_user(current_user(), True), "csrf": session["csrf_token"]})


@app.patch("/api/me")
@login_required
@csrf_required
def update_me():
    u = current_user(); data = request.get_json(silent=True) or {}
    display = (data.get("display_name") or u["display_name"]).strip()[:80]
    bio = (data.get("bio") or "").strip()[:160]
    theme = data.get("theme") if data.get("theme") in ("system", "light", "dark") else u["theme"]
    language = data.get("language") if data.get("language") in LANGUAGES else u["language"]
    notify_messages = 1 if data.get("notify_messages", u["notify_messages"]) else 0
    read_receipts = 1 if data.get("read_receipts", u["read_receipts"]) else 0
    online_status = 1 if data.get("online_status", u["online_status"]) else 0
    conn = get_db()
    conn.execute("UPDATE users SET display_name=?,bio=?,theme=?,language=?,notify_messages=?,read_receipts=?,online_status=?,last_seen=? WHERE id=?",
                 (display,bio,theme,language,notify_messages,read_receipts,online_status,now_iso(),u["id"]))
    conn.commit(); row = conn.execute("SELECT * FROM users WHERE id=?", (u["id"],)).fetchone(); conn.close()
    return jsonify({"ok": True, "user": json_user(row, True)})


@app.post("/api/me/avatar")
@login_required
@csrf_required
def update_avatar():
    f = request.files.get("file")
    if not f or not f.filename: return jsonify({"ok": False, "error": "فایل تصویر ارسال نشده است."}), 400
    ext = f.filename.rsplit(".", 1)[-1].lower() if "." in f.filename else ""
    if ext not in IMAGE_EXT: return jsonify({"ok": False, "error": "فقط تصویر مجاز است."}), 400
    uid = user_id(); filename = f"avatar_{uid}_{secrets.token_hex(6)}.{ext}"; f.save(UPLOAD_DIR / filename)
    conn = get_db(); conn.execute("UPDATE users SET avatar=? WHERE id=?", (f"/uploads/{filename}", uid)); conn.commit(); conn.close()
    return jsonify({"ok": True, "avatar": f"/uploads/{filename}"})


@app.get("/api/users")
@login_required
def users():
    q = (request.args.get("q") or "").strip().lower(); uid = user_id(); conn = get_db()
    if q:
        rows = conn.execute("SELECT * FROM users WHERE id<>? AND is_bot=0 AND (LOWER(username) LIKE ? OR LOWER(display_name) LIKE ?) ORDER BY display_name LIMIT 50", (uid,f"%{q}%",f"%{q}%")).fetchall()
    else:
        rows = conn.execute("SELECT * FROM users WHERE id<>? AND is_bot=0 ORDER BY id DESC LIMIT 50", (uid,)).fetchall()
    conn.close(); return jsonify({"ok": True, "users": [json_user(r) for r in rows]})


@app.get("/api/users/<int:target_id>")
@login_required
def get_user(target_id):
    conn=get_db(); row=conn.execute("SELECT * FROM users WHERE id=?",(target_id,)).fetchone(); conn.close()
    if not row: return jsonify({"ok":False,"error":"کاربر پیدا نشد."}),404
    return jsonify({"ok":True,"user":json_user(row)})


@app.get("/api/contacts")
@login_required
def get_contacts():
    uid=user_id(); conn=get_db(); rows=conn.execute("SELECT u.* FROM users u JOIN contacts c ON c.contact_id=u.id WHERE c.owner_id=? ORDER BY u.display_name",(uid,)).fetchall(); conn.close()
    return jsonify({"ok":True,"contacts":[json_user(r) for r in rows]})


@app.post("/api/contacts/<int:target_id>")
@login_required
@csrf_required
def add_contact(target_id):
    uid=user_id(); conn=get_db()
    if uid==target_id: conn.close(); return jsonify({"ok":False,"error":"خودتان را نمی‌توانید مخاطب اضافه کنید."}),400
    if not conn.execute("SELECT 1 FROM users WHERE id=?",(target_id,)).fetchone(): conn.close(); return jsonify({"ok":False,"error":"کاربر پیدا نشد."}),404
    conn.execute("INSERT OR IGNORE INTO contacts(owner_id,contact_id,created_at) VALUES(?,?,?)",(uid,target_id,now_iso())); conn.commit(); conn.close(); return jsonify({"ok":True})


@app.delete("/api/contacts/<int:target_id>")
@login_required
@csrf_required
def remove_contact(target_id):
    conn=get_db(); conn.execute("DELETE FROM contacts WHERE owner_id=? AND contact_id=?",(user_id(),target_id)); conn.commit(); conn.close(); return jsonify({"ok":True})


@app.post("/api/blocks/<int:target_id>")
@login_required
@csrf_required
def block_user(target_id):
    uid=user_id(); conn=get_db(); conn.execute("INSERT OR IGNORE INTO blocks(blocker_id,blocked_id,created_at) VALUES(?,?,?)",(uid,target_id,now_iso())); conn.commit(); conn.close(); return jsonify({"ok":True})


@app.delete("/api/blocks/<int:target_id>")
@login_required
@csrf_required
def unblock_user(target_id):
    conn=get_db(); conn.execute("DELETE FROM blocks WHERE blocker_id=? AND blocked_id=?",(user_id(),target_id)); conn.commit(); conn.close(); return jsonify({"ok":True})


@app.get("/api/notifications")
@login_required
def notifications():
    conn=get_db(); uid=user_id(); rows=conn.execute("SELECT id,type,chat_id,message_id,payload,is_read,created_at FROM notifications WHERE user_id=? ORDER BY id DESC LIMIT 80",(uid,)).fetchall(); conn.close()
    return jsonify({"ok":True,"notifications":[dict(r) for r in rows],"unread":sum(1 for r in rows if not r["is_read"])})


@app.post("/api/notifications/read")
@login_required
@csrf_required
def notifications_read():
    conn=get_db(); conn.execute("UPDATE notifications SET is_read=1 WHERE user_id=?",(user_id(),)); conn.commit(); conn.close(); return jsonify({"ok":True})


@app.post("/api/heartbeat")
@login_required
@csrf_required
def heartbeat():
    uid=user_id(); conn=get_db(); conn.execute("UPDATE users SET last_seen=? WHERE id=?",(now_iso(),uid)); conn.commit(); conn.close(); return jsonify({"ok":True,"ts":now_iso()})


@app.get("/api/chats")
@login_required
def chats():
    uid=user_id(); conn=get_db(); rows=conn.execute("""
        SELECT c.id,c.kind,c.title,c.description,c.owner_id,c.username,c.is_public,c.created_at,c.updated_at,
        (SELECT body FROM messages m WHERE m.chat_id=c.id AND m.deleted_at='' ORDER BY m.id DESC LIMIT 1) last_body,
        (SELECT created_at FROM messages m WHERE m.chat_id=c.id ORDER BY m.id DESC LIMIT 1) last_message_at,
        COALESCE((SELECT COUNT(*) FROM messages m WHERE m.chat_id=c.id AND m.id>COALESCE(rs.last_read_message_id,0) AND m.sender_id<>? AND m.deleted_at=''),0) unread_count
        FROM chats c JOIN chat_members cm ON cm.chat_id=c.id AND cm.user_id=? LEFT JOIN read_state rs ON rs.chat_id=c.id AND rs.user_id=?
        ORDER BY COALESCE(last_message_at,c.updated_at,c.created_at) DESC
    """,(uid,uid,uid)).fetchall()
    result=[]
    for r in rows:
        item=dict(r)
        if r["kind"]=="private":
            peer=other_user(conn,r["id"],uid); item["peer"]=json_user(peer) if peer else None; item["display_title"]=peer["display_name"] if peer else r["title"]; item["display_avatar"]=peer["avatar"] if peer else ""; item["online"]=False
            if peer and peer["last_seen"]:
                try: item["online"]=(datetime.now(timezone.utc)-datetime.fromisoformat(peer["last_seen"])).total_seconds()<45
                except Exception: pass
        else:
            item["display_title"]=r["title"]; item["display_avatar"]=""
        result.append(item)
    conn.close(); return jsonify({"ok":True,"chats":result})


@app.get("/api/chats/<int:chat_id>")
@login_required
def chat_info(chat_id):
    uid=user_id(); conn=get_db(); chat=chat_row(conn,chat_id); member=is_member(conn,chat_id,uid)
    if not chat or not member: conn.close(); return jsonify({"ok":False,"error":"گفتگو پیدا نشد یا دسترسی ندارید."}),404
    members=conn.execute("SELECT u.id,u.username,u.display_name,u.avatar,u.bio,u.is_bot,cm.role,cm.joined_at FROM users u JOIN chat_members cm ON cm.user_id=u.id WHERE cm.chat_id=? ORDER BY CASE cm.role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,u.display_name",(chat_id,)).fetchall()
    pins=conn.execute("SELECT pm.message_id,pm.pinned_at,pmbody.body FROM pinned_messages pm JOIN messages pmbody ON pmbody.id=pm.message_id WHERE pm.chat_id=? ORDER BY pm.pinned_at DESC LIMIT 20",(chat_id,)).fetchall()
    conn.close(); return jsonify({"ok":True,"chat":dict(chat),"member":dict(member),"members":[dict(m) for m in members],"pins":[dict(p) for p in pins]})


@app.post("/api/chats/private")
@login_required
@csrf_required
def create_private():
    uid=user_id(); data=request.get_json(silent=True) or {}; username=(data.get("username") or "").strip().lower(); conn=get_db(); peer=conn.execute("SELECT * FROM users WHERE username=?",(username,)).fetchone()
    if not peer: conn.close(); return jsonify({"ok":False,"error":"کاربر پیدا نشد."}),404
    if peer["id"]==uid: conn.close(); return jsonify({"ok":False,"error":"نمی‌توان با خودتان چت خصوصی ساخت."}),400
    if not peer["is_bot"] and is_blocked(conn,uid,peer["id"]): conn.close(); return jsonify({"ok":False,"error":"این کاربر مسدود است."}),403
    existing=conn.execute("""SELECT c.id FROM chats c JOIN chat_members a ON a.chat_id=c.id AND a.user_id=? JOIN chat_members b ON b.chat_id=c.id AND b.user_id=? WHERE c.kind='private' LIMIT 1""",(uid,peer["id"])).fetchone()
    if existing: cid=existing["id"]
    else:
        ts=now_iso(); cur=conn.execute("INSERT INTO chats(kind,title,owner_id,created_at,updated_at) VALUES(?,?,?,?,?)",("private",peer["display_name"],None,ts,ts)); cid=cur.lastrowid; conn.executemany("INSERT INTO chat_members(chat_id,user_id,role,joined_at) VALUES(?,?,?,?)",[(cid,uid,"member",ts),(cid,peer["id"],"member",ts)]); conn.commit()
    conn.close(); return jsonify({"ok":True,"chat_id":cid})


def create_broadcast_chat(uid,title,kind,description,username,is_public,member_usernames):
    conn=get_db(); ts=now_iso(); cur=conn.execute("INSERT INTO chats(kind,title,description,owner_id,username,is_public,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)",(kind,title,description,uid,username,is_public,ts,ts)); cid=cur.lastrowid
    conn.execute("INSERT INTO chat_members(chat_id,user_id,role,joined_at) VALUES(?,?,?,?)",(cid,uid,"owner",ts))
    for uname in member_usernames[:200]:
        peer=conn.execute("SELECT id FROM users WHERE username=?",(str(uname).strip().lower().lstrip("@"),)).fetchone()
        if peer and peer["id"]!=uid: conn.execute("INSERT OR IGNORE INTO chat_members(chat_id,user_id,role,joined_at) VALUES(?,?,?,?)",(cid,peer["id"],"member",ts))
    conn.commit(); conn.close(); return cid


@app.post("/api/chats/group")
@login_required
@csrf_required
def create_group():
    data=request.get_json(silent=True) or {}; title=(data.get("title") or "").strip()[:80]; desc=(data.get("description") or "").strip()[:300]; usernames=data.get("usernames") or []
    if len(title)<2: return jsonify({"ok":False,"error":"نام گروه کوتاه است."}),400
    return jsonify({"ok":True,"chat_id":create_broadcast_chat(user_id(),title,"group",desc,"",0,usernames)})


@app.post("/api/chats/channel")
@login_required
@csrf_required
def create_channel():
    data=request.get_json(silent=True) or {}; title=(data.get("title") or "").strip()[:80]; desc=(data.get("description") or "").strip()[:300]; username=(data.get("username") or "").strip().lower().lstrip("@").replace(" ","")[:32]; public=1 if data.get("is_public") else 0
    if len(title)<2: return jsonify({"ok":False,"error":"نام کانال کوتاه است."}),400
    if public and username:
        conn=get_db(); exists=conn.execute("SELECT 1 FROM chats WHERE kind='channel' AND username=?",(username,)).fetchone(); conn.close()
        if exists: return jsonify({"ok":False,"error":"این نام کاربری کانال قبلاً استفاده شده است."}),409
    return jsonify({"ok":True,"chat_id":create_broadcast_chat(user_id(),title,"channel",desc,username,public,[])})


@app.post("/api/chats/<int:chat_id>/join")
@login_required
@csrf_required
def join_chat(chat_id):
    uid=user_id(); conn=get_db(); chat=chat_row(conn,chat_id)
    if not chat: conn.close(); return jsonify({"ok":False,"error":"گفتگو پیدا نشد."}),404
    if chat["kind"]=="private": conn.close(); return jsonify({"ok":False,"error":"این گفتگوی خصوصی است."}),400
    if is_member(conn,chat_id,uid): conn.close(); return jsonify({"ok":True,"status":"member"})
    if chat["is_public"]:
        conn.execute("INSERT INTO chat_members(chat_id,user_id,role,joined_at) VALUES(?,?,?,?)",(chat_id,uid,"member",now_iso())); conn.commit(); conn.close(); return jsonify({"ok":True,"status":"member"})
    conn.execute("INSERT OR IGNORE INTO join_requests(chat_id,user_id,created_at) VALUES(?,?,?)",(chat_id,uid,now_iso())); conn.commit(); conn.close(); return jsonify({"ok":True,"status":"pending"})


@app.get("/api/chats/<int:chat_id>/members")
@login_required
def get_members(chat_id):
    conn=get_db(); uid=user_id()
    if not is_member(conn,chat_id,uid): conn.close(); return jsonify({"ok":False,"error":"دسترسی ندارید."}),403
    rows=conn.execute("SELECT u.id,u.username,u.display_name,u.avatar,u.bio,u.is_bot,cm.role,cm.joined_at FROM users u JOIN chat_members cm ON cm.user_id=u.id WHERE cm.chat_id=? ORDER BY CASE cm.role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,u.display_name",(chat_id,)).fetchall(); conn.close()
    return jsonify({"ok":True,"members":[dict(r) for r in rows]})


@app.post("/api/chats/<int:chat_id>/members")
@login_required
@csrf_required
def add_member(chat_id):
    data=request.get_json(silent=True) or {}; username=(data.get("username") or "").strip().lower().lstrip("@"); uid=user_id(); conn=get_db(); chat=chat_row(conn,chat_id)
    if not chat or not can_manage_chat(conn,chat,uid): conn.close(); return jsonify({"ok":False,"error":"اجازه مدیریت اعضا را ندارید."}),403
    peer=conn.execute("SELECT id FROM users WHERE username=?",(username,)).fetchone()
    if not peer: conn.close(); return jsonify({"ok":False,"error":"کاربر پیدا نشد."}),404
    ts=now_iso(); conn.execute("INSERT OR IGNORE INTO chat_members(chat_id,user_id,role,joined_at) VALUES(?,?,?,?)",(chat_id,peer["id"],"member",ts)); conn.execute("DELETE FROM join_requests WHERE chat_id=? AND user_id=?",(chat_id,peer["id"])); conn.commit(); conn.close(); return jsonify({"ok":True})


@app.patch("/api/chats/<int:chat_id>/members/<int:target_id>")
@login_required
@csrf_required
def set_member_role(chat_id,target_id):
    role=(request.get_json(silent=True) or {}).get("role"); uid=user_id()
    if role not in ("member","admin"): return jsonify({"ok":False,"error":"نقش نامعتبر است."}),400
    conn=get_db(); me=is_member(conn,chat_id,uid); target=is_member(conn,chat_id,target_id)
    if not me or me["role"]!="owner" or not target: conn.close(); return jsonify({"ok":False,"error":"فقط مالک می‌تواند نقش را تغییر دهد."}),403
    conn.execute("UPDATE chat_members SET role=? WHERE chat_id=? AND user_id=?",(role,chat_id,target_id)); conn.commit(); conn.close(); return jsonify({"ok":True})


@app.delete("/api/chats/<int:chat_id>/members/<int:target_id>")
@login_required
@csrf_required
def remove_member(chat_id,target_id):
    uid=user_id(); conn=get_db(); me=is_member(conn,chat_id,uid); target=is_member(conn,chat_id,target_id)
    if not me or not target: conn.close(); return jsonify({"ok":False,"error":"عضو پیدا نشد."}),404
    if target["role"]=="owner" or me["role"] not in ("owner","admin") or (target["role"]=="admin" and me["role"]!="owner"):
        conn.close(); return jsonify({"ok":False,"error":"اجازه حذف این عضو را ندارید."}),403
    conn.execute("DELETE FROM chat_members WHERE chat_id=? AND user_id=?",(chat_id,target_id)); conn.commit(); conn.close(); return jsonify({"ok":True})


@app.get("/api/chats/<int:chat_id>/messages")
@login_required
def get_messages(chat_id):
    uid=user_id(); limit=min(max(int(request.args.get("limit",80)),1),150); before=int(request.args.get("before",0) or 0); after=int(request.args.get("after",0) or 0); conn=get_db()
    if not is_member(conn,chat_id,uid): conn.close(); return jsonify({"ok":False,"error":"دسترسی ندارید."}),403
    base="SELECT m.id,m.chat_id,m.sender_id,m.body,m.attachment,m.attachment_name,m.attachment_mime,m.created_at,m.edited_at,m.deleted_at,m.reply_to_id,u.username,u.display_name,u.avatar FROM messages m JOIN users u ON u.id=m.sender_id WHERE m.chat_id=?"
    if after: rows=conn.execute(base+" AND m.id>? ORDER BY m.id ASC LIMIT ?",(chat_id,after,limit)).fetchall()
    elif before: rows=list(reversed(conn.execute(base+" AND m.id<? ORDER BY m.id DESC LIMIT ?",(chat_id,before,limit)).fetchall()))
    else: rows=list(reversed(conn.execute(base+" ORDER BY m.id DESC LIMIT ?",(chat_id,limit)).fetchall()))
    data=[message_json(conn,r,uid) for r in rows]; conn.close(); return jsonify({"ok":True,"messages":data})


@app.post("/api/chats/<int:chat_id>/messages")
@login_required
@csrf_required
def send_message(chat_id):
    uid=user_id(); body=(request.form.get("body") or "").strip()[:8000]
    try: reply_id=int(request.form.get("reply_to_id") or 0)
    except ValueError: reply_id=0
    conn=get_db(); member=is_member(conn,chat_id,uid); chat=chat_row(conn,chat_id)
    if not member or not chat: conn.close(); return jsonify({"ok":False,"error":"دسترسی ندارید."}),403
    if chat["kind"]=="channel" and member["role"] not in ("owner","admin"): conn.close(); return jsonify({"ok":False,"error":"فقط مدیران کانال می‌توانند پیام بفرستند."}),403
    peer=other_user(conn,chat_id,uid) if chat["kind"]=="private" else None
    if peer and not peer["is_bot"] and is_blocked(conn,uid,peer["id"]): conn.close(); return jsonify({"ok":False,"error":"این کاربر مسدود است."}),403
    attachment=attachment_name=attachment_mime=""; f=request.files.get("file")
    if f and f.filename:
        ext=f.filename.rsplit(".",1)[-1].lower() if "." in f.filename else ""
        if ext not in ALLOWED_EXT: conn.close(); return jsonify({"ok":False,"error":"نوع فایل مجاز نیست."}),400
        safe=secure_filename(f.filename) or "file"; unique=f"{int(datetime.now().timestamp())}_{secrets.token_hex(5)}_{safe}"; f.save(UPLOAD_DIR/unique); attachment=f"/uploads/{unique}"; attachment_name=f.filename[:180]; attachment_mime=f.mimetype or "application/octet-stream"
    if not body and not attachment: conn.close(); return jsonify({"ok":False,"error":"پیام خالی است."}),400
    if reply_id and not conn.execute("SELECT 1 FROM messages WHERE id=? AND chat_id=?",(reply_id,chat_id)).fetchone(): reply_id=0
    ts=now_iso(); cur=conn.execute("INSERT INTO messages(chat_id,sender_id,body,attachment,attachment_name,attachment_mime,created_at,reply_to_id) VALUES(?,?,?,?,?,?,?,?)",(chat_id,uid,body,attachment,attachment_name,attachment_mime,ts,reply_id or None)); mid=cur.lastrowid
    conn.execute("UPDATE chats SET updated_at=? WHERE id=?",(ts,chat_id)); conn.execute("UPDATE users SET last_seen=? WHERE id=?",(ts,uid)); conn.execute("INSERT OR REPLACE INTO read_state(chat_id,user_id,last_read_message_id,updated_at) VALUES(?,?,?,?)",(chat_id,uid,mid,ts))
    notify_chat_members(conn,chat_id,uid,"message",mid,{"preview":body[:100] if body else attachment_name}); notify_bot_update(conn,chat_id,uid,mid,body,attachment_name)
    conn.commit(); row=conn.execute("SELECT m.*,u.username,u.display_name,u.avatar FROM messages m JOIN users u ON u.id=m.sender_id WHERE m.id=?",(mid,)).fetchone(); data=message_json(conn,row,uid)
    recipients=[r["user_id"] for r in conn.execute("SELECT user_id FROM chat_members WHERE chat_id=? AND user_id<>?",(chat_id,uid)).fetchall()]
    conn.close()
    ws_broadcast_users(recipients,{"type":"message.new","chat_id":chat_id,"message":data})
    return jsonify({"ok":True,"message":data})


@app.patch("/api/messages/<int:message_id>")
@login_required
@csrf_required
def edit_message(message_id):
    uid=user_id(); body=(request.get_json(silent=True) or {}).get("body"); body=(body or "").strip()[:8000]
    if not body: return jsonify({"ok":False,"error":"متن خالی است."}),400
    conn=get_db(); row=conn.execute("SELECT * FROM messages WHERE id=?",(message_id,)).fetchone()
    if not row or row["sender_id"]!=uid or row["deleted_at"]: conn.close(); return jsonify({"ok":False,"error":"امکان ویرایش این پیام وجود ندارد."}),403
    ts=now_iso(); conn.execute("UPDATE messages SET body=?,edited_at=? WHERE id=?",(body,ts,message_id)); conn.execute("UPDATE chats SET updated_at=? WHERE id=?",(ts,row["chat_id"])); conn.commit(); new=conn.execute("SELECT m.*,u.username,u.display_name,u.avatar FROM messages m JOIN users u ON u.id=m.sender_id WHERE m.id=?",(message_id,)).fetchone(); data=message_json(conn,new,uid); recipients=[r["user_id"] for r in conn.execute("SELECT user_id FROM chat_members WHERE chat_id=? AND user_id<>?",(row["chat_id"],uid)).fetchall()]; conn.close(); ws_broadcast_users(recipients,{"type":"message.update","chat_id":row["chat_id"],"message":data}); return jsonify({"ok":True,"message":data})


@app.delete("/api/messages/<int:message_id>")
@login_required
@csrf_required
def delete_message(message_id):
    uid=user_id(); conn=get_db(); row=conn.execute("SELECT * FROM messages WHERE id=?",(message_id,)).fetchone()
    if not row: conn.close(); return jsonify({"ok":False,"error":"پیام پیدا نشد."}),404
    member=is_member(conn,row["chat_id"],uid); allowed=row["sender_id"]==uid or (member and member["role"] in ("owner","admin"))
    if not allowed: conn.close(); return jsonify({"ok":False,"error":"اجازه حذف این پیام را ندارید."}),403
    ts=now_iso(); conn.execute("UPDATE messages SET body='',attachment='',attachment_name='',attachment_mime='',deleted_at=? WHERE id=?",(ts,message_id)); conn.commit(); recipients=[r["user_id"] for r in conn.execute("SELECT user_id FROM chat_members WHERE chat_id=? AND user_id<>?",(row["chat_id"],uid)).fetchall()]; conn.close(); ws_broadcast_users(recipients,{"type":"message.delete","chat_id":row["chat_id"],"message_id":message_id}); return jsonify({"ok":True})


@app.post("/api/messages/<int:message_id>/react")
@login_required
@csrf_required
def react_message(message_id):
    reaction=(request.get_json(silent=True) or {}).get("reaction")
    if reaction not in REACTIONS: return jsonify({"ok":False,"error":"واکنش نامعتبر است."}),400
    uid=user_id(); conn=get_db(); row=conn.execute("SELECT chat_id FROM messages WHERE id=?",(message_id,)).fetchone()
    if not row or not is_member(conn,row["chat_id"],uid): conn.close(); return jsonify({"ok":False,"error":"دسترسی ندارید."}),403
    exists=conn.execute("SELECT 1 FROM message_reactions WHERE message_id=? AND user_id=? AND reaction=?",(message_id,uid,reaction)).fetchone()
    if exists: conn.execute("DELETE FROM message_reactions WHERE message_id=? AND user_id=? AND reaction=?",(message_id,uid,reaction))
    else: conn.execute("DELETE FROM message_reactions WHERE message_id=? AND user_id=?",(message_id,uid)); conn.execute("INSERT INTO message_reactions(message_id,user_id,reaction,created_at) VALUES(?,?,?,?)",(message_id,uid,reaction,now_iso()))
    conn.commit(); recipients=[r["user_id"] for r in conn.execute("SELECT user_id FROM chat_members WHERE chat_id=? AND user_id<>?",(row["chat_id"],uid)).fetchall()]; conn.close(); ws_broadcast_users(recipients,{"type":"message.reaction","chat_id":row["chat_id"],"message_id":message_id}); return jsonify({"ok":True})


@app.post("/api/chats/<int:chat_id>/read")
@login_required
@csrf_required
def mark_read(chat_id):
    uid=user_id(); last=int((request.get_json(silent=True) or {}).get("message_id") or 0); conn=get_db()
    if not is_member(conn,chat_id,uid): conn.close(); return jsonify({"ok":False,"error":"دسترسی ندارید."}),403
    conn.execute("INSERT OR REPLACE INTO read_state(chat_id,user_id,last_read_message_id,updated_at) VALUES(?,?,?,?)",(chat_id,uid,last,now_iso())); conn.execute("UPDATE notifications SET is_read=1 WHERE user_id=? AND chat_id=?",(uid,chat_id)); conn.commit(); conn.close(); return jsonify({"ok":True})


@app.post("/api/chats/<int:chat_id>/pin/<int:message_id>")
@login_required
@csrf_required
def pin_message(chat_id,message_id):
    uid=user_id(); conn=get_db(); chat=chat_row(conn,chat_id)
    if not chat or not can_manage_chat(conn,chat,uid): conn.close(); return jsonify({"ok":False,"error":"اجازه سنجاق ندارید."}),403
    if not conn.execute("SELECT 1 FROM messages WHERE id=? AND chat_id=?",(message_id,chat_id)).fetchone(): conn.close(); return jsonify({"ok":False,"error":"پیام پیدا نشد."}),404
    conn.execute("INSERT OR REPLACE INTO pinned_messages(chat_id,message_id,pinned_by,pinned_at) VALUES(?,?,?,?)",(chat_id,message_id,uid,now_iso())); conn.commit(); conn.close(); return jsonify({"ok":True})


@app.delete("/api/chats/<int:chat_id>/pin/<int:message_id>")
@login_required
@csrf_required
def unpin_message(chat_id,message_id):
    uid=user_id(); conn=get_db(); chat=chat_row(conn,chat_id)
    if not chat or not can_manage_chat(conn,chat,uid): conn.close(); return jsonify({"ok":False,"error":"اجازه لغو سنجاق ندارید."}),403
    conn.execute("DELETE FROM pinned_messages WHERE chat_id=? AND message_id=?",(chat_id,message_id)); conn.commit(); conn.close(); return jsonify({"ok":True})


@app.get("/api/search/messages")
@login_required
def search_messages():
    q=(request.args.get("q") or "").strip(); uid=user_id()
    if len(q)<2: return jsonify({"ok":True,"messages":[]})
    conn=get_db(); rows=conn.execute("""SELECT m.id,m.chat_id,m.body,m.created_at,m.sender_id,c.title,c.kind,u.display_name FROM messages m JOIN chat_members cm ON cm.chat_id=m.chat_id AND cm.user_id=? JOIN chats c ON c.id=m.chat_id JOIN users u ON u.id=m.sender_id WHERE m.deleted_at='' AND m.body LIKE ? ORDER BY m.id DESC LIMIT 150""",(uid,f"%{q}%")).fetchall(); conn.close(); return jsonify({"ok":True,"messages":[dict(r) for r in rows]})


@app.get("/api/discover")
@login_required
def discover():
    q=(request.args.get("q") or "").strip().lower(); conn=get_db(); rows=conn.execute("SELECT id,title,description,username,is_public,kind FROM chats WHERE kind='channel' AND is_public=1 AND (LOWER(title) LIKE ? OR LOWER(username) LIKE ? OR LOWER(description) LIKE ?) ORDER BY title LIMIT 80",(f"%{q}%",f"%{q}%",f"%{q}%")).fetchall(); conn.close(); return jsonify({"ok":True,"channels":[dict(r) for r in rows]})


@app.get("/api/summary")
@login_required
def summary():
    uid=user_id(); conn=get_db(); stats={
        "chats":conn.execute("SELECT COUNT(*) c FROM chat_members WHERE user_id=?",(uid,)).fetchone()["c"],
        "groups":conn.execute("SELECT COUNT(*) c FROM chat_members cm JOIN chats c ON c.id=cm.chat_id WHERE cm.user_id=? AND c.kind='group'",(uid,)).fetchone()["c"],
        "channels":conn.execute("SELECT COUNT(*) c FROM chat_members cm JOIN chats c ON c.id=cm.chat_id WHERE cm.user_id=? AND c.kind='channel'",(uid,)).fetchone()["c"],
        "contacts":conn.execute("SELECT COUNT(*) c FROM contacts WHERE owner_id=?",(uid,)).fetchone()["c"],
        "unread":conn.execute("SELECT COUNT(*) c FROM notifications WHERE user_id=? AND is_read=0",(uid,)).fetchone()["c"],
        "bots":conn.execute("SELECT COUNT(*) c FROM bots WHERE owner_id=?",(uid,)).fetchone()["c"],
    }; conn.close(); return jsonify({"ok":True,"stats":stats})

# ----------------------------- BotFather / Bot API -----------------------------

@app.get("/api/bots")
@login_required
def list_bots():
    uid=user_id(); conn=get_db(); rows=conn.execute("SELECT b.id,b.user_id,b.description,b.enabled,b.created_at,u.username,u.display_name FROM bots b JOIN users u ON u.id=b.user_id WHERE b.owner_id=? ORDER BY b.id DESC",(uid,)).fetchall()
    data=[]
    for r in rows:
        commands=conn.execute("SELECT command,description FROM bot_commands WHERE bot_id=? ORDER BY command",(r["id"],)).fetchall(); data.append({**dict(r),"commands":[dict(c) for c in commands]})
    conn.close(); return jsonify({"ok":True,"bots":data})


@app.post("/api/bots")
@login_required
@csrf_required
def create_bot():
    uid=user_id(); data=request.get_json(silent=True) or {}; name=(data.get("name") or "").strip()[:64]; username=(data.get("username") or "").strip().lower().lstrip("@").replace(" ","")[:32]; desc=(data.get("description") or "").strip()[:200]
    if len(name)<2 or len(username)<3 or not username.endswith("bot") or not username.replace("_","").isalnum(): return jsonify({"ok":False,"error":"نام ربات یا username نامعتبر است."}),400
    conn=get_db()
    if conn.execute("SELECT 1 FROM users WHERE username=?",(username,)).fetchone(): conn.close(); return jsonify({"ok":False,"error":"این username قبلاً استفاده شده است."}),409
    ts=now_iso(); token=generate_bot_token(username)
    cur=conn.execute("INSERT INTO users(username,password_hash,display_name,bio,created_at,last_seen,is_bot,bot_owner_id) VALUES(?,?,?,?,?,?,1,?)",(username,generate_password_hash(secrets.token_urlsafe(32)),name,"ربات Web IV",ts,ts,uid)); bot_uid=cur.lastrowid
    cur=conn.execute("INSERT INTO bots(user_id,owner_id,token_hash,token_prefix,description,created_at) VALUES(?,?,?,?,?,?)",(bot_uid,uid,hash_token(token),token[:10],desc,ts)); bot_id=cur.lastrowid
    conn.execute("INSERT INTO bot_commands(bot_id,command,description) VALUES(?,?,?)",(bot_id,"help","راهنمای ربات")); conn.commit(); conn.close()
    return jsonify({"ok":True,"bot":{"id":bot_id,"username":username,"name":name,"description":desc},"token":token})


@app.patch("/api/bots/<int:bot_id>")
@login_required
@csrf_required
def update_bot(bot_id):
    uid=user_id(); data=request.get_json(silent=True) or {}; conn=get_db(); bot=bot_owner_ok(conn,bot_id,uid)
    if not bot: conn.close(); return jsonify({"ok":False,"error":"ربات پیدا نشد."}),404
    changes=[]; values=[]
    if "description" in data: changes.append("description=?"); values.append((data.get("description") or "")[:200])
    if "enabled" in data: changes.append("enabled=?"); values.append(1 if data.get("enabled") else 0)
    if changes: values.append(bot_id); conn.execute("UPDATE bots SET "+",".join(changes)+" WHERE id=?",values); conn.commit()
    conn.close(); return jsonify({"ok":True})


@app.delete("/api/bots/<int:bot_id>")
@login_required
@csrf_required
def delete_bot(bot_id):
    uid=user_id(); conn=get_db(); bot=bot_owner_ok(conn,bot_id,uid)
    if not bot: conn.close(); return jsonify({"ok":False,"error":"ربات پیدا نشد."}),404
    conn.execute("DELETE FROM bots WHERE id=?",(bot_id,)); conn.execute("DELETE FROM users WHERE id=?",(bot["user_id"],)); conn.commit(); conn.close(); return jsonify({"ok":True})


@app.post("/api/bots/<int:bot_id>/regenerate")
@login_required
@csrf_required
def regenerate_bot(bot_id):
    uid=user_id(); conn=get_db(); bot=bot_owner_ok(conn,bot_id,uid)
    if not bot: conn.close(); return jsonify({"ok":False,"error":"ربات پیدا نشد."}),404
    token=generate_bot_token("bot"); conn.execute("UPDATE bots SET token_hash=?,token_prefix=? WHERE id=?",(hash_token(token),token[:10],bot_id)); conn.commit(); conn.close(); return jsonify({"ok":True,"token":token})


@app.get("/api/bots/<int:bot_id>/bans")
@login_required
def bot_bans(bot_id):
    uid=user_id(); conn=get_db(); bot=bot_owner_ok(conn,bot_id,uid)
    if not bot: conn.close(); return jsonify({"ok":False,"error":"ربات پیدا نشد."}),404
    rows=conn.execute("SELECT u.id,u.username,u.display_name,bb.created_at,bb.reason FROM bot_bans bb JOIN users u ON u.id=bb.user_id WHERE bb.bot_id=? ORDER BY bb.created_at DESC",(bot_id,)).fetchall(); conn.close(); return jsonify({"ok":True,"bans":[dict(r) for r in rows]})


@app.post("/api/bots/<int:bot_id>/ban")
@login_required
@csrf_required
def bot_ban(bot_id):
    uid=user_id(); data=request.get_json(silent=True) or {}; username=(data.get("username") or "").strip().lower().lstrip("@"); conn=get_db(); bot=bot_owner_ok(conn,bot_id,uid); target=conn.execute("SELECT id FROM users WHERE username=?",(username,)).fetchone() if bot else None
    if not bot: conn.close(); return jsonify({"ok":False,"error":"ربات پیدا نشد."}),404
    if not target: conn.close(); return jsonify({"ok":False,"error":"کاربر پیدا نشد."}),404
    conn.execute("INSERT OR REPLACE INTO bot_bans(bot_id,user_id,created_at,reason) VALUES(?,?,?,?)",(bot_id,target["id"],now_iso(),"owner")); conn.commit(); conn.close(); return jsonify({"ok":True})


@app.post("/api/bots/<int:bot_id>/unban")
@login_required
@csrf_required
def bot_unban(bot_id):
    uid=user_id(); data=request.get_json(silent=True) or {}; username=(data.get("username") or "").strip().lower().lstrip("@"); conn=get_db(); bot=bot_owner_ok(conn,bot_id,uid); target=conn.execute("SELECT id FROM users WHERE username=?",(username,)).fetchone() if bot else None
    if not bot: conn.close(); return jsonify({"ok":False,"error":"ربات پیدا نشد."}),404
    if not target: conn.close(); return jsonify({"ok":False,"error":"کاربر پیدا نشد."}),404
    conn.execute("DELETE FROM bot_bans WHERE bot_id=? AND user_id=?",(bot_id,target["id"])); conn.commit(); conn.close(); return jsonify({"ok":True})


@app.post("/api/bots/<int:bot_id>/commands")
@login_required
@csrf_required
def bot_set_commands(bot_id):
    uid=user_id(); items=(request.get_json(silent=True) or {}).get("commands") or []; conn=get_db(); bot=bot_owner_ok(conn,bot_id,uid)
    if not bot: conn.close(); return jsonify({"ok":False,"error":"ربات پیدا نشد."}),404
    conn.execute("DELETE FROM bot_commands WHERE bot_id=?",(bot_id,))
    for item in items[:50]:
        command=str(item.get("command") or "").strip().lstrip("/").lower(); description=str(item.get("description") or "").strip()[:120]
        if command and command.replace("_","").isalnum(): conn.execute("INSERT OR REPLACE INTO bot_commands(bot_id,command,description) VALUES(?,?,?)",(bot_id,command,description))
    conn.commit(); conn.close(); return jsonify({"ok":True})


@app.post("/api/botfather/command")
@login_required
@csrf_required
def botfather_command():
    return jsonify({"ok":True,"reply":botfather_reply(user_id(),(request.get_json(silent=True) or {}).get("text",""))})


@app.get("/bot/<token>/getMe")
def bot_get_me(token):
    bot=validate_bot_token(token)
    if not bot: return jsonify({"ok":False,"description":"Invalid bot token"}),401
    conn=get_db(); u=conn.execute("SELECT id,username,display_name,bio FROM users WHERE id=?",(bot["user_id"],)).fetchone(); conn.close()
    return jsonify({"ok":True,"result":{"id":u["id"],"is_bot":True,"first_name":u["display_name"],"username":u["username"],"description":bot["description"]}})


@app.post("/bot/<token>/sendMessage")
def bot_send_message(token):
    bot=validate_bot_token(token)
    if not bot: return jsonify({"ok":False,"description":"Invalid bot token"}),401
    data=request.get_json(silent=True) or {}; chat_id=int(data.get("chat_id") or 0); text=str(data.get("text") or "").strip()[:8000]
    if not chat_id or not text: return jsonify({"ok":False,"description":"chat_id and text are required"}),400
    conn=get_db(); member=is_member(conn,chat_id,bot["user_id"]); chat=chat_row(conn,chat_id)
    if not member or not chat: conn.close(); return jsonify({"ok":False,"description":"Bot is not a member of this chat"}),403
    if conn.execute("SELECT 1 FROM bot_chat_bans WHERE bot_id=? AND chat_id=? AND user_id=?",(bot["id"],chat_id,bot["user_id"])).fetchone(): conn.close(); return jsonify({"ok":False,"description":"Bot is banned in this chat"}),403
    ts=now_iso(); cur=conn.execute("INSERT INTO messages(chat_id,sender_id,body,created_at) VALUES(?,?,?,?)",(chat_id,bot["user_id"],text,ts)); mid=cur.lastrowid; conn.execute("UPDATE chats SET updated_at=? WHERE id=?",(ts,chat_id)); notify_chat_members(conn,chat_id,bot["user_id"],"message",mid,{"preview":text[:100]}); conn.commit(); row=conn.execute("SELECT m.*,u.username,u.display_name,u.avatar FROM messages m JOIN users u ON u.id=m.sender_id WHERE m.id=?",(mid,)).fetchone(); data_out=message_json(conn,row,bot["user_id"]); recipients=[r["user_id"] for r in conn.execute("SELECT user_id FROM chat_members WHERE chat_id=? AND user_id<>?",(chat_id,bot["user_id"])).fetchall()]; conn.close(); ws_broadcast_users(recipients,{"type":"message.new","chat_id":chat_id,"message":data_out}); return jsonify({"ok":True,"result":{"message_id":mid,"chat":{"id":chat_id},"text":text}})


@app.get("/bot/<token>/getUpdates")
def bot_get_updates(token):
    bot=validate_bot_token(token)
    if not bot: return jsonify({"ok":False,"description":"Invalid bot token"}),401
    offset=int(request.args.get("offset",0) or 0); limit=min(max(int(request.args.get("limit",100)),1),100); conn=get_db(); rows=conn.execute("SELECT id,update_json FROM bot_updates WHERE bot_id=? AND id>? ORDER BY id ASC LIMIT ?",(bot["id"],offset,limit)).fetchall(); updates=[]
    for r in rows:
        payload=json.loads(r["update_json"]); payload["update_id"]=r["id"]; updates.append(payload)
    conn.close(); return jsonify({"ok":True,"result":updates})


@app.post("/bot/<token>/banChatMember")
def bot_ban_chat_member(token):
    bot=validate_bot_token(token)
    if not bot: return jsonify({"ok":False,"description":"Invalid bot token"}),401
    data=request.get_json(silent=True) or {}; chat_id=int(data.get("chat_id") or 0); target_id=int(data.get("user_id") or 0); conn=get_db()
    if not bot_is_chat_admin(conn,bot["user_id"],chat_id): conn.close(); return jsonify({"ok":False,"description":"Bot must be an admin"}),403
    target=is_member(conn,chat_id,target_id)
    if not target: conn.close(); return jsonify({"ok":False,"description":"User is not a member"}),404
    if target["role"]=="owner": conn.close(); return jsonify({"ok":False,"description":"Cannot ban owner"}),400
    conn.execute("DELETE FROM chat_members WHERE chat_id=? AND user_id=?",(chat_id,target_id)); conn.execute("INSERT OR REPLACE INTO bot_chat_bans(bot_id,chat_id,user_id,created_at,reason) VALUES(?,?,?,?,?)",(bot["id"],chat_id,target_id,now_iso(),"bot api")); conn.commit(); conn.close(); return jsonify({"ok":True,"result":True})


@app.post("/bot/<token>/unbanChatMember")
def bot_unban_chat_member(token):
    bot=validate_bot_token(token)
    if not bot: return jsonify({"ok":False,"description":"Invalid bot token"}),401
    data=request.get_json(silent=True) or {}; chat_id=int(data.get("chat_id") or 0); target_id=int(data.get("user_id") or 0); conn=get_db()
    if not bot_is_chat_admin(conn,bot["user_id"],chat_id): conn.close(); return jsonify({"ok":False,"description":"Bot must be an admin"}),403
    conn.execute("DELETE FROM bot_chat_bans WHERE bot_id=? AND chat_id=? AND user_id=?",(bot["id"],chat_id,target_id)); conn.commit(); conn.close(); return jsonify({"ok":True,"result":True})


# ----------------------------- WebRTC signaling -----------------------------

@app.get("/api/calls/config")
@login_required
def call_config():
    return jsonify({"ok":True,"ice_servers":[{"urls":STUN_URL}]})


@app.post("/api/calls/signal")
@login_required
@csrf_required
def call_signal():
    uid=user_id(); data=request.get_json(silent=True) or {}; room_id=str(data.get("room_id") or "")[:120]; recipient_id=int(data.get("recipient_id") or 0); kind=str(data.get("type") or "")[:32]; payload=data.get("payload") or {}
    if not room_id or not recipient_id or kind not in ("offer","answer","candidate","hangup","busy"): return jsonify({"ok":False,"error":"سیگنال نامعتبر است."}),400
    conn=get_db();
    if not conn.execute("SELECT 1 FROM users WHERE id=?",(recipient_id,)).fetchone(): conn.close(); return jsonify({"ok":False,"error":"کاربر مقصد پیدا نشد."}),404
    conn.execute("INSERT INTO rtc_signals(room_id,sender_id,recipient_id,type,payload,created_at) VALUES(?,?,?,?,?,?)",(room_id,uid,recipient_id,kind,json.dumps(payload,ensure_ascii=False),now_iso())); conn.commit(); conn.close(); ws_send_to_user(recipient_id,{"type":"call.signal","room_id":room_id,"sender_id":uid,"signal_type":kind,"payload":payload}); return jsonify({"ok":True})


@app.get("/api/calls/signals")
@login_required
def call_signals():
    uid=user_id(); after=int(request.args.get("after",0) or 0); conn=get_db(); rows=conn.execute("SELECT id,room_id,sender_id,type,payload,created_at FROM rtc_signals WHERE recipient_id=? AND id>? ORDER BY id ASC LIMIT 60",(uid,after)).fetchall(); out=[]
    for r in rows:
        out.append({"id":r["id"],"room_id":r["room_id"],"sender_id":r["sender_id"],"type":r["type"],"payload":json.loads(r["payload"])})
    conn.close(); return jsonify({"ok":True,"signals":out})


@app.post("/api/calls/end")
@login_required
@csrf_required
def call_end():
    uid=user_id(); data=request.get_json(silent=True) or {}; room_id=str(data.get("room_id") or "")[:120]; recipient_id=int(data.get("recipient_id") or 0)
    conn=get_db(); conn.execute("DELETE FROM rtc_signals WHERE room_id=? AND (sender_id=? OR recipient_id=?)",(room_id,uid,uid)); conn.commit(); conn.close();
    if recipient_id: ws_send_to_user(recipient_id,{"type":"call.ended","room_id":room_id,"sender_id":uid})
    return jsonify({"ok":True})


@app.post("/api/botfather/ban")
@login_required
@csrf_required
def botfather_ban_alias():
    # API-compatible convenience alias for the UI; uses selected bot in session.
    bot_id=session.get("botfather_bot_id"); data=request.get_json(silent=True) or {}; username=data.get("username") or ""; return jsonify({"ok":True,"reply":botfather_reply(user_id(),f"/ban {username}")}) if bot_id else (jsonify({"ok":False,"error":"ابتدا ربات را انتخاب کن."}),400)


if Sock is not None:
    sock = Sock(app)

    @sock.route("/ws")
    def websocket(ws):
        uid=session.get("user_id")
        if not uid:
            try: ws.close()
            except Exception: pass
            return
        with WS_LOCK:
            WS_CLIENTS.setdefault(uid,[]).append(ws)
        try:
            while True:
                msg=ws.receive()
                if msg is None: break
                try:
                    data=json.loads(msg)
                except Exception:
                    data={}
                if data.get("type")=="ping":
                    ws.send(json.dumps({"type":"pong","ts":now_iso()}))
        except Exception:
            pass
        finally:
            with WS_LOCK:
                if uid in WS_CLIENTS:
                    WS_CLIENTS[uid].remove(ws) if ws in WS_CLIENTS[uid] else None
                    if not WS_CLIENTS[uid]: WS_CLIENTS.pop(uid,None)


# ============================== WEB IV v4 API ==============================

def v4_ensure_user_state(conn, uid):
    ts = now_iso()
    conn.execute("INSERT OR IGNORE INTO user_settings(user_id,language,theme_mode) VALUES(?,?,?)", (uid, 'fa', 'system'))
    conn.execute("INSERT OR IGNORE INTO wallets(user_id,stars,boosts,updated_at) VALUES(?,?,?,?)", (uid, 0, 0, ts))
    conn.commit()


def v4_require_chat_member(conn, chat_id, uid):
    return conn.execute("SELECT * FROM chat_members WHERE chat_id=? AND user_id=?", (chat_id, uid)).fetchone()


def v4_require_manager(conn, chat_id, uid):
    chat = conn.execute("SELECT * FROM chats WHERE id=?", (chat_id,)).fetchone()
    member = v4_require_chat_member(conn, chat_id, uid)
    if not chat or not member or member['role'] not in ('owner', 'admin'):
        return None, None
    return chat, member


def v4_log(conn, chat_id, actor_id, action, target_id=0, payload=None):
    conn.execute("INSERT INTO admin_actions(chat_id,actor_id,action,target_id,payload,created_at) VALUES(?,?,?,?,?,?)",
                 (chat_id, actor_id, action, target_id or 0, json.dumps(payload or {}, ensure_ascii=False), now_iso()))


def v4_dispatch_scheduled(conn):
    now = now_iso()
    rows = conn.execute("SELECT * FROM scheduled_messages WHERE send_at<=? ORDER BY id ASC LIMIT 30", (now,)).fetchall()
    for r in rows:
        cur = conn.execute("INSERT INTO messages(chat_id,sender_id,body,attachment,attachment_name,attachment_mime,created_at,reply_to_id) VALUES(?,?,?,?,?,?,?,?)",
                           (r['chat_id'], r['sender_id'], r['body'], r['attachment'], r['attachment_name'], r['attachment_mime'], now, r['reply_to_id']))
        mid = cur.lastrowid
        conn.execute("UPDATE chats SET updated_at=? WHERE id=?", (now, r['chat_id']))
        notify_chat_members(conn, r['chat_id'], r['sender_id'], 'scheduled_message', mid, {'preview': (r['body'] or '')[:120]})
        if r['repeat_seconds'] and int(r['repeat_seconds']) > 0:
            try:
                nxt = datetime.fromisoformat(r['send_at']) + timedelta(seconds=int(r['repeat_seconds']))
                conn.execute("UPDATE scheduled_messages SET send_at=? WHERE id=?", (nxt.isoformat(timespec='seconds'), r['id']))
            except Exception:
                conn.execute("DELETE FROM scheduled_messages WHERE id=?", (r['id'],))
        else:
            conn.execute("DELETE FROM scheduled_messages WHERE id=?", (r['id'],))
    if rows:
        conn.commit()


@app.before_request
def _v4_before_request():
    if request.path.startswith('/api/') or request.path.startswith('/bot/'):
        try:
            conn = get_db(); v4_dispatch_scheduled(conn); conn.close()
        except Exception:
            pass


@app.get('/api/v4/features')
def v4_features():
    return jsonify({'ok': True, 'version': '4.1.0-fullstack', 'features': [
        'private_chats','groups','channels','topics','folders','stories','polls','reactions','replies',
        'forwarding','scheduled_messages','repeating_messages','drafts','saved_messages','notifications',
        'contacts','block','admin_log','communities','stars','gifts','botfather','bot_api','mini_apps',
        'voice_calls','video_calls','websocket','pwa','multilanguage','rtl_ltr','rich_text','media_uploads'
    ]})


@app.get('/api/settings')
@login_required
def v4_get_settings():
    uid = user_id(); conn = get_db(); v4_ensure_user_state(conn, uid)
    u = conn.execute('SELECT * FROM user_settings WHERE user_id=?', (uid,)).fetchone()
    w = conn.execute('SELECT * FROM wallets WHERE user_id=?', (uid,)).fetchone(); conn.close()
    return jsonify({'ok': True, 'settings': dict(u), 'wallet': dict(w)})


@app.patch('/api/settings')
@login_required
@csrf_required
def v4_update_settings():
    uid = user_id(); data = request.get_json(silent=True) or {}; conn = get_db(); v4_ensure_user_state(conn, uid)
    fields = {
        'language': str(data.get('language') or 'fa')[:8], 'theme_mode': str(data.get('theme_mode') or 'system')[:16],
        'accent': str(data.get('accent') or 'blue')[:24], 'animation': 1 if data.get('animation', 1) else 0,
        'power_saving': 1 if data.get('power_saving', 0) else 0, 'autoplay': 1 if data.get('autoplay', 1) else 0,
        'auto_download': 1 if data.get('auto_download', 1) else 0, 'read_receipts': 1 if data.get('read_receipts', 1) else 0,
        'last_seen_privacy': str(data.get('last_seen_privacy') or 'everyone')[:32],
        'profile_photo_privacy': str(data.get('profile_photo_privacy') or 'everyone')[:32],
        'phone_privacy': str(data.get('phone_privacy') or 'nobody')[:32],
        'calls_privacy': str(data.get('calls_privacy') or 'everyone')[:32],
        'groups_privacy': str(data.get('groups_privacy') or 'everyone')[:32],
        'forwards_privacy': str(data.get('forwards_privacy') or 'everyone')[:32],
        'noforwards_default': 1 if data.get('noforwards_default', 0) else 0,
    }
    sets = ','.join(f'{k}=?' for k in fields); conn.execute(f'UPDATE user_settings SET {sets} WHERE user_id=?', tuple(fields.values())+(uid,))
    conn.execute('UPDATE users SET language=?,theme=?,read_receipts=? WHERE id=?', (fields['language'], fields['theme_mode'], fields['read_receipts'], uid))
    conn.commit(); row = conn.execute('SELECT * FROM user_settings WHERE user_id=?', (uid,)).fetchone(); conn.close()
    return jsonify({'ok': True, 'settings': dict(row)})


@app.get('/api/folders')
@login_required
def v4_list_folders():
    conn=get_db(); rows=conn.execute('SELECT * FROM chat_folders WHERE owner_id=? ORDER BY id', (user_id(),)).fetchall(); conn.close()
    return jsonify({'ok':True,'folders':[dict(r) for r in rows]})


@app.post('/api/folders')
@login_required
@csrf_required
def v4_create_folder():
    data=request.get_json(silent=True) or {}; title=(data.get('title') or 'پوشه جدید').strip()[:50]; uid=user_id(); ts=now_iso(); conn=get_db()
    cur=conn.execute('INSERT INTO chat_folders(owner_id,title,icon,include_private,include_groups,include_channels,include_bots,include_unread,exclude_muted,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
                     (uid,title,str(data.get('icon') or 'folder')[:20],1 if data.get('include_private',1) else 0,1 if data.get('include_groups',1) else 0,1 if data.get('include_channels',1) else 0,1 if data.get('include_bots',1) else 0,1 if data.get('include_unread',0) else 0,1 if data.get('exclude_muted',0) else 0,ts,ts))
    conn.commit(); r=conn.execute('SELECT * FROM chat_folders WHERE id=?',(cur.lastrowid,)).fetchone(); conn.close(); return jsonify({'ok':True,'folder':dict(r)})


@app.patch('/api/folders/<int:folder_id>')
@login_required
@csrf_required
def v4_update_folder(folder_id):
    uid=user_id(); data=request.get_json(silent=True) or {}; conn=get_db(); f=conn.execute('SELECT * FROM chat_folders WHERE id=? AND owner_id=?',(folder_id,uid)).fetchone()
    if not f: conn.close(); return jsonify({'ok':False,'error':'پوشه پیدا نشد.'}),404
    allowed=['title','icon','include_private','include_groups','include_channels','include_bots','include_unread','exclude_muted']
    vals={k:data[k] for k in allowed if k in data};
    if 'title' in vals: vals['title']=str(vals['title'])[:50]
    for k in allowed[2:]:
        if k in vals: vals[k]=1 if vals[k] else 0
    if vals:
        sets=','.join(f'{k}=?' for k in vals); conn.execute(f'UPDATE chat_folders SET {sets},updated_at=? WHERE id=?', tuple(vals.values())+(now_iso(),folder_id)); conn.commit()
    r=conn.execute('SELECT * FROM chat_folders WHERE id=?',(folder_id,)).fetchone(); conn.close(); return jsonify({'ok':True,'folder':dict(r)})


@app.delete('/api/folders/<int:folder_id>')
@login_required
@csrf_required
def v4_delete_folder(folder_id):
    conn=get_db(); cur=conn.execute('DELETE FROM chat_folders WHERE id=? AND owner_id=?',(folder_id,user_id())); conn.commit(); conn.close(); return jsonify({'ok':bool(cur.rowcount)})


@app.post('/api/folders/<int:folder_id>/chats/<int:chat_id>')
@login_required
@csrf_required
def v4_add_folder_chat(folder_id,chat_id):
    uid=user_id(); conn=get_db(); f=conn.execute('SELECT 1 FROM chat_folders WHERE id=? AND owner_id=?',(folder_id,uid)).fetchone(); m=v4_require_chat_member(conn,chat_id,uid)
    if not f or not m: conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    conn.execute('INSERT OR REPLACE INTO folder_chats(folder_id,chat_id,pinned) VALUES(?,?,?)',(folder_id,chat_id,1 if (request.get_json(silent=True) or {}).get('pinned') else 0)); conn.commit(); conn.close(); return jsonify({'ok':True})


@app.delete('/api/folders/<int:folder_id>/chats/<int:chat_id>')
@login_required
@csrf_required
def v4_remove_folder_chat(folder_id,chat_id):
    conn=get_db(); conn.execute('DELETE FROM folder_chats WHERE folder_id=? AND chat_id=?',(folder_id,chat_id)); conn.commit(); conn.close(); return jsonify({'ok':True})


@app.get('/api/chats/<int:chat_id>/settings')
@login_required
def v4_chat_settings(chat_id):
    uid=user_id(); conn=get_db();
    if not v4_require_chat_member(conn,chat_id,uid): conn.close(); return jsonify({'ok':False,'error':'عضو این گفتگو نیستید.'}),403
    conn.execute('INSERT OR IGNORE INTO chat_settings(chat_id,user_id) VALUES(?,?)',(chat_id,uid)); conn.commit(); r=conn.execute('SELECT * FROM chat_settings WHERE chat_id=? AND user_id=?',(chat_id,uid)).fetchone(); conn.close(); return jsonify({'ok':True,'settings':dict(r)})


@app.patch('/api/chats/<int:chat_id>/settings')
@login_required
@csrf_required
def v4_update_chat_settings(chat_id):
    uid=user_id(); data=request.get_json(silent=True) or {}; conn=get_db();
    if not v4_require_chat_member(conn,chat_id,uid): conn.close(); return jsonify({'ok':False,'error':'عضو این گفتگو نیستید.'}),403
    conn.execute('INSERT OR IGNORE INTO chat_settings(chat_id,user_id) VALUES(?,?)',(chat_id,uid))
    allowed=['muted','wallpaper','theme','ttl_seconds','no_forwards','reactions_json']; vals={k:data[k] for k in allowed if k in data}
    if vals:
        for k in ('muted','ttl_seconds','no_forwards'):
            if k in vals:
                try: vals[k]=max(0,int(vals[k]))
                except Exception: vals[k]=0
        if 'theme' in vals: vals['theme']=str(vals['theme'])[:40]
        if 'wallpaper' in vals: vals['wallpaper']=str(vals['wallpaper'])[:300]
        if 'reactions_json' in vals:
            vals['reactions_json']=json.dumps(vals['reactions_json'],ensure_ascii=False) if not isinstance(vals['reactions_json'],str) else vals['reactions_json']
        sets=','.join(f'{k}=?' for k in vals); conn.execute(f'UPDATE chat_settings SET {sets} WHERE chat_id=? AND user_id=?',tuple(vals.values())+(chat_id,uid)); conn.commit()
    r=conn.execute('SELECT * FROM chat_settings WHERE chat_id=? AND user_id=?',(chat_id,uid)).fetchone(); conn.close(); return jsonify({'ok':True,'settings':dict(r)})


@app.get('/api/drafts/<int:chat_id>')
@login_required
def v4_get_draft(chat_id):
    conn=get_db(); r=conn.execute('SELECT body,updated_at FROM drafts WHERE user_id=? AND chat_id=?',(user_id(),chat_id)).fetchone(); conn.close(); return jsonify({'ok':True,'draft':dict(r) if r else {'body':'','updated_at':''}})


@app.put('/api/drafts/<int:chat_id>')
@login_required
@csrf_required
def v4_put_draft(chat_id):
    body=str((request.get_json(silent=True) or {}).get('body') or '')[:16000]; uid=user_id(); conn=get_db(); conn.execute('INSERT INTO drafts(user_id,chat_id,body,updated_at) VALUES(?,?,?,?) ON CONFLICT(user_id,chat_id) DO UPDATE SET body=excluded.body,updated_at=excluded.updated_at',(uid,chat_id,body,now_iso())); conn.commit(); conn.close(); return jsonify({'ok':True})


@app.delete('/api/drafts/<int:chat_id>')
@login_required
@csrf_required
def v4_delete_draft(chat_id):
    conn=get_db(); conn.execute('DELETE FROM drafts WHERE user_id=? AND chat_id=?',(user_id(),chat_id)); conn.commit(); conn.close(); return jsonify({'ok':True})


@app.get('/api/chats/<int:chat_id>/scheduled')
@login_required
def v4_scheduled_list(chat_id):
    uid=user_id(); conn=get_db();
    if not v4_require_chat_member(conn,chat_id,uid): conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    rows=conn.execute('SELECT id,body,attachment,attachment_name,attachment_mime,reply_to_id,send_at,repeat_seconds,silent,created_at FROM scheduled_messages WHERE chat_id=? AND sender_id=? ORDER BY send_at',(chat_id,uid)).fetchall(); conn.close(); return jsonify({'ok':True,'messages':[dict(r) for r in rows]})


@app.post('/api/chats/<int:chat_id>/scheduled')
@login_required
@csrf_required
def v4_schedule(chat_id):
    uid=user_id(); data=request.get_json(silent=True) or {}; send_at=str(data.get('send_at') or '').strip(); body=str(data.get('body') or '')[:8000]; repeat=max(0,int(data.get('repeat_seconds') or 0)); silent=1 if data.get('silent') else 0
    try: datetime.fromisoformat(send_at)
    except Exception: return jsonify({'ok':False,'error':'زمان زمان‌بندی معتبر نیست.'}),400
    conn=get_db();
    if not v4_require_chat_member(conn,chat_id,uid): conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    cur=conn.execute('INSERT INTO scheduled_messages(chat_id,sender_id,body,reply_to_id,send_at,repeat_seconds,silent,created_at) VALUES(?,?,?,?,?,?,?,?)',(chat_id,uid,body,int(data.get('reply_to_id') or 0) or None,send_at,repeat,silent,now_iso())); conn.commit(); r=conn.execute('SELECT * FROM scheduled_messages WHERE id=?',(cur.lastrowid,)).fetchone(); conn.close(); return jsonify({'ok':True,'message':dict(r)})


@app.delete('/api/scheduled/<int:scheduled_id>')
@login_required
@csrf_required
def v4_delete_scheduled(scheduled_id):
    conn=get_db(); cur=conn.execute('DELETE FROM scheduled_messages WHERE id=? AND sender_id=?',(scheduled_id,user_id())); conn.commit(); conn.close(); return jsonify({'ok':bool(cur.rowcount)})


@app.post('/api/messages/<int:message_id>/forward')
@login_required
@csrf_required
def v4_forward(message_id):
    uid=user_id(); data=request.get_json(silent=True) or {}; to_chat=int(data.get('to_chat_id') or 0); attribution=0 if data.get('hide_attribution') else 1; conn=get_db(); src=conn.execute('SELECT * FROM messages WHERE id=? AND deleted_at=\'\'',(message_id,)).fetchone()
    if not src or not v4_require_chat_member(conn,src['chat_id'],uid) or not v4_require_chat_member(conn,to_chat,uid): conn.close(); return jsonify({'ok':False,'error':'پیام یا گفتگو در دسترس نیست.'}),404
    settings=conn.execute('SELECT no_forwards FROM chat_settings WHERE chat_id=? AND user_id=?',(src['chat_id'],uid)).fetchone();
    if settings and settings['no_forwards']: conn.close(); return jsonify({'ok':False,'error':'بازنشر این گفتگو غیرفعال است.'}),403
    ts=now_iso(); cur=conn.execute('INSERT INTO messages(chat_id,sender_id,body,attachment,attachment_name,attachment_mime,created_at) VALUES(?,?,?,?,?,?,?)',(to_chat,uid,src['body'],src['attachment'],src['attachment_name'],src['attachment_mime'],ts)); mid=cur.lastrowid
    conn.execute('INSERT OR REPLACE INTO message_forwards(message_id,from_message_id,from_chat_id,attribution) VALUES(?,?,?,?)',(mid,message_id,src['chat_id'],attribution)); conn.execute('UPDATE chats SET updated_at=? WHERE id=?',(ts,to_chat)); notify_chat_members(conn,to_chat,uid,'message',mid,{'preview':(src['body'] or '')[:100]}); conn.commit(); row=conn.execute('SELECT m.*,u.username,u.display_name,u.avatar FROM messages m JOIN users u ON u.id=m.sender_id WHERE m.id=?',(mid,)).fetchone(); out=message_json(conn,row,uid); conn.close(); return jsonify({'ok':True,'message':out})


@app.post('/api/messages/<int:message_id>/save')
@login_required
@csrf_required
def v4_save_message(message_id):
    data=request.get_json(silent=True) or {}; tag=str(data.get('tag') or '')[:40]; uid=user_id(); conn=get_db(); exists=conn.execute('SELECT id FROM messages WHERE id=?',(message_id,)).fetchone();
    if not exists: conn.close(); return jsonify({'ok':False,'error':'پیام پیدا نشد.'}),404
    conn.execute('INSERT OR REPLACE INTO saved_messages(user_id,message_id,tag,saved_at) VALUES(?,?,?,?)',(uid,message_id,tag,now_iso())); conn.commit(); conn.close(); return jsonify({'ok':True})


@app.delete('/api/messages/<int:message_id>/save')
@login_required
@csrf_required
def v4_unsave_message(message_id):
    conn=get_db(); conn.execute('DELETE FROM saved_messages WHERE user_id=? AND message_id=?',(user_id(),message_id)); conn.commit(); conn.close(); return jsonify({'ok':True})


@app.get('/api/saved-messages')
@login_required
def v4_saved_messages():
    conn=get_db(); rows=conn.execute('SELECT sm.message_id,sm.tag,sm.saved_at,m.chat_id,m.body,m.attachment,m.attachment_name,m.created_at,u.username,u.display_name FROM saved_messages sm JOIN messages m ON m.id=sm.message_id JOIN users u ON u.id=m.sender_id WHERE sm.user_id=? ORDER BY sm.saved_at DESC LIMIT 200',(user_id(),)).fetchall(); conn.close(); return jsonify({'ok':True,'messages':[dict(r) for r in rows]})


@app.get('/api/chats/<int:chat_id>/topics')
@login_required
def v4_topics(chat_id):
    conn=get_db();
    if not v4_require_chat_member(conn,chat_id,user_id()): conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    rows=conn.execute('SELECT * FROM topics WHERE chat_id=? ORDER BY pinned DESC,id DESC',(chat_id,)).fetchall(); conn.close(); return jsonify({'ok':True,'topics':[dict(r) for r in rows]})


@app.post('/api/chats/<int:chat_id>/topics')
@login_required
@csrf_required
def v4_topic_create(chat_id):
    uid=user_id(); data=request.get_json(silent=True) or {}; title=str(data.get('title') or 'موضوع جدید').strip()[:80]; icon=str(data.get('icon') or '💬')[:8]; conn=get_db(); chat,member=v4_require_manager(conn,chat_id,uid)
    if not chat: conn.close(); return jsonify({'ok':False,'error':'فقط مدیر می‌تواند موضوع بسازد.'}),403
    cur=conn.execute('INSERT INTO topics(chat_id,title,icon,created_by,created_at) VALUES(?,?,?,?,?)',(chat_id,title,icon,uid,now_iso())); v4_log(conn,chat_id,uid,'topic_create',cur.lastrowid,{'title':title}); conn.commit(); r=conn.execute('SELECT * FROM topics WHERE id=?',(cur.lastrowid,)).fetchone(); conn.close(); return jsonify({'ok':True,'topic':dict(r)})


@app.patch('/api/topics/<int:topic_id>')
@login_required
@csrf_required
def v4_topic_update(topic_id):
    uid=user_id(); data=request.get_json(silent=True) or {}; conn=get_db(); t=conn.execute('SELECT * FROM topics WHERE id=?',(topic_id,)).fetchone()
    if not t: conn.close(); return jsonify({'ok':False,'error':'موضوع پیدا نشد.'}),404
    chat,member=v4_require_manager(conn,t['chat_id'],uid)
    if not chat: conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    vals={k:data[k] for k in ('title','icon','pinned','closed') if k in data};
    for k in ('pinned','closed'):
        if k in vals: vals[k]=1 if vals[k] else 0
    if 'title' in vals: vals['title']=str(vals['title'])[:80]
    if 'icon' in vals: vals['icon']=str(vals['icon'])[:8]
    if vals:
        sets=','.join(f'{k}=?' for k in vals); conn.execute(f'UPDATE topics SET {sets} WHERE id=?',tuple(vals.values())+(topic_id,)); v4_log(conn,t['chat_id'],uid,'topic_update',topic_id,vals); conn.commit()
    r=conn.execute('SELECT * FROM topics WHERE id=?',(topic_id,)).fetchone(); conn.close(); return jsonify({'ok':True,'topic':dict(r)})


@app.delete('/api/topics/<int:topic_id>')
@login_required
@csrf_required
def v4_topic_delete(topic_id):
    uid=user_id(); conn=get_db(); t=conn.execute('SELECT * FROM topics WHERE id=?',(topic_id,)).fetchone();
    if not t: conn.close(); return jsonify({'ok':False,'error':'موضوع پیدا نشد.'}),404
    chat,_=v4_require_manager(conn,t['chat_id'],uid)
    if not chat: conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    conn.execute('DELETE FROM topics WHERE id=?',(topic_id,)); v4_log(conn,t['chat_id'],uid,'topic_delete',topic_id); conn.commit(); conn.close(); return jsonify({'ok':True})


@app.post('/api/chats/<int:chat_id>/polls')
@login_required
@csrf_required
def v4_create_poll(chat_id):
    uid=user_id(); data=request.get_json(silent=True) or {}; question=str(data.get('question') or '').strip()[:300]; options=[str(x).strip()[:100] for x in (data.get('options') or []) if str(x).strip()][:10]
    if not question or len(options)<2: return jsonify({'ok':False,'error':'سؤال و حداقل دو گزینه لازم است.'}),400
    conn=get_db();
    if not v4_require_chat_member(conn,chat_id,uid): conn.close(); return jsonify({'ok':False,'error':'عضو گفتگو نیستید.'}),403
    ts=now_iso(); cur=conn.execute('INSERT INTO messages(chat_id,sender_id,body,created_at) VALUES(?,?,?,?)',(chat_id,uid,question,ts)); mid=cur.lastrowid
    pid=conn.execute('INSERT INTO polls(chat_id,message_id,question,options_json,counts_json,allows_multiple,quiz,correct_index,closes_at,anonymous,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)',(chat_id,mid,question,json.dumps(options,ensure_ascii=False),json.dumps([0]*len(options)),1 if data.get('allows_multiple') else 0,1 if data.get('quiz') else 0,int(data.get('correct_index',-1)),str(data.get('closes_at') or ''),0 if data.get('anonymous') is False else 1,ts)).lastrowid
    notify_chat_members(conn,chat_id,uid,'message',mid,{'preview':question[:100]}); conn.commit(); conn.close(); return jsonify({'ok':True,'poll_id':pid,'message_id':mid})


@app.get('/api/polls/<int:poll_id>')
@login_required
def v4_get_poll(poll_id):
    conn=get_db(); p=conn.execute('SELECT * FROM polls WHERE id=?',(poll_id,)).fetchone();
    if not p: conn.close(); return jsonify({'ok':False,'error':'نظرسنجی پیدا نشد.'}),404
    if not v4_require_chat_member(conn,p['chat_id'],user_id()): conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    votes=conn.execute('SELECT option_index,COUNT(*) c FROM poll_votes WHERE poll_id=? GROUP BY option_index',(poll_id,)).fetchall(); counts=[0]*len(json.loads(p['options_json']));
    for r in votes:
        if 0<=r['option_index']<len(counts): counts[r['option_index']]=r['c']
    mine=[r['option_index'] for r in conn.execute('SELECT option_index FROM poll_votes WHERE poll_id=? AND user_id=?',(poll_id,user_id())).fetchall()]; conn.close()
    return jsonify({'ok':True,'poll':{'id':p['id'],'question':p['question'],'options':json.loads(p['options_json']),'counts':counts,'allows_multiple':bool(p['allows_multiple']),'quiz':bool(p['quiz']),'correct_index':p['correct_index'],'closes_at':p['closes_at'],'anonymous':bool(p['anonymous']),'mine':mine}})


@app.post('/api/polls/<int:poll_id>/vote')
@login_required
@csrf_required
def v4_vote_poll(poll_id):
    data=request.get_json(silent=True) or {}; opts=data.get('options');
    if not isinstance(opts,list): opts=[data.get('option_index')]
    opts=[int(x) for x in opts if str(x).lstrip('-').isdigit()]; uid=user_id(); conn=get_db(); p=conn.execute('SELECT * FROM polls WHERE id=?',(poll_id,)).fetchone();
    if not p: conn.close(); return jsonify({'ok':False,'error':'نظرسنجی پیدا نشد.'}),404
    if not v4_require_chat_member(conn,p['chat_id'],uid): conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    options=json.loads(p['options_json']); opts=[x for x in opts if 0<=x<len(options)]
    if not opts: conn.close(); return jsonify({'ok':False,'error':'گزینه نامعتبر.'}),400
    if not p['allows_multiple'] and len(opts)>1: opts=opts[:1]
    if not p['allows_multiple']: conn.execute('DELETE FROM poll_votes WHERE poll_id=? AND user_id=?',(poll_id,uid))
    for x in opts: conn.execute('INSERT OR IGNORE INTO poll_votes(poll_id,user_id,option_index,created_at) VALUES(?,?,?,?)',(poll_id,uid,x,now_iso()))
    conn.commit(); conn.close(); return jsonify({'ok':True})


@app.get('/api/stories')
@login_required
def v4_stories():
    uid=user_id(); conn=get_db(); rows=conn.execute('SELECT s.*,u.username,u.display_name,u.avatar FROM stories s JOIN users u ON u.id=s.user_id WHERE s.expires_at>? AND (s.privacy=\'everyone\' OR s.user_id=? OR (s.privacy=\'contacts\' AND EXISTS(SELECT 1 FROM contacts c WHERE c.owner_id=? AND c.contact_id=s.user_id))) ORDER BY s.created_at DESC LIMIT 100',(now_iso(),uid,uid)).fetchall(); conn.close(); return jsonify({'ok':True,'stories':[dict(r) for r in rows]})


@app.post('/api/stories')
@login_required
@csrf_required
def v4_create_story():
    uid=user_id(); f=request.files.get('file'); caption=(request.form.get('caption') or '')[:1000]; privacy=(request.form.get('privacy') or 'contacts')[:20]
    if not f: return jsonify({'ok':False,'error':'فایل ارسال نشده است.'}),400
    ext=f.filename.rsplit('.',1)[-1].lower() if '.' in f.filename else 'bin'; allowed={'jpg','jpeg','png','webp','gif','mp4','webm','mov'}
    if ext not in allowed: return jsonify({'ok':False,'error':'فرمت استوری مجاز نیست.'}),400
    name=f'story_{uid}_{secrets.token_hex(8)}.{ext}'; f.save(UPLOAD_DIR/name); typ='video' if ext in {'mp4','webm','mov'} else 'image'; ts=datetime.now(timezone.utc); exp=ts+timedelta(hours=24); conn=get_db(); cur=conn.execute('INSERT INTO stories(user_id,media_url,media_type,caption,privacy,created_at,expires_at) VALUES(?,?,?,?,?,?,?)',(uid,f'/uploads/{name}',typ,caption,privacy,ts.isoformat(timespec='seconds'),exp.isoformat(timespec='seconds'))); conn.commit(); r=conn.execute('SELECT * FROM stories WHERE id=?',(cur.lastrowid,)).fetchone(); conn.close(); return jsonify({'ok':True,'story':dict(r)})


@app.post('/api/stories/<int:story_id>/view')
@login_required
@csrf_required
def v4_story_view(story_id):
    uid=user_id(); conn=get_db(); s=conn.execute('SELECT * FROM stories WHERE id=?',(story_id,)).fetchone();
    if not s: conn.close(); return jsonify({'ok':False,'error':'استوری پیدا نشد.'}),404
    conn.execute('INSERT OR REPLACE INTO story_views(story_id,user_id,viewed_at) VALUES(?,?,?)',(story_id,uid,now_iso()));
    conn.execute('UPDATE stories SET views=views+1 WHERE id=?',(story_id,)); conn.commit(); conn.close(); return jsonify({'ok':True})


@app.post('/api/stories/<int:story_id>/react')
@login_required
@csrf_required
def v4_story_react(story_id):
    data=request.get_json(silent=True) or {}; reaction=str(data.get('reaction') or 'like')[:24]; conn=get_db(); conn.execute('INSERT OR REPLACE INTO story_reactions(story_id,user_id,reaction,created_at) VALUES(?,?,?,?)',(story_id,user_id(),reaction,now_iso())); conn.commit(); conn.close(); return jsonify({'ok':True})


@app.get('/api/stories/<int:story_id>/views')
@login_required
def v4_story_views(story_id):
    uid=user_id(); conn=get_db(); s=conn.execute('SELECT * FROM stories WHERE id=? AND user_id=?',(story_id,uid)).fetchone();
    if not s: conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    rows=conn.execute('SELECT u.id,u.username,u.display_name,u.avatar,v.viewed_at FROM story_views v JOIN users u ON u.id=v.user_id WHERE v.story_id=? ORDER BY v.viewed_at DESC',(story_id,)).fetchall(); conn.close(); return jsonify({'ok':True,'views':[dict(r) for r in rows]})


@app.delete('/api/stories/<int:story_id>')
@login_required
@csrf_required
def v4_story_delete(story_id):
    conn=get_db(); s=conn.execute('SELECT media_url FROM stories WHERE id=? AND user_id=?',(story_id,user_id())).fetchone();
    if s: conn.execute('DELETE FROM stories WHERE id=?',(story_id,)); conn.commit()
    conn.close(); return jsonify({'ok':bool(s)})


@app.get('/api/chats/<int:chat_id>/admin-log')
@login_required
def v4_admin_log(chat_id):
    conn=get_db(); chat,_=v4_require_manager(conn,chat_id,user_id())
    if not chat: conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    rows=conn.execute('SELECT a.*,u.username,u.display_name FROM admin_actions a JOIN users u ON u.id=a.actor_id WHERE a.chat_id=? ORDER BY a.id DESC LIMIT 200',(chat_id,)).fetchall(); conn.close(); return jsonify({'ok':True,'actions':[dict(r) for r in rows]})


@app.patch('/api/chats/<int:chat_id>/member/<int:target_id>/ban')
@login_required
@csrf_required
def v4_ban_member(chat_id,target_id):
    uid=user_id(); data=request.get_json(silent=True) or {}; conn=get_db(); chat,admin=v4_require_manager(conn,chat_id,uid)
    if not chat: conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    target=conn.execute('SELECT * FROM chat_members WHERE chat_id=? AND user_id=?',(chat_id,target_id)).fetchone()
    if not target or target['role']=='owner': conn.close(); return jsonify({'ok':False,'error':'امکان بن این کاربر نیست.'}),400
    conn.execute('DELETE FROM chat_members WHERE chat_id=? AND user_id=?',(chat_id,target_id));
    v4_log(conn,chat_id,uid,'member_ban',target_id,{'reason':str(data.get('reason') or '')[:160]}); conn.commit(); conn.close(); return jsonify({'ok':True})


@app.post('/api/chats/<int:chat_id>/unban/<int:target_id>')
@login_required
@csrf_required
def v4_unban_member(chat_id,target_id):
    uid=user_id(); conn=get_db(); chat,admin=v4_require_manager(conn,chat_id,uid)
    if not chat: conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    conn.execute('INSERT OR IGNORE INTO chat_members(chat_id,user_id,role,joined_at) VALUES(?,?,?,?)',(chat_id,target_id,'member',now_iso())); v4_log(conn,chat_id,uid,'member_unban',target_id); conn.commit(); conn.close(); return jsonify({'ok':True})


@app.post('/api/chats/<int:chat_id>/no-forwards')
@login_required
@csrf_required
def v4_no_forwards(chat_id):
    uid=user_id(); data=request.get_json(silent=True) or {}; enabled=1 if data.get('enabled') else 0; conn=get_db(); chat,_=v4_require_manager(conn,chat_id,uid)
    if not chat: conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    conn.execute('UPDATE chats SET description=description WHERE id=?',(chat_id,));
    # store owner/admin default in chat settings as a canonical flag for the acting admin
    conn.execute('INSERT OR IGNORE INTO chat_settings(chat_id,user_id) VALUES(?,?)',(chat_id,uid)); conn.execute('UPDATE chat_settings SET no_forwards=? WHERE chat_id=? AND user_id=?',(enabled,chat_id,uid)); v4_log(conn,chat_id,uid,'no_forwards',0,{'enabled':enabled}); conn.commit(); conn.close(); return jsonify({'ok':True,'enabled':bool(enabled)})


@app.delete('/api/chats/<int:chat_id>/history')
@login_required
@csrf_required
def v4_clear_history(chat_id):
    uid=user_id(); conn=get_db();
    if not v4_require_chat_member(conn,chat_id,uid): conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    data=request.get_json(silent=True) or {}; before_id=int(data.get('before_id') or 0)
    if before_id: conn.execute('UPDATE messages SET deleted_at=? WHERE chat_id=? AND id<=?',(now_iso(),chat_id,before_id))
    else: conn.execute('UPDATE messages SET deleted_at=? WHERE chat_id=?',(now_iso(),chat_id))
    conn.commit(); conn.close(); return jsonify({'ok':True})


@app.post('/api/messages/report')
@login_required
@csrf_required
def v4_report_message():
    data=request.get_json(silent=True) or {}; mid=int(data.get('message_id') or 0); reason=str(data.get('reason') or 'spam')[:80]; conn=get_db(); m=conn.execute('SELECT chat_id,sender_id FROM messages WHERE id=?',(mid,)).fetchone();
    if not m or not v4_require_chat_member(conn,m['chat_id'],user_id()): conn.close(); return jsonify({'ok':False,'error':'پیام پیدا نشد.'}),404
    v4_log(conn,m['chat_id'],user_id(),'message_report',m['sender_id'],{'message_id':mid,'reason':reason}); conn.commit(); conn.close(); return jsonify({'ok':True})


@app.get('/api/chats/<int:chat_id>/stats')
@login_required
def v4_chat_stats(chat_id):
    uid=user_id(); conn=get_db();
    if not v4_require_chat_member(conn,chat_id,uid): conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    members=conn.execute('SELECT COUNT(*) c FROM chat_members WHERE chat_id=?',(chat_id,)).fetchone()['c']; msgs=conn.execute("SELECT COUNT(*) c FROM messages WHERE chat_id=? AND deleted_at=''",(chat_id,)).fetchone()['c']; media=conn.execute("SELECT COUNT(*) c FROM messages WHERE chat_id=? AND deleted_at='' AND attachment<>''",(chat_id,)).fetchone()['c']; views=conn.execute('SELECT COALESCE(SUM(views),0) c FROM stories WHERE user_id=?',(uid,)).fetchone()['c']; conn.close(); return jsonify({'ok':True,'stats':{'members':members,'messages':msgs,'media':media,'story_views':views}})


@app.get('/api/communities')
@login_required
def v4_communities():
    conn=get_db(); rows=conn.execute('SELECT * FROM communities WHERE owner_id=? ORDER BY id DESC',(user_id(),)).fetchall(); conn.close(); return jsonify({'ok':True,'communities':[dict(r) for r in rows]})


@app.post('/api/communities')
@login_required
@csrf_required
def v4_create_community():
    data=request.get_json(silent=True) or {}; title=str(data.get('title') or 'Community').strip()[:80]; desc=str(data.get('description') or '')[:300]; uid=user_id(); conn=get_db(); cur=conn.execute('INSERT INTO communities(owner_id,title,description,created_at) VALUES(?,?,?,?)',(uid,title,desc,now_iso())); conn.commit(); r=conn.execute('SELECT * FROM communities WHERE id=?',(cur.lastrowid,)).fetchone(); conn.close(); return jsonify({'ok':True,'community':dict(r)})


@app.post('/api/communities/<int:community_id>/chats/<int:chat_id>')
@login_required
@csrf_required
def v4_add_community_chat(community_id,chat_id):
    uid=user_id(); conn=get_db(); c=conn.execute('SELECT 1 FROM communities WHERE id=? AND owner_id=?',(community_id,uid)).fetchone(); m=v4_require_chat_member(conn,chat_id,uid)
    if not c or not m: conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    conn.execute('INSERT OR REPLACE INTO community_chats(community_id,chat_id,visible,suggested) VALUES(?,?,?,?)',(community_id,chat_id,1,1 if (request.get_json(silent=True) or {}).get('suggested') else 0)); conn.commit(); conn.close(); return jsonify({'ok':True})


@app.get('/api/communities/<int:community_id>/chats')
@login_required
def v4_community_chats(community_id):
    uid=user_id(); conn=get_db(); c=conn.execute('SELECT 1 FROM communities WHERE id=? AND owner_id=?',(community_id,uid)).fetchone();
    if not c: conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    rows=conn.execute('SELECT c.*,cc.visible,cc.suggested FROM community_chats cc JOIN chats c ON c.id=cc.chat_id WHERE cc.community_id=? ORDER BY c.title',(community_id,)).fetchall(); conn.close(); return jsonify({'ok':True,'chats':[dict(r) for r in rows]})


@app.get('/api/profile/<int:target_id>')
@login_required
def v4_profile(target_id):
    conn=get_db(); u=conn.execute('SELECT * FROM users WHERE id=?',(target_id,)).fetchone();
    if not u: conn.close(); return jsonify({'ok':False,'error':'کاربر پیدا نشد.'}),404
    stats=conn.execute('SELECT COUNT(*) c FROM chat_members WHERE user_id=?',(target_id,)).fetchone()['c']; gifts=conn.execute('SELECT COUNT(*) c FROM gifts WHERE recipient_id=?',(target_id,)).fetchone()['c']; conn.close(); return jsonify({'ok':True,'user':json_user(u),'stats':{'chats':stats,'gifts':gifts}})


@app.post('/api/gifts')
@login_required
@csrf_required
def v4_send_gift():
    uid=user_id(); data=request.get_json(silent=True) or {}; target=int(data.get('recipient_id') or 0); title=str(data.get('title') or 'Web IV Gift')[:80]; value=max(0,int(data.get('value') or 0)); msg=str(data.get('message') or '')[:200]; conn=get_db();
    if not conn.execute('SELECT 1 FROM users WHERE id=?',(target,)).fetchone(): conn.close(); return jsonify({'ok':False,'error':'کاربر پیدا نشد.'}),404
    wallet=conn.execute('SELECT * FROM wallets WHERE user_id=?',(uid,)).fetchone();
    if value>0 and (not wallet or wallet['stars']<value): conn.close(); return jsonify({'ok':False,'error':'موجودی Stars کافی نیست.'}),400
    if value>0:
        conn.execute('UPDATE wallets SET stars=stars-?,updated_at=? WHERE user_id=?',(value,now_iso(),uid)); conn.execute('INSERT OR IGNORE INTO wallets(user_id,stars,boosts,updated_at) VALUES(?,?,?,?)',(target,value,0,now_iso())); conn.execute('UPDATE wallets SET stars=stars+?,updated_at=? WHERE user_id=?',(value,now_iso(),target))
    cur=conn.execute('INSERT INTO gifts(sender_id,recipient_id,title,value,kind,message,created_at) VALUES(?,?,?,?,?,?,?)',(uid,target,title,value,'gift',msg,now_iso())); conn.commit(); conn.close(); return jsonify({'ok':True,'gift_id':cur.lastrowid})


@app.get('/api/wallet')
@login_required
def v4_wallet():
    conn=get_db(); v4_ensure_user_state(conn,user_id()); r=conn.execute('SELECT * FROM wallets WHERE user_id=?',(user_id(),)).fetchone(); conn.close(); return jsonify({'ok':True,'wallet':dict(r)})


@app.post('/api/wallet/topup-demo')
@login_required
@csrf_required
def v4_wallet_demo():
    data=request.get_json(silent=True) or {}; amount=max(0,min(100000,int(data.get('stars') or 0))); conn=get_db(); v4_ensure_user_state(conn,user_id()); conn.execute('UPDATE wallets SET stars=stars+?,updated_at=? WHERE user_id=?',(amount,now_iso(),user_id())); conn.commit(); r=conn.execute('SELECT * FROM wallets WHERE user_id=?',(user_id(),)).fetchone(); conn.close(); return jsonify({'ok':True,'wallet':dict(r),'demo':True})


@app.get('/api/bots/<int:bot_id>/settings')
@login_required
def v4_bot_settings_get(bot_id):
    uid=user_id(); conn=get_db(); b=bot_owner_ok(conn,bot_id,uid)
    if not b: conn.close(); return jsonify({'ok':False,'error':'ربات پیدا نشد.'}),404
    conn.execute('INSERT OR IGNORE INTO bot_settings(bot_id) VALUES(?)',(bot_id,)); conn.commit(); r=conn.execute('SELECT * FROM bot_settings WHERE bot_id=?',(bot_id,)).fetchone(); cmds=conn.execute('SELECT command,description FROM bot_commands WHERE bot_id=? ORDER BY command',(bot_id,)).fetchall(); conn.close(); return jsonify({'ok':True,'settings':dict(r),'commands':[dict(x) for x in cmds]})


@app.patch('/api/bots/<int:bot_id>/settings')
@login_required
@csrf_required
def v4_bot_settings_patch(bot_id):
    uid=user_id(); data=request.get_json(silent=True) or {}; conn=get_db(); b=bot_owner_ok(conn,bot_id,uid)
    if not b: conn.close(); return jsonify({'ok':False,'error':'ربات پیدا نشد.'}),404
    conn.execute('INSERT OR IGNORE INTO bot_settings(bot_id) VALUES(?)',(bot_id,)); vals={k:data[k] for k in ('about','menu_button','inline_enabled','web_app_url','reply_keyboard_json','inline_keyboard_json') if k in data}
    for k in ('about','menu_button','web_app_url'): vals[k]=str(vals[k])[:500]
    for k in ('inline_enabled',): vals[k]=1 if vals[k] else 0
    for k in ('reply_keyboard_json','inline_keyboard_json'):
        if k in vals and not isinstance(vals[k],str): vals[k]=json.dumps(vals[k],ensure_ascii=False)
    if vals:
        sets=','.join(f'{k}=?' for k in vals); conn.execute(f'UPDATE bot_settings SET {sets} WHERE bot_id=?',tuple(vals.values())+(bot_id,)); conn.commit()
    r=conn.execute('SELECT * FROM bot_settings WHERE bot_id=?',(bot_id,)).fetchone(); conn.close(); return jsonify({'ok':True,'settings':dict(r)})


@app.post('/api/botfather/command')
@login_required
@csrf_required
def v4_botfather_command():
    uid=user_id(); text_in=(request.get_json(silent=True) or {}).get('text',''); reply=botfather_reply(uid,text_in); return jsonify({'ok':True,'reply':reply})


@app.get('/api/mini-apps')
@login_required
def v4_mini_apps():
    conn=get_db(); rows=conn.execute('SELECT ma.*,u.username bot_username FROM mini_apps ma JOIN bots b ON b.id=ma.bot_id JOIN users u ON u.id=b.user_id WHERE b.owner_id=? ORDER BY ma.id DESC',(user_id(),)).fetchall(); conn.close(); return jsonify({'ok':True,'apps':[dict(r) for r in rows]})


@app.post('/api/mini-apps')
@login_required
@csrf_required
def v4_mini_app_create():
    uid=user_id(); data=request.get_json(silent=True) or {}; bot_id=int(data.get('bot_id') or 0); name=str(data.get('name') or '')[:80]; short=str(data.get('short_name') or '')[:64]; url=str(data.get('url') or '')[:500]; desc=str(data.get('description') or '')[:300]; conn=get_db(); b=bot_owner_ok(conn,bot_id,uid)
    if not b or not name or not short or not url: conn.close(); return jsonify({'ok':False,'error':'اطلاعات Mini App ناقص است.'}),400
    cur=conn.execute('INSERT INTO mini_apps(bot_id,name,short_name,url,description,created_at) VALUES(?,?,?,?,?,?)',(bot_id,name,short,url,desc,now_iso())); conn.commit(); r=conn.execute('SELECT * FROM mini_apps WHERE id=?',(cur.lastrowid,)).fetchone(); conn.close(); return jsonify({'ok':True,'app':dict(r)})


@app.delete('/api/mini-apps/<int:app_id>')
@login_required
@csrf_required
def v4_mini_app_delete(app_id):
    uid=user_id(); conn=get_db(); cur=conn.execute('DELETE FROM mini_apps WHERE id=? AND bot_id IN (SELECT id FROM bots WHERE owner_id=?)',(app_id,uid)); conn.commit(); conn.close(); return jsonify({'ok':bool(cur.rowcount)})


@app.get('/mini-app/<int:app_id>')
def v4_mini_app_shell(app_id):
    # A safe, inert container for Web IV bots' mini-app URLs.
    conn=get_db(); r=conn.execute('SELECT * FROM mini_apps WHERE id=?',(app_id,)).fetchone(); conn.close()
    if not r: abort(404)
    return f"""<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>{r['name']}</title><style>body{{margin:0;font-family:system-ui;background:#eef5fb}}iframe{{width:100vw;height:100vh;border:0}}</style></head><body><iframe src='{r['url']}' sandbox='allow-scripts allow-forms allow-same-origin'></iframe></body></html>"""


@app.get('/api/search/global')
@login_required
def v4_global_search():
    q=(request.args.get('q') or '').strip(); uid=user_id(); conn=get_db();
    if not q: conn.close(); return jsonify({'ok':True,'users':[],'chats':[],'messages':[]})
    like=f'%{q}%'; users_rows=conn.execute('SELECT id,username,display_name,avatar,bio,is_bot FROM users WHERE id<>? AND (username LIKE ? OR display_name LIKE ? OR bio LIKE ?) ORDER BY is_bot DESC,display_name LIMIT 40',(uid,like,like,like)).fetchall()
    chats_rows=conn.execute('SELECT DISTINCT c.* FROM chats c JOIN chat_members cm ON cm.chat_id=c.id AND cm.user_id=? WHERE c.title LIKE ? OR c.username LIKE ? OR c.description LIKE ? ORDER BY c.updated_at DESC LIMIT 40',(uid,like,like,like)).fetchall()
    msg_rows=conn.execute("SELECT m.id,m.chat_id,m.body,m.created_at,u.username,u.display_name FROM messages m JOIN chat_members cm ON cm.chat_id=m.chat_id AND cm.user_id=? JOIN users u ON u.id=m.sender_id WHERE m.deleted_at='' AND m.body LIKE ? ORDER BY m.id DESC LIMIT 80",(uid,like)).fetchall(); conn.close()
    return jsonify({'ok':True,'users':[dict(x) for x in users_rows],'chats':[dict(x) for x in chats_rows],'messages':[dict(x) for x in msg_rows]})


@app.get('/api/hashtags/<tag>')
@login_required
def v4_hashtag(tag):
    tag=tag[:64].lstrip('#'); conn=get_db(); rows=conn.execute("SELECT m.id,m.chat_id,m.body,m.created_at,u.username,u.display_name FROM messages m JOIN users u ON u.id=m.sender_id JOIN chat_members cm ON cm.chat_id=m.chat_id AND cm.user_id=? WHERE m.deleted_at='' AND m.body LIKE ? ORDER BY m.id DESC LIMIT 100",(user_id(),f'%#{tag}%')).fetchall(); conn.close(); return jsonify({'ok':True,'messages':[dict(r) for r in rows]})


@app.get('/api/chats/<int:chat_id>/thread/<int:message_id>')
@login_required
def v4_thread(chat_id,message_id):
    conn=get_db();
    if not v4_require_chat_member(conn,chat_id,user_id()): conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    rows=conn.execute('SELECT m.*,u.username,u.display_name,u.avatar FROM messages m JOIN users u ON u.id=m.sender_id WHERE m.id=? OR m.reply_to_id=? ORDER BY m.id ASC LIMIT 200',(message_id,message_id)).fetchall(); out=[message_json(conn,r,user_id()) for r in rows]; conn.close(); return jsonify({'ok':True,'messages':out})


@app.get('/api/discovery')
@login_required
def v4_discovery():
    conn=get_db(); rows=conn.execute("SELECT c.*,COUNT(cm2.user_id) members FROM chats c LEFT JOIN chat_members cm2 ON cm2.chat_id=c.id WHERE c.is_public=1 AND c.kind IN ('group','channel') GROUP BY c.id ORDER BY members DESC,c.id DESC LIMIT 100").fetchall(); conn.close(); return jsonify({'ok':True,'public_chats':[dict(x) for x in rows]})


@app.get('/api/health')
def v4_health():
    conn=get_db(); conn.execute('SELECT 1').fetchone(); conn.close(); return jsonify({'ok':True,'service':'Web IV','version':'4.0.0','time':now_iso()})

# ========================= WEB IV 2026 FEATURE LAYER =========================

@app.get('/api/profile-music')
@login_required
def profile_music_get():
    conn=get_db(); r=conn.execute('SELECT * FROM profile_music WHERE user_id=?',(user_id(),)).fetchone(); conn.close()
    return jsonify({'ok':True,'music':dict(r) if r else {'title':'','artist':'','url':''}})

@app.patch('/api/profile-music')
@login_required
@csrf_required
def profile_music_set():
    d=request.get_json(silent=True) or {}; title=str(d.get('title') or '')[:120]; artist=str(d.get('artist') or '')[:120]; url=str(d.get('url') or '')[:500]; conn=get_db()
    conn.execute('INSERT OR REPLACE INTO profile_music(user_id,title,artist,url,updated_at) VALUES(?,?,?,?,?)',(user_id(),title,artist,url,now_iso())); conn.commit(); conn.close(); return jsonify({'ok':True})

@app.patch('/api/emoji-status')
@login_required
@csrf_required
def emoji_status_set():
    d=request.get_json(silent=True) or {}; value=str(d.get('value') or '')[:32]; conn=get_db(); conn.execute('INSERT OR REPLACE INTO emoji_status(user_id,value,updated_at) VALUES(?,?,?)',(user_id(),value,now_iso())); conn.commit(); conn.close(); return jsonify({'ok':True,'value':value})

@app.get('/api/chats/<int:chat_id>/member-tags')
@login_required
def member_tags_get(chat_id):
    conn=get_db();
    if not v4_require_chat_member(conn,chat_id,user_id()): conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    rows=conn.execute('SELECT mt.user_id,mt.tag,u.username,u.display_name,u.avatar FROM member_tags mt JOIN users u ON u.id=mt.user_id WHERE mt.chat_id=? ORDER BY u.display_name',(chat_id,)).fetchall(); conn.close(); return jsonify({'ok':True,'tags':[dict(r) for r in rows]})

@app.patch('/api/chats/<int:chat_id>/member-tags/<int:target_id>')
@login_required
@csrf_required
def member_tag_set(chat_id,target_id):
    d=request.get_json(silent=True) or {}; tag=str(d.get('tag') or '')[:60]; uid=user_id(); conn=get_db(); chat,member=v4_require_manager(conn,chat_id,uid)
    if not chat: conn.close(); return jsonify({'ok':False,'error':'مدیر نیستید.'}),403
    if not v4_require_chat_member(conn,chat_id,target_id): conn.close(); return jsonify({'ok':False,'error':'عضو پیدا نشد.'}),404
    if tag: conn.execute('INSERT OR REPLACE INTO member_tags(chat_id,user_id,tag,updated_at) VALUES(?,?,?,?)',(chat_id,target_id,tag,now_iso()))
    else: conn.execute('DELETE FROM member_tags WHERE chat_id=? AND user_id=?',(chat_id,target_id))
    v4_log(conn,chat_id,uid,'member_tag',target_id,{'tag':tag}); conn.commit(); conn.close(); return jsonify({'ok':True,'tag':tag})

@app.get('/api/chats/<int:chat_id>/welcome')
@login_required
def welcome_get(chat_id):
    conn=get_db();
    if not v4_require_chat_member(conn,chat_id,user_id()): conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    r=conn.execute('SELECT * FROM welcome_messages WHERE chat_id=?',(chat_id,)).fetchone(); conn.close(); return jsonify({'ok':True,'welcome':dict(r) if r else {'enabled':1,'body':''}})

@app.patch('/api/chats/<int:chat_id>/welcome')
@login_required
@csrf_required
def welcome_set(chat_id):
    uid=user_id(); d=request.get_json(silent=True) or {}; enabled=1 if d.get('enabled',True) else 0; body=str(d.get('body') or '')[:2000]; conn=get_db(); chat,_=v4_require_manager(conn,chat_id,uid)
    if not chat: conn.close(); return jsonify({'ok':False,'error':'مدیر نیستید.'}),403
    conn.execute('INSERT OR REPLACE INTO welcome_messages(chat_id,enabled,body,created_at) VALUES(?,?,?,?)',(chat_id,enabled,body,now_iso())); v4_log(conn,chat_id,uid,'welcome_message',0,{'enabled':enabled}); conn.commit(); conn.close(); return jsonify({'ok':True})

@app.get('/api/messages/<int:message_id>/meta')
@login_required
def message_meta_get(message_id):
    conn=get_db(); r=conn.execute('SELECT mm.* FROM message_meta mm JOIN messages m ON m.id=mm.message_id JOIN chat_members cm ON cm.chat_id=m.chat_id AND cm.user_id=? WHERE mm.message_id=?',(user_id(),message_id)).fetchone(); conn.close(); return jsonify({'ok':True,'meta':json.loads(r['rich_json']) if r else {}})

@app.patch('/api/messages/<int:message_id>/meta')
@login_required
@csrf_required
def message_meta_set(message_id):
    d=request.get_json(silent=True) or {}; rich=d.get('rich') if isinstance(d.get('rich'),dict) else {}; conn=get_db(); m=conn.execute('SELECT chat_id FROM messages WHERE id=?',(message_id,)).fetchone()
    if not m or not v4_require_chat_member(conn,m['chat_id'],user_id()): conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    conn.execute('INSERT OR REPLACE INTO message_meta(message_id,rich_json,updated_at) VALUES(?,?,?)',(message_id,json.dumps(rich,ensure_ascii=False),now_iso())); conn.commit(); conn.close(); return jsonify({'ok':True,'meta':rich})

@app.get('/api/premium')
@login_required
def premium_get():
    conn=get_db(); r=conn.execute('SELECT * FROM premium_accounts WHERE user_id=?',(user_id(),)).fetchone(); conn.close(); return jsonify({'ok':True,'premium':dict(r) if r else {'active':0,'plan':'free','expires_at':''}})

@app.post('/api/premium/demo')
@login_required
@csrf_required
def premium_demo():
    expiry=(datetime.now(timezone.utc)+timedelta(days=30)).isoformat(timespec='seconds'); conn=get_db(); conn.execute('INSERT OR REPLACE INTO premium_accounts(user_id,active,expires_at,plan,updated_at) VALUES(?,?,?,?,?)',(user_id(),1,expiry,'demo',now_iso())); conn.commit(); conn.close(); return jsonify({'ok':True,'expires_at':expiry})

@app.post('/api/chats/<int:chat_id>/boost')
@login_required
@csrf_required
def chat_boost(chat_id):
    uid=user_id(); conn=get_db();
    if not v4_require_chat_member(conn,chat_id,uid): conn.close(); return jsonify({'ok':False,'error':'عضو نیستید.'}),403
    conn.execute('INSERT OR IGNORE INTO chat_boosts(chat_id,boosts,updated_at) VALUES(?,?,?)',(chat_id,0,now_iso())); conn.execute('UPDATE chat_boosts SET boosts=boosts+1,updated_at=? WHERE chat_id=?',(now_iso(),chat_id)); conn.execute('UPDATE wallets SET boosts=boosts+1,updated_at=? WHERE user_id=?',(now_iso(),uid)); conn.commit(); r=conn.execute('SELECT boosts FROM chat_boosts WHERE chat_id=?',(chat_id,)).fetchone(); conn.close(); return jsonify({'ok':True,'boosts':r['boosts']})

@app.get('/api/chats/<int:chat_id>/boosts')
@login_required
def chat_boosts_get(chat_id):
    conn=get_db();
    if not v4_require_chat_member(conn,chat_id,user_id()): conn.close(); return jsonify({'ok':False,'error':'عضو نیستید.'}),403
    r=conn.execute('SELECT boosts FROM chat_boosts WHERE chat_id=?',(chat_id,)).fetchone(); conn.close(); return jsonify({'ok':True,'boosts':r['boosts'] if r else 0})

@app.get('/api/contact-notes/<int:target_id>')
@login_required
def contact_note_get(target_id):
    conn=get_db(); r=conn.execute('SELECT note FROM contact_notes WHERE owner_id=? AND contact_id=?',(user_id(),target_id)).fetchone(); conn.close(); return jsonify({'ok':True,'note':r['note'] if r else ''})

@app.patch('/api/contact-notes/<int:target_id>')
@login_required
@csrf_required
def contact_note_set(target_id):
    note=str((request.get_json(silent=True) or {}).get('note') or '')[:1000]; conn=get_db(); conn.execute('INSERT OR REPLACE INTO contact_notes(owner_id,contact_id,note,updated_at) VALUES(?,?,?,?)',(user_id(),target_id,note,now_iso())); conn.commit(); conn.close(); return jsonify({'ok':True})

@app.get('/api/business/settings')
@login_required
def business_get():
    conn=get_db(); r=conn.execute('SELECT * FROM business_settings WHERE user_id=?',(user_id(),)).fetchone(); conn.close(); return jsonify({'ok':True,'settings':dict(r) if r else {}})

@app.patch('/api/business/settings')
@login_required
@csrf_required
def business_set():
    d=request.get_json(silent=True) or {}; vals=[str(d.get(k) or '')[:300] for k in ('work_start','work_end','location','greeting','away','intro')]; conn=get_db(); conn.execute('INSERT OR REPLACE INTO business_settings(user_id,work_start,work_end,location,greeting,away,intro,updated_at) VALUES(?,?,?,?,?,?,?,?)',(user_id(),*vals,now_iso())); conn.commit(); conn.close(); return jsonify({'ok':True})

@app.get('/api/business/quick-replies')
@login_required
def quick_replies_get():
    conn=get_db(); rows=conn.execute('SELECT * FROM business_quick_replies WHERE user_id=? ORDER BY shortcut',(user_id(),)).fetchall(); conn.close(); return jsonify({'ok':True,'quick_replies':[dict(r) for r in rows]})

@app.post('/api/business/quick-replies')
@login_required
@csrf_required
def quick_reply_add():
    d=request.get_json(silent=True) or {}; shortcut=str(d.get('shortcut') or '').strip().lstrip('/')[:64]; textv=str(d.get('text') or '')[:3000];
    if not shortcut or not textv:return jsonify({'ok':False,'error':'اطلاعات ناقص است.'}),400
    conn=get_db(); conn.execute('INSERT OR REPLACE INTO business_quick_replies(user_id,shortcut,text,updated_at) VALUES(?,?,?,?)',(user_id(),shortcut,textv,now_iso())); conn.commit(); conn.close(); return jsonify({'ok':True})

@app.delete('/api/business/quick-replies/<int:reply_id>')
@login_required
@csrf_required
def quick_reply_delete(reply_id):
    conn=get_db(); cur=conn.execute('DELETE FROM business_quick_replies WHERE id=? AND user_id=?',(reply_id,user_id())); conn.commit(); conn.close(); return jsonify({'ok':bool(cur.rowcount)})

@app.post('/api/giveaways')
@login_required
@csrf_required
def giveaway_create():
    d=request.get_json(silent=True) or {}; chat_id=int(d.get('chat_id') or 0); title=str(d.get('title') or '')[:120]; prize=str(d.get('prize') or '')[:200]; winners=max(1,min(int(d.get('winners_count') or 1),100)); ends=str(d.get('ends_at') or '')[:40]; conn=get_db(); chat,_=v4_require_manager(conn,chat_id,user_id())
    if not chat or not title or not ends: conn.close(); return jsonify({'ok':False,'error':'اطلاعات مسابقه ناقص است.'}),400
    cur=conn.execute('INSERT INTO giveaways(chat_id,title,prize,winners_count,ends_at,created_at) VALUES(?,?,?,?,?,?)',(chat_id,title,prize,winners,ends,now_iso())); conn.commit(); r=conn.execute('SELECT * FROM giveaways WHERE id=?',(cur.lastrowid,)).fetchone(); conn.close(); return jsonify({'ok':True,'giveaway':dict(r)})

@app.get('/api/chats/<int:chat_id>/giveaways')
@login_required
def giveaway_list(chat_id):
    conn=get_db();
    if not v4_require_chat_member(conn,chat_id,user_id()): conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    rows=conn.execute('SELECT * FROM giveaways WHERE chat_id=? ORDER BY id DESC LIMIT 50',(chat_id,)).fetchall(); conn.close(); return jsonify({'ok':True,'giveaways':[dict(r) for r in rows]})

@app.post('/api/giveaways/<int:giveaway_id>/enter')
@login_required
@csrf_required
def giveaway_enter(giveaway_id):
    conn=get_db(); r=conn.execute('SELECT chat_id FROM giveaways WHERE id=?',(giveaway_id,)).fetchone();
    if not r or not v4_require_chat_member(conn,r['chat_id'],user_id()): conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    conn.execute('INSERT OR IGNORE INTO giveaway_entries(giveaway_id,user_id,created_at) VALUES(?,?,?)',(giveaway_id,user_id(),now_iso())); conn.commit(); conn.close(); return jsonify({'ok':True})

@app.post('/api/checklists')
@login_required
@csrf_required
def checklist_create():
    d=request.get_json(silent=True) or {}; chat_id=int(d.get('chat_id') or 0); title=str(d.get('title') or '')[:160]; items=d.get('items') if isinstance(d.get('items'),list) else []; conn=get_db();
    if not v4_require_chat_member(conn,chat_id,user_id()) or not title: conn.close(); return jsonify({'ok':False,'error':'اطلاعات ناقص است.'}),400
    cur=conn.execute('INSERT INTO checklists(chat_id,title,created_by,created_at) VALUES(?,?,?,?)',(chat_id,title,user_id(),now_iso())); cid=cur.lastrowid
    for i,item in enumerate(items[:100]):
        it=str(item).strip()[:300]
        if it: conn.execute('INSERT INTO checklist_items(checklist_id,title,position) VALUES(?,?,?)',(cid,it,i))
    conn.commit(); conn.close(); return jsonify({'ok':True,'checklist_id':cid})

@app.get('/api/checklists/<int:checklist_id>')
@login_required
def checklist_get(checklist_id):
    conn=get_db(); c=conn.execute('SELECT * FROM checklists WHERE id=?',(checklist_id,)).fetchone();
    if not c or not v4_require_chat_member(conn,c['chat_id'],user_id()): conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    items=conn.execute('SELECT * FROM checklist_items WHERE checklist_id=? ORDER BY position,id',(checklist_id,)).fetchall(); conn.close(); return jsonify({'ok':True,'checklist':dict(c),'items':[dict(x) for x in items]})

@app.patch('/api/checklists/items/<int:item_id>')
@login_required
@csrf_required
def checklist_toggle(item_id):
    d=request.get_json(silent=True) or {}; done=1 if d.get('done') else 0; conn=get_db(); r=conn.execute('SELECT c.chat_id FROM checklist_items i JOIN checklists c ON c.id=i.checklist_id WHERE i.id=?',(item_id,)).fetchone();
    if not r or not v4_require_chat_member(conn,r['chat_id'],user_id()): conn.close(); return jsonify({'ok':False,'error':'دسترسی ندارید.'}),403
    conn.execute('UPDATE checklist_items SET done=? WHERE id=?',(done,item_id)); conn.commit(); conn.close(); return jsonify({'ok':True,'done':bool(done)})

@app.get('/api/stickers/search')
@login_required
def sticker_search():
    q=(request.args.get('q') or '').strip()[:80]
    built=[{'id':'basic','title':'Web IV Basics','type':'static','items':['🙂','😀','😂','😍','😎','🔥']},{'id':'work','title':'Work','type':'static','items':['✅','📌','💡','⭐','🚀']}]
    if q: built=[x for x in built if q.lower() in x['title'].lower()]
    return jsonify({'ok':True,'sets':built})

# ======================= END WEB IV 2026 FEATURE LAYER =======================


# ============================ END WEB IV v4 API ============================


@app.errorhandler(413)
def too_large(_):
    return jsonify({"ok":False,"error":f"حجم فایل بیش از {MAX_UPLOAD_MB}MB است."}),413


@app.errorhandler(Exception)
def handle_exception(exc):
    app.logger.exception("Web IV error")
    if request.path.startswith("/api/") or request.path.startswith("/bot/"):
        return jsonify({"ok":False,"error":"خطای داخلی سرور."}),500
    return "Internal Server Error",500


init_db()

if __name__ == "__main__":
    app.run(host="0.0.0.0",port=int(os.environ.get("PORT",8080)),debug=False)
