// Chart + widget primitives for Perdura Capital
// All SVG, dark-theme aware via CSS variables.

const { useState, useEffect, useRef, useMemo, useCallback } = React;

// ─── Number formatting ────────────────────────────────────────────────────
const fmtUSD = (n, opts = {}) => {
  const { compact = false, decimals = 0 } = opts;
  if (n === null || n === undefined || isNaN(n)) return "—";
  const sign = n < 0 ? "−" : "";
  const abs = Math.abs(n);
  if (compact) {
    if (abs >= 1e9) return sign + "$" + (abs/1e9).toFixed(2) + "B";
    if (abs >= 1e6) return sign + "$" + (abs/1e6).toFixed(2) + "M";
    if (abs >= 1e3) return sign + "$" + (abs/1e3).toFixed(1) + "K";
    return sign + "$" + abs.toFixed(decimals);
  }
  return sign + "$" + abs.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
};
const fmtPct = (n, decimals = 1) => {
  if (n === null || n === undefined || isNaN(n)) return "—";
  return (n >= 0 ? "+" : "") + n.toFixed(decimals) + "%";
};
const fmtNum = (n, opts = {}) => {
  if (n === null || n === undefined || isNaN(n)) return "—";
  const { compact = false } = opts;
  if (compact && Math.abs(n) >= 1000) {
    if (Math.abs(n) >= 1e6) return (n/1e6).toFixed(1) + "M";
    return (n/1e3).toFixed(1) + "K";
  }
  return n.toLocaleString("en-US");
};
const deltaPct = (curr, prev) => prev === 0 ? 0 : ((curr - prev) / prev) * 100;

window.PerduraFmt = { fmtUSD, fmtPct, fmtNum, deltaPct };

// ─── Sparkline ────────────────────────────────────────────────────────────
function Sparkline({ values, width = 84, height = 28, color = "var(--accent)", fill = true, strokeWidth = 1.5 }) {
  if (!values || values.length === 0) return null;
  const min = Math.min(...values), max = Math.max(...values);
  const range = max - min || 1;
  const step = width / (values.length - 1);
  const pts = values.map((v, i) => [i * step, height - ((v - min) / range) * (height - 2) - 1]);
  const path = pts.map((p, i) => (i === 0 ? "M" : "L") + p[0].toFixed(1) + "," + p[1].toFixed(1)).join(" ");
  const area = path + " L" + width + "," + height + " L0," + height + " Z";
  const gid = "spg-" + Math.random().toString(36).slice(2, 8);
  return (
    <svg width={width} height={height} style={{ display: "block", overflow: "visible" }}>
      <defs>
        <linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor={color} stopOpacity="0.28" />
          <stop offset="100%" stopColor={color} stopOpacity="0" />
        </linearGradient>
      </defs>
      {fill && <path d={area} fill={"url(#" + gid + ")"} />}
      <path d={path} fill="none" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

// ─── Area / line chart ────────────────────────────────────────────────────
function AreaChart({ series, labels = [], height = 220, yFmt = (v) => fmtUSD(v, { compact: true }), showAxis = true, legend = true }) {
  // series = [{ name, values, color, dashed?, fill? }]
  const ref = useRef(null);
  const [width, setWidth] = useState(600);
  const [hover, setHover] = useState(null);

  useEffect(() => {
    if (!ref.current) return;
    const ro = new ResizeObserver(entries => {
      for (const e of entries) setWidth(Math.max(280, e.contentRect.width));
    });
    ro.observe(ref.current);
    return () => ro.disconnect();
  }, []);

  const padL = 48, padR = 12, padT = 14, padB = 26;
  const innerW = width - padL - padR;
  const innerH = height - padT - padB;
  const n = series[0]?.values.length || 0;
  const all = series.flatMap(s => s.values);
  const minV = Math.min(...all, 0);
  const maxV = Math.max(...all);
  const rangeV = maxV - minV || 1;
  const xStep = n > 1 ? innerW / (n - 1) : innerW;
  const yFor = (v) => padT + innerH - ((v - minV) / rangeV) * innerH;
  const xFor = (i) => padL + i * xStep;

  // y ticks
  const ticks = 4;
  const tickValues = Array.from({ length: ticks + 1 }, (_, i) => minV + (rangeV * i / ticks));

  const onMove = (e) => {
    const rect = e.currentTarget.getBoundingClientRect();
    const x = e.clientX - rect.left - padL;
    const i = Math.round(x / xStep);
    if (i >= 0 && i < n) setHover(i);
  };

  return (
    <div ref={ref} style={{ width: "100%", position: "relative" }}>
      <svg width={width} height={height} onMouseMove={onMove} onMouseLeave={() => setHover(null)} style={{ display: "block" }}>
        {/* Grid */}
        {showAxis && tickValues.map((tv, i) => (
          <g key={i}>
            <line x1={padL} x2={width - padR} y1={yFor(tv)} y2={yFor(tv)} stroke="var(--grid)" strokeDasharray="2 4" />
            <text x={padL - 8} y={yFor(tv) + 3} textAnchor="end" fontSize="10" fill="var(--text-3)" fontFamily="ui-monospace, 'Geist Mono', monospace">
              {yFmt(tv)}
            </text>
          </g>
        ))}
        {/* X labels */}
        {showAxis && labels.map((l, i) => {
          if (n > 14 && i % 2 !== 0 && i !== n - 1) return null;
          return (
            <text key={i} x={xFor(i)} y={height - 8} textAnchor="middle" fontSize="10" fill="var(--text-3)">
              {l}
            </text>
          );
        })}

        {/* Series */}
        {series.map((s, si) => {
          const pts = s.values.map((v, i) => [xFor(i), yFor(v)]);
          const path = pts.map((p, i) => (i === 0 ? "M" : "L") + p[0].toFixed(1) + "," + p[1].toFixed(1)).join(" ");
          const area = path + " L" + xFor(n - 1) + "," + (padT + innerH) + " L" + padL + "," + (padT + innerH) + " Z";
          const gid = "ach-" + si + "-" + Math.random().toString(36).slice(2, 6);
          return (
            <g key={si}>
              <defs>
                <linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
                  <stop offset="0%" stopColor={s.color} stopOpacity={s.fill === false ? 0 : 0.20} />
                  <stop offset="100%" stopColor={s.color} stopOpacity="0" />
                </linearGradient>
              </defs>
              {s.fill !== false && <path d={area} fill={"url(#" + gid + ")"} />}
              <path d={path} fill="none" stroke={s.color} strokeWidth={s.strokeWidth || 1.75}
                strokeDasharray={s.dashed ? "4 4" : undefined}
                strokeLinecap="round" strokeLinejoin="round" />
            </g>
          );
        })}

        {/* Hover */}
        {hover !== null && (
          <g>
            <line x1={xFor(hover)} x2={xFor(hover)} y1={padT} y2={padT + innerH} stroke="var(--text-4)" />
            {series.map((s, si) => (
              <circle key={si} cx={xFor(hover)} cy={yFor(s.values[hover])} r="3.5" fill={s.color} stroke="var(--on-accent)" strokeWidth="2" />
            ))}
          </g>
        )}
      </svg>

      {/* Hover tooltip */}
      {hover !== null && (
        <div style={{
          position: "absolute", top: 8, right: 12,
          background: "rgba(12,16,24,0.92)", border: "1px solid var(--border-strong)",
          borderRadius: 6, padding: "8px 10px", fontSize: 11, color: "var(--text)",
          backdropFilter: "blur(12px)", pointerEvents: "none", minWidth: 120,
        }}>
          <div style={{ color: "var(--text-3)", marginBottom: 4, fontSize: 10, textTransform: "uppercase", letterSpacing: 0.5 }}>
            {labels[hover]}
          </div>
          {series.map((s, si) => (
            <div key={si} style={{ display: "flex", justifyContent: "space-between", gap: 12, fontFamily: "ui-monospace, monospace", marginTop: si > 0 ? 2 : 0 }}>
              <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
                <span style={{ width: 8, height: 2, background: s.color, borderRadius: 1 }} />
                <span style={{ color: "var(--text-2)" }}>{s.name}</span>
              </span>
              <span>{yFmt(s.values[hover])}</span>
            </div>
          ))}
        </div>
      )}

      {/* Legend */}
      {legend && series.length > 1 && (
        <div style={{ display: "flex", gap: 16, marginTop: 6, paddingLeft: padL, fontSize: 11 }}>
          {series.map((s, i) => (
            <div key={i} style={{ display: "inline-flex", alignItems: "center", gap: 6, color: "var(--text-2)" }}>
              <span style={{ width: 10, height: 2, background: s.color, borderRadius: 1, borderTop: s.dashed ? "1px dashed " + s.color : undefined, background: s.dashed ? "transparent" : s.color }} />
              {s.name}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ─── Bar chart ────────────────────────────────────────────────────────────
function BarChart({ data, height = 200, yFmt = (v) => fmtUSD(v, { compact: true }), colorFn = null, label = "value", labelKey = "label" }) {
  const ref = useRef(null);
  const [width, setWidth] = useState(600);
  useEffect(() => {
    if (!ref.current) return;
    const ro = new ResizeObserver(es => { for (const e of es) setWidth(Math.max(280, e.contentRect.width)); });
    ro.observe(ref.current); return () => ro.disconnect();
  }, []);

  const padL = 48, padR = 12, padT = 12, padB = 28;
  const innerW = width - padL - padR;
  const innerH = height - padT - padB;
  const max = Math.max(...data.map(d => d.value), 1);
  const barW = (innerW / data.length) * 0.62;
  const gap = (innerW / data.length) * 0.38;

  return (
    <div ref={ref} style={{ width: "100%" }}>
      <svg width={width} height={height} style={{ display: "block" }}>
        {[0, 0.25, 0.5, 0.75, 1].map(t => (
          <line key={t} x1={padL} x2={width - padR} y1={padT + innerH * (1 - t)} y2={padT + innerH * (1 - t)}
            stroke="var(--grid)" strokeDasharray="2 4" />
        ))}
        {[0, 0.5, 1].map(t => {
          const v = max * t;
          return <text key={t} x={padL - 8} y={padT + innerH * (1 - t) + 3} textAnchor="end" fontSize="10"
            fill="var(--text-3)" fontFamily="ui-monospace, monospace">{yFmt(v)}</text>;
        })}
        {data.map((d, i) => {
          const h = (d.value / max) * innerH;
          const x = padL + i * (barW + gap) + gap/2;
          const y = padT + innerH - h;
          const c = colorFn ? colorFn(d, i) : "var(--accent)";
          return (
            <g key={i}>
              <rect x={x} y={y} width={barW} height={h} fill={c} rx="2" opacity="0.95" />
              <rect x={x} y={y} width={barW} height={2} fill={c} rx="1" />
              <text x={x + barW/2} y={height - 10} textAnchor="middle" fontSize="10" fill="var(--text-3)">
                {d[labelKey]}
              </text>
            </g>
          );
        })}
      </svg>
    </div>
  );
}

// ─── Stacked bar (waterfall-friendly) ─────────────────────────────────────
function StackedBar({ buckets, height = 16, fmt = fmtUSD }) {
  const total = buckets.reduce((s, b) => s + b.value, 0);
  return (
    <div>
      <div style={{ display: "flex", height, borderRadius: 3, overflow: "hidden", background: "var(--hover)" }}>
        {buckets.map((b, i) => (
          <div key={i} title={b.label + ": " + fmt(b.value)}
            style={{ width: (b.value / total * 100) + "%", background: b.color, borderRight: i < buckets.length - 1 ? "1px solid rgba(10,14,22,0.6)" : undefined }} />
        ))}
      </div>
      <div style={{ display: "flex", justifyContent: "space-between", marginTop: 6, fontSize: 10, color: "var(--text-3)" }}>
        {buckets.map((b, i) => (
          <span key={i} style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
            <span style={{ width: 6, height: 6, borderRadius: 1, background: b.color }} />
            {b.label}
          </span>
        ))}
      </div>
    </div>
  );
}

// ─── KPI Registry: definition + sources + related page for every label ─
// When a KPICard is clicked, this powers the explainer modal.
window.PerduraKPIInfo = {
  // ─ Financial / P&L ─
  "Revenue":                  { what: "Top-line gross revenue recognized in the period.", formula: "Sum of all revenue accounts (4xxx)", source: "GL · Revenue accounts", related: "overview" },
  "Revenue MTD":              { what: "Revenue recognized so far this month.", formula: "Sum of revenue transactions · current MTD", source: "GL · Revenue accounts", related: "overview" },
  "Revenue (current period)": { what: "Revenue for the selected period.", formula: "Sum of revenue accounts within period", source: "GL · Revenue accounts", related: "sales" },
  "Total revenue (LTM)":      { what: "Last-twelve-months revenue across the customer base.", formula: "Sum of customer revenue · trailing 12 months", source: "GL · Customer dimension", related: "customer" },
  "Gross profit":             { what: "Revenue less cost of goods sold.", formula: "Revenue − COGS", source: "GL · 4xxx − 5xxx", related: "margin" },
  "Gross margin":             { what: "Gross profit as a percent of revenue.", formula: "(Revenue − COGS) ÷ Revenue", source: "GL · 4xxx and 5xxx accounts", related: "margin" },
  "Gross margin (current)":   { what: "Gross margin % for the current period.", formula: "(Revenue − COGS) ÷ Revenue · current period", source: "GL · Revenue and COGS accounts", related: "margin" },
  "Trailing 6mo GM%":         { what: "Average gross margin % over the trailing 6 months.", formula: "Mean of monthly GM% · last 6 months", source: "GL · monthly close data", related: "margin" },
  "Opex":                     { what: "Operating expenses for the period — everything below gross profit and above EBITDA.", formula: "Sum of opex accounts (6xxx)", source: "GL · Operating expense accounts", related: "expenses" },
  "Total opex (month)":       { what: "All operating expenses recognized this month.", formula: "Sum of 6xxx accounts · current month", source: "GL · Opex accounts", related: "expenses" },
  "Fixed costs":              { what: "Costs that don't vary with revenue — rent, salaries, software.", formula: "Sum of expense accounts tagged Fixed", source: "GL · accounts with cost_type=Fixed", related: "expenses" },
  "Variable costs":           { what: "Costs that scale with revenue or volume — commissions, fulfillment.", formula: "Sum of expense accounts tagged Variable", source: "GL · accounts with cost_type=Variable", related: "expenses" },
  "Savings identified":       { what: "Sum of AI-detected annualized savings opportunities.", formula: "Sum of cancel/renegotiate items × annualized", source: "Expense intelligence module · AI", related: "expenses" },
  "EBITDA":                   { what: "Earnings before interest, tax, depreciation, and amortization. Operating profit.", formula: "Revenue − COGS − Opex", source: "GL · derived", related: "overview" },
  "EBITDA MTD":               { what: "Operating profit so far this month.", formula: "Revenue MTD − COGS MTD − Opex MTD", source: "GL · current MTD", related: "overview" },
  "Margin exceptions":        { what: "Number of open margin-related anomalies needing review.", formula: "Count of anomalies category=Margin · status=open", source: "Anomaly engine", related: "exceptions" },
  "Margin variance":          { what: "Standard deviation of monthly GM% over the trailing 12 months.", formula: "STDEV(monthly GM%) · 12mo window", source: "GL · monthly history", related: "margin" },
  "Net profit (LTM)":         { what: "Net income after all costs and tax over the last 12 months.", formula: "EBITDA − D&A − Interest − Tax", source: "GL · full P&L", related: "overview" },

  // ─ Cash ─
  "Cash on hand":             { what: "Liquid cash across all operating accounts.", formula: "Sum of bank account balances", source: "Bank feed (Plaid / direct)", related: "cash" },
  "Cash position (current)":  { what: "Total cash across all entities and accounts as of today.", formula: "Sum of cash account balances", source: "Bank feed", related: "cash" },
  "Projected runway":         { what: "How long current cash lasts at the trailing burn rate.", formula: "Cash on hand ÷ Avg daily net burn", source: "Bank feed + cash flow projection", related: "cash" },
  "13-week runway":           { what: "How many weeks of runway the 13-week forecast projects.", formula: "Closing cash ÷ Weekly burn", source: "13-week cash forecast", related: "cash" },
  "Net burn / day":           { what: "Average daily net cash outflow.", formula: "(Starting cash − Ending cash) ÷ Days", source: "Bank feed · period delta", related: "cash" },
  "DSO":                      { what: "Days Sales Outstanding — average days a customer takes to pay.", formula: "(Average AR ÷ Annual Sales) × 365", source: "AR aging + Revenue", related: "arap" },

  // ─ AR / AP ─
  "Total AR":                 { what: "Total open accounts receivable across all customers.", formula: "Sum of unpaid invoice balances", source: "AR aging snapshot", related: "arap" },
  "Overdue AR":               { what: "AR balance past due date.", formula: "Sum of AR in buckets >0 days", source: "AR aging · buckets 1-30/31-60/61-90/>90", related: "arap" },
  "Total AP":                 { what: "Total open vendor bills.", formula: "Sum of unpaid bill balances", source: "AP aging snapshot", related: "arap" },
  "Net working AR-AP":        { what: "Receivables minus payables — the net working capital position.", formula: "Total AR − Total AP", source: "AR + AP aging", related: "arap" },

  // ─ Forecast ─
  "TTM revenue":              { what: "Trailing twelve months of revenue.", formula: "Sum of revenue · trailing 12 months", source: "GL · Revenue", related: "forecast" },
  "Forward 12mo · base":      { what: "Base case revenue forecast over the next 12 months.", formula: "Driver-based forecast model · base assumptions", source: "Forecast engine", related: "forecast" },
  "Forward 12mo · upside":    { what: "Optimistic revenue forecast.", formula: "Driver-based forecast · upside assumptions", source: "Forecast engine", related: "forecast" },
  "Forward 12mo · downside":  { what: "Downside revenue forecast.", formula: "Driver-based forecast · downside assumptions", source: "Forecast engine", related: "forecast" },

  // ─ Sales ─
  "Orders":                   { what: "Total order count for the period.", formula: "Count of order transactions", source: "Order system + GL", related: "sales" },
  "Avg order value":          { what: "Average revenue per order.", formula: "Revenue ÷ Order count", source: "Order system + Revenue", related: "sales" },
  "Repeat customer %":        { what: "Share of orders from customers who ordered before.", formula: "Repeat orders ÷ Total orders", source: "Order history + customer dimension", related: "sales" },

  // ─ Customer ─
  "Total customers":          { what: "Active customers in the period.", formula: "Count of customers with revenue in period", source: "Customer dimension + GL", related: "customer" },
  "Unprofitable customers":   { what: "Customers where net contribution is negative after cost-to-serve.", formula: "Count where net profit < 0", source: "Customer profitability calc", related: "customer" },
  "Top 10 share":             { what: "Share of revenue from the top 10 customers.", formula: "Sum of top 10 customer revenue ÷ Total", source: "Customer dimension · ranked", related: "patterns" },
  "At-risk customers":        { what: "Customers showing signal of churn (delayed orders / declining frequency).", formula: "Customers where days-since-order > baseline × 2", source: "Order history · behavioral signal", related: "patterns" },
  "Upsell candidates":        { what: "Customers fitting the profile of your top-cohort but with low product penetration.", formula: "Lookalike model · top cohort vs current state", source: "Customer dimension + product mix", related: "patterns" },

  // ─ Inventory ─
  "Inventory value (at cost)":{ what: "Total inventory value at standard cost.", formula: "Sum of (stock on hand × unit cost) across SKUs", source: "Inventory module · ending position", related: "inventory" },
  "GMROI":                    { what: "Gross Margin Return on Inventory — GP $ per $ of inventory.", formula: "Gross profit $ ÷ Average inventory $", source: "GL + inventory", related: "inventory" },
  "Inventory turns":          { what: "How many times inventory is sold and replaced annually.", formula: "COGS ÷ Average inventory", source: "GL + inventory", related: "inventory" },
  "Slow/dead capital":        { what: "Inventory value tied up in slow or dead-velocity SKUs.", formula: "Sum of inventory value where velocity ∈ {slow, dead}", source: "Inventory module · velocity classification", related: "inventory" },

  // ─ Supply Chain ─
  "Active vendors":           { what: "Count of vendors transacted with in the period.", formula: "Distinct vendors in AP", source: "AP + vendor master", related: "supply" },
  "Avg lead time":            { what: "Average days from PO to receipt across vendors.", formula: "Mean(actual delivery − PO date)", source: "Purchase orders + receipts", related: "supply" },
  "On-time delivery":         { what: "Share of receipts delivered on or before promised date.", formula: "On-time receipts ÷ Total receipts", source: "Purchase orders + receipts", related: "supply" },
  "At-risk SKUs":             { what: "SKUs projected to stock out within 30 days.", formula: "SKUs where stockoutDays < 30 + open PO < demand", source: "Inventory + PO data", related: "supply" },

  // ─ Service productivity ─
  "Avg utilization":          { what: "Billable hours as a share of total available capacity.", formula: "Billable hours ÷ Capacity hours", source: "Time tracking", related: "service" },
  "Realization":              { what: "Billed value as a share of standard-rate value worked.", formula: "Billed $ ÷ (Standard rate × hours worked)", source: "Time entries + rate cards", related: "service" },
  "Bench cost":               { what: "Annualized cost of below-target utilization.", formula: "(Target − Actual util) × Capacity × Bill rate", source: "Utilization calc", related: "service" },
  "Revenue per FTE":          { what: "Last-twelve-months revenue divided by headcount.", formula: "Revenue (LTM) ÷ Active headcount", source: "GL + HR data", related: "service" },

  // ─ Stores / Retail ─
  "Stores":                   { what: "Active retail locations.", formula: "Count of open stores", source: "Store master", related: "stores" },
  "Total revenue (MTD)":      { what: "Month-to-date revenue across the store fleet.", formula: "Sum of store-level revenue MTD", source: "POS + GL", related: "stores" },
  "Comp store sales":         { what: "Same-store revenue change vs prior comparable period.", formula: "(Current − Prior) ÷ Prior · comp stores only", source: "POS · qualifying stores", related: "stores" },
  "Avg revenue / sqft":       { what: "Revenue per square foot of retail space.", formula: "Total store revenue ÷ Total sqft", source: "POS + store master", related: "stores" },

  // ─ Manufacturing ─
  "OEE — overall":            { what: "Overall Equipment Effectiveness — composite of availability, performance, quality.", formula: "Availability × Performance × Quality", source: "Production system", related: "manufacturing" },
  "Availability":             { what: "Uptime vs planned production time.", formula: "Actual run time ÷ Planned production time", source: "Production system", related: "manufacturing" },
  "Performance":              { what: "Actual cycle time vs ideal cycle time.", formula: "Actual output ÷ Ideal output at run time", source: "Production system", related: "manufacturing" },
  "Quality":                  { what: "First-pass yield rate.", formula: "Good units ÷ Total units produced", source: "Production system + QA", related: "manufacturing" },

  // ─ Subscription ─
  "MRR":                      { what: "Monthly recurring revenue.", formula: "Sum of active subscription monthly value", source: "Billing system", related: "subscription" },
  "ARR":                      { what: "Annualized recurring revenue (MRR × 12).", formula: "MRR × 12", source: "Billing system", related: "subscription" },
  "Net retention":            { what: "Revenue retained from an existing customer base including expansion.", formula: "(Start MRR + Exp − Contraction − Churn) ÷ Start MRR", source: "MRR movement", related: "subscription" },
  "Gross retention":          { what: "Revenue retained from an existing customer base, ignoring expansion.", formula: "(Start MRR − Contraction − Churn) ÷ Start MRR", source: "MRR movement", related: "subscription" },

  // ─ Acquisition ─
  "Blended CAC":              { what: "Average cost to acquire a new customer across all channels.", formula: "Total S&M spend ÷ New customers", source: "Marketing spend + customer count", related: "acquisition" },
  "Blended LTV / margin":     { what: "Average lifetime value (margin) per new customer.", formula: "Sum(channel LTV × cohort size) ÷ Total new", source: "Customer history + GL", related: "acquisition" },
  "LTV / CAC":                { what: "Ratio of lifetime margin to acquisition cost. 3.0x is healthy.", formula: "LTV ÷ CAC", source: "Derived", related: "acquisition" },
  "Payback period":           { what: "Months for cumulative margin to cover CAC.", formula: "CAC ÷ Monthly margin per customer", source: "Derived", related: "acquisition" },

  // ─ Pricing ─
  "Realized price %":         { what: "Net price as a share of list price (blended).", formula: "Net price ÷ List price", source: "Order line items", related: "pricing" },
  "Avg discount":             { what: "Average discount taken vs list price.", formula: "1 − (Net ÷ List)", source: "Order line items", related: "pricing" },
  "Discount leakage":         { what: "Annualized dollars lost to discounting beyond target.", formula: "Sum((actual − target discount) × volume) annualized", source: "Pricing intelligence calc", related: "pricing" },
  "GM% lift if at target":    { what: "Gross margin improvement if all discounts were at target.", formula: "Recovered margin ÷ Revenue", source: "Pricing intelligence calc", related: "pricing" },

  // ─ Capital allocation ─
  "Projected FCF · 12mo":     { what: "Free cash flow projected over the next 12 months.", formula: "Forecast OCF − CapEx", source: "Forecast engine + CapEx plan", related: "capital" },
  "Cash reserve target":      { what: "Cash reserve policy — typically 6 months of fixed burn.", formula: "Monthly fixed burn × 6", source: "GL · fixed expense classification", related: "capital" },
  "Reserve gap":              { what: "Difference between current cash and the reserve target.", formula: "Max(0, Target − Current cash)", source: "Bank feed + policy", related: "capital" },
  "Discretionary capital":    { what: "FCF available to deploy after reserve top-up.", formula: "Projected FCF − Reserve gap", source: "Derived", related: "capital" },

  // ─ Exceptions / decisions / risk ─
  "Open exceptions":          { what: "Anomalies detected by the engine and awaiting review.", formula: "Count where status=open", source: "Anomaly engine", related: "exceptions" },
  "Resolved this month":      { what: "Anomalies closed this month.", formula: "Count where status=resolved this month", source: "Anomaly engine", related: "exceptions" },
  "Avg time to resolve":      { what: "Average days from detection to resolution.", formula: "Mean(resolved_at − detected_at)", source: "Anomaly engine", related: "exceptions" },
  "$ impact (open)":          { what: "Sum of estimated $ impact across open exceptions.", formula: "Sum of impact estimates", source: "Anomaly engine · AI estimation", related: "exceptions" },
  "Open decisions":           { what: "Strategic decisions queued and not yet acted on.", formula: "Count where status=open", source: "Decisions module", related: "decisions" },
  "Recommended impact":       { what: "Sum of AI-recommended scenario net impacts across open decisions.", formula: "Sum(best scenario net) where status=open", source: "Decisions module", related: "decisions" },
  "Avg AI confidence":        { what: "Average AI confidence score across open decisions.", formula: "Mean(decision confidence)", source: "Decisions module", related: "decisions" },
  "Decisions in last 30d":    { what: "Decisions resolved (accepted / deferred / dismissed) in last 30 days.", formula: "Count where resolved_at within 30d", source: "Decisions module", related: "decisions" },
  "Customer concentration":   { what: "Share of revenue from the top 3 customers. Higher = more concentration risk.", formula: "Top 3 customer rev ÷ Total revenue", source: "Customer dimension · ranked", related: "risk" },
  "Largest customer":         { what: "Share of revenue from the single largest customer.", formula: "Top customer rev ÷ Total revenue", source: "Customer dimension", related: "risk" },
  "Vendor dependency":        { what: "Share of COGS from the top vendor.", formula: "Top vendor spend ÷ Total COGS", source: "AP + GL", related: "supply" },
  "Aggregate risk score":     { what: "Composite score across concentration, supplier, cash, and credit risks.", formula: "Weighted composite of risk categories", source: "Risk module", related: "risk" },

  // ─ Ratios page ─
  "Total ratios tracked":     { what: "Total financial ratios computed live.", formula: "Count across 6 ratio categories", source: "Ratios engine", related: "ratios" },
  "On target":                { what: "Number of ratios meeting their target threshold.", formula: "Count where status=on-target", source: "Ratios engine", related: "ratios" },
  "Watch list":               { what: "Ratios within tolerance band of target.", formula: "Count where status=watch", source: "Ratios engine", related: "ratios" },
  "Off target":               { what: "Ratios outside the tolerance band.", formula: "Count where status=off-target", source: "Ratios engine", related: "ratios" },

  // ─ Users / Config / Period ─
  "Active seats":             { what: "Paid user seats currently in use.", formula: "Count of active users", source: "User management", related: "settings_users" },
  "Pending invites":          { what: "Invitations sent but not yet accepted.", formula: "Count where status=invited", source: "User management", related: "settings_users" },
  "MFA adoption":             { what: "Share of active users with MFA enabled.", formula: "MFA users ÷ Active users", source: "User management · security", related: "settings_users" },
  "Audit log retention":      { what: "How long audit history is preserved.", formula: "Configured retention window", source: "Plan + security policy", related: "settings_users" },
  "Data sources connected":   { what: "Count of integrations currently syncing.", formula: "Count of connectors · status=active", source: "Integration manager", related: "settings_config" },
  "GL accounts mapped":       { what: "Share of GL accounts mapped to canonical categories.", formula: "Mapped accounts ÷ Total accounts", source: "Mapping engine", related: "settings_config" },
  "Last sync":                { what: "Time since the most recent data refresh.", formula: "Now − last_sync_timestamp", source: "Integration manager", related: "settings_config" },
  "AI mapping rules":         { what: "AI-learned rules that auto-classify new accounts.", formula: "Count of active rules", source: "Mapping engine", related: "settings_config" },
  "Current fiscal year":      { what: "The fiscal year you're currently reporting against.", formula: "Configured FY", source: "Period & Calendar settings", related: "settings_period" },
  "Current period":           { what: "The period currently selected for reporting.", formula: "Configured period · MTD/QTD/YTD/TTM", source: "Period & Calendar settings", related: "settings_period" },
  "Books closed through":     { what: "Last month/period that has been formally closed.", formula: "Latest closed period", source: "Close calendar", related: "settings_period" },
  "Forecast horizon":         { what: "How far forward the forecast projects.", formula: "Configured forward window", source: "Forecast engine", related: "settings_period" },
};

// ─── Topbar period key → PERIOD_OPTIONS key mapping ───────────────────
const TOPBAR_PERIOD_MAP = {
  "MTD": "this_month",
  "QTD": "qtd",
  "YTD": "ytd",
  "TTM": "ttm",
};
window.TOPBAR_PERIOD_MAP = TOPBAR_PERIOD_MAP;

// ─── PeriodComparer — robust, reusable period + comparison selector ────
// PL series are 12 months indexed 0..11 with 11 = current month (May 2026)
// Month labels: index 0 = Jun 2025, index 11 = May 2026
const PL_MONTH_LABELS = ["Jun 25","Jul 25","Aug 25","Sep 25","Oct 25","Nov 25","Dec 25","Jan 26","Feb 26","Mar 26","Apr 26","May 26"];

const PERIOD_OPTIONS = [
  { v: "this_month",    l: "This month (May 2026)",    months: 1,  end: 11 },
  { v: "last_month",    l: "Last month (April 2026)",  months: 1,  end: 10 },
  { v: "last_3_months", l: "Last 3 months",            months: 3,  end: 11 },
  { v: "last_6_months", l: "Last 6 months",            months: 6,  end: 11 },
  { v: "qtd",           l: "Quarter to date (Q2 2026)",months: 3,  end: 11 },
  { v: "ytd",           l: "Year to date (2026)",      months: 5,  end: 11 },
  { v: "ttm",           l: "Trailing 12 months",       months: 12, end: 11 },
  { v: "fy25",          l: "Full year FY2025",         months: 12, end: 11 },
  { v: "custom",        l: "Custom range…",            months: 1,  end: 11 },
];
const COMPARE_TO_OPTIONS = [
  { v: "prior_period",  l: "The period right before this one" },
  { v: "same_last_year",l: "Same period a year ago" },
  { v: "budget",        l: "Budget / plan for this period" },
  { v: "forecast",      l: "Forecast for this period" },
  { v: "none",          l: "Don't compare — show just current" },
];

// For a custom range, periodValue = "custom" and the opt needs startIdx/endIdx populated externally.
// We pass customRange = { start: 0..11, end: 0..11 } as a separate prop.
function periodIndices(periodValue, customRange) {
  if (periodValue === "custom" && customRange) {
    const s = Math.min(customRange.start, customRange.end);
    const e = Math.max(customRange.start, customRange.end);
    const arr = []; for (let i = s; i <= e; i++) arr.push(i);
    const months = e - s + 1;
    return { idx: arr, opt: { v: "custom", l: "Custom range", months, end: e } };
  }
  const opt = PERIOD_OPTIONS.find(o => o.v === periodValue) || PERIOD_OPTIONS[0];
  const start = Math.max(0, opt.end - opt.months + 1);
  const arr = [];
  for (let i = start; i <= opt.end; i++) arr.push(i);
  return { idx: arr, opt };
}
function compareIndices(periodOpt, compareValue) {
  if (compareValue === "none") return { idx: [], label: "" };
  const { months } = periodOpt;
  if (compareValue === "prior_period") {
    const end = Math.max(0, periodOpt.end - months);
    const start = Math.max(0, end - months + 1);
    const arr = []; for (let i = start; i <= end; i++) arr.push(i);
    return { idx: arr, label: "the prior " + (months === 1 ? "month" : months + " months") };
  }
  if (compareValue === "same_last_year") {
    const end = Math.min(11, Math.max(0, periodOpt.end));
    const start = Math.max(0, end - months + 1);
    const arr = []; for (let i = start; i <= end; i++) arr.push(i);
    return { idx: arr, label: "the same period a year ago" };
  }
  const { idx } = periodIndices(periodOpt.v || "ttm");
  return { idx, label: compareValue === "budget" ? "plan" : "forecast" };
}

// ─── Custom range month picker ──────────────────────────────────────────────
function CustomRangePicker({ customRange, onChange }) {
  const { useState: useStateCR } = React;
  const [hovered, setHovered] = useStateCR(null);

  const start = Math.min(customRange.start, customRange.end);
  const end   = Math.max(customRange.start, customRange.end);
  const selecting = customRange._selectingEnd;

  const handleClick = (idx) => {
    if (!selecting) {
      onChange({ start: idx, end: idx, _selectingEnd: true });
    } else {
      const s = Math.min(customRange.start, idx);
      const e = Math.max(customRange.start, idx);
      onChange({ start: s, end: e, _selectingEnd: false });
    }
  };

  const effectiveEnd = (selecting && hovered !== null) ? Math.max(customRange.start, hovered) : end;
  const effectiveStart = (selecting && hovered !== null) ? Math.min(customRange.start, hovered) : start;

  return (
    <div className="pc-custom-range">
      <div className="pc-custom-range-hint">
        {selecting ? "Click an end month" : "Click a start month"}
      </div>
      <div className="pc-custom-range-grid">
        {PL_MONTH_LABELS.map((lbl, i) => {
          const inRange = i >= effectiveStart && i <= effectiveEnd;
          const isAnchor = i === customRange.start && selecting;
          const isStart  = i === Math.min(effectiveStart, effectiveEnd) && !selecting;
          const isEnd    = i === Math.max(effectiveStart, effectiveEnd) && !selecting;
          return (
            <button
              key={i}
              className={"pc-cr-month" + (inRange ? " in-range" : "") + (isAnchor || isStart || isEnd ? " edge" : "")}
              onClick={() => handleClick(i)}
              onMouseEnter={() => selecting && setHovered(i)}
              onMouseLeave={() => selecting && setHovered(null)}
            >
              {lbl}
            </button>
          );
        })}
      </div>
      {!selecting && (
        <div className="pc-custom-range-label">
          {PL_MONTH_LABELS[start]} — {PL_MONTH_LABELS[end]} &nbsp;({end - start + 1} month{end - start > 0 ? "s" : ""})
        </div>
      )}
    </div>
  );
}

function PeriodComparer({ value, compareTo, onChange, onCompareChange, customRange, onCustomRangeChange }) {
  const { useState: useStatePC } = React;
  const [showCustom, setShowCustom] = useStatePC(value === "custom");
  const internalRange = customRange || { start: 11, end: 11, _selectingEnd: false };

  const handlePeriodChange = (v) => {
    if (v === "custom") {
      setShowCustom(true);
      onChange(v);
    } else {
      setShowCustom(false);
      onChange(v);
    }
  };

  const selectedOpt = PERIOD_OPTIONS.find(o => o.v === value);
  const displayLabel = value === "custom" && !internalRange._selectingEnd && onCustomRangeChange
    ? `${PL_MONTH_LABELS[Math.min(internalRange.start, internalRange.end)]} – ${PL_MONTH_LABELS[Math.max(internalRange.start, internalRange.end)]}`
    : (selectedOpt ? selectedOpt.l : "");

  return (
    <div className="pc-comparer">
      <div className="pc-comparer-row">
        <label className="pc-comparer-label">Show me</label>
        <select
          className="pc-period-select pc-comparer-select"
          value={value}
          onChange={(e) => handlePeriodChange(e.target.value)}
        >
          {PERIOD_OPTIONS.map(o => (
            <option key={o.v} value={o.v}>
              {o.v === "custom" && value === "custom" && !internalRange._selectingEnd
                ? displayLabel
                : o.l}
            </option>
          ))}
        </select>
      </div>
      {showCustom && onCustomRangeChange && (
        <CustomRangePicker
          customRange={internalRange}
          onChange={(r) => {
            onCustomRangeChange(r);
            if (!r._selectingEnd) setShowCustom(false);
          }}
        />
      )}
      <div className="pc-comparer-row">
        <label className="pc-comparer-label">Compare to</label>
        <select className="pc-period-select pc-comparer-select" value={compareTo} onChange={(e) => onCompareChange(e.target.value)}>
          {COMPARE_TO_OPTIONS.map(o => <option key={o.v} value={o.v}>{o.l}</option>)}
        </select>
      </div>
    </div>
  );
}

window.PeriodComparer = PeriodComparer;
window.PERIOD_OPTIONS = PERIOD_OPTIONS;
window.COMPARE_TO_OPTIONS = COMPARE_TO_OPTIONS;
window.periodIndices = periodIndices;
window.compareIndices = compareIndices;
window.PL_MONTH_LABELS = PL_MONTH_LABELS;

// ─── usePeriod hook ────────────────────────────────────────────────────────
// Standardized period state management for all pages.
// Usage:
//   const { period, compareTo, customRange, curIdx, cmpIdx, curOpt, sumAt,
//           setPeriod, setCompareTo, cmpMult, cmpLbl, compareDeltaSub,
//           PeriodComparerEl } = usePeriod(globalPeriod, setGlobalPeriod);
function usePeriod(globalPeriod, setGlobalPeriod) {
  const TOPBAR_MAP = window.TOPBAR_PERIOD_MAP || { MTD: "this_month", QTD: "qtd", YTD: "ytd", TTM: "ttm" };
  const REVERSE_MAP = { this_month: "MTD", last_month: "MTD", qtd: "QTD", ytd: "YTD", ttm: "TTM" };
  const toLocal = (gp) => TOPBAR_MAP[gp] || "this_month";

  const [period, setPeriodLocal] = React.useState(() => toLocal(globalPeriod));
  const [compareTo, setCompareTo] = React.useState("prior_period");
  const [customRange, setCustomRange] = React.useState({ start: 11, end: 11, _selectingEnd: false });

  React.useEffect(() => {
    if (globalPeriod !== undefined) setPeriodLocal(toLocal(globalPeriod));
  }, [globalPeriod]);

  const setPeriod = (v) => {
    setPeriodLocal(v);
    if (setGlobalPeriod && REVERSE_MAP[v]) setGlobalPeriod(REVERSE_MAP[v]);
  };

  const { idx: curIdx, opt: curOpt } = periodIndices(period, period === "custom" ? customRange : undefined);
  const { idx: cmpIdx, label: cmpLbl } = compareIndices(curOpt, compareTo);
  const cmpMult = compareTo === "budget" ? 1.05 : compareTo === "forecast" ? 1.03 : 1;
  const compareDeltaSub = compareTo === "none" ? null : ("vs " + (cmpLbl || "prior period"));

  const sumAt = (arr, indices) => (indices || curIdx).reduce((s, i) => s + (arr[i] || 0), 0);

  const PeriodComparerEl = (
    <PeriodComparer
      value={period}
      compareTo={compareTo}
      onChange={setPeriod}
      onCompareChange={setCompareTo}
      customRange={customRange}
      onCustomRangeChange={setCustomRange}
    />
  );

  return { period, compareTo, customRange, curIdx, cmpIdx, curOpt, sumAt,
           setPeriod, setCompareTo, setCustomRange, cmpMult, cmpLbl, compareDeltaSub,
           PeriodComparerEl };
}
window.usePeriod = usePeriod;

// ─── KPI Card ─────────────────────────────────────────────────────────────
function KPICard({ label, value, delta, sub, spark, sparkColor, onClick, accent, hint, children }) {
  const positive = delta >= 0;
  const color = accent || (positive ? "var(--positive)" : "var(--danger)");
  const [explainOpen, setExplainOpen] = useState(false);
  const info = window.PerduraKPIInfo && window.PerduraKPIInfo[label];

  const handleClick = (e) => {
    if (onClick) onClick(e);
    else if (info) setExplainOpen(true);
  };

  return (
    <>
      <div className="pc-kpi pc-kpi-clickable" onClick={handleClick} style={{ cursor: "pointer" }}>
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div className="pc-kpi-label">
              {label}
              <span className="pc-kpi-info-dot" title="Click for definition + source">ⓘ</span>
            </div>
            <div className="pc-kpi-value">{value}</div>
            {delta !== undefined && delta !== null && (
              <div style={{ display: "inline-flex", alignItems: "center", gap: 6, marginTop: 4 }}>
                <span className="pc-delta" style={{ color, background: positive ? "rgba(110,231,183,0.10)" : "rgba(248,113,113,0.10)" }}>
                  {positive ? "▲" : "▼"} {Math.abs(delta).toFixed(1)}%
                </span>
                {sub && <span style={{ fontSize: 11, color: "var(--text-3)" }}>{sub}</span>}
              </div>
            )}
            {hint && <div style={{ fontSize: 11, color: "var(--text-3)", marginTop: 6 }}>{hint}</div>}
          </div>
          {spark && (
            <div style={{ flexShrink: 0 }}>
              <Sparkline values={spark} color={sparkColor || color} />
            </div>
          )}
        </div>
        {children}
      </div>
      {explainOpen && info && (
        <KPIExplainModal label={label} value={value} info={info} accent={color} onClose={() => setExplainOpen(false)} />
      )}
    </>
  );
}

function KPIExplainModal({ label, value, info, accent, onClose }) {
  useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);
  return (
    <div className="pc-modal-backdrop" onClick={onClose}>
      <div className="pc-modal" style={{ maxWidth: 580 }} onClick={(e) => e.stopPropagation()}>
        <div className="pc-modal-hd">
          <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
            <span style={{ width: 8, height: 8, borderRadius: "50%", background: accent, boxShadow: "0 0 8px " + accent }} />
            <span style={{ fontSize: 11, color: "var(--text-3)", textTransform: "uppercase", letterSpacing: 0.6, fontWeight: 500 }}>KPI definition</span>
          </div>
          <button className="pc-icon-btn" onClick={onClose} title="Close">✕</button>
        </div>
        <div className="pc-modal-body">
          <div style={{ fontSize: 11, color: "var(--text-3)", textTransform: "uppercase", letterSpacing: 0.5 }}>{label}</div>
          <div style={{ fontSize: 36, fontFamily: "'Instrument Serif', serif", fontWeight: 400, color: accent, marginTop: 4, marginBottom: 4, lineHeight: 1 }}>{value}</div>
          <div style={{ fontSize: 14, color: "var(--text)", lineHeight: 1.6, margin: "16px 0 22px" }}>
            {info.what}
          </div>
          {info.formula && (
            <div style={{ marginBottom: 16 }}>
              <div style={{ fontSize: 10.5, color: "var(--text-3)", textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 6 }}>Formula</div>
              <div style={{ fontFamily: "'Geist Mono', ui-monospace, monospace", fontSize: 13, color: "var(--text)", padding: "10px 14px", background: "var(--bg-elev-1)", border: "1px solid var(--border)", borderRadius: 6 }}>
                {info.formula}
              </div>
            </div>
          )}
          <div style={{ marginBottom: 16 }}>
            <div style={{ fontSize: 10.5, color: "var(--text-3)", textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 6 }}>Source data</div>
            <div style={{ fontSize: 12.5, color: "var(--text-2)", padding: "10px 14px", background: "var(--bg-elev-1)", border: "1px solid var(--border)", borderRadius: 6 }}>
              {info.source}
            </div>
          </div>
          {info.related && (
            <button className="pc-btn primary" style={{ width: "100%" }} onClick={() => {
              const setPage = window.__perduraSetPage;
              if (setPage) setPage(info.related);
              onClose();
            }}>
              Go to detailed view <span style={{ marginLeft: 6 }}>→</span>
            </button>
          )}
        </div>
      </div>
    </div>
  );
}

// ─── Severity pill ────────────────────────────────────────────────────────
function SevPill({ sev }) {
  const map = {
    critical: { bg: "rgba(248,113,113,0.12)", color: "#fca5a5", label: "Critical" },
    high:     { bg: "rgba(251,146,60,0.12)",  color: "#fdba74", label: "High" },
    medium:   { bg: "rgba(251,191,36,0.10)",  color: "#fcd34d", label: "Medium" },
    low:      { bg: "rgba(148,163,184,0.10)", color: "#cbd5e1", label: "Low" },
  };
  const s = map[sev] || map.low;
  return (
    <span className="pc-sevpill" style={{ background: s.bg, color: s.color }}>
      <span style={{ width: 5, height: 5, borderRadius: "50%", background: s.color, display: "inline-block" }} />
      {s.label}
    </span>
  );
}

// ─── Bullet chart (vs. target) ────────────────────────────────────────────
function Bullet({ value, target, max, height = 8, accent = "var(--accent)" }) {
  const v = Math.min(value / max, 1);
  const t = Math.min(target / max, 1);
  return (
    <div style={{ position: "relative", height, background: "var(--grid)", borderRadius: 2 }}>
      <div style={{ position: "absolute", left: 0, top: 0, height: "100%", width: (v*100) + "%", background: accent, borderRadius: 2 }} />
      <div style={{ position: "absolute", left: (t*100) + "%", top: -2, height: height + 4, width: 2, background: "var(--text-2)" }} />
    </div>
  );
}

// ─── Drill modal (3-level support) ────────────────────────────────────────
function DrillModal({ trail, onClose, onPop, children }) {
  // trail = [{ label, onClick? }] — last one is current
  useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose && onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);
  if (!trail) return null;
  return (
    <div className="pc-modal-backdrop" onClick={onClose}>
      <div className="pc-modal" onClick={(e) => e.stopPropagation()}>
        <div className="pc-modal-hd">
          <div className="pc-breadcrumb">
            {trail.map((t, i) => (
              <React.Fragment key={i}>
                {i > 0 && <span style={{ opacity: 0.4 }}>›</span>}
                <span style={{
                  cursor: i < trail.length - 1 ? "pointer" : "default",
                  color: i === trail.length - 1 ? "rgba(255,255,255,0.95)" : "var(--text-3)",
                  fontWeight: i === trail.length - 1 ? 500 : 400,
                }}
                  onClick={() => i < trail.length - 1 && onPop && onPop(i)}>
                  {t.label}
                </span>
              </React.Fragment>
            ))}
          </div>
          <button className="pc-icon-btn" onClick={onClose} title="Close">✕</button>
        </div>
        <div className="pc-modal-body">{children}</div>
      </div>
    </div>
  );
}

// ─── Donut ────────────────────────────────────────────────────────────────
function Donut({ segments, size = 96, stroke = 12, center }) {
  const total = segments.reduce((s, x) => s + x.value, 0);
  const r = (size - stroke) / 2;
  const C = 2 * Math.PI * r;
  let offset = 0;
  return (
    <div style={{ position: "relative", width: size, height: size }}>
      <svg width={size} height={size} style={{ transform: "rotate(-90deg)" }}>
        <circle cx={size/2} cy={size/2} r={r} fill="none" stroke="var(--border)" strokeWidth={stroke} />
        {segments.map((s, i) => {
          const dash = (s.value / total) * C;
          const arr = `${dash} ${C - dash}`;
          const node = <circle key={i} cx={size/2} cy={size/2} r={r} fill="none" stroke={s.color} strokeWidth={stroke}
            strokeDasharray={arr} strokeDashoffset={-offset} strokeLinecap="butt" />;
          offset += dash;
          return node;
        })}
      </svg>
      {center && (
        <div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column" }}>
          {center}
        </div>
      )}
    </div>
  );
}

// ─── Heatmap (for utilization etc) ────────────────────────────────────────
function Heatmap({ rows, cols, data, max, colorFn, cellSize = 22, gap = 2, rowLabels, colLabels }) {
  return (
    <div style={{ display: "inline-grid", gridTemplateColumns: `120px repeat(${cols.length}, ${cellSize}px)`, gap, alignItems: "center" }}>
      <div></div>
      {colLabels.map((c, i) => (
        <div key={i} style={{ fontSize: 9, color: "var(--text-3)", textAlign: "center" }}>{c}</div>
      ))}
      {rows.map((r, ri) => (
        <React.Fragment key={ri}>
          <div style={{ fontSize: 11, color: "var(--text-2)", paddingRight: 8 }}>{rowLabels[ri]}</div>
          {cols.map((c, ci) => {
            const v = data[ri][ci];
            return <div key={ci} title={`${rowLabels[ri]} • ${colLabels[ci]}: ${v}`}
              style={{ width: cellSize, height: cellSize, borderRadius: 2, background: colorFn(v, max) }} />;
          })}
        </React.Fragment>
      ))}
    </div>
  );
}

// ─── CSV / Excel export ───────────────────────────────────────────────────
// Generates a tab-separated .xls file (which Excel and Numbers open natively).
function exportRows(filename, rows, sheetName = "Sheet1") {
  if (!rows || rows.length === 0) return;
  const headers = Object.keys(rows[0]);
  const escape = (v) => {
    if (v === null || v === undefined) return "";
    let s = typeof v === "object" ? JSON.stringify(v) : String(v);
    // Strip leading currency symbols and commas for numeric-looking strings so Excel reads as number
    return s.replace(/\t/g, " ").replace(/\n/g, " ");
  };
  // XML SpreadsheetML (Excel 2003) — opens in Excel, preserves headers
  const xmlEscape = (s) => String(s)
    .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;").replace(/'/g, "&apos;");
  const isNumber = (v) => typeof v === "number" || (typeof v === "string" && /^-?[\d.,]+%?$/.test(v));
  const cellType = (v) => isNumber(v) && typeof v === "number" ? "Number" : "String";
  const cellValue = (v) => isNumber(v) && typeof v === "number" ? v : xmlEscape(escape(v));
  const headerCells = headers.map(h => `<Cell ss:StyleID="h"><Data ss:Type="String">${xmlEscape(h)}</Data></Cell>`).join("");
  const bodyRows = rows.map(row =>
    `<Row>${headers.map(h => `<Cell><Data ss:Type="${cellType(row[h])}">${cellValue(row[h])}</Data></Cell>`).join("")}</Row>`
  ).join("");
  const xml = `<?xml version="1.0"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
 xmlns:o="urn:schemas-microsoft-com:office:office"
 xmlns:x="urn:schemas-microsoft-com:office:excel"
 xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
<Styles><Style ss:ID="h"><Font ss:Bold="1"/><Interior ss:Color="#E8E8E8" ss:Pattern="Solid"/></Style></Styles>
<Worksheet ss:Name="${xmlEscape(sheetName)}">
<Table>
<Row>${headerCells}</Row>
${bodyRows}
</Table>
</Worksheet>
</Workbook>`;
  const blob = new Blob([xml], { type: "application/vnd.ms-excel" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename.endsWith(".xls") ? filename : filename + ".xls";
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  setTimeout(() => URL.revokeObjectURL(url), 200);
}

function ExportButton({ rows, filename, label = "Export", sheet }) {
  return (
    <button className="pc-btn-mini ghost" onClick={() => exportRows(filename, rows, sheet || filename)} title="Export to Excel">
      <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
        <path d="M12 3v14 M5 12l7 7 7-7 M3 21h18" />
      </svg>
      {label}
    </button>
  );
}

// Export everything
Object.assign(window, {
  Sparkline, AreaChart, BarChart, StackedBar, KPICard, SevPill, Bullet, DrillModal, Donut, Heatmap,
  fmtUSD, fmtPct, fmtNum, deltaPct,
  exportRows, ExportButton,
});
