// NO PROD JSON API (bucket C) — mock until backend (see PROD_API_INVENTORY.md)
/* Bonus Programs — the promotions list shown when opening "Promozioni Bonus".

   Each row is a promotion with action buttons. The document/report action
   opens the per-promotion "Activations" report: the list of players that used
   the promotion, with the full bonus lifecycle (assigned → redeemed → wagered
   → expired/cancelled). Each per-promotion report surfaces that promotion's
   Total Budget and the % of that budget already reached — the list page
   itself deliberately does not roll budget up across promotions. */

const { useState: useStateBP, useMemo: useMemoBP } = React;

/* it-IT money formatting → "3.000,00" */
const bpFmt = (n) => Number(n || 0).toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const bpPct = (n) => Number(n || 0).toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " %";
const bpP2 = (n) => String(n).padStart(2, "0");
const bpDateTime = (ts) => { const d = new Date(ts); return `${bpP2(d.getUTCDate())}/${bpP2(d.getUTCMonth() + 1)}/${String(d.getUTCFullYear()).slice(2)} ${bpP2(d.getUTCHours())}:${bpP2(d.getUTCMinutes())}`; };

const bpHash = (s) => { let h = 2166136261 >>> 0; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; };
const bpRng = pbRng;   // was a local copy of mulberry32 — see pbRng in src/data.jsx
/* ------------------------------------------------------------------ *
 * THE DATA. The list, the activations and the budget were all invented.
 *
 * `BP_PROMOS` was ten hand-written programmes with hand-written version
 * numbers (v1 to v8) and budgets, kept in localStorage under `pb-bonus-programs`
 * so anything created survived a reload — which made it look persisted rather
 * than local, and is why the delete toast had to say "the row returns on
 * reload" while the row plainly did not.
 *
 * `bpGenActivations` was worse: forty to a hundred and ten player activations
 * per programme, each with a bonus amount, a wagering requirement at "typical
 * 40x", an amount already wagered and a redeemed total — and the budget-reached
 * figure the modal shows was the sum of them. Every number in that report was a
 * PRNG seeded on the programme id.
 *
 * All three read now: `bonus_programs` for the list, `bonus_instances` for the
 * activations, and the budget figure summed from the instances that exist.
 * Writes go through save_bonus_program / delete_bonus_program (supabase/049) —
 * NOT app_write, because a programme is eight tables and one version number and
 * a column list can keep neither promise.
 * ------------------------------------------------------------------ */

const BP_FETCH_MAX = 500;

/* The list speaks the wizard's vocabulary ("Wagering bonus", "Casino",
   "Active"); the database speaks its own ('wagering_bonus', 'casino',
   'active'). Mapped at this edge in both directions so neither side has to
   know about the other, and so an unknown value renders as itself rather than
   as a guess. */
const BP_PRODUCT_TO_DB = { Casino: "casino", Sports: "sports", Sport: "sports", Poker: "poker", Lottery: "lottery" };
const BP_PRODUCT_FROM_DB = { casino: "Casino", sports: "Sports", poker: "Poker", lottery: "Lottery" };
const BP_BONUS_TO_DB = {
  "Wagering bonus": "wagering_bonus", "Freespin": "freespin", "Freebet": "freebet",
  "Golden chip": "golden_chip", "Cash bonus": "cash_bonus", "No deposit": "no_deposit",
};
const BP_BONUS_FROM_DB = {
  wagering_bonus: "Wagering bonus", freespin: "Freespin", freebet: "Freebet",
  golden_chip: "Golden chip", cash_bonus: "Cash bonus", no_deposit: "No deposit",
};
const BP_STATUS_TO_DB = { Active: "active", Paused: "paused", Draft: "draft", Ended: "archived", Archived: "archived" };
const BP_STATUS_FROM_DB = { active: "Active", paused: "Paused", draft: "Draft", archived: "Ended" };

const bpDbDate = (iso) => (iso ? String(iso).slice(0, 16).replace("T", " ") : "-");

const bpRowFromDb = (r) => ({
  id: r.id,
  /* `pid` was a twelve-hex-character invention. program_uid is a real uuid the
     database generates, shortened for display only. */
  pid: r.program_uid ? String(r.program_uid).replace(/-/g, "").slice(0, 12) : String(r.id),
  program_uid: r.program_uid,
  name: r.display_name || r.name || "",
  raw_name: r.name || "",
  skin_id: r.skin_id,
  skin: r.skin ? r.skin.name : String(r.skin_id),
  product: BP_PRODUCT_FROM_DB[r.product_type] || r.product_type || "",
  bonusType: BP_BONUS_FROM_DB[r.bonus_type] || r.bonus_type || "",
  status: BP_STATUS_FROM_DB[r.status] || r.status || "",
  priority: r.priority == null ? 0 : Number(r.priority),
  start: bpDbDate(r.starts_at),
  end: bpDbDate(r.ends_at),
  /* THE REAL VERSION, from config_version — the number save_bonus_program bumps
     when and only when the terms change (049). Was a hand-written 1-8. */
  version: r.config_version == null ? 1 : Number(r.config_version),
  budget: r.budget_amount == null ? 0 : Number(r.budget_amount),
  /* The raw parent and its seven configuration rows, carried through untouched
     so the wizard can prefill from what is STORED rather than from the summary
     the list renders. An Edit form that opened blank would save blanks. */
  _db: r,
});

/* ------------------------------------------------------------------ *
 * The wizard's form -> save_bonus_program's eight arguments.
 *
 * THE WIZARD USED TO DISCARD MOST OF WHAT IT COLLECTED. `onCreate` forwarded
 * nine summary fields — name, skin, product, bonus type, status, priority, the
 * two dates and a budget — and dropped everything an operator filled in on
 * steps 3 to 7: the calculation method, the wagering multiplier and its
 * contributions, the deposit gate, the eligibility caps, the forfeiture
 * behaviour. Seven steps of form, two steps' worth of payload.
 *
 * Every vocabulary is translated HERE and nowhere else. The wizard says
 * "Deposit % Match" because that is what an operator reads; 004 says
 * 'deposit_match' because that is what the engine branches on. A screen that
 * sent the label would fail a CHECK constraint naming a column the operator
 * never saw.
 * ------------------------------------------------------------------ */
const BP_CALC_TO_DB = {
  "Fixed Amount": "fixed_amount", "Deposit % Match": "deposit_match", "Wager Rate-Based": "wager_rate_based",
};
const BP_GAMES_FILTER_TO_DB = {
  All: "all", "Provider Allowlist": "provider_allowlist", "Game Allowlist": "game_allowlist", Denylist: "denylist",
};
const BP_OUTCOME_TO_DB = {
  "Convert bonus to cash": "convert_bonus_to_cash",
  "Transfer winnings to bonus balance": "transfer_winnings_to_bonus",
};
const BP_CONSUMPTION_TO_DB = { "Cash first": "cash_first", "Bonus first": "bonus_first" };
const BP_KYC_TO_DB = { None: "none", Basic: "basic", Full: "full" };
const BP_UNMET_TO_DB = {
  "Keep balance locked indefinitely": "lock_indefinitely",
  "Forfeit bonus-derived winnings after window expires": "forfeit_on_expiry",
};
const BP_TRIGGER_TO_DB = {
  "Signup / Registration Trigger": "signup",
  "Deposit Approved % Match Trigger": "deposit",
  "Bet-Based Bonus Calculation Trigger": "bet_based",
  "Net-loss Cashback Trigger": "cashback",
  "Manual / CRM Assignment Trigger": "manual",
};
/* Two campaign-end options upstream, three values in the schema. The one the
   wizard does not offer ('stop_issuing') is the column default and is what a
   programme gets if neither is chosen. */
const BP_CAMPAIGN_END_TO_DB = {
  "Stop issuing + allow active bonuses to continue": "continue_active",
  "Stop issuing + force forfeit active bonuses": "force_forfeit",
};
const BP_WIZARD_BONUS_TO_DB = {
  wagering: "wagering_bonus", freespin: "freespin", cash: "cash_bonus", nodeposit: "no_deposit",
};

/* "" and null both mean "not set" and must stay null rather than becoming 0.
   A wagering multiplier of 0 is a bonus with no requirement at all — the same
   class of defect supabase/047 was written about, and the reason every numeric
   here goes through one function. */
const bpNum = (v) => (v === "" || v == null || isNaN(Number(v)) ? null : Number(v));

const bpRpcArgs = (f, id) => {
  const iso = (v) => (v ? new Date(v).toISOString() : null);
  return {
    p_id: id || null,
    p_program: {
      skin_id: f.skin_id ? Number(f.skin_id) : undefined,
      name: (f.programName || "").trim(),
      display_name: (f.displayName || "").trim() || null,
      short_description: f.shortDescription || null,
      terms_conditions: f.terms || null,
      key_terms: f.keyTerms || null,
      product_type: BP_PRODUCT_TO_DB[f.productType] || "casino",
      bonus_type: BP_WIZARD_BONUS_TO_DB[f.bonusType] || "wagering_bonus",
      trigger_type: BP_TRIGGER_TO_DB[f.triggerType] || null,
      status: BP_STATUS_TO_DB[f.status] || "draft",
      priority: bpNum(f.priority) == null ? 100 : Number(f.priority),
      starts_at: iso(f.startDate),
      ends_at: iso(f.endDate),
      campaign_end_handling: BP_CAMPAIGN_END_TO_DB[f.campaignEnd] || "stop_issuing",
      budget_amount: bpNum(f.globalMaxValue),
    },
    p_terms: {
      calculation_method: BP_CALC_TO_DB[f.calcMethod] || "fixed_amount",
      fixed_bonus_amount: bpNum(f.fixedAmount),
      match_percentage: bpNum(f.matchPct),
      minimum_bonus_amount: bpNum(f.minBonusAmount),
      maximum_bonus_amount: bpNum(f.maxBonusAmount),
      wager_rate_amount: bpNum(f.wagerRateAmount),
      wager_unit_size: bpNum(f.wagerUnitSize),
      max_cashout_winnings_cap: bpNum(f.maxCashout),
      completion_outcome: BP_OUTCOME_TO_DB[f.completionOutcome] || "convert_bonus_to_cash",
      manual_forfeit_allowed: !!f.manualForfeit,
      number_of_freespins: bpNum(f.freespinCount),
      freespin_bet_amount: bpNum(f.freespinCoinValue),
    },
    p_wagering: {
      wagering_multiplier: bpNum(f.wageringMultiplier),
      wagering_time_limit_hours: bpNum(f.wageringTimeLimit),
      bonus_funds_expiry_hours: bpNum(f.bonusFundsExpiry),
      slots_contribution: bpNum(f.slotsContribution),
      live_casino_contribution: bpNum(f.liveContribution),
      sports_contribution: bpNum(f.sportsContribution),
      default_contribution: bpNum(f.defaultContribution),
      min_bet_while_active: bpNum(f.minBet),
      max_bet_while_active: bpNum(f.maxBet),
      balance_consumption_order: BP_CONSUMPTION_TO_DB[f.consumptionOrder] || undefined,
      allowed_games_filter: BP_GAMES_FILTER_TO_DB[f.gamesFilter] || undefined,
    },
    /* '{}' rather than null when the gate is OFF: null means "leave whatever is
       there alone", and an operator who unticks the gate means to clear it. */
    p_deposit_gate: f.depositGateEnabled ? {
      enabled: true,
      required_percent: bpNum(f.requiredDepositPct),
      window_value: bpNum(f.depositWindowValue),
      window_unit: (f.depositWindowUnit || "Hours").toLowerCase(),
      unmet_behavior: BP_UNMET_TO_DB[f.unmetDepositBehavior] || "lock_indefinitely",
      min_deposit_amount: bpNum(f.minDepositAmount),
    } : {},
    p_eligibility: {
      new_players_only: !!f.newPlayersOnly,
      kyc_requirement: BP_KYC_TO_DB[f.kyc] || "none",
      fraud_risk_block: !!f.fraudBlock,
      per_player_max_claims: bpNum(f.perPlayerMaxClaims),
      max_active_bonuses_per_player: bpNum(f.maxActivePerCategory),
      global_max_issued_count: bpNum(f.globalMaxCount),
      global_max_issued_value: bpNum(f.globalMaxValue),
      global_max_converted_value: bpNum(f.globalMaxConverted),
    },
    p_bet_based: f.triggerType === "Bet-Based Bonus Calculation Trigger" ? {
      min_bet_count: bpNum(f.minBetCount),
      window_hours: bpNum(f.evalWindowHours),
    } : {},
    p_cashback: f.triggerType === "Net-loss Cashback Trigger" ? {
      periodicity: (f.netLossPeriod || "Daily").toLowerCase(),
      cashback_rate: bpNum(f.cashbackPct),
    } : {},
  };
};

const bpUseDb = () => {
  const feed = useHrsFetch(() => window.sb.list("bonusPrograms", { limit: BP_FETCH_MAX }), []);
  const skinsFeed = useHrsFetch(() => window.sb.list("skins", { limit: 200 }), []);
  /* The wizard's freespin-provider and allowed-provider pickers decide where a
     bonus may be played. They read a hardcoded array until now; a name that is
     not a real provider makes a bonus playable nowhere. */
  const provFeed = useHrsFetch(() => window.sb.list("providers", { limit: 500 }), []);

  const promos = useMemoBP(() => (feed.data || []).map(bpRowFromDb), [feed.data]);
  const skins = useMemoBP(
    () => (skinsFeed.data || []).map(k => ({ id: k.id, name: k.name })), [skinsFeed.data]);
  const bpProviders = useMemoBP(
    () => (provFeed.data || []).map(p => ({ id: p.id, name: p.name })), [provFeed.data]);

  return {
    promos, skins, bpProviders,
    truncated: feed.meta && feed.meta.total > promos.length,
    loading: feed.loading || skinsFeed.loading,
    error: feed.error || skinsFeed.error,
    retry: () => { feed.retry(); skinsFeed.retry(); },
    feeds: [feed],
  };
};

const BP_STATUS_CHIP = { Active: "chip--ok", Paused: "chip--warn", Ended: "chip--neutral", Draft: "chip--info" };
const BP_ACT_STATUS_CHIP = { Active: "chip--ok", Redeemed: "chip--info", Expired: "chip--neutral", Failed: "chip--err", Canceled: "chip--warn" };
const BP_ACT_STATUSES = ["Expired", "Failed", "Expired", "Canceled", "Expired", "Failed", "Active", "Redeemed"];

/* The per-programme activations, from `bonus_instances`.
--
   `bpGenActivations` produced forty to a hundred and ten of these per
   programme from a PRNG seeded on the programme id: a bonus amount from a list
   of five, a wagering requirement at a "typical 40x", an amount already
   wagered, a redeemed total, and a status from a weighted array. The
   budget-reached figure the modal headlines was the sum of those invented
   amounts, presented against a budget that was itself invented.
--
   Every one of those is a real column on bonus_instances. `required_wagering`
   and `wagered_amount` in particular are maintained by the wagering engine —
   `wagered_amount` is derived from bonus_wager_logs by trigger and a direct
   UPDATE raises (004) — so they are the two figures a screen has least business
   guessing. */
const BP_ACT_FROM_DB = {
  active: "Active", pending: "Active", completed: "Redeemed", converted: "Redeemed",
  expired: "Expired", forfeited: "Failed", cancelled: "Canceled", terminated: "Canceled",
};
const bpTs = (iso) => (iso ? new Date(iso).getTime() : null);
const bpActRow = (r) => ({
  id: r.id,
  player: r.user ? r.user.username : String(r.user_id),
  bonusAmount: Number(r.bonus_amount) || 0,
  date: bpTs(r.issued_at),
  /* "assignedBy" was a 12% coin flip between "operator" and "-". granted_by_id
     is null for an engine grant and a real operator for a manual one, which is
     the distinction the column was pretending to draw. */
  assignedBy: r.grantedBy ? r.grantedBy.username : "-",
  expiry: bpTs(r.expires_at),
  redeemedAt: bpTs(r.completed_at),
  balance: Number(r.remaining_bonus_balance) || 0,
  wagered: Number(r.wagered_amount) || 0,
  wageringRemaining: Number(r.wagering_remaining) || 0,
  redeemedAmount: Number(r.converted_amount) || 0,
  status: BP_ACT_FROM_DB[r.status] || r.status || "",
});

/* ---------- Activations modal (per-promotion report) ---------- */
const ActivationsModal = ({ promo, onClose }) => {
  const feed = useHrsFetch(
    () => window.sb.list("bonusInstances", { limit: 1000, filters: { program: promo.id } }), [promo.id]);
  const rows = useMemoBP(() => (feed.data || []).map(bpActRow), [feed.data]);
  /* CANCEL, on the campaign's own activations list — the same forfeit_bonus()
     the player screen calls (supabase/053). It debits whatever is left and only
     then closes the row, because 004's R6 refuses a terminal instance holding a
     balance. Refetching afterwards is not cosmetic: the row's status, balance
     and the campaign's "budget reached" header all move with it. */
  const csaveBP = useHrsSave([feed]);
  const cancelActivation = (r) => csaveBP.run(
    () => window.sb.rpc("forfeit_bonus", { p_instance_id: r.id, p_reason: "Cancelled from the campaign's activations" }),
    { done: `Bonus cancelled for ${r.player}`, fail: `The bonus was not cancelled for ${r.player}` });
  /* "Budget reached" is the sum of what has actually been ISSUED under this
     programme, which is what the header compares against budget_amount. It was
     the sum of the invented amounts above. */
  const reached = useMemoBP(() => rows.reduce((a, r) => a + r.bonusAmount, 0), [rows]);
  const [draft, setDraft] = useStateBP({ player: "", status: "ALL", dateFrom: "", dateTo: "", expFrom: "", expTo: "", amtFrom: "", amtTo: "" });
  const [applied, setApplied] = useStateBP(draft);
  const setD = (patch) => setDraft(d => ({ ...d, ...patch }));

  const filtered = useMemoBP(() => {
    const dF = applied.dateFrom ? Date.parse(applied.dateFrom + "T00:00:00Z") : -Infinity;
    const dT = applied.dateTo ? Date.parse(applied.dateTo + "T23:59:59Z") : Infinity;
    const eF = applied.expFrom ? Date.parse(applied.expFrom + "T00:00:00Z") : -Infinity;
    const eT = applied.expTo ? Date.parse(applied.expTo + "T23:59:59Z") : Infinity;
    const aF = parseFloat(applied.amtFrom); const aT = parseFloat(applied.amtTo);
    const pq = applied.player.trim().toLowerCase();
    return rows.filter(r => {
      if (pq && !r.player.toLowerCase().includes(pq) && !String(r.id).includes(pq)) return false;
      if (applied.status !== "ALL" && r.status !== applied.status) return false;
      if (r.date < dF || r.date > dT) return false;
      if (r.expiry < eF || r.expiry > eT) return false;
      if (!isNaN(aF) && r.bonusAmount < aF) return false;
      if (!isNaN(aT) && r.bonusAmount > aT) return false;
      return true;
    });
  }, [rows, applied]);

  const reachedPct = promo.budget ? (reached / promo.budget) * 100 : 0;

  // Activation totals + widget stats (whole campaign, not filtered).
  const stats = useMemoBP(() => {
    const by = { Active: 0, Redeemed: 0, Canceled: 0, Failed: 0, Expired: 0 };
    let issued = 0, redeemedAmt = 0, wagered = 0;
    rows.forEach(r => { by[r.status] = (by[r.status] || 0) + 1; issued += r.bonusAmount; redeemedAmt += r.redeemedAmount; wagered += r.wagered; });
    const total = rows.length;
    return { by, total, issued, redeemedAmt, wagered, redeemRate: total ? (by.Redeemed / total) * 100 : 0 };
  }, [rows]);

  const exportCSV = () => {
    if (!window.PAYBO) return;
    window.PAYBO.downloadCSV(`activations-${promo.id}.csv`, filtered, [
      { key: "id", label: "id" }, { key: "player", label: "player" },
      { key: "bonusAmount", label: "bonus_amount" },
      { key: "date", label: "date", get: r => bpDateTime(r.date) },
      { key: "assignedBy", label: "assigned_by" },
      { key: "expiry", label: "expiry_date", get: r => bpDateTime(r.expiry) },
      { key: "redeemedAt", label: "redeemed_at", get: r => r.redeemedAt ? bpDateTime(r.redeemedAt) : "" },
      { key: "balance", label: "balance" }, { key: "wagered", label: "wagered_amount" },
      { key: "wageringRemaining", label: "wagering_remaining" }, { key: "redeemedAmount", label: "redeemed_amount" },
      { key: "status", label: "status" },
    ]);
    window.PAYBO.emitToast && window.PAYBO.emitToast({
      id: `export-act-${Date.now()}`, tx_id: `Activations · ${promo.name}`,
      amount: 0, currency: "CSV", player: `${filtered.length} rows`,
      reason: "Export queued · recorded in the Export Requests table.",
    });
  };

  return (
    <div className="bp-modal-scrim" onClick={onClose}>
      <div className="bp-modal report-page" onClick={e => e.stopPropagation()}>
        <div className="bp-modal__head">
          <div className="bp-modal__title">Activations: "{promo.name}"</div>
          <button className="btn btn--ghost btn--icon" onClick={onClose}><Icon name="x" size={15} /></button>
        </div>

        {/* Budget overview for this promotion's report */}
        <div className="bp-budget">
          <div className="bp-budget__item"><span className="lbl">Total Budget of the Promotion</span><span className="val">{bpFmt(promo.budget)}</span></div>
          <div className="bp-budget__item"><span className="lbl">% of Budget reached</span><span className="val">{bpPct(reachedPct)}</span></div>
          <div className="bp-budget__bar"><span style={{ width: `${Math.min(100, reachedPct)}%` }} /></div>
        </div>

        {/* Activation totals + widget stats */}
        <div className="act-stats">
          <div className="act-stat act-stat--total"><div className="act-stat-v">{stats.total.toLocaleString("it-IT")}</div><div className="act-stat-k">Total activations</div></div>
          <div className="act-stat act-stat--active"><div className="act-stat-v">{stats.by.Active.toLocaleString("it-IT")}</div><div className="act-stat-k">Active</div></div>
          <div className="act-stat act-stat--redeemed"><div className="act-stat-v">{stats.by.Redeemed.toLocaleString("it-IT")}</div><div className="act-stat-k">Redeemed</div></div>
          <div className="act-stat act-stat--canceled"><div className="act-stat-v">{stats.by.Canceled.toLocaleString("it-IT")}</div><div className="act-stat-k">Canceled</div></div>
          <div className="act-stat act-stat--failed"><div className="act-stat-v">{stats.by.Failed.toLocaleString("it-IT")}</div><div className="act-stat-k">Failed</div></div>
          <div className="act-stat act-stat--expired"><div className="act-stat-v">{stats.by.Expired.toLocaleString("it-IT")}</div><div className="act-stat-k">Expired</div></div>
        </div>
        <div className="act-stats act-stats--money">
          <div className="act-mstat"><div className="act-mstat-ic"><Icon name="tag" size={16} /></div><div><div className="act-mstat-v">{bpFmt(stats.issued)}</div><div className="act-mstat-k">Bonus amount issued</div></div></div>
          <div className="act-mstat"><div className="act-mstat-ic"><Icon name="wallet" size={16} /></div><div><div className="act-mstat-v">{bpFmt(stats.redeemedAmt)}</div><div className="act-mstat-k">Redeemed amount</div></div></div>
          <div className="act-mstat"><div className="act-mstat-ic"><Icon name="chart" size={16} /></div><div><div className="act-mstat-v">{bpFmt(stats.wagered)}</div><div className="act-mstat-k">Wagered amount</div></div></div>
          <div className="act-mstat"><div className="act-mstat-ic"><Icon name="percent" size={16} /></div><div><div className="act-mstat-v">{bpPct(stats.redeemRate)}</div><div className="act-mstat-k">Redeem rate</div></div></div>
        </div>

        {/* Filters */}
        <div className="rpt-filters" style={{ marginBottom: 14 }}>
          <div style={{ display: "flex", gap: 22, flexWrap: "wrap", flex: 1 }}>
            <div className="rpt-field" style={{ flex: 1, minWidth: 220 }}>
              <label>Player</label>
              <input className="input" value={draft.player} onChange={e => setD({ player: e.target.value })} placeholder="Player" style={{ width: "100%" }} />
            </div>
            <div className="rpt-field" style={{ minWidth: 180 }}>
              <label>Status</label>
              <select className="select" value={draft.status} onChange={e => setD({ status: e.target.value })}>
                <option value="ALL">Select</option>
                {["Active", "Redeemed", "Expired", "Failed", "Canceled"].map(s => <option key={s} value={s}>{s}</option>)}
              </select>
            </div>
          </div>
          <div style={{ display: "flex", gap: 22, flexWrap: "wrap", width: "100%" }}>
            <div className="rpt-field"><label>Date</label><div className="rpt-daterow">
              <input className="input rpt-date" type="date" value={draft.dateFrom} onChange={e => setD({ dateFrom: e.target.value })} />
              <input className="input rpt-date" type="date" value={draft.dateTo} onChange={e => setD({ dateTo: e.target.value })} />
            </div></div>
            <div className="rpt-field"><label>Expiry date</label><div className="rpt-daterow">
              <input className="input rpt-date" type="date" value={draft.expFrom} onChange={e => setD({ expFrom: e.target.value })} />
              <input className="input rpt-date" type="date" value={draft.expTo} onChange={e => setD({ expTo: e.target.value })} />
            </div></div>
            <div className="rpt-field"><label>Amount</label><div className="rpt-daterow">
              <input className="input rpt-time" placeholder="From" value={draft.amtFrom} onChange={e => setD({ amtFrom: e.target.value })} />
              <input className="input rpt-time" placeholder="To" value={draft.amtTo} onChange={e => setD({ amtTo: e.target.value })} />
            </div></div>
            <div className="rpt-actions" style={{ flexDirection: "row", alignSelf: "flex-end" }}>
              <button className="rpt-btn rpt-btn--blue" onClick={() => setApplied(draft)}><Icon name="search" size={14} /> Search</button>
              <button className="rpt-btn rpt-btn--blue" onClick={exportCSV}><Icon name="download" size={14} /> Export</button>
            </div>
          </div>
        </div>

        {/* Activations table */}
        <div className="panel" style={{ overflow: "hidden" }}>
          <div style={{ maxHeight: "calc(100vh - 420px)", overflow: "auto" }}>
            <table className="data-table">
              <thead>
                <tr>
                  <th>ID</th><th>Player</th><th>Bonus amount</th><th>Date</th><th>Assigned By</th>
                  <th>Expiry date</th><th>Redeemed at</th><th>Balance</th><th>Wagered Amount</th>
                  <th>Wagering Amount</th><th>Redeemed amount</th><th>Status</th><th>Cancel</th>
                </tr>
              </thead>
              <tbody>
                {filtered.length === 0 && <tr><td colSpan={13} style={{ padding: "36px", textAlign: "center", color: "var(--text-tertiary)" }}>No activations match your filters.</td></tr>}
                {filtered.map((r, i) => (
                  <tr key={r.id + "-" + i}>
                    <td>{r.id}</td>
                    <td>{r.player}</td>
                    <td>{bpFmt(r.bonusAmount)}</td>
                    <td>{bpDateTime(r.date)}</td>
                    <td>{r.assignedBy}</td>
                    <td>{bpDateTime(r.expiry)}</td>
                    <td>{r.redeemedAt ? bpDateTime(r.redeemedAt) : "-"}</td>
                    <td>{bpFmt(r.balance)}</td>
                    <td>{bpFmt(r.wagered)}</td>
                    <td>{bpFmt(r.wageringRemaining)}</td>
                    <td>{bpFmt(r.redeemedAmount)}</td>
                    <td><span className={`chip ${BP_ACT_STATUS_CHIP[r.status] || "chip--neutral"}`}>{r.status}</span></td>
                    <td>{r.status === "Active" ? (
                      <button className="btn btn--ghost btn--icon btn--sm" disabled={csaveBP.busy}
                        title="Cancel this bonus — the remaining balance leaves the bonus wallet with a ledger row saying where it went"
                        onClick={() => cancelActivation(r)}>
                        <Icon name="x" size={12} />
                      </button>) : "-"}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </div>
      </div>
    </div>
  );
};

/* `bpReadPromos` / `bpWritePromos` / `bpNewPid` are gone with the seed. They
   kept the list in localStorage under `pb-bonus-programs`, which survived a
   reload and so read as persistence — the one flavour of fake data that
   convinces hardest, because the usual test for "is this real" is to refresh. */

/* ---------- Bonus Programs page ---------- */
const BonusPrograms = ({ brand }) => {
  window.useLocale && window.useLocale();
  // List vs. create-wizard gets its own URL (/bonus-campaigns vs
  // /bonus-campaigns/create) via useUrlTab (src/routes.jsx).
  const [view, setView] = window.useUrlTab("/bonus-campaigns", [["list", "List", ""], ["create", "Create", "create"]], "list");
  const { promos, skins, bpProviders, truncated, loading, error, retry, feeds } = bpUseDb();
  const save = useHrsSave(feeds);
  const [skin, setSkin] = useStateBP("");
  const [status, setStatus] = useStateBP("ALL");
  const [product, setProduct] = useStateBP("ALL");
  const [search, setSearch] = useStateBP("");
  const [reportPromo, setReportPromo] = useStateBP(null);
  const [editing, setEditing] = useStateBP(null);

  /* The skin filter defaults to the first brand the operator can see, and
     cannot be known before `skins` loads. Was hardcoded to "Juegojoker". */
  React.useEffect(() => {
    if (!skin && skins.length) setSkin(String(skins[0].id));
  }, [skins]);

  const skinOptions = skins;

  const filtered = useMemoBP(() => {
    const q = search.trim().toLowerCase();
    return promos.filter(p => {
      if (skin && String(p.skin_id) !== String(skin)) return false;
      if (status !== "ALL" && p.status !== status) return false;
      if (product !== "ALL" && p.product !== product) return false;
      if (q && !p.name.toLowerCase().includes(q)) return false;
      return true;
    });
  }, [promos, skin, status, product, search]);

  /* ARCHIVE, not delete. delete_bonus_program (049) soft-deletes and sets
     status = archived, so the programme stops being issued while its rows
     survive — reports over past bonuses still resolve its name. It returns the
     count of instances still live under it, which is reported rather than used
     as a refusal: a mispriced campaign has to be stoppable now, and live
     bonuses carry frozen terms of their own. */
  const removeProgram = (p) => save.run(
    () => window.sb.rpc("delete_bonus_program", { p_id: p.id }), {
      done: `Archived · ${p.name}`,
      fail: `${p.name} was not archived`,
    }).then(res => {
      const live = res && res.ok && res.data ? res.data.live_instances : null;
      if (res && res.ok && live) {
        window.hrsToast && window.hrsToast(`${p.name} archived`,
          `${live} bonus instance(s) are still live under it. They keep the terms they were issued on and settle normally — archiving stops new ones being issued.`);
      }
      return res;
    });

  /* One call, eight tables, one transaction. The wizard's whole form is
     forwarded — it used to hand over nine summary fields and drop the rest,
     so the wagering rules and caps an operator filled in on steps 4 and 5 were
     collected and thrown away. */
  const saveProgram = (draft, id) => save.run(
    () => window.sb.rpc("save_bonus_program", bpRpcArgs(draft, id)), {
      done: id ? `Saved · ${draft.displayName || draft.programName}` : `Created · ${draft.displayName || draft.programName}`,
      fail: id ? "The programme was not saved" : "The programme was not created",
    }).then(res => {
      if (res && res.ok) {
        const d = res.data || {};
        if (d.version_bumped) {
          window.hrsToast && window.hrsToast(`Now version ${d.config_version}`,
            "The terms changed, so the config version moved. Bonuses already issued keep the version they were issued on.");
        }
        setEditing(null);
        setView("list");
      }
      return res;
    });

  if (view === "create") {
    return (
      <BonusProgramWizard
        skins={skins}
        providers={bpProviders}
        row={editing}
        busy={save.busy}
        onBack={() => { setEditing(null); setView("list"); }}
        /* The WHOLE form, not nine summary fields. bpRpcArgs maps every step
           onto save_bonus_program's eight arguments; the RPC writes them in one
           transaction and decides whether config_version moves. */
        onCreate={(draft) => saveProgram(draft, editing ? editing.id : null)}
      />
    );
  }

  return (
    <div className="page report-page bonus-programs">
      <div className="page__header" style={{ justifyContent: "space-between", width: "100%" }}>
        <div className="page__title" style={{ color: "var(--p-700)" }}>Bonus Programs</div>
        <div style={{ display: "flex", gap: 10 }}>
          {/* Opens the promo-code list, mirroring the real platform where the
              two screens link to each other (the promo list carries a "Bonus
              Programs" back-link). Until Aug 2026 this fired a toast instead,
              because PromoCodeController had never been extracted and there
              was no screen to reach — see ISYSTEM_REFERENCE.md §Batch 7. */}
          <button className="rpt-btn rpt-btn--export" style={{ minWidth: 0, height: 40 }}
            onClick={() => window.goRoute && window.goRoute("bonus-promo-codes")}>
            <Icon name="tag" size={14} /> Promo Code</button>
          <button className="btn btn--ghost" onClick={() => setView("create")}><Icon name="plus" size={14} /> New Bonus Program</button>
        </div>
      </div>

      {/* Budget is intentionally NOT rolled up here — it is shown only
          per-promotion, inside each promotion's Activations report. */}

      {/* Filters */}
      <div className="rpt-filters">
        <div className="rpt-field"><label>Skin</label>
          <select className="select" value={skin} onChange={e => setSkin(e.target.value)} style={{ minWidth: 220 }}>
            {skinOptions.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
          </select>
        </div>
        <div className="rpt-field"><label>Status</label>
          <select className="select" value={status} onChange={e => setStatus(e.target.value)} style={{ minWidth: 180 }}>
            <option value="ALL">All Status</option>
            <option value="Active">Active</option>
            <option value="Paused">Paused</option>
            <option value="Draft">Draft</option>
            <option value="Archived">Archived</option>
          </select>
        </div>
        <div className="rpt-field"><label>Product Type</label>
          <select className="select" value={product} onChange={e => setProduct(e.target.value)} style={{ minWidth: 180 }}>
            <option value="ALL">All Products</option>
            <option value="Casino">Casino</option>
            <option value="Sport">Sport</option>
          </select>
        </div>
        <div className="rpt-field" style={{ flex: 1, minWidth: 220 }}><label>Search</label>
          <input className="input" value={search} onChange={e => setSearch(e.target.value)} placeholder="Search by name..." style={{ width: "100%" }} />
        </div>
        <div className="rpt-actions" style={{ marginLeft: 0 }}>
          <button className="rpt-btn rpt-btn--blue" onClick={() => { /* filters already live */ }}>Filter</button>
        </div>
      </div>

      {truncated && (
        <div className="panel" style={{ padding: "10px 14px", marginBottom: 10, color: "var(--warn-700)" }}>
          <Icon name="alert" size={13} /> There are more bonus programmes than the {BP_FETCH_MAX} this
          screen fetches. The list and the search below cover the fetched rows only.
        </div>
      )}

      {/* Programs table */}
      {loading ? <HrsSkeleton rows={6} cols={12} />
        : error ? <HrsError error={error} onRetry={retry} /> : (
      <div className="panel" style={{ overflow: "hidden" }}>
        <div style={{ overflowX: "auto" }}>
          <table className="data-table bp-table">
            <thead>
              <tr>
                <th>ID</th><th>Program ID</th><th>Name</th><th>Skin</th><th>Product Type</th>
                <th>Bonus Type</th><th>Status</th><th>Priority</th><th>Start Date (UTC+0)</th>
                <th>End Date (UTC+0)</th><th>Version</th><th>Actions</th>
              </tr>
            </thead>
            <tbody>
              {filtered.length === 0 && <tr><td colSpan={12} style={{ padding: "36px", textAlign: "center", color: "var(--text-tertiary)" }}>No bonus programs match your filters.</td></tr>}
              {filtered.map(p => (
                <tr key={p.id}>
                  <td>{p.id}</td>
                  <td className="mono" style={{ color: "var(--text-tertiary)" }}>{p.pid.slice(0, 8)}…</td>
                  <td style={{ fontWeight: 700, color: "var(--text-primary)" }}>{p.name}</td>
                  <td>{p.skin}</td>
                  <td><span className="chip chip--info">{p.product}</span></td>
                  <td>{p.bonusType}</td>
                  <td><span className={`chip ${BP_STATUS_CHIP[p.status] || "chip--neutral"}`}>{p.status}</span></td>
                  <td>{p.priority}</td>
                  <td>{p.start}</td>
                  <td>{p.end}</td>
                  <td>{p.version}</td>
                  <td>
                    <div className="bp-actions">
                      <button className="bp-act bp-act--report" title="Activations report" onClick={() => setReportPromo(p)}><Icon name="receipt" size={15} /></button>
                      {/* View and Edit both open the wizard, prefilled from the
                          stored programme and its seven configuration rows. They
                          were disabled with "needs the stored bonus-program
                          record, to reopen the wizard prefilled" — that record
                          is read now, so they work. View is Edit with the save
                          suppressed; there is no separate read-only render
                          upstream either. */}
                      <button className="bp-act" title="View program"
                        onClick={() => { setEditing(Object.assign({}, p, { _readOnly: true })); setView("create"); }}>
                        <Icon name="eye" size={15} />
                      </button>
                      <button className="bp-act" title="Edit program"
                        onClick={() => { setEditing(p); setView("create"); }}>
                        <Icon name="edit" size={14} />
                      </button>
                      <button className="bp-act bp-act--danger" title="Archive — stops issuing, keeps the row" disabled={save.busy}
                        onClick={() => { if (window.confirm(`Archive bonus program "${p.name}"?\n\nIt stops being issued. Bonuses already live under it keep the terms they were issued on and settle normally, and the row survives so past bonuses still name it.`)) removeProgram(p); }}>
                        <Icon name="trash" size={14} />
                      </button>
                    </div>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>

      )}

      {reportPromo && <ActivationsModal promo={reportPromo} onClose={() => setReportPromo(null)} />}
    </div>
  );
};

window.BonusPrograms = BonusPrograms;
