// BusinessHealthRead — window.BusinessHealthRead + window.BusinessHealthPage
//
// The plain-language "how is my business doing" read, for an owner with no
// financial background. Two layers:
//
//   Layer A — universal health. What every business is judged on regardless of
//     what it sells: profitability, cash, collections, cost trend, concentration.
//   Layer B — the industry read. The metrics THIS industry lives by, in its own
//     words, sourced entirely from the `industries` overlay row that data-live.js
//     attaches to the profile (kpi_emphasis / benchmark_ranges /
//     sensitivity_thresholds / vocabulary).
//
// THE THESIS: explained, not displayed. No item is ever a bare number. Each one
// states what it is in a sentence an owner reads once, where it sits against a
// real benchmark, and WHY it moved — gated by attribution, so a movement with no
// establishable cause says exactly that instead of offering a plausible guess.
// Drill-through is on every number and required for none of them.
//
// NO CONDITIONALS ON INDUSTRY OR TENANT LIVE HERE. Layer A is a declarative
// aspect catalog keyed by KPICatalog metric id; Layer B is whatever the overlay
// row says. Adding an aspect = adding a catalog entry. Adding an industry =
// seeding an `industries` row. Neither touches this file.
//
// Honest states (no fabrication, ever):
//   · a metric whose inputs aren't all present does NOT render (strict
//     hasAllRequiredData gate — no dimmed "not available" rows)
//   · a metric with no real benchmark renders its value and NO comparison
//   · a thin/absent industry overlay drops Layer B entirely and the universal
//     read stands alone — industry depth is never faked
//
// Props: { data, companyProfile, scopedCompanyId, globalPeriod, compact?, setPage? }

(function () {
  const h = React.createElement;

  // ── Layer A — the universal aspect catalog ─────────────────────────────────
  // Each entry binds a KPICatalog metric id to how it should be SAID. `say`
  // renders the value as a sentence; `sayBm` renders the benchmark clause, and
  // is omitted from the output entirely when no real band exists.
  //
  // `weight` is the aspect's inherent importance and only breaks ties — the
  // ranking that matters is computed from benchmark position and movement.
  // `tip` names a MetricTooltip glossary key when the sentence leans on a term.
  const UNIVERSAL = [
    {
      metric: "gross_margin_pct", aspect: "Profitability", weight: 5, tip: "Gross Margin",
      question: "Does what you sell leave enough behind?",
      say: (v, C) => "You keep " + C.per100(v) + " of every " + C.unit100 + " of sales, after paying for what you sold",
      sayBm: (b, C) => "typical for your industry is " + C.per100(b),
    },
    {
      metric: "ebitda_margin_pct", aspect: "Profitability", weight: 4,
      question: "Is the business actually making money?",
      say: (v, C) => C.per100(v) + " of every " + C.unit100 + " of sales is left as profit once running costs are paid",
      sayBm: (b, C) => "typical is " + C.per100(b),
    },
    {
      metric: "opex_ratio", aspect: "Cost trend", weight: 4,
      question: "Are your overheads under control?",
      say: (v, C) => "Overheads take " + C.per100(v) + " of every " + C.unit100 + " of sales",
      sayBm: (b, C) => "typical is " + C.per100(b),
    },
    {
      metric: "cash_reserves_days", aspect: "Cash", weight: 5,
      question: "How long could you keep the lights on?",
      say: (v, C) => "Your cash covers " + C.days(v) + " of running costs",
      sayBm: (b, C) => "typical is " + C.days(b),
    },
    {
      metric: "cash_runway_months", aspect: "Cash", weight: 5,
      question: "Is cash running out?",
      say: (v, C) => "At the current rate you're spending more than you bring in, cash lasts " + C.months(v),
      sayBm: (b, C) => "typical is " + C.months(b),
    },
    {
      metric: "operating_cf", aspect: "Cash", weight: 4,
      question: "Is the business producing cash or consuming it?",
      say: (v, C) => (v >= 0 ? "The business produced " + C.money(v) + " of cash from operations"
                             : "The business consumed " + C.money(Math.abs(v)) + " of cash in operations"),
      sayBm: (b, C) => "typical is " + C.money(b),
    },
    {
      metric: "dso_days", aspect: "Collections", weight: 4, tip: "DSO",
      question: "Are your customers paying you on time?",
      say: (v, C) => "Customers take " + C.days(v) + " to pay you",
      sayBm: (b, C) => "typical is " + C.days(b),
    },
    {
      metric: "overdues_ratio", aspect: "Collections", weight: 4,
      question: "Is money you're owed going stale?",
      say: (v, C) => C.pct(v) + " of what you're owed is more than 60 days late",
      sayBm: (b, C) => "typical is " + C.pct(b),
    },
    {
      metric: "dpo_days", aspect: "Cost trend", weight: 2, tip: "DPO",
      question: "How fast are you paying suppliers?",
      say: (v, C) => "You take " + C.days(v) + " to pay your suppliers",
      sayBm: (b, C) => "typical is " + C.days(b),
    },
    {
      metric: "revenue_growth_yoy", aspect: "Growth", weight: 3,
      question: "Is the business growing?",
      say: (v, C) => (v >= 0 ? "Sales are up " + C.pct(v) + " on last year"
                             : "Sales are down " + C.pct(Math.abs(v)) + " on last year"),
      sayBm: (b, C) => "typical is " + C.pct(b),
    },
  ];

  // Concentration is the one universal aspect with no formula: it is computed
  // from the AR subledger by PerduraAnalytics, which carries its own
  // {available, reason} gate. Sourced there rather than mapped onto a lookalike.
  const CONCENTRATION = {
    aspect: "Concentration", weight: 5,
    question: "How much rides on one customer?",
  };

  function Read(props) {
    const K = window.PerduraPageKit;
    const KC = window.KPICatalog || {};
    const PE = window.PerduraProfileEngine;
    const F = window.PerduraFormat;
    if (!K) return null;

    const { data, companyProfile, scopedCompanyId, compact } = props;
    const plH = (data && (data.plHistory || data.pl)) || {};
    const cash = (data && data.cash) || null;

    // Period — the global selector is the source of truth when set; otherwise
    // the trailing twelve months, matching every other analytical surface.
    const anchor = K.anchorFromPlH(plH);
    const gpr = (props.globalPeriod && props.globalPeriod.resolved) || null;
    const range = gpr
      ? { start: new Date(gpr.startDate), end: new Date(gpr.endDate), label: gpr.label }
      : K.resolvePeriod("ltm", anchor, null);
    const cmpRange = K.comparePeriod(range, "prior_period");
    const roll = (span) => (span && span[1] >= span[0]) ? {
      revenue: K.sumIdx(plH.revenue, span), cogs: K.sumIdx(plH.cogs, span),
      opex: K.sumIdx(plH.opex, span), gp: K.sumIdx(plH.gp, span), ebitda: K.sumIdx(plH.ebitda, span),
    } : null;
    const T = K.ttm(plH);
    const W = {
      cur: roll(K.plIdxRange(plH, range)) || (T ? T.cur : null),
      prior: (cmpRange ? roll(K.plIdxRange(plH, cmpRange)) : null) || (T ? T.prior : null),
    };
    if (!W.cur) return null;

    // Canonical archetype — prefers the industry overlay's own archetype_slug,
    // falls back to the classifier. Never an industry string comparison.
    const canon = (PE && PE.canonicalArchetype) ? PE.canonicalArchetype(companyProfile || {}).canon
      : ((companyProfile && companyProfile.archetype) || "default");
    const overlay = (companyProfile && companyProfile.industry_overlay) || null;

    // ── Currency-safe plain language ────────────────────────────────────────
    // "47¢ of every sales dollar" only reads correctly in USD. Per-100 in the
    // tenant's own symbol reads correctly in every currency the platform
    // supports, and is no less plain. Driven by the format profile, not by any
    // tenant conditional.
    const symbol = (F && F.currencyDef) ? (F.currencyDef().symbol || "$") : "$";
    const money = (v) => (F && F.money) ? F.money(v, { compact: true }) : (symbol + Math.round(v || 0).toLocaleString());
    const C = {
      unit100: symbol + "100",
      per100: (frac) => symbol + Math.round((Number(frac) || 0) * 100),
      pct: (frac) => Math.round((Number(frac) || 0) * 100) + "%",
      days: (v) => Math.round(v) + (Math.round(v) === 1 ? " day" : " days"),
      months: (v) => (Number(v).toFixed(1)) + " months",
      money: money,
    };

    // ── The data shape the catalog formulas consume ─────────────────────────
    // Mirrors the KPI Scorecard's shape so both surfaces compute the same
    // numbers from the same sources. Absent fields stay `undefined` (never
    // null) so a formula yields NaN rather than a fake zero — and the strict
    // gate then drops the metric instead of rendering a wrong number.
    const aging = (data && data.aging) || {};
    const bsData = (data && (data.bs || data.balance_sheet || data.balanceSheet)) || {};
    const cfData = (data && (data.cashflow || data.cash_flow)) || {};
    const arOut = (aging.ar && aging.ar.outstanding != null) ? Number(aging.ar.outstanding) : null;
    const apOut = (aging.ap && aging.ap.outstanding != null) ? Number(aging.ap.outstanding) : null;
    const ar61 = (aging.ar && Array.isArray(aging.ar.buckets))
      ? (Number(aging.ar.buckets[2] || 0) + Number(aging.ar.buckets[3] || 0)) : null;

    const buildShape = (w, isCur) => {
      const s = {
        revenue: w.revenue || 0, cogs: w.cogs || 0, operating_expenses: w.opex || 0,
        gross_profit: w.gp || 0, ebitda: w.ebitda || 0,
        monthly_revenue: w.revenue ? w.revenue / 12 : undefined,
        monthly_opex: w.opex ? w.opex / 12 : undefined,
      };
      if (isCur) {
        s.cash = (cash && Number.isFinite(cash.endCash)) ? cash.endCash : (bsData.cash || undefined);
        s.revenue_prior_year = (W.prior && W.prior.revenue) || undefined;
        const burn = (s.monthly_opex || 0) - (s.monthly_revenue || 0);
        s.monthly_burn = burn > 0 ? burn : undefined;   // no burn → no runway metric, honestly
        s.ar_total = arOut != null ? arOut : undefined;
        s.ap_total = apOut != null ? apOut : undefined;
        s.ar_61plus = ar61 != null ? ar61 : undefined;
        s.inventory = bsData.inventory || undefined;
        s.net_income = (data && data.net_income_ltm != null) ? data.net_income_ltm : undefined;
        s.operating_cf = cfData.operating || cfData.operating_cf || undefined;
      }
      return s;
    };
    const shape = buildShape(W.cur, true);
    const priorShape = W.prior ? buildShape(W.prior, false) : null;

    // ── Drill wiring — every number opens the universal DrillPanel ──────────
    // GL codes are resolved from the tenant's OWN txns + taxonomy, so a metric
    // drills into its real composition (line ▸ account ▸ transactions).
    const tax = window.PerduraTaxonomy;
    const allTxns = (data && Array.isArray(data.txns)) ? data.txns : [];
    const codesForSection = (section) => {
      if (!tax || !tax.sectionForCategory || !allTxns.length) return [];
      const seen = {};
      for (let i = 0; i < allTxns.length; i++) {
        const t = allTxns[i];
        if (t.account_code == null || !t.canonical_category) continue;
        let sec = null; try { sec = tax.sectionForCategory(t.canonical_category, companyProfile); } catch (e) {}
        if (sec === section) seen[String(t.account_code)] = true;
      }
      return Object.keys(seen);
    };
    const toISO = (d) => (d instanceof Date && !isNaN(d)) ? d.toISOString().slice(0, 10) : null;
    const drill = (metricId) => {
      const dr = KC.getKPIDrill ? KC.getKPIDrill(metricId) : null;
      if (dr && dr.kind === "pl" && window.openDrillPanel) {
        const codes = codesForSection(dr.section);
        if (codes.length) {
          window.openDrillPanel({
            name: dr.label, category: dr.label, pageType: "income_statement", codes: codes,
            start: toISO(range.start), end: toISO(range.end),
            back: "business_health", backLabel: "Business Health",
          });
          return true;
        }
      }
      if (dr && dr.kind === "page") {
        if (dr.drillKey && window.__perduraSetDrillKey) window.__perduraSetDrillKey(dr.drillKey);
        window.__perduraSetPage && window.__perduraSetPage(dr.page);
        return true;
      }
      return false;
    };

    // ── The attribution gate ────────────────────────────────────────────────
    // Mirrors the Movement Explainer's contract: narration is only allowed to
    // claim what the decomposition establishes. explainKPIInputs is pure math on
    // the metric's own declared inputs — so "why" is derived, never authored.
    //   attributed   EVERY input is comparable, one moved materially, rest flat
    //   partial      several moved, OR some input can't be compared at all
    //   unattributed no prior window, nothing comparable, or nothing moved
    //
    // The uncomparable-input case is the one that matters. Point-in-time inputs
    // (cash, AR, AP) are only carried on the current window, so a metric like
    // "days of cash" can compare its opex input but not its cash input. Claiming
    // "opex moved and nothing else did" would be a fabricated certainty about the
    // input we cannot see. Any uncomparable input therefore caps the badge at
    // `partial` and is named out loud as uncompared.
    const MATERIAL = 0.02;   // 2% — below this an input is treated as flat
    const attribute = (metricId) => {
      if (!priorShape) {
        return { badge: "unattributed", why: "No earlier period to compare against yet, so no cause is established.", inputs: [] };
      }
      const all = (KC.explainKPIInputs ? KC.explainKPIInputs(metricId, shape, priorShape) : []);
      const inputs = all.filter((x) => x.deltaPct != null);
      const uncompared = all.filter((x) => x.deltaPct == null);
      const uncomparedNames = uncompared.map((x) => x.label.toLowerCase()).join(" and ");
      if (!inputs.length) {
        return { badge: "unattributed", inputs: all,
          why: "The inputs behind this can't be compared to the earlier period, so no cause is established." };
      }
      const movers = inputs.filter((x) => Math.abs(x.deltaPct) >= MATERIAL)
        .sort((a, b) => Math.abs(b.deltaPct) - Math.abs(a.deltaPct));
      const dir = (x) => (x.deltaPct >= 0 ? "rose" : "fell");
      const amt = (x) => Math.abs(Math.round(x.deltaPct * 100)) + "%";
      if (!movers.length) {
        return { badge: "unattributed", inputs: all,
          why: uncompared.length
            ? "What can be compared held steady, and " + uncomparedNames + " can't be compared to the earlier period — so no cause is established."
            : "Nothing underneath this moved much — it held roughly steady." };
      }
      const m = movers[0];
      const lead = m.label + " " + dir(m) + " " + amt(m) + " to " + money(m.cur);
      if (uncompared.length) {
        return { badge: "partial", inputs: all,
          why: lead + ", but " + uncomparedNames + " can't be compared to the earlier period. That's part of the picture, not all of it." };
      }
      if (movers.length === 1) {
        return { badge: "attributed", inputs: all,
          why: lead + ", and nothing else underneath it moved much. That accounts for the change." };
      }
      const b = movers[1];
      return { badge: "partial", inputs: all,
        why: lead + " and " + b.label.toLowerCase() + " " + dir(b) + " " + amt(b) +
          ". Both are pulling on it, so no single cause explains the change on its own." };
    };

    // Status against a RESOLVED band, using the platform's own thresholds
    // (±5% of median = on track, within 15% = watch, beyond = below).
    const statusFor = (value, bm, direction) => {
      if (!bm || bm.p50 == null || !bm.p50) return "none";
      const ratio = direction === "dn" ? (value === 0 ? Infinity : bm.p50 / value) : value / bm.p50;
      if (!Number.isFinite(ratio)) return "none";
      return ratio >= 1.05 ? "green" : ratio >= 0.85 ? "amber" : "red";
    };

    // ── Build one item ──────────────────────────────────────────────────────
    // Returns null when the metric can't be honestly rendered: unknown id,
    // missing inputs, or a formula that doesn't produce a finite number.
    const buildItem = (metricId, def, layer) => {
      const kpi = (KC.KPI_CATALOG || []).find((k) => k.id === metricId);
      if (!kpi || !kpi.formula) return null;
      if (KC.hasAllRequiredData && !KC.hasAllRequiredData(metricId, shape)) return null;
      const value = kpi.formula(shape);
      if (value == null || !Number.isFinite(value)) return null;
      const prior = (priorShape && kpi.formula) ? kpi.formula(priorShape) : null;
      const bm = KC.getBenchmarkFor ? KC.getBenchmarkFor(metricId, canon, overlay) : null;
      // Judged against the band actually SHOWN. KC.getKPIStatus re-derives the
      // archetype band internally, which would rate a metric red against the
      // archetype median while the industry band beside it shows the same value
      // as better than typical. Same thresholds, resolved band.
      const status = statusFor(value, bm, kpi.direction);

      // Sentence: the value clause always; the benchmark clause only when a real
      // band exists. No band → no comparison, no invented "typical". When the
      // value and the median are within a rounding of each other, the comparison
      // clause would just restate the same figure — say it's typical instead.
      const typicalFor = (bm && bm.source === "industry") ? "for your industry" : "for businesses like yours";
      let sentence = def.say(value, C);
      if (bm && bm.p50 != null && def.sayBm) {
        const rel = bm.p50 !== 0 ? Math.abs(value - bm.p50) / Math.abs(bm.p50) : null;
        sentence += (rel != null && rel < 0.05)
          ? " — about typical " + typicalFor
          : " — " + def.sayBm(bm.p50, C);
      }
      sentence += ".";

      const deltaPct = (prior != null && Number.isFinite(prior) && prior !== 0)
        ? (value - prior) / Math.abs(prior) : null;
      const good = deltaPct == null ? null
        : (kpi.direction === "dn" ? deltaPct < 0 : deltaPct > 0);

      return {
        id: metricId, layer: layer, aspect: def.aspect, question: def.question,
        label: KC.getKPILabel ? KC.getKPILabel(metricId, canon) : kpi.label,
        tip: def.tip || null, sentence: sentence, value: value, prior: prior,
        deltaPct: deltaPct, good: good, fmt: kpi.fmt, bm: bm, status: status,
        weight: def.weight || 3, attribution: attribute(metricId),
        drillable: !!(KC.getKPIDrill && KC.getKPIDrill(metricId)),
        sensitivity: null,
      };
    };

    // ── Materiality ─────────────────────────────────────────────────────────
    // Ranked by what matters, not by category order. A metric is material when
    // it sits BELOW its benchmark (only shortfalls — beating the benchmark is
    // good news, not a thing to lead with) or when it moved sharply. Weight
    // only breaks ties.
    const materiality = (it) => {
      let s = 0;
      if (it.status === "red") s += 100;
      else if (it.status === "amber") s += 50;
      if (it.deltaPct != null) {
        const swing = Math.min(Math.abs(it.deltaPct), 1) * 40;
        s += (it.good === false) ? swing : swing * 0.25;   // bad moves lead
      }
      s += it.weight;
      return s;
    };

    // ── Layer A ─────────────────────────────────────────────────────────────
    const layerA = UNIVERSAL.map((d) => buildItem(d.metric, d, "universal")).filter(Boolean);

    // Concentration — sourced from the subledger analytics' own gate.
    const conc = (data && data.analytics && data.analytics.customerHHI) || null;
    const topC = (data && data.analytics && data.analytics.topCustomers) || null;
    if (conc && conc.available && conc.top_share != null) {
      const topName = (topC && topC.available && topC.rows && topC.rows[0]) ? topC.rows[0].name : null;
      const share = conc.top_share;
      const sentence = (topName ? "Your biggest customer, " + topName + ", is " : "Your biggest customer is ") +
        Math.round(share * 100) + "% of everything you sell" +
        (conc.count ? ", across " + conc.count + " customers" : "") + ".";
      // A real band exists for this only when the overlay carries one; there is
      // no archetype fallback for concentration, so most tenants see the number
      // with no comparison — which is the honest outcome.
      const ranges = overlay && overlay.benchmark_ranges;
      const cbm = (ranges && ranges.top_customer_revenue_share) || null;
      layerA.push({
        id: "top_customer_revenue_share", layer: "universal", aspect: CONCENTRATION.aspect,
        question: CONCENTRATION.question, label: "Customer concentration", tip: null,
        sentence: sentence + (cbm && cbm.p50 != null ? " Typical for your industry is " + Math.round(cbm.p50 * 100) + "%." : ""),
        value: share, prior: null, deltaPct: null, good: null, fmt: "pct",
        bm: cbm && cbm.p50 != null ? { p25: cbm.p25, p50: cbm.p50, p75: cbm.p75, source: "industry" } : null,
        status: (cbm && cbm.p50 != null) ? (share > cbm.p75 ? "red" : share > cbm.p50 ? "amber" : "green") : "none",
        weight: CONCENTRATION.weight,
        attribution: {
          badge: "attributed",
          why: "Measured directly from your invoices: one customer's share of everything billed in the period.",
          inputs: [],
        },
        drillable: true, drillPage: "customer_intel", sensitivity: null,
      });
    }

    // ── Layer B — the industry read ─────────────────────────────────────────
    // Everything here comes from the overlay row. No overlay → no Layer B, and
    // the universal read stands alone rather than industry depth being faked.
    const emphasis = (overlay && Array.isArray(overlay.kpi_emphasis)) ? overlay.kpi_emphasis : [];
    const inA = {}; layerA.forEach((it) => { inA[it.id] = true; });
    const layerB = [];
    emphasis.forEach((overlayId) => {
      const catalogId = KC.resolveMetricId ? KC.resolveMetricId(overlayId) : null;
      if (!catalogId) return;                 // no computable equivalent → omitted
      const def = UNIVERSAL.find((d) => d.metric === catalogId);
      if (!def) return;                       // no plain-language rendering → omitted
      const it = buildItem(catalogId, def, "industry");
      if (!it) return;
      // The industry's own watch-threshold, translated from its closed grammar.
      const raw = overlay.sensitivity_thresholds && overlay.sensitivity_thresholds[overlayId];
      it.sensitivity = KC.humanizeSensitivity ? KC.humanizeSensitivity(raw) : null;
      layerB.push(it);
    });
    // A metric the industry emphasises is ITS read — don't say it twice.
    const inB = {}; layerB.forEach((it) => { inB[it.id] = true; });
    const layerAFinal = layerA.filter((it) => !inB[it.id]);

    layerAFinal.sort((a, b) => materiality(b) - materiality(a));
    layerB.sort((a, b) => materiality(b) - materiality(a));

    // Layer B only earns its heading when it actually has something real to say.
    const hasIndustryRead = !!(overlay && layerB.length);

    // ── Presentation ────────────────────────────────────────────────────────
    const BADGE = {
      attributed:   { dot: "🟢", label: "Explained",      tone: "#0f9d76", bg: "rgba(16,185,129,.08)" },
      partial:      { dot: "🟡", label: "Partly explained", tone: "#b8860b", bg: "rgba(184,146,30,.10)" },
      unattributed: { dot: "⚪", label: "No cause established", tone: "#6B7280", bg: "rgba(107,114,128,.08)" },
    };
    const fmtVal = (v, fmt) => {
      if (v == null || !Number.isFinite(v)) return "—";
      if (fmt === "pct") return (v * 100).toFixed(1) + "%";
      if (fmt === "days") return Math.round(v) + "d";
      if (fmt === "months") return v.toFixed(1) + "mo";
      if (fmt === "multiple") return v.toFixed(2) + "×";
      if (fmt === "dollar") return money(v);
      return String(v);
    };
    const label = (it) => (it.tip && window.MetricTooltip)
      ? h("span", { style: { display: "inline-flex", alignItems: "center" } }, it.label, h(window.MetricTooltip, { metric: it.tip }))
      : it.label;

    // Where it sits in the real band — rendered only when a band exists.
    const Band = (it) => {
      if (!it.bm || it.bm.p50 == null) return null;
      const lo = it.bm.p25, hi = it.bm.p75;
      if (lo == null || hi == null || hi === lo) return null;
      const pos = Math.max(0, Math.min(1, (it.value - lo) / (hi - lo)));
      const p50pos = Math.max(0, Math.min(1, (it.bm.p50 - lo) / (hi - lo)));
      const col = it.status === "green" ? K.POS : it.status === "red" ? K.NEG : "#D97706";
      return h("div", { style: { minWidth: 116, maxWidth: 140 },
        title: "Lower quarter " + fmtVal(lo, it.fmt) + " · middle " + fmtVal(it.bm.p50, it.fmt) + " · upper quarter " + fmtVal(hi, it.fmt) +
          (it.bm.source === "industry" ? " (your industry)" : " (businesses like yours)") },
        h("div", { style: { display: "flex", justifyContent: "space-between", fontSize: 8, color: "#9ca3af", marginBottom: 2 } },
          h("span", null, fmtVal(lo, it.fmt)), h("span", null, fmtVal(hi, it.fmt))),
        h("div", { style: { position: "relative", height: 6, background: "#EEF2F7", borderRadius: 999 } },
          h("div", { style: { position: "absolute", left: (p50pos * 100) + "%", top: -1, width: 1, height: 8, background: "#94a3b8" } }),
          h("div", { style: { position: "absolute", left: "calc(" + (pos * 100) + "% - 4px)", top: -1, width: 8, height: 8, borderRadius: "50%", background: col, border: "1px solid #fff" } })),
        h("div", { style: { fontSize: 8, color: "#9ca3af", marginTop: 2, textAlign: "center" } },
          it.bm.source === "industry" ? "your industry" : "businesses like yours"));
    };

    const Item = (it) => {
      const b = BADGE[it.attribution.badge] || BADGE.unattributed;
      const canDrill = it.drillable;
      const onDrill = () => {
        if (it.drillPage) { window.__perduraSetPage && window.__perduraSetPage(it.drillPage); return; }
        drill(it.id);
      };
      const arrow = it.deltaPct == null ? null
        : h("span", { style: { color: it.good === false ? K.NEG : it.good === true ? K.POS : K.MUTE, fontWeight: 700, fontSize: 11 } },
            (it.deltaPct >= 0 ? "↑" : "↓") + " " + Math.abs(Math.round(it.deltaPct * 100)) + "% vs " +
            (cmpRange ? cmpRange.label.toLowerCase() : "the earlier period"));

      return h("div", { key: it.layer + it.id,
        style: { padding: "14px 16px", borderTop: "1px solid " + K.LINE, display: "flex", gap: 14, alignItems: "flex-start", flexWrap: "wrap" } },

        // The read itself — the sentence IS the product. Everything else supports it.
        h("div", { style: { flex: "1 1 380px", minWidth: 260 } },
          h("div", { style: { fontSize: 10, color: "#8fa0c0", textTransform: "uppercase", letterSpacing: ".07em", fontWeight: 700, marginBottom: 3 } },
            it.aspect, h("span", { style: { color: "#cbd5e1", fontWeight: 400, textTransform: "none", letterSpacing: 0 } }, " · " + it.question)),
          h("div", { style: { fontSize: 14.5, color: K.INK, lineHeight: 1.5, fontWeight: 500 } }, it.sentence),

          // The why — gated. An unattributed movement says so plainly.
          h("div", { style: { marginTop: 7, display: "flex", gap: 7, alignItems: "flex-start" } },
            h("span", { title: "How much of this movement the data actually explains",
              style: { flexShrink: 0, fontSize: 9.5, fontWeight: 700, color: b.tone, background: b.bg, border: "1px solid " + b.tone + "33", borderRadius: 999, padding: "2px 7px", whiteSpace: "nowrap" } },
              b.dot + " " + b.label),
            h("div", { style: { fontSize: 12, color: "#475569", lineHeight: 1.5 } }, it.attribution.why)),

          it.sensitivity ? h("div", { style: { marginTop: 6, fontSize: 11.5, color: "#6475a0" } },
            "Your industry watches this for ", h("b", null, it.sensitivity), ".") : null,

          canDrill ? h("button", {
            onClick: onDrill,
            style: { marginTop: 8, background: "none", border: "none", padding: 0, cursor: "pointer", color: "#1C4ED8", fontSize: 11.5, fontWeight: 600, fontFamily: "inherit" },
          }, "See what's behind this →") : null),

        // The number + where it sits. Supporting, not the headline.
        h("div", { style: { flex: "0 0 auto", textAlign: "right", minWidth: 96 } },
          h("div", { title: canDrill ? "Drill into what drives this" : undefined,
            onClick: canDrill ? onDrill : undefined,
            style: { fontSize: 21, fontWeight: 800, fontFamily: "'JetBrains Mono','Geist Mono',monospace", color: K.INK, cursor: canDrill ? "pointer" : "default", lineHeight: 1.15 } },
            fmtVal(it.value, it.fmt)),
          h("div", { style: { fontSize: 10, color: K.MUTE, marginTop: 2 } }, label(it)),
          arrow ? h("div", { style: { marginTop: 3 } }, arrow) : null),

        h("div", { style: { flex: "0 0 auto" } }, Band(it)));
    };

    const Section = (title, sub, items) => h(K.Card, { padding: 0 },
      h("div", { style: { padding: "14px 16px 12px" } },
        h("div", { style: { fontSize: 14, fontWeight: 800, color: K.INK } }, title),
        sub ? h("div", { style: { fontSize: 12, color: K.MUTE, marginTop: 2, lineHeight: 1.45 } }, sub) : null),
      items.map(Item));

    if (!layerAFinal.length && !layerB.length) {
      return h(K.Card, null, h("div", { style: { padding: 20, color: K.MUTE, fontSize: 13, lineHeight: 1.6 } },
        "There isn't enough connected data yet to read how the business is doing. ",
        "Connect your accounting system or import a trial balance and this fills in on its own."));
    }

    // Headline — the one-line answer, before any detail.
    const worst = [].concat(layerB, layerAFinal).sort((a, b) => materiality(b) - materiality(a))[0];
    const attention = [].concat(layerAFinal, layerB).filter((it) => it.status === "red" || it.status === "amber").length;
    const measured = layerAFinal.length + layerB.length;
    const headline = attention === 0
      ? "Nothing is flashing red. Everything measured is at or above what's typical for businesses like yours."
      : attention + " of " + measured + " things worth watching" +
        (worst && (worst.status === "red" || worst.status === "amber") ? " — start with " + worst.label + "." : ".");

    const body = [
      h("div", { key: "headline", style: { padding: "13px 16px", background: "#0d2040", color: "#fff", borderRadius: 10, fontSize: 14.5, fontWeight: 600, lineHeight: 1.5 } },
        headline,
        h("div", { style: { fontSize: 11.5, fontWeight: 400, color: "#9fb3d9", marginTop: 4 } },
          "Ordered by what matters most, not by category. " + range.label + ". Every number opens the detail behind it — you never have to dig to understand the read.")),

      hasIndustryRead ? h("div", { key: "b" },
        Section(overlay.name + " — what your industry lives by",
          "The measures this industry is judged on, with its own benchmarks.",
          layerB)) : null,

      layerAFinal.length ? h("div", { key: "a", style: { marginTop: hasIndustryRead ? 12 : 0 } },
        Section(hasIndustryRead ? "Everything else that matters" : "How your business is performing",
          hasIndustryRead ? "Universal measures every business is judged on."
            : (overlay ? "Universal measures every business is judged on."
                       : "Universal measures every business is judged on. Set your industry in Settings to also see the measures your industry is judged on."),
          layerAFinal)) : null,
    ];

    if (compact) return h("div", { style: { display: "flex", flexDirection: "column", gap: 12 } }, body);

    return h("div", { style: { display: "flex", flexDirection: "column", gap: 12 } },
      body,
      // The server-computed, attribution-gated narrative for the P&L these
      // measures are built from — the same engine the statement pages use.
      window.MovementExplainer ? h(window.MovementExplainer, {
        key: "movement", tenantId: scopedCompanyId, pageType: "income_statement",
        periodStart: gpr ? gpr.startDate : null, periodEnd: gpr ? gpr.endDate : null,
        title: "What moved, and why",
      }) : null,

      h("div", { key: "foot", style: { fontSize: 11.5, color: "var(--text-3)", padding: "4px 2px", lineHeight: 1.6 } },
        "A measure only appears when your data supports it — nothing here is estimated or filled in. " +
        "Comparisons use real benchmark bands" +
        (hasIndustryRead ? " from your industry profile where it carries one, and from businesses of your type otherwise" : " for businesses of your type") +
        "; where no benchmark exists the number is shown on its own, without a comparison."));
  }

  // Page wrapper — the landing read as a destination.
  function Page(props) {
    const K = window.PerduraPageKit;
    if (!K) return h("div", { className: "pc-page" }, "Loading…");
    const profile = props.companyProfile || {};
    const name = profile.display_name || profile.name || "your business";
    return h(K.Shell, {
      hero: {
        eyebrow: "BUSINESS HEALTH",
        title: "How " + name + " is doing",
        subtitle: "The plain-language read — what's working, what isn't, and why.",
      },
    }, h(Read, props));
  }

  window.BusinessHealthRead = Read;
  window.BusinessHealthPage = Page;
})();
