/* global window, module, require */
/*
 * Win/loss engine — what the closed book says about how this team wins.
 *
 * The question this answers is not "how did that deal go", it is "across every
 * deal that has finished, what separates the ones we won from the ones we lost,
 * and which of the deals still open carry the losing condition today".
 *
 * Nothing in here is an opinion. Every number is a count of rows a reader could
 * list by name, and `evidence()` hands back exactly those rows so the UI can.
 *
 * ── The three corpora, and why they are not interchangeable ────────────────
 *
 *   crm      ForecastEngine.BACKTEST_COHORT — 26 closed deals carrying what the
 *            CRM record knew: which qualification evidence was missing, the
 *            buyer and seller confidence scores, stage, owner.
 *   signals  fixtures/history/aggregate.json — 23 closed deals carrying what the
 *            BUYER did: the seven behavioural markers, the real loss reason, and
 *            how many days passed before a new stakeholder was reached.
 *   book     the closed rows of SEED_DEALS — 9 deals which carry neither, and
 *            are here for one reason: they are the only closed deals that open
 *            in the deal room, so a drill-down can be clicked rather than read.
 *
 * The first two do not overlap in what they record, so a marker's "win rate
 * without it" is measured over a different population than an evidence gap's.
 * Ranking them in one list would silently compare two denominators, so they
 * never are: `read()` returns two lenses, each printing its own n. That split
 * is not a workaround — the signals lens is the half a CRM cannot produce, and
 * saying so is the point.
 */
(function () {
  "use strict";

  // ───────────────────────────────── state ─────────────────────────────────
  // Mirrors movement-adapter.jsx: a plain state object, a subscriber list, and
  // a notify() on every transition. The home widget paints from the synchronous
  // corpora on first render and re-renders when the markers land.
  var state = { history: null, historyStatus: "idle", historyError: null };
  var subs = [];
  function subscribe(fn) {
    subs.push(fn);
    return function () { subs = subs.filter(function (f) { return f !== fn; }); };
  }
  function notify() {
    subs.slice().forEach(function (fn) {
      try { fn(state); } catch (e) { /* a listener must not break the load */ }
    });
  }

  function asArr(v) { return Array.isArray(v) ? v : []; }
  function pct(n, d) { return d ? Math.round((n / d) * 100) : 0; }

  // ─────────────────────────── canonical identity ──────────────────────────
  // The app writes the same person three ways ("Ridha Mami" in the fixtures,
  // "Ridha M." in the backtest cohort, an id in REPS). Owner is resolved to an
  // id once, here, so nothing downstream ever matches on a display string.
  function ownerIdOf(name) {
    var s = String(name || "").toLowerCase();
    if (/ridha/.test(s)) return "ridha";
    if (/priya/.test(s)) return "priya";
    if (/marco/.test(s)) return "marco";
    if (/jen/.test(s)) return "jen";
    if (/sofia/.test(s)) return "sofia";
    return "unknown";
  }

  // ───────────────────────────── normalisation ─────────────────────────────
  // One row shape across three schemas. `gaps: null` and `markers: null` mean
  // "this corpus cannot answer that question" — which is NOT the same as "the
  // answer is none", and the factor math has to keep the difference.

  function crmRows() {
    var FE = window.ForecastEngine;
    var cohort = (FE && FE.BACKTEST_COHORT) || [];
    return cohort.map(function (d) {
      return {
        key: "crm:" + d.id,
        source: "crm",
        account: d.company,
        value: d.value || 0,
        outcome: d.outcome === 1 ? "won" : "lost",
        owner: d.aeName,
        ownerId: ownerIdOf(d.aeName),
        closedOn: d.closeDate || null,
        stage: d.stage || null,
        gaps: asArr(d.evidenceMissing),
        markers: null,
        markerOffsets: null,
        buyerScore: typeof d.buyerScore === "number" ? d.buyerScore : null,
        sellerScore: typeof d.sellerScore === "number" ? d.sellerScore : null,
        lossReason: null,
        cycleDays: null,
        daysToContact: null,
        firstMarkerDate: null,
        championSilentDays: null,
        dealId: null,
      };
    });
  }

  function signalRows(history) {
    return asArr(history && history.deals).map(function (d) {
      var created = Date.parse(d.created), closed = Date.parse(d.closed);
      return {
        key: "signals:" + d.deal_id,
        source: "signals",
        account: d.account,
        value: d.amount_eur || 0,
        // No-decision is a third outcome, not a loss. It is reported on its own
        // because a stalled deal and a beaten one need different answers — and
        // the stalled ones are usually the bigger number nobody looks at.
        outcome: d.outcome === "won" ? "won" : (NODECISION_RE.test(d.loss_reason || "") ? "nodecision" : "lost"),
        owner: d.owner,
        ownerId: ownerIdOf(d.owner),
        closedOn: d.closed || null,
        stage: null,
        gaps: null,
        markers: asArr(d.markers_present),
        markerOffsets: d.marker_offsets_days || {},
        buyerScore: null,
        sellerScore: null,
        lossReason: d.loss_reason || null,
        cycleDays: isFinite(created) && isFinite(closed)
          ? Math.round((closed - created) / 86400000) : null,
        daysToContact: typeof d.days_to_contact_after_first_marker === "number"
          ? d.days_to_contact_after_first_marker : null,
        firstMarkerDate: d.first_marker_date || null,
        championSilentDays: typeof d.champion_silent_days === "number"
          ? d.champion_silent_days : null,
        dealId: null,
      };
    });
  }

  // "No decision" and its variants ("No decision / budget reallocated").
  var NODECISION_RE = /no decision|no-decision/i;
  var CLOSED_RE = /closed/i;
  var LOST_RE = /lost/i;
  function bookRows() {
    return (window.SEED_DEALS || []).filter(function (d) {
      return CLOSED_RE.test(String(d.stage || "") + " " + String(d.statusLabel || ""));
    }).map(function (d) {
      var lost = LOST_RE.test(String(d.stage || "") + " " + String(d.statusLabel || "") + " " + String(d.status || ""));
      return {
        key: "book:" + d.id,
        source: "book",
        account: d.company,
        value: d.value || 0,
        outcome: lost ? "lost" : "won",
        owner: null,
        ownerId: "ridha",
        closedOn: d.closeDate || null,
        // Where it slipped, never the "Closed Lost" label — that is an outcome,
        // not a stage, and it would show up as its own bar.
        stage: d.lostAtStage || null,
        gaps: null,
        markers: null,
        markerOffsets: null,
        buyerScore: typeof d.buyerScore === "number" ? d.buyerScore : null,
        sellerScore: typeof d.sellerScore === "number" ? d.sellerScore : null,
        lossReason: null,
        cycleDays: null,
        daysToContact: null,
        firstMarkerDate: null,
        championSilentDays: null,
        dealId: d.id,
      };
    });
  }

  function allRows() {
    return crmRows().concat(signalRows(state.history)).concat(bookRows());
  }

  // ────────────────────────────── the factors ──────────────────────────────
  // Every factor answers a yes/no question about a closed deal, in THREE
  // values: yes, no, and "this deal cannot say". The third is the one that
  // matters. A backtest row has no champion-silence figure; folding it into
  // "the champion did not go quiet" would be an invention, so it is dropped
  // from both arms instead. `applies()` is what draws that line.
  //
  // Thresholds are DECLARED, never scanned. 14 days is "two weeks" because
  // that is a fortnight, not because 14 scored best — 7, 10 and 21 all
  // "work" on this corpus, which is exactly why picking the winner would be
  // fitting noise rather than finding a pattern.

  var GAP_AUTHORITY = /economic buyer|decision-maker|approval path/i;
  var GAP_PLAN = /mutual plan|business pain/i;

  function hasGap(row, re) {
    return asArr(row.gaps).some(function (g) { return re.test(g); });
  }
  function hasMarker(row, id) {
    return asArr(row.markers).indexOf(id) !== -1;
  }

  var isCrm = function (r) { return r.gaps !== null; };
  var isSignals = function (r) { return r.markers !== null; };
  var hasScores = function (r) {
    return r.gaps !== null && typeof r.buyerScore === "number" && typeof r.sellerScore === "number";
  };
  var hasContactClock = function (r) { return r.markers !== null && r.daysToContact !== null; };
  var hasSilenceClock = function (r) { return r.markers !== null && r.championSilentDays !== null; };

  var FACTORS = [
    // ── lens: what the CRM record knew ──────────────────────────────────
    {
      id: "record-complete", family: "gap", lens: "crm", applies: isCrm,
      label: "the deal record is complete before you propose",
      mirror: "something in the record is still missing",
      short: "Record complete", shortNot: "Something missing",
      present: function (r) { return asArr(r.gaps).length === 0; },
    },
    {
      id: "buyer-confident", family: "score", lens: "crm", applies: hasScores,
      label: "the buyer's own confidence is at 70 or above",
      mirror: "the buyer's confidence is below 70",
      short: "Buyer confident", shortNot: "Buyer unsure",
      present: function (r) { return r.buyerScore >= 70; },
    },
    {
      id: "no-authority", family: "gap", lens: "crm", applies: isCrm,
      label: "nobody with authority is named",
      mirror: "somebody with authority is named",
      short: "Nobody named", shortNot: "Someone named",
      present: function (r) { return hasGap(r, GAP_AUTHORITY); },
    },
    {
      id: "no-next-step", family: "gap", lens: "crm", applies: isCrm,
      label: "there is no agreed next step",
      mirror: "a next step is agreed",
      short: "No next step", shortNot: "Next step agreed",
      present: function (r) { return hasGap(r, /next step/i); },
    },
    {
      id: "buyer-behind", family: "score", lens: "crm", applies: hasScores,
      label: "the buyer is more than 20 points behind the seller",
      mirror: "the buyer is keeping pace with the seller",
      short: "Buyer far behind", shortNot: "Buyer keeping pace",
      present: function (r) { return r.sellerScore - r.buyerScore > 20; },
    },
    {
      id: "no-plan-or-pain", family: "gap", lens: "crm", applies: isCrm,
      label: "there is no written plan and the pain is unquantified",
      mirror: "the plan is written and the pain is quantified",
      short: "No written plan", shortNot: "Plan written",
      present: function (r) { return hasGap(r, GAP_PLAN); },
    },
    {
      id: "no-champion", family: "gap", lens: "crm", applies: isCrm,
      label: "there is no champion",
      mirror: "there is a champion",
      short: "No champion", shortNot: "Champion in place",
      present: function (r) { return hasGap(r, /champion/i); },
    },

    // ── lens: what the buyer did ────────────────────────────────────────
    {
      id: "champion-silent", family: "clock", lens: "signals", applies: hasSilenceClock,
      label: "the champion goes quiet for two weeks or more",
      mirror: "the champion keeps answering inside two weeks",
      short: "Champion went quiet", shortNot: "Champion kept answering",
      present: function (r) { return r.championSilentDays >= 14; },
    },
    {
      id: "fast-contact", family: "clock", lens: "signals", applies: hasContactClock,
      label: "you reach a new stakeholder within a week of the first warning sign",
      mirror: "you take more than a week to reach a new stakeholder",
      short: "Reached in a week", shortNot: "Took longer",
      present: function (r) { return r.daysToContact <= 7; },
    },
    {
      id: "late-contact", family: "clock", lens: "signals", applies: hasContactClock,
      label: "you take more than two weeks to reach a new stakeholder",
      mirror: "you reach a new stakeholder inside two weeks",
      short: "Took over two weeks", shortNot: "Reached sooner",
      present: function (r) { return r.daysToContact > 14; },
    },
    {
      id: "marker-load", family: "marker", lens: "signals", applies: isSignals,
      label: "five or more warning signs stack up on one deal",
      mirror: "the deal carries fewer than five warning signs",
      short: "Five or more signs", shortNot: "Fewer than five",
      present: function (r) { return asArr(r.markers).length >= 5; },
    },
    {
      id: "m7", family: "marker", lens: "signals", applies: isSignals,
      label: "the buyer's own people engage a competitor in public",
      mirror: "the buyer's people stay off the competitor's posts",
      short: "Buyer engaged a rival", shortNot: "No rival contact",
      present: function (r) { return hasMarker(r, "M7"); },
    },
    {
      id: "m6", family: "marker", lens: "signals", applies: isSignals,
      label: "an extended security assessment lands before any commercial agreement",
      mirror: "security stays in step with the commercial track",
      short: "Security came first", shortNot: "Security in step",
      present: function (r) { return hasMarker(r, "M6"); },
    },
    {
      id: "m3", family: "marker", lens: "signals", applies: isSignals,
      label: "the executive step gets folded into a wider cycle",
      mirror: "the executive step keeps its own date",
      short: "Exec step folded in", shortNot: "Exec step kept its date",
      present: function (r) { return hasMarker(r, "M3"); },
    },
    {
      id: "m5", family: "marker", lens: "signals", applies: isSignals,
      label: "procurement arrives to compare suppliers, not to paper a deal",
      mirror: "procurement arrives to paper the deal",
      short: "Procurement comparing", shortNot: "Procurement papering",
      present: function (r) { return hasMarker(r, "M5"); },
    },
    {
      id: "m1", family: "marker", lens: "signals", applies: isSignals,
      label: "a senior person arrives and nobody contacts them",
      mirror: "a senior arrival is contacted",
      short: "Senior arrival ignored", shortNot: "Senior arrival contacted",
      present: function (r) { return hasMarker(r, "M1"); },
    },
    {
      id: "m4", family: "marker", lens: "signals", applies: isSignals,
      label: "the buyer's reply time breaks the deal's own pattern",
      mirror: "the buyer keeps replying at their usual pace",
      short: "Reply pace broke", shortNot: "Reply pace held",
      present: function (r) { return hasMarker(r, "M4"); },
    },
    {
      id: "m2", family: "marker", lens: "signals", applies: isSignals,
      label: "the champion stops saying \"we\" about the decision",
      mirror: "the champion still says \"we\" about the decision",
      short: "Champion said \"they\"", shortNot: "Champion said \"we\"",
      present: function (r) { return hasMarker(r, "M2"); },
    },
  ];

  // A factor is read only when BOTH arms hold at least this many deals. Below
  // it, one deal changing hands moves the rate by 20 points or more, and the
  // number stops being a number. Same threshold as MATCH_MIN in
  // movement-engine.js:693, so the product speaks one figure.
  var MIN_ARM = 5;

  function scoreFactor(def, rows) {
    var pool = rows.filter(def.applies);
    var wi = pool.filter(def.present);
    var wo = pool.filter(function (r) { return !def.present(r); });
    var wiWon = wi.filter(function (r) { return r.outcome === "won"; }).length;
    var woWon = wo.filter(function (r) { return r.outcome === "won"; }).length;
    var withRate = pct(wiWon, wi.length), withoutRate = pct(woWon, wo.length);
    var thin = wi.length < MIN_ARM || wo.length < MIN_ARM;
    return {
      id: def.id, family: def.family, lens: def.lens, label: def.label, mirror: def.mirror,
      short: def.short, shortNot: def.shortNot,
      applicable: pool.length,
      withN: wi.length, withWon: wiWon, withRate: withRate,
      withoutN: wo.length, withoutWon: woWon, withoutRate: withoutRate,
      lift: withRate - withoutRate,
      direction: withRate - withoutRate >= 0 ? "win" : "lose",
      suppressed: thin,
      // A reason is a template plus its numbers, never a built string: the
      // French copy has to be free to reorder them.
      suppressReason: thin
        ? (wi.length < MIN_ARM
            ? { key: "{n} deals carried it — needs {min} on each side to read.", vars: { n: wi.length, min: MIN_ARM } }
            : { key: "{n} deals did not carry it — needs {min} on each side to read.", vars: { n: wo.length, min: MIN_ARM } })
        : null,
      withKeys: wi.map(function (r) { return r.key; }),
      withoutKeys: wo.map(function (r) { return r.key; }),
    };
  }

  // Two factors can be the same rope seen twice — "a competitor in public" and
  // "security before commercial" land on six of the same ten deals. Reporting
  // both as independent findings double-counts the same losses, so the weaker
  // one is demoted and the stronger one carries a note saying whose deals it
  // shares.
  function jaccard(a, b) {
    var set = {}, inter = 0;
    a.forEach(function (k) { set[k] = 1; });
    b.forEach(function (k) { if (set[k]) inter++; });
    var union = a.length + b.length - inter;
    return union ? inter / union : 0;
  }
  var OVERLAP_MAX = 0.6;
  function foldOverlaps(list) {
    var kept = [];
    // Only factors that survived the arm test take part. A factor suppressed
    // for a thin arm must not demote anything: "the champion stops saying we"
    // sits on 21 of 23 deals, so it overlaps with everything, and letting it
    // fold the others would empty the lens on the strength of a number the
    // rule has already refused to read.
    list.slice()
      .filter(function (f) { return !f.suppressed; })
      .sort(function (a, b) { return Math.abs(b.lift) - Math.abs(a.lift); })
      .forEach(function (f) {
        var twin = null;
        kept.forEach(function (k) {
          // Same family only. Two factors read off the SAME instrument — two
          // evidence fields, two markers — can be one rope seen twice. Two
          // factors read off DIFFERENT instruments that happen to land on the
          // same deals are two independent readings agreeing, which is the
          // most useful thing a corpus this size can offer, not a duplicate.
          if (twin || k.direction !== f.direction || k.family !== f.family) return;
          var j = jaccard(k.withKeys, f.withKeys);
          if (j >= OVERLAP_MAX) twin = { of: k, j: j };
          else if (j > 0.3) {
            k.sharesWith = (k.sharesWith || []).concat([{
              id: f.id, label: f.label,
              shared: f.withKeys.filter(function (x) { return k.withKeys.indexOf(x) !== -1; }).length,
              ofN: k.withN,
            }]);
          }
        });
        if (twin) {
          f.suppressed = true;
          f.suppressReason = { key: "Lands on the same deals as “{label}”.", vars: { label: twin.of.label } };
        }
        kept.push(f);
      });
    return list;
  }

  // ───────────────────────────── the timing read ───────────────────────────
  // How long it took to reach a new person after the first warning sign, and
  // what that cost. `matchHistory` (movement-engine.js:737) buckets the same
  // field into three for ONE deal's matched set; at portfolio level the middle
  // bucket holds two deals, and a 0% bar on two deals reads as a law. So this
  // reads as two bars, and the deals that never raised a warning sign at all
  // are reported separately rather than folded into either side.
  function timingOf(rows) {
    var pool = rows.filter(hasContactClock);
    var quiet = rows.filter(function (r) { return r.markers !== null && r.daysToContact === null; });
    function bar(id, label, p) {
      var set = pool.filter(p);
      var won = set.filter(function (r) { return r.outcome === "won"; }).length;
      return { id: id, label: label, deals: set.length, won: won, rate: pct(won, set.length) };
    }
    return {
      bars: [
        bar("within7", "Reached within 7 days", function (r) { return r.daysToContact <= 7; }),
        bar("after7", "Reached after 7 days", function (r) { return r.daysToContact > 7; }),
      ],
      neverFlagged: {
        deals: quiet.length,
        won: quiet.filter(function (r) { return r.outcome === "won"; }).length,
      },
      anchor: liveAnchor(),
    };
  }

  // The only deal in the book that can honestly be placed on this chart is the
  // one the movement engine has actually read events for. Everything else has
  // no first-warning-sign date, so there is no day count to plot — and an
  // invented position on a chart about response time would be the exact
  // failure this feature exists to name.
  function liveAnchor() {
    try {
      var a = window.MovementAdapter && window.MovementAdapter.analysis();
      var d = a && a.match && a.match.daysSinceFirstMarker;
      if (typeof d !== "number") return null;
      return {
        deal: (a.crm && (a.crm.account || a.crm.opportunity)) || "your live deal",
        days: d,
        bar: d <= 7 ? "within7" : "after7",
      };
    } catch (e) { return null; }
  }

  // ─────────────────────────────── the verdict ─────────────────────────────
  // One sentence, both halves counted. Where a factor's own mirror arm is big
  // enough to read, the sentence is built from that single factor stated both
  // ways — one lever, not two unrelated observations. Only when the mirror is
  // too thin do the two halves come from different factors, and then never
  // from different lenses: a sentence that crossed lenses would compare two
  // denominators in the most prominent string on the screen.
  function verdictOf(shown) {
    var losses = shown.filter(function (f) { return f.direction === "lose"; })
      .sort(function (a, b) { return a.lift - b.lift || b.withN - a.withN; });
    var wins = shown.filter(function (f) { return f.direction === "win"; })
      .sort(function (a, b) { return b.lift - a.lift || b.withN - a.withN; });
    var lose = losses[0];
    if (!lose || Math.abs(lose.lift) < 20) {
      return { shown: false, reason: "no-pattern" };
    }
    // The mirror of a loss factor is its own "without" arm.
    if (lose.withoutN >= MIN_ARM) {
      return {
        shown: true, mirrored: true, lens: lose.lens, factorId: lose.id,
        win: { label: lose.mirror, won: lose.withoutWon, n: lose.withoutN, rate: lose.withoutRate },
        lose: { label: lose.label, won: lose.withWon, n: lose.withN, rate: lose.withRate },
      };
    }
    var win = wins.filter(function (f) { return f.lens === lose.lens; })[0] || wins[0];
    if (!win) return { shown: false, reason: "no-pattern" };
    return {
      shown: true, mirrored: false, lens: lose.lens, factorId: lose.id,
      win: { label: win.label, won: win.withWon, n: win.withN, rate: win.withRate },
      lose: { label: lose.label, won: lose.withWon, n: lose.withN, rate: lose.withRate },
    };
  }

  // ─────────────────────── carry-over onto the open book ───────────────────
  // What a losing pattern is worth is whether it is happening right now. Each
  // predicate returns true, false, or null — null being "this deal has not
  // been mapped well enough to say", which is 16 of the 23 open deals for
  // anything stakeholder-shaped and is itself worth printing.
  //
  // `sameMeasure` is the honest badge. Only the buyer/seller gap is literally
  // the same two fields on the closed deals and the open ones; everything else
  // is a related signal, and the UI has to say which it is looking at.
  var AUTHORITY_ROLE = /CFO|Chief|Financ|VP Finance|Procure|COO|CTO|Directrice Financière|Direction du Budget/i;
  var AUTHORITY_NUDGE = /ECONOMIC BUYER|MISSING STAKEHOLDERS/;
  var AUTHORITY_EVENT = /CFO unidentified|CFO not looped in|decision-maker unknown|sponsor moved on/i;
  var NEXTSTEP_NUDGE = /COMMITMENT OVERDUE|MISSING APPROVAL PATH/;
  var NEXTSTEP_EVENT = /stalled between calls|proposal unopened|eval expiring|stage lags reality|thread cooling|went silent/i;

  function nudgeKinds(d) { return asArr(d.nudges).map(function (n) { return String(n.kind || ""); }); }
  function eventLabels(d) { return asArr(d.scoreEvents).map(function (e) { return String(e.label || ""); }); }
  function anyMatch(list, re) { return list.some(function (s) { return re.test(s); }); }

  var DRIFT_NUDGE = /STAKEHOLDER SILENT/;
  var DRIFT_EVENT = /went silent|thread cooling|champion changed job|sponsor moved on/i;

  var CARRY = {
    // The champion pulling away. On the closed book this is measured in days
    // of silence; an open deal has no dated buyer reply to count from, so this
    // reads the signals the app already raises instead. A related signal, not
    // the same measurement, and the card says which it is looking at.
    "champion-drift": {
      sameMeasure: false,
      predicateLabel: "the champion has gone quiet or moved on",
      run: function (d) {
        if (anyMatch(nudgeKinds(d), DRIFT_NUDGE)) return true;
        if (anyMatch(eventLabels(d), DRIFT_EVENT)) return true;
        if (!asArr(d.nudges).length && !asArr(d.scoreEvents).length) return null;
        return false;
      },
      why: function (d) {
        return (eventLabels(d).filter(function (l) { return DRIFT_EVENT.test(l); })[0]
          || nudgeKinds(d).filter(function (k) { return DRIFT_NUDGE.test(k); })[0]
          || "champion drifting").toLowerCase();
      },
    },
    "buyer-behind": {
      sameMeasure: true,
      predicateLabel: "the seller is more than 20 points ahead of the buyer",
      run: function (d) {
        if (typeof d.buyerScore !== "number" || typeof d.sellerScore !== "number") return null;
        return d.sellerScore - d.buyerScore > 20;
      },
      why: function (d) { return "buyer " + d.buyerScore + ", seller " + d.sellerScore; },
    },
    "no-authority": {
      sameMeasure: false,
      predicateLabel: "no decision-maker or finance owner in the room",
      run: function (d) {
        var stk = asArr(d.stakeholders);
        if (stk.some(function (s) { return s.missing && AUTHORITY_ROLE.test(String(s.role || "")); })) return true;
        if (anyMatch(nudgeKinds(d), AUTHORITY_NUDGE)) return true;
        if (anyMatch(eventLabels(d), AUTHORITY_EVENT)) return true;
        if (stk.length >= 2 && stk.some(function (s) { return !s.missing && AUTHORITY_ROLE.test(String(s.role || "")); })) return false;
        if (stk.length < 2 && !asArr(d.nudges).length && !asArr(d.scoreEvents).length) return null;
        return false;
      },
      why: function (d) {
        var m = asArr(d.stakeholders).filter(function (s) { return s.missing && AUTHORITY_ROLE.test(String(s.role || "")); })[0];
        if (m) return m.role + " never reached";
        var n = nudgeKinds(d).filter(function (k) { return AUTHORITY_NUDGE.test(k); })[0];
        if (n) return n.toLowerCase();
        return (eventLabels(d).filter(function (l) { return AUTHORITY_EVENT.test(l); })[0] || "authority unmapped");
      },
    },
    "no-next-step": {
      sameMeasure: false,
      predicateLabel: "nothing agreed that the buyer owns next",
      run: function (d) {
        if (anyMatch(nudgeKinds(d), NEXTSTEP_NUDGE)) return true;
        if (anyMatch(eventLabels(d), NEXTSTEP_EVENT)) return true;
        if (!asArr(d.nudges).length && !asArr(d.scoreEvents).length) return null;
        return false;
      },
      why: function (d) {
        return (eventLabels(d).filter(function (l) { return NEXTSTEP_EVENT.test(l); })[0]
          || nudgeKinds(d).filter(function (k) { return NEXTSTEP_NUDGE.test(k); })[0]
          || "no owned next step").toLowerCase();
      },
    },
  };

  // Why the rest have no list. Naming the missing field is a stronger thing to
  // say than a list built from a field that is not there.
  var NO_CARRY = {
    "champion-silent": "Every open deal would need a dated buyer reply. Most carry activity stamped “This week”, with no date to count from.",
    "late-contact": "There is no first-warning-sign date on an open deal, so there is no clock to read.",
    "fast-contact": "There is no first-warning-sign date on an open deal, so there is no clock to read.",
    "marker-load": "Warning signs are derived from a raw event bundle. Only the live deal has one.",
    "m1": "Derived from the buyer's own event stream, which open deals do not carry here.",
    "m2": "Derived from the buyer's own event stream, which open deals do not carry here.",
    "m3": "Derived from the buyer's own event stream, which open deals do not carry here.",
    "m4": "Derived from the buyer's own event stream, which open deals do not carry here.",
    "m5": "Derived from the buyer's own event stream, which open deals do not carry here.",
    "m6": "Derived from the buyer's own event stream, which open deals do not carry here.",
    "m7": "Derived from the buyer's own event stream, which open deals do not carry here.",
    "record-complete": "This one is a strength, not a gap to chase.",
    "buyer-confident": "This one is a strength, not a gap to chase.",
    "no-plan-or-pain": "The open book does not record whether a written plan exists.",
    "no-champion": "The open book records a champion on every deal, so there is nothing to find.",
  };

  // `vle-live` is the movement demo's raw-event deal: no scores, no mapped
  // stakeholders. It belongs to the timing anchor, not to any open-book count,
  // and is named in `excluded` so the total on screen adds up.
  function openBook(deals) {
    return (deals || window.SEED_DEALS || []).filter(function (d) {
      return !CLOSED_RE.test(String(d.stage || "") + " " + String(d.statusLabel || "")) && d.id !== "vle-live";
    });
  }

  function carryOver(factorId, deals) {
    var rule = CARRY[factorId];
    if (!rule) {
      return { kind: "none", reason: NO_CARRY[factorId] || "Not measurable on an open deal.",
               carrying: [], clear: 0, unknown: 0, value: 0 };
    }
    var carrying = [], clear = 0, unknown = 0;
    openBook(deals).forEach(function (d) {
      var v = rule.run(d);
      if (v === null) { unknown++; return; }
      if (!v) { clear++; return; }
      carrying.push({ id: d.id, company: d.company, value: d.value || 0, why: rule.why(d) });
    });
    carrying.sort(function (a, b) { return b.value - a.value; });
    return {
      kind: rule.sameMeasure ? "same-measure" : "proxy",
      predicateLabel: rule.predicateLabel,
      reason: null,
      carrying: carrying, clear: clear, unknown: unknown,
      value: carrying.reduce(function (s, x) { return s + x.value; }, 0),
    };
  }


  // ───────────────── stated reason vs what the evidence shows ──────────────
  // The finding the whole field is built on: the loss reason in the CRM is
  // usually not the reason. Clozd puts buyer/seller agreement at about 15%;
  // Corporate Visions, over 100,000 B2B transactions, found the two sides give
  // different answers roughly 70% of the time. Reps blame price about twice as
  // often as buyers do. What actually decides deals — champion confidence,
  // implementation risk, time-to-value — is almost never in the record.
  //
  // This function does not interview anybody. It does the one thing a CRM plus
  // an event stream CAN do: take the reason that WAS recorded, and report what
  // the buyer was doing on those same deals — including which came first.

  // The marker that IS the stated reason, where one exists. "Competitor" is a
  // claim about M7; nothing in the event stream corresponds to "Price".
  var REASON_MARKER = { competitor: "M7" };
  // The signals worth cross-tabbing, in the order a reader should meet them.
  var CROSS = [
    { id: "drift", label: "the champion had stopped saying \u201cwe\u201d",
      test: function (r) { return hasMarker(r, "M2"); } },
    { id: "silent", label: "the champion had gone quiet for two weeks or more",
      test: function (r) { return r.championSilentDays !== null && r.championSilentDays >= 14; } },
    { id: "rival", label: "the buyer engaged a competitor in public",
      test: function (r) { return hasMarker(r, "M7"); } },
    { id: "late", label: "nobody was reached for more than two weeks",
      test: function (r) { return r.daysToContact !== null && r.daysToContact > 14; } },
    { id: "security", label: "security landed before any commercial agreement",
      test: function (r) { return hasMarker(r, "M6"); } },
  ];

  function median(a) {
    if (!a.length) return null;
    var b = a.slice().sort(function (x, y) { return x - y; });
    return b[Math.floor(b.length / 2)];
  }

  function contradiction() {
    var rows = allRows();
    var closedLost = rows.filter(function (r) { return r.outcome === "lost" || r.outcome === "nodecision"; });
    // Rows that could carry a reason at all — only the signals corpus records
    // one, so coverage is measured against that, not against all 58.
    // Coverage is measured against EVERY deal that did not close won, not only
    // the ones that happen to carry the field. "18 of your 32 losses have no
    // reason recorded anywhere" is itself one of the findings.
    var withReason = closedLost.filter(function (r) { return !!r.lossReason; });

    var by = {};
    withReason.forEach(function (r) {
      by[r.lossReason] = by[r.lossReason] || { reason: r.lossReason, n: 0, value: 0, rows: [] };
      by[r.lossReason].n++;
      by[r.lossReason].value += r.value || 0;
      by[r.lossReason].rows.push(r);
    });
    var reasons = Object.keys(by).map(function (k) { return by[k]; })
      .sort(function (a, b) { return b.n - a.n || b.value - a.value; });

    var lead = reasons[0] || null;
    var detail = null;
    if (lead) {
      var signals = CROSS.map(function (c) {
        var hit = lead.rows.filter(function (r) { return r.markers !== null && c.test(r); });
        return { id: c.id, label: c.label, n: hit.length, of: lead.rows.length };
      }).filter(function (x) { return x.of > 0; })
        .sort(function (a, b) { return b.n - a.n; });

      // Which came first — the drift, or the thing the CRM blamed? Only asked
      // where both are datable on the same deal, and the count of deals where
      // it could NOT be asked is reported rather than quietly dropped.
      var mk = REASON_MARKER[String(lead.reason || "").toLowerCase().split(/[^a-z]/)[0]];
      var ordering = null;
      if (mk) {
        var gaps = [], notComparable = 0, other = 0, pairs = [];
        lead.rows.forEach(function (r) {
          var o = r.markerOffsets || {};
          if (o.M2 == null || o[mk] == null) { notComparable++; return; }
          if (o.M2 < o[mk]) {
            gaps.push(o[mk] - o.M2);
            // Kept per deal so the reveal can DRAW the gap rather than assert
            // it: one track per deal, the drift, then the distance, then the
            // competitor.
            pairs.push({ account: r.account, drift: o.M2, rival: o[mk], gap: o[mk] - o.M2 });
          } else other++;
        });
        pairs.sort(function (a, b) { return a.gap - b.gap; });
        ordering = {
          before: gaps.length, other: other, notComparable: notComparable,
          comparable: gaps.length + other,
          medianDays: median(gaps),
          maxGap: gaps.length ? Math.max.apply(null, gaps) : 0,
          pairs: pairs,
        };
      }
      detail = { reason: lead.reason, n: lead.rows.length, value: lead.value, signals: signals, ordering: ordering };
    }

    return {
      reasons: reasons.map(function (r) { return { reason: r.reason, n: r.n, value: r.value }; }),
      recorded: withReason.length,
      unrecorded: closedLost.length - withReason.length,
      outOf: closedLost.length,
      lead: detail,
    };
  }

  // ───────────────────────────── the breakdowns ────────────────────────────
  // The standard win/loss cuts. Every one of them is thin on this book, so
  // each row carries its own sample and anything under five deals is flagged
  // rather than dressed up as a rate.
  function rate(set) {
    var won = set.filter(function (r) { return r.outcome === "won"; }).length;
    return { won: won, n: set.length, rate: pct(won, set.length), thin: set.length < MIN_ARM };
  }
  function groupBy(rows, keyOf) {
    var by = {};
    rows.forEach(function (r) {
      var k = keyOf(r);
      if (k == null) return;
      (by[k] = by[k] || []).push(r);
    });
    return Object.keys(by).map(function (k) {
      var g = rate(by[k]);
      g.label = k;
      return g;
    }).sort(function (a, b) { return b.n - a.n || b.rate - a.rate; });
  }

  var SIZE_BANDS = [
    { label: "Under 80K", lo: 0, hi: 80000 },
    { label: "80K to 130K", lo: 80000, hi: 130000 },
    { label: "Over 130K", lo: 130000, hi: Infinity },
  ];
  function bandOf(v) {
    for (var i = 0; i < SIZE_BANDS.length; i++) {
      if (v >= SIZE_BANDS[i].lo && v < SIZE_BANDS[i].hi) return SIZE_BANDS[i].label;
    }
    return null;
  }
  function quarterOf(iso) {
    var m = /^(\d{4})-(\d{2})/.exec(String(iso || ""));
    if (!m) return null;
    return "Q" + (Math.floor((parseInt(m[2], 10) - 1) / 3) + 1) + " " + m[1];
  }

  function breakdowns() {
    var rows = allRows();
    var segOf = (window.SalesMemory && !window.SalesMemory.unavailable && window.SalesMemory.segmentOf) || null;
    var cyc = rows.filter(function (r) { return r.cycleDays !== null; });
    var wonCyc = cyc.filter(function (r) { return r.outcome === "won"; }).map(function (r) { return r.cycleDays; });
    var lostCyc = cyc.filter(function (r) { return r.outcome !== "won"; }).map(function (r) { return r.cycleDays; });
    var mean = function (a) { return a.length ? Math.round(a.reduce(function (s, x) { return s + x; }, 0) / a.length) : null; };

    return [
      { id: "segment", label: "By industry",
        rows: segOf ? groupBy(rows, function (r) { return segOf(r.account); }) : [] },
      { id: "size", label: "By deal size",
        rows: groupBy(rows, function (r) { return bandOf(r.value || 0); }) },
      { id: "stage", label: "By stage at close",
        rows: groupBy(rows, function (r) { return r.stage; }) },
      { id: "trend", label: "By quarter closed", chronological: true,
        rows: groupBy(rows, function (r) {
          return r.closedOn && /^\d{4}-/.test(r.closedOn) ? quarterOf(r.closedOn)
            : (r.closedOn ? quarterOf(new Date(r.closedOn).toISOString()) : null);
        }) },
    ].map(function (b) {
      // A trend is only a trend in date order; everything else ranks by size.
      if (b.chronological) {
        b.rows.sort(function (a, c) {
          var qa = /^Q(\d) (\d{4})$/.exec(a.label), qc = /^Q(\d) (\d{4})$/.exec(c.label);
          if (!qa || !qc) return 0;
          return (qa[2] - qc[2]) || (qa[1] - qc[1]);
        });
      }
      b.rows = b.rows.slice(0, 10);
      return b;
    })
     .concat([{ id: "cycle", label: "Sales cycle", cycle: {
        won: mean(wonCyc), lost: mean(lostCyc), n: cyc.length,
        thin: cyc.length < MIN_ARM,
     }, rows: [] }]);
  }

  // ──────────────────────────────── read() ─────────────────────────────────
  var LENSES = [
    { id: "crm", label: "What the record knew", sub: "Evidence logged on the deal at forecast time" },
    { id: "signals", label: "What the buyer did", sub: "Behaviour the CRM never captured" },
  ];

  function read(opts) {
    var rows = allRows();
    var scored = FACTORS.map(function (def) { return scoreFactor(def, rows); });
    var lenses = LENSES.map(function (L) {
      var pool = rows.filter(function (r) { return L.id === "crm" ? isCrm(r) : isSignals(r); });
      var mine = scored.filter(function (f) { return f.lens === L.id && f.applicable > 0; });
      foldOverlaps(mine);
      var won = pool.filter(function (r) { return r.outcome === "won"; }).length;
      return {
        id: L.id, label: L.label, sub: L.sub,
        n: pool.length, won: won, lost: pool.length - won, baseRate: pct(won, pool.length),
        ready: pool.length > 0,
        factors: mine.filter(function (f) { return !f.suppressed; })
          .sort(function (a, b) { return Math.abs(b.lift) - Math.abs(a.lift); }),
        suppressed: mine.filter(function (f) { return f.suppressed; })
          .sort(function (a, b) { return b.withN - a.withN; }),
      };
    });

    var shown = lenses.reduce(function (acc, L) { return acc.concat(L.factors); }, []);
    var won = rows.filter(function (r) { return r.outcome === "won"; }).length;
    var headline = scored.filter(function (f) { return f.id === "record-complete"; })[0];

    return {
      scope: (opts && opts.scope) || "team",
      cohort: {
        n: rows.length, won: won,
        lost: rows.filter(function (r) { return r.outcome === "lost"; }).length,
        noDecision: rows.filter(function (r) { return r.outcome === "nodecision"; }).length,
        winRate: pct(won, rows.length),
        window: (state.history && state.history.source) || "the closed book",
        sources: [
          { id: "crm", n: rows.filter(function (r) { return r.source === "crm"; }).length },
          { id: "signals", n: rows.filter(function (r) { return r.source === "signals"; }).length },
          { id: "book", n: rows.filter(function (r) { return r.source === "book"; }).length },
        ],
      },
      verdict: verdictOf(shown),
      headline: headline && !headline.suppressed
        ? { won: headline.withWon, n: headline.withN, label: headline.label }
        : null,
      lenses: lenses,
      // The chart ranks conditions. "The record is complete" is close to the
      // complement of the worst two, so it leads as the headline fact instead
      // of competing with them for a bar.
      top: shown.filter(function (f) { return f.id !== "record-complete"; })
        .sort(function (a, b) { return Math.abs(b.lift) - Math.abs(a.lift); }),
      timing: timingOf(rows),
      contradiction: contradiction(),
      breakdowns: breakdowns(),
      lossReasons: lossReasonsOf(rows),
      historyStatus: state.historyStatus,
    };
  }

  function lossReasonsOf(rows) {
    var by = {};
    rows.forEach(function (r) {
      if (r.outcome !== "lost" || !r.lossReason) return;
      by[r.lossReason] = by[r.lossReason] || { reason: r.lossReason, n: 0, value: 0 };
      by[r.lossReason].n++;
      by[r.lossReason].value += r.value || 0;
    });
    return Object.keys(by).map(function (k) { return by[k]; })
      .sort(function (a, b) { return b.n - a.n; });
  }

  // The closed deals behind one arm of one factor, by name. Everything on the
  // screen has to be listable, or the numbers are just assertions.
  function evidence(factorId) {
    var rows = allRows(), byKey = {};
    rows.forEach(function (r) { byKey[r.key] = r; });
    var def = FACTORS.filter(function (f) { return f.id === factorId; })[0];
    if (!def) return { withRows: [], withoutRows: [] };
    var f = scoreFactor(def, rows);
    function shape(keys) {
      return keys.map(function (k) { return byKey[k]; }).filter(Boolean)
        .sort(function (a, b) { return (b.value || 0) - (a.value || 0); })
        .map(function (r) {
          return { key: r.key, account: r.account, value: r.value, outcome: r.outcome,
                   closedOn: r.closedOn, dealId: r.dealId };
        });
    }
    return { withRows: shape(f.withKeys), withoutRows: shape(f.withoutKeys) };
  }

  // ───────────────────────────────── loading ───────────────────────────────
  // The CRM rows and the book rows are already in memory, so the first paint
  // is a real answer rather than a spinner. The markers are one 20KB file
  // away; they fill the second lens in when they land.
  //
  // This does NOT wait on MovementAdapter.load(): that promise only settles
  // after a live HubSpot read with an 8s timeout (movement-adapter.jsx:70),
  // and the home page must not be held open by a bridge. If the adapter has
  // already put the file in memory, take it for free instead.
  function ingest(json) {
    state.history = json;
    state.historyStatus = "ready";
    notify();
    return json;
  }

  // How long a real read would take. The prototype's closed book is a 20KB
  // file on the same origin, so it lands before anybody has finished reading
  // the sign-in screen — which means the whole waiting experience, the one
  // this product will actually live in against a real CRM, is unreachable.
  // `?slow=45` makes it behave like the customer's book: the first-run crunch
  // waits, and skipping lands in the workspace with the data still coming.
  function simulatedDelayMs() {
    try {
      var m = /[?&]slow=(\d{1,3})/.exec(window.location.search || "");
      if (!m) return 0;
      return Math.min(300, parseInt(m[1], 10)) * 1000;
    } catch (e) { return 0; }
  }

  var loading = null;
  function load() {
    if (loading) return loading;
    var cached = null;
    try {
      var b = window.MovementAdapter && window.MovementAdapter.state().bundles;
      cached = b && b.history;
    } catch (e) { /* the adapter is optional */ }
    state.historyStatus = "loading";
    // index.html asks for this file before any other script queues, precisely
    // so the first-run screen is not left waiting on a request stuck behind
    // two dozen fixture reads. Take that promise whenever it exists.
    var early = window.__nudgeHistory || null;
    var source = cached
      ? Promise.resolve(cached)
      : early
        ? early.then(function (j) { if (j) return j; throw new Error("history preload empty"); })
        : fetch("fixtures/history/aggregate.json").then(function (r) {
            if (!r.ok) throw new Error("history \u2192 " + r.status);
            return r.json();
          });
    var held = simulatedDelayMs();
    if (held > 0) {
      source = source.then(function (j) {
        return new Promise(function (res) { setTimeout(function () { res(j); }, held); });
      });
    }
    loading = source.then(ingest, function (e) {
        // One lens degrades. The screen does not: the CRM lens has already
        // painted, and saying so beats an error nobody can act on.
        state.historyStatus = "degraded";
        state.historyError = e && e.message ? e.message : String(e);
        notify();
        return null;
      });
    return loading;
  }

  var WinLoss = {
    read: read,
    contradiction: contradiction,
    breakdowns: breakdowns,
    evidence: evidence,
    carryOver: carryOver,
    load: load,
    ingest: ingest,
    subscribe: subscribe,
    state: function () { return state; },
    ready: function () { return state.historyStatus === "ready"; },
    // exported so the verifier can assert each rule on its own
    FACTORS: FACTORS, MIN_ARM: MIN_ARM, CARRY: CARRY,
    scoreFactor: scoreFactor, allRows: allRows, openBook: openBook,
    timingOf: timingOf, verdictOf: verdictOf, ownerIdOf: ownerIdOf,
  };

  if (typeof module !== "undefined" && module.exports) module.exports = WinLoss;
  if (typeof window !== "undefined") {
    window.WinLoss = WinLoss;
    if (typeof fetch === "function") load();
  }
})();
