"""
stack_manager.py

Stack management feature: leaders create a persistent embed, players apply to join,
leader accepts/declines applicants.
"""

# Standard Library Imports
import json
import time
from pathlib import Path

# Third-Party Library Imports
import discord
from discord import app_commands
from discord.ext import commands

# Local Module Imports
from .utils import (
    DEFAULT_FOOTER_CAT,
    STACKS_DIR,
    load_json,
    write_json,
    is_blacklisted,
    gate_entitle,
    permission_fail,
    esc,
    collect_command_stats,
    command_locale,
)
from .utils import t, guild_lang

MAX_MEMBERS = 8
MAX_REQUESTERS = 20


# ============================================================================
# HELPERS
# ============================================================================

def get_stack_path(guild_id: str, leader_discord_id: str) -> Path:
    """Return the filesystem Path for a stack's JSON file.

    Args:
        guild_id: Discord guild ID string.
        leader_discord_id: Discord user ID string of the stack leader.

    Returns:
        Path object pointing to the stack JSON file.
    """
    return STACKS_DIR / f"{guild_id}-{leader_discord_id}.json"


async def load_stack(guild_id: str, leader_discord_id: str) -> dict | None:
    """Load a stack's data from its JSON file asynchronously.

    Args:
        guild_id: Discord guild ID string.
        leader_discord_id: Discord user ID string of the stack leader.

    Returns:
        The parsed stack dict, or None if the file does not exist or is empty.
    """
    path = get_stack_path(guild_id, leader_discord_id)
    if not path.exists():
        return None
    data = await load_json(path, None)
    return data if data else None


def load_stack_sync(guild_id: str, leader_discord_id: str) -> dict | None:
    """Sync version for use in __init__ methods that can't be async."""
    path = get_stack_path(guild_id, leader_discord_id)
    if not path.exists():
        return None
    try:
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        return data if data else None
    except Exception:
        return None


async def save_stack(data: dict) -> bool:
    """Persist a stack's data dict to its JSON file.

    Args:
        data: Stack data dict containing at least guild_id and leader_discord_id.

    Returns:
        True on success, False on failure.
    """
    path = get_stack_path(data["guild_id"], data["leader_discord_id"])
    STACKS_DIR.mkdir(parents=True, exist_ok=True)
    return await write_json(path, data)


def build_stack_embed(data: dict, lang: str = "en") -> discord.Embed:
    """Build the public-facing Discord embed for a stack.

    Shows the stack name, member list with vehicles, leader star indicator,
    and any pending applicants in the queue.

    Args:
        data: Stack data dict with members, requesters, leader info, and name.
        lang: Language code for i18n.

    Returns:
        A discord.Embed ready to send or edit into a message.
    """
    leader_nick = esc(data["leader_nick"])
    members = data.get("members", [])
    requesters = data.get("requesters", [])

    embed = discord.Embed(
        title=data.get("name") or t(lang, "stacks.stack_title", leader=data['leader_nick']),
        color=discord.Color.blurple(),
    )

    if members:
        lines = []
        for m in members:
            nick = esc(m['nick'])
            vehicle = esc(m['vehicle'])
            if m["discord_id"] == data["leader_discord_id"]:
                lines.append(f"{nick} — {vehicle}  (⭐)")
            else:
                lines.append(f"{nick} — {vehicle}")
        members_text = "\n".join(lines)
    else:
        members_text = t(lang, "stacks.no_members")

    embed.add_field(
        name=t(lang, "stacks.members_field", count=len(members), max=MAX_MEMBERS),
        value=members_text,
        inline=False,
    )

    if requesters:
        req_lines = [
            f"{esc(r['nick'])} — {esc(r['vehicle'])}"
            for r in requesters
        ]
        embed.add_field(
            name=t(lang, "stacks.queue_field", count=len(requesters), max=MAX_REQUESTERS),
            value="\n".join(req_lines),
            inline=False,
        )

    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    return embed


def _build_manage_embed(data: dict, lang: str = "en") -> discord.Embed:
    """Build the ephemeral management embed showing members and queue.

    Args:
        data: Stack data dict with members and requesters lists.
        lang: Language code for i18n.

    Returns:
        A discord.Embed for the leader's management panel.
    """
    requesters = data.get("requesters", [])
    members = data.get("members", [])
    embed = discord.Embed(title=t(lang, "stacks.manage_title"), color=discord.Color.blurple())
    embed.add_field(
        name=t(lang, "stacks.members_field", count=len(members), max=MAX_MEMBERS),
        value="\n".join(
            f"{esc(m['nick'])} — {esc(m['vehicle'])}"
            for m in members
        ) or t(lang, "common.none_option"),
        inline=False,
    )
    embed.add_field(
        name=t(lang, "stacks.queue_field", count=len(requesters), max=MAX_REQUESTERS),
        value="\n".join(
            f"{esc(r['nick'])} — {esc(r['vehicle'])}"
            for r in requesters
        ) or t(lang, "stacks.no_pending_requests"),
        inline=False,
    )
    embed.set_footer(text=DEFAULT_FOOTER_CAT)
    return embed


async def _do_transfer(
    data: dict,
    new_leader_id: str,
    public_stack_view: "StackView",
    lang: str = "en",
) -> str | None:
    """
    Transfer stack ownership to new_leader_id.
    Renames the JSON file, updates public_stack_view in place.
    Returns an error string on failure, or None on success.
    """
    guild_id = data["guild_id"]
    old_leader_id = data["leader_discord_id"]

    new_leader = next((m for m in data["members"] if m["discord_id"] == new_leader_id), None)
    if new_leader is None:
        return t(lang, "stacks.member_not_exists")

    new_path = get_stack_path(guild_id, new_leader_id)
    if new_path.exists():
        return t(lang, "stacks.already_has_stack")

    old_path = get_stack_path(guild_id, old_leader_id)

    data["leader_discord_id"] = new_leader["discord_id"]
    data["leader_nick"] = new_leader["nick"]
    data["leader_vehicle"] = new_leader["vehicle"]
    data["members"] = [m for m in data["members"] if m["discord_id"] != old_leader_id]

    await save_stack(data)          # writes to new path
    old_path.unlink(missing_ok=True)

    public_stack_view.leader_discord_id = new_leader_id
    return None


async def refresh_stack_message(view: "StackView") -> None:
    """Re-render and edit the public stack message with current data.

    Args:
        view: The StackView whose associated message should be refreshed.
    """
    if view.message is None:
        return
    data = await load_stack(view.guild_id, view.leader_discord_id)
    if data is None:
        return
    embed = build_stack_embed(data, lang=view.lang)
    await view.message.edit(embed=embed, view=view)


# ============================================================================
# MODAL
# ============================================================================

class JoinStackModal(discord.ui.Modal, title="Apply to Join Stack"):
    """Modal presented to users requesting to join a stack.

    Collects the vehicle the player intends to use and adds them
    to the stack's requester queue upon submission.
    """

    vehicle_input = discord.ui.TextInput(
        label="What will you play?",
        placeholder="e.g. F-16C, WZ305...",
        max_length=100,
        required=True,
    )

    def __init__(self, guild_id: str, leader_discord_id: str, stack_view: "StackView", lang: str = "en"):
        super().__init__()
        self.guild_id = guild_id
        self.leader_discord_id = leader_discord_id
        self.stack_view = stack_view
        self.lang = lang
        self.title = t(lang, "stacks.join_modal_title")
        self.vehicle_input.label = t(lang, "stacks.join_vehicle_label")
        self.vehicle_input.placeholder = t(lang, "stacks.join_vehicle_placeholder")

    async def on_submit(self, interaction: discord.Interaction) -> None:
        """Validate the application and append the user to the stack's request queue."""
        data = await load_stack(self.guild_id, self.leader_discord_id)
        if data is None:
            await interaction.response.send_message(
                t(self.lang, "stacks.no_longer_exists"), ephemeral=True
            )
            return

        user_id = str(interaction.user.id)
        nick = interaction.user.display_name
        vehicle = self.vehicle_input.value.strip()

        if any(m["discord_id"] == user_id for m in data["members"]):
            await interaction.response.send_message(
                t(self.lang, "stacks.already_member"), ephemeral=True
            )
            return

        if any(r["discord_id"] == user_id for r in data["requesters"]):
            await interaction.response.send_message(
                t(self.lang, "stacks.already_applied"), ephemeral=True
            )
            return

        if len(data["requesters"]) >= MAX_REQUESTERS:
            await interaction.response.send_message(
                t(self.lang, "stacks.queue_full", max=MAX_REQUESTERS),
                ephemeral=True,
            )
            return

        data["requesters"].append({"discord_id": user_id, "nick": nick, "vehicle": vehicle})
        await save_stack(data)

        await refresh_stack_message(self.stack_view)

        await interaction.response.send_message(
            t(self.lang, "stacks.application_sent"), ephemeral=True
        )


# ============================================================================
# DISBAND CONFIRMATION VIEW
# ============================================================================

class DisbandConfirmView(discord.ui.View):
    """Ephemeral confirmation view with Yes/Cancel buttons for disbanding a stack."""

    def __init__(self, stack_view: "StackView", lang: str = "en"):
        super().__init__(timeout=30)
        self.stack_view = stack_view
        self.lang = lang
        self.confirm.label = t(lang, "buttons.yes_disband")
        self.cancel.label = t(lang, "buttons.cancel")

    @discord.ui.button(label="Yes, Disband", style=discord.ButtonStyle.red)
    async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
        await interaction.response.edit_message(
            content=t(self.lang, "stacks.stack_disbanded"), embed=None, view=None
        )
        path = get_stack_path(self.stack_view.guild_id, self.stack_view.leader_discord_id)
        path.unlink(missing_ok=True)

        embed = discord.Embed(
            title=t(self.lang, "stacks.disbanded_title"),
            description=t(self.lang, "stacks.disbanded_desc"),
            color=discord.Color.dark_red(),
        )
        embed.set_footer(text=DEFAULT_FOOTER_CAT)
        if self.stack_view.message:
            await self.stack_view.message.edit(embed=embed, view=None)
        self.stack_view.stop()

    @discord.ui.button(label="Cancel", style=discord.ButtonStyle.grey)
    async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
        await interaction.response.edit_message(content=t(self.lang, "stacks.cancelled"), embed=None, view=None)


# ============================================================================
# TRANSFER LEAVE VIEW (ephemeral, leader-only)
# ============================================================================

class TransferLeaveView(discord.ui.View):
    """Shown when the leader clicks Leave / Withdraw and other members exist."""

    def __init__(self, guild_id: str, leader_discord_id: str, public_stack_view: "StackView", lang: str = "en"):
        super().__init__(timeout=60)
        self.guild_id = guild_id
        self.leader_discord_id = leader_discord_id
        self.public_stack_view = public_stack_view
        self.lang = lang
        self.selected_uid: str | None = None

        data = load_stack_sync(guild_id, leader_discord_id)
        non_leader = [
            m for m in (data.get("members", []) if data else [])
            if m["discord_id"] != leader_discord_id
        ]
        options = [
            discord.SelectOption(
                label=f"{m['nick']} — {m['vehicle']}"[:100],
                value=m["discord_id"],
            )
            for m in non_leader
        ]
        select = discord.ui.Select(placeholder=t(lang, "stacks.select_new_leader"), options=options)
        select.callback = self._on_select
        self.add_item(select)

        transfer_btn = discord.ui.Button(label=t(lang, "buttons.transfer_leave"), style=discord.ButtonStyle.blurple)
        transfer_btn.callback = self._on_transfer
        self.add_item(transfer_btn)

    async def _on_select(self, interaction: discord.Interaction) -> None:
        if interaction.data:
            self.selected_uid = interaction.data["values"][0]  # type: ignore[typeddict-item]
        await interaction.response.defer()

    async def _on_transfer(self, interaction: discord.Interaction) -> None:
        if not self.selected_uid:
            await interaction.response.send_message(
                t(self.lang, "stacks.select_member_transfer"), ephemeral=True
            )
            return

        data = await load_stack(self.guild_id, self.leader_discord_id)
        if data is None:
            await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True)
            return

        new_nick = next(
            (m["nick"] for m in data["members"] if m["discord_id"] == self.selected_uid), "?"
        )
        error = await _do_transfer(data, self.selected_uid, self.public_stack_view, lang=self.lang)
        if error:
            await interaction.response.send_message(error, ephemeral=True)
            return

        await refresh_stack_message(self.public_stack_view)
        await interaction.response.edit_message(
            content=t(self.lang, "stacks.ownership_transferred", nick=esc(new_nick)),
            view=None,
        )


# ============================================================================
# MANAGE SUB-VIEWS (ephemeral, leader-only)
# ============================================================================

class AcceptMembersView(discord.ui.View):
    """Sub-view: accept or decline applicants from the queue."""

    def __init__(self, guild_id: str, leader_discord_id: str, public_stack_view: "StackView", lang: str = "en"):
        super().__init__(timeout=300)
        self.guild_id = guild_id
        self.leader_discord_id = leader_discord_id
        self.public_stack_view = public_stack_view
        self.lang = lang
        self.selected_requester_uids: list[str] = []

        data = load_stack_sync(guild_id, leader_discord_id)
        requesters = data.get("requesters", []) if data else []

        if requesters:
            req_options = [
                discord.SelectOption(
                    label=f"{r['nick']} — {r['vehicle']}"[:100],
                    value=r["discord_id"],
                )
                for r in requesters
            ]
            req_select = discord.ui.Select(
                placeholder=t(lang, "stacks.select_applicants"),
                options=req_options,
                min_values=1,
                max_values=len(req_options),
            )
            req_select.callback = self._on_requester_select
            self.add_item(req_select)

            accept_btn = discord.ui.Button(label=t(lang, "buttons.accept_selected"), style=discord.ButtonStyle.green)
            accept_btn.callback = self._on_accept
            self.add_item(accept_btn)

            accept_all_btn = discord.ui.Button(label=t(lang, "buttons.accept_all"), style=discord.ButtonStyle.green)
            accept_all_btn.callback = self._on_accept_all
            self.add_item(accept_all_btn)

            decline_btn = discord.ui.Button(label=t(lang, "buttons.decline_selected"), style=discord.ButtonStyle.red)
            decline_btn.callback = self._on_decline
            self.add_item(decline_btn)
        else:
            disabled_req = discord.ui.Select(
                placeholder=t(lang, "stacks.no_pending_applications"),
                options=[discord.SelectOption(label="—", value="none")],
                disabled=True,
            )
            self.add_item(disabled_req)

        back_btn = discord.ui.Button(label=t(lang, "buttons.back"), style=discord.ButtonStyle.grey)
        back_btn.callback = self._on_back
        self.add_item(back_btn)

    async def _on_requester_select(self, interaction: discord.Interaction) -> None:
        if interaction.data:
            self.selected_requester_uids = interaction.data["values"]  # type: ignore[typeddict-item]
        await interaction.response.defer()

    async def _on_accept(self, interaction: discord.Interaction) -> None:
        if not self.selected_requester_uids:
            await interaction.response.send_message(
                t(self.lang, "stacks.select_applicant_first"), ephemeral=True
            )
            return

        data = await load_stack(self.guild_id, self.leader_discord_id)
        if data is None:
            await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True)
            return

        available_slots = MAX_MEMBERS - len(data["members"])
        to_accept_ids = self.selected_requester_uids[:available_slots]

        if not to_accept_ids:
            await interaction.response.send_message(
                t(self.lang, "stacks.stack_full", max=MAX_MEMBERS), ephemeral=True
            )
            return

        accepted = [r for r in data["requesters"] if r["discord_id"] in to_accept_ids]
        data["members"].extend(accepted)
        accepted_ids = {r["discord_id"] for r in accepted}
        data["requesters"] = [r for r in data["requesters"] if r["discord_id"] not in accepted_ids]
        await save_stack(data)
        self.selected_requester_uids = []

        await refresh_stack_message(self.public_stack_view)

        new_view = AcceptMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang)
        await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view)

    async def _on_accept_all(self, interaction: discord.Interaction) -> None:
        data = await load_stack(self.guild_id, self.leader_discord_id)
        if data is None:
            await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True)
            return

        available_slots = MAX_MEMBERS - len(data["members"])
        if available_slots <= 0:
            await interaction.response.send_message(
                t(self.lang, "stacks.stack_full", max=MAX_MEMBERS), ephemeral=True
            )
            return

        to_accept = data["requesters"][:available_slots]
        accepted_ids = {r["discord_id"] for r in to_accept}
        data["members"].extend(to_accept)
        data["requesters"] = [r for r in data["requesters"] if r["discord_id"] not in accepted_ids]
        await save_stack(data)
        self.selected_requester_uids = []

        await refresh_stack_message(self.public_stack_view)

        new_view = AcceptMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang)
        await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view)

    async def _on_decline(self, interaction: discord.Interaction) -> None:
        if not self.selected_requester_uids:
            await interaction.response.send_message(
                t(self.lang, "stacks.select_applicant_first"), ephemeral=True
            )
            return

        data = await load_stack(self.guild_id, self.leader_discord_id)
        if data is None:
            await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True)
            return

        declined_ids = set(self.selected_requester_uids)
        data["requesters"] = [r for r in data["requesters"] if r["discord_id"] not in declined_ids]
        await save_stack(data)
        self.selected_requester_uids = []

        await refresh_stack_message(self.public_stack_view)

        new_view = AcceptMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang)
        await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view)

    async def _on_back(self, interaction: discord.Interaction) -> None:
        data = await load_stack(self.guild_id, self.leader_discord_id)
        new_view = ManageStackView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang)
        await interaction.response.edit_message(
            content=None, embed=_build_manage_embed(data, lang=self.lang) if data else None, view=new_view
        )


class RemoveMembersView(discord.ui.View):
    """Sub-view: remove members and/or requesters by various bulk or selective actions."""

    def __init__(self, guild_id: str, leader_discord_id: str, public_stack_view: "StackView", lang: str = "en"):
        super().__init__(timeout=300)
        self.guild_id = guild_id
        self.leader_discord_id = leader_discord_id
        self.public_stack_view = public_stack_view
        self.lang = lang
        # values are prefixed: "m:{id}" for members, "r:{id}" for requesters
        self.selected_values: list[str] = []

        data = load_stack_sync(guild_id, leader_discord_id)
        non_leader_members = [
            m for m in (data.get("members", []) if data else [])
            if m["discord_id"] != leader_discord_id
        ]
        requesters = data.get("requesters", []) if data else []

        all_options = [
            discord.SelectOption(
                label=f"[Active] {m['nick']} — {m['vehicle']}"[:100],
                value=f"m:{m['discord_id']}",
            )
            for m in non_leader_members
        ] + [
            discord.SelectOption(
                label=f"[Queue] {r['nick']} — {r['vehicle']}"[:100],
                value=f"r:{r['discord_id']}",
            )
            for r in requesters
        ]

        if all_options:
            select = discord.ui.Select(
                placeholder=t(lang, "stacks.select_to_remove"),
                options=all_options,
                min_values=1,
                max_values=len(all_options),
            )
            select.callback = self._on_select
            self.add_item(select)
        else:
            self.add_item(discord.ui.Select(
                placeholder=t(lang, "stacks.no_members_or_applicants"),
                options=[discord.SelectOption(label="—", value="none")],
                disabled=True,
            ))

        remove_all_btn = discord.ui.Button(label=t(lang, "buttons.remove_all"), style=discord.ButtonStyle.red)
        remove_all_btn.callback = self._on_remove_all
        self.add_item(remove_all_btn)

        remove_active_btn = discord.ui.Button(label=t(lang, "buttons.remove_active"), style=discord.ButtonStyle.red)
        remove_active_btn.callback = self._on_remove_active
        self.add_item(remove_active_btn)

        remove_queued_btn = discord.ui.Button(label=t(lang, "buttons.remove_queued"), style=discord.ButtonStyle.red)
        remove_queued_btn.callback = self._on_remove_queued
        self.add_item(remove_queued_btn)

        remove_selected_btn = discord.ui.Button(label=t(lang, "buttons.remove_selected"), style=discord.ButtonStyle.red)
        remove_selected_btn.callback = self._on_remove_selected
        self.add_item(remove_selected_btn)

        back_btn = discord.ui.Button(label=t(lang, "buttons.back"), style=discord.ButtonStyle.grey)
        back_btn.callback = self._on_back
        self.add_item(back_btn)

    async def _on_select(self, interaction: discord.Interaction) -> None:
        if interaction.data:
            self.selected_values = interaction.data["values"]  # type: ignore[typeddict-item]
        await interaction.response.defer()

    async def _apply_removal(
        self,
        interaction: discord.Interaction,
        remove_member_ids: set[str],
        remove_requester_ids: set[str],
    ) -> None:
        data = await load_stack(self.guild_id, self.leader_discord_id)
        if data is None:
            await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True)
            return

        remove_member_ids -= {self.leader_discord_id}
        data["members"] = [m for m in data["members"] if m["discord_id"] not in remove_member_ids]
        data["requesters"] = [r for r in data["requesters"] if r["discord_id"] not in remove_requester_ids]
        await save_stack(data)
        self.selected_values = []

        await refresh_stack_message(self.public_stack_view)

        new_view = RemoveMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang)
        await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view)

    async def _on_remove_all(self, interaction: discord.Interaction) -> None:
        data = await load_stack(self.guild_id, self.leader_discord_id)
        if data is None:
            await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True)
            return
        member_ids = {m["discord_id"] for m in data.get("members", [])}
        requester_ids = {r["discord_id"] for r in data.get("requesters", [])}
        await self._apply_removal(interaction, member_ids, requester_ids)

    async def _on_remove_active(self, interaction: discord.Interaction) -> None:
        data = await load_stack(self.guild_id, self.leader_discord_id)
        if data is None:
            await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True)
            return
        member_ids = {m["discord_id"] for m in data.get("members", [])}
        await self._apply_removal(interaction, member_ids, set())

    async def _on_remove_queued(self, interaction: discord.Interaction) -> None:
        data = await load_stack(self.guild_id, self.leader_discord_id)
        if data is None:
            await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True)
            return
        requester_ids = {r["discord_id"] for r in data.get("requesters", [])}
        await self._apply_removal(interaction, set(), requester_ids)

    async def _on_remove_selected(self, interaction: discord.Interaction) -> None:
        if not self.selected_values:
            await interaction.response.send_message(
                t(self.lang, "stacks.select_person_first"), ephemeral=True
            )
            return
        member_ids = {v[2:] for v in self.selected_values if v.startswith("m:")}
        requester_ids = {v[2:] for v in self.selected_values if v.startswith("r:")}
        await self._apply_removal(interaction, member_ids, requester_ids)

    async def _on_back(self, interaction: discord.Interaction) -> None:
        data = await load_stack(self.guild_id, self.leader_discord_id)
        new_view = ManageStackView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang)
        await interaction.response.edit_message(
            content=None, embed=_build_manage_embed(data, lang=self.lang) if data else None, view=new_view
        )


class PingMessageModal(discord.ui.Modal, title="Ping Message"):
    """Modal for composing an optional custom ping message."""

    message = discord.ui.TextInput(
        label="Custom message (optional)",
        placeholder='e.g. Come now! Stack starting!',
        required=False,
        max_length=200,
    )

    def __init__(
        self,
        guild_id: str,
        leader_discord_id: str,
        ping_type: str,
        selected_uids: list[str] | None = None,
        lang: str = "en",
    ):
        super().__init__()
        self.guild_id = guild_id
        self.leader_discord_id = leader_discord_id
        self.ping_type = ping_type  # "all" | "active" | "queued" | "select"
        self.selected_uids = selected_uids or []
        self.lang = lang
        self.title = t(lang, "stacks.ping_modal_title")
        self.message.label = t(lang, "stacks.ping_message_label")
        self.message.placeholder = t(lang, "stacks.ping_message_placeholder")

    async def on_submit(self, interaction: discord.Interaction) -> None:
        data = await load_stack(self.guild_id, self.leader_discord_id)
        if data is None:
            await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True)
            return

        if self.ping_type == "all":
            targets = (
                [m["discord_id"] for m in data.get("members", []) if m["discord_id"] != self.leader_discord_id]
                + [r["discord_id"] for r in data.get("requesters", [])]
            )
        elif self.ping_type == "active":
            targets = [m["discord_id"] for m in data.get("members", [])]
        elif self.ping_type == "queued":
            targets = [r["discord_id"] for r in data.get("requesters", [])]
        else:
            targets = self.selected_uids

        if not targets:
            await interaction.response.send_message(t(self.lang, "stacks.no_one_to_ping"), ephemeral=True)
            return

        mentions = " ".join(f"<@{uid}>" for uid in targets)
        custom = self.message.value.strip()
        if custom:
            content = f"{mentions} {custom}"
        else:
            stack_name = data.get("name") or "the stack"
            content = f"{mentions}\n-# {t(self.lang, 'stacks.ping_footer', leader=esc(data['leader_nick']), stack=stack_name)}"

        await interaction.response.send_message(t(self.lang, "stacks.pinged"), ephemeral=True)
        await interaction.channel.send(content)  # type: ignore[union-attr]


class PingMembersView(discord.ui.View):
    """Sub-view: ping all, active members, queued applicants, or a custom selection."""

    def __init__(self, guild_id: str, leader_discord_id: str, public_stack_view: "StackView", lang: str = "en"):
        super().__init__(timeout=300)
        self.guild_id = guild_id
        self.leader_discord_id = leader_discord_id
        self.public_stack_view = public_stack_view
        self.lang = lang
        self.selected_uids: list[str] = []

        data = load_stack_sync(guild_id, leader_discord_id)
        members = data.get("members", []) if data else []
        requesters = data.get("requesters", []) if data else []

        all_options = [
            discord.SelectOption(
                label=f"[Active] {m['nick']} — {m['vehicle']}"[:100],
                value=m["discord_id"],
            )
            for m in members
        ] + [
            discord.SelectOption(
                label=f"[Queue] {r['nick']} — {r['vehicle']}"[:100],
                value=r["discord_id"],
            )
            for r in requesters
        ]

        if all_options:
            select = discord.ui.Select(
                placeholder=t(lang, "stacks.select_to_ping"),
                options=all_options,
                min_values=1,
                max_values=len(all_options),
            )
            select.callback = self._on_select
            self.add_item(select)
        else:
            self.add_item(discord.ui.Select(
                placeholder=t(lang, "stacks.no_members_or_applicants"),
                options=[discord.SelectOption(label="—", value="none")],
                disabled=True,
            ))

        ping_all_btn = discord.ui.Button(label=t(lang, "buttons.ping_all"), style=discord.ButtonStyle.green)
        ping_all_btn.callback = self._on_ping_all
        self.add_item(ping_all_btn)

        ping_active_btn = discord.ui.Button(label=t(lang, "buttons.ping_active"), style=discord.ButtonStyle.blurple)
        ping_active_btn.callback = self._on_ping_active
        self.add_item(ping_active_btn)

        ping_queued_btn = discord.ui.Button(label=t(lang, "buttons.ping_queued"), style=discord.ButtonStyle.blurple)
        ping_queued_btn.callback = self._on_ping_queued
        self.add_item(ping_queued_btn)

        ping_select_btn = discord.ui.Button(label=t(lang, "buttons.ping_selected"), style=discord.ButtonStyle.blurple)
        ping_select_btn.callback = self._on_ping_selected
        self.add_item(ping_select_btn)

        back_btn = discord.ui.Button(label=t(lang, "buttons.back"), style=discord.ButtonStyle.grey)
        back_btn.callback = self._on_back
        self.add_item(back_btn)

    async def _on_select(self, interaction: discord.Interaction) -> None:
        if interaction.data:
            self.selected_uids = interaction.data["values"]  # type: ignore[typeddict-item]
        await interaction.response.defer()

    async def _on_ping_all(self, interaction: discord.Interaction) -> None:
        await interaction.response.send_modal(
            PingMessageModal(self.guild_id, self.leader_discord_id, "all", lang=self.lang)
        )

    async def _on_ping_active(self, interaction: discord.Interaction) -> None:
        await interaction.response.send_modal(
            PingMessageModal(self.guild_id, self.leader_discord_id, "active", lang=self.lang)
        )

    async def _on_ping_queued(self, interaction: discord.Interaction) -> None:
        await interaction.response.send_modal(
            PingMessageModal(self.guild_id, self.leader_discord_id, "queued", lang=self.lang)
        )

    async def _on_ping_selected(self, interaction: discord.Interaction) -> None:
        if not self.selected_uids:
            await interaction.response.send_message(
                t(self.lang, "stacks.select_from_dropdown"), ephemeral=True
            )
            return
        await interaction.response.send_modal(
            PingMessageModal(self.guild_id, self.leader_discord_id, "select", self.selected_uids, lang=self.lang)
        )

    async def _on_back(self, interaction: discord.Interaction) -> None:
        data = await load_stack(self.guild_id, self.leader_discord_id)
        new_view = ManageStackView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang)
        await interaction.response.edit_message(
            content=None, embed=_build_manage_embed(data, lang=self.lang) if data else None, view=new_view
        )


class RenameStackModal(discord.ui.Modal, title="Rename Stack"):
    """Modal for the stack leader to rename their stack."""

    name = discord.ui.TextInput(
        label="Stack name",
        placeholder="e.g. Night Owls, Alpha Squad...",
        max_length=50,
        required=True,
    )

    def __init__(self, guild_id: str, leader_discord_id: str, public_stack_view: "StackView", lang: str = "en"):
        super().__init__()
        self.guild_id = guild_id
        self.leader_discord_id = leader_discord_id
        self.public_stack_view = public_stack_view
        self.lang = lang
        self.title = t(lang, "stacks.rename_modal_title")
        self.name.label = t(lang, "stacks.rename_label")
        self.name.placeholder = t(lang, "stacks.rename_placeholder")

    async def on_submit(self, interaction: discord.Interaction) -> None:
        data = await load_stack(self.guild_id, self.leader_discord_id)
        if data is None:
            await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True)
            return

        data["name"] = self.name.value.strip()
        await save_stack(data)

        await refresh_stack_message(self.public_stack_view)
        await interaction.response.send_message(
            t(self.lang, "stacks.stack_renamed", name=data['name']), ephemeral=True
        )


# ============================================================================
# MANAGE VIEW (ephemeral, leader-only)
# ============================================================================

class ManageStackView(discord.ui.View):
    """Entry view with 4 navigation buttons for stack management.

    Buttons: Accept Members, Remove Members, Ping Members, Rename Stack.
    """

    def __init__(self, guild_id: str, leader_discord_id: str, public_stack_view: "StackView", lang: str = "en"):
        super().__init__(timeout=300)
        self.guild_id = guild_id
        self.leader_discord_id = leader_discord_id
        self.public_stack_view = public_stack_view
        self.lang = lang
        self.accept_btn.label = t(lang, "buttons.accept_members")
        self.remove_btn.label = t(lang, "buttons.remove_members")
        self.ping_btn.label = t(lang, "buttons.ping_members")
        self.rename_btn.label = t(lang, "buttons.rename_stack")

    @discord.ui.button(label="Accept Members", style=discord.ButtonStyle.green)
    async def accept_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
        data = await load_stack(self.guild_id, self.leader_discord_id)
        if data is None:
            await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True)
            return
        new_view = AcceptMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang)
        await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view)

    @discord.ui.button(label="Remove Members", style=discord.ButtonStyle.red)
    async def remove_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
        data = await load_stack(self.guild_id, self.leader_discord_id)
        if data is None:
            await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True)
            return
        new_view = RemoveMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang)
        await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view)

    @discord.ui.button(label="Ping Members", style=discord.ButtonStyle.blurple)
    async def ping_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
        data = await load_stack(self.guild_id, self.leader_discord_id)
        if data is None:
            await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True)
            return
        new_view = PingMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang)
        await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view)

    @discord.ui.button(label="Rename Stack", style=discord.ButtonStyle.grey)
    async def rename_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
        await interaction.response.send_modal(
            RenameStackModal(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang)
        )


# ============================================================================
# PUBLIC STACK VIEW
# ============================================================================

class StackView(discord.ui.View):
    """Public persistent view attached to the stack embed message.

    Provides Join, Leave, Manage, and Disband buttons. Times out after
    8 hours, at which point the stack is automatically cleaned up.
    """

    def __init__(self, guild_id: str, leader_discord_id: str, lang: str = "en"):
        super().__init__(timeout=28800)  # 8 hours
        self.guild_id = guild_id
        self.leader_discord_id = leader_discord_id
        self.lang = lang
        self.message: discord.Message | None = None
        self.join_btn.label = t(lang, "buttons.request_to_join")
        self.leave_btn.label = t(lang, "buttons.leave_withdraw")
        self.manage_btn.label = t(lang, "buttons.manage_stack")
        self.disband_btn.label = t(lang, "buttons.disband_stack")

    @discord.ui.button(label="Request to Join", style=discord.ButtonStyle.green)
    async def join_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
        """Open the JoinStackModal for the user to submit a join application."""
        data = await load_stack(self.guild_id, self.leader_discord_id)
        if data is None:
            await interaction.response.send_message(
                t(self.lang, "stacks.no_longer_exists"), ephemeral=True
            )
            return

        await interaction.response.send_modal(
            JoinStackModal(self.guild_id, self.leader_discord_id, self, lang=self.lang)
        )

    @discord.ui.button(label="Leave / Withdraw", style=discord.ButtonStyle.grey)
    async def leave_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
        """Remove the user from members or requesters, or prompt ownership transfer if leader."""
        user_id = str(interaction.user.id)

        if user_id == self.leader_discord_id:
            data = await load_stack(self.guild_id, self.leader_discord_id)
            non_leader = [
                m for m in (data.get("members", []) if data else [])
                if m["discord_id"] != self.leader_discord_id
            ]
            if not non_leader:
                await interaction.response.send_message(
                    t(self.lang, "stacks.only_member_use_disband"),
                    ephemeral=True,
                )
                return
            await interaction.response.send_message(
                t(self.lang, "stacks.select_transfer_prompt"),
                view=TransferLeaveView(self.guild_id, self.leader_discord_id, self, lang=self.lang),
                ephemeral=True,
            )
            return

        data = await load_stack(self.guild_id, self.leader_discord_id)
        if data is None:
            await interaction.response.send_message(
                t(self.lang, "stacks.no_longer_exists"), ephemeral=True
            )
            return

        if any(m["discord_id"] == user_id for m in data["members"]):
            data["members"] = [m for m in data["members"] if m["discord_id"] != user_id]
            await save_stack(data)
            await refresh_stack_message(self)
            await interaction.response.send_message(t(self.lang, "stacks.left_stack"), ephemeral=True)
            return

        if any(r["discord_id"] == user_id for r in data["requesters"]):
            data["requesters"] = [r for r in data["requesters"] if r["discord_id"] != user_id]
            await save_stack(data)
            await refresh_stack_message(self)
            await interaction.response.send_message(
                t(self.lang, "stacks.application_withdrawn"), ephemeral=True
            )
            return

        await interaction.response.send_message(
            t(self.lang, "stacks.not_member_or_applicant"), ephemeral=True
        )

    @discord.ui.button(label="Manage Stack ⚙️", style=discord.ButtonStyle.blurple)
    async def manage_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
        """Open the ephemeral management panel (leader-only)."""
        if interaction.user.id != int(self.leader_discord_id):
            await interaction.response.send_message(
                t(self.lang, "stacks.leader_only_manage"), ephemeral=True
            )
            return

        data = await load_stack(self.guild_id, self.leader_discord_id)
        if data is None:
            await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True)
            return

        manage_view = ManageStackView(self.guild_id, self.leader_discord_id, self, lang=self.lang)
        await interaction.response.send_message(
            embed=_build_manage_embed(data, lang=self.lang), view=manage_view, ephemeral=True
        )

    @discord.ui.button(label="Disband Stack", style=discord.ButtonStyle.red)
    async def disband_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
        """Show a disband confirmation prompt (leader-only)."""
        if interaction.user.id != int(self.leader_discord_id):
            await interaction.response.send_message(
                t(self.lang, "stacks.leader_only_disband"), ephemeral=True
            )
            return

        await interaction.response.send_message(
            t(self.lang, "stacks.confirm_disband"),
            view=DisbandConfirmView(self, lang=self.lang),
            ephemeral=True,
        )

    async def on_timeout(self) -> None:
        path = get_stack_path(self.guild_id, self.leader_discord_id)
        try:
            path.unlink(missing_ok=True)
        except Exception:
            pass

        if self.message is None:
            return
        try:
            embed = discord.Embed(
                title=t(self.lang, "stacks.expired_title"),
                description=t(self.lang, "stacks.expired_desc"),
                color=discord.Color.dark_grey(),
            )
            embed.set_footer(text=DEFAULT_FOOTER_CAT)
            await self.message.edit(embed=embed, view=None)
        except Exception:
            pass


# ============================================================================
# FORCE CREATE VIEW (ephemeral, used when duplicate stack exists)
# ============================================================================

class ForceCreateView(discord.ui.View):
    """Shown when the user already has a stack JSON — lets them nuke it and start fresh."""

    def __init__(self, guild_id: str, leader_id: str, nick: str, vehicle: str, channel_id: str, lang: str = "en"):
        super().__init__(timeout=30)
        self.guild_id = guild_id
        self.leader_id = leader_id
        self.nick = nick
        self.vehicle = vehicle
        self.channel_id = channel_id
        self.lang = lang
        self.confirm.label = t(lang, "buttons.force_disband_create")
        self.cancel.label = t(lang, "buttons.cancel")

    @discord.ui.button(label="Force Disband & Create New", style=discord.ButtonStyle.red)
    async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
        get_stack_path(self.guild_id, self.leader_id).unlink(missing_ok=True)

        data = {
            "guild_id": self.guild_id,
            "leader_discord_id": self.leader_id,
            "leader_nick": self.nick,
            "leader_vehicle": self.vehicle,
            "channel_id": self.channel_id,
            "message_id": None,
            "name": None,
            "created_at": int(time.time()),
            "members": [
                {"discord_id": self.leader_id, "nick": self.nick, "vehicle": self.vehicle}
            ],
            "requesters": [],
        }
        STACKS_DIR.mkdir(parents=True, exist_ok=True)
        await save_stack(data)

        view = StackView(self.guild_id, self.leader_id, lang=self.lang)
        embed = build_stack_embed(data, lang=self.lang)
        msg = await interaction.channel.send(embed=embed, view=view)  # type: ignore[union-attr]

        view.message = msg
        data["message_id"] = str(msg.id)
        await save_stack(data)

        await interaction.response.edit_message(
            content=t(self.lang, "stacks.force_created"), view=None
        )

    @discord.ui.button(label="Cancel", style=discord.ButtonStyle.grey)
    async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
        await interaction.response.edit_message(content=t(self.lang, "stacks.cancelled"), view=None)


# ============================================================================
# COMMAND REGISTRATION
# ============================================================================

def register_commands(bot: commands.Bot) -> None:
    """Register the /stack-create and /stack-manage slash commands on the bot.

    Args:
        bot: The Discord bot instance to register commands on.
    """

    @is_blacklisted()
    @gate_entitle("standard")
    @bot.tree.command(name="stack-create", description=command_locale("Create a new player stack", "commands.stack_create.description"))
    @app_commands.describe(vehicle=command_locale("What vehicle will you start with?", "commands.stack_create.vehicle"))
    async def stack_create(interaction: discord.Interaction, vehicle: str) -> None:
        await collect_command_stats(interaction)
        await interaction.response.defer(ephemeral=False)

        guild_id = str(interaction.guild_id)
        lang = await guild_lang(interaction.guild_id) if interaction.guild_id else "en"
        leader_id = str(interaction.user.id)
        nick = interaction.user.display_name

        stack_path = get_stack_path(guild_id, leader_id)
        if stack_path.exists():
            await interaction.followup.send(
                t(lang, "stacks.already_active_stack"),
                view=ForceCreateView(guild_id, leader_id, nick, vehicle.strip(), str(interaction.channel_id), lang=lang),
                ephemeral=True,
            )
            return

        data = {
            "guild_id": guild_id,
            "leader_discord_id": leader_id,
            "leader_nick": nick,
            "leader_vehicle": vehicle.strip(),
            "channel_id": str(interaction.channel_id),
            "message_id": None,
            "name": None,
            "created_at": int(time.time()),
            "members": [
                {"discord_id": leader_id, "nick": nick, "vehicle": vehicle.strip()}
            ],
            "requesters": [],
        }

        STACKS_DIR.mkdir(parents=True, exist_ok=True)
        await save_stack(data)

        view = StackView(guild_id, leader_id, lang=lang)
        embed = build_stack_embed(data, lang=lang)
        msg = await interaction.followup.send(embed=embed, view=view, wait=True)

        view.message = msg
        data["message_id"] = str(msg.id)
        await save_stack(data)

    @stack_create.error
    async def stack_create_error(interaction: discord.Interaction, error: Exception) -> None:
        await permission_fail(interaction, error)

    @is_blacklisted()
    @gate_entitle("standard")
    @bot.tree.command(name="stack-manage", description=command_locale("Re-post your active stack embed to this channel", "commands.stack_manage.description"))
    async def stack_manage(interaction: discord.Interaction) -> None:
        await collect_command_stats(interaction)
        await interaction.response.defer(ephemeral=False)

        guild_id = str(interaction.guild_id)
        lang = await guild_lang(interaction.guild_id) if interaction.guild_id else "en"
        leader_id = str(interaction.user.id)

        data = await load_stack(guild_id, leader_id)
        if data is None:
            await interaction.followup.send(
                t(lang, "stacks.no_active_stack"),
                ephemeral=True,
            )
            return

        view = StackView(guild_id, leader_id, lang=lang)
        embed = build_stack_embed(data, lang=lang)
        msg = await interaction.followup.send(embed=embed, view=view, wait=True)

        view.message = msg
        data["message_id"] = str(msg.id)
        await save_stack(data)

    @stack_manage.error
    async def stack_manage_error(interaction: discord.Interaction, error: Exception) -> None:
        await permission_fail(interaction, error)
