# modules/spicepay/spicepay.py
import re
import time
import asyncio
from typing import List, Dict, Tuple, Optional
import discord
from discord.ext import commands
from discord import app_commands
from modules.common.settings import cfg

# Accept both for backward compatibility; display uses "Refiner"
VALID_ROLES = {"crawler_owner", "carrier_owner", "refiner_owner", "lsr_owner"}

# ------------------------ emoji helpers ------------------------

def _emoji_str(bot: commands.Bot, emoji_id: Optional[int], fallback: str) -> str:
    if emoji_id:
        e = bot.get_emoji(int(emoji_id))
        if e:
            return str(e)
    return fallback

# ------------------------ core math ------------------------

def _normalize_roles(raw: str) -> List[str]:
    """
    Accepts flexible input; maps common aliases to canonical role keys.
    New canonical: refiner_owner (old: lsr_owner still accepted).
    """
    if not raw:
        return []
    txt = raw.strip().lower()
    tokens = re.split(r"[,\s]+", txt)
    out = set()
    for tok in tokens:
        if not tok:
            continue
        t = tok.replace("-", "").replace("_", "")
        if t in ("crawler", "cr", "craw", "crawlerowner"):
            out.add("crawler_owner")
        elif t in ("carrier", "ca", "car", "carrierowner"):
            out.add("carrier_owner")
        elif t in ("refiner", "refinery", "refineryowner", "refinerowner", "lsr", "lsrowner", "largespicerefinery"):
            out.add("refiner_owner")
        elif t in ("none", "no", "nil"):
            return []
    return list(out)

def calculate_spice_distribution(
    spice_total: int,
    participants: List[Dict],
    *,
    base_weight: float = 25.0,
    carrier_bonus: float = 12.5,
    crawler_bonus: float = 12.5,
    lsr_cut_percent: float = 10.0,   # config key name retained; UI calls it "refinery cut"
    lsr_required: bool = True,       # True for melange payouts
) -> Tuple[List[Dict], List[str]]:
    errors: List[str] = []
    result: List[Dict] = []

    if not isinstance(spice_total, int) or spice_total < 0:
        return [], ["Spice total must be a non-negative integer."]
    if base_weight < 0 or carrier_bonus < 0 or crawler_bonus < 0:
        return [], ["Weights must be non-negative."]
    if lsr_cut_percent < 0 or lsr_cut_percent > 100:
        return [], ["Refinery cut percent must be between 0 and 100."]

    seen_names = set()
    refiner_owners: List[str] = []
    cleaned: List[Dict] = []

    for i, p in enumerate(participants):
        if not isinstance(p, dict):
            errors.append(f"Participant at index {i} is not a dict.")
            continue

        name = p.get('name')
        participation = p.get('participation', 0)
        roles = p.get('roles', [])

        if not name or not isinstance(name, str):
            errors.append(f"Participant at index {i} has an invalid or missing name.")
            continue
        name = name.strip()
        if name in seen_names:
            errors.append(f"Duplicate participant name: '{name}'.")
            continue
        seen_names.add(name)

        if isinstance(participation, str):
            participation = participation.strip().replace("%", "")
            try:
                participation = float(participation)
            except Exception:
                errors.append(f"Participant '{name}' has invalid participation value.")
                continue

        if not isinstance(participation, (int, float)) or not (0 <= participation <= 100):
            errors.append(f"Participant '{name}' has invalid participation: {participation}. Must be 0–100.")
            continue

        if isinstance(roles, str):
            roles = _normalize_roles(roles)
        if not isinstance(roles, list):
            errors.append(f"Participant '{name}' has invalid roles payload.")
            continue

        invalid = [r for r in roles if r not in VALID_ROLES]
        if invalid:
            errors.append(f"Participant '{name}' has invalid roles: {invalid}.")
            continue

        if 'refiner_owner' in roles or 'lsr_owner' in roles:
            refiner_owners.append(name)

        cleaned.append({'name': name, 'participation': float(participation), 'roles': roles})

    # Refiner checks depend on payout mode
    if lsr_required:
        if len(refiner_owners) > 1:
            errors.append(f"Multiple refiner owners detected: {refiner_owners}. Only one is allowed.")
        elif len(refiner_owners) == 0:
            errors.append("No refiner owner found. Exactly one is required for melange payouts.")
    else:
        if len(refiner_owners) > 1:
            errors.append(f"Multiple refiner owners detected: {refiner_owners}. Only zero or one is allowed for sand payouts.")

    if errors:
        return [], errors

    refiner_owner = refiner_owners[0] if refiner_owners else None
    refinery_cut = int(spice_total * (lsr_cut_percent / 100.0)) if (lsr_required and refiner_owner) else 0
    distributable = spice_total - refinery_cut

    # Compute weights
    weights: Dict[str, float] = {}
    total_weight = 0.0
    for p in cleaned:
        w = float(base_weight) * (p['participation'] / 100.0)
        if 'carrier_owner' in p['roles']:
            w += float(carrier_bonus)
        if 'crawler_owner' in p['roles']:
            w += float(crawler_bonus)
        weights[p['name']] = w
        total_weight += w

    if total_weight <= 0:
        return [], [
            "Total effective weight is zero. Increase participation or weighting factors "
            "(e.g., raise base ×% and/or bonuses) so at least one participant has non-zero weight."
        ]

    # Proportional allocation
    spice_raw: Dict[str, int] = {}
    total_floored = 0
    for p in cleaned:
        name = p['name']
        w = weights.get(name, 0.0)
        share = int((w / total_weight) * distributable)
        spice_raw[name] = share
        total_floored += share

    remainder = distributable - total_floored
    if refiner_owner:
        spice_raw[refiner_owner] = spice_raw.get(refiner_owner, 0) + refinery_cut + remainder
    else:
        top = max(spice_raw.items(), key=lambda kv: kv[1])[0]
        spice_raw[top] += remainder

    result = [{
        'name': p['name'],
        'spice': int(spice_raw.get(p['name'], 0)),
        'participation': p['participation'],
        'roles': p['roles'],
    } for p in cleaned]

    return result, []

# ------------------------ formatting ------------------------

def _fmt_roles_short(roles: List[str]) -> str:
    if not roles:
        return "Escort"
    short = []
    for r in roles:
        if r in ("refiner_owner", "lsr_owner"): short.append("Refiner")
        elif r == "crawler_owner": short.append("Crawler")
        elif r == "carrier_owner": short.append("Carrier")
    return ",".join(short) if short else "Escort"

def _format_table(results: List[Dict], unit_header: str, unit_suffix: str) -> str:
    rows = sorted(results, key=lambda r: (-r['spice'], r['name'].lower()))
    any_zero = any((r.get('participation') or 0) <= 0 for r in rows)

    own_header = "Owner of/Role"
    name_w = max(4, *(len(r['name']) for r in rows)) if rows else 4
    own_w  = max(len(own_header), *(len(_fmt_roles_short(r['roles'])) for r in rows)) if rows else len(own_header)
    pay_w  = max(6, len(unit_header))

    header = f"{'Name'.ljust(name_w)}  {'Active %'.rjust(8)}  {own_header.ljust(own_w)}  {unit_header.rjust(pay_w)}"
    sep = "-" * len(header)
    lines = [header, sep]

    for r in rows:
        name = r['name'].ljust(name_w)
        pct_val = int(round(r.get('participation') or 0))
        pct = f"{pct_val}%"
        if pct_val == 0:
            pct += "*"  # owner-only marker
        pct = pct.rjust(8)
        owned = _fmt_roles_short(r.get('roles', [])).ljust(own_w)
        amount = f"{r['spice']} {unit_suffix}"
        lines.append(f"{name}  {pct}  {owned}  {amount}")

    if any_zero:
        lines.append("")
        lines.append("* 0% = owner only (did not actively join the run)")
    return "```\n" + "\n".join(lines) + "\n```"

# -------- Modals --------

class _SetupModal(discord.ui.Modal, title="Spice Pay — Setup"):
    def __init__(self, cog: "SpicePayCog", pre_participants: Optional[int]):
        super().__init__()
        self.cog = cog
        self.pre_participants = pre_participants

        self.total_field = discord.ui.TextInput(
            label="Total spice yield (sand)",
            placeholder="e.g. 12345",
            required=True,
            max_length=10
        )
        self.count_field = discord.ui.TextInput(
            label="Participants (incl. owners)",
            placeholder="Total number of people (add refiner/carrier/crawler owners too)",
            required=True,
            max_length=45,  # allow a clearer hint
            default=str(pre_participants) if pre_participants is not None else None
        )

        self.add_item(self.total_field)
        self.add_item(self.count_field)

    async def on_submit(self, interaction: discord.Interaction):
        try:
            total = int(self.total_field.value.strip())
            if total < 0:
                raise ValueError
        except Exception:
            return await interaction.response.send_message("❌ Total must be a non-negative integer.", ephemeral=True)

        try:
            count = int(str(self.count_field.value).strip())
            if not (1 <= count <= 25):
                raise ValueError
        except Exception:
            return await interaction.response.send_message("❌ Participants must be between 1 and 25.", ephemeral=True)

        await self.cog.start_session(interaction, total, count)

class _ParticipantModal(discord.ui.Modal):
    def __init__(self, cog: "SpicePayCog", session_key, index: int, existing: Optional[Dict]):
        super().__init__(title=f"Participant {index}")
        self.cog = cog
        self.session_key = session_key
        self.index = index
        ex = existing or {}

        self.name = discord.ui.TextInput(
            label="Name",
            placeholder="Player name (or @mention text)",
            required=True,
            max_length=64,
            default=ex.get("name") if ex else None
        )
        self.part = discord.ui.TextInput(
            label="Active participation % (0–100)",
            placeholder="How much they actively joined the run (0..100; 0% = owner only)",
            required=True,
            max_length=45,
            default=str(int(ex["participation"])) if "participation" in ex else None
        )
        self.roles = discord.ui.TextInput(
            label="Owner of (optional)",
            placeholder="refiner, crawler, carrier — leave empty if escort/non-owner",
            required=False,
            max_length=64,
            default=",".join(ex["roles"]) if ex and ex.get("roles") else None
        )
        self.add_item(self.name)
        self.add_item(self.part)
        self.add_item(self.roles)

    async def on_submit(self, interaction: discord.Interaction):
        sess = self.cog.sessions.get(self.session_key)
        if not sess:
            return await interaction.response.send_message("Session expired. Please run /spicepay again.", ephemeral=True)

        name = self.name.value.strip()
        part_raw = str(self.part.value).strip().replace("%", "")
        try:
            participation = float(part_raw)
        except Exception:
            return await interaction.response.send_message("❌ Participation must be a number (0–100).", ephemeral=True)
        if participation < 0 or participation > 100:
            return await interaction.response.send_message("❌ Participation must be 0–100.", ephemeral=True)

        roles = _normalize_roles(self.roles.value or "")

        idx = self.index - 1
        while len(sess["participants"]) < idx + 1:
            sess["participants"].append({})
        sess["participants"][idx] = {"name": name, "participation": participation, "roles": roles}

        if sess["next_index"] == self.index and sess["next_index"] < sess["count"]:
            sess["next_index"] += 1

        await self.cog.render_progress(interaction, self.session_key)

class _MelangeModal(discord.ui.Modal, title="Set melange refinery yield"):
    def __init__(self, cog: "SpicePayCog", session_key):
        super().__init__()
        self.cog = cog
        self.session_key = session_key
        self.amount = discord.ui.TextInput(
            label="Melange refinery yield (integer)",
            placeholder="e.g. 123",
            required=True,
            max_length=10
        )
        self.add_item(self.amount)

    async def on_submit(self, interaction: discord.Interaction):
        sess = self.cog.sessions.get(self.session_key)
        if not sess:
            return await interaction.response.send_message("Session expired.", ephemeral=True)
        val = self.amount.value.strip()
        try:
            m = int(val)
            if m < 0:
                raise ValueError
        except Exception:
            return await interaction.response.send_message("❌ Melange total must be a non-negative integer.", ephemeral=True)
        sess["melange_total"] = m
        await self.cog.render_progress(interaction, self.session_key)

class _WeightsModal(discord.ui.Modal, title="Adjust weighting factors"):
    def __init__(self, cog: "SpicePayCog", session_key):
        super().__init__()
        self.cog = cog
        self.session_key = session_key

        sess = self.cog.sessions.get(session_key) or {}
        w = (sess.get("weights") or {})
        bw = w.get("base", self.cog.base_weight)
        cab = w.get("carrier", self.cog.carrier_bonus)
        crb = w.get("crawler", self.cog.crawler_bonus)
        lsr = w.get("lsr", self.cog.lsr_cut_percent)

        self.base = discord.ui.TextInput(label="Base × active %", required=False, max_length=10, placeholder=str(bw))
        self.carrier = discord.ui.TextInput(label="Carrier bonus (+)", required=False, max_length=10, placeholder=str(cab))
        self.crawler = discord.ui.TextInput(label="Crawler bonus (+)", required=False, max_length=10, placeholder=str(crb))
        self.lsr = discord.ui.TextInput(label="Refinery cut % (melange)", required=False, max_length=10, placeholder=str(lsr))

        self.add_item(self.base)
        self.add_item(self.carrier)
        self.add_item(self.crawler)
        self.add_item(self.lsr)

    async def on_submit(self, interaction: discord.Interaction):
        sess = self.cog.sessions.get(self.session_key)
        if not sess:
            return await interaction.response.send_message("Session expired.", ephemeral=True)

        def _parse_nonneg(opt_str, pct=False):
            if not opt_str:
                return None
            s = str(opt_str).strip().replace("%", "")
            try:
                v = float(s)
            except Exception:
                return "bad"
            if v < 0:
                return "bad"
            if pct and (v < 0 or v > 100):
                return "bad"
            return v

        b = _parse_nonneg(self.base.value)
        cab = _parse_nonneg(self.carrier.value)
        crb = _parse_nonneg(self.crawler.value)
        lsr = _parse_nonneg(self.lsr.value, pct=True)

        if "bad" in (b, cab, crb, lsr):
            return await interaction.response.send_message(
                "❌ Invalid values. Use non-negative numbers; refinery % must be 0–100.",
                ephemeral=True
            )

        w = sess.get("weights") or {}
        if b is not None: w["base"] = b
        if cab is not None: w["carrier"] = cab
        if crb is not None: w["crawler"] = crb
        if lsr is not None: w["lsr"] = lsr
        sess["weights"] = w

        # Soft warning for extreme values
        if any(v is not None and v > 1000 for v in (b, cab, crb)):
            try:
                await interaction.response.send_message(
                    "⚠️ Very large weights don’t change ratios (we normalize), but may affect rounding slightly.",
                    ephemeral=True
                )
            except discord.InteractionResponded:
                await interaction.followup.send(
                    "⚠️ Very large weights don’t change ratios (we normalize), but may affect rounding slightly.",
                    ephemeral=True
                )
            return await self.cog.render_progress(interaction, self.session_key)

        await self.cog.render_progress(interaction, self.session_key)

# ------------------------ Views ------------------------

class _HelpView(discord.ui.View):
    def __init__(self, cog: "SpicePayCog", session_key: tuple):
        super().__init__(timeout=600)  # keep session alive
        self.cog = cog
        self.session_key = session_key

    async def on_timeout(self):
        pass

    @discord.ui.button(label="Return to setup", style=discord.ButtonStyle.primary)
    async def back(self, interaction: discord.Interaction, _btn: discord.ui.Button):
        await self.cog.render_progress(interaction, self.session_key)

    @discord.ui.button(label="Adjust weighting factors", style=discord.ButtonStyle.secondary)
    async def open_weights(self, interaction: discord.Interaction, _btn: discord.ui.Button):
        await interaction.response.send_modal(_WeightsModal(self.cog, self.session_key))

class _WizardView(discord.ui.View):
    def __init__(self, cog: "SpicePayCog", session_key: tuple):
        super().__init__(timeout=600)
        self.cog = cog
        self.session_key = session_key

    async def on_timeout(self):
        pass

    @discord.ui.button(label="Add / Edit participant", style=discord.ButtonStyle.primary, row=0)
    async def add_edit(self, interaction: discord.Interaction, _button: discord.ui.Button):
        sess = self.cog.sessions.get(self.session_key)
        if not sess:
            return await interaction.response.send_message("Session expired. Please run /spicepay again.", ephemeral=True)
        idx = sess["next_index"]
        if idx > sess["count"]:
            idx = sess["count"]
        existing = sess["participants"][idx-1] if 0 <= idx-1 < len(sess["participants"]) else None
        await interaction.response.send_modal(_ParticipantModal(self.cog, self.session_key, idx, existing))

    @discord.ui.button(label="Previous", style=discord.ButtonStyle.secondary, row=0)
    async def previous(self, interaction: discord.Interaction, _button: discord.ui.Button):
        sess = self.cog.sessions.get(self.session_key)
        if not sess:
            return await interaction.response.send_message("Session expired.", ephemeral=True)
        sess["next_index"] = max(1, sess["next_index"] - 1)
        await self.cog.render_progress(interaction, self.session_key)

    @discord.ui.button(label="Next", style=discord.ButtonStyle.secondary, row=0)
    async def next(self, interaction: discord.Interaction, _button: discord.ui.Button):
        sess = self.cog.sessions.get(self.session_key)
        if not sess:
            return await interaction.response.send_message("Session expired.", ephemeral=True)
        sess["next_index"] = min(sess["count"], sess["next_index"] + 1)
        await self.cog.render_progress(interaction, self.session_key)

    @discord.ui.button(label="Toggle payout: Sand/Melange", style=discord.ButtonStyle.secondary, row=1)
    async def toggle_payout(self, interaction: discord.Interaction, _button: discord.ui.Button):
        sess = self.cog.sessions.get(self.session_key)
        if not sess:
            return await interaction.response.send_message("Session expired.", ephemeral=True)
        sess["payout_type"] = "melange" if sess.get("payout_type") == "sand" else "sand"
        if sess["payout_type"] == "melange" and not sess.get("melange_total"):
            return await interaction.response.send_modal(_MelangeModal(self.cog, self.session_key))
        await self.cog.render_progress(interaction, self.session_key)

    @discord.ui.button(label="Set melange refinery yield", style=discord.ButtonStyle.primary, row=1)
    async def set_melange(self, interaction: discord.Interaction, _button: discord.ui.Button):
        await interaction.response.send_modal(_MelangeModal(self.cog, self.session_key))

    @discord.ui.button(label="Adjust weighting factors", style=discord.ButtonStyle.secondary, row=1)
    async def adjust_weights(self, interaction: discord.Interaction, _button: discord.ui.Button):
        await interaction.response.send_modal(_WeightsModal(self.cog, self.session_key))

    @discord.ui.button(label="Weights help", style=discord.ButtonStyle.secondary, row=2)
    async def weights_help(self, interaction: discord.Interaction, _button: discord.ui.Button):
        help_txt = (
            "**How the split works**\n"
            "• **Participants are people** (or a guild as one person). Vehicles/refinery are **owned** by someone.\n"
            "• Everyone gets a *weight*: **Base × active %** + **bonuses** (Carrier/Crawler if they own them).\n"
            "• We split the pot **proportionally** to those weights (normalized; only ratios matter).\n"
            "• **Melange payout** only: a **refinery cut %** is taken first and given to the **Refiner owner**.\n"
            "• People with **0%** are treated as **owner only** (get owner bonuses/cut but didn’t actively join the run).\n"
            "• Rounding leftovers go to the Refiner (melange) or the top earner (sand).\n\n"
            "_Tap **Return to setup** below to go back, or **Adjust weighting factors** to change numbers._"
        )
        try:
            await interaction.response.edit_message(content=help_txt, view=_HelpView(self.cog, self.session_key))
        except discord.InteractionResponded:
            await interaction.followup.send(help_txt, ephemeral=True, view=_HelpView(self.cog, self.session_key))

    # ---------- Presets row ----------
    @discord.ui.button(label="Preset: Owner-heavy", style=discord.ButtonStyle.secondary, row=3)
    async def preset_owner(self, interaction: discord.Interaction, _btn: discord.ui.Button):
        await self.cog.apply_preset(interaction, self.session_key, preset="owner")

    @discord.ui.button(label="Preset: Participation-heavy", style=discord.ButtonStyle.secondary, row=3)
    async def preset_part(self, interaction: discord.Interaction, _btn: discord.ui.Button):
        await self.cog.apply_preset(interaction, self.session_key, preset="participation")

    @discord.ui.button(label="Preset: Fair (server defaults)", style=discord.ButtonStyle.secondary, row=3)
    async def preset_fair(self, interaction: discord.Interaction, _btn: discord.ui.Button):
        await self.cog.apply_preset(interaction, self.session_key, preset="fair")

    @discord.ui.button(label="Preset: Even split", style=discord.ButtonStyle.secondary, row=3)
    async def preset_even(self, interaction: discord.Interaction, _btn: discord.ui.Button):
        await self.cog.apply_preset(interaction, self.session_key, preset="even")

    @discord.ui.button(label="Save as my defaults", style=discord.ButtonStyle.success, row=3)
    async def save_defaults(self, interaction: discord.Interaction, _btn: discord.ui.Button):
        await self.cog.save_user_defaults(interaction, self.session_key)

    @discord.ui.button(label="Finish", style=discord.ButtonStyle.success, row=2)
    async def finish(self, interaction: discord.Interaction, _button: discord.ui.Button):
        await self.cog.finish_and_show(interaction, self.session_key)

    @discord.ui.button(label="Cancel", style=discord.ButtonStyle.danger, row=2)
    async def cancel(self, interaction: discord.Interaction, _button: discord.ui.Button):
        self.cog.sessions.pop(self.session_key, None)
        await interaction.response.edit_message(content="Spice pay session cancelled.", view=None)

class _StartView(discord.ui.View):
    def __init__(self, cog: "SpicePayCog", participants: Optional[int]):
        super().__init__(timeout=600)  # was 120
        self.cog = cog
        self.participants = participants

    @discord.ui.button(label="Open setup", style=discord.ButtonStyle.primary)
    async def open_setup(self, interaction: discord.Interaction, _btn: discord.ui.Button):
        try:
            await interaction.response.send_modal(_SetupModal(self.cog, self.participants))
        except Exception as e:
            print("[spicepay] fallback button failed:", repr(e))
            await interaction.response.send_message("Still couldn’t open the modal. Do I have the right permissions here?", ephemeral=True)

# ------------------------ Cog ------------------------

class SpicePayCog(commands.Cog):
    def __init__(self, bot):
        self.bot = bot
        self.sessions: Dict[tuple, Dict] = {}

        r = cfg(bot)
        def _f(key, default):
            try:
                return float(r.get(key, str(default)))
            except Exception:
                return float(default)
        self.base_weight = _f('spicepay_base_weight', 25.0)
        self.carrier_bonus = _f('spicepay_carrier_bonus', 12.5)
        self.crawler_bonus = _f('spicepay_crawler_bonus', 12.5)
        self.lsr_cut_percent = _f('spicepay_lsr_cut_percent', 10.0)  # keep key; UI calls it refinery cut

        def _i(key):
            try:
                v = r.get(key, "")
                return int(v) if v else None
            except Exception:
                return None
        self.emoji_sand_id = _i('emoji_sand_id')
        self.emoji_melange_id = _i('emoji_melange_id')
        self.emoji_cc_id = _i('emoji_carrier_crawler_id')

        # Session reaper (clean up sessions older than 60 minutes)
        self._reaper_task = asyncio.create_task(self._session_reaper())

    def cog_unload(self):
        try:
            self._reaper_task.cancel()
        except Exception:
            pass

    async def _session_reaper(self):
        await self.bot.wait_until_ready()
        while not self.bot.is_closed():
            now = time.time()
            ttl = 3600  # 60 min
            dead = [k for k, s in list(self.sessions.items())
                    if now - s.get("created_ts", now) > ttl]
            for k in dead:
                self.sessions.pop(k, None)
            await asyncio.sleep(300)  # sweep every 5 min

    # ----- user prefs helpers (persisted to data_file) -----

    def _get_user_prefs(self, user_id: int) -> Optional[Dict]:
        prefs = self.bot.data_manager.get('spicepay_prefs')
        for row in prefs:
            if row.get('user_id') == user_id:
                return row
        return None

    def _save_user_prefs(self, user_id: int, weights: Dict[str, float]):
        # overwrite existing
        self.bot.data_manager.remove('spicepay_prefs', lambda r: r.get('user_id') == user_id)
        self.bot.data_manager.add('spicepay_prefs', {
            'user_id': user_id,
            'weights': {
                'base': float(weights.get('base', self.base_weight)),
                'carrier': float(weights.get('carrier', self.carrier_bonus)),
                'crawler': float(weights.get('crawler', self.crawler_bonus)),
                'lsr': float(weights.get('lsr', self.lsr_cut_percent)),
            },
            'saved_ts': time.time()
        })

    # ----- commands -----

    @app_commands.command(name="spicepay", description="Calculate spice payout via a guided wizard (opens a form)")
    @app_commands.describe(
        participants="Total number of people involved (include owners of refiner/carrier/crawler)",
        force_new="Start a fresh session even if one is active"
    )
    async def spicepay(self, interaction: discord.Interaction, participants: Optional[int] = None, force_new: Optional[bool] = False):
        key = (interaction.guild.id if interaction.guild else 0, interaction.user.id)
        if not force_new and key in self.sessions:
            return await self.render_progress(interaction, key, new_message=True)

        try:
            await interaction.response.send_modal(_SetupModal(self, participants))
        except Exception as e:
            print("[spicepay] send_modal failed:", repr(e))
            try:
                await interaction.response.defer(ephemeral=True, thinking=False)
            except Exception:
                pass
            await interaction.followup.send(
                "Couldn’t open the setup modal automatically. Click below to start:",
                ephemeral=True,
                view=_StartView(self, participants)
            )

    @app_commands.command(name="spicepay_resume", description="Reopen your active spice pay session")
    async def spicepay_resume(self, interaction: discord.Interaction):
        key = (interaction.guild.id if interaction.guild else 0, interaction.user.id)
        if key in self.sessions:
            await self.render_progress(interaction, key, new_message=True)
        else:
            await interaction.response.send_message(
                "No active session found. Run **/spicepay** to start a new one.",
                ephemeral=True
            )

    @app_commands.command(name="spicepay_cancel", description="Cancel your active spicepay session")
    async def spicepay_cancel(self, interaction: discord.Interaction):
        key = (interaction.guild.id if interaction.guild else 0, interaction.user.id)
        if key in self.sessions:
            self.sessions.pop(key, None)
            await interaction.response.send_message("Cancelled your spice pay session.", ephemeral=True)
        else:
            await interaction.response.send_message("No active spice pay session.", ephemeral=True)

    @app_commands.command(name="spicepay_config", description="Show the current spicepay weight settings")
    async def spicepay_config(self, interaction: discord.Interaction):
        txt = (
            "**SpicePay configuration**\n"
            f"- Refinery cut (melange payouts): **{self.lsr_cut_percent}%**\n"
            f"- Base weight: **{self.base_weight} × active %**\n"
            f"- Carrier bonus: **+{self.carrier_bonus}**\n"
            f"- Crawler bonus: **+{self.crawler_bonus}**\n\n"
            "_Set via environment variables or your INI. Restart the bot after changing._"
        )
        await interaction.response.send_message(txt, ephemeral=True)

    # ----- session helpers -----

    async def start_session(self, interaction: discord.Interaction, total: int, count: int):
        g = interaction.guild
        key = (g.id if g else 0, interaction.user.id)

        # seed weights from user prefs if present
        w = {}
        user_prefs = self._get_user_prefs(interaction.user.id)
        if user_prefs and isinstance(user_prefs.get('weights'), dict):
            w = {
                'base': float(user_prefs['weights'].get('base', self.base_weight)),
                'carrier': float(user_prefs['weights'].get('carrier', self.carrier_bonus)),
                'crawler': float(user_prefs['weights'].get('crawler', self.crawler_bonus)),
                'lsr': float(user_prefs['weights'].get('lsr', self.lsr_cut_percent)),
            }

        # Auto-fill initiator as participant #1 (easy to adjust)
        display = interaction.user.display_name if isinstance(interaction.user, discord.Member) else interaction.user.name
        participants_seed = [{'name': display, 'participation': 50.0, 'roles': []}]

        self.sessions[key] = {
            "total": total,                # sand yield
            "count": count,
            "participants": participants_seed,
            "next_index": 1,               # stays on 1 so they can review/edit their autofill
            "created_ts": time.time(),
            "payout_type": "sand",         # 'sand' or 'melange'
            "melange_total": None,         # required if payout_type == 'melange'
            "weights": w                   # optional overrides: base, carrier, crawler, lsr(refinery)
        }
        await self.render_progress(interaction, key, new_message=True)

    async def render_progress(self, interaction: discord.Interaction, key: tuple, new_message: bool = False):
        sess = self.sessions.get(key)
        if not sess:
            return await interaction.response.send_message("Session expired.", ephemeral=True)

        sand_emoji = _emoji_str(self.bot, self.emoji_sand_id, "🏜️")
        mel_emoji = _emoji_str(self.bot, self.emoji_melange_id, "🧪")
        cc_emoji = _emoji_str(self.bot, self.emoji_cc_id, "🚛")

        w = sess.get("weights") or {}
        bw = w.get("base", self.base_weight)
        cab = w.get("carrier", self.carrier_bonus)
        crb = w.get("crawler", self.crawler_bonus)
        lsrp = w.get("lsr", self.lsr_cut_percent)

        filled = sum(1 for p in sess["participants"] if p.get("name"))
        idx = sess["next_index"]
        total_pct = int(round(sum((p.get("participation") or 0) for p in sess["participants"])))

        payout_line = f"Payout: **Sand Spice** {sand_emoji}"
        if sess.get("payout_type") == "melange":
            if sess.get("melange_total") is None:
                payout_line = f"Payout: **Spice Melange** {mel_emoji} — _set melange refinery yield_"
            else:
                payout_line = f"Payout: **Spice Melange** {mel_emoji} — total **{sess['melange_total']}**"

        progress = (
            f"**Spice Pay Setup** — sand yield: **{sess['total']}** {sand_emoji} | participants: **{sess['count']}** | "
            f"total active ≈ **{total_pct}%**\n"
            f"{payout_line}\n"
            f"Filled: **{filled}/{sess['count']}** — next: **{idx}**\n"
            f"_Weighting factors: Refinery cut {lsrp}% (melange only) | Base {bw}× active % | Carrier +{cab} | Crawler +{crb} — "
            f"normalized; only ratios matter._ {cc_emoji}"
        )

        preview_lines = []
        for i in range(sess["count"]):
            p = sess["participants"][i] if i < len(sess["participants"]) else None
            if p and p.get("name"):
                mark = " (owner only)" if int(round(p.get("participation") or 0)) == 0 else ""
                preview_lines.append(f"{i+1}. {p['name']} — {int(p['participation'])}%{mark} — {_fmt_roles_short(p.get('roles', []))}")
            else:
                preview_lines.append(f"{i+1}. _empty_")

        content = progress + "\n" + "\n".join(preview_lines)
        view = _WizardView(self, key)

        try:
            if new_message:
                await interaction.response.send_message(content, ephemeral=True, view=view)
            else:
                await interaction.response.edit_message(content=content, view=view)
        except discord.InteractionResponded:
            await interaction.followup.send(content, ephemeral=True, view=view)

    async def apply_preset(self, interaction: discord.Interaction, key: tuple, preset: str):
        sess = self.sessions.get(key)
        if not sess:
            return await interaction.response.send_message("Session expired.", ephemeral=True)

        if preset == "owner":
            w = {'base': 15.0, 'carrier': 25.0, 'crawler': 25.0, 'lsr': sess.get('weights', {}).get('lsr', self.lsr_cut_percent)}
        elif preset == "participation":
            w = {'base': 50.0, 'carrier': 5.0,  'crawler': 5.0,  'lsr': sess.get('weights', {}).get('lsr', self.lsr_cut_percent)}
        elif preset == "even":
            w = {'base': 1.0,  'carrier': 0.0, 'crawler': 0.0, 'lsr': 0.0}
        else:  # "fair" -> server defaults
            w = {'base': self.base_weight, 'carrier': self.carrier_bonus, 'crawler': self.crawler_bonus, 'lsr': self.lsr_cut_percent}

        sess['weights'] = w
        await self.render_progress(interaction, key)

    async def save_user_defaults(self, interaction: discord.Interaction, key: tuple):
        sess = self.sessions.get(key)
        if not sess:
            return await interaction.response.send_message("Session expired.", ephemeral=True)
        w = sess.get('weights') or {
            'base': self.base_weight,
            'carrier': self.carrier_bonus,
            'crawler': self.crawler_bonus,
            'lsr': self.lsr_cut_percent
        }
        self._save_user_prefs(interaction.user.id, w)
        try:
            await interaction.response.send_message("Saved these weighting factors as your defaults ✅", ephemeral=True)
        except discord.InteractionResponded:
            await interaction.followup.send("Saved these weighting factors as your defaults ✅", ephemeral=True)

    async def finish_and_show(self, interaction: discord.Interaction, key: tuple):
        sess = self.sessions.get(key)
        if not sess:
            return await interaction.response.send_message("Session expired.", ephemeral=True)

        payout_type = sess.get("payout_type", "sand")
        if payout_type == "melange" and sess.get("melange_total") is None:
            return await interaction.response.send_message("❌ Set the melange refinery yield first.", ephemeral=True)

        w = sess.get("weights") or {}
        bw = w.get("base", self.base_weight)
        cab = w.get("carrier", self.carrier_bonus)
        crb = w.get("crawler", self.crawler_bonus)
        lsrp = w.get("lsr", self.lsr_cut_percent)

        participants = []
        names_seen = set()
        for i in range(sess["count"]):
            p = sess["participants"][i] if i < len(sess["participants"]) else {}
            name = (p.get("name") or f"User{i+1}").strip()
            if name in names_seen:
                name = f"{name}_{i+1}"
            names_seen.add(name)
            part = p.get("participation", 0)
            roles = p.get("roles", [])
            participants.append({"name": name, "participation": part, "roles": roles})

        if payout_type == "sand":
            total = int(sess["total"])
            lsr_req = False
            lsr_pct = 0.0
            unit_header = "Sand Spice"
            unit_suffix = "Sand Spice"
            title_emoji = _emoji_str(self.bot, self.emoji_sand_id, "🏜️")
            payout_name = "Sand Spice"
        else:
            total = int(sess["melange_total"])
            lsr_req = True
            lsr_pct = float(lsrp)
            unit_header = "Spice Melange"
            unit_suffix = "Spice Melange"
            title_emoji = _emoji_str(self.bot, self.emoji_melange_id, "🧪")
            payout_name = "Spice Melange"

        results, errors = calculate_spice_distribution(
            total,
            participants,
            base_weight=bw,
            carrier_bonus=cab,
            crawler_bonus=crb,
            lsr_cut_percent=lsr_pct,
            lsr_required=lsr_req
        )
        if errors:
            err_text = "❌ **Problems found:**\n" + "\n".join(f"- {e}" for e in errors) + "\n\n" \
                       "Use **Add / Edit participant** to fix, or **Cancel**."
            try:
                await interaction.response.edit_message(content=err_text, view=_WizardView(self, key))
            except discord.InteractionResponded:
                await interaction.followup.send(err_text, ephemeral=True, view=_WizardView(self, key))
            return

        table = _format_table(results, unit_header, unit_suffix)

        class _PostView(discord.ui.View):
            def __init__(self, outer: "SpicePayCog"):
                super().__init__(timeout=600)
                self.outer = outer

            @discord.ui.button(label="Post to channel", style=discord.ButtonStyle.primary)
            async def post(self, inter: discord.Interaction, _btn: discord.ui.Button):
                header = f"**Payout: {payout_name}** {title_emoji}"
                settings = f"_Base {bw}× active % | Carrier +{cab} | Crawler +{crb}_"
                if payout_type == "melange":
                    settings = f"_Refinery cut {lsrp}% | " + settings
                txt = f"{header}\n{settings}\n{table}"
                await inter.channel.send(txt)
                await inter.response.edit_message(content="Posted to channel ✅", view=None)

            @discord.ui.button(label="Back", style=discord.ButtonStyle.secondary)
            async def back(self, inter: discord.Interaction, _btn: discord.ui.Button):
                await self.outer.render_progress(inter, key)

        content = f"**Preview** (not posted): **Payout: {payout_name}** {title_emoji}\n{table}"
        try:
            await interaction.response.edit_message(content=content, view=_PostView(self))
        except discord.InteractionResponded:
            await interaction.followup.send(content=content, ephemeral=True, view=_PostView(self))

# ------------------------ setup ------------------------

async def setup(bot: commands.Bot):
    cog = SpicePayCog(bot)
    await bot.add_cog(cog)
