/* global React */
// =========================================================
// Objective Appendix — a DEDICATED PAGE listing the full,
// unfiltered finding set (window.APPENDIX_FINDINGS), with a
// sticky filter + search header.
//   • Type filter: Sentiment · Line efficiency · Consumer behavior · Audience
//   • Search across finding types, products, segments, attributes, text.
// CX curation: each finding carries an "Add to an argument" control
// (cascade: Objective → Argument). Assigning it makes the finding
// appear (curatable) inside that argument card. In-session only.
// =========================================================

const APX_ARGKEY = "objective-3::appendix";

// ---- category taxonomy ----
const APX_CATS = [
  { key: "all", label: "All findings" },
  { key: "sentiment", label: "Sentiment rankings" },
  { key: "le", label: "Line efficiency" },
  { key: "behavior", label: "Strong drivers" },
  { key: "audience", label: "Audience & data notes" },
];
function catOf(f) {
  if (f.kind === "sentiment") return "sentiment";
  if (f.kind === "line_efficiency") return "le";
  if (f.kind === "advdet") return "behavior";
  return "audience"; // stat + note
}

// =========================================================
// FLEXIBLE SEARCH ENGINE
// Fuzzy (typo-tolerant) + synonym-expanded + weighted ranking,
// and it REPORTS why each finding matched (field + matched text)
// so the UI can show relevance + highlighted "matched on" chips.
// =========================================================

// synonym / concept expansion — each query word also matches these terms.
// (multi-word entries are matched as a phrase against the field text)
const APX_SYN = {
  price: ["cost", "costs", "expensive", "affordable", "affordability", "value", "pricing", "priced", "worth"],
  cost: ["price", "expensive", "value", "pricing"],
  value: ["price", "worth", "affordable"],
  quality: ["durable", "durability", "construction", "craftsmanship", "well made", "materials"],
  fit: ["sizing", "size", "sizes", "true to size"],
  size: ["fit", "sizing", "sizes"],
  comfort: ["comfortable", "comfy", "feel", "cushion"],
  style: ["styling", "design", "look", "aesthetic", "fashionable"],
  color: ["colour", "colors", "colours", "shade", "shades", "colorway"],
  return: ["returns", "exchange", "exchanges", "send back", "refund"],
  returns: ["return", "exchange", "refund"],
  shipping: ["delivery", "deliver", "ship", "arrived"],
  detractor: ["disliked", "dislike", "negative", "detractors", "worst"],
  detractors: ["disliked", "negative", "worst"],
  advocate: ["liked", "positive", "advocates", "promoters", "best", "favorite"],
  advocates: ["liked", "positive", "best"],
  liked: ["advocates", "positive", "favorite"],
  disliked: ["detractors", "negative"],
  customer: ["relationship", "regularly shop", "purchased", "buyer", "shopper"],
  loyal: ["regularly shop", "favorite", "frequently shopped"],
  women: ["womens", "female", "woman"],
  womens: ["women", "female"],
  men: ["mens", "male", "man"],
  mens: ["men", "male"],
  genz: ["<=24", "25-29", "younger"],
  gen: ["<=24", "25-29"],
  young: ["<=24", "25-29", "younger"],
  younger: ["<=24", "25-29"],
  millennial: ["25-29", "30-39"],
  millennials: ["25-29", "30-39"],
  boomer: ["60-69", ">=70"],
  boomers: ["60-69", ">=70"],
  older: ["50-59", "60-69", ">=70"],
  bag: ["crossbody", "tote", "handbag", "purse", "satchel"],
  boot: ["boots", "bootie", "booties"],
  boots: ["boot"],
};

// weighted, typed fields per finding. `type` is the human label used in chips.
function fieldsOf(f) {
  const F = [];
  const add = (type, text, w) => { if (text) F.push({ type, text: String(text), w }); };
  if (f.kind === "line_efficiency") {
    add("Type", "Line efficiency", 1.6); add("Method", "incremental reach TURF", 1.3);
    (f.rows || []).forEach((r) => add("Concept", r.label, 2));
  } else if (f.kind === "sentiment") {
    add("Type", "Sentiment ranking", 1.5);
    add("Segment", f.field === "Overall" ? "All respondents" : f.segValue, 3);
    if (f.field === "Age group") add("Segment", "Age " + f.segValue, 3);
    if (f.field === "Relationship to Tecovas") { add("Segment", "Relationship to Tecovas", 2.2); add("Segment", "customer", 1.4); }
    (f.tiers || []).forEach((t) => t.items.forEach((it) => add("Attribute", it.label, 1.6)));
  } else if (f.kind === "advdet") {
    add("Product", f.product, 3);
    add("Type", f.polarity === "positive" ? "Liked most · advocates" : "Disliked most · detractors", 2);
    add("Context", f.question, 1);
    (f.rows || []).forEach((r) => add("Attribute", r.label, 1.8));
  } else if (f.kind === "stat") {
    add("Type", "Audience profile", 1.5); add("Context", f.question, 1.2);
    (f.rows || []).forEach((r) => add("Attribute", r.label, 1.5));
  } else if (f.kind === "note") {
    add("Type", f.noteType === "malformed" ? "Flagged · data quality" : "Null result", 1.5);
    add("Product", f.product, 2.4); add("Context", f.question, 1.1); add("Context", f.text || "", 0.8);
  }
  return F;
}

// bounded Levenshtein distance
function lev(a, b) {
  const m = a.length, n = b.length;
  if (Math.abs(m - n) > 2) return 3;
  let prev = Array.from({ length: n + 1 }, (_, i) => i);
  for (let i = 1; i <= m; i++) {
    const cur = [i];
    for (let j = 1; j <= n; j++) {
      const cost = a[i - 1] === b[j - 1] ? 0 : 1;
      cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
    }
    prev = cur;
  }
  return prev[n];
}
function stem(w) { return w.replace(/(ies)$/, "y").replace(/(es|s)$/, "").replace(/(ing|ed)$/, ""); }

// quality of matching one query word against one field word (0..1)
function wordQuality(word, q) {
  if (!word || !q) return 0;
  if (word === q) return 1;
  if (word.startsWith(q) && q.length >= 2) return 0.9;
  if (q.startsWith(word) && word.length >= 3) return 0.82;
  if (q.length >= 3 && word.includes(q)) return 0.75;
  if (word.length >= 3 && q.includes(word)) return 0.7;
  if (stem(word) === stem(q) && stem(q).length >= 3) return 0.8;
  const d = lev(word, q), L = Math.max(word.length, q.length);
  if (d === 1 && L >= 4) return 0.62;
  if (d === 2 && L >= 7) return 0.48;
  return 0;
}

function expandToken(raw) {
  const variants = [{ term: raw, syn: false }];
  (APX_SYN[raw] || []).forEach((s) => variants.push({ term: s, syn: true }));
  return { raw, variants };
}

// score one finding against expanded tokens; capture best match per token
function scoreFinding(f, expanded) {
  const fields = fieldsOf(f);
  let total = 0; const matches = []; let allMatched = true;
  for (const et of expanded) {
    let best = null;
    for (const fld of fields) {
      const lowered = fld.text.toLowerCase();
      const words = lowered.split(/[^a-z0-9<>=+-]+/).filter(Boolean);
      for (const v of et.variants) {
        let q = 0, mw = v.term;
        if (v.term.indexOf(" ") >= 0) { if (lowered.includes(v.term)) { q = 0.78; mw = v.term; } }
        else { for (const w of words) { const qq = wordQuality(w, v.term); if (qq > q) { q = qq; mw = w; } } }
        if (q > 0) {
          const sc = q * fld.w * (v.syn ? 0.85 : 1);
          if (!best || sc > best.sc) best = { sc, q, type: fld.type, text: fld.text, matched: mw, variant: v.term, syn: v.syn, raw: et.raw };
        }
      }
    }
    if (best) { total += best.sc; matches.push(best); } else { allMatched = false; }
  }
  return { score: total, matches, allMatched };
}

// legacy signature kept for the no-query grouped path (always true when empty)
function matchesQuery(f, tokens) {
  if (!tokens.length) return true;
  return scoreFinding(f, tokens.map(expandToken)).allMatched;
}

// ---- objective-anchor badges ----
function AppObjTags({ relevantObjs, offObjective }) {
  const nums = ["01", "02", "03"];
  return (
    <span className="apx-objtags" title={offObjective
      ? "This finding was not anchored to any objective"
      : "Anchored to objective " + relevantObjs.map((n) => +n).join(", ")}>
      <span className="apx-objtags__lab">Anchored to</span>
      {nums.map((n) => (
        <span key={n} className={"apx-objpill" + (relevantObjs.indexOf(n) >= 0 ? " is-on" : "")}>{+n}</span>
      ))}
      {offObjective && <span className="apx-objpill apx-objpill--off">Unmapped</span>}
    </span>
  );
}

// ---- "Add to an argument" cascade control ----
// findings: the finding object(s) this control assigns (a heatmap passes its
// whole product set). Renders on the segment-bar line beside "Explore the data".
function ApxAssign({ findings, assign, assignments, objectives }) {
  const [open, setOpen] = React.useState(false);
  const [hoverObj, setHoverObj] = React.useState(null);
  React.useEffect(() => {
    if (!open) return;
    const close = (e) => { if (!e.target.closest(".apx-assign")) { setOpen(false); setHoverObj(null); } };
    document.addEventListener("mousedown", close);
    return () => document.removeEventListener("mousedown", close);
  }, [open]);
  if (!assign || !findings || !findings.length) return null;
  const cur = (assignments && assignments[findings[0].rawId]) || [];
  const isOn = (o, a) => cur.some((t) => t.slug === o.slug && t.argId === a.id);
  const toggle = (o, a) => findings.forEach((f) => assign(f.rawId, { slug: o.slug, argId: a.id, objNum: o.num, label: a.argument[0] }));
  return (
    <div className="fc-ctrls apx-assign">
      <button className="fc-ib" data-tip="Add to an argument" onClick={() => setOpen((o) => !o)}>
        <i className="ti ti-plus"></i>
      </button>
      {open && (
        <div className="fc-menu apx-assignmenu">
          <div className="fc-menu__sub">Add this finding to</div>
          {(objectives || []).map((o) => (
            <div key={o.slug} className="apx-casc" onMouseEnter={() => setHoverObj(o.slug)} onMouseLeave={() => setHoverObj((s) => s === o.slug ? null : s)}>
              <button className="fc-menu__item apx-casc__obj">
                <span className="apx-cascade__objlab"><b>Objective {o.num}</b>{o.objective}</span>
                <i className="ti fc-menu__check ti-chevron-right"></i>
              </button>
              {hoverObj === o.slug && (
                <div className="fc-menu apx-submenu">
                  {o.arguments.map((a) => {
                    const on = isOn(o, a);
                    return (
                      <button key={a.id} className={"fc-menu__item apx-submenu__arg" + (on ? " is-active" : "")}
                        onClick={() => toggle(o, a)}>
                        <i className={"ti apx-submenu__box " + (on ? "ti-square-check-filled" : "ti-square")}></i>
                        <span>{a.argument[0]}</span>
                      </button>
                    );
                  })}
                </div>
              )}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// aligns an overlay control cluster onto the block's segment-bar line
function useApxSegAlign(ref) {
  React.useLayoutEffect(() => {
    const el = ref.current; if (!el) return;
    const seg = el.querySelector(".ap-seg");
    if (seg) { const s = seg.getBoundingClientRect(), e = el.getBoundingClientRect(); el.style.setProperty("--fc-seg-top", (s.top - e.top + s.height / 2 - 16) + "px"); }
    const trace = el.querySelector(".ap-seg__trace");
    if (trace) { const br = el.getBoundingClientRect(), tr = trace.getBoundingClientRect(); el.style.setProperty("--fc-ctrls-right", (br.right - tr.left + 2) + "px"); }
  });
}

function AssignedChip({ cur }) {
  return null;
}

// ---- relevance-feedback strip (why a finding matched + how strongly) ----
function hlText(text, terms) {
  const list = (Array.isArray(terms) ? terms : [terms]).filter(Boolean);
  // find all match ranges, merge overlaps, then render
  const lower = text.toLowerCase();
  let ranges = [];
  list.forEach((t) => { const i = lower.indexOf(t.toLowerCase()); if (i >= 0) ranges.push([i, i + t.length]); });
  if (!ranges.length) return text;
  ranges.sort((a, b) => a[0] - b[0]);
  const merged = [ranges[0]];
  for (let k = 1; k < ranges.length; k++) { const last = merged[merged.length - 1]; if (ranges[k][0] <= last[1]) last[1] = Math.max(last[1], ranges[k][1]); else merged.push(ranges[k]); }
  const out = []; let pos = 0;
  merged.forEach(([s, e], k) => { if (s > pos) out.push(text.slice(pos, s)); out.push(<mark key={k} className="apx-mk">{text.slice(s, e)}</mark>); pos = e; });
  if (pos < text.length) out.push(text.slice(pos));
  return out;
}
function ApxMatchStrip({ match }) {
  if (!match) return null;
  const pct = match.pct;
  const strength = pct >= 78 ? "strong" : pct >= 45 ? "good" : "partial";
  // group by field; collect every matched term + strongest score + syn flag
  const groups = {};
  match.matches.forEach((m) => {
    const k = m.type + "|" + m.text;
    const g = groups[k] || (groups[k] = { type: m.type, text: m.text, terms: [], sc: 0, syn: true, raws: new Set(), vars: new Set() });
    g.terms.push(m.matched); g.sc = Math.max(g.sc, m.sc); g.syn = g.syn && m.syn; g.raws.add(m.raw); if (m.syn) g.vars.add(m.variant);
  });
  const chips = Object.values(groups).sort((a, b) => b.sc - a.sc);
  return (
    <div className="apx-match">
      <div className="apx-match__rel" title={pct + "% relevance"}>
        <span className="apx-match__pct">{strength} match</span>
        <span className={"apx-relbar apx-relbar--" + strength}><span className="apx-relbar__fill" style={{ width: pct + "%" }}></span></span>
      </div>
      <div className="apx-why">
        <span className="apx-why__lab">Matched on</span>
        {chips.map((g, i) => (
          <span key={i} className={"apx-chip-why" + (g.syn ? " is-syn" : "")}
            title={g.syn ? "“" + [...g.raws].join(", ") + "” \u2248 “" + [...g.vars].join(", ") + "”" : "Matched your term “" + [...g.raws].join(", ") + "”"}>
            <span className="apx-chip-why__t">{g.type}</span>
            <span className="apx-chip-why__v">{g.syn && <span className="apx-chip-why__syn">{"\u2248 "}</span>}{hlText(g.text, g.terms)}</span>
          </span>
        ))}
      </div>
    </div>
  );
}

// ---- single-finding block (line efficiency · sentiment · stat) ----
function AppFindingBlock({ f, label, assign, assignments, objectives, match }) {
  const FindingViz = window.ApFindingViz;
  const ref = React.useRef(null);
  useApxSegAlign(ref);
  const cur = assignments && assignments[f.rawId];
  return (
    <div ref={ref} className={"ap-findblock apx-block" + (match ? " apx-block--ranked" : "")}>
      {match && <ApxMatchStrip match={match} />}
      <div className="ap-findlabel apx-findlabel">
        <AppObjTags relevantObjs={f.relevantObjs} offObjective={f.offObjective} />
        <AssignedChip cur={cur} />
      </div>
      <ApxAssign findings={[f]} assign={assign} assignments={assignments} objectives={objectives} />
      {FindingViz ? <FindingViz f={f} hl={match ? [...new Set(match.matches.map((m) => m.matched))] : undefined} /> : null}
    </div>
  );
}

// ---- data-note card (malformed / null results that have no usable viz) ----
function AppNoteCard({ f, label, match }) {
  const isMalformed = f.noteType === "malformed";
  return (
    <div className={"ap-findblock apx-block" + (match ? " apx-block--ranked" : "")}>
      {match && <ApxMatchStrip match={match} />}
      <div className="ap-findlabel apx-findlabel">
        <span className="ap-findlabel__tag">{label}</span>
        <AppObjTags relevantObjs={f.relevantObjs} offObjective={f.offObjective} />
      </div>
      <div className={"apx-note" + (isMalformed ? " apx-note--warn" : "")}>
        <div className="apx-note__head">
          <span className={"apx-note__badge" + (isMalformed ? " apx-note__badge--warn" : "")}>
            {isMalformed ? "Flagged · data quality" : "Null result"}
          </span>
          <span className="apx-note__prod">{f.product}{f.n ? " · " + f.n + " respondents" : ""}</span>
        </div>
        <div className="apx-note__q">{f.question}</div>
        <div className="apx-note__body">
          {isMalformed
            ? <span>Reported count exceeds the respondent base — this finding is internally inconsistent and excluded from the charts. Surfaced here for review.</span>
            : <span>No statistically standout attribute (weak overall concentration). No usable signal to chart.</span>}
        </div>
        {f.reason && <div className="apx-note__reason">{f.reason}</div>}
      </div>
    </div>
  );
}

// ---- a labelled group section ----
function AppGroup({ group, assign, assignments, objectives }) {
  const Heatmap = window.ApAdvDetHeatmap;
  const isHeat = group.key === "liked" || group.key === "disliked";
  const heatRef = React.useRef(null);
  useApxSegAlign(heatRef);
  if (!group.findings.length) return null;
  const heatCur = isHeat && assignments && assignments[group.findings[0].rawId];

  return (
    <section className="apx-group">
      <header className="apx-group__head">
        <h4 className="apx-group__title">{group.label}</h4>
        <span className="apx-group__count">{group.findings.length} {group.findings.length === 1 ? "finding" : "findings"}</span>
      </header>

      {isHeat ? (
        <div ref={heatRef} className="apx-block" style={{ position: "relative" }}>
          <div className="apx-heatmeta">
            <AppObjTags relevantObjs={group.findings[0] ? group.findings[0].relevantObjs : []} offObjective={false} />
            <AssignedChip cur={heatCur} />
            <span className="apx-heatmeta__hint">{group.findings.length} {group.findings.length === 1 ? "concept" : "concepts"}</span>
          </div>
          <ApxAssign findings={group.findings} assign={assign} assignments={assignments} objectives={objectives} />
          {Heatmap ? <Heatmap findings={group.findings} polarity={group.polarity} /> : null}
        </div>
      ) : (
        <div className="apx-group__list">
          {group.findings.map((f, i) => {
            const label = f.kind === "line_efficiency" ? "Line efficiency"
              : f.kind === "sentiment" ? (f.field === "Overall" ? "All respondents" : f.field === "Age group" ? "Age " + f.segValue : f.segValue)
              : f.kind === "note" ? (f.noteType === "malformed" ? "Flagged" : "Null result")
              : "Audience profile";
            return f.kind === "note"
              ? <AppNoteCard key={f.rawId + i} f={f} label={label} />
              : <AppFindingBlock key={f.rawId + i} f={f} label={label}
                  assign={assign} assignments={assignments} objectives={objectives} />;
          })}
        </div>
      )}
    </section>
  );
}

// type label shown on a ranked result card
function apxLabelOf(f) {
  return f.kind === "line_efficiency" ? "Line efficiency"
    : f.kind === "sentiment" ? (f.field === "Overall" ? "Sentiment · all respondents" : f.field === "Age group" ? "Sentiment · age " + f.segValue : "Sentiment · " + (f.field === "Relationship to Tecovas" ? "relationship" : f.field))
    : f.kind === "advdet" ? (f.polarity === "positive" ? "Liked most · " + f.product : "Disliked most · " + f.product)
    : f.kind === "note" ? (f.noteType === "malformed" ? "Flagged" : "Null result")
    : "Audience profile";
}

// =========================================================
// Appendix PAGE — search + grouped results (no query) OR a
// relevance-ranked flat list with match feedback (query active).
// =========================================================
function AppendixPage({ cat: catProp, setCat: setCatProp, assign, assignments, objectives } = {}) {
  const DATA = window.APPENDIX_FINDINGS;
  const [catState, setCatState] = React.useState("all");
  const cat = catProp != null ? catProp : catState;
  const setCat = setCatProp || setCatState;
  const [query, setQuery] = React.useState("");
  const scrollRef = React.useRef(null);
  React.useEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = 0; }, [cat]);
  if (!DATA) return <div style={{ padding: 24, color: "var(--ms-fg-muted)" }}>Loading appendix…</div>;

  // normalize known multi-word phrases -> single tokens, then drop stray 1-char tokens
  const normalized = query.trim().toLowerCase().replace(/\bgen\s+z\b/g, "genz").replace(/\bgen\s*z\b/g, "genz");
  const tokens = normalized.split(/\s+/).filter((t) => t.length >= 2);
  const searching = tokens.length > 0;
  const expanded = React.useMemo(() => tokens.map(expandToken), [query]);

  // score every finding once (query active)
  const scored = React.useMemo(() => {
    if (!searching) return [];
    const out = [];
    DATA.groups.forEach((g) => g.findings.forEach((f) => {
      const r = scoreFinding(f, expanded);
      if (r.allMatched && r.score > 0) out.push({ f, ...r });
    }));
    const max = out.reduce((m, x) => Math.max(m, x.score), 0) || 1;
    out.forEach((x) => { x.pct = Math.max(18, Math.round((x.score / max) * 100)); });
    out.sort((a, b) => b.score - a.score);
    return out;
  }, [DATA, query]);

  // per-category counts (respect search, ignore active cat)
  const catCounts = React.useMemo(() => {
    const m = { all: 0 };
    APX_CATS.forEach((c) => { if (c.key !== "all") m[c.key] = 0; });
    if (searching) {
      scored.forEach(({ f }) => { m.all += 1; m[catOf(f)] += 1; });
    } else {
      DATA.groups.forEach((g) => g.findings.forEach((f) => { m.all += 1; m[catOf(f)] += 1; }));
    }
    return m;
  }, [DATA, query, scored]);

  // synonyms that actually fired (for the "also matching" hint)
  const synHint = React.useMemo(() => {
    if (!searching) return [];
    const used = {};
    scored.forEach((x) => x.matches.forEach((m) => { if (m.syn) (used[m.raw] = used[m.raw] || new Set()).add(m.variant); }));
    return Object.entries(used).map(([raw, set]) => ({ raw, terms: [...set].slice(0, 4) }));
  }, [scored]);

  const rankedShown = searching ? scored.filter((x) => cat === "all" || catOf(x.f) === cat) : [];

  // no-query grouped path
  const groups = DATA.groups.map((g) => ({ ...g, findings: g.findings.filter((f) => cat === "all" || catOf(f) === cat) }));
  const groupedShown = groups.reduce((n, g) => n + g.findings.length, 0);
  const shown = searching ? rankedShown.length : groupedShown;

  const searchBar = (
    <div className="apx-toolbar">
      <div className="apx-search">
        <svg className="apx-search__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
        <input className="apx-search__input" type="text" value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Search — try “crossbdy”, “price”, “gen z”, “returns”…" />
        {query && <button className="apx-search__clear" onClick={() => setQuery("")} aria-label="Clear search">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
        </button>}
      </div>
    </div>
  );

  return (
    <div className="gl-bx-read ap-read apx-page" ref={scrollRef} data-screen-label="Appendix · all findings">
      <div className="apx-page__body">
        {searchBar}
        {searching && shown > 0 && (
          <div className="apx-results-bar">
            <div className="apx-results-bar__count"><b>{shown}</b> {shown === 1 ? "finding" : "findings"} · sorted by relevance</div>
            {synHint.length > 0 && (
              <div className="apx-results-bar__syn">
                {synHint.map((s, i) => (
                  <span key={i} className="apx-syntag"><b>{s.raw}</b> also matched {s.terms.map((t) => "“" + t + "”").join(", ")}</span>
                ))}
              </div>
            )}
          </div>
        )}
        {shown === 0 ? (
          <div className="apx-empty">
            <div className="apx-empty__title">No findings match</div>
            <div className="apx-empty__sub">Search is fuzzy — try a product name, an age band (e.g. “30-39” or “gen z”), “relationship”, or an attribute like “price” or “fit”.</div>
            <button className="apx-empty__reset" onClick={() => { setQuery(""); setCat("all"); }}>Clear filters</button>
          </div>
        ) : searching ? (
          <div className="apx-ranked">
            {rankedShown.map((x, i) => {
              const f = x.f;
              const label = apxLabelOf(f);
              const m = { pct: x.pct, matches: x.matches };
              return f.kind === "note"
                ? <AppNoteCard key={f.rawId + i} f={f} label={label} match={m} />
                : <AppFindingBlock key={f.rawId + i} f={f} label={label} match={m}
                    assign={assign} assignments={assignments} objectives={objectives} />;
            })}
          </div>
        ) : (
          groups.map((g) => <AppGroup key={g.key} group={g}
            assign={assign} assignments={assignments} objectives={objectives} />)
        )}
        <div className="apx-page__end">End · {shown} of {DATA.totalUnique} findings</div>
      </div>
    </div>
  );
}

Object.assign(window, { AppendixPage, APX_CATS, apxCatOf: catOf });
