// Represents: GET /reports/conversion/ · ConversionReportController::index → admin.reports.conversion.index
/* Traced Aug 2026 (architecture item 2). A real host report with its own
   controller and route; it predated ISYSTEM_REFERENCE, which is the only
   reason it carried no header. */
/* Conversion report.

   One row per registered player showing whether they became a first-time
   depositor and how. The summary strip tracks the funnel for the (filtered)
   set: how many registered, how many made a first deposit, the conversion
   rate, and the conversion broken down by payment origin.

   WHAT THIS FILE USED TO BE. Every row came from a seeded PRNG: 465 registered
   players, 172 of them depositors, twelve real rows transcribed by hand at the
   top so the table matched a screenshot, and a funnel percentage computed from
   all of it. The arithmetic was correct and that is what made it convincing —
   the gauge agreed with the counts, the counts agreed with the table, and the
   whole thing described nothing.

   `report_player_conversion` (supabase/058) replaces it. A conversion there is
   a settled CREDIT of type 1 to the player's REAL wallet: not a deposit
   request, which is an intention, and not a bonus credit, which is the easiest
   way to make an acquisition channel look like it works. */

const { useState: useStateCV, useMemo: useMemoCV } = React;

const cvP2 = (n) => String(n).padStart(2, "0");
const cvDate = (ts) => {
  /* `new Date(null)` is the epoch, so an absent timestamp used to render as a
     real-looking 01/01/1970. A missing date is a dash. */
  if (!ts) return "—";
  const d = new Date(ts);
  if (isNaN(d.getTime())) return "—";
  return `${cvP2(d.getUTCDate())}/${cvP2(d.getUTCMonth() + 1)}/${d.getUTCFullYear()} ${cvP2(d.getUTCHours())}:${cvP2(d.getUTCMinutes())}:${cvP2(d.getUTCSeconds())}`;
};
const cvAmt = (n) => (n === null || n === undefined || n === ""
  ? "—"
  : Number(n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }));
const cvPct = (n) => (n === null || n === undefined ? "—" : Number(n).toFixed(2) + " %");

/* The label for a converted player with no settled deposit_request behind the
   ledger row. isystem prints "Manual Transfer" for this; here the two cases are
   kept apart, because "an operator typed it in" and "the request row is
   missing" are different facts and one label for both hides the second. */
const CV_NO_METHOD = "Manual / no request";

/* The page size the report asks the database for. Named rather than inlined
   because the funnel below depends on it: if the server has more rows than
   this, every count on the strip is short, and the screen says so instead of
   showing a smaller, wrong percentage. */
const CV_LIMIT = 2000;

const CV_DEFAULTS = {
  parent: "ALL", start: "", startTime: "00:00:00", end: "", endTime: "23:59:59",
  ip: "", origin: "ALL", orderBy: "regdate", order: "desc",
};

const cvIsoOrNull = (day, time, fallbackTime) => {
  if (!day) return null;
  return `${day}T${time || fallbackTime}Z`;
};

const Conversion = ({ brand }) => {
  window.useLocale && window.useLocale();
  const [draft, setDraft] = useStateCV(CV_DEFAULTS);
  const [applied, setApplied] = useStateCV(CV_DEFAULTS);
  const setD = (patch) => setDraft(d => ({ ...d, ...patch }));

  /* THE DATE RANGE IS SENT TO THE SERVER, not applied to a page of rows after
     the fact. The old version filtered client-side over a generated array; over
     a real table that is the difference between "465 registered in June" and
     "465 rows happened to come back". */
  const cvFeed = useHrsFetch(() => window.sb.list("reportPlayerConversion", {
    limit: CV_LIMIT,
    filters: {
      ...(applied.parent !== "ALL" ? { parent: applied.parent } : {}),
      ...(applied.ip.trim() ? { ip: applied.ip.trim() } : {}),
      ...(applied.origin === "No Deposit" ? { converted: false } : {}),
      ...(applied.origin === "Converted" ? { converted: true } : {}),
      ...(cvIsoOrNull(applied.start, applied.startTime, "00:00:00")
        ? { from: cvIsoOrNull(applied.start, applied.startTime, "00:00:00") } : {}),
      ...(cvIsoOrNull(applied.end, applied.endTime, "23:59:59")
        ? { to: cvIsoOrNull(applied.end, applied.endTime, "23:59:59") } : {}),
    },
  }), [applied]);

  const rows = useMemoCV(() => (cvFeed.data || []).map(r => ({
    id: r.user_id,
    username: r.username,
    parentId: r.parent_id,
    parent: r.parent_username || "—",
    skin: r.skin_name || "—",
    regTs: r.registered_at ? Date.parse(r.registered_at) : null,
    ip: r.registration_ip || "—",
    depositTs: r.deposited_at ? Date.parse(r.deposited_at) : null,
    amount: r.first_deposit_amount === null || r.first_deposit_amount === undefined
      ? null : Number(r.first_deposit_amount),
    currency: r.first_deposit_currency || r.currency || "",
    converted: !!r.converted,
    hours: r.hours_to_convert === null || r.hours_to_convert === undefined
      ? null : Number(r.hours_to_convert),
    /* `converted && !method_name` is the manual case; `!converted` has no
       origin at all. Collapsing them into one string is what the old code did
       and it is why "No Deposit" appeared in an Origin column. */
    origin: r.converted ? (r.method_name || CV_NO_METHOD) : null,
  })), [cvFeed.data]);

  /* The parent list comes from the rows the query returned — the real screen
     builds its dropdown the same way, from the promoters that appear in the
     result. Ten hardcoded agency names used to sit here. */
  const cvParents = useMemoCV(() => {
    const seen = new Map();
    rows.forEach(r => { if (r.parentId && !seen.has(r.parentId)) seen.set(r.parentId, r.parent); });
    return [...seen.entries()].sort((a, b) => String(a[1]).localeCompare(String(b[1])));
  }, [rows]);

  const sorted = useMemoCV(() => {
    const dir = applied.order === "asc" ? 1 : -1;
    const key = applied.orderBy === "regdate" ? "regTs" : applied.orderBy === "amount" ? "amount" : "id";
    return [...rows].sort((a, b) => {
      const av = a[key], bv = b[key];
      /* NULLS LAST, whichever direction. Coercing them to 0 puts every
         non-depositor at the top of an "Amount, ascending" sort as if they had
         deposited nothing, which is not the same as not having deposited. */
      if (av === null && bv === null) return 0;
      if (av === null) return 1;
      if (bv === null) return -1;
      return (av - bv) * dir;
    });
  }, [rows, applied]);

  /* THE FUNNEL, over what the server returned. `meta.total` is the row count
     the database reports for the same filter, so when it exceeds what came back
     the strip says the numbers are partial instead of quietly describing a
     page. The old version could not have this problem because it invented the
     whole population. */
  const cvTotal = (cvFeed.meta && typeof cvFeed.meta.total === "number") ? cvFeed.meta.total : null;
  const cvTruncated = cvTotal !== null && cvTotal > rows.length;

  const summary = useMemoCV(() => {
    const registered = rows.length;
    const depositors = rows.filter(r => r.converted).length;
    const rate = registered ? (depositors / registered) * 100 : null;
    const byOrigin = {};
    rows.forEach(r => { if (r.converted) byOrigin[r.origin] = (byOrigin[r.origin] || 0) + 1; });
    const origins = Object.entries(byOrigin)
      .sort((a, b) => b[1] - a[1])
      .map(([o, c]) => ({ origin: o, count: c, pct: registered ? (c / registered) * 100 : 0 }));
    /* Median rather than mean: one player who deposited eleven months after
       signing up drags a mean into uselessness, and the question the column
       answers is "how long does this usually take". */
    const hrs = rows.filter(r => r.hours !== null).map(r => r.hours).sort((a, b) => a - b);
    const median = hrs.length
      ? (hrs.length % 2 ? hrs[(hrs.length - 1) / 2] : (hrs[hrs.length / 2 - 1] + hrs[hrs.length / 2]) / 2)
      : null;
    return { registered, depositors, rate, origins, median };
  }, [rows]);

  const search = () => setApplied(draft);
  const reset = () => { setDraft(CV_DEFAULTS); setApplied(CV_DEFAULTS); };

  const exportCSV = () => {
    if (!window.PAYBO) return;
    const stamp = new Date().toISOString().slice(0, 10);
    window.PAYBO.downloadCSV(`conversion-${stamp}.csv`, sorted, [
      { key: "id", label: "user_id" },
      { key: "username", label: "username" },
      { key: "parent", label: "parent" },
      { key: "skin", label: "skin" },
      { key: "reg", label: "registration_date", get: r => cvDate(r.regTs) },
      { key: "ip", label: "registration_ip" },
      { key: "deposit", label: "first_deposit_date", get: r => (r.converted ? cvDate(r.depositTs) : "") },
      { key: "amount", label: "first_deposit_amount", get: r => (r.amount === null ? "" : r.amount) },
      { key: "currency", label: "currency" },
      { key: "hours", label: "hours_to_convert", get: r => (r.hours === null ? "" : r.hours) },
      { key: "origin", label: "origin", get: r => r.origin || "" },
    ]);
  };

  /* REAL NAVIGATION, not a toast describing one. The old handler emitted
     "Opening the player profile (host)" and stayed on the page. There is no
     per-player deep link in this build — the host Players screen holds its
     selection in memory — so this goes to that screen the way every other
     cross-screen link here does: pushState plus a popstate event, the app's own
     convention (see hrplGoPlayers in HostReportPlayersRpt.jsx). */
  const openPlayer = () => {
    const path = (window.pathForActive && window.pathForActive("host-players")) || "/players";
    try {
      if (window.location.pathname !== path) window.history.pushState({ active: "host-players" }, "", path);
      const ev = typeof PopStateEvent === "function" ? new PopStateEvent("popstate") : new Event("popstate");
      window.dispatchEvent(ev);
    } catch (_e) {
      window.location.href = path;
    }
  };

  return (
    <div className="page report-page conversion">
      <div className="page__header">
        <div className="page__title">Conversion</div>
      </div>

      {/* ---------- Filters ---------- */}
      <div className="rpt-filters">
        <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
          <div style={{ display: "flex", gap: 26, flexWrap: "wrap", alignItems: "flex-start" }}>
            <div className="rpt-field">
              <label>Parent</label>
              <select className="select" value={draft.parent} onChange={e => setD({ parent: e.target.value })}>
                <option value="ALL">ALL</option>
                {cvParents.map(([id, name]) => <option key={id} value={id}>{name}</option>)}
              </select>
              {cvParents.length === 0 && (
                <div className="cv-stat-sub">Built from the parents present in the result — run a search first.</div>
              )}
            </div>
            <div className="rpt-field">
              <label>Registration date ( UTC )</label>
              <div className="rpt-daterow">
                <input className="input rpt-date" type="date" value={draft.start} onChange={e => setD({ start: e.target.value })} />
                <input className="input rpt-time" type="time" step="1" value={draft.startTime} onChange={e => setD({ startTime: e.target.value })} />
                <input className="input rpt-date" type="date" value={draft.end} onChange={e => setD({ end: e.target.value })} />
                <input className="input rpt-time" type="time" step="1" value={draft.endTime} onChange={e => setD({ endTime: e.target.value })} />
              </div>
            </div>
            <div className="rpt-field">
              <label>Registration IP</label>
              <input className="input" value={draft.ip} onChange={e => setD({ ip: e.target.value })} placeholder="127.0.0.1" />
            </div>
            <div className="rpt-field">
              <label>Conversion</label>
              {/* WAS A LIST OF TWO HARDCODED PROVIDER NAMES. The origin of a
                  deposit is a payment method id, and which ones exist is a
                  property of the platform — so the filter here is the one thing
                  the view can answer for certain, converted or not, and origin
                  is a column on the table below. */}
              <select className="select" value={draft.origin} onChange={e => setD({ origin: e.target.value })}>
                <option value="ALL">- ALL -</option>
                <option value="Converted">Converted</option>
                <option value="No Deposit">No Deposit</option>
              </select>
            </div>
          </div>
          <div style={{ display: "flex", gap: 26, flexWrap: "wrap" }}>
            <div className="rpt-field">
              <label>Order by</label>
              <select className="select" value={draft.orderBy} onChange={e => setD({ orderBy: e.target.value })}>
                <option value="id">ID</option>
                <option value="regdate">Registration date</option>
                <option value="amount">Amount</option>
              </select>
            </div>
            <div className="rpt-field">
              <label>Order</label>
              <select className="select" value={draft.order} onChange={e => setD({ order: e.target.value })}>
                <option value="desc">Descending</option>
                <option value="asc">Ascending</option>
              </select>
            </div>
          </div>
        </div>

        <div className="rpt-actions">
          <div className="rpt-actions-row">
            <button className="rpt-btn rpt-btn--search" onClick={search}><Icon name="search" size={14} /> Search</button>
            <button className="rpt-btn rpt-btn--export" onClick={exportCSV} disabled={!rows.length}><Icon name="download" size={14} /> Export</button>
          </div>
          <div className="rpt-actions-row">
            <button className="rpt-btn rpt-btn--reset" onClick={reset}><Icon name="x" size={14} /> Reset Filters</button>
          </div>
        </div>
      </div>

      {cvFeed.loading && <HrsSkeleton rows={8} cols={9} />}
      {!cvFeed.loading && cvFeed.error && <HrsError error={cvFeed.error} onRetry={cvFeed.retry} />}

      {!cvFeed.loading && !cvFeed.error && (
        <>
          {/* THE PARTIAL-RESULT BANNER. Without it the strip below would report
              a conversion rate for the first CV_LIMIT rows and call it the
              funnel. */}
          {cvTruncated && (
            <div className="panel" style={{ padding: "10px 14px", marginBottom: 12, fontSize: 12.5,
                                            borderLeft: "3px solid var(--warn-500, #f59e0b)" }}>
              <b>These figures cover {rows.length.toLocaleString("en-US")} of {cvTotal.toLocaleString("en-US")} matching players.</b>{" "}
              The counts and the rate below describe the rows on this page, not the whole filtered set —
              narrow the date range to make them the same thing.
            </div>
          )}

          {/* ---------- Summary stat widgets ---------- */}
          <div className="cv-stats">
            <div className="cv-stat cv-stat--a">
              <div className="cv-stat-ic"><Icon name="users" size={18} /></div>
              <div className="cv-stat-body">
                <div className="cv-stat-v">{summary.registered.toLocaleString("en-US")}</div>
                <div className="cv-stat-k">Players Registered</div>
              </div>
            </div>
            <div className="cv-stat cv-stat--b">
              <div className="cv-stat-ic"><Icon name="credit_card" size={18} /></div>
              <div className="cv-stat-body">
                <div className="cv-stat-v">{summary.depositors.toLocaleString("en-US")}</div>
                <div className="cv-stat-k">First Depositors</div>
              </div>
            </div>
            <div className="cv-stat cv-stat--rate">
              <div className="cv-gauge" style={{ "--pct": `${Math.min(100, summary.rate || 0)}` }}>
                <div className="cv-gauge-hole"><span>{cvPct(summary.rate)}</span></div>
              </div>
              <div className="cv-stat-body">
                <div className="cv-stat-k" style={{ marginTop: 0 }}>Conversion Rate</div>
                <div className="cv-stat-sub">
                  {summary.registered === 0
                    /* 0 of 0 is 0%, arithmetically. It is not a conversion rate. */
                    ? "No registrations in range — there is no rate to report"
                    : `${summary.depositors} of ${summary.registered} registered`}
                </div>
                {summary.median !== null && (
                  <div className="cv-stat-sub">Median time to first deposit: {summary.median.toFixed(1)} h</div>
                )}
              </div>
            </div>
            <div className="cv-stat cv-stat--details">
              <div className="cv-stat-k" style={{ marginBottom: 10 }}>Conversion by origin</div>
              {summary.origins.length === 0 && <div className="cv-stat-sub">No first deposits in range</div>}
              {summary.origins.map(o => (
                <div key={o.origin} className="cv-origin">
                  <div className="cv-origin-top"><span>{o.origin}</span><b>{cvPct(o.pct)}</b></div>
                  <div className="cv-origin-bar"><div className="cv-origin-fill" style={{ width: `${Math.min(100, o.pct)}%` }} /></div>
                </div>
              ))}
            </div>
          </div>

          {/* ---------- Player table ---------- */}
          <div className="panel" style={{ overflow: "hidden" }}>
            <div style={{ maxHeight: "calc(100vh - 440px)", overflow: "auto" }}>
              <table className="data-table">
                <thead>
                  <tr>
                    <th>ID</th>
                    <th>Username</th>
                    <th>Parent</th>
                    <th>Skin</th>
                    <th>Registration date</th>
                    <th>Registration IP</th>
                    <th>First deposit</th>
                    <th style={{ textAlign: "right" }}>Amount</th>
                    <th>Origin</th>
                  </tr>
                </thead>
                <tbody>
                  {sorted.length === 0 && (
                    <tr><td colSpan={9} style={{ padding: "40px 20px", textAlign: "center", color: "var(--text-tertiary)" }}>
                      No players match these filters.
                    </td></tr>
                  )}
                  {sorted.map(r => (
                    <tr key={r.id}>
                      <td>{r.id}</td>
                      <td style={{ textAlign: "center" }}>
                        <button className="rpt-user" onClick={openPlayer} title="Open the host Players screen — this build has no per-player deep link">
                          {r.username} <Icon name="chevron_right" size={12} className="chev" />
                        </button>
                      </td>
                      <td>{r.parent}</td>
                      <td>{r.skin}</td>
                      <td>{cvDate(r.regTs)}</td>
                      <td>{r.ip}</td>
                      {/* "No deposit" is a STATE, and it belongs in the column
                          that asks about the deposit — not repeated into Amount
                          and Origin, where it reads as a value. */}
                      <td>{r.converted ? cvDate(r.depositTs) : <span style={{ color: "var(--text-tertiary)" }}>No deposit</span>}</td>
                      <td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums" }}>
                        {r.converted ? `${cvAmt(r.amount)} ${r.currency}` : <span style={{ color: "var(--text-tertiary)" }}>—</span>}
                      </td>
                      <td>{r.origin || <span style={{ color: "var(--text-tertiary)" }}>—</span>}</td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          </div>
        </>
      )}
    </div>
  );
};

window.Conversion = Conversion;
