# modules/reaction_role/reaction_role.py
from __future__ import annotations

from datetime import datetime, timezone
from typing import Any, Dict, Optional, Iterable, Tuple
import time

import discord
from discord.ext import commands

from modules.common.emoji_accept import is_accept
from modules.common.settings import cfg  # dynamic settings helper

CHECKMARK = '✅'  # kept for consistency with other flows


# ------------------ small helpers ------------------

def _fmt_dt(ts: float) -> str:
    return discord.utils.format_dt(datetime.fromtimestamp(ts, tz=timezone.utc), style="f")


def _as_list(x: Optional[Iterable]) -> list:
    return list(x) if isinstance(x, Iterable) else []


# ------------------ Fedaykin approval buttons ------------------

class _FedaykinApprovalView(discord.ui.View):
    """Approval buttons for a single Fedaykin request (persistent)."""

    def __init__(self, cog: "ReactionRoleCog", req: Dict[str, Any], *, timeout: Optional[float] = None):
        super().__init__(timeout=timeout)
        self.cog = cog
        self.req = req  # dict persisted via data_manager

    async def interaction_check(self, interaction: discord.Interaction) -> bool:
        # Only Field Mod or Admin may act — and only if a Head exists
        member = interaction.user
        if not isinstance(member, discord.Member):
            await interaction.response.send_message("Server context required.", ephemeral=True)
            return False

        if self.cog._fedaykin_headless or not (interaction.guild and self.cog._has_fedaykin_head(interaction.guild)):
            await interaction.response.send_message(
                "No **Fedaykin Head** is currently appointed. This request remains pending until one is appointed.",
                ephemeral=True
            )
            return False

        allow_roles = {self.cog.field_mod_role_id, self.cog.admin_role_id}
        if not any(r.id in allow_roles for r in getattr(member, "roles", [])):
            await interaction.response.send_message("You are not allowed to decide this request.", ephemeral=True)
            return False
        return True

    async def _finish(self, interaction: discord.Interaction, status: str):
        await self.cog._fedaykin_decide(
            guild=interaction.guild,
            actor=interaction.user if isinstance(interaction.user, discord.Member) else None,
            req=self.req,
            status=status,
            edit_view=True,
        )
        try:
            await interaction.response.send_message(f"{status.capitalize()}.", ephemeral=True)
        except Exception:
            pass

    @discord.ui.button(label="Approve", style=discord.ButtonStyle.success, custom_id="fdk.approve")
    async def _approve(self, interaction: discord.Interaction, button: discord.ui.Button):
        await self._finish(interaction, "approved")

    @discord.ui.button(label="Reject", style=discord.ButtonStyle.danger, custom_id="fdk.reject")
    async def _reject(self, interaction: discord.Interaction, button: discord.ui.Button):
        await self._finish(interaction, "rejected")


# ------------------ Main cog ------------------

class ReactionRoleCog(commands.Cog):
    """
    Original: Records agreements and manages Full Access.
    Added: Crew reaction roles (Harvester/Escort toggles) + Fedaykin approval flow via buttons.
    - No debug slash commands.
    - Debounced nickname review to avoid duplicates when users add multiple accept emojis.
    - Fedaykin role is removed when the user unreacts the Fedaykin emoji.
    - Settings are reloaded dynamically on each event (hot-apply without restart).
    - NEW: Only users with Full Access may claim/request crew roles.
    """

    def __init__(self, bot):
        self.bot = bot

        # Snapshot (will be refreshed dynamically on each event)
        self._settings = {}
        self._hub_channel_id: Optional[int] = None  # cache for crew hub channel

        # Debounce / locks to prevent duplicate nickname review on bursty reactions
        self._nick_debounce: Dict[tuple[int, int], float] = {}  # (guild_id, user_id) -> last_ts
        self._nick_lock: set[tuple[int, int]] = set()           # in-flight review creations

        # Fedaykin head state (global)
        self._fedaykin_headless: bool = False

        # Apply initial snapshot and schedule restore
        self._refresh_settings(force=True)
        self.bot.loop.create_task(self._boot_restore())

    # ------------------ settings ------------------

    def _refresh_settings(self, *, force: bool = False):
        """Hot-read settings on demand; cheap and avoids restart requirement."""
        r = cfg(self.bot)
        s = {
            # Message IDs
            "rules_msg_id": r.int('rules_message_id', 0),
            "engage_msg_id": r.int('engagement_message_id', 0),
            "nick_msg_id": r.int('nickname_message_id', 0),
            "crew_msg_id": r.int('crew_roles_message_id', 0),

            # Role IDs
            "rules_role": r.int('rules_role_id', 0),
            "engage_role": r.int('engagement_role_id', 0),
            "full_access_role": r.int('full_access_role_id', 0),
            "role_harvest_id": r.int('role_harvest_crew_id', 0),
            "role_escort_id": r.int('role_escort_crew_id', 0),
            "role_fedaykin_id": r.int('role_fedaykin_id', 0),

            # Approver roles
            "field_mod_role_id": r.int('field_mod_role_id', 0),
            "admin_role_id": r.int('admin_role_id', 0),

            # Emojis
            "emoji_harvest_id": r.int('emoji_harvester_crew', 0),
            "emoji_escort_id": r.int('emoji_escort_crew', 0),
            "emoji_fedaykin_id": r.int('emoji_fedaykin', 0),

            # Channels
            "report_channel_id": r.int('report_channel_id', 0),
            "mod_channel_id": r.int('mod_channel_id', 0),
            "modlog_channel_id": r.int('modlog_channel_id', 0),
        }
        self._settings = s

    # handy properties (avoid tons of self._settings["x"] everywhere)
    @property
    def rules_msg_id(self) -> int: return self._settings.get("rules_msg_id", 0)
    @property
    def engage_msg_id(self) -> int: return self._settings.get("engage_msg_id", 0)
    @property
    def nick_msg_id(self) -> int: return self._settings.get("nick_msg_id", 0)
    @property
    def crew_msg_id(self) -> int: return self._settings.get("crew_msg_id", 0)

    @property
    def rules_role(self) -> int: return self._settings.get("rules_role", 0)
    @property
    def engage_role(self) -> int: return self._settings.get("engage_role", 0)
    @property
    def full_access_role(self) -> int: return self._settings.get("full_access_role", 0)
    @property
    def role_harvest_id(self) -> int: return self._settings.get("role_harvest_id", 0)
    @property
    def role_escort_id(self) -> int: return self._settings.get("role_escort_id", 0)
    @property
    def role_fedaykin_id(self) -> int: return self._settings.get("role_fedaykin_id", 0)

    @property
    def field_mod_role_id(self) -> int: return self._settings.get("field_mod_role_id", 0)
    @property
    def admin_role_id(self) -> int: return self._settings.get("admin_role_id", 0)

    @property
    def emoji_harvest_id(self) -> int: return self._settings.get("emoji_harvest_id", 0)
    @property
    def emoji_escort_id(self) -> int: return self._settings.get("emoji_escort_id", 0)
    @property
    def emoji_fedaykin_id(self) -> int: return self._settings.get("emoji_fedaykin_id", 0)

    @property
    def report_channel_id(self) -> int: return self._settings.get("report_channel_id", 0)
    @property
    def mod_channel_id(self) -> int: return self._settings.get("mod_channel_id", 0)
    @property
    def modlog_channel_id(self) -> int: return self._settings.get("modlog_channel_id", 0)

    # ------------------ boot & persistence ------------------

    async def _boot_restore(self):
        await self.bot.wait_until_ready()
        self._refresh_settings()
        dm = self.bot.data_manager

        # Ensure list key exists to be safe
        if not isinstance(dm.get('fedaykin_requests'), list):
            try:
                dm.add('fedaykin_requests', {"_init": True})
                dm.remove('fedaykin_requests', lambda x: isinstance(x, dict) and x.get("_init") is True)
            except Exception:
                pass

        # Re-register views for any pending requests
        try:
            for req in _as_list(dm.get('fedaykin_requests')):
                if req.get("status") == "pending" and req.get("review_message_id") and req.get("review_channel_id"):
                    self.bot.add_view(_FedaykinApprovalView(self, req), message_id=int(req["review_message_id"]))
        except Exception:
            pass

        # Evaluate head state at boot and act accordingly
        try:
            hg = cfg(self.bot).int('home_guild_id', 0)
            guild = self.bot.get_guild(hg) if hg else (self.bot.guilds[0] if self.bot.guilds else None)
            await self._maybe_transition_head_state(guild)
        except Exception:
            pass    

        try:
            if guild:
                await self._repair_orphaned_pending_cards(guild)
        except Exception:
            pass

    async def _save_fedaykin_request(self, req: Dict[str, Any]):
        """Upsert by (guild_id, user_id)."""
        dm = self.bot.data_manager
        gid, uid = int(req["guild_id"]), int(req["user_id"])
        try:
            for _ in list(_as_list(dm.get('fedaykin_requests'))):
                dm.remove('fedaykin_requests', lambda x, gid=gid, uid=uid:
                          int(x.get("guild_id", 0)) == gid and int(x.get("user_id", 0)) == uid)
            dm.add('fedaykin_requests', dict(req))
        except Exception:
            pass

    # ------------------ shared helpers ------------------

    async def _get_member(self, guild: discord.Guild, user_id: int):
        if not guild:
            return None
        m = guild.get_member(user_id)
        if m is None:
            try:
                m = await guild.fetch_member(user_id)
            except Exception:
                return None
        return m

    async def _user_has_any_accept(self, guild: discord.Guild, channel_id: int, message_id: int, user_id: int) -> bool:
        """Return True if the user still has at least one 'accept' reaction on the message."""
        try:
            ch = guild.get_channel(channel_id)
            if not ch:
                return False
            msg = await ch.fetch_message(message_id)
            for rxn in msg.reactions:
                if is_accept(rxn.emoji):
                    async for u in rxn.users(limit=None):
                        if u.id == user_id:
                            return True
            return False
        except Exception:
            return False

    def _has_full_initiated(self, member: discord.Member) -> bool:
        """User must have Full Access role to claim/request crew roles."""
        if not member or not isinstance(member.guild, discord.Guild):
            return False
        role = member.guild.get_role(self.full_access_role) if self.full_access_role else None
        return bool(role and role in member.roles)

    async def _remove_reaction_silent(self, guild: discord.Guild, channel_id: int, message_id: int,
                                      emoji: discord.PartialEmoji | discord.Emoji | str, member: discord.Member):
        """Best-effort: remove a reaction without messaging the user."""
        try:
            ch = guild.get_channel(channel_id)
            if not isinstance(ch, (discord.TextChannel, discord.Thread)):
                return
            msg = await ch.fetch_message(message_id)
            await msg.remove_reaction(emoji, member)
        except Exception:
            pass

    async def maybe_apply_full_access(self, member: discord.Member):
        """Grant when Rules+RoE+Nickname *claimed*; revoke when any missing."""
        guild = member.guild
        role = guild.get_role(self.full_access_role) if self.full_access_role else None
        if not role:
            return

        dm = self.bot.data_manager
        has_rules = member.id in _as_list(dm.get('agreed_rules'))
        has_engage = member.id in _as_list(dm.get('agreed_engagement'))
        has_nick_claim = member.id in _as_list(dm.get('agreed_nickname'))
        has_all = has_rules and has_engage and has_nick_claim

        try:
            if has_all and role not in member.roles:
                await member.add_roles(role, reason="All agreements completed (nickname may be pending)")
            elif not has_all and role in member.roles:
                await member.remove_roles(role, reason="Agreements incomplete or nickname unclaimed")
        except discord.Forbidden:
            pass
        except Exception:
            pass

        # Best-effort: refresh user card
        cards = self.bot.get_cog('UserCardsCog')
        if cards:
            try:
                await cards.refresh_card(member)
            except Exception:
                pass

    async def _find_hub_message(self, guild: discord.Guild) -> Tuple[Optional[discord.Message], Optional[int]]:
        """Return (message, channel_id) for the hub message, scanning text channels if needed."""
        if not self.crew_msg_id:
            return None, None

        # Try cached channel
        if self._hub_channel_id:
            ch = guild.get_channel(self._hub_channel_id)
            if isinstance(ch, discord.TextChannel):
                try:
                    msg = await ch.fetch_message(self.crew_msg_id)
                    return msg, ch.id
                except Exception:
                    pass

        # Scan
        for ch in guild.text_channels:
            try:
                msg = await ch.fetch_message(self.crew_msg_id)
                self._hub_channel_id = ch.id
                return msg, ch.id
            except (discord.NotFound, discord.Forbidden):
                continue
            except Exception:
                continue
        return None, None

    async def _edit_review_message_footer(self, req: Dict[str, Any], note: str, *, clear_view: bool = False):
        try:
            ch = self.bot.get_channel(int(req["review_channel_id"]))
            if isinstance(ch, (discord.TextChannel, discord.Thread)):
                msg = await ch.fetch_message(int(req["review_message_id"]))
                emb = msg.embeds[0] if msg.embeds else discord.Embed(title="Fedaykin Request")
                if emb.footer and emb.footer.text:
                    emb.set_footer(text=emb.footer.text + "\n" + note)
                else:
                    emb.set_footer(text=note)
                if clear_view:
                    await msg.edit(embed=emb, view=None)
                else:
                    await msg.edit(embed=emb)  # leave existing components untouched
        except Exception:
            pass

    async def _modlog_decision(self, guild: discord.Guild, status: str, target: discord.Member, actor: discord.Member, req: Dict[str, Any]):
        """Send an approval/rejection/revoke line to modlog channel."""
        chan = self.bot.get_channel(self.modlog_channel_id)
        if not isinstance(chan, (discord.TextChannel, discord.Thread)):
            return
        color_map = {
            "approved": discord.Color.green(),
            "rejected": discord.Color.red(),
            "withdrawn": discord.Color.orange(),
            "revoked": discord.Color.orange()
        }
        colour = color_map.get(status, discord.Color.blurple())
        jump = f"https://discord.com/channels/{guild.id}/{req.get('review_channel_id')}/{req.get('review_message_id')}" \
               if req.get('review_channel_id') and req.get('review_message_id') else ""
        created_ts = float(req.get("created_ts", datetime.now(timezone.utc).timestamp()))
        decided_ts = float(req.get("decision_ts", datetime.now(timezone.utc).timestamp()))
        emb = discord.Embed(
            title=f"Fedaykin {status.capitalize()}",
            description=(f"[View review card]({jump})" if jump else "No card link available."),
            color=colour,
            timestamp=datetime.fromtimestamp(decided_ts, tz=timezone.utc),
        )
        emb.add_field(name="User", value=f"{target.mention} (`{target.id}`)", inline=True)
        emb.add_field(name="By", value=f"{actor.mention} (`{actor.id}`)", inline=True)
        emb.add_field(name="Requested", value=_fmt_dt(created_ts), inline=True)
        emb.add_field(name="Decision", value=_fmt_dt(decided_ts), inline=True)
        try:
            await chan.send(embed=emb)
        except Exception:
            pass

    async def _fedaykin_decide(self, guild: Optional[discord.Guild], actor: Optional[discord.Member], req: Dict[str, Any], status: str, *, edit_view: bool = False):
        """Common decision path for approvals via buttons."""
        if guild is None or actor is None:
            return

        # Idempotency
        if req.get("status") != "pending" and status in ("approved", "rejected"):
            return

        target = await self._get_member(guild, int(req["user_id"]))
        if target is None:
            return

        role = guild.get_role(self.role_fedaykin_id)

        if status == "approved":
            if role is None:
                await self._edit_review_message_footer(
                    req, f"**Decision attempt:** Approve by {actor.mention} failed — Fedaykin role not configured."
                )
                return
            try:
                await target.add_roles(role, reason=f"Fedaykin approved by {actor} ({actor.id})")
            except Exception:
                pass
        elif status == "rejected":
            if role and role in target.roles:
                try:
                    await target.remove_roles(role, reason=f"Fedaykin rejected by {actor} ({actor.id})")
                except Exception:
                    pass
        elif status in ("withdrawn", "revoked"):
            # Both withdrawn (user cancels before approval) and revoked (user unreacts after approval)
            if role and role in target.roles:
                try:
                    await target.remove_roles(role, reason=f"Fedaykin {status} by user")
                except Exception:
                    pass

        if status in ("approved", "rejected"):
            req["status"] = status
            req["approver_id"] = int(actor.id)
        else:
            req["status"] = status  # withdrawn / revoked don't set approver
        req["decision_ts"] = datetime.now(timezone.utc).timestamp()
        await self._save_fedaykin_request(req)

        note = f"**Decision:** {status.capitalize()} by {actor.mention if actor else 'system'} at {discord.utils.format_dt(datetime.now(timezone.utc), style='f')}"
        await self._edit_review_message_footer(req, note, clear_view=edit_view)

        # modlog
        try:
            await self._modlog_decision(guild, status, target, actor or guild.me, req)
        except Exception:
            pass

    async def _post_fedaykin_card(self, guild: discord.Guild, member: discord.Member, hub_id: int) -> bool:
        """Post the Fedaykin approval card; return True if posted somewhere. While headless, queue pending only."""
        # Require Full Access to even request Fedaykin
        if not self._has_full_initiated(member):
            return False

        # If headless or effectively headless (no Head members): queue silently
        if self._fedaykin_headless or not self._has_fedaykin_head(guild):
            await self._queue_pending(guild, member, reason="headless_runtime")
            return False

        created_ts = datetime.now(timezone.utc).timestamp()
        emb = discord.Embed(
            title="Fedaykin Request",
            description=f"{member.mention} requested **Fedaykin** via reaction.",
            color=discord.Color.orange(),
            timestamp=datetime.fromtimestamp(created_ts, tz=timezone.utc),
        )
        emb.add_field(name="User", value=f"{member} (`{member.id}`)", inline=True)
        emb.add_field(name="Requested", value=_fmt_dt(created_ts), inline=True)
        emb.set_footer(text=f"Hub message ID: {hub_id}")

        req = {
            "guild_id": guild.id,
            "user_id": member.id,
            "status": "pending",
            "created_ts": created_ts,
            "review_message_id": 0,
            "review_channel_id": 0,
        }
        view = _FedaykinApprovalView(self, req, timeout=None)

        # Primary: report_channel_id; fallback: mod_channel_id
        targets = []
        chan = self.bot.get_channel(self.report_channel_id)
        if isinstance(chan, (discord.TextChannel, discord.Thread)):
            targets.append(chan)
        mchan = self.bot.get_channel(self.mod_channel_id)
        if isinstance(mchan, (discord.TextChannel, discord.Thread)):
            targets.append(mchan)

        for ch in targets:
            try:
                msg = await ch.send(embed=emb, view=view)
                req["review_message_id"] = int(msg.id)
                req["review_channel_id"] = int(msg.channel.id)
                await self._save_fedaykin_request(req)
                try:
                    self.bot.add_view(_FedaykinApprovalView(self, req), message_id=msg.id)
                except Exception:
                    pass
                return True
            except Exception:
                continue
        return False

    # ---------- Fedaykin Head detection & transitions ----------

    def _has_fedaykin_head(self, guild: discord.Guild) -> bool:
        """True if any member currently has the Field Mod (Fedaykin Head) role."""
        if not guild or not self.field_mod_role_id:
            return False
        role = guild.get_role(self.field_mod_role_id)
        return bool(role and role.members)

    async def _modlog_head_summary(self, guild: discord.Guild, *, head_present: bool,
                                   revoked: int = 0, queued: int = 0, posted: int = 0):
        """Brief summary in modlog on headless/head-found transitions."""
        chan = self.bot.get_channel(self.modlog_channel_id)
        if not isinstance(chan, (discord.TextChannel, discord.Thread)):
            return
        colour = discord.Color.green() if head_present else discord.Color.orange()
        title = "Fedaykin Head present — queued reviews sent" if head_present else "No Fedaykin Head — roles revoked & queued"
        desc = (
            f"**Posted reviews:** {posted}" if head_present else
            f"**Revoked roles:** {revoked}\n**Queued reviews:** {queued}"
        )
        try:
            emb = discord.Embed(title=title, description=desc, color=colour,
                                timestamp=datetime.now(timezone.utc))
            await chan.send(embed=emb)
        except Exception:
            pass

    async def _queue_pending(self, guild: discord.Guild, member: discord.Member, *, reason: str = "headless"):
        """Upsert a silent pending request (no card)."""
        dm = self.bot.data_manager
        created_ts = datetime.now(timezone.utc).timestamp()
        # Preserve an existing headless pending if one already exists (don’t duplicate)
        existing = next((x for x in _as_list(dm.get('fedaykin_requests'))
                         if x.get("guild_id") == guild.id and x.get("user_id") == member.id
                         and x.get("status") == "pending" and int(x.get("review_message_id", 0)) == 0), None)
        req = {
            "guild_id": guild.id,
            "user_id": member.id,
            "status": "pending",
            "created_ts": existing.get("created_ts", created_ts) if existing else created_ts,
            "review_message_id": 0,
            "review_channel_id": 0,
            "reason": reason,
        }
        await self._save_fedaykin_request(req)

    async def _headless_revoke_and_queue(self, guild: discord.Guild) -> tuple[int, int]:
        """When headless: remove Fedaykin role from all members and queue pending reviews."""
        revoked = queued = 0
        if not guild or not self.role_fedaykin_id:
            return revoked, queued
        role = guild.get_role(self.role_fedaykin_id)
        if not role:
            return revoked, queued

        for m in list(role.members):
            if m.bot:
                continue
            try:
                await m.remove_roles(role, reason="Fedaykin headless – role temporarily revoked")
                revoked += 1
            except Exception:
                pass
            try:
                await self._queue_pending(guild, m, reason="headless_boot")
                queued += 1
            except Exception:
                pass
        return revoked, queued

    async def _flush_pending_to_cards(self, guild: discord.Guild) -> int:
        """When a head exists: send cards for any queued pendings (review_message_id == 0)."""
        dm = self.bot.data_manager
        pending = [x for x in _as_list(dm.get('fedaykin_requests'))
                   if x.get("guild_id") == guild.id and x.get("status") == "pending"
                   and int(x.get("review_message_id", 0)) == 0]
        posted = 0
        for req in pending:
            member = await self._get_member(guild, int(req.get("user_id", 0)))
            if not member:
                continue
            # This will upsert the request with live message/channel IDs
            ok = await self._post_fedaykin_card(guild, member, self.crew_msg_id or 0)
            if ok:
                posted += 1
        return posted

    async def _maybe_transition_head_state(self, guild: Optional[discord.Guild]):
        """Recompute head state; if it changes, perform the required mass action + log."""
        if guild is None:
            return

        # Require a configured, resolvable Field Mod role to take global actions
        if not self.field_mod_role_id:
            return
        head_role = guild.get_role(self.field_mod_role_id)
        if head_role is None:
            # Misconfigured: do not mass-revoke; other paths will still queue/block appropriately
            return

        has_head_now = bool(head_role.members)
        if has_head_now and self._fedaykin_headless:
            # Head appeared — flush queued to cards
            self._fedaykin_headless = False
            posted = await self._flush_pending_to_cards(guild)
            await self._repair_orphaned_pending_cards(guild)
            await self._modlog_head_summary(guild, head_present=True, posted=posted)
        elif not has_head_now and not self._fedaykin_headless:
            # Became headless — revoke all fedaykins and queue pendings
            self._fedaykin_headless = True
            revoked, queued = await self._headless_revoke_and_queue(guild)
            await self._modlog_head_summary(guild, head_present=False, revoked=revoked, queued=queued)

    # inside ReactionRoleCog class
    async def _repair_orphaned_pending_cards(self, guild: discord.Guild) -> int:
        """Detect pending requests whose review message was deleted; reset them so they can be re-posted."""
        dm = self.bot.data_manager
        repaired = 0
        for req in list(_as_list(dm.get('fedaykin_requests'))):
            if int(req.get("guild_id", 0)) != guild.id or req.get("status") != "pending":
                continue
            mid = int(req.get("review_message_id", 0) or 0)
            cid = int(req.get("review_channel_id", 0) or 0)
            if not mid or not cid:
                continue  # already queued (no card)
            ch = self.bot.get_channel(cid)
            exists = False
            if isinstance(ch, (discord.TextChannel, discord.Thread)):
                try:
                    await ch.fetch_message(mid)
                    exists = True
                except (discord.NotFound, discord.Forbidden):
                    exists = False
                except Exception:
                    exists = False
            if not exists:
                # reset to "queued" so normal flow can re-post
                req["review_message_id"] = 0
                req["review_channel_id"] = 0
                await self._save_fedaykin_request(req)
                repaired += 1

        # If we repaired and a head exists, immediately flush to new cards
        if repaired and not self._fedaykin_headless and self._has_fedaykin_head(guild):
            await self._flush_pending_to_cards(guild)
        return repaired

    # ------------------ listeners ------------------

    @commands.Cog.listener()
    async def on_member_update(self, before: discord.Member, after: discord.Member):
        """Detect when someone gains/loses the Fedaykin Head role and transition state if needed."""
        try:
            hg = cfg(self.bot).int('home_guild_id', 0)
            if after.guild.id != (hg or after.guild.id):
                return
        except Exception:
            pass

        # If the role IDs aren’t configured yet, just ignore
        if not self.field_mod_role_id:
            return

        had = any(r.id == self.field_mod_role_id for r in getattr(before, "roles", []))
        has = any(r.id == self.field_mod_role_id for r in getattr(after, "roles", []))
        if had == has:
            return  # no change on this member

        # Re-evaluate overall head presence and transition if the global state changes
        await self._maybe_transition_head_state(after.guild)

    @commands.Cog.listener()
    async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
        # hot-reload settings
        self._refresh_settings()

        if not payload.guild_id:
            return

        guild = self.bot.get_guild(payload.guild_id)

        # Ensure head state stays accurate even if settings/users change at runtime
        try:
            hg = cfg(self.bot).int('home_guild_id', 0)
            base_guild = self.bot.get_guild(hg) or guild
            await self._maybe_transition_head_state(base_guild)
        except Exception:
            pass

        member = await self._get_member(guild, payload.user_id)
        if not member or member.bot:
            return

        dm = self.bot.data_manager

        # ----- Original accept-based flows -----
        try:
            if is_accept(payload.emoji):

                # RULES
                if self.rules_msg_id and payload.message_id == self.rules_msg_id:
                    role = guild.get_role(self.rules_role)
                    if role:
                        try:
                            await member.add_roles(role, reason="Agreed to rules")
                        except Exception:
                            pass
                    if member.id not in _as_list(dm.get('agreed_rules')):
                        dm.add('agreed_rules', int(member.id))

                # ENGAGEMENT
                elif self.engage_msg_id and payload.message_id == self.engage_msg_id:
                    role = guild.get_role(self.engage_role)
                    if role:
                        try:
                            await member.add_roles(role, reason="Agreed to engagement")
                        except Exception:
                            pass
                    if member.id not in _as_list(dm.get('agreed_engagement')):
                        dm.add('agreed_engagement', int(member.id))

                # NICKNAME (debounced + idempotent)
                elif self.nick_msg_id and payload.message_id == self.nick_msg_id:
                    now = time.monotonic()
                    key = (guild.id, member.id)
                    last = self._nick_debounce.get(key, 0.0)
                    if now - last < 10.0:
                        return  # debounce bursty multi-emoji reacts
                    self._nick_debounce[key] = now

                    # If any pending review already exists for this user, do nothing
                    has_pending_review = any(
                        r.get('guild_id') == guild.id and r.get('user_id') == member.id and r.get('status') == 'pending'
                        for r in _as_list(dm.get('nick_reviews'))
                    )
                    if has_pending_review:
                        return

                    # In-flight lock to avoid concurrent duplicate openings
                    if key in self._nick_lock:
                        return
                    self._nick_lock.add(key)

                    try:
                        # Clear stale marker if no active review, then open exactly one
                        dm.remove('nick_claim_pending', lambda x: x == member.id)
                        nn = self.bot.get_cog('NickNudgeCog')
                        if nn and hasattr(nn, 'ensure_pending_and_maybe_open'):
                            try:
                                await nn.ensure_pending_and_maybe_open(guild, member, source="claim")
                            except Exception:
                                pass
                    finally:
                        self._nick_lock.discard(key)
        except Exception:
            pass

        # ----- Crew roles hub (custom emoji toggles + Fedaykin request) -----
        try:
            if self.crew_msg_id and payload.message_id == self.crew_msg_id and payload.emoji.id:
                # Gate: must have Full Access to claim/request crew roles
                if not self._has_full_initiated(member):
                    await self._remove_reaction_silent(guild, payload.channel_id, payload.message_id, payload.emoji, member)
                    return

                # Harvester / Escort
                if payload.emoji.id == self.emoji_harvest_id and self.role_harvest_id:
                    role = guild.get_role(self.role_harvest_id)
                    if role:
                        try:
                            await member.add_roles(role, reason=f"Reaction role via hub {self.crew_msg_id}")
                        except Exception:
                            pass
                    return

                if payload.emoji.id == self.emoji_escort_id and self.role_escort_id:
                    role = guild.get_role(self.role_escort_id)
                    if role:
                        try:
                            await member.add_roles(role, reason=f"Reaction role via hub {self.crew_msg_id}")
                        except Exception:
                            pass
                    return

                # Fedaykin -> open approval card (only one pending allowed)
                if payload.emoji.id == self.emoji_fedaykin_id:
                    fed_role = guild.get_role(self.role_fedaykin_id) if self.role_fedaykin_id else None
                    if fed_role and fed_role in member.roles:
                        return  # already has Fedaykin

                    pending = next((x for x in _as_list(dm.get('fedaykin_requests'))
                                    if x.get("guild_id") == guild.id and x.get("user_id") == member.id and x.get("status") == "pending"), None)
                    if pending:
                        return

                    hub_msg, _ = await self._find_hub_message(guild)
                    hub_id = hub_msg.id if hub_msg else self.crew_msg_id
                    await self._post_fedaykin_card(guild, member, hub_id)
                    return
        except Exception:
            pass

        # Only the original (agreements) flow affects Full Access
        await self.maybe_apply_full_access(member)

    @commands.Cog.listener()
    async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent):
        # hot-reload settings
        self._refresh_settings()

        if not payload.guild_id:
            return

        guild = self.bot.get_guild(payload.guild_id)

        # Ensure head state stays accurate even if settings/users change at runtime
        try:
            hg = cfg(self.bot).int('home_guild_id', 0)
            base_guild = self.bot.get_guild(hg) or guild
            await self._maybe_transition_head_state(base_guild)
        except Exception:
            pass

        member = await self._get_member(guild, payload.user_id)
        if not member or member.bot:
            return

        dm = self.bot.data_manager

        # ----- Original accept-based flows -----
        try:
            if is_accept(payload.emoji):
                # RULES
                if self.rules_msg_id and payload.message_id == self.rules_msg_id:
                    dm.remove('agreed_rules', lambda x: x == member.id)
                    role = guild.get_role(self.rules_role)
                    if role and role in member.roles:
                        try:
                            await member.remove_roles(role, reason="Rules un-ticked")
                        except Exception:
                            pass

                # ENGAGEMENT
                elif self.engage_msg_id and payload.message_id == self.engage_msg_id:
                    dm.remove('agreed_engagement', lambda x: x == member.id)
                    role = guild.get_role(self.engage_role)
                    if role and role in member.roles:
                        try:
                            await member.remove_roles(role, reason="Engagement un-ticked")
                        except Exception:
                            pass

                # NICKNAME: clear only if no other accept left
                elif self.nick_msg_id and payload.message_id == self.nick_msg_id:
                    still_has_accept = await self._user_has_any_accept(
                        guild, payload.channel_id, payload.message_id, member.id
                    )
                    if not still_has_accept:
                        dm.remove('agreed_nickname', lambda x: x == member.id)
                        dm.remove('nick_claim_pending', lambda x: x == member.id)
                        dm.remove('nick_verified', lambda x: x == member.id)
        except Exception:
            pass

        # ----- Crew roles hub (custom emoji) -----
        try:
            if self.crew_msg_id and payload.message_id == self.crew_msg_id and payload.emoji.id:
                # Harvester
                if payload.emoji.id == self.emoji_harvest_id and self.role_harvest_id:
                    role = guild.get_role(self.role_harvest_id)
                    if role and role in member.roles:
                        try:
                            await member.remove_roles(role, reason=f"Reaction role via hub {self.crew_msg_id} (unreact)")
                        except Exception:
                            pass
                    return

                # Escort
                if payload.emoji.id == self.emoji_escort_id and self.role_escort_id:
                    role = guild.get_role(self.role_escort_id)
                    if role and role in member.roles:
                        try:
                            await member.remove_roles(role, reason=f"Reaction role via hub {self.crew_msg_id} (unreact)")
                        except Exception:
                            pass
                    return

                # Fedaykin unreact -> remove role + mark request accordingly
                if payload.emoji.id == self.emoji_fedaykin_id:
                    fed_role = guild.get_role(self.role_fedaykin_id) if self.role_fedaykin_id else None

                    # Find a related request if any (prefer pending; else last approved)
                    req = None
                    pend = [x for x in _as_list(dm.get('fedaykin_requests'))
                            if x.get("guild_id") == guild.id and x.get("user_id") == member.id and x.get("status") == "pending"]
                    if pend:
                        req = pend[-1]
                        req["status"] = "withdrawn"
                    else:
                        appr = [x for x in _as_list(dm.get('fedaykin_requests'))
                                if x.get("guild_id") == guild.id and x.get("user_id") == member.id and x.get("status") == "approved"]
                        if appr:
                            req = appr[-1]
                            req["status"] = "revoked"

                    # Always remove the role on unreact (per requirement)
                    if fed_role and fed_role in member.roles:
                        try:
                            await member.remove_roles(fed_role, reason="Fedaykin reaction removed")
                        except Exception:
                            pass

                    if req:
                        req["decision_ts"] = datetime.now(timezone.utc).timestamp()
                        await self._save_fedaykin_request(req)
                        actor = guild.me  # system action
                        status = req["status"]
                        # Update card footer if we still have it
                        await self._edit_review_message_footer(
                            req,
                            f"**Decision:** {status.capitalize()} (user unreact) at {discord.utils.format_dt(datetime.now(timezone.utc), style='f')}",
                            clear_view=True,
                        )
                        # Modlog
                        try:
                            await self._modlog_decision(guild, status, member, actor, req)
                        except Exception:
                            pass
                    return
        except Exception:
            pass

        # Only the original (agreements) flow affects Full Access
        await self.maybe_apply_full_access(member)


async def setup(bot):
    await bot.add_cog(ReactionRoleCog(bot))
