// Data store, seed data, and matching algorithm for BadMatch
const { useState: useState_s, useEffect: useEffect_s, useMemo: useMemo_s } = React;

/* =========================================================
   Seed data — realistic Thai names + plausible stats
   ========================================================= */
const _N = Date.now();
const _D = (days) => _N - days*24*3600*1000;

/* Players are owned by Admins; split between u2 (โค้ชเอก) and u3 (โค้ชบี).
   activeGroupId/activeSince = the ONE online group the player is currently checked into. */
const _H = (h) => _N - h*3600*1000;
const SEED_PLAYERS = [
  { id: "p1",  name: "ธนากร อินทร์ทอง",  level: 7.0, age: 28, hand: "R", gender:"M", activeGroupId:"g1", activeSince:_H(2),   ownerId:"u2", createdAt:_D(20), updatedAt:_D(2),  updatedBy:"u2" },
  { id: "p2",  name: "ปรียา สงวนศักดิ์", level: 6.5, age: 25, hand: "R", gender:"F", activeGroupId:"g1", activeSince:_H(2),   ownerId:"u2", createdAt:_D(19), updatedAt:_D(3),  updatedBy:"u2" },
  { id: "p3",  name: "ณัฐวุฒิ พรหมเดช",  level: 8.5, age: 31, hand: "L", gender:"M", activeGroupId:"g1", activeSince:_H(2),   ownerId:"u2", createdAt:_D(18), updatedAt:_D(1),  updatedBy:"u2" },
  { id: "p4",  name: "ศิริพร แก้วใส",   level: 5.0, age: 22, hand: "R", gender:"F", activeGroupId:"g1", activeSince:_H(2),   ownerId:"u2", createdAt:_D(17), updatedAt:_D(4),  updatedBy:"u2" },
  { id: "p5",  name: "อนุชา เมืองทอง",  level: 7.5, age: 34, hand: "R", gender:"M", activeGroupId:"g1", activeSince:_H(2),   ownerId:"u2", createdAt:_D(15), updatedAt:_D(2),  updatedBy:"u2" },
  { id: "p6",  name: "วันดี ใจดี",      level: 4.0, age: 27, hand: "R", gender:"F", activeGroupId:"g1", activeSince:_H(2),   ownerId:"u2", createdAt:_D(14), updatedAt:_D(5),  updatedBy:"u2" },
  { id: "p7",  name: "ภาคิน ตันติกุล",   level: 9.0, age: 30, hand: "R", gender:"M", activeGroupId:"g1", activeSince:_H(2),   ownerId:"u2", createdAt:_D(12), updatedAt:_D(1),  updatedBy:"u2" },
  { id: "p8",  name: "ปรียา สงวนศักดิ์", level: 5.5, age: 33, hand: "L", gender:"F", activeGroupId:"g3", activeSince:_H(1),   ownerId:"u3", createdAt:_D(10), updatedAt:_D(3),  updatedBy:"u3" }, // duplicate name across users — allowed
  { id: "p9",  name: "ชัยวัฒน์ ศรีสุข",  level: 6.0, age: 26, hand: "R", gender:"M", activeGroupId:"g1", activeSince:_H(2),   ownerId:"u2", createdAt:_D(9),  updatedAt:_D(2),  updatedBy:"u2" },
  { id: "p10", name: "มินตรา พงษ์เพชร",  level: 4.5, age: 24, hand: "R", gender:"F", activeGroupId:"g1", activeSince:_H(2),   ownerId:"u2", createdAt:_D(8),  updatedAt:_D(4),  updatedBy:"u2" },
  { id: "p11", name: "เทพพร วงศ์อนันต์",  level: 8.0, age: 29, hand: "L", gender:"M", activeGroupId:"g1", activeSince:_H(2),   ownerId:"u2", createdAt:_D(6),  updatedAt:_D(1),  updatedBy:"u2" },
  { id: "p12", name: "ปนัดดา แสงทอง",   level: 6.5, age: 35, hand: "R", gender:"F", activeGroupId:"g3", activeSince:_H(1),   ownerId:"u3", createdAt:_D(5),  updatedAt:_D(2),  updatedBy:"u3" },
];

/* ----- Seed users (for RBAC demo) ----- */
const SEED_USERS = [
  { id: "u1", name: "Super User",  email: "super@badmatch.app",  role: "super", status: "active",   expiry: null,             createdAt: Date.now() - 30*24*3600*1000, permViewerCustomize: true },
  { id: "u2", name: "โค้ชเอก",       email: "admin@badmatch.app",  role: "admin", status: "active",   expiry: "2026-12-31",     createdAt: Date.now() - 20*24*3600*1000, permViewerCustomize: true },
  { id: "u3", name: "โค้ชบี",         email: "admin2@badmatch.app", role: "admin", status: "active",   expiry: "2026-09-30",     createdAt: Date.now() - 10*24*3600*1000, permViewerCustomize: false },
  { id: "u5", name: "อดีตสมาชิก",    email: "old@badmatch.app",    role: "admin", status: "disabled", expiry: "2025-12-01",     createdAt: Date.now() - 90*24*3600*1000, permViewerCustomize: false },
];

const SEED_STATS = {};
SEED_PLAYERS.forEach((p, i) => {
  SEED_STATS[p.id] = {
    matches: 8 + ((i * 3) % 9),
    wins: 3 + ((i * 7) % 6),
    losses: 0,
  };
  SEED_STATS[p.id].losses = SEED_STATS[p.id].matches - SEED_STATS[p.id].wins;
});

const SEED_GROUPS = [
  {
    id: "g1",
    name: "ก๊วนเย็นวันพุธ",
    venue: "Smash Arena รามอินทรา",
    color: "#0a84ff",
    mode: "double",
    ownerId: "u2",
    createdAt: _D(20), updatedAt: _D(1), updatedBy: "u2",
    settings: { balanceMode: "total", skillGap: 2.0, rotation: "fair", genderMode: "any" },
    shareToken: "sh_w3dn",
    shareEnabled: true,
    shareExpiry: "2026-12-31",
    date: "2026-05-27",
    playerIds: ["p1","p2","p3","p4","p5","p6","p7","p9","p10","p11"],
    courts: [
      { id: "c1", name: "1", settings: { balanceMode: "similar", skillGap: 0.5 }, ownerId:"u2", createdAt:_D(20), updatedAt:_D(3), updatedBy:"u2" },
      { id: "c2", name: "2", ownerId:"u2", createdAt:_D(20), updatedAt:_D(20), updatedBy:"u2" },
      { id: "c3", name: "3", settings: { skillGap: 3.0 }, ownerId:"u2", createdAt:_D(18), updatedAt:_D(5), updatedBy:"u2" },
    ],
    active: { c1: { players: ["p1","p4","p7","p6"], startedAt: Date.now() - 8*60*1000 },
              c2: { players: ["p3","p10","p5","p2"], startedAt: Date.now() - 4*60*1000 },
              c3: null },
    upcoming: [
      { id: "m1", court: "c3", players: ["p11","p9","p7","p4"] },
      { id: "m2", court: "c1", players: ["p3","p2","p5","p10"] },
      { id: "m3", court: "c2", players: ["p6","p1","p11","p9"] },
    ],
    history: [
      { id: "h1", at: Date.now() - 24*60*1000, court: "c1", players: ["p1","p2","p3","p4"], winners: ["p1","p2"] },
      { id: "h2", at: Date.now() - 40*60*1000, court: "c2", players: ["p5","p6","p7","p9"], winners: ["p7","p9"] },
      { id: "h3", at: Date.now() - 58*60*1000, court: "c3", players: ["p10","p11","p4","p2"], winners: null },
    ],
    plays: { p1: 4, p2: 5, p3: 3, p4: 4, p5: 3, p6: 2, p7: 4, p9: 3, p10: 3, p11: 2 },
  },
  {
    id: "g2",
    name: "ออฟฟิศชาวแบด",
    venue: "BG Court ลาดพร้าว",
    color: "#bf5af2",
    mode: "single",
    ownerId: "u2",
    createdAt: _D(15), updatedAt: _D(2), updatedBy: "u2",
    settings: { balanceMode: "similar", skillGap: 1.0, rotation: "fair", genderMode: "any" },
    shareToken: "sh_off1",
    shareEnabled: false,
    shareExpiry: null,
    date: "2026-05-26",
    playerIds: ["p1","p3","p7","p11","p12"],
    courts: [{ id: "c1", name: "A", ownerId:"u2", createdAt:_D(15), updatedAt:_D(15), updatedBy:"u2" }, { id: "c2", name: "B", ownerId:"u2", createdAt:_D(15), updatedAt:_D(15), updatedBy:"u2" }],
    active: { c1: null, c2: null },
    upcoming: [],
    history: [],
    plays: {},
  },
  {
    id: "g3",
    name: "เสาร์เช้าคลับ",
    venue: "Banthat Thong Hall",
    color: "#ff9f0a",
    mode: "double",
    ownerId: "u3",
    createdAt: _D(10), updatedAt: _D(4), updatedBy: "u3",
    settings: { balanceMode: "total", skillGap: 2.0, rotation: "fair", genderMode: "mixed" },
    shareToken: "sh_sat3",
    shareEnabled: true,
    shareExpiry: null,
    date: "2026-05-23",
    playerIds: ["p2","p4","p6","p8","p10","p12"],
    courts: [{ id: "c1", name: "1", ownerId:"u3", createdAt:_D(10), updatedAt:_D(10), updatedBy:"u3" }],
    active: { c1: null },
    upcoming: [],
    history: [],
    plays: {},
  },
];

const SEED_LOGS = [
  { at: Date.now() - 3*60*1000, type: "match.end",   actor: "you", desc: "บันทึกผล: ก๊วนเย็นวันพุธ • สนาม 1 — ผู้ชนะ ธนากร & ศิริพร" },
  { at: Date.now() - 9*60*1000, type: "match.start", actor: "you", desc: "เริ่มแมตช์ใหม่: สนาม 2 (3v4 ระดับใกล้เคียง)" },
  { at: Date.now() - 22*60*1000, type: "shuffle",     actor: "you", desc: "สุ่มจัดคู่ — Balance score 92%" },
  { at: Date.now() - 60*60*1000, type: "player.add",  actor: "you", desc: "เพิ่มผู้เล่น: ปนัดดา แสงทอง (Lv 6)" },
  { at: Date.now() - 2*60*60*1000, type: "group.create", actor: "you", desc: "สร้างก๊วน: ก๊วนเย็นวันพุธ (3 สนาม)" },
  { at: Date.now() - 24*60*60*1000, type: "auth.login",   actor: "you", desc: "เข้าสู่ระบบจาก Safari (macOS)" },
];

/* =========================================================
   Viewer Analytics — seed viewer sessions (per public-share view)
   Each session records: GroupId, ViewDateTime (firstAt), IPAddress,
   SessionId, UserAgent. `active` = currently watching live.
   ========================================================= */
const _UA = [
  "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4) AppleWebKit/605.1.15 Safari/604.1",
  "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 Chrome/124 Mobile",
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124",
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123",
  "Mozilla/5.0 (iPad; CPU OS 17_4) AppleWebKit/605.1.15 Safari/604.1",
];
const _randIp = (seed) => {
  let h = 0; for (let i=0;i<seed.length;i++) h = (h*31 + seed.charCodeAt(i)) & 0x7fffffff;
  return `${49 + h%150}.${h%256}.${(h>>3)%256}.${(h>>7)%256}`;
};
function _seedViewerSessions() {
  const out = [];
  let n = 0;
  const mk = (groupId, minsAgo, active, durMin) => {
    const id = "vs_" + (++n).toString(36).padStart(3, "0");
    const last = Date.now() - minsAgo*60*1000;
    out.push({
      id, groupId,
      ip: _randIp(id + groupId),
      userAgent: _UA[n % _UA.length],
      firstAt: last - (durMin||3)*60*1000,
      lastAt: active ? Date.now() - (n % 20)*1000 : last,
      active: !!active,
      simulated: !!active, // seeded "live" viewers stay online for the demo
    });
  };
  // g1 — popular Wednesday session: live viewers + history today & prior days
  [0,0,0].forEach(() => mk("g1", 0, true, 12));         // 3 watching live now
  for (let i=0;i<9;i++)  mk("g1", 30 + i*18, false, 4 + (i%5));    // earlier today
  for (let i=0;i<7;i++)  mk("g1", 60*24 + i*40, false, 5);         // yesterday
  for (let i=0;i<5;i++)  mk("g1", 60*24*2 + i*55, false, 6);       // 2 days ago
  // g3 — Saturday club: 1 live + some today
  mk("g3", 0, true, 8);
  for (let i=0;i<4;i++)  mk("g3", 45 + i*25, false, 5);
  for (let i=0;i<6;i++)  mk("g3", 60*24 + i*30, false, 4);
  return out;
}
const SEED_VIEWER_SESSIONS = _seedViewerSessions();

/* A viewer session counts as "online now" if flagged active and seen recently.
   Seeded `simulated` viewers represent real people currently watching and
   stay online; real public-view sessions use the live heartbeat window. */
const VIEWER_ACTIVE_WINDOW = 60*1000; // 60s heartbeat window
function isViewerActive(s) {
  if (!s.active) return false;
  if (s.simulated) return true;
  return (Date.now() - s.lastAt) < VIEWER_ACTIVE_WINDOW;
}

/* =========================================================
   System Config — global, Super User only
   ========================================================= */
const DEFAULT_SYSTEM_CONFIG = {
  precisionMode: "decimal",  // "integer" | "decimal"
  matchPriority: "balance",  // "balance" (strict, best balance) | "diversity" (flexible, more partner variety, may trade some balance)
  matchWarnMinutes: 60,      // show warning badge on court after this many minutes
  matchAutoEndMinutes: 120,  // auto-end match (no winner) after this many minutes
  sessionClearHours: 4,      // auto-clear group session (games, queue, players) after this many hours of inactivity
};

/* Level utilities — driven by precisionMode */
function levelStep(cfg)   { return cfg?.precisionMode === "integer" ? 1 : 0.5; }
function formatLevel(lvl, cfg) {
  if (lvl == null) return "—";
  return cfg?.precisionMode === "integer" ? String(Math.round(lvl)) : Number(lvl).toFixed(1);
}
function snapLevel(lvl, cfg) {
  const step = levelStep(cfg);
  let v = Math.max(1, Math.min(10, Number(lvl) || 1));
  return Math.round(v / step) * step;
}
function gapStep(cfg) { return cfg?.precisionMode === "integer" ? 1 : 0.5; }
function snapGap(g, cfg) {
  const step = gapStep(cfg);
  let v = Math.max(0.5, Math.min(5, Number(g) || 1));
  return Math.round(v / step) * step;
}

/* =========================================================
   Matching algorithm
   - For DOUBLE: pair players so |teamA.sum - teamB.sum| minimal,
     and total court avg variance is low across all courts.
   - For SINGLE: pair players whose levels are closest.
   - Fairness: prefer players with fewer "plays" count
   ========================================================= */
function shuffleArr(arr) {
  const a = arr.slice();
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}

// Compute consecutive-recent-play streaks. `rounds` is an array of player-id
// arrays, MOST-RECENT FIRST. streak[id] = how many of the most recent rounds the
// player appeared in without a gap (a player who sat out last round has streak 0).
function computeStreaks(rounds) {
  const streak = {};
  const all = new Set();
  rounds.forEach(r => (r || []).forEach(id => all.add(id)));
  all.forEach(id => {
    let s = 0;
    for (const r of rounds) { if ((r || []).includes(id)) s++; else break; }
    streak[id] = s;
  });
  return streak;
}

function pickFair(pool, plays, n, streaks = {}, maxConsec = 2) {
  // Choose who PLAYS this round. Two-tier priority:
  //   1) Rest rule — anyone who has already played `maxConsec` rounds in a row is
  //      pushed to the back so they sit out (prevents 3+ consecutive games). This
  //      degrades gracefully: if too many need rest to fill the round, the ones
  //      with fewer total plays still get pulled in.
  //   2) Fairness — among equally-rested players, fewest total plays go first.
  const sorted = pool.slice().sort((a, b) => {
    const ra = (streaks[a.id] || 0) >= maxConsec ? 1 : 0;
    const rb = (streaks[b.id] || 0) >= maxConsec ? 1 : 0;
    if (ra !== rb) return ra - rb;               // needs-rest players go last
    const pa = plays[a.id] || 0, pb = plays[b.id] || 0;
    if (pa !== pb) return pa - pb;               // fewer total plays first
    return Math.random() - 0.5;                  // random tiebreak
  });
  return sorted.slice(0, n);
}

function bestPairing4(four, mode, genderMode, recentPartnerSets = [], matchPriority = "balance") {
  // 3 unique ways to split 4 players into 2 pairs
  const [a, b, c, d] = four;
  const cands = [
    [[a, b], [c, d]],
    [[a, c], [b, d]],
    [[a, d], [b, c]],
  ];
  const isMixedTeam = (t) => {
    const g = t.map(p => p.gender || "U");
    return g.includes("M") && g.includes("F");
  };
  // Build partner-pair key for a team
  const pairKey = (t) => t.map(p => p.id).sort().join("+");

  const diversity = matchPriority === "diversity";
  // In diversity mode, repeating a recent partner costs more (so the engine will
  // accept up to ~1.5 levels of imbalance to vary partners). In balance mode the
  // penalty is soft — it only breaks ties / near-ties.
  const penLast = diversity ? 6 : 3.5;
  const penPrev = diversity ? 3 : 1.5;

  const scored = [];
  for (const split of cands) {
    const t1 = split[0], t2 = split[1];
    let balanceScore;
    if (mode === "similar") {
      // Mode A: each partner should be similar level
      balanceScore = Math.abs(t1[0].level - t1[1].level) + Math.abs(t2[0].level - t2[1].level);
    } else {
      // Mode B (total): teams should have similar sums
      const s1 = t1[0].level + t1[1].level;
      const s2 = t2[0].level + t2[1].level;
      balanceScore = Math.abs(s1 - s2);
    }
    let score = balanceScore;
    // Mixed-doubles preference: reward arrangements where each team is 1 male + 1 female.
    if (genderMode === "mixed") {
      const mixedTeams = (isMixedTeam(t1) ? 1 : 0) + (isMixedTeam(t2) ? 1 : 0);
      score += (2 - mixedTeams) * 5;
    }
    // Recent-partner penalty: discourage repeating the same partner pair in
    // consecutive rounds. Most-recent round penalised more heavily.
    [t1, t2].forEach(team => {
      const key = pairKey(team);
      recentPartnerSets.forEach((set, i) => {
        if (set.has(key)) score += i === 0 ? penLast : penPrev;
      });
    });
    scored.push({ split, score, balanceScore });
  }

  scored.sort((x, y) => x.score - y.score);
  let chosen = scored[0];
  // Diversity mode: among splits within a small epsilon of the best score, pick
  // randomly so partner combinations vary instead of always locking the argmin.
  if (diversity) {
    const near = scored.filter(s => s.score <= scored[0].score + 0.5);
    chosen = near[Math.floor(Math.random() * near.length)];
  }
  // diff reflects the pure balance quality of the chosen split (not the penalties),
  // so the reported Balance % stays meaningful.
  return { split: chosen.split, diff: chosen.balanceScore };
}

function generateMatches({ players, courts, mode, plays, settings, recentMatches = [], recentRounds = [] }) {
  // recentMatches: array of match objects/id-arrays from the last 2 completed/active
  //   matches on this court, most-recent first. Used to build partner-pair sets
  //   passed into bestPairing4 so the same partner duo is penalised if repeated.
  // recentRounds: array of player-id arrays, one per ROUND (all courts combined),
  //   most-recent first. Used to compute consecutive-play streaks so a player never
  //   plays more than `maxConsecutive` rounds in a row before being rested.
  const groupBalance = settings?.balanceMode || "total";
  const groupGap = settings?.skillGap ?? 99;
  const groupGender = settings?.genderMode || "any";
  const matchPriority = settings?.matchPriority || "balance";

  // Resolve per-court effective settings (court overrides group)
  const resolved = courts.map(c => {
    const cs = c.settings || {};
    return {
      court: c,
      mode: cs.mode || mode,
      balanceMode: cs.balanceMode || groupBalance,
      skillGap: cs.skillGap ?? groupGap,
      genderMode: cs.genderMode || groupGender,
      needPerCourt: (cs.mode || mode) === "double" ? 4 : 2,
    };
  });
  const totalNeed = resolved.reduce((s, r) => s + r.needPerCourt, 0);
  if (players.length < totalNeed) return null;

  // Step 1: pick fair players for all courts combined. Honors the rest rule
  // (no more than `maxConsecutive` rounds in a row) then fairness (fewest plays).
  const maxConsec = settings?.maxConsecutive ?? 2;
  const streaks = computeStreaks(recentRounds);
  const chosen = pickFair(players, plays, totalNeed, streaks, maxConsec);

  // Step 2: distribute to courts. We allocate by sorted order so each court
  // gets players for its own balanceMode preference.
  // First, sort by level descending; courts with stricter gap get the closest band.
  // Simpler approach: sort, then peel off the right-sized chunk per court,
  // preferring courts with smaller skillGap first (tighter courts get the narrowest pool).
  const sorted = chosen.slice().sort((a, b) => b.level - a.level);
  const courtOrder = resolved.map((r, i) => ({ ...r, _idx: i }))
    .sort((a, b) => a.skillGap - b.skillGap);

  // Greedy slice — find the contiguous window with smallest range for each tight court
  const used = new Set();
  const courtBuckets = new Array(resolved.length).fill(null);
  // pick the level-closest adjacent group of `k` from a level-sorted array
  const closestRun = (arr, k) => {
    if (arr.length < k) return null;
    let bi = 0, bd = Infinity;
    for (let i = 0; i + k <= arr.length; i++) {
      const d = arr[i].level - arr[i + k - 1].level;
      if (d < bd) { bd = d; bi = i; }
    }
    return arr.slice(bi, bi + k);
  };
  courtOrder.forEach(co => {
    const remaining = sorted.filter(p => !used.has(p.id));
    if (remaining.length < co.needPerCourt) {
      courtBuckets[co._idx] = remaining.slice(0, co.needPerCourt);
      remaining.slice(0, co.needPerCourt).forEach(p => used.add(p.id));
      return;
    }

    // ---- Gender mode: build a preferred candidate pool, but ALWAYS fall back
    //      to the full remaining pool so matchmaking is never blocked. ----
    const males   = remaining.filter(p => (p.gender || "U") === "M");
    const females = remaining.filter(p => (p.gender || "U") === "F");
    let cand = remaining;
    let picked = null;
    if (co.genderMode === "male" && males.length >= co.needPerCourt) {
      cand = males;
    } else if (co.genderMode === "female" && females.length >= co.needPerCourt) {
      cand = females;
    } else if (co.genderMode === "mixed" && co.needPerCourt === 4
               && males.length >= 2 && females.length >= 2) {
      // Mixed doubles: take the level-closest 2 males + 2 females
      picked = [...closestRun(males, 2), ...closestRun(females, 2)]
        .sort((a, b) => b.level - a.level);
    } else if (co.genderMode === "mixed" && co.needPerCourt === 2
               && males.length >= 1 && females.length >= 1) {
      // Mixed singles: one male vs one female (closest level pairing)
      let bestM = males[0], bestF = females[0], bd = Infinity;
      males.forEach(m => females.forEach(f => {
        const d = Math.abs(m.level - f.level);
        if (d < bd) { bd = d; bestM = m; bestF = f; }
      }));
      picked = [bestM, bestF];
    }

    if (!picked) {
      // Find window with the smallest level spread within the candidate pool
      let bestStart = 0, bestRange = Infinity;
      for (let i = 0; i + co.needPerCourt <= cand.length; i++) {
        const window = cand.slice(i, i + co.needPerCourt);
        const range = window[0].level - window[window.length - 1].level;
        if (range < bestRange) { bestRange = range; bestStart = i; }
      }
      picked = cand.slice(bestStart, bestStart + co.needPerCourt);
    }
    courtBuckets[co._idx] = picked;
    picked.forEach(p => used.add(p.id));
  });

  // Build per-court recent-partner sets from recentMatches (last 2 rounds).
  // recentMatches is a flat array: [[courtId, [p1,p2,p3,p4]], ...] most-recent first,
  // OR a plain array of player-id arrays when court context is not available
  // (queue generation). We match by court id when possible, else use position order.
  const recentByCourt = (courtId, idx) => {
    const setsForCourt = [];
    // Support both {courtId, players} objects and plain player-id arrays
    recentMatches.slice(0, 2).forEach(entry => {
      let ids;
      if (entry && typeof entry === "object" && !Array.isArray(entry)) {
        if (entry.court === courtId || entry.court === undefined) ids = entry.players;
      } else if (Array.isArray(entry)) {
        ids = entry;
      }
      if (!ids || ids.length < 4) return;
      const s = new Set();
      s.add([ids[0], ids[1]].sort().join("+"));
      s.add([ids[2], ids[3]].sort().join("+"));
      setsForCourt.push(s);
    });
    return setsForCourt;
  };

  // Step 3: per court, pair according to its balanceMode
  const result = resolved.map((r, i) => {
    const bucket = courtBuckets[i] || [];
    const overrideUsed = !!(r.court.settings && (
      r.court.settings.mode || r.court.settings.balanceMode || r.court.settings.skillGap !== undefined
    ));
    if (r.mode === "double" && bucket.length === 4) {
      const recentPartnerSets = recentByCourt(r.court.id, i);
      const { split, diff } = bestPairing4(bucket, r.balanceMode, r.genderMode, recentPartnerSets, matchPriority);
      return {
        court: r.court.id,
        players: [...split[0].map(p => p.id), ...split[1].map(p => p.id)],
        teams: [split[0].map(p => p.id), split[1].map(p => p.id)],
        diff,
        gap: bucket.length ? (Math.max(...bucket.map(p => p.level)) - Math.min(...bucket.map(p => p.level))) : 0,
        skillGap: r.skillGap,
        balanceMode: r.balanceMode,
        overrideUsed,
      };
    }
    return {
      court: r.court.id,
      players: bucket.map(p => p.id),
      teams: [[bucket[0]?.id], [bucket[1]?.id]],
      diff: Math.abs((bucket[0]?.level || 0) - (bucket[1]?.level || 0)),
      gap: bucket.length ? (Math.max(...bucket.map(p => p.level)) - Math.min(...bucket.map(p => p.level))) : 0,
      skillGap: r.skillGap,
      balanceMode: r.balanceMode,
      overrideUsed,
    };
  });

  const avgDiff = result.reduce((s, r) => s + r.diff, 0) / result.length;
  const maxDiff = 9; // generous
  const balance = Math.max(0, Math.round((1 - avgDiff / maxDiff) * 100));
  const violations = result.filter(r => r.gap > r.skillGap + 0.001).length;
  return { matches: result, balance, violations };
}

/* =========================================================
   Skill Recommendation Engine
   Rules (default, Super User configurable):
   - winRate > 70% over ≥ 8 matches → suggest LEVEL UP
   - winRate < 30% over ≥ 8 matches → suggest LEVEL DOWN
   - <8 matches → "not enough data"
   ========================================================= */
const DEFAULT_RECO_RULES = {
  minMatches: 8,
  upWinRate: 0.7,
  downWinRate: 0.3,
};

function buildRecommendations(players, groups, rules = DEFAULT_RECO_RULES) {
  // Aggregate stats from all groups + history
  const agg = {};
  players.forEach(p => {
    agg[p.id] = {
      player: p,
      matches: p.stats?.matches || 0,
      wins: p.stats?.wins || 0,
      losses: p.stats?.losses || 0,
      recentWins: 0,
      recentLosses: 0,
    };
  });
  groups.forEach(g => {
    g.history?.forEach(h => {
      if (!h.winners) return;
      h.players.forEach(id => {
        if (!agg[id]) return;
        if (h.winners.includes(id)) agg[id].recentWins += 1;
        else agg[id].recentLosses += 1;
      });
    });
  });

  return Object.values(agg).map(a => {
    const total = a.matches || (a.wins + a.losses);
    const winRate = total ? a.wins / total : 0;
    let action = "hold", reason = "ข้อมูลไม่พอ";
    if (total >= rules.minMatches) {
      if (winRate >= rules.upWinRate) {
        action = "up";
        reason = `ชนะ ${Math.round(winRate*100)}% (${total} แมตช์) — แนะนำเพิ่มระดับ`;
      } else if (winRate <= rules.downWinRate) {
        action = "down";
        reason = `ชนะ ${Math.round(winRate*100)}% (${total} แมตช์) — แนะนำลดระดับ`;
      } else {
        action = "stable";
        reason = `ชนะ ${Math.round(winRate*100)}% (${total} แมตช์) — เหมาะสมแล้ว`;
      }
    }
    return { ...a, winRate, total, action, reason };
  });
}

/* =========================================================
   Store
   ========================================================= */
function useStore(initialUser) {
  const [players, setPlayers] = useState_s(() => SEED_PLAYERS.map(p => ({ ...p, stats: SEED_STATS[p.id] })));
  const [groups, setGroups]   = useState_s(SEED_GROUPS);
  const [logs, setLogs]       = useState_s(SEED_LOGS);
  const [users, setUsers]     = useState_s(SEED_USERS);
  const [recoRules, setRecoRules] = useState_s(DEFAULT_RECO_RULES);
  const [systemConfig, setSystemConfig] = useState_s(DEFAULT_SYSTEM_CONFIG);
  const [viewerSessions, setViewerSessions] = useState_s(SEED_VIEWER_SESSIONS);
  const [currentUser, setCurrentUser] = useState_s(initialUser || null);

  // Live-backend mode: when true, data is hydrated from Supabase (see app.jsx).
  const sbMode = typeof window !== "undefined" && window.SB_ENABLED === true;

  // IDs: real UUIDs in live mode (DB columns are uuid), prefixed-random otherwise.
  const genId = (prefix) =>
    (sbMode && typeof crypto !== "undefined" && crypto.randomUUID)
      ? crypto.randomUUID()
      : prefix + Math.random().toString(36).slice(2, 8);

  // Fire-and-forget Supabase write with a console error on failure.
  const sbWrite = (fn) => { if (sbMode) { try { Promise.resolve(fn()).catch(e => console.error("[BadMatch] write failed:", e)); } catch (e) { console.error("[BadMatch] write failed:", e); } } };

  // Replace the whole in-memory dataset with rows loaded from Supabase.
  // Keeps the exact nested shape the screens already expect.
  const hydrate = (payload) => {
    if (!payload) return;
    if (payload.players)        setPlayers(payload.players);
    if (payload.groups)         setGroups(payload.groups);
    if (payload.users)          setUsers(payload.users);
    if (payload.logs)           setLogs(payload.logs);
    if (payload.viewerSessions) setViewerSessions(payload.viewerSessions);
    if (payload.systemConfig)   setSystemConfig(payload.systemConfig);
  };

  const addLog = (entry) => {
    const full = {
      at: Date.now(),
      actor: currentUser?.email || "system",
      actorId: currentUser?.id || null,
      ip: "203.0.113.42", // mock IP for prototype
      ...entry,
    };
    setLogs(prev => [full, ...prev]);
    sbWrite(() => window.SBData.insertLog(full));
  };

  /* Players */
  const addPlayer = (p) => {
    const id = genId("p");
    const now = Date.now();
    const np = {
      id, ...p,
      stats: { matches: 0, wins: 0, losses: 0 },
      ownerId: currentUser?.id || null,
      createdAt: now, updatedAt: now, updatedBy: currentUser?.id || null,
    };
    setPlayers(prev => [...prev, np]);
    sbWrite(() => window.SBData.insertPlayer(np));
    addLog({ type: "player.add", entityType:"player", entityId:id,
             desc: `เพิ่มผู้เล่น: ${np.name} (Lv ${Number(np.level).toFixed(1)})`,
             newValue: { name: np.name, level: np.level } });
    return id;
  };
  const updatePlayer = (id, patch) => {
    const before = players.find(p => p.id === id);
    setPlayers(prev => prev.map(p => p.id === id
      ? { ...p, ...patch, updatedAt: Date.now(), updatedBy: currentUser?.id || null }
      : p));
    sbWrite(() => window.SBData.updatePlayerRow(id, patch));
    const parts = [];
    if ("name" in patch && patch.name !== before?.name) parts.push(`ชื่อ ${before?.name} → ${patch.name}`);
    if ("level" in patch && Number(patch.level) !== Number(before?.level)) parts.push(`ระดับ Lv ${Number(before?.level).toFixed(1)} → ${Number(patch.level).toFixed(1)}`);
    if ("gender" in patch && patch.gender !== before?.gender) parts.push(`เพศ → ${patch.gender}`);
    if ("age" in patch && patch.age !== before?.age) parts.push(`อายุ → ${patch.age ?? "-"}`);
    if ("hand" in patch && patch.hand !== before?.hand) parts.push(`มือ → ${patch.hand}`);
    addLog({ type: "player.edit", entityType:"player", entityId:id,
             desc: `แก้ไขผู้เล่น: ${before?.name || id}${parts.length ? " — " + parts.join(", ") : " — อัปเดตข้อมูล"}`,
             oldValue: before && Object.fromEntries(Object.keys(patch).map(k => [k, before[k]])),
             newValue: patch });
  };
  const deletePlayer = (id) => {
    const p = players.find(x => x.id === id);
    setPlayers(prev => prev.filter(x => x.id !== id));
    sbWrite(() => window.SBData.deletePlayerRow(id));
    if (p) addLog({ type: "player.delete", entityType:"player", entityId:id,
                    desc: `ลบผู้เล่น: ${p.name}`, oldValue: { name: p.name, level: p.level } });
  };

  /* Active-group session — a player can be checked into only ONE online group */
  const joinActiveGroup = (playerId, groupId) => {
    const now = Date.now();
    setPlayers(prev => prev.map(p => p.id === playerId
      ? { ...p, activeGroupId: groupId, activeSince: now, updatedAt: now, updatedBy: currentUser?.id || null }
      : p));
    sbWrite(() => window.SBData.updatePlayerRow(playerId, { activeGroupId: groupId, activeSince: now }));
    const p = players.find(x => x.id === playerId);
    const g = groups.find(x => x.id === groupId);
    addLog({ type: "player.join", entityType: "player", entityId: playerId,
             desc: `${p?.name || playerId} เข้าร่วมก๊วน ${g?.name || groupId}` });
    touchGroupActivity(groupId);
  };
  const endActiveGroup = (playerId) => {
    const p = players.find(x => x.id === playerId);
    const gid = p?.activeGroupId;
    setPlayers(prev => prev.map(x => x.id === playerId
      ? { ...x, activeGroupId: null, activeSince: null, updatedAt: Date.now(), updatedBy: currentUser?.id || null }
      : x));
    sbWrite(() => window.SBData.updatePlayerRow(playerId, { activeGroupId: null, activeSince: null }));
    // Clear the player out of their active group's live courts + upcoming queue
    if (gid) {
      setGroups(prev => prev.map(g => {
        if (g.id !== gid) return g;
        const active = { ...g.active };
        Object.keys(active).forEach(cid => {
          if (active[cid]?.players?.includes(playerId)) active[cid] = null;
        });
        const upcoming = (g.upcoming || []).filter(m => !m.players.includes(playerId));
        const next = { ...g, active, upcoming };
        sbWrite(() => window.SBData.persistGroup(next, g));
        return next;
      }));
    }
    const g = groups.find(x => x.id === gid);
    addLog({ type: "player.endgroup", entityType: "player", entityId: playerId,
             desc: `${p?.name || playerId} จบก๊วน ${g?.name || gid || "-"} — ล้างสถานะและคิว` });
  };

  /* Viewer Analytics — public-share view sessions (realtime heartbeat) */
  const logViewerView = ({ groupId }) => {
    const id = "vs_live_" + Math.random().toString(36).slice(2, 8);
    const now = Date.now();
    setViewerSessions(prev => [...prev, {
      id, groupId,
      ip: "203.0.113." + (2 + Math.floor(Math.random() * 250)),
      userAgent: (typeof navigator !== "undefined" && navigator.userAgent) || "unknown",
      firstAt: now, lastAt: now, active: true, simulated: false,
    }]);
    return id;
  };
  const viewerHeartbeat = (id) =>
    setViewerSessions(prev => prev.map(s => s.id === id ? { ...s, lastAt: Date.now(), active: true } : s));
  const endViewerSession = (id) =>
    setViewerSessions(prev => prev.map(s => s.id === id ? { ...s, active: false, lastAt: Date.now() } : s));

  /* Groups */
  const addGroup = (g) => {
    const id = genId("g");
    const now = Date.now();
    const ng = {
      id, name: g.name, venue: g.venue || "", color: g.color || "#0a84ff",
      mode: g.mode || "double", date: new Date().toISOString().slice(0,10),
      ownerId: currentUser?.id || "u2",
      createdAt: now, updatedAt: now, updatedBy: currentUser?.id || null,
      settings: g.settings || { balanceMode: "total", skillGap: 2, rotation: "fair" },
      shareToken: (sbMode ? "sh_" + Math.random().toString(36).slice(2, 10) : "sh_" + Math.random().toString(36).slice(2, 6)),
      shareEnabled: true, shareExpiry: null,
      playerIds: g.playerIds || [],
      courts: (g.courts || []).map((c, i) => ({
        id: genId("c"), name: c.name,
        ownerId: currentUser?.id || null,
        createdAt: now, updatedAt: now, updatedBy: currentUser?.id || null,
      })),
      active: {}, upcoming: [], history: [], plays: {},
    };
    setGroups(prev => [ng, ...prev]);
    sbWrite(() => window.SBData.insertGroupFull(ng));
    addLog({ type: "group.create", entityType:"group", entityId:id,
             desc: `สร้างก๊วน: ${ng.name} (${ng.courts.length} สนาม)`,
             newValue: { name: ng.name, mode: ng.mode } });
    return id;
  };
  const updateGroup = (id, patch) => {
    setGroups(prev => prev.map(g => {
      if (g.id !== id) return g;
      const computed = typeof patch === "function" ? patch(g) : { ...g, ...patch };
      const next = { ...computed, updatedAt: Date.now(), updatedBy: currentUser?.id || null };
      sbWrite(() => window.SBData.persistGroup(next, g));
      return next;
    }));
  };
  const deleteGroup = (id) => {
    const g = groups.find(x => x.id === id);
    setGroups(prev => prev.filter(x => x.id !== id));
    sbWrite(() => window.SBData.deleteGroupRow(id));
    if (g) addLog({ type: "group.delete", entityType:"group", entityId:id,
                    desc: `ลบก๊วน: ${g.name}`, oldValue: { name: g.name } });
  };

  const touchGroupActivity = (groupId) => {
    const now = Date.now();
    setGroups(prev => prev.map(g => {
      if (g.id !== groupId) return g;
      const next = { ...g, lastActivityAt: now };
      sbWrite(() => window.SBData.updateGroupCols(groupId, { lastActivityAt: now }));
      return next;
    }));
  };

  const clearGroupSession = (groupId, { auto = false } = {}) => {
    const g = groups.find(x => x.id === groupId);
    if (!g) return;
    // Eject all players from session (clear activeGroupId)
    const ejected = players.filter(p => p.activeGroupId === groupId);
    ejected.forEach(p => {
      setPlayers(prev => prev.map(x => x.id === p.id
        ? { ...x, activeGroupId: null, activeSince: null, updatedAt: Date.now(), updatedBy: currentUser?.id || null }
        : x));
      sbWrite(() => window.SBData.updatePlayerRow(p.id, { activeGroupId: null, activeSince: null }));
    });
    // Clear courts, queue, plays, and reset lastActivityAt
    const clearedActive = Object.fromEntries(g.courts.map(c => [c.id, null]));
    const next = { ...g, active: clearedActive, upcoming: [], plays: {}, lastActivityAt: null };
    setGroups(prev => prev.map(x => x.id === groupId ? next : x));
    sbWrite(() => window.SBData.persistGroup(next, g));
    const label = auto ? `เคลียก๊วนอัตโนมัติ (หมดเวลา ${systemConfig?.sessionClearHours ?? 4} ชม.): ` : "เคลียก๊วน: ";
    addLog({
      type: "group.clear", entityType: "group", entityId: groupId,
      desc: `${label}${g.name} — ล้าง ${ejected.length} ผู้เล่น, แมตช์, คิว, รอบเล่น`,
      newValue: { clearedPlayers: ejected.length, auto },
    });
  };

  // Format a match's player ids as "A & B vs C & D" (with levels) for audit logs.
  const matchupOf = (ids) => {
    const fmt = (id) => {
      const p = players.find(x => x.id === id);
      const nm = p?.name?.split(" ")[0] || "?";
      const lv = p?.level == null ? "?" : formatLevel(p.level, systemConfig);
      return `${nm} (Lv ${lv})`;
    };
    const h = Math.ceil((ids || []).length / 2);
    return `${(ids||[]).slice(0, h).map(fmt).join(" & ")} พบ ${(ids||[]).slice(h).map(fmt).join(" & ")}`;
  };

  /* Queue randomization — re-rolls ONE waiting queue match (identified by id),
     court-agnostic since queues are no longer bound to a court (the user picks
     the court only at placement time). Honors the group's match type / gender
     mode / skill gap / balance mode via generateMatches, draws only from
     genuinely-free players (no one currently on a live court or booked in
     another queue slot) so the same player can never be double-booked. */
  const randomizeQueueMatch = (groupId, matchId) => {
    const g = groups.find(x => x.id === groupId);
    if (!g) return { ok: false, reason: "no-group" };

    const groupPlayers = g.playerIds.map(id => players.find(p => p.id === id)).filter(Boolean);

    const upcoming = (g.upcoming || []).slice();
    const targetIdx = upcoming.findIndex(m => m.id === matchId);
    if (targetIdx < 0) return { ok: false, reason: "no-match" };
    const prevMatch = upcoming[targetIdx];

    // Available pool = only genuinely-free players: NOT in any live match on any
    // court, and NOT reserved in another queue slot. Hard no-double-booking rule.
    const reserved = new Set();
    Object.values(g.active || {}).forEach(m => m?.players?.forEach(id => reserved.add(id)));
    upcoming.forEach((m, i) => { if (i !== targetIdx) m.players.forEach(id => reserved.add(id)); });
    const pool = groupPlayers.filter(p => !reserved.has(p.id));

    const need = g.mode === "double" ? 4 : 2;
    if (pool.length < need) return { ok: false, reason: "not-enough", need, have: pool.length };

    // Reuse the full engine via a synthetic court carrying group defaults.
    // Pass the 2 most-recent completed matches so partner-repeat penalty fires,
    // and the recent rounds (1 court → 1 match per round) for the rest rule.
    const recentMatches = (g.history || []).slice(0, 2).map(h => h.players);
    const recentRounds = (g.history || []).slice(0, 3).map(h => h.players);
    const synthCourt = { id: "queue", name: "คิว", settings: {} };
    const result = generateMatches({
      players: pool, courts: [synthCourt], mode: g.mode,
      plays: g.plays || {},
      settings: { ...g.settings, matchPriority: systemConfig?.matchPriority },
      recentMatches, recentRounds,
    });
    if (!result) return { ok: false, reason: "not-enough", need, have: pool.length };

    const m = result.matches[0];
    const newMatch = { ...prevMatch, players: m.players, teams: m.teams };
    upcoming[targetIdx] = newMatch;
    updateGroup(groupId, { upcoming });

    const names = (ids) => ids.map(id => players.find(p => p.id === id)?.name?.split(" ")[0] || "?").join(", ");
    addLog({
      type: "queue.randomize", entityType: "group", entityId: groupId,
      desc: `สุ่มคิวใหม่: ${g.name} • คิว #${targetIdx + 1} • ${matchupOf(newMatch.players)} (เดิม: ${matchupOf(prevMatch.players)})` + (result.violations ? " • ⚠ เกิน skill gap" : ""),
      oldValue: { queue: prevMatch.players, names: names(prevMatch.players) },
      newValue: { queue: newMatch.players, names: names(newMatch.players) },
    });
    return { ok: true, balance: result.balance, violations: result.violations, newMatch, prevMatch };
  };

  /* Re-roll ONE queue slot using the WHOLE roster (incl. players currently on a
     court), like planNextRoundMatch — for the "สุ่มใหม่" button. Fair rotation via
     effective plays (stored + other-queue appearances + active) and the planned
     rounds fed into the rest rule. Placement guard still blocks double-booking. */
  const replanQueueMatch = (groupId, matchId) => {
    const g = groups.find(x => x.id === groupId);
    if (!g) return { ok: false, reason: "no-group" };
    const groupPlayers = g.playerIds.map(id => players.find(p => p.id === id)).filter(Boolean);
    const upcoming = (g.upcoming || []).slice();
    const targetIdx = upcoming.findIndex(m => m.id === matchId);
    if (targetIdx < 0) return { ok: false, reason: "no-match" };
    const prevMatch = upcoming[targetIdx];
    const need = g.mode === "double" ? 4 : 2;

    // Whole roster except players booked in OTHER queue slots (the target is replaced).
    const otherQueued = new Set();
    upcoming.forEach((m, i) => { if (i !== targetIdx) m.players.forEach(id => otherQueued.add(id)); });
    const pool = groupPlayers.filter(p => !otherQueued.has(p.id));
    if (pool.length < need) return { ok: false, reason: "not-enough", need, have: pool.length };

    const activeRound = [];
    Object.values(g.active || {}).forEach(m => m?.players?.forEach(id => activeRound.push(id)));
    // Other planned queue rounds (most-recent-first) + active + history → rest rule rotates.
    const plannedRounds = upcoming.filter((_, i) => i !== targetIdx).map(m => m.players).slice().reverse();
    const recentRounds = [...plannedRounds, activeRound, ...(g.history || []).slice(0, 2).map(h => h.players)]
      .filter(r => r && r.length);
    const recentMatches = (g.history || []).slice(0, 2).map(h => h.players);

    const planPlays = { ...(g.plays || {}) };
    upcoming.forEach((m, i) => { if (i !== targetIdx) m.players.forEach(id => { planPlays[id] = (planPlays[id] || 0) + 1; }); });
    activeRound.forEach(id => { planPlays[id] = (planPlays[id] || 0) + 1; });

    const synthCourt = { id: "queue", name: "คิว", settings: {} };
    const result = generateMatches({
      players: pool, courts: [synthCourt], mode: g.mode,
      plays: planPlays,
      settings: { ...g.settings, matchPriority: systemConfig?.matchPriority },
      recentMatches, recentRounds,
    });
    if (!result) return { ok: false, reason: "not-enough", need, have: pool.length };
    const mm = result.matches[0];
    if (new Set(mm.players).size !== mm.players.length) return { ok: false, reason: "dup-guard" };

    const newMatch = { ...prevMatch, players: mm.players, teams: mm.teams };
    upcoming[targetIdx] = newMatch;
    updateGroup(groupId, { upcoming });
    addLog({
      type: "queue.randomize", entityType: "group", entityId: groupId,
      desc: `สุ่มใหม่ (รวมคนกำลังเล่น): ${g.name} • คิว #${targetIdx + 1} • ${matchupOf(newMatch.players)} (เดิม: ${matchupOf(prevMatch.players)})` + (result.violations ? " • ⚠ เกิน skill gap" : ""),
      oldValue: { queue: prevMatch.players }, newValue: { queue: newMatch.players },
    });
    return { ok: true, balance: result.balance, violations: result.violations, newMatch, prevMatch };
  };

  /* Append a NEW queue match drawn from genuinely-free players (not live on a
     court, not already booked in another queue slot). Lets the session keep
     going when the queue empties but courts are free and players are waiting —
     without reshuffling the live courts. Same no-double-booking guarantee. */
  const addQueueMatch = (groupId) => {
    // Compute inside the functional updater (see planNextRoundMatch) so rapid repeated
    // presses always see the latest queue — otherwise a stale read could draw the same
    // free players twice and double-book them across two queue slots.
    let outcome = { ok: false, reason: "no-group" };
    let logInfo = null;
    setGroups(prev => prev.map(g => {
      if (g.id !== groupId) return g;

      const groupPlayers = g.playerIds.map(id => players.find(p => p.id === id)).filter(Boolean);
      const reserved = new Set();
      Object.values(g.active || {}).forEach(m => m?.players?.forEach(id => reserved.add(id)));
      (g.upcoming || []).forEach(m => m.players.forEach(id => reserved.add(id)));
      const pool = groupPlayers.filter(p => !reserved.has(p.id));

      const need = g.mode === "double" ? 4 : 2;
      if (pool.length < need) { outcome = { ok: false, reason: "not-enough", need, have: pool.length }; return g; }

      const recentMatchesForQueue = (g.history || []).slice(0, 2).map(h => h.players);
      const recentRoundsForQueue = (g.history || []).slice(0, 3).map(h => h.players);
      const synthCourt = { id: "queue", name: "คิว", settings: {} };
      const result = generateMatches({
        players: pool, courts: [synthCourt], mode: g.mode,
        plays: g.plays || {},
        settings: { ...g.settings, matchPriority: systemConfig?.matchPriority },
        recentMatches: recentMatchesForQueue, recentRounds: recentRoundsForQueue,
      });
      if (!result) { outcome = { ok: false, reason: "not-enough", need, have: pool.length }; return g; }

      const mm = result.matches[0];
      if (new Set(mm.players).size !== mm.players.length) { outcome = { ok: false, reason: "dup-guard" }; return g; }

      const newMatch = { id: "m" + Math.random().toString(36).slice(2, 6), players: mm.players, teams: mm.teams };
      const upcoming = [...(g.upcoming || []), newMatch];
      const next = { ...g, upcoming, updatedAt: Date.now(), updatedBy: currentUser?.id || null };
      sbWrite(() => window.SBData.persistGroup(next, g));
      outcome = { ok: true, balance: result.balance, violations: result.violations, newMatch };
      logInfo = { name: g.name, queueNo: upcoming.length, players: newMatch.players, violations: result.violations };
      return next;
    }));

    if (logInfo) {
      const names = (ids) => ids.map(id => players.find(p => p.id === id)?.name?.split(" ")[0] || "?").join(", ");
      addLog({
        type: "queue.add", entityType: "group", entityId: groupId,
        desc: `เพิ่มคิว (จากคนพักรอ): ${logInfo.name} • คิว #${logInfo.queueNo} • ${matchupOf(logInfo.players)}` + (logInfo.violations ? " • ⚠ เกิน skill gap" : ""),
        newValue: { queue: logInfo.players, names: names(logInfo.players) },
      });
      touchGroupActivity(groupId);
    }
    return outcome;
  };

  /* Plan the NEXT round(s) — works even when not enough players are benched right
     now (e.g. 6 players / 1 court → only 2 ever free). Unlike addQueueMatch, the pool
     is the WHOLE roster and players MAY recur across future rounds (the queue is a
     sequence of rounds in time), so you can press this repeatedly to stack as many
     fair future rounds as you like. Fairness comes from an effective play count
     (stored plays + how many queue rounds they're already in + 1 if mid-game), not
     from hard exclusion. Safety: a queued match still can't be PLACED on a court
     until its players actually free up (placeQueueOnCourt guards active conflicts),
     so no double-booking is possible — and generateMatches guarantees 4 distinct
     players within a single match. */
  const planNextRoundMatch = (groupId) => {
    // Compute INSIDE the functional state updater so we always read the latest
    // committed group (its current upcoming/plays/active). Reading from the `groups`
    // closure instead made rapid repeated presses see a stale (empty) queue, so every
    // press recomputed the SAME match → identical rounds. (No StrictMode here, so the
    // updater runs once; mirrors updateGroup which also persists inside the updater.)
    let outcome = { ok: false, reason: "no-group" };
    let logInfo = null;
    setGroups(prev => prev.map(g => {
      if (g.id !== groupId) return g;

      const groupPlayers = g.playerIds.map(id => players.find(p => p.id === id)).filter(Boolean);
      const need = g.mode === "double" ? 4 : 2;
      if (groupPlayers.length < need) {
        outcome = { ok: false, reason: "not-enough", need, have: groupPlayers.length };
        return g;
      }

      // Whole roster is eligible every round — repeats across rounds are intentional.
      const pool = groupPlayers;

      // Players currently ON COURT count as the most-recent round so the rest rule
      // favours benched players.
      const activeRound = [];
      Object.values(g.active || {}).forEach(m => m?.players?.forEach(id => activeRound.push(id)));
      const recentMatches = (g.history || []).slice(0, 2).map(h => h.players);
      // CRITICAL for repeat-presses: feed the already-planned queue rounds into the
      // rest-rule history (most-recent-first). Without this, players stuck "resting"
      // from the live active/history rounds (e.g. someone in both active + last game)
      // would be excluded EVERY press → the same 4 players repeat. Including the
      // planned rounds lets their streaks age out so the next plan rotates fairly.
      const plannedRounds = (g.upcoming || []).map(m => m.players).slice().reverse();
      const recentRounds = [...plannedRounds, activeRound, ...(g.history || []).slice(0, 2).map(h => h.players)]
        .filter(r => r && r.length);

      // Effective fairness counter for THIS pass (NOT persisted): stored plays +
      // #queue-rounds already booked into + 1 if mid-game. Pressing repeatedly thus
      // rotates players fairly across each successive future round.
      const planPlays = { ...(g.plays || {}) };
      (g.upcoming || []).forEach(m => m.players.forEach(id => { planPlays[id] = (planPlays[id] || 0) + 1; }));
      activeRound.forEach(id => { planPlays[id] = (planPlays[id] || 0) + 1; });

      const synthCourt = { id: "queue", name: "คิว", settings: {} };
      const result = generateMatches({
        players: pool, courts: [synthCourt], mode: g.mode,
        plays: planPlays,
        settings: { ...g.settings, matchPriority: systemConfig?.matchPriority },
        recentMatches, recentRounds,
      });
      if (!result) { outcome = { ok: false, reason: "not-enough", need, have: groupPlayers.length }; return g; }

      const mm = result.matches[0];
      if (new Set(mm.players).size !== mm.players.length) { outcome = { ok: false, reason: "dup-guard" }; return g; }

      const newMatch = { id: "m" + Math.random().toString(36).slice(2, 6), players: mm.players, teams: mm.teams };
      const upcoming = [...(g.upcoming || []), newMatch];
      const next = { ...g, upcoming, updatedAt: Date.now(), updatedBy: currentUser?.id || null };
      sbWrite(() => window.SBData.persistGroup(next, g));
      outcome = { ok: true, balance: result.balance, violations: result.violations, newMatch };
      logInfo = { name: g.name, queueNo: upcoming.length, players: newMatch.players, violations: result.violations };
      return next;
    }));

    if (logInfo) {
      const names = (ids) => ids.map(id => players.find(p => p.id === id)?.name?.split(" ")[0] || "?").join(", ");
      addLog({
        type: "queue.plan", entityType: "group", entityId: groupId,
        desc: `วางแผนรอบถัดไป: ${logInfo.name} • คิว #${logInfo.queueNo} • ${matchupOf(logInfo.players)}` + (logInfo.violations ? " • ⚠ เกิน skill gap" : ""),
        newValue: { queue: logInfo.players, names: names(logInfo.players) },
      });
      touchGroupActivity(groupId);
    }
    return outcome;
  };

  /* Share controls */
  const regenerateShareToken = (groupId) => {
    const newToken = "sh_" + Math.random().toString(36).slice(2, sbMode ? 10 : 8);
    setGroups(prev => prev.map(g => {
      if (g.id !== groupId) return g;
      const next = { ...g, shareToken: newToken };
      sbWrite(() => window.SBData.updateGroupCols(groupId, next));
      return next;
    }));
    const g = groups.find(x => x.id === groupId);
    if (g) addLog({ type: "share.regenerate", desc: `สร้าง token ใหม่: ${g.name}` });
    return newToken;
  };
  const setShareEnabled = (groupId, enabled) => {
    setGroups(prev => prev.map(g => {
      if (g.id !== groupId) return g;
      const next = { ...g, shareEnabled: enabled };
      sbWrite(() => window.SBData.updateGroupCols(groupId, next));
      return next;
    }));
    const g = groups.find(x => x.id === groupId);
    if (g) addLog({ type: enabled ? "share.enable" : "share.disable",
                    desc: `${enabled ? "เปิดใช้งาน" : "ปิดใช้งาน"}การแชร์: ${g.name}` });
  };
  const setShareExpiry = (groupId, expiry) => {
    setGroups(prev => prev.map(g => {
      if (g.id !== groupId) return g;
      const next = { ...g, shareExpiry: expiry || null };
      sbWrite(() => window.SBData.updateGroupCols(groupId, next));
      return next;
    }));
    const g = groups.find(x => x.id === groupId);
    if (g) addLog({ type: "share.expiry", desc: `ตั้งวันหมดอายุอยู่ที่: ${g.name} → ${expiry || "ไม่จำกัด"}` });
  };

  /* Users (Super User only — enforced at UI level) */
  const addUser = async (u) => {
    if (sbMode) {
      // In live mode, create both auth.users + profile via Edge Function.
      const { data: s } = await window.sb.auth.getSession();
      const token = s?.session?.access_token;
      if (!token) throw new Error("ไม่พบ session — กรุณา Login ใหม่");
      const res = await window.SBData.createUser(u, token);
      if (res.error) throw new Error(res.error);
      const nu = res.profile ? window.SBData.mapProfile(res.profile) : null;
      if (nu) setUsers(prev => [...prev, nu]);
      addLog({ type: "user.add", desc: `สร้างผู้ใช้: ${u.email} (${u.role})` });
      return nu;
    }
    // Demo mode: local-only
    const id = genId("u");
    const nu = { id, status: "active", createdAt: Date.now(), ...u };
    setUsers(prev => [...prev, nu]);
    addLog({ type: "user.add", desc: `สร้างผู้ใช้: ${nu.email} (${nu.role})` });
    return nu;
  };
  const updateUser = (id, patch) => {
    setUsers(prev => prev.map(u => u.id === id ? { ...u, ...patch } : u));
    sbWrite(() => window.SBData.updateProfile(id, patch));
    const u = users.find(x => x.id === id);
    if (u) addLog({ type: "user.edit", desc: `แก้ไขผู้ใช้: ${u.email}` });
  };
  const deleteUser = async (id) => {
    const u = users.find(x => x.id === id);
    if (!u) return;
    if (sbMode) {
      const { data: s } = await window.sb.auth.getSession();
      const token = s?.session?.access_token;
      if (!token) throw new Error("ไม่พบ session — กรุณา Login ใหม่");
      const res = await window.SBData.deleteUser(id, token);
      if (res.error) throw new Error(res.error);
    }
    setUsers(prev => prev.filter(x => x.id !== id));
    addLog({ type: "user.delete", desc: `ลบผู้ใช้: ${u.email} (${u.role})` });
  };

  const toggleUserStatus = (id) => {
    const u = users.find(x => x.id === id);
    if (!u) return;
    const newStatus = u.status === "active" ? "disabled" : "active";
    setUsers(prev => prev.map(x => x.id === id ? { ...x, status: newStatus } : x));
    sbWrite(() => window.SBData.updateProfile(id, { status: newStatus }));
    addLog({ type: newStatus === "active" ? "user.enable" : "user.disable",
            desc: `${newStatus === "active" ? "เปิดใช้งาน" : "ระงับ"} ${u.email}` });
  };

  // Super grants/revokes an Owner's permission to customize viewer display.
  // Privacy-first: on REVOKE, force-hide leaderboard/history on all that owner's
  // groups so a viewer can never keep seeing data the owner is no longer allowed
  // to expose. Returns nothing; updates users + (on revoke) groups.
  const setUserViewerPerm = (id, allowed) => {
    const u = users.find(x => x.id === id);
    if (!u) return;
    setUsers(prev => prev.map(x => x.id === id ? { ...x, permViewerCustomize: !!allowed } : x));
    sbWrite(() => window.SBData.updateProfile(id, { permViewerCustomize: !!allowed }));
    if (!allowed) {
      setGroups(prev => prev.map(g => {
        if (g.ownerId !== id) return g;
        const s = g.settings || {};
        if (!s.viewerShowLeaderboard && !s.viewerShowHistory) return g;
        const next = { ...g, settings: { ...s, viewerShowLeaderboard: false, viewerShowHistory: false } };
        sbWrite(() => window.SBData.persistGroup(next, g));
        return next;
      }));
    }
    addLog({ type: allowed ? "user.perm.grant" : "user.perm.revoke",
            desc: `${allowed ? "เปิด" : "ปิด"}สิทธิ์ปรับแต่งหน้า Viewer: ${u.email}` });
  };

  /* Permission helpers */
  const role = currentUser?.role || "admin";
  const isSuper = role === "super";
  const userId = currentUser?.id || null;

  const can = {
    manageUsers:  isSuper,
    manageGroups: isSuper || role === "admin",
    editPlayers:  isSuper || role === "admin",
    runMatching:  isSuper || role === "admin",
    configRules:  isSuper,
    viewAdmin:    isSuper,                          // audit/logs — super only
    shareGroup:   isSuper || role === "admin",
    viewAllData:  isSuper,
    // Owner may configure what viewers see (leaderboard/history) ONLY if a Super
    // User granted them the permission. Super can always customize.
    customizeViewer: isSuper || !!currentUser?.permViewerCustomize,
  };

  /* Visibility — admin sees own data; super sees all (toggleable via scope) */
  // scope: "mine" (default) | "all" (super only)
  const [scope, setScope] = useState_s("mine");
  const effectiveScope = isSuper ? scope : "mine";

  const visibleGroups = effectiveScope === "all"
    ? groups
    : groups.filter(g => g.ownerId === userId);
  const visiblePlayers = effectiveScope === "all"
    ? players
    : players.filter(p => p.ownerId === userId);
  const visibleLogs = effectiveScope === "all"
    ? logs
    : logs.filter(l => !l.actorId || l.actorId === userId);

  // Viewer sessions are group-scoped: a Group Owner only sees analytics for groups they own
  const _visibleGroupIds = new Set(visibleGroups.map(g => g.id));
  const visibleViewerSessions = viewerSessions.filter(s => _visibleGroupIds.has(s.groupId));

  // Ownership check helper
  const ownsGroup  = (g) => isSuper || g?.ownerId === userId;
  const ownsPlayer = (p) => isSuper || p?.ownerId === userId;

  /* Lookup helper */
  const userById = useMemo_s(
    () => Object.fromEntries(users.map(u => [u.id, u])),
    [users]
  );

  return {
    sbMode, hydrate,
    players, setPlayers, addPlayer, updatePlayer, deletePlayer,
    joinActiveGroup, endActiveGroup,
    groups, setGroups, addGroup, updateGroup, deleteGroup, randomizeQueueMatch, replanQueueMatch, addQueueMatch, planNextRoundMatch,
    touchGroupActivity, clearGroupSession,
    logs, setLogs, addLog,
    users, setUsers, addUser, updateUser, deleteUser, toggleUserStatus, setUserViewerPerm, userById,
    // Force-change-password flag — persisted in profiles.must_change_password.
    mustChangePassword: currentUser?.mustChangePassword ?? false,
    setMustChangePassword: (value, targetUserId) => {
      const uid = targetUserId || currentUser?.id;
      if (!uid) return;
      setUsers(prev => prev.map(u => u.id === uid ? { ...u, mustChangePassword: !!value } : u));
      if (uid === currentUser?.id) {
        setCurrentUser(prev => prev ? { ...prev, mustChangePassword: !!value } : prev);
      }
      sbWrite(() => window.SBData.updateProfile(uid, { mustChangePassword: !!value }));
    },
    // UI theme ('light' | 'dark') — persisted in profiles.theme (cross-device) AND
    // cached in localStorage so it applies instantly on next load before the profile
    // arrives. setTheme updates the current user only.
    theme: currentUser?.theme || "light",
    setTheme: (value) => {
      const next = value === "dark" ? "dark" : "light";
      try { localStorage.setItem("badmatch_theme", next); } catch (_) {}
      setCurrentUser(prev => prev ? { ...prev, theme: next } : prev);
      setUsers(prev => prev.map(u => u.id === currentUser?.id ? { ...u, theme: next } : u));
      if (currentUser?.id) sbWrite(() => window.SBData.updateProfile(currentUser.id, { theme: next }));
    },
    // Tour completion state for the current user — persisted in profiles.tour_done.
    // setTourDone(true)  → marks the tour complete (won't auto-launch again).
    // setTourDone(false) → resets so the tour shows on next load (Super can do for any user).
    tourDone: currentUser?.tourDone ?? false,
    setTourDone: (value, targetUserId) => {
      const uid = targetUserId || currentUser?.id;
      if (!uid) return;
      // Update local users list (for the Super User table display)
      setUsers(prev => prev.map(u => u.id === uid ? { ...u, tourDone: !!value } : u));
      // Update currentUser if it's their own flag
      if (uid === currentUser?.id) {
        setCurrentUser(prev => prev ? { ...prev, tourDone: !!value } : prev);
      }
      sbWrite(() => window.SBData.updateProfile(uid, { tourDone: !!value }));
    },
    recoRules, setRecoRules,
    systemConfig,
    // Wrap setSystemConfig so changes are written through to Supabase.
    setSystemConfig: (updaterOrValue) => {
      setSystemConfig(prev => {
        const next = typeof updaterOrValue === "function"
          ? updaterOrValue(prev)
          : { ...prev, ...updaterOrValue };
        sbWrite(() => window.SBData.saveSystemConfig(next));
        return next;
      });
    },
    viewerSessions, setViewerSessions, visibleViewerSessions, logViewerView, viewerHeartbeat, endViewerSession,
    currentUser, setCurrentUser, role, can, isSuper, userId,
    regenerateShareToken, setShareEnabled, setShareExpiry,
    /* visibility */
    scope, setScope, effectiveScope,
    visibleGroups, visiblePlayers, visibleLogs,
    ownsGroup, ownsPlayer,
    /* realtime */
    setupRealtime() {
      if (!sbMode || !window.SBData?.subscribeRealtime) return () => {};
      const pendingGroups = new Set();
      let debounceTimer = null;
      function scheduleGroupReload(groupId) {
        pendingGroups.add(groupId);
        clearTimeout(debounceTimer);
        debounceTimer = setTimeout(async () => {
          const ids = [...pendingGroups];
          pendingGroups.clear();
          for (const gid of ids) {
            const updated = await window.SBData.loadGroup(gid);
            if (updated) setGroups(prev => prev.map(g => g.id === gid ? updated : g));
          }
        }, 600);
      }
      window.SBData.subscribeRealtime({
        onPlayerChange(event, row) {
          if (!row?.id) return;
          const p = window.SBData.mapPlayer(row);
          if (event === "INSERT") setPlayers(prev => prev.some(x => x.id === p.id) ? prev : [...prev, p]);
          else if (event === "UPDATE") setPlayers(prev => prev.map(x => x.id === p.id ? { ...x, ...p } : x));
          else if (event === "DELETE") setPlayers(prev => prev.filter(x => x.id !== row.id));
        },
        onLogInsert(row) {
          if (!row) return;
          const l = window.SBData.mapLog(row);
          setLogs(prev => {
            // Skip near-duplicate from our own optimistic write (same type+actor within 10s)
            if (prev.some(x => x.type === l.type && x.actorId === l.actorId && Math.abs((x.at || 0) - (l.at || 0)) < 10000)) return prev;
            return [l, ...prev].slice(0, 300);
          });
        },
        onGroupChange(groupId) { scheduleGroupReload(groupId); },
      });
      return () => { clearTimeout(debounceTimer); window.SBData.unsubscribeRealtime(); };
    },
    teardownRealtime() {
      if (sbMode && window.SBData?.unsubscribeRealtime) window.SBData.unsubscribeRealtime();
    },
  };
}

Object.assign(window, {
  SEED_PLAYERS, SEED_GROUPS, SEED_LOGS, SEED_USERS, SEED_VIEWER_SESSIONS,
  generateMatches, shuffleArr, useStore,
  buildRecommendations, DEFAULT_RECO_RULES,
  DEFAULT_SYSTEM_CONFIG, levelStep, formatLevel, snapLevel, gapStep, snapGap,
  isViewerActive, VIEWER_ACTIVE_WINDOW,
});
