#!/usr/bin/env python3
"""
Discord bridge bot for the TiviMate 5.3.3 panel.

What it does:
  - Connects to Discord as a bot and watches ONE room (channel).
  - Exposes an HTTP API the TV panel calls:
      GET  /health                 -> {"ok": true}
      GET  /login                  -> OAuth2 "Log in with Discord" URL
      GET  /callback?code=...      -> OAuth2 token exchange (user logs in)
      GET  /me?token=...           -> who is logged in (name + avatar)
      GET  /messages?token=...     -> recent messages in the room
      POST /send?token=...         -> post a message as the logged-in user
      GET  /logout?token=...       -> revoke the session
  - Only members of YOUR server can get in. Discord's OAuth2 scope
    "guilds.members.read" lets us verify membership before granting access.

Security:
  - The bot token, OAuth2 client secret, and room ID live ONLY here on the
    server (config.json). They are never in the APK.
  - The TV never sees the bot token. It only holds a short-lived session
    token issued after a successful Discord login.
"""

import asyncio
import io
import json
import os
import secrets
import sqlite3
import time
import urllib.parse

import aiohttp
import discord
from aiohttp import web

# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(BASE_DIR, "config.json"), "r") as f:
    CONFIG = json.load(f)

BOT_TOKEN = CONFIG["bot_token"]
CLIENT_ID = CONFIG["client_id"]
CLIENT_SECRET = CONFIG["client_secret"]
ROOM_ID = int(CONFIG["room_id"])
GUILD_ID = int(CONFIG["guild_id"])
PUBLIC_BASE = CONFIG["public_base"].rstrip("/")
PANEL_BASE = CONFIG.get("panel_base", "").rstrip("/")
HTTP_PORT = int(CONFIG.get("http_port", 8080))

# The OAuth redirect must be a URL the USER'S BROWSER can reach. That's the
# panel's discord.php (publicly hosted), NOT the bot (which is on the Pi
# behind the router). Discord sends the browser back here with ?code=...
OAUTH_REDIRECT = f"{PANEL_BASE}/discord.php"
DISCORD_API = "https://discord.com/api/v10"

# In-memory session store: session_token -> {user_id, name, avatar, expires}
SESSIONS = {}
SESSION_TTL = 60 * 60 * 24  # 24 hours

# ---------------------------------------------------------------------------
# Message persistence (SQLite)
# ---------------------------------------------------------------------------
# Messages are stored on disk so they survive bot restarts and stay visible
# to members who join later (even after the sender logs out). Old messages
# are auto-deleted after MESSAGE_TTL_DAYS days.
DB_PATH = os.path.join(BASE_DIR, "messages.db")
MESSAGE_TTL_DAYS = 1  # auto-delete chat history after 24 hours
MAX_CACHE = 200  # max messages returned to the panel at once


def db_connect():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn


def db_init():
    conn = db_connect()
    conn.execute(
        """
        CREATE TABLE IF NOT EXISTS messages (
            id TEXT PRIMARY KEY,
            author TEXT NOT NULL,
            avatar TEXT NOT NULL DEFAULT '',
            content TEXT NOT NULL,
            ts REAL NOT NULL
        )
        """
    )
    conn.commit()
    conn.close()


def db_add_message(msg_id, author, avatar, content, ts):
    conn = db_connect()
    conn.execute(
        "INSERT OR IGNORE INTO messages (id, author, avatar, content, ts) VALUES (?,?,?,?,?)",
        (str(msg_id), author, avatar, content, ts),
    )
    conn.commit()
    conn.close()


def db_get_messages(limit=MAX_CACHE):
    conn = db_connect()
    rows = conn.execute(
        "SELECT id, author, avatar, content, ts FROM messages ORDER BY ts ASC LIMIT ?",
        (limit,),
    ).fetchall()
    conn.close()
    return [
        {
            "id": r["id"],
            "author": r["author"],
            "avatar": r["avatar"],
            "content": r["content"],
            "ts": r["ts"],
        }
        for r in rows
    ]


def db_cleanup_old():
    """Delete messages older than MESSAGE_TTL_DAYS. Returns count removed."""
    cutoff = time.time() - (MESSAGE_TTL_DAYS * 24 * 60 * 60)
    conn = db_connect()
    cur = conn.execute("DELETE FROM messages WHERE ts < ?", (cutoff,))
    conn.commit()
    removed = cur.rowcount
    conn.close()
    return removed


def db_delete_message(msg_id, author):
    """Delete a message by id, but only if it belongs to the given author.
    Returns True if a row was deleted."""
    conn = db_connect()
    cur = conn.execute(
        "DELETE FROM messages WHERE id = ? AND author = ?",
        (str(msg_id), author),
    )
    conn.commit()
    removed = cur.rowcount
    conn.close()
    return removed > 0


# ---------------------------------------------------------------------------
# Discord client
# ---------------------------------------------------------------------------
intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)


@client.event
async def on_ready():
    print(f"[bot] logged in as {client.user} (id {client.user.id})")
    print(f"[bot] watching room {ROOM_ID}")
    db_init()
    # Run the 7-day cleanup once at startup, then on a schedule.
    removed = db_cleanup_old()
    if removed:
        print(f"[bot] cleaned up {removed} old message(s)")
    client.loop.create_task(cleanup_loop())


async def cleanup_loop():
    """Auto-delete messages older than 7 days, every hour."""
    while True:
        await asyncio.sleep(60 * 60)  # 1 hour
        try:
            removed = db_cleanup_old()
            if removed:
                print(f"[bot] auto-cleaned {removed} old message(s)")
        except Exception as e:
            print(f"[bot] cleanup error: {e}")


@client.event
async def on_message(message):
    if message.channel.id != ROOM_ID:
        return
    if message.author.bot:
        return
    # Build the stored content: the text plus any image attachments. Discord
    # puts images in message.attachments (message.content is empty for a pure
    # image post), so we append them as [IMG]url[/IMG] markers the panel
    # renders as <img>.
    content = message.content or ""
    for att in message.attachments:
        if att.content_type and att.content_type.startswith("image/"):
            content += f"\n[IMG]{att.url}[/IMG]"
    db_add_message(
        str(message.id),
        message.author.display_name,
        str(message.author.display_avatar.url),
        content,
        message.created_at.timestamp(),
    )


# ---------------------------------------------------------------------------
# OAuth2 helpers
# ---------------------------------------------------------------------------
def make_session(user_id, name, avatar):
    token = secrets.token_urlsafe(32)
    SESSIONS[token] = {
        "user_id": user_id,
        "name": name,
        "avatar": avatar,
        "expires": time.time() + SESSION_TTL,
    }
    return token


def get_session(token):
    s = SESSIONS.get(token)
    if not s:
        return None
    if s["expires"] < time.time():
        SESSIONS.pop(token, None)
        return None
    return s


async def fetch_json(session, url, headers=None, params=None):
    async with session.get(url, headers=headers, params=params) as resp:
        return resp.status, await resp.json()


# ---------------------------------------------------------------------------
# HTTP handlers
# ---------------------------------------------------------------------------
async def handle_health(request):
    return web.json_response({"ok": True, "bot": str(client.user) if client.user else None})


async def handle_login(request):
    params = {
        "client_id": CLIENT_ID,
        "redirect_uri": OAUTH_REDIRECT,
        "response_type": "code",
        "scope": "identify guilds guilds.members.read",
        "prompt": "consent",
    }
    url = f"{DISCORD_API}/oauth2/authorize?" + urllib.parse.urlencode(params)
    return web.json_response({"login_url": url})


async def handle_callback(request):
    code = request.query.get("code")
    if not code:
        return web.json_response({"error": "missing code"}, status=400)

    data = {
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": OAUTH_REDIRECT,
    }
    headers = {"Content-Type": "application/x-www-form-urlencoded"}

    async with aiohttp.ClientSession() as session:
        async with session.post(f"{DISCORD_API}/oauth2/token", data=data, headers=headers) as resp:
            if resp.status != 200:
                body = await resp.text()
                return web.json_response({"error": "token exchange failed", "detail": body}, status=400)
            token_data = await resp.json()

        access_token = token_data["access_token"]
        auth_headers = {"Authorization": f"Bearer {access_token}"}

        # 1) Who is the user?
        status, me = await fetch_json(session, f"{DISCORD_API}/users/@me", headers=auth_headers)
        if status != 200:
            return web.json_response({"error": "could not fetch user"}, status=400)

        # 2) Are they a member of YOUR server? (the security gate)
        status, guilds = await fetch_json(session, f"{DISCORD_API}/users/@me/guilds", headers=auth_headers)
        if status != 200:
            return web.json_response({"error": "could not fetch guilds"}, status=400)

        member_of_guild = any(g["id"] == str(GUILD_ID) for g in guilds)
        if not member_of_guild:
            return web.json_response(
                {"error": "not_a_member",
                 "message": "You are not a member of this Discord server."},
                status=403,
            )

        name = me.get("username", "Unknown")
        avatar = f"https://cdn.discordapp.com/avatars/{me['id']}/{me['avatar']}.png" if me.get("avatar") else ""
        token = make_session(me["id"], name, avatar)
        return web.json_response({"token": token, "name": name, "avatar": avatar})


async def handle_me(request):
    s = get_session(request.query.get("token", ""))
    if not s:
        return web.json_response({"error": "unauthorized"}, status=401)
    return web.json_response({"name": s["name"], "avatar": s["avatar"]})


async def handle_messages(request):
    s = get_session(request.query.get("token", ""))
    if not s:
        return web.json_response({"error": "unauthorized"}, status=401)
    return web.json_response({"messages": db_get_messages()})


async def handle_members(request):
    s = get_session(request.query.get("token", ""))
    if not s:
        return web.json_response({"error": "unauthorized"}, status=401)
    # Only show members who are CURRENTLY logged in via the app (active
    # sessions). We do NOT list voice-channel members or recent Discord
    # chatters — the sidebar is meant to show who is in the app room now.
    now = time.time()
    members = []
    seen = set()
    for sess in SESSIONS.values():
        if sess["expires"] < now:
            continue
        uid = sess.get("user_id")
        if uid in seen:
            continue
        seen.add(uid)
        members.append({"name": sess.get("name", "Unknown"), "avatar": sess.get("avatar", "")})
    return web.json_response({"members": members})


async def handle_send(request):
    s = get_session(request.query.get("token", ""))
    if not s:
        return web.json_response({"error": "unauthorized"}, status=401)

    body = await request.json()
    content = (body.get("content") or "").strip()
    if not content:
        return web.json_response({"error": "empty message"}, status=400)
    if len(content) > 2000:
        return web.json_response({"error": "message too long"}, status=400)

    channel = client.get_channel(ROOM_ID)
    if channel is None:
        return web.json_response({"error": "room not found"}, status=500)

    # Post as the bot, but prefix with the user's name so it's clear who said it.
    msg = await channel.send(f"**{s['name']}:** {content}")
    # Persist it so it shows up immediately and survives restarts.
    # (on_message skips bot messages, so without this the user's own message
    # would never be stored.)
    db_add_message(
        str(msg.id),
        s["name"],
        s.get("avatar", ""),
        content,
        msg.created_at.timestamp(),
    )
    return web.json_response({"ok": True})


async def handle_delete(request):
    s = get_session(request.query.get("token", ""))
    if not s:
        return web.json_response({"error": "unauthorized"}, status=401)

    msg_id = request.query.get("id", "")
    if not msg_id:
        return web.json_response({"error": "missing id"}, status=400)

    # Only the author can delete their own message.
    if not db_delete_message(msg_id, s["name"]):
        return web.json_response({"error": "not found or not yours"}, status=404)

    # Also delete the message from Discord (the bot posted it, so it can
    # delete it). Best-effort — if it fails, the DB row is already gone.
    try:
        channel = client.get_channel(ROOM_ID)
        if channel is not None:
            msg = await channel.fetch_message(int(msg_id))
            await msg.delete()
    except Exception:
        pass

    return web.json_response({"ok": True})


async def handle_logout(request):
    token = request.query.get("token", "")
    SESSIONS.pop(token, None)
    return web.json_response({"ok": True})


# ---------------------------------------------------------------------------
# Speech-to-text (voice input)
# ---------------------------------------------------------------------------
# The APK records audio from the device mic and POSTs it here as a WAV. We
# transcribe it with faster-whisper (runs locally on the Pi, no cloud, no
# API key) and return the text. This BYPASSES Alexa entirely — the Fire TV
# system recognizer routes to Alexa and talks back, so we do our own STT.
_whisper_model = None


def get_whisper_model():
    global _whisper_model
    if _whisper_model is None:
        from faster_whisper import WhisperModel
        # "tiny" is fast on a Pi; "base" is more accurate but slower.
        _whisper_model = WhisperModel("tiny", device="cpu", compute_type="int8")
    return _whisper_model


async def handle_transcribe(request):
    # No auth needed — the bot is only reachable through the tunnel, and the
    # audio is just a short voice clip. (Keeps the APK simple.)
    body = await request.read()
    if not body:
        return web.json_response({"text": ""}, status=400)

    def _run():
        model = get_whisper_model()
        segments, _info = model.transcribe(io.BytesIO(body), language=None)
        return "".join(s.text for s in segments).strip()

    try:
        text = await asyncio.get_event_loop().run_in_executor(None, _run)
    except Exception as e:
        print(f"[bot] transcribe error: {e}")
        return web.json_response({"text": ""}, status=500)
    return web.json_response({"text": text})


# ---------------------------------------------------------------------------
# App wiring
# ---------------------------------------------------------------------------
@web.middleware
async def cors_middleware(request, handler):
    # Allow the panel page (your panel host) to call this bot cross-origin.
    if request.method == "OPTIONS":
        return web.Response(status=204, headers={
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
            "Access-Control-Allow-Headers": "Content-Type, Authorization",
        })
    resp = await handler(request)
    resp.headers["Access-Control-Allow-Origin"] = "*"
    resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
    resp.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
    return resp


def make_app():
    app = web.Application(middlewares=[cors_middleware])
    app.router.add_get("/health", handle_health)
    app.router.add_get("/login", handle_login)
    app.router.add_get("/callback", handle_callback)
    app.router.add_get("/me", handle_me)
    app.router.add_get("/messages", handle_messages)
    app.router.add_get("/members", handle_members)
    app.router.add_post("/send", handle_send)
    app.router.add_get("/delete", handle_delete)
    app.router.add_get("/logout", handle_logout)
    app.router.add_post("/transcribe", handle_transcribe)
    return app


async def main():
    # Ensure the DB table exists before the HTTP server starts, so /messages
    # and /send work even before the Discord client has connected.
    db_init()
    app = make_app()
    runner = web.AppRunner(app)
    await runner.setup()
    site = web.TCPSite(runner, "0.0.0.0", HTTP_PORT)
    await site.start()
    print(f"[http] listening on :{HTTP_PORT}")
    await client.start(BOT_TOKEN)


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print("\n[bot] stopped")
