// store.jsx — Shared state for admin + public site.
//
// TWO MODES, picked automatically based on whether window.SUPABASE_CONFIG
// has both `url` and `anonKey` set:
//
//   1) localStorage-only (default if Supabase not configured)
//      State lives in browser localStorage. Cross-tab sync via the native
//      `storage` event. Cross-document iframe sync within the same origin.
//      Per-browser — visitors see defaults from this file, not your edits.
//
//   2) Supabase-backed (when SUPABASE_CONFIG.url + anonKey are filled in)
//      Source of truth is the `site_kv` table in your Supabase project.
//      localStorage acts as a fast cache so first paint is instant; it gets
//      hydrated on boot and on every Realtime push from the server.
//      Admin writes go to Supabase first, then update the cache. Public
//      visitors see admin's edits live (via Realtime postgres_changes).
//
// React-specific glue (useStoredState) lives at the bottom and is exposed
// on `window` so admin.jsx and app.jsx can import it without modules.

(function () {
  const KEYS = {
    DEALS: "xb.deals",
    BRAND: "xb.brand",
    PROFILE: "xb.profile",
    SETTINGS: "xb.settings"
  };

  // Mapping from full localStorage key → short Supabase row key.
  // Supabase rows live under "deals"/"brand"/"profile"/"settings"; the
  // `xb.` prefix is local-only namespacing to avoid colliding with
  // unrelated localStorage entries on the same origin.
  const SHORT_KEY = {
    [KEYS.DEALS]:    "deals",
    [KEYS.BRAND]:    "brand",
    [KEYS.PROFILE]:  "profile",
    [KEYS.SETTINGS]: "settings"
  };
  const FULL_KEY = Object.fromEntries(
    Object.entries(SHORT_KEY).map(([full, short]) => [short, full])
  );

  const DEFAULT_BRAND = {
    siteName: "XOOTICBONUS",
    headline: "Best casino deals,",
    headlineHighlight: "no fluff.",
    subhead: "One top pick. A handful of solid backups. All verified, all exclusive codes.",
    accent: "#22c55e",
    motion: true,
    liveFeed: true,
    highlightIntensity: 1
  };

  const DEFAULT_PROFILE = {
    name: "9XOOTICBONUX",
    handle: "9xootic",
    followers: "551",
    bio: "I partner with online casinos to negotiate exclusive bonuses for my followers.",
    telegram: "https://t.me/xooticbonus",
    twitch: "https://twitch.tv/9xootic",
    email: "hi@xooticbonus.com"
  };

  const DEFAULT_SETTINGS = {
    show18: true,
    showDisclaimer: true,
    disclosure: "I earn a commission when you sign up through my codes — at no cost to you.",
    title: "XOOTICBONUS — Hand-picked Casino Deals",
    metaDesc: "Hand-picked, verified-weekly casino bonus codes. Exclusive deals, fast payouts.",
    domain: "xooticbonus.com"
  };

  // ─── Supabase client (optional) ──────────────────────────────────────
  const cfg = window.SUPABASE_CONFIG || {};
  const sbAvailable = !!(cfg.url && cfg.anonKey && window.supabase && window.supabase.createClient);
  const sb = sbAvailable
    ? window.supabase.createClient(cfg.url, cfg.anonKey, {
        auth: { persistSession: true, autoRefreshToken: true }
      })
    : null;

  // ─── Local cache (always used) ───────────────────────────────────────
  function loadLocal(key, fallback) {
    try {
      const raw = localStorage.getItem(key);
      if (!raw) return fallback;
      const parsed = JSON.parse(raw);
      // Merge non-array objects with defaults so newly added fields appear.
      if (parsed && !Array.isArray(parsed) && typeof parsed === "object" && fallback && typeof fallback === "object") {
        return { ...fallback, ...parsed };
      }
      return parsed;
    } catch (e) {
      return fallback;
    }
  }

  function writeCache(key, value) {
    try {
      if (value == null) {
        localStorage.removeItem(key);
      } else {
        localStorage.setItem(key, JSON.stringify(value));
      }
    } catch (e) { /* quota or disabled */ }
    window.dispatchEvent(new CustomEvent("xb:storage", { detail: { key, value } }));
  }

  // ─── Subscribe (cross-doc + same-doc) ────────────────────────────────
  function subscribe(key, fn) {
    const onStorage = (e) => {
      if (e.key !== key) return;
      try { fn(e.newValue ? JSON.parse(e.newValue) : null); } catch (_) {}
    };
    const onLocal = (e) => {
      if (!e.detail || e.detail.key !== key) return;
      fn(e.detail.value);
    };
    window.addEventListener("storage", onStorage);
    window.addEventListener("xb:storage", onLocal);
    return () => {
      window.removeEventListener("storage", onStorage);
      window.removeEventListener("xb:storage", onLocal);
    };
  }

  // ─── Save (cache + Supabase if enabled) ──────────────────────────────
  // Save is fire-and-forget for Supabase; cache writes happen synchronously
  // so the calling component re-renders immediately without waiting on the
  // network. If Supabase write fails (offline, RLS), we keep the optimistic
  // cache value and surface the error via console.
  async function persistRemote(key, value) {
    if (!sb) return { error: null };
    const shortKey = SHORT_KEY[key];
    if (!shortKey) return { error: null };
    const { error } = await sb.from("site_kv").upsert({ key: shortKey, value });
    if (error) {
      console.warn("Supabase save failed for", shortKey, error);
      // Surface to listening UI (admin shows a toast). Wrapped so a missing
      // dispatchEvent (very old environments) doesn't break the save path.
      try {
        window.dispatchEvent(new CustomEvent("xb:save-error", {
          detail: { key: shortKey, error }
        }));
      } catch (e) {}
    }
    return { error };
  }

  function save(key, value) {
    writeCache(key, value);
    persistRemote(key, value);
  }

  // ─── Hydrate from Supabase ───────────────────────────────────────────
  // Pull all known rows on boot. Compare against cache and only fire change
  // events when something actually differs, to avoid noisy re-renders.
  // Returns { ok, error } so admin can render a "couldn't sync" banner
  // when the user might be looking at stale localStorage cache.
  async function hydrate() {
    if (!sb) return { ok: true, error: null };
    try {
      const { data, error } = await sb
        .from("site_kv")
        .select("key, value")
        .in("key", Object.values(SHORT_KEY));
      if (error) {
        console.warn("Supabase hydrate failed:", error);
        return { ok: false, error };
      }
      data.forEach(row => {
        const fullKey = FULL_KEY[row.key];
        if (!fullKey) return;
        try {
          const existing = localStorage.getItem(fullKey);
          const next = JSON.stringify(row.value);
          if (existing !== next) writeCache(fullKey, row.value);
        } catch (_) {}
      });
      return { ok: true, error: null };
    } catch (e) {
      console.warn("Supabase hydrate threw:", e);
      return { ok: false, error: e };
    }
  }

  // ─── Realtime push ───────────────────────────────────────────────────
  // postgres_changes fires for INSERT / UPDATE / DELETE on site_kv. We
  // mirror the change into local cache and notify subscribers.
  function startRealtime() {
    if (!sb) return;
    sb.channel("site_kv_changes")
      .on("postgres_changes",
        { event: "*", schema: "public", table: "site_kv" },
        (payload) => {
          const row = payload.new && payload.new.key ? payload.new : payload.old;
          if (!row || !row.key) return;
          const fullKey = FULL_KEY[row.key];
          if (!fullKey) return;
          if (payload.eventType === "DELETE") {
            writeCache(fullKey, null);
          } else {
            writeCache(fullKey, row.value);
          }
        })
      .subscribe();
  }

  // ─── Auth ────────────────────────────────────────────────────────────
  // captchaToken is required when Supabase Attack Protection has hCaptcha
  // enabled. Caller passes the one-time token from the hCaptcha widget.
  async function signIn(email, password, captchaToken) {
    if (!sb) return { data: null, error: new Error("Supabase not configured") };
    const opts = { email, password };
    if (captchaToken) opts.options = { captchaToken };
    return sb.auth.signInWithPassword(opts);
  }

  async function signOut() {
    if (!sb) return { error: null };
    return sb.auth.signOut();
  }

  async function getSession() {
    if (!sb) return { data: { session: null } };
    return sb.auth.getSession();
  }

  function onAuthStateChange(cb) {
    if (!sb) return () => {};
    const { data } = sb.auth.onAuthStateChange(cb);
    return () => data.subscription.unsubscribe();
  }

  function reset() {
    Object.values(KEYS).forEach(k => localStorage.removeItem(k));
  }

  // ─── URL sanitisers ──────────────────────────────────────────────────
  // Defense in depth: validate at save AND at render. Blocks:
  //   - javascript:, data: (other than safe image types), vbscript:, file:
  //   - protocol-relative URLs (//evil.com → resolves to https://evil.com
  //     in browser context, would otherwise sneak past the scheme check)
  //   - any unknown scheme
  // Allows http(s), mailto, tel, and relative paths.
  function safeUrl(value) {
    if (value == null) return null;
    const s = String(value).trim();
    if (!s) return null;
    if (s.startsWith("//")) return null;       // protocol-relative
    if (!/^[a-z][a-z0-9+.-]*:/i.test(s)) return s;       // relative path
    if (/^(https?|mailto|tel):/i.test(s)) return s;
    return null;
  }

  // For <img src>. Allows http(s), relative paths, and a strict whitelist of
  // raster image data URIs. Rejects data:image/svg+xml — SVG can carry
  // executable script via <svg onload=...> or embedded <script> tags, and
  // future browser changes around image preloading could re-open the
  // execution path even though current <img src> rendering is static.
  function safeImgSrc(value) {
    if (value == null) return null;
    const s = String(value).trim();
    if (!s) return null;
    if (s.startsWith("//")) return null;
    if (!/^[a-z][a-z0-9+.-]*:/i.test(s)) return s;
    if (/^https?:/i.test(s)) return s;
    if (/^data:image\/(png|jpeg|jpg|gif|webp);/i.test(s)) return s;
    return null;
  }

  // ─── Copy tracking ───────────────────────────────────────────────────
  // Defense in depth so the dashboard count reflects real engagement, not
  // refresh-spam or trivial bots:
  //
  //   1. Per-tab dedup via sessionStorage  → max 1 count per code per 30s
  //      in this browser tab. Catches double-clicks and refresh hammering.
  //   2. Per-session DB rate limit (5 min) → enforced in the record_copy
  //      RPC. Same opaque session token can't repeat the same code.
  //   3. Per-code burst limit (10/min)     → enforced in the RPC across
  //      all sessions. Catches naive botnet attempts on a hot code.
  //
  // We deliberately don't store IPs, fingerprints, or anything tied to
  // identity. The session token is a per-tab UUID; closing the tab loses
  // it. Worst case: a determined attacker uses many real browsers — that's
  // hard to defend against without proper anti-bot infrastructure (out of
  // scope for an affiliate site).
  const COPY_DEDUP_MS = 30 * 1000;

  // Skip all tracking calls when running inside an iframe (admin live
  // preview) so the admin's own previewing doesn't pollute analytics.
  function isInIframe() {
    try { return window.self !== window.top; } catch (e) { return false; }
  }

  // Cache the session token in module scope so it's stable across calls
  // even when sessionStorage is unavailable (Safari Lockdown Mode, some
  // privacy extensions, embedded webviews). Without this cache, each
  // tracking call would generate a fresh UUID and bypass server-side
  // per-session dedup → over-counting from real users on those browsers.
  let __sessionToken = null;
  function getSessionToken() {
    if (__sessionToken) return __sessionToken;
    try {
      let s = sessionStorage.getItem("xb.session");
      if (!s) {
        s = (window.crypto && crypto.randomUUID) ? crypto.randomUUID()
          : (Date.now().toString(36) + Math.random().toString(36).slice(2));
        sessionStorage.setItem("xb.session", s);
      }
      __sessionToken = s;
      return s;
    } catch (e) {
      __sessionToken = "anon-" + Math.random().toString(36).slice(2);
      return __sessionToken;
    }
  }

  async function recordCopy(dealId, code) {
    if (!sb || !code || isInIframe()) return;
    const dedupKey = "xb.copy." + code;
    try {
      const last = sessionStorage.getItem(dedupKey);
      if (last && (Date.now() - parseInt(last, 10) < COPY_DEDUP_MS)) return;
      sessionStorage.setItem(dedupKey, String(Date.now()));
    } catch (e) { /* sessionStorage disabled — skip the local dedup */ }
    try {
      const { error } = await sb.rpc("record_copy", {
        p_deal_id: dealId || null,
        p_code: code,
        p_session: getSessionToken()
      });
      if (error) console.warn("recordCopy RPC failed:", error);
    } catch (e) { console.warn("recordCopy threw:", e); }
  }

  // Per-tab dedup for impressions: 30 min matches the server-side window.
  const VIEW_DEDUP_MS = 30 * 60 * 1000;

  async function recordView(dealId) {
    if (!sb || !dealId || isInIframe()) return;
    const dedupKey = "xb.view." + dealId;
    try {
      const last = sessionStorage.getItem(dedupKey);
      if (last && (Date.now() - parseInt(last, 10) < VIEW_DEDUP_MS)) return;
      sessionStorage.setItem(dedupKey, String(Date.now()));
    } catch (e) {}
    try {
      const { error } = await sb.rpc("record_view", {
        p_deal_id: dealId,
        p_session: getSessionToken()
      });
      if (error) console.warn("recordView RPC failed:", error);
    } catch (e) { console.warn("recordView threw:", e); }
  }

  // Best-effort device class derivation from the user-agent string.
  // Three buckets — partners care about mobile vs desktop split, finer
  // detail (browser/OS) is not worth the privacy/UI cost.
  function getDeviceClass() {
    const ua = (navigator && navigator.userAgent) || "";
    if (!ua) return null;
    if (/iPad|Tablet|PlayBook|Silk/i.test(ua)) return "tablet";
    if (/Mobi|Android|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua)) return "mobile";
    return "desktop";
  }

  // Cloudflare's open trace endpoint resolves the visitor's country at the
  // edge from their IP and returns a tiny text response. We extract just
  // the 2-letter ISO code — no IP is sent to OUR backend, no PII stored.
  // Cached in localStorage for the session window so repeat refreshes
  // don't re-hit the endpoint.
  async function getCountry() {
    try {
      const cached = localStorage.getItem("xb.cc.cache");
      if (cached) {
        const { v, t } = JSON.parse(cached);
        if (Date.now() - t < 30 * 60 * 1000) return v || null;
      }
    } catch (e) {}
    try {
      const r = await fetch("https://www.cloudflare.com/cdn-cgi/trace");
      if (!r.ok) return null;
      const text = await r.text();
      const m = text.match(/loc=([A-Z]{2})/);
      const cc = m ? m[1] : null;
      try { localStorage.setItem("xb.cc.cache", JSON.stringify({ v: cc, t: Date.now() })); } catch (e) {}
      return cc;
    } catch (e) { return null; }
  }

  async function recordVisit() {
    if (!sb || isInIframe()) return;
    const VISIT_WINDOW_MS = 30 * 60 * 1000;
    let already = false;
    try {
      if (sessionStorage.getItem("xb.visited") === "1") already = true;
      else sessionStorage.setItem("xb.visited", "1");
    } catch (e) {}
    if (!already) {
      try {
        const lastTs = parseInt(localStorage.getItem("xb.visited.at") || "0", 10);
        if (lastTs && Date.now() - lastTs < VISIT_WINDOW_MS) already = true;
        else localStorage.setItem("xb.visited.at", String(Date.now()));
      } catch (e) {}
    }
    if (already) return;
    // Strip referrer to its hostname for privacy. document.referrer can
    // be a full URL with sensitive query params.
    let referrerHost = "";
    try {
      if (document.referrer) {
        const u = new URL(document.referrer);
        referrerHost = u.hostname.replace(/^www\./i, "");
      }
    } catch (e) {}
    // Geo + device. getCountry is async; if it fails we still send the
    // visit with country=null. We don't block the RPC on it for too long.
    const country = await Promise.race([
      getCountry(),
      new Promise((res) => setTimeout(() => res(null), 1500))
    ]);
    const deviceClass = getDeviceClass();
    try {
      const { error } = await sb.rpc("record_visit", {
        p_session: getSessionToken(),
        p_referrer_host: referrerHost,
        p_country: country || "",
        p_device_class: deviceClass || ""
      });
      if (error) console.warn("recordVisit RPC failed:", error);
    } catch (e) { console.warn("recordVisit threw:", e); }
  }

  async function recordClaim(dealId, code) {
    if (!sb || !dealId || isInIframe()) return;
    // Per-tab dedup separate from copies — same deal won't double-count
    // even if the visitor clicks copy then claim back to back.
    const dedupKey = "xb.claim." + dealId;
    try {
      const last = sessionStorage.getItem(dedupKey);
      if (last && (Date.now() - parseInt(last, 10) < COPY_DEDUP_MS)) return;
      sessionStorage.setItem(dedupKey, String(Date.now()));
    } catch (e) {}
    try {
      const { error } = await sb.rpc("record_claim", {
        p_deal_id: dealId,
        p_code: code || null,
        p_session: getSessionToken()
      });
      if (error) console.warn("recordClaim RPC failed:", error);
    } catch (e) { console.warn("recordClaim threw:", e); }
  }

  // Single dashboard query: pulls every event stream + extras (country,
  // device, conversions) over the current `days` window AND the previous
  // `days` window for period-over-period delta. One round trip — six
  // parallel SELECTs.
  //
  // Returns:
  //   {
  //     visits:    { total, perDay, byReferrer, byCountry, byDevice, repeatRate },
  //     views:     { total, perDeal, perDay, perDealDay },
  //     copies:    { total, perDeal, perDay, perDealDay },
  //     claims:    { total, perDeal, perDay, perDealDay, perSession },
  //     conversions:{ total, perDeal, perKind, byBtag, revenue: { total, byCurrency } },
  //     hourOfDay: number[24],          // claims per hour 0..23
  //     dayOfWeek: number[7],           // claims per weekday 0..6 (Sun..Sat)
  //     previous:  { visits, views, copies, claims, conversions } totals,
  //     window:    { days, since: iso, until: iso }
  //   }
  async function fetchEngagement(days) {
    if (!sb) return null;
    const d = days || 7;
    const now = Date.now();
    const dayMs = 24 * 60 * 60 * 1000;
    const sinceCur  = new Date(now - d * dayMs).toISOString();
    const sincePrev = new Date(now - 2 * d * dayMs).toISOString();
    const untilPrev = sinceCur; // boundary between previous and current

    const [visitsRes, viewsRes, copiesRes, claimsRes, convRes, prevRes] = await Promise.all([
      sb.from("visit_events").select("session, referrer_host, country, device_class, visited_at").gte("visited_at", sinceCur),
      sb.from("view_events").select("deal_id, viewed_at").gte("viewed_at", sinceCur),
      sb.from("copy_events").select("deal_id, copied_at").gte("copied_at", sinceCur),
      sb.from("claim_events").select("deal_id, session, clicked_at").gte("clicked_at", sinceCur),
      sb.from("conversion_events").select("deal_id, btag, kind, amount, currency, recorded_at").gte("recorded_at", sinceCur),
      // Previous-period totals (no per-day detail needed — just counts).
      Promise.all([
        sb.from("visit_events").select("id", { count: "exact", head: true }).gte("visited_at", sincePrev).lt("visited_at", untilPrev),
        sb.from("view_events").select("id",  { count: "exact", head: true }).gte("viewed_at",  sincePrev).lt("viewed_at",  untilPrev),
        sb.from("copy_events").select("id",  { count: "exact", head: true }).gte("copied_at",  sincePrev).lt("copied_at",  untilPrev),
        sb.from("claim_events").select("id", { count: "exact", head: true }).gte("clicked_at", sincePrev).lt("clicked_at", untilPrev),
        sb.from("conversion_events").select("id", { count: "exact", head: true }).gte("recorded_at", sincePrev).lt("recorded_at", untilPrev)
      ])
    ]);
    if (visitsRes.error) console.warn("visit_events fetch failed:", visitsRes.error);
    if (viewsRes.error)  console.warn("view_events fetch failed:",  viewsRes.error);
    if (copiesRes.error) console.warn("copy_events fetch failed:",  copiesRes.error);
    if (claimsRes.error) console.warn("claim_events fetch failed:", claimsRes.error);
    if (convRes.error)   console.warn("conversion_events fetch failed:", convRes.error);

    // Bucket by local calendar date (YYYY-MM-DD) rather than rolling
    // 24-hour windows. Spring-forward and fall-back DST transitions only
    // produce 23h or 25h "days", so fixed-millisecond math drifts twice
    // a year — a fall-back day shows ~4% extra traffic, spring-forward
    // ~4% less. Calendar-date keys avoid this entirely.
    const dateKey = (date) =>
      date.getFullYear() + "-" +
      String(date.getMonth() + 1).padStart(2, "0") + "-" +
      String(date.getDate()).padStart(2, "0");

    const today = new Date();
    today.setHours(0, 0, 0, 0);
    const slotByKey = {};
    for (let i = 0; i < d; i++) {
      const day = new Date(today);
      day.setDate(today.getDate() - (d - 1 - i)); // i=0 → oldest, i=d-1 → today
      slotByKey[dateKey(day)] = i;
    }

    const bucket = (events, tsField) => {
      const total = events.length;
      const perDeal = {};
      const perDay = new Array(d).fill(0);
      const perDealDay = {};
      events.forEach((e) => {
        const dealKey = e.deal_id || "_unknown";
        perDeal[dealKey] = (perDeal[dealKey] || 0) + 1;
        const slot = slotByKey[dateKey(new Date(e[tsField]))];
        if (slot != null) {
          perDay[slot]++;
          if (!perDealDay[dealKey]) perDealDay[dealKey] = new Array(d).fill(0);
          perDealDay[dealKey][slot]++;
        }
      });
      return { total, perDeal, perDay, perDealDay };
    };

    // ── Visits: bucket by day + referrer + country + device + repeat-rate
    const visitsRaw = visitsRes.data || [];
    const visitsPerDay = new Array(d).fill(0);
    const byReferrer = {};
    const byCountry  = {};
    const byDevice   = {};
    const sessionCounts = {};
    visitsRaw.forEach((e) => {
      const slot = slotByKey[dateKey(new Date(e.visited_at))];
      if (slot != null) visitsPerDay[slot]++;
      const ref = (e.referrer_host && e.referrer_host.trim()) || "(direct)";
      byReferrer[ref] = (byReferrer[ref] || 0) + 1;
      const cc  = (e.country && e.country.trim()) || "??";
      byCountry[cc] = (byCountry[cc] || 0) + 1;
      const dv  = (e.device_class && e.device_class.trim()) || "unknown";
      byDevice[dv]  = (byDevice[dv]  || 0) + 1;
      if (e.session) sessionCounts[e.session] = (sessionCounts[e.session] || 0) + 1;
    });
    const sessionList = Object.values(sessionCounts);
    const repeatRate = sessionList.length
      ? sessionList.filter((c) => c > 1).length / sessionList.length
      : 0;

    // ── Hour-of-day + day-of-week: derived from CLAIMS (the engagement
    //    signal partners care about — not just page loads).
    const hourOfDay = new Array(24).fill(0);
    const dayOfWeek = new Array(7).fill(0);
    (claimsRes.data || []).forEach((e) => {
      const t = new Date(e.clicked_at);
      hourOfDay[t.getHours()]++;
      dayOfWeek[t.getDay()]++;
    });

    // ── Conversions: bucket by deal + kind, sum revenue per currency
    const convRaw = convRes.data || [];
    const convPerDeal = {};
    const convPerKind = { signup: 0, ftd: 0, deposit: 0, revenue: 0 };
    const revByCurrency = {};
    convRaw.forEach((c) => {
      const k = c.deal_id || "_unknown";
      if (!convPerDeal[k]) convPerDeal[k] = { signup: 0, ftd: 0, deposit: 0, revenue: 0, amount: 0 };
      if (convPerKind[c.kind] !== undefined) convPerKind[c.kind]++;
      if (convPerDeal[k][c.kind] !== undefined) convPerDeal[k][c.kind]++;
      if (c.amount != null) {
        const num = Number(c.amount) || 0;
        convPerDeal[k].amount += num;
        const cur = (c.currency || "EUR").toUpperCase();
        revByCurrency[cur] = (revByCurrency[cur] || 0) + num;
      }
    });
    // Track which sessions converted (for the funnel).
    const convertedSessions = new Set(convRaw.filter((c) => c.kind === "signup" || c.kind === "ftd").map((c) => c.btag));

    // ── Previous-period totals (only counts, for delta badges)
    const prev = (prevRes && Array.isArray(prevRes)) ? prevRes : [];
    const prevTotal = (i) => (prev[i] && prev[i].count != null ? prev[i].count : 0);

    return {
      visits: {
        total: visitsRaw.length,
        perDay: visitsPerDay,
        byReferrer,
        byCountry,
        byDevice,
        repeatRate
      },
      views:  bucket(viewsRes.data  || [], "viewed_at"),
      copies: bucket(copiesRes.data || [], "copied_at"),
      claims: { ...bucket(claimsRes.data || [], "clicked_at"), convertedSessions: convertedSessions.size },
      conversions: {
        total: convRaw.length,
        perDeal: convPerDeal,
        perKind: convPerKind,
        revenue: revByCurrency
      },
      hourOfDay,
      dayOfWeek,
      previous: {
        visits:      prevTotal(0),
        views:       prevTotal(1),
        copies:      prevTotal(2),
        claims:      prevTotal(3),
        conversions: prevTotal(4)
      },
      window: { days: d, since: sinceCur, until: new Date(now).toISOString() }
    };
  }

  // ── Postback / conversion-tracking admin helpers ─────────────────────
  async function fetchPostbackSecret(dealId) {
    if (!sb || !dealId) return null;
    const { data, error } = await sb.from("deal_secrets").select("secret").eq("deal_id", dealId).maybeSingle();
    if (error) { console.warn("fetchPostbackSecret failed:", error); return null; }
    return data ? data.secret : null;
  }

  // Generates a fresh secret and upserts it. Use the Web Crypto API for
  // entropy; fall back to a Math.random concat if Crypto is missing
  // (very old browsers — won't realistically apply to admin Chrome).
  async function rotatePostbackSecret(dealId) {
    if (!sb || !dealId) return { error: new Error("Supabase not configured") };
    let secret;
    try {
      const a = new Uint8Array(24);
      crypto.getRandomValues(a);
      secret = Array.from(a, (b) => b.toString(16).padStart(2, "0")).join("");
    } catch (e) {
      secret = (Date.now().toString(36) + Math.random().toString(36).slice(2)).repeat(2);
    }
    const { error } = await sb.from("deal_secrets")
      .upsert({ deal_id: dealId, secret }, { onConflict: "deal_id" });
    if (error) {
      console.warn("rotatePostbackSecret failed:", error);
      return { error };
    }
    return { secret, error: null };
  }

  // ─── Boot ────────────────────────────────────────────────────────────
  // Expose the hydration promise so the admin can gate edit UI on it
  // (otherwise a fast click in the first ~200ms of page load could edit
  // the defaults before fresh server data arrives, then save back over
  // whatever was actually in the DB). Resolves to { ok, error }.
  let hydrationDone = Promise.resolve({ ok: true, error: null });
  if (sb) {
    hydrationDone = hydrate();
    startRealtime();
  }

  // ─── Public API ──────────────────────────────────────────────────────
  window.XBStore = {
    KEYS,
    DEFAULT_BRAND,
    DEFAULT_PROFILE,
    DEFAULT_SETTINGS,
    isSupabaseEnabled: sbAvailable,
    load: loadLocal,
    save,
    subscribe,
    reset,
    loadDeals(fallback) { return loadLocal(KEYS.DEALS, fallback); },
    saveDeals(v)        { save(KEYS.DEALS, v); },
    loadBrand()         { return loadLocal(KEYS.BRAND, DEFAULT_BRAND); },
    saveBrand(v)        { save(KEYS.BRAND, v); },
    loadProfile()       { return loadLocal(KEYS.PROFILE, DEFAULT_PROFILE); },
    saveProfile(v)      { save(KEYS.PROFILE, v); },
    loadSettings()      { return loadLocal(KEYS.SETTINGS, DEFAULT_SETTINGS); },
    saveSettings(v)     { save(KEYS.SETTINGS, v); },
    signIn, signOut, getSession, onAuthStateChange,
    recordCopy, recordClaim, recordView, recordVisit,
    fetchEngagement,
    fetchPostbackSecret, rotatePostbackSecret,
    safeUrl, safeImgSrc,
    hydrationDone,
    getSessionToken,
    // For diagnostics
    _sb: sb
  };

  // ─── React helper (exposed globally) ─────────────────────────────────
  // useStoredState(key, loader): like useState, but reads from XBStore on
  // mount, writes to XBStore on every set, and re-syncs whenever a change
  // event fires for that key (cross-tab, Realtime push, etc.). Pass a
  // loader closure that returns the current cached value with defaults
  // merged in (e.g. () => XBStore.loadBrand()).
  function useStoredState(key, loader) {
    const R = window.React;
    const [val, setVal] = R.useState(loader);
    R.useEffect(() => window.XBStore.subscribe(key, () => {
      setVal(loader());
    }), [key]);
    const set = R.useCallback((updater) => {
      setVal((prev) => {
        const next = typeof updater === "function" ? updater(prev) : updater;
        window.XBStore.save(key, next);
        return next;
      });
    }, [key]);
    return [val, set];
  }
  window.useStoredState = useStoredState;
})();
