// Performance-vs-cost scatter (log cost axis) with a Pareto frontier, plus an
// optional "inference-time scaling" overlay that connects each multi-effort
// model's low→max points into a curve. Styled to match the other charts (reuses
// the .trend-* / .tc-* / .filter-* CSS). Reads window.MODELS_ALL from data.js,
// which carries the per-effort rows and per-response cost columns.
//
// Two configured wrappers are mounted on methodology.html:
//   window.LmcaCostChart    — LMCA score (y) vs. avg LMCA cost per response (x)
//   window.DtbenchCostChart — DTBench score (y) vs. avg DTBench cost per response (x)
const { useState: useCcState } = React;

const CC_LMCA = "Argument evaluation (LMCA)";
const CC_DT = "Decision theory (DTBench)";
const CC_EFFORT_ORDER = ["low", "medium", "high", "xhigh", "max"];

// Tall frames: the scaling curves + frontier all live in the upper score band,
// so a taller plot gives them room to separate rather than pile up.
const CC_DESK = { W: 1000, H: 620, ml: 62, mr: 40, mt: 28, mb: 74, vbX: 0 };
// Phone frame, same reasoning as the other charts: the SVG scales to its column
// so the viewBox IS the type size — a ~520-unit box renders ~1.9x larger than
// the 1000-unit desktop one at the same on-screen width.
const CC_PHONE = { W: 520, H: 720, ml: 44, mr: 30, mt: 24, mb: 80, vbX: -19 };
const CC_PHONE_MQ = "(max-width: 760px)";

const log10 = (v) => Math.log(v) / Math.LN10;
// Curve endpoint labels are tight on space: drop the "Claude " prefix so the
// Anthropic models read as "Opus 5" / "Fable 5" / "Sonnet 5".
const ccShort = (n) => n.replace(/^Claude\s+/, "");

// Mix a hex colour toward black (f<0) or white (f>0), |f| in [0,1]. Used to give
// each model in a lab its own shade so same-colour curves are still separable.
function ccShade(hex, f) {
  const m = String(hex).replace("#", "");
  if (m.length < 6) return hex;
  const r = parseInt(m.slice(0, 2), 16), g = parseInt(m.slice(2, 4), 16), b = parseInt(m.slice(4, 6), 16);
  const t = f < 0 ? 0 : 255, a = Math.abs(f);
  const h = (c) => Math.round(c + (t - c) * a).toString(16).padStart(2, "0");
  return "#" + h(r) + h(g) + h(b);
}

// Decade tick label from its exponent: -4 -> "$0.0001", -1 -> "$0.10", 0 -> "$1".
function ccTickLabel(e) {
  if (e >= 0) return "$" + Math.round(Math.pow(10, e)).toLocaleString();
  if (e === -1) return "$0.10";
  if (e === -2) return "$0.01";
  return "$0." + "0".repeat(-e - 1) + "1";
}
// Actual cost, two significant figures: 0.00069 -> "$0.00069", 0.038 -> "$0.038".
function ccFmtCost(v) {
  if (v == null) return "—";
  if (v >= 1) return "$" + v.toFixed(2);
  return "$" + Number(v.toPrecision(2)).toString();
}

// Single-model Pareto set: points not dominated by any single model (min cost,
// max score). These are the candidates the mixing frontier is built from.
function ccSinglePareto(pts) {
  return pts.filter((p) => !pts.some((q) =>
    q !== p && q.x <= p.x && q.y >= p.y && (q.x < p.x || q.y > p.y)));
}

// Mixing-aware frontier: the upper convex hull in LINEAR (cost, score) space,
// truncated at the top-scoring vertex. Query-routing between two models mixes
// cost — and, for a linear score like DT accuracy, the score — linearly, so the
// hull's upper-left edges are the achievable efficient frontier (each segment is
// the set of routing mixes between its two endpoint models). Segments are later
// sampled in linear space so they curve correctly on the log x-axis.
// NOTE: for a correlation score (LMCA) mixing is NOT linear, so there the hull
// is an approximation (see the chart caption).
function ccMixingFrontier(pts) {
  const P = pts.slice().sort((a, b) => a.x - b.x || a.y - b.y);
  const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
  const up = [];
  for (let i = P.length - 1; i >= 0; i--) {        // right → left
    const p = P[i];
    while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p) <= 0) up.pop();
    up.push(p);
  }
  up.reverse();                                     // now left → right
  let ymaxI = 0;
  for (let i = 1; i < up.length; i++) if (up[i].y > up[ymaxI].y) ymaxI = i;
  return up.slice(0, ymaxI + 1);                    // stop at the top-scoring vertex
}

function PerfCostChart({
  id, title, metricKey, costKey, orgColors, orgOrder,
  yMin, yMax, yTicks, yCeiling, yLabel, yFmt, yTipFmt, xLabel, caption,
}) {
  const [isPhone, setIsPhone] = useCcState(
    () => typeof window !== "undefined" && window.matchMedia
      ? window.matchMedia(CC_PHONE_MQ).matches : false);
  React.useEffect(() => {
    if (!window.matchMedia) return;
    const mq = window.matchMedia(CC_PHONE_MQ);
    const on = () => setIsPhone(mq.matches);
    on();
    mq.addEventListener ? mq.addEventListener("change", on) : mq.addListener(on);
    return () => (mq.removeEventListener ? mq.removeEventListener("change", on) : mq.removeListener(on));
  }, []);
  const CC = isPhone ? CC_PHONE : CC_DESK;
  const DOT_R = isPhone ? 5.5 : 4;
  const AXT_X = isPhone ? 0 : 16;
  const [hover, setHover] = useCcState(null);
  // Which models' inference-time-scaling curves are drawn (per-model toggle).
  // Empty by default — the chart opens as the frontier scatter; you switch on
  // whichever curves you want, so they never all pile up at once.
  const [active, setActive] = useCcState(() => new Set());
  const [hidden, setHidden] = useCcState({});
  const ttRef = React.useRef(null);
  const [ttFit, setTtFit] = useCcState({ flip: false, nudge: 0 });

  const colorFor = (org) => (orgColors && orgColors[org]) || "#9a938a";
  const isHidden = (org) => !!hidden[org];

  // Build points from MODELS_ALL: any model with this metric score and a
  // positive per-response cost on this benchmark. Effort variants come through
  // as their own points (they carry effort + base for the scaling curves).
  const allPts = (window.MODELS_ALL || window.MODELS || [])
    .map((m) => ({
      name: m.name, org: m.org,
      x: m[costKey], y: m.scores[metricKey],
      effort: m.effort || "", base: m.base || "",
    }))
    .filter((p) => p.x != null && p.x > 0 && p.y != null);

  const pts = allPts.filter((p) => !isHidden(p.org));

  // Log-x domain from the visible points, padded by ~0.3 of a decade each side.
  const lxs = pts.map((p) => log10(p.x));
  const lmin = lxs.length ? Math.min(...lxs) : -4;
  const lmax = lxs.length ? Math.max(...lxs) : 0;
  const L0 = lmin - 0.3, L1 = lmax + 0.3;
  const xTickExps = [];
  for (let e = Math.ceil(L0); e <= Math.floor(L1); e++) xTickExps.push(e);

  const px = (x) => CC.ml + ((log10(x) - L0) / (L1 - L0)) * (CC.W - CC.ml - CC.mr);
  const py = (y) => CC.mt + (1 - (y - yMin) / (yMax - yMin)) * (CC.H - CC.mt - CC.mb);

  // Mixing-aware frontier (upper convex hull in linear cost space). Each hull
  // segment is a routing mix between its two endpoint models, so it's drawn
  // sampled in LINEAR space and mapped through the log-x scale, which bends the
  // straight linear-$ segment into the correct curve on the axis.
  const single = ccSinglePareto(pts);
  const frontier = ccMixingFrontier(pts);
  const frontierSet = new Set(frontier.map((p) => p.name + "|" + p.effort));
  const onFront = (p) => frontierSet.has(p.name + "|" + p.effort);
  let frontierPath = "";
  for (let i = 0; i < frontier.length - 1; i++) {
    const a = frontier[i], b = frontier[i + 1];
    const N = 40;
    for (let t = 0; t <= N; t++) {
      const x = a.x + (b.x - a.x) * (t / N);
      const y = a.y + (b.y - a.y) * (t / N);
      frontierPath += `${i === 0 && t === 0 ? "M" : "L"}${px(x).toFixed(1)},${py(y).toFixed(1)}`;
    }
  }
  // Single-model Pareto points that a mix beats (not on the hull) — ringed, per
  // the reference figure, to show mixing strictly dominates them.
  const mixBeaten = single.filter((p) => !onFront(p));
  const mixBeatenSet = new Set(mixBeaten.map((p) => p.name + "|" + p.effort));

  // Per-model curve colours: each multi-effort model gets its lab's hue varied
  // in lightness, so two same-lab curves (e.g. all three Anthropic) stay apart.
  const baseInfo = new Map();   // base -> { org }
  for (const p of allPts) if (p.base && !baseInfo.has(p.base)) baseInfo.set(p.base, { org: p.org });
  const CURVE_ORG_ORDER = ["Anthropic", "OpenAI", "Google DeepMind"];
  const curveModels = Array.from(baseInfo.keys()).sort((a, b) => {
    const ra = CURVE_ORG_ORDER.indexOf(baseInfo.get(a).org);
    const rb = CURVE_ORG_ORDER.indexOf(baseInfo.get(b).org);
    return (ra < 0 ? 99 : ra) - (rb < 0 ? 99 : rb) || a.localeCompare(b);
  });
  const curveColor = {};
  const byOrgList = {};
  for (const b of curveModels) (byOrgList[baseInfo.get(b).org] ||= []).push(b);
  for (const org of Object.keys(byOrgList)) {
    const list = byOrgList[org], n = list.length;
    list.forEach((b, i) => {
      const f = n === 1 ? -0.05 : (-0.32 + (0.64 * i) / (n - 1)); // dark → light
      curveColor[b] = ccShade(colorFor(org), f);
    });
  }

  // Active inference-time scaling curves, ordered low → max.
  const curvesOn = active.size > 0;
  const curves = [];
  {
    const byBase = new Map();
    for (const p of pts) {
      if (!p.base || !active.has(p.base)) continue;
      if (!byBase.has(p.base)) byBase.set(p.base, []);
      byBase.get(p.base).push(p);
    }
    for (const [base, group] of byBase) {
      const ordered = group.slice().sort(
        (a, b) => CC_EFFORT_ORDER.indexOf(a.effort) - CC_EFFORT_ORDER.indexOf(b.effort));
      if (ordered.length < 2) continue;
      curves.push({ base, color: curveColor[base], pts: ordered });
    }
  }
  // Endpoint labels for the active curves, de-collided vertically where their
  // endpoints sit near each other in x.
  const curveLabels = curves.map((c) => {
    const last = c.pts[c.pts.length - 1];
    const text = ccShort(c.base);
    const cx = px(last.x);
    let anchor = "start", lx = cx + 8;
    if (lx + text.length * 7 > CC.W - CC.mr) { anchor = "end"; lx = cx - 8; }
    return { color: c.color, text, cx, x: lx, anchor, y: py(last.y) - 6 };
  }).sort((a, b) => a.y - b.y);
  const LAB_GAP = 15;
  const placedLabels = [];
  for (const lp of curveLabels) {
    for (const q of placedLabels) {
      if (Math.abs(q.cx - lp.cx) < 95 && lp.y < q.y + LAB_GAP) lp.y = q.y + LAB_GAP;
    }
    placedLabels.push(lp);
  }

  // Legend order: major labs first, then orgOrder, then the rest.
  const MAJOR_LABS = ["OpenAI", "Anthropic", "Google DeepMind"];
  const orgRank = (o) => {
    const m = MAJOR_LABS.indexOf(o);
    if (m >= 0) return m;
    const i = (orgOrder || []).indexOf(o);
    return 100 + (i < 0 ? 900 : i);
  };
  const orgsPresent = Array.from(new Set(allPts.map((p) => p.org))).sort((a, b) => orgRank(a) - orgRank(b));
  const toggleOrg = (org) => setHidden((h) => ({ ...h, [org]: !h[org] }));

  const cy = CC.mt + (CC.H - CC.mt - CC.mb) / 2;
  const hoverP = hover != null && !isHidden(hover.org) ? hover : null;
  const hoverLeftPct = hoverP ? (px(hoverP.x) / CC.W) * 100 : 0;
  React.useLayoutEffect(() => {
    const el = ttRef.current, host = el && el.offsetParent;
    if (!el || !host) return;
    const w = el.offsetWidth, hw = host.clientWidth;
    const cx = (hoverLeftPct / 100) * hw;
    const flip = cx + 16 + w > hw - 2;
    const natural = flip ? cx - 16 - w : cx + 16;
    let nudge = 0;
    if (natural < 2) nudge = 2 - natural;
    else if (natural + w > hw - 2) nudge = (hw - 2 - w) - natural;
    setTtFit((prev) => (prev.flip === flip && Math.abs(prev.nudge - nudge) < 0.5
      ? prev : { flip, nudge }));
  }, [hoverP, hoverLeftPct]);

  const legend = (
    <div className="trend-legend">
      {orgsPresent.map((org) => (
        <button key={org} type="button"
          className={"tc-leg" + (isHidden(org) ? " is-off" : "")}
          onClick={() => toggleOrg(org)}
          title={isHidden(org) ? "Show " + org : "Hide " + org}>
          <span className="tc-leg-dot" style={{ "--leg-c": colorFor(org) }} />
          {org}
        </button>
      ))}
    </div>
  );

  return (
    <section className="trend-wrap" id={id}>
      <div className="trend-head">
        <div>
          <div className="eyebrow">Cost</div>
          <div className="trend-title">{title}</div>
        </div>
      </div>

      <div style={{ display: "flex", gap: "16px 20px", flexWrap: "wrap", margin: "0 auto 20px" }}>
        <div className="filter-group">
          <div className="filter-label">Inference-time scaling curves</div>
          <div className="filter-opts">
            <button type="button" onClick={() => setActive(new Set(curveModels))}
              className={"filter-chip" + (curveModels.length && active.size === curveModels.length ? " is-active" : "")}>All</button>
            <button type="button" onClick={() => setActive(new Set())}
              className={"filter-chip" + (active.size === 0 ? " is-active" : "")}>None</button>
            {curveModels.map((b) => (
              <button key={b} type="button"
                onClick={() => setActive((s) => { const n = new Set(s); n.has(b) ? n.delete(b) : n.add(b); return n; })}
                className={"filter-chip" + (active.has(b) ? " is-active" : "")}>
                <span style={{ display: "inline-block", width: "9px", height: "9px", borderRadius: "50%",
                  background: curveColor[b], marginRight: "6px", verticalAlign: "middle" }} />
                {ccShort(b)}
              </button>
            ))}
          </div>
        </div>
      </div>

      <div className="trend-plot">
        <svg viewBox={`${CC.vbX} 0 ${CC.W - CC.vbX} ${CC.H}`} className="trend-svg" preserveAspectRatio="xMidYMid meet">
          <defs>
            <clipPath id={`cc-clip-${id}`}>
              <rect x={CC.ml} y={CC.mt} width={CC.W - CC.ml - CC.mr} height={CC.H - CC.mt - CC.mb} />
            </clipPath>
          </defs>

          {/* y gridlines + labels */}
          {yTicks.map((v) => (
            <g key={`y${v}`}>
              <line x1={CC.ml} x2={CC.W - CC.mr} y1={py(v)} y2={py(v)} className="tc-grid" />
              <text x={CC.ml - 12} y={py(v)} className="tc-ylabel" dominantBaseline="middle" textAnchor="end">{yFmt(v)}</text>
            </g>
          ))}
          {/* x decade ticks + labels */}
          {xTickExps.map((e) => {
            const xv = Math.pow(10, e);
            return (
              <g key={`x${e}`}>
                <line x1={px(xv)} x2={px(xv)} y1={CC.mt} y2={CC.H - CC.mb} className="tc-grid" />
                <line x1={px(xv)} x2={px(xv)} y1={CC.H - CC.mb} y2={CC.H - CC.mb + 6} className="tc-tick" />
                <text x={px(xv)} y={CC.H - CC.mb + 22} className="tc-xlabel" textAnchor="middle">{ccTickLabel(e)}</text>
              </g>
            );
          })}
          <line x1={CC.ml} x2={CC.W - CC.mr} y1={CC.H - CC.mb} y2={CC.H - CC.mb} className="tc-axis" />

          {/* axis titles */}
          <text className="tc-axis-title" transform={`rotate(-90 ${AXT_X} ${cy})`} x={AXT_X} y={cy}
                textAnchor="middle" style={isPhone ? { fontSize: "13px" } : undefined}>{yLabel}</text>
          <text className="tc-xlabel" x={CC.ml + (CC.W - CC.ml - CC.mr) / 2} y={CC.H - CC.mb + 44} textAnchor="middle">{xLabel}</text>

          {/* ceiling reference */}
          {yCeiling != null && (
            <line x1={CC.ml} x2={CC.W - CC.mr} y1={py(yCeiling)} y2={py(yCeiling)}
                  stroke="var(--ink-2, #3a3730)" strokeWidth="1.25" strokeDasharray="5 4" strokeOpacity="0.55" />
          )}

          <g clipPath={`url(#cc-clip-${id})`}>
            {/* mixing-aware frontier line (linear-$ segments, curved by log-x) */}
            {frontier.length > 1 && (
              <path d={frontierPath} fill="none" stroke="var(--teal)" strokeWidth="2.25" strokeOpacity="0.9" />
            )}

            {/* scatter points (dimmed while any effort curve is overlaid) */}
            {pts.map((p, i) => {
              const front = onFront(p);
              const beaten = mixBeatenSet.has(p.name + "|" + p.effort);
              const dim = curvesOn ? 0.16 : (front ? 0.95 : 0.78);
              return (
                <circle
                  key={p.name + p.effort + i}
                  cx={px(p.x)} cy={py(p.y)}
                  r={hoverP === p ? DOT_R + 2 : (front && !curvesOn ? DOT_R + 0.5 : DOT_R)}
                  fill={(beaten && !curvesOn) ? "var(--paper, #f7f3ea)" : colorFor(p.org)}
                  fillOpacity={(beaten && !curvesOn) ? 1 : dim}
                  stroke={curvesOn ? "none" : (front ? "var(--teal)" : (beaten ? colorFor(p.org) : "none"))}
                  strokeWidth={!curvesOn && (front || beaten) ? 1.5 : 0}
                  className="tc-pt"
                  onMouseEnter={() => setHover(p)}
                  onMouseLeave={() => setHover(null)}
                />
              );
            })}

            {/* inference-time scaling curves (paths + markers, clipped) */}
            {curves.map((c) => {
              const col = c.color;
              const d = c.pts.map((p, i) => `${i ? "L" : "M"}${px(p.x).toFixed(1)},${py(p.y).toFixed(1)}`).join(" ");
              return (
                <g key={"curve" + c.base}>
                  <path d={d} fill="none" stroke={col} strokeWidth="2.25" strokeOpacity="0.95"
                        strokeLinejoin="round" strokeLinecap="round" />
                  {c.pts.map((p, i) => (
                    <circle key={c.base + p.effort + i} cx={px(p.x)} cy={py(p.y)}
                      r={hoverP === p ? DOT_R + 2 : DOT_R + 0.5} fill={col} fillOpacity="0.95"
                      className="tc-pt"
                      onMouseEnter={() => setHover(p)} onMouseLeave={() => setHover(null)} />
                  ))}
                </g>
              );
            })}
          </g>

          {/* curve endpoint labels — outside the clip (may sit in the right
              margin), de-collided vertically, paper halo so they read over dots */}
          {placedLabels.map((lp, k) => (
            <text key={"lab" + k} x={lp.x} y={lp.y} className="tc-label" textAnchor={lp.anchor}
                  style={{ fill: lp.color, paintOrder: "stroke", stroke: "var(--paper, #f7f3ea)", strokeWidth: 3, strokeLinejoin: "round" }}>
              {lp.text}
            </text>
          ))}
        </svg>

        {hoverP && (() => {
          const p = hoverP;
          const effTag = p.effort ? " · " + p.effort : "";
          return (
            <div ref={ttRef}
                 className={"tc-tooltip" + (ttFit.flip ? " tc-tooltip--flip" : "")}
                 style={{ left: `${hoverLeftPct}%`, top: `${(py(p.y) / CC.H) * 100}%`, "--tt-nudge": `${ttFit.nudge}px` }}>
              <div className="tc-tt-name">{p.base && p.effort && p.effort !== "max" ? p.base : p.name}{effTag}</div>
              <div className="tc-tt-meta mono-small">{p.org} · {ccFmtCost(p.x)} / response</div>
              <div className="tc-tt-score" style={{ color: colorFor(p.org) }}>{yTipFmt(p.y)}</div>
            </div>
          );
        })()}
      </div>

      {legend}
      {caption ? <p className="trend-figcaption mono-small">{caption}</p> : null}
    </section>
  );
}

function LmcaCostChart({ orgColors }) {
  return (
    <PerfCostChart
      id="lmca-cost"
      title="LMCA performance vs. cost"
      metricKey={CC_LMCA} costKey="lmcaCost"
      orgColors={orgColors}
      orgOrder={window.ORGS || []}
      yMin={0} yMax={104} yCeiling={85} yTicks={[0, 20, 40, 60, 80, 100]}
      yLabel="LMCA score" yFmt={(v) => v} yTipFmt={(v) => v.toFixed(1)}
      xLabel="Avg cost per response (USD, log scale)"
      caption={<>Each point is a model's LMCA score against its average cost per LMCA response (log scale). The teal line is the <strong>mixing-aware</strong> Pareto frontier: the best score reachable at each cost by <em>routing</em> queries between models, so each segment is the set of routing mixes of its two endpoint models (drawn in linear-cost space, hence curved on the log axis). Under routing the covariance with the human ratings blends linearly and — since LMCA is a scale-invariant correlation — so does the score, so the frontier is exact when models share a rating scale; a √-variance term bends a segment slightly below the line only when two models' raw rating scales differ. Hollow points are single models that a mix beats. Switch on a model under <em>Inference-time scaling curves</em> to trace how it scales with reasoning effort (low → max). The dashed line is the estimated LMCA ceiling (85).</>}
    />
  );
}

function DtbenchCostChart({ orgColors }) {
  return (
    <PerfCostChart
      id="dtbench-cost"
      title="DTBench performance vs. cost"
      metricKey={CC_DT} costKey="dtCost"
      orgColors={orgColors}
      orgOrder={window.ORGS || []}
      yMin={0} yMax={104} yCeiling={100} yTicks={[0, 20, 40, 60, 80, 100]}
      yLabel="DTBench score" yFmt={(v) => v} yTipFmt={(v) => v.toFixed(1)}
      xLabel="Avg cost per response (USD, log scale)"
      caption={<>Each point is a model's DTBench score against its average cost per DTBench response (log scale). The teal line is the <strong>mixing-aware</strong> Pareto frontier: the best score reachable at each cost by <em>routing</em> queries between models, so each segment is the set of mixes of its two endpoint models (drawn in linear-cost space, hence curved on the log axis). DTBench accuracy combines linearly under mixing, so this frontier is exact. Hollow points are single models that a mix beats. Switch on a model under <em>Inference-time scaling curves</em> to trace how it scales with reasoning effort (low → max).</>}
    />
  );
}

window.PerfCostChart = PerfCostChart;
window.LmcaCostChart = LmcaCostChart;
window.DtbenchCostChart = DtbenchCostChart;
