// NO PROD JSON API (bucket B — feed is a DataTables render payload or
//   {"html":...}, not data) — mock until backend (see PROD_API_INVENTORY.md)
// Represents: GET /payments/to-confirm/ · AdminPaymentsController::show('to-confirm') — see docs/ISYSTEM_REFERENCE.md §Batch 10.2
/* PayBO → To Confirm. NEW SCREEN (Batch 10).

   THE PROTOTYPE ALREADY HAD SOMETHING CALLED "To Confirm" AND IT IS NOT THIS.
   Transactions.jsx carries a `to_confirm` status tab that filters the
   transaction list. isystem's To Confirm is its own PayBO section over its own
   table, `payment_cascade_holds` — a WITHDRAWAL HELD MID-CASCADE, not a
   transaction with a status. Different entity, different columns, different
   actions. The status tab stays (it is a real thing too); this is the section.

   Why it was invisible to the coverage check, and the third distinct blind
   spot found in this audit:
     · controller-level  -> UsersController "covered" hid /myaccount        (Batch 9)
     · action-level      -> one action, ONE view, TEN screens               (here)
   `AdminPaymentsController::show($section)` returns `admin.payments.index`
   for every one of dashboard | transactions | deposits | withdrawals |
   methods | players | reports | activity | settings | to-confirm. An
   action-level check sees one screen; an operator sees ten. The manifest now
   reads the route's own where() constraint, so a section added upstream shows
   up untriaged rather than hiding behind a sibling.

   TWO KINDS OF HOLD, and the row reads differently for each:
     withdrawal          a PSP in the cascade failed; the next one is queued.
                         Buttons: Confirm / Skip.
     withdrawal_review   the amount is at or above the brand's review
                         threshold and NO PSP HAS BEEN CALLED YET.
                         Buttons: Approve / Skip.

   RESERVED FUNDS CHANGE WHAT SKIP MEANS. isystem answered "are these funds
   reserved" with a non-empty 64-character `reserved_token` string it matched
   against withdrawal_requests.token; here the hold carries a real foreign key
   and "reserved" means the withdrawal has a debit ledger entry. On a reserved
   hold, Skip refunds and the
   button turns danger-red; on an unreserved one it just advances past the
   failed PSP. Same button, two very different consequences — modelled, and
   spelled out in the confirm dialog.

   Writes are a FORM POST with a redirect and a session flash, not AJAX:
   POST /payments/to-confirm/action, validated hold_id:required|integer,
   action:required|in:confirm,skip. Ownership is re-checked on the write, and a
   hold outside the operator's skins returns the SAME message as a missing id
   ("Hold not found or not accessible.") so the endpoint cannot be used to
   probe another brand's hold ids. Kept.

   Confirm calls a PSP and Skip can refund a reservation. Both move money, so
   both are DISABLED behind NoBackend per docs/REAL_VS_MOCK.md §4. Everything
   else — the two kinds, the reserved distinction, the columns, the ownership
   rule — is real.

   <!-- SUGGESTION: toConfirmAction catches any Throwable and flashes `Action failed: ` + the raw exception message straight to the operator. Map PSP errors to operator-facing text; today an internal exception string reaches the UI. -->

   Deliberately NOT added (isystem has none of these): filters, search,
   sorting, pagination, export, bulk confirm/skip, or a history of decided
   holds. The section lists pending rows and nothing else. */

/* Nine holds used to be generated here, complete with five plausible PSP error
   strings ("cURL error 28: Operation timed out after 30001 milliseconds"),
   invented player usernames and reserved-fund tokens. Every row on this queue
   is a withdrawal waiting on a human decision — the worst possible place for a
   fabricated one. They are `payment_cascade_holds` rows now. */
const ptcHoldRow = (h) => {
  const req = Array.isArray(h.request) ? h.request[0] : h.request;
  return {
    id: Number(h.id),
    /* A hold with a threshold and no failed provider is the review case: the
       amount is at or above the brand's review threshold and no PSP has been
       called. Anything else is a cascade retry. */
    kind: h.threshold_amount != null && !h.failedProvider ? "withdrawal_review" : "withdrawal",
    created_at: h.created_at ? String(h.created_at).replace("T", " ").slice(0, 16) : "—",
    user_id: h.user_id == null ? null : Number(h.user_id),
    username: h.user ? h.user.username : "—",
    skin_id: h.skin_id == null ? null : Number(h.skin_id),
    brand: h.skin ? h.skin.name : "—",
    amount: Number(h.amount) || 0,
    currency: h.currency || (h.skin ? h.skin.currency : "") || "",
    /* "reserved" = the funds already left the player. One boolean derived from
       the withdrawal's debit ledger entry, not a token string compared by hand. */
    reserved: !!(req && req.debit_ledger_entry_id),
    withdrawalRequestId: h.withdrawal_request_id == null ? null : Number(h.withdrawal_request_id),
    threshold_amount: h.threshold_amount == null ? null : Number(h.threshold_amount),
    failed_provider_code: h.failedProvider ? h.failedProvider.code : null,
    next_provider_code: h.nextProvider ? h.nextProvider.code : null,
    outcome: h.outcome || null,
    last_error: h.reason || "",
    status: h.status,
  };
};

const ptcMoney = (v, c) =>
  `${Number(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${c}`;

/* rtrim(rtrim((string) $threshold, '0'), '.') — 2000.00 renders as "2", not
   "2000.00". Reproduced because it is what an operator sees. */
const ptcThreshold = (v) => String(v).replace(/0+$/, "").replace(/\.$/, "") || String(v);

const PtcDialog = ({ hold, action, onClose }) => {
  const isReview = hold.kind === "withdrawal_review";
  const isReserved = !!hold.reserved;
  const skipRefunds = action === "skip" && isReserved;
  return (
    <div className="bp-modal-scrim" onClick={onClose}>
      <div className="cg-modal" onClick={(e) => e.stopPropagation()}>
        <div className="cg-modal-title">
          {action === "confirm" ? (isReview ? "Approve" : "Confirm") : "Skip"} hold #{hold.id}
        </div>
        <div className="cg-modal-body">
          <div className={`ptc-consequence${skipRefunds ? " ptc-consequence--danger" : ""}`}>
            <Icon name={skipRefunds ? "alert" : "info"} size={14} />
            <div>
              {action === "confirm" ? (
                isReview
                  ? <>Approving releases the withdrawal into the cascade. <b>No PSP has been called yet</b> — this is the first call.</>
                  : <>Confirming calls <b>{hold.next_provider_code}</b>, the next PSP after <b>{hold.failed_provider_code}</b> failed. If that call also returns <code>ERROR</code> the hold goes to <code>failed</code>; several paths reset it to <code>pending</code> so it stays in this queue.</>
              ) : (
                skipRefunds
                  ? <><b>These funds are already debited</b> (<code>withdrawal_requests.debit_ledger_entry_id</code> is set on request {hold.withdrawalRequestId}). Skipping refunds them — the money goes back to the player and the withdrawal does not happen.</>
                  : <>Nothing is reserved on this hold, so skipping just advances past the failed PSP. No money moves.</>
              )}
            </div>
          </div>
          <dl className="ptc-dl">
            <div><dt>Player</dt><dd>{hold.username} <span className="ptc-dim">#{hold.user_id}</span></dd></div>
            <div><dt>Brand</dt><dd>{hold.brand}</dd></div>
            <div><dt>Amount</dt><dd>{ptcMoney(hold.amount, hold.currency)}</dd></div>
            <div><dt>Kind</dt><dd><code>{hold.kind}</code></dd></div>
            <div><dt>Entry point</dt><dd><code>{hold.entry_point}</code></dd></div>
            <div><dt>Flow</dt><dd><code>{hold.flow_uuid}</code></dd></div>
          </dl>
        </div>
        <div className="cg-modal-foot">
          <button className="hrs-btn" onClick={onClose}>Cancel</button>
          <NoBackend
            need="POST /payments/to-confirm/action"
            what={action === "confirm" ? "Confirm the hold" : "Skip the hold"}
            className={`hrs-btn ${skipRefunds ? "ptc-btn--danger" : "hrs-btn--filters"}`}>
            {action === "confirm" ? (isReview ? "Approve" : "Confirm") : (skipRefunds ? "Skip and refund" : "Skip")}
          </NoBackend>
        </div>
      </div>
    </div>
  );
};

const PayboToConfirm = () => {
  const feed = useHrsFetch(() => window.sb.list("cascadeHolds", { limit: 500 }), []);
  const holds = React.useMemo(() => (feed.data || []).map(ptcHoldRow), [feed.data]);
  const [dialog, setDialog] = React.useState(null);

  const cols = [
    { key: "created_at", label: "Created", render: (h) => <span className="ptc-mono">{h.created_at}</span> },
    { key: "why", label: "Why", render: (h) => h.kind === "withdrawal_review"
      ? <span className="ptc-why ptc-why--review" title={`Held because the amount is at or above this brand's review threshold of ${ptcThreshold(h.threshold_amount)}. No PSP has been called.`}>
          Review<div className="ptc-why__sub">&ge; {ptcThreshold(h.threshold_amount)}</div>
        </span>
      : <span className="ptc-why" title={`${h.failed_provider_code} failed; ${h.next_provider_code} is queued next.`}>
          Cascade<div className="ptc-why__sub">{h.failed_provider_code} &rarr; {h.next_provider_code}</div>
        </span> },
    { key: "username", label: "Player", render: (h) => <>{h.username} <span className="ptc-dim">#{h.user_id}</span></> },
    { key: "brand", label: "Brand" },
    { key: "amount", label: "Amount", align: "right", render: (h) => (
      <span className="ptc-amt">
        {ptcMoney(h.amount, h.currency)}
        {h.reserved && <span className="ptc-reserved" title="Funds already debited — withdrawal_requests.debit_ledger_entry_id is set. Skipping this hold refunds them.">reserved</span>}
      </span>) },
    { key: "failed_provider_code", label: "Failed PSP", render: (h) => <span className="ptc-mono">{h.failed_provider_code || "—"}</span> },
    { key: "next_provider_code", label: "Next PSP", render: (h) => <span className="ptc-mono"><strong>{h.next_provider_code || "—"}</strong></span> },
    { key: "outcome", label: "Outcome", render: (h) => h.outcome ? <span className="chip chip--neutral">{h.outcome}</span> : "—" },
    { key: "last_error", label: "Last error", render: (h) => h.last_error
      ? <span className="ptc-err" title={h.last_error}>{h.last_error.length > 60 ? h.last_error.slice(0, 60) + "…" : h.last_error}</span>
      : "—" },
    { key: "actions", label: "Actions", render: (h) => (
      <div className="ptc-actions">
        <button className="hrs-btn hrs-btn--filters hrs-btn--sm" onClick={() => setDialog({ hold: h, action: "confirm" })}>
          {h.kind === "withdrawal_review" ? "Approve" : "Confirm"}
        </button>
        <button className={`hrs-btn hrs-btn--sm${h.reserved ? " ptc-btn--danger" : ""}`}
          onClick={() => setDialog({ hold: h, action: "skip" })}>
          Skip
        </button>
      </div>) },
  ];

  const reviewCount = holds.filter(h => h.kind === "withdrawal_review").length;
  const reservedCount = holds.filter(h => h.reserved).length;

  return (
    <HrsShell
      title="To Confirm"
      subtitle="Withdrawals held mid-cascade, waiting on an operator."
      gate={["isPaymentsAdmin()"]}
      gateNote={<> Every PayBO section aborts <b>403</b> rather than degrading, and the write endpoint re-checks skin ownership: a hold outside your skins returns the same message as a hold that does not exist, so the endpoint cannot be used to probe other brands' ids.</>}
      explainer={{
        title: "What this queue is, in plain English",
        bullets: [
          "These are not transactions with a status. They are rows in payment_cascade_holds — a withdrawal that stopped part-way and needs a decision.",
          "Two reasons a withdrawal lands here: a PSP failed and the next one is queued (Confirm), or the amount is at or above the brand's review threshold and no PSP has been called yet (Approve).",
          "Where the amount says “reserved”, Skip is a REFUND — the money goes back to the player. Where it does not, Skip just advances past the failed PSP.",
          "The Transactions page also has a “To confirm” tab. That is a different thing: a status filter over transactions, not this queue.",
        ],
      }}
    >
      <HrsKpis
        items={[
          { label: "Pending holds", value: String(holds.length) },
          { label: "Awaiting review", value: String(reviewCount), sub: "amount ≥ threshold, no PSP called" },
          { label: "Cascade retries", value: String(holds.length - reviewCount), sub: "a PSP failed, next is queued" },
          { label: "With reserved funds", value: String(reservedCount), sub: "Skip refunds these" },
        ]}
        note="Only status = pending rows are listed. isystem shows no counters here; these summarise what is on screen."
      />

      <HrsSection title="Pending" sub="No filters, no search, no sorting, no pagination, no export — the real section has none of them.">
        {/* An empty payout-decision queue and a failed read are opposite
            facts, and this screen has no filters to blame either on. */}
        <HrsAsync state={feed} skeletonRows={6} skeletonCols={10}
                  empty="Nothing is waiting on a decision.">
          {() => <HrsTable columns={cols} rows={holds} rowKey="id" empty="Nothing is waiting on a decision." />}
        </HrsAsync>
      </HrsSection>

      {dialog && <PtcDialog hold={dialog.hold} action={dialog.action} onClose={() => setDialog(null)} />}
    </HrsShell>
  );
};

window.PayboToConfirm = PayboToConfirm;
