/* global MovementEngine */
/*
 * Bridge between the buyer-movement engine and the deal shape the rest of the
 * app already speaks.
 *
 * The point of this file is that the "Did the buyer move?" demo needs almost no
 * new screen. Activity, Stakeholders, Meetings, Conversation and Next move all
 * render this deal with no changes at all — they just need the deal object
 * filled in from raw events instead of from hand-written fixtures. So: fetch
 * the raw JSON, run the engine once, and map the result onto `activity`,
 * `stakeholders`, `nudges`, `buyerScore` / `sellerScore` and the trend arrays.
 *
 * Nothing here decides anything. Every number it copies was computed in
 * movement-engine.js from a payload; this file only arranges them.
 */
(function () {
  "use strict";

  var DEAL_DIRS = ["vle-live", "h1-orbit-freight", "h2-caldera-retail", "h3-halden-mobility", "h4-brightwater"];
  var FILES = {
    "vle-live": ["emails", "calls", "notes", "slack", "linkedin", "crm", "meetings", "latency", "stakeholders"],
    "h1-orbit-freight": ["emails", "calls", "notes", "linkedin", "crm"],
    "h2-caldera-retail": ["emails", "calls", "slack", "crm"],
    "h3-halden-mobility": ["emails", "calls", "slack", "linkedin", "crm"],
    "h4-brightwater": ["calls", "crm"],
  };

  // ───────────────────────────── the live CRM link ─────────────────────────
  // `hubspot-demo-data/tools/vle-bridge.ts` serves the CRM half of this deal,
  // read out of HubSpot every three seconds, in exactly the shape the fixtures
  // use. Five files, and only five: emails, calls, notes, the deal record and
  // the contact log.
  //
  // The other four — linkedin, slack, latency, meetings — are NEVER live. Two
  // of the seven markers come from there, and the demo's whole argument is that
  // Nudge sees what the CRM cannot. Serving them off the same bridge would
  // quietly throw that away.
  //
  // The LinkedIn and Slack events DO exist in the portal, as notes written by
  // `hubspot-demo-data/tools/vle-mirror.ts`, so the record a prospect opens
  // accounts for every row shown here. That changes nothing above: both bridges
  // drop any note whose first line is a mirror header, so those 19 events reach
  // this adapter from disk and from disk only, exactly as before.
  //
  // Everything falls back to the fixture on any failure. That is the demo's
  // insurance: with the bridge down, on a plane, or on a hotel network, the
  // deal room still opens and still tells the same story.
  /**
   * Where the live CRM read comes from.
   *
   * On a laptop it is the bridge on :8011. Anywhere else it is this site's own
   * `/api/vle`, because a page served over HTTPS cannot fetch http://localhost
   * at all — the browser blocks it as mixed content, and even if it did not it
   * would be reaching for the VIEWER's machine rather than a server.
   *
   * Same shapes either way, so nothing downstream knows the difference.
   */
  var LOCAL = /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname);
  var LIVE_BASE = LOCAL ? "http://localhost:8011" : "/api/vle";
  var LIVE_URLS = LOCAL
    ? { file: function (d, f) { return LIVE_BASE + "/" + d + "/" + f + ".json"; },
        version: function () { return LIVE_BASE + "/version"; },
        fire: function (slug) { return LIVE_BASE + "/fire/" + slug; } }
    : { file: function (d, f) { return LIVE_BASE + "?file=" + f; },
        version: function () { return LIVE_BASE + "?file=version"; },
        fire: function (slug) { return LIVE_BASE + "?fire=" + slug; } };
  var LIVE_FILES = { "vle-live": ["emails", "calls", "notes", "crm", "stakeholders"] };
  // The deployed function reads HubSpot on a cold start, which is slower than a
  // bridge that already has the answer in memory.
  var LIVE_TIMEOUT_MS = LOCAL ? 1500 : 8000;

  var state = { status: "idle", error: null, analysis: null, source: "fixtures", live: 0, version: 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 getJson(path) {
    return fetch(path, { cache: "no-store" }).then(function (r) {
      if (!r.ok) throw new Error(path + " → " + r.status);
      return r.json();
    });
  }

  // A bridge that is starting up, or wedged, must not hold the deal room open.
  // Give it a moment, then take the fixture and move on.
  function getLive(dir, file) {
    var ctl = typeof AbortController !== "undefined" ? new AbortController() : null;
    var timer = ctl ? setTimeout(function () { ctl.abort(); }, LIVE_TIMEOUT_MS) : null;
    return fetch(LIVE_URLS.file(dir, file), {
      cache: "no-store",
      signal: ctl ? ctl.signal : undefined,
    }).then(function (r) {
      if (timer) clearTimeout(timer);
      if (!r.ok) throw new Error("bridge " + file + " → " + r.status);
      return r.json();
    }, function (e) {
      if (timer) clearTimeout(timer);
      throw e;
    });
  }

  function loadBundle(dir) {
    var liveable = LIVE_FILES[dir] || [];
    var liveCount = 0;
    return Promise.all(FILES[dir].map(function (f) {
      var fixture = function () { return getJson("fixtures/" + dir + "/" + f + ".json"); };
      var p = liveable.indexOf(f) === -1
        ? fixture()
        : getLive(dir, f).then(function (j) { liveCount++; return j; }, fixture);
      return p.then(function (j) { return [f, j]; });
    })).then(function (pairs) {
      if (dir === "vle-live") {
        state.live = liveCount;
        state.source = liveCount === 0 ? "fixtures" : liveCount === liveable.length ? "hubspot" : "mixed";
      }
      return pairs.reduce(function (o, p) { o[p[0]] = p[1]; return o; }, {});
    });
  }

  var loading = null;
  function load() {
    if (loading) return loading;
    state.status = "loading"; notify();
    loading = Promise.all(
      DEAL_DIRS.map(loadBundle).concat([getJson("fixtures/history/aggregate.json")])
    ).then(function (parts) {
      var history = parts.pop();
      var historic = {};
      DEAL_DIRS.slice(1).forEach(function (d, i) { historic[d] = parts[i + 1]; });
      state.analysis = MovementEngine.analyse({ live: parts[0], history: history, historic: historic });
      state.bundles = { live: parts[0], historic: historic, history: history };
      state.status = "ready";
      notify();
      return state.analysis;
    }).catch(function (e) {
      state.status = "error";
      state.error = e && e.message ? e.message : String(e);
      notify();
      // Swallowed on purpose: a missing fixture must degrade to a message on
      // the tab, never take the whole deal room down.
      return null;
    });
    return loading;
  }

  // ───────────────────────────── shaping helpers ───────────────────────────

  // Read the date off the ISO string itself. The fixtures are stamped +02:00, so
  // anything before 02:00 local would land on the previous day through the UTC
  // getters — which is how 24 August starts rendering as the 23rd.
  var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
  function fmtDay(iso) {
    var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || ""));
    if (!m) return String(iso || "");
    return MONTHS[parseInt(m[2], 10) - 1] + " " + parseInt(m[3], 10) + ", " + m[1];
  }
  function firstLines(text, n) {
    return String(text || "").split(/\n\s*\n/)[0].replace(/\s+/g, " ").trim().slice(0, n || 220);
  }
  function nameFromEmail(addr, people, sellerNames) {
    if (!addr) return "Ridha";
    for (var i = 0; i < people.length; i++) if (people[i].email === addr) return people[i].name;
    return sellerNames[addr] || addr;
  }

  // Raw events → the activity items ActivityTab already renders. The kinds are
  // the ones the app uses elsewhere, so filters, icons and the buyer/seller
  // partition keep working without touching that component.
  function toActivity(analysis, bundle) {
    var people = ((bundle.stakeholders || {}).people) || [];
    var sellers = {
      "ridha@addvocate.ai": "Ridha", "priya@addvocate.ai": "Priya Raman",
      "dan@addvocate.ai": "Dan Okafor", "sofia@addvocate.ai": "Sofia Berg",
    };
    return analysis.events.map(function (e) {
      var p = e.payload || {};
      var time = fmtDay(e.timestamp);
      var base = { time: time, timeIso: e.timestamp, eventId: e.event_id };

      if (e.source === "email") {
        if (p.kind === "no_reply") {
          return Object.assign(base, {
            kind: "email", actor: p.awaited_from ? nameFromEmail(p.awaited_from, people, sellers) : "Buyer",
            title: "No reply", tag: "awaiting",
            body: p.body,
          });
        }
        return Object.assign(base, {
          kind: "email", actor: nameFromEmail(p.from, people, sellers),
          title: p.subject || "Email", body: firstLines(p.body),
          tag: sellers[p.from] ? "outbound" : "inbound",
        });
      }
      if (e.source === "call") {
        var names = (p.attendees || []).map(function (a) { return a.name; });
        var head = (p.transcript || [])[0];
        return Object.assign(base, {
          kind: "meeting",
          actor: p.internal ? "Ridha" : names.join(" + "),
          title: (p.internal ? "Internal call" : "Call") +
                 (p.duration_min ? " · " + p.duration_min + " min" : "") +
                 (p.attendee_count ? " · " + p.attendee_count + " attendees" : ""),
          body: head ? head.speaker + ": " + head.text : "",
          tag: p.internal ? "internal" : "call",
        });
      }
      if (e.source === "note") {
        return Object.assign(base, { kind: "note", actor: "Ridha", title: "Note", body: firstLines(p.body), tag: "internal" });
      }
      if (e.source === "slack") {
        var msgs = p.messages || [];
        return Object.assign(base, {
          kind: "note", actor: "Ridha", tag: p.channel,
          title: "Slack · " + p.channel,
          body: msgs.map(function (m) { return m.author.split(" ")[0] + ": " + m.text; }).join("  ·  "),
        });
      }
      // linkedin
      var what = p.action === "posted" ? "posted"
        : p.action === "commented" ? "commented on " + p.object
        : p.action === "reacted" ? "reacted to " + p.object
        : p.action === "viewed_profile" ? "viewed " + p.object
        : p.action === "followed_company" ? "followed " + p.object
        : p.action.replace(/_/g, " ");
      return Object.assign(base, {
        kind: "linkedin", actor: p.actor,
        // The whole phrase, not just the verb: two profile views on the same day
        // are only distinguishable by who was looked at.
        title: p.action === "posted" ? p.actor + " posted" : p.actor + " " + what,
        body: p.action === "posted" ? firstLines(p.object) : (p.text || what),
        tag: "linkedin",
      });
    }).reverse(); // newest first, the order the timeline renders in
  }

  // Contact log → the stakeholder shape. Power and support are read off title
  // seniority and how much the person has actually engaged, not stored.
  function toStakeholders(analysis, bundle) {
    var r = analysis.roles;
    return ((bundle.stakeholders || {}).people || []).map(function (p) {
      var senior = /VP|Chief|CFO|CEO|Head of|Director/i.test(p.title) ? 1 : 0;
      var signs = r.economicBuyer && r.economicBuyer.name === p.name;
      return {
        name: p.name,
        role: p.title,
        power: signs ? 5 : senior ? 4 : 3,
        support: !p.touches ? 1 : p.touches > 20 ? 5 : p.touches > 3 ? 3 : 2,
        missing: !p.touches,
      };
    });
  }

  // A derived action, in the shape AIActionsTab already ranks and previews.
  // `evidence` feeds shared.jsx's buildRecommendationEvidence untouched, so the
  // evidence strip, quote and drawer all work with no change.
  /**
   * The home board's priority card, derived.
   *
   * `screens-home.jsx:isPriorityDeal` needs two things this deal did not have:
   * a status that is not "healthy", and a `DEAL_DIAGNOSIS` entry carrying a
   * `fix`. Every other deal gets that entry hand-written in `data.jsx` —
   * severity, headline, what, why, fix, all authored prose.
   *
   * This deal may not. "Nothing is pre-labelled" is the rule the whole demo
   * exists to prove, and a hand-written verdict sitting in `data.jsx` would be
   * the same lie as one sitting in a fixture, just somewhere the fixture guard
   * cannot see it. So the card is assembled here, at read time, out of numbers
   * the engine already derived: the last backward transition, the marker count,
   * the CRM's own forecast category, the matched-history rate for this deal's
   * bucket, and the top-ranked action.
   *
   * It is registered on `window.DEAL_DIAGNOSIS` rather than returned on the
   * deal because `diagnosisForDeal()` only ever reads that registry.
   */
  function toDiagnosis(a, deal) {
    var s = a.series || {};
    var m = a.match || {};
    var gap = (s.crmScore || 0) - (s.buyerScore || 0);
    var top = (a.actions || [])[0];
    if (!top) return null;
    var back = (a.timeline || []).filter(function (t) { return t.direction === "backward"; }).pop();
    var crmCat = (a.crm || {}).forecast_cat || "";
    var bucket = (m.buckets || {})[m.myBucket] || null;
    var d = draftFor(top, a);

    /**
     * Two clocks, and each one names the event it started on.
     *
     * `daysSinceBackwards` (15) runs from the FIRST backward move on 27 Aug;
     * `daysSinceFirstMarker` (18) runs from the new stakeholder's post on
     * 24 Aug, and the historic buckets are keyed on the second. They differ by
     * three days and both are true, so the headline is anchored on the move the
     * "15 days" is counted from — not on the latest transition, which would
     * silently put a 7 Sep date beside a 27 Aug clock.
     */
    var what = m.backwardsDate
      ? m.daysSinceBackwards + " days since that move. The buyer now reads " + s.state
        + "; the CRM still says " + (crmCat || "Commit") + " at " + s.crmScore + "%."
      : "The buyer reads " + s.state + " while the CRM says " + (crmCat || "Commit") + " at " + s.crmScore + "%.";

    var why = "This shape has appeared " + m.total + " times in this org's own closed history — "
      + m.lost + " of them lost."
      + (bucket ? " On day " + m.daysSinceFirstMarker + " since the first marker, deals in that bucket closed "
        + bucket.rate + "%." : "");

    return {
      // Same thresholds PriorityDealCard uses for its own tone, so the card's
      // severity dot and its gap figure can never disagree.
      severity: gap >= 25 ? "warn" : gap >= 10 ? "amber" : "good",
      headline: m.backwardsDate ? "Buyer moved backwards on " + fmtDay(m.backwardsDate)
        : back ? "Buyer moved to " + back.to : "Buyer at " + s.state,
      what: what,
      why: why,
      fix: top.title,
      fixMeta: top.form,
      symptoms: [
        { tone: "warn", label: a.markers.count + " of " + a.markers.all.length + " markers" },
        { tone: "warn", label: "\u2212" + gap + " pts vs CRM" },
        { tone: "warn", label: m.lost + " of " + m.total + " matched lost" },
      ],
      draft: d ? {
        channel: d.channel === "email" ? "Email" : d.channel,
        to: [d.recipientName, deal.company].filter(Boolean).join(", "),
        subject: d.subject,
        text: d.body,
      } : null,
    };
  }

  function toNudges(analysis) {
    var KIND = {
      A1: "STAKEHOLDER GAP", A2: "PROCESS TRAP", A3: "COMPARISON SET",
      A4: "COMPETITIVE PROCESS", A5: "STALLED DECISION",
    };
    var byId = {};
    analysis.events.forEach(function (e) { byId[e.event_id] = e; });
    return analysis.actions.map(function (x) {
      var src = byId[x.frameEvent || x.proofEvent] || null;
      return {
        kind: KIND[x.id] || "RECOMMENDED",
        title: x.title,
        sub: x.sub,
        action: x.form,
        executeLabel: x.id === "A1" ? "Draft the one-pager"
          : x.id === "A2" ? "Propose the session"
          : x.id === "A4" ? "Ask for the criteria"
          : x.id === "A5" ? "Ask for the date"
          : "Reply to procurement",
        severity: x.severity,
        movement: x,
        evidence: {
          source: src ? ({ email: "Email", call: "Meeting", linkedin: "LinkedIn", note: "Note", slack: "Slack" })[src.source] : "Nudge",
          label: "Derived from " + (x.basis || []).length + " counted outcomes",
          time: src ? fmtDay(src.timestamp) : analysis.asOf,
          detail: (x.basis || []).map(function (b) { return b.label + ": " + b.value; }).join(" · "),
          proof: x.frame || x.proof || x.sub,
        },
        draft: draftFor(x, analysis),
      };
    });
  }

  // The copy for each action, built out of the buyer's own words and the
  // buyer's own numbers. Nothing generic — that is the point of the beat.
  function draftFor(x, a) {
    var champ = (a.roles.champion || {}).name || "your champion";
    var champFirst = champ.split(" ")[0];
    if (x.id === "A1") {
      return {
        channel: "email", recipientName: champ, recipientRole: (a.roles.champion || {}).title,
        recipientEmail: (a.roles.champion || {}).email,
        subject: "One page for " + (x.title.match(/Reach ([^,]+?) through/) || [])[1],
        body: "Hi " + champFirst + ",\n\nYou asked me not to front-run Tom's review, so this is for you to send, not me.\n\n" +
          "One page, rewritten. The case we built is framed on forecast variance for Sven. Tom's own words when he started were “" +
          (x.frame || "") + "” — so it's reframed on that, and the only numbers in it are yours: " +
          (x.proof || "your validation result") + ".\n\nSend it as yours. If he wants 20 minutes with me afterwards, good. If not, you've still answered his review before it reaches you.\n\nRidha",
      };
    }
    if (x.id === "A2") {
      var sec = (a.roles.security || {}).name || "security";
      return {
        channel: "email", recipientName: sec, recipientRole: (a.roles.security || {}).title,
        recipientEmail: (a.roles.security || {}).email,
        subject: "Security assessment — 30 minutes instead?",
        body: "Hi " + String(sec).split(" ")[0] + ",\n\nWe'll complete the assessment. Before we do, could we take 30 minutes?\n\n" +
          "The extended version is scoped for systems holding customer contract data. Ours doesn't, and which of the 142 items actually apply changes a lot on that one answer. Half an hour now probably saves both of us a fortnight.\n\nRidha",
      };
    }
    return {
      channel: "email", recipientName: (a.roles.procurement || {}).name, recipientRole: (a.roles.procurement || {}).title,
      recipientEmail: (a.roles.procurement || {}).email,
      subject: "Vendor onboarding pack — pricing",
      body: "Dear " + String((a.roles.procurement || {}).name || "").split(" ")[0] + ",\n\n" +
        "The pack is attached with 24 month pricing, which is the term already agreed with Marta and Sven.\n\n" +
        "The 12 and 36 month lines depend on volume commitment, so before I put numbers against them: the request mentions each shortlisted supplier. Could you tell me who else is on the shortlist and what the evaluation criteria are? I'll price against the same basis they are.\n\nRidha",
    };
  }

  // ───────────────────────── the deal, filled in ───────────────────────────

  function apply(deal) {
    if (!deal || !deal.movementMode) return deal;
    var a = state.analysis;
    if (!a) return deal;
    var bundle = state.bundles.live;
    var s = a.series;

    // Monthly samples of the same two lines the movement tab draws.
    var pts = s.points;
    var step = Math.max(1, Math.floor(pts.length / 11));
    var sampled = [];
    for (var i = 0; i < pts.length; i += step) sampled.push(pts[i]);
    if (sampled[sampled.length - 1] !== pts[pts.length - 1]) sampled.push(pts[pts.length - 1]);

    /**
     * The CRM record on the deal header, from the CRM.
     *
     * `stage` was pinned to the literal "Validation" and the close date, value
     * and forecast label came from `data.jsx` — fine while every one of them was
     * a constant, wrong the moment the record became live. A rep pushing the
     * close date in HubSpot has to change the date on the header, or the first
     * thing the demo claims is the one thing the screen does not do.
     */
    var crm = a.crm || {};
    // "4 of 5 (Validation)" → "Validation", which is what the strip renders.
    var stageLabel = /\(([^)]+)\)/.exec(crm.stage || "");
    var closeLabel = crm.close_date
      ? new Date(crm.close_date + "T00:00:00Z").toLocaleDateString(window.NudgeI18n.locale(), {
          month: "short", day: "numeric", year: "numeric", timeZone: "UTC",
        })
      : deal.closeDate;

    var gapNow = (s.crmScore || 0) - (s.buyerScore || 0);
    // Registered before the deal is returned so the home board sees both halves
    // — the status and the diagnosis — on the same render.
    var diag = toDiagnosis(a, deal);
    if (diag && window.DEAL_DIAGNOSIS) window.DEAL_DIAGNOSIS[deal.id] = diag;

    return Object.assign({}, deal, {
      stage: stageLabel ? stageLabel[1] : (crm.stage || "Validation"),
      closeDate: closeLabel,
      value: crm.amount_acv != null ? crm.amount_acv : deal.value,
      arr: crm.amount_acv != null
        ? (crm.currency || "") + " " + Number(crm.amount_acv).toLocaleString("en-GB") +
          (crm.term_months ? " / " + crm.term_months + " months" : "")
        : deal.arr,
      statusLabel: crm.forecast_cat ? crm.forecast_cat + " · close " + closeLabel : deal.statusLabel,
      buyerScore: s.buyerScore,
      sellerScore: s.crmScore,
      gap: s.buyerScore - s.crmScore,
      trend: sampled.map(function (p) { return p.buyer; }),
      sellerTrend: sampled.map(function (p) { return p.crm; }),
      champion: (a.roles.champion || {}).name,
      championRole: (a.roles.champion || {}).title,
      activity: toActivity(a, bundle),
      stakeholders: toStakeholders(a, bundle),
      nudges: toNudges(a),
      movement: a,
      /**
       * The board's verdict, from the engine rather than from `data.jsx`.
       *
       * The seed record says "healthy" because that is what the CRM believes —
       * it is the CRM's row, and the demo opens on it being wrong. Leaving it
       * there kept the deal out of Top priority deals entirely, which is the one
       * place the disagreement is worth the most: the CRM has it at 80% and
       * Commit, and Nudge ranks it against every other deal the rep owns.
       */
      status: gapNow >= 10 ? "at-risk" : deal.status,
    });
  }

  /**
   * Read it all again and tell everyone.
   *
   * `load()` memoises on `loading` so the boot fetch happens once; clearing it
   * is the whole of a reload. Every subscriber — `app.jsx:1497` refills the deal,
   * `screens-movement.jsx:523` re-renders the tab — is already wired for this,
   * so a change in HubSpot reaches the screen without anything new in React.
   */
  function reload() {
    loading = null;
    return load();
  }

  /**
   * Watch the bridge for a new version and reload when it changes.
   *
   * Polls `/version`, which is a hash of exactly what the bridge would serve, so
   * a poll that finds nothing new costs one small request and does nothing at
   * all. It also notices the bridge coming back after being down, and reloads
   * then too — which is what makes starting the bridge mid-demo work.
   */
  var watchTimer = null;
  function watch(intervalMs) {
    if (watchTimer) clearInterval(watchTimer);
    var seen = null;
    var wasLive = false;
    watchTimer = setInterval(function () {
      fetch(LIVE_URLS.version(), { cache: "no-store" })
        .then(function (r) { return r.ok ? r.json() : null; })
        .then(function (v) {
          if (!v) throw new Error("no version");
          var changed = seen !== null && v.version !== seen;
          seen = v.version;
          if (state.version !== v.version) { state.version = v.version; if (!changed) notify(); }
          if (changed || !wasLive) {
            wasLive = true;
            // `!wasLive` covers the bridge arriving after the page did.
            if (changed || state.source !== "hubspot") reload();
          }
        })
        .catch(function () {
          // The bridge is gone. Fall back to the fixtures for real — once, on
          // the way down, not on every failed poll. Leaving the last live read
          // on screen would be worse than the fallback: the chip would go on
          // saying "live" over data that had stopped moving, which is the exact
          // thing it exists to prevent.
          if (wasLive) {
            wasLive = false;
            state.version = null;
            reload();
          }
        });
    }, intervalMs || 3000);
    return function () { clearInterval(watchTimer); watchTimer = null; };
  }

  /**
   * Which demo beat a recommended move corresponds to, if any.
   *
   * A1, A4 and A5 map to one — the move the history says separates the wins, and
   * the two replies to what just happened. Each sends a real mail. A2 and A3 are
   * real recommendations with no scripted beat behind them, so they fall through
   * to the normal prototype behaviour and return null here.
   */
  var ACTION_BEAT = { A1: "action", A4: "criteria", A5: "askdate" };
  function beatForAction(nudge) {
    var id = nudge && nudge.movement && nudge.movement.id;
    return ACTION_BEAT[id] || null;
  }

  /**
   * Send a recommended move for real.
   *
   * The prototype's usual path (`NudgeBackend.execute`) fabricates a buyer reply
   * on a timer and overlays a score. On every other deal that is a fine
   * simulation. On this one it would be a lie told in the middle of the argument
   * against exactly that: the header would jump because the tool that suggested
   * the action decided it had worked, while the chart beside it — derived from
   * the CRM — would not move at all.
   *
   * So here the click writes a real outbound email into HubSpot. The bridge sees
   * it on the next poll like any other CRM change, the engine re-reads it, and
   * the buyer's line stays exactly where it was, because the buyer has not
   * answered yet. Which is the truth, and a better demo than the lift.
   *
   * Returns null when there is nothing live to do, so the caller falls back.
   */
  function sendAction(nudge) {
    var beat = beatForAction(nudge);
    if (!beat || state.source !== "hubspot") return null;
    return fetch(LIVE_URLS.fire(beat), { method: "POST" })
      .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
      .then(function (res) {
        // A 409 means the beat has already fired — someone ran it from the
        // terminal, or clicked twice. Not an error worth a red toast.
        if (res.ok) reload();
        return res.body;
      })
      .catch(function (e) { return { ok: false, message: e.message }; });
  }

  window.MovementAdapter = {
    load: load,
    reload: reload,
    watch: watch,
    apply: apply,
    sendAction: sendAction,
    state: function () { return state; },
    analysis: function () { return state.analysis; },
    subscribe: subscribe,
  };

  /**
   * A small chip saying which data the room is actually looking at.
   *
   * It lives here, and draws itself in plain DOM, for two reasons. The bridge
   * can go down mid-demo and the fallback is deliberately silent — the deal room
   * keeps working off the fixtures — so without something visible nobody would
   * know the live half had stopped, and "watch it change in HubSpot" would
   * quietly become a lie. And `screens-movement.jsx` is the one file in this
   * feature somebody else may be editing at the same time; adding a component
   * there to say one word is not worth the collision.
   *
   * It follows the app theme rather than inverting, per the house rule, and
   * colours only the state that is a problem.
   */
  function mountIndicator() {
    var el = document.createElement("div");
    el.id = "hs-live-chip";
    el.setAttribute("aria-live", "polite");
    el.style.cssText = [
      "position:fixed", "right:14px", "bottom:14px", "z-index:60",
      "display:flex", "align-items:center", "gap:7px",
      "padding:6px 11px", "border-radius:999px",
      "font:500 11px/1.4 var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)",
      "letter-spacing:.02em",
      "background:var(--surface, #fff)", "color:var(--text-dim, #667)",
      "border:1px solid var(--border, rgba(0,0,0,.10))",
      "box-shadow:0 1px 3px rgba(0,0,0,.06)",
      "cursor:default", "user-select:none",
    ].join(";");

    var dot = document.createElement("span");
    dot.style.cssText = "width:7px;height:7px;border-radius:50%;flex:none;transition:background .2s";
    var label = document.createElement("span");
    el.appendChild(dot);
    el.appendChild(label);

    function paint() {
      var liveAll = state.source === "hubspot";
      var some = state.source === "mixed";
      dot.style.background = liveAll ? "var(--good, #1a9c63)" : some ? "var(--warn, #b7791f)" : "var(--text-faint, #9aa)";
      label.textContent = liveAll
        ? "HubSpot · live" + (state.version ? " · " + state.version.slice(0, 6) : "")
        : some
          ? "HubSpot · partial (" + state.live + "/5)"
          : "fixtures · bridge offline";
      el.title = liveAll
        ? "The deal record, emails, calls, notes and contacts are being read from HubSpot every 3 seconds. LinkedIn, Slack and reply latency are never read from the CRM — the analysis is derived without them."
        : LOCAL
          ? "The HubSpot bridge is not answering, so this deal is running on its fixtures. Start it with: npm run vle:bridge"
          : "The live CRM read is not answering, so this deal is running on its fixtures.";
      el.style.opacity = liveAll ? "0.75" : "1";
    }

    paint();
    subscribe(paint);
    (document.body || document.documentElement).appendChild(el);
  }

  // Warm the data the moment the app boots, so the tab paints instantly rather
  // than flashing a spinner in front of a room — then keep watching the bridge.
  if (typeof window !== "undefined") {
    load();
    watch(3000);
    if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", mountIndicator);
    else mountIndicator();
  }
})();
