// Portfolio questions on our own paper — the certain half of NC-16.
//
// WHAT WAS MISSING. Migration `0049` built three views on 2026-07-30 —
// `cw.portfolio_run`, `cw.portfolio_position`, `cw.portfolio_unresolved` —
// with a stated representation rule, a scoping fence written into each view's
// own WHERE clause, a grant to five of the six roles and a test suite
// (`db/test/portfolio.test.mjs`). `reads.py` registered both reads. And no
// screen ever called either: they were the LAST TWO entries in
// `SERVED_TO_NOBODY`, the ledger inside
// `db/test/a-built-thing-has-a-way-in.test.mjs`, and PRODUCT.md §3b item 4
// named them as "the last two reads the doorway serves that api.jsx cannot
// call". Twenty-five days built and unreachable.
//
// So the company could not answer, from the screen, either of the two
// questions this product is for after the paper is signed:
//
//   "WHICH OF OUR CONTRACTS CARRY THIS CLAUSE?" — the question every recall
//   starts with. A clause version is found to be wrong, or the law under it
//   moves, and the first thing anybody needs is the count of agreements
//   standing on it.
//
//   "WHERE COULD WE CHOOSE NOTHING AT ALL?" — the library's own holes, seen
//   from the paper rather than from the catalogue. `cw.portfolio_unresolved`
//   counts the decisions where the forge could select no clause, which is a
//   different fact from a category having no clauses: it means a risk was
//   RAISED on a real deal and the library had no answer to it.
//
// ── THE THREE RULES THIS SCREEN MUST NOT BREAK ────────────────────────────
//
//   1 · UNRESOLVED IS ITS OWN FIGURE, NEVER FOLDED INTO ZERO. `0049`'s own
//       comment says why: "an unresolved decision folded into an absent one
//       reads as 'no exposure' when it means 'unmeasured exposure'". They are
//       two panels here, never one table with a blank clause column, and the
//       unresolved panel is drawn even when the positions panel is empty.
//
//   2 · THE RUNG IS PINNED, NOT LOOKED UP. `rung_at_run` comes from the run's
//       own snapshot, so a historic answer reproduces after a supersession
//       instead of being recomputed against today's library. The screen says
//       "at the time" in words, because a rung drawn with no tense reads as a
//       claim about the ladder as it stands now — which it is not, and which
//       would be wrong for exactly the clauses somebody is investigating.
//
//   3 · AN UNATTACHED RUN IS COUNTED AND NEVER DROPPED. A run with no
//       agreement is a build nobody signed; it cannot be counted as an
//       agreement and must not vanish, so `0049` gives it its own column and
//       this screen gives it its own words. `agreements` and `decisions` are
//       DIFFERENT NUMBERS and the row shows both — one agreement can carry a
//       clause at two severities.
//
// ── AND NO FIGURE HERE IS A RATE ──────────────────────────────────────────
//
// Every proportion is written `N of M` over its own measured total. The same
// rule `panel-measures.jsx` states at length: a percentage reads the same at
// four agreements as at four hundred.
//
// ── WHAT IS AND IS NOT PRESSABLE ──────────────────────────────────────────
//
// The drill rule (`home.jsx`'s header, and PRODUCT.md §4 item 7): a figure
// either reaches the set it counted or is not drawn as a control. `0049`
// serves COUNTS and no endpoint anywhere returns the agreements behind one —
// `cw.portfolio_position` aggregates and there is no per-clause agreement
// list to fetch. So every figure on this screen is deliberately inert, and
// the panel says which question it cannot answer rather than offering a
// control that would do nothing. Wiring that drill-down needs a read that
// does not exist; it is named in the handoff rather than faked here.

const { useMemo: usePortfolioMemo } = React;

// The severities this product uses, in the order it always draws them, with
// the ink each already wears. Written once here rather than at the three call
// sites below — a vocabulary written out by hand drifts (S325).
const PORTFOLIO_SEVERITY_INK = {
  high: 'bad',
  medium: 'warn',
  low: 'ok',
};

function severityChip(severity) {
  const tone = PORTFOLIO_SEVERITY_INK[String(severity || '').toLowerCase()];
  return (
    <span className={`chip chip-std${tone ? ` chip-${tone}` : ''}`}>
      {severity || 'unstated'}
    </span>
  );
}

// ── The five sentences at the top ─────────────────────────────────────────
//
// COUNTED OFF THE ROWS THIS ROLE WAS ANSWERED, never off a paragraph and
// never off a second read. Both panels and this band therefore cannot
// disagree with each other: if the fence returned a requester three rows,
// every number here is over those three.
//
// `agreements` CANNOT BE SUMMED ACROSS ROWS. One agreement carrying four
// clauses appears in four rows' `agreements` counts, so adding them would
// report four agreements where there is one. The honest summary is the
// MAXIMUM any single row saw, labelled as what it is — "at least" — because
// the aggregate views do not carry the distinct agreement set and the screen
// must not invent it. This is the trap that a dashboard usually walks into.
function portfolioSummary(positions, unresolved) {
  const clauseKeys = new Set(
    positions.map((r) => `${r.clause_id}@${r.version}`));
  const categories = new Set([
    ...positions.map((r) => r.category_key),
    ...unresolved.map((r) => r.category_key),
  ]);
  return {
    clauses: clauseKeys.size,
    categories: categories.size,
    decisions: positions.reduce((n, r) => n + Number(r.decisions || 0), 0),
    unresolved: unresolved.reduce((n, r) => n + Number(r.unresolved || 0), 0),
    // "At least this many agreements are represented" — see above.
    atLeastAgreements: positions.reduce(
      (n, r) => Math.max(n, Number(r.agreements || 0)), 0),
    unattached: positions.reduce(
      (n, r) => Math.max(n, Number(r.unattached_runs || 0)), 0),
  };
}

// ── 1 · What our paper actually says ──────────────────────────────────────
function PortfolioPositions({ rows }) {
  const filter = useListFilter(rows, {
    view: 'portfolio:positions',
    fields: ['category', 'category_key', 'clause_id', 'title', 'severity'],
    // THE FACET IS A FIELD NAME and the hook derives the options from the rows
    // themselves, so a library that adds a fourth severity needs no edit here.
    facet: 'severity',
  });

  return (
    <div>
      <PanelHead
        title="Which of our contracts carry each clause"
        sub="Counted from the recorded decisions of every agreement's latest build, plus every build that never became an agreement. The rung is the one the clause occupied when the deal was assembled — not the one it occupies today."
        right={<FilterCount filter={filter} />} />
      <ListFilter filter={filter} testid="portfolio-positions"
                  facetLabel="severity"
                  placeholder="search by clause, category or title" />

      {rows.length === 0 ? (
        <Empty
          kicker="no positions"
          line="No build has recorded a clause decision you can see."
          sub="This is a fact about what has been built and about what your role
               may count, not about the library. A requester's numbers are
               computed over runs they created or deals they own; Legal's and
               the Auditor's over every one." />
      ) : filter.shown.length === 0 ? (
        <NoMatch kicker="no match" noun="positions" />
      ) : (
        <div className="panel">
          {filter.shown.map((r) => (
            <div className="waiting-row measure-row"
                 key={`${r.category_key}/${r.severity}/${r.clause_id}/${r.version}`}>
              <div className="min-w-0" style={{ flex: 1 }}>
                <div className="flex items-center gap-2 flex-wrap">
                  <span className="text-[13px]" style={{ color: 'var(--ink)' }}>
                    {r.title || r.clause_id}
                  </span>
                  <span className="caption font-mono">
                    {r.clause_id} v{r.version}
                  </span>
                  {severityChip(r.severity)}
                  {/* THE FLOOR IS THE FACT THAT MATTERS on a recall. `0049`
                      pins `was_floor` from the run's snapshot: a clause that
                      was the floor at build time had nothing below it to fall
                      back to, so a contract standing on it had no retreat.
                      Drawn only when true — a "not the floor" chip on every
                      other row would be noise. */}
                  {r.was_floor === true && (
                    <span className="chip chip-warn" title="At build time this position was the floor of its ladder — there was nothing below it to fall back to.">
                      was the floor
                    </span>
                  )}
                </div>
                <div className="caption mt-1">
                  {r.category} <span className="font-mono">{r.category_key}</span>
                  {' · '}
                  {/* AN ABSENCE WITH ITS REASON, never a nought. A run whose
                      snapshot has no rung for this clause answers null, which
                      happens when the clause was selected outside a ladder.
                      Drawing that as rung 0 would invent a position. */}
                  {r.rung_at_run === null || r.rung_at_run === undefined
                    ? <em>no rung recorded on the build's own snapshot</em>
                    : <>rung {r.rung_at_run} at the time</>}
                </div>
              </div>
              <div className="measure-aside">
                <div className="text-[13px]" style={{ color: 'var(--ink)' }}>
                  {Number(r.agreements)}{' '}
                  {Number(r.agreements) === 1 ? 'agreement' : 'agreements'}
                </div>
                <div className="caption">
                  {/* DECISIONS AND AGREEMENTS ARE DIFFERENT NUMBERS and both
                      are shown, because one agreement can record this clause
                      at more than one severity. Collapsing them would make a
                      recall list look shorter than it is. */}
                  {Number(r.decisions)}{' '}
                  {Number(r.decisions) === 1 ? 'decision' : 'decisions'}
                </div>
                {Number(r.unattached_runs) > 0 && (
                  <div className="caption" title="Builds that never became an agreement. Counted separately so a draft can never be mistaken for signed paper.">
                    + {Number(r.unattached_runs)} unsigned{' '}
                    {Number(r.unattached_runs) === 1 ? 'build' : 'builds'}
                  </div>
                )}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ── 2 · Where nothing could be chosen ─────────────────────────────────────
//
// ITS OWN PANEL, ALWAYS DRAWN. An empty one here is a real and good answer —
// "every risk raised on your deals had an approved position for it" — and it
// is a different sentence from the positions panel being empty. Folding this
// into the table above is the exact mistake `0049` wrote its comment against.
function PortfolioUnresolved({ rows, measuredOver }) {
  const filter = useListFilter(rows, {
    view: 'portfolio:unresolved',
    fields: ['category', 'category_key', 'severity'],
  });

  return (
    <div>
      <PanelHead
        title="Where the library had no answer"
        sub="Decisions on real builds where nothing could be selected. This is not the same as a category with no clauses: a risk was raised on a deal and the approved language had nothing to meet it."
        right={<FilterCount filter={filter} />} />

      {rows.length === 0 ? (
        <Empty
          kicker="nothing unresolved"
          line="Every risk raised on the builds you can see was met by approved language."
          sub={`Measured over ${measuredOver} recorded decision${measuredOver === 1 ? '' : 's'}. An empty panel here is an answer, not an absent read — a category with no clauses at all would not appear, because nothing has asked it for one yet.`} />
      ) : (
        <>
          <ListFilter filter={filter} testid="portfolio-unresolved"
                      placeholder="search by category" minRows={4} />
          {filter.shown.length === 0 ? (
            <NoMatch kicker="no match" noun="categories" />
          ) : (
            <div className="panel">
              {filter.shown.map((r) => (
                <div className="waiting-row measure-row"
                     key={`${r.category_key}/${r.severity}`}>
                  <div className="min-w-0" style={{ flex: 1 }}>
                    <div className="flex items-center gap-2 flex-wrap">
                      <span className="text-[13px]" style={{ color: 'var(--ink)' }}>
                        {r.category}
                      </span>
                      <span className="caption font-mono">{r.category_key}</span>
                      {severityChip(r.severity)}
                    </div>
                    <div className="caption mt-1">
                      The forge could select no approved clause for this
                      category at this severity.
                    </div>
                  </div>
                  <div className="measure-aside">
                    <div className="text-[13px]" style={{ color: 'var(--bad)' }}>
                      {Number(r.unresolved)}{' '}
                      {Number(r.unresolved) === 1 ? 'decision' : 'decisions'}
                    </div>
                    <div className="caption">
                      across {Number(r.agreements)}{' '}
                      {Number(r.agreements) === 1 ? 'agreement' : 'agreements'}
                    </div>
                    {Number(r.unattached_runs) > 0 && (
                      <div className="caption" title="Builds that never became an agreement.">
                        + {Number(r.unattached_runs)} unsigned{' '}
                        {Number(r.unattached_runs) === 1 ? 'build' : 'builds'}
                      </div>
                    )}
                  </div>
                </div>
              ))}
            </div>
          )}
        </>
      )}
    </div>
  );
}

// ── The pane ──────────────────────────────────────────────────────────────
//
// TWO READS, ONE PANE, AND A SINGLE FAILURE STORY. Both are fetched together
// so the summary band, the positions and the unresolved panel are all
// computed over one answer from one moment. Fetching them separately would
// let the band disagree with the table it sits above after a slow second
// request — the kind of contradiction a reader has no way to notice.
function PortfolioPane({ me }) {
  const pane = usePane(async () => {
    const [positions, unresolved] = await Promise.all([
      API.portfolioPositions(),
      API.portfolioUnresolved(),
    ]);
    if (!positions.ok) return positions;
    if (!unresolved.ok) return unresolved;
    return { ok: true, rows: positions.rows,
             body: { unresolved: unresolved.rows } };
  });

  const positions = pane.rows || [];
  const unresolved = (pane.body && pane.body.unresolved) || [];
  const sum = usePortfolioMemo(
    () => portfolioSummary(positions, unresolved),
    [positions, unresolved]);

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

  // WHAT THIS ROLE IS BEING SHOWN, said on the screen rather than assumed.
  // `cw.portfolio_run` fences itself: a requester counts over runs they
  // created or deals they own, everybody else over every run. A requester who
  // read "12 agreements carry this clause" as a company total would draw
  // exactly the wrong conclusion from a recall, so the scope is written into
  // the header rather than left to be inferred from a small number.
  const scoped = me && me.role === 'requester';

  return (
    <div>
      <PaneHead
        title="Our own paper"
        kicker="portfolio"
        sub={scoped
          ? 'How many of YOUR agreements stand on each approved clause, and where the library had no answer. Counted from the record — no model reads any of this.'
          : 'How many agreements stand on each approved clause, and where the library had no answer. Counted from the record — no model reads any of this.'} />

      {/* THE BAND, AND EVERY BOX IN IT INERT ON PURPOSE. No `to` is passed:
          `0049` serves aggregates and nothing serves the agreements behind
          one, so a pressable figure here would be a control that goes
          nowhere. The paragraph under the band says so in words rather than
          leaving a reader to discover it by clicking. */}
      <TileStrip tiles={[
        { label: 'clause versions in force', n: sum.clauses },
        { label: 'categories touched', n: sum.categories },
        { label: 'recorded decisions', n: sum.decisions },
        // NO INK ON THIS ONE, though it is the figure that matters most.
        // `TileStrip` draws a tile's number in the strip's own weight and
        // takes no style — and tinting it here would mean reaching past the
        // shared component, which is how five copies become six. The panel
        // below carries the emphasis, in its rows and in its words.
        { label: 'unresolved decisions', n: sum.unresolved },
      ]} />

      <p className="caption mt-3 mb-5">
        Counted over each agreement's <strong>latest</strong> build, so a
        renegotiated deal is counted once and not twice, plus every build that
        never became an agreement — those are counted on their own and never
        as signed paper.{' '}
        {sum.atLeastAgreements > 0 && (
          <>At least {sum.atLeastAgreements}{' '}
          {sum.atLeastAgreements === 1 ? 'agreement is' : 'agreements are'}{' '}
          represented; the exact number of distinct agreements is not something
          these counts carry, so it is not stated.{' '}</>
        )}
        {scoped && (
          <><strong>These are your deals only</strong> — the numbers are
          computed over builds you recorded or agreements you own, never over
          the company's whole book.{' '}</>
        )}
        No figure above can be pressed: the record holds these as counts, and
        the list of agreements behind any one of them is not served by
        anything yet.
      </p>

      <div className="mb-6">
        <PortfolioPositions rows={positions} />
      </div>

      <PortfolioUnresolved rows={unresolved} measuredOver={sum.decisions} />

      <p className="caption mt-5">
        This is the <strong>certain</strong> half of the portfolio question —
        arithmetic over decisions the record already holds, with no model
        involved and nothing inferred from prose. The counterparty's own paper
        is a separate and unbuilt question; what a supplier's other contracts
        say close to the same thing is answered on{' '}
        <strong>suppliers</strong>, by meaning, and labelled there as the
        estimate it is.
      </p>
    </div>
  );
}
