# This Python file uses the following encoding: utf-8
# trunk-ignore-all(bandit/B311)
# pylint: disable=line-too-long
# pylint: disable=too-many-lines
"""
Module of a python bot named Conjurer - used to work on BDSM discord servers.

Startup is defensive by design:

* every cog is loaded independently - one broken/missing dependency disables
  that cog only, never the whole bot,
* cogs that need a sibling service (musician / librarian) are only enabled
  after a positive health check; a watchdog keeps re-checking and enables them
  the moment the service comes alive,
* fatal misconfiguration (missing Discord token) exits loudly on stderr with a
  clear message instead of dying silently into a log file.
"""
import asyncio
import logging
import os
import sys

# *=========================================== Standard Library Imports
import random
import threading
from logging import handlers

# *==============Imported libraries
import discord
import requests
from discord.ext import commands

from communication_subroutine import comm_subroutine
from constants import (
    ENCODING,
    FILE_SERVICE_ADDRESS,
    GET_MP3,
    LIBRARIAN_SERVICE_ADDRESS,
    LOGFILE,
    RADIO_SERVICE_ADDRESS,
    TOKEN,
)

logger = logging.getLogger("discord")
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")

# File log (rotated). constants ensures the directory exists; this is belt and
# braces for exotic overrides.
os.makedirs(os.path.dirname(LOGFILE) or ".", exist_ok=True)
handler = handlers.RotatingFileHandler(
    filename=LOGFILE,
    encoding=ENCODING,
    mode="a",
    maxBytes=6 * 1024 * 1024,
    backupCount=6,
    )
handler.setFormatter(formatter)
logger.addHandler(handler)

# Console log so `docker logs` / journalctl actually show what happened.
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# Some dependency calls logging.basicConfig(), adding a root handler; without
# this the 'discord' logger's records get printed twice (our format + root's).
logger.propagate = False

# *=========================================== Initializations
intents = discord.Intents.default()
intents.message_content = True
intents.typing = True
intents.presences = True
intents.members = True
intents.messages = True
intents.voice_states = True
intents.moderation = True

# on_member_ban - wyswietl na glownym kanale pieczatke "Niech spierdala"
# on_member_unban - "mam wyjebane"

random.seed()
client = commands.Bot(intents=intents, command_prefix="$")

# *=========================================== Extension groups
# Core cogs depend on nothing but the bot itself (broken ones are skipped
# individually). Service groups are gated on a health check of the service
# they talk to and enabled later by the watchdog when the service appears.
CORE_EXTENSIONS = [
    "administration_commands",
    "ai_commands",
    "other_commands",
    "latex_commands",
    "voice_recognition_commands",
    "conanjurer_commands",
]

SERVICE_EXTENSION_GROUPS = {
    # musician (file service): Discord music download/search, file shares
    "musician": {
        "health_url": f"{FILE_SERVICE_ADDRESS}{GET_MP3}",
        "extensions": ["music_commands", "file_search_commands"],
    },
    # betoniarka (radio operator colocated with Liquidsoap): radio playlists
    "radio": {
        "health_url": f"{RADIO_SERVICE_ADDRESS}/ping",
        "extensions": ["radio_commands"],
    },
    # librarian: DOI / Crossref search
    "librarian": {
        "health_url": f"{LIBRARIAN_SERVICE_ADDRESS}/",
        "extensions": ["librarian_commands"],
    },
}

SERVICE_RECHECK_SECONDS = 300


def _service_alive(url: str) -> bool:
    """True when the service answers HTTP at all (any status code counts)."""
    try:
        requests.get(url, timeout=3)
        return True
    except requests.exceptions.RequestException:
        return False


async def _load_extension_safe(name: str) -> bool:
    """Load one extension; log and continue instead of killing startup."""
    if name in client.extensions:
        return True
    try:
        await client.load_extension(name)
        logger.info("Extension loaded: %s", name)
        return True
    except Exception:  # pylint: disable=broad-exception-caught
        logger.exception("Extension FAILED (disabled, bot continues): %s", name)
        return False


async def _load_service_groups() -> bool:
    """Health-check each service group and load its cogs when alive.

    Returns True when anything new was loaded (caller may want to re-sync).
    """
    loaded_any = False
    for service, group in SERVICE_EXTENSION_GROUPS.items():
        missing = [e for e in group["extensions"] if e not in client.extensions]
        if not missing:
            continue
        alive = await asyncio.to_thread(_service_alive, group["health_url"])
        if not alive:
            logger.warning(
                "Service '%s' unreachable (%s) - cogs stay disabled: %s",
                service,
                group["health_url"],
                ", ".join(missing),
            )
            continue
        logger.info("Service '%s' is alive - enabling: %s", service, ", ".join(missing))
        for extension in missing:
            if await _load_extension_safe(extension):
                loaded_any = True
    return loaded_any


async def _sync_tree() -> None:
    try:
        await client.tree.sync()
    except Exception:  # pylint: disable=broad-exception-caught
        logger.exception("Slash-command tree sync failed (commands may lag)")


async def _service_watchdog() -> None:
    """Periodically retry offline services and enable their cogs when up."""
    while not client.is_closed():
        await asyncio.sleep(SERVICE_RECHECK_SECONDS)
        try:
            if await _load_service_groups():
                await _sync_tree()
        except Exception:  # pylint: disable=broad-exception-caught
            logger.exception("Service watchdog tick failed")


_STARTUP_DONE = False


# *=========================================== Define Events
@client.event
async def on_ready():
    """Metoda wywoływana przy połączeniu do serwera."""
    global _STARTUP_DONE  # pylint: disable=global-statement
    logger.info("%s has connected to Discord!", client.user)
    if _STARTUP_DONE:
        logger.info("Reconnected - extensions already loaded")
        return
    _STARTUP_DONE = True
    logger.info("Reactor: online")

    for extension in CORE_EXTENSIONS:
        await _load_extension_safe(extension)

    await _load_service_groups()
    logger.info("Sensors: online")

    logger.info(client.cogs)
    await _sync_tree()
    for com in client.commands:
        logger.info("Command %s is awejleble", com.qualified_name)

    asyncio.create_task(_service_watchdog())

    logger.info("Logged in as ----> %s", client.user)
    logger.info("ID:%s ", client.user.id)
    logger.info("All systems: operational")


# *=========================================== Runtime orchestration
# The legacy bootstrap used two bare threads (client.run + comm_subroutine) and
# joined them, which made a clean shutdown impossible.  We now drive everything
# from a single asyncio loop: the Flask comm layer still runs in its own
# threads (via asyncio.to_thread) but is steered through a shared stop_event so
# the bot can stop both halves cooperatively.


async def _run_comm_subroutine(stop_event: threading.Event) -> None:
    """Run the blocking comm subroutine in a worker thread.

    A crash here takes down the internal HTTP endpoints only - the Discord
    side keeps running, so log loudly and swallow.
    """
    try:
        await asyncio.to_thread(comm_subroutine, stop_event)
    except Exception:  # pylint: disable=broad-exception-caught
        logger.exception(
            "Comm layer crashed - internal HTTP endpoints are down "
            "(musician/librarian callbacks will not arrive); bot continues"
        )


async def _run_bot(token: str, shutdown_event: asyncio.Event) -> None:
    """Start the Discord client; log WHY it died before flagging shutdown.

    Exceptions were previously swallowed by ``gather(return_exceptions=True)``
    which made a failed login look like a clean exit (silent crash-loop in
    docker). Now every death is diagnosed on the console first.
    """
    try:
        # NOTE: log_handler is a Client.run()-only kwarg (run() configures
        # logging, then calls start()); start() takes just the token. We set
        # up our own handlers above, so nothing is lost.
        await client.start(token)
    except discord.LoginFailure:
        logger.critical(
            "FATAL: Discord REJECTED the token (Improper token). Fix the "
            "'discord' entry in the netrc mounted at CONJURER_NETRC_FILE or "
            "the DISCORD_TOKEN env var. If the token leaked/reset, generate a "
            "new one in the Discord Developer Portal -> Bot -> Reset Token."
        )
        raise
    except discord.PrivilegedIntentsRequired:
        logger.critical(
            "FATAL: this bot application does not have the Privileged Gateway "
            "Intents enabled. Open Discord Developer Portal -> your app -> "
            "Bot -> enable 'Presence', 'Server Members' and 'Message Content' "
            "Intents, then restart."
        )
        raise
    except Exception:  # pylint: disable=broad-exception-caught
        logger.exception("FATAL: Discord client crashed at startup/runtime")
        raise
    finally:
        shutdown_event.set()


async def main() -> int:
    """Run both halves; return a process exit code (0 = clean shutdown)."""
    if TOKEN:
        token_source = (
            "env DISCORD_TOKEN" if os.getenv("DISCORD_TOKEN") else "netrc file"
        )
        logger.info(
            "Discord token: loaded from %s (length %d)", token_source, len(TOKEN)
        )

    logger.info("Starting discord bot")
    shutdown_event = asyncio.Event()
    comm_stop_event = threading.Event()

    comm_task = asyncio.create_task(_run_comm_subroutine(comm_stop_event))
    bot_task = asyncio.create_task(_run_bot(TOKEN, shutdown_event))

    exit_code = 0
    try:
        await shutdown_event.wait()
    except (KeyboardInterrupt, asyncio.CancelledError):
        logger.info("Shutdown signal received")
        comm_stop_event.set()
        await client.close()
    finally:
        comm_stop_event.set()
        if not client.is_closed():
            await client.close()
        results = await asyncio.gather(bot_task, comm_task, return_exceptions=True)
        for result in results:
            if isinstance(result, BaseException) and not isinstance(
                result, asyncio.CancelledError
            ):
                # Already logged with full traceback inside the task; repeat
                # the one-liner so it is the LAST thing in `docker logs`.
                logger.critical("Task died: %r", result)
                exit_code = 1
    return exit_code


# *================================== Run
if __name__ == "__main__":
    if not TOKEN:
        # Loud, unmissable and in `docker logs`: this is THE most common cause
        # of a silent container crash-loop.
        MSG = (
            "FATAL: Discord token missing.\n"
            "Provide it via the DISCORD_TOKEN environment variable or a netrc "
            "file (machine 'discord') at the path in CONJURER_NETRC_FILE.\n"
            "Docker: check that your secrets mount exists, e.g.\n"
            "  -v /srv/conjurer/secrets/.netrc:/secrets/.netrc:ro\n"
            "  CONJURER_NETRC_FILE=/secrets/.netrc"
        )
        logger.critical(MSG)
        sys.exit(MSG)
    sys.exit(asyncio.run(main()))
