// Reporting (RP-01…RP-04), routing (RP-02) and the friction scorecard (RP-03).
//
// Three panes, three audiences:
//
//   · ReportingPane — legal admin and the auditor. Renders what the report
//     views answer; every figure is derived server-side, fresh, and nothing
//     here computes a number the database did not.
//   · RoutePane — the review desk's routing board: who holds each pending
//     ticket, who owns its category, and which tickets escalated because
//     nobody took them. Claiming and releasing are the two acts.
//   · FrictionPane — the vendor scorecard, readable by a requester at intake
//     ON PURPOSE. The cost column is an estimate and the ROW says so; this
//     pane renders that label rather than re-deciding it.
//
// The rule of every pane in this shell holds: a refusal renders as the
// database's own sentence, never as an empty list.

// ── The reports this desk offers ──────────────────────────────────────────
//
// WHY THIS TABLE EXISTS. Until 2026-08-24 this pane was one column, nine
// sections deep — 4,102px of content in a 559px viewport, measured on the
// Legal admin's screen. Nothing on it could be narrowed, nothing could be
// taken away as a file, and one figure at the top was computed over
// forty-eight rows the page had already fetched and never drew.
//
// A report is now CHOSEN rather than scrolled to, and everything a report
// needs to be one — what it answers, which read feeds it, what its search box
// looks at, what its export writes — is declared here in one place.
//
// PLAIN DATA, NO JSX, so `db/test/the-reporting-desk.test.mjs` can lift it and
// check every entry against the pane that renders it. The row renderers live
// in ROW below, keyed by the same `key`, because those DO build JSX and a
// table with a function in it cannot be lifted.
//
// `csv` IS THE EXPORT'S COLUMNS, and the guard checks every name against the
// columns its read actually returns. A file that leaves this building with an
// empty column named after something the view does not have is evidence of
// nothing, and nobody would find out from the screen.
// THE ORDER IS THE QUESTION A DESK GETS ASKED IN, and it is also why the
// shortest report opens first: where is the friction, where does the risk
// sit, what needs amending, how fast are we moving, who is carrying it, and
// is the referral workflow real. The page opens on eight rows rather than on
// forty-eight, which is what a summary screen is for.
const REPORTS = [
  {
    key: 'contested',
    noun: 'category',
    label: 'contested categories',
    title: 'Most contested categories',
    sub: 'Escalations, supplier pushback and conceded positions, by category. A high '
       + 'count says a standard position generates argument — what to do about the '
       + 'words is Legal’s.',
    read: 'reportContested',
    search: ['category_key', 'label'],
    facet: null,
    placeholder: 'category',
    stem: 'contested-categories',
    csv: ['category_key', 'label', 'contests', 'tickets_escalated',
          'tickets_supplier_paper', 'tickets_rejected', 'positions_opened',
          'positions_conceded', 'positions_escalated'],
    empty: 'No category has generated a contest yet.',
  },
  {
    key: 'exposure',
    noun: 'category',
    label: 'risk exposure',
    title: 'Risk exposure — live portfolio',
    sub: 'Executed, still-active agreements by category and the severity their run '
       + 'recorded — the record, not today’s library.',
    read: 'reportExposure',
    search: ['category_key', 'label', 'severity'],
    facet: 'severity',
    facetLabel: 'every severity',
    placeholder: 'category or severity',
    stem: 'risk-exposure',
    csv: ['category_key', 'label', 'severity', 'active_agreements'],
    empty: 'No executed agreement is currently active.',
  },
  {
    key: 'shift',
    noun: 'clause',
    label: 'policy shift',
    title: 'Policy shift — the amendment worklist',
    sub: 'Live agreements measured against the CURRENT library: superseded versions and '
       + 'missing always-include categories. The worklist a campaign starts from — never '
       + 'the amendments themselves.',
    read: 'reportPolicyShift',
    search: ['agreement_id', 'counterparty', 'category_key', 'clause_id', 'exposure'],
    facet: 'exposure',
    facetLabel: 'every kind',
    placeholder: 'deal, counterparty, clause or kind',
    stem: 'policy-shift',
    csv: ['agreement_id', 'counterparty', 'executed_on', 'category_key', 'clause_id',
          'executed_version', 'current_version', 'exposure'],
    empty: 'Every live agreement matches the current library.',
  },
  {
    key: 'cycle',
    noun: 'deal',
    label: 'cycle times',
    title: 'Cycle times',
    sub: 'How long each deal took — opened, first assembled, signed — and how many '
       + 'rounds it took to get there. The mean above is the mean of these rows.',
    read: 'reportVelocity',
    search: ['agreement_id', 'counterparty', 'status'],
    facet: 'status',
    facetLabel: 'every status',
    placeholder: 'deal, counterparty, or status',
    stem: 'cycle-times',
    csv: ['agreement_id', 'counterparty', 'status', 'opened_on', 'first_run_on',
          'executed_on', 'days_open_to_first_assembly', 'days_assembly_to_signature',
          'days_open_to_signature', 'negotiation_turns'],
    empty: 'No deal has been opened yet, so nothing has a cycle time.',
  },
  {
    key: 'reviewers',
    noun: 'reviewer',
    label: 'reviewer throughput',
    title: 'Reviewer throughput',
    sub: 'A workload signal for staffing, never a performance score — the mean hides '
       + 'the hard tickets.',
    read: 'reportReviewers',
    search: ['reviewer'],
    facet: null,
    placeholder: 'reviewer',
    stem: 'reviewer-throughput',
    csv: ['reviewer', 'decided', 'verified', 'rejected', 'mean_hours_to_decision'],
    empty: 'Nobody has decided a ticket yet.',
  },
  {
    // THE ONE REPORT WITH NO FILTER AND NO EXPORT, and the reason is written
    // down rather than left as an absence somebody re-discovers as a defect:
    // it is four measurements over four different reads, drawn by a shared
    // component (PanelMeasures) that three other screens use unchanged. A
    // search box over four lists at once would search none of them honestly,
    // and one export of four differently-shaped sets is four files pretending
    // to be one.
    key: 'panel',
    label: 'the expert panel',
    title: 'The expert panel',
    sub: 'Whether the referral workflow is real: how loaded each discipline is, how '
       + 'often the door out of it is used, where Legal decided against the advice, '
       + 'and what has stopped while it waits.',
    read: null,
    bespoke: 'four measurements over four reads, drawn by the shared PanelMeasures '
           + 'component — one search box over four lists would search none of them '
           + 'honestly',
  },
];

// How each report's rows are drawn. Keyed by REPORTS[].key — the guard fails
// if either side gains an entry the other does not have.
const ROW = {
  cycle: (r) => ({
    key: r.agreement_id,
    title: `${r.agreement_id} · ${r.counterparty}`,
    sub: `${r.days_open_to_first_assembly ?? '—'} days to first assembly · `
       + `${r.days_assembly_to_signature ?? '—'} assembly → signature · `
       + `${Number(r.negotiation_turns)} negotiation turn`
       + `${Number(r.negotiation_turns) === 1 ? '' : 's'}`,
    at: null,
    chips: (
      <span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
        <span className="chip chip-std">{r.status}</span>
        {r.days_open_to_signature == null
          // NOT A ZERO. A deal that has not been signed has no days-to-
          // signature, and printing one would be the same lie as a rack
          // printing a nought for an area nobody measured.
          ? <span className="caption">not signed yet</span>
          : <span className="chip chip-ok">{Number(r.days_open_to_signature)} days to signature</span>}
      </span>
    ),
  }),
  contested: (r) => ({
    key: r.category_key,
    title: `${r.label} — ${Number(r.contests)} contests`,
    sub: `${Number(r.tickets_escalated)} escalated · `
       + `${Number(r.tickets_supplier_paper)} supplier paper · `
       + `${Number(r.positions_conceded)} of ${Number(r.positions_opened)} positions conceded`,
    at: null,
  }),
  reviewers: (r) => ({
    key: r.reviewer,
    title: `${r.reviewer} — ${Number(r.decided)} decided`,
    sub: `${Number(r.verified)} verified · ${Number(r.rejected)} rejected · `
       + `${r.mean_hours_to_decision ?? '—'} mean hours to decision`,
    at: null,
  }),
  exposure: (r) => ({
    key: `${r.category_key}-${r.severity}`,
    title: `${r.label} · ${r.severity}`,
    sub: `${Number(r.active_agreements)} active agreement${Number(r.active_agreements) === 1 ? '' : 's'}`,
    at: null,
    chips: <span className={`chip ${r.severity === 'High' ? 'chip-pending' : 'chip-std'}`}>
      {r.severity}
    </span>,
  }),
  shift: (r) => ({
    key: `${r.agreement_id}-${r.clause_id}`,
    title: `${r.agreement_id} · ${r.counterparty} — ${r.clause_id}`,
    sub: r.exposure === 'outdated'
      ? `carries v${r.executed_version}; the library is at v${r.current_version}`
      : 'always-include category absent from the executed run',
    at: null,
    chips: <span className={`chip ${r.exposure === 'missing' ? 'chip-pending' : 'chip-std'}`}>
      {r.exposure}
    </span>,
  }),
};

// The picture over a report, where there is an honest one to draw. A band is a
// COUNT of the rows in front of you, so it narrows when the search box does —
// a chart that kept describing the whole set while the list under it showed
// three rows would be two answers to one question.
//
// Only reports whose rows carry a kind get one. `BandBar` draws nothing at all
// when the total is nought, which is its own rule and the right one: a
// zero-width bar reads as an answer.
const PICTURE = {
  exposure: (rows) => ({
    of: 'active agreements',
    bands: [
      { key: 'High', label: 'at high severity', tone: 'warn',
        n: rows.filter((r) => r.severity === 'High')
               .reduce((sum, r) => sum + Number(r.active_agreements), 0) },
      { key: 'Standard', label: 'at standard severity', tone: 'ok',
        n: rows.filter((r) => r.severity === 'Standard')
               .reduce((sum, r) => sum + Number(r.active_agreements), 0) },
      { key: 'Low', label: 'at low severity', tone: 'wait',
        n: rows.filter((r) => r.severity === 'Low')
               .reduce((sum, r) => sum + Number(r.active_agreements), 0) },
    ],
  }),
  shift: (rows) => ({
    of: 'clauses on live agreements',
    bands: [
      { key: 'outdated', label: 'carrying a superseded version', tone: 'wait',
        n: rows.filter((r) => r.exposure === 'outdated').length },
      { key: 'missing', label: 'missing an always-include category', tone: 'warn',
        n: rows.filter((r) => r.exposure === 'missing').length },
    ],
  }),
  // NO PICTURE FOR CONTESTED CATEGORIES, deliberately. This desk has exactly
  // three band inks — ok, warn, wait — and eight categories. Cycling three
  // colours over eight segments makes a bar whose colours mean nothing, on a
  // desk whose first rule is that nothing is said by colour alone. The eight
  // counts are in the list, in order, which is the honest drawing of them.
};

// One report: its head, its search box, its picture, its rows, its export.
// EVERY REPORT GETS THE SAME FOUR, which is the point of the table — the pane
// used to give each section a different amount of help depending on when it
// was written.
function Report({ report, pane, me }) {
  const filter = useListFilter(pane.rows, {
    // ONE COMPONENT DRAWS SIX REPORTS, so the list key is the report's own.
    // A single key here would put the velocity report's saved views on the
    // contested-categories report, where they narrow different columns.
    view: `reporting:${report.key}`,
    fields: report.search || [],
    facet: report.facet || undefined,
  });

  if (pane.status === 'loading') return <Loading />;
  if (pane.status === 'failed') return <LoadFailed reason={pane.reason} />;

  const rows = filter.shown;
  // THE EXPORT CARRIES ITS OWN SCOPE, and none of that arithmetic is this
  // pane's: `csvLabel` says how many of how many BEFORE the press, and
  // `downloadCsv` writes the same two numbers into the filename, because the
  // filename is the part that stays with the file once it is out of the
  // building (S319, and the auditor's export before it).
  const csv = () => downloadCsv({
    stem: report.stem,
    head: report.csv,
    rows,
    total: filter.total,
    by: me && me.person,
  });

  const picture = PICTURE[report.key] ? PICTURE[report.key](rows) : null;

  return (
    <div>
      <PanelHead
        title={report.title}
        sub={report.sub}
        right={
          <span style={{ display: 'inline-flex', gap: 10, alignItems: 'center' }}>
            <FilterCount filter={filter} />
            <button className="btn btn-sm" data-testid={`export-${report.key}`}
                    onClick={csv} disabled={rows.length === 0}>
              {csvLabel(rows.length, filter.total)}
            </button>
          </span>
        } />

      <ListFilter filter={filter} testid={`report-${report.key}`}
                  placeholder={report.placeholder}
                  facetLabel={report.facetLabel} />

      {picture && (
        <div className="mt-3" data-testid={`picture-${report.key}`}>
          <BandBar
            bands={picture.bands}
            total={picture.bands.reduce((sum, b) => sum + b.n, 0)}
            empty={`Nothing in view to divide — no ${picture.of} in the rows shown.`} />
        </div>
      )}

      <div className="mt-3">
        {/* A FILTER THAT MATCHES NOTHING IS NOT AN EMPTY REPORT, and it must
            not wear its clothes. Caught by driving it: typing a needle that
            matched none of the twenty-two policy-shift rows produced "Every
            live agreement matches the current library" — a sentence about the
            portfolio, printed because of something typed in a box. `NoMatch`
            is the shared idiom for exactly this and predates this pane. */}
        {rows.length === 0 && filter.total > 0
          ? <NoMatch kicker={report.label} noun={report.noun} />
          : <WaitingList
              order="given"
              items={rows.map(ROW[report.key])}
              empty={<Empty kicker={report.label} line={report.empty} />}
            />}
      </div>
    </div>
  );
}

function ReportingPane({ me }) {
  const queue = usePane(() => API.reportQueue());
  const velocity = usePane(() => API.reportVelocity());
  const contested = usePane(() => API.reportContested());
  const reviewers = usePane(() => API.reportReviewers());
  const exposure = usePane(() => API.reportExposure());
  const shift = usePane(() => API.reportPolicyShift());
  // ── The expert panel's own measurements (0092, 0093) ──────────────────
  // ADR-0013 promised three figures that say whether the referral workflow is
  // real; 0092 built them, 0093 added the fourth, and none had a screen. They
  // land HERE rather than on a pane of their own because the grant is exactly
  // this pane's audience — legal_admin and auditor.
  const panelLoad = usePane(() => API.panelLoad());
  const panelWaivers = usePane(() => API.panelWaivers());
  const panelAgainst = usePane(() => API.panelAgainstAdvice());
  const held = usePane(() => API.ticketsHeld());

  // WHICH REPORT IS ON THE DESK. One at a time, because nine sections in one
  // column is a document rather than a desk — and the figures above choose
  // between them, which is what makes a figure a way IN rather than a picture
  // of the work.
  const [showing, setShowing] = useState(REPORTS[0].key);

  const PANE = {
    cycle: velocity, contested, reviewers, exposure, shift,
  };

  if (queue.status === 'loading') return <Loading />;
  if (queue.status === 'failed') return <LoadFailed reason={queue.reason} />;

  const q = queue.rows[0] ?? {};
  const executed = velocity.rows.filter((r) => r.executed_on != null);
  const signedDays = executed
    .map((r) => Number(r.days_open_to_signature))
    .filter((n) => Number.isFinite(n));
  const meanCycle = signedDays.length
    ? Math.round(signedDays.reduce((a, b) => a + b, 0) / signedDays.length)
    : null;

  const report = REPORTS.find((r) => r.key === showing) || REPORTS[0];

  return (
    <div>
      <PaneHead
        title="Reporting"
        sub="Derived fresh from the record on every load — there is no report store to go stale." />

      {/* EVERY FIGURE LEADS TO WHAT IT COUNTED (S333), and one of them does so
          for the first time on 2026-08-24: the mean days to signature was
          drawn deliberately inert because "it counts a set this pane does not
          list", and the forty-eight rows it is the mean OF were being fetched
          by this pane already. They are a report now, so the figure is a
          control that reaches exactly its own set.

          THE TWO QUEUE FIGURES ARE UNCHANGED AND STILL IMPERFECT. They count
          PENDING TICKETS and they select the contested-categories report,
          which counts contests by category — related, and not the same set.
          Left as it was found rather than quietly re-pointed: the honest
          destinations are the review desk and the tickets list, which the
          auditor holding this same pane does not have. That is a decision
          about what an auditor may be offered, and it is recorded in the
          handoff rather than taken here. */}
      <TileStrip tiles={[
        { label: 'tickets pending', n: Number(q.pending ?? 0),
          to: () => setShowing('contested'), on: showing === 'contested',
          describe: 'show the contested categories report' },
        { label: 'pending over a week', n: Number(q.pending_over_week ?? 0),
          to: () => setShowing('contested'), on: showing === 'contested',
          describe: 'show the contested categories report' },
        {
          label: meanCycle === null
            ? 'mean days open → signature — nothing signed yet'
            : `mean days open → signature · ${signedDays.length} signed`,
          n: meanCycle === null ? '—' : meanCycle,
          to: meanCycle === null ? null : () => setShowing('cycle'),
          on: showing === 'cycle',
          describe: `show the ${velocity.rows.length} deals this mean was computed over`,
        },
        { label: 'held for an expert',
          n: held.status === 'loaded' ? held.rows.length : null,
          to: held.status === 'loaded' ? () => setShowing('panel') : null,
          on: showing === 'panel',
          describe: `show the ${held.rows.length} tickets held for an expert` },
      ]} />

      {/* THE DESK'S OWN INDEX. Every report in the table is offered — a report
          that exists and cannot be chosen is a section nobody can reach, which
          is precisely what the scroll had become at four thousand pixels. */}
      <div className="report-picker mt-5" data-testid="report-picker">
        <span className="section-label">reports</span>
        {REPORTS.map((r) => (
          <button
            key={r.key}
            data-testid={`report-${r.key}`}
            className={`report-btn${showing === r.key ? ' open' : ''}`}
            aria-pressed={showing === r.key}
            onClick={() => setShowing(r.key)}
          >
            {r.label}
          </button>
        ))}
      </div>

      <div className="mt-5" data-testid={`showing-${report.key}`}>
        {report.key === 'panel' ? (
          <div>
            <PanelHead title={report.title} sub={report.sub} />
            <PanelMeasures
              load={panelLoad}
              waivers={panelWaivers}
              against={panelAgainst}
              held={held} />
          </div>
        ) : (
          <Report report={report} pane={PANE[report.key]} me={me} />
        )}
      </div>
    </div>
  );
}

function RoutePane({ me }) {
  const pane = usePane(() => API.ticketRoute());
  // No agreement to search on: cw.ticket_route carries ticket_id,
  // category_key, severity, created_at, claimed_by, category_owner and
  // escalated, and nothing that names the contract. Raised in
  // LEGALS-QUEUE-DOES-NOT-NAME-THE-CONTRACT — a migration, not a screen fix.
  const filter = useListFilter(pane.rows, {
    view: 'reporting:queue',
    fields: ['ticket_id', 'category_key', 'severity', 'claimed_by', 'category_owner'],
    facet: 'severity',
  });
  const [acting, setActing] = useState(null);
  const [refused, setRefused] = useState(null);

  if (pane.status === 'loading') return <Loading />;
  if (pane.status === 'failed') return <LoadFailed reason={pane.reason} />;

  const act = async (fn, ticketId) => {
    setActing(ticketId); setRefused(null);
    const r = await fn({ ticket_id: ticketId });
    setActing(null);
    if (!r.ok) setRefused(r.reason);
    pane.reload();
  };

  return (
    <div>
      <PaneHead
        title="Routing"
        sub="Every pending ticket: who holds it, who owns its category, and what nobody took. The owner comes from the ladder at read time — reassign a ladder and every open ticket reroutes at once."
        right={<FilterCount filter={filter} />} />

      <ListFilter filter={filter} testid="routing"
                  placeholder="ticket, category, or who holds it"
                  facetLabel="every severity" />

      {refused && <Refused what="That claim was refused." reason={refused} />}

      <WaitingList
        order="given"
        items={filter.shown.map((r) => ({
          key: r.ticket_id,
          title: `Ticket ${r.ticket_id} · ${r.category_key} · ${r.severity}`,
          sub: r.claimed_by
            ? `claimed by ${r.claimed_by}`
            : `unclaimed · category owner ${r.category_owner ?? '— no ladder names one'}`,
          at: r.created_at,
          chips: (
            <span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
              {r.escalated && <span className="chip chip-pending">escalated to owner</span>}
              {/* HANDED OVER AS A REFERENCE, which is why the double-fire check
                  walked past these two: `act(API.claimTicket, id)` never
                  writes `API.claimTicket(` anywhere, so a scan looking for the
                  call found nothing. Both were firing twice, proven by
                  pressing them. */}
              {r.claimed_by === me.person
                ? <ActButton className="btn btn-sm" disabled={acting === r.ticket_id}
                    onClick={() => act(API.releaseClaim, r.ticket_id)}>release</ActButton>
                : !r.claimed_by &&
                  <ActButton className="btn btn-sm" disabled={acting === r.ticket_id}
                    onClick={() => act(API.claimTicket, r.ticket_id)}>claim</ActButton>}
            </span>
          ),
        }))}
        empty={<Empty kicker="routing"
          line="Nothing is pending — an empty queue, not a failed read." />}
      />

      <p className="caption mt-3">
        Claiming says <em>I am looking at this</em> — it is coordination, not
        adjudication, and a colleague can release an absent colleague's claim.
        A ticket unclaimed past the escalation window appears on its category
        owner's own waiting list until somebody takes it.
      </p>
    </div>
  );
}

function FrictionPane() {
  const pane = usePane(() => API.vendorFriction());
  if (pane.status === 'loading') return <Loading />;
  if (pane.status === 'failed') return <LoadFailed reason={pane.reason} />;

  return (
    <div>
      <PaneHead
        title="Vendor friction"
        sub="What negotiating with each counterparty has historically cost — checked before committing to one, not discovered after." />

      <WaitingList
        order="given"
        items={pane.rows.map((r) => ({
          key: r.counterparty,
          title: `${r.counterparty} — ${r.friction_per_deal} friction per deal`,
          sub: `${Number(r.deals)} deals (${Number(r.executed)} signed) · `
             + `${Number(r.rounds_received)} redline rounds · `
             + `${Number(r.positions_escalated)} escalations · `
             + `${Number(r.supplier_paper_tickets)} supplier-paper tickets`,
          at: null,
          chips: (
            <span className="chip chip-std" title={r.cost_is}>
              ≈ ${Number(r.estimated_handling_cost_usd).toLocaleString()} estimated
            </span>
          ),
        }))}
        empty={<Empty kicker="vendor friction"
          line="No counterparty history yet — the first deal writes the first row." />}
      />

      <p className="caption mt-3">
        The counts are measured from the record. <strong>The dollar figure is an
        estimate</strong> — it multiplies those counts by hours-and-rate
        assumptions the Administrator maintains as visible settings, and the row
        itself says so. Names group verbatim: a misspelled vendor is two rows,
        which is the incentive to type names consistently.
      </p>
    </div>
  );
}
