/* global React, window */
// =========================================================
// STUDY-DESIGN FRAMEWORK — the single editable source of research rationale.
//
// This file is DATA, not UI. Everything the product says about *why* a study is
// designed the way it is resolves through here, so the research thinking can be
// revised without touching a component. If a rationale reads wrong in the mock,
// fix it here.
//
// TONE: talk like a person who knows research, not like a research tool. Short
// sentences. Plain words. No "leverage", "signal", "instrument", "validity".
// Say what happens and what it costs the reader if it's wrong.
//
// The chain: Decision → Eligibility → Population → Divergence → Resolution.
//   Decision      what you're actually deciding
//   Eligibility   Demographics — who is allowed to answer     [shape: Guard]
//   Population    Consumer profiles — who we compare          [shape: Role]
//   Divergence    Critical questions — what could change it   [shape: If-then]
//   Resolution    Depth — whether you can read the difference
//
// Governing rule at every link: something earns its place only if a different
// answer would lead you to do something different.
// =========================================================

// ---------- Evidence: what we already know about each group ----------
// A Digital Twin is not an invention — it's a compression of THIS brand's prior
// respondents. So the honest unit of trust is coverage: how much history stands
// behind each group. Twin share is therefore an OUTPUT of coverage, never a dial
// someone set. Same rule we hold for credits.
//
// The vocabulary is deliberate: history / already asked / know well / never
// surveyed. Never "synthetic", "simulated" or "AI-generated" — those words
// confirm the fear they're meant to answer.
const SEGMENT_HISTORY = {
  "core loyalist": { studies: 11, responses: 6200, since: 2021 },
  "platform regular": { studies: 7, responses: 4100, since: 2022 },
  "co-op regular": { studies: 9, responses: 5300, since: 2021 },
  "daily trainer": { studies: 8, responses: 4400, since: 2022 },
  "competitor brand loyalist": { studies: 4, responses: 2600, since: 2023 },
  "trend-led switcher": { studies: 3, responses: 1800, since: 2024 },
  "trend-led shopper": { studies: 3, responses: 1650, since: 2024 },
  "weekend hiker": { studies: 2, responses: 1200, since: 2024 },
  "brand-aware switcher": { studies: 2, responses: 900, since: 2025 },
  "cross-brand shopper": { studies: 2, responses: 1050, since: 2024 },
  "crossover buyer": { studies: 1, responses: 620, since: 2025 },
  "performance-first buyer": { studies: 3, responses: 1900, since: 2023 },
  "everyday runner": { studies: 4, responses: 2400, since: 2023 },
  "weekend racer": { studies: 1, responses: 540, since: 2025 },
  "value seeker": { studies: 2, responses: 1100, since: 2024 },
  // The ones you're losing are the ones you never got to ask.
  "lapsed customer": { studies: 0, responses: 0 },
  "lapsed platform buyer": { studies: 0, responses: 0 },
  "lapsed co-op member": { studies: 0, responses: 0 },
  "switched-away runner": { studies: 0, responses: 0 },
  "price-first outdoor buyer": { studies: 0, responses: 0 },
  "discount-code buyer": { studies: 0, responses: 0 }
};

// One paired-study record, and the two numbers a merchant can actually use.
// SCHEMA PROPOSAL — for the engineering conversation, not a measured result:
//   paired      studies run twin-and-human in parallel on the same script
//   sameCall    how often twins picked the same winner (the decision metric)
//   meanGapPts  mean absolute gap on top-line scores (the precision metric)
// "Same call" leads because it answers the question they're really asking —
// would I have made the same decision? — and it is falsifiable.
const BACKTEST = { paired: 18, sameCall: 17, meanGapPts: 2.1, since: 2024 };
const backtestLine = () => "Ran side by side with a live human panel " + BACKTEST.paired +
  " times. Same winning answer in " + BACKTEST.sameCall + " of " + BACKTEST.paired +
  ", and within " + BACKTEST.meanGapPts + " points on the scores.";

// Bands, not false precision. A band maps to a twin ceiling for that group.
const COVERAGE_BANDS = {
  deep: { id: "deep", label: "Know them well",
    tone: "#3a8518", note: "Enough history to answer without asking again." },
  partial: { id: "partial", label: "Know them a bit",
    tone: "#0070eb", note: "Some history, so we check it against real people." },
  syndicated: { id: "syndicated", label: "Industry data only",
    tone: "#8c5800", note: "No history of your own here yet — we start from industry data and verify with real people." },
  none: { id: "none", label: "Never asked them",
    tone: "#8c5800", note: "No history at all, so this group is real people only." }
};
// Segments a brand has never surveyed split two ways: ones the industry pool can
// speak to, and ones only real people can. Lapsed buyers are the second kind —
// by definition they left before you could ask them.
const NEVER_ASKED = /lapsed|switched.away|price-first|discount/i;
const FULL_HISTORY = 6200; // the best-covered segment we have
const SYNDICATED_STRENGTH = 34; // backtested industry data, no history of your own
const LAPSED_STRENGTH = 12;     // hardest case: they left before you could ask
function strengthOf(h, bandId) {
  if (!h || !h.responses) return bandId === "none" ? LAPSED_STRENGTH : SYNDICATED_STRENGTH;
  return Math.max(8, Math.min(100, Math.round(100 * h.responses / FULL_HISTORY)));
}
// Drafted profiles carry brand-flavoured names ("Boho Core Loyalists") for the
// same groups the history is filed under ("core loyalist"), so the lookup
// matches on the group inside the name. Without this a drafted segment reads
// "never asked" on its card while being priced as fully covered.
function historyFor(key) {
  if (SEGMENT_HISTORY[key]) return { key, h: SEGMENT_HISTORY[key] };
  const norm = (s) => s.toLowerCase().replace(/[^a-z]+/g, " ").replace(/s\b/g, "").trim();
  const words = new Set(norm(key).split(" ").filter(w => w.length > 2));
  let best = null;
  for (const name of Object.keys(SEGMENT_HISTORY)) {
    const parts = norm(name).split(" ").filter(w => w.length > 2);
    const hit = parts.filter(w => words.has(w)).length;
    // Half the group's words have to appear, so "Boho Core — Premium Tier" can
    // find "core loyalist" but "Price-Sensitive Explorer" stays honest.
    if (hit && hit >= Math.ceil(parts.length / 2) && (!best || hit > best.hit)) best = { key: name, h: SEGMENT_HISTORY[name], hit };
  }
  return best;
}
function coverageOf(name) {
  const key = String(name || "").toLowerCase().trim();
  const m = historyFor(key);
  const h = m && m.h;
  if (!h) return { ...COVERAGE_BANDS.syndicated, studies: 0, responses: 0, known: false, strength: SYNDICATED_STRENGTH };
  if (h.studies === 0) {
    const band = NEVER_ASKED.test(m.key) ? COVERAGE_BANDS.none : COVERAGE_BANDS.syndicated;
    return { ...band, studies: 0, responses: 0, known: true, strength: strengthOf(null, band.id) };
  }
  const band = (h.studies >= 4 && h.responses >= 2500) ? COVERAGE_BANDS.deep : COVERAGE_BANDS.partial;
  return { ...band, ...h, known: true, strength: strengthOf(h, band.id) };
}
// The line that sits on the profile card. Concrete where we can be, plain where
// we can't — the admission is the part that earns belief.
function coverageLine(name) {
  const c = coverageOf(name);
  if (c.id === "deep") return c.responses.toLocaleString() + " people from this group have answered before, across " +
    c.studies + " studies since " + c.since + ".";
  if (c.id === "partial") return c.responses.toLocaleString() + " have answered before, across " +
    c.studies + (c.studies === 1 ? " study" : " studies") + ".";
  // Syndicated groups run on backtested industry data — the line has to say what
  // stands behind them, or "haven't asked" reads as a contradiction next to a
  // twin share and a field time with no recruiting in it.
  if (c.id === "syndicated") return "You haven't asked this group yet — they run on backtested industry data.";
  return "You've never been able to ask this group — they left before you could.";
}

// The same evidence at chip length, for cards where the sentence is too much:
// how many Digital Twins the group's history supports, across how many studies.
function coverageShort(name) {
  const c = coverageOf(name);
  if (c.responses > 0) return c.responses.toLocaleString() + " Digital Twins · " + c.studies + (c.studies === 1 ? " study" : " studies");
  if (c.id === "syndicated") return "Digital Twins trained on syndicated research";
  return "Never asked";
}

// ---------- Where the twins run out ----------
// A Digital Twin can only answer what this brand's history has taught it. These
// questions have no such history: nobody has been asked them before, so there is
// nothing to compress. They are the reason a study carries real people at all.
// (Mock: in the real product this comes from the training corpus per question.)
const TWIN_GAPS = {
  channel: "Channel mix has shifted since we last asked — the twins would answer on an old retail map.",
  shopfreq: "Frequency has never been asked as its own question; the history infers it.",
  sustain: "Sustainability language shifts faster than our history — twins would answer from last year's vocabulary.",
  discovery: "Discovery habits moved to channels we've never fielded on.",
  social: "No history on content behavior for this category.",
  occasion: "Wear occasion has never been asked as its own question here.",
  fitpref: "Fit language is product-specific — nothing generalisable to train on.",
  spend: "Stated spend needs a live read; the twins' history predates the current price ladder.",
  // Product questions: what a style is worth, how it reads in the hand and how a
  // new colorway lands can't be answered from history of styles nobody has seen.
  expprice: "Price expectations on unreleased styles need a live read — the history predates this ladder.",
  materials: "Material and finish impressions come from seeing the style; nothing in the history covers these fabrications.",
  colorway: "This season's colorways have never been in market, so there's no history to train on.",
  reasonwhy: "Open text is a human answer by definition — the twins have no language of their own to give."
};
// At the two-hour read the design is twin-answerable by construction: nothing
// that needs a fresh human answer can be in it, so nothing carries the badge.
const needsHuman = (attrId, tier) => tier !== "directional" && !!TWIN_GAPS[attrId];
const humanReason = (attrId) => TWIN_GAPS[attrId] || "";

// The panel mix, derived. Each group's share is its coverage capped by the
// depth's appetite; the headline percentage is their average. This is why the
// number moves when the audience moves, and why "why 70%?" has an answer.
const DEPTH_CURVE = {
  directional: { floor: 100, slope: 0 },
  decision:    { floor: 58,  slope: 0.42 },
  defensible:  { floor: 5,   slope: 0.95 }
};
// Questions with no twin training data have to be put to people, and they are put
// to people in every group — so they cap each segment's twin share. Only the
// two-hour read, whose design carries no such questions, can reach 100%.
const TWIN_CEILING = { directional: 100, decision: 45, defensible: 25 };
const shareFor = (tierId, strength) => {
  const c = DEPTH_CURVE[tierId] || DEPTH_CURVE.decision;
  const cap = TWIN_CEILING[tierId] != null ? TWIN_CEILING[tierId] : 85;
  return Math.max(0, Math.min(cap, Math.round((c.floor + c.slope * strength) / 5) * 5));
};
// Colour is a fact about the number, so it comes from the share — not the band.
// All three traceable to the palette: --green, --muted / --ms-ink-3, --amber-tx.
const shareTone = (share) => share >= 75 ? "#3a8518" : share >= 45 ? "#636c79" : "#8c5800";

// The history is a fact; what we do about it depends on the depth. Keeping the
// two in separate sentences is what lets one group read "answer without asking
// again" at Quick read and "real people behind the rest" at Fully defensible
// without either line contradicting the number beside it.
function mixConsequence(bandId, share) {
  const thin = bandId === "none" || bandId === "syndicated";
  if (share === 0) return "Real people only.";
  if (share === 100) {
    if (bandId === "deep") return "Enough history to answer without asking again.";
    if (bandId === "partial") return "Enough to answer on at this depth.";
    return "Backtesting against comparable shoppers covers this group for a read at this depth.";
  }
  if (thin) return "Backtesting against comparable shoppers gives us " + share +
    "% we can lean on, and real people cover the other " + (100 - share) + "%.";
  return "We run " + share + "% Digital Twins here and put real people behind the other " + (100 - share) + "%.";
}
function panelMix(pool, tierId, opts) {
  const o = opts || {};
  const list = (pool || []).slice(0, o.limit || (pool || []).length);
  // Confidentiality is a hard guarantee, so it overrides coverage entirely —
  // including for groups we would otherwise field with real people.
  if (o.confidential) {
    return { twin: 100, confidential: true,
      rows: list.map(p => {
        const band = coverageOf(p.name);
        return { name: p.name, band, share: 100, tone: shareTone(100), label: "Digital Twins",
          line: band.share === 100 ? coverageLine(p.name)
            : "These assets can't be shown to a human panel, so this group runs on Digital Twins" +
              (band.share === 0 ? " built from industry data." : " for now.") };
      }),
      why: "These assets can't be shown to a human panel, so every group runs on Digital Twins." };
  }
  const rows = list.map(p => {
    const band = coverageOf(p.name);
    const share = shareFor(tierId, band.strength);
    return { name: p.name, band, share, tone: shareTone(share),
      label: share === 100 ? "Digital Twins" : share === 0 ? "Real people" : share + "% Twins",
      line: coverageLine(p.name) + " " + mixConsequence(band.id, share) };
  });
  if (!rows.length) return { twin: shareFor(tierId, 100), rows, why: "" };
  const twin = Math.round(rows.reduce((s, r) => s + r.share, 0) / rows.length);
  // Partition by share, not by band — a deep-coverage group capped below 100
  // belongs in "mixed", not in both. The parts must sum to rows.length.
  const full = rows.filter(r => r.share === 100).length;
  const mixed = rows.filter(r => r.share > 0 && r.share < 100).length;
  const fresh = rows.filter(r => r.share === 0).length;
  const n = rows.length;
  const grp = (k) => k === 1 ? "group" : "groups";
  let why;
  const anyThin = rows.some(r => r.band.id === "none" || r.band.id === "syndicated");
  if (full === n) why = anyThin
    ? "Every group runs on Digital Twins — your own history where you have it, backtested industry data where you don't."
    : n === 1 ? "This is a group we can answer from what you already know."
    : "Every group here runs on what you already know.";
  else {
    const parts = [];
    if (full) parts.push(full + " of your " + n + " " + grp(n) + " " + (full === 1 ? "runs" : "run") + " entirely on what you already know");
    if (mixed) parts.push((full ? mixed : mixed + " of your " + n + " " + grp(n)) + " " + (mixed === 1 ? "gets" : "get") + " real people alongside the twins");
    if (fresh) parts.push((full || mixed ? fresh : fresh + " of your " + n + " " + grp(n)) + " " + (fresh === 1 ? "is" : "are") + " real people only");
    why = (parts.length > 2 ? parts.slice(0, -1).join(", ") + ", and " + parts[parts.length - 1] : parts.join(", and ")) + ".";
  }
  return { twin, rows, why, full, mixed, fresh };
}

// ---------- Population: the three roles ----------
// A profile is a stance toward the decision, not a demographic cluster.
const PROFILE_ROLES = {
  protect: { id: "protect", label: "Protect", tone: "#3a8518",
    def: "The people you already have.",
    stake: "These are your regulars. If they go cold on this, you lose sales you already had." },
  acquire: { id: "acquire", label: "Acquire", tone: "#0070eb",
    def: "The people you're trying to win.",
    stake: "These are the people you're trying to win. If they shrug, there's no growth in it." },
  defend: { id: "defend", label: "Defend", tone: "#8c5800",
    def: "The people you're losing.",
    stake: "You're losing these people. This tells you whether you can get them back." }
};
const ROLE_ORDER = ["protect", "acquire", "defend"];

// Explicit assignments win; the heuristic covers profiles the model invents.
const ROLE_MAP = {
  "core loyalist": "protect", "platform regular": "protect", "co-op regular": "protect",
  "daily trainer": "protect", "competitor brand loyalist": "protect",
  "trend-led switcher": "acquire", "trend-led shopper": "acquire", "weekend hiker": "acquire",
  "crossover buyer": "acquire", "brand-aware switcher": "acquire", "cross-brand shopper": "acquire",
  "performance-first buyer": "acquire", "weekend racer": "acquire", "everyday runner": "acquire",
  "value seeker": "defend", "lapsed customer": "defend", "discount-code buyer": "defend",
  "lapsed platform buyer": "defend", "price-first outdoor buyer": "defend", "lapsed co-op member": "defend",
  "switched-away runner": "defend"
};
const LOSING_THEM = /losing (share|them|ground)|switch(ing|ed)?\s+(away|to (a )?competitor)|defect|churn|lapsed|left (us|the brand)|moved (away|to a competitor)|abandon|no longer (buy|shop)/i;
const ROLE_HINTS = [
  [/lapsed|former|churn|left us|at.risk|discount|markdown|price-first|value|switched.away/i, "defend"],
  [/loyal|core|regular|repeat|member|daily|heavy|franchise/i, "protect"],
  [/switch|trend|new|crossover|prospect|cross-brand|curious|aware/i, "acquire"]
];
function roleOf(name, index, desc) {
  const n = String(name || "").toLowerCase().trim();
  if (desc && LOSING_THEM.test(String(desc))) return "defend";
  if (ROLE_MAP[n]) return ROLE_MAP[n];
  for (const [re, role] of ROLE_HINTS) if (re.test(n)) return role;
  return ROLE_ORDER[(index || 0) % 3];
}
// Why THIS profile is in the study. The segment's own description is NOT
// rationale — it's a definition, so it belongs on the card itself and is
// returned separately here.
function profileWhy(p, index) {
  const prof = (p && typeof p === "object") ? p : { name: p };
  const role = prof._role || roleOf(prof.name, index, prof.desc);
  const r = PROFILE_ROLES[role];
  const rank = index === 0 ? " Of the three, this is the one the whole decision turns on." : "";
  return { role, roleLabel: r.label, tone: r.tone, desc: String(prof.desc || "").trim(), why: r.stake + rank };
}

// Guarantee the trio is visible at the default depth without hand-ordering every
// pool. Position 1 is the most decision-critical pick and is never displaced;
// positions 2 and 3 are filled by the highest-ranked profiles of the two roles
// not yet represented. Works for authored and live pools alike.
function rankPool(profiles) {
  const list = (profiles || []).slice();
  if (list.length < 3) return list.map((p, i) => ({ ...p, _role: p._role || roleOf(p.name, i, p.desc) }));
  const tagged = list.map((p, i) => ({ ...p, _role: p._role || roleOf(p.name, i, p.desc) }));
  const head = tagged.shift();
  const out = [head];
  ROLE_ORDER.filter(r => r !== head._role).forEach(role => {
    const ix = tagged.findIndex(x => x._role === role);
    if (ix >= 0) out.push(tagged.splice(ix, 1)[0]);
  });
  return out.concat(tagged);
}

// Brands and acronyms keep their capitals; everything else reads as prose.
const SEG_BRANDS = /^(competitor brand|makersights|retailer [a-z])$/i;
const deCap = (w) => (/^[A-Z][a-z]+$/.test(w) && !SEG_BRANDS.test(w)) ? w.charAt(0).toLowerCase() + w.slice(1) : w;
function segPhrase(name) {
  const n = String(name || "").trim()
    .split(/\s+/).map(w => w.split("-").map(deCap).join("-")).join(" ");
  return /s$/i.test(n) ? n : n + "s";
}
function segmentList(pool) {
  const n = (pool || []).slice(0, 3).map(p => segPhrase(p.name));
  if (n.length >= 3) return n.slice(0, -1).join(", ") + " and " + n[n.length - 1];
  if (n.length === 2) return n[0] + " and " + n[1];
  return n[0] || "";
}
// Fallback only, for a source that declares no {segments} placeholder.
function audienceObjective(pool) {
  const n = (pool || []).slice(0, 3).map(p => segPhrase(p.name));
  if (n.length >= 3) return "Compare " + n[0] + " against " + n[1] + " and " + n[2];
  if (n.length === 2) return "Compare " + n[0] + " against " + n[1];
  if (n.length === 1) return "Read the study on " + n[0];
  return null;
}
// Substitute {segments} wherever the author put it, keeping their wording and
// punctuation. Only a source with no placeholder falls through — and then we
// only intervene if the line actually contradicts the panel.
function alignGoals(fp, pool) {
  const goals = ((fp || {}).goals || []).slice();
  if (!goals.length || !(pool || []).length) return fp;
  const ix = goals.findIndex(g => /\{segments\}/.test(g));
  if (ix >= 0) {
    goals[ix] = goals[ix].replace(/\{segments\}/g, segmentList(pool));
    return { ...fp, goals };
  }
  if (goals.length < 2) return fp;
  const stem = (s) => String(s).toLowerCase().replace(/s$/, "");
  const demoted = (pool || []).slice(3).map(p => stem(p.name));
  const g1 = String(goals[1] || "").toLowerCase();
  if (!demoted.some(n => n && g1.includes(n))) return fp;
  const line = audienceObjective(pool);
  if (!line) return fp;
  goals[1] = /[.!?]$/.test(goals[0] || "") ? line + "." : line;
  return { ...fp, goals };
}

// ---------- Eligibility: demographics ----------
// kind "gate"  — a qualification. Get it wrong and the answers are worthless.
// kind "quota" — a balance. Not a qualification; keeps the mix true to market.
const DEMO_RATIONALE = {
  geo: { kind: "gate", standard: true },
  income: { kind: "gate", guard: "Only asks people who could realistically buy at your price.",
    prevents: "Otherwise everything comes back \u201Ctoo expensive\u201D and you learn nothing else." },
  age: { kind: "gate", standard: true },
  gender: { kind: "gate", standard: true },
  locale: { kind: "quota", guard: "Balances city and suburb, who see different things in store.",
    prevents: "Otherwise you mistake what's on the shelf for what people prefer." },
  household: { kind: "quota", guard: "Balances household types against the market.",
    prevents: "One type dominating skews both budget and occasion." },
  education: { kind: "quota", guard: "Balance only \u2014 it doesn't decide who gets in.",
    prevents: "Here so the group looks like the market, nothing more." },
  employment: { kind: "quota", guard: "Balance only \u2014 it doesn't decide who gets in.",
    prevents: "Stops the panel filling up with whoever has time to answer surveys." }
};
const DEMO_DEFAULT = { kind: "quota", guard: "Balances the group against the market.",
  prevents: "Here for balance, not to decide who gets in." };
const demoWhy = (id) => DEMO_RATIONALE[id] || DEMO_DEFAULT;

// ---------- Divergence: critical questions ----------
// Every question says what we think will differ and what you'd do about it.
// No expectation, no question — that's the whole discipline of this link.
const CUT_RATIONALE = {
  pricetier: { expect: "People who shop at different price points feel differently about the same style",
    then: "you'd price it differently, or send different styles to different stores" },
  channel: { expect: "Full-price and off-price shoppers judge value against different numbers",
    then: "you'd change where a style sells, not whether you keep it" },
  shopfreq: { expect: "Frequent buyers want newness where occasional buyers want the basics",
    then: "you'd rethink how often you drop, and how much of it is core" },
  brandown: { expect: "People who own a competitor judge this against what's already in their closet",
    then: "you'd sharpen the case for switching" },
  lastpurchase: { expect: "Recent buyers want the familiar; lapsed ones want the new",
    then: "you'd know if this line holds your base or wins people back" },
  spend: { expect: "People disagree on what's worth paying more for",
    then: "you'd set the price where the drop-off actually starts" },
  sustain: { expect: "People who care about sustainability weigh materials differently",
    then: "you'd lead with the material story, or drop it" },
  fitpref: { expect: "How someone likes clothes to fit changes how they see the same shape",
    then: "you'd adjust the sizing, or say more about fit on the page" },
  social: { expect: "People who follow fashion online warm to new things sooner",
    then: "you'd change when you launch and who you seed it with" },
  occasion: { expect: "Work and going-out shoppers want different things from one style",
    then: "you'd move where it sits in the store rather than cut it" },
  discovery: { expect: "How someone found you colors what they already think",
    then: "you'd shift spend between social, stores and press" },
  loyalty: { expect: "Loyalty members are warmer about everything",
    then: "you'd take a little off the top when reading their scores" }
};
const CUT_DEFAULT = { expect: "This splits people into different answers",
  then: "you'd read those groups apart before deciding" };
const cutWhy = (id) => CUT_RATIONALE[id] || CUT_DEFAULT;
// `expect` is a FINITE CLAUSE that stands alone as a sentence.
function cutSentence(id) {
  const r = cutWhy(id);
  return { label: "Hypothesis", text: r.expect + ". If it holds, " + r.then + ".", tip: r.expect + "." };
}
// The gate/quota distinction is carried by the wording itself, not by a label.
function demoSentence(id) {
  const r = demoWhy(id);
  if (r.standard) return { kind: r.kind, standard: true, text: "", tip: "" };
  return { kind: r.kind, text: r.guard + " " + r.prevents, tip: r.prevents };
}

// ---------- What we deliberately left out ----------
// Anyone can add questions. Knowing what to leave out is the harder call, so we
// show it. Cost is computed live to keep the trade-off concrete.
function exclusions(m) {
  const cells = Math.max(1, (m && m.cells) || 1);
  const per = Math.max(40, Math.round(((m && m.n) || 500) / (cells * 2) / 5) * 5);
  return [
    { label: "Splitting the results by age",
      why: "We check age on the way in, but we don't read by it \u2014 there's no reason to think 30-year-olds want different styles here than 40-year-olds.",
      cost: "Doing it would halve every group again, to about " + per + " people each." },
    { label: "A catch-all \u201Canything else?\u201D box",
      why: "It always fills up, and it almost never changes a decision.",
      cost: "Slowest thing to read, and the structured questions already cover it." },
    { label: "A block of competitor questions",
      why: "You're choosing your own range here, not repositioning against a rival.",
      cost: "Another eight questions, and people start dropping out." }
  ];
}

// ---------- The staged reveal ----------
// Two forms of the same four steps: a short label while it's happening, and a
// line that stays in the conversation afterwards so the reasoning is still
// there if anyone goes looking.
function revealBeats(fp) {
  const who = (fp && fp.audience) ? String(fp.audience).replace(/\.$/, "") : "the people this affects";
  return [
    ["Reading what you're deciding\u2026", 1],
    ["Working out who should answer\u2026", 2],
    ["Picking the groups worth comparing\u2026", 3],
    ["Checking each group is big enough to read\u2026", 4]
  ];
}
const REOPEN_BEATS = [
  ["Reading back what this had to decide\u2026", 1],
  ["Checking who should answer\u2026", 2],
  ["Lining up the groups again\u2026", 3],
  ["Checking each group is still big enough\u2026", 4]
];
// The persistent version. Stays in the transcript.
function setupNote(fp, pool) {
  const who = (fp && fp.audience) ? String(fp.audience).replace(/\.$/, "") : "the people this affects";
  const names = segmentList(pool);
  return {
    title: "How I set this up",
    changes: [
      { section: "Who answers", detail: who.charAt(0).toUpperCase() + who.slice(1) + "." },
      { section: "Who we compare", detail: names ? names.charAt(0).toUpperCase() + names.slice(1) + "." : "The groups worth splitting out." }
    ]
  };
}

// ---------- Resolution ----------
const plural = (n, word) => n + " " + word + (n === 1 ? "" : "s");
const whenPhrase = (d) => (/^same day/i.test(d) ? d.toLowerCase() : "in " + d.toLowerCase());
function depthTrade(current, recommended, poolNames) {
  if (!current || !recommended || current === recommended) return null;
  const ci = window.TIER_ORDER.indexOf(current), ri = window.TIER_ORDER.indexOf(recommended);
  const cSpec = window.specOf(current), rSpec = window.specOf(recommended);
  const names = (poolNames || []).map(n => String(n));
  if (ci < ri) {
    const lost = names.slice(cSpec.profiles, rSpec.profiles);
    return { dir: "down",
      line: (lost.length ? "You won't see " + lost.join(" or ") + " separately" : "You won't see the smaller groups separately") +
        ". Fine if the decision doesn't hang on them \u2014 and you get it back " + whenPhrase(cSpec.delivery) + " instead of " + rSpec.delivery.toLowerCase() + "." };
  }
  const gained = names.slice(rSpec.profiles, cSpec.profiles);
  return { dir: "up",
    line: (gained.length ? "Adds " + gained.join(" and ") : "Adds the smaller groups") +
      ". Worth it if someone outside your team is going to ask who you spoke to." };
}

// ---------- The forwardable artifact ----------
// Your buyer's real worry isn't picking the wrong study — it's looking foolish
// for having picked it. This is what they send on when someone asks "why this
// audience?", so it reads like a note from a researcher, not a chat log.
function studyRationale({ name, account, objective, demos, cuts, profiles, metrics, tierLabel, sel }) {
  const L = [];
  L.push("STUDY RATIONALE — " + (name || "Untitled study"));
  if (account) L.push("Account: " + account);
  L.push("Depth: " + tierLabel);
  L.push("");
  L.push("WHAT WE'RE DECIDING");
  L.push(objective || "—");
  L.push("");
  L.push("WHO WE'RE ASKING  (and who we're not)");
  (demos || []).forEach(a => {
    const picked = (sel && sel[a.id] && sel[a.id].length) ? ": " + sel[a.id].join(", ") : "";
    const s = demoSentence(a.id);
    L.push("• " + a.name + picked + (s.text ? " — " + s.text : ""));
  });
  L.push("");
  L.push("THE GROUPS WE COMPARE");
  (profiles || []).forEach((p, i) => {
    const w = profileWhy(p, i);
    L.push("• " + p.name + " — " + w.roleLabel.toUpperCase() + ". " + (w.desc ? w.desc + " " : "") + w.why);
  });
  L.push("");
  L.push("WHAT WE THINK WILL SPLIT THEM");
  (cuts || []).forEach(a => { const s = cutSentence(a.id); L.push("• " + a.name + " — " + s.label + ": " + s.text); });
  L.push("");
  L.push("WHAT WE LEFT OUT ON PURPOSE");
  exclusions(metrics).forEach(e => L.push("• " + e.label + " — " + e.why + " " + e.cost));
  L.push("");
  L.push("CAN YOU TRUST THE SPLITS?");
  L.push("~" + metrics.n.toLocaleString() + " people · " + plural(metrics.profiles, "group") + " × " +
    plural(metrics.markets, "market") + " = " + plural(metrics.cells, "cell") + " · about " + metrics.cellN + " people in each.");
  L.push("You want roughly 60 in a group before you read it on its own. This design " +
    (metrics.readable ? "clears that." : "doesn't \u2014 widen the audience or go a step deeper."));
  L.push("");
  L.push("WHERE THE ANSWERS COME FROM");
  L.push("Digital Twins are built from your own past respondents — people who already answered for you.");
  ((metrics.mix && metrics.mix.rows) || []).forEach(r => {
    L.push("• " + r.name + " — " + r.label + ". " + r.line);
  });
  L.push("Overall: " + metrics.twin + "% Digital Twins.");
  L.push("");
  L.push("HOW WE KNOW THE TWINS HOLD UP");
  L.push(backtestLine());
  L.push("Same accuracy where we have history. Real people where we don't.");
  return L.join("\n");
}

Object.assign(window, {
  FW: { PROFILE_ROLES, ROLE_ORDER, roleOf, profileWhy, rankPool, segPhrase, segmentList,
    audienceObjective, alignGoals, demoWhy, cutWhy, demoSentence, cutSentence, exclusions,
    revealBeats, REOPEN_BEATS, setupNote, depthTrade, studyRationale,
    SEGMENT_HISTORY, BACKTEST, backtestLine, COVERAGE_BANDS, coverageOf, coverageLine, coverageShort, TWIN_GAPS, TWIN_CEILING, needsHuman, humanReason, panelMix, DEPTH_CURVE, shareFor, shareTone, strengthOf }
});
