#!/usr/bin/env python3
# bot.py — TDX BLACK HOST main entry point
import os
import sys
import html
import signal
import logging
import threading
import zipfile
import fcntl
import atexit
from pathlib import Path
from typing import Optional

import telebot
from telebot import types
from flask import Flask

import database as db
import process_manager as pm
import security_scanner as scanner
import ui
from config import (
    BOT_TOKEN, OWNER_ID, PORT, LOCK_FILE,
    PROJECTS_DIR, MAX_FILE_SIZE_MB, MAX_ZIP_EXTRACT_MB, BRAND
)

logger = logging.getLogger('TDX.Bot')
bot = telebot.TeleBot(BOT_TOKEN, parse_mode='HTML', threaded=True)
app = Flask(__name__)

# ── single-instance lock ───────────────────────────────────────────────────

_lock_fd = None

def acquire_lock() -> bool:
    global _lock_fd
    try:
        _lock_fd = open(LOCK_FILE, 'w')
        fcntl.flock(_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        _lock_fd.write(str(os.getpid()))
        _lock_fd.flush()
        return True
    except (IOError, OSError):
        return False


def release_lock() -> None:
    global _lock_fd
    if _lock_fd:
        try:
            fcntl.flock(_lock_fd, fcntl.LOCK_UN)
            _lock_fd.close()
        except Exception:
            pass
        try:
            LOCK_FILE.unlink(missing_ok=True)
        except Exception:
            pass


atexit.register(release_lock)

# ── helpers ────────────────────────────────────────────────────────────────

def is_admin(user_id: int) -> bool:
    return db.is_admin(user_id, OWNER_ID)


def is_owner(user_id: int) -> bool:
    return user_id == OWNER_ID


def bot_locked(user_id: int) -> bool:
    if is_admin(user_id):
        return False
    return db.get_setting('bot_locked') == '1'


def safe_edit(call: types.CallbackQuery, text: str,
              reply_markup=None, parse_mode: str = 'HTML') -> None:
    try:
        bot.edit_message_text(
            text, call.message.chat.id, call.message.message_id,
            reply_markup=reply_markup, parse_mode=parse_mode
        )
    except Exception:
        bot.send_message(call.message.chat.id, text,
                         reply_markup=reply_markup, parse_mode=parse_mode)


def safe_answer(call: types.CallbackQuery, text: str = '', alert: bool = False) -> None:
    try:
        bot.answer_callback_query(call.id, text, show_alert=alert)
    except Exception:
        pass


def register_user(user: types.User) -> None:
    db.upsert_user(user.id, user.username, user.first_name, user.last_name)
    if is_owner(user.id):
        db.upsert_subscription(user.id, 'OWNER')


def project_dir(owner_id: int) -> Path:
    d = PROJECTS_DIR / f"user_{owner_id}"
    d.mkdir(parents=True, exist_ok=True)
    return d


# ── user state tracking (upload flow) ─────────────────────────────────────

_upload_state: dict = {}  # user_id -> 'awaiting_file'

# ── /start ─────────────────────────────────────────────────────────────────

@bot.message_handler(commands=['start'])
def cmd_start(msg: types.Message) -> None:
    register_user(msg.from_user)
    uid = msg.from_user.id

    if db.is_banned(uid):
        bot.send_message(uid, ui.header("ACCESS DENIED") + "<b>Your account has been suspended.</b>")
        return

    bot.send_message(
        uid,
        ui.header("HOSTING CONTROL CENTER") +
        f"<b>Welcome to {BRAND}</b>\n"
        "Professional bot hosting infrastructure.\n\n"
        "<i>Select an operation below.</i>",
        reply_markup=ui.main_menu_kb()
    )


@bot.message_handler(commands=['admin'])
def cmd_admin(msg: types.Message) -> None:
    register_user(msg.from_user)
    uid = msg.from_user.id
    if not is_admin(uid):
        bot.send_message(uid, "<b>ACCESS DENIED</b>")
        return
    bot.send_message(
        uid,
        ui.header("ADMIN CONTROL CENTER") +
        "<b>System administration panel.</b>",
        reply_markup=ui.admin_main_kb()
    )


# ── file upload handler ────────────────────────────────────────────────────

@bot.message_handler(content_types=['document'])
def handle_document(msg: types.Message) -> None:
    register_user(msg.from_user)
    uid = msg.from_user.id

    if db.is_banned(uid):
        return
    if bot_locked(uid):
        bot.send_message(uid, ui.header("MAINTENANCE") +
                         db.get_setting('maintenance_msg'))
        return
    if _upload_state.get(uid) != 'awaiting_file':
        bot.send_message(uid, "<b>Use Upload Project from the menu first.</b>",
                         reply_markup=ui.back_kb())
        return

    _upload_state.pop(uid, None)

    doc = msg.document
    if not doc:
        return

    fname = doc.file_name or 'unnamed'
    suffix = Path(fname).suffix.lower()

    if suffix not in ('.py', '.js', '.zip'):
        bot.send_message(uid, "<b>Unsupported type.</b> Upload <code>.py</code>, <code>.js</code>, or <code>.zip</code>.",
                         reply_markup=ui.back_kb())
        return

    size_bytes = doc.file_size or 0
    if size_bytes > MAX_FILE_SIZE_MB * 1024 * 1024:
        bot.send_message(uid, f"<b>File too large.</b> Max {MAX_FILE_SIZE_MB} MB.",
                         reply_markup=ui.back_kb())
        return

    limit = db.get_plan_limit(uid, OWNER_ID)
    current = db.count_user_projects(uid)
    if limit != -1 and current >= limit:
        bot.send_message(uid,
            f"<b>Project limit reached.</b> Your plan allows {limit} projects.\n"
            "Upgrade to Premium for more.",
            reply_markup=ui.back_kb())
        return

    status_msg = bot.send_message(uid, "<i>Uploading and scanning...</i>")

    try:
        file_info = bot.get_file(doc.file_id)
        file_bytes = bot.download_file(file_info.file_path)
    except Exception as e:
        bot.edit_message_text(f"<b>Download failed:</b> {e}",
                              uid, status_msg.message_id)
        return

    dest_dir = project_dir(uid)

    # sanitize filename
    safe_name = "".join(c for c in fname if c.isalnum() or c in '._-')[:64] or 'project'
    dest_path = dest_dir / safe_name

    # prevent collision
    counter = 1
    while dest_path.exists():
        stem = Path(safe_name).stem
        dest_path = dest_dir / f"{stem}_{counter}{suffix}"
        counter += 1

    dest_path.write_bytes(file_bytes)

    # handle ZIP extraction
    if suffix == '.zip':
        extract_dir = dest_dir / f"zip_{dest_path.stem}"
        extract_dir.mkdir(exist_ok=True)
        try:
            with zipfile.ZipFile(dest_path, 'r') as zf:
                total_size = sum(i.file_size for i in zf.infolist())
                if total_size > MAX_ZIP_EXTRACT_MB * 1024 * 1024:
                    bot.edit_message_text("<b>ZIP content too large.</b>",
                                          uid, status_msg.message_id)
                    dest_path.unlink(missing_ok=True)
                    return
                for member in zf.infolist():
                    if '..' in member.filename or member.filename.startswith('/'):
                        continue
                    zf.extract(member, extract_dir)
        except zipfile.BadZipFile:
            bot.edit_message_text("<b>Invalid ZIP archive.</b>",
                                  uid, status_msg.message_id)
            dest_path.unlink(missing_ok=True)
            return

    # scan
    syntax_status, risk_level, scan_details = scanner.scan_file(str(dest_path))

    project_id = db.create_project(
        owner_id=uid,
        name=safe_name,
        file_type=suffix.lstrip('.'),
        file_path=str(dest_path),
        file_size=size_bytes
    )
    db.update_project_scan(project_id, risk_level, scan_details)

    # notify admins for approval
    _notify_admins_pending(project_id, uid, safe_name, risk_level)

    risk_icon = {'SAFE': '🟢', 'WARNING': '🟡', 'HIGH RISK': '🔴'}.get(risk_level, '⚪')
    bot.edit_message_text(
        ui.header("UPLOAD COMPLETE") +
        f"<b>Project:</b> <code>{html.escape(safe_name)}</code>\n"
        f"<b>Syntax:</b> <code>{html.escape(syntax_status)}</code>\n"
        f"<b>Scan:</b> {risk_icon} <code>{risk_level}</code>\n\n"
        "<i>Pending admin approval before execution.</i>",
        uid, status_msg.message_id,
        reply_markup=ui.back_kb("my_projects")
    )


def _notify_admins_pending(project_id: int, owner_id: int, name: str, risk: str) -> None:
    admins = db.get_all_admins()
    admin_ids = {a['user_id'] for a in admins} | {OWNER_ID}
    risk_icon = {'SAFE': '🟢', 'WARNING': '🟡', 'HIGH RISK': '🔴'}.get(risk, '⚪')

    text = (
        ui.header("PENDING APPROVAL") +
        f"<b>Project:</b> <code>{html.escape(name)}</code>\n"
        f"<b>Owner ID:</b> <code>{owner_id}</code>\n"
        f"<b>Scan:</b> {risk_icon} <code>{risk}</code>\n\n"
        "<i>Open Admin Panel → Pending to review.</i>"
    )

    kb = types.InlineKeyboardMarkup(row_width=2)
    kb.add(
        types.InlineKeyboardButton("⬡ Inspect", callback_data=f"adm_file_{project_id}"),
    )

    for aid in admin_ids:
        try:
            bot.send_message(aid, text, reply_markup=kb)
        except Exception:
            pass


# ── callback router ────────────────────────────────────────────────────────

@bot.callback_query_handler(func=lambda c: True)
def route_callback(call: types.CallbackQuery) -> None:
    register_user(call.from_user)
    uid = call.from_user.id
    data = call.data

    if db.is_banned(uid) and not data.startswith('adm_'):
        safe_answer(call, "Your account is suspended.", alert=True)
        return

    # ── navigation ──
    if data == 'menu':
        safe_edit(call,
                  ui.header("HOSTING CONTROL CENTER") + "<b>Select an operation.</b>",
                  reply_markup=ui.main_menu_kb())

    elif data == 'dashboard':
        _cb_dashboard(call, uid)

    elif data == 'upload':
        _cb_upload(call, uid)

    elif data == 'my_projects':
        _cb_my_projects(call, uid)

    elif data == 'running_projects':
        _cb_running(call, uid)

    elif data == 'stats':
        _cb_stats(call, uid)

    elif data == 'subscription':
        _cb_subscription(call, uid)

    elif data == 'support':
        safe_edit(call,
                  ui.header("SUPPORT") +
                  "<b>Contact the server administrator.</b>\n"
                  "Use /admin mention or check bot description.",
                  reply_markup=ui.back_kb())

    # ── project actions ──
    elif data.startswith('project_'):
        _cb_project_detail(call, uid, int(data.split('_', 1)[1]))

    elif data.startswith('start_'):
        _cb_start(call, uid, int(data.split('_', 1)[1]), admin=False)

    elif data.startswith('stop_'):
        _cb_stop(call, uid, int(data.split('_', 1)[1]), admin=False)

    elif data.startswith('restart_'):
        _cb_restart(call, uid, int(data.split('_', 1)[1]), admin=False)

    elif data.startswith('logs_'):
        _cb_logs(call, uid, int(data.split('_', 1)[1]), admin=False)

    elif data.startswith('download_'):
        _cb_download(call, uid, int(data.split('_', 1)[1]), admin=False)

    elif data.startswith('rescan_'):
        _cb_rescan(call, uid, int(data.split('_', 1)[1]), admin=False)

    elif data.startswith('del_confirm_'):
        _cb_del_confirm(call, uid, int(data.split('_', 2)[2]))

    elif data.startswith('del_execute_'):
        _cb_del_execute(call, uid, int(data.split('_', 2)[2]))

    # ── admin ──
    elif data.startswith('adm_') and is_admin(uid):
        _route_admin(call, uid, data)

    elif data.startswith('adm_') and not is_admin(uid):
        safe_answer(call, "Access denied.", alert=True)

    else:
        safe_answer(call)


# ── user callbacks ─────────────────────────────────────────────────────────

def _cb_dashboard(call, uid: int) -> None:
    user = db.get_user(uid)
    sub = db.get_subscription(uid)
    projects = db.get_user_projects(uid)
    running_count = sum(1 for p in projects if p['status'] == 'running')
    safe_edit(call, ui.dashboard_msg(user, sub, projects, running_count),
              reply_markup=ui.main_menu_kb())


def _cb_upload(call, uid: int) -> None:
    if bot_locked(uid):
        safe_answer(call, "System is under maintenance.", alert=True)
        return
    _upload_state[uid] = 'awaiting_file'
    safe_edit(call,
              ui.header("UPLOAD PROJECT") +
              "<b>Send your file now.</b>\n"
              "Supported: <code>.py</code>  <code>.js</code>  <code>.zip</code>\n"
              f"Max size: <code>{MAX_FILE_SIZE_MB} MB</code>",
              reply_markup=ui.back_kb())


def _cb_my_projects(call, uid: int) -> None:
    projects = db.get_user_projects(uid)
    if not projects:
        safe_edit(call,
                  ui.header("MY PROJECTS") + "<b>No projects found.</b>",
                  reply_markup=ui.back_kb())
        return
    safe_edit(call,
              ui.header("MY PROJECTS") + f"<b>{len(projects)} project(s)</b>",
              reply_markup=ui.projects_list_kb(projects))


def _cb_running(call, uid: int) -> None:
    projects = [p for p in db.get_user_projects(uid) if p['status'] == 'running']
    if not projects:
        safe_edit(call,
                  ui.header("RUNNING PROJECTS") + "<b>No active processes.</b>",
                  reply_markup=ui.back_kb())
        return
    safe_edit(call,
              ui.header("RUNNING PROJECTS") + f"<b>{len(projects)} active</b>",
              reply_markup=ui.projects_list_kb(projects))


def _cb_stats(call, uid: int) -> None:
    safe_edit(call, ui.system_msg(), reply_markup=ui.back_kb())


def _cb_subscription(call, uid: int) -> None:
    from config import SUBSCRIPTION_LIMITS
    sub = db.get_subscription(uid)
    plan = sub['plan'] if sub else 'FREE'
    limit = db.get_plan_limit(uid, OWNER_ID)
    current = db.count_user_projects(uid)
    limit_str = str(limit) if limit != -1 else '∞'
    text = (
        ui.header("SUBSCRIPTION") +
        f"<b>Plan:</b> <code>{plan}</code>\n"
        f"<b>Projects:</b> <code>{current}/{limit_str}</code>\n"
        f"<b>Expires:</b> <code>{sub['expires_at'] or 'Never'}</code>\n\n"
        "<b>Plans:</b>\n"
        "  Free — 3 projects\n"
        "  Premium — 15 projects\n"
        "  Admin — Unlimited\n"
    )
    safe_edit(call, text, reply_markup=ui.back_kb())


def _cb_project_detail(call, uid: int, project_id: int) -> None:
    project = db.get_project(project_id)
    if not project or (project['owner_id'] != uid and not is_admin(uid)):
        safe_answer(call, "Access denied.", alert=True)
        return
    stats = pm.get_process_stats(project_id)
    is_run = pm.is_running(project_id)
    safe_edit(call, ui.project_detail_msg(project, stats),
              reply_markup=ui.project_kb(project_id, is_run))


def _cb_start(call, uid: int, project_id: int, admin: bool = False) -> None:
    project = db.get_project(project_id)
    if not project:
        safe_answer(call, "Project not found.", alert=True)
        return
    if not admin and project['owner_id'] != uid:
        safe_answer(call, "Access denied.", alert=True)
        return
    if project['approval_status'] != 'approved' and not admin:
        safe_answer(call, "Pending admin approval.", alert=True)
        return
    ok, msg = pm.start_process(project_id)
    safe_answer(call, msg, alert=not ok)
    if ok:
        _cb_project_detail(call, uid, project_id)


def _cb_stop(call, uid: int, project_id: int, admin: bool = False) -> None:
    project = db.get_project(project_id)
    if not project:
        safe_answer(call, "Project not found.", alert=True)
        return
    if not admin and project['owner_id'] != uid:
        safe_answer(call, "Access denied.", alert=True)
        return
    ok, msg = pm.stop_process(project_id)
    safe_answer(call, msg, alert=not ok)
    _cb_project_detail(call, uid, project_id)


def _cb_restart(call, uid: int, project_id: int, admin: bool = False) -> None:
    project = db.get_project(project_id)
    if not project:
        safe_answer(call, "Project not found.", alert=True)
        return
    if not admin and project['owner_id'] != uid:
        safe_answer(call, "Access denied.", alert=True)
        return
    ok, msg = pm.restart_process(project_id)
    safe_answer(call, msg, alert=not ok)
    _cb_project_detail(call, uid, project_id)


def _cb_logs(call, uid: int, project_id: int, admin: bool = False) -> None:
    project = db.get_project(project_id)
    if not project:
        safe_answer(call, "Not found.", alert=True)
        return
    if not admin and project['owner_id'] != uid:
        safe_answer(call, "Access denied.", alert=True)
        return
    logs = pm.get_logs(project_id)
    safe_logs = html.escape(logs[-3000:])
    text = (
        ui.header("RUNTIME LOGS") +
        f"<b>Project:</b> <code>{html.escape(project['name'])}</code>\n\n"
        f"<pre>{safe_logs}</pre>"
    )
    safe_edit(call, text, reply_markup=ui.back_kb(f"project_{project_id}"))


def _cb_download(call, uid: int, project_id: int, admin: bool = False) -> None:
    project = db.get_project(project_id)
    if not project:
        safe_answer(call, "Not found.", alert=True)
        return
    if not admin and project['owner_id'] != uid:
        safe_answer(call, "Access denied.", alert=True)
        return
    path = Path(project['file_path'])
    if not path.exists():
        safe_answer(call, "File missing from disk.", alert=True)
        return
    safe_answer(call)
    with open(path, 'rb') as f:
        bot.send_document(call.message.chat.id, f, caption=f"<code>{html.escape(project['name'])}</code>")


def _cb_rescan(call, uid: int, project_id: int, admin: bool = False) -> None:
    project = db.get_project(project_id)
    if not project:
        safe_answer(call, "Not found.", alert=True)
        return
    if not admin and project['owner_id'] != uid:
        safe_answer(call, "Access denied.", alert=True)
        return
    syntax, risk, details = scanner.scan_file(project['file_path'])
    db.update_project_scan(project_id, risk, details)
    safe_answer(call, f"Rescan complete: {risk}")
    _cb_project_detail(call, uid, project_id)


def _cb_del_confirm(call, uid: int, project_id: int) -> None:
    project = db.get_project(project_id)
    if not project or (project['owner_id'] != uid and not is_admin(uid)):
        safe_answer(call, "Access denied.", alert=True)
        return
    safe_edit(call,
              ui.header("CONFIRM DELETION") +
              f"<b>Delete project:</b> <code>{html.escape(project['name'])}</code>\n\n"
              "<b>This action cannot be undone.</b>",
              reply_markup=ui.confirm_delete_kb(project_id))


def _cb_del_execute(call, uid: int, project_id: int) -> None:
    project = db.get_project(project_id)
    if not project or (project['owner_id'] != uid and not is_admin(uid)):
        safe_answer(call, "Access denied.", alert=True)
        return
    pm.stop_process(project_id)
    try:
        Path(project['file_path']).unlink(missing_ok=True)
    except Exception:
        pass
    db.delete_project(project_id)
    safe_answer(call, "Project deleted.")
    safe_edit(call,
              ui.header("PROJECT DELETED") + "<b>Deployment removed.</b>",
              reply_markup=ui.back_kb("my_projects"))


# ── admin callbacks ────────────────────────────────────────────────────────

def _route_admin(call, uid: int, data: str) -> None:
    if data == 'adm_system':
        _adm_system(call)

    elif data == 'adm_files':
        _adm_files(call)

    elif data == 'adm_users':
        _adm_users(call)

    elif data == 'adm_pending':
        _adm_pending(call)

    elif data == 'adm_admins':
        _adm_admins(call, uid)

    elif data == 'adm_broadcast':
        _adm_broadcast_prompt(call, uid)

    elif data == 'adm_lock_toggle':
        _adm_lock_toggle(call)

    elif data.startswith('adm_file_'):
        _adm_file_detail(call, uid, int(data.split('_', 2)[2]))

    elif data.startswith('adm_start_'):
        _cb_start(call, uid, int(data.split('_', 2)[2]), admin=True)

    elif data.startswith('adm_stop_'):
        _cb_stop(call, uid, int(data.split('_', 2)[2]), admin=True)

    elif data.startswith('adm_restart_'):
        _cb_restart(call, uid, int(data.split('_', 2)[2]), admin=True)

    elif data.startswith('adm_logs_'):
        _cb_logs(call, uid, int(data.split('_', 2)[2]), admin=True)

    elif data.startswith('adm_dl_'):
        _cb_download(call, uid, int(data.split('_', 2)[2]), admin=True)

    elif data.startswith('adm_rescan_'):
        _cb_rescan(call, uid, int(data.split('_', 2)[2]), admin=True)

    elif data.startswith('adm_approve_'):
        _adm_approve(call, int(data.split('_', 2)[2]))

    elif data.startswith('adm_reject_'):
        _adm_reject(call, int(data.split('_', 2)[2]))

    elif data.startswith('adm_del_'):
        project_id = int(data.split('_', 2)[2])
        _cb_del_execute(call, uid, project_id)

    elif data.startswith('adm_user_'):
        _adm_user_detail(call, int(data.split('_', 2)[2]))

    elif data.startswith('adm_ban_'):
        _adm_ban(call, int(data.split('_', 2)[2]))

    elif data.startswith('adm_unban_'):
        _adm_unban(call, int(data.split('_', 2)[2]))

    elif data.startswith('adm_userfiles_'):
        _adm_userfiles(call, int(data.split('_', 2)[2]))

    elif data.startswith('adm_usersub_'):
        _adm_usersub(call, int(data.split('_', 2)[2]))

    elif data.startswith('adm_setplan_'):
        parts = data.split('_')
        target_id = int(parts[2])
        plan = parts[3]
        _adm_setplan(call, target_id, plan)

    else:
        safe_answer(call)


def _adm_system(call) -> None:
    total_users = db.get_user_count()
    total_projects = len(db.get_all_projects())
    running = db.get_running_count()
    pending = len(db.get_pending_projects())
    text = (
        ui.system_msg() +
        f"\n<b>Total Users:</b> <code>{total_users}</code>\n"
        f"<b>Total Projects:</b> <code>{total_projects}</code>\n"
        f"<b>Running:</b> <code>{running}</code>\n"
        f"<b>Pending Review:</b> <code>{pending}</code>\n"
    )
    safe_edit(call, text, reply_markup=ui.back_kb("adm_system"))
    # send fresh admin kb
    bot.send_message(call.message.chat.id,
                     "<i>Admin panel:</i>", reply_markup=ui.admin_main_kb())


def _adm_files(call) -> None:
    projects = db.get_all_projects()
    if not projects:
        safe_edit(call, ui.header("FILE MANAGEMENT") + "<b>No projects on record.</b>",
                  reply_markup=ui.back_kb("adm_system"))
        return
    # paginate: show first 20
    shown = projects[:20]
    kb = types.InlineKeyboardMarkup(row_width=1)
    for p in shown:
        status_icon = "▶" if p['status'] == 'running' else "■"
        approval_icon = "✓" if p['approval_status'] == 'approved' else "⏳"
        label = f"{status_icon}{approval_icon} [{p['owner_id']}] {p['name'][:25]}"
        kb.add(types.InlineKeyboardButton(label, callback_data=f"adm_file_{p['id']}"))
    kb.add(types.InlineKeyboardButton("← Back", callback_data="adm_system"))
    safe_edit(call,
              ui.header("FILE MANAGEMENT") + f"<b>{len(projects)} project(s)</b>",
              reply_markup=kb)


def _adm_users(call) -> None:
    users = db.get_all_users()
    if not users:
        safe_edit(call, ui.header("USER MANAGEMENT") + "<b>No users.</b>",
                  reply_markup=ui.back_kb("adm_system"))
        return
    shown = users[:20]
    kb = types.InlineKeyboardMarkup(row_width=1)
    for u in shown:
        ban_mark = "✕" if u['is_banned'] else ""
        uname = f"@{u['username']}" if u['username'] else str(u['user_id'])
        label = f"{ban_mark} {html.escape(u['first_name'] or '')} {uname}"
        kb.add(types.InlineKeyboardButton(label[:50], callback_data=f"adm_user_{u['user_id']}"))
    kb.add(types.InlineKeyboardButton("← Back", callback_data="adm_system"))
    safe_edit(call,
              ui.header("USER MANAGEMENT") + f"<b>{len(users)} user(s)</b>",
              reply_markup=kb)


def _adm_pending(call) -> None:
    projects = db.get_pending_projects()
    if not projects:
        safe_edit(call, ui.header("PENDING REVIEW") + "<b>Queue empty.</b>",
                  reply_markup=ui.back_kb("adm_system"))
        return
    kb = types.InlineKeyboardMarkup(row_width=1)
    for p in projects:
        risk_icon = {'SAFE': '🟢', 'WARNING': '🟡', 'HIGH RISK': '🔴'}.get(p['scan_result'], '⚪')
        label = f"{risk_icon} [{p['owner_id']}] {p['name'][:30]}"
        kb.add(types.InlineKeyboardButton(label, callback_data=f"adm_file_{p['id']}"))
    kb.add(types.InlineKeyboardButton("← Back", callback_data="adm_system"))
    safe_edit(call,
              ui.header("PENDING REVIEW") + f"<b>{len(projects)} awaiting approval</b>",
              reply_markup=kb)


def _adm_file_detail(call, uid: int, project_id: int) -> None:
    project = db.get_project(project_id)
    if not project:
        safe_answer(call, "Not found.", alert=True)
        return
    owner = db.get_user(project['owner_id'])
    stats = pm.get_process_stats(project_id)
    is_run = pm.is_running(project_id)
    safe_edit(call, ui.admin_file_inspect_msg(project, owner, stats),
              reply_markup=ui.admin_file_kb(project_id, is_run))


def _adm_approve(call, project_id: int) -> None:
    project = db.get_project(project_id)
    if not project:
        safe_answer(call, "Not found.", alert=True)
        return
    db.update_project_approval(project_id, 'approved')
    # notify owner
    try:
        bot.send_message(
            project['owner_id'],
            ui.header("PROJECT APPROVED") +
            f"<b>{html.escape(project['name'])}</b> has been approved.\n"
            "You may now start your deployment."
        )
    except Exception:
        pass
    safe_answer(call, "Approved.")
    _adm_file_detail(call, call.from_user.id, project_id)


def _adm_reject(call, project_id: int) -> None:
    project = db.get_project(project_id)
    if not project:
        safe_answer(call, "Not found.", alert=True)
        return
    db.update_project_approval(project_id, 'rejected')
    db.update_project_status(project_id, 'stopped')
    try:
        bot.send_message(
            project['owner_id'],
            ui.header("PROJECT REJECTED") +
            f"<b>{html.escape(project['name'])}</b> did not pass review."
        )
    except Exception:
        pass
    safe_answer(call, "Rejected.")
    _adm_pending(call)


def _adm_admins(call, uid: int) -> None:
    admins = db.get_all_admins()
    text = ui.header("ADMINISTRATOR PANEL")
    for a in admins:
        text += f"  <code>{a['user_id']}</code> (added {a['added_at'][:10]})\n"
    if not admins:
        text += "<b>No admins configured.</b>\n"
    text += f"\n<b>Owner:</b> <code>{OWNER_ID}</code> (permanent)\n"
    text += "\n<i>Use /addadmin &lt;id&gt; or /removeadmin &lt;id&gt;</i>"
    safe_edit(call, text, reply_markup=ui.back_kb("adm_system"))


def _adm_user_detail(call, target_id: int) -> None:
    user = db.get_user(target_id)
    if not user:
        safe_answer(call, "User not found.", alert=True)
        return
    sub = db.get_subscription(target_id)
    plan = sub['plan'] if sub else 'FREE'
    projects = db.get_user_projects(target_id)
    text = (
        ui.header("USER PROFILE") +
        f"<b>Name:</b> {html.escape(user['first_name'] or '')} {html.escape(user['last_name'] or '')}\n"
        f"<b>Username:</b> @{html.escape(user['username'] or '—')}\n"
        f"<b>ID:</b> <code>{user['user_id']}</code>\n"
        f"<b>Status:</b> {'🔴 Banned' if user['is_banned'] else '🟢 Active'}\n"
        f"<b>Plan:</b> <code>{plan}</code>\n"
        f"<b>Projects:</b> {len(projects)}\n"
        f"<b>Joined:</b> <code>{user['created_at'][:10]}</code>\n"
        f"<b>Last seen:</b> <code>{user['last_seen'][:10]}</code>\n"
    )
    safe_edit(call, text,
              reply_markup=ui.admin_user_kb(target_id, bool(user['is_banned'])))


def _adm_ban(call, target_id: int) -> None:
    if target_id == OWNER_ID:
        safe_answer(call, "Cannot ban owner.", alert=True)
        return
    db.ban_user(target_id, call.from_user.id, reason='Admin action')
    safe_answer(call, "User banned.")
    _adm_user_detail(call, target_id)


def _adm_unban(call, target_id: int) -> None:
    db.unban_user(target_id)
    safe_answer(call, "User unbanned.")
    _adm_user_detail(call, target_id)


def _adm_userfiles(call, target_id: int) -> None:
    projects = db.get_user_projects(target_id)
    if not projects:
        safe_answer(call, "No projects.", alert=True)
        return
    kb = types.InlineKeyboardMarkup(row_width=1)
    for p in projects:
        status_icon = "▶" if p['status'] == 'running' else "■"
        kb.add(types.InlineKeyboardButton(
            f"{status_icon} {p['name'][:30]}",
            callback_data=f"adm_file_{p['id']}"
        ))
    kb.add(types.InlineKeyboardButton("← Back", callback_data=f"adm_user_{target_id}"))
    safe_edit(call,
              ui.header("USER FILES") + f"<b>Owner ID:</b> <code>{target_id}</code>\n{len(projects)} project(s)",
              reply_markup=kb)


def _adm_usersub(call, target_id: int) -> None:
    safe_edit(call,
              ui.header("CHANGE SUBSCRIPTION") + f"<b>User:</b> <code>{target_id}</code>",
              reply_markup=ui.sub_change_kb(target_id))


def _adm_setplan(call, target_id: int, plan: str) -> None:
    db.upsert_subscription(target_id, plan)
    safe_answer(call, f"Plan set to {plan}.")
    _adm_user_detail(call, target_id)


def _adm_lock_toggle(call) -> None:
    current = db.get_setting('bot_locked')
    new_val = '0' if current == '1' else '1'
    db.set_setting('bot_locked', new_val)
    state = "LOCKED" if new_val == '1' else "UNLOCKED"
    safe_answer(call, f"Bot {state}.", alert=True)
    bot.send_message(call.message.chat.id,
                     ui.header("BOT SETTINGS") + f"<b>Status:</b> <code>{state}</code>",
                     reply_markup=ui.admin_main_kb())


def _adm_broadcast_prompt(call, uid: int) -> None:
    safe_edit(call,
              ui.header("BROADCAST") +
              "<b>Send your broadcast message now.</b>\n"
              "Supports: text, photo, video, document.\n\n"
              "<i>Next message you send will be broadcast.</i>",
              reply_markup=ui.back_kb("adm_system"))
    _broadcast_state[uid] = True


_broadcast_state: dict = {}


@bot.message_handler(func=lambda m: _broadcast_state.get(m.from_user.id))
def handle_broadcast(msg: types.Message) -> None:
    uid = msg.from_user.id
    if not is_admin(uid):
        return
    _broadcast_state.pop(uid, None)

    users = db.get_all_users()
    sent = 0
    failed = 0
    blocked = 0

    def do_broadcast():
        nonlocal sent, failed, blocked
        for user in users:
            if user['is_banned']:
                blocked += 1
                continue
            try:
                if msg.content_type == 'text':
                    bot.send_message(user['user_id'], msg.text or '')
                elif msg.content_type == 'photo':
                    bot.send_photo(user['user_id'], msg.photo[-1].file_id,
                                   caption=msg.caption or '')
                elif msg.content_type == 'video':
                    bot.send_video(user['user_id'], msg.video.file_id,
                                   caption=msg.caption or '')
                elif msg.content_type == 'document':
                    bot.send_document(user['user_id'], msg.document.file_id,
                                      caption=msg.caption or '')
                sent += 1
            except Exception as e:
                err = str(e).lower()
                if 'blocked' in err or 'deactivated' in err:
                    blocked += 1
                else:
                    failed += 1

        bot.send_message(
            uid,
            ui.header("BROADCAST COMPLETE") +
            f"<b>Total:</b> {len(users)}\n"
            f"<b>Sent:</b> {sent}\n"
            f"<b>Failed:</b> {failed}\n"
            f"<b>Blocked:</b> {blocked}"
        )

    threading.Thread(target=do_broadcast, daemon=True).start()
    bot.send_message(uid, "<i>Broadcast initiated in background.</i>")


# ── admin commands ─────────────────────────────────────────────────────────

@bot.message_handler(commands=['addadmin'])
def cmd_addadmin(msg: types.Message) -> None:
    if not is_owner(msg.from_user.id):
        bot.send_message(msg.chat.id, "<b>Owner only.</b>")
        return
    parts = msg.text.split()
    if len(parts) < 2 or not parts[1].isdigit():
        bot.send_message(msg.chat.id, "Usage: /addadmin &lt;user_id&gt;")
        return
    target = int(parts[1])
    if target == OWNER_ID:
        bot.send_message(msg.chat.id, "Owner is always admin.")
        return
    db.add_admin(target, msg.from_user.id)
    bot.send_message(msg.chat.id, f"<b>Admin added:</b> <code>{target}</code>")


@bot.message_handler(commands=['removeadmin'])
def cmd_removeadmin(msg: types.Message) -> None:
    if not is_owner(msg.from_user.id):
        bot.send_message(msg.chat.id, "<b>Owner only.</b>")
        return
    parts = msg.text.split()
    if len(parts) < 2 or not parts[1].isdigit():
        bot.send_message(msg.chat.id, "Usage: /removeadmin &lt;user_id&gt;")
        return
    target = int(parts[1])
    if target == OWNER_ID:
        bot.send_message(msg.chat.id, "<b>Cannot remove owner.</b>")
        return
    db.remove_admin(target)
    bot.send_message(msg.chat.id, f"<b>Admin removed:</b> <code>{target}</code>")


# ── Flask health server ────────────────────────────────────────────────────

@app.route('/')
def health():
    return f"{BRAND} Online", 200


@app.route('/status')
def status():
    return {
        "status": "online",
        "users": db.get_user_count(),
        "running": db.get_running_count(),
        "locked": db.get_setting('bot_locked') == '1',
    }, 200


def start_flask() -> None:
    app.run(host='0.0.0.0', port=PORT, debug=False, use_reloader=False)


# ── graceful shutdown ──────────────────────────────────────────────────────

def shutdown(signum=None, frame=None) -> None:
    logger.info("Shutdown signal received. Stopping all processes...")
    pm.stop_all()
    release_lock()
    logger.info("Clean shutdown complete.")
    sys.exit(0)


signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)


# ── entry point ────────────────────────────────────────────────────────────

def main() -> None:
    # single-instance guard
    if not acquire_lock():
        logger.critical("Another instance is already running. Exiting immediately.")
        sys.exit(1)

    # init
    db.init_db()
    pm.sync_status_on_startup()

    # Flask in daemon thread
    flask_thread = threading.Thread(target=start_flask, daemon=True)
    flask_thread.start()
    logger.info(f"Health server running on port {PORT}")

    # remove existing webhook before polling
    try:
        bot.remove_webhook()
        logger.info("Webhook cleared.")
    except Exception as e:
        logger.warning(f"Could not clear webhook: {e}")

    logger.info(f"{BRAND} v{config.VERSION} starting polling...")

    try:
        bot.infinity_polling(
            timeout=30,
            long_polling_timeout=20,
            allowed_updates=['message', 'callback_query'],
            restart_on_change=False,
            skip_pending=True,
        )
    except Exception as e:
        err = str(e)
        if '409' in err or 'Conflict' in err:
            logger.critical("Telegram 409 Conflict: Another polling instance is running. Exiting.")
        else:
            logger.exception(f"Polling fatal error: {e}")
    finally:
        shutdown()


if __name__ == '__main__':
    import config  # noqa — needed for version in main()
    main()