/* global window */
// ─────────────────────────────────────────────────────────────────────────────
// PRIORITY ENGINE — why a deal sits at the top of the queue.
//
// The rhythm board answers "is the buyer still keeping time?". That is one
// dimension, and until now the Top-priority widget borrowed its answer: the
// card said "Champion reposted Linnworks" under the heading "Why this is
// priority", which is a diagnosis, not a ranking. It never said why THAT deal
// outranked the two below it.
//
// Priority is a different question with a different shape. A deal rises for
// reasons that do not overlap: how much money is on it, how long since the
// buyer last did something that cost them, how little room is left before the
// committed date, what went backwards, who still cannot say yes, which way the
// read is moving, and how much of the motion is ours. Each is scored
// separately, each carries its own evidence, and the card prints the
// arithmetic. Every line is a name, a date, or a number, so the rep can
// disagree with any one of them.
//
// Source: /Users/mamifamily/nudge-movement/HANDOFF.md and verdict-design.md.
// The two ideas taken from there:
//   · the beat window — a stage expects a movement act every N working days,
//     and the clock runs against that expectation, not against a flat SLA;
//   · the cost test — a buyer REPLY is not a beat. Only an act that cost the
//     buyer something counts, and a deal with no such act on record has not
//     "gone quiet", it has never started. That is the strongest priority
//     signal there is, and the one a CRM cannot produce.
// ─────────────────────────────────────────────────────────────────────────────

(function () {
  "use strict";

  // The prototype's corpus clock. Same instant the rhythm board uses
  // (screens-deals.jsx DEALS_NOW) so the two surfaces never disagree about
  // how old something is.
  var PRIORITY_NOW = new Date("2026-05-28T00:00:00").getTime();
  var DAY = 86400000;

  // Working days a stage is expected to go between buyer movement acts.
  // Defaults from verdict-design.md; the real engine calibrates these to the
  // account's own won deals, which is why they live in one table.
  var STAGE_WINDOW = {
    discovery: 14,
    profiling: 14,
    qualification: 14,
    scoping: 12,
    proposal: 10,
    demo: 10,
    validation: 7,
    negotiation: 5,
  };
  var DEFAULT_WINDOW = 10;

  function stageWindow(stage) {
    var key = String(stage || "").trim().toLowerCase();
    return STAGE_WINDOW[key] || DEFAULT_WINDOW;
  }

  // ── dates ────────────────────────────────────────────────────────────────
  var MONTHS = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 };

  // The corpus writes times half a dozen ways ("Apr 24, 9:14 AM", "May 20,
  // 2026", "Today, 8:42 AM", "Yesterday", "This week"). Anything we cannot
  // date returns null and is then treated as undated evidence rather than
  // being silently given today's date — a wrong date would make the clock lie.
  function parseCorpusTime(raw, now) {
    if (!raw) return null;
    var s = String(raw).trim();
    if (/^today/i.test(s)) return now;
    if (/^yesterday|^hier/i.test(s)) return now - DAY;
    if (/^this week|^cette semaine/i.test(s)) return now - 3 * DAY;
    var rel = s.match(/^il y a (\d+)\s*j/i);
    if (rel) return now - Number(rel[1]) * DAY;
    // The demo freshener (data.jsx LIVE_DEMO_ACTIVITY) stamps the newest
    // events relatively. Left unparsed they read as undated, which is how the
    // clock came to claim "nothing inbound at all" on a deal whose buyer had
    // replied fifteen minutes earlier.
    var ago = s.match(/^(\d+)\s*(m|h|d)\s*ago/i);
    if (ago) {
      var n = Number(ago[1]);
      var unit = ago[2].toLowerCase();
      return now - n * (unit === "m" ? 60000 : unit === "h" ? 3600000 : DAY);
    }
    var m = s.match(/^([A-Za-z]{3})[a-z]*\s+(\d{1,2})(?:,\s*(\d{4}))?/);
    if (!m) return null;
    var mo = MONTHS[m[1].toLowerCase()];
    if (mo == null) return null;
    var year = m[3] ? Number(m[3]) : new Date(now).getFullYear();
    return new Date(year, mo, Number(m[2])).getTime();
  }

  // Calendar days flatter every gap that spans a weekend, and the beat window
  // is written in working days, so both sides of the comparison are counted
  // the same way.
  function workingDaysBetween(fromMs, toMs) {
    if (fromMs == null || toMs == null) return null;
    var a = Math.min(fromMs, toMs), b = Math.max(fromMs, toMs);
    var days = 0;
    var cur = new Date(a); cur.setHours(0, 0, 0, 0);
    var end = new Date(b); end.setHours(0, 0, 0, 0);
    while (cur.getTime() < end.getTime()) {
      cur.setDate(cur.getDate() + 1);
      var d = cur.getDay();
      if (d !== 0 && d !== 6) days++;
    }
    return days;
  }

  function calendarDays(fromMs, toMs) {
    if (fromMs == null || toMs == null) return null;
    return Math.round((toMs - fromMs) / DAY);
  }

  // ── the cost test ────────────────────────────────────────────────────────
  // A movement act is something the buyer spent to do: their own people's
  // time, their procurement process, their access, their name on a document.
  // Answering an email is not on this list, and neither is attending a
  // meeting we booked. Patterns are ordered — the first match names the act.
  var MOVEMENT_ACTS = [
    { re: /\b(cost cent|requisition|purchase order|raised a \bpo\b|opened procurement|supplier onboard|vendor (file|form|number))\b/i, act: "opened the money path" },
    { re: /\b(security review|infosec|vendor review|questionnaire|annex|pen ?test|\bdpa\b|legal review|redlines? (returned|back))\b/i, act: "put us through their own review" },
    { re: /\b(credential|read-?only replica|sandbox|provisioned access|granted access|environment access|data (extract|set)|api key)\b/i, act: "handed over access" },
    { re: /\b(mutual action plan|close plan)\b/i, act: "agreed a mutual action plan" },
    { re: /\b(business case|internal (case|memo|deck|brief)|planning pack)\b/i, act: "produced an internal artifact" },
    { re: /\binternal\b[^.]*\b(review|meeting|session|sync)\b|\b(review|meeting|session)\b[^.]*\binternal\b/i, act: "booked their own internal review" },
    { re: /\b(steering|architecture board|committee|exec review|board slot|their agenda)\b/i, act: "put us on their agenda" },
    { re: /\b(introduc|looped in|brought (in )?(their|her|his)|added (their|her|his))\b.*\b(cfo|cto|ceo|finance|legal|security|procurement|coo|vp)\b/i, act: "brought a decision-maker in" },
    { re: /\b(approved|sign(ed)?-?off|signed off|authoris|authoriz)\b/i, act: "signed something off" },
  ];

  // The precision floor a purely semantic detector runs into (HANDOFF §6,
  // kirbeck-security): the buyer NAMING procurement, budget or legal is not
  // the same as the buyer OPENING it. A sentence that hedges, defers or asks
  // is a statement about a future act, and costs the buyer nothing. Checked
  // before the act patterns, so one hedge disqualifies the whole line.
  var NOT_AN_ACT = /\b(contingent on|subject to|would (have to|need)|will (have to|need)|once we|if we|hoping to|asked (about|when|whether)|question about|considering|exploring|might|plan(ning)? to|expects? to|come back to you)\b/i;

  function matchMovementAct(text) {
    var s = String(text || "");
    if (NOT_AN_ACT.test(s)) return null;
    for (var i = 0; i < MOVEMENT_ACTS.length; i++) {
      if (MOVEMENT_ACTS[i].re.test(s)) return MOVEMENT_ACTS[i].act;
    }
    return null;
  }

  // Reads the deal's activity feed and splits it three ways: dated movement
  // acts, dated buyer responses (cheap — a reply, a question), and undated
  // proof carried on the committee rows ("Pricing approved") which counts as
  // evidence the buyer moved but cannot start a clock.
  function readBuyerEvidence(deal, now) {
    var diag = (window.DEAL_DIAGNOSIS || {})[deal.id]
      || (window.DEAL_DIAGNOSIS || {})[deal.sourceDealId]
      || {};
    var acts = [], responses = [];

    (deal.activity || []).forEach(function (item) {
      if (!item) return;
      if (item.tag !== "buyer" && item.tag !== "milestone") return;
      var ts = parseCorpusTime(item.time, now);
      var text = [item.title, item.body].filter(Boolean).join(" · ");
      var act = matchMovementAct(text);
      var row = {
        ts: ts,
        when: item.time,
        actor: normaliseActor(item.actor || deal.champion),
        title: item.title,
        act: act,
      };
      if (act) acts.push(row); else responses.push(row);
    });

    var undated = [];
    (diag.committee || []).forEach(function (row) {
      if (!row || row.state === "warn") return;
      var act = matchMovementAct(row.signal);
      if (act) undated.push({ actor: row.name, role: row.role, title: row.signal, act: act });
    });

    var dated = acts.filter(function (a) { return a.ts != null; })
      .sort(function (a, b) { return b.ts - a.ts; });
    var lastResponse = responses.filter(function (r) { return r.ts != null; })
      .sort(function (a, b) { return b.ts - a.ts; })[0] || null;

    return {
      lastAct: dated[0] || null,
      actCount: acts.length,
      lastResponse: lastResponse,
      responseCount: responses.length,
      undated: undated,
      // Three states, and the difference between them is the whole product:
      //   moved     — a dated act, so the clock is real
      //   undated   — proof exists but carries no date
      //   unproven  — nothing the buyer paid for is on record at all
      state: dated.length ? "moved" : (undated.length ? "undated" : "unproven"),
    };
  }

  // ── formatting ───────────────────────────────────────────────────────────
  function priMoney(v) {
    var n = Number(v) || 0;
    if (n >= 1000000) return "$" + (n / 1000000).toFixed(n >= 10000000 ? 0 : 1) + "M";
    if (n >= 1000) return "$" + Math.round(n / 1000) + "K";
    return "$" + n;
  }
  function clamp01(n) { return Math.max(0, Math.min(1, n)); }
  // English pluralises on n !== 1; French on n >= 2, so "0 jour" stays
  // singular. Routed through the locale so the rule follows the language.
  function plural(n, one, many) {
    var w = window.NudgeI18n;
    if (w && w.lang === "fr") return Math.abs(n) < 2 ? one : (many || one);
    return n === 1 ? one : (many || one + "s");
  }
  // Short alias — this engine builds most of its sentences, so t() appears a
  // lot and the copy has to stay readable next to the arithmetic.
  function T(k, v) { return window.t ? window.t(k, v) : k; }

  // ── the dimensions ───────────────────────────────────────────────────────
  // Weights sum to 100. They are deliberately not equal: money and the
  // movement clock decide most rankings, and carry — how far ahead the seller
  // is — decides almost none, because it is a fact about us.
  var DIMENSIONS = [
    { key: "clock",    label: T("dim.Clock"),    weight: 22 },
    { key: "money",    label: T("dim.Money"),    weight: 20 },
    { key: "deadline", label: T("dim.Deadline"), weight: 18 },
    { key: "setback",  label: T("dim.Setback"),  weight: 15 },
    { key: "coverage", label: T("dim.Coverage"), weight: 12 },
    { key: "momentum", label: T("dim.Momentum"), weight: 8 },
    { key: "carry",    label: T("dim.Carry"),    weight: 5 },
    // Manager-only, and supplied by the caller because the deal list cannot
    // know it: the dollars this deal is putting into the call that the
    // buyer's own behaviour does not back. It outweighs everything else
    // because it is the manager's actual question.
    { key: "forecast", label: T("dim.Forecast"), weight: 25 },
  ];
  var WEIGHT = DIMENSIONS.reduce(function (m, d) { m[d.key] = d.weight; return m; }, {});

  // Every factor carries four things: the points it contributed, the evidence
  // line (a name, a date or a number the rep can argue with), an optional
  // second line naming the expectation it was judged against, and `phrase` —
  // the clause the one-line headline is assembled from.
  function factor(key, points, evidence, detail, tone, phrase, short) {
    return {
      key: key,
      label: (DIMENSIONS.filter(function (d) { return d.key === key; })[0] || {}).label || key,
      weight: WEIGHT[key],
      points: Math.round(points * 10) / 10,
      fill: clamp01(points / WEIGHT[key]),
      evidence: evidence,
      detail: detail || null,
      tone: tone || "plain",
      phrase: phrase || null,
      // The stack list has one clipped line per deal, so every factor also
      // carries a form that survives it. Truncating `evidence` there cut the
      // payload off ("...agreed a mut…") and left the row saying nothing.
      short: short || evidence,
    };
  }

  function moneyFactor(deal, ctx) {
    var v = deal.value || deal.amount || 0;
    if (!v) return null;
    var share = ctx.totalValue ? v / ctx.totalValue : 0;
    var pts = WEIGHT.money * clamp01(ctx.maxValue ? v / ctx.maxValue : 0);
    var ev = priMoney(v);
    if (ctx.count > 1) ev += " · " + T("{pct}% of everything in this queue", { pct: Math.round(share * 100) });
    return factor("money", pts, ev, null, "plain",
      share >= 0.4
        ? T("{amount} is on it, most of the money in this queue", { amount: priMoney(v) })
        : T("{amount} is on it", { amount: priMoney(v) }),
      ctx.count > 1 ? priMoney(v) + " · " + T("{pct}% of the queue", { pct: Math.round(share * 100) }) : priMoney(v));
  }

  function clockFactor(deal, ctx, ev) {
    var w = stageWindow(deal.stage);
    var expect = T("{stage} expects a beat every {n} working {unit}",
      { stage: deal.stage, n: w, unit: T(plural(w, "day", "days")) });

    if (ev.state === "unproven") {
      // The strongest reading in the model, and the one the CRM cannot make:
      // the buyer has never spent anything. Not a stall — never started.
      var since = ev.responseCount
        ? T(", only {n} {unit}", { n: ev.responseCount, unit: T(plural(ev.responseCount, "reply", "replies")) })
        : T(", and nothing inbound at all");
      return factor("clock", WEIGHT.clock,
        T("No buyer-owned act on record") + since,
        expect + T(". None has ever landed."), "warn",
        T("the buyer has never done anything that cost them"),
        T("No buyer-owned act on record, ever"));
    }

    if (ev.state === "undated") {
      // Proof exists but carries no date, so there is no clock to run. Scored
      // at a fixed fraction of the weight rather than off the last reply —
      // pricing a reply as a beat is exactly the mistake this dimension was
      // built to stop making.
      var u = ev.undated[0];
      var pts = WEIGHT.clock * 0.6;
      return factor("clock", pts,
        T("{who} {act}, but it carries no date", { who: u.actor || T("The buyer"), act: T(u.act) }),
        (ev.responseCount
          ? T("{n} {unit} since, none of them a beat", { n: ev.responseCount, unit: T(plural(ev.responseCount, "reply", "replies")) }) + " · "
          : "") + expect,
        "amber",
        T("the only proof they moved carries no date"),
        T("The one proof they moved carries no date"));
    }

    var last = ev.lastAct;
    var wd = workingDaysBetween(last.ts, ctx.now);
    var ratio = wd / w;
    return factor("clock", WEIGHT.clock * clamp01(ratio / 3),
      T("{n} working {unit} since {who} {act}", { n: wd, unit: T(plural(wd, "day", "days")), who: last.actor, act: T(last.act) }),
      T("Last beat: {title} ({when})", { title: T(last.title), when: last.when }) + " · " + expect,
      ratio >= 2 ? "warn" : ratio >= 1 ? "amber" : "plain",
      T("{n} working {unit} since the buyer last moved, against a {w}-day beat",
        { n: wd, unit: T(plural(wd, "day", "days")), w: w }),
      T("{n} working {unit} since the last beat, against {w}",
        { n: wd, unit: T(plural(wd, "day", "days")), w: w }));
  }

  function deadlineFactor(deal, ctx) {
    var t = Date.parse(deal.closeDate || "");
    if (Number.isNaN(t)) return null;
    var days = calendarDays(ctx.now, t);
    var w = stageWindow(deal.stage);
    if (days < 0) {
      return factor("deadline", WEIGHT.deadline,
        T("The committed date passed {n} {unit} ago", { n: Math.abs(days), unit: T(plural(Math.abs(days), "day", "days")) }),
        T("Still open at {stage}. The date in the CRM is no longer a forecast.", { stage: deal.stage }), "warn",
        T("the committed date has already passed"),
        T("Committed date passed {n} {unit} ago", { n: Math.abs(days), unit: T(plural(Math.abs(days), "day", "days")) }));
    }
    var wd = workingDaysBetween(ctx.now, t);
    var beats = Math.floor(wd / w);
    var pts = WEIGHT.deadline * clamp01(1 - beats / 6);
    var room = beats === 0
      ? T("no room for another full beat")
      : T("room for {n} more {unit}", { n: beats, unit: T(plural(beats, "beat", "beats")) });
    return factor("deadline", pts,
      T("Closes in {n} {unit}", { n: days, unit: T(plural(days, "day", "days")) }) + " · " + room,
      beats <= 1 ? T("At this stage's tempo that is the whole remaining runway.") : null,
      beats <= 1 ? "warn" : beats <= 3 ? "amber" : "plain",
      beats === 0
        ? T("the date is {n} days out and there is no room left for another beat", { n: days })
        : T("the date is {n} days out with room for {b} more {unit}", { n: days, b: beats, unit: T(plural(beats, "beat", "beats")) }),
      T("Closes in {n} {unit}", { n: days, unit: T(plural(days, "day", "days")) }) + " · " + room);
  }

  function setbackFactor(deal) {
    var worst = (deal.scoreEvents || [])
      .filter(function (e) { return e && e.delta < 0; })
      .sort(function (a, b) { return a.delta - b.delta; })[0];
    if (!worst) return null;
    var mag = Math.abs(worst.delta);
    return factor("setback", WEIGHT.setback * clamp01(mag / 20),
      "\u2212" + mag + " pts · " + T(worst.label), null,
      mag >= 15 ? "warn" : "amber",
      T("it went backwards — {what}", { what: T(worst.label) }),
      "\u2212" + mag + " pts · " + T(worst.label));
  }

  function coverageFactor(deal) {
    var diag = (window.DEAL_DIAGNOSIS || {})[deal.id]
      || (window.DEAL_DIAGNOSIS || {})[deal.sourceDealId] || {};
    var holes = (diag.committee || []).filter(function (r) {
      return r && (r.state === "warn" || r.state === "amber");
    });
    var missing = (deal.nudges || []).filter(function (n) {
      return n && /missing|not identified|unknown|blocked/i.test(n.kind || "");
    });
    var count = holes.length;
    if (!count && !missing.length) return null;
    var names = holes.slice(0, 2).map(function (r) {
      return T(r.role) + " : " + T(r.signal || r.name);
    });
    if (!names.length) names = missing.slice(0, 2).map(function (n) { return n.title || n.kind; });
    var pts = WEIGHT.coverage * clamp01((count + missing.length * 0.5) / 4);
    return factor("coverage", pts,
      names.join(" · "),
      count >= 2 ? T("{n} of {total} named roles cannot move this forward.", { n: count, total: (diag.committee || []).length }) : null,
      count >= 2 ? "warn" : "amber",
      count >= 2 ? T("nobody named on it can move it forward") : T("a named role is blocked"),
      count >= 2
        ? T("{n} of {total} named roles are blocked", { n: count, total: (diag.committee || []).length || count })
        : (names[0] || T("A named role is blocked")));
  }

  function momentumFactor(deal) {
    var t = deal.trend || [];
    if (t.length < 3) return null;
    var window5 = t.slice(-5);
    var drop = window5[0] - window5[window5.length - 1];
    if (drop <= 1) return null;
    return factor("momentum", WEIGHT.momentum * clamp01(drop / 20),
      T("Buyer read \u2212{n} pts across the last {reads} reads", { n: Math.abs(drop), reads: window5.length }),
      null, drop >= 10 ? "warn" : "amber",
      T("the buyer read is falling, not flat"),
      T("Buyer read \u2212{n} pts and still falling", { n: Math.abs(drop) }));
  }

  function carryFactor(deal) {
    var buyer = deal.buyerScore, seller = deal.sellerScore;
    if (buyer == null || seller == null) return null;
    var drift = seller - buyer;
    if (drift <= 4) return null;
    return factor("carry", WEIGHT.carry * clamp01(drift / 35),
      T("Seller {seller} / buyer {buyer} — {drift} points of this deal is us", { seller: seller, buyer: buyer, drift: drift }),
      null, drift >= 25 ? "warn" : "plain",
      T("almost all of the motion on it is ours"),
      T("{drift} of the {seller} points of motion on it are ours", { drift: drift, seller: seller }));
  }

  // ── the composed sentence ────────────────────────────────────────────────
  // One line, built from the two dimensions that actually did the lifting.
  // The card's focal signal; the ledger underneath is the proof.
  function composeHeadline(factors) {
    var clauses = factors
      .map(function (f) { return f.phrase; })
      .filter(Boolean)
      .slice(0, 2);
    if (!clauses.length) return T("Nothing is pushing this up right now.");
    var line = clauses.length === 1 ? clauses[0] : T("{a}, and {b}", { a: clauses[0], b: clauses[1] });
    return line.charAt(0).toUpperCase() + line.slice(1) + ".";
  }

  // ── board ────────────────────────────────────────────────────────────────
  // Scores and ranks a queue. `extra` lets a caller add a dimension the deal
  // list cannot know about — the manager view passes forecast inflation.
  function buildPriorityBoard(deals, opts) {
    var list = (deals || []).filter(Boolean);
    var options = opts || {};
    var now = options.now || PRIORITY_NOW;
    var values = list.map(function (d) { return d.value || d.amount || 0; });
    var ctx = {
      now: now,
      count: list.length,
      totalValue: values.reduce(function (s, v) { return s + v; }, 0),
      maxValue: values.reduce(function (m, v) { return Math.max(m, v); }, 0),
    };

    var rows = list.map(function (deal) {
      var ev = readBuyerEvidence(deal, now);
      var factors = [
        clockFactor(deal, ctx, ev),
        moneyFactor(deal, ctx),
        deadlineFactor(deal, ctx),
        setbackFactor(deal),
        coverageFactor(deal),
        momentumFactor(deal),
        carryFactor(deal),
      ].filter(Boolean);

      var addon = typeof options.extraFactor === "function" ? options.extraFactor(deal, ctx) : null;
      if (addon) factors.push(addon);

      factors.sort(function (a, b) { return b.points - a.points; });
      var score = factors.reduce(function (s, f) { return s + f.points; }, 0);

      return {
        id: deal.id,
        company: deal.company,
        deal: deal,
        evidence: ev,
        factors: factors,
        byKey: factors.reduce(function (m, f) { m[f.key] = f; return m; }, {}),
        // A dimension is "pushing" once it has earned a quarter of its own
        // weight — below that it is noise and printing it dilutes the card.
        pushing: factors.filter(function (f) { return f.points >= f.weight * 0.25; }),
        score: Math.round(score),
        lead: factors[0] || null,
        // A deal can rank high on money, a passed date and a setback while
        // the buyer moved this morning. That fact does not belong in the
        // ledger — it contributes nothing to the rank — but it changes what
        // the rep should do next, so the card names it separately.
        counterweight: (function () {
          if (ev.state !== "moved" || !ev.lastAct) return null;
          var wd = workingDaysBetween(ev.lastAct.ts, now);
          if (wd > stageWindow(deal.stage)) return null;
          return {
            text: ev.lastAct.title + " — " + ev.lastAct.actor
              + (wd === 0 ? ", today" : ", " + wd + " working " + plural(wd, "day") + " ago")
              + ". The beat is current.",
          };
        })(),
        headline: composeHeadline(factors),
        // How concentrated the rank is. If one dimension carries most of the
        // score, say so — a card that claims seven reasons when one of them
        // is doing the work is the same failure as the story it replaced.
        spread: (function () {
          var total = factors.reduce(function (t, f) { return t + f.points; }, 0);
          var top = factors[0] ? factors[0].points : 0;
          var top3 = factors.slice(0, 3).reduce(function (t, f) { return t + f.points; }, 0);
          return {
            concentrated: total > 0 && top / total >= 0.5,
            topShare: total > 0 ? top / total : 0,
            top3: Math.round(top3),
            total: Math.round(total),
          };
        })(),
      };
    });

    rows.sort(function (a, b) {
      if (b.score !== a.score) return b.score - a.score;
      return (b.deal.value || 0) - (a.deal.value || 0);
    });
    rows.forEach(function (r, i) { r.rank = i + 1; r.total = rows.length; });

    // The distinctive dimension — where this deal is furthest above the rest
    // of the queue on that same dimension. Not its biggest factor: at a given
    // moment most of a queue can be failing the same clock, and repeating
    // "clock" on every row in the list tells the rep nothing. What separates
    // the rows is where each one is unlike the others.
    var means = {};
    DIMENSIONS.forEach(function (d) {
      var sum = rows.reduce(function (t, r) { return t + ((r.byKey[d.key] || {}).points || 0); }, 0);
      means[d.key] = rows.length ? sum / rows.length : 0;
    });
    rows.forEach(function (r) {
      var best = null;
      r.factors.forEach(function (f) {
        var lift = f.points - means[f.key];
        if (!best || lift > best.lift) best = { factor: f, lift: Math.round(lift * 10) / 10 };
      });
      r.distinct = best && best.lift > 0 ? best.factor : r.lead;
      r.distinctLift = best ? best.lift : 0;
    });

    // The comparison line — the part that actually answers "why this one".
    // Rank 1 is measured against the deal directly beneath it; everything
    // else against the deal directly above.
    rows.forEach(function (r, i) {
      var other = i === 0 ? rows[1] : rows[i - 1];
      if (!other) { r.compare = null; r.compareLine = null; return; }
      var deltas = DIMENSIONS.map(function (d) {
        var mine = (r.byKey[d.key] || {}).points || 0;
        var theirs = (other.byKey[d.key] || {}).points || 0;
        return { key: d.key, label: d.label, delta: Math.round((mine - theirs) * 10) / 10 };
      });
      var wins = deltas.filter(function (x) { return x.delta >= 2; })
        .sort(function (a, b) { return b.delta - a.delta; }).slice(0, 2);
      var losses = deltas.filter(function (x) { return x.delta <= -2; })
        .sort(function (a, b) { return a.delta - b.delta; }).slice(0, 1);
      // Dimension labels are already localized, so lower-casing them here is
      // lower-casing the French ("Horloge" -> "horloge"), which is what the
      // sentence wants in both languages.
      var winText = wins.map(function (w) { return w.label.toLowerCase() + " (+" + w.delta + ")"; })
        .join(" " + T("and|join") + " ");
      var lossText = losses.length
        ? " " + T("{company} leads on {dim} (+{n}).", { company: other.company, dim: losses[0].label.toLowerCase(), n: Math.abs(losses[0].delta) })
        : "";
      r.compare = { against: other.id, company: other.company, wins: wins, losses: losses };
      r.compareLine = i === 0
        ? (wins.length
            ? T("Above {company} on {dims}.", { company: other.company, dims: winText }) + lossText
            : T("Level with {company} — ranked first on {dim}.", { company: other.company, dim: r.lead ? r.lead.label.toLowerCase() : T("value") }))
        : (losses.length
            ? T("Below {company}: it leads on {dim} (+{n}).", { company: other.company, dim: losses[0].label.toLowerCase(), n: Math.abs(losses[0].delta) })
                + (wins.length ? " " + T("This one is ahead on {dims}.", { dims: winText }) : "")
            : T("Below {company} on a narrower margin than any single dimension.", { company: other.company }));
    });

    var byId = {};
    rows.forEach(function (r) { byId[r.id] = r; });
    return { rows: rows, byId: byId, order: rows.map(function (r) { return r.deal; }), context: ctx };
  }

  // Manager view only. `inflation` is the call inflation the forecast engine
  // computed for this deal; `max` is the worst in the queue, so the dimension
  // is scored relative to the queue the manager is actually looking at.
  function forecastFactor(inflation, max) {
    if (!inflation || inflation <= 0) return null;
    return factor("forecast", WEIGHT.forecast * clamp01(max ? inflation / max : 0),
      priMoney(inflation) + " of call the buyer's behaviour does not back",
      "Counted in the commit, unsupported by buyer-owned evidence.", "warn",
      priMoney(inflation) + " of this is air in the call",
      priMoney(inflation) + " of air in the call");
  }

  function normaliseActor(name) {
    return String(name || "").replace(/^you\s*\+\s*/i, "").trim() || "the buyer";
  }

  window.NudgePriority = {
    NOW: PRIORITY_NOW,
    forecastFactor: forecastFactor,
    DIMENSIONS: DIMENSIONS,
    build: buildPriorityBoard,
    stageWindow: stageWindow,
    parseCorpusTime: parseCorpusTime,
    workingDaysBetween: workingDaysBetween,
    readBuyerEvidence: readBuyerEvidence,
    money: priMoney,
  };
})();
