"""
botscript.py

Main Discord bot module. Defines the MyBot class and all slash commands including
squadron comparison, player statistics, leaderboards, notifications, metadata management,
translations, and administrative functions with interactive UI components.
"""

# Standard Library Imports
import asyncio
import gzip
import json
import logging
import math
import os
import re
import time as time_module
import traceback
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional, cast

# Third-Party Library Imports
import aiofiles
import aiosqlite
import deepl
import discord
import matplotlib
matplotlib.use('Agg')  # Use non-interactive backend
import matplotlib.cm as cm  # noqa: E402
import matplotlib.dates as mdates  # noqa: E402
import matplotlib.pyplot as plt  # noqa: E402
import numpy as np
from discord import Color, Embed, app_commands
from discord.ext import commands
from discord.ui import Select, View, button
from dotenv import load_dotenv
import matplotlib.patheffects as path_effects  # noqa: E402
from matplotlib.collections import LineCollection
from matplotlib.colors import TwoSlopeNorm

# Local Module Imports (relative imports within BOT package)
from . import utils
from .analytics import (
    get_map_stats, get_comp_analysis, get_player_consistency,
    get_time_performance, get_matchup_history,
)
from .autologging import init_players_db, load_replay_data_from_disk, build_scoreboard_view
from data_parser import LangTableReader, count_unit_types, get_unit_type_abbrev, normalize_name
from shared_store import get_linked_uid, save_player_link
from .game_api import (
    ClanInfoError,
    obtain_clan_info_api,
    obtain_clan_new_points,
    obtain_clans_leaderboard,
)
from .health import init_health, get_health_snapshot, get_recent_ttl_stats
from .utils import t, guild_lang
from .lux_apis import fetch_replay_by_id
from .meta_manager import (
    add_player_to_guild_meta,
    bulk_add_squadron_players_to_guild_meta,
    create_or_update_guild,
    get_guild_meta_players,
    get_guild_settings,
    get_squadron_owner,
    init_meta_db,
    refresh_guild_player_vehicles,
    remove_player_from_guild_meta,
    search_guild_meta_by_vehicle,
    transfer_squadron_to_guild,
    update_guild_settings,
    update_squadron_password,
    validate_squadron_password,
)
from .scoreboard import create_scoreboard
from .stack_manager import register_commands as register_stack_commands
from .tasks import start_all_tasks
from . import utils as _utils
from .utils import (
    STORAGE_DIR,
    ICONS_DIR,
    SQ_BATTLES_DB_PATH,
    SQUADRONS_DB_PATH,
    COMMAND_DATA_DB_PATH,
    DISCORD_SKU_ID_STANDARD,
    ENTITLEMENTS_DB_PATH,
    TOKEN,
    DEFAULT_FOOTER_CAT,
    compress_json,
    decompress_json,
    init_game_cache,
    init_vehicle_translation_cache,
    invalidate_entitled_guilds_cache,
    is_admin,
    is_blacklisted,
    gate_entitle,
    is_guild_entitled,
    get_guild_tier,
    permission_fail,
    refresh_entitled_guilds,
    tier_cap,
    tier_enforcement_active,
    tier_allows_wildcard,
    TIER_ORDER,
    DISCORD_SKU_ID_PRO,
    DISCORD_SKU_ID_MAX,
    enabled_pref_keys_for,
    enabled_non_wildcard_keys_for,
    allowed_pref_keys_for,
    is_notif_enabled,
    WILDCARD_KEYS,
    resolve_clan,
    resolve_clan_id,
    resolve_clans,
    get_guild_squadron,
    load_guild_preferences,
    save_guild_preferences,
    load_features,
    save_features,
    load_json,
    write_json,
    set_bot,
    esc,
    init_command_stats_db,
    collect_command_stats,
    command_locale,
    is_dev_team,
    LocaleJsonTranslator,
    COMP_FREE_UNTIL_TS,
    COMP_LIMIT_PER_TIMESLOT,
    COMP_LIMIT_PER_USER_PER_TIMESLOT,
    get_current_timeslot_start_ts,
    get_comp_usage_in_timeslot,
    get_comp_usage_in_timeslot_by_user,
    get_entitled_guild_ids,
    SQB_SLOTS_POSTED,
    RECAP_LANGS,
    RecapError,
    get_player_recap,
    get_seasons,
    get_squadron_recap,
    replay_session_dir,
)
from .wl import wl_bootstrap

# Load environment variables from .env file
load_dotenv()

logging.basicConfig(level=logging.INFO)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("hpack").setLevel(logging.WARNING)
logging.getLogger("deepl").setLevel(logging.WARNING)

intents = discord.Intents.default()
intents.message_content = False
intents.reactions = True
intents.messages = True

class MyBot(commands.Bot):
    """Custom Discord bot subclass for SRE Bot.

    Extends commands.Bot with a sync guard to prevent duplicate
    slash-command tree syncs on reconnect.
    """

    def __init__(self):
        """Initialize the bot with default prefix and configured intents."""
        super().__init__(command_prefix='~', intents=intents)
        self.synced = False

    async def setup_hook(self):
        """Sync the slash-command tree with Discord on first startup."""
        await self.tree.set_translator(LocaleJsonTranslator())
        await self.tree.sync()
        self.synced = True

bot = MyBot()
set_bot(bot)  # Register bot globally for other modules
register_stack_commands(bot)


@bot.tree.error
async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
    """Fallback slash-command error handler for commands without local handlers."""
    await permission_fail(interaction, error)



@bot.event
async def on_ready():
    """Handle bot startup: write guild report, refresh presence, init caches, and start tasks."""
    GUILD_TOTAL = len(bot.guilds)

    out_path = STORAGE_DIR / "SREBOT_GUILDS.txt"
    with out_path.open("w", encoding="utf-8") as f:
        f.write(f"We have logged in as {bot.user} in the following Guilds:\n\n")
        for guild in bot.guilds:
            created = guild.created_at.strftime("%Y-%m-%d")
            f.write(
                f" - {guild.name} (id: {guild.id}) | Members: {guild.member_count} | Created: {created}\n"
            )

    logging.info(f"Guild report written to {out_path.resolve()}")
    logging.info(f' - Total Guilds: {GUILD_TOTAL}')

    await _refresh_presence()

    if not bot.synced:
        await bot.tree.sync()
        bot.synced = True

    try:
        await init_game_cache()
        logging.info("Initialized game cache . . .")
    except Exception as e:
        logging.error(f"Error initializing game cache in startup: {e}")

    try:
        await init_vehicle_translation_cache()
    except Exception as e:
        logging.error(f"Error initializing vehicle translation cache in startup: {e}")

    try:
        await refresh_entitled_guilds(force=True)
        logging.info("Initialized entitlements cache")
    except Exception as e:
        logging.error(f"Error initializing entitlements cache: {e}")

    try:
        await cast(Callable[[], Awaitable[None]], init_meta_db)()
        logging.info("Initialized Meta.db . . .")
    except Exception as e:
        logging.error(f"Error initializing Meta.db in startup: {e}")

    try:
        async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as conn:
            await init_players_db(conn)
        logging.info("Initialized sq_battles.db . . .")
    except Exception as e:
        logging.error(f"Error initializing sq_battles.db in startup: {e}")

    try:
        await init_command_stats_db()
    except Exception as e:
        logging.error(f"Error initializing COMMAND_DATA.db in startup: {e}")

    try:
        # Initialize health monitoring
        init_health(started_at=time_module.time(), guild_count=GUILD_TOTAL)

        # Start all background tasks from tasks.py
        await cast(Callable[[], Awaitable[None]], start_all_tasks)()

        # Start the W/L queue consumer
        wl_bootstrap()

        logging.info("Engines 1-3 are a go . . .")
        logging.info("We have liftoff ! ! !")

    except Exception as e:
        logging.error(f"Error starting tasks in startup: {e}")
    

@bot.event
async def on_guild_join(guild):
    """Handle joining a new guild: update presence and send setup hint.

    Args:
        guild: The Discord guild the bot just joined.
    """
    GUILD_TOTAL = len(bot.guilds)

    logging.info(f"Joined new guild: {guild.name} (id: {guild.id})")
    logging.info(f"Updated total guilds: {GUILD_TOTAL}")

    try:
        await _refresh_presence()
    except Exception as e:
        logging.error(f"Error changing bot status after guild join: {e}")

    # Send setup hint to system channel or first available text channel
    try:
        target = guild.system_channel
        if not target:
            for ch in guild.text_channels:
                if ch.permissions_for(guild.me).send_messages:
                    target = ch
                    break
        if target:
            lang = 'en'
            embed = Embed(
                title=t(lang, "events.guild_join_title"),
                description=t(lang, "events.guild_join_desc"),
                color=Color.blue()
            )
            embed.set_footer(text=DEFAULT_FOOTER_CAT)
            await target.send(embed=embed)
    except Exception as e:
        logging.error(f"Error sending setup hint to guild {guild.id}: {e}")


@bot.event
async def on_guild_remove(guild):
    """Handle being removed from a guild: update presence.

    Args:
        guild: The Discord guild the bot was removed from.
    """
    GUILD_TOTAL = len(bot.guilds)

    logging.info(f"Removed from guild: {guild.name} (id: {guild.id})")
    logging.info(f"Updated total guilds: {GUILD_TOTAL}")

    try:
        await _refresh_presence()
    except Exception as e:
        logging.error(f"Error changing bot status after guild removal: {e}")


@bot.event
async def on_entitlement_create(entitlement):
    """Invalidate the entitlement cache when a Discord SKU subscription is created."""
    invalidate_entitled_guilds_cache()
    logging.info(f"[PREMIUM] Discord entitlement created for guild {entitlement.guild_id}, cache invalidated")


async def _refresh_presence():
    """Update the bot's Discord presence to show the current guild count."""
    logging.info("Refreshing status . . .")
    GUILD_TOTAL = len(bot.guilds)

    await bot.change_presence(
        activity=discord.Activity(
            type=discord.ActivityType.playing,
            name=f"Playing SQB in {GUILD_TOTAL} servers!"
        )
    )

async def squadron_autocomplete(
    interaction: discord.Interaction,
    current: str,
) -> List[discord.app_commands.Choice[str]]:
    """Autocomplete for squadron short names. Shared across all squadron-param commands."""
    try:
        async with aiosqlite.connect(SQUADRONS_DB_PATH) as db:
            await db.create_function("ulower", 1, str.lower)
            if not current:
                async with db.execute(
                    """SELECT short_name, long_name FROM squadrons_data
                       WHERE short_name IS NOT NULL
                       ORDER BY position ASC NULLS LAST
                       LIMIT 25"""
                ) as cursor:
                    rows = await cursor.fetchall()
            else:
                search = f"%{current}%"
                async with db.execute(
                    """SELECT short_name, long_name FROM squadrons_data
                       WHERE short_name IS NOT NULL
                         AND (ulower(short_name) LIKE ulower(?)
                              OR ulower(long_name) LIKE ulower(?)
                              OR ulower(tag_name) LIKE ulower(?))
                       ORDER BY
                         CASE WHEN ulower(short_name) = ulower(?) THEN 0
                              WHEN ulower(short_name) LIKE ulower(?) THEN 1
                              ELSE 2
                         END,
                         position ASC NULLS LAST
                       LIMIT 25""",
                    (search, search, search, current, f"{current}%")
                ) as cursor:
                    rows = await cursor.fetchall()

        return [
            discord.app_commands.Choice(
                name=row[0],
                value=row[0]
            )
            for row in rows
        ]
    except Exception:
        return []


@is_blacklisted()
@bot.tree.command(name='comp', description=command_locale('Find the last known comps for a given team', "commands.comp.description"))
@app_commands.describe(squadron_short=command_locale('The shortname of the enemy team', "commands.comp.squadron_short"))
@discord.app_commands.autocomplete(squadron_short=squadron_autocomplete)
async def comp(interaction: discord.Interaction, squadron_short: str):
    """Show the last known team compositions for a squadron.

    Loads comp data from the COMPS JSON directory, filters to comps seen in the
    last hour, sorts by recency, and displays each comp with player-vehicle
    lists and unit-type notation (e.g. 2F / 1T / 1AA).

    Args:
        interaction: The Discord interaction.
        squadron_short: Short name of the enemy team.
    """
    guild = interaction.guild
    if guild is None:
        return

    lang = await guild_lang(guild.id)

    await interaction.response.defer()

    # ── Comp usage limit (after free period) ──────────────────────────
    comp_footer = DEFAULT_FOOTER_CAT
    now_ts_check = int(time_module.time())
    if now_ts_check >= COMP_FREE_UNTIL_TS:
        slot_start = get_current_timeslot_start_ts()
        if slot_start is not None:
            entitled = await is_guild_entitled(guild.id)
            if not entitled:
                used_server = await get_comp_usage_in_timeslot(guild.id, slot_start)
                used_user = await get_comp_usage_in_timeslot_by_user(
                    interaction.user.id,
                    slot_start,
                    exclude_guild_ids=get_entitled_guild_ids(),
                )
                logging.info(
                    "[COMP-USAGE] guild=%s user=%s squadron=%s used_server=%s/%s used_user=%s/%s slot_start=%s entitled=%s",
                    guild.id,
                    interaction.user.id,
                    squadron_short,
                    used_server,
                    COMP_LIMIT_PER_TIMESLOT,
                    used_user,
                    COMP_LIMIT_PER_USER_PER_TIMESLOT,
                    slot_start,
                    entitled,
                )
                if used_user >= COMP_LIMIT_PER_USER_PER_TIMESLOT:
                    logging.warning(
                        "[COMP-LIMIT] guild=%s user=%s squadron=%s scope=user used_user=%s/%s slot_start=%s action=blocked",
                        guild.id,
                        interaction.user.id,
                        squadron_short,
                        used_user,
                        COMP_LIMIT_PER_USER_PER_TIMESLOT,
                        slot_start,
                    )
                    embed_limit = discord.Embed(
                        title=t(lang, "comp.limit_reached_title"),
                        description=t(
                            lang,
                            "comp.user_limit_reached_desc",
                            limit=COMP_LIMIT_PER_USER_PER_TIMESLOT,
                        ),
                        color=discord.Color.red()
                    )
                    embed_limit.set_footer(
                        text=t(
                            lang,
                            "comp.user_remaining_footer",
                            remaining=0,
                            limit=COMP_LIMIT_PER_USER_PER_TIMESLOT,
                        )
                    )
                    return await interaction.followup.send(embed=embed_limit)
                if used_server >= COMP_LIMIT_PER_TIMESLOT:
                    logging.warning(
                        "[COMP-LIMIT] guild=%s user=%s squadron=%s scope=server used_server=%s/%s slot_start=%s action=blocked",
                        guild.id,
                        interaction.user.id,
                        squadron_short,
                        used_server,
                        COMP_LIMIT_PER_TIMESLOT,
                        slot_start,
                    )
                    embed_limit = discord.Embed(
                        title=t(lang, "comp.limit_reached_title"),
                        description=t(lang, "comp.limit_reached_desc", limit=COMP_LIMIT_PER_TIMESLOT),
                        color=discord.Color.red()
                    )
                    embed_limit.set_footer(text=t(lang, "comp.remaining_footer", remaining=0, limit=COMP_LIMIT_PER_TIMESLOT))
                    return await interaction.followup.send(embed=embed_limit)
                user_remaining = COMP_LIMIT_PER_USER_PER_TIMESLOT - used_user - 1
                server_remaining = COMP_LIMIT_PER_TIMESLOT - used_server - 1
                logging.info(
                    "[COMP-REMAINING] guild=%s user=%s squadron=%s user_remaining=%s/%s server_remaining=%s/%s slot_start=%s",
                    guild.id,
                    interaction.user.id,
                    squadron_short,
                    user_remaining,
                    COMP_LIMIT_PER_USER_PER_TIMESLOT,
                    server_remaining,
                    COMP_LIMIT_PER_TIMESLOT,
                    slot_start,
                )
                comp_footer = t(
                    lang,
                    "comp.remaining_footer_combined",
                    user_remaining=user_remaining,
                    user_limit=COMP_LIMIT_PER_USER_PER_TIMESLOT,
                    server_remaining=server_remaining,
                    server_limit=COMP_LIMIT_PER_TIMESLOT,
                )

    squadron_short = squadron_short.upper()

    # ── Avg TTL across the last 20 games globally ─────────────────────
    # TTL = received_unix - endtime_unix: how long after a match ended
    # the result was ingested. Not squadron-scoped — this is a server-wide
    # signal, so when Gaijin is slow we can explain why no "recent" comps
    # show up: the games we have are simply older than the freshness cap.
    avg_ttl_seconds: Optional[int] = None
    ttl_sample_size = 0
    try:
        ttl_stats = await get_recent_ttl_stats(limit=20)
        avg_ttl_seconds = ttl_stats["avg_delay"]
        ttl_sample_size = ttl_stats["sample_size"]
    except Exception:
        logging.exception("(COMP-CMD) Failed to compute TTL stats")

    if avg_ttl_seconds is not None:
        a_min, a_sec = divmod(avg_ttl_seconds, 60)
        if avg_ttl_seconds > 3600:
            ttl_icon = " 🚨"
        elif avg_ttl_seconds > 600:
            ttl_icon = " ⚠️"
        else:
            ttl_icon = ""
        ttl_footer = f"Avg TTL (last {ttl_sample_size}): {a_min}m {a_sec:02d}s{ttl_icon}"
        comp_footer = f"{comp_footer}\n{ttl_footer}" if comp_footer else ttl_footer

    comp_dir   = STORAGE_DIR / "COMPS"
    squadron_key = re.sub(r'^[^A-Za-z0-9]+|[^A-Za-z0-9]+$', '', squadron_short).upper() or squadron_short.upper()
    squad_file = comp_dir / f"{squadron_key}.json"
    if not squad_file.exists():
        return await interaction.followup.send(
            embed=discord.Embed(
                title=t(lang, "comp.not_found_title"),
                description=t(lang, "comp.not_found_desc", squadron=squadron_short),
                color=discord.Color.red()
            )
        )

    # Load all comps
    try:
        async with aiofiles.open(squad_file, "r", encoding="utf-8") as f:
            comps_data = json.loads(await f.read())
        logging.info(f"(COMP-CMD) Loaded {len(comps_data)} comps for {squadron_short}")
    except Exception as e:
        logging.error(f"(COMP-CMD) Failed to load comp file for {squadron_short}: {e}")
        return await interaction.followup.send(
            embed=discord.Embed(
                title=t(lang, "comp.error_loading_title"),
                description=t(lang, "comp.error_loading_desc", error=e),
                color=discord.Color.red()
            )
        )

    # Thresholds
    # Normal freshness window is 1h. When the server-wide avg TTL is high
    # (>50m), recently played comps may not have been ingested yet — extend
    # to 90m so users still see something, and flag any field past 60m with
    # an alarm icon on the age line.
    base_threshold_seconds = 3600  # 1 hour
    extended_threshold_seconds = 5400  # 90 minutes
    ttl_extended = avg_ttl_seconds is not None and avg_ttl_seconds > 3000
    threshold_seconds = extended_threshold_seconds if ttl_extended else base_threshold_seconds
    now_ts            = int(time_module.time())

    embed = discord.Embed(
        title=t(lang, "comp.title", squadron=squadron_short),
        description=t(lang, "comp.desc", minutes=threshold_seconds // 60),
        color=discord.Color.blurple()
    )
    embed.set_footer(text=comp_footer)

    added_any   = False
    comp_index  = 1  # <- start numbering here

    # Sort by most recent (upd or reg)
    for comp_key, comp in sorted(
        comps_data.items(),
        key=lambda kv: kv[1].get("upd") or kv[1].get("reg", 0),
        reverse=True
    ):
        logging.info(f"(COMP-CMD) Processing {comp_key} for {squadron_short}")
        reg_ts       = comp.get("reg", 0)
        upd_ts       = comp.get("upd", 0)
        last_seen_ts = upd_ts or reg_ts
        age          = now_ts - last_seen_ts

        # Skip if too old
        if age > threshold_seconds:
            continue

        # Discord‐style relative timestamps
        reg_str  = f"<t:{reg_ts}:R>"
        last_str = f"<t:{last_seen_ts}:R>"

        # Build player list block with padding
        players = comp.get("Players", [])
        if players:
            _type_order = {"F": 0, "B": 1, "H": 2, "L": 3, "T": 4, "AA": 5, "?": 6}
            players = sorted(players, key=lambda p: _type_order.get(get_unit_type_abbrev(p.get("vehicle_internal")), 6))
            max_nick_len = max(len(p["nick"]) for p in players)
            lines = []
            for p in players:
                try:
                    vehicle_raw = p.get("vehicle")
                    vehicle = normalize_name(vehicle_raw) or "DISCONNECTED"
                    lines.append(f"{p['nick']:<{max_nick_len}} | {vehicle}")
                except Exception as e:
                    logging.error(f"(COMP-CMD) Error processing player {p.get('nick', 'UNKNOWN')} in {comp_key}: {e}, player data: {p}")
                    lines.append(f"{p.get('nick', 'UNKNOWN'):<{max_nick_len}}: ERROR")
            block = "```\n" + "\n".join(lines) + "\n```"
        else:
            block = t(lang, "comp.no_players_recorded")

        # Build comp notation
        if players:
            vehicles = []
            try:
                for p in players:
                    v_internal = p.get("vehicle_internal")
                    if v_internal is None:
                        logging.warning(f"(COMP-CMD) Player {p.get('nick', 'UNKNOWN')} missing vehicle_internal in {comp_key}, player data: {p}")
                    vehicles.append(v_internal)
                    
                notation_list = count_unit_types(vehicles)
            except Exception as e:
                logging.error(f"(COMP-CMD) Error building comp notation for {comp_key}: {e}, vehicles: {vehicles}")
                notation_list = {}
            comp_order    = [
                ("F", "Fighters"), 
                ("B", "Bombers"), 
                ("H", "Helicopters"),
                ("L", "Light"),    
                ("T", "Tanks"),   
                ("AA", "AA"),      
                ("?", "?")
            ]
            summary_parts = [
                f"{notation_list.get(code, 0)}{code}"
                for code, _ in comp_order
                if notation_list.get(code, 0) > 0
            ]
            comp_notation = " / ".join(summary_parts) or "None"
        else:
            comp_notation = "None"

        comp_key_str = str(comp_key) if comp_key is not None else ""
        comp_key_str = comp_key_str.replace("COMP", "")
        squad_key = f"({comp_key_str})"
        comp_title = t(lang, "comp.comp_title", index=comp_index)
        comp_index += 1

        if age > base_threshold_seconds:
            age_icon = ' 🚨'
        elif age > 1200:
            age_icon = ' ⚠️'
        else:
            age_icon = ''
        embed.add_field(
            name=comp_title,
            value=(
                #f"SQ Number {squad_key}\n"
                t(lang, "comp.last_seen_label", timestamp=last_str, warning=age_icon) + "\n"
                + t(lang, "comp.comp_label", notation=comp_notation) + "\n"
                + f"{block}"
            ),
            inline=False
        )
        added_any = True

    if not added_any:
        no_embed = discord.Embed(
            title=t(lang, "comp.no_recent_title"),
            description=t(lang, "comp.no_recent_desc", minutes=threshold_seconds // 60),
            color=discord.Color.red()
        )
        no_embed.set_footer(text=comp_footer)
        return await interaction.followup.send(embed=no_embed)

    await collect_command_stats(interaction)
    await interaction.followup.send(embed=embed)


@comp.error
async def comp_perm_error(interaction, error):
    await permission_fail(interaction, error)



@is_blacklisted()
@is_admin()
@gate_entitle("standard")
@bot.tree.command(
    name="quick-log",
    description=command_locale("Quickly set an alarm for this squadron in this channel", "commands.quick_log.description")
)
@app_commands.describe(
    squadron_name=command_locale("The SHORT name of the squadron to monitor", "commands.quick_log.squadron_name"),
    type=command_locale("Choose Logs, Points, Player Leave, Leaderboard, Weekly BR, or Both", "commands.quick_log.type")
)
@app_commands.choices(type=[
    app_commands.Choice(name=command_locale("Logs", "commands.quick_log.choice_logs"), value="Logs"),
    app_commands.Choice(name=command_locale("Points", "commands.quick_log.choice_points"), value="Points"),
    app_commands.Choice(name=command_locale("Player Leave", "commands.quick_log.choice_player_leave"), value="Leave"),
    app_commands.Choice(name=command_locale("Leaderboard", "commands.quick_log.choice_leaderboard"), value="Leaderboard"),
    app_commands.Choice(name=command_locale("Weekly BR", "commands.quick_log.choice_weekly_br"), value="WeeklyBR"),
    app_commands.Choice(name=command_locale("Both Logs and Points", "commands.quick_log.choice_both"), value="Both"),
])
@discord.app_commands.autocomplete(squadron_name=squadron_autocomplete)
async def quick_log(
    interaction: discord.Interaction,
    squadron_name: str = "",
    type: str = "Logs"
):
    """Set a Logs, Points, Player Leave, Leaderboard, Weekly BR, or Both alarm for a squadron.

    Resolves the squadron name, validates the alarm type, writes the channel
    preference to the guild's preferences JSON, and confirms with a premium
    reminder if applicable.

    Args:
        interaction: The Discord interaction.
        squadron_name: Short name of the squadron to monitor (empty = wildcard
            for Logs and Weekly BR).
        type: Alarm type -- Logs, Points, Player Leave, Leaderboard, Weekly BR, or Both.
    """
    await collect_command_stats(interaction)

    # Normalize and validate type
    type_normalized = type.title() if type.lower() != "weeklybr" else "WeeklyBR"
    type_lower = type_normalized.lower()
    is_leaderboard = type_lower in ("leaderboard", "leaderboards")
    is_both = type_lower in ("both", "all")
    is_weekly_br = type_lower == "weeklybr"
    is_leave = type_lower in ("leave", "player leave", "player_leave")

    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    if is_leaderboard:
        alarm_type = "Leaderboard"
    elif is_both:
        alarm_type = "Both"
    elif is_weekly_br:
        alarm_type = "WeeklyBR"
    elif is_leave:
        alarm_type = "Leave"
    else:
        if type_normalized not in ("Logs", "Points", "Leave"):
            await interaction.response.send_message(
                t(lang, "quick_log.invalid_type"),
                ephemeral=True
            )
            return
        alarm_type = type_normalized

    # Defer response to allow I/O
    await interaction.response.defer()

    guild_id = interaction.guild.id # type: ignore
    guild_name = interaction.guild.name # type: ignore

    # Load existing preferences or initialize
    preferences = await load_guild_preferences(guild_id)
    logging.info(f"Loaded preferences for guild {guild_id}")


    # At the top of quick_log (after loading prefs etc.)
    guild_id = str(interaction.guild_id)

    # `pref_key` is the dict key written into preferences. Real squadrons key
    # by str(clan_id) so a future rename doesn't orphan the entry; wildcard /
    # leaderboard slots keep their literal special string.
    # `display_name` is what we show to the user.
    resolved_clan: Optional[Dict[str, Any]] = None
    short_for_entry: Optional[str] = None
    if is_leaderboard:
        long_name = "Global"
        pref_key = "Global"
        display_name = "Global"
    elif is_weekly_br and not squadron_name:
        # Weekly BR allows empty squadron -> wildcard (top-20 mode)
        long_name = "everything"
        pref_key = "everything"
        display_name = "everything"
    else:
        # Logs, Points, Player Leave, Both, and Weekly-BR-with-squadron require a name
        if not squadron_name:
            await interaction.followup.send(
                t(lang, "quick_log.squadron_required"),
                ephemeral=True
            )
            return

        sq_lower = squadron_name
        if sq_lower in ("*", "everything", "all"):
            if alarm_type not in ("Logs", "WeeklyBR"):
                await interaction.followup.send(
                    t(lang, "quick_log.wildcard_logs_only"),
                    ephemeral=True
                )
                return
            long_name = "everything"
            pref_key = "everything"
            display_name = "everything"
        else:
            clan = await resolve_clan(short=sq_lower)

            if not clan or clan.get("long_name") == "<unresolved>":
                await interaction.followup.send(
                    t(lang, "quick_log.squadron_not_resolved", squadron=squadron_name),
                    ephemeral=True
                )
                return
            resolved_clan = clan
            long_name = str(clan.get("long_name") or "")
            short_for_entry = str(clan.get("short_name") or "") or None
            clan_id_val = clan.get("clan_id")
            pref_key = str(clan_id_val) if clan_id_val else long_name
            display_name = long_name


    # Determine channel mention
    channel_id = interaction.channel.id # type: ignore
    channel_mention = f"<#{channel_id}>"

    # Tier gating — blocks wildcards on Standard and rejects new additions past the per-notif cap.
    # Channel swaps on already-enabled entries are allowed (no counted delta).
    # Free/unentitled users are NOT blocked here — the autologging premium gate already
    # stops dispatch for them, and letting them configure prefs up-front is better UX.
    tier = await get_guild_tier(interaction.guild_id) if interaction.guild_id else None
    if tier_enforcement_active() and tier is not None:
        is_wildcard = long_name.lower() in WILDCARD_KEYS

        # Wildcard tier-gate doesn't apply to Leaderboard or Weekly BR
        # (both are uncapped + free, like /schedule).
        if is_wildcard and alarm_type not in ("Leaderboard", "WeeklyBR") \
                and not tier_allows_wildcard(tier):
            wb_embed = discord.Embed(
                title=t(lang, "autolog.wildcard_blocked_title"),
                description=t(lang, "autolog.wildcard_blocked_desc",
                              tier=(tier or "none").title(), notif="Logs"),
                color=discord.Color.orange(),
            )
            wb_embed.set_footer(text=t(lang, "autolog.over_cap_footer"))
            await interaction.followup.send(embed=wb_embed, ephemeral=True)
            return

        if is_leaderboard or is_weekly_br:
            types_to_check: list[str] = []  # Both are uncapped
        elif is_both:
            types_to_check = ["Logs", "Points"]
        elif alarm_type in ("Logs", "Points"):
            types_to_check = [alarm_type]
        else:
            types_to_check = []

        for nt in types_to_check:
            cap = tier_cap(tier, nt)
            if cap is None:
                continue  # unlimited for this tier
            already_enabled = is_notif_enabled(preferences.get(pref_key, {}), nt)
            if already_enabled:
                continue  # channel swap on existing entry — no new slot consumed
            # Wildcards (*, all, everything) don't count toward the cap
            if long_name.lower() in WILDCARD_KEYS:
                continue
            enabled_now = set(enabled_non_wildcard_keys_for(preferences, nt)) - {pref_key}
            if len(enabled_now) >= cap:
                oc_embed = discord.Embed(
                    title=t(lang, "autolog.over_cap_title"),
                    description=t(lang, "autolog.over_cap_desc",
                                  tier=(tier or "none").title(), notif=nt,
                                  cap=cap, squadron=display_name),
                    color=discord.Color.orange(),
                )
                oc_embed.set_footer(text=t(lang, "autolog.over_cap_footer"))
                await interaction.followup.send(embed=oc_embed, ephemeral=True)
                return

    # Set or overwrite the channel for this alarm type
    entry = preferences.setdefault(pref_key, {})
    if isinstance(entry, dict):
        # Cache display fields on the entry so a stale squadrons_data lookup
        # later still has a name to show.
        if resolved_clan and long_name:
            entry["Long"] = long_name
        if short_for_entry:
            entry["Short"] = short_for_entry
    if is_both:
        entry["Logs"] = channel_mention
        entry["Points"] = channel_mention
    else:
        entry[alarm_type] = channel_mention

    # Save back to preferences file
    success = await save_guild_preferences(interaction.guild.id, preferences)  # type: ignore
    if not success:
        await interaction.followup.send(
            t(lang, "quick_log.save_failed"),
            ephemeral=True
        )
        return
    logging.info(f"Saved preferences for guild {guild_id}")

    # Premium reminder for log types
    logs_involved = is_both or alarm_type == "Logs"
    premium_note = ""
    if logs_involved and interaction.guild_id and not await is_guild_entitled(interaction.guild_id):
        premium_note = t(lang, "quick_log.premium_warning")

    # Confirmation message
    if is_leaderboard:
        await interaction.followup.send(
            t(lang, "quick_log.leaderboard_set"),
            ephemeral=True
        )
    elif is_weekly_br:
        if long_name == "everything":
            await interaction.followup.send(
                t(lang, "quick_log.weekly_br_wildcard_set"),
                ephemeral=True
            )
        else:
            await interaction.followup.send(
                t(lang, "quick_log.weekly_br_squadron_set", squadron=long_name),
                ephemeral=True
            )
    elif is_both:
        await interaction.followup.send(
            t(lang, "quick_log.both_set", squadron=long_name, premium_note=premium_note),
            ephemeral=True
        )
    else:
        await interaction.followup.send(
            t(lang, "quick_log.alarm_set", alarm_type=alarm_type, squadron=long_name, premium_note=premium_note),
            ephemeral=True
        )

    logging.info(
        f"{guild_name} ({guild_id}) is now setting {alarm_type} alarm for "
        f"{long_name} in channel ID {channel_id}"
    )


@quick_log.error
async def quick_log_error(interaction, error):
    await permission_fail(interaction, error)


# ============================================================================
# /test-weekly-br -- preview the Weekly BR Report for the current/most-recent
# BR window. Admin-only. Always sends the wildcard embeds (top-20 + top-5
# players each); if a squadron is provided, also sends the per-squadron embed
# (top-15 players for that squadron). Useful for previewing the format
# without waiting for the scheduled fire time.
# ============================================================================


def _pick_test_br_window() -> Optional[Dict[str, Any]]:
    """Return the most recently ended BR window (mirrors the real scheduled task)."""
    schedule_path = Path(__file__).parent / "SCHEDULE.json"
    try:
        with open(schedule_path, "r", encoding="utf-8") as fp:
            schedule = json.load(fp)
    except Exception as e:
        logging.error("[TEST-WBR] Failed to read SCHEDULE.json: %s", e)
        return None

    now_ts = int(time_module.time())
    most_recent_past = None
    for entry in schedule:
        try:
            e = int(entry["end"])
        except (KeyError, TypeError, ValueError):
            continue
        if e < now_ts and (most_recent_past is None or e > int(most_recent_past["end"])):
            most_recent_past = entry
    return most_recent_past


@is_blacklisted()
@bot.tree.command(
    name="test-weekly-br",
    description="[DEV] Preview the Weekly BR Report for the current/most-recent BR window",
)
@app_commands.describe(
    squadron_name="Optional: also send the per-squadron variant for this squadron",
)
@discord.app_commands.autocomplete(squadron_name=squadron_autocomplete)
async def test_weekly_br(
    interaction: discord.Interaction,
    squadron_name: str = "",
):
    """Preview Weekly BR Report embeds in the current channel."""
    from .task_executors import (
        _build_squadron_embed,
        _build_wildcard_embeds,
        _load_squadron_name_lookup,
    )
    from .weekly_br_elo import (
        squadron_report_for_variants,
        top_n_squadrons_with_top_k_players,
    )

    await collect_command_stats(interaction)
    if not await is_dev_team(interaction):
        await interaction.response.send_message(t("en", "dev.restricted_dev_team"), ephemeral=True)
        return
    await interaction.response.defer(ephemeral=False)

    lang = await guild_lang(interaction.guild.id) if interaction.guild else "en"

    window = _pick_test_br_window()
    if window is None:
        await interaction.followup.send(
            "Could not pick a BR window from SCHEDULE.json.",
            ephemeral=True,
        )
        return

    start_ts = int(window["start"])
    end_ts = int(window["end"])

    # Build wildcard payload (top 20 squadrons x top 5 players each)
    wildcard_payload = await top_n_squadrons_with_top_k_players(
        start_ts, end_ts, n=20, k=5
    )

    if not wildcard_payload:
        await interaction.followup.send(
            f"No squadron activity in the {window['max_br']} BR window "
            f"(<t:{start_ts}:D> → <t:{end_ts}:D>) yet — nothing to preview.",
            ephemeral=True,
        )
        return

    name_lookup = await _load_squadron_name_lookup()
    channel = interaction.channel
    if not isinstance(channel, (discord.TextChannel, discord.Thread)):
        await interaction.followup.send(
            "This command must be run in a regular text channel or thread.",
            ephemeral=True,
        )
        return

    # Wildcard preview (always)
    wildcard_embeds = _build_wildcard_embeds(window, wildcard_payload, name_lookup, lang)
    if wildcard_embeds:
        await channel.send(embeds=wildcard_embeds)

    # Per-squadron preview (only if squadron_name provided)
    sq_section_status = ""
    if squadron_name:
        clan = await resolve_clan(short=squadron_name)
        if not clan or clan.get("long_name") == "<unresolved>":
            sq_section_status = (
                f"⚠️ Squadron `{squadron_name}` could not be resolved — "
                "skipped per-squadron preview."
            )
        else:
            sq_info = {
                "long_name": str(clan.get("long_name") or ""),
                "tag_name": str(clan.get("tag_name") or ""),
                "short_name": str(clan.get("short_name") or ""),
                "clan_id": clan.get("clan_id"),
            }
            variants = [sq_info["long_name"], sq_info["tag_name"], sq_info["short_name"]]
            sq_row, roster = await squadron_report_for_variants(
                start_ts, end_ts, variants, k=15
            )
            sq_embed = _build_squadron_embed(window, sq_info, sq_row, roster, lang)
            await channel.send(embed=sq_embed)

    summary = (
        f"✅ Previewed Weekly BR Report for **{window['max_br']} BR** "
        f"(<t:{start_ts}:D> → <t:{end_ts}:D>)\n"
        f"• Wildcard: {len(wildcard_payload)} squadrons rendered\n"
        f"• Per-squadron: "
        + ("rendered above" if squadron_name and not sq_section_status
           else (sq_section_status or "skipped (no squadron_name)"))
    )
    await interaction.followup.send(summary, ephemeral=True)


@test_weekly_br.error
async def test_weekly_br_error(interaction, error):
    await permission_fail(interaction, error)


async def _diagnose_perms_logic(interaction: discord.Interaction, target_channel=None):
    """Core logic for autolog diagnostics. Called from the diagnose channel select."""
    await interaction.response.defer(ephemeral=True)

    guild = interaction.guild
    if guild is None:
        await interaction.followup.send(t('en', "common.must_use_in_server"), ephemeral=True)
        return

    lang = await guild_lang(guild.id)
    guild_id = str(guild.id)
    channel = target_channel or interaction.channel
    if channel is None or not isinstance(channel, discord.abc.GuildChannel):
        await interaction.followup.send(t(lang, "common.could_not_resolve_channel"), ephemeral=True)
        return
    bot_member = guild.me

    lines: list[str] = []

    # ── 1. Check bot permissions in this channel ──
    perms = channel.permissions_for(bot_member)
    perm_checks = {
        "View Channel": perms.view_channel,
        "Send Messages": perms.send_messages,
        "Attach Files": perms.attach_files,
        "Embed Links": perms.embed_links,
    }
    all_ok = all(perm_checks.values())
    lines.append(t(lang, "diagnostics.channel_permissions_header", channel_id=channel.id))
    for name, has in perm_checks.items():
        lines.append(f"  {'\u2705' if has else '\u274c'} {name}")
    if not all_ok:
        lines.append(t(lang, "diagnostics.perms_needed"))
    lines.append("")

    # ── 2. Check /set-squadron ──
    squadrons_json = await load_json(STORAGE_DIR / "SQUADRONS.json", {})

    guild_sq = squadrons_json.get(guild_id, {})
    if guild_sq:
        lines.append(t(lang, "diagnostics.server_squadron_header"))
        lines.append(t(lang, "diagnostics.server_squadron_short", short=guild_sq.get('SQ_ShortHand_Name', 'N/A')))
        lines.append(t(lang, "diagnostics.server_squadron_long", long=guild_sq.get('SQ_LongHandName', 'N/A')))
    else:
        lines.append(t(lang, "diagnostics.server_squadron_header"))
        lines.append(t(lang, "diagnostics.server_squadron_not_set"))
    lines.append("")

    # ── 3. Check preferences (this is what actually controls autologging) ──
    prefs = await load_guild_preferences(guild.id)
    lines.append(t(lang, "diagnostics.autolog_prefs_header"))

    if not prefs:
        lines.append(t(lang, "diagnostics.autolog_none_configured"))
        lines.append(t(lang, "diagnostics.autolog_setup_hint"))
    else:
        has_any_logs = False
        for key, cfg in prefs.items():
            if utils.is_foreign_pref_entry(cfg):
                continue
            logs_chan = cfg.get("Logs", "")
            if not logs_chan or "DISABLED" in str(logs_chan).upper():
                continue
            has_any_logs = True
            chan_id_match = re.search(r"\b(\d{17,19})\b", str(logs_chan))
            chan_id = int(chan_id_match.group(1)) if chan_id_match else None

            # Check if channel is valid
            if chan_id:
                target_ch = bot.get_channel(chan_id)
                if target_ch is None:
                    try:
                        target_ch = await bot.fetch_channel(chan_id)
                    except Exception:
                        target_ch = None

                if target_ch and isinstance(target_ch, discord.abc.GuildChannel):
                    target_perms = target_ch.permissions_for(bot_member)
                    ch_ok = target_perms.send_messages and target_perms.attach_files
                    icon = "\u2705" if ch_ok else "\u274c"
                    status = "" if ch_ok else t(lang, "diagnostics.missing_send_attach")
                else:
                    icon = "\u274c"
                    status = t(lang, "diagnostics.channel_not_found")
            else:
                icon = "\u274c"
                status = t(lang, "diagnostics.invalid_channel_id")

            is_selected_channel = chan_id == channel.id
            here = t(lang, "diagnostics.selected_channel_tag") if is_selected_channel else ""
            lines.append(f"  {icon} `{key}` -> <#{chan_id}>{here}{status}")

        if not has_any_logs:
            lines.append(t(lang, "diagnostics.autolog_no_logs_channels"))
            lines.append(t(lang, "diagnostics.autolog_enable_hint"))

    lines.append("")

    # ── 4. Premium / entitlement check ──
    lines.append(t(lang, "diagnostics.premium_status_header"))
    if await is_guild_entitled(guild.id):
        lines.append(t(lang, "diagnostics.premium_active"))
    else:
        lines.append(t(lang, "diagnostics.premium_not_subscribed"))
        lines.append(t(lang, "diagnostics.premium_autolog_required"))
    lines.append("")

    # ── Send ──
    embed = discord.Embed(
        title=t(lang, "diagnostics.title"),
        description="\n".join(lines),
        color=discord.Color.blue()
    )
    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    await interaction.followup.send(embed=embed, ephemeral=True)


def _extract_pref_channel_id(raw: Any) -> Optional[int]:
    """Extract a Discord channel ID from a stored preference value."""
    if raw is None:
        return None
    match = re.search(r"\b(\d{17,19})\b", str(raw))
    return int(match.group(1)) if match else None


def _format_pref_target_name(pref_key: str, settings: dict[str, Any]) -> str:
    """Prefer Short/Long squadron labels over raw preference keys."""
    if pref_key.lower() in WILDCARD_KEYS or pref_key == "Global":
        return pref_key

    short_name = str(settings.get("Short") or "").strip()
    long_name = str(settings.get("Long") or "").strip()

    if short_name and long_name and short_name.lower() != long_name.lower():
        return f"{short_name} - {long_name}"
    if short_name:
        return short_name
    if long_name:
        return long_name
    return pref_key


def _configured_pref_targets(preferences: dict[str, Any]) -> list[dict[str, Any]]:
    """Return enabled channel targets from autolog preferences."""
    targets: list[dict[str, Any]] = []
    seen: set[tuple[str, str, int]] = set()
    for squadron, settings in preferences.items():
        if not isinstance(settings, dict) or utils.is_foreign_pref_entry(settings):
            continue
        pref_key = str(squadron)
        display_name = _format_pref_target_name(pref_key, settings)
        for notif_type, raw in settings.items():
            if notif_type in ("Short", "Long"):
                continue
            raw_s = str(raw or "")
            if not raw_s or "DISABLED" in raw_s.upper():
                continue
            channel_id = _extract_pref_channel_id(raw_s)
            if channel_id is None:
                continue
            dedupe_key = (pref_key, str(notif_type), channel_id)
            if dedupe_key in seen:
                continue
            seen.add(dedupe_key)
            targets.append({
                "squadron": pref_key,
                "display": display_name,
                "type": str(notif_type),
                "raw": raw_s,
                "channel_id": channel_id,
                "short": str(settings.get("Short") or ""),
                "long": str(settings.get("Long") or ""),
            })
    return targets


REPORTS_NOTIF_TYPE = "Reports"
REPORTS_STORAGE_TYPES = ("Leaderboard", "WeeklyBR")


def _notif_type_label(notif_type: str) -> str:
    if notif_type == REPORTS_NOTIF_TYPE:
        return "Reports"
    if notif_type == "WeeklyBR":
        return "Weekly BR"
    return str(notif_type)


def _notif_types_for_management(notif_type: str) -> tuple[str, ...]:
    if notif_type == REPORTS_NOTIF_TYPE:
        return REPORTS_STORAGE_TYPES
    return (notif_type,)


def _encode_management_value(squadron: str, notif_type: str) -> str:
    return f"{notif_type}::{squadron}"


def _decode_management_value(value: str) -> tuple[str, str]:
    notif_type, squadron = value.split("::", 1)
    return notif_type, squadron


def _management_pref_rows(
    preferences: dict[str, Any], notif_type: str
) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for squadron, settings in preferences.items():
        if not isinstance(settings, dict) or utils.is_foreign_pref_entry(settings):
            continue
        for storage_type in _notif_types_for_management(notif_type):
            if storage_type in settings:
                rows.append({
                    "squadron": squadron,
                    "settings": settings,
                    "notif_type": storage_type,
                })
    return rows


def _perm_diag_embed(
    guild: discord.Guild,
    target: dict[str, Any],
    channel: Optional[discord.abc.GuildChannel],
    entitled: bool,
) -> discord.Embed:
    """Build a support-facing permission diagnostic embed for one pref target."""
    channel_id = int(target["channel_id"])
    embed = discord.Embed(
        title="Configured Channel Permissions",
        color=discord.Color.green() if channel and entitled else discord.Color.red(),
    )
    embed.add_field(name="Server", value=f"{esc(guild.name)} (`{guild.id}`)", inline=False)
    embed.add_field(
        name="Preference",
        value=(
            f"**{esc(target['type'])}** for `{esc(target.get('display') or target['squadron'])}`\n"
            f"Preference key: `{esc(target['squadron'])}`\n"
            f"Stored value: `{esc(target['raw'])}`\n"
            f"Channel ID: `{channel_id}`"
        ),
        inline=False,
    )
    embed.add_field(
        name="Entitlement Check",
        value=(
            "✅ Premium entitlement active for this server."
            if entitled else
            "❌ Premium entitlement is not active; autolog uploads are blocked."
        ),
        inline=False,
    )

    if channel is None:
        embed.description = "The bot cannot resolve this configured channel in the target server."
        result_lines = ["❌ Channel not found or the bot has no access to see it."]
        if not entitled:
            result_lines.append("❌ Autolog dispatch is also blocked by missing premium entitlement.")
        embed.add_field(
            name="Result",
            value="\n".join(result_lines),
            inline=False,
        )
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        return embed

    perms = channel.permissions_for(guild.me)
    checks = {
        "View Channel": perms.view_channel,
        "Send Messages": perms.send_messages,
        "Attach Files": perms.attach_files,
        "Embed Links": perms.embed_links,
        "Read Message History": perms.read_message_history,
    }
    ok = all(checks.values())
    embed.color = discord.Color.green() if ok and entitled else discord.Color.red()
    embed.add_field(name="Resolved Channel", value=f"{channel.mention} (`{channel.id}`)", inline=False)
    embed.add_field(
        name="Permission Check",
        value="\n".join(f"{'✅' if allowed else '❌'} {name}" for name, allowed in checks.items()),
        inline=False,
    )
    embed.add_field(
        name="Autolog Result",
        value=(
            "❌ Autolog uploads will not run until this server has an active premium entitlement."
            if not entitled else
            (
                "✅ Scoreboard uploads should work for this channel."
                if ok else
                "❌ Discord will reject scoreboard uploads until the missing channel permissions are fixed."
            )
        ),
        inline=False,
    )
    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    return embed


class GuildPermTargetSelect(discord.ui.Select):
    """Dropdown of configured autolog channels for a target guild."""

    def __init__(self, guild: discord.Guild, targets: list[dict[str, Any]], owner_id: int, page: int = 0):
        self.guild = guild
        self.targets = targets
        self.owner_id = owner_id
        self.page = page
        start = page * 25
        page_targets = targets[start:start + 25]
        options: list[discord.SelectOption] = []
        for idx, target in enumerate(page_targets, start=start):
            desc_bits = [str(target["channel_id"])]
            if target.get("squadron") and target.get("display") and target["squadron"] != target["display"]:
                desc_bits.append(f"key {target['squadron']}")
            options.append(discord.SelectOption(
                label=f"{target['type']}: {target.get('display') or target['squadron']}"[:100],
                description=" • ".join(desc_bits)[:100],
                value=str(idx),
            ))
        super().__init__(
            placeholder=f"Select configured channel ({page + 1}/{max(1, math.ceil(len(targets) / 25))})",
            min_values=1,
            max_values=1,
            options=options,
        )

    async def callback(self, interaction: discord.Interaction):
        if interaction.user.id != self.owner_id:
            await interaction.response.defer()
            return
        target = self.targets[int(self.values[0])]
        channel_id = int(target["channel_id"])
        channel = self.guild.get_channel(channel_id)
        if channel is None:
            try:
                fetched = await bot.fetch_channel(channel_id)
                channel = fetched if isinstance(fetched, discord.abc.GuildChannel) else None
            except Exception:
                channel = None
        entitled = await is_guild_entitled(self.guild.id)
        await interaction.response.send_message(
            embed=_perm_diag_embed(self.guild, target, channel, entitled),
            ephemeral=True,
        )


class GuildPermPageButton(discord.ui.Button):
    """Page button for the /view-guild-perms target selector."""

    def __init__(self, label: str, delta: int):
        super().__init__(label=label, style=discord.ButtonStyle.secondary)
        self.delta = delta

    async def callback(self, interaction: discord.Interaction):
        view: GuildPermTargetView = self.view  # type: ignore
        if interaction.user.id != view.owner_id:
            await interaction.response.defer()
            return
        view.page = max(0, min(view.max_page, view.page + self.delta))
        view.refresh_items()
        await interaction.response.edit_message(view=view)


class GuildPermTargetView(discord.ui.View):
    """Paginated configured-channel selector for /view-guild-perms."""

    def __init__(self, guild: discord.Guild, targets: list[dict[str, Any]], owner_id: int, page: int = 0):
        super().__init__(timeout=300)
        self.guild = guild
        self.targets = targets
        self.owner_id = owner_id
        self.page = page
        self.max_page = max(0, math.ceil(len(targets) / 25) - 1)
        self.refresh_items()

    def refresh_items(self):
        self.clear_items()
        self.add_item(GuildPermTargetSelect(self.guild, self.targets, self.owner_id, self.page))
        if self.max_page > 0:
            self.add_item(GuildPermPageButton("Previous", -1))
            self.add_item(GuildPermPageButton("Next", 1))


@is_blacklisted()
@bot.tree.command(name="view-guild-perms", description="[DEV] Diagnose configured autolog channel permissions for a server")
@app_commands.describe(server_id="Discord server ID to inspect")
async def view_guild_perms(interaction: discord.Interaction, server_id: str):
    """Support command for diagnosing autolog channels from any server/ticket."""
    await collect_command_stats(interaction)
    if not await is_dev_team(interaction):
        await interaction.response.send_message(t("en", "dev.restricted_dev_team"), ephemeral=True)
        return

    await interaction.response.defer(ephemeral=True)
    if not server_id.isdigit() or not (17 <= len(server_id) <= 19):
        await interaction.followup.send(t("en", "dev.invalid_server_id"), ephemeral=True)
        return

    guild = bot.get_guild(int(server_id))
    if guild is None:
        await interaction.followup.send(f"I am not in a server with ID `{esc(server_id)}`.", ephemeral=True)
        return

    preferences = await load_guild_preferences(guild.id)
    targets = _configured_pref_targets(preferences)
    if not targets:
        await interaction.followup.send(
            f"No enabled configured notification channels found for **{esc(guild.name)}** (`{guild.id}`).",
            ephemeral=True,
        )
        return

    view = GuildPermTargetView(guild, targets, interaction.user.id)
    embed = discord.Embed(
        title="Select Configured Channel",
        description=(
            f"Server: **{esc(guild.name)}** (`{guild.id}`)\n"
            f"Configured enabled targets: `{len(targets)}`\n\n"
            "Select a channel from this server's stored preferences to diagnose the bot's effective permissions."
        ),
        color=discord.Color.blurple(),
    )
    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    await interaction.followup.send(embed=embed, view=view, ephemeral=True)


@is_blacklisted()
@bot.tree.command(name="sq-info", description=command_locale("Fetch information about a squadron", "commands.sq_info.description"))
@app_commands.describe(squadron=command_locale("The short name of the squadron", "commands.common.squadron_short"))
@discord.app_commands.autocomplete(squadron=squadron_autocomplete)
async def sq_info(interaction: discord.Interaction, squadron: str = ""):
    """Fetch and display squadron info including placement, total points, and member list.

    Resolves the squadron, calls the game API for current point data,
    looks up leaderboard placement, and builds a paginated embed with
    all members and their individual points.

    Args:
        interaction: The Discord interaction.
        squadron: Short name of the squadron (falls back to guild default).
    """
    await collect_command_stats(interaction)
    await interaction.response.defer(ephemeral=False)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    try:
        clan = await get_guild_squadron(interaction.guild_id, squadron)
    except ValueError as e:
        embed = discord.Embed(title=t(lang, "common.error_title"), description=str(e), color=discord.Color.red())
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        return await interaction.followup.send(embed=embed, ephemeral=True)

    squadron_name = clan["long_name"]

    # Fetch squadron info and turn into embed
    sq_data = await obtain_clan_new_points(squadron_name)
    if sq_data:
        members: dict[str, dict[str, str | int]] = sq_data[0]
        total_points: int = sq_data[1]

        placement, _ = await get_current_squadron_placement(squadron_name, squadron or "")

        embed = discord.Embed(
            title=t(lang, "sq_info.title", squadron=squadron_name),
            color=discord.Color.green()
        )
        embed.add_field(name=t(lang, "sq_info.placement_field"), value=f"#{placement}" if placement else "N/A", inline=True)
        embed.add_field(name=t(lang, "sq_info.total_points_field"), value=f"{total_points:,}", inline=True)
        embed.add_field(name=t(lang, "sq_info.total_members_field"), value=str(len(members)), inline=True)

        # Build full member list (all 128 possible members)
        lines: list[str] = []
        for uid, info in members.items():
            raw_nick: str = str(info.get("nick", "Unknown"))
            nick: str = esc(raw_nick)
            points: int = int(info.get("points", 0))
            lines.append(f"**{nick}** — {points:,} pts")

        # Split into chunks so no field exceeds 1024 characters
        chunk = ""
        first = True
        for line in lines:
            if len(chunk) + len(line) + 1 > 1024:
                embed.add_field(
                    name=t(lang, "sq_info.members_field") if first else "\u00A0",
                    value=chunk.rstrip(),
                    inline=False
                )
                first = False
                chunk = ""
            chunk += line + "\n"

        if chunk:
            embed.add_field(
                name=t(lang, "sq_info.members_field") if first else "\u00A0",
                value=chunk.rstrip(),
                inline=False
            )

        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        await interaction.followup.send(embed=embed)
    else:
        await interaction.followup.send(t(lang, "sq_info.fetch_failed"), ephemeral=True)


@sq_info.error
async def sq_info_error(interaction, error):
    await permission_fail(interaction, error)


# Roster composition graph palette (separate from line-chart palette so it can
# be retuned independently if categories grow).
SQ_INFO_GRAPH_CATEGORY_COLORS = {
    'core':   '#5cb85c',  # green   — high games + WR ≥ 50%
    'active': '#f0ad4e',  # amber   — high games + WR < 50%
    'weak':   '#d9534f',  # red     — below median games / no games
}


@is_blacklisted()
@bot.tree.command(
    name="sq-info-graph",
    description=command_locale(
        "Show a roster composition graph by activity and WR (current season)",
        "commands.sq_info_graph.description",
    ),
)
@app_commands.describe(squadron=command_locale("The short name of the squadron", "commands.common.squadron_short"))
@discord.app_commands.autocomplete(squadron=squadron_autocomplete)
async def sq_info_graph(interaction: discord.Interaction, squadron: str = ""):
    """Render a per-member WR bar chart, grouped into core / active / weak blocks.

    Pulls the current squadron roster the same way /sq-info does, then aggregates
    each member's games + wins from sq_battles.db within the current season's
    timestamp window. Members are bucketed by games-vs-median and WR-vs-50%, then
    drawn left-to-right in CORE → ACTIVE → WEAK order, sorted by games desc inside
    each bucket. Bar height = WR%.
    """
    await collect_command_stats(interaction)
    await interaction.response.defer(ephemeral=False)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    try:
        clan = await get_guild_squadron(interaction.guild_id, squadron)
    except ValueError as e:
        embed = discord.Embed(title=t(lang, "common.error_title"), description=str(e), color=discord.Color.red())
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        return await interaction.followup.send(embed=embed, ephemeral=True)

    squadron_name = clan["long_name"]

    # Resolve the current in-progress season (we only score games inside its window).
    seasons = get_seasons()
    current_season_name: Optional[str] = None
    season_start = 0
    season_end = 0
    for name, rng in seasons.items():
        if rng["status"] == "in_progress":
            current_season_name = name
            season_start = int(rng["start"])
            season_end = int(rng["end"])
            break

    if current_season_name is None:
        embed = discord.Embed(
            title=t(lang, "common.error_title"),
            description=t(lang, "sq_info_graph.no_active_season"),
            color=discord.Color.red(),
        )
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        return await interaction.followup.send(embed=embed, ephemeral=True)

    sq_data = await obtain_clan_new_points(squadron_name)
    if not sq_data:
        return await interaction.followup.send(t(lang, "sq_info.fetch_failed"), ephemeral=True)

    members: dict[str, dict[str, str | int]] = sq_data[0]
    if not members:
        embed = discord.Embed(
            title=t(lang, "common.error_title"),
            description=t(lang, "sq_info_graph.no_members", squadron=squadron_name),
            color=discord.Color.orange(),
        )
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        return await interaction.followup.send(embed=embed, ephemeral=True)

    uids = list(members.keys())

    # Aggregate per-UID games + wins for the current season in a single query.
    stats: dict[str, dict[str, int]] = {}
    placeholders = ",".join("?" * len(uids))
    try:
        async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db:
            db.row_factory = aiosqlite.Row
            async with db.execute(
                f"""
                SELECT UID,
                       COUNT(*) AS games,
                       SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) AS wins
                FROM player_games_hist
                WHERE UID IN ({placeholders})
                  AND endtime_unix BETWEEN ? AND ?
                GROUP BY UID
                """,
                [*uids, season_start, season_end],
            ) as cursor:
                async for row in cursor:
                    stats[row["UID"]] = {
                        "games": int(row["games"] or 0),
                        "wins": int(row["wins"] or 0),
                    }
    except Exception as e:
        logging.error(f"sq_info_graph DB query failed: {e}")
        return await interaction.followup.send(
            t(lang, "common.database_error", error=str(e)[:1500]),
            ephemeral=True,
        )

    # Build per-member records.
    records: list[dict] = []
    for uid in uids:
        s = stats.get(uid, {"games": 0, "wins": 0})
        games = s["games"]
        wins = s["wins"]
        wr = (wins / games * 100.0) if games > 0 else 0.0
        nick = str(members[uid].get("nick", "Unknown"))
        records.append({"uid": uid, "nick": nick, "games": games, "wins": wins, "wr": wr})

    # Median games threshold computed only over members with at least one game,
    # so a roster of mostly-inactive members doesn't drop the bar to zero.
    active_games = [r["games"] for r in records if r["games"] >= 1]
    median_games = float(np.median(active_games)) if active_games else 0.0

    # Percentile-based bucketing scales with the squadron's own WR distribution
    # rather than absolute thresholds. CORE = top 30% by WR and active enough,
    # ACTIVE = next slice up to top 45% with relaxed activity, WEAK = the rest.
    sq_total_games = sum(r["games"] for r in records)
    sq_total_wins = sum(r["wins"] for r in records)
    squadron_wr = (sq_total_wins / sq_total_games * 100.0) if sq_total_games > 0 else 50.0

    n_total = len(records)
    core_rank_cutoff = max(1, int(n_total * 0.30)) if n_total > 0 else 0
    active_rank_cutoff = max(core_rank_cutoff, int(n_total * 0.45)) if n_total > 0 else 0

    # "Games around median" — a slightly relaxed activity floor for ACTIVE so
    # a top-WR member who plays a bit below median doesn't get dumped into WEAK.
    games_threshold_active = max(1.0, median_games * 0.7)

    # Rank members by WR desc; tiebreak by games desc so heavier players win ties.
    ranked = sorted(records, key=lambda r: (-r["wr"], -r["games"]))

    # Capture the WR boundary values for the chart's threshold lines.
    core_wr_threshold = ranked[core_rank_cutoff - 1]["wr"] if 0 < core_rank_cutoff <= len(ranked) else 0.0
    active_wr_threshold = ranked[active_rank_cutoff - 1]["wr"] if 0 < active_rank_cutoff <= len(ranked) else 0.0

    bucket_by_uid: dict[str, str] = {}
    for i, r in enumerate(ranked):
        if i < core_rank_cutoff and r["games"] >= median_games and r["games"] >= 1:
            bucket_by_uid[r["uid"]] = "core"
        elif i < active_rank_cutoff and r["games"] >= games_threshold_active and r["games"] >= 1:
            bucket_by_uid[r["uid"]] = "active"
        else:
            bucket_by_uid[r["uid"]] = "weak"

    core: list[dict] = []
    active: list[dict] = []
    weak: list[dict] = []
    for r in records:
        b = bucket_by_uid.get(r["uid"], "weak")
        if b == "core":
            core.append(r)
        elif b == "active":
            active.append(r)
        else:
            weak.append(r)

    # Within each block: most-played first (ties broken by WR desc).
    for bucket in (core, active, weak):
        bucket.sort(key=lambda r: (-r["games"], -r["wr"]))

    ordered = core + active + weak
    n = len(ordered)

    # Render
    fig, ax = plt.subplots(figsize=(16, 8), facecolor=SQ_STATS_GRAPH_COLORS['bg'])
    ax.set_facecolor(SQ_STATS_GRAPH_COLORS['plot_bg'])

    x_positions = list(range(n))
    heights = [r["wr"] for r in ordered]
    colors = (
        [SQ_INFO_GRAPH_CATEGORY_COLORS['core']] * len(core)
        + [SQ_INFO_GRAPH_CATEGORY_COLORS['active']] * len(active)
        + [SQ_INFO_GRAPH_CATEGORY_COLORS['weak']] * len(weak)
    )

    if n > 0:
        # Faint ghost stubs first so 0-WR / 0-game members are still visible as
        # an occupied slot rather than invisible empty space. Drawn under the
        # real WR bars.
        ghost_height = 2.5
        ax.bar(
            x_positions,
            [ghost_height] * n,
            width=1.0,
            color='#3a3a48',
            edgecolor=SQ_STATS_GRAPH_COLORS['bg'],
            linewidth=0.5,
            align='center',
            zorder=1,
        )
        ax.bar(
            x_positions,
            heights,
            width=1.0,
            color=colors,
            edgecolor=SQ_STATS_GRAPH_COLORS['bg'],
            linewidth=0.5,
            align='center',
            zorder=2,
        )

        # Per-bar vertical labels: "{nick} · {games}g" rotated 90°, drawn inside
        # the coloured portion when there's room, or above the bar otherwise.
        # Black text with a thin white halo keeps it readable on both green and
        # red fills.
        for i, r in enumerate(ordered):
            label = f"{r['nick']} · {r['games']}g"
            # Truncate to keep extreme nicks from overflowing the plot top.
            if len(label) > 28:
                label = label[:27] + "…"
            wr_val = r["wr"]
            if wr_val >= 12:
                y_text = wr_val / 2.0
                color = '#0b0b0b'
                va = 'center'
            else:
                # Stack label above bar (or above the ghost stub for 0-game members).
                y_text = max(wr_val, ghost_height) + 1.5
                color = SQ_STATS_GRAPH_COLORS['text']
                va = 'bottom'
            txt = ax.text(
                i, y_text, label,
                rotation=90, ha='center', va=va,
                fontsize=6, color=color, alpha=0.95,
                clip_on=True,
                zorder=3,
            )
            txt.set_path_effects([
                path_effects.withStroke(linewidth=1.2, foreground='#ffffff' if color == '#0b0b0b' else '#000000'),
            ])

    # 50% WR reference line (absolute "winning vs losing" baseline).
    ax.axhline(50, color=SQ_STATS_GRAPH_COLORS['text'], linestyle=':', linewidth=1, alpha=0.3)

    # CORE WR boundary — the lowest WR in the top-30% rank slice. Anyone at or
    # above this WR is a CORE candidate (still gated on games ≥ median).
    if core_wr_threshold > 0:
        ax.axhline(
            core_wr_threshold,
            color=SQ_INFO_GRAPH_CATEGORY_COLORS['core'],
            linestyle='--', linewidth=1.2, alpha=0.75, zorder=4,
        )
        ax.text(
            n - 0.5 if n > 0 else 0.5, core_wr_threshold + 1.2,
            t(lang, "sq_info_graph.core_threshold_line", wr=f"{core_wr_threshold:.1f}"),
            ha='right', va='bottom',
            color=SQ_INFO_GRAPH_CATEGORY_COLORS['core'],
            fontsize=9, fontweight='bold', alpha=0.9, zorder=5,
        )

    # WEAK WR boundary — the lowest WR in the top-45% rank slice. Below this
    # by WR alone puts a member in WEAK.
    if active_wr_threshold > 0 and active_wr_threshold < core_wr_threshold:
        ax.axhline(
            active_wr_threshold,
            color=SQ_INFO_GRAPH_CATEGORY_COLORS['weak'],
            linestyle='--', linewidth=1.2, alpha=0.75, zorder=4,
        )
        ax.text(
            n - 0.5 if n > 0 else 0.5, active_wr_threshold + 1.2,
            t(lang, "sq_info_graph.weak_threshold_line", wr=f"{active_wr_threshold:.1f}"),
            ha='right', va='bottom',
            color=SQ_INFO_GRAPH_CATEGORY_COLORS['weak'],
            fontsize=9, fontweight='bold', alpha=0.9, zorder=5,
        )

    # Vertical dividers between blocks.
    if core and (active or weak):
        ax.axvline(len(core) - 0.5, color=SQ_STATS_GRAPH_COLORS['text'], linewidth=1, alpha=0.7)
    if (core or active) and weak:
        ax.axvline(len(core) + len(active) - 0.5, color=SQ_STATS_GRAPH_COLORS['text'], linewidth=1, alpha=0.7)

    def _block_avg_wr(bucket: list[dict]) -> float:
        total_games = sum(b["games"] for b in bucket)
        total_wins = sum(b["wins"] for b in bucket)
        return (total_wins / total_games * 100.0) if total_games > 0 else 0.0

    if n > 0:
        if core:
            cx = (len(core) - 1) / 2.0
            ax.text(
                cx, 105,
                t(lang, "sq_info_graph.core_header", count=len(core), avg=f"{_block_avg_wr(core):.1f}"),
                ha='center', va='bottom',
                color=SQ_INFO_GRAPH_CATEGORY_COLORS['core'], fontweight='bold', fontsize=11,
            )
        if active:
            ax_x = len(core) + (len(active) - 1) / 2.0
            ax.text(
                ax_x, 105,
                t(lang, "sq_info_graph.active_header", count=len(active), avg=f"{_block_avg_wr(active):.1f}"),
                ha='center', va='bottom',
                color=SQ_INFO_GRAPH_CATEGORY_COLORS['active'], fontweight='bold', fontsize=11,
            )
        if weak:
            wx = len(core) + len(active) + (len(weak) - 1) / 2.0
            ax.text(
                wx, 105,
                t(lang, "sq_info_graph.weak_header", count=len(weak), avg=f"{_block_avg_wr(weak):.1f}"),
                ha='center', va='bottom',
                color=SQ_INFO_GRAPH_CATEGORY_COLORS['weak'], fontweight='bold', fontsize=11,
            )

    ax.set_ylim(0, 118)
    ax.set_xlim(-0.5, max(n - 0.5, 0.5))
    ax.set_ylabel(t(lang, "sq_info_graph.y_label"), fontsize=12, color=SQ_STATS_GRAPH_COLORS['text'])
    ax.set_title(
        t(lang, "sq_info_graph.title", squadron=squadron_name, season=current_season_name),
        fontsize=14, fontweight='bold', color=SQ_STATS_GRAPH_COLORS['text'],
    )
    ax.grid(True, alpha=0.2, color=SQ_STATS_GRAPH_COLORS['grid'], axis='y')
    ax.tick_params(colors=SQ_STATS_GRAPH_COLORS['text'])
    for spine in ax.spines.values():
        spine.set_color(SQ_STATS_GRAPH_COLORS['text'])
    ax.set_xticks([])
    ax.set_yticks([0, 25, 50, 75, 100])
    ax.set_yticklabels(['0%', '25%', '50%', '75%', '100%'])

    plt.tight_layout()

    safe_squadron = re.sub(r'[^A-Za-z0-9_-]+', '_', squadron_name) or 'squadron'
    temp_path = Path(f"/tmp/sq_info_graph_{safe_squadron}_{int(time_module.time())}.png")
    plt.savefig(temp_path, dpi=150, bbox_inches='tight', facecolor=SQ_STATS_GRAPH_COLORS['bg'])
    plt.close(fig)

    embed = discord.Embed(
        title=t(lang, "sq_info_graph.embed_title", squadron=squadron_name),
        description=t(
            lang, "sq_info_graph.embed_desc",
            season=current_season_name,
            core=len(core), active=len(active), weak=len(weak),
            median=int(round(median_games)),
        ),
        color=discord.Color.green(),
    )
    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    embed.set_image(url=f"attachment://{temp_path.name}")

    try:
        await interaction.followup.send(embed=embed, file=discord.File(temp_path))
    finally:
        try:
            temp_path.unlink()
        except Exception:
            pass


@sq_info_graph.error
async def sq_info_graph_error(interaction, error):
    await permission_fail(interaction, error)


# ═══════════════════════════════════════════════════════════════════════════
# SEASON RECAP CARDS (/sq-card, /card)
# ═══════════════════════════════════════════════════════════════════════════

# Cached pre-formatted season choices (refreshed periodically so status transitions
# from in_progress → completed get picked up during long bot uptimes).
_SEASONS_CHOICES_CACHE: list[tuple[str, str]] = []  # (name, display_label)
_SEASONS_CHOICES_CACHE_TS: float = 0.0
_SEASONS_CHOICES_TTL_SECONDS = 60.0


def _build_seasons_choices() -> list[tuple[str, str]]:
    """Return (value, display_label) pairs, most recent first."""
    seasons = get_seasons()
    entries = sorted(seasons.items(), key=lambda kv: kv[1]["start"], reverse=True)
    return [
        (name, f"{name} (in progress)" if rng["status"] == "in_progress" else name)
        for name, rng in entries
    ]


def _refresh_seasons_choices_cache() -> list[tuple[str, str]]:
    global _SEASONS_CHOICES_CACHE, _SEASONS_CHOICES_CACHE_TS
    _SEASONS_CHOICES_CACHE = _build_seasons_choices()
    _SEASONS_CHOICES_CACHE_TS = time_module.time()
    return _SEASONS_CHOICES_CACHE


# Pre-warm at import so the first autocomplete call doesn't pay the parse cost.
try:
    _refresh_seasons_choices_cache()
except Exception as _e:
    print(f"[WARN] Failed to pre-warm seasons cache: {_e}", flush=True)


async def seasons_autocomplete(
    interaction: discord.Interaction,
    current: str,
) -> List[discord.app_commands.Choice[str]]:
    """Autocomplete for season names from constants/seasons. Most recent first.

    Kept deliberately minimal — Discord invalidates the interaction after 3s,
    so every op here must be O(1)-ish on cached data.
    """
    try:
        now = time_module.time()
        if now - _SEASONS_CHOICES_CACHE_TS > _SEASONS_CHOICES_TTL_SECONDS:
            choices = _refresh_seasons_choices_cache()
        else:
            choices = _SEASONS_CHOICES_CACHE
        current_l = (current or "").lower()
        if current_l:
            choices = [c for c in choices if current_l in c[0].lower()]
        return [
            discord.app_commands.Choice(name=label, value=name)
            for name, label in choices[:25]
        ]
    except Exception as e:
        print(f"[AUTOCOMPLETE ERROR] seasons_autocomplete failed: {e}", flush=True)
        return []


def _recap_lang(guild_lang_code: str) -> str:
    """Map a guild lang to a lang the recap renderer supports (falls back to 'en')."""
    return guild_lang_code if guild_lang_code in RECAP_LANGS else 'en'


RECAP_THEME_CHOICES = [
    app_commands.Choice(name=command_locale("Dark", "commands.common.choice_dark"), value="dark"),
    app_commands.Choice(name=command_locale("Light", "commands.common.choice_light"), value="light"),
]


@is_blacklisted()
@gate_entitle("standard")
@bot.tree.command(name="sq-card", description=command_locale("Generate a season recap card for a squadron", "commands.sq_card.description"))
@app_commands.describe(
    season=command_locale("The season to generate the card for", "commands.common.season"),
    squadron=command_locale("The short name of the squadron", "commands.sq_card.squadron"),
    theme=command_locale("Card color theme", "commands.common.theme"),
)
@app_commands.choices(theme=RECAP_THEME_CHOICES)
@discord.app_commands.autocomplete(
    season=seasons_autocomplete,
    squadron=squadron_autocomplete,
)
async def sq_card(
    interaction: discord.Interaction,
    season: str,
    squadron: str = "",
    theme: app_commands.Choice[str] | None = None,
):
    """Generate and send a season recap card PNG for a squadron.

    Args:
        interaction: The Discord interaction.
        season: Season identifier (e.g. "2026-II").
        squadron: Short name of the squadron (falls back to guild default).
        theme: "dark" (default) or "light".
    """
    await collect_command_stats(interaction)
    await interaction.response.defer(ephemeral=False)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
    theme_value = theme.value if theme else 'dark'

    # Validate season up-front so bad input fails fast without rendering.
    seasons = get_seasons()
    if season not in seasons:
        embed = discord.Embed(
            title=t(lang, "common.error_title"),
            description=t(lang, "recap_card.unknown_season", season=season),
            color=discord.Color.red(),
        )
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        return await interaction.followup.send(embed=embed, ephemeral=True)

    try:
        clan = await get_guild_squadron(interaction.guild_id, squadron)
    except ValueError as e:
        embed = discord.Embed(title=t(lang, "common.error_title"), description=str(e), color=discord.Color.red())
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        return await interaction.followup.send(embed=embed, ephemeral=True)

    squadron_name = clan["long_name"]
    short_name = clan.get("short_name") or squadron_name

    clan_id = await resolve_clan_id(squadron_name)
    if clan_id is None:
        embed = discord.Embed(
            title=t(lang, "common.error_title"),
            description=t(lang, "recap_card.no_clan_id", squadron=squadron_name),
            color=discord.Color.red(),
        )
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        return await interaction.followup.send(embed=embed, ephemeral=True)

    try:
        path = await get_squadron_recap(clan_id, season, theme_value, _recap_lang(lang))
    except RecapError:
        embed = discord.Embed(
            title=t(lang, "common.error_title"),
            description=t(lang, "recap_card.render_failed"),
            color=discord.Color.red(),
        )
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        return await interaction.followup.send(embed=embed, ephemeral=True)

    safe_name = re.sub(r'[^A-Za-z0-9_-]+', '_', short_name) or str(clan_id)
    filename = f"{safe_name}-{season}.png"
    await interaction.followup.send(file=discord.File(path, filename=filename))


@sq_card.error
async def sq_card_error(interaction, error):
    await permission_fail(interaction, error)


# Dark mode color scheme for graphs
SQ_STATS_GRAPH_COLORS = {
    'bg': '#1e1e2e',           # Background color
    'plot_bg': '#2b2b3c',      # Plot area background
    'text': "#16c52e",         # Text color
    'grid': "#6d6d6d",         # Grid line color
    'squadron_line': "#12ed2f", # Squadron total line
}


class PlayerSelect(discord.ui.Select):
    """Paginated dropdown for selecting players from a squadron's point history.

    Each page shows up to 25 players (Discord select limit). Selections are
    tracked in the parent PlayerRefineView so they persist across page changes.
    """

    def __init__(self, squadron_name: str, timestamps: list, player_histories: dict,
                 dates_numeric: list, filtered_ticks: list, filtered_labels: list, page: int = 0, parent_view=None, lang: str = "en"):
        self.squadron_name = squadron_name
        self.timestamps = timestamps
        self.all_player_histories = player_histories
        self.dates_numeric = dates_numeric
        self.filtered_ticks = filtered_ticks
        self.filtered_labels = filtered_labels
        self.page = page
        self.parent_view = parent_view
        self.lang = lang

        # Pagination: 25 players per page (Discord limit)
        players_per_page = 25
        all_players = list(player_histories.items())
        start_idx = page * players_per_page
        end_idx = start_idx + players_per_page
        page_players = all_players[start_idx:end_idx]

        # Create options with player name and UID
        options = []
        for uid, player_data in page_players:
            clean_nick = re.sub(r'[^a-zA-Z0-9\u0400-\u04FF\u4E00-\u9FFF\s_-]', '', player_data["nick"])
            # Truncate name if too long (Discord limit is 100 chars for label, 100 for description)
            label = clean_nick[:80] if len(clean_nick) > 80 else clean_nick
            description = f"UID: {uid}"
            # Mark as default if already selected
            is_default = parent_view and uid in parent_view.selected_uids if parent_view else False
            options.append(discord.SelectOption(label=label, description=description, value=uid, default=is_default))

        super().__init__(
            placeholder=t(lang, "sq_stats.select_players_placeholder", page=page + 1),
            min_values=0,
            max_values=len(options),
            options=options
        )

    async def callback(self, interaction: discord.Interaction):
        # Update parent view's selected UIDs
        if self.parent_view:
            # Remove previous selections from this page
            players_per_page = 25
            all_players = list(self.all_player_histories.keys())
            start_idx = self.page * players_per_page
            end_idx = start_idx + players_per_page
            page_player_uids = all_players[start_idx:end_idx]

            # Remove old selections from this page
            self.parent_view.selected_uids = {uid for uid in self.parent_view.selected_uids
                                             if uid not in page_player_uids}

            # Add new selections
            self.parent_view.selected_uids.update(self.values)

        # Acknowledge interaction silently (message stays for further interaction)
        await interaction.response.defer()


class PlayerRefineView(discord.ui.View):
    """Interactive view for refining player selections and generating filtered charts.

    Contains a PlayerSelect dropdown, pagination buttons, and a "Generate Chart"
    button. Selected player UIDs persist across page changes so users can pick
    players from multiple pages before charting.
    """

    def __init__(self, squadron_name: str, timestamps: list, player_histories: dict,
                 dates_numeric: list, filtered_ticks: list, filtered_labels: list, page: int = 0, selected_uids: Optional[set] = None, lang: str = "en"):
        super().__init__(timeout=7200)  # 2 hours
        self.message: Optional[discord.Message] = None
        self.squadron_name = squadron_name
        self.timestamps = timestamps
        self.player_histories = player_histories
        self.dates_numeric = dates_numeric
        self.filtered_ticks = filtered_ticks
        self.filtered_labels = filtered_labels
        self.page = page
        self.selected_uids = selected_uids if selected_uids is not None else set()
        self.lang = lang

        # Add player select dropdown
        self.select = PlayerSelect(squadron_name, timestamps, player_histories, dates_numeric, filtered_ticks, filtered_labels, page, parent_view=self, lang=lang)
        self.add_item(self.select)

        # Add pagination buttons if needed
        players_per_page = 25
        total_pages = (len(player_histories) + players_per_page - 1) // players_per_page

        if total_pages > 1:
            # Previous button
            prev_button = discord.ui.Button(
                label=t(lang, "buttons.prev_arrow"),
                style=discord.ButtonStyle.secondary,
                disabled=(page == 0)
            )
            prev_button.callback = self.previous_page
            self.add_item(prev_button)

            # Next button
            next_button = discord.ui.Button(
                label=t(lang, "buttons.next_arrow"),
                style=discord.ButtonStyle.secondary,
                disabled=(page >= total_pages - 1)
            )
            next_button.callback = self.next_page
            self.add_item(next_button)

        # Add "Generate Chart" button
        generate_button = discord.ui.Button(
            label=t(lang, "buttons.generate_chart"),
            style=discord.ButtonStyle.success,
            row=2
        )
        generate_button.callback = self.generate_chart
        self.add_item(generate_button)

    async def on_timeout(self):
        for item in self.children:
            item.disabled = True  # type: ignore[attr-defined]
        try:
            if self.message:
                await self.message.edit(view=self)
        except Exception:
            pass

    async def generate_chart(self, interaction: discord.Interaction):
        """Generate a player points chart filtered to the currently selected UIDs."""
        await interaction.response.defer(ephemeral=False)
        lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

        if not self.selected_uids:
            await interaction.followup.send(t(lang, "common.no_players_selected"), ephemeral=True)
            return

        # Filter player_histories to only selected UIDs
        filtered_histories = {uid: self.player_histories[uid] for uid in self.selected_uids
                             if uid in self.player_histories}

        # Generate chart with filtered players
        view_helper = SquadronStatsView(self.squadron_name, self.timestamps, self.player_histories,
                                       self.dates_numeric, self.filtered_ticks, self.filtered_labels, lang=lang)
        embed, temp_path = await view_helper.generate_player_chart(filtered_histories, lang=lang)

        # Create new refine view for further refinement (preserve selections)
        refine_view = PlayerRefineView(self.squadron_name, self.timestamps, self.player_histories,
                                      self.dates_numeric, self.filtered_ticks, self.filtered_labels, 0, self.selected_uids, lang=lang)

        refine_view.message = await interaction.followup.send(embed=embed, file=discord.File(temp_path), view=refine_view, wait=True)

        # Clean up temporary file
        try:
            temp_path.unlink()
        except Exception:
            pass

    async def previous_page(self, interaction: discord.Interaction):
        new_page = self.page - 1
        await self.update_page(interaction, new_page)

    async def next_page(self, interaction: discord.Interaction):
        new_page = self.page + 1
        await self.update_page(interaction, new_page)

    async def update_page(self, interaction: discord.Interaction, new_page: int):
        # Create new view with updated page, preserving selected UIDs
        new_view = PlayerRefineView(self.squadron_name, self.timestamps, self.player_histories,
                                    self.dates_numeric, self.filtered_ticks, self.filtered_labels, new_page, self.selected_uids, lang=self.lang)

        # Update the message with new view
        await interaction.response.edit_message(view=new_view)


class SquadronStatsView(discord.ui.View):
    """View attached to /sq-stats with buttons for player breakdown and leaderboard comparison.

    Holds all the squadron's historical data needed to generate individual player
    charts and nearby-squadron comparison charts on demand.
    """

    def __init__(self, squadron_name: str, timestamps: list, player_histories: dict, dates_numeric: list, filtered_ticks: list, filtered_labels: list, lang: str = "en"):
        super().__init__(timeout=21600)
        self.message: Optional[discord.Message] = None
        self.squadron_name = squadron_name
        self.timestamps = timestamps
        self.player_histories = player_histories
        self.dates_numeric = dates_numeric
        self.filtered_ticks = filtered_ticks
        self.filtered_labels = filtered_labels
        self.lang = lang
        self.view_player_stats.label = t(lang, "buttons.view_player_stats")
        self.compare_nearby_squadrons.label = t(lang, "buttons.compare_nearby")

    async def on_timeout(self):
        for item in self.children:
            item.disabled = True  # type: ignore[attr-defined]
        try:
            if self.message:
                await self.message.edit(view=self)
        except Exception:
            pass

    async def generate_player_chart(self, filtered_player_histories: Optional[dict] = None, lang: str = "en"):
        """Generate player stats chart. If filtered_player_histories is provided, use that, otherwise use all players."""
        player_data = filtered_player_histories if filtered_player_histories else self.player_histories

        # Create player stats chart with dark mode
        fig, ax = plt.subplots(figsize=(16, 8), facecolor=SQ_STATS_GRAPH_COLORS['bg'])
        ax.set_facecolor(SQ_STATS_GRAPH_COLORS['plot_bg'])

        # Collect all point changes to determine color scale range
        all_changes = []
        for uid, p_data in player_data.items():
            points = p_data["points"]
            if len(points) == len(self.dates_numeric) and len(points) > 1:
                for i in range(1, len(points)):
                    change = points[i] - points[i-1]
                    all_changes.append(change)

        # Determine color scale bounds using percentiles to avoid extreme outliers
        if all_changes:
            # Use 5th and 95th percentiles to avoid outliers dominating the scale
            p5 = float(np.percentile(all_changes, 5))
            p95 = float(np.percentile(all_changes, 95))

            # Ensure we have negative and positive bounds for diverging colormap
            # But don't force them to be symmetric - use actual data distribution
            vmin = float(min(p5, -abs(p95) * 0.1)) if p5 >= 0 else p5  # Allow negative even if all gains
            vmax = float(max(p95, abs(p5) * 0.1)) if p95 <= 0 else p95  # Allow positive even if all losses

            # TwoSlopeNorm requires vmin < vcenter(0) < vmax strictly
            # When all changes are 0, both vmin and vmax become 0, causing ValueError
            if vmin >= 0:
                vmin = -1.0
            if vmax <= 0:
                vmax = 1.0
        else:
            vmin, vmax = -1.0, 1.0

        # Create colormap: Red for decreases, Yellow for neutral, Green for increases
        # Use TwoSlopeNorm to ensure 0 is always at the center (yellow)
        cmap = cm.get_cmap('RdYlGn')
        norm = TwoSlopeNorm(vmin=vmin, vcenter=0.0, vmax=vmax)

        # Collect endpoint positions for smart labeling
        endpoints = []
        for uid, p_data in player_data.items():
            points = p_data["points"]
            if len(points) == len(self.dates_numeric) and len(points) > 1:
                # Create line segments with colors based on point change
                segments = []
                colors = []

                for i in range(len(points) - 1):
                    # Create segment from point i to point i+1
                    segment = [(self.dates_numeric[i], points[i]),
                              (self.dates_numeric[i+1], points[i+1])]
                    segments.append(segment)

                    # Calculate change and map to color
                    change = points[i+1] - points[i]
                    color = cmap(norm(change))
                    colors.append(color)

                # Create and add LineCollection
                lc = LineCollection(segments, colors=colors, linewidths=1.5, alpha=0.7)
                ax.add_collection(cast(Any, lc))

                # Clean special characters from nickname (keep ASCII, Cyrillic, Chinese/CJK, spaces, underscores, hyphens)
                clean_nick = re.sub(r'[^a-zA-Z0-9\u0400-\u04FF\u4E00-\u9FFF\s_-]', '', p_data["nick"])
                # Use average color for endpoint label (based on overall trend)
                avg_change = (points[-1] - points[0]) / (len(points) - 1) if len(points) > 1 else 0
                avg_color = cmap(norm(avg_change))
                endpoints.append((points[-1], clean_nick, avg_color))

        # Sort endpoints by Y position
        endpoints.sort(key=lambda x: x[0])

        # Set axis limits based on data (LineCollection doesn't auto-scale)
        if self.dates_numeric and endpoints:
            all_y_values = []
            for uid, p_data in player_data.items():
                if len(p_data["points"]) == len(self.dates_numeric):
                    all_y_values.extend(p_data["points"])

            if all_y_values:
                ax.set_xlim(min(self.dates_numeric), max(self.dates_numeric))
                ax.set_ylim(min(all_y_values) - 50, max(all_y_values) + 50)

        # Extend x-axis to make room for labels on the right
        x_min, x_max = ax.get_xlim()
        x_range = x_max - x_min
        ax.set_xlim(x_min, x_max + x_range * 0.08)  # Add 8% padding to right

        # Set minimum separation based on whether this is a refined view or all players
        # Non-refined (all players): 50 points minimum separation
        # Refined (filtered players): 25 points minimum separation
        min_separation = 25 if filtered_player_histories is not None else 50

        # Only label players with enough vertical separation
        last_labeled_y = None
        for y_pos, nick, color in endpoints:
            if last_labeled_y is None or abs(y_pos - last_labeled_y) >= min_separation:
                ax.annotate(nick,
                           xy=(self.dates_numeric[-1], y_pos),
                           xytext=(5, 0),
                           textcoords='offset points',
                           fontsize=7,
                           color=SQ_STATS_GRAPH_COLORS['text'],
                           va='center',
                           alpha=0.9)
                last_labeled_y = y_pos

        # Formatting with dark mode colors
        ax.set_xlabel('Time', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text'])
        ax.set_ylabel('Player Points', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text'])
        ax.set_title(f'{self.squadron_name}', fontsize=14, fontweight='bold', color=SQ_STATS_GRAPH_COLORS['text'])
        ax.grid(True, alpha=0.2, color=SQ_STATS_GRAPH_COLORS['grid'])
        ax.tick_params(colors=SQ_STATS_GRAPH_COLORS['text'])
        ax.spines['bottom'].set_color(SQ_STATS_GRAPH_COLORS['text'])
        ax.spines['top'].set_color(SQ_STATS_GRAPH_COLORS['text'])
        ax.spines['left'].set_color(SQ_STATS_GRAPH_COLORS['text'])
        ax.spines['right'].set_color(SQ_STATS_GRAPH_COLORS['text'])

        # Set x-axis ticks to only show where changes occurred
        ax.set_xticks(self.filtered_ticks)
        ax.set_xticklabels(self.filtered_labels, rotation=45, ha='right', color=SQ_STATS_GRAPH_COLORS['text'])

        plt.tight_layout()

        # Save to temporary file
        temp_path = Path(f"/tmp/sq_stats_players_{self.squadron_name.replace(' ', '_')}_{int(time_module.time())}.png")
        plt.savefig(temp_path, dpi=150, bbox_inches='tight', facecolor=SQ_STATS_GRAPH_COLORS['bg'])
        plt.close(fig)

        # Return the embed and temp_path
        embed = discord.Embed(
            title=t(lang, "sq_stats.player_title", squadron=self.squadron_name),
            description=t(lang, "sq_stats.player_desc"),
            color=discord.Color.green()
        )
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        embed.set_image(url=f"attachment://{temp_path.name}")

        return embed, temp_path

    async def generate_squadron_comparison_chart(self, lang: str = "en"):
        """Generate comparison chart showing this squadron vs 10 above and 10 below in leaderboard."""
        # Get position of current squadron from squadrons_data
        async with aiosqlite.connect(SQUADRONS_DB_PATH) as db:
            async with db.execute("""
                SELECT position FROM squadrons_data
                WHERE LOWER(long_name) = ?
                LIMIT 1
            """, (self.squadron_name.lower(),)) as cursor:
                row = await cursor.fetchone()
                if not row:
                    return None, None, t(lang, "sq_stats.squadron_not_found_error")
                current_position = row[0]

            # Get squadrons 5 above and 5 below
            min_pos = max(0, current_position - 5)
            max_pos = current_position + 5

            async with db.execute("""
                SELECT long_name, short_name, position
                FROM squadrons_data
                WHERE position >= ? AND position <= ? AND position IS NOT NULL
                ORDER BY position ASC
            """, (min_pos, max_pos)) as cursor:
                nearby_squadrons = await cursor.fetchall()

        if not nearby_squadrons:
            return None, None, t(lang, "sq_stats.no_nearby_error")

        # Fetch historical data for each squadron
        squadron_histories = {}
        async with aiosqlite.connect(SQUADRONS_DB_PATH) as db:
            for long_name, short_name, position in nearby_squadrons:
                async with db.execute("""
                    SELECT unix_time, total_score
                    FROM squadrons_points
                    WHERE long_name = ?
                    ORDER BY unix_time ASC
                """, (long_name,)) as cursor:
                    rows = await cursor.fetchall()
                    if rows:
                        # Limit to same timeframe as current squadron
                        if len(self.timestamps) > 0:
                            min_time = min(self.timestamps)
                            max_time = max(self.timestamps)
                            filtered_rows = [(t, s) for t, s in rows if min_time <= t <= max_time]
                            if filtered_rows:
                                squadron_histories[long_name] = {
                                    "short_name": short_name,
                                    "position": position,
                                    "timestamps": [r[0] for r in filtered_rows],
                                    "scores": [r[1] for r in filtered_rows]
                                }

        if not squadron_histories:
            return None, None, t(lang, "sq_stats.no_historical_error")

        # Create comparison chart with dark mode
        fig, ax = plt.subplots(figsize=(16, 10), facecolor=SQ_STATS_GRAPH_COLORS['bg'])
        ax.set_facecolor(SQ_STATS_GRAPH_COLORS['plot_bg'])

        # Convert timestamps to datetime for formatting

        # Find squadrons that have overtaken ours (their score crossed above ours at some point)
        overtaking_squadrons = set()
        our_data_pre = next((data for ln, data in squadron_histories.items()
                             if ln.lower() == self.squadron_name.lower()), None)
        if our_data_pre:
            our_dates_numeric_pre = [mdates.date2num(datetime.fromtimestamp(ts)) for ts in our_data_pre["timestamps"]]
            our_scores_pre = our_data_pre["scores"]
            for long_name, data in squadron_histories.items():
                if long_name.lower() == self.squadron_name.lower():
                    continue
                other_dates = [mdates.date2num(datetime.fromtimestamp(ts)) for ts in data["timestamps"]]
                other_scores = data["scores"]
                common_times = sorted(set(our_dates_numeric_pre) & set(other_dates))
                if len(common_times) < 2:
                    continue
                our_at_common = [our_scores_pre[our_dates_numeric_pre.index(t)] for t in common_times]
                other_at_common = [other_scores[other_dates.index(t)] for t in common_times]
                for i in range(1, len(common_times)):
                    diff_prev = our_at_common[i-1] - other_at_common[i-1]
                    diff_curr = our_at_common[i] - other_at_common[i]
                    if diff_prev * diff_curr < 0 and diff_curr < 0:  # other crossed above us
                        overtaking_squadrons.add(long_name)
                        break

        # Assign colors: our squadron = green, overtakers = red, rest = grey
        grey_shades = ['#888888', '#999999', '#aaaaaa', '#777777', '#bbbbbb',
                       '#666666', '#cccccc', '#555555', '#dddddd', '#444444']
        grey_idx = 0

        # Store our squadron's data for crossover detection
        our_dates_numeric = None
        our_scores = None

        endpoints = []
        for long_name, data in squadron_histories.items():
            dates = [datetime.fromtimestamp(ts) for ts in data["timestamps"]]
            dates_numeric = [mdates.date2num(d) for d in dates]
            scores = data["scores"]

            is_ours = long_name.lower() == self.squadron_name.lower()
            has_overtaken = long_name in overtaking_squadrons

            if is_ours:
                color = SQ_STATS_GRAPH_COLORS['squadron_line']
                linewidth = 3.5
                alpha = 1.0
                zorder = 100
                marker = 'o'
                our_dates_numeric = dates_numeric
                our_scores = scores
            elif has_overtaken:
                color = '#e84040'
                linewidth = 2.5
                alpha = 0.9
                zorder = 50
                marker = None
            else:
                color = grey_shades[grey_idx % len(grey_shades)]
                grey_idx += 1
                linewidth = 1.2
                alpha = 0.4
                zorder = 1
                marker = None

            ax.plot(dates_numeric, scores,
                   marker=marker,
                   linestyle='-',
                   linewidth=linewidth,
                   markersize=4,
                   color=color,
                   alpha=alpha,
                   zorder=zorder)

            # Store endpoint for labeling
            display_name = data["short_name"] if data["short_name"] else long_name[:15]
            if is_ours:
                display_name = f"★ {display_name}"
            endpoints.append((scores[-1], display_name, color, data["position"], is_ours, has_overtaken))

        # Detect and highlight crossover points (where our squadron crosses another)
        if our_dates_numeric and our_scores:
            for long_name, data in squadron_histories.items():
                if long_name.lower() == self.squadron_name.lower():
                    continue
                other_dates = [mdates.date2num(datetime.fromtimestamp(ts)) for ts in data["timestamps"]]
                other_scores = data["scores"]

                # Interpolate both to common timestamps for comparison
                common_times = sorted(set(our_dates_numeric) & set(other_dates))
                if len(common_times) < 2:
                    continue

                our_at_common = [our_scores[our_dates_numeric.index(t)] for t in common_times]
                other_at_common = [other_scores[other_dates.index(t)] for t in common_times]

                # Find crossover points (sign change in difference)
                for i in range(1, len(common_times)):
                    diff_prev = our_at_common[i-1] - other_at_common[i-1]
                    diff_curr = our_at_common[i] - other_at_common[i]
                    if diff_prev * diff_curr < 0:  # sign changed = crossover
                        cross_x = common_times[i]
                        cross_y = our_at_common[i]
                        # Green if we crossed above them, red if we fell below
                        cross_color = SQ_STATS_GRAPH_COLORS['squadron_line'] if diff_curr > 0 else '#e84040'
                        ax.plot(cross_x, cross_y, marker='X', markersize=14,
                               color=cross_color, zorder=200, markeredgecolor='#000000',
                               markeredgewidth=1.5)

        # Sort endpoints by Y position for labeling
        endpoints.sort(key=lambda x: x[0])

        # Extend x-axis to make room for labels
        if endpoints:
            x_min, x_max = ax.get_xlim()
            x_range = x_max - x_min
            ax.set_xlim(x_min, x_max + x_range * 0.12)

            # Label squadrons with smart spacing
            last_labeled_y = None
            min_separation = (max(e[0] for e in endpoints) - min(e[0] for e in endpoints)) / 30
            for y_pos, name, color, position, is_ours, has_overtaken in endpoints:
                if last_labeled_y is None or abs(y_pos - last_labeled_y) >= min_separation:
                    label_color = color if (is_ours or has_overtaken) else SQ_STATS_GRAPH_COLORS['text']
                    ax.annotate(f"#{position+1} {name}",
                               xy=(x_max, y_pos),
                               xytext=(5, 0),
                               textcoords='offset points',
                               fontsize=9,
                               color=label_color,
                               va='center',
                               alpha=1.0 if (is_ours or has_overtaken) else 0.6,
                               fontweight='bold' if (is_ours or has_overtaken) else 'normal')
                    last_labeled_y = y_pos

        # Formatting with dark mode colors
        ax.set_xlabel('Time', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text'])
        ax.set_ylabel('Total Squadron Score', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text'])
        ax.set_title(f'Leaderboard Comparison (±5 positions)', fontsize=14, fontweight='bold', color=SQ_STATS_GRAPH_COLORS['text'])
        ax.grid(True, alpha=0.2, color=SQ_STATS_GRAPH_COLORS['grid'])
        ax.tick_params(colors=SQ_STATS_GRAPH_COLORS['text'])
        ax.spines['bottom'].set_color(SQ_STATS_GRAPH_COLORS['text'])
        ax.spines['top'].set_color(SQ_STATS_GRAPH_COLORS['text'])
        ax.spines['left'].set_color(SQ_STATS_GRAPH_COLORS['text'])
        ax.spines['right'].set_color(SQ_STATS_GRAPH_COLORS['text'])

        # Format x-axis with dates
        ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d'))
        ax.xaxis.set_major_locator(mdates.AutoDateLocator())
        plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha='right', color=SQ_STATS_GRAPH_COLORS['text'])

        plt.tight_layout()

        # Save to temporary file
        temp_path = Path(f"/tmp/sq_comparison_{self.squadron_name.replace(' ', '_')}_{int(time_module.time())}.png")
        plt.savefig(temp_path, dpi=150, bbox_inches='tight', facecolor=SQ_STATS_GRAPH_COLORS['bg'])
        plt.close(fig)

        # Determine actual range of positions shown (convert to 1-indexed for display)
        if squadron_histories:
            positions = [data["position"] for data in squadron_histories.values()]
            min_shown_pos = min(positions) + 1  # Convert to 1-indexed
            max_shown_pos = max(positions) + 1  # Convert to 1-indexed
            position_range = f"#{min_shown_pos} to #{max_shown_pos}"
        else:
            position_range = "N/A"

        # Create embed
        embed = discord.Embed(
            title=t(lang, "sq_stats.comparison_title", squadron=self.squadron_name),
            description=t(lang, "sq_stats.comparison_desc", range=position_range),
            color=discord.Color.purple()
        )
        embed.add_field(name=t(lang, "sq_stats.current_position_field"), value=f"#{current_position+1}", inline=True)
        embed.add_field(name=t(lang, "sq_stats.squadrons_shown_field"), value=str(len(squadron_histories)), inline=True)
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        embed.set_image(url=f"attachment://{temp_path.name}")

        return embed, temp_path, None

    @discord.ui.button(label="📊 View Player Stats", style=discord.ButtonStyle.primary)
    async def view_player_stats(self, interaction: discord.Interaction, button: discord.ui.Button):
        """Generate and send the individual player points chart with a refine view."""
        await interaction.response.defer(ephemeral=False)
        lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

        # Generate chart for all players
        embed, temp_path = await self.generate_player_chart(lang=lang)

        # Create view with refine button
        refine_view = PlayerRefineView(self.squadron_name, self.timestamps, self.player_histories,
                                       self.dates_numeric, self.filtered_ticks, self.filtered_labels, lang=lang)

        refine_view.message = await interaction.followup.send(embed=embed, file=discord.File(temp_path), view=refine_view, wait=True)

        # Clean up temporary file
        try:
            temp_path.unlink()
        except Exception:
            pass

    @discord.ui.button(label="📈 Compare Nearby Squadrons", style=discord.ButtonStyle.secondary)
    async def compare_nearby_squadrons(self, interaction: discord.Interaction, button: discord.ui.Button):
        """Generate and send a leaderboard comparison chart with 5 squadrons above and below."""
        await interaction.response.defer(ephemeral=False)
        lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

        # Generate comparison chart
        embed, temp_path, error = await self.generate_squadron_comparison_chart(lang=lang)

        if error or not embed or not temp_path:
            error_embed = discord.Embed(
                title=t(lang, "common.error_title"),
                description=error or t(lang, "sq_stats.comparison_chart_failed"),
                color=discord.Color.red()
            )
            error_embed.set_footer(text=DEFAULT_FOOTER_CAT)
            await interaction.followup.send(embed=error_embed, ephemeral=True)
            return

        await interaction.followup.send(embed=embed, file=discord.File(temp_path))

        # Clean up temporary file
        try:
            temp_path.unlink()
        except Exception:
            pass



@is_blacklisted()
@bot.tree.command(
    name="sq-stats",
    description=command_locale("Display a squadron's points over time", "commands.sq_stats.description"),
    guild=None
)
@discord.app_commands.autocomplete(squadron=squadron_autocomplete)
async def sq_stats(interaction: discord.Interaction, squadron: str = "", data_points: int = 150):
    """Display a squadron's total score trend over time as a line chart.

    Reads historical point snapshots from squadrons_points in SQLite, plots
    the total score with timeslot-aware x-axis labels (NA/EU), and attaches
    a SquadronStatsView with buttons for player breakdown and leaderboard
    comparison.

    Args:
        interaction: The Discord interaction.
        squadron: Short name of the squadron (falls back to guild default).
        data_points: Number of recent data points to plot (clamped to 2-500).
    """
    await collect_command_stats(interaction)
    await interaction.response.defer(ephemeral=False)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    # Clamp data_points to minimum of 2, so its not just a single dot lol
    if data_points < 1:
        data_points = 2
    if data_points > 500:
        data_points = 500

    try:
        clan = await get_guild_squadron(interaction.guild_id, squadron)
    except ValueError as e:
        embed = discord.Embed(title=t(lang, "common.error_title"), description=str(e), color=discord.Color.red())
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        return await interaction.followup.send(embed=embed, ephemeral=True)

    squadron_name = clan["long_name"]

    # Read historical data from squadrons_points table
    timestamps = []
    total_scores = []
    clan_pts_data = []

    async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=30.0) as db:
        async with db.execute("""
            SELECT unix_time, total_score, clan_pts
            FROM squadrons_points
            WHERE long_name = ?
            ORDER BY unix_time ASC
        """, (squadron_name,)) as cursor:
            rows = await cursor.fetchall()
            for row in rows:
                timestamps.append(row[0])
                total_scores.append(row[1])
                clan_pts_data.append(row[2])

    if not timestamps or not total_scores:
        embed = discord.Embed(
            title=t(lang, "sq_stats.no_data_title"),
            description=t(lang, "sq_stats.no_data_desc", squadron=squadron_name),
            color=discord.Color.orange()
        )
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        await interaction.followup.send(embed=embed, ephemeral=True)
        return

    # Limit to last N data points
    if len(timestamps) > data_points:
        timestamps = timestamps[-data_points:]
        total_scores = total_scores[-data_points:]
        clan_pts_data = clan_pts_data[-data_points:]

    # Parse player data from clan_pts JSON
    # Format: [members_dict, total_score] where members_dict = {uid: {"nick": str, "points": int}}
    player_histories = {}  # {uid: {"nick": str, "points": [list of points over time]}}

    for clan_pts_json in clan_pts_data:
        try:
            members_dict, _ = decompress_json(clan_pts_json)
            for uid, player_data in members_dict.items():
                if uid not in player_histories:
                    player_histories[uid] = {
                        "nick": player_data["nick"],
                        "points": []
                    }
                player_histories[uid]["points"].append(player_data["points"])
        except (json.JSONDecodeError, KeyError, ValueError, TypeError, OSError):
            # If parsing fails, skip this data point
            continue

    # Create line chart with dark mode
    fig, ax = plt.subplots(figsize=(12, 6), facecolor=SQ_STATS_GRAPH_COLORS['bg'])
    ax.set_facecolor(SQ_STATS_GRAPH_COLORS['plot_bg'])

    # Convert timestamps to datetime objects for better formatting
    dates = [datetime.fromtimestamp(ts) for ts in timestamps]
    # Convert to matplotlib numeric format for type compatibility
    dates_numeric = [mdates.date2num(d) for d in dates]

    # Plot squadron total with dark mode color
    ax.plot(dates_numeric, total_scores, marker='o', linestyle='-', linewidth=2.5, markersize=5, color=SQ_STATS_GRAPH_COLORS['squadron_line'])

    # Formatting with dark mode colors
    ax.set_xlabel('Time', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text'])
    ax.set_ylabel('Total Squadron Score', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text'])
    ax.set_title(f'{squadron_name}', fontsize=14, fontweight='bold', color=SQ_STATS_GRAPH_COLORS['text'])
    ax.grid(True, alpha=0.2, color=SQ_STATS_GRAPH_COLORS['grid'])
    ax.tick_params(colors=SQ_STATS_GRAPH_COLORS['text'])
    ax.spines['bottom'].set_color(SQ_STATS_GRAPH_COLORS['text'])
    ax.spines['top'].set_color(SQ_STATS_GRAPH_COLORS['text'])
    ax.spines['left'].set_color(SQ_STATS_GRAPH_COLORS['text'])
    ax.spines['right'].set_color(SQ_STATS_GRAPH_COLORS['text'])

    # Add padding to y-axis to prevent annotation clipping
    y_min, y_max = ax.get_ylim()
    y_range = y_max - y_min
    ax.set_ylim(y_min, y_max + y_range * 0.15)  # Add 15% padding to top for arrow annotations

    # Determine timeslot region by hour: NA ~01:00-07:00, EU ~14:00-22:00
    def get_timeslot_region(dt):
        """Return 'NA' or 'EU' region label based on UTC hour, or None if outside SQB hours."""
        if 0 <= dt.hour <= 9:
            return 'NA'
        elif dt.hour >= 13:
            return 'EU'
        return None

    # Only show ticks where the score changed, with one label per timeslot
    # A timeslot instance is identified by (date, region) e.g. ("02/15", "EU")
    # The label goes on the first change within that timeslot
    filtered_ticks = []   # date_num values for tick placement
    filtered_labels = []  # label strings ('' for unlabeled ticks)
    filtered_scores = []  # scores at each filtered tick (for annotations)
    labeled_timeslots = set()

    for i, (dt, date_num, score) in enumerate(zip(dates, dates_numeric, total_scores)):
        # Always include the first data point as a tick
        if i == 0:
            region = get_timeslot_region(dt)
            if region:
                date_str = dt.strftime('%m/%d')
                filtered_ticks.append(date_num)
                filtered_labels.append(f'{date_str} {region}')
                filtered_scores.append(score)
                labeled_timeslots.add((dt.strftime('%Y-%m-%d'), region))
            else:
                filtered_ticks.append(date_num)
                filtered_labels.append('')
                filtered_scores.append(score)
            continue

        # Skip points where the score didn't change
        if score == total_scores[i - 1]:
            continue

        filtered_ticks.append(date_num)
        filtered_scores.append(score)

        # Check if this timeslot instance needs a label
        region = get_timeslot_region(dt)
        if region:
            timeslot_key = (dt.strftime('%Y-%m-%d'), region)
            if timeslot_key not in labeled_timeslots:
                date_str = dt.strftime('%m/%d')
                filtered_labels.append(f'{date_str} {region}')
                labeled_timeslots.add(timeslot_key)
            else:
                filtered_labels.append('')
        else:
            filtered_labels.append('')

    # Set x-axis ticks to only show where changes occurred
    ax.set_xticks(filtered_ticks)
    ax.set_xticklabels(filtered_labels, rotation=45, ha='right', color=SQ_STATS_GRAPH_COLORS['text'])

    # Add score annotations on labeled tick points (first change per timeslot)
    # Skip annotations that would overlap with any previously placed annotation
    labeled_annotations = [(tick, score) for tick, label, score in
                           zip(filtered_ticks, filtered_labels, filtered_scores)
                           if label]  # Only annotate points that have a visible label

    if labeled_annotations:
        # Convert axis limits to figure out data-per-pixel ratios
        x_range = ax.get_xlim()[1] - ax.get_xlim()[0]
        y_range_axis = ax.get_ylim()[1] - ax.get_ylim()[0]
        fig_width, fig_height = fig.get_size_inches()
        dpi = fig.dpi
        # Approximate text box size in data coords (font 11 ~ 15px height, ~60px width)
        text_h = y_range_axis * 40 / (fig_height * dpi)
        text_w = x_range * 80 / (fig_width * dpi)

        placed = []  # list of (text_x, text_y) in data coords for placed annotations

        for ann_idx, (date_num, score) in enumerate(labeled_annotations):
            # First annotation goes upper-right to avoid clipping against y-axis
            if ann_idx == 0:
                x_offset, y_offset, h_align = 20, 30, 'left'
            else:
                x_offset, y_offset, h_align = -20, 30, 'right'

            # Approximate where the text will land in data coords
            text_x = date_num + (x_range * x_offset / (fig_width * dpi))
            text_y = score + (y_range_axis * y_offset / (fig_height * dpi))

            # Check if this would overlap with any previously placed annotation
            too_close = False
            for px, py in placed:
                if abs(text_x - px) < text_w and abs(text_y - py) < text_h:
                    too_close = True
                    break

            if too_close:
                continue

            ax.annotate(f'{score:,}',
                       xy=(float(date_num), float(score)),
                       xytext=(x_offset, y_offset),
                       textcoords='offset points',
                       ha=h_align,
                       fontsize=11,
                       fontweight='bold',
                       color=SQ_STATS_GRAPH_COLORS['text'],
                       alpha=1.0,
                       zorder=10,
                       arrowprops=dict(
                           arrowstyle='->',
                           color=SQ_STATS_GRAPH_COLORS['text'],
                           alpha=0.9,
                           lw=1.5
                       ))
            placed.append((text_x, text_y))

    plt.tight_layout()

    # Save to temporary file
    temp_path = Path(f"/tmp/sq_stats_{squadron_name.replace(' ', '_')}_{int(time_module.time())}.png")
    plt.savefig(temp_path, dpi=150, bbox_inches='tight', facecolor=SQ_STATS_GRAPH_COLORS['bg'])
    plt.close(fig)

    # Send the chart
    embed = discord.Embed(
        title=t(lang, "sq_stats.title", squadron=squadron_name),
        description=t(lang, "sq_stats.desc", count=len(timestamps)),
        color=discord.Color.blue()
    )
    embed.add_field(name=t(lang, "sq_stats.previous_score_field"), value=f"{total_scores[0]:,}", inline=True)
    embed.add_field(name=t(lang, "sq_stats.current_score_field"), value=f"{total_scores[-1]:,}", inline=True)
    change = total_scores[-1] - total_scores[0]
    embed.add_field(name=t(lang, "sq_stats.change_field"), value=f"{change:+,}", inline=True)
    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    embed.set_image(url=f"attachment://{temp_path.name}")

    # Create view with button for player stats
    view = SquadronStatsView(squadron_name, timestamps, player_histories, dates_numeric, filtered_ticks, filtered_labels, lang=lang)

    view.message = await interaction.followup.send(embed=embed, file=discord.File(temp_path), view=view, wait=True)

    # Clean up temporary file
    try:
        temp_path.unlink()
    except Exception:
        pass


@sq_stats.error
async def sq_stats_error(interaction, error):
    await permission_fail(interaction, error)



async def load_leaderboard_readonly(db_path: Path) -> tuple[list[tuple[str, str, float]], list[float]]:
    """
    Async read-only snapshot of the leaderboard from squadrons.db.

    Returns:
      rows: [(long_name, short_name, clanrating), ...] sorted DESC by clanrating
      ratings_desc: [clanrating, ...] sorted DESC
    """
    rows: list[tuple[str, str, float]] = []
    async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as con:
        con.row_factory = aiosqlite.Row
        async with con.execute(
            """
            SELECT long_name, short_name, clanrating
            FROM squadrons_data
            WHERE clanrating IS NOT NULL AND clanrating > 0
            ORDER BY clanrating DESC
            """
        ) as cur:
            async for r in cur:
                rows.append((r["long_name"], r["short_name"], float(r["clanrating"])))
    ratings_desc = [r[2] for r in rows]
    return rows, ratings_desc


def find_current_rank(
    rows: list[tuple[str, str, float]],
    squadron_long: str,
    squadron_short: str,
) -> tuple[Optional[float], Optional[int]]:
    """Find this squadron's current rating and 1-based rank in the rows snapshot."""
    long_lower = (squadron_long or "").lower()
    short_lower = (squadron_short or "").lower()
    for idx, (long_name, short_name, rating) in enumerate(rows, start=1):
        if ((long_name and long_name.lower() == long_lower) or
            (short_name and short_name.lower() == short_lower)):
            return float(rating), idx
    return None, None


async def get_current_squadron_placement(
    squadron_long: str, squadron_short: str
) -> tuple[Optional[int], Optional[float]]:
    """Return (1-based rank, rating) for a squadron, or (None, None) if not found."""
    rows, _ = await load_leaderboard_readonly(SQUADRONS_DB_PATH)
    rating, rank = find_current_rank(rows, squadron_long, squadron_short)
    return rank, rating


def project_rank(ratings_desc: list[float], projected_rating: float) -> int:
    """Return 1-based projected rank for a DESC list of ratings."""
    for idx, rating in enumerate(ratings_desc, start=1):
        if projected_rating >= rating:
            return idx
    return len(ratings_desc) + 1  # below all current entries




async def _sq_player_autocomplete(
    interaction: discord.Interaction,
    current: str,
) -> List[discord.app_commands.Choice[str]]:
    """Autocomplete player names from the player_games_hist table, scoped to the selected squadron."""
    sq_short = getattr(interaction.namespace, "squadron_short", "") or ""

    # Resolve squadron to clan_id
    clan_id: Optional[int] = None
    try:
        if sq_short:
            async with aiosqlite.connect(SQUADRONS_DB_PATH) as db:
                async with db.execute(
                    "SELECT clan_id FROM squadrons_data WHERE LOWER(short_name) = ? LIMIT 1",
                    (sq_short.lower(),),
                ) as cur:
                    row = await cur.fetchone()
                    if row:
                        clan_id = row[0]
        else:
            # Try guild default
            guild_id = str(interaction.guild_id) if interaction.guild_id else ""
            try:
                sq_cfg = await load_json(STORAGE_DIR / "SQUADRONS.json", {})
                sq_short_default = sq_cfg.get(guild_id, {}).get("SQ_ShortHand_Name", "")
                if sq_short_default:
                    async with aiosqlite.connect(SQUADRONS_DB_PATH) as db:
                        async with db.execute(
                            "SELECT clan_id FROM squadrons_data WHERE LOWER(short_name) = ? LIMIT 1",
                            (sq_short_default.lower(),),
                        ) as cur:
                            row = await cur.fetchone()
                            if row:
                                clan_id = row[0]
            except Exception:
                pass

        if clan_id is None:
            return []

        async with aiosqlite.connect(SQUADRONS_DB_PATH) as db:
            await db.create_function("ulower", 1, str.lower)
            if not current or len(current) < 1:
                # Show top members by points
                async with db.execute(
                    "SELECT nick FROM squadron_members WHERE clan_id = ? ORDER BY points DESC LIMIT 25",
                    (clan_id,),
                ) as cur:
                    rows = await cur.fetchall()
            else:
                async with db.execute(
                    """
                    SELECT nick FROM squadron_members
                    WHERE clan_id = ? AND ulower(nick) LIKE ulower(?)
                    ORDER BY
                        CASE WHEN ulower(nick) = ulower(?) THEN 0
                             WHEN ulower(nick) LIKE ulower(?) THEN 1
                             ELSE 2
                        END,
                        points DESC
                    LIMIT 25
                    """,
                    (clan_id, f"%{current}%", current, f"{current}%"),
                ) as cur:
                    rows = await cur.fetchall()

        return [
            discord.app_commands.Choice(name=row[0][:100], value=row[0][:100])
            for row in rows
        ]
    except Exception:
        return []


# =============================
#  /loss-calculator COMMAND
# =============================
@is_blacklisted()
@bot.tree.command(
    name="loss-calculator",
    description=command_locale("Calculate the point loss if players leave a squadron", "commands.loss_calculator.description")
)
@app_commands.describe(
    squadron_short=command_locale("The short name of the squadron", "commands.common.squadron_short"),
    player1=command_locale("Player leaving", "commands.loss_calculator.player1"),
    player2=command_locale("Player leaving (optional)", "commands.loss_calculator.player_optional"),
    player3=command_locale("Player leaving (optional)", "commands.loss_calculator.player_optional"),
    player4=command_locale("Player leaving (optional)", "commands.loss_calculator.player_optional"),
    player5=command_locale("Player leaving (optional)", "commands.loss_calculator.player_optional"),
    player6=command_locale("Player leaving (optional)", "commands.loss_calculator.player_optional"),
    player7=command_locale("Player leaving (optional)", "commands.loss_calculator.player_optional"),
)
@discord.app_commands.autocomplete(
    squadron_short=squadron_autocomplete,
    player1=_sq_player_autocomplete, player2=_sq_player_autocomplete,
    player3=_sq_player_autocomplete, player4=_sq_player_autocomplete,
    player5=_sq_player_autocomplete, player6=_sq_player_autocomplete,
    player7=_sq_player_autocomplete,
)
async def loss_calculator(
    interaction: discord.Interaction,
    player1: str,
    squadron_short: str = "",
    player2: str = "", player3: str = "",
    player4: str = "", player5: str = "",
    player6: str = "", player7: str = "",
):
    """Calculate projected point loss and rank change if players leave a squadron.

    Fetches live squad data, matches player names to UIDs, computes
    effective point contributions using the top-20 rate multiplier,
    and projects the new leaderboard rank after removal.

    Args:
        interaction: The Discord interaction.
        player1: Required player name leaving the squadron.
        squadron_short: Short name of the squadron (falls back to guild default).
        player2-player7: Optional additional player names leaving.
    """
    await collect_command_stats(interaction)
    await interaction.response.defer(ephemeral=True)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    try:
        clan = await get_guild_squadron(interaction.guild_id, squadron_short)
    except ValueError as e:
        return await interaction.followup.send(str(e), ephemeral=True)

    squadron_name = clan["long_name"]
    squadron_short_key = clan["short_name"]

    # --- Fetch live squad data ---
    try:
        sq_data, sq_total = await obtain_clan_new_points(squadron_name)
    except Exception as e:
        await interaction.followup.send(t(lang, "loss_calc.fetch_failed", error=str(e)), ephemeral=True)
        return

    if not sq_data or sq_total <= 0:
        await interaction.followup.send(t(lang, "loss_calc.no_point_data"), ephemeral=True)
        return

    sorted_players = sorted(sq_data.items(), key=lambda kv: kv[1]["points"], reverse=True)

    # --- Match provided player names to UIDs in sq_data ---
    player_names = [p for p in [player1, player2, player3, player4, player5, player6, player7] if p]
    selected_ids: list[str] = []
    not_found: list[str] = []

    nick_to_uid = {d["nick"].lower(): uid for uid, d in sq_data.items()}
    for name in player_names:
        uid = nick_to_uid.get(name.lower())
        if uid and uid not in selected_ids:
            selected_ids.append(uid)
        else:
            # Fuzzy fallback: partial match
            matches = [u for nick, u in nick_to_uid.items() if name.lower() in nick]
            if len(matches) == 1 and matches[0] not in selected_ids:
                selected_ids.append(matches[0])
            elif not uid:
                not_found.append(name)

    if not selected_ids:
        await interaction.followup.send(
            t(lang, "loss_calc.no_matching_players", squadron=squadron_name), ephemeral=True)
        return

    # --- Compute rate_x and loss ---
    TOP_N = 20
    top20_sum = sum(d["points"] for _, d in sorted_players[:TOP_N])
    other_sum = sum(d["points"] for _, d in sorted_players[TOP_N:])
    rate_x = (sq_total - top20_sum) / other_sum if other_sum > 0 else 0.0

    total_eff = 0.0
    for pid in selected_ids:
        raw = sq_data[pid]["points"]
        idx = next(i for i, (p, _) in enumerate(sorted_players) if p == pid)
        eff = raw if idx < TOP_N else raw * rate_x
        total_eff += eff

    remaining = [(pid, d) for pid, d in sorted_players if pid not in selected_ids]
    new_top20 = sum(d["points"] for _, d in remaining[:TOP_N])
    new_other = sum(d["points"] for _, d in remaining[TOP_N:])
    new_total = new_top20 + rate_x * new_other
    real_loss = sq_total - new_total

    # --- Leaderboard projection ---
    current_rank, current_rating = await get_current_squadron_placement(squadron_name, squadron_short_key)
    _, ratings_desc = await load_leaderboard_readonly(SQUADRONS_DB_PATH)

    # --- Build embed ---
    e = discord.Embed(
        title=t(lang, "loss_calc.title", squadron=esc(squadron_name)),
        color=discord.Color.blurple(),
    )
    e.add_field(
        name=t(lang, "loss_calc.players_leaving_field"),
        value=", ".join(
            f"{esc(sq_data[p]['nick'])} ({sq_data[p]['points']} pts)" for p in selected_ids
        ),
        inline=False,
    )
    e.add_field(name=t(lang, "loss_calc.share_of_total_field"), value=f"{(total_eff / sq_total * 100):.3f}%", inline=True)
    e.add_field(name=t(lang, "loss_calc.points_lost_real_field"), value=f"{real_loss:.1f}", inline=True)
    e.add_field(name=t(lang, "loss_calc.points_lost_raw_field"), value=f"{total_eff:.1f}", inline=True)

    if current_rating is not None and current_rank is not None:
        projected_rating = max(0.0, current_rating - real_loss)
        # Exclude our own rating so we rank against other squadrons only
        other_ratings = [r for i, r in enumerate(ratings_desc) if i != current_rank - 1]
        projected_rank = project_rank(other_ratings, projected_rating)
        positions_lost = max(0, projected_rank - current_rank)
        e.add_field(name=t(lang, "loss_calc.squadron_rating_field"), value=f"{current_rating:.0f} -> {projected_rating:.0f}", inline=True)
        e.add_field(name=t(lang, "loss_calc.squadron_position_field"), value=f"#{current_rank} -> #{projected_rank}", inline=True)
        e.add_field(name=t(lang, "loss_calc.positions_lost_field"), value=str(positions_lost), inline=True)

    if not_found:
        e.set_footer(text=t(lang, "loss_calc.not_found_footer", players=", ".join(not_found)))

    await interaction.followup.send(embed=e, ephemeral=True)


@loss_calculator.error
async def loss_calculator_error(interaction, error):
    await permission_fail(interaction, error)




@is_blacklisted()
@bot.tree.command(name="website", description=command_locale("Get a link to the SRE Bot website", "commands.website.description"))
async def website(interaction: discord.Interaction):
    """Send the SRE Bot website URL."""
    await collect_command_stats(interaction)
    await interaction.response.send_message("https://sre.pawjob.us/")


@website.error
async def website_perm_error(interaction, error):
    await permission_fail(interaction, error)


# ═══════════════════════════════════════════════════════════════════════════
# PLAYER AUTOCOMPLETE (shared by /player-stats and /compare)
# ═══════════════════════════════════════════════════════════════════════════

async def player_autocomplete(
    interaction: discord.Interaction,
    current: str,
) -> List[discord.app_commands.Choice[str]]:
    """Autocomplete for player nicknames from the battle history DB."""
    if not current or len(current) < 2:
        return []
    try:
        async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
            await db.create_function("ulower", 1, str.lower)
            async with db.execute(
                """
                SELECT nick, UID FROM (
                    SELECT nick, UID, MAX(session_id) as last_seen
                    FROM player_games_hist
                    WHERE nick LIKE ? COLLATE NOCASE
                    GROUP BY UID
                    ORDER BY
                        CASE WHEN ulower(nick) = ulower(?) THEN 0
                             WHEN ulower(nick) LIKE ulower(?) THEN 1
                             ELSE 2
                        END,
                        last_seen DESC
                    LIMIT 25
                )
                """,
                (f"{current}%", current, f"{current}%"),
            ) as cursor:
                rows = await cursor.fetchall()
        return [
            discord.app_commands.Choice(name=row[0][:100], value=row[0][:100])
            for row in rows
        ]
    except Exception as e:
        print(f"[AUTOCOMPLETE ERROR] player_autocomplete failed for '{current}': {e}\n{traceback.format_exc()}", flush=True)
        return []


# ═══════════════════════════════════════════════════════════════════════════
# PLAYER SEASON RECAP CARD (/card)
# ═══════════════════════════════════════════════════════════════════════════

async def _send_player_card(
    interaction: discord.Interaction,
    uid: int,
    nick: str,
    season: str,
    theme: str,
    lang: str,
    *,
    followup: bool,
):
    """Render and send a player card; shared by /card and the disambiguation view."""
    try:
        path = await get_player_recap(uid, season, theme, _recap_lang(lang))
    except RecapError:
        embed = discord.Embed(
            title=t(lang, "common.error_title"),
            description=t(lang, "recap_card.render_failed"),
            color=discord.Color.red(),
        )
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        if followup:
            await interaction.followup.send(embed=embed, ephemeral=True)
        else:
            await interaction.edit_original_response(embed=embed, view=None)
        return

    safe_name = re.sub(r'[^A-Za-z0-9_-]+', '_', nick) or str(uid)
    filename = f"{safe_name}-{season}.png"
    file = discord.File(path, filename=filename)
    if followup:
        await interaction.followup.send(file=file)
    else:
        await interaction.edit_original_response(
            content=None, attachments=[file], view=None
        )


class CardPlayerSelectView(View):
    """Disambiguation dropdown when a nick resolves to multiple UIDs."""

    def __init__(self, results, author: discord.abc.User, season: str, theme: str, lang: str = "en"):
        super().__init__(timeout=60)
        self.author = author
        self.results = results
        self.season = season
        self.theme = theme
        self.lang = lang

        options = [
            discord.SelectOption(
                label=row["nick"][:100],
                description=f"UID: {row['UID']}"[:100],
                value=str(row["UID"])[:100],
            )
            for row in results[:25]
        ]
        self.select = Select(
            placeholder=t(lang, "player.select_player_placeholder"),
            options=options,
            min_values=1,
            max_values=1,
        )
        self.select.callback = self.select_callback
        self.add_item(self.select)

    async def interaction_check(self, interaction: discord.Interaction) -> bool:
        return interaction.user.id == self.author.id

    async def select_callback(self, interaction: discord.Interaction):
        await interaction.response.defer()
        uid = int(self.select.values[0])
        nick = next(
            (r["nick"] for r in self.results if str(r["UID"]) == self.select.values[0]),
            str(uid),
        )
        await _send_player_card(
            interaction, uid, nick, self.season, self.theme, self.lang, followup=False
        )


@is_blacklisted()
@gate_entitle("standard")
@bot.tree.command(name="card", description=command_locale("Generate a season recap card for a player", "commands.card.description"))
@app_commands.describe(
    season=command_locale("The season to generate the card for", "commands.common.season"),
    player=command_locale("The player's username", "commands.common.player_username"),
    theme=command_locale("Card color theme", "commands.common.theme"),
)
@app_commands.choices(theme=RECAP_THEME_CHOICES)
@discord.app_commands.autocomplete(
    season=seasons_autocomplete,
    player=player_autocomplete,
)
async def card(
    interaction: discord.Interaction,
    season: str,
    player: str = "",
    theme: app_commands.Choice[str] | None = None,
):
    """Generate and send a season recap card PNG for a player.

    If multiple players share the nick, shows a disambiguation dropdown.

    Args:
        interaction: The Discord interaction.
        season: Season identifier (e.g. "2026-II").
        player: Player's War Thunder username.
        theme: "dark" (default) or "light".
    """
    await collect_command_stats(interaction)
    await interaction.response.defer(ephemeral=False)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
    theme_value = theme.value if theme else 'dark'

    seasons = get_seasons()
    if season not in seasons:
        embed = discord.Embed(
            title=t(lang, "common.error_title"),
            description=t(lang, "recap_card.unknown_season", season=season),
            color=discord.Color.red(),
        )
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        return await interaction.followup.send(embed=embed, ephemeral=True)

    # No player given — fall back to the caller's linked account, if any.
    if not player:
        linked_uid = get_linked_uid(interaction.user.id)
        if not linked_uid:
            return await interaction.followup.send(
                t(lang, "player.must_provide_or_link"), ephemeral=True
            )
        nick = await _latest_nick_for_uid(linked_uid)
        return await _send_player_card(
            interaction, int(linked_uid), nick, season, theme_value, lang, followup=True,
        )

    try:
        async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
            db.row_factory = aiosqlite.Row
            await db.create_function("ulower", 1, str.lower)
            async with db.execute(
                """
                SELECT UID, MIN(nick) AS nick
                FROM player_games_hist
                WHERE ulower(nick) LIKE ulower(?)
                GROUP BY UID
                ORDER BY nick
                LIMIT 25
                """,
                (f"%{player}%",),
            ) as cursor:
                results = list(await cursor.fetchall())
    except Exception as e:
        error_str = str(e)[:1800]
        return await interaction.followup.send(
            t(lang, "common.database_error", error=error_str), ephemeral=True
        )

    if not results:
        return await interaction.followup.send(
            t(lang, "player.no_players_found", username=player), ephemeral=True
        )

    if len(results) > 1:
        return await interaction.followup.send(
            t(lang, "player.multiple_matches"),
            view=CardPlayerSelectView(
                results, interaction.user, season, theme_value, lang=lang
            ),
        )

    await _send_player_card(
        interaction, int(results[0]["UID"]), results[0]["nick"],
        season, theme_value, lang, followup=True,
    )


@card.error
async def card_error(interaction, error):
    await permission_fail(interaction, error)


# ═══════════════════════════════════════════════════════════════════════════
# PLAYER STATS WITH VEHICLE BREAKDOWN
# ═══════════════════════════════════════════════════════════════════════════

class PlayerSelectViewForStats(View):
    """View for selecting a player when multiple matches are found"""
    def __init__(self, results, author: discord.abc.User, lang: str = "en"):
        super().__init__(timeout=30)
        self.author = author
        self.results = results
        self.lang = lang

        options = [
            discord.SelectOption(
                label=row["nick"][:100],
                description=f"UID: {row['UID']}"[:100],
                value=str(row["UID"])[:100]
            )
            for row in results[:25]  # Discord limit of 25 options
        ]

        self.select = Select(
            placeholder=t(lang, "player.select_player_placeholder"),
            options=options,
            min_values=1,
            max_values=1
        )
        self.select.callback = self.select_callback
        self.add_item(self.select)

    async def interaction_check(self, interaction: discord.Interaction) -> bool:
        return interaction.user.id == self.author.id

    async def select_callback(self, interaction: discord.Interaction):
        """Fetch aggregated vehicle stats for the selected player and show VehicleStatsView."""
        uid = self.select.values[0]

        # Defer to show thinking state
        await interaction.response.defer()

        # Fetch vehicle stats for selected player
        try:
            async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
                db.row_factory = aiosqlite.Row

                # Get player info
                async with db.execute(
                    "SELECT nick, squadron_name FROM player_games_hist WHERE UID = ? ORDER BY session_id DESC LIMIT 1",
                    (uid,)
                ) as cursor:
                    player_row = await cursor.fetchone()

                if not player_row:
                    await interaction.followup.send(t(self.lang, "player.no_stats_found", uid=uid), ephemeral=True)
                    return

                # Get aggregated vehicle stats
                async with db.execute(
                    """
                    SELECT
                        vehicle_internal,
                        vehicle,
                        SUM(ground_kills) as total_ground_kills,
                        SUM(air_kills) as total_air_kills,
                        SUM(assists) as total_assists,
                        SUM(captures) as total_captures,
                        SUM(deaths) as total_deaths,
                        SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins,
                        SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END) as losses,
                        COUNT(*) as total_battles
                    FROM player_games_hist
                    WHERE UID = ?
                    GROUP BY vehicle_internal
                    ORDER BY total_battles DESC
                    """,
                    (uid,)
                ) as cursor:
                    vehicle_rows = await cursor.fetchall()
        except Exception as e:
            error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e)
            await interaction.followup.send(t(self.lang, "common.database_error", error=error_str), ephemeral=True)
            return

        if not vehicle_rows:
            await interaction.followup.send(t(self.lang, "player.no_vehicle_stats"), ephemeral=True)
            return

        # Convert rows to dicts with calculated win rate
        vehicle_stats = []
        for row in vehicle_rows:
            wins = row['wins'] or 0
            losses = row['losses'] or 0
            total_battles = row['total_battles'] or 0

            win_rate = '0.0'
            if total_battles > 0 and wins >= 0:
                win_rate = f"{(wins / total_battles * 100):.1f}"

            vehicle_name = utils.apply_vehicle_name_filters(row['vehicle']) if row['vehicle'] else ""

            vehicle_stats.append({
                'vehicle_internal': row['vehicle_internal'],
                'vehicle': vehicle_name or row['vehicle_internal'],
                'total_ground_kills': row['total_ground_kills'] or 0,
                'total_air_kills': row['total_air_kills'] or 0,
                'total_assists': row['total_assists'] or 0,
                'total_captures': row['total_captures'] or 0,
                'total_deaths': row['total_deaths'] or 0,
                'wins': wins,
                'losses': losses,
                'total_battles': total_battles,
                'win_rate': win_rate
            })

        player_info = {
            'nick': player_row['nick'],
            'squadron': player_row['squadron_name'],
            'uid': uid
        }

        # Update message with vehicle dropdown
        await interaction.edit_original_response(
            content=t(self.lang, "player.vehicles_found", count=len(vehicle_stats), nick=esc(player_info['nick'])),
            view=VehicleStatsView(vehicle_stats, player_info, self.author, lang=self.lang)
        )


class VehicleStatsView(View):
    """View for selecting a vehicle to see detailed stats with pagination"""
    def __init__(self, vehicle_stats: list, player_info: dict, author: discord.abc.User, page: int = 0, lang: str = "en"):
        super().__init__(timeout=60)
        self.author = author
        self.vehicle_stats = vehicle_stats
        self.player_info = player_info
        self.page = page
        self.lang = lang
        self.total_pages = (len(vehicle_stats) + 24) // 25  # Ceiling division

        # Get current page of vehicles
        start_idx = page * 25
        end_idx = min(start_idx + 25, len(vehicle_stats))
        current_vehicles = vehicle_stats[start_idx:end_idx]

        # Create dropdown options from vehicle stats
        options = []
        for idx, vehicle in enumerate(current_vehicles):
            actual_idx = start_idx + idx

            # Calculate K/D ratio
            total_kills = vehicle['total_ground_kills'] + vehicle['total_air_kills']
            deaths = vehicle['total_deaths'] if vehicle['total_deaths'] > 0 else 1
            kd = round(total_kills / deaths, 2)

            # Create label with vehicle name and basic stats
            label = vehicle['vehicle'][:100] if vehicle['vehicle'] else f"Vehicle {actual_idx+1}"
            description = f"Battles: {vehicle['total_battles']} | K/D: {kd} | WR: {vehicle['win_rate']}%"

            options.append(discord.SelectOption(
                label=label[:100],
                description=description[:100],
                value=str(actual_idx)  # Use actual index in full list
            ))

        self.select = Select(
            placeholder=t(lang, "player.vehicle_select_placeholder", page=page + 1, total=self.total_pages),
            options=options,
            min_values=1,
            max_values=1
        )
        self.select.callback = self.select_callback
        self.add_item(self.select)

        # Add pagination buttons if needed
        if self.total_pages > 1:
            prev_button = discord.ui.Button(
                label=t(lang, "buttons.prev_arrow"),
                style=discord.ButtonStyle.secondary,
                disabled=(page == 0)
            )
            next_button = discord.ui.Button(
                label=t(lang, "buttons.next_arrow"),
                style=discord.ButtonStyle.secondary,
                disabled=(page >= self.total_pages - 1)
            )

            async def prev_callback(interaction: discord.Interaction):
                await interaction.response.edit_message(
                    content=t(self.lang, "player.vehicles_found", count=len(self.vehicle_stats), nick=esc(self.player_info['nick'])),
                    view=VehicleStatsView(self.vehicle_stats, self.player_info, self.author, self.page - 1, lang=self.lang)
                )

            async def next_callback(interaction: discord.Interaction):
                await interaction.response.edit_message(
                    content=t(self.lang, "player.vehicles_found", count=len(self.vehicle_stats), nick=esc(self.player_info['nick'])),
                    view=VehicleStatsView(self.vehicle_stats, self.player_info, self.author, self.page + 1, lang=self.lang)
                )

            prev_button.callback = prev_callback
            next_button.callback = next_callback

            self.add_item(prev_button)
            self.add_item(next_button)

    async def interaction_check(self, interaction: discord.Interaction) -> bool:
        return interaction.user.id == self.author.id

    async def select_callback(self, interaction: discord.Interaction):
        """Build a detailed stats embed for the selected vehicle and refresh the same message."""
        # Get selected vehicle stats
        vehicle_idx = int(self.select.values[0])
        vehicle = self.vehicle_stats[vehicle_idx]

        # Calculate additional stats
        total_kills = vehicle['total_ground_kills'] + vehicle['total_air_kills']
        deaths = vehicle['total_deaths'] if vehicle['total_deaths'] > 0 else 1
        kd_ratio = round(total_kills / deaths, 2)
        win_rate = vehicle['win_rate']

        # Find vehicle icon
        vehicle_internal = vehicle['vehicle_internal']
        icon_filename = f"{vehicle_internal}.png"
        icon_path = ICONS_DIR / "VEHICLES" / icon_filename

        # Use not_found.png if vehicle icon doesn't exist
        if not icon_path.exists():
            icon_path = ICONS_DIR / "not_found.png"
            icon_filename = "not_found.png"

        # Create embed with vehicle stats
        embed = discord.Embed(
            title=f"{vehicle['vehicle']}",
            description=t(self.lang, "player.stats_desc", nick=esc(self.player_info['nick']), squadron=self.player_info['squadron'], uid=self.player_info['uid']),
            color=discord.Color.blue()
        )

        # Set vehicle icon as thumbnail
        embed.set_thumbnail(url=f"attachment://{icon_filename}")

        # Combat stats
        gk = f"{vehicle['total_ground_kills']:,}"
        ak = f"{vehicle['total_air_kills']:,}"
        tk = f"{total_kills:,}"
        ast = f"{vehicle['total_assists']:,}"
        dth = f"{vehicle['total_deaths']:,}"
        cap = f"{vehicle['total_captures']:,}"
        embed.add_field(
            name="\u200b",
            value=(
                f"{t(self.lang, 'player.combat_stats_header')}\n"
                f"{t(self.lang, 'player.ground_kills_label', value=gk)}\n"
                f"{t(self.lang, 'player.air_kills_label', value=ak)}\n"
                f"{t(self.lang, 'player.total_kills_label', value=tk)}\n"
                f"{t(self.lang, 'player.assists_label', value=ast)}\n"
                f"{t(self.lang, 'player.deaths_label', value=dth)}\n"
                f"{t(self.lang, 'player.kd_label', value=kd_ratio)}\n"
                f"{t(self.lang, 'player.captures_label', value=cap)}\n"
            ),
            inline=False
        )

        # Battle record
        tb = f"{vehicle['total_battles']:,}"
        wn = f"{vehicle['wins']:,}"
        ls = f"{vehicle['losses']:,}"
        embed.add_field(
            name="\u200b",
            value=(
                f"{t(self.lang, 'player.battle_record_header')}\n"
                f"{t(self.lang, 'player.total_battles_label', value=tb)}\n"
                f"{t(self.lang, 'player.wins_label', value=wn)}\n"
                f"{t(self.lang, 'player.losses_label', value=ls)}\n"
                f"{t(self.lang, 'player.win_rate_label', value=win_rate)}"
            ),
            inline=False
        )

        embed.set_footer(text=DEFAULT_FOOTER_CAT)

        # Create file object for the icon
        file = discord.File(fp=icon_path, filename=icon_filename)

        await interaction.response.edit_message(
            content=t(self.lang, "player.vehicles_found", count=len(self.vehicle_stats), nick=esc(self.player_info['nick'])),
            embed=embed,
            attachments=[file],
            view=self,
        )


@is_blacklisted()
@bot.tree.command(
    name="player-stats",
    description=command_locale("View detailed vehicle statistics for a player", "commands.player_stats.description")
)
@app_commands.describe(
    username=command_locale("The WT username for stats request", "commands.player_stats.username"),
    uid=command_locale("The WT UID for stats request", "commands.player_stats.uid")
)
@discord.app_commands.autocomplete(username=player_autocomplete)
async def player_stats(interaction: discord.Interaction, username: str = "", uid: str = ""):
    """View per-vehicle battle statistics for a player.

    Resolves the player by UID or username search. If multiple username
    matches are found, shows a PlayerSelectViewForStats dropdown. Otherwise
    fetches aggregated vehicle stats and presents a VehicleStatsView with
    paginated vehicle dropdown.

    Args:
        interaction: The Discord interaction.
        username: War Thunder username to search for.
        uid: War Thunder UID for direct lookup.
    """
    await collect_command_stats(interaction)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    player_row = None
    vehicle_rows: list = []

    _VEHICLE_STATS_SQL = """
        SELECT
            vehicle_internal,
            vehicle,
            SUM(ground_kills) as total_ground_kills,
            SUM(air_kills) as total_air_kills,
            SUM(assists) as total_assists,
            SUM(captures) as total_captures,
            SUM(deaths) as total_deaths,
            SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins,
            SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END) as losses,
            COUNT(*) as total_battles
        FROM player_games_hist
        WHERE UID = ?
        GROUP BY vehicle_internal
        ORDER BY total_battles DESC
    """

    # Handle UID lookup
    if uid:
        await interaction.response.defer(thinking=True)
        target_uid = uid
    elif username:
        await interaction.response.defer(thinking=True)

        # Search, then fetch all vehicle data in a single connection.
        try:
            async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
                db.row_factory = aiosqlite.Row

                # Exact match uses idx_pgh_nick; fall back to substring LIKE only if needed.
                async with db.execute(
                    """
                    SELECT UID, MIN(nick) AS nick
                    FROM player_games_hist
                    WHERE nick = ? COLLATE NOCASE
                    GROUP BY UID
                    ORDER BY nick
                    LIMIT 25
                    """,
                    (username,),
                ) as cursor:
                    results = list(await cursor.fetchall())

                if not results:
                    async with db.execute(
                        """
                        SELECT UID, MIN(nick) AS nick
                        FROM player_games_hist
                        WHERE nick LIKE ? COLLATE NOCASE
                        GROUP BY UID
                        ORDER BY nick
                        LIMIT 25
                        """,
                        (f"%{username}%",),
                    ) as cursor:
                        results = list(await cursor.fetchall())

                if not results:
                    await interaction.followup.send(
                        t(lang, "player.no_players_found", username=username),
                        ephemeral=True
                    )
                    return
                elif len(results) > 1:
                    await interaction.followup.send(
                        t(lang, "player.multiple_matches"),
                        view=PlayerSelectViewForStats(results, interaction.user, lang=lang)
                    )
                    return

                target_uid = results[0]["UID"]

                # Fetch vehicle stats in the same connection to avoid a second open/close.
                async with db.execute(
                    "SELECT nick, squadron_name FROM player_games_hist WHERE UID = ? ORDER BY session_id DESC LIMIT 1",
                    (target_uid,)
                ) as cursor:
                    player_row = await cursor.fetchone()

                if not player_row:
                    await interaction.followup.send(t(lang, "player.no_stats_found", uid=target_uid), ephemeral=True)
                    return

                async with db.execute(_VEHICLE_STATS_SQL, (target_uid,)) as cursor:
                    vehicle_rows = list(await cursor.fetchall())
        except Exception as e:
            error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e)
            await interaction.followup.send(t(lang, "common.database_error", error=error_str), ephemeral=True)
            return
    else:
        # No explicit input — fall back to the caller's linked account, if any.
        linked_uid = get_linked_uid(interaction.user.id)
        if not linked_uid:
            await interaction.response.send_message(
                t(lang, "player.must_provide_or_link"),
                ephemeral=True
            )
            return
        await interaction.response.defer(thinking=True)
        uid = linked_uid
        target_uid = linked_uid

    # UID path: fetch vehicle stats (username path already has them from above).
    if uid:
        try:
            async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
                db.row_factory = aiosqlite.Row

                async with db.execute(
                    "SELECT nick, squadron_name FROM player_games_hist WHERE UID = ? ORDER BY session_id DESC LIMIT 1",
                    (target_uid,)
                ) as cursor:
                    player_row = await cursor.fetchone()

                if not player_row:
                    await interaction.followup.send(t(lang, "player.no_stats_found", uid=target_uid), ephemeral=True)
                    return

                async with db.execute(_VEHICLE_STATS_SQL, (target_uid,)) as cursor:
                    vehicle_rows = list(await cursor.fetchall())
        except Exception as e:
            error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e)
            await interaction.followup.send(t(lang, "common.database_error", error=error_str), ephemeral=True)
            return

    if not vehicle_rows:
        await interaction.followup.send(t(lang, "player.no_vehicle_stats"), ephemeral=True)
        return

    # Convert rows to dicts with calculated win rate
    vehicle_stats = []
    for row in vehicle_rows:
        wins = row['wins'] or 0
        losses = row['losses'] or 0
        total_battles = row['total_battles'] or 0

        win_rate = '0.0'
        if total_battles > 0 and wins >= 0:
            win_rate = f"{(wins / total_battles * 100):.1f}"

        vehicle_name = utils.apply_vehicle_name_filters(row['vehicle']) if row['vehicle'] else ""

        vehicle_stats.append({
            'vehicle_internal': row['vehicle_internal'],
            'vehicle': vehicle_name or row['vehicle_internal'],
            'total_ground_kills': row['total_ground_kills'] or 0,
            'total_air_kills': row['total_air_kills'] or 0,
            'total_assists': row['total_assists'] or 0,
            'total_captures': row['total_captures'] or 0,
            'total_deaths': row['total_deaths'] or 0,
            'wins': wins,
            'losses': losses,
            'total_battles': total_battles,
            'win_rate': win_rate
        })

    assert player_row is not None
    player_info = {
        'nick': player_row['nick'],
        'squadron': player_row['squadron_name'],
        'uid': target_uid
    }

    # Send initial message with dropdown
    await interaction.followup.send(
        t(lang, "player.vehicles_found", count=len(vehicle_stats), nick=esc(player_info['nick'])),
        view=VehicleStatsView(vehicle_stats, player_info, interaction.user, lang=lang)
    )


@player_stats.error
async def player_stats_perm_error(interaction, error):
    await permission_fail(interaction, error)


async def _resolve_player_for_link(username: str) -> list:
    """Return [{UID, nick}] rows matching a username (exact, then substring)."""
    async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
        db.row_factory = aiosqlite.Row
        async with db.execute(
            "SELECT UID, MIN(nick) AS nick FROM player_games_hist "
            "WHERE nick = ? COLLATE NOCASE GROUP BY UID ORDER BY nick LIMIT 25",
            (username,),
        ) as cursor:
            results = list(await cursor.fetchall())
        if not results:
            async with db.execute(
                "SELECT UID, MIN(nick) AS nick FROM player_games_hist "
                "WHERE nick LIKE ? COLLATE NOCASE GROUP BY UID ORDER BY nick LIMIT 25",
                (f"%{username}%",),
            ) as cursor:
                results = list(await cursor.fetchall())
    return results


async def _latest_nick_for_uid(uid: str) -> str:
    """Best-effort latest nick for a UID; falls back to the UID string."""
    try:
        async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
            db.row_factory = aiosqlite.Row
            async with db.execute(
                "SELECT nick FROM player_games_hist WHERE UID = ? ORDER BY session_id DESC LIMIT 1",
                (uid,),
            ) as cursor:
                row = await cursor.fetchone()
                if row:
                    return row["nick"]
    except Exception:
        pass
    return uid


class SetPlayerSelectView(View):
    """Select which matched player to link to the invoking Discord user."""

    def __init__(self, results, author: discord.abc.User, lang: str = "en"):
        super().__init__(timeout=30)
        self.author = author
        self.lang = lang
        options = [
            discord.SelectOption(
                label=row["nick"][:100],
                description=f"UID: {row['UID']}"[:100],
                value=str(row["UID"])[:100],
            )
            for row in results[:25]
        ]
        self.select = Select(
            placeholder=t(lang, "player.select_player_placeholder"),
            options=options,
            min_values=1,
            max_values=1,
        )
        self.select.callback = self.select_callback
        self.add_item(self.select)

    async def interaction_check(self, interaction: discord.Interaction) -> bool:
        return interaction.user.id == self.author.id

    async def select_callback(self, interaction: discord.Interaction):
        uid = self.select.values[0]
        nick = await _latest_nick_for_uid(uid)
        save_player_link(interaction.user.id, uid)
        await interaction.response.edit_message(
            content=t(self.lang, "player.link_success", nick=esc(nick), uid=uid),
            view=None,
        )


@is_blacklisted()
@bot.tree.command(
    name="set-player",
    description=command_locale("Link your Discord account to a War Thunder player", "commands.set_player.description"),
)
@app_commands.describe(
    username=command_locale("The WT username to link", "commands.set_player.username"),
    uid=command_locale("The WT UID to link", "commands.set_player.uid"),
)
@discord.app_commands.autocomplete(username=player_autocomplete)
async def set_player(interaction: discord.Interaction, username: str = "", uid: str = ""):
    """Link the invoking Discord user to a WT account in the shared PLAYERS.json.

    Once linked, commands like /player-stats default to this account when run
    without an explicit username/uid.
    """
    await collect_command_stats(interaction)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    if uid:
        await interaction.response.defer(thinking=True, ephemeral=True)
        nick = await _latest_nick_for_uid(uid)
        save_player_link(interaction.user.id, uid)
        await interaction.followup.send(
            t(lang, "player.link_success", nick=esc(nick), uid=uid), ephemeral=True
        )
        return

    if username:
        await interaction.response.defer(thinking=True, ephemeral=True)
        try:
            results = await _resolve_player_for_link(username)
        except Exception as e:
            error_str = str(e)[:1800]
            await interaction.followup.send(t(lang, "common.database_error", error=error_str), ephemeral=True)
            return
        if not results:
            await interaction.followup.send(
                t(lang, "player.no_players_found", username=username), ephemeral=True
            )
            return
        if len(results) > 1:
            await interaction.followup.send(
                t(lang, "player.link_select"),
                view=SetPlayerSelectView(results, interaction.user, lang=lang),
                ephemeral=True,
            )
            return
        target_uid = str(results[0]["UID"])
        save_player_link(interaction.user.id, target_uid)
        await interaction.followup.send(
            t(lang, "player.link_success", nick=esc(results[0]["nick"]), uid=target_uid),
            ephemeral=True,
        )
        return

    await interaction.response.send_message(t(lang, "player.must_provide_input"), ephemeral=True)


@set_player.error
async def set_player_perm_error(interaction, error):
    await permission_fail(interaction, error)


# ═══════════════════════════════════════════════════════════════════════════
# /view-player-games — Last 20 games for a player
# ═══════════════════════════════════════════════════════════════════════════

class FindPlayerView(discord.ui.View):
    """Paginated embed view for displaying a player's recent game history.

    Each page contains embed fields for a subset of sessions, with
    previous/next buttons for navigation.
    """

    def __init__(self, pages: list[list[tuple[str, str]]], summary_desc: str, player_nick: str, lang: str = "en"):
        super().__init__(timeout=120)
        self.message: Optional[discord.Message] = None
        self.pages = pages
        self.summary_desc = summary_desc
        self.player_nick = player_nick
        self.lang = lang
        self.page = 0
        self._update_buttons()
        self.prev_btn.label = t(lang, "buttons.prev_arrow_only")
        self.next_btn.label = t(lang, "buttons.next_arrow_only")

    def _update_buttons(self) -> None:
        """Enable or disable prev/next buttons based on the current page index."""
        self.prev_btn.disabled = self.page == 0
        self.next_btn.disabled = self.page >= len(self.pages) - 1

    def build_embed(self) -> discord.Embed:
        """Build the embed for the current page of game history fields.

        Returns:
            Discord Embed with session fields for the current page.
        """
        embed = discord.Embed(
            title=self.player_nick,
            description=self.summary_desc,
            color=discord.Color.blurple(),
        )
        for name, value in self.pages[self.page]:
            embed.add_field(name=name, value=value, inline=False)
        footer = f"Page {self.page + 1}/{len(self.pages)} • {DEFAULT_FOOTER_CAT}" if len(self.pages) > 1 else DEFAULT_FOOTER_CAT
        embed.set_footer(text=footer)
        return embed

    async def on_timeout(self):
        for item in self.children:
            item.disabled = True  # type: ignore[attr-defined]
        try:
            if self.message:
                await self.message.edit(view=self)
        except Exception:
            pass

    @discord.ui.button(label="◀", style=discord.ButtonStyle.secondary)
    async def prev_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
        self.page -= 1
        self._update_buttons()
        await interaction.response.edit_message(embed=self.build_embed(), view=self)

    @discord.ui.button(label="▶", style=discord.ButtonStyle.secondary)
    async def next_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
        self.page += 1
        self._update_buttons()
        await interaction.response.edit_message(embed=self.build_embed(), view=self)

@is_blacklisted()
@bot.tree.command(
    name="view-player-games",
    description=command_locale("View the last 20 games for a player", "commands.view_player_games.description")
)
@app_commands.describe(player=command_locale("The player's username", "commands.common.player_username"))
@discord.app_commands.autocomplete(player=player_autocomplete)
async def view_player_games(interaction: discord.Interaction, player: str = ""):
    """Display a player's recent squadron battle sessions with win/loss, comps, and opponents.

    Resolves the player nickname to a UID, queries sessions from the last 8 hours
    in sq_battles.db, enriches each session with opponent squadron and match summary
    data, and presents the results in a paginated FindPlayerView.

    Args:
        interaction: The Discord interaction.
        player: The player's username (supports autocomplete).
    """
    await collect_command_stats(interaction)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
    await interaction.response.defer(ephemeral=False)

    if not player:
        # Fall back to the caller's linked account, if any.
        linked_uid = get_linked_uid(interaction.user.id)
        if not linked_uid:
            return await interaction.followup.send(
                t(lang, "player.must_provide_or_link"), ephemeral=True
            )
        target_uid = linked_uid
        player_nick = esc(await _latest_nick_for_uid(linked_uid))
    else:
        # Resolve player nick → most-recently-seen UID
        try:
            async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
                db.row_factory = aiosqlite.Row
                await db.create_function("ulower", 1, str.lower)
                async with db.execute(
                    """
                    SELECT UID, nick, MAX(endtime_unix) AS last_seen
                    FROM player_games_hist
                    WHERE ulower(nick) = ulower(?)
                    GROUP BY UID
                    ORDER BY last_seen DESC
                    LIMIT 1
                    """,
                    (player,),
                ) as cursor:
                    uid_row = await cursor.fetchone()
        except Exception as e:
            return await interaction.followup.send(t(lang, "common.database_error", error=str(e)[:1800]), ephemeral=True)

        if not uid_row:
            return await interaction.followup.send(
                embed=discord.Embed(
                    title=t(lang, "player.not_found_title"),
                    description=t(lang, "player.not_found_desc", player=esc(player)),
                    color=discord.Color.red(),
                ).set_footer(text=DEFAULT_FOOTER_CAT)
            )

        target_uid = uid_row["UID"]
        player_nick = esc(uid_row["nick"])

    # Query last 20 sessions
    try:
        async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
            db.row_factory = aiosqlite.Row

            async with db.execute(
                "SELECT squadron_tagged FROM player_games_hist WHERE UID = ? ORDER BY endtime_unix DESC LIMIT 1",
                (target_uid,),
            ) as cursor:
                sq_row = await cursor.fetchone()
            sq_tag = sq_row["squadron_tagged"] if sq_row else ""

            cutoff = int(time_module.time()) - 8 * 3600
            async with db.execute(
                """
                SELECT
                    session_id,
                    MAX(endtime_unix) AS endtime_unix,
                    MAX(victor_bool)  AS victor_bool,
                    GROUP_CONCAT(vehicle_internal, '||') AS vehicles
                FROM player_games_hist
                WHERE UID = ? AND endtime_unix >= ?
                GROUP BY session_id
                ORDER BY MAX(endtime_unix) DESC
                """,
                (target_uid, cutoff),
            ) as cursor:
                sessions = list(await cursor.fetchall())
    except Exception as e:
        return await interaction.followup.send(t(lang, "common.database_error", error=str(e)[:1800]), ephemeral=True)

    if not sessions:
        return await interaction.followup.send(
            embed=discord.Embed(
                title=t(lang, "player_games.no_recent_title"),
                description=t(lang, "player_games.no_recent_desc", player=player_nick),
                color=discord.Color.red(),
            ).set_footer(text=DEFAULT_FOOTER_CAT)
        )

    # Fetch match_summary for opponent, map, and full team JSON
    session_ids = [s["session_id"] for s in sessions]
    placeholders = ",".join("?" * len(session_ids))
    try:
        async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
            db.row_factory = aiosqlite.Row
            async with db.execute(
                f"SELECT session_id, map_name, winning_sq, losing_sq, winning_team_json, losing_team_json "
                f"FROM match_summary WHERE session_id IN ({placeholders})",
                session_ids,
            ) as cursor:
                ms_rows = await cursor.fetchall()
    except Exception:
        ms_rows = []
    ms_map = {r["session_id"]: r for r in ms_rows}

    _type_order = {"F": 0, "B": 1, "H": 2, "L": 3, "T": 4, "AA": 5, "?": 6}
    _comp_order = [("F",), ("B",), ("H",), ("L",), ("T",), ("AA",), ("?",)]

    def _comp_notation(veh_internals: list[str]) -> str:
        """Build a compact composition notation string (e.g. '2F / 1T / 1AA') from vehicle internals.

        Args:
            veh_internals: List of internal vehicle name strings.

        Returns:
            Formatted composition string, or '—' if empty.
        """
        nd = count_unit_types(veh_internals)
        parts = [f"{nd[code]}{code}" for (code,) in _comp_order if nd.get(code, 0) > 0]
        return " / ".join(parts) or "—"

    def _team_block(players: list[dict]) -> str:
        """Format a team's player list into a fixed-width code block sorted by vehicle type.

        Args:
            players: List of player dicts with 'nick', 'vehicle', and 'vehicle_new' keys.

        Returns:
            Discord code block string with aligned nick | vehicle rows.
        """
        sorted_players = sorted(
            players,
            key=lambda p: _type_order.get(get_unit_type_abbrev(p.get("vehicle")), 6)
        )
        max_nick = max((len(p.get("nick", "?")) for p in sorted_players), default=1)
        lines = [
            f"{p.get('nick', '?'):<{max_nick}} | {p.get('vehicle_new') or normalize_name(p.get('vehicle') or '') or '?'}"
            for p in sorted_players
        ]
        return "```\n" + "\n".join(lines) + "\n```"

    # Tally wins/losses and build a list of game groups
    # Each game group is 1–2 (field_name, field_value) tuples
    wins = 0
    losses = 0
    comp_counter: dict[str, int] = {}
    game_groups: list[list[tuple[str, str]]] = []

    for s in sessions:
        is_win = (s["victor_bool"] or "").upper() == "WIN"
        wins += is_win
        losses += not is_win

        ms = ms_map.get(s["session_id"])
        if ms:
            opponent    = esc((ms["losing_sq"] if is_win else ms["winning_sq"]) or "?")
            raw_map     = re.sub(r'\[.*?\]', '', ms["map_name"] or "").strip()
            map_name    = esc(raw_map or "Unknown")
            my_json     = ms["winning_team_json"] if is_win else ms["losing_team_json"]
            opp_json    = ms["losing_team_json"]  if is_win else ms["winning_team_json"]
            try:
                my_players = (decompress_json(my_json) if my_json else {}).get("players", [])
            except (json.JSONDecodeError, TypeError):
                my_players = []
            try:
                opp_players = (decompress_json(opp_json) if opp_json else {}).get("players", [])
            except (json.JSONDecodeError, TypeError):
                opp_players = []
        else:
            opponent    = "?"
            map_name    = "Unknown"
            my_players  = []
            opp_players = []

        my_veh = [p.get("vehicle") for p in my_players if p.get("vehicle")] or \
                 [v for v in (s["vehicles"] or "").split("||") if v]
        my_comp = _comp_notation(my_veh)
        comp_counter[my_comp] = comp_counter.get(my_comp, 0) + 1

        ts       = f"<t:{s['endtime_unix']}:R>" if s["endtime_unix"] else ""
        icon     = "👑" if is_win else "💔"
        my_sq    = esc(sq_tag[1:-1] if len(sq_tag) > 2 else sq_tag) if sq_tag else "?"
        header   = f"{icon} {my_sq} vs {opponent} · {map_name} · {ts}"

        if my_players or opp_players:
            my_val  = _team_block(my_players)  if my_players  else f"`{my_comp}`"
            opp_val = _team_block(opp_players) if opp_players else "`no data`"
            # Field names appear right above each block — use sq names
            game_groups.append([(header, my_val), (opponent, opp_val)])
        else:
            game_groups.append([(header, f"`{my_comp}`")])

    total = wins + losses
    wr = f"{wins / total * 100:.1f}" if total > 0 else "0.0"

    if comp_counter:
        sorted_comps = sorted(comp_counter.items(), key=lambda x: -x[1])
        comps_text = t(lang, "player_games.comps_played_header") + "\n" + "\n".join(f"**×{c}** `{k}`" for k, c in sorted_comps)
    else:
        comps_text = ""

    sq_display = esc(sq_tag[1:-1] if len(sq_tag) > 2 else sq_tag) if sq_tag else 'N/A'
    summary_desc = (
        t(lang, "player_games.squadron_label", squadron=sq_display) + "\n"
        + t(lang, "player_games.record_label", wins=wins, losses=losses, wr=wr)
        + comps_text
    )

    # Pack game groups into pages: max 5 games per page, budget 5500 - desc chars
    field_budget = 5500 - len(summary_desc)
    pages: list[list[tuple[str, str]]] = []
    current_page: list[tuple[str, str]] = []
    current_games = 0
    current_chars = 0
    for group in game_groups:
        group_chars = sum(len(n) + len(v) for n, v in group)
        if current_page and (current_games >= 5 or current_chars + group_chars > field_budget):
            pages.append(current_page)
            current_page = []
            current_games = 0
            current_chars = 0
        current_page.extend(group)
        current_games += 1
        current_chars += group_chars
    if current_page:
        pages.append(current_page)

    view = FindPlayerView(pages, summary_desc, player_nick, lang=lang)
    view.message = await interaction.followup.send(embed=view.build_embed(), view=view, wait=True)


@view_player_games.error  # type: ignore[attr-defined]
async def view_player_games_perm_error(interaction, error):
    await permission_fail(interaction, error)


# ═══════════════════════════════════════════════════════════════════════════
# VIEW MATCH
# ═══════════════════════════════════════════════════════════════════════════

class ViewMatchSelectView(discord.ui.View):
    """Dropdown of a player's recent games; selecting one generates the scoreboard."""

    def __init__(self, games: list[dict], author: discord.abc.User, lang: str = "en"):
        super().__init__(timeout=120)
        self.message: Optional[discord.Message] = None
        self.author = author
        self.lang = lang

        options = []
        for g in games[:25]:  # Discord select max 25
            ts_label = f"<t:{g['endtime_unix']}:R>" if g["endtime_unix"] else ""
            icon = "\U0001f451" if (g["victor_bool"] or "").upper() == "WIN" else "\U0001f494"
            opponent = g.get("opponent") or "?"
            map_name = re.sub(r'\[.*?\]', '', g.get("map_name") or "").strip() or "Unknown"
            label = f"{icon} vs {opponent} \u00b7 {map_name}"[:100]
            options.append(discord.SelectOption(
                label=label,
                value=g["session_id"],
                description=f"ID: {g['session_id']}"[:100],
            ))

        select = Select(placeholder=t(lang, "match.select_match_placeholder"), options=options)
        select.callback = self._on_select
        self.add_item(select)

    async def on_timeout(self):
        for item in self.children:
            item.disabled = True  # type: ignore[attr-defined]
        try:
            if self.message:
                await self.message.edit(view=self)
        except Exception:
            pass

    async def interaction_check(self, interaction: discord.Interaction) -> bool:
        return interaction.user.id == self.author.id

    async def _on_select(self, interaction: discord.Interaction):
        """Handle game selection from the dropdown and generate its scoreboard."""
        values = (interaction.data or {}).get("values") or []  # type: ignore[union-attr]
        if not values:
            return await interaction.response.send_message(t(self.lang, "common.no_selection_received"), ephemeral=True)
        session_id: str = values[0]
        await interaction.response.defer(thinking=True)
        await _send_view_match_scoreboard(interaction, session_id, lang=self.lang)


async def _send_view_match_scoreboard(interaction: discord.Interaction, session_id: str, lang: str = "en"):
    """Fetch a replay by ID (disk first, then API), build a scoreboard, and send it."""

    session_id = session_id.lower()

    # 1. Try loading from disk
    replay_data = load_replay_data_from_disk(session_id)

    # 2. If not on disk, fetch from API (needs decimal ID, not hex)
    if not replay_data:
        try:
            decimal_id = str(int(session_id, 16))
        except ValueError:
            decimal_id = session_id  # Already decimal or invalid — let API handle it
        raw = await fetch_replay_by_id(decimal_id)
        if not raw:
            return await interaction.followup.send(
                embed=discord.Embed(
                    title=t(lang, "match.not_found_title"),
                    description=t(lang, "match.not_found_desc", match_id=esc(session_id)),
                    color=discord.Color.red(),
                ).set_footer(text=DEFAULT_FOOTER_CAT),
                ephemeral=True,
            )

        # The API may return {"completed": [...]} directly, {"data": {...}} wrapper, or just the replay dict
        if "completed" in raw:
            wrapped = raw
        elif "data" in raw and isinstance(raw["data"], dict):
            wrapped = {"completed": [raw["data"]]}
        else:
            wrapped = {"completed": [raw]}

        replay_data = utils.transform_to_local_format(wrapped)
        if not replay_data:
            return await interaction.followup.send(
                embed=discord.Embed(
                    title=t(lang, "match.invalid_data_title"),
                    description=t(lang, "match.invalid_data_desc"),
                    color=discord.Color.red(),
                ).set_footer(text=DEFAULT_FOOTER_CAT),
                ephemeral=True,
            )

        # Save to disk for buttons (Chat Log, Battle Log read from disk)
        replay_dir = replay_session_dir(session_id)
        replay_dir.mkdir(parents=True, exist_ok=True)
        raw = json.dumps(replay_data, ensure_ascii=False).encode("utf-8")
        compressed = await asyncio.to_thread(gzip.compress, raw)
        async with aiofiles.open(replay_dir / "replay_data.json.gz", "wb") as f:
            await f.write(compressed)

    # 3. Translate vehicles
    translate = LangTableReader("English")
    for team in replay_data.get("teams", []):
        for player in team.get("players", []):
            vehicle = player.get("vehicle")
            if vehicle:
                translated = translate.get_translate(vehicle)
                player["vehicle_new"] = translated if translated else vehicle
            else:
                player["vehicle"] = "DISCONNECTED"
                player["vehicle_new"] = "DISCONNECTED"

    # 4. Resolve clan long names
    squads = [t.get("squadron") for t in replay_data.get("teams", []) if t.get("squadron")]
    squads_tagged = [t.get("squadron_tagged") for t in replay_data.get("teams", []) if t.get("squadron_tagged")]
    resolved = await resolve_clans(shorts=squads, tags=squads_tagged)
    for team, clan_info in zip(replay_data.get("teams", []), resolved):
        if team and clan_info.get("long_name"):
            team["squadron_long"] = clan_info["long_name"]

    # 5. Build scoreboard
    winner = replay_data.get("winning_team_squadron", "")
    is_draw = replay_data.get("draw", False)
    teams = replay_data.get("teams", [])
    timestamp = replay_data.get("timestamp", 0)
    map_name = replay_data.get("map", "")

    match_details: dict = {"utc_timestamp": str(timestamp), "session_id": session_id}
    try:
        async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as _conn:
            async with _conn.execute(
                "SELECT received_unix FROM match_summary WHERE session_id = ?",
                (session_id,),
            ) as _cur:
                _row = await _cur.fetchone()
        if _row and _row[0] is not None:
            match_details["received_unix"] = int(_row[0])
    except Exception:
        pass

    output_dir = replay_session_dir(session_id)
    output_dir.mkdir(parents=True, exist_ok=True)
    output_path = output_dir / "game_result-not_involved-English.png"

    if not output_path.exists():
        await create_scoreboard(
            match_details,
            winner,
            teams[0] if len(teams) > 0 else {},
            teams[1] if len(teams) > 1 else {},
            map_name,
            str(output_path),
            bar_color="not_involved",
            is_draw=is_draw,
        )

    if not output_path.exists():
        return await interaction.followup.send(
            embed=discord.Embed(
                title=t(lang, "match.scoreboard_error_title"),
                description=t(lang, "match.scoreboard_error_desc"),
                color=discord.Color.red(),
            ).set_footer(text=DEFAULT_FOOTER_CAT),
            ephemeral=True,
        )

    # 6. Send with buttons
    view = build_scoreboard_view(interaction.guild_id or 0, session_id, lang=lang)
    with open(output_path, "rb") as f:
        await interaction.followup.send(
            file=discord.File(f, filename="game_result.png"),
            view=view,
        )


@is_blacklisted()
@bot.tree.command(
    name="view-match",
    description=command_locale("View a match scoreboard by ID or player", "commands.view_match.description")
)
@app_commands.describe(
    match_id=command_locale("The session hex ID of the match to view", "commands.view_match.match_id"),
    player_name=command_locale("A player's username to browse recent matches", "commands.view_match.player_name"),
)
@discord.app_commands.autocomplete(player_name=player_autocomplete)
async def view_match(
    interaction: discord.Interaction,
    match_id: Optional[str] = None,
    player_name: Optional[str] = None,
):
    """View a match scoreboard by direct session ID or by browsing a player's recent games.

    If match_id is provided, defers and renders the scoreboard directly via
    _send_view_match_scoreboard. If player_name is provided, resolves the player's
    UID, fetches their last 100 sessions with opponent/map info, and presents a
    ViewMatchSelectView dropdown for the user to pick a match.
    """
    await collect_command_stats(interaction)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
    if not match_id and not player_name:
        return await interaction.response.send_message(
            embed=discord.Embed(
                title=t(lang, "match.missing_input_title"),
                description=t(lang, "match.missing_input_desc"),
                color=discord.Color.red(),
            ).set_footer(text=DEFAULT_FOOTER_CAT),
            ephemeral=True,
        )

    # Direct ID lookup
    if match_id:
        await interaction.response.defer(thinking=True)
        # Normalise: strip leading "0" prefix if provided, strip whitespace
        match_id = match_id.strip().lstrip("0") or match_id.strip()
        await _send_view_match_scoreboard(interaction, match_id, lang=lang)
        return

    # Player lookup → show dropdown of last 100 games
    await interaction.response.defer(thinking=True, ephemeral=True)

    try:
        async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
            db.row_factory = aiosqlite.Row
            await db.create_function("ulower", 1, str.lower)

            # Resolve nick → UID
            async with db.execute(
                """
                SELECT UID, nick, MAX(endtime_unix) AS last_seen
                FROM player_games_hist
                WHERE ulower(nick) = ulower(?)
                GROUP BY UID
                ORDER BY last_seen DESC
                LIMIT 1
                """,
                (player_name,),
            ) as cursor:
                uid_row = await cursor.fetchone()

            if not uid_row:
                return await interaction.followup.send(
                    embed=discord.Embed(
                        title=t(lang, "player.not_found_title"),
                        description=t(lang, "player.not_found_desc", player=esc(player_name or '')),
                        color=discord.Color.red(),
                    ).set_footer(text=DEFAULT_FOOTER_CAT),
                    ephemeral=True,
                )

            target_uid = uid_row["UID"]
            player_nick = uid_row["nick"]

            # Fetch last 100 sessions with opponent info
            async with db.execute(
                """
                SELECT
                    p.session_id,
                    MAX(p.endtime_unix) AS endtime_unix,
                    MAX(p.victor_bool)  AS victor_bool,
                    ms.map_name,
                    CASE
                        WHEN UPPER(MAX(p.victor_bool)) = 'WIN' THEN ms.losing_sq
                        ELSE ms.winning_sq
                    END AS opponent
                FROM player_games_hist p
                LEFT JOIN match_summary ms ON ms.session_id = p.session_id
                WHERE p.UID = ?
                GROUP BY p.session_id
                ORDER BY MAX(p.endtime_unix) DESC
                LIMIT 100
                """,
                (target_uid,),
            ) as cursor:
                sessions = [dict(row) for row in await cursor.fetchall()]

    except Exception as e:
        return await interaction.followup.send(
            t(lang, "common.database_error", error=str(e)[:1800]),
            ephemeral=True,
        )

    if not sessions:
        return await interaction.followup.send(
            embed=discord.Embed(
                title=t(lang, "match.no_games_title"),
                description=t(lang, "match.no_games_desc", player=esc(player_nick)),
                color=discord.Color.red(),
            ).set_footer(text=DEFAULT_FOOTER_CAT),
            ephemeral=True,
        )

    view = ViewMatchSelectView(sessions, interaction.user, lang=lang)
    view.message = await interaction.followup.send(
        embed=discord.Embed(
            title=t(lang, "match.recent_matches_title", player=esc(player_nick)),
            description=t(lang, "match.recent_matches_desc", count=len(sessions)),
            color=discord.Color.blurple(),
        ).set_footer(text=DEFAULT_FOOTER_CAT),
        view=view,
        ephemeral=True,
        wait=True,
    )


@view_match.error
async def view_match_perm_error(interaction, error):
    await permission_fail(interaction, error)


# ═══════════════════════════════════════════════════════════════════════════
# PLAYER COMPARISON
# ═══════════════════════════════════════════════════════════════════════════

_COMPARE_STATS_ORDER = [
    ("total_battles", "compare.battles_label"),
    ("wins", "compare.wins_label"),
    ("losses", "compare.losses_label"),
    ("win_rate", "compare.win_rate_label"),
    ("ground_kills", "compare.ground_kills_label"),
    ("air_kills", "compare.air_kills_label"),
    ("total_kills", "compare.total_kills_label"),
    ("assists", "compare.assists_label"),
    ("deaths", "compare.deaths_label"),
    ("kd", "compare.kd_label"),
    ("captures", "compare.captures_label"),
]

# Stats where lower is better
_LOWER_IS_BETTER = {"deaths", "losses"}


async def _resolve_player_uids_batch(db, usernames: List[str], lang: str = "en"):
    """Resolve multiple usernames in one pass. Returns (uid_list, error_msg) or (None, error_msg)."""
    uids: List[str] = []
    for name in usernames:
        async with db.execute(
            """
            SELECT UID, nick FROM (
                SELECT UID, nick, ROW_NUMBER() OVER (PARTITION BY UID ORDER BY endtime_unix DESC) as rn
                FROM player_games_hist
                WHERE ulower(nick) LIKE ulower(?)
            ) WHERE rn = 1
            ORDER BY nick LIMIT 25
            """,
            (f"%{name}%",),
        ) as cursor:
            results = list(await cursor.fetchall())
        if not results:
            return None, t(lang, "compare.no_players_found", name=name)
        if len(results) > 1:
            matches = ", ".join(esc(r["nick"]) for r in results[:10])
            return None, t(lang, "compare.multiple_matches", name=name, matches=matches)
        uids.append(results[0]["UID"])
    return uids, None


async def _fetch_players_aggregate_batch(db, uids: List[str]) -> List[Optional[dict]]:
    """Fetch aggregate stats for multiple UIDs in two batched queries."""
    placeholders = ",".join("?" for _ in uids)

    # Batch 1: latest nick + squadron for each UID
    info_map: dict = {}
    async with db.execute(
        f"""
        SELECT UID, nick, squadron_name FROM (
            SELECT UID, nick, squadron_name,
                   ROW_NUMBER() OVER (PARTITION BY UID ORDER BY endtime_unix DESC) as rn
            FROM player_games_hist
            WHERE UID IN ({placeholders})
        ) WHERE rn = 1
        """,
        uids,
    ) as cursor:
        async for row in cursor:
            info_map[row["UID"]] = row

    # Batch 2: aggregate stats for all UIDs at once
    stats_map: dict = {}
    async with db.execute(
        f"""
        SELECT
            UID,
            SUM(ground_kills) as ground_kills,
            SUM(air_kills) as air_kills,
            SUM(assists) as assists,
            SUM(captures) as captures,
            SUM(deaths) as deaths,
            SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins,
            SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END) as losses,
            COUNT(*) as total_battles
        FROM player_games_hist
        WHERE UID IN ({placeholders})
        GROUP BY UID
        """,
        uids,
    ) as cursor:
        async for row in cursor:
            stats_map[row["UID"]] = row

    # Assemble results in UID order
    results: List[Optional[dict]] = []
    for uid in uids:
        info = info_map.get(uid)
        row = stats_map.get(uid)
        if not info or not row or not row["total_battles"]:
            results.append(None)
            continue

        total_kills = (row["ground_kills"] or 0) + (row["air_kills"] or 0)
        deaths = row["deaths"] or 0
        total_battles = row["total_battles"] or 0
        wins = row["wins"] or 0
        losses = row["losses"] or 0

        results.append({
            "nick": info["nick"],
            "squadron": info["squadron_name"] or "None",
            "uid": uid,
            "ground_kills": row["ground_kills"] or 0,
            "air_kills": row["air_kills"] or 0,
            "total_kills": total_kills,
            "assists": row["assists"] or 0,
            "captures": row["captures"] or 0,
            "deaths": deaths,
            "kd": round(total_kills / deaths, 2) if deaths > 0 else float(total_kills),
            "wins": wins,
            "losses": losses,
            "total_battles": total_battles,
            "win_rate": f"{(wins / total_battles * 100):.1f}" if total_battles > 0 else "0.0",
        })
    return results


def _build_compare_embed(players: List[dict], lang: str = "en") -> Embed:
    """Build a side-by-side comparison embed for multiple players."""

    # Pre-compute best value per stat
    best: dict = {}
    all_tied: dict = {}
    for key, _ in _COMPARE_STATS_ORDER:
        nums = [float(p[key]) for p in players]
        if key in _LOWER_IS_BETTER:
            best[key] = min(nums)
        else:
            best[key] = max(nums)
        all_tied[key] = len(set(nums)) == 1

    # Count how many stats each player "wins" to find the overall best
    win_counts = []
    for p in players:
        count = 0
        for key, _ in _COMPARE_STATS_ORDER:
            if not all_tied[key] and float(p[key]) == best[key]:
                count += 1
        win_counts.append(count)
    max_wins = max(win_counts)

    title = "  vs  ".join(esc(p["nick"]) for p in players)
    embed = Embed(title=title, color=Color.blue())

    for i, p in enumerate(players):
        star = " \u2b50" if win_counts[i] == max_wins and max_wins > 0 else ""
        lines = []
        for key, label_key in _COMPARE_STATS_ORDER:
            val = p[key]
            formatted = f"{val:,}" if isinstance(val, int) else str(val)
            suffix = "%" if key == "win_rate" else ""
            is_best = not all_tied[key] and float(p[key]) == best[key]
            label = t(lang, label_key)
            line = f"{label}: {formatted}{suffix}"
            if is_best:
                line = f"**{line}**"
            lines.append(line)
        embed.add_field(
            name=f"{esc(p['nick'])} [{esc(p['squadron'])}]{star}",
            value="\n".join(lines),
            inline=True,
        )

    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    return embed


async def _generate_compare_graph(players: List[dict], lang: str = "en") -> tuple[Optional[Path], List[str]]:
    """Generate a points-over-time graph for the given players over the last 90 days.

    Reads per-player points from the squadrons_points table (same source as /sq-stats).
    Returns (path, missing_nicks) where missing_nicks lists players with no graph data.
    """
    cutoff = int(time_module.time()) - (90 * 86400)
    uid_set = {p["uid"] for p in players}
    uid_to_nick = {p["uid"]: p["nick"] for p in players}

    # player_points: {uid: [(unix_time, points), ...]}
    player_points: dict = {p["uid"]: [] for p in players}

    # --- Phase 1: fast indexed lookup via known squadron names ---
    squadrons_needed: set[str] = set()

    async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=30.0) as db:
        await db.execute("PRAGMA busy_timeout=30000;")

        # Current membership: squadron_members → squadrons_data
        uid_ph = ",".join("?" for _ in uid_set)
        async with db.execute(
            f"""
            SELECT DISTINCT sd.long_name
            FROM squadron_members sm
            JOIN squadrons_data sd ON sm.clan_id = sd.clan_id
            WHERE sm.uid IN ({uid_ph})
            """,
            list(uid_set),
        ) as cursor:
            async for row in cursor:
                squadrons_needed.add(row[0])

        # Also include the squadron from battle history (already in player dict)
        for p in players:
            if p["squadron"] and p["squadron"] != "None":
                squadrons_needed.add(p["squadron"])

        # Fast per-squadron queries (uses idx_squadrons_points_longname_time)
        for sq_name in squadrons_needed:
            async with db.execute(
                """
                SELECT unix_time, clan_pts
                FROM squadrons_points
                WHERE long_name = ? AND unix_time > ?
                ORDER BY unix_time ASC
                """,
                (sq_name, cutoff),
            ) as cursor:
                async for row in cursor:
                    try:
                        members_dict, _ = decompress_json(row[1])
                        for uid_str, pdata in members_dict.items():
                            if uid_str in uid_set:
                                player_points[uid_str].append(
                                    (row[0], pdata["points"])
                                )
                    except (json.JSONDecodeError, KeyError, ValueError, TypeError, OSError):
                        continue

        # --- Phase 2: lookup historical squadrons from battle records ---
        missing_uids = {p["uid"] for p in players if not player_points[p["uid"]]}
        if missing_uids:
            # Find squadrons these players played for via sq_battles.db
            extra_squads: set[str] = set()
            uid_ph2 = ",".join("?" for _ in missing_uids)
            async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as sqdb:
                async with sqdb.execute(
                    f"SELECT DISTINCT squadron_name FROM player_games_hist WHERE UID IN ({uid_ph2})",
                    list(missing_uids),
                ) as cursor:
                    async for row in cursor:
                        if row[0] and row[0] not in squadrons_needed:
                            extra_squads.add(row[0])

            # Query those squadrons using the same indexed path as Phase 1
            for sq_name in extra_squads:
                async with db.execute(
                    """
                    SELECT unix_time, clan_pts
                    FROM squadrons_points
                    WHERE long_name = ? AND unix_time > ?
                    ORDER BY unix_time ASC
                    """,
                    (sq_name, cutoff),
                ) as cursor:
                    async for row in cursor:
                        try:
                            members_dict, _ = decompress_json(row[1])
                            for uid_str, pdata in members_dict.items():
                                if uid_str in missing_uids:
                                    player_points[uid_str].append(
                                        (row[0], pdata["points"])
                                    )
                        except (json.JSONDecodeError, KeyError, ValueError, TypeError, OSError):
                            continue

    # Sort each player's points by timestamp (data may come from multiple squadrons)
    for uid in player_points:
        player_points[uid].sort(key=lambda x: x[0])

    # Identify players with no data
    missing_nicks = [uid_to_nick[p["uid"]] for p in players if not player_points[p["uid"]]]

    # Check if we got any data at all
    if not any(pts for pts in player_points.values()):
        return None, missing_nicks

    # Plot
    fig, ax = plt.subplots(figsize=(12, 6), facecolor=SQ_STATS_GRAPH_COLORS['bg'])
    ax.set_facecolor(SQ_STATS_GRAPH_COLORS['plot_bg'])

    colors = ['#12ed2f', '#ed4012', '#127eed', '#edd012', '#ed12c7', '#12edd0', '#ed8a12']
    for idx, p in enumerate(players):
        pts = player_points[p["uid"]]
        if pts:
            times = [mdates.date2num(datetime.fromtimestamp(t)) for t, _ in pts]
            values = [v for _, v in pts]
            color = colors[idx % len(colors)]
            ax.plot(times, values, linewidth=2, color=color, label=uid_to_nick[p["uid"]])

    ax.set_xlabel('Date', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text'])
    ax.set_ylabel('Points', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text'])
    ax.set_title(t(lang, "compare.graph_title"), fontsize=14, fontweight='bold',
                 color=SQ_STATS_GRAPH_COLORS['text'])
    ax.grid(True, alpha=0.2, color=SQ_STATS_GRAPH_COLORS['grid'])
    ax.tick_params(colors=SQ_STATS_GRAPH_COLORS['text'])
    for spine in ax.spines.values():
        spine.set_color(SQ_STATS_GRAPH_COLORS['text'])
    ax.legend(facecolor=SQ_STATS_GRAPH_COLORS['plot_bg'],
              edgecolor=SQ_STATS_GRAPH_COLORS['grid'],
              labelcolor=SQ_STATS_GRAPH_COLORS['text'])
    ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d'))
    fig.autofmt_xdate()
    plt.tight_layout()

    temp_path = Path(f"/tmp/compare_{'_'.join(p['uid'] for p in players[:3])}_{int(time_module.time())}.png")
    plt.savefig(temp_path, dpi=150, bbox_inches='tight', facecolor=SQ_STATS_GRAPH_COLORS['bg'])
    plt.close(fig)
    return temp_path, missing_nicks


class CompareGraphView(View):
    """View with a graph button and per-player website link buttons."""

    def __init__(self, players: List[dict], lang: str = "en"):
        super().__init__(timeout=120)
        self.players = players
        self.lang = lang
        for i, p in enumerate(players):
            self.add_item(discord.ui.Button(
                label=p["nick"][:80],
                style=discord.ButtonStyle.link,
                url=f"https://sre.pawjob.us/players/{p['uid']}",
                row=0 if i < 4 else 1,
            ))
        self.show_graph.label = t(lang, "buttons.show_graph")

    @discord.ui.button(label="Show Graph", style=discord.ButtonStyle.blurple, row=2)
    async def show_graph(self, interaction: discord.Interaction, button: discord.ui.Button):
        """Fetch 90-day points graph for compared players and attach it to the message."""
        await interaction.response.defer()
        temp_path, missing_nicks = await _generate_compare_graph(self.players, lang=self.lang)
        if not temp_path:
            await interaction.followup.send(t(self.lang, "compare.no_graph_data"), ephemeral=True)
            return
        button.disabled = True
        await interaction.edit_original_response(view=self)
        if missing_nicks:
            names = ", ".join(f"**{n}**" for n in missing_nicks)
            await interaction.followup.send(
                content=t(self.lang, "compare.no_squadron_points_data", names=names),
                file=discord.File(temp_path),
            )
        else:
            await interaction.followup.send(file=discord.File(temp_path))
        try:
            temp_path.unlink()
        except Exception:
            pass


@is_blacklisted()
@bot.tree.command(name="compare", description=command_locale("Compare aggregate SQB stats between players", "commands.compare.description"))
@app_commands.describe(
    player1=command_locale("First player username", "commands.compare.player1"),
    player2=command_locale("Second player username", "commands.compare.player2"),
    player3=command_locale("Additional player username (optional)", "commands.compare.player_optional"),
    player4=command_locale("Additional player username (optional)", "commands.compare.player_optional"),
    player5=command_locale("Additional player username (optional)", "commands.compare.player_optional"),
    player6=command_locale("Additional player username (optional)", "commands.compare.player_optional"),
    player7=command_locale("Additional player username (optional)", "commands.compare.player_optional"),
)
@discord.app_commands.autocomplete(
    player1=player_autocomplete, player2=player_autocomplete,
    player3=player_autocomplete, player4=player_autocomplete,
    player5=player_autocomplete, player6=player_autocomplete,
    player7=player_autocomplete,
)
async def compare(
    interaction: discord.Interaction,
    player1: str, player2: str,
    player3: str = "", player4: str = "",
    player5: str = "", player6: str = "",
    player7: str = "",
):
    """Compare aggregate squadron battle stats between 2-7 players.

    Resolves each username to a UID, fetches aggregated battle stats (kills, deaths,
    win rate, etc.) from sq_battles.db, builds a side-by-side comparison embed, and
    attaches a CompareGraphView with a graph button and per-player website links.
    """
    await collect_command_stats(interaction)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
    await interaction.response.defer(thinking=True)

    usernames = [p for p in [player1, player2, player3, player4, player5, player6, player7] if p]

    try:
        async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db:
            await db.execute("PRAGMA busy_timeout=30000;")
            await db.create_function("ulower", 1, str.lower)
            db.row_factory = aiosqlite.Row

            uids, err = await _resolve_player_uids_batch(db, usernames, lang=lang)
            if err or uids is None:
                await interaction.followup.send(err or t(lang, "compare.could_not_resolve"), ephemeral=True)
                return

            player_stats = await _fetch_players_aggregate_batch(db, uids)

    except Exception as e:
        error_str = str(e)[:1800]
        await interaction.followup.send(t(lang, "common.database_error", error=error_str), ephemeral=True)
        return

    # Check for any failed lookups
    players = []
    for i, p in enumerate(player_stats):
        if not p:
            await interaction.followup.send(
                t(lang, "compare.could_not_fetch", name=usernames[i]), ephemeral=True)
            return
        players.append(p)

    view = CompareGraphView(players, lang=lang)
    await interaction.followup.send(embed=_build_compare_embed(players, lang=lang), view=view)


@compare.error
async def compare_perm_error(interaction, error):
    await permission_fail(interaction, error)


@is_blacklisted()
@bot.tree.command(name="leaderboard", description=command_locale("Get the SRE Bot global leaderboard", "commands.leaderboard.description"))
async def leaderboard(interaction: discord.Interaction):
    """Send the SRE Bot global leaderboard URL."""
    await collect_command_stats(interaction)
    await interaction.response.send_message(
        "https://sre.pawjob.us/leaderboard/players"
    )

@leaderboard.error
async def leaderboard_perm_error(interaction, error):
    await permission_fail(interaction, error)



@is_blacklisted()
@is_admin()
@bot.tree.command(name='set-squadron', description=command_locale('Set the squadron tag for this server', "commands.set_squadron.description"))
@app_commands.describe(abbreviated_name=command_locale('The short name of the squadron to set', "commands.set_squadron.abbreviated_name"))
@discord.app_commands.autocomplete(abbreviated_name=squadron_autocomplete)
async def set_squadron(interaction: discord.Interaction, abbreviated_name: str):
    """Set or swap the squadron tag for this server.

    Resolves the abbreviated squadron name via resolve_clan, then either sets it
    directly in SQUADRONS.json or shows a ConfirmSwapView if a different squadron
    is already configured for this guild.
    """
    await collect_command_stats(interaction)
    await interaction.response.defer(ephemeral=True)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    squadrons_path = STORAGE_DIR / "SQUADRONS.json"
    squadrons = await load_json(squadrons_path, {})

    guild_id = str(interaction.guild_id)

    # Clean input
    new_short = re.sub(r'\W+', '', abbreviated_name)

    # Resolve using short name only
    try:
        clan = await resolve_clan(short=new_short)
    except Exception as e:
        logging.error(f"Error resolving squadron {new_short}: {e}")
        clan = None

    if not clan or clan["long_name"] == "<unresolved>":
        embed = Embed(
            title=t(lang, "common.error_title"),
            description=t(lang, "squadron.not_found_desc", squadron=new_short),
            color=Color.red()
        )
        return await interaction.followup.send(embed=embed, ephemeral=True)

    new_long = clan["long_name"]

    # Same swap/confirmation logic as before
    if (guild_id in squadrons
        and squadrons[guild_id]['SQ_ShortHand_Name'] != new_short):
        old_long = squadrons[guild_id]['SQ_LongHandName']

        class ConfirmSwapView(View):
            """Confirmation prompt to swap the server's squadron assignment."""

            def __init__(self, lang):
                super().__init__(timeout=60)
                self.lang = lang
                self.confirm.label = t(lang, "buttons.confirm_swap")
                self.cancel.label = t(lang, "buttons.cancel_swap")

            @button(label="Yes, swap it", style=discord.ButtonStyle.green)
            async def confirm(self, button_interaction: discord.Interaction, button: discord.ui.Button):
                squadrons[guild_id] = {
                    "SQ_ShortHand_Name": new_short,
                    "SQ_LongHandName": new_long
                }
                await write_json(squadrons_path, squadrons)
                logging.info(f"Swapped squadron for guild {guild_id}: {old_long} → {new_long}")

                embed_swapped = Embed(
                    title=t(self.lang, "squadron.swap_title"),
                    description=t(self.lang, "squadron.swap_desc", old=old_long, new=new_long),
                    color=Color.green()
                )
                await button_interaction.response.edit_message(embed=embed_swapped, view=None)
                self.stop()

            @button(label="No, keep the old one", style=discord.ButtonStyle.red)
            async def cancel(self, button_interaction: discord.Interaction, button: discord.ui.Button):
                await button_interaction.response.edit_message(
                    content=t(self.lang, "squadron.swap_cancelled"), embed=None, view=None
                )
                self.stop()

        view = ConfirmSwapView(lang)
        embed_confirm = Embed(
            title=t(lang, "squadron.already_set_title"),
            description=t(lang, "squadron.already_set_desc", old=old_long, new=new_long),
            color=Color.gold()
        )
        return await interaction.followup.send(embed=embed_confirm, view=view, ephemeral=True)

    # Otherwise set it fresh
    squadrons[guild_id] = {
        "SQ_ShortHand_Name": new_short,
        "SQ_LongHandName": new_long
    }
    await write_json(squadrons_path, squadrons)
    logging.info(f"Set squadron for guild {guild_id} → {new_long}")

    embed_set = Embed(
        title=t(lang, "squadron.set_title"),
        description=t(lang, "squadron.set_desc", squadron=new_long),
        color=Color.green()
    )
    embed_set.add_field(name=t(lang, "squadron.short_name_field"), value=new_short, inline=True)
    embed_set.add_field(name=t(lang, "squadron.long_name_field"), value=new_long, inline=True)
    embed_set.set_footer(text=DEFAULT_FOOTER_CAT)
    await interaction.followup.send(embed=embed_set, ephemeral=False)


@set_squadron.error
async def set_squadron_error(interaction, error):
    await permission_fail(interaction, error)


# ============================================================================
# SETUP WIZARD
# ============================================================================

@dataclass
class SetupState:
    """State bag passed through all setup wizard steps."""
    guild_id: str
    squadron_short: Optional[str] = None
    squadron_long: Optional[str] = None
    squadron_clan_id: Optional[int] = None
    logs_channel_id: Optional[int] = None
    points_channel_id: Optional[int] = None


def _step1_embed(state: SetupState, lang: str = "en") -> Embed:
    """Build the Step 1 welcome embed."""
    desc = t(lang, "setup.step1_desc")
    if state.squadron_short:
        desc += t(lang, "setup.step1_current_sq", short=state.squadron_short, long=state.squadron_long)
    embed = Embed(title=t(lang, "setup.step1_title"), description=desc, color=Color.blue())
    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    return embed


def _step2_embed(state: SetupState, lang: str = "en") -> Embed:
    """Build the Step 2 logs channel embed."""
    desc = t(lang, "setup.step2_desc", short=state.squadron_short, long=state.squadron_long)
    embed = Embed(title=t(lang, "setup.step2_title"), description=desc, color=Color.blue())
    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    return embed


def _step3_embed(state: SetupState, lang: str = "en") -> Embed:
    """Build the Step 3 points channel embed."""
    desc = t(lang, "setup.step3_desc")
    if state.logs_channel_id:
        desc += t(lang, "setup.step3_same_as_logs")
    embed = Embed(title=t(lang, "setup.step3_title"), description=desc, color=Color.blue())
    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    return embed


async def _summary_embed(state: SetupState, lang: str = "en") -> Embed:
    """Build the summary embed."""
    sq = f"[{state.squadron_short}] {state.squadron_long}" if state.squadron_short else t(lang, "common.not_configured")
    logs = f"<#{state.logs_channel_id}>" if state.logs_channel_id else t(lang, "common.not_configured")
    points = f"<#{state.points_channel_id}>" if state.points_channel_id else t(lang, "common.not_configured")

    embed = Embed(
        title=t(lang, "setup.summary_title"),
        description=t(lang, "setup.summary_desc"),
        color=Color.green(),
    )
    embed.add_field(name=t(lang, "setup.squadron_field"), value=sq, inline=False)
    embed.add_field(name=t(lang, "setup.logs_channel_field"), value=logs, inline=True)
    embed.add_field(name=t(lang, "setup.points_channel_field"), value=points, inline=True)

    if state.logs_channel_id and not await is_guild_entitled(int(state.guild_id)):
        embed.add_field(
            name=t(lang, "setup.premium_required_field"),
            value=t(lang, "setup.premium_required_value"),
            inline=False,
        )

    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    return embed


class SetupSquadronModal(discord.ui.Modal):
    """Modal for entering the squadron short name."""

    def __init__(self, state: SetupState, lang: str = "en"):
        super().__init__(title=t(lang, "setup.modal_title"))
        self.state = state
        self.lang = lang
        self.squadron_input = discord.ui.TextInput(
            label=t(lang, "setup.modal_label"),
            placeholder=t(lang, "setup.modal_placeholder"),
            required=True,
            max_length=50,
        )
        self.add_item(self.squadron_input)

    async def on_submit(self, interaction: discord.Interaction):
        raw = self.squadron_input.value.strip()
        short = re.sub(r'\W+', '', raw)

        try:
            clan = await resolve_clan(short=short)
        except Exception as e:
            logging.error(f"Error resolving squadron {short} in setup wizard: {e}")
            clan = None

        if not clan or clan["long_name"] == "<unresolved>":
            await interaction.response.send_message(
                t(self.lang, "setup.squadron_not_found", squadron=raw),
                ephemeral=True,
            )
            return

        new_long = clan["long_name"]
        new_short = clan["short_name"]

        # Save to SQUADRONS.json (same pattern as /set-squadron)
        squadrons_path = STORAGE_DIR / "SQUADRONS.json"
        squadrons = await load_json(squadrons_path, {})

        # Only write if value actually changed
        current = squadrons.get(self.state.guild_id)
        if not current or current.get("SQ_ShortHand_Name") != new_short:
            squadrons[self.state.guild_id] = {
                "SQ_ShortHand_Name": new_short,
                "SQ_LongHandName": new_long,
            }
            await write_json(squadrons_path, squadrons)
            logging.info(f"Setup wizard: set squadron for guild {self.state.guild_id} → {new_long}")

        self.state.squadron_short = new_short
        self.state.squadron_long = new_long
        clan_id_val = clan.get("clan_id") if isinstance(clan, dict) else None
        self.state.squadron_clan_id = int(clan_id_val) if clan_id_val else None

        # Advance to Step 2
        view = SetupLogsView(self.state, self.lang)
        await interaction.response.edit_message(embed=_step2_embed(self.state, self.lang), view=view)


class SetupWelcomeView(discord.ui.View):
    """Step 1 — Welcome + Set Squadron."""

    def __init__(self, state: SetupState, lang: str = "en"):
        super().__init__(timeout=300)
        self.state = state
        self.lang = lang
        self.set_squadron_btn.label = t(lang, "buttons.set_squadron")
        self.skip_step.label = t(lang, "buttons.skip")
        # Only show Skip if squadron is already configured
        if not state.squadron_short:
            self.skip_step.disabled = True
            self.remove_item(self.skip_step)

    @discord.ui.button(label="Set Squadron", style=discord.ButtonStyle.green)
    async def set_squadron_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
        modal = SetupSquadronModal(self.state, self.lang)
        await interaction.response.send_modal(modal)

    @discord.ui.button(label="Skip", style=discord.ButtonStyle.grey)
    async def skip_step(self, interaction: discord.Interaction, button: discord.ui.Button):
        view = SetupLogsView(self.state, self.lang)
        await interaction.response.edit_message(embed=_step2_embed(self.state, self.lang), view=view)


class SetupLogsChannelSelect(discord.ui.ChannelSelect):
    """Channel select for the logs channel (Step 2)."""

    def __init__(self, state: SetupState, lang: str = "en"):
        super().__init__(
            placeholder=t(lang, "setup.logs_channel_placeholder"),
            channel_types=[discord.ChannelType.text, discord.ChannelType.news],
            min_values=1,
            max_values=1,
        )
        self.state = state
        self.lang = lang

    async def callback(self, interaction: discord.Interaction):
        channel = self.values[0]
        self.state.logs_channel_id = channel.id

        # Save to preferences
        await _save_channel_pref(self.state, "Logs", channel.id)

        # Advance to Step 3
        view = SetupPointsView(self.state, self.lang)
        await interaction.response.edit_message(embed=_step3_embed(self.state, self.lang), view=view)


class SetupLogsView(discord.ui.View):
    """Step 2 — Set Logs Channel."""

    def __init__(self, state: SetupState, lang: str = "en"):
        super().__init__(timeout=300)
        self.state = state
        self.lang = lang
        self.add_item(SetupLogsChannelSelect(state, lang))
        self.skip_step.label = t(lang, "buttons.skip")

    @discord.ui.button(label="Skip", style=discord.ButtonStyle.grey)
    async def skip_step(self, interaction: discord.Interaction, button: discord.ui.Button):
        view = SetupPointsView(self.state, self.lang)
        await interaction.response.edit_message(embed=_step3_embed(self.state, self.lang), view=view)


class SetupPointsChannelSelect(discord.ui.ChannelSelect):
    """Channel select for the points channel (Step 3)."""

    def __init__(self, state: SetupState, lang: str = "en"):
        super().__init__(
            placeholder=t(lang, "setup.points_channel_placeholder"),
            channel_types=[discord.ChannelType.text, discord.ChannelType.news],
            min_values=1,
            max_values=1,
        )
        self.state = state
        self.lang = lang

    async def callback(self, interaction: discord.Interaction):
        channel = self.values[0]
        self.state.points_channel_id = channel.id

        # Save to preferences
        await _save_channel_pref(self.state, "Points", channel.id)

        # Advance to Summary
        await interaction.response.edit_message(embed=await _summary_embed(self.state, self.lang), view=None)


class SetupPointsView(discord.ui.View):
    """Step 3 — Set Points Channel."""

    def __init__(self, state: SetupState, lang: str = "en"):
        super().__init__(timeout=300)
        self.state = state
        self.lang = lang
        self.add_item(SetupPointsChannelSelect(state, lang))
        self.same_as_logs_btn.label = t(lang, "buttons.same_as_logs")
        self.skip_step.label = t(lang, "buttons.skip")
        # Only show "Same as Logs" if logs channel was set
        if not state.logs_channel_id:
            self.remove_item(self.same_as_logs_btn)

    @discord.ui.button(label="Same as Logs", style=discord.ButtonStyle.blurple)
    async def same_as_logs_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
        assert self.state.logs_channel_id is not None  # button only shown when logs is set
        self.state.points_channel_id = self.state.logs_channel_id

        # Save to preferences
        await _save_channel_pref(self.state, "Points", self.state.logs_channel_id)

        await interaction.response.edit_message(embed=await _summary_embed(self.state, self.lang), view=None)

    @discord.ui.button(label="Skip", style=discord.ButtonStyle.grey)
    async def skip_step(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.edit_message(embed=await _summary_embed(self.state, self.lang), view=None)


async def _save_channel_pref(state: SetupState, alarm_type: str, channel_id: int):
    """Save a Logs or Points channel preference (same format as quick-log)."""
    if not state.squadron_long:
        return
    guild_id = int(state.guild_id)
    prefs = await load_guild_preferences(guild_id)
    # Prefer the stable clan_id as the prefs key (matches quick-log writes).
    pref_key = str(state.squadron_clan_id) if state.squadron_clan_id else state.squadron_long
    entry = prefs.setdefault(pref_key, {})
    entry[alarm_type] = f"<#{channel_id}>"
    if state.squadron_long:
        entry["Long"] = state.squadron_long
    if state.squadron_short:
        entry["Short"] = state.squadron_short
    await save_guild_preferences(guild_id, prefs)
    logging.info(
        f"Setup wizard: saved {alarm_type} channel {channel_id} for "
        f"{state.squadron_long} (key={pref_key}) in guild {state.guild_id}"
    )


@is_blacklisted()
@is_admin()
@bot.tree.command(name='setup', description=command_locale('Set up the bot for this server', "commands.setup.description"))
async def setup(interaction: discord.Interaction):
    """Launch the server setup wizard.

    Pre-fills state from existing SQUADRONS.json config, then presents a
    three-step wizard: (1) set squadron, (2) choose logs channel, (3) choose
    points channel. Each step is a Discord UI view with skip/continue buttons.
    """
    await collect_command_stats(interaction)
    await interaction.response.defer(ephemeral=True)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    guild_id = str(interaction.guild_id)

    # Pre-fill state from existing config
    state = SetupState(guild_id=guild_id)

    squadrons_path = STORAGE_DIR / "SQUADRONS.json"
    squadrons = await load_json(squadrons_path, {})
    if guild_id in squadrons:
        state.squadron_short = squadrons[guild_id].get("SQ_ShortHand_Name")
        state.squadron_long = squadrons[guild_id].get("SQ_LongHandName")
        # Resolve clan_id so subsequent prefs writes use the canonical key.
        if state.squadron_short:
            try:
                clan = await resolve_clan(short=state.squadron_short)
                if clan and clan.get("clan_id"):
                    state.squadron_clan_id = int(clan["clan_id"])
            except Exception:
                pass

    view = SetupWelcomeView(state, lang)
    await interaction.followup.send(embed=_step1_embed(state, lang), view=view, ephemeral=True)


@setup.error
async def setup_error(interaction, error):
    await permission_fail(interaction, error)


class PasswordModal(discord.ui.Modal):
    """Modal for entering a squadron's access password to authenticate or claim ownership."""

    def __init__(self, squadron_clanID: str, squadron_name: str, guild_id: str,
                 is_owner: bool = False, password_hint: str = "", lang: str = "en"):
        self.lang = lang
        super().__init__(title=t(lang, "meta_management.password_modal_title"))
        self.squadron_clanID = squadron_clanID
        self.squadron_name = squadron_name
        self.guild_id = guild_id
        self.is_owner = is_owner
        placeholder = f"Hint: {password_hint}" if password_hint else t(lang, "meta_management.password_modal_placeholder")
        self.password_input = discord.ui.TextInput(
            label=t(lang, "meta_management.password_modal_label"),
            placeholder=placeholder,
            required=True,
            max_length=50
        )
        self.add_item(self.password_input)

    async def on_submit(self, interaction: discord.Interaction):
        """Validate the password, optionally transfer the squadron, and show MetaManagementView.

        If the submitting server already owns the squadron, re-authenticates without
        transfer. Otherwise checks the squadron data lock before transferring
        ownership to the new guild. On success, displays the management settings panel.
        """
        # Validate password
        password_provided = self.password_input.value.strip()
        logging.info(f"[PASSWORD CHECK] squadron_clanID: {self.squadron_clanID}, is_owner: {self.is_owner}")

        is_valid = await validate_squadron_password(self.squadron_clanID, password_provided)

        if not is_valid:
            logging.warning(f"[PASSWORD CHECK] FAILED for squadron {self.squadron_clanID}")
            embed = discord.Embed(
                title=t(self.lang, "meta_management.access_denied_title"),
                description=t(self.lang, "meta_management.access_denied_desc"),
                color=discord.Color.red()
            )
            embed.set_footer(text=DEFAULT_FOOTER_CAT)
            await interaction.response.send_message(embed=embed, ephemeral=True)
            return

        logging.info(f"[PASSWORD CHECK] SUCCESS for squadron {self.squadron_clanID}")

        # Get squadron owner settings
        squadron_owner = await get_squadron_owner(self.squadron_clanID)

        if self.is_owner:
            # This server owns the squadron, they're just re-authenticating
            # No transfer needed, just show settings
            guild_settings = await get_guild_settings(self.guild_id)
        else:
            # Different server trying to claim the squadron
            # Check if squadron data is locked
            if squadron_owner and squadron_owner.get("lock_squadron_data", False):
                logging.warning(f"[PASSWORD CHECK] Transfer BLOCKED - squadron {self.squadron_clanID} is locked")
                embed = discord.Embed(
                    title=t(self.lang, "meta_management.data_locked_title"),
                    description=t(self.lang, "meta_management.data_locked_desc", squadron=self.squadron_name),
                    color=discord.Color.orange()
                )
                embed.set_footer(text=DEFAULT_FOOTER_CAT)
                await interaction.response.send_message(embed=embed, ephemeral=True)
                return

            # Password correct and not locked - transfer squadron to this server
            await transfer_squadron_to_guild(self.squadron_clanID, self.guild_id, self.squadron_name)

            # Get updated settings after transfer
            guild_settings = await get_guild_settings(self.guild_id)

        if not guild_settings:
            await interaction.response.send_message(
                t(self.lang, "meta_management.error_retrieving_settings"),
                ephemeral=True
            )
            return

        # Show success message with management view
        if self.is_owner:
            embed = discord.Embed(
                title=t(self.lang, "meta_management.authenticated_title"),
                description=t(self.lang, "meta_management.authenticated_desc", squadron=self.squadron_name),
                color=discord.Color.green()
            )
        else:
            embed = discord.Embed(
                title=t(self.lang, "meta_management.claimed_title"),
                description=t(self.lang, "meta_management.claimed_desc", squadron=self.squadron_name),
                color=discord.Color.green()
            )
        embed.add_field(
            name=t(self.lang, "meta_management.password_requirement_field"),
            value=t(self.lang, "meta_management.enabled_value") if guild_settings["require_password"] else t(self.lang, "meta_management.disabled_value"),
            inline=True
        )
        embed.add_field(
            name=t(self.lang, "meta_management.data_lock_field"),
            value=t(self.lang, "meta_management.enabled_value") if guild_settings["lock_squadron_data"] else t(self.lang, "meta_management.disabled_value"),
            inline=True
        )
        embed.add_field(
            name=t(self.lang, "meta_management.public_meta_field"),
            value=t(self.lang, "meta_management.enabled_value") if guild_settings["allow_public_meta"] else t(self.lang, "meta_management.disabled_value"),
            inline=True
        )
        if interaction.guild and interaction.user.id == interaction.guild.owner_id:
            embed.add_field(
                name=t(self.lang, "meta_management.access_password_field"),
                value=f"`{guild_settings['access_password']}`",
                inline=False
            )
        embed.set_footer(text=DEFAULT_FOOTER_CAT)

        # Create management view
        view = MetaManagementView(
            guild_id=self.guild_id,
            squadron_name=self.squadron_name,
            squadron_clanID=self.squadron_clanID,
            require_password=guild_settings["require_password"],
            lock_squadron_data=guild_settings["lock_squadron_data"],
            allow_public_meta=guild_settings["allow_public_meta"],
            lang=self.lang
        )

        # Update button styles based on current state
        if guild_settings["require_password"]:
            button = view.children[0]
            if isinstance(button, discord.ui.Button):
                button.style = discord.ButtonStyle.success
                button.label = t(self.lang, "buttons.password_required")

        if guild_settings["lock_squadron_data"]:
            button = view.children[1]
            if isinstance(button, discord.ui.Button):
                button.style = discord.ButtonStyle.success
                button.label = t(self.lang, "buttons.data_locked")

        if guild_settings["allow_public_meta"]:
            button = view.children[2]
            if isinstance(button, discord.ui.Button):
                button.style = discord.ButtonStyle.success
                button.label = t(self.lang, "buttons.public_enabled")

        await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
        view.message = await interaction.original_response()


class ChangePasswordModal(discord.ui.Modal):
    """Modal for changing a squadron's access password (server owner only)."""

    def __init__(self, squadron_clanID: str, squadron_name: str, guild_id: str, lang: str = "en"):
        self.lang = lang
        super().__init__(title=t(lang, "meta_management.change_pw_modal_title"))
        self.squadron_clanID = squadron_clanID
        self.squadron_name = squadron_name
        self.guild_id = guild_id
        self.current_password = discord.ui.TextInput(
            label=t(lang, "meta_management.current_password_label"),
            placeholder=t(lang, "meta_management.current_password_placeholder"),
            required=True,
            max_length=50
        )
        self.new_password = discord.ui.TextInput(
            label=t(lang, "meta_management.new_password_label"),
            placeholder=t(lang, "meta_management.new_password_placeholder"),
            required=True,
            max_length=50
        )
        self.confirm_password = discord.ui.TextInput(
            label=t(lang, "meta_management.confirm_password_label"),
            placeholder=t(lang, "meta_management.confirm_password_placeholder"),
            required=True,
            max_length=50
        )
        self.hint_input = discord.ui.TextInput(
            label=t(lang, "meta_management.hint_label"),
            placeholder=t(lang, "meta_management.hint_placeholder"),
            required=False,
            max_length=100
        )
        self.add_item(self.current_password)
        self.add_item(self.new_password)
        self.add_item(self.confirm_password)
        self.add_item(self.hint_input)

    async def on_submit(self, interaction: discord.Interaction):
        """Validate the current password, check new passwords match, then update.

        Sends a confirmation message with the new password on success.
        """
        await interaction.response.defer(ephemeral=True)

        current = self.current_password.value.strip()
        new_pw = self.new_password.value.strip()
        confirm = self.confirm_password.value.strip()
        hint = self.hint_input.value.strip()

        # Validate current password
        is_valid = await validate_squadron_password(self.squadron_clanID, current)
        if not is_valid:
            await interaction.followup.send(
                t(self.lang, "meta_management.pw_incorrect"), ephemeral=True
            )
            return

        # Validate new passwords match
        if new_pw != confirm:
            await interaction.followup.send(
                t(self.lang, "meta_management.pw_mismatch"), ephemeral=True
            )
            return

        # Validate new password isn't empty after strip
        if not new_pw:
            await interaction.followup.send(
                t(self.lang, "meta_management.pw_empty"), ephemeral=True
            )
            return

        # Update password and hint
        await update_squadron_password(
            self.squadron_clanID, new_pw,
            password_hint=hint if hint else ""
        )

        logging.info(f"[PASSWORD CHANGE] Password changed for squadron {self.squadron_clanID} by guild {self.guild_id}")

        hint_suffix = t(self.lang, "meta_management.pw_changed_hint", hint=hint) if hint else ""
        await interaction.followup.send(
            t(self.lang, "meta_management.pw_changed", squadron=self.squadron_name, password=new_pw) + hint_suffix,
            ephemeral=True
        )


class PlayerAddModal(discord.ui.Modal):
    """Modal for adding a single player to the guild's meta roster by UID or nickname."""

    def __init__(self, guild_id: str, squadron_clanID: str, parent_view, lang: str = "en"):
        self.lang = lang
        super().__init__(title=t(lang, "meta_management.player_add_modal_title"))
        self.guild_id = guild_id
        self.squadron_clanID = squadron_clanID
        self.parent_view = parent_view
        self.player_input = discord.ui.TextInput(
            label=t(lang, "meta_management.player_add_label"),
            placeholder=t(lang, "meta_management.player_add_placeholder"),
            required=True,
            max_length=100
        )
        self.add_item(self.player_input)

    async def on_submit(self, interaction: discord.Interaction):
        """Look up the player in Players_Global by UID or nick, then add to guild meta.

        Refreshes the parent PlayerManagementView on success.
        """
        await interaction.response.defer(ephemeral=True)

        player_input = self.player_input.value.strip()

        # Try to find player by UID or nickname in Players_Global
        meta_db_path = STORAGE_DIR / "Meta.db"
        async with aiosqlite.connect(meta_db_path) as db:
            # Try by UID first
            cursor = await db.execute("""
                SELECT DISTINCT userID, nick, clanTag, clanName, clanID
                FROM Players_Global
                WHERE userID = ?
                LIMIT 1
            """, (player_input,))
            row = await cursor.fetchone()

            # If not found, try by nickname
            if not row:
                cursor = await db.execute("""
                    SELECT DISTINCT userID, nick, clanTag, clanName, clanID
                    FROM Players_Global
                    WHERE LOWER(nick) = ?
                    LIMIT 1
                """, (player_input.lower(),))
                row = await cursor.fetchone()

        if not row:
            await interaction.followup.send(t(self.lang, "meta_management.player_not_found", player=player_input), ephemeral=True)
            return

        user_id = str(row[0])
        nick = str(row[1])

        # Add player to guild meta
        success, message = await add_player_to_guild_meta(self.guild_id, user_id, self.squadron_clanID)

        if success:
            await interaction.followup.send(f"✅ {message}", ephemeral=True)
            # Refresh the parent view
            await self.parent_view.refresh_display(interaction)
        else:
            await interaction.followup.send(f"❌ {message}", ephemeral=True)


class PlayerManagementView(discord.ui.View):
    """Paginated view for managing a squadron's meta roster (add/remove players)."""

    def __init__(self, guild_id: str, squadron_name: str, squadron_clanID: str, lang: str = "en"):
        super().__init__(timeout=300)  # 5 minutes
        self.guild_id = guild_id
        self.squadron_name = squadron_name
        self.squadron_clanID = squadron_clanID
        self.lang = lang
        self.current_page = 0
        self.players_per_page = 10

    async def refresh_display(self, interaction: discord.Interaction):
        """Rebuild and re-render the player roster embed with pagination and action buttons.

        Fetches the current player list from the meta database, paginates it, and
        updates the message with add/remove controls and navigation buttons.

        Args:
            interaction: The Discord interaction to edit the message on.
        """
        # Get all players in guild meta
        players = await get_guild_meta_players(self.guild_id)

        # Create embed
        embed = discord.Embed(
            title=t(self.lang, "meta_management.roster_title", squadron=self.squadron_name),
            description=t(self.lang, "meta_management.roster_desc", clan_id=self.squadron_clanID, count=len(players)),
            color=discord.Color.blue()
        )

        if players:
            # Paginate players
            start_idx = self.current_page * self.players_per_page
            end_idx = start_idx + self.players_per_page
            page_players = players[start_idx:end_idx]

            player_list = []
            for i, player in enumerate(page_players, start=start_idx + 1):
                player_list.append(
                    f"{i}. **{esc(player['nick'])}** (`{player['userID']}`)"
                )

            total_pages = (len(players) - 1) // self.players_per_page + 1
            embed.add_field(
                name=t(self.lang, "meta_management.roster_page_field", page=self.current_page + 1, total=total_pages),
                value="\n".join(player_list),
                inline=False
            )
        else:
            embed.add_field(
                name=t(self.lang, "meta_management.no_players_field"),
                value=t(self.lang, "meta_management.no_players_hint"),
                inline=False
            )

        embed.set_footer(text=DEFAULT_FOOTER_CAT)

        # Update buttons
        self.clear_items()
        self.add_item(AddPlayerButton(self.guild_id, self.squadron_clanID, self, self.lang))
        self.add_item(AddAllSquadronButton(self.guild_id, self.squadron_name, self.squadron_clanID, self, self.lang))

        if players:
            # Calculate indices for current page
            start_idx = self.current_page * self.players_per_page
            end_idx = start_idx + self.players_per_page
            self.add_item(RemovePlayerSelect(self.guild_id, players[start_idx:end_idx], self, self.lang))

            # Add pagination buttons if needed
            total_pages = (len(players) - 1) // self.players_per_page + 1
            if total_pages > 1:
                if self.current_page > 0:
                    self.add_item(PlayerPrevPageButton(self, self.lang))
                if self.current_page < total_pages - 1:
                    self.add_item(PlayerNextPageButton(self, self.lang))

        self.add_item(BackToSettingsButton(self.guild_id, self.squadron_name, self.squadron_clanID, self.lang))

        # Edit or send message
        try:
            await interaction.response.edit_message(embed=embed, view=self)
        except discord.errors.InteractionResponded:
            await interaction.edit_original_response(embed=embed, view=self)


class AddPlayerButton(discord.ui.Button):
    """Button that opens the PlayerAddModal to add a single player to the meta roster."""

    def __init__(self, guild_id: str, squadron_clanID: str, parent_view, lang: str = "en"):
        super().__init__(label=t(lang, "buttons.add_player"), style=discord.ButtonStyle.success, row=0)
        self.guild_id = guild_id
        self.squadron_clanID = squadron_clanID
        self.parent_view = parent_view
        self.lang = lang

    async def callback(self, interaction: discord.Interaction):
        modal = PlayerAddModal(self.guild_id, self.squadron_clanID, self.parent_view, self.lang)
        await interaction.response.send_modal(modal)


class AddAllSquadronButton(discord.ui.Button):
    """Button that syncs the entire squadron roster into the guild's meta player list."""

    def __init__(self, guild_id: str, squadron_name: str, squadron_clanID: str, parent_view, lang: str = "en"):
        super().__init__(label=t(lang, "buttons.update_all"), style=discord.ButtonStyle.primary, row=0)
        self.guild_id = guild_id
        self.squadron_name = squadron_name
        self.squadron_clanID = squadron_clanID
        self.parent_view = parent_view
        self.lang = lang

    async def callback(self, interaction: discord.Interaction):
        """Fetch all squadron members via API, bulk-sync the roster, and refresh vehicles.

        Resolves the clan tag from squadrons_data, fetches current members via
        obtain_clan_new_points, bulk-adds new / removes departed members, then
        kicks off a background task to refresh vehicle data for the guild.
        """
        await interaction.response.defer(ephemeral=True)

        # Get squadron tag from squadrons_data
        clan_tag = ""
        try:
            async with aiosqlite.connect(SQUADRONS_DB_PATH) as db:
                cursor = await db.execute(
                    "SELECT tag_name FROM squadrons_data WHERE clan_id = ? LIMIT 1",
                    (self.squadron_clanID,)
                )
                row = await cursor.fetchone()
                if row:
                    clan_tag = row[0] or ""
        except Exception:
            pass

        # Fetch all squadron members from API
        try:
            members, _total_points = await obtain_clan_new_points(self.squadron_name)
        except Exception as e:
            await interaction.followup.send(
                t(self.lang, "meta_management.fetch_members_failed", error=e),
                ephemeral=True
            )
            return

        if not members:
            await interaction.followup.send(
                t(self.lang, "meta_management.no_members_found"),
                ephemeral=True
            )
            return

        # Sync all members (add new, remove departed)
        added, removed, skipped = await bulk_add_squadron_players_to_guild_meta(
            self.guild_id, self.squadron_clanID, self.squadron_name, clan_tag, members
        )

        parts = [t(self.lang, "meta_management.roster_synced")]
        if added:
            parts.append(t(self.lang, "meta_management.roster_added", count=added))
        if removed:
            parts.append(t(self.lang, "meta_management.roster_removed", count=removed))
        if skipped:
            parts.append(t(self.lang, "meta_management.roster_up_to_date", count=skipped))
        parts.append(t(self.lang, "meta_management.refreshing_vehicles"))
        await interaction.followup.send(" · ".join(parts), ephemeral=True)

        # Refresh vehicle data for all guild members in the background
        asyncio.create_task(refresh_guild_player_vehicles(self.guild_id))

        # Refresh parent view
        await self.parent_view.refresh_display(interaction)


class RemovePlayerSelect(discord.ui.Select):
    """Dropdown select to remove a player from the guild's meta roster."""

    def __init__(self, guild_id: str, players: list, parent_view, lang: str = "en"):
        options = [
            discord.SelectOption(
                label=f"{player['nick'][:50]}",  # Truncate if too long
                description=f"UID: {player['userID']}",
                value=player['userID']
            )
            for player in players[:25]  # Discord limit
        ]
        super().__init__(placeholder=t(lang, "meta_management.remove_player_placeholder"), options=options, row=1)
        self.guild_id = guild_id
        self.parent_view = parent_view
        self.lang = lang

    async def callback(self, interaction: discord.Interaction):
        await interaction.response.defer(ephemeral=True)

        user_id = self.values[0]
        success, message = await remove_player_from_guild_meta(self.guild_id, user_id)

        if success:
            await interaction.followup.send(f"✅ {message}", ephemeral=True)
            # Refresh the parent view
            await self.parent_view.refresh_display(interaction)
        else:
            await interaction.followup.send(f"❌ {message}", ephemeral=True)


class PlayerPrevPageButton(discord.ui.Button):
    """Previous-page button for PlayerManagementView pagination."""

    def __init__(self, parent_view, lang: str = "en"):
        super().__init__(label=t(lang, "buttons.prev_arrow"), style=discord.ButtonStyle.secondary, row=2)
        self.parent_view = parent_view

    async def callback(self, interaction: discord.Interaction):
        self.parent_view.current_page -= 1
        await self.parent_view.refresh_display(interaction)


class PlayerNextPageButton(discord.ui.Button):
    """Next-page button for PlayerManagementView pagination."""

    def __init__(self, parent_view, lang: str = "en"):
        super().__init__(label=t(lang, "buttons.next_arrow"), style=discord.ButtonStyle.secondary, row=2)
        self.parent_view = parent_view

    async def callback(self, interaction: discord.Interaction):
        self.parent_view.current_page += 1
        await self.parent_view.refresh_display(interaction)


class BackToSettingsButton(discord.ui.Button):
    """Button that navigates back from player management to the MetaManagementView settings panel."""

    def __init__(self, guild_id: str, squadron_name: str, squadron_clanID: str, lang: str = "en"):
        super().__init__(label=t(lang, "buttons.back_to_settings"), style=discord.ButtonStyle.secondary, row=3)
        self.guild_id = guild_id
        self.squadron_name = squadron_name
        self.squadron_clanID = squadron_clanID
        self.lang = lang

    async def callback(self, interaction: discord.Interaction):
        """Reload guild settings and rebuild the MetaManagementView with current toggle states."""
        await interaction.response.defer(ephemeral=True)

        # Get current settings
        guild_settings = await get_guild_settings(self.guild_id)

        if not guild_settings:
            await interaction.followup.send(
                t(self.lang, "meta_management.error_retrieving_settings_retry"),
                ephemeral=True
            )
            return

        # Show settings embed
        embed = discord.Embed(
            title=t(self.lang, "meta_management.settings_title"),
            description=t(self.lang, "meta_management.settings_desc", squadron=self.squadron_name, clan_id=self.squadron_clanID),
            color=discord.Color.blue()
        )
        embed.add_field(
            name=t(self.lang, "meta_management.password_requirement_field"),
            value=t(self.lang, "meta_management.enabled_value") if guild_settings["require_password"] else t(self.lang, "meta_management.disabled_value"),
            inline=True
        )
        embed.add_field(
            name=t(self.lang, "meta_management.data_lock_field"),
            value=t(self.lang, "meta_management.enabled_value") if guild_settings["lock_squadron_data"] else t(self.lang, "meta_management.disabled_value"),
            inline=True
        )
        embed.add_field(
            name=t(self.lang, "meta_management.public_meta_field"),
            value=t(self.lang, "meta_management.enabled_value") if guild_settings["allow_public_meta"] else t(self.lang, "meta_management.disabled_value"),
            inline=True
        )
        if interaction.guild and interaction.user.id == interaction.guild.owner_id:
            embed.add_field(
                name=t(self.lang, "meta_management.access_password_field"),
                value=f"`{guild_settings['access_password']}`",
                inline=False
            )
        embed.set_footer(text=DEFAULT_FOOTER_CAT)

        # Create view with buttons
        view = MetaManagementView(
            guild_id=self.guild_id,
            squadron_name=self.squadron_name,
            squadron_clanID=self.squadron_clanID,
            require_password=guild_settings["require_password"],
            lock_squadron_data=guild_settings["lock_squadron_data"],
            allow_public_meta=guild_settings["allow_public_meta"],
            lang=self.lang
        )

        # Update button styles based on current state
        if guild_settings["require_password"]:
            button = view.children[0]
            if isinstance(button, discord.ui.Button):
                button.style = discord.ButtonStyle.success
                button.label = t(self.lang, "buttons.password_required")

        if guild_settings["lock_squadron_data"]:
            button = view.children[1]
            if isinstance(button, discord.ui.Button):
                button.style = discord.ButtonStyle.success
                button.label = t(self.lang, "buttons.data_locked")

        if guild_settings["allow_public_meta"]:
            button = view.children[2]
            if isinstance(button, discord.ui.Button):
                button.style = discord.ButtonStyle.success
                button.label = t(self.lang, "buttons.public_enabled")

        view.message = await interaction.edit_original_response(embed=embed, view=view)


class MetaManagementView(discord.ui.View):
    """Settings panel for managing squadron meta configuration.

    Provides toggle buttons for password requirement, data lock, and public meta
    access, plus navigation to player roster management and password change.
    """

    def __init__(self, guild_id: str, squadron_name: str, squadron_clanID: str,
                 require_password: bool, lock_squadron_data: bool, allow_public_meta: bool,
                 lang: str = "en"):
        super().__init__(timeout=300)  # 5 minutes
        self.message: Optional[discord.Message] = None
        self.guild_id = guild_id
        self.squadron_name = squadron_name
        self.squadron_clanID = squadron_clanID
        self.require_password = require_password
        self.lock_squadron_data = lock_squadron_data
        self.allow_public_meta = allow_public_meta
        self.lang = lang
        self.toggle_password.label = t(lang, "buttons.require_password")
        self.toggle_lock.label = t(lang, "buttons.lock_data")
        self.toggle_public_meta.label = t(lang, "buttons.allow_public")
        self.update_meta_accounts.label = t(lang, "buttons.update_accounts")
        self.change_password.label = t(lang, "buttons.change_password")
        self.show_help.label = t(lang, "buttons.help")

    async def on_timeout(self):
        for item in self.children:
            item.disabled = True  # type: ignore[attr-defined]
        try:
            if self.message:
                await self.message.edit(view=self)
        except Exception:
            pass

    @discord.ui.button(label="🔒 Require Password", style=discord.ButtonStyle.secondary, row=0)
    async def toggle_password(self, interaction: discord.Interaction, button: discord.ui.Button):
        """Toggle whether a password is required to access meta management on this server."""
        # Toggle the setting
        new_value = not self.require_password
        success = await update_guild_settings(self.guild_id, require_password=new_value)

        if success:
            self.require_password = new_value
            # Update button style
            button.style = discord.ButtonStyle.success if new_value else discord.ButtonStyle.secondary
            button.label = t(self.lang, "buttons.password_required") if new_value else t(self.lang, "buttons.require_password")

            await interaction.response.edit_message(view=self)
            state_str = t(self.lang, "common.enabled") if new_value else t(self.lang, "common.disabled")
            await interaction.followup.send(
                t(self.lang, "meta_management.password_toggled", state=state_str),
                ephemeral=True
            )
        else:
            await interaction.response.send_message(t(self.lang, "common.failed_update_setting"), ephemeral=True)

    @discord.ui.button(label="🔐 Lock Squadron Data", style=discord.ButtonStyle.secondary, row=0)
    async def toggle_lock(self, interaction: discord.Interaction, button: discord.ui.Button):
        """Toggle whether squadron data is locked to this server (prevents transfers)."""
        # Toggle the setting
        new_value = not self.lock_squadron_data
        success = await update_guild_settings(self.guild_id, lock_squadron_data=new_value)

        if success:
            self.lock_squadron_data = new_value
            # Update button style
            button.style = discord.ButtonStyle.success if new_value else discord.ButtonStyle.secondary
            button.label = t(self.lang, "buttons.data_locked") if new_value else t(self.lang, "buttons.lock_data")

            await interaction.response.edit_message(view=self)
            state_str = t(self.lang, "common.enabled") if new_value else t(self.lang, "common.disabled")
            await interaction.followup.send(
                t(self.lang, "meta_management.lock_toggled", state=state_str),
                ephemeral=True
            )
        else:
            await interaction.response.send_message(t(self.lang, "common.failed_update_setting"), ephemeral=True)

    @discord.ui.button(label="👥 Allow Public Meta", style=discord.ButtonStyle.secondary, row=1)
    async def toggle_public_meta(self, interaction: discord.Interaction, button: discord.ui.Button):
        """Toggle whether non-admin members can use the /meta command."""
        # Toggle the setting
        new_value = not self.allow_public_meta
        success = await update_guild_settings(self.guild_id, allow_public_meta=new_value)

        if success:
            self.allow_public_meta = new_value
            # Update button style
            button.style = discord.ButtonStyle.success if new_value else discord.ButtonStyle.secondary
            button.label = t(self.lang, "buttons.public_enabled") if new_value else t(self.lang, "buttons.allow_public")

            await interaction.response.edit_message(view=self)
            state_str = t(self.lang, "common.enabled") if new_value else t(self.lang, "common.disabled")
            detail = t(self.lang, "meta_management.public_meta_enabled_detail") if new_value else t(self.lang, "meta_management.public_meta_disabled_detail")
            await interaction.followup.send(
                t(self.lang, "meta_management.public_meta_toggled", state=state_str, detail=detail),
                ephemeral=True
            )
        else:
            await interaction.response.send_message(t(self.lang, "common.failed_update_setting"), ephemeral=True)

    @discord.ui.button(label="📋 Update Meta Accounts", style=discord.ButtonStyle.primary, row=1)
    async def update_meta_accounts(self, interaction: discord.Interaction, button: discord.ui.Button):
        """Open the PlayerManagementView for adding/removing players from the meta roster."""
        # Show player management view
        view = PlayerManagementView(self.guild_id, self.squadron_name, self.squadron_clanID, self.lang)
        await view.refresh_display(interaction)

    @discord.ui.button(label="🔑 Change Password", style=discord.ButtonStyle.secondary, row=2)
    async def change_password(self, interaction: discord.Interaction, button: discord.ui.Button):
        """Open the ChangePasswordModal (restricted to the server owner)."""
        if not interaction.guild or interaction.user.id != interaction.guild.owner_id:
            await interaction.response.send_message(
                t(self.lang, "meta_management.owner_only_password"),
                ephemeral=True
            )
            return
        modal = ChangePasswordModal(self.squadron_clanID, self.squadron_name, self.guild_id, self.lang)
        await interaction.response.send_modal(modal)

    @discord.ui.button(label="❓ Help", style=discord.ButtonStyle.secondary, row=3)
    async def show_help(self, interaction: discord.Interaction, button: discord.ui.Button):
        """Display an ephemeral embed explaining each meta management setting."""
        embed = discord.Embed(
            title=t(self.lang, "meta_management.help_title"),
            description=t(self.lang, "meta_management.help_desc"),
            color=discord.Color.blue()
        )

        embed.add_field(
            name=t(self.lang, "meta_management.help_password_field"),
            value=t(self.lang, "meta_management.help_password_value"),
            inline=False
        )

        embed.add_field(
            name=t(self.lang, "meta_management.help_require_field"),
            value=t(self.lang, "meta_management.help_require_value"),
            inline=False
        )

        embed.add_field(
            name=t(self.lang, "meta_management.help_lock_field"),
            value=t(self.lang, "meta_management.help_lock_value"),
            inline=False
        )

        embed.add_field(
            name=t(self.lang, "meta_management.help_public_field"),
            value=t(self.lang, "meta_management.help_public_value"),
            inline=False
        )

        embed.add_field(
            name=t(self.lang, "meta_management.help_accounts_field"),
            value=t(self.lang, "meta_management.help_accounts_value"),
            inline=False
        )

        embed.add_field(
            name=t(self.lang, "meta_management.help_change_pw_field"),
            value=t(self.lang, "meta_management.help_change_pw_value"),
            inline=False
        )

        embed.set_footer(text=DEFAULT_FOOTER_CAT)

        await interaction.response.send_message(embed=embed, ephemeral=True)


@is_blacklisted()
@is_admin()
@gate_entitle("standard")
@bot.tree.command(name='meta-management', description=command_locale('Manage meta data access settings for this server', "commands.meta_management.description"))
async def meta_management(interaction: discord.Interaction):
    """Manage meta data access settings for the guild's squadron.

    Resolves the guild's squadron and clan ID, then determines the setup state:
    first-time setup (generates password and creates guild entry), owner access
    (bypasses or prompts password based on require_password setting), or
    foreign-server access (always requires password). Displays a settings panel
    with toggle buttons for password requirement, data lock, and public meta access.
    """
    await collect_command_stats(interaction)

    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
    guild_id = str(interaction.guild_id)

    try:
        clan = await get_guild_squadron(interaction.guild_id)
    except ValueError as e:
        await interaction.response.defer(ephemeral=True)
        embed = discord.Embed(title=t(lang, "common.error_title"), description=str(e), color=discord.Color.red())
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        return await interaction.followup.send(embed=embed, ephemeral=True)

    squadron_name = clan["long_name"]

    # Get clanID from squadrons.db
    async with aiosqlite.connect(SQUADRONS_DB_PATH) as db:
        cursor = await db.execute("""
            SELECT clan_id FROM squadrons_data
            WHERE LOWER(long_name) = ?
            LIMIT 1
        """, (squadron_name.lower(),))
        row = await cursor.fetchone()

        if not row:
            # Try Players_Global table from Meta.db
            meta_db_path = STORAGE_DIR / "Meta.db"
            async with aiosqlite.connect(meta_db_path) as meta_db:
                cursor = await meta_db.execute("""
                    SELECT DISTINCT clanID FROM Players_Global
                    WHERE LOWER(clanName) = ?
                    LIMIT 1
                """, (squadron_name.lower(),))
                row = await cursor.fetchone()

        if not row or not row[0]:
            await interaction.response.defer(ephemeral=True)
            embed = discord.Embed(
                title=t(lang, "meta_management.squadron_not_found_title"),
                description=t(lang, "meta_management.squadron_not_found_desc", squadron=squadron_name),
                color=discord.Color.red()
            )
            embed.set_footer(text=DEFAULT_FOOTER_CAT)
            await interaction.followup.send(embed=embed, ephemeral=True)
            return

        squadron_clanID = str(row[0])

    # Check if squadron is claimed by ANY server
    squadron_owner = await get_squadron_owner(squadron_clanID)

    # Check if THIS server has settings for this squadron
    guild_settings = await get_guild_settings(guild_id)

    # Determine state:
    # 1. Squadron not claimed by anyone → First time setup
    # 2. Squadron claimed by THIS server → Check require_password setting
    #    - If require_password enabled → Show password modal
    #    - If require_password disabled → Show settings directly
    # 3. Squadron claimed by DIFFERENT server → Require password

    if squadron_owner is None:
        # First time setup - no one has claimed this squadron yet
        is_first_time = True
    elif squadron_owner["guild_id"] == guild_id:
        # This server owns this squadron
        # Check if require_password is enabled
        if squadron_owner.get("require_password", False):
            # Password required even for owner
            hint = squadron_owner.get("password_hint", "")
            modal = PasswordModal(squadron_clanID, squadron_name, guild_id, is_owner=True, password_hint=hint, lang=lang)
            await interaction.response.send_modal(modal)
            return
        else:
            # No password required, proceed to settings
            is_first_time = False
    else:
        # Squadron claimed by a different server - require password
        # Show password modal (can't defer before modal)
        hint = squadron_owner.get("password_hint", "") if squadron_owner else ""
        modal = PasswordModal(squadron_clanID, squadron_name, guild_id, is_owner=False, password_hint=hint, lang=lang)
        await interaction.response.send_modal(modal)
        return

    # Now we can defer for the rest
    await interaction.response.defer(ephemeral=True)

    if is_first_time:
        # First time setup - create guild entry
        password = await create_or_update_guild(
            guild_id=guild_id,
            squadron_clanID=squadron_clanID,
            squadron_name=squadron_name,
            require_password=False,
            lock_squadron_data=False
        )

        # Show password to user (only server owner sees the password)
        is_guild_owner = interaction.guild and interaction.user.id == interaction.guild.owner_id
        if is_guild_owner:
            description = t(lang, "meta_management.first_time_owner_desc",
                            squadron=squadron_name, clan_id=squadron_clanID, password=password)
        else:
            description = t(lang, "meta_management.first_time_non_owner_desc",
                            squadron=squadron_name, clan_id=squadron_clanID)
        embed = discord.Embed(
            title=t(lang, "meta_management.first_time_title"),
            description=description,
            color=discord.Color.blue()
        )
        embed.add_field(
            name=t(lang, "meta_management.settings_field"),
            value=t(lang, "meta_management.settings_hint"),
            inline=False
        )
        embed.set_footer(text=DEFAULT_FOOTER_CAT)

        # Create view with buttons
        view = MetaManagementView(
            guild_id=guild_id,
            squadron_name=squadron_name,
            squadron_clanID=squadron_clanID,
            require_password=False,
            lock_squadron_data=False,
            allow_public_meta=False,
            lang=lang
        )

        view.message = await interaction.followup.send(embed=embed, view=view, ephemeral=True, wait=True)

    else:
        # Already set up - show current settings
        if not guild_settings:
            await interaction.followup.send(
                t(lang, "meta_management.error_retrieving_settings_retry"),
                ephemeral=True
            )
            return

        embed = discord.Embed(
            title=t(lang, "meta_management.settings_title"),
            description=t(lang, "meta_management.settings_desc", squadron=squadron_name, clan_id=squadron_clanID),
            color=discord.Color.blue()
        )
        embed.add_field(
            name=t(lang, "meta_management.password_requirement_field"),
            value=t(lang, "meta_management.enabled_value") if guild_settings["require_password"] else t(lang, "meta_management.disabled_value"),
            inline=True
        )
        embed.add_field(
            name=t(lang, "meta_management.data_lock_field"),
            value=t(lang, "meta_management.enabled_value") if guild_settings["lock_squadron_data"] else t(lang, "meta_management.disabled_value"),
            inline=True
        )
        embed.add_field(
            name=t(lang, "meta_management.public_meta_field"),
            value=t(lang, "meta_management.enabled_value") if guild_settings["allow_public_meta"] else t(lang, "meta_management.disabled_value"),
            inline=True
        )
        if interaction.guild and interaction.user.id == interaction.guild.owner_id:
            embed.add_field(
                name=t(lang, "meta_management.access_password_field"),
                value=f"`{guild_settings['access_password']}`",
                inline=False
            )
        embed.set_footer(text=DEFAULT_FOOTER_CAT)

        # Create view with buttons (set initial states)
        view = MetaManagementView(
            guild_id=guild_id,
            squadron_name=squadron_name,
            squadron_clanID=squadron_clanID,
            require_password=guild_settings["require_password"],
            lock_squadron_data=guild_settings["lock_squadron_data"],
            allow_public_meta=guild_settings["allow_public_meta"],
            lang=lang
        )

        # Update button styles based on current state
        if guild_settings["require_password"]:
            button = view.children[0]
            if isinstance(button, discord.ui.Button):
                button.style = discord.ButtonStyle.success
                button.label = t(lang, "buttons.password_required")

        if guild_settings["lock_squadron_data"]:
            button = view.children[1]
            if isinstance(button, discord.ui.Button):
                button.style = discord.ButtonStyle.success
                button.label = t(lang, "buttons.data_locked")

        if guild_settings["allow_public_meta"]:
            button = view.children[2]
            if isinstance(button, discord.ui.Button):
                button.style = discord.ButtonStyle.success
                button.label = t(lang, "buttons.public_enabled")

        view.message = await interaction.followup.send(embed=embed, view=view, ephemeral=True, wait=True)


@meta_management.error
async def meta_management_error(interaction, error):
    await permission_fail(interaction, error)


async def meta_vehicle_autocomplete(
    interaction: discord.Interaction,
    current: str,
) -> List[discord.app_commands.Choice[str]]:
    """Autocomplete for vehicle names in /meta command. Shows all game vehicles (minus naval)."""
    cache = _utils.game_data_cache
    if not cache:
        return []

    # game_data_cache entries: [cdk, human, icon, misc_params] (only vehicles with icons)
    # Filter out naval vehicles
    vehicles = [
        {"vehicle_name": entry[0], "vehicle_human": entry[1]}
        for entry in cache
        if not entry[3].get("ship", False) and not entry[3].get("boat", False)
    ]

    if not current:
        # Return first 25 vehicles alphabetically by human name
        sorted_vehicles = sorted(vehicles, key=lambda v: (v.get("vehicle_human") or v.get("vehicle_name", "")).lower())
        return [
            discord.app_commands.Choice(
                name=v.get("vehicle_human") or v.get("vehicle_name", "Unknown")[:100],
                value=v.get("vehicle_name", "")
            )
            for v in sorted_vehicles[:25]
        ]

    # Filter vehicles matching the current input
    current_lower = current.lower()
    search_terms = [t for t in current_lower.split() if t]

    matches = []
    for v in vehicles:
        internal = v.get("vehicle_name", "")
        human = v.get("vehicle_human", "") or internal
        combined = f"{internal} {human}".lower()

        # Score the match
        score = 0

        # Check if all search terms appear
        if all(term in combined for term in search_terms):
            score = 10

        # Bonus for exact substring match
        if current_lower in combined:
            score += 15

        # Bonus for starting with search term
        if human.lower().startswith(current_lower) or internal.lower().startswith(current_lower):
            score += 20

        if score > 0:
            matches.append((score, human, internal))

    # Sort by score (descending), then alphabetically
    matches.sort(key=lambda x: (-x[0], x[1].lower()))

    return [
        discord.app_commands.Choice(name=human[:100], value=internal)
        for score, human, internal in matches[:25]
    ]


class MetaResultsView(discord.ui.View):
    """Paginated view for displaying meta vehicle search results across multiple embeds."""

    def __init__(self, pages: list, title: str, total_players: int, lang: str = "en"):
        super().__init__(timeout=300)
        self.message: Optional[discord.Message] = None
        self.pages = pages
        self.title = title
        self.total_players = total_players
        self.current_page = 0
        self.lang = lang
        self.prev_page.label = t(lang, "buttons.prev_arrow")
        self.next_page.label = t(lang, "buttons.next_arrow")

    async def on_timeout(self):
        for item in self.children:
            item.disabled = True  # type: ignore[attr-defined]
        try:
            if self.message:
                await self.message.edit(view=self)
        except Exception:
            pass

    def build_embed(self) -> discord.Embed:
        """Build the embed for the current page of meta search results.

        Returns:
            discord.Embed: Embed containing player-vehicle matches for the current page.
        """
        embed = discord.Embed(
            title=self.title,
            description=t(self.lang, "meta.matches_found", count=self.total_players) + "\n\n"
                       + "\n".join(self.pages[self.current_page]),
            color=discord.Color.green()
        )
        embed.set_footer(text=f"Page {self.current_page + 1}/{len(self.pages)} • {DEFAULT_FOOTER_CAT}")
        return embed

    @discord.ui.button(label="◀ Previous", style=discord.ButtonStyle.secondary)
    async def prev_page(self, interaction: discord.Interaction, button: discord.ui.Button):
        if self.current_page > 0:
            self.current_page -= 1
        await interaction.response.edit_message(embed=self.build_embed(), view=self)

    @discord.ui.button(label="Next ▶", style=discord.ButtonStyle.secondary)
    async def next_page(self, interaction: discord.Interaction, button: discord.ui.Button):
        if self.current_page < len(self.pages) - 1:
            self.current_page += 1
        await interaction.response.edit_message(embed=self.build_embed(), view=self)


@is_blacklisted()
@gate_entitle("standard")
@bot.tree.command(name='meta', description=command_locale('Search squadron meta roster by vehicle name', "commands.meta.description"))
@discord.app_commands.describe(vehicle=command_locale("Vehicle name to search for", "commands.meta.vehicle"))
@discord.app_commands.autocomplete(vehicle=meta_vehicle_autocomplete)
async def meta(interaction: discord.Interaction, vehicle: str):
    """Search the squadron's meta roster for players who own a given vehicle.

    Checks guild settings and permissions (admin or public meta access), resolves
    the vehicle name via autocomplete or partial match, queries the meta database,
    aggregates stats across game modes and vehicle variants, then displays results
    in a paginated embed showing spawns, deaths, and kills per player.
    """
    await collect_command_stats(interaction)

    guild_id = str(interaction.guild_id)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    # Get guild settings
    guild_settings = await get_guild_settings(guild_id)

    if not guild_settings:
        await interaction.response.send_message(
            t(lang, "meta.not_configured"),
            ephemeral=True
        )
        return

    # Check permissions: must be admin OR allow_public_meta must be enabled
    is_admin = isinstance(interaction.user, discord.Member) and interaction.user.guild_permissions.administrator
    allow_public = guild_settings.get("allow_public_meta", False)

    if not is_admin and not allow_public:
        await interaction.response.send_message(
            t(lang, "meta.no_permission"),
            ephemeral=True
        )
        return

    try:
        clan = await get_guild_squadron(interaction.guild_id)
    except ValueError as e:
        return await interaction.response.send_message(str(e), ephemeral=True)

    squadron_name = clan["long_name"]
    # The meta roster is bound to whichever squadron was registered via
    # /meta-management (stored in Guilds.squadron_name). SQUADRONS.json (used by
    # get_guild_squadron) can drift from that, which would make the points API
    # call return members of the wrong squadron and zero uid overlap. Prefer
    # the meta roster's own squadron name for the points lookup.
    points_squadron_name = guild_settings.get("squadron_name") or squadron_name

    await interaction.response.defer(ephemeral=True)

    vehicle_search = vehicle.strip()

    # Translate internal name to human-readable for display
    # If translation succeeds, input is a known internal name (from autocomplete)
    translator = LangTableReader("English")
    vehicle_display = translator.get_translate(vehicle_search)
    is_exact = vehicle_display is not None
    if vehicle_display:
        vehicle_display = normalize_name(vehicle_display)
    else:
        vehicle_display = vehicle_search

    # Search for vehicles matching the input
    results = await search_guild_meta_by_vehicle(guild_id, vehicle_search, exact_only=is_exact)

    if not results:
        msg = t(lang, "meta.no_results", vehicle=vehicle_display)
        if is_admin:
            msg += t(lang, "meta.no_results_admin_hint")
        await interaction.followup.send(msg, ephemeral=True)
        return

    # Group results by player, aggregate by human-readable name to combine variants
    # (e.g., germ_leopard_2a4m and germ_leopard_2a4m_can both display as "Leopard 2A4M")
    translator = LangTableReader("English")
    player_vehicles = {}
    for result in results:
        user_id = result['userID']
        if user_id not in player_vehicles:
            clan_tag = result['clanTag'] or ''
            if len(clan_tag) > 2:
                clan_tag = clan_tag[1:-1]
            player_vehicles[user_id] = {
                'nick': result['nick'],
                'clanTag': clan_tag,
                'vehicles': {}  # Dict keyed by human name to aggregate modes AND variants
            }

        intname = result['intname']
        # Translate to human-readable name for aggregation key
        readable_name = translator.get_translate(intname)
        if readable_name:
            readable_name = normalize_name(readable_name)
        else:
            readable_name = intname

        if readable_name not in player_vehicles[user_id]['vehicles']:
            player_vehicles[user_id]['vehicles'][readable_name] = {
                'readable_name': readable_name,
                'flyouts': 0,
                'deaths': 0,
                'ground_kills': 0,
                'air_kills': 0,
                'games': 0
            }
        # Aggregate stats across modes AND vehicle variants with same display name
        player_vehicles[user_id]['vehicles'][readable_name]['flyouts'] += result['flyouts']
        player_vehicles[user_id]['vehicles'][readable_name]['deaths'] += result['deaths']
        player_vehicles[user_id]['vehicles'][readable_name]['ground_kills'] += result['ground_kills']
        player_vehicles[user_id]['vehicles'][readable_name]['air_kills'] += result['air_kills']
        player_vehicles[user_id]['vehicles'][readable_name]['games'] += result.get('was_in_session', 0)

    # Fetch live squadron points so we can show each player's current rating.
    # Players whose current squadron differs from the guild's squadron won't appear
    # in this map and will fall back to a placeholder.
    # Retry once: obtain_clan_new_points returns ({}, 0) silently on JWT refresh,
    # so a second call after the refresh succeeds.
    points_map: dict[str, int] = {}
    for _ in range(2):
        try:
            sq_members, _total = await obtain_clan_new_points(points_squadron_name)
        except (ClanInfoError, OSError, ValueError) as e:
            logging.warning("meta: obtain_clan_new_points failed for %s: %s", points_squadron_name, e)
            break
        if sq_members:
            points_map = {str(uid): int(info.get("points", 0)) for uid, info in sq_members.items()}
            break
    if not points_map:
        logging.warning("meta: empty points_map for squadron '%s'", points_squadron_name)

    # Build player entries as text lines
    points_lbl = t(lang, "meta.points_label")
    kdr_lbl = t(lang, "meta.kdr_label")
    games_lbl = t(lang, "meta.games_label")
    no_points = t(lang, "meta.no_points")

    all_lines = []
    for user_id, data in player_vehicles.items():
        player_name = esc(str(data['nick']))
        pts = points_map.get(str(user_id))
        pts_str = f"{pts:,}" if pts is not None else no_points

        for veh in data['vehicles'].values():
            readable_name = veh['readable_name']
            kills = veh['ground_kills'] + veh['air_kills']
            deaths = veh['deaths']
            if deaths > 0:
                kdr_str = f"{kills / deaths:.2f}"
            elif kills > 0:
                kdr_str = "∞"
            else:
                kdr_str = "0.00"
            all_lines.append(
                f"**{player_name}** — {readable_name}\n"
                f"└─ {points_lbl}: {pts_str} | {kdr_lbl}: {kdr_str} | "
                f"{games_lbl}: {veh['games']}"
            )

    # Paginate: group lines into pages that fit within embed limits
    pages = []
    current_page_lines = []
    # title + description + footer overhead ~200 chars, keep page content under 5000
    current_size = 0
    for line in all_lines:
        line_size = len(line) + 1  # +1 for newline
        if current_page_lines and current_size + line_size > 4000:
            pages.append(current_page_lines)
            current_page_lines = []
            current_size = 0
        current_page_lines.append(line)
        current_size += line_size
    if current_page_lines:
        pages.append(current_page_lines)

    total_players = len(player_vehicles)

    search_title = t(lang, "meta.search_title", vehicle=vehicle_display)

    if len(pages) == 1:
        # Single page, no pagination needed
        embed = discord.Embed(
            title=search_title,
            description=t(lang, "meta.matches_found", count=total_players) + "\n\n" + "\n".join(pages[0]),
            color=discord.Color.green()
        )
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        await interaction.followup.send(embed=embed, ephemeral=True)
    else:
        # Multiple pages, use pagination view
        view = MetaResultsView(pages, search_title, total_players, lang)
        embed = view.build_embed()
        view.message = await interaction.followup.send(embed=embed, view=view, ephemeral=True, wait=True)


@meta.error
async def meta_error(interaction, error):
    await permission_fail(interaction, error)


@is_blacklisted()
@bot.tree.command(name='top', description=command_locale('Get the top 20 squadrons with detailed stats', "commands.top.description"))
async def top(interaction: discord.Interaction):
    """Display the top 20 squadrons with rating, kills, K/D, win rate, and playtime."""
    await collect_command_stats(interaction)
    await interaction.response.defer()
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    squadron_data = await obtain_clans_leaderboard(start=0, count=20)
    if not squadron_data:
        await interaction.followup.send(t(lang, "top.fetch_failed"), ephemeral=True)
        return

    embed = discord.Embed(title=t(lang, "top.title"), color=discord.Color.purple())

    for idx, squadron in enumerate(squadron_data, start=1):
        ground_kills = squadron.get("g_kills", 0)
        air_kills = squadron.get("a_kills", 0)
        total_kills = ground_kills + air_kills
        deaths = squadron.get("deaths", 1)  # Avoid division by zero
        kd_ratio = round(total_kills / deaths, 2) if deaths else "N/A"

        # Calculate win rate (ensure battles is not zero to avoid division errors)
        wins = squadron.get("wins", 0)
        battles = squadron.get("battles", 0)
        win_rate = round((wins / battles) * 100, 2) if battles else "N/A"

        playtime_minutes = squadron.get("playtime", 0)
        days = playtime_minutes // 1440
        hours = (playtime_minutes % 1440) // 60
        minutes = playtime_minutes % 60
        formatted_playtime = f"{days}d {hours}h {minutes}m"

        short_name = squadron['short_name']
        win_rate_display = "N/A" if win_rate == "N/A" else str(win_rate) + "%"

        embed.add_field(
            name=f"**{idx} - {short_name}**",
            value=(
                f"{t(lang, 'top.rating_label', value=squadron.get('clanrating', 'N/A'))}\n"
                f"{t(lang, 'top.air_kills_label', value=air_kills)}\n"
                f"{t(lang, 'top.ground_kills_label', value=ground_kills)}\n"
                f"{t(lang, 'top.deaths_label', value=deaths)}\n"
                f"{t(lang, 'top.kd_label', value=kd_ratio)}\n"
                f"{t(lang, 'top.win_rate_label', value=win_rate_display)}\n"
                f"{t(lang, 'top.playtime_label', value=formatted_playtime)}\n"
                "\u200b"  # Adds spacing
            ),
            inline=True  # Each squadron appears on a new line
        )

    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    await interaction.followup.send(embed=embed, ephemeral=False)

@top.error
async def top_perm_error(interaction, error):
    await permission_fail(interaction, error)



class LanguageListSelect(discord.ui.Select):
    """Dropdown select for choosing the bot's display language for a server."""

    def __init__(self, lang: str = 'en'):
        self.lang = lang
        # Mapping of displayed language names to canonical stored values
        self.language_mapping = {
            "English": "<English>",
            "Français": "<French>",
            "Italiano": "<Italian>",
            "Deutsch": "<German>",
            "Español": "<Spanish>",
            "Русский": "<Russian>",
            "Polski": "<Polish>",
            "Čeština": "<Czech>",
            "简体中文": "<Chinese>",
            "Português": "<Portuguese>",
            "Українська": "<Ukrainian>",
        }

        options = [
            discord.SelectOption(label=label, value=label)
            for label in self.language_mapping
        ]

        super().__init__(
            placeholder=t(lang, "language.select_placeholder"),
            min_values=1,
            max_values=1,
            options=options
        )

    async def callback(self, interaction: discord.Interaction):
        guild_id = interaction.guild.id # type: ignore
        guild_name = interaction.guild.name # type: ignore
        features = await load_features(guild_id)

        selected_display = self.values[0]
        canonical_value = self.language_mapping.get(selected_display, f"<{selected_display}>")

        features["Language"] = canonical_value
        await save_features(guild_id, features)

        await interaction.response.send_message(t(self.lang, "language.language_set", language=selected_display), ephemeral=True)
        logging.info(f"Guild {guild_name} ({guild_id}) set their language to {canonical_value}")

class LanguageListView(discord.ui.View):
    """View wrapper containing the LanguageListSelect dropdown for /language."""

    def __init__(self, lang: str = 'en'):
        super().__init__()
        self.add_item(LanguageListSelect(lang))


@is_blacklisted()
@is_admin()
@bot.tree.command(name="language",description=command_locale("Change the bot's language.", "commands.language.description"))
async def language(interaction: discord.Interaction):
    """Present a dropdown to change the bot's display language for this server."""
    await collect_command_stats(interaction)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
    view = LanguageListView(lang)
    await interaction.response.send_message(t(lang, "language.prompt"), view=view, ephemeral=True)


@language.error
async def language_error(interaction, error):
    await permission_fail(interaction, error)



# Map target‐codes → (native name, English name)
LANGUAGE_OPTIONS = {
    "RU":      ("Русский",           "Russian"),
    #"EN-US":   ("English (US)",     "English (US)"),
    "EN-GB":   ("English",           "English"),
    "UK":      ("Українська",        "Ukrainian"),
    "ES":      ("Español",           "Spanish"),
    "FR":      ("Français",          "French"),
    "DE":      ("Deutsch",           "German"),
    "ZH-HANS": ("简体中文",           "Chinese"),
    "JA":      ("日本語",             "Japanese"),
    "KO":      ("한국어",             "Korean"),
    "IT":      ("Italiano",          "Italian"),
    "PT-PT":   ("Português",         "Portuguese"),
    #"PT-BR":   ("Português (Brasil)","Portuguese (Brazil)"),
    "PL":      ("Polski",            "Polish"),
    "LT":      ("Lietuvių",          "Lithuanian"),
    "LV":      ("Latviešu",          "Latvian"),
    "ET":      ("Eesti",             "Estonian"),
    #"DA":      ("Dansk",            "Danish"),
    "FI":      ("Suomi",             "Finnish"),
    #"ID":      ("Bahasa Indonesia", "Indonesian"),
    #"NB":      ("Norsk bokmål",     "Norwegian"),
    "NL":      ("Nederlands",        "Dutch"),
    "SV":      ("Svenska",           "Swedish"),
    "CS":      ("Čeština",           "Czech"),
    #"SK":      ("Slovenčina",       "Slovak"),
    #"SL":      ("Slovenščina",      "Slovenian"),
    "RO":      ("Română",            "Romanian"),
    "BG":      ("Български",         "Bulgarian"),
    "EL":      ("Ελληνικά",          "Greek"),
    "HU":      ("Magyar",            "Hungarian"),
    "AR":      ("العربية",          "Arabic"),
    "TR":      ("Türkçe",            "Turkish"),
}

DEEPL_API_KEY = os.environ.get("DEEPL_KEY")
translator = deepl.Translator(DEEPL_API_KEY) if DEEPL_API_KEY else None

def perform_translation(text: str, target_language: str) -> str:
    """Translate text to the target language using the DeepL API.

    Args:
        text: The text to translate.
        target_language: DeepL target language code (e.g. "DE", "FR").

    Returns:
        Translated text, or an error message if translation fails.
    """
    if not translator:
        return "Translation unavailable (DeepL not configured)"
    try:
        result = translator.translate_text(text, target_lang=target_language.upper())
        return result.text # type: ignore
    except Exception as e:
        logging.error(f"Translation failed: {e}")
        return "Translation failed"

# ── UI Components ─────────────────────────────────────────────────────────────

class LanguageSelect(discord.ui.Select):
    """Dropdown for selecting a target language to translate a message via DeepL."""

    def __init__(self, original_message: discord.Message, lang: str = "en"):
        options = [
            discord.SelectOption(
                label=f"{native} ({english})",
                value=code
            )
            for code, (native, english) in LANGUAGE_OPTIONS.items()
        ]
        super().__init__(
            placeholder=t(lang, "language.translate_placeholder"),
            min_values=1, max_values=1,
            options=options
        )
        self._msg = original_message

    async def callback(self, interaction: discord.Interaction):
        target = self.values[0]
        text = self._msg.content
        translated = await asyncio.to_thread(perform_translation, text, target)
        await interaction.response.send_message(
            f"**{self._msg.author.display_name} → {LANGUAGE_OPTIONS[target][1]}:**\n{translated}",
            ephemeral=True
        )
        self.view.stop() # type: ignore


class LanguageView(discord.ui.View):
    """View wrapper containing the LanguageSelect dropdown for the Translate Message context menu."""

    def __init__(self, message: discord.Message, lang: str = "en"):
        super().__init__(timeout=1200)
        self.add_item(LanguageSelect(message, lang=lang))


# ── Replace your TranslatorCog/slash-command with this context menu ─────────

@bot.tree.context_menu(name=command_locale("Translate Message", "commands.translate_message.name"))
async def translate_message(
    interaction: discord.Interaction,
    message: discord.Message
):
    """Right-click any message → Apps → Translate Message."""
    lang = await guild_lang(interaction.guild.id) if interaction.guild else "en"
    view = LanguageView(message, lang=lang)
    await interaction.response.send_message(
        t(lang, "language.translate_prompt"),
        view=view,
        ephemeral=True
    )


@is_blacklisted()
@gate_entitle("standard")
@bot.tree.command(name="sq-track", description=command_locale("Track a squadron and compare stats against the last check", "commands.sq_track.description"))
@app_commands.describe(
    squadron_short_name=command_locale("Short name of the squadron to track", "commands.sq_track.squadron_short_name")
)
@discord.app_commands.autocomplete(squadron_short_name=squadron_autocomplete)
async def track_squadron(
    interaction: discord.Interaction,
    squadron_short_name: str
):
    """Track a squadron's stats and show deltas since the last check.

    Fetches current squadron stats (rating, kills, wins, K/D, etc.), loads
    the previous snapshot from disk, computes and displays diffs for each stat,
    then saves the current snapshot for the next comparison.
    """
    await collect_command_stats(interaction)
    await interaction.response.defer()
    logging.info("Running /sq-track")
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    clan = await resolve_clan(short=squadron_short_name.lower())
    if not clan or clan["long_name"] == "<unresolved>":
        await interaction.followup.send(t(lang, "track.squadron_not_found"), ephemeral=True)
        return

    squadron_long = clan["long_name"]

    # Now fetch full clan info from your API/db using the long name
    clan_data = await obtain_clan_info_api(squadron_long, "clanName")
    if not clan_data:
        await interaction.followup.send(t(lang, "track.fetch_failed"), ephemeral=True)
        return

    # Extract stats
    clan_tag = clan_data.get("tag", squadron_short_name)
    clan_tag = clan_tag[1:-1]

    clan_long = clan_data.get("tag", squadron_short_name.upper())
    astat = clan_data.get("astat", {})
    points       = int(astat.get("dr_era5_hist", 0))
    ground_kills = int(astat.get("gkills_hist", 0))
    air_kills    = int(astat.get("akills_hist", 0))
    deaths       = int(astat.get("deaths_hist", 0))

    battles = int(astat.get("battles_hist", 0))
    wins    = int(astat.get("wins_hist", 0))
    members = clan_data.get("members", [])
    member_count = len(members) if isinstance(members, (list, dict)) else int(members or 0)

    total_kills = ground_kills + air_kills
    kd_ratio = total_kills / deaths if deaths > 0 else total_kills
    kd_ratio_percentage = f"{kd_ratio:.2f}"

    losses = battles - wins
    win_rate = (wins / battles) * 100 if battles > 0 else 0
    win_rate_percentage = f"{win_rate:.2f}%"

    # --- Placement ---
    placement, _ = await get_current_squadron_placement(squadron_long, squadron_short_name)

    # --- Snapshot tracking ---
    guild_id = str(interaction.guild_id if interaction.guild_id else interaction.user.id)
    sq_key = clan_tag.upper()
    tracks_dir = STORAGE_DIR / "TRACKS" / guild_id
    tracks_dir.mkdir(parents=True, exist_ok=True)
    snapshot_path = tracks_dir / f"{sq_key}.json"

    current_snapshot = {
        "placement": placement,
        "points": points,
        "members": member_count,
        "battles": battles,
        "wins": wins,
        "losses": losses,
        "total_kills": total_kills,
        "ground_kills": ground_kills,
        "air_kills": air_kills,
        "deaths": deaths,
        "kd_ratio": kd_ratio,
        "win_rate": win_rate,
    }

    # Load previous snapshot if it exists
    prev = await load_json(snapshot_path, None)

    # Save current snapshot for next time
    await write_json(snapshot_path, current_snapshot)

    # Helper to format a value with its diff
    def _diff(label, cur, old_val, fmt="d", suffix=""):
        """Return display string.  fmt='d' for int, 'f2' for 2-decimal float."""
        if fmt == "f2":
            display = f"{cur:.2f}{suffix}"
        else:
            display = f"{cur:,}{suffix}"

        if old_val is None:
            return display
        delta = cur - old_val
        if delta == 0:
            return display
        if fmt == "f2":
            sign = "+" if delta > 0 else ""
            return f"{display} ({sign}{delta:.2f})"
        else:
            sign = "+" if delta > 0 else ""
            return f"{display} ({sign}{delta:,})"

    p = prev or {}

    # Format placement diff (lower rank = better, so invert sign display)
    if placement is not None:
        placement_str = f"#{placement}"
        old_placement = p.get("placement")
        if old_placement is not None:
            delta = placement - old_placement
            if delta != 0:
                # negative delta = moved up = good, show as positive
                sign = "+" if delta < 0 else ""
                placement_str += f" ({sign}{-delta:,})"
    else:
        placement_str = "N/A"

    # Build embed
    embed = discord.Embed(title=f"**{clan_tag}**", color=discord.Color.green())
    embed.add_field(name=t(lang, "common.placement_field"),    value=placement_str,                                            inline=True)
    embed.add_field(name=t(lang, "common.points_field"),       value=_diff("Points",       points,       p.get("points")),       inline=True)
    embed.add_field(name=t(lang, "common.members_field"),      value=_diff("Members",      member_count, p.get("members")),      inline=True)
    embed.add_field(name=t(lang, "common.win_rate_field"),     value=_diff("Win Rate",     win_rate,     p.get("win_rate"),     fmt="f2", suffix="%"), inline=True)

    embed.add_field(name=t(lang, "common.battles_field"),      value=_diff("Battles",      battles,      p.get("battles")),      inline=True)
    embed.add_field(name=t(lang, "common.wins_field"),         value=_diff("Wins",         wins,         p.get("wins")),         inline=True)
    embed.add_field(name=t(lang, "common.losses_field"),       value=_diff("Losses",       losses,       p.get("losses")),       inline=True)

    embed.add_field(name=t(lang, "common.total_kills_field"),  value=_diff("Total Kills",  total_kills,  p.get("total_kills")),  inline=True)
    embed.add_field(name=t(lang, "common.ground_kills_field"), value=_diff("Ground Kills", ground_kills, p.get("ground_kills")), inline=True)
    embed.add_field(name=t(lang, "common.air_kills_field"),    value=_diff("Air Kills",    air_kills,    p.get("air_kills")),    inline=True)

    embed.add_field(name=t(lang, "common.deaths_field"),       value=_diff("Deaths",       deaths,       p.get("deaths")),       inline=True)
    embed.add_field(name=t(lang, "common.kd_field"),           value=_diff("KD Ratio",     kd_ratio,     p.get("kd_ratio"),     fmt="f2"), inline=True)

    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    await interaction.followup.send(embed=embed, ephemeral=False)


@track_squadron.error
async def track_perm_error(interaction, error):
    await permission_fail(interaction, error)


# ============================================================================
# ============================================================================
# /analytics — Advanced SQB analytics
# ============================================================================

CONSISTENCY_PAGE_SIZE = 10
MAP_PAGE_SIZE = 15


def _build_map_embed(data: list[dict], sq_short: str, page: int, lang: str = 'en') -> Embed:
    """Build one page of the map win-rates embed with visual bar charts.

    Args:
        data: List of map stat dicts with 'map_name', 'wins', 'losses', 'win_rate'.
        sq_short: Squadron short name for the embed title.
        page: Zero-indexed page number.
        lang: Locale code for translations.

    Returns:
        Embed displaying map win rates for the requested page.
    """
    total_pages = math.ceil(len(data) / MAP_PAGE_SIZE)
    start = page * MAP_PAGE_SIZE
    page_data = data[start:start + MAP_PAGE_SIZE]
    lines = []
    for r in page_data:
        filled = max(1, round(r["win_rate"] / 10))
        bar = "\u2588" * filled + "\u2591" * (10 - filled)
        lines.append(f"**{esc(r['map_name'])}** — {r['wins']}W / {r['losses']}L ({r['win_rate']}%)\n> {bar}")
    embed = Embed(title=t(lang, "analytics.map_title", squadron=sq_short), description="\n".join(lines), color=Color.green())
    embed.set_footer(text=f"Page {page + 1}/{total_pages} • {len(data)} maps • {DEFAULT_FOOTER_CAT}")
    return embed


class MapStatsPaginatorView(discord.ui.View):
    """Paginated view for browsing map win-rate statistics with Prev/Next buttons."""

    def __init__(self, data: list[dict], sq_short: str, page: int = 0, lang: str = 'en'):
        super().__init__(timeout=300)
        self.data = data
        self.sq_short = sq_short
        self.page = page
        self.lang = lang
        self.total_pages = math.ceil(len(data) / MAP_PAGE_SIZE)
        self._update_buttons()
        self.prev_btn.label = t(lang, "buttons.prev")
        self.next_btn.label = t(lang, "buttons.next")

    def _update_buttons(self):
        self.prev_btn.disabled = self.page <= 0
        self.next_btn.disabled = self.page >= self.total_pages - 1

    @discord.ui.button(label="Prev", style=discord.ButtonStyle.grey)
    async def prev_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
        self.page = max(0, self.page - 1)
        self._update_buttons()
        await interaction.response.edit_message(embed=_build_map_embed(self.data, self.sq_short, self.page, self.lang), view=self)

    @discord.ui.button(label="Next", style=discord.ButtonStyle.grey)
    async def next_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
        self.page = min(self.total_pages - 1, self.page + 1)
        self._update_buttons()
        await interaction.response.edit_message(embed=_build_map_embed(self.data, self.sq_short, self.page, self.lang), view=self)


def _build_consistency_embed(data: list[dict], sq_short: str, page: int, lang: str = 'en') -> Embed:
    """Build one page of the consistency embed."""
    total_pages = math.ceil(len(data) / CONSISTENCY_PAGE_SIZE)
    start = page * CONSISTENCY_PAGE_SIZE
    end = start + CONSISTENCY_PAGE_SIZE
    page_data = data[start:end]

    # Scale bars relative to the full dataset range
    all_scores = [r["consistency_score"] for r in data]
    lo, hi = min(all_scores), max(all_scores)
    spread = hi - lo if hi > lo else 1

    lines = []
    for i, r in enumerate(page_data, start + 1):
        nick = esc(r["nick"])
        normalized = 1 - (r["consistency_score"] - lo) / spread
        filled = max(1, round(normalized * 10))
        bar = "\u2588" * filled + "\u2591" * (10 - filled)
        lines.append(
            f"**{i}.** {nick} — {r['avg_kills']} avg kills / {r['avg_deaths']} avg deaths "
            f"({r['games']} matches)\n> {bar}"
        )

    embed = Embed(
        title=t(lang, "analytics.consistency_title", squadron=sq_short),
        description=t(lang, "analytics.consistency_desc") + "\n\n" + "\n".join(lines),
        color=Color.green(),
    )
    embed.set_footer(text=f"Page {page + 1}/{total_pages} • {len(data)} players • {DEFAULT_FOOTER_CAT}")
    return embed


class ConsistencyPaginatorView(discord.ui.View):
    """Paginated view for browsing player consistency rankings with Prev/Next buttons."""

    def __init__(self, data: list[dict], sq_short: str, page: int = 0, lang: str = 'en'):
        super().__init__(timeout=300)
        self.data = data
        self.sq_short = sq_short
        self.page = page
        self.lang = lang
        self.total_pages = math.ceil(len(data) / CONSISTENCY_PAGE_SIZE)
        self._update_buttons()
        self.prev_btn.label = t(lang, "buttons.prev")
        self.next_btn.label = t(lang, "buttons.next")

    def _update_buttons(self):
        self.prev_btn.disabled = self.page <= 0
        self.next_btn.disabled = self.page >= self.total_pages - 1

    @discord.ui.button(label="Prev", style=discord.ButtonStyle.grey)
    async def prev_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
        self.page = max(0, self.page - 1)
        self._update_buttons()
        await interaction.response.edit_message(
            embed=_build_consistency_embed(self.data, self.sq_short, self.page, self.lang),
            view=self,
        )

    @discord.ui.button(label="Next", style=discord.ButtonStyle.grey)
    async def next_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
        self.page = min(self.total_pages - 1, self.page + 1)
        self._update_buttons()
        await interaction.response.edit_message(
            embed=_build_consistency_embed(self.data, self.sq_short, self.page, self.lang),
            view=self,
        )


@is_blacklisted()
@gate_entitle("standard")
@bot.tree.command(name="analytics", description=command_locale("View advanced SQB analytics for a squadron", "commands.analytics.description"))
@app_commands.describe(
    squadron=command_locale("Squadron short name", "commands.common.squadron_short"),
    view=command_locale("Which analytics view to show", "commands.analytics.view")
)
@app_commands.choices(view=[
    app_commands.Choice(name=command_locale("Map Win Rates", "commands.analytics.choice_maps"), value="maps"),
    app_commands.Choice(name=command_locale("Team Compositions", "commands.analytics.choice_comps"), value="comps"),
    app_commands.Choice(name=command_locale("Player Consistency", "commands.analytics.choice_consistency"), value="consistency"),
    app_commands.Choice(name=command_locale("Time of Day", "commands.analytics.choice_time"), value="time"),
    app_commands.Choice(name=command_locale("Matchup History", "commands.analytics.choice_matchups"), value="matchups"),
])
@discord.app_commands.autocomplete(squadron=squadron_autocomplete)
async def analytics_cmd(interaction: discord.Interaction, view: str = "maps", squadron: str = ""):
    """Display advanced SQB analytics for a squadron.

    Supports four views: map win rates (paginated), team compositions (top 10),
    player consistency (paginated, sorted by K/D), and time-of-day performance
    with EU/NA timeslot dividers and Discord-localized timestamps.
    """
    await collect_command_stats(interaction)
    await interaction.response.defer(ephemeral=False)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    try:
        clan = await get_guild_squadron(interaction.guild_id, squadron)
    except ValueError as e:
        return await interaction.followup.send(
            embed=Embed(title=t(lang, "common.error_title"), description=str(e), color=Color.red())
        )
    sq_short = clan["short_name"]
    sq_long = clan["long_name"]

    if view == "maps":
        data = await get_map_stats(sq_short)
        if not data:
            return await interaction.followup.send(
                embed=Embed(title=t(lang, "analytics.no_data_title"), description=t(lang, "analytics.no_matches_desc"), color=Color.blue())
            )
        embed = _build_map_embed(data, sq_short, 0, lang)
        paginator = MapStatsPaginatorView(data, sq_short, lang=lang)
        return await interaction.followup.send(embed=embed, view=paginator)

    elif view == "comps":
        data = await get_comp_analysis(sq_short)
        if not data:
            return await interaction.followup.send(
                embed=Embed(title=t(lang, "analytics.no_data_title"), description=t(lang, "analytics.no_comp_desc"), color=Color.blue())
            )
        lines = []
        for r in data[:10]:
            filled = max(1, round(r["win_rate"] / 10))
            bar = "\u2588" * filled + "\u2591" * (10 - filled)
            lines.append(f"`{r['comp_signature']}` — {r['wins']}W / {r['losses']}L ({r['win_rate']}%) [{r['total']} games]\n> {bar}")
        embed = Embed(title=t(lang, "analytics.comp_title", squadron=sq_short), description="\n".join(lines), color=Color.green())

    elif view == "consistency":
        data = await get_player_consistency(sq_short)
        if not data:
            return await interaction.followup.send(
                embed=Embed(title=t(lang, "analytics.no_data_title"), description=t(lang, "analytics.no_consistency_desc"), color=Color.blue())
            )
        embed = _build_consistency_embed(data, sq_short, 0, lang)
        paginator = ConsistencyPaginatorView(data, sq_short, lang=lang)
        return await interaction.followup.send(embed=embed, view=paginator)

    elif view == "time":
        data = await get_time_performance(sq_short)
        if not data:
            return await interaction.followup.send(
                embed=Embed(title=t(lang, "analytics.no_data_title"), description=t(lang, "analytics.no_time_desc"), color=Color.blue())
            )
        # Use a reference date (epoch day 0) to build Discord timestamps for each hour
        # Discord <t:UNIX:t> renders in the viewer's local timezone
        ref = datetime(2025, 1, 1, tzinfo=timezone.utc)

        # SQB timeslot regions (from tasks.py: EU 13:55-22:10, NA 00:55-07:10 UTC)
        eu_hours = set(range(14, 23))   # 14:00-22:00 UTC
        na_hours = set(range(1, 8))     # 01:00-07:00 UTC

        lines = []
        prev_region = None
        for hour in range(24):
            if hour not in data:
                continue
            stats = data[hour]

            # Insert region dividers
            if hour in eu_hours:
                region = "eu"
            elif hour in na_hours:
                region = "na"
            else:
                region = "off"

            if region != prev_region:
                if region == "eu":
                    lines.append(t(lang, "analytics.eu_timeslot"))
                elif region == "na":
                    lines.append(t(lang, "analytics.na_timeslot"))
                elif prev_region is not None:
                    lines.append(t(lang, "analytics.off_peak"))
                prev_region = region

            ts = int(ref.replace(hour=hour).timestamp())
            filled = max(1, round(stats["win_rate"] / 10))
            bar = "\u2588" * filled + "\u2591" * (10 - filled)
            lines.append(f"<t:{ts}:t> {stats['wins']}W / {stats['losses']}L ({stats['win_rate']}%)\n> {bar}")
        embed = Embed(title=t(lang, "analytics.time_title", squadron=sq_short), description="\n".join(lines), color=Color.green())

    elif view == "matchups":
        data = await get_matchup_history(sq_short)
        if not data["won_against"] and not data["lost_against"]:
            return await interaction.followup.send(
                embed=Embed(title=t(lang, "analytics.no_data_title"), description=t(lang, "analytics.no_matchups_desc"), color=Color.blue())
            )

        def _fmt(entries: list[dict]) -> str:
            if not entries:
                return "—"
            return "\n".join(
                f"**{esc(e['opponent'])}** — {e['wins']}W / {e['losses']}L ({e['total']} games)"
                for e in entries
            )

        embed = Embed(
            title=t(lang, "analytics.matchups_title", squadron=sq_short),
            color=Color.green(),
        )
        embed.add_field(name=t(lang, "analytics.matchups_won_field"), value=_fmt(data["won_against"]), inline=True)
        embed.add_field(name=t(lang, "analytics.matchups_lost_field"), value=_fmt(data["lost_against"]), inline=True)
        embed.set_footer(text=f"{data['total_opponents']} unique opponents • {DEFAULT_FOOTER_CAT}")
        return await interaction.followup.send(embed=embed)

    else:
        return await interaction.followup.send(t(lang, "analytics.unknown_view"), ephemeral=True)

    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    await interaction.followup.send(embed=embed)


# /recent — Last 5 matches for a squadron
# ============================================================================

@is_blacklisted()
@bot.tree.command(
    name="recent",
    description=command_locale("Show recent squadron battles for a squadron", "commands.recent.description")
)
@app_commands.describe(
    squadron=command_locale("Short name of the squadron", "commands.common.squadron_short"),
    length=command_locale("Number of matches to show", "commands.recent.length")
)
@discord.app_commands.autocomplete(squadron=squadron_autocomplete)
async def recent(interaction: discord.Interaction, squadron: str = "", length: int = 10):
    """Show recent SQB match history for a squadron.

    Queries match_summary from SQLite for the squadron's recent wins/losses,
    formats each match with opponent, map, and relative timestamp, then splits
    results across multiple embeds if the content exceeds Discord's character limit.
    """
    await collect_command_stats(interaction)
    await interaction.response.defer(ephemeral=False)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
    length = max(1, min(length, 100))

    # Resolve squadron
    try:
        clan = await get_guild_squadron(interaction.guild_id, squadron)
    except ValueError as e:
        return await interaction.followup.send(
            embed=Embed(title=t(lang, "common.error_title"), description=str(e), color=Color.red())
        )
    squadron_long = clan["long_name"]
    squadron_short = clan["short_name"]
    clan_id_val = clan.get("clan_id") if isinstance(clan, dict) else None

    # Prefer clan_id so post-rename history stays attached. Fall back to
    # short_name lookup for any rows that didn't backfill (orphans).
    async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db:
        await db.execute("PRAGMA busy_timeout=30000;")
        if clan_id_val:
            async with db.execute(
                """SELECT session_id, map_name, endtime_unix, winning_sq, losing_sq
                   FROM match_summary
                   WHERE winning_clan_id = ? OR losing_clan_id = ?
                      OR ((winning_clan_id IS NULL OR losing_clan_id IS NULL)
                          AND (winning_sq = ? OR losing_sq = ?))
                   ORDER BY endtime_unix DESC
                   LIMIT ?""",
                (clan_id_val, clan_id_val, squadron_short, squadron_short, length)
            ) as cursor:
                rows = await cursor.fetchall()
        else:
            async with db.execute(
                """SELECT session_id, map_name, endtime_unix, winning_sq, losing_sq
                   FROM match_summary
                   WHERE winning_sq = ? OR losing_sq = ?
                   ORDER BY endtime_unix DESC
                   LIMIT ?""",
                (squadron_short, squadron_short, length)
            ) as cursor:
                rows = await cursor.fetchall()

    if not rows:
        embed = Embed(
            title=t(lang, "recent.title", squadron=squadron_short),
            description=t(lang, "recent.no_matches_desc"),
            color=Color.blue()
        )
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        return await interaction.followup.send(embed=embed)

    lines = []
    for session_id, map_name, endtime_unix, winning_sq, losing_sq in rows:
        is_win = (winning_sq == squadron_short)
        opponent = losing_sq if is_win else winning_sq
        if is_win:
            entry = f"\U0001f451 **{squadron_short}** vs {opponent} \U0001f494"
        else:
            entry = f"\U0001f494 **{squadron_short}** vs {opponent} \U0001f451"
        ts = f"<t:{endtime_unix}:R>" if endtime_unix else "Unknown"
        entry += f"\n> **Map:** {map_name or 'Unknown'} | {ts}"
        lines.append(entry)

    # Split into multiple embeds if description exceeds 4096 chars
    embeds = []
    current_lines: list[str] = []
    current_len = 0
    for line in lines:
        if current_len + len(line) + 1 > 4000 and current_lines:
            embed = Embed(
                title=t(lang, "recent.title", squadron=squadron_short) if not embeds else "",
                description="\n".join(current_lines),
                color=Color.blue()
            )
            embeds.append(embed)
            current_lines = []
            current_len = 0
        current_lines.append(line)
        current_len += len(line) + 1

    if current_lines:
        embed = Embed(
            title=t(lang, "recent.title", squadron=squadron_short) if not embeds else "",
            description="\n".join(current_lines),
            color=Color.blue()
        )
        embeds.append(embed)

    embeds[-1].set_footer(text=DEFAULT_FOOTER_CAT)
    await interaction.followup.send(embeds=embeds)


@recent.error
async def recent_error(interaction, error):
    await permission_fail(interaction, error)


# ============================================================================
# /vs — Head-to-head record against another squadron
# ============================================================================

@is_blacklisted()
@bot.tree.command(
    name="vs",
    description=command_locale("Head-to-head record between two squadrons", "commands.vs.description")
)
@app_commands.describe(
    squadron_a=command_locale("First squadron", "commands.vs.squadron_a"),
    squadron_b=command_locale("Second squadron", "commands.vs.squadron_b")
)
@discord.app_commands.autocomplete(squadron_a=squadron_autocomplete, squadron_b=squadron_autocomplete)
async def vs(interaction: discord.Interaction, squadron_a: str = "", squadron_b: str = ""):
    """Display the head-to-head SQB record between two squadrons.

    Resolves both squadron names (falling back to the server default), queries
    match_summary for all games between them, computes the win/loss record, and
    shows the most recent 5 encounters with map and timestamp details.
    """
    await collect_command_stats(interaction)
    await interaction.response.defer(ephemeral=False)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    # Fill in blanks from server default
    if not squadron_a or not squadron_b:
        if not squadron_a and not squadron_b:
            return await interaction.followup.send(
                embed=Embed(
                    title=t(lang, "h2h.two_required_title"),
                    description=t(lang, "h2h.two_required_desc"),
                    color=Color.red()
                )
            )
        try:
            default_clan = await get_guild_squadron(interaction.guild_id)
            default_short = default_clan["short_name"]
        except ValueError:
            default_short = ""

        if not squadron_a:
            if not default_short:
                return await interaction.followup.send(
                    embed=Embed(title=t(lang, "h2h.two_required_title"),
                                description=t(lang, "h2h.provide_a_desc"),
                                color=Color.red())
                )
            squadron_a = default_short
        elif not squadron_b:
            if not default_short:
                return await interaction.followup.send(
                    embed=Embed(title=t(lang, "h2h.two_required_title"),
                                description=t(lang, "h2h.provide_b_desc"),
                                color=Color.red())
                )
            squadron_b = default_short

    # Resolve squadron A
    try:
        clan_a = await get_guild_squadron(interaction.guild_id, squadron_a)
    except ValueError as e:
        return await interaction.followup.send(
            embed=Embed(title=t(lang, "h2h.squadron_not_found_title"), description=str(e), color=Color.red())
        )
    a_long = clan_a["long_name"]
    a_short = clan_a["short_name"]

    # Resolve squadron B
    try:
        clan_b = await get_guild_squadron(interaction.guild_id, squadron_b)
    except ValueError as e:
        return await interaction.followup.send(
            embed=Embed(title=t(lang, "h2h.squadron_not_found_title"), description=str(e), color=Color.red())
        )
    b_long = clan_b["long_name"]
    b_short = clan_b["short_name"]

    if a_short == b_short:
        return await interaction.followup.send(
            embed=Embed(
                title=t(lang, "h2h.same_squadron_title"),
                description=t(lang, "h2h.same_squadron_desc"),
                color=Color.orange()
            )
        )

    # Query head-to-head matches (match_summary stores short squadron tags)
    async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db:
        await db.execute("PRAGMA busy_timeout=30000;")
        async with db.execute(
            """SELECT session_id, map_name, endtime_unix, winning_sq, losing_sq
               FROM match_summary
               WHERE (winning_sq = ? AND losing_sq = ?)
                  OR (winning_sq = ? AND losing_sq = ?)
               ORDER BY endtime_unix DESC""",
            (a_short, b_short, b_short, a_short)
        ) as cursor:
            all_matches = await cursor.fetchall()

    all_matches = list(all_matches)
    a_wins = sum(1 for r in all_matches if r[3] == a_short)
    b_wins = sum(1 for r in all_matches if r[3] == b_short)
    total = len(all_matches)

    if a_wins > b_wins:
        color = Color.green()
    elif b_wins > a_wins:
        color = Color.red()
    else:
        color = Color.gold()

    embed = Embed(
        title=f"{a_short} vs {b_short}",
        description=t(lang, "h2h.record_desc", a_wins=a_wins, b_wins=b_wins, total=total),
        color=color
    )

    if not all_matches:
        embed.description = t(lang, "h2h.no_matches_desc", a=a_short, b=b_short)
    else:
        for session_id, map_name, endtime_unix, winning_sq, losing_sq in all_matches[:5]:
            a_won = (winning_sq == a_short)
            if a_won:
                title = f"\U0001f451 {a_short} vs {b_short} \U0001f494"
            else:
                title = f"\U0001f494 {a_short} vs {b_short} \U0001f451"
            ts = f"<t:{endtime_unix}:R>" if endtime_unix else "Unknown"

            embed.add_field(
                name=title,
                value=f"**Map:** {map_name or 'Unknown'} | {ts}",
                inline=False
            )

    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    await interaction.followup.send(embed=embed)


@vs.error
async def vs_error(interaction, error):
    await permission_fail(interaction, error)


class NotificationTypeSelect(discord.ui.Select):
    """Dropdown to choose a notification management category."""

    def __init__(self, lang: str = 'en'):
        self.lang = lang
        options = [
            discord.SelectOption(label=t(lang, "autolog.logs_option"),        description=t(lang, "autolog.logs_option_desc")),
            discord.SelectOption(label=t(lang, "autolog.points_option"),      description=t(lang, "autolog.points_option_desc")),
            discord.SelectOption(label="Reports", description="Manage leaderboard and Weekly BR report routes."),
        ]
        super().__init__(
            placeholder=t(lang, "autolog.select_notif_placeholder"),
            min_values=1,
            max_values=1,
            options=options
        )

    async def callback(self, interaction: discord.Interaction):
        notif_type = self.values[0]
        guild_id = interaction.guild.id  # type: ignore

        # Proceed to Step 2: Squadron selection.
        view = await create_squadron_select_view(guild_id, notif_type, self.lang)

        header = t(self.lang, "autolog.selected_type", type=_notif_type_label(notif_type))
        # Append a tier-cap usage header so the user knows their limits upfront.
        # Skip for Free users (no tier) and for uncapped categories.
        if tier_enforcement_active() and notif_type not in ("Leaderboard", REPORTS_NOTIF_TYPE):
            tier = await get_guild_tier(guild_id)
            if tier is not None:
                prefs = await load_guild_preferences(guild_id)
                used = len(enabled_non_wildcard_keys_for(prefs, notif_type))
                cap = tier_cap(tier, notif_type)
                cap_str = "∞" if cap is None else str(cap)
                header += "\n" + t(self.lang, "autolog.cap_header",
                                   used=used, cap=cap_str, notif=_notif_type_label(notif_type), tier=tier.title())

        await interaction.response.send_message(header, view=view, ephemeral=True)


class NotificationManagementView(discord.ui.View):
    """Top-level view containing the notification type selector."""

    def __init__(self, lang: str = 'en'):
        super().__init__(timeout=1200)
        self.message: Optional[discord.Message] = None
        self.add_item(NotificationTypeSelect(lang))

    async def on_timeout(self):
        for item in self.children:
            item.disabled = True  # type: ignore[attr-defined]
        try:
            if self.message:
                await self.message.edit(view=self)
        except Exception:
            pass


# Basic (non-paginated) squadron dropdown (if 25 or fewer squadrons)
class SquadronSelect(discord.ui.Select):
    """Dropdown listing squadrons with a specific notification type configured (<=25)."""

    def __init__(self, guild_id, notif_type, option_rows, lang: str = 'en',
                 over_cap_keys: Optional[set[str]] = None,
                 wildcard_blocked: bool = False):
        self.guild_id = guild_id
        self.notif_type = notif_type
        self.option_rows = option_rows
        self.lang = lang
        enabled_label = t(lang, "common.enabled")
        disabled_label = t(lang, "common.disabled")
        over_cap_keys = over_cap_keys or set()
        options = []
        for row in option_rows:
            squadron = row["squadron"]
            settings = row["settings"]
            actual_notif_type = row["notif_type"]
            channel_val = settings[actual_notif_type]
            is_disabled = channel_val.startswith("<#DISABLED-")
            if is_disabled:
                prefix = "❌"
                state = disabled_label
            elif wildcard_blocked and squadron.lower() in WILDCARD_KEYS:
                prefix = "🚫"
                state = enabled_label
            elif squadron in over_cap_keys:
                prefix = "⚠️"
                state = enabled_label
            else:
                prefix = "✅"
                state = enabled_label
            display_label = settings.get("Long") if isinstance(settings, dict) else None
            display_label = display_label or squadron
            type_suffix = f" [{_notif_type_label(actual_notif_type)}]" if notif_type == REPORTS_NOTIF_TYPE else ""
            options.append(
                discord.SelectOption(
                    label=f"{prefix} {display_label}{type_suffix}",
                    value=_encode_management_value(squadron, actual_notif_type),
                    description=f"{_notif_type_label(actual_notif_type)} • {state}: {channel_val}"))
        if not options:
            options.append(
                discord.SelectOption(label=t(lang, "common.none_option"),
                                     description=t(lang, "autolog.no_squadrons_desc"),
                                     value="none"))
        super().__init__(placeholder=t(lang, "autolog.select_squadron_placeholder"),
                         min_values=1,
                         max_values=1,
                         options=options)

    async def callback(self, interaction: discord.Interaction):
        selected_value = self.values[0]
        if selected_value == "none":
            await interaction.response.send_message(
                t(self.lang, "autolog.no_squadrons_available"),
                ephemeral=True)
            return
        selected_notif_type, selected_squadron = _decode_management_value(selected_value)

        # Retrieve the current channel value for the selected notification type.
        preferences = await load_guild_preferences(self.guild_id)
        squadron_settings = preferences.get(selected_squadron, {})
        channel_value = squadron_settings.get(selected_notif_type, "Not configured")
        if channel_value != "Not configured":
            # Check if the value is wrapped in <#...>
            if channel_value.startswith("<#") and channel_value.endswith(">"):
                channel_id_str = channel_value[2:-1]
            else:
                channel_id_str = channel_value
            # Remove "DISABLED-" if present.
            if channel_id_str.startswith("DISABLED-"):
                channel_id_str = channel_id_str[len("DISABLED-"):]
            try:
                channel_id = int(channel_id_str)
                channel = interaction.guild.get_channel(channel_id) # type: ignore
                channel_name = channel.name if channel else t(self.lang, "common.unknown")
            except ValueError:
                channel_name = t(self.lang, "common.unknown")
        else:
            channel_name = t(self.lang, "common.not_configured")

        # Proceed to Step 3: Display toggle and change channel buttons.
        view = ToggleView(self.guild_id, selected_notif_type, selected_squadron, channel_value, self.lang)
        # Display name comes from the entry's cached "Long" (set by /quick_log
        # and refreshed by the autolog Short/Long sync). Falls back to the key
        # itself for orphan / pre-migration entries.
        display_squadron = (
            squadron_settings.get("Long")
            if isinstance(squadron_settings, dict) else None
        ) or selected_squadron
        # Special messaging for Global (Leaderboard)
        if selected_squadron == "Global":
            message = t(self.lang, "autolog.managing_global", type=_notif_type_label(selected_notif_type), channel=channel_name)
        else:
            message = t(self.lang, "autolog.managing_squadron", type=_notif_type_label(selected_notif_type), squadron=display_squadron, channel=channel_name)
        await interaction.response.send_message(
            message,
            view=view,
            ephemeral=True)


# New classes for paginated squadron selection (> 25 squadrons, somehow.)
class PaginatedSquadronSelect(discord.ui.Select):
    """Paginated squadron dropdown for servers tracking more than 25 squadrons."""

    def __init__(self, guild_id, notif_type, option_rows, page=0, lang: str = 'en',
                 over_cap_keys: Optional[set[str]] = None, wildcard_blocked: bool = False):
        self.guild_id = guild_id
        self.notif_type = notif_type
        self.option_rows = option_rows
        self.page = page
        self.lang = lang
        self.over_cap_keys = over_cap_keys or set()
        self.wildcard_blocked = wildcard_blocked
        options = self.get_options(page)
        super().__init__(placeholder=t(lang, "autolog.select_squadron_page_placeholder", page=page+1),
                         min_values=1,
                         max_values=1,
                         options=options)

    def get_options(self, page):
        start = page * 25
        end = start + 25
        options = []
        enabled_label = t(self.lang, "common.enabled")
        disabled_label = t(self.lang, "common.disabled")
        for row in self.option_rows[start:end]:
            squadron = row["squadron"]
            settings = row["settings"]
            actual_notif_type = row["notif_type"]
            channel_val = settings[actual_notif_type]
            is_disabled = channel_val.startswith("<#DISABLED-")
            if is_disabled:
                prefix = "❌"
                state = disabled_label
            elif self.wildcard_blocked and squadron.lower() in WILDCARD_KEYS:
                prefix = "🚫"
                state = enabled_label
            elif squadron in self.over_cap_keys:
                prefix = "⚠️"
                state = enabled_label
            else:
                prefix = "✅"
                state = enabled_label
            display_label = settings.get("Long") if isinstance(settings, dict) else None
            display_label = display_label or squadron
            type_suffix = f" [{_notif_type_label(actual_notif_type)}]" if self.notif_type == REPORTS_NOTIF_TYPE else ""
            options.append(
                discord.SelectOption(label=f"{prefix} {display_label}{type_suffix}",
                                     value=_encode_management_value(squadron, actual_notif_type),
                                     description=f"{_notif_type_label(actual_notif_type)} • {state}: {channel_val}"))
        if not options:
            options.append(
                discord.SelectOption(label=t(self.lang, "common.none_option"),
                                     description=t(self.lang, "autolog.no_squadrons_desc"),
                                     value="none"))
        return options

    async def callback(self, interaction: discord.Interaction):
        selected_value = self.values[0]
        if selected_value == "none":
            await interaction.response.send_message(
                t(self.lang, "autolog.no_squadrons_available"),
                ephemeral=True)
            return
        selected_notif_type, selected_squadron = _decode_management_value(selected_value)

        preferences = await load_guild_preferences(self.guild_id)
        squadron_settings = preferences.get(selected_squadron, {})
        channel_value = squadron_settings.get(selected_notif_type, "Not configured")
        if channel_value != "Not configured":
            # Check if the value is wrapped in <#...>
            if channel_value.startswith("<#") and channel_value.endswith(">"):
                channel_id_str = channel_value[2:-1]
            else:
                channel_id_str = channel_value
            # Remove "DISABLED-" if present.
            if channel_id_str.startswith("DISABLED-"):
                channel_id_str = channel_id_str[len("DISABLED-"):]
            try:
                channel_id = int(channel_id_str)
                channel = interaction.guild.get_channel(channel_id) # type: ignore
                channel_name = channel.name if channel else t(self.lang, "common.unknown")
            except ValueError:
                channel_name = t(self.lang, "common.unknown")
        else:
            channel_name = t(self.lang, "common.not_configured")

        view = ToggleView(self.guild_id, selected_notif_type, selected_squadron, channel_value, self.lang)
        display_squadron = (
            squadron_settings.get("Long")
            if isinstance(squadron_settings, dict) else None
        ) or selected_squadron
        # Special messaging for Global (Leaderboard)
        if selected_squadron == "Global":
            message = t(self.lang, "autolog.managing_global", type=_notif_type_label(selected_notif_type), channel=channel_name)
        else:
            message = t(self.lang, "autolog.managing_squadron", type=_notif_type_label(selected_notif_type), squadron=display_squadron, channel=channel_name)
        await interaction.response.send_message(
            message,
            view=view,
            ephemeral=True)


class PrevPageButton(discord.ui.Button):
    """Previous-page button for PaginatedSquadronSelectView."""

    def __init__(self, lang: str = 'en'):
        super().__init__(label=t(lang, "buttons.previous"), style=discord.ButtonStyle.secondary)
        self.lang = lang

    async def callback(self, interaction: discord.Interaction):
        view: PaginatedSquadronSelectView = self.view # type: ignore
        if view.page > 0:
            view.page -= 1
            view.select.page = view.page
            view.select.options = view.select.get_options(view.page)
            view.select.placeholder = t(self.lang, "autolog.select_squadron_page_placeholder", page=view.page+1)
        await interaction.response.edit_message(view=view)


class NextPageButton(discord.ui.Button):
    """Next-page button for PaginatedSquadronSelectView."""

    def __init__(self, lang: str = 'en'):
        super().__init__(label=t(lang, "buttons.next"), style=discord.ButtonStyle.secondary)
        self.lang = lang

    async def callback(self, interaction: discord.Interaction):
        view: PaginatedSquadronSelectView = self.view # type: ignore
        if view.page < view.total_pages - 1:
            view.page += 1
            view.select.page = view.page
            view.select.options = view.select.get_options(view.page)
            view.select.placeholder = t(self.lang, "autolog.select_squadron_page_placeholder", page=view.page+1)
        await interaction.response.edit_message(view=view)


class PaginatedSquadronSelectView(discord.ui.View):
    """View combining a paginated squadron select with prev/next navigation buttons."""

    def __init__(self, guild_id, notif_type, option_rows, page=0, lang: str = 'en',
                 over_cap_keys: Optional[set[str]] = None, wildcard_blocked: bool = False):
        super().__init__(timeout=1200)
        self.guild_id = guild_id
        self.notif_type = notif_type
        self.option_rows = option_rows
        self.page = page
        self.lang = lang
        self.total_pages = math.ceil(len(option_rows) / 25)
        self.select = PaginatedSquadronSelect(guild_id, notif_type,
                                              option_rows, page, lang,
                                              over_cap_keys=over_cap_keys,
                                              wildcard_blocked=wildcard_blocked)
        self.add_item(self.select)
        self.add_item(PrevPageButton(lang))
        self.add_item(NextPageButton(lang))


async def create_squadron_select_view(guild_id, notif_type, lang: str = 'en'):
    """Return a View with either a paginated select or a basic select, based on number of squadrons.

    When tier enforcement is active, adds ⚠️ badges to squadrons enabled beyond the
    tier cap and 🚫 to wildcard entries on tiers that don't allow them.
    """
    preferences = await load_guild_preferences(guild_id)
    option_rows = _management_pref_rows(preferences, notif_type)

    # Compute tier-related badge sets for the chosen notif type.
    # Free/unentitled (tier=None) skips badges — premium gate handles them upstream.
    over_cap_keys: set[str] = set()
    wildcard_blocked = False
    if tier_enforcement_active() and notif_type not in ("Leaderboard", REPORTS_NOTIF_TYPE):
        tier = await get_guild_tier(guild_id)
        if tier is not None:
            wildcard_blocked = not tier_allows_wildcard(tier)
            enabled = set(enabled_pref_keys_for(preferences, notif_type))
            allowed = allowed_pref_keys_for(preferences, tier, notif_type)
            over_cap_keys = enabled - allowed

    if len(option_rows) > 25:
        return PaginatedSquadronSelectView(
            guild_id, notif_type, option_rows, lang=lang,
            over_cap_keys=over_cap_keys, wildcard_blocked=wildcard_blocked,
        )
    else:
        view = discord.ui.View(timeout=180)
        view.add_item(SquadronSelect(
            guild_id, notif_type, option_rows, lang,
            over_cap_keys=over_cap_keys, wildcard_blocked=wildcard_blocked,
        ))
        return view


class ToggleButton(discord.ui.Button):
    """Button that enables or disables a notification for a specific squadron."""

    def __init__(self, guild_id, notif_type, squadron, channel_value, lang: str = 'en'):
        self.lang = lang
        # If it already has "DISABLED-", we're currently disabled → label should read "Enable"
        label = t(lang, "buttons.enable") if "DISABLED-" in channel_value else t(lang, "buttons.disable")
        super().__init__(label=label, style=discord.ButtonStyle.primary)
        self.guild_id = guild_id
        self.notif_type = notif_type
        self.squadron = squadron

    async def callback(self, interaction: discord.Interaction):
        # 1) reload prefs
        prefs = await load_guild_preferences(self.guild_id)
        current = prefs.get(self.squadron, {}).get(self.notif_type, "")

        # 2) extract the raw channel ID (handles bare digits, <#ID>, or <#DISABLED-ID>)
        m = re.search(r"(?:<#DISABLED-?)?(\d+)>?$", current)
        if not m:
            return await interaction.response.edit_message(
                content="⚠️ Couldn't parse stored channel ID.", view=self.view
            )
        raw_id = m.group(1)

        # 3) flip state
        is_currently_disabled = "DISABLED-" in current

        # Tier enforcement — only runs when flipping DISABLED → ENABLED.
        # Leaderboard/Weekly BR routes are uncapped; disabling is always allowed.
        # Free/unentitled users skip this gate — the autologging premium gate blocks
        # dispatch for them anyway, and letting them configure prefs up-front is better UX.
        tier = await get_guild_tier(self.guild_id)
        if is_currently_disabled and tier is not None and tier_enforcement_active() and self.notif_type not in ("Leaderboard", "WeeklyBR"):
            if self.squadron.lower() in WILDCARD_KEYS and not tier_allows_wildcard(tier):
                wb_embed = discord.Embed(
                    title=t(self.lang, "autolog.wildcard_blocked_title"),
                    description=t(self.lang, "autolog.wildcard_blocked_desc",
                                  tier=(tier or "none").title(), notif=_notif_type_label(self.notif_type)),
                    color=discord.Color.orange(),
                )
                wb_embed.set_footer(text=t(self.lang, "autolog.over_cap_footer"))
                await interaction.response.send_message(embed=wb_embed, ephemeral=True)
                return

            cap = tier_cap(tier, self.notif_type)
            # Wildcards don't count toward the cap — skip the cap check when enabling one
            if cap is not None and self.squadron.lower() not in WILDCARD_KEYS:
                enabled_now = set(enabled_non_wildcard_keys_for(prefs, self.notif_type)) - {self.squadron}
                if len(enabled_now) >= cap:
                    entry = prefs.get(self.squadron) or {}
                    display_squadron = (entry.get("Long") if isinstance(entry, dict) else None) or self.squadron
                    oc_embed = discord.Embed(
                        title=t(self.lang, "autolog.over_cap_title"),
                        description=t(self.lang, "autolog.over_cap_desc",
                                      tier=(tier or "none").title(), notif=_notif_type_label(self.notif_type),
                                      cap=cap, squadron=display_squadron),
                        color=discord.Color.orange(),
                    )
                    oc_embed.set_footer(text=t(self.lang, "autolog.over_cap_footer"))
                    await interaction.response.send_message(embed=oc_embed, ephemeral=True)
                    return

        if is_currently_disabled:
            new_value = f"<#{raw_id}>"
            state_word = t(self.lang, "common.enabled")
            self.label = t(self.lang, "buttons.disable")
        else:
            new_value = f"<#DISABLED-{raw_id}>"
            state_word = t(self.lang, "common.disabled")
            self.label = t(self.lang, "buttons.enable")

        # 4) save and update UI
        prefs[self.squadron][self.notif_type] = new_value
        await save_guild_preferences(self.guild_id, prefs)

        entry = prefs.get(self.squadron) or {}
        display_squadron = (entry.get("Long") if isinstance(entry, dict) else None) or self.squadron
        # Special messaging for Global (Leaderboard)
        if self.squadron == "Global":
            message = t(self.lang, "autolog.global_toggled", type=_notif_type_label(self.notif_type), state=state_word)
        else:
            message = t(self.lang, "autolog.squadron_toggled", type=_notif_type_label(self.notif_type), squadron=display_squadron, state=state_word)

        await interaction.response.edit_message(
            content=message,
            view=self.view
        )



class ChangeChannelButton(discord.ui.Button):
    """Button that opens a channel selector to reassign a notification's target channel."""

    def __init__(self, guild_id, notif_type, squadron, lang: str = 'en'):
        super().__init__(label=t(lang, "buttons.change_channel"),
                         style=discord.ButtonStyle.secondary)
        self.guild_id = guild_id
        self.notif_type = notif_type
        self.squadron = squadron
        self.lang = lang

    async def callback(self, interaction: discord.Interaction):
        guild = interaction.guild
        # If there are more than 25 channels, use the paginated view.
        if len(guild.text_channels) > 25: # type: ignore
            view = PaginatedChannelSelectView(guild, self.squadron,
                                              self.notif_type, lang=self.lang)
        else:
            view = ChannelSelectView(guild, self.notif_type, self.squadron, lang=self.lang)
        await interaction.response.send_message(t(self.lang, "autolog.select_channel"),
                                                view=view,
                                                ephemeral=True)


# The existing ChannelSelect view (for servers with 25 or fewer text channels)
class ChannelSelect(discord.ui.Select):
    """Dropdown listing all text channels in a guild (<=25 channels)."""

    def __init__(self, guild, notif_type, squadron, lang: str = 'en'):
        self.notif_type = notif_type
        self.squadron = squadron
        self.lang = lang
        options = []
        for channel in guild.text_channels:
            options.append(
                discord.SelectOption(label=channel.name,
                                     value=str(channel.id)))
        super().__init__(placeholder=t(lang, "autolog.select_channel_placeholder"),
                         min_values=1,
                         max_values=1,
                         options=options)

    async def callback(self, interaction: discord.Interaction):
        selected_channel_id = self.values[0]
        new_value = f"<#{selected_channel_id}>"
        preferences = await load_guild_preferences(interaction.guild.id) # type: ignore
        if self.squadron in preferences and self.notif_type in preferences[
                self.squadron]:
            preferences[self.squadron][self.notif_type] = new_value
            await save_guild_preferences(interaction.guild.id, preferences) # type: ignore
            entry = preferences.get(self.squadron) or {}
            display_squadron = (entry.get("Long") if isinstance(entry, dict) else None) or self.squadron
            # Special messaging for Global (Leaderboard)
            if self.squadron == "Global":
                message = t(self.lang, "autolog.channel_updated_global", type=_notif_type_label(self.notif_type), channel=new_value)
            else:
                message = t(self.lang, "autolog.channel_updated_squadron", type=_notif_type_label(self.notif_type), squadron=display_squadron, channel=new_value)
            await interaction.response.send_message(
                message,
                ephemeral=True)
        else:
            await interaction.response.send_message(t(self.lang, "common.configuration_not_found"),
                                                    ephemeral=True)


class ChannelSelectView(discord.ui.View):
    """Container view wrapping a single ChannelSelect dropdown."""

    def __init__(self, guild, notif_type, squadron, lang: str = 'en'):
        super().__init__(timeout=1200)
        self.add_item(ChannelSelect(guild, notif_type, squadron, lang))


class ToggleView(discord.ui.View):
    """View combining a ToggleButton and ChangeChannelButton for a notification entry."""

    def __init__(self, guild_id, notif_type, squadron, channel_value="Not configured", lang: str = 'en'):
        super().__init__(timeout=1200)
        self.add_item(
            ToggleButton(guild_id, notif_type, squadron, channel_value, lang))
        self.add_item(ChangeChannelButton(guild_id, notif_type, squadron, lang))


# Paginated select menu for channels.
class PaginatedChannelSelect(discord.ui.Select):
    """Paginated channel dropdown for servers with more than 25 text channels."""

    def __init__(self, guild, squadron, notif_type, page=0, lang: str = 'en'):
        self.guild = guild
        self.squadron = squadron
        self.notif_type = notif_type
        self.page = page
        self.lang = lang
        self.channels = list(guild.text_channels)
        options = self.get_options(page)
        super().__init__(placeholder=t(lang, "autolog.select_channel_page_placeholder", page=page+1),
                         min_values=1,
                         max_values=1,
                         options=options)

    def get_options(self, page):
        start = page * 25
        end = start + 25
        options = []
        for channel in self.channels[start:end]:
            options.append(
                discord.SelectOption(label=channel.name,
                                     value=str(channel.id)))
        # If there are no channels for this page, provide a fallback option.
        if not options:
            options.append(
                discord.SelectOption(label=t(self.lang, "common.none_option"),
                                     description=t(self.lang, "autolog.no_channels_desc"),
                                     value="none"))
        return options

    async def callback(self, interaction: discord.Interaction):
        selected_channel_id = self.values[0]
        if selected_channel_id == "none":
            await interaction.response.send_message(t(self.lang, "common.no_channel_selected"),
                                                    ephemeral=True)
            return

        new_value = f"<#{selected_channel_id}>"
        preferences = await load_guild_preferences(interaction.guild.id) # type: ignore
        if self.squadron in preferences and self.notif_type in preferences[
                self.squadron]:
            preferences[self.squadron][self.notif_type] = new_value
            await save_guild_preferences(interaction.guild.id, preferences) # type: ignore
            entry = preferences.get(self.squadron) or {}
            display_squadron = (entry.get("Long") if isinstance(entry, dict) else None) or self.squadron
            # Special messaging for Global (Leaderboard)
            if self.squadron == "Global":
                message = t(self.lang, "autolog.channel_updated_global", type=_notif_type_label(self.notif_type), channel=new_value)
            else:
                message = t(self.lang, "autolog.channel_updated_squadron", type=_notif_type_label(self.notif_type), squadron=display_squadron, channel=new_value)
            await interaction.response.send_message(
                message,
                ephemeral=True)
        else:
            await interaction.response.send_message(t(self.lang, "common.configuration_not_found"),
                                                    ephemeral=True)


# Button to go to the previous page.
class PrevChannelPageButton(discord.ui.Button):
    """Previous-page button for PaginatedChannelSelectView."""

    def __init__(self, lang: str = 'en'):
        super().__init__(label=t(lang, "buttons.previous"), style=discord.ButtonStyle.secondary)
        self.lang = lang

    async def callback(self, interaction: discord.Interaction):
        view: PaginatedChannelSelectView = self.view # type: ignore
        if view.page > 0:
            view.page -= 1
            view.select.page = view.page
            view.select.options = view.select.get_options(view.page)
            view.select.placeholder = t(self.lang, "autolog.select_channel_page_placeholder", page=view.page+1)
        await interaction.response.edit_message(view=view)


# Button to go to the next page.
class NextChannelPageButton(discord.ui.Button):
    """Next-page button for PaginatedChannelSelectView."""

    def __init__(self, lang: str = 'en'):
        super().__init__(label=t(lang, "buttons.next"), style=discord.ButtonStyle.secondary)
        self.lang = lang

    async def callback(self, interaction: discord.Interaction):
        view: PaginatedChannelSelectView = self.view # type: ignore
        if view.page < view.total_pages - 1:
            view.page += 1
            view.select.page = view.page
            view.select.options = view.select.get_options(view.page)
            view.select.placeholder = t(self.lang, "autolog.select_channel_page_placeholder", page=view.page+1)
        await interaction.response.edit_message(view=view)


# View that contains the paginated channel select and navigation buttons.
class PaginatedChannelSelectView(discord.ui.View):
    """View combining a paginated channel select with prev/next navigation buttons."""

    def __init__(self, guild, squadron, notif_type, page=0, lang: str = 'en'):
        super().__init__(timeout=1200)
        self.guild = guild
        self.squadron = squadron
        self.notif_type = notif_type
        self.channels = list(guild.text_channels)
        self.page = page
        self.lang = lang
        self.total_pages = math.ceil(len(self.channels) / 25)
        self.select = PaginatedChannelSelect(guild, squadron, notif_type, page, lang)
        self.add_item(self.select)
        self.add_item(PrevChannelPageButton(lang))
        self.add_item(NextChannelPageButton(lang))



class DiagnoseChannelSelect(discord.ui.ChannelSelect):
    """Channel picker that triggers autolog permission diagnostics on the selected channel."""

    def __init__(self, lang: str = 'en'):
        super().__init__(
            placeholder=t(lang, "autolog.diagnose_channel_placeholder"),
            channel_types=[discord.ChannelType.text, discord.ChannelType.news],
            min_values=1,
            max_values=1,
        )

    async def callback(self, interaction: discord.Interaction):
        channel = self.values[0]
        resolved = interaction.guild.get_channel(channel.id) if interaction.guild else None
        await _diagnose_perms_logic(interaction, target_channel=resolved or channel)


class DiagnoseChannelView(discord.ui.View):
    """Container view wrapping a DiagnoseChannelSelect dropdown."""

    def __init__(self, lang: str = 'en'):
        super().__init__(timeout=1200)
        self.add_item(DiagnoseChannelSelect(lang))


class AutologManagementView(discord.ui.View):
    """Main autolog view with buttons for managing notifications and diagnosing permissions."""

    def __init__(self, lang: str = 'en'):
        super().__init__(timeout=1200)
        self.lang = lang
        # Update button labels to translated values
        self.manage_notifications.label = t(lang, "buttons.manage_notifications")
        self.diagnose_permissions.label = t(lang, "buttons.diagnose_permissions")

    @discord.ui.button(label="Manage Notifications", style=discord.ButtonStyle.primary, emoji="🔔")
    async def manage_notifications(self, interaction: discord.Interaction, button: discord.ui.Button):
        view = NotificationManagementView(self.lang)
        await interaction.response.send_message(
            t(self.lang, "autolog.select_notif_type"), view=view, ephemeral=True)
        view.message = await interaction.original_response()

    @discord.ui.button(label="Diagnose Permissions", style=discord.ButtonStyle.secondary, emoji="🔧")
    async def diagnose_permissions(self, interaction: discord.Interaction, button: discord.ui.Button):
        view = DiagnoseChannelView(self.lang)
        await interaction.response.send_message(
            t(self.lang, "autolog.select_channel_diagnose"), view=view, ephemeral=True)


@is_blacklisted()
@is_admin()
@bot.tree.command(
    name="autolog-management",
    description=command_locale("Manage autolog notifications and diagnose permissions", "commands.autolog_management.description")
)
async def autolog_management(interaction: discord.Interaction):
    """Show premium status and present the autolog management panel."""
    await collect_command_stats(interaction)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
    if interaction.guild_id and await is_guild_entitled(interaction.guild_id):
        premium_line = t(lang, "autolog.premium_active_line")
    else:
        premium_line = t(lang, "autolog.premium_not_subscribed_line")

    view = AutologManagementView(lang)
    await interaction.response.send_message(
        f"{premium_line}{t(lang, 'autolog.what_to_do')}", view=view, ephemeral=True)

@autolog_management.error  # type: ignore[attr-defined]
async def autolog_management_error(interaction, error):
    await permission_fail(interaction, error)


@is_blacklisted()
@is_admin()
@bot.tree.command(
    name="diagnose-perms",
    description=command_locale("Diagnose autolog permissions for this channel", "commands.diagnose_perms.description")
)
async def diagnose_perms(interaction: discord.Interaction):
    """Run autolog permission diagnostics on the current channel."""
    await collect_command_stats(interaction)
    await _diagnose_perms_logic(interaction)

@diagnose_perms.error
async def diagnose_perms_error(interaction, error):
    await permission_fail(interaction, error)



@is_blacklisted()
@is_admin()
@bot.tree.command(name="unlock", description=command_locale("Unlock premium features for this server", "commands.unlock.description"))
async def unlock_cmd(interaction: discord.Interaction):
    """Show premium info and subscription buttons, or active subscription details if already subscribed."""
    await collect_command_stats(interaction)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    current_tier = await get_guild_tier(interaction.guild_id) if interaction.guild_id else None

    # Detect billing source so we surface the right upgrade/manage path.
    is_discord_sub = False
    is_website_sub = False
    if current_tier is not None:
        try:
            async for ent in bot.entitlements(exclude_ended=True):
                if ent.guild_id == interaction.guild_id:
                    is_discord_sub = True
                    break
        except Exception:
            pass
        try:
            async with aiosqlite.connect(ENTITLEMENTS_DB_PATH) as db:
                cursor = await db.execute(
                    "SELECT 1 FROM guild_entitlements WHERE guild_id=? AND status='active'",
                    [str(interaction.guild_id)],
                )
                if await cursor.fetchone():
                    is_website_sub = True
        except Exception:
            pass

    # Build the view with appropriate buttons for each scenario.
    view = discord.ui.View()

    # SKU → Discord premium buttons, only added when the env var is set.
    def _maybe_add_sku(sku_env: Optional[str | int]) -> None:
        try:
            sku_int = int(sku_env) if sku_env else 0
        except (TypeError, ValueError):
            return
        if sku_int:
            view.add_item(discord.ui.Button(style=discord.ButtonStyle.premium, sku_id=sku_int))

    if current_tier is None:
        # Not subscribed — offer all 3 Discord SKUs plus a website "See plans" link.
        embed = discord.Embed(
            title=t(lang, "unlock.title"),
            description=t(lang, "unlock.desc"),
            color=discord.Color.gold(),
        )
        embed.set_footer(text=DEFAULT_FOOTER_CAT)

        _maybe_add_sku(DISCORD_SKU_ID_STANDARD)
        _maybe_add_sku(DISCORD_SKU_ID_PRO)
        _maybe_add_sku(DISCORD_SKU_ID_MAX)
        view.add_item(discord.ui.Button(
            label=t(lang, "buttons.subscribe_website"),
            url="https://sre.pawjob.us/premium",
            style=discord.ButtonStyle.link,
        ))

        if not view.children:
            embed.add_field(name=t(lang, "unlock.coming_soon_field"),
                            value=t(lang, "unlock.coming_soon_value"), inline=False)
            await interaction.response.send_message(embed=embed, ephemeral=True)
            return

        await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
        return

    # Subscribed — show current tier, plus an upgrade path (SKU buttons for Discord
    # subs, website link for Whop subs) and a manage link.
    tier_label = current_tier.title()
    sub_embed = discord.Embed(
        title=t(lang, "unlock.already_subscribed_title"),
        description=t(lang, "unlock.current_tier", tier=tier_label),
        color=discord.Color.green(),
    )

    higher_tiers = [t_ for t_ in ("pro", "max") if TIER_ORDER.index(t_) > TIER_ORDER.index(current_tier)]

    if higher_tiers:
        # Show upgrade buttons. Prefer Discord SKU button for Discord subs; fallback
        # to website anchor for Whop subs (Discord subs can also deep-link to website).
        sub_embed.add_field(
            name=t(lang, "unlock.upgrade_to", tier="/".join(ht.title() for ht in higher_tiers)),
            value=t(lang, "unlock.upgrade_to_value", tier="/".join(ht.title() for ht in higher_tiers)),
            inline=False,
        )
        if is_discord_sub:
            # Add SKU buttons for each higher tier
            for ht in higher_tiers:
                sku = DISCORD_SKU_ID_PRO if ht == "pro" else DISCORD_SKU_ID_MAX
                _maybe_add_sku(sku)
        # Always add a website upgrade link too — Whop users need it, Discord users
        # might prefer it, and it surfaces the full 4-card comparison.
        for ht in higher_tiers:
            view.add_item(discord.ui.Button(
                label=t(lang, "unlock.upgrade_to", tier=ht.title()),
                url=f"https://sre.pawjob.us/premium#{ht}",
                style=discord.ButtonStyle.link,
            ))

    if is_discord_sub:
        sub_embed.add_field(
            name=t(lang, "unlock.manage_discord_field"),
            value=t(lang, "unlock.manage_discord_value"),
            inline=False,
        )
    elif is_website_sub:
        sub_embed.add_field(
            name=t(lang, "unlock.manage_website_field"),
            value=t(lang, "unlock.manage_website_value"),
            inline=False,
        )

    sub_embed.set_footer(text=DEFAULT_FOOTER_CAT)
    if view.children:
        await interaction.response.send_message(embed=sub_embed, view=view, ephemeral=True)
    else:
        await interaction.response.send_message(embed=sub_embed, ephemeral=True)


@unlock_cmd.error
async def unlock_error(interaction, error):
    await permission_fail(interaction, error)


@is_blacklisted()
@bot.tree.command(name="credits", description=command_locale("View the team credited for building this", "commands.credits.description"))
async def credits(interaction: discord.Interaction):
    """Display the SRE Bot development team credits."""
    await collect_command_stats(interaction)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    embed = discord.Embed(
        title=t(lang, "misc.credits_title"),
        description=t(lang, "misc.credits_desc"),
        color=discord.Color.gold()
    )

    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    await interaction.response.send_message(embed=embed, ephemeral=False)


# ── /schedule config ── edit these when the season changes ─────────────────
SCHEDULE_TITLE       = "SEASON SCHEDULE"
_SCHEDULE_JSON = Path(__file__).parent / "SCHEDULE.json"

def _schedule_footer(lang: str) -> str:
    """Build timeslot footer with Discord timestamps for today's posted SQB slots."""
    today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
    lines = []
    for name, posted_start, posted_end in SQB_SLOTS_POSTED:
        start_ts = int(today.replace(hour=posted_start.hour, minute=posted_start.minute).timestamp())
        end_ts   = int(today.replace(hour=posted_end.hour,   minute=posted_end.minute).timestamp())
        label = t(lang, "misc.schedule_timeslot_label", region=name)
        lines.append(f"{label}: **<t:{start_ts}:t> - <t:{end_ts}:t>**")
    return "\n".join(lines)
# ────────────────────────────────────────────────────────────────────────────


@is_blacklisted()
@bot.tree.command(name="schedule", description=command_locale("View the current season BR schedule", "commands.schedule.description"))
async def schedule_cmd(interaction: discord.Interaction):
    """Load SCHEDULE.json and display BR tiers with dates, highlighting the current tier."""
    await collect_command_stats(interaction)
    await interaction.response.defer()
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    if not _SCHEDULE_JSON.exists():
        await interaction.followup.send(
            embed=discord.Embed(
                title=t(lang, "misc.schedule_not_found_title"),
                description=t(lang, "misc.schedule_not_found_desc"),
                color=discord.Color.red()
            ),
            ephemeral=True
        )
        return

    entries = await load_json(_SCHEDULE_JSON, [])

    now = int(time_module.time())
    lines = []

    for entry in entries:
        br       = entry["max_br"]
        start_ts = entry["start"]
        end_ts   = entry["end"]
        br_str   = f"{br:.1f}"
        date_str = f"<t:{start_ts}:d> — <t:{end_ts}:d>"

        is_past    = now >= end_ts
        is_current = start_ts <= now < end_ts

        if is_past:
            lines.append(f"> ~~**{br_str} ({date_str})**~~")
        elif is_current:
            lines.append("")
            lines.append(f"> ▶ **{br_str} ({date_str})**")
            lines.append("")
        else:
            lines.append(f"> **{br_str} ({date_str})**")

    description = "\n".join(lines) + f"\n\n{_schedule_footer(lang)}"

    embed = discord.Embed(
        title=t(lang, "misc.schedule_title"),
        description=description,
        color=discord.Color.gold()
    )
    embed.set_footer(text=DEFAULT_FOOTER_CAT)

    await interaction.followup.send(embed=embed)


@schedule_cmd.error
async def schedule_error(interaction, error):
    await permission_fail(interaction, error)


# ── /news config ─────────────────────────────────────────────────────────────
_NEWS_JSON = Path(__file__).parent / "NEWS.json"
# ─────────────────────────────────────────────────────────────────────────────


@is_blacklisted()
@bot.tree.command(name="news", description=command_locale("View the latest SRE Bot news and announcements", "commands.news.description"))
async def news_cmd(interaction: discord.Interaction):
    """Load NEWS.json and display up to 10 news entries as embeds."""
    await collect_command_stats(interaction)
    await interaction.response.defer()
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    if not _NEWS_JSON.exists():
        await interaction.followup.send(
            embed=discord.Embed(
                title=t(lang, "misc.news_no_news_title"),
                description=t(lang, "misc.news_no_news_desc"),
                color=discord.Color.blurple()
            ),
            ephemeral=True
        )
        return

    all_entries = await load_json(_NEWS_JSON, [])

    # Filter out expired entries
    now_ts = int(time_module.time())
    entries = [e for e in all_entries if now_ts < e.get("expires", float("inf"))]

    if not entries:
        await interaction.followup.send(
            embed=discord.Embed(
                title=t(lang, "misc.news_no_news_title"),
                description=t(lang, "misc.news_no_news_desc"),
                color=discord.Color.blurple()
            ),
            ephemeral=True
        )
        return

    embeds = []
    for i, item in enumerate(entries[:10]):  # Discord max 10 embeds per message
        embed = discord.Embed(
            title=item.get("title", "Announcement"),
            description=item.get("body", ""),
            color=discord.Color.blurple()
        )
        if i == len(entries) - 1 or i == 9:
            embed.set_footer(text=t(lang, "misc.news_footer"))
        embeds.append(embed)

    await interaction.followup.send(embeds=embeds)


@news_cmd.error
async def news_error(interaction, error):
    await permission_fail(interaction, error)



@is_blacklisted()
@bot.tree.command(name="help", description=command_locale("View the guide, ToS, and support links", "commands.help.description"))
async def help(interaction: discord.Interaction):
    """Display the full 29-command guide with documentation and support links."""
    await collect_command_stats(interaction)
    support_server = "https://discord.gg/BCvkK8JhPe"
    documentation_link = "https://sre.pawjob.us/docs"
    tos_link = "https://sre.pawjob.us/terms"
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    command_groups = [
        ("misc.help_group_admin", [
            ("/setup", "commands.setup.description"),
            ("/quick-log", "commands.quick_log.description"),
            ("/autolog-management", "commands.autolog_management.description"),
            ("/set-squadron", "commands.set_squadron.description"),
            ("/diagnose-perms", "commands.diagnose_perms.description"),
        ]),
        ("misc.help_group_squadron", [
            ("/sq-info", "commands.sq_info.description"),
            ("/sq-info-graph", "commands.sq_info_graph.description"),
            ("/sq-stats", "commands.sq_stats.description"),
            ("/sq-track", "commands.sq_track.description"),
            ("/sq-card", "commands.sq_card.description"),
            ("/comp", "commands.comp.description"),
            ("/vs", "commands.vs.description"),
            ("/recent", "commands.recent.description"),
        ]),
        ("misc.help_group_rankings", [
            ("/top", "commands.top.description"),
            ("/leaderboard", "commands.leaderboard.description"),
            ("/analytics", "commands.analytics.description"),
            ("/loss-calculator", "commands.loss_calculator.description"),
        ]),
        ("misc.help_group_players", [
            ("/player-stats", "commands.player_stats.description"),
            ("/view-player-games", "commands.view_player_games.description"),
            ("/view-match", "commands.view_match.description"),
            ("/compare", "commands.compare.description"),
            ("/card", "commands.card.description"),
            ("/set-player", "commands.set_player.description"),
        ]),
        ("misc.help_group_meta", [
            ("/meta-management", "commands.meta_management.description"),
            ("/meta", "commands.meta.description"),
        ]),
        ("misc.help_group_stacks", [
            ("/stack-create", "commands.stack_create.description"),
            ("/stack-manage", "commands.stack_manage.description"),
        ]),
        ("misc.help_group_settings", [
            ("/language", "commands.language.description"),
            ("/schedule", "commands.schedule.description"),
            ("/website", "commands.website.description"),
            ("/credits", "commands.credits.description"),
            ("/news", "commands.news.description"),
            ("/donate", "commands.donate.description"),
            ("/bot-status", "commands.bot_status.description"),
            ("/unlock", "commands.unlock.description"),
            ("Translate Message", "misc.help_translate_hint"),
        ]),
    ]

    description = (
        t(lang, "misc.help_links", docs=documentation_link, support=support_server)
        + "\n"
        + t(lang, "misc.help_terms", terms=tos_link)
    )
    embed = discord.Embed(
        title=t(lang, "misc.help_title"),
        description=description,
        color=discord.Color.blue(),
    )
    for group_key, rows in command_groups:
        value = "\n".join(f"`{name}` — {t(lang, key)}" for name, key in rows)
        embed.add_field(name=t(lang, group_key), value=value, inline=False)
    embed.set_footer(text=DEFAULT_FOOTER_CAT)

    await interaction.response.send_message(embed=embed, ephemeral=False)



@is_blacklisted()
@bot.tree.command(name="donate", description=command_locale("Support the development of SRE Bot", "commands.donate.description"))
async def donate_cmd(interaction: discord.Interaction):
    """Show the Ko-fi donation link."""
    await collect_command_stats(interaction)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
    embed = discord.Embed(
        title=t(lang, "misc.donate_title"),
        description=t(lang, "misc.donate_desc"),
        color=discord.Color.from_rgb(255, 94, 91),
    )
    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    await interaction.response.send_message(embed=embed, ephemeral=False)


@is_blacklisted()
@bot.tree.command(
    name="bot-status",
    description=command_locale(
        "View bot status: last game received and average TTL",
        "commands.bot_status.description",
    ),
)
async def bot_status_public(interaction: discord.Interaction):
    """Public-facing status: last received game timestamp + avg TTL across the last 30 games."""
    await collect_command_stats(interaction)
    lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'

    await interaction.response.defer(ephemeral=False)

    last_received_ts: int | None = None
    avg_delay: int | None = None
    sample_size = 0
    try:
        stats = await get_recent_ttl_stats(limit=30)
        avg_delay = stats["avg_delay"]
        sample_size = stats["sample_size"]
        last_received_ts = stats["last_received_ts"]
    except Exception:
        logging.exception("Failed to compute /bot-status TTL stats")

    embed = discord.Embed(
        title=t(lang, "misc.status_title"),
        color=discord.Color.green(),
    )

    if last_received_ts:
        last_value = f"<t:{last_received_ts}:R> (<t:{last_received_ts}:T>)"
    else:
        last_value = t(lang, "misc.status_no_data")
    embed.add_field(name=t(lang, "misc.status_last_received"), value=last_value, inline=False)

    if avg_delay is not None:
        a_min, a_sec = divmod(avg_delay, 60)
        ttl_value = f"{a_min}m {a_sec:02d}s"
        embed.add_field(name=t(lang, "misc.status_avg_ttl"), value=ttl_value, inline=False)
        if avg_delay > 20 * 60:
            embed.add_field(name="​", value=t(lang, "misc.status_gaijin_slow"), inline=False)
            embed.color = discord.Color.orange()
    else:
        embed.add_field(name=t(lang, "misc.status_avg_ttl"), value=t(lang, "misc.status_no_data"), inline=False)

    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    await interaction.followup.send(embed=embed, ephemeral=False)


class EntitlementsPaginator(discord.ui.View):
    """Paginated view displaying active entitlements from Discord, Whop, and manual sources."""

    ITEMS_PER_PAGE = 10

    def __init__(self, discord_lines: list[str], whop_lines: list[str], owner_id: int, manual_lines: list[str] | None = None, lang: str = 'en'):
        super().__init__(timeout=300)
        self.discord_lines = discord_lines
        self.whop_lines = whop_lines
        self.manual_lines = manual_lines or []
        self.owner_id = owner_id
        self.page = 0
        self.lang = lang
        # Build flat list of (line, source) for unified pagination
        self.all_entries: list[tuple[str, str]] = []
        for line in discord_lines:
            self.all_entries.append((line, "discord"))
        for line in whop_lines:
            self.all_entries.append((line, "whop"))
        for line in self.manual_lines:
            self.all_entries.append((line, "manual"))
        self.max_page = max(0, (len(self.all_entries) - 1) // self.ITEMS_PER_PAGE)
        self._update_buttons()
        self.prev_btn.label = t(lang, "buttons.previous")
        self.next_btn.label = t(lang, "buttons.next")

    def _update_buttons(self):
        self.prev_btn.disabled = self.page <= 0
        self.next_btn.disabled = self.page >= self.max_page

    def build_embed(self) -> discord.Embed:
        """Build the embed for the current page of entitlement entries."""
        start = self.page * self.ITEMS_PER_PAGE
        end = start + self.ITEMS_PER_PAGE
        page_entries = self.all_entries[start:end]

        description_parts = []
        for line, source in page_entries:
            tag = {"discord": t(self.lang, "dev.entitlements_tag_discord"), "whop": t(self.lang, "dev.entitlements_tag_whop"), "manual": t(self.lang, "dev.entitlements_tag_manual")}[source]
            description_parts.append(f"[**{tag}**] {line}")

        embed = discord.Embed(
            title=t(self.lang, "dev.entitlements_title", count=len(self.all_entries)),
            description="\n\n".join(description_parts) if description_parts else t(self.lang, "dev.entitlements_no_entries"),
            color=discord.Color.blurple(),
        )
        embed.set_footer(text=f"Page {self.page + 1}/{self.max_page + 1} · {len(self.discord_lines)} {t(self.lang, 'dev.entitlements_tag_discord')} · {len(self.whop_lines)} {t(self.lang, 'dev.entitlements_tag_whop')} · {len(self.manual_lines)} {t(self.lang, 'dev.entitlements_tag_manual')}")
        return embed

    @discord.ui.button(label="Previous", style=discord.ButtonStyle.secondary)
    async def prev_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
        if interaction.user.id != self.owner_id:
            return await interaction.response.defer()
        self.page = max(0, self.page - 1)
        self._update_buttons()
        await interaction.response.edit_message(embed=self.build_embed(), view=self)

    @discord.ui.button(label="Next", style=discord.ButtonStyle.secondary)
    async def next_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
        if interaction.user.id != self.owner_id:
            return await interaction.response.defer()
        self.page = min(self.max_page, self.page + 1)
        self._update_buttons()
        await interaction.response.edit_message(embed=self.build_embed(), view=self)


@is_blacklisted()
@bot.tree.command(name="bot-status-dev", description="[DEV] View bot health dashboard")
async def bot_status(interaction: discord.Interaction):
    """Display the bot health dashboard: uptime, guilds, task statuses, WebSocket info, and avg TTL (last 30)."""
    await collect_command_stats(interaction)
    if not await is_dev_team(interaction):
        await interaction.response.send_message(t("en", "dev.restricted_dev_team"), ephemeral=True)
        return

    await interaction.response.defer(ephemeral=True)

    snapshot = await get_health_snapshot()

    # Uptime
    started = snapshot.get("bot_started_at", 0)
    uptime_s = int(time_module.time() - started) if started else 0
    hours, rem = divmod(uptime_s, 3600)
    minutes, seconds = divmod(rem, 60)
    uptime_str = f"{hours}h {minutes}m {seconds}s"

    embed = discord.Embed(
        title=t("en", "dev.health_title"),
        color=discord.Color.green(),
    )
    embed.add_field(name=t("en", "dev.health_uptime"), value=uptime_str, inline=True)
    embed.add_field(name=t("en", "dev.health_guilds"), value=str(snapshot.get("guild_count", 0)), inline=True)
    embed.add_field(
        name=t("en", "dev.health_games_processed"),
        value=f"1h: {snapshot.get('games_processed_1h', 0)} | 24h: {snapshot.get('games_processed_24h', 0)}",
        inline=False,
    )

    # Task statuses
    tasks_info = snapshot.get("tasks", {})
    if tasks_info:
        lines = []
        for name, info in sorted(tasks_info.items()):
            status_icon = "\u2705" if info.get("status") == "ok" else "\u274c"
            last_run = info.get("last_run", 0)
            ago = f"<t:{int(last_run)}:R>" if last_run else t("en", "dev.health_never")
            errs = info.get("error_count", 0)
            line = f"{status_icon} **{name}** — {ago}"
            if errs:
                line += " " + t("en", "dev.health_errors", count=errs)
            lines.append(line)
        embed.add_field(name=t("en", "dev.health_tasks"), value="\n".join(lines), inline=False)

    # WebSocket statuses
    ws_info = snapshot.get("websocket", {})
    if ws_info:
        ws_lines = []
        for name, info in ws_info.items():
            connected = "\u2705" if info.get("connected") else "\u274c"
            last_msg = info.get("last_message_at", 0)
            ago = f"<t:{int(last_msg)}:R>" if last_msg else t("en", "dev.health_never")
            count = info.get("messages_processed", 0)
            ws_lines.append(f"{connected} **{name}** — " + t("en", "dev.health_last_msg", ago=ago, count=count))
        embed.add_field(name=t("en", "dev.health_websocket"), value="\n".join(ws_lines), inline=False)

    # Avg TTL (Spectra receive delay) for the last 30 games
    try:
        ttl_stats = await get_recent_ttl_stats(limit=30)
        if ttl_stats["avg_delay"] is not None:
            a_min, a_sec = divmod(ttl_stats["avg_delay"], 60)
            mn_min, mn_sec = divmod(ttl_stats["min_delay"], 60)
            mx_min, mx_sec = divmod(ttl_stats["max_delay"], 60)
            ttl_value = (
                f"**Avg:** {a_min}m{a_sec:02d}s • "
                f"**Min:** {mn_min}m{mn_sec:02d}s • "
                f"**Max:** {mx_min}m{mx_sec:02d}s • "
                f"**N:** {ttl_stats['sample_size']}"
            )
        else:
            ttl_value = t("en", "dev.health_never")
    except Exception as e:
        ttl_value = f"⚠️ {e}"
    embed.add_field(name=t("en", "dev.health_avg_ttl"), value=ttl_value, inline=False)

    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    await interaction.followup.send(embed=embed, ephemeral=True)


@is_blacklisted()
@bot.tree.command(name="view-entitlements", description="[DEV] View all active guild entitlements")
async def view_entitlements_cmd(interaction: discord.Interaction):
    """Show a paginated list of all active entitlements (Discord, Whop, manual)."""
    await collect_command_stats(interaction)
    if not await is_dev_team(interaction):
        await interaction.response.send_message(t("en", "dev.restricted_dev_team"), ephemeral=True)
        return

    await interaction.response.defer(ephemeral=True)

    # --- Discord native entitlements ---
    from .utils import sku_id_to_tier
    discord_lines: list[str] = []
    async for ent in bot.entitlements(guild=None, exclude_ended=True):
        guild_id = ent.guild_id or "—"
        guild = bot.get_guild(ent.guild_id) if ent.guild_id else None
        guild_name = guild.name if guild else "Unknown Server"
        starts = f"<t:{int(ent.starts_at.timestamp())}:d>" if ent.starts_at else "—"
        ends = f"<t:{int(ent.ends_at.timestamp())}:d>" if ent.ends_at else "—"
        tier = (sku_id_to_tier(str(ent.sku_id)) or "standard").title()
        discord_lines.append(
            f"**{guild_name}** (`{guild_id}`)\n"
            f"> Tier: `{tier}` · ID: `{ent.id}` · SKU: `{ent.sku_id}`\n"
            f"> Started: {starts} · Expires: {ends}"
        )

    # --- Whop (website) entitlements from entitlements.db ---
    whop_lines: list[str] = []
    manual_lines: list[str] = []
    try:
        async with aiosqlite.connect(ENTITLEMENTS_DB_PATH) as db:
            async for row in await db.execute(
                "SELECT guild_id, whop_membership_id, status, renewed_at, tier FROM guild_entitlements WHERE status='active'"
            ):
                gid = int(row[0])
                membership_id = row[1] or "—"
                renewed = f"<t:{row[3]}:d>" if row[3] else "—"
                tier = (row[4] or "standard").title()
                guild = bot.get_guild(gid)
                guild_name = guild.name if guild else "Unknown Server"
                whop_lines.append(
                    f"**{guild_name}** (`{gid}`)\n"
                    f"> Tier: `{tier}` · Membership: `{membership_id}`\n"
                    f"> Renewed: {renewed}"
                )
            # --- Manual entitlements ---
            now = int(time_module.time())
            async for row in await db.execute(
                "SELECT guild_id, expires_at, created_at, tier FROM manual_entitlements WHERE expires_at > ?", (now,)
            ):
                gid = int(row[0])
                expires = f"<t:{row[1]}:d>" if row[1] else "—"
                created = f"<t:{row[2]}:d>" if row[2] else "—"
                tier = (row[3] or "standard").title()
                guild = bot.get_guild(gid)
                guild_name = guild.name if guild else "Unknown Server"
                manual_lines.append(
                    f"**{guild_name}** (`{gid}`)\n"
                    f"> Tier: `{tier}`\n"
                    f"> Expires: {expires}\n"
                    f"> Created: {created}"
                )
    except Exception as e:
        whop_lines.append(f"*Failed to read entitlements.db: {e}*")

    if not discord_lines and not whop_lines and not manual_lines:
        await interaction.followup.send(
            embed=discord.Embed(
                title=t("en", "dev.entitlements_empty_title"),
                description=t("en", "dev.entitlements_empty_desc"),
                color=discord.Color.blurple()
            ),
            ephemeral=True
        )
        return

    paginator = EntitlementsPaginator(discord_lines, whop_lines, interaction.user.id, manual_lines=manual_lines)
    await interaction.followup.send(embed=paginator.build_embed(), view=paginator, ephemeral=True)


@view_entitlements_cmd.error
async def view_entitlements_error(interaction, error):
    await permission_fail(interaction, error)


@is_blacklisted()
@bot.tree.command(name="entitle", description="[BOT OWNER] Manually entitle a server until a given UNIX timestamp")
@app_commands.describe(
    server_id="The Discord server (guild) ID to entitle",
    unix_ts="UNIX timestamp for when the entitlement expires (must be >1 month from now)",
    tier="Which tier to grant (standard / pro / max)",
)
@app_commands.choices(tier=[
    app_commands.Choice(name="Standard (10 squads)", value="standard"),
    app_commands.Choice(name="Pro (25 squads, wildcards)", value="pro"),
    app_commands.Choice(name="Max (unlimited)", value="max"),
])
async def entitle_cmd(
    interaction: discord.Interaction,
    server_id: str,
    unix_ts: int,
    tier: str = "max",
):
    """Manually create a premium entitlement for a guild until the given UNIX timestamp."""
    await collect_command_stats(interaction)
    if not await bot.is_owner(interaction.user):
        await interaction.response.send_message(t("en", "dev.restricted_bot_owner"), ephemeral=True)
        return

    await interaction.response.defer(ephemeral=True)

    # Validate server_id format
    if not server_id.isdigit() or not (17 <= len(server_id) <= 19):
        await interaction.followup.send(t("en", "dev.invalid_server_id"), ephemeral=True)
        return

    if tier not in ("standard", "pro", "max"):
        await interaction.followup.send("Invalid tier. Must be standard/pro/max.", ephemeral=True)
        return

    # Validate unix_ts is at least 1 month ahead
    now = int(time_module.time())
    one_month = 30 * 24 * 60 * 60  # 30 days in seconds
    if unix_ts <= now + one_month:
        await interaction.followup.send(
            t("en", "dev.expiry_too_soon", now=now, min=now + one_month, provided=unix_ts),
            ephemeral=True
        )
        return

    # Insert into manual_entitlements
    try:
        async with aiosqlite.connect(ENTITLEMENTS_DB_PATH) as db:
            await db.execute(
                "CREATE TABLE IF NOT EXISTS manual_entitlements ("
                "guild_id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, "
                "created_at INTEGER DEFAULT (strftime('%s','now')), tier TEXT)"
            )
            # Ensure tier column exists on pre-existing tables.
            try:
                await db.execute("ALTER TABLE manual_entitlements ADD COLUMN tier TEXT")
            except Exception:
                pass  # column already exists
            await db.execute(
                "INSERT INTO manual_entitlements (guild_id, expires_at, created_at, tier) "
                "VALUES (?, ?, ?, ?) "
                "ON CONFLICT(guild_id) DO UPDATE SET "
                "expires_at=excluded.expires_at, created_at=excluded.created_at, tier=excluded.tier",
                (server_id, unix_ts, now, tier)
            )
            await db.commit()
    except Exception as e:
        await interaction.followup.send(t("en", "dev.entitlement_write_failed", error=e), ephemeral=True)
        return

    # Invalidate entitlement cache so it takes effect immediately
    invalidate_entitled_guilds_cache()

    guild = bot.get_guild(int(server_id))
    guild_name = guild.name if guild else "Unknown Server"

    embed = discord.Embed(
        title=t("en", "dev.entitlement_created_title"),
        description=t("en", "dev.entitlement_created_desc", guild_name=guild_name, server_id=server_id, unix_ts=unix_ts, now=now)
                    + f"\nTier: **{tier.title()}**",
        color=discord.Color.green()
    )
    await interaction.followup.send(embed=embed, ephemeral=True)


@entitle_cmd.error
async def entitle_error(interaction, error):
    await permission_fail(interaction, error)


# ── /query — predefined admin queries ───────────────────────────────────

QUERY_CHOICES = [
    # Squadrons (squadrons.db)
    app_commands.Choice(name="All squadrons with points",              value="sq_with_points"),
    app_commands.Choice(name="Top 20 squadrons by rating",            value="sq_top20"),
    app_commands.Choice(name="Squadron member count breakdown",       value="sq_member_counts"),
    app_commands.Choice(name="Total squadrons stored",                value="sq_total"),
    # SQB (sq_battles.db)
    app_commands.Choice(name="Total stored games",                    value="sqb_total_games"),
    app_commands.Choice(name="Games in last 24 hours",                value="sqb_games_24h"),
    app_commands.Choice(name="Games in last 7 days",                  value="sqb_games_7d"),
    app_commands.Choice(name="Games in last 30 days",                 value="sqb_games_30d"),
    app_commands.Choice(name="Most active players (top 20)",          value="sqb_active_players"),
    app_commands.Choice(name="Most played maps (top 15)",             value="sqb_top_maps"),
    app_commands.Choice(name="Spectra receive delay (last 20 games)", value="sqb_receive_delay"),
    # Command usage (COMMAND_DATA.db)
    app_commands.Choice(name="Command usage (top 20)",                value="cmd_top20"),
    app_commands.Choice(name="Command usage (last 24h)",              value="cmd_last24h"),
    app_commands.Choice(name="Command usage by guild (top 15)",       value="cmd_by_guild"),
]


async def _run_query(query_key: str) -> tuple[str, str]:
    """Run a predefined query and return (title, body) strings."""
    now = int(time_module.time())

    # ── Squadrons DB ──
    if query_key == "sq_with_points":
        async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=10.0) as db:
            rows = list(await db.execute_fetchall(
                "SELECT short_name, long_name, clanrating FROM squadrons_data "
                "WHERE clanrating IS NOT NULL AND clanrating > 0 "
                "ORDER BY clanrating DESC"
            ))
        total = len(rows)
        lines = [f"**{i}.** {esc(r[0])} ({esc(r[1])}) — {int(r[2]):,}" for i, r in enumerate(rows[:50], 1)]
        if total > 50:
            lines.append(f"\n*… and {total - 50} more*")
        return f"Squadrons With Points ({total})", "\n".join(lines) or "None"

    if query_key == "sq_top20":
        async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=10.0) as db:
            rows = list(await db.execute_fetchall(
                "SELECT short_name, long_name, clanrating, members FROM squadrons_data "
                "WHERE clanrating IS NOT NULL AND clanrating > 0 "
                "ORDER BY clanrating DESC LIMIT 20"
            ))
        lines = [f"**{i}.** {esc(r[0])} ({esc(r[1])}) — {int(r[2]):,} pts, {r[3] or '?'} members"
                 for i, r in enumerate(rows, 1)]
        return "Top 20 Squadrons by Rating", "\n".join(lines) or "No data"

    if query_key == "sq_member_counts":
        async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=10.0) as db:
            rows = list(await db.execute_fetchall(
                "SELECT sd.short_name, COUNT(sm.uid) as cnt "
                "FROM squadrons_data sd "
                "LEFT JOIN squadron_members sm ON sd.clan_id = sm.clan_id "
                "GROUP BY sd.clan_id "
                "ORDER BY cnt DESC LIMIT 25"
            ))
        lines = [f"**{i}.** {esc(r[0])} — {r[1]} members" for i, r in enumerate(rows, 1)]
        return "Squadron Member Counts (Top 25)", "\n".join(lines) or "No data"

    if query_key == "sq_total":
        async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=10.0) as db:
            row_total = list(await db.execute_fetchall("SELECT COUNT(*) FROM squadrons_data"))
            row_pts = list(await db.execute_fetchall(
                "SELECT COUNT(*) FROM squadrons_data WHERE clanrating IS NOT NULL AND clanrating > 0"
            ))
            row_members = list(await db.execute_fetchall("SELECT COUNT(*) FROM squadron_members"))
        body = (
            f"**Total squadrons stored:** {row_total[0][0]:,}\n"
            f"**With points (rating > 0):** {row_pts[0][0]:,}\n"
            f"**Total members tracked:** {row_members[0][0]:,}"
        )
        return "Squadrons DB Summary", body

    # ── SQ Battles DB ──
    if query_key == "sqb_total_games":
        async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db:
            r_matches = list(await db.execute_fetchall("SELECT COUNT(*) FROM match_summary"))
            r_entries = list(await db.execute_fetchall("SELECT COUNT(*) FROM player_games_hist"))
            r_players = list(await db.execute_fetchall("SELECT COUNT(DISTINCT UID) FROM player_games_hist"))
        body = (
            f"**Total matches:** {r_matches[0][0]:,}\n"
            f"**Total player-vehicle entries:** {r_entries[0][0]:,}\n"
            f"**Unique players:** {r_players[0][0]:,}"
        )
        return "SQ Battles DB Summary", body

    if query_key.startswith("sqb_games_"):
        window_map = {"sqb_games_24h": (86400, "24 Hours"), "sqb_games_7d": (604800, "7 Days"), "sqb_games_30d": (2592000, "30 Days")}
        seconds, label = window_map[query_key]
        cutoff = now - seconds
        async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db:
            r_matches = list(await db.execute_fetchall(
                "SELECT COUNT(*) FROM match_summary WHERE endtime_unix >= ?", (cutoff,)
            ))
            r_entries = list(await db.execute_fetchall(
                "SELECT COUNT(*) FROM player_games_hist WHERE endtime_unix >= ?", (cutoff,)
            ))
            r_players = list(await db.execute_fetchall(
                "SELECT COUNT(DISTINCT UID) FROM player_games_hist WHERE endtime_unix >= ?", (cutoff,)
            ))
        body = (
            f"**Matches:** {r_matches[0][0]:,}\n"
            f"**Player-vehicle entries:** {r_entries[0][0]:,}\n"
            f"**Unique players:** {r_players[0][0]:,}"
        )
        return f"SQ Battles — Last {label}", body

    if query_key == "sqb_active_players":
        async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db:
            rows = list(await db.execute_fetchall(
                "SELECT nick, COUNT(DISTINCT session_id) as games "
                "FROM player_games_hist "
                "GROUP BY UID "
                "ORDER BY games DESC LIMIT 20"
            ))
        lines = [f"**{i}.** {esc(r[0])} — {r[1]:,} games" for i, r in enumerate(rows, 1)]
        return "Most Active Players (All Time)", "\n".join(lines) or "No data"

    if query_key == "sqb_top_maps":
        async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db:
            rows = list(await db.execute_fetchall(
                "SELECT map_name, COUNT(*) as cnt "
                "FROM match_summary "
                "WHERE map_name IS NOT NULL AND map_name != '' "
                "GROUP BY map_name "
                "ORDER BY cnt DESC LIMIT 15"
            ))
        lines = [f"**{i}.** {esc(r[0])} — {r[1]:,} games" for i, r in enumerate(rows, 1)]
        return "Most Played Maps", "\n".join(lines) or "No data"

    if query_key == "sqb_receive_delay":
        async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db:
            rows = list(await db.execute_fetchall(
                "SELECT session_id, endtime_unix, received_unix "
                "FROM match_summary "
                "WHERE received_unix IS NOT NULL AND endtime_unix IS NOT NULL "
                "ORDER BY endtime_unix DESC LIMIT 20"
            ))
        if not rows:
            return (
                "Spectra Receive Delay (Last 20 Games)",
                "No data yet — `received_unix` is only populated for games processed after this build.",
            )
        delays = [int(r[2]) - int(r[1]) for r in rows]
        lines = []
        for r, d in zip(rows, delays):
            sid = str(r[0])
            end_str = datetime.fromtimestamp(int(r[1]), tz=timezone.utc).strftime("%m-%d %H:%M:%S")
            mins, secs = divmod(max(d, 0), 60)
            lines.append(f"`{sid}` end {end_str}Z → +{mins}m{secs:02d}s")
        avg = sum(delays) / len(delays)
        a_min, a_sec = divmod(int(avg), 60)
        m_min, m_sec = divmod(min(delays), 60)
        x_min, x_sec = divmod(max(delays), 60)
        header = (
            f"**Avg:** {a_min}m{a_sec:02d}s • "
            f"**Min:** {m_min}m{m_sec:02d}s • "
            f"**Max:** {x_min}m{x_sec:02d}s • "
            f"**N:** {len(rows)}\n\n"
        )
        return "Spectra Receive Delay (Last 20 Games)", header + "\n".join(lines)

    # ── Command Usage (COMMAND_DATA.db) ──
    if query_key == "cmd_top20":
        async with aiosqlite.connect(COMMAND_DATA_DB_PATH, timeout=10.0) as db:
            rows = list(await db.execute_fetchall(
                "SELECT command_name, COUNT(*) as cnt "
                "FROM command_usage "
                "GROUP BY command_name "
                "ORDER BY cnt DESC LIMIT 20"
            ))
        lines = [f"**{i}.** /{r[0]} — {r[1]:,} uses" for i, r in enumerate(rows, 1)]
        return "Command Usage (All Time, Top 20)", "\n".join(lines) or "No data yet"

    if query_key == "cmd_last24h":
        cutoff = now - 86400
        async with aiosqlite.connect(COMMAND_DATA_DB_PATH, timeout=10.0) as db:
            rows = list(await db.execute_fetchall(
                "SELECT command_name, COUNT(*) as cnt "
                "FROM command_usage "
                "WHERE timestamp >= ? "
                "GROUP BY command_name "
                "ORDER BY cnt DESC LIMIT 20",
                (cutoff,)
            ))
            r_total = list(await db.execute_fetchall(
                "SELECT COUNT(*) FROM command_usage WHERE timestamp >= ?", (cutoff,)
            ))
        total = r_total[0][0] if r_total else 0
        lines = [f"**{i}.** /{r[0]} — {r[1]:,} uses" for i, r in enumerate(rows, 1)]
        header = f"**Total invocations (24h):** {total:,}\n\n"
        return "Command Usage (Last 24h)", header + ("\n".join(lines) or "No data yet")

    if query_key == "cmd_by_guild":
        async with aiosqlite.connect(COMMAND_DATA_DB_PATH, timeout=10.0) as db:
            rows = list(await db.execute_fetchall(
                "SELECT guild_id, COUNT(*) as cnt "
                "FROM command_usage "
                "WHERE guild_id IS NOT NULL "
                "GROUP BY guild_id "
                "ORDER BY cnt DESC LIMIT 15"
            ))
        lines = []
        for i, r in enumerate(rows, 1):
            gid = r[0]
            guild = bot.get_guild(int(gid)) if gid else None
            name = esc(guild.name) if guild else f"ID: {gid}"
            lines.append(f"**{i}.** {name} — {r[1]:,} uses")
        return "Command Usage by Guild (Top 15)", "\n".join(lines) or "No data yet"

    return "Unknown Query", "Query not recognised."


@is_blacklisted()
@bot.tree.command(name="query", description="[DEV] Run predefined database queries")
@app_commands.describe(query="Select a query to run")
@app_commands.choices(query=QUERY_CHOICES)
async def query_cmd(interaction: discord.Interaction, query: app_commands.Choice[str]):
    """Run a predefined read-only database query. Restricted to dev team and bot owner."""
    await collect_command_stats(interaction)
    if not await is_dev_team(interaction):
        await interaction.response.send_message(t("en", "dev.restricted_dev_team"), ephemeral=True)
        return

    await interaction.response.defer(ephemeral=True)

    try:
        title, body = await _run_query(query.value)
    except Exception as e:
        logging.error(f"[/query] Error running query '{query.value}': {e}", exc_info=True)
        await interaction.followup.send(t("en", "dev.query_failed", error=e), ephemeral=True)
        return

    # Discord embed description limit is 4096 chars
    if len(body) > 4000:
        body = body[:3997] + "…"

    embed = discord.Embed(title=title, description=body, color=discord.Color.blurple())
    embed.set_footer(text=f"{t('en', 'dev.query_prefix', name=query.name)} • {DEFAULT_FOOTER_CAT}")
    await interaction.followup.send(embed=embed, ephemeral=True)


@query_cmd.error
async def query_error(interaction, error):
    await permission_fail(interaction, error)


if __name__ == "__main__":
    # Make sure required secrets are available
    for key in ['DISCORD_KEY', 'DEEPL_KEY']:
        if key not in os.environ:
            print(f"Warning: {key} environment variable is not set")

    if TOKEN != None:
        bot.run(TOKEN)
