/* Stateful store for the working SPIF tracker.
 *
 * Single useReducer wrapped in a Context. Persists to localStorage so
 * refreshes don't lose your work. Seeds from the demo data in data.jsx
 * the first time the app loads.
 *
 * Exposes:
 *   useStore()                 → { state, actions, today }
 *
 * Actions:
 *   addHire(managerId, hire)            // hire: { name, role, startDate, offerSigned }
 *   updateHire(managerId, hireId, patch)
 *   markHireSale(managerId, hireId, saleDate)   // patch shortcut
 *   deleteHire(managerId, hireId)
 *
 *   addSale(csamId, sale)               // sale: { account, boothValue, leadId, closedDate, status }
 *   updateSale(csamId, saleId, patch)
 *   deleteSale(csamId, saleId)
 *
 *   updateSpiff(spiffKey, patch)        // spiffKey: 'manager' | 'csam'
 *
 *   setToday(iso)                       // for testing countdown urgency
 *   resetDemoData()
 */

const STORAGE_KEY = "pb-spif-tracker:v5";

const StoreContext = React.createContext(null);

function initialState() {
  return {
    today: TODAY.toISOString().slice(0, 10),
    managers: deepClone(MANAGERS),
    aes: deepClone(AES),
    csams: deepClone(CSAMS),
    spiffs: deepClone(SPIFFS),
  };
}

function deepClone(x) { return JSON.parse(JSON.stringify(x)); }

function loadFromStorage() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return null;
    const parsed = JSON.parse(raw);
    if (parsed && parsed.__v === 5) {
      const s = parsed.state;
      // Always refresh the reference date to the current TODAY so date
      // changes in data.jsx take effect without wiping saved entries.
      s.today = TODAY.toISOString().slice(0, 10);
      // Merge any roster additions from data.jsx (matched by id) so new
      // people show up without wiping data already entered for others.
      s.managers = mergeRoster(s.managers, MANAGERS);
      s.aes      = mergeRoster(s.aes, AES);
      s.csams    = mergeRoster(s.csams, CSAMS);
      // Fold in any newly-defined SPIFFs so new programs appear for
      // people whose saved blob predates them.
      s.spiffs   = { ...deepClone(SPIFFS), ...(s.spiffs || {}) };
      return normalizeState(s);
    }
  } catch (e) { /* fall through */ }
  return null;
}

/* People intentionally removed from the program. Their id is filtered out of
 * both the seed and any saved/synced state, so removing someone from the
 * roster sticks even though saved entries are otherwise preserved. */
const REMOVED_IDS = ["ae-samir"];

/* Keep saved entries for people already in storage; append any seed
 * members whose id isn't present yet. Order follows the seed roster.
 * Anyone in REMOVED_IDS is dropped from both sides. */
function mergeRoster(saved, seed) {
  const byId = {};
  (saved || []).forEach(p => { byId[p.id] = p; });
  return (seed || []).map(seedPerson => byId[seedPerson.id] || deepClone(seedPerson))
    // include any saved people not in the seed (e.g. ad-hoc additions)
    .concat((saved || []).filter(p => !(seed || []).some(sp => sp.id === p.id)))
    .filter(p => !REMOVED_IDS.includes(p.id));
}

function saveToStorage(state) {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify({ __v: 5, state }));
  } catch (e) { /* ignore */ }
}

function reducer(state, action) {
  switch (action.type) {
    case "addHire": {
      const { managerId, hire } = action;
      const hireWithId = { id: "h-" + Math.random().toString(36).slice(2, 9), firstSale: null, ...hire };
      return {
        ...state,
        managers: state.managers.map(m =>
          m.id === managerId ? { ...m, hires: [...m.hires, hireWithId] } : m
        ),
      };
    }
    case "updateHire": {
      const { managerId, hireId, patch } = action;
      return {
        ...state,
        managers: state.managers.map(m =>
          m.id === managerId
            ? { ...m, hires: m.hires.map(h => h.id === hireId ? { ...h, ...patch } : h) }
            : m
        ),
      };
    }
    case "deleteHire": {
      const { managerId, hireId } = action;
      return {
        ...state,
        managers: state.managers.map(m =>
          m.id === managerId ? { ...m, hires: m.hires.filter(h => h.id !== hireId) } : m
        ),
      };
    }

    case "addSale": {
      const { csamId, sale } = action;
      const commission = +(sale.boothValue * 0.25).toFixed(0);
      const saleWithId = { id: "s-" + Math.random().toString(36).slice(2, 9), commission, status: "pending", ...sale };
      // Recompute commission if boothValue is in sale
      saleWithId.commission = +(saleWithId.boothValue * 0.25).toFixed(0);
      return {
        ...state,
        csams: state.csams.map(c =>
          c.id === csamId ? { ...c, sales: [...(c.sales || []), saleWithId] } : c
        ),
      };
    }
    case "updateSale": {
      const { csamId, saleId, patch } = action;
      return {
        ...state,
        csams: state.csams.map(c =>
          c.id === csamId
            ? { ...c, sales: c.sales.map(s => {
                if (s.id !== saleId) return s;
                const merged = { ...s, ...patch };
                if ("boothValue" in patch) merged.commission = +(merged.boothValue * 0.25).toFixed(0);
                return merged;
              }) }
            : c
        ),
      };
    }
    case "deleteSale": {
      const { csamId, saleId } = action;
      return {
        ...state,
        csams: state.csams.map(c =>
          c.id === csamId ? { ...c, sales: c.sales.filter(s => s.id !== saleId) } : c
        ),
      };
    }

    case "addPresentation": {
      const { csamId, pres } = action;
      const withId = { id: "pr-" + Math.random().toString(36).slice(2, 9), ...pres };
      return {
        ...state,
        csams: state.csams.map(c =>
          c.id === csamId ? { ...c, presentations: [...(c.presentations || []), withId] } : c
        ),
      };
    }
    case "deletePresentation": {
      const { csamId, presId } = action;
      return {
        ...state,
        csams: state.csams.map(c =>
          c.id === csamId ? { ...c, presentations: (c.presentations || []).filter(p => p.id !== presId) } : c
        ),
      };
    }
    case "setMst": {
      const { csamId, mst } = action;
      return {
        ...state,
        csams: state.csams.map(c =>
          c.id === csamId ? { ...c, mstMeetings: Math.max(0, mst) } : c
        ),
      };
    }

    case "addDemo": {
      const { aeId, demo } = action;
      const withId = { id: "d-" + Math.random().toString(36).slice(2, 9), ...demo };
      return {
        ...state,
        aes: state.aes.map(a =>
          a.id === aeId ? { ...a, demos: [...(a.demos || []), withId] } : a
        ),
      };
    }
    case "deleteDemo": {
      const { aeId, demoId } = action;
      return {
        ...state,
        aes: state.aes.map(a =>
          a.id === aeId ? { ...a, demos: (a.demos || []).filter(d => d.id !== demoId) } : a
        ),
      };
    }

    /* ----- Industry Classification deals (PBD-CAT on AEs, PBD-CST on CSAMs) ----- */
    case "addDeal": {
      const { kind, repId, deal } = action;
      const coll  = kind === "cat" ? "aes" : "csams";
      const field = kind === "cat" ? "catDeals" : "cstDeals";
      const withId = { id: "dl-" + Math.random().toString(36).slice(2, 9), ...deal };
      return {
        ...state,
        [coll]: state[coll].map(p =>
          p.id === repId ? { ...p, [field]: [...(p[field] || []), withId] } : p
        ),
      };
    }
    case "updateDeal": {
      const { kind, repId, dealId, patch } = action;
      const coll  = kind === "cat" ? "aes" : "csams";
      const field = kind === "cat" ? "catDeals" : "cstDeals";
      return {
        ...state,
        [coll]: state[coll].map(p =>
          p.id === repId
            ? { ...p, [field]: (p[field] || []).map(d => d.id === dealId ? { ...d, ...patch } : d) }
            : p
        ),
      };
    }
    case "deleteDeal": {
      const { kind, repId, dealId } = action;
      const coll  = kind === "cat" ? "aes" : "csams";
      const field = kind === "cat" ? "catDeals" : "cstDeals";
      return {
        ...state,
        [coll]: state[coll].map(p =>
          p.id === repId ? { ...p, [field]: (p[field] || []).filter(d => d.id !== dealId) } : p
        ),
      };
    }

    /* ----- PartStore deals (combined AE + CSAM; rep lives in either roster) ----- */
    case "addPsDeal": {
      const { repId, deal } = action;
      const withId = { id: "ps-" + Math.random().toString(36).slice(2, 9), status: "signed", ...deal };
      const coll = state.aes.some(a => a.id === repId) ? "aes" : "csams";
      return {
        ...state,
        [coll]: state[coll].map(p =>
          p.id === repId ? { ...p, psDeals: [...(p.psDeals || []), withId] } : p
        ),
      };
    }
    case "updatePsDeal": {
      const { repId, dealId, patch } = action;
      const coll = state.aes.some(a => a.id === repId) ? "aes" : "csams";
      return {
        ...state,
        [coll]: state[coll].map(p =>
          p.id === repId
            ? { ...p, psDeals: (p.psDeals || []).map(d => d.id === dealId ? { ...d, ...patch } : d) }
            : p
        ),
      };
    }
    case "deletePsDeal": {
      const { repId, dealId } = action;
      const coll = state.aes.some(a => a.id === repId) ? "aes" : "csams";
      return {
        ...state,
        [coll]: state[coll].map(p =>
          p.id === repId ? { ...p, psDeals: (p.psDeals || []).filter(d => d.id !== dealId) } : p
        ),
      };
    }

    case "updateSpiff": {
      const { spiffKey, patch } = action;
      return { ...state, spiffs: { ...state.spiffs, [spiffKey]: { ...state.spiffs[spiffKey], ...patch } } };
    }

    case "setToday": {
      return { ...state, today: action.iso };
    }

    case "hydrate": {
      return action.state;
    }

    case "reset": {
      return initialState();
    }

    default:
      return state;
  }
}

/* Firebase Realtime Database drops empty arrays/objects and can return
 * arrays as integer-keyed objects. Coerce anything back to a real array. */
function toArray(v) {
  if (Array.isArray(v)) return v;
  if (v && typeof v === "object") return Object.keys(v).sort((a, b) => a - b).map(k => v[k]);
  return [];
}

/* Guarantee every roster member has the nested collections the UI maps over,
 * so a blob round-tripped through Firebase never crashes the render. */
function normalizeState(s) {
  s.managers = toArray(s.managers);
  s.aes = toArray(s.aes);
  s.csams = toArray(s.csams);
  s.managers.forEach(m => { m.hires = toArray(m.hires); });
  s.aes.forEach(a => { a.demos = toArray(a.demos); a.catDeals = toArray(a.catDeals); a.psDeals = toArray(a.psDeals); });
  s.csams.forEach(c => {
    c.sales = toArray(c.sales);
    c.presentations = toArray(c.presentations);
    c.cstDeals = toArray(c.cstDeals);
    c.psDeals = toArray(c.psDeals);
    if (typeof c.mstMeetings !== "number") c.mstMeetings = 0;
  });
  return s;
}

/* Merge a state blob loaded from a shared source (Firebase) with the seed:
 * refresh the reference date, fold in roster additions, and make sure any
 * newly-defined SPIFFs appear. Returns a fully-formed state object. */
function mergeLoadedState(s) {
  const norm = normalizeState({ ...s });
  const out = { ...norm };
  out.today = TODAY.toISOString().slice(0, 10);
  out.managers = mergeRoster(norm.managers, MANAGERS);
  out.aes      = mergeRoster(norm.aes, AES);
  out.csams    = mergeRoster(norm.csams, CSAMS);
  out.spiffs   = { ...deepClone(SPIFFS), ...(norm.spiffs || {}) };
  return normalizeState(out);
}

const DB_PATH = "spifTracker/state";

/* True if a state blob holds any real entered data (not just an empty seed).
 * Used to stop an empty client from ever overwriting a populated database. */
function hasMeaningfulData(s) {
  if (!s) return false;
  const anyHires = (s.managers || []).some(m => (m.hires || []).length > 0);
  const anyAe = (s.aes || []).some(a =>
    (a.demos || []).length > 0 || (a.catDeals || []).length > 0 || (a.psDeals || []).length > 0);
  const anyCsam = (s.csams || []).some(c =>
    (c.sales || []).length > 0 || (c.presentations || []).length > 0 ||
    (c.cstDeals || []).length > 0 || (c.psDeals || []).length > 0 || (c.mstMeetings || 0) > 0);
  return anyHires || anyAe || anyCsam;
}

function StoreProvider({ children }) {
  const [state, dispatch] = React.useReducer(
    reducer,
    null,
    () => loadFromStorage() || initialState()
  );

  const db = window.FIREBASE_DB || null;
  const [syncStatus, setSyncStatus] = React.useState(db ? "connecting" : "local");

  // Keep a live ref to current state for use inside Firebase callbacks.
  const stateRef = React.useRef(state);
  stateRef.current = state;

  // Tracks the JSON we last sent to / received from Firebase, so the
  // write effect and the listener don't ping-pong each other forever.
  const syncedJSON = React.useRef(null);

  // CRITICAL GUARD: we must NOT write to Firebase until we've received the
  // first snapshot back from it. Otherwise a fresh load (empty localStorage
  // after a version bump / new device) would push its empty initial state
  // to Firebase and clobber everyone's real data before reading it. This ref
  // flips true only once the initial read has completed.
  const hydratedRef = React.useRef(false);

  // ----- Subscribe to the shared dataset -----
  React.useEffect(() => {
    if (!db) return;

    const ref = db.ref(DB_PATH);
    const handler = ref.on("value", (snap) => {
      const val = snap.val();
      if (!val || !hasMeaningfulData(val)) {
        // Firebase has no real data yet. Mark hydrated so local edits can
        // now flow up — but only SEED the DB if THIS client actually holds
        // data worth saving (a migration from local-only). An empty client
        // must never overwrite a populated DB, and never seed emptiness.
        hydratedRef.current = true;
        const init = stateRef.current;
        if (hasMeaningfulData(init)) {
          syncedJSON.current = JSON.stringify(init);
          ref.set(init).catch((e) => console.warn("[SPIF] seed write failed", e));
        }
        return;
      }
      const merged = mergeLoadedState(val);
      const mergedJSON = JSON.stringify(merged);
      hydratedRef.current = true;
      if (mergedJSON === syncedJSON.current) return; // echo of our own write
      syncedJSON.current = mergedJSON;
      // If merging added roster members / new SPIFFs, persist that back once.
      if (mergedJSON !== JSON.stringify(val)) ref.set(merged).catch((e) => console.warn("[SPIF] merge write failed", e));
      dispatch({ type: "hydrate", state: merged });
      saveToStorage(merged);
    }, () => setSyncStatus("local"));

    // Connection indicator
    const connRef = db.ref(".info/connected");
    const connHandler = connRef.on("value", (s) =>
      setSyncStatus(s.val() ? "synced" : "connecting"));

    return () => { ref.off("value", handler); connRef.off("value", connHandler); };
  }, [db]);

  // ----- Persist local changes (localStorage always; Firebase if present) -----
  React.useEffect(() => {
    saveToStorage(state);
    if (!db) return;
    // Never write to Firebase before the first read has landed — this is the
    // guard that prevents an empty fresh-load from wiping the shared data.
    if (!hydratedRef.current) return;
    const json = JSON.stringify(state);
    if (json === syncedJSON.current) return; // this change came from a remote sync
    syncedJSON.current = json;
    db.ref(DB_PATH).set(state).catch((e) => console.warn("[SPIF] write failed", e));
  }, [state, db]);

  const actions = React.useMemo(() => ({
    addHire:      (managerId, hire) => dispatch({ type: "addHire", managerId, hire }),
    updateHire:   (managerId, hireId, patch) => dispatch({ type: "updateHire", managerId, hireId, patch }),
    markHireSale: (managerId, hireId, saleDate) => dispatch({ type: "updateHire", managerId, hireId, patch: { firstSale: saleDate } }),
    deleteHire:   (managerId, hireId) => dispatch({ type: "deleteHire", managerId, hireId }),

    addSale:      (csamId, sale) => dispatch({ type: "addSale", csamId, sale }),
    updateSale:   (csamId, saleId, patch) => dispatch({ type: "updateSale", csamId, saleId, patch }),
    deleteSale:   (csamId, saleId) => dispatch({ type: "deleteSale", csamId, saleId }),

    addPresentation:    (csamId, pres) => dispatch({ type: "addPresentation", csamId, pres }),
    deletePresentation: (csamId, presId) => dispatch({ type: "deletePresentation", csamId, presId }),
    setMst:             (csamId, mst) => dispatch({ type: "setMst", csamId, mst }),

    addDemo:            (aeId, demo) => dispatch({ type: "addDemo", aeId, demo }),
    deleteDemo:         (aeId, demoId) => dispatch({ type: "deleteDemo", aeId, demoId }),

    addDeal:            (kind, repId, deal) => dispatch({ type: "addDeal", kind, repId, deal }),
    updateDeal:         (kind, repId, dealId, patch) => dispatch({ type: "updateDeal", kind, repId, dealId, patch }),
    deleteDeal:         (kind, repId, dealId) => dispatch({ type: "deleteDeal", kind, repId, dealId }),

    addPsDeal:          (repId, deal) => dispatch({ type: "addPsDeal", repId, deal }),
    updatePsDeal:       (repId, dealId, patch) => dispatch({ type: "updatePsDeal", repId, dealId, patch }),
    deletePsDeal:       (repId, dealId) => dispatch({ type: "deletePsDeal", repId, dealId }),

    updateSpiff:  (spiffKey, patch) => dispatch({ type: "updateSpiff", spiffKey, patch }),
    setToday:     (iso) => dispatch({ type: "setToday", iso }),
    resetDemo:    () => {
      localStorage.removeItem(STORAGE_KEY);
      const fresh = initialState();
      syncedJSON.current = JSON.stringify(fresh);
      if (db) db.ref(DB_PATH).set(fresh).catch(() => {});
      dispatch({ type: "reset" });
    },
  }), [db]);

  // computed derived values
  const today = new Date(state.today + "T00:00:00");

  const value = React.useMemo(
    () => ({ state, actions, today, syncStatus }),
    [state, actions, syncStatus]
  );

  return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>;
}

function useStore() {
  const ctx = React.useContext(StoreContext);
  if (!ctx) throw new Error("useStore must be used inside <StoreProvider>");
  return ctx;
}

/* Re-implement the stat helpers from data.jsx but parameterized by today. */
function hireStatusFor(hire, today, windowDays) {
  const start = new Date(hire.startDate + "T00:00:00");
  const deadline = new Date(start);
  deadline.setDate(deadline.getDate() + windowDays);
  const elapsed = Math.max(0, Math.round((today - start) / 86400000));
  const remaining = Math.round((deadline - today) / 86400000);
  const percent = Math.max(0, Math.min(100, (elapsed / windowDays) * 100));
  let status = "pending";
  if (hire.firstSale && new Date(hire.firstSale + "T00:00:00") <= deadline) status = "qualified";
  else if (today > deadline) status = "expired";
  return { ...hire,
    status,
    deadline: deadline.toISOString().slice(0,10),
    elapsed, remaining, totalDays: windowDays, percent,
    qualified: status === "qualified",
  };
}

function managerStatsFor(mgr, today, spiff) {
  const hires = mgr.hires.map(h => hireStatusFor(h, today, spiff.windowDays));
  const qualified = hires.filter(h => h.status === "qualified").length;
  const pending   = hires.filter(h => h.status === "pending").length;
  const expired   = hires.filter(h => h.status === "expired").length;
  const earned    = qualified * spiff.payout;
  const potential = pending * spiff.payout;
  const pendingHires = hires.filter(h => h.status === "pending");
  const soonest = pendingHires.length
    ? pendingHires.reduce((a,b) => a.remaining < b.remaining ? a : b).remaining
    : null;
  return { hires, qualified, pending, expired, earned, potential, soonest, total: hires.length };
}

function csamStatsFor(csam) {
  const sales = csam.sales || [];
  const totalCommission = sales.reduce((s, x) => s + x.commission, 0);
  const totalBooth = sales.reduce((s, x) => s + x.boothValue, 0);
  const paid = sales.filter(s => s.status === "paid").length;
  const pending = sales.filter(s => s.status === "pending").length;
  return { sales, totalCommission, totalBooth, paid, pending, count: sales.length };
}

/* Presentation Sprint stats. ISO date strings compare lexically, so a
 * simple string range check is a correct in-window test. */
function inWindow(dateStr, start, end) {
  return dateStr >= start && dateStr <= end;
}
function presentationStatsFor(csam, spiff) {
  const pres = (csam.presentations || []).slice().sort((a, b) => a.date.localeCompare(b.date));
  const contest = pres.filter(p => inWindow(p.date, spiff.contestStart, spiff.contestEnd)).length;
  const team    = pres.filter(p => inWindow(p.date, spiff.teamStart, spiff.teamEnd)).length;
  const mst = csam.mstMeetings || 0;
  const benchmark = spiff.individualBenchmark;
  const threshold = Math.ceil(benchmark * spiff.eligibilityThreshold); // 80% of 5 → 4
  const attainment = benchmark ? team / benchmark : 0;
  const eligible = team >= threshold;            // ≥80% of individual benchmark
  const contestEligible = mst >= spiff.contestMinMst;
  return { presentations: pres, contest, team, mst, total: pres.length,
           benchmark, threshold, attainment, eligible, contestEligible };
}

/* Team-level rollup for the Presentation Sprint. */
function presentationTeamFor(csams, spiff, today) {
  const rows = csams.map(c => ({ id: c.id, name: c.name, ...presentationStatsFor(c, spiff) }));
  const teamTotal = rows.reduce((s, r) => s + r.team, 0);
  const achieved = teamTotal >= spiff.teamTarget;
  const eligibleRows = rows.filter(r => r.eligible);
  const eligibleReps = eligibleRows.length;
  const poolShare = eligibleReps ? Math.round(spiff.teamPool / eligibleReps) : 0;

  // contest winner: most contest-window presentations among MST-eligible reps
  const contenders = rows.filter(r => r.contestEligible && r.contest > 0);
  const winner = contenders.length
    ? contenders.reduce((a, b) => (b.contest > a.contest ? b : a))
    : null;

  const daysLeft = Math.max(0, Math.round((new Date(spiff.teamEnd + "T00:00:00") - today) / 86400000));
  const contestClosed = today > new Date(spiff.contestEnd + "T00:00:00");

  return { rows, teamTotal, achieved, eligibleReps, poolShare, winner, daysLeft, contestClosed,
           target: spiff.teamTarget, benchmark: spiff.teamBenchmark };
}

/* ---------- AE Demo Sprint stats ---------- */

function aeWeekIndexOf(date, spiff) {
  return spiff.weekRanges.findIndex(w => date >= w.start && date <= w.end);
}

/* Normalize a demo record to a { [categoryKey]: count } map of benchmarks
 * hit in that session. Backward compatible with the old single-category
 * shape ({ category: "advertising" } → { advertising: 1 }). */
function demoHits(d) {
  if (d && d.hits) return d.hits;
  if (d && d.category) return { [d.category]: 1 };
  return {};
}

function aeDemoStatsFor(ae, spiff, today) {
  const demos = (ae.demos || []).filter(d => d.date >= spiff.start && d.date <= spiff.end);
  // completed weeks = week ranges whose end is on/before today
  const weeksElapsed = spiff.weekRanges.filter(w => today >= new Date(w.end + "T00:00:00")).length;

  // weekly matrix: { [weekIdx]: { [catKey]: count } }
  const weekly = spiff.weekRanges.map(() => ({}));
  for (const d of demos) {
    const wi = aeWeekIndexOf(d.date, spiff);
    if (wi >= 0) {
      const h = demoHits(d);
      for (const k in h) weekly[wi][k] = (weekly[wi][k] || 0) + h[k];
    }
  }

  // ----- Current week (real-time) -----
  // Which week contains today? If today falls in a gap/weekend or past the
  // end, fall back to the most recent week that has started.
  let currentWeek = spiff.weekRanges.findIndex(w =>
    today >= new Date(w.start + "T00:00:00") && today <= new Date(w.end + "T23:59:59"));
  const inActiveWeek = currentWeek >= 0;
  if (currentWeek < 0) {
    const started = spiff.weekRanges.filter(w => today >= new Date(w.start + "T00:00:00")).length;
    currentWeek = started === 0 ? -1 : Math.min(spiff.weeks - 1, started - 1);
  }
  const contestStarted = currentWeek >= 0;

  // Fraction of the current week elapsed (inclusive of today), for live pace.
  let weekProgress = 1;
  if (inActiveWeek) {
    const ws = new Date(spiff.weekRanges[currentWeek].start + "T00:00:00");
    const we = new Date(spiff.weekRanges[currentWeek].end + "T00:00:00");
    const totalDays = Math.round((we - ws) / 86400000) + 1;
    const elapsed = Math.round((today - ws) / 86400000) + 1;
    weekProgress = Math.max(0, Math.min(1, elapsed / totalDays));
  }

  const byCat = {};
  let totalLogged = 0;
  for (const cat of spiff.categories) {
    const logged = demos.reduce((s, d) => s + (demoHits(d)[cat.key] || 0), 0);
    const fullBench = cat.weekly * spiff.weeks;
    const pace = cat.weekly * weeksElapsed;
    const pct = fullBench ? Math.min(100, (logged / fullBench) * 100) : 0;

    // Current-week, real-time figures
    const weekGot = contestStarted ? (weekly[currentWeek]?.[cat.key] || 0) : 0;
    const weekTarget = cat.weekly;
    const weekPace = weekTarget * weekProgress;          // expected so far this week
    const weekOnTrack = contestStarted && weekGot >= Math.round(weekPace);
    const weekMet = weekGot >= weekTarget;
    const weekPct = weekTarget ? Math.min(100, (weekGot / weekTarget) * 100) : 0;

    byCat[cat.key] = { logged, fullBench, pace, weekly: cat.weekly, pct,
                       onTrack: logged >= pace, label: cat.label, short: cat.short,
                       weekGot, weekTarget, weekPace, weekOnTrack, weekMet, weekPct };
    totalLogged += logged;
  }
  const overallBalanced = spiff.categories.reduce((s, c) => s + byCat[c.key].pct, 0) / spiff.categories.length;
  const onTrackCount = spiff.categories.filter(c => byCat[c.key].onTrack).length;
  const weekOnTrackCount = spiff.categories.filter(c => byCat[c.key].weekOnTrack).length;
  const weekMetCount = spiff.categories.filter(c => byCat[c.key].weekMet).length;
  const weekLogged = spiff.categories.reduce((s, c) => s + byCat[c.key].weekGot, 0);
  const weekTargetTotal = spiff.categories.reduce((s, c) => s + c.weekly, 0);
  const overallPct = spiff.totalExpected ? (totalLogged / spiff.totalExpected) * 100 : 0;

  return { demos, byCat, totalLogged, sessions: demos.length, overallBalanced,
           onTrackCount, overallPct, weeksElapsed, weekly,
           currentWeek, contestStarted, inActiveWeek, weekProgress,
           weekOnTrackCount, weekMetCount, weekLogged, weekTargetTotal };
}

function aeDemoTeamFor(aes, spiff, today) {
  const rows = aes.map(a => ({ id: a.id, name: a.name, ...aeDemoStatsFor(a, spiff, today) }))
    .sort((x, y) => y.overallBalanced - x.overallBalanced
                 || y.onTrackCount - x.onTrackCount
                 || y.totalLogged - x.totalLogged);
  const leader = rows[0] && rows[0].totalLogged > 0 ? rows[0] : null;
  const teamTotal = rows.reduce((s, r) => s + r.totalLogged, 0);

  const start = new Date(spiff.start + "T00:00:00");
  const end   = new Date(spiff.end + "T00:00:00");
  let status = "active";
  if (today < start) status = "upcoming";
  else if (today > end) status = "ended";
  const daysLeft = Math.max(0, Math.round((end - today) / 86400000));
  const daysToStart = Math.max(0, Math.round((start - today) / 86400000));

  // current week index (containing today), else last started week
  let currentWeek = spiff.weekRanges.findIndex(w =>
    today >= new Date(w.start + "T00:00:00") && today <= new Date(w.end + "T23:59:59"));
  if (currentWeek < 0) {
    const started = spiff.weekRanges.filter(w => today >= new Date(w.start + "T00:00:00")).length;
    currentWeek = Math.max(0, Math.min(spiff.weeks - 1, started - 1));
  }
  const weeksElapsed = rows[0]?.weeksElapsed || 0;
  const avgAttainment = rows.length ? rows.reduce((s, r) => s + r.overallBalanced, 0) / rows.length : 0;

  return { rows, leader, teamTotal, status, daysLeft, daysToStart, currentWeek, weeksElapsed, avgAttainment };
}

/* ---------- Industry Classification SPIFFs (PBD-CAT / PBD-CST) ----------
 * One rollup serves both. Per-rep payout follows spiff.tiers:
 *   CAT: tier1 = 1+ close, tier2 = 2+ closes, tier3 = 3+ closes OR rev leader.
 *   CST: tier1 = 1+ full-rate renewal, tier2 = 1+ upgrade, tier3 = rev leader.
 * The trophy (and its tier-3 bonus) always goes to the segment-revenue leader. */
function industryDealsOf(rep, spiff) {
  const field = spiff.kind === "cat" ? "catDeals" : "cstDeals";
  return (rep[field] || []).slice().sort((a, b) => (b.closeDate || "").localeCompare(a.closeDate || ""));
}

function industryTeamFor(reps, spiff, today) {
  let rows = reps.map(r => {
    const deals = industryDealsOf(r, spiff);
    const revenue = deals.reduce((s, d) => s + (Number(d.revenue) || 0), 0);
    const renewals = deals.filter(d => d.type === "renewal").length;
    const upgrades = deals.filter(d => d.type === "upgrade").length;
    const expansions = deals.filter(d => d.type === "expansion").length;
    const bySeg = {};
    spiff.segments.forEach(sg => {
      const dd = deals.filter(d => d.segment === sg.key);
      bySeg[sg.key] = { count: dd.length, revenue: dd.reduce((s, d) => s + (Number(d.revenue) || 0), 0) };
    });
    return { id: r.id, name: r.name, deals, count: deals.length, revenue,
             renewals, upgrades, expansions, bySeg };
  });

  // Revenue leader (trophy holder) — only among reps with any revenue.
  const revLeader = rows.reduce((best, r) =>
    (r.revenue > 0 && (!best || r.revenue > best.revenue)) ? r : best, null);

  rows = rows.map(r => {
    const isLeader = !!revLeader && r.id === revLeader.id;
    const t = spiff.tiers;
    let earned = 0;
    const tierState = [];
    if (spiff.kind === "cat") {
      const s1 = r.count >= 1, s2 = r.count >= 2, s3 = r.count >= 3 || isLeader;
      if (s1) earned += t[0].amount;
      if (s2) earned += t[1].amount;
      if (s3) earned += t[2].amount;
      tierState.push(s1, s2, s3);
    } else {
      const s1 = r.renewals >= 1, s2 = r.upgrades >= 1, s3 = isLeader;
      if (s1) earned += t[0].amount;
      if (s2) earned += t[1].amount;
      if (s3) earned += t[2].amount;
      tierState.push(s1, s2, s3);
    }
    return { ...r, earned, trophy: isLeader, tierState };
  });

  rows.sort((a, b) => b.earned - a.earned || b.revenue - a.revenue || b.count - a.count);

  const start = new Date(spiff.start + "T00:00:00");
  const end   = new Date(spiff.end + "T00:00:00");
  let status = "active";
  if (today < start) status = "upcoming";
  else if (today > end) status = "ended";
  const daysLeft = Math.max(0, Math.round((end - today) / 86400000));

  return {
    rows, revLeader,
    leader: rows[0] && rows[0].count > 0 ? rows[0] : null,
    totalDeals:   rows.reduce((s, r) => s + r.count, 0),
    totalRevenue: rows.reduce((s, r) => s + r.revenue, 0),
    totalPaid:    rows.reduce((s, r) => s + r.earned, 0),
    repsWithDeals: rows.filter(r => r.count > 0).length,
    status, daysLeft,
  };
}

/* ---------- PartStore SPIFF (combined AE + CSAM) ----------
 * A deal is "signed" when logged, and "activated" once the customer uploads
 * inventory. Commission is only credited on activation. Deals must be signed
 * by spiff.signBy to stay eligible. */
function partStoreTeamFor(aes, csams, spiff, today) {
  const all = [
    ...aes.map(a => ({ id: a.id, name: a.name, role: "AE", deals: a.psDeals || [] })),
    ...csams.map(c => ({ id: c.id, name: c.name, role: "CSAM", deals: c.psDeals || [] })),
  ];
  let rows = all.map(p => {
    const deals = p.deals.slice().sort((a, b) => (b.signedDate || "").localeCompare(a.signedDate || ""));
    const activated = deals.filter(d => d.status === "activated");
    const pendingDeals = deals.filter(d => d.status !== "activated");
    const earned  = activated.reduce((s, d) => s + (Number(d.commission) || 0), 0);
    const pending = pendingDeals.reduce((s, d) => s + (Number(d.commission) || 0), 0);
    return {
      ...p, deals,
      count: deals.length,
      activatedCount: activated.length,
      signedCount: pendingDeals.length,
      earned, pending,
    };
  });
  rows.sort((a, b) => b.earned - a.earned || b.activatedCount - a.activatedCount || b.count - a.count);

  const signBy = new Date(spiff.signBy + "T00:00:00");
  const inv    = new Date(spiff.inventoryDeadline + "T00:00:00");
  const start  = new Date(spiff.start + "T00:00:00");
  let status = "active";
  if (today < start) status = "upcoming";
  else if (today > inv) status = "ended";
  const daysToSign = Math.max(0, Math.round((signBy - today) / 86400000));
  const daysToInventory = Math.max(0, Math.round((inv - today) / 86400000));
  const signingClosed = today > signBy;

  return {
    rows,
    leader: rows[0] && rows[0].count > 0 ? rows[0] : null,
    totalDeals:     rows.reduce((s, r) => s + r.count, 0),
    activatedDeals: rows.reduce((s, r) => s + r.activatedCount, 0),
    signedDeals:    rows.reduce((s, r) => s + r.signedCount, 0),
    earned:         rows.reduce((s, r) => s + r.earned, 0),
    pending:        rows.reduce((s, r) => s + r.pending, 0),
    repsParticipating: rows.filter(r => r.count > 0).length,
    totalReps: all.length,
    status, daysToSign, daysToInventory, signingClosed,
  };
}

function useOrgTotals() {
  const { state, today } = useStore();
  return React.useMemo(() => {
    const mgr  = state.managers.map(m => managerStatsFor(m, today, state.spiffs.manager));
    const csam = state.csams.map(c => csamStatsFor(c));
    return {
      manager: {
        totalHires: mgr.reduce((s,x)=>s+x.total,0),
        qualified:  mgr.reduce((s,x)=>s+x.qualified,0),
        pending:    mgr.reduce((s,x)=>s+x.pending,0),
        expired:    mgr.reduce((s,x)=>s+x.expired,0),
        earned:     mgr.reduce((s,x)=>s+x.earned,0),
        potential:  mgr.reduce((s,x)=>s+x.potential,0),
      },
      csam: {
        sales:         csam.reduce((s,x)=>s+x.count,0),
        reps:          state.csams.length,
        repsWithSales: csam.filter(x=>x.count>0).length,
        commission:    csam.reduce((s,x)=>s+x.totalCommission,0),
        paid:          csam.reduce((s,x)=>s+x.paid,0),
        pending:       csam.reduce((s,x)=>s+x.pending,0),
        boothGross:    csam.reduce((s,x)=>s+x.totalBooth,0),
      },
    };
  }, [state, today]);
}

Object.assign(window, {
  StoreProvider, useStore, useOrgTotals,
  hireStatusFor, managerStatsFor, csamStatsFor,
  presentationStatsFor, presentationTeamFor,
  aeDemoStatsFor, aeDemoTeamFor, aeWeekIndexOf, demoHits,
  industryTeamFor, industryDealsOf,
  partStoreTeamFor,
});
