/* =============================================================
   page-pm.jsx — Project Management module
   ─────────────────────────────────────────────────────────────
   Implements the Project Management module surface:

     - Module shell with three tabs + right-rail comments
     - Editor tab: sub-tabs × 5 entities, search, filters, side
       editor form with per-field controls
     - What's next: auto-derived rail + drag-and-drop kanban
     - Activity: filtered reverse-chronological feed
     - Universal threaded comments (Markdown subset, @mentions)
     - Animated counters + sparklines in the hero band
     - Sync menu: download updated xlsx, snapshot import/export
   ============================================================= */
const { useEffect, useMemo, useRef, useState, useCallback } = React;

/* ---------------- Tiny helpers ---------------- */
function relTime(iso) {
  if (!iso) return "";
  const d = new Date(iso);
  const diff = (Date.now() - d.getTime()) / 1000;
  if (diff < 60) return `${Math.floor(diff)}s ago`;
  if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
  if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
  return `${Math.floor(diff / 86400)}d ago`;
}

function initials(nameOrEmail) {
  if (!nameOrEmail) return "?";
  const s = String(nameOrEmail);
  const parts = s.split(/[\s@._-]+/).filter(Boolean);
  if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
  return s.slice(0, 2).toUpperCase();
}

function colorForActor(s) {
  if (!s) return "#888";
  let h = 0;
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) & 0xffffffff;
  return `hsl(${Math.abs(h) % 360}deg 45% 45%)`;
}

/* Storage-mode indicator (GA-18): pmStorageMode() + <StorageModePill/> now live in
   components.jsx (loaded first) so PM, tracker editors, readiness editors and the
   discussion rail all share one helper + one component. Referenced here as globals. */

function MCAvatar({ name, email, size = 26 }) {
  const id = name || email || "?";
  return (
    <span
      className="mc-avatar pm-avatar"
      style={{
        width: size, height: size, borderRadius: size / 2,
        background: colorForActor(id),
        fontSize: Math.max(9, size * 0.42),
      }}
      title={name ? `${name}${email ? ` · ${email}` : ""}` : email}
    >
      {initials(id)}
    </span>
  );
}

/* ---------- Animated counter (rAF easing) ---------- */
function AnimatedCounter({ value = 0, duration = 600, format = (v) => v.toLocaleString() }) {
  const [displayed, setDisplayed] = useState(value);
  const startRef = useRef({ t: 0, from: value });
  useEffect(() => {
    const reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reducedMotion) { setDisplayed(value); return; }
    startRef.current = { t: performance.now(), from: displayed };
    let frame;
    const tick = (now) => {
      const elapsed = now - startRef.current.t;
      const k = Math.min(1, elapsed / duration);
      const e = 1 - Math.pow(1 - k, 3);
      setDisplayed(startRef.current.from + (value - startRef.current.from) * e);
      if (k < 1) frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [value]);
  return <>{format(Math.round(displayed))}</>;
}

/* ---------- Sparkline ---------- */
function Sparkline({ data, width = 80, height = 24, color = "var(--accent)" }) {
  if (!data || !data.length) {
    return <svg width={width} height={height}/>;
  }
  const max = Math.max(...data, 1);
  const min = Math.min(...data, 0);
  const range = max - min || 1;
  const pts = data.map((v, i) => {
    const x = (i / (data.length - 1)) * (width - 2) + 1;
    const y = height - 2 - ((v - min) / range) * (height - 4);
    return `${x},${y}`;
  });
  const d = "M " + pts.join(" L ");
  return (
    <svg width={width} height={height} className="pm-spark">
      <path d={d} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/>
      <circle cx={pts[pts.length - 1].split(",")[0]} cy={pts[pts.length - 1].split(",")[1]} r="2" fill={color}/>
    </svg>
  );
}

function MCSparkbar({ data, height = 28 }) {
  const values = Array.isArray(data) && data.length ? data : [0];
  const max = Math.max(...values, 1);
  return (
    <div className="mc-sparkbar" style={{ height }}>
      {values.map((v, i) => (
        <span key={i} style={{ height: `${Math.max(8, (v / max) * 100)}%` }} />
      ))}
    </div>
  );
}

function MCStat({ label, value, foot, tone = "", primary, data }) {
  return (
    <div className={"mc-stat" + (primary ? " primary" : "") + (tone ? ` ${tone}` : "")}>
      <div className="mc-stat-label">{label}</div>
      <div className="mc-stat-big"><AnimatedCounter value={Number(value) || 0}/></div>
      {data && <div className="mc-spark-wrap"><MCSparkbar data={data}/></div>}
      {foot && <div className="mc-stat-foot">{foot}</div>}
    </div>
  );
}

function MCPill({ tone = "neutral", soft = false, children, mono = false }) {
  return <span className={"mc-pill tone-" + tone + (soft ? " soft" : "") + (mono ? " mono" : "")}>{children}</span>;
}

function MCStatusDot({ tone = "neutral", pulse = false }) {
  return <span className={"mc-dot tone-" + tone + (pulse ? " pulse" : "")}/>;
}

function MCEmpty({ title = "Nothing here yet", sub = "" }) {
  return (
    <div className="mc-empty">
      <div className="mc-empty-title">{title}</div>
      {sub && <div className="mc-empty-sub">{sub}</div>}
    </div>
  );
}

function MCSearchBox({ value, onChange, placeholder = "Search..." }) {
  return (
    <div className="mc-search">
      <Glyph name="search" size={14}/>
      <input value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder}/>
      {value && <button className="mc-search-clear" onClick={() => onChange("")} title="Clear search">×</button>}
    </div>
  );
}

function mcDateOnly(value) {
  if (!value) return "";
  if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) return value.slice(0, 10);
  const d = value instanceof Date ? value : new Date(value);
  if (isNaN(d)) return "";
  return d.toISOString().slice(0, 10);
}

function mcDaysUntil(value) {
  const iso = mcDateOnly(value);
  if (!iso) return null;
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  const d = new Date(iso + "T00:00:00");
  return Math.ceil((d.getTime() - today.getTime()) / 86400000);
}

/* ---------- Markdown subset renderer (safe) ---------- */
// Canonical @mention handle (mirrors client.js toHandle): lowercase, drop
// leading domain + dots/dashes/underscores/spaces so "Jane.Doe" == "janedoe".
function canonMentionHandle(s) {
  return String(s || "").toLowerCase().split("@")[0].replace(/[._\-\s]+/g, "");
}
function renderMarkdownSubset(text, knownHandles) {
  if (!text) return null;
  const esc = (s) => s
    .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
  let html = esc(text);
  // @mentions — when a knownHandles set is supplied (GA-10), only highlight
  // handles that resolve to a real user; an unknown @handle stays plain text
  // so it never looks like a successful (but undelivered) notification target.
  html = html.replace(/(^|\s)@([a-zA-Z0-9._-]+)/g, (full, lead, handle) => {
    if (knownHandles && !knownHandles.has(canonMentionHandle(handle))) return full;
    return `${lead}<span class="pm-mention">@${handle}</span>`;
  });
  // Bold, italic, code
  html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
  html = html.replace(/(?:^|[^*])\*([^*]+)\*/g, (m, g) => m.replace("*" + g + "*", "<em>" + g + "</em>"));
  html = html.replace(/`([^`]+)`/g, '<code class="pm-code">$1</code>');
  // Links
  html = html.replace(/https?:\/\/[^\s<]+/g, (u) => `<a href="${u}" target="_blank" rel="noreferrer">${u}</a>`);
  // Newlines → <br>
  html = html.replace(/\n/g, "<br/>");
  return <span dangerouslySetInnerHTML={{ __html: html }} />;
}

/* =============================================================
   PmHero — animated metrics band at the top of the page
   ============================================================= */
function MCCommandBar({ model, tasks, activity, plans }) {
  const today = new Date().toISOString().slice(0, 10);
  // Combine plans + tasks for the headline counters so the new Planner
  // surface drives the same hero numbers as the legacy task surface.
  const combined = useMemo(() => {
    const out = [];
    for (const t of (tasks || [])) out.push({ status: t.status, dueDate: t.dueDate });
    for (const p of (plans || [])) out.push({ status: p.status === "cancelled" ? "done" : p.status, dueDate: p.targetDate });
    return out;
  }, [tasks, plans]);
  const overdueCount = combined.filter((t) => t.status !== "done" && t.dueDate && t.dueDate < today).length;
  const openCount = combined.filter((t) => t.status !== "done").length;
  const underlayOpen = ((model && model.underlay) || []).filter((l) => !PRData.isActivatedLink(l)).length;

  const upcoming = useMemo(() => {
    return PRData.upcomingEvents(model, 14).length;
  }, [model]);
  const nextEvent = useMemo(() => {
    return PRData.upcomingEvents(model, 28)
      .filter((c) => c.date && !isNaN(c.date))
      .sort((a, b) => a.date - b.date)[0] || null;
  }, [model]);

  // 14-day sparkline buckets for activity
  const sparkData = useMemo(() => {
    const buckets = new Array(14).fill(0);
    const now = Date.now();
    for (const a of activity) {
      const d = new Date(a.createdAt).getTime();
      const days = Math.floor((now - d) / (24 * 3600 * 1000));
      if (days >= 0 && days < 14) buckets[13 - days]++;
    }
    return buckets;
  }, [activity]);

  const openWork = openCount + underlayOpen;
  const nextDays = nextEvent ? mcDaysUntil(nextEvent.date) : null;

  return (
    <div className="mc-cmdbar">
      <div className="mc-cmdbar-grid">
        <MCStat
          primary
          label="Open work"
          value={openWork}
          data={sparkData}
          foot={<><span className="mono">{openCount}</span> task/plan items · <span className="mono">{underlayOpen}</span> underlay links</>}
        />
        <MCStat
          tone={overdueCount > 0 ? "danger" : ""}
          label="Overdue"
          value={overdueCount}
          data={sparkData}
          foot={overdueCount > 0 ? "Needs attention" : "No late task/plan dates"}
        />
        <MCStat
          label="Coming up · 14d"
          value={upcoming}
          data={sparkData}
          foot="Workbook-derived milestones"
        />
        <div className="mc-stat callout">
          <div className="mc-stat-label">
            <MCStatusDot tone={nextEvent ? "accent" : "neutral"} pulse={!!nextEvent}/>
            Next checkpoint
          </div>
          {nextEvent ? (
            <>
              <div className="mc-callout-title">{PRData.fmtDate(nextEvent.date)}</div>
              <div className="mc-callout-meta">
                <span className="mc-callout-kind">{nextEvent.kind}</span>
                <span className="mc-callout-eta">
                  {nextDays === 0 ? "today" : nextDays > 0 ? `in ${nextDays}d` : `${Math.abs(nextDays)}d late`}
                </span>
              </div>
              <div className="mc-stat-foot">{nextEvent.title}</div>
            </>
          ) : (
            <>
              <div className="mc-callout-title muted">No dated milestone</div>
              <div className="mc-stat-foot">The canonical model has no upcoming events in the next 28 days.</div>
            </>
          )}
        </div>
      </div>
      <div className="mc-cmdbar-ribbon"/>
    </div>
  );
}

function PmHero(props) {
  return <MCCommandBar {...props}/>;
}

/* =============================================================
   PmEditor — Tab 1
   ============================================================= */
const EDITOR_TABS = [
  { id: "sites",       label: "Sites",              entity: "overlay" },
  { id: "underlay",    label: "Underlay links",     entity: "underlay" },
  { id: "isp",         label: "ISP terminations",   entity: "isp" },
  { id: "newunderlay", label: "New underlay",       entity: "newUnderlay" },
];

function PmEditor({ model, role, focus, setFocus }) {
  const [subTab, setSubTab] = useState("sites");
  const [search, setSearch] = useState("");
  const [filters, setFilters] = useState({});
  const [busy, setBusy] = useState(false);
  const [bulkOpen, setBulkOpen] = useState(false);
  const [pageLimit, setPageLimit] = useState(250);
  const [newRowOpen, setNewRowOpen] = useState(false);
  const canEdit = role === "engineer" || role === "po" || role === "admin";
  const canBulk = role === "po" || role === "admin";

  // PROP-12: when a focus payload comes in (e.g. from "Open in PM" on a tracker),
  // switch to the right sub-tab so the row is visible and the panel opens.
  useEffect(() => {
    if (!focus || !focus.entity) return;
    const map = { underlay: "underlay", overlay: "sites", isp: "isp", newUnderlay: "newunderlay", site: "sites" };
    const target = map[focus.entity] || "sites";
    if (target !== subTab) setSubTab(target);
    // Pre-fill search with the id so the row pops to the top.
    if (focus.idRef && !search) setSearch(String(focus.idRef));
  }, [focus]);

  const rows = useMemo(() => {
    if (!model) return [];
    // GA-07: every sub-tab selector must fall back to [] so a missing model
    // array can never make `filtered = rows.filter(...)` throw and blank the
    // editor. Log (don't hide) when an expected array is absent.
    const pick = (key, val) => {
      if (val == null) console.warn("[PmEditor] model." + key + " is missing; rendering empty table");
      return val || [];
    };
    if (subTab === "sites")       return model.canonicalSites || model.sites || [];
    if (subTab === "underlay")    return pick("underlay", model.underlay);
    if (subTab === "isp")         return pick("isp", model.isp);
    if (subTab === "newunderlay") return pick("newUnderlay", model.newUnderlay);
    return [];
  }, [model, subTab]);
  const counts = useMemo(() => PRData.modelCounts(model), [model]);

  const filtered = useMemo(() => {
    const q = search.trim().toLowerCase();
    return rows.filter((r) => {
      if (filters.region && r.region !== filters.region) return false;
      if (filters.country && r.country !== filters.country) return false;
      if (filters.batch && (r.batch || "") !== filters.batch) return false;
      if (q) {
        const hay = JSON.stringify(r).toLowerCase();
        if (!hay.includes(q)) return false;
      }
      return true;
    });
  }, [rows, filters, search]);

  const opts = useMemo(() => {
    const u = (key) => [...new Set(rows.map((r) => r[key]).filter(Boolean))].sort();
    return {
      region: u("region"),
      country: u("country"),
      batch: u("batch"),
    };
  }, [rows]);

  const cols = useMemo(() => buildEditorColumns(subTab), [subTab]);

  const idKey = subTab === "sites" ? "siteCode" :
                subTab === "underlay" ? "linkRef" :
                subTab === "isp" ? "linkRef" :
                "linkRef";
  const entity = subTab === "sites" ? "overlay" :
                 subTab === "underlay" ? "underlay" :
                 subTab === "isp" ? "isp" :
                 "newUnderlay";
  const keyLabel = subTab === "sites" ? "Site code" : "Link Ref";

  // FT-08: whole-row create / delete dispatch per editor sub-tab. Mirrors the
  // per-field patch dispatch in EntityEditor.handleSave, but for whole rows and
  // routed through the FT-07 client surface (invariant 3 — every write goes via
  // PMClient, so both run modes work: LocalClient writes the FT-05 store + notify;
  // AzureClient POSTs/DELETEs the FT-04 routes). The Sites tab adds a real site
  // (createSite -> sitesReference -> canonicalSites: per the model contract the
  // only source that yields a site with no link); the link-bearing tabs add their
  // own domain row, whose new siteCode then surfaces a canonical site for free.
  const rowCreate = useCallback((body) => {
    if (subTab === "sites")       return PMClient.createSite(body);
    if (subTab === "underlay")    return PMClient.createLink(body);
    if (subTab === "isp")         return PMClient.createIsp(body);
    if (subTab === "newunderlay") return PMClient.createNewUnderlay(body);
    return Promise.reject(new Error("Unknown editor tab: " + subTab));
  }, [subTab]);
  const rowDelete = useCallback((id) => {
    if (subTab === "sites")       return PMClient.deleteSite(id);
    if (subTab === "underlay")    return PMClient.deleteLink(id);
    if (subTab === "isp")         return PMClient.deleteIsp(id);
    if (subTab === "newunderlay") return PMClient.deleteNewUnderlay(id);
    return Promise.reject(new Error("Unknown editor tab: " + subTab));
  }, [subTab]);

  const onRowClick = useCallback((r) => {
    setFocus({ entity, idRef: r[idKey] || r.linkRef || r.siteCode || r.siteName, data: r, kind: subTab });
  }, [entity, idKey, subTab, setFocus]);

  // PMNU-06: jump from a site's mirrored New Underlay block to the owning Underlay
  // link editor. Mirror fields are read-only here; their source of truth is the
  // Underlay row, so navigation re-focuses the editor on that link (the focus
  // effect above flips the sub-tab to "underlay").
  const onOpenUnderlay = useCallback((linkRef) => {
    if (!linkRef || !model) return;
    const data = (model.underlay || []).find((r) => r.linkRef === linkRef) || null;
    setFocus({ entity: "underlay", idRef: linkRef, data, kind: "underlay" });
  }, [model, setFocus]);

  return (
    <div className="mc-editor">
      <div className="mc-sub-nav">
        <div className="mc-segmented">
          {EDITOR_TABS.map((t) => (
            <button key={t.id}
                    className={"mc-seg" + (subTab === t.id ? " active" : "")}
                    onClick={() => setSubTab(t.id)}>
              {t.label}
              <span className="mc-seg-count">
                {t.id === subTab ? filtered.length : (model ? (
                  t.id === "sites" ? counts.sites :
                  t.id === "underlay" ? counts.underlay :
                  t.id === "isp" ? counts.isp :
                  counts.newUnderlay
                ) : 0)}
              </span>
            </button>
          ))}
        </div>
        <div className="mc-sub-nav-right">
          <span className="mc-count">{rows.length.toLocaleString()} source rows</span>
        </div>
      </div>

      <div className="mc-toolbar">
        <MCSearchBox value={search} onChange={setSearch} placeholder={`Search ${EDITOR_TABS.find((t) => t.id === subTab).label.toLowerCase()}...`}/>
        <FilterSelect label="Region" value={filters.region} onChange={(v) => setFilters({ ...filters, region: v })} options={opts.region}/>
        <FilterSelect label="Country" value={filters.country} onChange={(v) => setFilters({ ...filters, country: v })} options={opts.country}/>
        {opts.batch.length > 0 && (
          <FilterSelect label="Batch" value={filters.batch} onChange={(v) => setFilters({ ...filters, batch: v })} options={opts.batch}/>
        )}
        {(Object.keys(filters).some((k) => filters[k]) || search) && (
          <button className="btn ghost mc-reset" onClick={() => { setFilters({}); setSearch(""); }}>Reset</button>
        )}
        <span style={{ flex: 1 }}/>
        {canEdit && (
          <button className="btn primary" onClick={() => setNewRowOpen(true)}>
            <Glyph name="add" size={13}/> New row
          </button>
        )}
        {canBulk && (
          <button className="btn" onClick={() => setBulkOpen(true)} disabled={!filtered.length}>
            <Glyph name="edit" size={13}/> Bulk update
          </button>
        )}
      </div>

      <div className="table-wrap table-dense mc-grid-wrap">
        <div className="table-toolbar">
          <span className="mono" style={{ fontSize: 11.5, color: "var(--ink-500)" }}>
            {filtered.length.toLocaleString()} of {rows.length.toLocaleString()} rows
          </span>
          <span style={{ flex: 1 }}/>
          <span style={{ fontSize: 11.5, color: "var(--ink-400)" }}>
            {filtered.length > pageLimit ? `Showing first ${pageLimit}` : ""}
          </span>
        </div>
        <DataTable
          columns={cols}
          rows={filtered.slice(0, pageLimit).map((r) => ({
            ...r,
            _focused: focus && focus.idRef === (r[idKey] || r.linkRef || r.siteCode || r.siteName),
          }))}
          onRowClick={onRowClick}
          maxHeight={"60vh"}
        />
        {filtered.length > pageLimit && (
          <div style={{ textAlign: "center", padding: 10 }}>
            <button className="btn" onClick={() => setPageLimit(pageLimit + 250)}>Load 250 more…</button>
          </div>
        )}
      </div>

      {focus && focus.kind === subTab && (
        <EntityEditor
          entity={entity}
          kind={subTab}
          record={focus.data}
          idRef={focus.idRef}
          focusTab={focus.tab}
          focusCommentId={focus.commentId}
          model={model}
          canEdit={canEdit}
          onOpenUnderlay={onOpenUnderlay}
          onDelete={rowDelete}
          onClose={() => setFocus(null)}
          onSaved={() => {/* model re-renders via PMClient.subscribe */}}
        />
      )}

      {bulkOpen && (
        <BulkUpdateModal
          entity={entity}
          rows={filtered}
          idKey={idKey}
          onClose={() => setBulkOpen(false)}
        />
      )}

      {newRowOpen && (
        <NewRowModal
          kind={subTab}
          keyField={idKey}
          keyLabel={keyLabel}
          onCreate={rowCreate}
          onClose={() => setNewRowOpen(false)}
        />
      )}
    </div>
  );
}

/* =============================================================
   NewRowModal — FT-08 "New row" affordance for the PM editor tabs.
   Collects the natural key (required) plus a few common fields, then
   routes through the tab's PMClient.create* method (rowCreate). The
   client runs every field through the data.js field-write gate
   (normalizePMFieldValue) so invariants 4/5 hold and New-Underlay
   mirror fields are dropped (re-derived from the owning Underlay row).
   After create the standalone store notify -> app subscribe -> rebuild
   path refreshes the model, so the new row appears in the table and the
   monitors with no bespoke reload.
   ============================================================= */
function NewRowModal({ kind, keyField, keyLabel, onCreate, onClose }) {
  const [draft, setDraft] = useState({});
  const [busy, setBusy] = useState(false);
  const keyVal = String(draft[keyField] || "").trim();
  const set = (k) => (e) => setDraft({ ...draft, [k]: e.target.value });
  const tab = EDITOR_TABS.find((t) => t.id === kind);
  const noun = tab ? tab.label.replace(/s$/, "").toLowerCase() : "row";

  const save = async () => {
    if (!keyVal || busy) return;
    setBusy(true);
    try {
      const body = {};
      Object.entries(draft).forEach(([k, v]) => {
        if (String(v == null ? "" : v).trim() !== "") body[k] = v;
      });
      body[keyField] = keyVal;
      await onCreate(body);
      onClose();
    } catch (e) {
      console.error("Create row failed", e);
      alert("Create failed: " + (e.message || e));
      setBusy(false);
    }
  };

  return (
    <div className="pm-modal-backdrop" onClick={onClose}>
      <div className="pm-modal" onClick={(e) => e.stopPropagation()}>
        <div className="pm-modal-h">
          <div className="title">New {noun}</div>
          <button className="btn ghost" onClick={onClose}><Glyph name="close" size={14}/></button>
        </div>
        <div className="pm-modal-body">
          <div className="pm-field">
            <label className="pm-field-label">{keyLabel} *</label>
            <input className="pm-input" value={draft[keyField] || ""} onChange={set(keyField)} autoFocus
                   placeholder={`Required — unique ${keyLabel.toLowerCase()}`}/>
          </div>
          <div className="pm-field">
            <label className="pm-field-label">Site name</label>
            <input className="pm-input" value={draft.siteName || ""} onChange={set("siteName")}/>
          </div>
          <div className="grid g-2" style={{ gap: 12 }}>
            <div className="pm-field">
              <label className="pm-field-label">Country</label>
              <input className="pm-input" value={draft.country || ""} onChange={set("country")}/>
            </div>
            <div className="pm-field">
              <label className="pm-field-label">Region</label>
              <input className="pm-input" value={draft.region || ""} onChange={set("region")}/>
            </div>
          </div>
          <div className="pm-readonly-pill" style={{ marginTop: 6 }}>
            Saved through your current storage mode and propagated to the monitors. Unknown
            or read-only fields (incl. New-Underlay mirror fields) are dropped automatically.
          </div>
        </div>
        <div className="pm-modal-actions">
          <span style={{ flex: 1 }}/>
          <button className="btn" onClick={onClose}>Cancel</button>
          <button className="btn primary" disabled={busy || !keyVal} onClick={save}>{busy ? "Creating…" : "Create row"}</button>
        </div>
      </div>
    </div>
  );
}

function buildEditorColumns(kind) {
  if (kind === "sites") {
    return [
      { key: "siteCode", label: "Code", width: 140, cellClass: "mono strong",
        render: (r) => <RowRef row={r} value={r.siteCode}/> },
      { key: "siteName", label: "Site", render: (r) => (
        <div style={{display:"flex", flexDirection:"column"}}>
          <span className="strong">{r.shortLabel || r.siteName || "—"}</span>
          <span className="muted" style={{fontSize: 11}}>{r.country} · {r.region}</span>
        </div>)},
      { key: "hwDelivery", label: "HW", width: 110, render: (r) => <OverrideAware r={r} field="hwDelivery"><StatusCell value={r.hwDelivery} kind="task"/></OverrideAware> },
      { key: "migrationStatus", label: "Migration", width: 130, render: (r) => <OverrideAware r={r} field="migrationStatus"><StatusCell value={r.migrationStatus || r.migrationSchedule} kind="task"/></OverrideAware> },
      { key: "confirmedMigration", label: "Migration date", width: 130, cellClass: "mono",
        render: (r) => <OverrideAware r={r} field="confirmedMigration">{PRData.fmtDate(r.confirmedMigration)}</OverrideAware> },
      { key: "techno", label: "TECHNO", width: 120, render: (r) => r.techno || <span className="muted">—</span>},
    ];
  }
  if (kind === "underlay") {
    return [
      { key: "linkRef", label: "Ref", width: 110, cellClass: "mono strong",
        render: (r) => <RowRef row={r} value={r.linkRef}/> },
      { key: "siteName", label: "Site", render: (r) => (
        <div style={{display:"flex", flexDirection:"column"}}>
          <span className="strong">{r.siteName || "—"}</span>
          <span className="muted" style={{fontSize: 11}}>{r.country} · {r.region}</span>
        </div>)},
      { key: "vendor", label: "Vendor", width: 110 },
      { key: "prOrderStatus", label: "Order", width: 110, render: (r) => <OverrideAware r={r} field="prOrderStatus"><StatusCell value={r.prOrderStatus} kind="task"/></OverrideAware> },
      { key: "circuitStatus", label: "Circuit", width: 170, render: (r) => <OverrideAware r={r} field="circuitStatus"><StatusCell value={r.circuitStatus} kind="circuit"/></OverrideAware> },
      { key: "cdd", label: "CDD", width: 110, cellClass: "mono", render: (r) => <OverrideAware r={r} field="cdd">{PRData.fmtDate(r.cdd)}</OverrideAware> },
      { key: "atRisk", label: "Risk", width: 90, render: (r) => <RiskPill level={r.riskLevel} atRisk={r.atRisk}/> },
    ];
  }
  if (kind === "isp") {
    return [
      { key: "linkRef", label: "Ref", width: 110, cellClass: "mono", render: (r) => <RowRef row={r} value={r.linkRef}/> },
      { key: "siteName", label: "Site", render: (r) => (
        <div style={{display:"flex", flexDirection:"column"}}>
          <span className="strong">{r.siteName || "—"}</span>
          <span className="muted" style={{fontSize: 11}}>{r.country} · {r.region}</span>
        </div>)},
      { key: "isp", label: "Legacy ISP", width: 140 },
      { key: "terminated", label: "Status", width: 110, render: (r) => <OverrideAware r={r} field="terminated"><StatusCell value={r.terminated} kind="task"/></OverrideAware> },
      { key: "terminationDate", label: "Term. date", width: 120, cellClass: "mono", render: (r) => PRData.fmtDate(r.terminationDate) },
    ];
  }
  // new underlay
  return [
    { key: "linkRef", label: "Ref", width: 110, cellClass: "mono strong", render: (r) => <RowRef row={r} value={r.linkRef}/> },
    { key: "siteName", label: "Site", render: (r) => (
      <div style={{display:"flex", flexDirection:"column"}}>
        <span className="strong">{r.siteName || "—"}</span>
        <span className="muted" style={{fontSize: 11}}>{r.country} · {r.region}</span>
      </div>)},
    { key: "isp", label: "ISP", width: 130 },
    { key: "circuitDelivered", label: "Delivered?", width: 110, render: (r) => <StatusCell value={r.circuitDelivered} kind="task"/>},
    { key: "taskPct", label: "Task %", width: 90, cellClass: "mono",
      render: (r) => r.taskPct !== null && r.taskPct !== undefined ? Math.round(r.taskPct) + "%" : "—" },
  ];
}

function RowRef({ row, value }) {
  return (
    <span className={row._focused ? "pm-row-focused" : ""}>
      {value || "—"}
    </span>
  );
}

function OverrideAware({ r, field, children }) {
  const isOverridden = r._overridden && r._overridden.includes(field);
  if (!isOverridden) return children;
  return (
    <span className="pm-override-mark" title="Edited locally — pending sync">
      {children}
      <span className="pm-override-dot"/>
    </span>
  );
}

/* =============================================================
   EntityEditor — per-field form in a side panel
   ============================================================= */
function EntityEditor({ entity, kind, record, idRef, focusTab, focusCommentId, model, canEdit, onOpenUnderlay, onDelete, onClose, onSaved }) {
  const [savingField, setSavingField] = useState(null);
  const [savedFlash, setSavedFlash] = useState(null);
  const [deleting, setDeleting] = useState(false);
  const [tab, setTab] = useState("fields");

  const fieldsSpec = useMemo(() => buildFieldsSpec(kind), [kind]);

  // GA-11: a notification deep-link can request the Discussion tab for a specific
  // comment. Open "comments" when asked, else default to "fields" for the row.
  // Keyed on idRef so manual tab toggles within the same row are never overridden.
  useEffect(() => {
    setTab(focusTab === "comments" ? "comments" : "fields");
  }, [idRef, focusTab, focusCommentId]);

  // Defensive: if a cross-module openInPM call set the focus but the row could not
  // be resolved in the effective model, render a clear "not found" panel instead
  // of crashing on record.siteCode.
  if (!record) {
    return (
      <SidePanel open={true} onClose={onClose} title={idRef || "Record"} subtitle={kind + " · not in current effective model"} className="mc-editor-panel">
        <div className="pm-readonly-pill" style={{ background: "var(--warn-soft)", color: "var(--warn)", margin: 10 }}>
          <strong>Row not found.</strong>
          <div style={{ fontSize: 12, marginTop: 4 }}>
            No <span className="mono">{entity}</span> row matches <span className="mono">{idRef}</span> in the current effective model.
            Use the table below to pick the right row, or close this panel and re-open from the source tracker.
          </div>
        </div>
      </SidePanel>
    );
  }

  const handleSave = useCallback(async (field, value) => {
    if (!canEdit) return;
    setSavingField(field);
    try {
      const priorMeta = (record && record._overrideMeta && record._overrideMeta[field]) || {};
      const hasPriorWorkbookValue = Object.prototype.hasOwnProperty.call(priorMeta, "workbookValue") &&
        priorMeta.workbookValue !== undefined && priorMeta.workbookValue !== null;
      const patch = {
        [field]: value,
        __meta: {
          [field]: {
            workbookValue: hasPriorWorkbookValue ? priorMeta.workbookValue : getRecordField(record, field),
            sourceEntity: entity,
            sourceField: field,
          },
        },
      };
      if (entity === "overlay")     await PMClient.patchOverlay(idRef, patch);
      else if (entity === "underlay") await PMClient.patchLink(idRef, patch);
      else if (entity === "isp")    await PMClient.patchIsp(idRef, patch);
      else if (entity === "newUnderlay") await PMClient.patchNewUnderlay(idRef, patch);
      else throw new Error("Unknown PM entity: " + entity);
      setSavedFlash(field);
      setTimeout(() => setSavedFlash(null), 1500);
      onSaved && onSaved();
    } catch (e) {
      console.error("Save failed", e);
      alert("Save failed: " + (e.message || e));
    } finally {
      setSavingField(null);
    }
  }, [canEdit, entity, idRef, onSaved, record]);

  const handleRevert = useCallback(async (field) => {
    if (!canEdit) return;
    try {
      const patch = { __clear: field };
      if (entity === "overlay")     await PMClient.patchOverlay(idRef, patch);
      else if (entity === "underlay") await PMClient.patchLink(idRef, patch);
      else if (entity === "isp")    await PMClient.patchIsp(idRef, patch);
      else if (entity === "newUnderlay") await PMClient.patchNewUnderlay(idRef, patch);
      else throw new Error("Unknown PM entity: " + entity);
      onSaved && onSaved();
    } catch (e) {
      console.error("Revert failed", e);
    }
  }, [canEdit, entity, idRef, onSaved]);

  // FT-08: whole-row delete. Mirrors the task delete precedent (confirm() then
  // PMClient.delete*). onDelete is the tab's PMClient.delete* dispatcher; on
  // success the row is tombstoned (standalone) / soft-deleted (server) and the
  // subscribe -> rebuild path drops it from the table and the monitors, so we
  // just close the panel.
  const handleDelete = useCallback(async () => {
    if (!canEdit || !onDelete) return;
    if (!confirm(`Delete this ${kind} row (${idRef})? You can re-add it with "New row".`)) return;
    setDeleting(true);
    try {
      await onDelete(idRef);
      onSaved && onSaved();
      onClose && onClose();
    } catch (e) {
      console.error("Delete row failed", e);
      alert("Delete failed: " + (e.message || e));
      setDeleting(false);
    }
  }, [canEdit, onDelete, idRef, kind, onSaved, onClose]);

  return (
    <SidePanel
      open={true}
      onClose={onClose}
      title={record.siteCode || record.linkRef || record.siteName || idRef || "Record"}
      subtitle={`${kind} · ${record.country || ""} ${record.region ? "· " + record.region : ""}`}
      className="mc-editor-panel"
    >
      <div className="pm-edit-tabs">
        <button className={tab === "fields" ? "active" : ""} onClick={() => setTab("fields")}>Fields</button>
        <button className={tab === "comments" ? "active" : ""} onClick={() => setTab("comments")}>Discussion</button>
      </div>

      {tab === "fields" && (
        <div className="pm-fields">
          {/* GA-18: editor-wide local-only vs server-backed indicator (shared component).
              Shown above the role pill so users always know whether a save stays in this
              browser or is shared via the backend. */}
          <StorageModePill/>
          {!canEdit && <div className="pm-readonly-pill">Read-only · switch role to edit</div>}
          {fieldsSpec.map((group) => (
            <React.Fragment key={group.label}>
              <div className="pm-fieldgroup">
                <div className="pm-fieldgroup-h">{group.label}</div>
                {group.description && <div className="pm-fieldgroup-desc">{group.description}</div>}
                {group.fields.map((f) => (
                  <FieldEditor
                    key={f.key}
                    field={f}
                    value={getRecordField(record, f.key)}
                    overridden={isRecordFieldOverridden(record, f.key)}
                    provenance={getRecordFieldProvenance(record, f.key)}
                    sourceMeta={getRecordFieldSourceMeta(record, f.key)}
                    saving={savingField === f.key}
                    flash={savedFlash === f.key}
                    disabled={!canEdit || f.readOnly}
                    onSave={(v) => handleSave(f.key, v)}
                    onRevert={() => handleRevert(f.key)}
                  />
                ))}
              </div>
              {/* PMNU-05: New Underlay workflow for the site's linked links, placed
                  after Hardware / before Migration. PMNU-06 added read-only mirrored
                  fields; PMNU-07 added inline Pre-Change Readiness editing. Remaining
                  phase editors arrive in PMNU-08..PMNU-11. */}
              {kind === "sites" && group.label === "Hardware" && (
                <SiteUnderlayStatus site={record} model={model} canEdit={canEdit} onOpenUnderlay={onOpenUnderlay} onSaved={onSaved}/>
              )}
            </React.Fragment>
          ))}
          {canEdit && onDelete && (
            <div className="pm-fieldgroup pm-danger-zone">
              <div className="pm-fieldgroup-h">Danger zone</div>
              <div className="pm-fieldgroup-desc">Removes this {kind} row from the effective model and the monitors. Re-add it any time with “New row”.</div>
              <button className="btn danger" onClick={handleDelete} disabled={deleting}>
                {deleting ? "Deleting…" : "Delete row"}
              </button>
            </div>
          )}
        </div>
      )}

      {tab === "comments" && (
        <PmCommentsRail entity={entity} idRef={idRef} highlightCommentId={focusCommentId} compact/>
      )}
    </SidePanel>
  );
}

/* =============================================================
   PMNU-05 — Underlay Status inside the PM site editor
   ─────────────────────────────────────────────────────────────
   Read-only summary of the New Underlay workflow for a site's
   linked links. Uses the shared resolveSiteNewUnderlay resolver so
   multi-link sites stay isolated by Link Ref (no merge-by-site-code),
   and the canonical newUnderlayReadinessStats classifier so phase
   grouping stays in sync with every other surface. Per-phase editing
   and mirrored-field rendering arrive in PMNU-06..PMNU-11.
   ============================================================= */
const NU_STATUS_PHASES = ["preChangeReadiness", "changeManagement", "linkMigration", "postMigration"];

// PMNU-06: the 9 Underlay-mirrored New Underlay fields, sourced from the data.js
// ownership contract so the read-only list cannot drift from the patch gate. Date
// keys are formatted; everything else renders as plain text.
const NU_MIRROR_FIELDS = (PRData.NEW_UNDERLAY_MIRRORED_FIELDS || []).map((f) => ({ key: f.key, label: f.label }));
const NU_MIRROR_DATE_KEYS = new Set(["cdd", "circuitDeliveryDate", "changeDate"]);
function formatMirrorValue(key, value) {
  if (value === null || value === undefined || value === "") return "—";
  if (NU_MIRROR_DATE_KEYS.has(key)) {
    const d = PRData.fmtDate(value);
    return d || "—";
  }
  return String(value);
}

// PMNU-07: engineer-owned Pre-Change Readiness fields, editable per linked New
// Underlay row straight from the site editor. Derived from the data.js readiness
// contract filtered through the shared UI phase classifier, so this field set stays
// identical to the New underlay editor tab's Pre-Change Readiness group. Saved via
// PMClient.patchNewUnderlay keyed by the row's exact Link Ref.
const NU_PRE_CHANGE_FIELDS = (PRData.NEW_UNDERLAY_READINESS || [])
  .filter((field) => newUnderlayReadinessUiPhase(field.key) === "preChangeReadiness")
  .map((field) => ({
    key: `readiness.${field.key}`,
    label: field.label,
    type: "enum",
    options: PRData.NEW_UNDERLAY_WORKFLOW_STATUS_OPTIONS || [],
    sourceLabel: "New Underlay",
  }));

// PMNU-08: engineer-owned Change Management fields, editable per linked New Underlay
// row from the site editor. Six status tasks (IMS/CNS/OCD/IAM-DW/Azure/CAB) come from
// the readiness contract filtered through the shared UI phase classifier — keeping this
// set identical to the New underlay editor tab's Change Management group — followed by
// the free-text Stakeholders Email (a top-level New Underlay field, not a readiness
// check). Change No. and Change Date are Underlay-mirrored and stay read-only in the
// mirror grid above; they are deliberately absent here. Enum tasks are validated against
// NEW_UNDERLAY_WORKFLOW_STATUS_OPTIONS by the patch gate; saved by exact Link Ref.
const NU_CHANGE_MGMT_FIELDS = [
  ...(PRData.NEW_UNDERLAY_READINESS || [])
    .filter((field) => newUnderlayReadinessUiPhase(field.key) === "changeManagement")
    .map((field) => ({
      key: `readiness.${field.key}`,
      label: field.label,
      type: "enum",
      options: PRData.NEW_UNDERLAY_WORKFLOW_STATUS_OPTIONS || [],
      sourceLabel: "New Underlay",
    })),
  { key: "stakeEmail", label: "Stakeholders Email", type: "text", sourceLabel: "New Underlay" },
];

// PMNU-09: engineer-owned Link Migration fields, editable per linked New Underlay row
// from the site editor. Five status checks — Gateway Connectivity, Internet
// Connectivity, User Tests OK?, Migration Successful, Rack Photo — come from the
// readiness contract filtered through the shared UI phase classifier (workbook columns
// AK-AO), keeping this set identical to the New underlay editor tab's Link Migration
// group. Rack Photo lives here, not Post Migration, and is a status enum: this phase is
// status tracking only, with no photo upload. Enum values validate against
// NEW_UNDERLAY_WORKFLOW_STATUS_OPTIONS by the patch gate; saved by exact Link Ref.
const NU_LINK_MIGRATION_FIELDS = (PRData.NEW_UNDERLAY_READINESS || [])
  .filter((field) => newUnderlayReadinessUiPhase(field.key) === "linkMigration")
  .map((field) => ({
    key: `readiness.${field.key}`,
    label: field.label,
    type: "enum",
    options: PRData.NEW_UNDERLAY_WORKFLOW_STATUS_OPTIONS || [],
    sourceLabel: "New Underlay",
  }));

// PMNU-10: engineer-owned Post Migration closeout fields, editable per linked New
// Underlay row from the site editor. Four status checks — ServiceNOW Update, Confluence
// Update, CMDB Update, Legacy Link Termination — come from the readiness contract
// filtered through the shared UI phase classifier (workbook columns AP-AS), keeping this
// set identical to the New underlay editor tab's Post Migration group. The ServiceNOW
// label keeps the workbook's exact wording (label carried from NEW_UNDERLAY_READINESS).
// Status tracking only: these are enum selects that write to PMStore by Link Ref — no
// ServiceNow call, no email, no external workflow. Enum values validate against
// NEW_UNDERLAY_WORKFLOW_STATUS_OPTIONS by the patch gate; saved by exact Link Ref.
const NU_POST_MIGRATION_FIELDS = (PRData.NEW_UNDERLAY_READINESS || [])
  .filter((field) => newUnderlayReadinessUiPhase(field.key) === "postMigration")
  .map((field) => ({
    key: `readiness.${field.key}`,
    label: field.label,
    type: "enum",
    options: PRData.NEW_UNDERLAY_WORKFLOW_STATUS_OPTIONS || [],
    sourceLabel: "New Underlay",
  }));

// PMNU-11: the New Underlay KPI — workbook column AT "Task Completed %". Unlike the
// phase blocks above this is a single top-level number field (not a readiness check),
// engineer-owned and editable per linked row from the site editor where the role allows.
// Saved via PMClient.patchNewUnderlay keyed by the row's exact Link Ref; the patch gate
// validates it as a 0-100 number (PM_NUMBER, group "kpi") exactly like the New underlay
// editor tab's KPI control. Distinct from the card header's computed readiness % — this
// is the manually tracked completion figure carried straight from the workbook.
const NU_KPI_FIELDS = [
  { key: "taskPct", label: "Task Completed %", type: "number", min: 0, max: 100, step: 1, sourceLabel: "New Underlay" },
];

// Most-recent local override across a row's per-field provenance, or null.
function latestRecordChange(record) {
  const meta = record && record._overrideMeta;
  if (!meta || typeof meta !== "object") return null;
  let best = null;
  for (const key of Object.keys(meta)) {
    const m = meta[key];
    const at = m && (m.changedAt || m.at);
    if (!at) continue;
    if (!best || new Date(at) > new Date(best.changedAt)) {
      best = { changedAt: at, changedBy: (m.changedBy || m.by || "") };
    }
  }
  return best;
}

function SiteUnderlayStatus({ site, model, canEdit, onOpenUnderlay, onSaved }) {
  const resolved = PRData.resolveSiteNewUnderlay
    ? PRData.resolveSiteNewUnderlay(site, model)
    : { rows: [], blocks: [], diagnostics: { noMatch: true, duplicateLinkRefs: [], ambiguousFallback: [], orphans: [] } };
  const blocks = resolved.blocks || [];
  const diag = resolved.diagnostics || {};
  const notes = [];
  if ((diag.duplicateLinkRefs || []).length) notes.push(`${diag.duplicateLinkRefs.length} duplicate Link Ref${diag.duplicateLinkRefs.length > 1 ? "s" : ""}`);
  if ((diag.ambiguousFallback || []).length) notes.push(`${diag.ambiguousFallback.length} site-code match${diag.ambiguousFallback.length > 1 ? "es" : ""} not auto-linked (own a different Link Ref)`);
  if ((diag.orphans || []).length) notes.push(`${diag.orphans.length} row${diag.orphans.length > 1 ? "s" : ""} without a Link Ref`);

  // PMNU-11 / PMNU-30: soft storage-mode indicator. pmStorageMode() is the single source
  // of wording shared with the editor-wide pill (EntityEditor) so the two never drift:
  // PMClient.kind is "azure" only when a backend base URL was explicitly configured at
  // boot; otherwise the standalone LocalClient stores edits in this browser's PMStore.
  // Surface that distinction next to the editor so users know whether a New Underlay edit
  // is local-only or shared/server-backed.
  const storage = pmStorageMode();
  const serverBacked = storage.serverBacked;
  return (
    <div className="pm-fieldgroup pm-nu-status" data-new-underlay-status="true">
      {/* Header text stays exactly "Underlay Status" (cross-surface assertions match it
          verbatim); the storage-mode pill sits in its own bar just below. */}
      <div className="pm-fieldgroup-h">Underlay Status</div>
      <div className="pm-nu-modebar">
        <StorageModePill compact/>
      </div>
      <div className="pm-fieldgroup-desc">
        {canEdit ? (
          <>New Underlay workflow for this site's linked links. Edit all four phases — Pre-Change Readiness, Change Management, Link Migration, and Post Migration — plus the Task Completed % KPI inline per link below; mirrored Underlay fields stay read-only.{!serverBacked && " Changes are saved locally in this browser only."}</>
        ) : (
          <>New Underlay workflow for this site's linked links — Pre-Change Readiness, Change Management, Link Migration, Post Migration, and the Task Completed % KPI per link. Read-only for your role; mirrored Underlay fields are always read-only.</>
        )}
      </div>
      {/* PMNU-13: the operational role gate (engineer/PO/admin can edit; management is
          monitoring-only) flows in via canEdit and disables every phase control below.
          Surface that read-only state in the section itself — matching the sibling New
          Underlay edit surfaces (Overlay readiness panel, tracker row editor) — so the
          gate is clear even when the panel is scrolled past the editor's top pill. This
          is a UI affordance, not a security control; server-side ProjectWrite enforces it. */}
      {!canEdit && (
        <div className="pm-readonly-pill pm-nu-readonly" data-nu-readonly="true">
          Read-only · management cannot edit
        </div>
      )}
      {notes.length > 0 && (
        <div className="pm-nu-notes" data-new-underlay-status-notes="true">Resolver notes: {notes.join(" · ")}.</div>
      )}
      {blocks.length === 0 ? (
        <div className="pm-nu-empty" data-new-underlay-status="empty">
          No linked New Underlay records for this site. A link appears here once a New Underlay row shares this site's Link Ref.
        </div>
      ) : (
        <div className="pm-nu-cards">
          {blocks.map((block) => (
            <SiteUnderlayStatusCard key={(block.row && block.row._id) || block.linkRefKey || block.linkRef} block={block} canEdit={canEdit} onOpenUnderlay={onOpenUnderlay} onSaved={onSaved}/>
          ))}
        </div>
      )}
    </div>
  );
}

function SiteUnderlayStatusCard({ block, canEdit, onOpenUnderlay, onSaved }) {
  const row = (block && block.row) || {};
  // PMNU-32: the whole link block is collapsible so a multi-link site stays navigable —
  // the head summary (Link Ref, readiness %, ready/blocked metrics, per-phase mini-bars)
  // stays visible while the mirror grid + four phase editors + KPI fold away. Default open
  // so the editor renders fully on first paint (and stays screen-reader reachable).
  const [open, setOpen] = useState(true);
  const [uid] = useState(() => "nucard-" + Math.random().toString(36).slice(2, 9));
  const bodyId = uid + "-body";
  const stats = PRData.newUnderlayReadinessStats ? PRData.newUnderlayReadinessStats(row) : null;
  const phases = (stats && stats.phases) || {};
  const blocked = (stats && stats.blockedReadinessCount) || 0;
  const ready = (stats && stats.readyReadiness) || 0;
  const total = (stats && stats.totalReadiness) || 0;
  const pct = (stats && stats.readinessPercent) || 0;
  const identity = [row.siteCode, row.siteName, [row.country, row.region].filter(Boolean).join(" / ")]
    .filter(Boolean).join(" · ");
  const lastChange = latestRecordChange(row);
  const tone = blocked > 0 ? "risk" : pct === 100 ? "ok" : "warn";
  // PMNU-06: mirrored fields are owned by the Underlay row. Offer navigation to the
  // owning link only when this New Underlay row actually matched one.
  const rel = row._relations || {};
  const matched = !!rel.matchedUnderlay;
  const underlayRef = matched ? (rel.underlayLinkRef || block.linkRef || "") : "";
  const canOpenUnderlay = !!(matched && underlayRef && typeof onOpenUnderlay === "function");
  return (
    <div className="pm-nu-card" data-nu-link-block="true" data-link-ref={block.linkRef || ""}>
      <div className="pm-nu-cardhead">
        <div className="pm-nu-cardhead-l">
          <span className="pm-nu-linkref mono">{block.linkRef || "—"}</span>
          {block.linkAB ? <span className="pm-nu-abbadge">Link {block.linkAB}</span> : null}
          {block.isOrphan ? <span className="pm-nu-abbadge pm-nu-orphan" title="Matched by site identity; no Link Ref to isolate this row.">no Link Ref</span> : null}
        </div>
        <div className="pm-nu-cardhead-r">
          <div className={"pm-nu-readypct pm-nu-" + tone}>{pct}%</div>
          <button
            type="button"
            className="pm-nu-collapse"
            aria-expanded={open}
            aria-controls={bodyId}
            aria-label={(open ? "Collapse" : "Expand") + " link details" + (block.linkRef ? " for " + block.linkRef : "")}
            onClick={() => setOpen((v) => !v)}
          >
            <span className="pm-nu-chevron" aria-hidden="true"/>
          </button>
        </div>
      </div>
      {identity && <div className="pm-nu-identity">{identity}</div>}
      <div className="pm-nu-metrics">
        <span className="pm-nu-metric"><em>Ready</em><strong>{ready}/{total}</strong></span>
        <span className={"pm-nu-metric" + (blocked > 0 ? " pm-nu-metric-risk" : "")}><em>Blocked</em><strong>{blocked}</strong></span>
      </div>
      <div className="pm-nu-phasegrid">
        {NU_STATUS_PHASES.map((key) => {
          const p = phases[key] || {};
          return (
            <div key={key} className="pm-nu-phase">
              <span>{p.label || key}</span>
              <strong>{(p.ready || 0)}/{(p.total || 0)}</strong>
              <div className="pm-nu-bar"><i style={{ width: `${Math.max(0, Math.min(100, p.percent || 0))}%` }}/></div>
              {(p.blocked || 0) > 0 && <em>{p.blocked} blocked</em>}
            </div>
          );
        })}
      </div>
      <div className="pm-nu-card-body" id={bodyId} hidden={!open}>
      <div className="pm-nu-mirror" data-nu-mirror="true">
        <div className="pm-nu-mirror-h">
          <span className="pm-nu-mirror-src">
            {matched
              ? <>From Underlay link <span className="mono">{underlayRef}</span> · read-only</>
              : "Mirrored fields · read-only · no matched Underlay link"}
          </span>
          {canOpenUnderlay ? (
            <button
              type="button"
              className="pm-nu-mirror-open"
              data-nu-open-underlay="true"
              onClick={() => onOpenUnderlay(underlayRef)}
              title={"Edit these values on Underlay link " + underlayRef}
            >
              Edit in PM Underlay
            </button>
          ) : (
            <span className="pm-nu-mirror-open pm-nu-mirror-open-off" data-nu-open-underlay="missing">No Underlay link</span>
          )}
        </div>
        <dl className="pm-nu-mirror-grid">
          {NU_MIRROR_FIELDS.map((m) => (
            <div className="pm-nu-mirror-field" key={m.key} data-mirror-key={m.key}>
              <dt>{m.label}</dt>
              <dd>{formatMirrorValue(m.key, getRecordField(row, m.key))}</dd>
            </div>
          ))}
        </dl>
      </div>
      <NuPhaseEditor
        phaseKey="preChangeReadiness"
        title="Pre-Change Readiness"
        description="Engineer-owned readiness checks before change execution. Saved to this link by Link Ref."
        fields={NU_PRE_CHANGE_FIELDS}
        row={row}
        linkRef={block.linkRef}
        canEdit={canEdit}
        onSaved={onSaved}
        summary={phases.preChangeReadiness}
      />
      <NuPhaseEditor
        phaseKey="changeManagement"
        title="Change Management"
        description="Engineer-owned change-management tasks for this link. Change No. and Change Date stay read-only above (mirrored from Underlay). Saved to this link by Link Ref."
        fields={NU_CHANGE_MGMT_FIELDS}
        row={row}
        linkRef={block.linkRef}
        canEdit={canEdit}
        onSaved={onSaved}
        summary={phases.changeManagement}
      />
      <NuPhaseEditor
        phaseKey="linkMigration"
        title="Link Migration"
        description="Engineer-owned migration execution checks for this link — Gateway, Internet, User Tests, Migration Successful, and Rack Photo. Status tracking only (no photo upload). Saved to this link by Link Ref."
        fields={NU_LINK_MIGRATION_FIELDS}
        row={row}
        linkRef={block.linkRef}
        canEdit={canEdit}
        onSaved={onSaved}
        summary={phases.linkMigration}
      />
      <NuPhaseEditor
        phaseKey="postMigration"
        title="Post Migration"
        description="Engineer-owned post-migration closeout for this link — ServiceNOW Update, Confluence Update, CMDB Update, and Legacy Link Termination. Status tracking only (no ServiceNow call or email). Saved to this link by Link Ref."
        fields={NU_POST_MIGRATION_FIELDS}
        row={row}
        linkRef={block.linkRef}
        canEdit={canEdit}
        onSaved={onSaved}
        summary={phases.postMigration}
      />
      <NuPhaseEditor
        phaseKey="kpi"
        title="Task Completion (KPI)"
        description="New Underlay KPI for this link — the workbook Task Completed % (column AT). Editable where your role allows; saved to this link by Link Ref. Separate from the readiness % shown above."
        fields={NU_KPI_FIELDS}
        row={row}
        linkRef={block.linkRef}
        canEdit={canEdit}
        onSaved={onSaved}
      />
      <div className="pm-nu-meta">
        {lastChange
          ? `Last changed ${lastChange.changedAt ? relTime(lastChange.changedAt) : ""}${lastChange.changedBy ? " · " + lastChange.changedBy : ""}`
          : "From workbook · no local edits"}
      </div>
      </div>
    </div>
  );
}

/* =============================================================
   PMNU-07 — Pre-Change Readiness editing inside the PM site editor
   ─────────────────────────────────────────────────────────────
   Engineer-owned readiness checks, editable per linked New Underlay
   row without leaving the site popup. Writes go through
   PMClient.patchNewUnderlay keyed by the row's *exact* Link Ref (never
   the site code), so multi-link sites stay isolated and each override
   re-matches its row on reload. Reuses FieldEditor for control parity
   with the New underlay editor tab. Built generic (phaseKey + fields)
   so PMNU-08..PMNU-10 can mount the other phases the same way.
   ============================================================= */
// PMNU-12: turn a rejected New Underlay save into a clear, classified inline message
// (no raw alert) so the user understands *why* a per-link write failed — a missing,
// duplicate, or ambiguous Link Ref, or a mirrored Underlay-owned field — and that
// nothing was stored. The write itself still goes only through PMClient.patchNewUnderlay.
function classifyNuSaveError(err, linkRef) {
  const message = String((err && err.message) || err || "");
  if (!linkRef) {
    return { kind: "missing", text: "This row has no Link Ref. New Underlay readiness is stored per Link Ref, so it can't be saved here — add a Link Ref on the New underlay editor first." };
  }
  if (/ambiguous newunderlay override target/i.test(message)) {
    return { kind: "ambiguous", text: "Link Ref " + linkRef + " matches more than one New Underlay row, so this edit can't be isolated to a single link. Resolve the duplicate Link Ref before editing." };
  }
  if (/read_only_field|underlaymirror|read-only/i.test(message)) {
    return { kind: "readonly", text: "That value is mirrored from the Underlay link and is read-only here. Edit it on the owning Underlay link instead." };
  }
  return { kind: "error", text: "Save failed: " + (message || "unknown error") + ". Nothing was stored for this link." };
}

function NuPhaseEditor({ phaseKey, title, description, fields, row, linkRef, canEdit, onSaved, summary }) {
  const [savingField, setSavingField] = useState(null);
  const [savedFlash, setSavedFlash] = useState(null);
  const [saveError, setSaveError] = useState(null);
  // PMNU-32: each phase is an independent disclosure so a link block stays compact when a
  // site carries many readiness fields. Default open keeps the editor fully visible (and
  // keyboard-/screen-reader-reachable) on first render; the engineer collapses the phases
  // they are not working on. Stable per-instance id wires the toggle button to its body
  // (aria-controls/aria-expanded) without depending on a specific React build's useId.
  const [open, setOpen] = useState(true);
  const [uid] = useState(() => "nuedit-" + Math.random().toString(36).slice(2, 9));
  // A row with no Link Ref (orphan, matched only by site identity) has no isolated
  // write target. Disable editing rather than risk a site-code-keyed write that could
  // bleed into a sibling link. PMNU-07 guardrail: patch by exact Link Ref.
  const canPatch = !!(canEdit && linkRef);

  const handleSave = useCallback(async (fieldKey, value) => {
    if (!canPatch) return;
    setSavingField(fieldKey);
    setSaveError(null);
    try {
      const priorMeta = (row && row._overrideMeta && row._overrideMeta[fieldKey]) || {};
      const hasPriorWorkbookValue = Object.prototype.hasOwnProperty.call(priorMeta, "workbookValue") &&
        priorMeta.workbookValue !== undefined && priorMeta.workbookValue !== null;
      const patch = {
        [fieldKey]: value,
        __meta: {
          [fieldKey]: {
            workbookValue: hasPriorWorkbookValue ? priorMeta.workbookValue : getRecordField(row, fieldKey),
            sourceEntity: "newUnderlay",
            sourceField: fieldKey,
          },
        },
      };
      await PMClient.patchNewUnderlay(linkRef, patch);
      setSavedFlash(fieldKey);
      setTimeout(() => setSavedFlash(null), 1500);
      onSaved && onSaved();
    } catch (e) {
      const classified = classifyNuSaveError(e, linkRef);
      // Missing/duplicate/ambiguous Link Ref and mirror read-only are expected,
      // gracefully-handled conditions surfaced inline — only log truly unexpected errors.
      if (classified.kind === "error") console.error("New Underlay readiness save failed", e);
      setSaveError(classified);
    } finally {
      setSavingField(null);
    }
  }, [canPatch, linkRef, row, onSaved]);

  const handleRevert = useCallback(async (fieldKey) => {
    if (!canPatch) return;
    setSaveError(null);
    try {
      await PMClient.patchNewUnderlay(linkRef, { __clear: fieldKey });
      onSaved && onSaved();
    } catch (e) {
      const classified = classifyNuSaveError(e, linkRef);
      if (classified.kind === "error") console.error("New Underlay readiness revert failed", e);
      setSaveError(classified);
    }
  }, [canPatch, linkRef, onSaved]);

  if (!fields.length) return null;
  const bodyId = uid + "-body";
  const titleId = uid + "-title";
  // Collapsed-state summary so a folded phase still reports its progress at a glance.
  // `summary` is the card's already-computed phase stat (NU_STATUS_PHASES); the KPI phase
  // has none, so the chip is simply omitted there.
  const hasSummary = !!(summary && typeof summary.total === "number");
  return (
    <div className="pm-nu-edit" data-nu-edit-phase={phaseKey}>
      {/* Disclosure toggle: a real <button> (Enter/Space + focus ring) rather than the
          old static heading. The phase title stays in the always-visible header so
          cross-surface label assertions keep matching even when the body is folded. */}
      <button
        type="button"
        className="pm-nu-edit-toggle"
        aria-expanded={open}
        aria-controls={bodyId}
        onClick={() => setOpen((v) => !v)}
      >
        <span className="pm-nu-chevron" aria-hidden="true"/>
        <span className="pm-nu-edit-h" id={titleId}>{title}</span>
        {hasSummary && (
          <span className="pm-nu-edit-summary" data-nu-phase-summary={phaseKey}>
            <span>{(summary.ready || 0)}/{(summary.total || 0)}</span>
            {(summary.blocked || 0) > 0 && <em>{summary.blocked} blocked</em>}
          </span>
        )}
      </button>
      {/* Disabled-note and save error sit outside the collapsible body so they stay
          visible (and queryable) regardless of fold state. */}
      {canEdit && !linkRef && (
        <div className="pm-nu-edit-note" data-nu-edit-disabled="no-link-ref">
          No Link Ref on this row — readiness is stored per Link Ref, so editing is disabled here. Add a Link Ref on the New underlay editor to edit.
        </div>
      )}
      {saveError && (
        <div className="pm-nu-edit-error" role="alert" data-nu-edit-error={saveError.kind}>
          {saveError.text}
        </div>
      )}
      <div className="pm-nu-edit-body" id={bodyId} hidden={!open}>
        {description && <div className="pm-nu-edit-desc">{description}</div>}
        <div className="pm-nu-edit-fields">
          {fields.map((f) => (
            <FieldEditor
              key={f.key}
              field={f}
              value={getRecordField(row, f.key)}
              overridden={isRecordFieldOverridden(row, f.key)}
              provenance={getRecordFieldProvenance(row, f.key)}
              sourceMeta={getRecordFieldSourceMeta(row, f.key)}
              saving={savingField === f.key}
              flash={savedFlash === f.key}
              disabled={!canPatch}
              onSave={(v) => handleSave(f.key, v)}
              onRevert={() => handleRevert(f.key)}
            />
          ))}
        </div>
      </div>
    </div>
  );
}

function getRecordField(record, field) {
  if (!record || !field) return undefined;
  if (!field.includes(".")) return record[field];
  return field.split(".").reduce((cur, part) => (cur && typeof cur === "object" ? cur[part] : undefined), record);
}

function isRecordFieldOverridden(record, field) {
  return !!(record && record._overridden && record._overridden.includes(field));
}

function getRecordFieldProvenance(record, field) {
  if (!record || !field) return null;
  return (record._overrideMeta && record._overrideMeta[field]) || null;
}

function getRecordFieldSourceMeta(record, field) {
  if (!record || !field) return null;
  return (record._mirrorMeta && record._mirrorMeta[field]) || null;
}

function FieldEditor({ field, value, overridden, provenance, sourceMeta, saving, flash, disabled, onSave, onRevert }) {
  const [local, setLocal] = useState(formatForInput(field, value));
  useEffect(() => { setLocal(formatForInput(field, value)); }, [field.key, value]);

  const commit = (next) => {
    if (next === undefined) next = local;
    const cleaned = parseFromInput(field, next);
    if (cleaned === value) return;
    onSave(cleaned);
  };

  const input = (() => {
    if (field.type === "enum") {
      const options = field.options || [];
      return (
        <select
          className="pm-input"
          aria-label={field.label}
          value={local || ""}
          disabled={disabled}
          onChange={(e) => { setLocal(e.target.value); onSave(e.target.value); }}
        >
          <option value="">—</option>
          {options.map((o) => <option key={o} value={o}>{o}</option>)}
        </select>
      );
    }
    if (field.type === "date") {
      return (
        <input
          type="date"
          className="pm-input"
          aria-label={field.label}
          value={local || ""}
          disabled={disabled}
          onChange={(e) => setLocal(e.target.value)}
          onBlur={() => commit()}
          onKeyDown={(e) => { if (e.key === "Enter") e.target.blur(); }}
        />
      );
    }
    if (field.type === "number") {
      return (
        <input
          type="number"
          className="pm-input"
          aria-label={field.label}
          value={local ?? ""}
          step={field.step || 1}
          min={field.min ?? undefined}
          max={field.max ?? undefined}
          disabled={disabled}
          onChange={(e) => setLocal(e.target.value)}
          onBlur={() => commit()}
          onKeyDown={(e) => { if (e.key === "Enter") e.target.blur(); }}
        />
      );
    }
    if (field.type === "longtext") {
      return (
        <textarea
          className="pm-input pm-textarea"
          aria-label={field.label}
          rows={3}
          value={local || ""}
          disabled={disabled}
          onChange={(e) => setLocal(e.target.value)}
          onBlur={() => commit()}
        />
      );
    }
    if (field.type === "boolean") {
      const val = (local || "").toLowerCase();
      return (
        <div className="pm-toggle" role="group" aria-label={field.label}>
          <button type="button" aria-label={field.label + ": Yes"} aria-pressed={val === "yes"} className={val === "yes" ? "active" : ""} disabled={disabled} onClick={() => { setLocal("Yes"); onSave("Yes"); }}>Yes</button>
          <button type="button" aria-label={field.label + ": No"} aria-pressed={val === "no"} className={val === "no" ? "active" : ""} disabled={disabled} onClick={() => { setLocal("No"); onSave("No"); }}>No</button>
          <button type="button" aria-label={field.label + ": clear"} aria-pressed={!val} className={!val ? "active" : ""} disabled={disabled} onClick={() => { setLocal(""); onSave(""); }}>—</button>
        </div>
      );
    }
    return (
      <input
        type="text"
        className="pm-input"
        aria-label={field.label}
        value={local || ""}
        disabled={disabled || field.readOnly}
        onChange={(e) => setLocal(e.target.value)}
        onBlur={() => commit()}
        onKeyDown={(e) => { if (e.key === "Enter") e.target.blur(); }}
      />
    );
  })();

  return (
    <div className="pm-field" data-field-key={field.key}>
      <div className="pm-field-label">
        {field.label}
        {overridden && <span className="pm-field-edited" title="Locally edited">★</span>}
        {field.sourceLabel && <span className="pm-field-source-tag">{field.sourceLabel}</span>}
      </div>
      <div className="pm-field-input">
        {input}
        {flash && <span className="pm-saved-pill">Saved</span>}
        {saving && <span className="pm-saving-spinner"/>}
        {overridden && !disabled && (
          <button className="btn ghost pm-revert-btn" onClick={onRevert} title="Revert to Excel value">
            ↺
          </button>
        )}
      </div>
      {field.sourceNote && (
        <div className="pm-field-source-note">
          {field.sourceNote}
          {sourceMeta && sourceMeta.sourceField ? (
            <span> Source field: <strong>{sourceMeta.sourceField}</strong></span>
          ) : null}
        </div>
      )}
      {overridden && provenance && (
        // PMNU-11: compact per-layer provenance. Each effective-model layer is shown as
        // its own chip when data is present — workbook base, local override (PMStore /
        // future backend write), the future server/DB value when the backend supplies one,
        // and the resolved effective value. changedBy/changedAt close the row. The
        // server/DB chip stays hidden in standalone (no dbValue), so local-only edits read
        // as workbook → local → effective without implying a shared backend value exists.
        <div className="pm-field-provenance" data-field-provenance="true">
          <span>Workbook <strong>{formatOverrideValue(provenance.workbookValue)}</strong></span>
          <span>Local <strong>{formatOverrideValue(provenance.localOverrideValue ?? provenance.effectiveValue ?? value)}</strong></span>
          {(provenance.dbValue !== undefined || provenance.serverValue !== undefined) && (
            <span>Server <strong>{formatOverrideValue(provenance.dbValue ?? provenance.serverValue)}</strong></span>
          )}
          <span>Effective <strong>{formatOverrideValue(provenance.effectiveValue ?? value)}</strong></span>
          {(provenance.changedBy || provenance.changedAt) && (
            <span>{[provenance.changedBy, provenance.changedAt ? relTime(provenance.changedAt) : ""].filter(Boolean).join(" - ")}</span>
          )}
        </div>
      )}
    </div>
  );
}

function formatOverrideValue(value) {
  if (value === null || value === undefined || value === "") return "-";
  if (value instanceof Date && !isNaN(value)) return PRData.fmtDate(value);
  if (Array.isArray(value)) return value.join(", ");
  if (typeof value === "object") {
    try { return JSON.stringify(value); } catch (_) { return String(value); }
  }
  return String(value);
}

function formatForInput(field, value) {
  if (value === null || value === undefined) return "";
  if (field.type === "date") {
    if (value instanceof Date && !isNaN(value)) return value.toISOString().slice(0, 10);
    if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}/)) return value.slice(0, 10);
    return "";
  }
  if (field.type === "number") {
    if (typeof value === "number") return String(value);
    const n = parseFloat(value);
    return isNaN(n) ? "" : String(n);
  }
  return value;
}

function parseFromInput(field, raw) {
  if (raw === "" || raw === null || raw === undefined) return "";
  if (field.type === "date") {
    // Store Date objects so downstream `instanceof Date` checks
    // (deriveUpcoming, KPI computations, fmtDate) keep working.
    // IndexedDB structured-clone handles Date natively.
    const d = new Date(raw);
    return isNaN(d) ? "" : d;
  }
  if (field.type === "number") {
    const n = parseFloat(raw);
    return isNaN(n) ? "" : n;
  }
  return raw;
}

/* ---------- Per-entity field specs (grouped) ---------- */
const STATUS_OPTIONS = {
  prOrder:    ["Ordered", "Cancelled", "To be Ordered", "Pending PR"],
  circuit:    ["Order Submitted", "Order Validated & Accepted", "Survey Complete + CDD Confirmed", "Build Complete (Delivered, Ready for Testing)", "Activated", "On-hold", "Customer request", "Solution Infeasible"],
  hwQuote:    ["Not Started", "Processing", "Provided"],
  hwPO:       ["Not Started", "Pending PR", "Quote Signed", "PO Released"],
  hwDelivery: ["Not Started", "In Progress", "Delivered"],
  migration:  ["Not Started", "In Planning", "Scheduled", "Migrated", "Failed"],
  risk:       ["Low", "Medium", "High"],
  yesno:      ["Yes", "No"],
  terminated: ["Yes", "No", "Initiated", "On Hold", "In Progress"],
  taskState:  ["Not Started", "Ready", "Blocked", "In Progress", "Completed", "Delivered", "Provided", "Fully Applied", "Failed"],
};

function buildFieldsSpec(kind) {
  if (kind === "sites") {
    return [
      { label: "Identity", fields: [
        { key: "siteCode", label: "Site code", type: "text", readOnly: true },
        { key: "siteName", label: "Site name", type: "text" },
        { key: "country",  label: "Country", type: "text" },
        { key: "region",   label: "Region", type: "text" },
        { key: "criticality", label: "Criticality", type: "enum", options: ["Gold", "Silver", "Bronze"] },
        { key: "techno",   label: "TECHNO", type: "text" },
      ]},
      { label: "Hardware", fields: [
        { key: "hwQuote",    label: "Quotation", type: "enum", options: STATUS_OPTIONS.hwQuote },
        { key: "hwPO",       label: "Purchase order", type: "enum", options: STATUS_OPTIONS.hwPO },
        { key: "hwDelivery", label: "Delivery status", type: "enum", options: STATUS_OPTIONS.hwDelivery },
        { key: "hwTarget",   label: "Delivery target", type: "date" },
      ]},
      { label: "Migration", fields: [
        { key: "migrationStatus",   label: "Status (col AI)", type: "enum", options: STATUS_OPTIONS.migration },
        { key: "migrationSchedule", label: "Schedule status", type: "enum", options: STATUS_OPTIONS.migration },
        { key: "confirmedMigration", label: "Confirmed migration date", type: "date" },
        { key: "migrationTimeline", label: "Timeline", type: "text" },
      ]},
      { label: "Notes", fields: [
        { key: "comment", label: "Comment", type: "longtext" },
      ]},
    ];
  }
  if (kind === "underlay") {
    return [
      { label: "Identity", fields: [
        { key: "linkRef", label: "Link Ref No", type: "text", readOnly: true },
        { key: "linkNum", label: "Link Number", type: "text" },
        { key: "siteName", label: "Site name", type: "text" },
        { key: "country", label: "Country", type: "text" },
        { key: "vendor", label: "Vendor", type: "text" },
        { key: "batch",  label: "Batch", type: "text" },
      ]},
      { label: "Order & delivery", fields: [
        { key: "prOrderStatus", label: "PR Order Status", type: "enum", options: STATUS_OPTIONS.prOrder },
        { key: "prOrderDate",   label: "PR Order Date", type: "date" },
        { key: "orderProgress", label: "Order Progress %", type: "number", min: 0, max: 100, step: 1 },
        { key: "circuitStatus", label: "Circuit delivery status", type: "enum", options: STATUS_OPTIONS.circuit },
        { key: "circuitDate",   label: "Circuit delivery date", type: "date" },
      ]},
      { label: "Validation & migration", fields: [
        { key: "lconStatus",    label: "LCON validation status", type: "text" },
        { key: "lconDate",      label: "LCON validation date", type: "date" },
        { key: "migrationDate", label: "Link migration date", type: "date" },
        { key: "cdd",           label: "CDD", type: "date" },
        { key: "planned",       label: "Planned delivery date", type: "date" },
        { key: "daysSlipped",   label: "Days slipped", type: "number" },
        { key: "migrationDone", label: "Migration complete?", type: "boolean" },
      ]},
      { label: "Risk & ownership", fields: [
        { key: "atRisk",    label: "At risk?", type: "boolean" },
        { key: "riskLevel", label: "Risk level", type: "enum", options: STATUS_OPTIONS.risk },
        { key: "blocker",   label: "Blockers", type: "longtext" },
        { key: "nextSteps", label: "Next steps", type: "longtext" },
        { key: "owner",     label: "Action owner", type: "text" },
      ]},
    ];
  }
  if (kind === "isp") {
    return [
      { label: "Identity", fields: [
        { key: "linkRef",   label: "Link Ref", type: "text", readOnly: true },
        { key: "linkNum",   label: "Link Number", type: "text" },
        { key: "siteName",  label: "Site name", type: "text" },
        { key: "country",   label: "Country", type: "text" },
        { key: "isp",       label: "Legacy ISP", type: "text" },
        { key: "circuitId", label: "Circuit ID", type: "text" },
      ]},
      { label: "Termination", fields: [
        { key: "requestDate",     label: "Request date", type: "date" },
        { key: "terminationDate", label: "Termination date", type: "date" },
        { key: "finalBilling",    label: "Final billing date", type: "date" },
        { key: "terminated",      label: "Terminated", type: "enum", options: STATUS_OPTIONS.terminated },
      ]},
      { label: "Contacts", fields: [
        { key: "lead",     label: "Lead", type: "text" },
        { key: "contact",  label: "Contact", type: "text" },
        { key: "comments", label: "Comments", type: "longtext" },
      ]},
    ];
  }
  const readinessFields = PRData.NEW_UNDERLAY_READINESS || [];
  const readinessByPhase = (phase) => readinessFields
    .filter((field) => newUnderlayReadinessUiPhase(field.key) === phase)
    .map((field) => ({
      key: `readiness.${field.key}`,
      label: field.label,
      type: "enum",
      options: PRData.NEW_UNDERLAY_WORKFLOW_STATUS_OPTIONS || STATUS_OPTIONS.taskState,
      sourceLabel: "New Underlay",
      bulkSafe: true,
    }));
  const mirrorNote = "Read-only mirror from the effective Underlay link. Edit the Underlay source row to change this value.";
  return [
    { label: "Identifiers", description: "Workbook row identity and site context for this New Underlay link.", fields: [
      { key: "linkRef",  label: "Link Ref.", type: "text", readOnly: true },
      { key: "siteCode", label: "Site code", type: "text" },
      { key: "siteName", label: "Site name", type: "text" },
      { key: "country",  label: "Country", type: "text" },
      { key: "region",   label: "Region", type: "text" },
      { key: "me",       label: "Management entity", type: "text" },
      { key: "linkAB",   label: "Link A/B", type: "text" },
    ]},
    { label: "Underlay mirrored values", description: "These values follow the matching Underlay row by Link Ref. and are protected from direct New Underlay edits.", fields: [
      { key: "isp",      label: "ISP / vendor", type: "text", readOnly: true, sourceLabel: "from Underlay", sourceNote: mirrorNote },
      { key: "partner",  label: "Local partner", type: "text", readOnly: true, sourceLabel: "from Underlay", sourceNote: mirrorNote },
      { key: "cdd",      label: "Committed delivery date", type: "date", readOnly: true, sourceLabel: "from Underlay", sourceNote: mirrorNote },
      { key: "lead",     label: "Lead", type: "text", readOnly: true, sourceLabel: "from Underlay", sourceNote: mirrorNote },
      { key: "surveyStatus", label: "Partner site survey status", type: "text", readOnly: true, sourceLabel: "from Underlay", sourceNote: mirrorNote },
      { key: "circuitDeliveryDate", label: "Circuit delivery date", type: "date", readOnly: true, sourceLabel: "from Underlay", sourceNote: mirrorNote },
      { key: "circuitDelivered", label: "Circuit delivered", type: "enum", options: STATUS_OPTIONS.yesno, readOnly: true, sourceLabel: "from Underlay", sourceNote: mirrorNote },
      { key: "changeNo", label: "Change No.", type: "text", readOnly: true, sourceLabel: "from Underlay", sourceNote: mirrorNote },
      { key: "changeDate", label: "Change date", type: "date", readOnly: true, sourceLabel: "from Underlay", sourceNote: mirrorNote },
    ]},
    { label: "Ordering", description: "New Underlay-owned ordering context that is not mirrored from Underlay.", fields: [
      { key: "batch",    label: "Batch", type: "text" },
      { key: "orderDate", label: "Order date", type: "date" },
      { key: "surveyDate", label: "Partner site survey date", type: "date" },
    ]},
    { label: "Pre-Change Readiness", description: "Engineer-owned readiness checks before change execution.", fields: readinessByPhase("preChangeReadiness") },
    { label: "Change Management", description: "Engineer-owned task readiness for the change-management workflow.", fields: [
      ...readinessByPhase("changeManagement"),
      { key: "stakeEmail", label: "Stakeholders Email", type: "text", sourceLabel: "New Underlay" },
    ]},
    { label: "Link Migration", description: "Engineer-owned migration execution checks.", fields: readinessByPhase("linkMigration") },
    { label: "Post Migration", description: "Engineer-owned closure and documentation checks.", fields: readinessByPhase("postMigration") },
    { label: "KPI", description: "Informational completion metric for the New Underlay workflow.", fields: [
      { key: "taskPct", label: "Task Completed %", type: "number", min: 0, max: 100, step: 1, sourceLabel: "New Underlay" },
    ]},
  ];
}

function newUnderlayReadinessUiPhase(key) {
  if (["rj45", "wanIp", "pdu", "rackSpace", "fortigate", "lconAvail", "hfSupport", "lconOk", "sdOk", "businessOk"].includes(key)) return "preChangeReadiness";
  if (["imsTask", "cnsTask", "ocdTask", "iamdwTask", "azureTask", "cab"].includes(key)) return "changeManagement";
  // Link Migration = workbook columns AK-AO: Gateway, Internet, User Tests, Migration Successful, Rack Photo.
  if (["gateway", "internet", "userTests", "migration", "rackPhoto"].includes(key)) return "linkMigration";
  return "postMigration";
}

/* =============================================================
   BulkUpdateModal — apply one field/value pair to many rows
   ============================================================= */
function BulkUpdateModal({ entity, rows, idKey, onClose }) {
  const [field, setField] = useState("");
  const [value, setValue] = useState("");
  const [busy, setBusy] = useState(false);
  const fields = useMemo(() => {
    const spec = buildFieldsSpec(
      entity === "overlay" ? "sites" :
      entity === "underlay" ? "underlay" :
      entity === "isp" ? "isp" : "newunderlay"
    );
    return spec.flatMap((g) => g.fields).filter((f) => {
      if (f.readOnly) return false;
      return entity !== "newUnderlay" || f.bulkSafe;
    });
  }, [entity]);

  const apply = async () => {
    if (!field || !rows.length) return;
    setBusy(true);
    try {
      for (const r of rows) {
        const id = r[idKey] || r.linkRef || r.siteCode || r.siteName;
        if (!id) continue;
        const priorMeta = (r && r._overrideMeta && r._overrideMeta[field]) || {};
        const hasPriorWorkbookValue = Object.prototype.hasOwnProperty.call(priorMeta, "workbookValue") &&
          priorMeta.workbookValue !== undefined && priorMeta.workbookValue !== null;
        const parsed = parseFromInput(selectedField || { type: "text" }, value);
        const patch = {
          [field]: parsed,
          __meta: {
            [field]: {
              workbookValue: hasPriorWorkbookValue ? priorMeta.workbookValue : getRecordField(r, field),
              sourceEntity: entity,
              sourceField: field,
            },
          },
        };
        if (entity === "overlay")     await PMClient.patchOverlay(id, patch);
        else if (entity === "underlay") await PMClient.patchLink(id, patch);
        else if (entity === "isp")    await PMClient.patchIsp(id, patch);
        else                          await PMClient.patchNewUnderlay(id, patch);
      }
      onClose();
    } finally {
      setBusy(false);
    }
  };

  const selectedField = fields.find((f) => f.key === field);

  return (
    <div className="pm-modal-backdrop" onClick={onClose}>
      <div className="pm-modal" onClick={(e) => e.stopPropagation()}>
        <div className="pm-modal-h">
          <div className="title">Bulk update · {rows.length} rows</div>
          <button className="btn ghost" onClick={onClose}><Glyph name="close" size={14}/></button>
        </div>
        <div className="pm-modal-body">
          <label className="pm-field-label">Field</label>
          <select className="pm-input" value={field} onChange={(e) => setField(e.target.value)}>
            <option value="">Choose a field…</option>
            {fields.map((f) => <option key={f.key} value={f.key}>{f.label}</option>)}
          </select>
          {selectedField && (
            <>
              <label className="pm-field-label" style={{ marginTop: 10 }}>New value</label>
              {selectedField.type === "enum" ? (
                <select className="pm-input" value={value} onChange={(e) => setValue(e.target.value)}>
                  <option value="">—</option>
                  {(selectedField.options || []).map((o) => <option key={o} value={o}>{o}</option>)}
                </select>
              ) : (
                <input className="pm-input" value={value} onChange={(e) => setValue(e.target.value)}/>
              )}
            </>
          )}
        </div>
        <div className="pm-modal-actions">
          <button className="btn" onClick={onClose}>Cancel</button>
          <button className="btn primary" disabled={!field || busy} onClick={apply}>
            {busy ? "Applying…" : `Apply to ${rows.length} rows`}
          </button>
        </div>
      </div>
    </div>
  );
}

/* =============================================================
   PmWhatsNext — Tab 2
   ============================================================= */
const KANBAN_COLUMNS = [
  { id: "todo",        label: "To do",       color: "var(--ink-300)" },
  { id: "in_progress", label: "In progress", color: "var(--info)" },
  { id: "blocked",     label: "Blocked",     color: "var(--risk)" },
  { id: "done",        label: "Done",        color: "var(--ok)" },
];

function PmWhatsNext({ model, tasks, role, setFocus, onTaskChange }) {
  const [window, setWindow] = useState(28);
  const [filter, setFilter] = useState({});
  const [newOpen, setNewOpen] = useState(false);
  const [editingTask, setEditingTask] = useState(null);
  const canEdit = role === "engineer" || role === "po" || role === "admin";
  const canCreate = canEdit;

  const upcoming = useMemo(() => deriveUpcoming(model, window), [model, window]);

  const tasksFiltered = useMemo(() => {
    return tasks.filter((t) => {
      if (filter.priority && t.priority !== filter.priority) return false;
      if (filter.owner && t.owner !== filter.owner) return false;
      return true;
    });
  }, [tasks, filter]);

  const owners = useMemo(() => [...new Set(tasks.map((t) => t.owner).filter(Boolean))], [tasks]);

  const onDrop = useCallback(async (taskId, newStatus) => {
    await PMClient.upsertTask({ id: taskId, status: newStatus });
    onTaskChange && onTaskChange();
  }, [onTaskChange]);

  return (
    <>
      <div className="pm-coming-up">
        <div className="pm-section-h">
          <div className="title">Coming up · auto-detected</div>
          <div className="pm-window-toggle">
            {[14, 28, 90].map((w) => (
              <button key={w} className={window === w ? "active" : ""} onClick={() => setWindow(w)}>
                Next {w}d
              </button>
            ))}
          </div>
        </div>
        <div className="pm-coming-grid">
          {upcoming.length === 0 && <div className="muted" style={{ padding: 16 }}>No upcoming items in window.</div>}
          {upcoming.slice(0, 30).map((c, i) => (
            <ComingUpCard key={i} card={c} setFocus={setFocus} canCreate={canCreate} onConvert={async () => {
              await PMClient.upsertTask({
                title: c.title, description: c.description,
                linkedSiteId: c.siteId, linkedLinkRef: c.linkRef,
                dueDate: c.date ? c.date.toISOString().slice(0, 10) : "",
                kind: "auto-" + c.kind, status: "todo", priority: "normal",
              });
              onTaskChange && onTaskChange();
            }}/>
          ))}
        </div>
      </div>

      <div className="pm-section-h" style={{ marginTop: 18 }}>
        <div className="title">Planning board</div>
        <div className="flex gap-sm">
          <FilterSelect label="Priority" value={filter.priority} onChange={(v) => setFilter({ ...filter, priority: v })}
            options={["low","normal","high","critical"]}/>
          {owners.length > 0 && (
            <FilterSelect label="Owner" value={filter.owner} onChange={(v) => setFilter({ ...filter, owner: v })} options={owners}/>
          )}
          {canCreate && (
            <button className="btn primary" onClick={() => setNewOpen(true)}><Glyph name="add" size={13}/> New task</button>
          )}
        </div>
      </div>

      <div className="pm-kanban">
        {KANBAN_COLUMNS.map((col) => (
          <KanbanColumn
            key={col.id}
            col={col}
            tasks={tasksFiltered.filter((t) => t.status === col.id)}
            canDrop={canEdit}
            onDrop={onDrop}
            onOpen={(t) => setEditingTask(t)}
          />
        ))}
      </div>

      {newOpen && (
        <TaskEditor task={null} onClose={() => setNewOpen(false)} onSaved={() => { setNewOpen(false); onTaskChange && onTaskChange(); }}/>
      )}
      {editingTask && (
        <TaskEditor task={editingTask} onClose={() => setEditingTask(null)} onSaved={() => { setEditingTask(null); onTaskChange && onTaskChange(); }}/>
      )}
    </>
  );
}

function deriveUpcoming(model, daysWindow) {
  return PRData.upcomingEvents(model, daysWindow);
}

function ComingUpCard({ card, setFocus, canCreate, onConvert }) {
  const days = Math.max(0, Math.ceil((card.date - Date.now()) / (24 * 3600 * 1000)));
  const kindColor = {
    "migration": "var(--accent)",
    "underlay-cdd": "var(--info)",
    "link-migration": "var(--ok)",
    "isp-term": "var(--warn)",
  }[card.kind] || "var(--ink-400)";
  return (
    <div className="pm-coming-card" style={{ borderLeftColor: kindColor }}>
      <div className="pm-coming-top">
        <span className="pm-coming-kind" style={{ background: kindColor }}>{card.kind}</span>
        <span className="pm-coming-eta mono">{days === 0 ? "today" : `in ${days}d`}</span>
      </div>
      <div className="pm-coming-title">{card.title}</div>
      <div className="pm-coming-meta">{card.country} · {card.region} · {PRData.fmtDate(card.date)}</div>
      <div className="pm-coming-actions">
        {card.siteId && (
          <button className="btn ghost" onClick={() => setFocus({ entity: "overlay", idRef: card.siteId, kind: "sites" })}>
            Open site
          </button>
        )}
        {card.linkRef && (
          <button className="btn ghost" onClick={() => setFocus({ entity: "underlay", idRef: card.linkRef, kind: "underlay" })}>
            Open link
          </button>
        )}
        {canCreate && (
          <button className="btn" onClick={onConvert}>+ Task</button>
        )}
      </div>
    </div>
  );
}

function KanbanColumn({ col, tasks, canDrop, onDrop, onOpen }) {
  const [hovering, setHovering] = useState(false);
  return (
    <div
      className={"pm-kcol mc-kcol" + (hovering ? " hovering" : "")}
      onDragOver={(e) => { if (canDrop) { e.preventDefault(); setHovering(true); } }}
      onDragLeave={() => setHovering(false)}
      onDrop={(e) => {
        setHovering(false);
        const id = e.dataTransfer.getData("text/plain");
        if (id && canDrop) onDrop(id, col.id);
      }}
    >
      <div className="pm-kcol-h mc-kcol-h">
        <span className="pm-kcol-dot mc-kcol-bar" style={{ background: col.color }}/>
        <span className="mc-kcol-label">{col.label}</span>
        <span className="mono pm-kcol-count mc-kcol-count">{tasks.length}</span>
      </div>
      <div className="pm-kcol-body mc-kcol-body">
        {tasks.length === 0 && <div className="pm-kcol-empty mc-kcol-empty">Drop a card here</div>}
        {tasks.map((t) => (
          <MCTaskCard key={t.id} task={t} draggable={canDrop} onOpen={() => onOpen(t)}/>
        ))}
      </div>
    </div>
  );
}

function MCTaskCard({ task, draggable, onOpen }) {
  const overdue = task.dueDate && task.dueDate < new Date().toISOString().slice(0, 10) && task.status !== "done";
  const priColor = {
    low: "var(--ink-300)", normal: "var(--info)", high: "var(--warn)", critical: "var(--risk)",
  }[task.priority || "normal"];
  return (
    <div
      className="pm-task mc-task"
      draggable={draggable}
      onDragStart={(e) => e.dataTransfer.setData("text/plain", task.id)}
      onClick={onOpen}
    >
      <div className="pm-task-h mc-task-h">
        <span className="pm-task-pri mc-task-pri" style={{ background: priColor }}/>
        <span className="pm-task-title mc-task-title">{task.title}</span>
      </div>
      <div className="pm-task-meta mc-task-meta">
        {task.linkedSiteId && <span className="mono mc-task-ref">{task.linkedSiteId}</span>}
        {task.linkedLinkRef && <span className="mono mc-task-ref">{task.linkedLinkRef}</span>}
        {task.dueDate && <span className={"pm-task-due mc-task-due" + (overdue ? " overdue" : "")}>{task.dueDate}</span>}
        {task.owner && <MCAvatar email={task.owner} size={20}/>}
      </div>
      {task.tags && task.tags.length > 0 && (
        <div className="pm-task-tags mc-task-tags">
          {task.tags.map((t) => <span key={t} className="pm-task-tag mc-task-tag">{t}</span>)}
        </div>
      )}
    </div>
  );
}

function TaskEditor({ task, onClose, onSaved }) {
  const [draft, setDraft] = useState(task || {
    title: "", description: "", status: "todo", priority: "normal",
    dueDate: "", owner: "", linkedSiteId: "", linkedLinkRef: "", tags: [],
  });
  const [tagInput, setTagInput] = useState("");
  const [busy, setBusy] = useState(false);

  const save = async () => {
    if (!draft.title.trim()) return;
    setBusy(true);
    try {
      await PMClient.upsertTask(draft);
      onSaved && onSaved();
    } finally {
      setBusy(false);
    }
  };

  const remove = async () => {
    if (!task || !task.id) return;
    if (!confirm("Delete this task?")) return;
    await PMClient.deleteTask(task.id);
    onSaved && onSaved();
  };

  return (
    <div className="pm-modal-backdrop" onClick={onClose}>
      <div className="pm-modal large" onClick={(e) => e.stopPropagation()}>
        <div className="pm-modal-h">
          <div className="title">{task ? "Edit task" : "New task"}</div>
          <button className="btn ghost" onClick={onClose}><Glyph name="close" size={14}/></button>
        </div>
        <div className="pm-modal-body">
          <div className="pm-field">
            <label className="pm-field-label">Title</label>
            <input className="pm-input" value={draft.title} onChange={(e) => setDraft({ ...draft, title: e.target.value })} autoFocus/>
          </div>
          <div className="pm-field">
            <label className="pm-field-label">Description</label>
            <textarea className="pm-input pm-textarea" rows={3} value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })}/>
          </div>
          <div className="grid g-2" style={{ gap: 12 }}>
            <div className="pm-field">
              <label className="pm-field-label">Status</label>
              <select className="pm-input" value={draft.status} onChange={(e) => setDraft({ ...draft, status: e.target.value })}>
                {KANBAN_COLUMNS.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
              </select>
            </div>
            <div className="pm-field">
              <label className="pm-field-label">Priority</label>
              <select className="pm-input" value={draft.priority} onChange={(e) => setDraft({ ...draft, priority: e.target.value })}>
                <option value="low">Low</option>
                <option value="normal">Normal</option>
                <option value="high">High</option>
                <option value="critical">Critical</option>
              </select>
            </div>
            <div className="pm-field">
              <label className="pm-field-label">Due date</label>
              <input className="pm-input" type="date" value={draft.dueDate || ""} onChange={(e) => setDraft({ ...draft, dueDate: e.target.value })}/>
            </div>
            <div className="pm-field">
              <label className="pm-field-label">Owner (email)</label>
              <input className="pm-input" value={draft.owner || ""} onChange={(e) => setDraft({ ...draft, owner: e.target.value })}/>
            </div>
            <div className="pm-field">
              <label className="pm-field-label">Linked site code</label>
              <input className="pm-input" value={draft.linkedSiteId || ""} onChange={(e) => setDraft({ ...draft, linkedSiteId: e.target.value })}/>
            </div>
            <div className="pm-field">
              <label className="pm-field-label">Linked link ref</label>
              <input className="pm-input" value={draft.linkedLinkRef || ""} onChange={(e) => setDraft({ ...draft, linkedLinkRef: e.target.value })}/>
            </div>
          </div>
          <div className="pm-field">
            <label className="pm-field-label">Tags</label>
            <div className="pm-tag-row">
              {(draft.tags || []).map((t) => (
                <span key={t} className="pm-task-tag">
                  {t}
                  <button className="btn ghost" onClick={() => setDraft({ ...draft, tags: draft.tags.filter((x) => x !== t) })}>×</button>
                </span>
              ))}
              <input className="pm-input" placeholder="Add tag…" value={tagInput}
                onChange={(e) => setTagInput(e.target.value)}
                onKeyDown={(e) => {
                  if (e.key === "Enter" && tagInput.trim()) {
                    setDraft({ ...draft, tags: [...(draft.tags || []), tagInput.trim()] });
                    setTagInput("");
                  }
                }}/>
            </div>
          </div>
          {task && task.id && (
            <PmCommentsRail entity="task" idRef={task.id} compact/>
          )}
        </div>
        <div className="pm-modal-actions">
          {task && task.id && <button className="btn danger" onClick={remove}>Delete</button>}
          <span style={{ flex: 1 }}/>
          <button className="btn" onClick={onClose}>Cancel</button>
          <button className="btn primary" disabled={busy || !draft.title.trim()} onClick={save}>{busy ? "Saving…" : "Save"}</button>
        </div>
      </div>
    </div>
  );
}

/* =============================================================
   PmActivity — Tab 3
   ============================================================= */
function PmActivity({ activity, model, setFocus }) {
  const [filter, setFilter] = useState({ window: "24h" });
  const [search, setSearch] = useState("");
  const filtered = useMemo(() => {
    const now = Date.now();
    const winMs = filter.window === "1h" ? 3600e3 : filter.window === "24h" ? 86400e3 : filter.window === "7d" ? 7 * 86400e3 : Infinity;
    const q = search.trim().toLowerCase();
    return activity.filter((a) => {
      const dt = new Date(a.createdAt).getTime();
      if (now - dt > winMs) return false;
      if (filter.actor && a.actor !== filter.actor) return false;
      if (filter.kind && a.kind !== filter.kind) return false;
      if (q && !JSON.stringify(a).toLowerCase().includes(q)) return false;
      return true;
    });
  }, [activity, filter, search]);

  const actors = useMemo(() => [...new Set(activity.map((a) => a.actor).filter(Boolean))], [activity]);

  return (
    <>
      <div className="mc-toolbar">
        <MCSearchBox value={search} onChange={setSearch} placeholder="Search activity..."/>
        <FilterSelect label="Window" value={filter.window} onChange={(v) => setFilter({ ...filter, window: v })} options={[
          { value: "1h", label: "Last hour" },
          { value: "24h", label: "Last 24h" },
          { value: "7d", label: "Last 7d" },
          { value: "all", label: "All time" },
        ]}/>
        {actors.length > 0 && <FilterSelect label="Actor" value={filter.actor} onChange={(v) => setFilter({ ...filter, actor: v })} options={actors}/>}
        <FilterSelect label="Kind" value={filter.kind} onChange={(v) => setFilter({ ...filter, kind: v })} options={[
          "edit","revert","comment","task-create","task-update","task-delete",
          "change-request-create","change-request-update","change-request-note","change-request-sent","change-request-writeback","change-request-delete"
        ]}/>
        <span style={{ flex: 1 }}/>
        <span className="mono" style={{ fontSize: 11.5, color: "var(--ink-500)" }}>{filtered.length} entries</span>
      </div>

      <div className="pm-feed mc-pulse-feed">
        {filtered.length === 0 && <Empty title="No activity in this window."/>}
        {filtered.map((a) => (
          <ActivityRow key={a.id} a={a} setFocus={setFocus}/>
        ))}
      </div>

      <PresenceBar/>
    </>
  );
}

function ActivityRow({ a, setFocus }) {
  const verb = a.kind === "edit" ? `edited ${a.field} on ${a.entity} ${a.idRef}`
    : a.kind === "revert" ? `reverted ${a.field} on ${a.entity} ${a.idRef}`
    : a.kind === "comment" ? `commented on ${a.entity} ${a.idRef}`
    : a.kind === "task-create" ? `created task “${a.title}”`
    : a.kind === "task-update" ? `updated task “${a.title}”`
    : a.kind === "task-delete" ? `deleted task “${a.title}”`
    : a.kind === "change-request-create" ? `created change request ${a.crNumber || a.idRef || ""}`
    : a.kind === "change-request-update" ? `updated change request ${a.crNumber || a.idRef || ""}`
    : a.kind === "change-request-note" ? `added change request update ${a.crNumber || a.idRef || ""}`
    : a.kind === "change-request-sent" ? `marked change request sent ${a.crNumber || a.idRef || ""}`
    : a.kind === "change-request-writeback" ? `wrote change request overrides ${a.crNumber || a.idRef || ""}`
    : a.kind === "change-request-delete" ? `deleted change request ${a.crNumber || a.idRef || ""}`
    : a.kind;
  const open = () => {
    if (a.entity && a.idRef && setFocus) {
      const kind = a.entity === "overlay" ? "sites" :
                   a.entity === "underlay" ? "underlay" :
                   a.entity === "isp" ? "isp" : "newunderlay";
      setFocus({ entity: a.entity, idRef: a.idRef, kind });
    }
  };
  return (
    <div className="pm-feed-row mc-event" onClick={open}>
      <span className={"mc-event-rail tone-" + (a.kind === "edit" ? "info" : a.kind === "comment" ? "warn" : a.kind && a.kind.includes("delete") ? "risk" : "neutral")}/>
      <MCAvatar name={a.actorName} email={a.actor}/>
      <div className="pm-feed-body mc-event-body">
        <div className="mc-event-line">
          <span className="pm-feed-actor mc-event-actor">{a.actorName || a.actor}</span>
          <span> </span>
          <span className="pm-feed-verb mc-event-verb">{verb}</span>
        </div>
        {a.kind === "edit" && (
          <div className="pm-feed-diff mc-event-diff">
            <span className="mono mc-diff-from">{stringify(a.before)}</span>
            <span className="muted mc-diff-arrow">→</span>
            <span className="mono mc-diff-to">{stringify(a.after)}</span>
          </div>
        )}
        {a.snippet && <div className="pm-feed-snippet mc-event-snippet">{a.snippet}</div>}
      </div>
      <div className="pm-feed-time mc-event-time">{relTime(a.createdAt)}</div>
    </div>
  );
}

function stringify(v) {
  if (v === null || v === undefined || v === "") return "—";
  if (v instanceof Date) return v.toISOString().slice(0, 10);
  if (typeof v === "object") return JSON.stringify(v);
  return String(v);
}

function PresenceBar() {
  const [others, setOthers] = useState([]);
  useEffect(() => {
    const KEY_PREFIX = "__pm_presence_";
    let cancelled = false;
    const timers = [];
    PMClient.getUser().then((u) => {
      if (cancelled) return;
      const myEmail = u && u.email ? u.email : "anon";
      const myKey = KEY_PREFIX + myEmail;
      const beat = () => {
        try { localStorage.setItem(myKey, JSON.stringify({ at: Date.now(), name: (u && (u.displayName || u.email)) || myEmail })); } catch (_) {}
      };
      const scan = () => {
        const cutoff = Date.now() - 60000;
        const list = [];
        for (let i = 0; i < localStorage.length; i++) {
          const k = localStorage.key(i);
          if (k && k.startsWith(KEY_PREFIX) && k !== myKey) {
            try {
              const v = JSON.parse(localStorage.getItem(k));
              if (v && v.at >= cutoff) list.push({ email: k.slice(KEY_PREFIX.length), name: v.name });
            } catch (_) {}
          }
        }
        if (!cancelled) setOthers(list);
      };
      beat(); scan();
      timers.push(setInterval(beat, 30000));
      timers.push(setInterval(scan, 10000));
    }).catch(() => {});
    return () => {
      cancelled = true;
      timers.forEach(clearInterval);
    };
  }, []);
  if (!others.length) return null;
  return (
    <div className="pm-presence">
      <span>Also viewing:</span>
      {others.map((o) => <MCAvatar key={o.email} name={o.name} email={o.email} size={22}/>)}
    </div>
  );
}

/* =============================================================
   PmChangeRequests — Change Request register + detail surface
   ============================================================= */
const CR_STATUS_META = {
  draft:     { label: "Draft",     tone: "neutral" },
  ready:     { label: "Ready",     tone: "info" },
  sent:      { label: "Sent",      tone: "accent" },
  updated:   { label: "Updated",   tone: "accent" },
  delayed:   { label: "Delayed",   tone: "warn" },
  completed: { label: "Completed", tone: "ok" },
  cancelled: { label: "Cancelled", tone: "neutral" },
};
const CR_TYPE_META = {
  "underlay-migration": { label: "Underlay migration", tone: "info" },
  "overlay-migration":  { label: "Overlay migration",  tone: "accent" },
  "combined-migration": { label: "Combined migration", tone: "ok" },
  "isp-termination":    { label: "ISP termination",    tone: "warn" },
  other:                { label: "Other",              tone: "neutral" },
};
const CR_KPI_STATUSES = ["draft", "ready", "sent", "delayed", "completed"];

function crLabel(map, key) {
  return (map[key] && map[key].label) || key || "—";
}
function crTone(map, key) {
  return (map[key] && map[key].tone) || "neutral";
}
function crMonth(value) {
  return value ? String(value).slice(0, 7) : "";
}
function crSplitList(value) {
  if (Array.isArray(value)) return value.map((v) => String(v).trim()).filter(Boolean);
  return String(value || "").split(/[,\n;]/).map((v) => v.trim()).filter(Boolean);
}
function crListText(value) {
  return (Array.isArray(value) ? value : []).join(", ");
}
function crBlockerText(value) {
  return (Array.isArray(value) ? value : [])
    .map((b) => typeof b === "string" ? b : (b && b.description) || "")
    .filter(Boolean)
    .join("\n");
}
function crSameText(a, b) {
  return !!a && !!b && String(a).trim().toLowerCase() === String(b).trim().toLowerCase();
}
function crDateInput(value) {
  const iso = mcDateOnly(value);
  return iso === "—" ? "" : iso;
}
function crPickTargetDate(values) {
  const dates = (values || []).map(crDateInput).filter(Boolean).sort();
  if (!dates.length) return "";
  const today = new Date().toISOString().slice(0, 10);
  return dates.find((d) => d >= today) || dates[0];
}
function crStatusSummary(links) {
  const rows = links || [];
  if (!rows.length) return "";
  const activated = rows.filter((l) => PRData.isActivatedLink ? PRData.isActivatedLink(l) : /activated/i.test(l.circuitStatus || "")).length;
  const statuses = [...new Set(rows.map((l) => l.circuitStatus || l.prOrderStatus || l.providerStatus).filter(Boolean))].slice(0, 3);
  return `${activated}/${rows.length} activated${statuses.length ? ` - ${statuses.join(", ")}` : ""}`;
}
function crReadinessSummary(site, model) {
  if (!site) return { rowCount: 0, readinessPercent: 0, phases: {}, hints: [] };
  if (typeof window !== "undefined" && window.PMReadiness && typeof window.PMReadiness.summaryForSite === "function") {
    return window.PMReadiness.summaryForSite(site, model);
  }
  const stats = PRData.siteNewUnderlayReadiness
    ? PRData.siteNewUnderlayReadiness(site, model)
    : { rowCount: 0, readinessPercent: 0, phases: {}, blockedReadinessCount: 0, blockedBeforeChange: 0, readyForMigration: false };
  const phase = (key) => (stats.phases && stats.phases[key]) || { key, label: key, percent: 0, blocked: 0, ready: 0, total: 0, complete: false };
  const blockedPhase = ["preChangeReadiness", "changeManagement", "linkMigration", "postMigration"].map(phase).find((p) => (p.blocked || 0) > 0);
  const hints = [];
  if ((stats.blockedBeforeChange || 0) > 0) hints.push({ key: "blocked-before-change", label: "Blocked before change", tone: "risk" });
  if (stats.readyForMigration) hints.push({ key: "ready-cr", label: "Ready for change request", tone: "ok" });
  if (stats.readyForMigration && (stats.linkMigrationCompleteLinks || 0) < (stats.rowCount || 0)) hints.push({ key: "ready-migration", label: "Ready for migration", tone: "ok" });
  if ((stats.linksInMigration || 0) > 0) hints.push({ key: "in-migration", label: "Link migration active", tone: "info" });
  if ((stats.postMigrationCompleteLinks || 0) < (stats.rowCount || 0) && (stats.linkMigrationCompleteLinks || 0) > 0) hints.push({ key: "post-pending", label: "Post-migration pending", tone: "warn" });
  if (!hints.length && stats.rowCount > 0) hints.push({ key: "readiness-open", label: "Readiness in progress", tone: "neutral" });
  if (!stats.rowCount) hints.push({ key: "no-new-underlay", label: "No New Underlay row", tone: "muted" });
  return {
    ...stats,
    blockedPhase,
    preChange: phase("preChangeReadiness"),
    changeManagement: phase("changeManagement"),
    migration: phase("linkMigration"),
    postMigration: phase("postMigration"),
    hints,
  };
}
function crReadinessContextLines(summary) {
  if (typeof window !== "undefined" && window.PMReadiness && typeof window.PMReadiness.contextLines === "function") {
    return window.PMReadiness.contextLines(summary);
  }
  const s = summary || {};
  const lines = [
    `New Underlay readiness: ${s.readinessPercent || 0}% across ${s.rowCount || 0} link row(s)`,
    `Pre-change readiness: ${(s.preChange && s.preChange.percent) || 0}%`,
    `Change management: ${(s.changeManagement && s.changeManagement.percent) || 0}%`,
    `Link migration: ${(s.migration && s.migration.percent) || 0}%`,
    `Post migration: ${(s.postMigration && s.postMigration.percent) || 0}%`,
  ];
  if (s.blockedPhase) lines.push(`Blocked phase: ${s.blockedPhase.label || s.blockedPhase.key}`);
  if (s.readyForMigration) lines.push("Eligibility: ready for change request and migration planning");
  if ((s.blockedBeforeChange || 0) > 0) lines.push(`Eligibility: ${s.blockedBeforeChange} blocked readiness item(s) before change`);
  if ((s.postMigrationCompleteLinks || 0) < (s.rowCount || 0) && (s.linkMigrationCompleteLinks || 0) > 0) lines.push("Eligibility: post-migration closure pending");
  return lines;
}
function crAppendTextSection(base, title, lines) {
  const clean = (lines || []).filter(Boolean);
  if (!clean.length) return base || "";
  const body = `${title}:\n${clean.map((line) => `- ${line}`).join("\n")}`;
  if (String(base || "").includes(title)) return base || "";
  return [base, body].filter((part) => String(part || "").trim()).join("\n\n");
}
function crSearchHaystack(site) {
  return [
    site.siteCode, site.siteName, site.shortLabel, site.country, site.region,
    site.isp, site.currentUnderlayStatus, site.currentOverlayStatus,
    ...(site.linkRefs || []), ...(site.sourceTypes || []), ...(site.blockers || []),
    site.newUnderlayReadinessPct, site.newUnderlayBlockedPhase,
    site.newUnderlayChangeManagement, site.newUnderlayMigration,
    ...((site.newUnderlayHints || []).map((h) => h.label || h.key)),
  ].filter(Boolean).join(" ").toLowerCase();
}
function crTextIncludes(value, query) {
  const q = String(query || "").trim().toLowerCase();
  if (!q) return true;
  return String(value || "").toLowerCase().includes(q);
}
function crAnyTextIncludes(values, query) {
  const q = String(query || "").trim().toLowerCase();
  if (!q) return true;
  return (values || []).some((v) => String(v || "").toLowerCase().includes(q));
}
function crUniqueOptions(rows, getter) {
  const values = new Set();
  (rows || []).forEach((row) => {
    const value = getter(row);
    (Array.isArray(value) ? value : [value]).forEach((v) => {
      const text = String(v || "").trim();
      if (text) values.add(text);
    });
  });
  return [...values].sort((a, b) => a.localeCompare(b));
}
function crHasDuplicateConflict(site) {
  if (!site) return false;
  if ((site.duplicateConflicts || []).length) return true;
  return (site.sourceRows || []).some((row) => row && row.duplicateOf);
}
function crSiteWarnings(site) {
  const warnings = [];
  const pull = (row) => {
    ((row && (row.parseWarnings || row.__parseWarnings)) || []).forEach((w) => {
      warnings.push(typeof w === "string" ? w : (w && (w.code || w.message)) || "parse warning");
    });
  };
  pull(site);
  pull(site && site.overlay);
  ((site && site.links) || []).forEach(pull);
  ((site && site.newUnderlayRows) || []).forEach(pull);
  ((site && site.ispRows) || []).forEach(pull);
  return warnings.filter(Boolean);
}
function crHasIncompleteData(site) {
  if (!site) return true;
  const hasIdentity = !!(site.siteCode || site.siteName || ((site.linkRefs || []).length));
  const missingReference = !hasIdentity || !site.country || !site.region || !site.siteName;
  return missingReference || crSiteWarnings(site).length > 0;
}
function crSiteOptions(model) {
  const byKey = new Map();
  const mergeObjects = (target, field, values, keyFn) => {
    if (!values || !values.length) return;
    target[field] = target[field] || [];
    const seen = new Set(target[field].map((v) => keyFn(v)));
    values.forEach((value) => {
      const key = keyFn(value);
      if (seen.has(key)) return;
      seen.add(key);
      target[field].push(value);
    });
  };
  const ensure = (row) => {
    if (!row) return null;
    const siteCode = row.siteCode || (row.id && !/^(site|name|link|circuit):/i.test(String(row.id)) ? row.id : "");
    const siteName = row.siteName || row.shortLabel || "";
    const key = (siteCode || siteName || row.linkRef || row._id || "").toUpperCase();
    if (!key) return null;
    if (!byKey.has(key)) {
      byKey.set(key, {
        siteCode,
        siteName,
        shortLabel: row.shortLabel || "",
        country: row.country || "",
        region: row.region || "",
        links: row.links || [],
        overlay: row.overlay || null,
        ispRows: row.ispRows || [],
        newUnderlayRows: row.newUnderlayRows || [],
        canonicalLinkRefs: row.canonicalLinkRefs || row.linkRefs || [],
        sourceRows: row.sourceRows || [],
        sourceTypes: row.sourceTypes || [],
        duplicateConflicts: row.duplicateConflicts || [],
        parseWarnings: row.parseWarnings || [],
      });
    }
    const cur = byKey.get(key);
    if (!cur.siteCode && siteCode) cur.siteCode = siteCode;
    if (!cur.siteName && siteName) cur.siteName = siteName;
    if (!cur.country && row.country) cur.country = row.country;
    if (!cur.region && row.region) cur.region = row.region;
    if (!cur.shortLabel && row.shortLabel) cur.shortLabel = row.shortLabel;
    if (!cur.overlay && row.overlay) cur.overlay = row.overlay;
    mergeObjects(cur, "sourceRows", row.sourceRows || [], (v) => [v.source, v.sourceSheet, v.sourceRowNumber, v.idRef].join("|"));
    mergeObjects(cur, "duplicateConflicts", row.duplicateConflicts || [], (v) => JSON.stringify(v));
    cur.sourceTypes = [...new Set([...(cur.sourceTypes || []), ...((row.sourceTypes || []))])];
    cur.parseWarnings = [...(cur.parseWarnings || []), ...((row.parseWarnings || []).filter(Boolean))];
    return cur;
  };
  ((model && model.canonicalSites) || (model && model.sites) || []).forEach(ensure);
  ((model && model.canonicalLinks) || []).forEach((link) => {
    const ref = link.linkRef || link.circuitId || link.id || "";
    const site = ensure({
      siteName: ((link.siteNames || [])[0]) || "",
      country: ((link.countries || [])[0]) || "",
      region: ((link.regions || [])[0]) || "",
      linkRef: ref,
      canonicalLinkRefs: ref ? [ref] : [],
      sourceRows: link.sources || [],
      sourceTypes: link.sourceTypes || [],
      duplicateConflicts: link.duplicateConflicts || [],
    });
    if (site && ref && !(site.canonicalLinkRefs || []).includes(ref)) site.canonicalLinkRefs.push(ref);
  });
  ((model && model.overlay) || []).forEach((o) => {
    const site = ensure({ ...o, overlay: o });
    if (site && !site.overlay) site.overlay = o;
  });
  ((model && model.underlay) || []).forEach((u) => {
    let site = [...byKey.values()].find((s) => crSameText(s.siteName, u.siteName));
    if (!site) site = ensure(u);
    if (!site) return;
    if (!site.links.some((l) => l.linkRef === u.linkRef)) site.links.push(u);
  });
  ((model && model.newUnderlay) || []).forEach((n) => {
    let site = [...byKey.values()].find((s) =>
      (n.siteCode && s.siteCode === n.siteCode) || crSameText(s.siteName, n.siteName)
    );
    if (!site) site = ensure(n);
    if (!site) return;
    site.newUnderlayRows.push(n);
  });
  ((model && model.isp) || []).forEach((i) => {
    let site = [...byKey.values()].find((s) =>
      (i.siteCode && s.siteCode === i.siteCode) || crSameText(s.siteName, i.siteName)
    );
    if (!site) site = ensure(i);
    if (!site) return;
    site.ispRows.push(i);
  });
  return [...byKey.values()].map((site) => {
    const overlay = site.overlay || {};
    const links = site.links || [];
    const newRows = site.newUnderlayRows || [];
    const ispRows = site.ispRows || [];
    const linkRefs = [...new Set([
      ...((site.canonicalLinkRefs || [])),
      ...links.map((l) => l.linkRef),
      ...newRows.map((n) => n.linkRef),
      ...ispRows.map((i) => i.linkRef),
      overlay.linkARef,
    ].filter(Boolean))];
    const isp = [...new Set([
      ...newRows.map((n) => n.isp),
      ...ispRows.map((i) => i.isp),
      ...links.map((l) => l.vendor || l.provider),
    ].filter(Boolean))].join(", ");
    const rawBlockers = [
      ...links.map((l) => l.blocker).filter(Boolean),
      ...newRows.map((n) => n.cab && /block|hold|no/i.test(n.cab) ? `CAB: ${n.cab}` : "").filter(Boolean),
    ];
    const readiness = crReadinessSummary({ ...site, linkRefs, links, newUnderlayRows: newRows, overlay }, model);
    const readinessBlockers = [];
    if ((readiness.blockedReadinessCount || 0) > 0) {
      readinessBlockers.push(`New Underlay: ${readiness.blockedReadinessCount} blocked readiness item(s)`);
    }
    if (readiness.blockedPhase) {
      readinessBlockers.push(`New Underlay blocked phase: ${readiness.blockedPhase.label || readiness.blockedPhase.key}`);
    }
    const blockers = [...rawBlockers, ...readinessBlockers];
    const targetDate = crPickTargetDate([
      overlay.confirmedMigration, overlay.hwTarget,
      ...links.flatMap((l) => [l.migrationDate, l.cdd, l.planned, l.circuitDate]),
      ...newRows.flatMap((n) => [n.changeDate, n.cdd, n.circuitDeliveryDate]),
      ...ispRows.map((i) => i.terminationDate),
    ]);
    return {
      ...site,
      linkRefs,
      isp,
      blockers,
      targetDate,
      currentUnderlayStatus: crStatusSummary(links),
      currentOverlayStatus: overlay.migrationStatus || overlay.migrationSchedule || "",
      oldTechnology: (overlay.techno || "").trim(),
      newUnderlayReadiness: readiness,
      newUnderlayReadinessPct: readiness.readinessPercent || 0,
      newUnderlayRowsCount: readiness.rowCount || newRows.length || 0,
      newUnderlayBlockedPhase: readiness.blockedPhase ? (readiness.blockedPhase.label || readiness.blockedPhase.key) : "",
      newUnderlayChangeManagement: `${(readiness.changeManagement && readiness.changeManagement.percent) || 0}% change management`,
      newUnderlayMigration: `${(readiness.migration && readiness.migration.percent) || 0}% link migration`,
      newUnderlayHints: readiness.hints || [],
    };
  })
    .filter((site) => site.siteCode || site.siteName || (site.linkRefs && site.linkRefs.length))
    .sort((a, b) => {
      const aKey = a.siteCode || a.siteName || (a.linkRefs && a.linkRefs[0]) || "";
      const bKey = b.siteCode || b.siteName || (b.linkRefs && b.linkRefs[0]) || "";
      return aKey.localeCompare(bKey);
    });
}
function crFindSite(model, cr) {
  const code = String((cr && cr.siteCode) || "").toUpperCase();
  const name = String((cr && cr.siteName) || "").toLowerCase();
  return crSiteOptions(model).find((s) =>
    (code && String(s.siteCode || "").toUpperCase() === code) ||
    (name && String(s.siteName || "").toLowerCase() === name)
  ) || null;
}
function crLinkedLinks(model, cr, site) {
  const refs = new Set(((cr && cr.linkRefs) || []).map((r) => String(r).toUpperCase()));
  const rows = refs.size
    ? ((model && model.underlay) || []).filter((u) => refs.has(String(u.linkRef || "").toUpperCase()))
    : ((site && site.links) || []);
  return rows.slice(0, 8);
}
function crBuildWriteBackOptions(model, cr, options = {}) {
  const refs = [...new Set(((cr && cr.linkRefs) || []).filter(Boolean))];
  const workbookValues = { underlay: {}, overlay: {}, isp: {}, newUnderlay: {} };
  const capture = (entity, idRef, row, fields) => {
    if (!idRef || !row) return;
    workbookValues[entity][idRef] = workbookValues[entity][idRef] || {};
    fields.forEach((field) => {
      if (Object.prototype.hasOwnProperty.call(row, field)) workbookValues[entity][idRef][field] = row[field];
    });
  };
  refs.forEach((ref) => {
    const refKey = String(ref).toUpperCase();
    const underlay = ((model && model.underlay) || []).find((row) => String(row.linkRef || "").toUpperCase() === refKey);
    const newUnderlay = ((model && model.newUnderlay) || []).find((row) => String(row.linkRef || "").toUpperCase() === refKey);
    const isp = ((model && model.isp) || []).find((row) => String(row.linkRef || "").toUpperCase() === refKey);
    capture("underlay", ref, underlay, ["changeRequestNo", "plannedChangeDate", "changeNo"]);
    capture("newUnderlay", ref, newUnderlay, ["changeNo", "changeDate"]);
    capture("isp", ref, isp, ["comments"]);
  });
  if (cr && cr.siteCode) {
    const siteCode = String(cr.siteCode).toUpperCase();
    const overlay = ((model && model.overlay) || []).find((row) => String(row.siteCode || "").toUpperCase() === siteCode);
    capture("overlay", cr.siteCode, overlay, ["changeNo", "confirmedMigration"]);
  }
  return {
    confirmOverlayDate: !!options.confirmOverlayDate,
    workbookValues,
  };
}
function crLinkedRowsSummary(model, cr, site, relatedPlan, updates, activity) {
  const refs = new Set(((cr && cr.linkRefs) || []).map((r) => String(r || "").toUpperCase()));
  const underlay = refs.size
    ? ((model && model.underlay) || []).filter((row) => refs.has(String(row.linkRef || "").toUpperCase()))
    : ((site && site.links) || []);
  const overlay = cr && cr.siteCode
    ? ((model && model.overlay) || []).filter((row) => String(row.siteCode || "").toUpperCase() === String(cr.siteCode || "").toUpperCase())
    : (site && site.overlay ? [site.overlay] : []);
  const isp = refs.size
    ? ((model && model.isp) || []).filter((row) => refs.has(String(row.linkRef || "").toUpperCase()))
    : ((site && site.ispRows) || []);
  const newUnderlay = refs.size
    ? ((model && model.newUnderlay) || []).filter((row) => refs.has(String(row.linkRef || "").toUpperCase()))
    : ((site && site.newUnderlayRows) || []);
  return [
    { label: "Canonical site", value: site ? "linked" : "missing", detail: (site && (site.siteCode || site.siteName)) || "No canonical site match" },
    { label: "Underlay links", value: underlay.length, detail: refs.size ? `${refs.size} CR refs selected` : "site-derived links" },
    { label: "Overlay row", value: overlay.length, detail: cr && cr.siteCode ? cr.siteCode : "site-derived overlay" },
    { label: "ISP rows", value: isp.length, detail: "matched by link ref or site" },
    { label: "New underlay rows", value: newUnderlay.length, detail: "matched by link ref or site" },
    { label: "Migration plan", value: relatedPlan ? "linked" : "none", detail: relatedPlan ? (relatedPlan.id || relatedPlan.type) : "no related plan" },
    { label: "Comments", value: "live", detail: "change_request comment rail" },
    { label: "Activity", value: (activity || []).length, detail: "activity feed scoped by CR id/number" },
    { label: "Updates", value: (updates || []).length, detail: "delay/scope/completion history" },
  ];
}
function CrStatusPill({ status }) {
  return <MCPill tone={crTone(CR_STATUS_META, status)} soft>{crLabel(CR_STATUS_META, status)}</MCPill>;
}
function CrTypePill({ type }) {
  return <MCPill tone={crTone(CR_TYPE_META, type)} soft>{crLabel(CR_TYPE_META, type)}</MCPill>;
}

function PmChangeRequests({ model, role, user, changeRequests, plans, activity, onChange }) {
  const [search, setSearch] = useState("");
  const [filters, setFilters] = useState({});
  const [selectedId, setSelectedId] = useState(null);
  const [editingCr, setEditingCr] = useState(null);
  const currentEmail = ((user && user.email) || "").toLowerCase();
  const canAccess = role === "engineer" || role === "po" || role === "admin";

  const canEditCr = useCallback((cr) => {
    if (!canAccess) return false;
    if (role === "po" || role === "admin") return true;
    if (!cr || !cr.id) return true;
    const owner = String(cr.createdBy || "").toLowerCase();
    return !owner || owner === "local" || (currentEmail && owner === currentEmail);
  }, [canAccess, role, currentEmail]);

  const selectedCr = useMemo(() => {
    if (!selectedId) return null;
    return (changeRequests || []).find((cr) => cr.id === selectedId) || null;
  }, [changeRequests, selectedId]);

  const opts = useMemo(() => {
    const sites = crSiteOptions(model);
    const countries = new Set(sites.map((s) => s.country).filter(Boolean));
    const regions = new Set(sites.map((s) => s.region).filter(Boolean));
    const months = new Set();
    (changeRequests || []).forEach((cr) => {
      if (cr.country) countries.add(cr.country);
      if (cr.region) regions.add(cr.region);
      if (cr.targetDate) months.add(crMonth(cr.targetDate));
    });
    return {
      country: [...countries].sort(),
      region: [...regions].sort(),
      month: [...months].sort(),
    };
  }, [model, changeRequests]);

  const counts = useMemo(() => {
    const c = {};
    CR_KPI_STATUSES.forEach((s) => { c[s] = 0; });
    (changeRequests || []).forEach((cr) => {
      if (c[cr.status] !== undefined) c[cr.status]++;
    });
    return c;
  }, [changeRequests]);

  const filtered = useMemo(() => {
    const q = search.trim().toLowerCase();
    return (changeRequests || []).filter((cr) => {
      if (filters.status && cr.status !== filters.status) return false;
      if (filters.type && cr.type !== filters.type) return false;
      if (filters.country && cr.country !== filters.country) return false;
      if (filters.region && cr.region !== filters.region) return false;
      if (filters.month && crMonth(cr.targetDate) !== filters.month) return false;
      if (q) {
        const hay = [
          cr.crNumber, cr.siteCode, cr.siteName, cr.country, cr.region,
          cr.type, cr.status, cr.createdBy, cr.isp, cr.oldTechnology,
          cr.impact, cr.risk, cr.migrationWindow, ...(cr.linkRefs || []),
        ].filter(Boolean).join(" ").toLowerCase();
        if (!hay.includes(q)) return false;
      }
      return true;
    });
  }, [changeRequests, filters, search]);

  const columns = useMemo(() => [
    { key: "crNumber", label: "CR number", width: 150, cellClass: "mono strong",
      render: (r) => <span>{r.crNumber || r.id}</span> },
    { key: "site", label: "Site", render: (r) => (
      <div className="pm-cr-site-cell">
        <span className="strong">{r.siteCode || r.siteName || "—"}</span>
        <span className="muted">{r.siteName || r.linkRefs?.join(", ") || "No linked site"}</span>
      </div>
    )},
    { key: "country", label: "Country", width: 120 },
    { key: "type", label: "Type", width: 160, render: (r) => <CrTypePill type={r.type}/> },
    { key: "targetDate", label: "Target date", width: 120, cellClass: "mono",
      render: (r) => r.targetDate || <span className="muted">—</span> },
    { key: "status", label: "Status", width: 120, render: (r) => <CrStatusPill status={r.status}/> },
    { key: "createdBy", label: "Owner / creator", width: 180, render: (r) => (
      <div className="pm-cr-owner">
        <MCAvatar email={r.createdBy || "local"} size={22}/>
        <span className="mono">{r.createdBy || "local"}</span>
      </div>
    )},
    { key: "updatedAt", label: "Last update", width: 120, render: (r) => relTime(r.updatedAt || r.createdAt) },
  ], []);

  if (!canAccess) {
    return <MCEmpty title="Change Requests are restricted" sub="Management role keeps monitoring-only access." />;
  }

  const hasFilters = search || Object.keys(filters).some((k) => filters[k]);
  const reset = () => { setSearch(""); setFilters({}); };

  return (
    <div className="pm-cr">
      <div className="pm-cr-kpis">
        {CR_KPI_STATUSES.map((status) => (
          <button
            key={status}
            className={"pm-cr-kpi tone-" + crTone(CR_STATUS_META, status) + (filters.status === status ? " active" : "")}
            onClick={() => setFilters({ ...filters, status: filters.status === status ? "" : status })}
          >
            <span>{crLabel(CR_STATUS_META, status)}</span>
            <strong className="mono">{counts[status] || 0}</strong>
          </button>
        ))}
      </div>

      <div className="mc-toolbar pm-cr-toolbar">
        <MCSearchBox value={search} onChange={setSearch} placeholder="Search CR number, site, country, owner, link, risk..."/>
        <FilterSelect label="Status" value={filters.status} onChange={(v) => setFilters({ ...filters, status: v })} options={[
          { value: "draft", label: "Draft" },
          { value: "ready", label: "Ready" },
          { value: "sent", label: "Sent" },
          { value: "updated", label: "Updated" },
          { value: "delayed", label: "Delayed" },
          { value: "completed", label: "Completed" },
          { value: "cancelled", label: "Cancelled" },
        ]}/>
        <FilterSelect label="Type" value={filters.type} onChange={(v) => setFilters({ ...filters, type: v })} options={Object.entries(CR_TYPE_META).map(([value, meta]) => ({ value, label: meta.label }))}/>
        <FilterSelect label="Country" value={filters.country} onChange={(v) => setFilters({ ...filters, country: v })} options={opts.country}/>
        <FilterSelect label="Region" value={filters.region} onChange={(v) => setFilters({ ...filters, region: v })} options={opts.region}/>
        <FilterSelect label="Target month" value={filters.month} onChange={(v) => setFilters({ ...filters, month: v })} options={opts.month}/>
        {hasFilters && <button className="btn ghost mc-reset" onClick={reset}>Reset</button>}
        <span style={{ flex: 1 }}/>
        <button className="btn primary" onClick={() => setEditingCr({})}>
          <Glyph name="add" size={13}/> New Change Request
        </button>
      </div>

      <div className="table-wrap table-dense mc-grid-wrap pm-cr-grid">
        <div className="table-toolbar">
          <span className="mono" style={{ fontSize: 11.5, color: "var(--ink-500)" }}>
            {filtered.length.toLocaleString()} of {(changeRequests || []).length.toLocaleString()} change requests
          </span>
          <span style={{ flex: 1 }}/>
          <span style={{ fontSize: 11.5, color: "var(--ink-400)" }}>Local register · Azure-ready contract</span>
        </div>
        {filtered.length > 0 ? (
          <DataTable
            columns={columns}
            rows={filtered.map((r) => ({ ...r, _id: r.id }))}
            onRowClick={(r) => setSelectedId(r.id)}
            maxHeight={"62vh"}
          />
        ) : (
          <div className="pm-cr-empty">
            <div className="pm-cr-empty-mark">CR</div>
            <div className="pm-cr-empty-title">{hasFilters ? "No change request matches these filters" : "No change requests yet"}</div>
            <div className="pm-cr-empty-sub">
              Create the first request from a site, link scope, target date, stakeholders, impact, risk and rollback plan.
            </div>
            <button className="btn primary" onClick={() => setEditingCr({})}>
              <Glyph name="add" size={13}/> New change request
            </button>
          </div>
        )}
      </div>

      {selectedCr && (
        <ChangeRequestDetailPanel
          cr={selectedCr}
          model={model}
          plans={plans}
          activity={activity}
          user={user}
          canEdit={canEditCr(selectedCr)}
          onClose={() => setSelectedId(null)}
          onEdit={() => setEditingCr(selectedCr)}
          onChange={onChange}
        />
      )}

      {editingCr && (
        <ChangeRequestModal
          cr={editingCr.id ? editingCr : null}
          model={model}
          user={user}
          onClose={() => setEditingCr(null)}
          onSaved={(saved) => {
            setEditingCr(null);
            setSelectedId(saved && saved.id ? saved.id : selectedId);
            onChange && onChange();
          }}
        />
      )}
    </div>
  );
}

function ChangeRequestDetailPanel({ cr, model, plans, activity, user, canEdit, onClose, onEdit, onChange }) {
  const [updates, setUpdates] = useState([]);
  const [draftUpdate, setDraftUpdate] = useState({ updateType: "general", message: "", reason: "", impact: "", nextAction: "", newTargetDate: "" });
  const [busy, setBusy] = useState(false);
  const [emailBusy, setEmailBusy] = useState("");
  const [emailPreview, setEmailPreview] = useState(null);
  const [writeBackBusy, setWriteBackBusy] = useState(false);
  const [writeBackConfirmOverlayDate, setWriteBackConfirmOverlayDate] = useState(false);

  const site = useMemo(() => crFindSite(model, cr), [model, cr]);
  const links = useMemo(() => crLinkedLinks(model, cr, site), [model, cr, site]);
  const relatedPlan = useMemo(() => {
    const rows = plans || [];
    if (cr.relatedPlanId) {
      const direct = rows.find((p) => p.id === cr.relatedPlanId);
      if (direct) return direct;
    }
    const refs = new Set((cr.linkRefs || []).map((r) => String(r || "").toUpperCase()));
    return rows.find((p) => {
      if (cr.siteCode && p.siteCode === cr.siteCode) return true;
      return (p.linkRefs || []).some((r) => refs.has(String(r || "").toUpperCase()));
    }) || null;
  }, [plans, cr]);
  const relatedActivity = useMemo(() => {
    return (activity || [])
      .filter((a) =>
        (a.entity === "change_request" && a.idRef === cr.id) ||
        (cr.crNumber && a.crNumber === cr.crNumber)
      )
      .slice(0, 8);
  }, [activity, cr]);
  const writeBackMappings = useMemo(() => window.CR_WRITE_BACK_MAPPING || [], []);
  const linkedRowsSummary = useMemo(
    () => crLinkedRowsSummary(model, cr, site, relatedPlan, updates, relatedActivity),
    [model, cr, site, relatedPlan, updates, relatedActivity]
  );
  const baseWriteBackOptions = useCallback((extra = {}) => ({
    ...extra,
    ...crBuildWriteBackOptions(model, cr, {
      confirmOverlayDate: Object.prototype.hasOwnProperty.call(extra, "confirmOverlayDate") ? extra.confirmOverlayDate : writeBackConfirmOverlayDate,
    }),
  }), [model, cr, writeBackConfirmOverlayDate]);

  const reloadUpdates = useCallback(async () => {
    if (!cr || !cr.id) { setUpdates([]); return; }
    const rows = await PMClient.listChangeRequestUpdates(cr.id).catch(() => []);
    setUpdates(rows || []);
  }, [cr && cr.id]);

  useEffect(() => {
    reloadUpdates();
    const unsub = PMClient.subscribe(() => reloadUpdates());
    return unsub;
  }, [reloadUpdates]);

  const previewEmail = async (mode) => {
    setEmailBusy(typeof mode === "string" ? mode : "update");
    try {
      const preview = await PMClient.renderChangeRequestEmail(cr.id, mode || "initial");
      setEmailPreview(preview);
    } finally {
      setEmailBusy("");
    }
  };

  const addUpdate = async () => {
    const hasBody = [draftUpdate.message, draftUpdate.reason, draftUpdate.impact, draftUpdate.nextAction].some((v) => String(v || "").trim());
    if (!hasBody && !["completion", "cancellation"].includes(draftUpdate.updateType)) return;
    setBusy(true);
    try {
      const rec = await PMClient.addChangeRequestUpdate(cr.id, {
        ...draftUpdate,
        oldTargetDate: cr.targetDate || "",
        message: draftUpdate.message,
        __writeBackOptions: baseWriteBackOptions({ confirmOverlayDate: writeBackConfirmOverlayDate }),
      });
      const preview = await PMClient.renderChangeRequestEmail(cr.id, { mode: "update", update: rec });
      setEmailPreview(preview);
      setDraftUpdate({ updateType: "general", message: "", reason: "", impact: "", nextAction: "", newTargetDate: "" });
      await reloadUpdates();
      onChange && onChange();
    } finally {
      setBusy(false);
    }
  };

  const writeBack = async () => {
    if (!canEdit || !PMClient.writeBackChangeRequest) return;
    setWriteBackBusy(true);
    try {
      await PMClient.writeBackChangeRequest(cr.id, baseWriteBackOptions({ reason: "manual", confirmOverlayDate: writeBackConfirmOverlayDate }));
      onChange && onChange();
    } finally {
      setWriteBackBusy(false);
    }
  };

  const fields = [
    { k: "CR number", v: cr.crNumber },
    { k: "Status", v: <CrStatusPill status={cr.status}/> },
    { k: "Type", v: <CrTypePill type={cr.type}/> },
    { k: "Target", v: cr.targetDate || "—" },
    { k: "Timezone", v: cr.timezone || "—" },
    { k: "Expected duration", v: cr.expectedDuration || "—" },
    { k: "Fallback date", v: cr.fallbackDate || "—" },
    { k: "Migration window", v: cr.migrationWindow || "—" },
    { k: "ServiceNow mailbox", v: cr.serviceNowMailbox || "—" },
    { k: "Requester", v: cr.requester || "—" },
    { k: "Owner", v: cr.owner || "—" },
  ];

  return (
    <SidePanel
      open={true}
      onClose={onClose}
      title={cr.crNumber || "Change request"}
      subtitle={[cr.siteCode || cr.siteName, cr.country, cr.region].filter(Boolean).join(" · ")}
      className="mc-editor-panel pm-cr-panel"
    >
      <div className="pm-cr-panel-actions">
        <CrStatusPill status={cr.status}/>
        <span style={{ flex: 1 }}/>
        <button className="btn" disabled={!!emailBusy} onClick={() => previewEmail("initial")}>Preview email</button>
        {canEdit && (
          <button
            className="btn"
            disabled={!!emailBusy || cr.status === "sent"}
            onClick={async () => {
              setEmailBusy("sent");
              try {
                const preview = await PMClient.renderChangeRequestEmail(cr.id, "initial");
                await PMClient.markChangeRequestSent(cr.id, {
                  method: "manual-standalone",
                  subject: preview.subject,
                  writeBackOptions: baseWriteBackOptions({ reason: "sent", confirmOverlayDate: false }),
                });
                onChange && onChange();
              } finally {
                setEmailBusy("");
              }
            }}
          >
            Mark as sent
          </button>
        )}
        {canEdit && <button className="btn primary" onClick={onEdit}><Glyph name="edit" size={13}/> Edit CR</button>}
      </div>

      <div className="pm-cr-section">
        <div className="pm-fieldgroup-h">Summary</div>
        <KVGrid items={fields}/>
        <CrLongText label="Impact" value={cr.impact}/>
        <CrLongText label="Business impact" value={cr.businessImpact}/>
        <CrLongText label="Technical impact" value={cr.technicalImpact}/>
        <CrLongText label="Risk" value={cr.risk}/>
        <CrLongText label="Implementation plan" value={cr.implementationPlan}/>
        <CrLongText label="Rollback plan" value={cr.rollbackPlan}/>
        <CrLongText label="Validation plan" value={cr.validationPlan}/>
      </div>

      <div className="pm-cr-section">
        <div className="pm-fieldgroup-h">Linked site / link data</div>
        <div className="pm-cr-linked">
          <div>
            <span className="pm-cr-linked-label">Site</span>
            <strong>{cr.siteCode || (site && site.siteCode) || "—"}</strong>
            <span>{cr.siteName || (site && site.siteName) || "No site name"}</span>
          </div>
          <div>
            <span className="pm-cr-linked-label">Country / region</span>
            <strong>{cr.country || (site && site.country) || "—"}</strong>
            <span>{cr.region || (site && site.region) || "—"}</span>
          </div>
          <div>
            <span className="pm-cr-linked-label">ISP / old technology</span>
            <strong>{cr.isp || "—"}</strong>
            <span>{cr.oldTechnology || "—"}</span>
          </div>
          <div>
            <span className="pm-cr-linked-label">Current underlay</span>
            <strong>{cr.currentUnderlayStatus || "—"}</strong>
            <span>Workbook status snapshot</span>
          </div>
          <div>
            <span className="pm-cr-linked-label">Current overlay</span>
            <strong>{cr.currentOverlayStatus || "—"}</strong>
            <span>Workbook migration snapshot</span>
          </div>
        </div>
        <div className="pm-cr-link-list">
          {links.length === 0 && <MCEmpty title="No linked underlay rows" sub="Add link references to connect the CR to workbook rows."/>}
          {links.map((link) => (
            <div key={link.linkRef || link.linkNum} className="pm-cr-link-row">
              <span className="mono strong">{link.linkRef || "—"}</span>
              <span>{link.vendor || link.localProvider || "—"}</span>
              <span>{link.circuitStatus || link.orderStatus || "—"}</span>
              <span className="mono">{PRData.fmtDate(link.migrationDate || link.cdd || link.planned)}</span>
            </div>
          ))}
        </div>
        <div className="pm-cr-linkage-grid">
          {linkedRowsSummary.map((item) => (
            <div key={item.label} className="pm-cr-linkage-card">
              <span>{item.label}</span>
              <strong>{item.value}</strong>
              <em>{item.detail}</em>
            </div>
          ))}
        </div>
      </div>

      <div className="pm-cr-section">
        <div className="pm-fieldgroup-h">Related migration plan</div>
        {relatedPlan ? (
          <div className="pm-cr-related-card">
            <div>
              <span className="pm-cr-linked-label">Plan</span>
              <strong>{relatedPlan.type || "migration plan"}</strong>
              <span>{relatedPlan.siteCode || relatedPlan.siteName || "Linked scope"}</span>
            </div>
            <div>
              <span className="pm-cr-linked-label">Status</span>
              <strong>{relatedPlan.status || "planned"}</strong>
              <span>{relatedPlan.targetDate || relatedPlan.targetWeek || "No target date"}</span>
            </div>
            <div>
              <span className="pm-cr-linked-label">Owner</span>
              <strong>{relatedPlan.ownerEmail || "-"}</strong>
              <span>{(relatedPlan.linkRefs || []).join(", ") || "No linked refs"}</span>
            </div>
          </div>
        ) : (
          <MCEmpty title="No related migration plan" sub="CRs still link to the selected site and workbook rows."/>
        )}
      </div>

      <div className="pm-cr-section">
        <div className="pm-fieldgroup-h">Timeline</div>
        <div className="pm-cr-timeline">
          <CrTimelineRow label="Created" value={cr.createdAt} actor={cr.createdBy}/>
          {cr.sentAt && <CrTimelineRow label="Sent" value={cr.sentAt}/>}
          <CrTimelineRow label="Updated" value={cr.updatedAt} actor={cr.updatedBy}/>
          {cr.targetDate && <CrTimelineRow label="Target migration" value={cr.targetDate}/>}
          {cr.writeBackAt && <CrTimelineRow label="Workbook write-back" value={cr.writeBackAt} actor={cr.writeBackBy}/>}
        </div>
      </div>

      <div className="pm-cr-section">
        <div className="pm-fieldgroup-h">Stakeholders</div>
        <CrEmailList label="To" values={cr.stakeholdersTo}/>
        <CrEmailList label="Cc" values={cr.stakeholdersCc}/>
      </div>

      <div className="pm-cr-section">
        <div className="pm-fieldgroup-h">Generated emails</div>
        <div className="pm-cr-generated">
          <button className="btn" disabled={!!emailBusy} onClick={() => previewEmail("initial")}>Initial request</button>
          <button className="btn" disabled={!!emailBusy || updates.length === 0} onClick={() => previewEmail({ mode: "update" })}>Latest update</button>
          <div className="pm-cr-generated-meta">
            <strong>{cr.sentSubject || "No sent subject recorded"}</strong>
            <span>{cr.sentAt ? `Marked sent ${relTime(cr.sentAt)} by ${cr.sentBy || "local"}` : "Standalone mode only previews, copies, opens mailto, or exports .eml."}</span>
          </div>
        </div>
      </div>

      {canEdit && (
        <div className="pm-cr-section">
          <div className="pm-fieldgroup-h">Workbook write-back</div>
          <div className="pm-cr-writeback">
            <div>
              <strong>{cr.writeBackSummary || "Ready to write CR references into workbook override fields"}</strong>
              <span>{cr.writeBackPendingExport ? "Pending workbook export: local PMStore overrides are active; download an updated workbook to write them into Excel." : "No workbook bytes are changed here; this writes local PMStore overrides only."}</span>
              {cr.writeBackMappingVersion && <span className="mono">Mapping {cr.writeBackMappingVersion}</span>}
            </div>
            <label className="pm-cr-check">
              <input
                type="checkbox"
                checked={writeBackConfirmOverlayDate}
                onChange={(e) => setWriteBackConfirmOverlayDate(e.target.checked)}
              />
              <span>Also confirm overlay migration date from target date</span>
            </label>
            <button className="btn primary" disabled={writeBackBusy || !PMClient.writeBackChangeRequest} onClick={writeBack}>
              {writeBackBusy ? "Writing..." : "Write back to workbook fields"}
            </button>
          </div>
          <div className="pm-cr-mapping-table">
            {writeBackMappings.map((m, i) => (
              <div key={`${m.entity}-${m.field}-${i}`} className={"pm-cr-mapping-row " + (m.classification || "").replace(/\W+/g, "-").toLowerCase()}>
                <span className="mono">{m.entity}.{m.field}</span>
                <strong>{m.classification}</strong>
                <em>{m.workbookField || "PM-only / Azure future"}</em>
              </div>
            ))}
          </div>
          {(cr.writeBackDetails || []).length > 0 && (
            <div className="pm-cr-writeback-details">
              {(cr.writeBackDetails || []).map((d, i) => (
                <div key={`${d.entity}-${d.idRef}-${d.field}-${i}`}>
                  <span className="mono">{d.entity}:{d.idRef}:{d.field}</span>
                  <strong>{d.classification}</strong>
                  <em>Workbook value preserved: {formatOverrideValue(d.workbookValue)}</em>
                </div>
              ))}
            </div>
          )}
        </div>
      )}

      <div className="pm-cr-section">
        <div className="pm-fieldgroup-h">Update history</div>
        <div className="pm-cr-updates">
          {updates.length === 0 && <MCEmpty title="No updates yet" sub="Delay, completion and scope-change notes will appear here."/>}
          {updates.map((u) => (
            <div key={u.id} className="pm-cr-update-row">
              <span className="pm-cr-update-dot"/>
              <div>
                <div className="pm-cr-update-h">
                  <strong>{u.updateType || "general"}</strong>
                  <button className="btn ghost" disabled={!!emailBusy} onClick={() => previewEmail({ mode: "update", updateId: u.id })}>Preview email</button>
                  <span className="muted">{relTime(u.createdAt)} · {u.createdBy || "local"}</span>
                </div>
                {u.message && <div className="pm-cr-update-body">{renderMarkdownSubset(u.message)}</div>}
                {u.reason && <div className="pm-cr-update-body"><strong>Reason:</strong> {renderMarkdownSubset(u.reason)}</div>}
                {u.impact && <div className="pm-cr-update-body"><strong>Impact:</strong> {renderMarkdownSubset(u.impact)}</div>}
                {u.nextAction && <div className="pm-cr-update-body"><strong>Next action:</strong> {renderMarkdownSubset(u.nextAction)}</div>}
                {u.oldTargetDate && <div className="mono pm-cr-update-date">Old target: {u.oldTargetDate}</div>}
                {u.newTargetDate && <div className="mono pm-cr-update-date">New target: {u.newTargetDate}</div>}
              </div>
            </div>
          ))}
        </div>
        {canEdit && (
          <div className="pm-cr-update-form">
            <select className="pm-input" value={draftUpdate.updateType} onChange={(e) => setDraftUpdate({ ...draftUpdate, updateType: e.target.value })}>
              <option value="general">General</option>
              <option value="delay">Delay</option>
              <option value="scope-change">Scope change</option>
              <option value="completion">Completion</option>
              <option value="cancellation">Cancellation</option>
            </select>
            <input className="pm-input" type="date" value={draftUpdate.newTargetDate} onChange={(e) => setDraftUpdate({ ...draftUpdate, newTargetDate: e.target.value })}/>
            <textarea className="pm-input pm-textarea" rows={2} placeholder="Reason for the update..." value={draftUpdate.reason} onChange={(e) => setDraftUpdate({ ...draftUpdate, reason: e.target.value })}/>
            <textarea className="pm-input pm-textarea" rows={2} placeholder="Impact of this update..." value={draftUpdate.impact} onChange={(e) => setDraftUpdate({ ...draftUpdate, impact: e.target.value })}/>
            <textarea className="pm-input pm-textarea" rows={2} placeholder="Next action..." value={draftUpdate.nextAction} onChange={(e) => setDraftUpdate({ ...draftUpdate, nextAction: e.target.value })}/>
            <textarea className="pm-input pm-textarea pm-cr-update-message" rows={3} placeholder="Additional update message..." value={draftUpdate.message} onChange={(e) => setDraftUpdate({ ...draftUpdate, message: e.target.value })}/>
            <button className="btn primary" disabled={busy} onClick={addUpdate}>{busy ? "Saving..." : "Add update"}</button>
          </div>
        )}
      </div>

      <div className="pm-cr-section">
        <div className="pm-fieldgroup-h">Activity</div>
        <div className="pm-cr-activity">
          {relatedActivity.length === 0 && <MCEmpty title="No CR activity yet" sub="Creation, sent, updates and write-back events will appear here."/>}
          {relatedActivity.map((a) => (
            <div key={a.id || `${a.kind}-${a.createdAt}`} className="pm-cr-activity-row">
              <span className="pm-cr-update-dot"/>
              <div>
                <strong>{a.kind || "activity"}</strong>
                <span>{a.snippet || a.status || a.field || "Change request activity"}</span>
              </div>
              <span className="muted">{relTime(a.createdAt)}</span>
            </div>
          ))}
        </div>
      </div>

      <div className="pm-cr-section">
        <PmCommentsRail entity="change_request" idRef={cr.id} compact/>
      </div>

      {emailPreview && (
        <ChangeRequestEmailPreviewModal
          cr={cr}
          preview={emailPreview}
          canMarkSent={canEdit && cr.status !== "sent"}
          writeBackOptions={baseWriteBackOptions({ reason: "sent", confirmOverlayDate: false })}
          onClose={() => setEmailPreview(null)}
          onMarkedSent={() => {
            setEmailPreview(null);
            onChange && onChange();
          }}
        />
      )}
    </SidePanel>
  );
}

function CrLongText({ label, value }) {
  if (!value) return null;
  return (
    <div className="pm-cr-long">
      <div className="pm-cr-long-label">{label}</div>
      <div className="pm-cr-long-body">{renderMarkdownSubset(value)}</div>
    </div>
  );
}
function CrTimelineRow({ label, value, actor }) {
  if (!value) return null;
  const shown = String(value).length <= 10 ? value : `${relTime(value)} · ${String(value).slice(0, 10)}`;
  return (
    <div className="pm-cr-timeline-row">
      <span className="pm-cr-timeline-dot"/>
      <span className="pm-cr-timeline-label">{label}</span>
      <span className="mono">{shown}</span>
      {actor && <span className="muted">{actor}</span>}
    </div>
  );
}
function CrEmailList({ label, values }) {
  const rows = Array.isArray(values) ? values : [];
  return (
    <div className="pm-cr-email-list">
      <span>{label}</span>
      <div>
        {rows.length === 0 && <span className="muted">—</span>}
        {rows.map((v) => <MCPill key={v} tone="neutral" soft mono>{v}</MCPill>)}
      </div>
    </div>
  );
}

function crDownloadBlob(blob, name) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = name;
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 500);
}
async function crCopyText(text) {
  if (navigator.clipboard && navigator.clipboard.writeText) {
    await navigator.clipboard.writeText(text);
    return true;
  }
  const ta = document.createElement("textarea");
  ta.value = text;
  ta.setAttribute("readonly", "");
  ta.style.position = "fixed";
  ta.style.left = "-9999px";
  document.body.appendChild(ta);
  ta.select();
  const ok = document.execCommand("copy");
  ta.remove();
  return ok;
}
function ChangeRequestEmailPreviewModal({ cr, preview, canMarkSent, writeBackOptions, onClose, onMarkedSent }) {
  const [copyState, setCopyState] = useState("");
  const [busy, setBusy] = useState(false);
  const copyBody = async () => {
    setCopyState("");
    try {
      await crCopyText(preview.body || "");
      setCopyState("Copied");
      setTimeout(() => setCopyState(""), 1600);
    } catch (e) {
      setCopyState("Copy failed");
    }
  };
  const exportEml = () => {
    const blob = new Blob([preview.eml || ""], { type: "message/rfc822;charset=utf-8" });
    crDownloadBlob(blob, preview.filename || "change-request.eml");
  };
  const markSent = async () => {
    setBusy(true);
    try {
      await PMClient.markChangeRequestSent(cr.id, {
        method: "standalone-preview",
        subject: preview.subject,
        writeBackOptions: writeBackOptions || {},
      });
      onMarkedSent && onMarkedSent();
    } finally {
      setBusy(false);
    }
  };
  return (
    <div className="pm-modal-backdrop pm-cr-email-backdrop" onClick={onClose}>
      <div className="pm-modal large pm-cr-email-modal" onClick={(e) => e.stopPropagation()}>
        <div className="pm-modal-h">
          <div>
            <div className="title">Preview email</div>
            <div className="muted" style={{ fontSize: 11.5 }}>
              Standalone mode only generates the draft. It does not send email or call ServiceNow.
            </div>
          </div>
          <button className="btn ghost" onClick={onClose}><Glyph name="close" size={14}/></button>
        </div>
        <div className="pm-modal-body pm-cr-email-preview">
          <div className="pm-cr-email-meta">
            <div>
              <span>Subject</span>
              <strong>{preview.subject}</strong>
            </div>
            <div>
              <span>To</span>
              <strong>{(preview.to || []).join(", ") || "No recipients"}</strong>
            </div>
            <div>
              <span>CC</span>
              <strong>{(preview.cc || []).join(", ") || "No CC"}</strong>
            </div>
          </div>
          <textarea className="pm-input pm-cr-email-body" readOnly value={preview.body || ""}/>
        </div>
        <div className="pm-modal-actions">
          <span className="muted" style={{ fontSize: 11.5 }}>{copyState || "Outlook-compatible .eml export uses an unsent draft header."}</span>
          <span style={{ flex: 1 }}/>
          <button className="btn" onClick={copyBody}>Copy email body</button>
          <button className="btn" onClick={() => { window.location.href = preview.mailto; }}>Open mail client</button>
          <button className="btn" onClick={exportEml}>Export .eml</button>
          {canMarkSent && <button className="btn primary" disabled={busy} onClick={markSent}>{busy ? "Saving..." : "Mark as sent"}</button>}
        </div>
      </div>
    </div>
  );
}

const CR_WIZARD_STEPS = [
  { id: "site", label: "Select site/link" },
  { id: "scope", label: "Migration scope" },
  { id: "schedule", label: "Schedule/window" },
  { id: "risk", label: "Impact/risk/rollback" },
  { id: "stakeholders", label: "Stakeholders" },
  { id: "review", label: "Review" },
];
const CR_SCOPE_OPTIONS = [
  { type: "underlay-migration", title: "Underlay link migration", detail: "Replace or activate the new underlay circuit before the SD-WAN cutover." },
  { type: "overlay-migration", title: "Overlay migration", detail: "Migrate the site overlay to Forti-SDWAN on the selected date." },
  { type: "combined-migration", title: "Combined underlay + overlay", detail: "Coordinate the link migration and overlay migration in one planned window." },
  { type: "isp-termination", title: "ISP termination", detail: "Terminate or decommission the legacy WAN/ISP link." },
  { type: "other", title: "Other", detail: "Use a custom scope while keeping the CR tied to the site and links." },
];
const CR_SITE_PAGE_SIZE = 100;
const CR_SITE_FILTER_DEFAULTS = {
  region: "",
  country: "",
  siteCode: "",
  siteName: "",
  linkRef: "",
  isp: "",
  underlayStatus: "",
  overlayStatus: "",
  migrationType: "",
};
const CR_MIGRATION_FILTER_OPTIONS = [
  { value: "underlay-migration", label: "Underlay eligible" },
  { value: "overlay-migration", label: "Overlay eligible" },
  { value: "combined-migration", label: "Combined eligible" },
  { value: "isp-termination", label: "ISP termination eligible" },
  { value: "other", label: "Any site/link" },
];

function crMigrationEligible(site, type) {
  if (!type) return true;
  const hasUnderlay = ((site && site.links) || []).length > 0 || ((site && site.linkRefs) || []).length > 0;
  const hasNewUnderlay = ((site && site.newUnderlayRows) || []).length > 0;
  const hasOverlay = !!((site && site.overlay) || (site && site.currentOverlayStatus));
  const hasIsp = !!((site && site.isp) || ((site && site.ispRows) || []).length);
  if (type === "underlay-migration") return hasUnderlay || hasNewUnderlay;
  if (type === "overlay-migration") return hasOverlay;
  if (type === "combined-migration") return (hasUnderlay || hasNewUnderlay) && hasOverlay;
  if (type === "isp-termination") return hasIsp;
  return true;
}

function crActiveSiteFilterCount(query, filters) {
  return (String(query || "").trim() ? 1 : 0) +
    Object.values(filters || {}).filter((value) => String(value || "").trim()).length;
}

function crDefaultDraft(user) {
  const email = (user && user.email) || "";
  return {
    status: "draft",
    type: "underlay-migration",
    siteCode: "",
    siteName: "",
    country: "",
    region: "",
    linkRefs: [],
    isp: "",
    oldTechnology: "",
    currentUnderlayStatus: "",
    currentOverlayStatus: "",
    targetDate: "",
    timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
    expectedDuration: "",
    fallbackDate: "",
    migrationWindow: "",
    impact: "",
    businessImpact: "",
    technicalImpact: "",
    risk: "",
    rollbackPlan: "",
    implementationPlan: "",
    validationPlan: "",
    blockers: [],
    stakeholdersTo: [],
    stakeholdersCc: [],
    serviceNowMailbox: "",
    requester: email,
    owner: email,
    distributionListId: "",
  };
}

function ChangeRequestModal({ cr, model, user, onClose, onSaved }) {
  const sites = useMemo(() => crSiteOptions(model), [model]);
  const [step, setStep] = useState(cr ? 5 : 0);
  const [siteQuery, setSiteQuery] = useState("");
  const [siteFilters, setSiteFilters] = useState(() => ({ ...CR_SITE_FILTER_DEFAULTS }));
  const [siteVisibleLimit, setSiteVisibleLimit] = useState(CR_SITE_PAGE_SIZE);
  const [selectedSiteKey, setSelectedSiteKey] = useState((cr && (cr.siteCode || cr.siteName)) || "");
  const [distLists, setDistLists] = useState([]);
  const [draft, setDraft] = useState(() => ({ ...crDefaultDraft(user), ...(cr || {}) }));
  const [linkText, setLinkText] = useState(crListText((cr && cr.linkRefs) || []));
  const [toText, setToText] = useState(crListText((cr && cr.stakeholdersTo) || []));
  const [ccText, setCcText] = useState(crListText((cr && cr.stakeholdersCc) || []));
  const [blockerText, setBlockerText] = useState(crBlockerText((cr && cr.blockers) || []));
  const [busy, setBusy] = useState(false);

  useEffect(() => {
    PMClient.listChangeRequestDistributionLists()
      .then((rows) => setDistLists(rows || []))
      .catch(() => setDistLists([]));
  }, []);

  useEffect(() => {
    setSiteVisibleLimit(CR_SITE_PAGE_SIZE);
  }, [
    siteQuery,
    siteFilters.region,
    siteFilters.country,
    siteFilters.siteCode,
    siteFilters.siteName,
    siteFilters.linkRef,
    siteFilters.isp,
    siteFilters.underlayStatus,
    siteFilters.overlayStatus,
    siteFilters.migrationType,
  ]);

  const currentSite = useMemo(() => {
    const key = String(selectedSiteKey || draft.siteCode || draft.siteName || "").toLowerCase();
    const code = String(draft.siteCode || "").toUpperCase();
    const name = String(draft.siteName || "").toLowerCase();
    return sites.find((s) =>
      (key && [s.siteCode, s.siteName, ...((s.linkRefs || []))].some((v) => String(v || "").toLowerCase() === key)) ||
      (code && String(s.siteCode || "").toUpperCase() === code) ||
      (name && String(s.siteName || "").toLowerCase() === name)
    ) || null;
  }, [sites, selectedSiteKey, draft.siteCode, draft.siteName]);

  const currentReadiness = useMemo(() => crReadinessSummary(currentSite, model), [currentSite, model]);
  const currentReadinessLines = useMemo(() => crReadinessContextLines(currentReadiness), [currentReadiness]);

  const linkRows = useMemo(() => {
    const rows = [];
    const seen = new Set();
    const add = (row, source) => {
      const ref = row && (row.linkRef || row.linkNum || row.circuitId);
      if (!ref || seen.has(ref)) return;
      seen.add(ref);
      rows.push({
        ref,
        source,
        isp: row.isp || row.vendor || row.provider || row.partner || "",
        status: row.circuitStatus || row.circuitDelivered || row.terminated || row.prOrderStatus || "",
        target: crDateInput(row.migrationDate || row.changeDate || row.cdd || row.planned || row.terminationDate),
        blocker: row.blocker || "",
      });
    };
    ((currentSite && currentSite.links) || []).forEach((l) => add(l, "Underlay"));
    ((currentSite && currentSite.newUnderlayRows) || []).forEach((l) => add(l, "New underlay"));
    ((currentSite && currentSite.ispRows) || []).forEach((l) => add(l, "ISP"));
    return rows;
  }, [currentSite]);

  const siteFilterOptions = useMemo(() => ({
    region: crUniqueOptions(sites, (s) => s.region),
    country: crUniqueOptions(sites, (s) => s.country),
    underlayStatus: crUniqueOptions(sites, (s) => s.currentUnderlayStatus),
    overlayStatus: crUniqueOptions(sites, (s) => s.currentOverlayStatus),
  }), [sites]);

  const filteredSites = useMemo(() => {
    const q = siteQuery.trim().toLowerCase();
    return sites.filter((site) => {
      if (q && !crSearchHaystack(site).includes(q)) return false;
      if (siteFilters.region && site.region !== siteFilters.region) return false;
      if (siteFilters.country && site.country !== siteFilters.country) return false;
      if (!crTextIncludes(site.siteCode, siteFilters.siteCode)) return false;
      if (!crTextIncludes(site.siteName || site.shortLabel, siteFilters.siteName)) return false;
      if (!crAnyTextIncludes(site.linkRefs || [], siteFilters.linkRef)) return false;
      if (!crTextIncludes(site.isp, siteFilters.isp)) return false;
      if (siteFilters.underlayStatus && site.currentUnderlayStatus !== siteFilters.underlayStatus) return false;
      if (siteFilters.overlayStatus && site.currentOverlayStatus !== siteFilters.overlayStatus) return false;
      if (!crMigrationEligible(site, siteFilters.migrationType)) return false;
      return true;
    });
  }, [sites, siteQuery, siteFilters]);

  const visibleSites = useMemo(() => filteredSites.slice(0, siteVisibleLimit), [filteredSites, siteVisibleLimit]);
  const activeSiteFilterCount = useMemo(() => crActiveSiteFilterCount(siteQuery, siteFilters), [siteQuery, siteFilters]);
  const siteWarningCounts = useMemo(() => ({
    incomplete: filteredSites.filter(crHasIncompleteData).length,
    conflicts: filteredSites.filter(crHasDuplicateConflict).length,
  }), [filteredSites]);

  const setSiteFilter = (key, value) => setSiteFilters((cur) => ({ ...cur, [key]: value }));
  const clearSiteFilters = () => {
    setSiteQuery("");
    setSiteFilters({ ...CR_SITE_FILTER_DEFAULTS });
  };

  const applySite = (site, refs) => {
    if (!site) return;
    const pickedRefs = refs || site.linkRefs || [];
    const readiness = crReadinessSummary(site, model);
    const readinessLines = crReadinessContextLines(readiness);
    const mirrorLines = ((site.newUnderlayRows || []).slice(0, 5)).map((row) => [
      row.linkRef ? `Link ${row.linkRef}` : "",
      row.changeNo ? `Change ${row.changeNo}` : "",
      crDateInput(row.changeDate) ? `Change date ${crDateInput(row.changeDate)}` : "",
      crDateInput(row.cdd) ? `CDD ${crDateInput(row.cdd)}` : "",
      row.circuitDelivered ? `Circuit delivered ${row.circuitDelivered}` : "",
    ].filter(Boolean).join(" - ")).filter(Boolean);
    const contextLines = [...readinessLines, ...mirrorLines.map((line) => `Underlay mirror: ${line}`)];
    const validationLines = [
      `Confirm New Underlay readiness remains at ${readiness.readinessPercent || 0}% or higher before the window.`,
      `Validate pre-change readiness: ${(readiness.preChange && readiness.preChange.percent) || 0}%.`,
      `Validate change management tasks: ${(readiness.changeManagement && readiness.changeManagement.percent) || 0}%.`,
      `Validate link migration gates: ${(readiness.migration && readiness.migration.percent) || 0}%.`,
    ];
    const riskLines = [];
    if ((readiness.blockedReadinessCount || 0) > 0) riskLines.push(`${readiness.blockedReadinessCount} New Underlay readiness item(s) are blocked.`);
    if (readiness.blockedPhase) riskLines.push(`Blocked phase: ${readiness.blockedPhase.label || readiness.blockedPhase.key}.`);
    if (!readiness.rowCount) riskLines.push("No New Underlay readiness row is linked to this site/link in the workbook-backed model.");
    const blockerLines = [...((site.blockers || [])), ...riskLines].filter(Boolean);
    setSelectedSiteKey(site.siteCode || site.siteName || pickedRefs[0] || "");
    setLinkText(pickedRefs.join(", "));
    if (!blockerText && blockerLines.length) setBlockerText([...new Set(blockerLines)].join("\n"));
    setDraft((cur) => ({
      ...cur,
      siteCode: site.siteCode || cur.siteCode,
      siteName: site.siteName || cur.siteName,
      country: site.country || cur.country,
      region: site.region || cur.region,
      linkRefs: pickedRefs,
      isp: site.isp || cur.isp,
      oldTechnology: site.oldTechnology || cur.oldTechnology,
      currentUnderlayStatus: site.currentUnderlayStatus || cur.currentUnderlayStatus,
      currentOverlayStatus: site.currentOverlayStatus || cur.currentOverlayStatus,
      targetDate: cur.targetDate || site.targetDate || "",
      implementationPlan: crAppendTextSection(cur.implementationPlan, "New Underlay context", contextLines),
      validationPlan: crAppendTextSection(cur.validationPlan, "Readiness validation", validationLines),
      risk: crAppendTextSection(cur.risk, "Readiness risk", riskLines),
    }));
  };

  const toggleLink = (ref) => {
    const set = new Set(crSplitList(linkText));
    if (set.has(ref)) set.delete(ref);
    else set.add(ref);
    const refs = [...set];
    setLinkText(refs.join(", "));
    setDraft((cur) => ({ ...cur, linkRefs: refs }));
  };

  const applyDistributionList = (id) => {
    const list = distLists.find((l) => l.id === id);
    setDraft((cur) => ({
      ...cur,
      distributionListId: id,
      serviceNowMailbox: (list && list.serviceNowMailbox) || cur.serviceNowMailbox,
    }));
    if (list) {
      if (list.stakeholdersTo && list.stakeholdersTo.length) setToText(list.stakeholdersTo.join(", "));
      if (list.stakeholdersCc && list.stakeholdersCc.length) setCcText(list.stakeholdersCc.join(", "));
    }
  };

  const canContinue = step !== 0 || !!(draft.siteCode || draft.siteName || crSplitList(linkText).length);
  const impact = draft.impact || [
    draft.businessImpact ? `Business impact: ${draft.businessImpact}` : "",
    draft.technicalImpact ? `Technical impact: ${draft.technicalImpact}` : "",
  ].filter(Boolean).join("\n\n");

  const save = async () => {
    setBusy(true);
    try {
      const payload = {
        ...draft,
        status: "draft",
        impact,
        linkRefs: crSplitList(linkText),
        stakeholdersTo: crSplitList(toText),
        stakeholdersCc: crSplitList(ccText),
        blockers: crSplitList(blockerText).map((description) => ({ description })),
      };
      const saved = await PMClient.upsertChangeRequest(payload);
      onSaved && onSaved(saved);
    } finally {
      setBusy(false);
    }
  };

  const stepId = CR_WIZARD_STEPS[step].id;
  const stepTitle = CR_WIZARD_STEPS[step].label;

  return (
    <div className="pm-modal-backdrop" onClick={onClose}>
      <div className="pm-modal large pm-cr-modal pm-cr-wizard" onClick={(e) => e.stopPropagation()}>
        <div className="pm-modal-h">
          <div>
            <div className="title">{cr ? "Edit change request" : "New Change Request"}</div>
            <div className="muted" style={{ fontSize: 11.5 }}>Draft a ServiceNow-ready request from workbook site and link data.</div>
          </div>
          <button className="btn ghost" onClick={onClose}><Glyph name="close" size={14}/></button>
        </div>

        <div className="pm-cr-wizard-steps">
          {CR_WIZARD_STEPS.map((s, i) => (
            <button key={s.id} className={(i === step ? "active " : "") + (i < step ? "done" : "")} onClick={() => setStep(i)}>
              <span className="mono">{i + 1}</span>
              <strong>{s.label}</strong>
            </button>
          ))}
        </div>

        <div className="pm-modal-body pm-cr-wizard-body">
          <div className="pm-cr-wizard-head">
            <div className="pm-fieldgroup-h">{stepTitle}</div>
            <div className="pm-cr-no-email">Standalone mode drafts the CR only. It does not send email or call ServiceNow.</div>
          </div>

          {stepId === "site" && (
            <div className="pm-cr-select-step">
              <div className="pm-cr-site-searchbar">
                <MCSearchBox value={siteQuery} onChange={setSiteQuery} placeholder="Search site code, site name, country, region, link ref, ISP, underlay status, overlay status"/>
                <div className="pm-cr-selector-meta">
                  <span className="mono pm-cr-result-count">{visibleSites.length} shown / {filteredSites.length} matched / {sites.length} total eligible</span>
                  <span className="pm-cr-filter-count">{activeSiteFilterCount} active filters</span>
                  {activeSiteFilterCount > 0 && <button className="btn ghost small" onClick={clearSiteFilters}>Clear</button>}
                </div>
              </div>
              <div className="pm-cr-filter-grid">
                <FilterSelect label="Region" value={siteFilters.region} onChange={(v) => setSiteFilter("region", v)} options={siteFilterOptions.region}/>
                <FilterSelect label="Country" value={siteFilters.country} onChange={(v) => setSiteFilter("country", v)} options={siteFilterOptions.country}/>
                <label className="pm-cr-filter-field">
                  <span>Site code</span>
                  <input className="pm-input" value={siteFilters.siteCode} onChange={(e) => setSiteFilter("siteCode", e.target.value)} placeholder="Any code"/>
                </label>
                <label className="pm-cr-filter-field">
                  <span>Site name</span>
                  <input className="pm-input" value={siteFilters.siteName} onChange={(e) => setSiteFilter("siteName", e.target.value)} placeholder="Any name"/>
                </label>
                <label className="pm-cr-filter-field">
                  <span>Link ref</span>
                  <input className="pm-input" value={siteFilters.linkRef} onChange={(e) => setSiteFilter("linkRef", e.target.value)} placeholder="Any link"/>
                </label>
                <label className="pm-cr-filter-field">
                  <span>ISP</span>
                  <input className="pm-input" value={siteFilters.isp} onChange={(e) => setSiteFilter("isp", e.target.value)} placeholder="Any ISP"/>
                </label>
                <FilterSelect label="Underlay status" value={siteFilters.underlayStatus} onChange={(v) => setSiteFilter("underlayStatus", v)} options={siteFilterOptions.underlayStatus}/>
                <FilterSelect label="Overlay status" value={siteFilters.overlayStatus} onChange={(v) => setSiteFilter("overlayStatus", v)} options={siteFilterOptions.overlayStatus}/>
                <FilterSelect label="Migration type" value={siteFilters.migrationType} onChange={(v) => setSiteFilter("migrationType", v)} options={CR_MIGRATION_FILTER_OPTIONS}/>
              </div>
              {(siteWarningCounts.incomplete > 0 || siteWarningCounts.conflicts > 0) && (
                <div className="pm-cr-site-warnings">
                  {siteWarningCounts.incomplete > 0 && <span className="pm-cr-site-warning warn">{siteWarningCounts.incomplete} matched options have incomplete workbook data.</span>}
                  {siteWarningCounts.conflicts > 0 && <span className="pm-cr-site-warning accent">{siteWarningCounts.conflicts} matched options have duplicate/conflict warnings.</span>}
                </div>
              )}
              {sites.length === 0 && <MCEmpty title="No workbook-backed site data loaded" sub="Reload the workbook or verify the supported Excel sheets before creating a Change Request."/>}
              {sites.length > 0 && filteredSites.length === 0 && (
                <MCEmpty title="No site found" sub="Adjust search or filters. Valid workbook-backed rows are not silently hidden."/>
              )}
              {sites.length > 0 && filteredSites.length > 0 && (
              <div className="pm-cr-site-picker">
                <div className="pm-cr-site-results">
                  {visibleSites.map((site, idx) => {
                    const siteKey = String(site.siteCode || site.siteName || (site.linkRefs && site.linkRefs[0]) || "").toLowerCase();
                    const active = !!selectedSiteKey && (selectedSiteKey || "").toLowerCase() === siteKey;
                    const flags = [
                      crHasIncompleteData(site) ? "Incomplete workbook data" : "",
                      crHasDuplicateConflict(site) ? "Duplicate/conflict" : "",
                    ].filter(Boolean);
                    return (
                      <button key={site.siteCode || site.siteName || (site.linkRefs && site.linkRefs[0]) || idx} className={"pm-cr-site-option" + (active ? " active" : "")} onClick={() => applySite(site)}>
                        <div>
                          <strong className="mono">{site.siteCode || (site.linkRefs && site.linkRefs[0]) || "No code"}</strong>
                          <span>{site.siteName || site.shortLabel || "Link-only record"}</span>
                        </div>
                        <div className="pm-cr-site-option-meta">
                          <span>{site.country || "Unknown country"}</span>
                          <span>{site.region || "No region"}</span>
                          <span>{site.currentUnderlayStatus || "No underlay status"}</span>
                          <span>{site.currentOverlayStatus || "No overlay status"}</span>
                        </div>
                        <div className="pm-cr-site-readiness" data-cr-readiness-option="true">
                          <span className="mono">NU readiness {site.newUnderlayReadinessPct || 0}%</span>
                          <span>{site.newUnderlayRowsCount || 0} row(s)</span>
                          <span>{site.newUnderlayChangeManagement || "0% change management"}</span>
                          <span>{site.newUnderlayMigration || "0% link migration"}</span>
                          {site.newUnderlayBlockedPhase && <span className="risk">Blocked: {site.newUnderlayBlockedPhase}</span>}
                        </div>
                        {(site.newUnderlayHints || []).length > 0 && (
                          <div className="pm-cr-site-readiness-meta">
                            {(site.newUnderlayHints || []).map((hint) => (
                              <span key={hint.key || hint.label} className={"pm-tag " + (hint.tone || "neutral")}>{hint.label || hint.key}</span>
                            ))}
                          </div>
                        )}
                        {flags.length > 0 && <div className="pm-cr-site-flags">{flags.map((flag) => <span key={flag} className="pm-cr-site-flag">{flag}</span>)}</div>}
                      </button>
                    );
                  })}
                  {filteredSites.length > visibleSites.length && (
                    <button className="btn ghost pm-cr-load-more" onClick={() => setSiteVisibleLimit((limit) => Math.min(limit + CR_SITE_PAGE_SIZE, filteredSites.length))}>
                      Load more ({filteredSites.length - visibleSites.length} remaining)
                    </button>
                  )}
                </div>
                <div className="pm-cr-selected-site">
                  <div className="pm-cr-selected-title">{draft.siteCode || draft.siteName || (crSplitList(linkText)[0] ? `Link ${crSplitList(linkText)[0]}` : "Select a site")}</div>
                  <KVGrid items={[
                    { k: "Site", v: draft.siteName },
                    { k: "Country", v: draft.country },
                    { k: "Region", v: draft.region },
                    { k: "ISP", v: draft.isp },
                    { k: "Underlay", v: draft.currentUnderlayStatus },
                    { k: "Overlay", v: draft.currentOverlayStatus },
                    { k: "Suggested date", v: draft.targetDate },
                  ]}/>
                  {currentReadiness && currentReadiness.rowCount > 0 && (
                    <div className="pm-cr-readiness-card" data-cr-readiness-selected="true">
                      <div className="pm-plan-readiness-head">
                        <strong>New Underlay context</strong>
                        <span className="mono">{currentReadiness.readinessPercent || 0}%</span>
                      </div>
                      <div className="pm-plan-readiness-grid">
                        <span>Pre-change <strong className="mono">{(currentReadiness.preChange && currentReadiness.preChange.percent) || 0}%</strong></span>
                        <span>Change management <strong className="mono">{(currentReadiness.changeManagement && currentReadiness.changeManagement.percent) || 0}%</strong></span>
                        <span>Link migration <strong className="mono">{(currentReadiness.migration && currentReadiness.migration.percent) || 0}%</strong></span>
                        <span>Post migration <strong className="mono">{(currentReadiness.postMigration && currentReadiness.postMigration.percent) || 0}%</strong></span>
                      </div>
                      <div className="pm-cr-site-readiness-meta">
                        {(currentReadiness.hints || []).map((hint) => <span key={hint.key || hint.label} className={"pm-tag " + (hint.tone || "neutral")}>{hint.label || hint.key}</span>)}
                      </div>
                    </div>
                  )}
                  <div className="pm-cr-link-checks">
                    <div className="pm-field-label">Linked links</div>
                    {linkRows.length === 0 && <MCEmpty title="No links resolved" sub="The CR can still be saved against the selected site."/>}
                    {linkRows.map((l) => {
                      const checked = crSplitList(linkText).includes(l.ref);
                      return (
                        <label key={l.ref} className={"pm-cr-link-check" + (checked ? " active" : "")}>
                          <input type="checkbox" checked={checked} onChange={() => toggleLink(l.ref)}/>
                          <span className="mono strong">{l.ref}</span>
                          <span>{l.source}</span>
                          <span>{l.isp || "No ISP"}</span>
                          <span>{l.status || "No status"}</span>
                        </label>
                      );
                    })}
                  </div>
                </div>
              </div>
              )}
            </div>
          )}

          {stepId === "scope" && (
            <div className="pm-cr-scope-grid">
              {CR_SCOPE_OPTIONS.map((opt) => (
                <button key={opt.type} className={"pm-cr-scope-card" + (draft.type === opt.type ? " active" : "")} onClick={() => setDraft({ ...draft, type: opt.type })}>
                  <CrTypePill type={opt.type}/>
                  <strong>{opt.title}</strong>
                  <span>{opt.detail}</span>
                </button>
              ))}
              <div className="pm-field pm-cr-scope-notes">
                <label className="pm-field-label">Link references</label>
                <input className="pm-input" value={linkText} onChange={(e) => setLinkText(e.target.value)} placeholder="Comma-separated link refs"/>
              </div>
              <div className="pm-field">
                <label className="pm-field-label">Old technology</label>
                <input className="pm-input" value={draft.oldTechnology || ""} onChange={(e) => setDraft({ ...draft, oldTechnology: e.target.value })} placeholder="MPLS, legacy ISP, internet, other"/>
              </div>
            </div>
          )}

          {stepId === "schedule" && (
            <div className="grid g-2" style={{ gap: 12 }}>
              <CrField label="Target date"><input className="pm-input" type="date" value={draft.targetDate || ""} onChange={(e) => setDraft({ ...draft, targetDate: e.target.value })}/></CrField>
              <CrField label="Fallback date"><input className="pm-input" type="date" value={draft.fallbackDate || ""} onChange={(e) => setDraft({ ...draft, fallbackDate: e.target.value })}/></CrField>
              <CrField label="Migration window"><input className="pm-input" value={draft.migrationWindow || ""} onChange={(e) => setDraft({ ...draft, migrationWindow: e.target.value })} placeholder="Sat 02:00-04:00"/></CrField>
              <CrField label="Timezone"><input className="pm-input" value={draft.timezone || ""} onChange={(e) => setDraft({ ...draft, timezone: e.target.value })} placeholder="Europe/Paris, UTC"/></CrField>
              <CrField label="Expected duration"><input className="pm-input" value={draft.expectedDuration || ""} onChange={(e) => setDraft({ ...draft, expectedDuration: e.target.value })} placeholder="2h, 4h, full maintenance window"/></CrField>
              <div className="pm-cr-context-card">
                <div className="pm-field-label">Workbook target signals</div>
                <div>Suggested date: <span className="mono">{(currentSite && currentSite.targetDate) || "None"}</span></div>
                <div>Underlay: {draft.currentUnderlayStatus || "No status"}</div>
                <div>Overlay: {draft.currentOverlayStatus || "No status"}</div>
                <div>New Underlay readiness: <span className="mono">{(currentReadiness && currentReadiness.readinessPercent) || 0}%</span></div>
              </div>
            </div>
          )}

          {stepId === "risk" && (
            <div className="pm-cr-risk-grid">
              <CrField label="Business impact"><textarea className="pm-input pm-textarea" rows={3} value={draft.businessImpact || ""} onChange={(e) => setDraft({ ...draft, businessImpact: e.target.value })}/></CrField>
              <CrField label="Technical impact"><textarea className="pm-input pm-textarea" rows={3} value={draft.technicalImpact || ""} onChange={(e) => setDraft({ ...draft, technicalImpact: e.target.value })}/></CrField>
              <CrField label="Implementation plan"><textarea className="pm-input pm-textarea" rows={4} value={draft.implementationPlan || ""} onChange={(e) => setDraft({ ...draft, implementationPlan: e.target.value })}/></CrField>
              <CrField label="Validation plan"><textarea className="pm-input pm-textarea" rows={4} value={draft.validationPlan || ""} onChange={(e) => setDraft({ ...draft, validationPlan: e.target.value })}/></CrField>
              <CrField label="Rollback plan"><textarea className="pm-input pm-textarea" rows={4} value={draft.rollbackPlan || ""} onChange={(e) => setDraft({ ...draft, rollbackPlan: e.target.value })}/></CrField>
              <CrField label="Known blockers"><textarea className="pm-input pm-textarea" rows={4} value={blockerText} onChange={(e) => setBlockerText(e.target.value)} placeholder="One blocker per line"/></CrField>
              <CrField label="Risk notes"><textarea className="pm-input pm-textarea" rows={3} value={draft.risk || ""} onChange={(e) => setDraft({ ...draft, risk: e.target.value })}/></CrField>
            </div>
          )}

          {stepId === "stakeholders" && (
            <div className="grid g-2" style={{ gap: 12 }}>
              <CrField label="Distribution list template">
                <select className="pm-input" value={draft.distributionListId || ""} onChange={(e) => applyDistributionList(e.target.value)}>
                  <option value="">No template</option>
                  {distLists.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
                </select>
              </CrField>
              <CrField label="ServiceNow mailbox"><input className="pm-input" value={draft.serviceNowMailbox || ""} onChange={(e) => setDraft({ ...draft, serviceNowMailbox: e.target.value })}/></CrField>
              <CrField label="Requester"><input className="pm-input" value={draft.requester || ""} onChange={(e) => setDraft({ ...draft, requester: e.target.value })}/></CrField>
              <CrField label="Owner"><input className="pm-input" value={draft.owner || ""} onChange={(e) => setDraft({ ...draft, owner: e.target.value })}/></CrField>
              <CrField label="To"><textarea className="pm-input pm-textarea" rows={3} value={toText} onChange={(e) => setToText(e.target.value)} placeholder="Comma, semicolon or line-separated emails"/></CrField>
              <CrField label="CC"><textarea className="pm-input pm-textarea" rows={3} value={ccText} onChange={(e) => setCcText(e.target.value)} placeholder="Comma, semicolon or line-separated emails"/></CrField>
            </div>
          )}

          {stepId === "review" && (
            <div className="pm-cr-review">
              <div className="pm-cr-review-card">
                <div className="pm-fieldgroup-h">Request summary</div>
                <KVGrid items={[
                  { k: "Site", v: `${draft.siteCode || "No code"} - ${draft.siteName || "No site name"}` },
                  { k: "Country / region", v: [draft.country, draft.region].filter(Boolean).join(" / ") },
                  { k: "Type", v: <CrTypePill type={draft.type}/> },
                  { k: "Links", v: crSplitList(linkText).join(", ") || "No linked links" },
                  { k: "Target", v: draft.targetDate || "No target date" },
                  { k: "Window", v: [draft.migrationWindow, draft.timezone, draft.expectedDuration].filter(Boolean).join(" - ") },
                  { k: "Fallback", v: draft.fallbackDate || "None" },
                  { k: "ServiceNow", v: draft.serviceNowMailbox || "No mailbox configured" },
                  { k: "Requester / owner", v: [draft.requester, draft.owner].filter(Boolean).join(" / ") || "Not assigned" },
                ]}/>
              </div>
              {currentReadiness && currentReadiness.rowCount > 0 && (
                <div className="pm-cr-review-card pm-cr-readiness-card" data-cr-readiness-review="true">
                  <div className="pm-fieldgroup-h">New Underlay context</div>
                  <KVGrid items={[
                    { k: "Readiness", v: `${currentReadiness.readinessPercent || 0}% across ${currentReadiness.rowCount || 0} row(s)` },
                    { k: "Pre-change", v: `${(currentReadiness.preChange && currentReadiness.preChange.percent) || 0}%` },
                    { k: "Change management", v: `${(currentReadiness.changeManagement && currentReadiness.changeManagement.percent) || 0}%` },
                    { k: "Link migration", v: `${(currentReadiness.migration && currentReadiness.migration.percent) || 0}%` },
                    { k: "Blocked phase", v: currentReadiness.blockedPhase ? (currentReadiness.blockedPhase.label || currentReadiness.blockedPhase.key) : "None" },
                  ]}/>
                  <div className="pm-cr-site-readiness-meta">
                    {(currentReadiness.hints || []).map((hint) => <span key={hint.key || hint.label} className={"pm-tag " + (hint.tone || "neutral")}>{hint.label || hint.key}</span>)}
                  </div>
                </div>
              )}
              <CrReviewText label="Business impact" value={draft.businessImpact}/>
              <CrReviewText label="Technical impact" value={draft.technicalImpact}/>
              <CrReviewText label="Risk" value={draft.risk}/>
              <CrReviewText label="Implementation" value={draft.implementationPlan}/>
              <CrReviewText label="Validation" value={draft.validationPlan}/>
              <CrReviewText label="Rollback" value={draft.rollbackPlan}/>
              <CrReviewText label="Known blockers" value={blockerText}/>
              <div className="pm-cr-review-card">
                <div className="pm-fieldgroup-h">Stakeholders</div>
                <CrEmailList label="To" values={crSplitList(toText)}/>
                <CrEmailList label="Cc" values={crSplitList(ccText)}/>
              </div>
            </div>
          )}
        </div>

        <div className="pm-modal-actions">
          <span className="muted" style={{ fontSize: 11.5 }}>
            Draft only - no email or ServiceNow API call in standalone.
          </span>
          <span style={{ flex: 1 }}/>
          <button className="btn" onClick={onClose}>Cancel</button>
          <button className="btn" disabled={step === 0} onClick={() => setStep(Math.max(0, step - 1))}>Back</button>
          {step < CR_WIZARD_STEPS.length - 1 ? (
            <button className="btn primary" disabled={!canContinue} onClick={() => setStep(Math.min(CR_WIZARD_STEPS.length - 1, step + 1))}>Next</button>
          ) : (
            <button className="btn primary" disabled={busy || !canContinue} onClick={save}>
              {busy ? "Saving..." : "Save as draft"}
            </button>
          )}
        </div>
      </div>
    </div>
  );
}

function CrField({ label, children }) {
  return (
    <div className="pm-field">
      <label className="pm-field-label">{label}</label>
      {children}
    </div>
  );
}
function CrReviewText({ label, value }) {
  if (!String(value || "").trim()) return null;
  return (
    <div className="pm-cr-review-card">
      <div className="pm-fieldgroup-h">{label}</div>
      <div className="pm-cr-long-body">{renderMarkdownSubset(value)}</div>
    </div>
  );
}

/* =============================================================
   PmCommentsRail — universal threaded comments
   ============================================================= */
function PmCommentsRail({ entity, idRef, highlightCommentId, compact }) {
  const [comments, setComments] = useState([]);
  const [user, setUser] = useState({ email: "", displayName: "" });
  const [users, setUsers] = useState([]);
  const [body, setBody] = useState("");
  const [replyTo, setReplyTo] = useState(null);
  const [busy, setBusy] = useState(false);
  const [flashId, setFlashId] = useState(null);
  const listRef = useRef(null);

  const reload = useCallback(async () => {
    if (!entity || !idRef) { setComments([]); return; }
    const cs = await PMClient.listComments(entity, idRef);
    setComments(cs);
  }, [entity, idRef]);

  useEffect(() => {
    reload();
    PMClient.getUser().then(setUser);
    const loadUsers = () => PMClient.listUsers().then((u) => setUsers(u || [])).catch(() => {});
    loadUsers();
    const unsub = PMClient.subscribe(() => { reload(); loadUsers(); });
    return unsub;
  }, [reload]);

  // Known handles drive which @mentions render as live mentions vs plain text
  // (GA-10): a tag only highlights when it resolves to a real, active user.
  const knownHandles = useMemo(() => {
    const set = new Set();
    for (const u of (users || [])) {
      if (u.isActive === false) continue;
      if (u.email) set.add(canonMentionHandle(u.email));
      if (u.displayName) set.add(canonMentionHandle(u.displayName));
    }
    return set;
  }, [users]);

  // GA-11: when a notification deep-links to a comment, scroll it into view and
  // flash it once the thread has loaded. The flash auto-clears; missing/stale
  // ids are a no-op (the deep-link still lands on the right thread).
  useEffect(() => {
    if (!highlightCommentId || !comments.length) { setFlashId(null); return; }
    if (!comments.some((c) => c.id === highlightCommentId)) return;
    setFlashId(highlightCommentId);
    const scrollT = setTimeout(() => {
      const root = listRef.current;
      const el = root && root.querySelector('[data-comment-id="' + highlightCommentId + '"]');
      if (el && el.scrollIntoView) { try { el.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (_) { el.scrollIntoView(); } }
    }, 60);
    const clearT = setTimeout(() => setFlashId(null), 2800);
    return () => { clearTimeout(scrollT); clearTimeout(clearT); };
  }, [highlightCommentId, comments]);

  if (!entity || !idRef) {
    return (
      <div className={"pm-comments" + (compact ? " compact" : "")}>
        <div className="pm-section-h"><div className="title">Discussion</div></div>
        <Empty title="No entity selected" sub="Click a row in the editor to start a thread"/>
      </div>
    );
  }

  const tree = buildCommentTree(comments);

  const post = async () => {
    if (!body.trim()) return;
    setBusy(true);
    try {
      await PMClient.addComment(entity, idRef, body, replyTo);
      setBody("");
      setReplyTo(null);
      await reload();
    } finally {
      setBusy(false);
    }
  };

  const del = async (id) => {
    if (!confirm("Delete this comment?")) return;
    await PMClient.deleteComment(id);
    await reload();
  };

  return (
    <div className={"pm-comments" + (compact ? " compact" : "")}>
      <div className="pm-section-h">
        <div className="title">Discussion · {entity} {idRef}</div>
        <span className="mono pm-comment-count">{comments.length}</span>
      </div>

      <div className="pm-comment-list" ref={listRef}>
        {tree.length === 0 && <Empty title="No comments yet" sub="Be the first to add a note"/>}
        {tree.map((c) => (
          <CommentNode key={c.id} c={c} user={user} knownHandles={knownHandles} highlightId={flashId} onReply={(id) => setReplyTo(id)} onDelete={del}/>
        ))}
      </div>

      <div className="pm-composer">
        {/* GA-18: discussion is an edit surface — show the shared storage-mode pill so
            users know a posted comment is local-only until a backend is configured. */}
        <div className="pm-nu-modebar"><StorageModePill compact/></div>
        {knownHandles.size === 0 && (
          <div className="pm-mention-empty" data-mention-empty>
            No teammates registered yet —{" "}
            <a href="#pm" className="pm-mention-empty-link" aria-label="Go to Project Management to add team members">Project Management → Team</a>
            {" "}so @mentions can notify the right person.
          </div>
        )}
        {replyTo && (
          <div className="pm-reply-banner">
            Replying — <button className="btn ghost" onClick={() => setReplyTo(null)}>cancel</button>
          </div>
        )}
        {typeof MentionTextarea === "function" ? (
          <MentionTextarea
            placeholder="Add a note · type @ to mention a teammate · Markdown subset"
            value={body}
            onChange={setBody}
            onKeyDown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === "Enter") post(); }}
          />
        ) : (
          <textarea
            placeholder="Add a note · @mention supported · Markdown subset"
            value={body}
            onChange={(e) => setBody(e.target.value)}
            onKeyDown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === "Enter") post(); }}
          />
        )}
        <div className="pm-composer-actions">
          <div className="muted" style={{ fontSize: 11 }}>Posting as {user.displayName || user.email || "local"} · ⌘/Ctrl+Enter to send</div>
          <button className="btn primary" disabled={busy || !body.trim()} onClick={post}>
            <Glyph name="send" size={12}/> Post
          </button>
        </div>
      </div>
    </div>
  );
}

function buildCommentTree(comments) {
  const byId = new Map(comments.map((c) => [c.id, { ...c, children: [] }]));
  const roots = [];
  for (const c of byId.values()) {
    if (c.parentId && byId.has(c.parentId)) byId.get(c.parentId).children.push(c);
    else roots.push(c);
  }
  return roots.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}

function CommentNode({ c, user, onReply, onDelete, depth = 0, knownHandles, highlightId }) {
  const mine = user && user.email && c.author === user.email;
  return (
    <div className={"pm-comment" + (highlightId === c.id ? " pm-comment-flash" : "")} data-comment-id={c.id} style={{ marginLeft: depth * 14 }}>
      <div className="pm-comment-h">
        <MCAvatar name={c.authorName} email={c.author} size={22}/>
        <span className="pm-comment-author">{c.authorName || c.author}</span>
        <span className="pm-comment-time">{relTime(c.createdAt)}</span>
        <span style={{ flex: 1 }}/>
        <button className="btn ghost pm-comment-act" onClick={() => onReply(c.id)}>Reply</button>
        {mine && <button className="btn ghost pm-comment-act danger" onClick={() => onDelete(c.id)}>Delete</button>}
      </div>
      <div className="pm-comment-body">{renderMarkdownSubset(c.body, knownHandles)}</div>
      {c.children && c.children.length > 0 && (
        <div className="pm-comment-thread">
          {c.children.map((child) => (
            <CommentNode key={child.id} c={child} user={user} knownHandles={knownHandles} highlightId={highlightId} onReply={onReply} onDelete={onDelete} depth={depth + 1}/>
          ))}
        </div>
      )}
    </div>
  );
}

/* =============================================================
   PmSyncMenu — export/import buttons
   ============================================================= */
function PmSyncMenu({ workbookBytesRef, onClose, onImported }) {
  const [busy, setBusy] = useState(false);
  const [importMode, setImportMode] = useState("merge");
  const [pendingCrExport, setPendingCrExport] = useState(0);
  const fileRef = useRef(null);

  useEffect(() => {
    PMClient.listChangeRequests ? PMClient.listChangeRequests({}).then((rows) => {
      setPendingCrExport((rows || []).filter((cr) => cr.writeBackPendingExport).length);
    }).catch(() => setPendingCrExport(0)) : setPendingCrExport(0);
  }, []);

  const dlBlob = (blob, name) => {
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = name;
    a.click();
    setTimeout(() => URL.revokeObjectURL(a.href), 5000);
  };

  const downloadWorkbook = async () => {
    setBusy(true);
    try {
      const blob = await PMClient.exportWorkbookXlsx(workbookBytesRef && workbookBytesRef.current);
      dlBlob(blob, "Master Deployment Tracker (updated).xlsx");
    } catch (e) {
      alert("Export failed: " + e.message);
    } finally {
      setBusy(false);
    }
  };

  const downloadSnapshot = async () => {
    const snap = await PMClient.exportSnapshot();
    const blob = new Blob([JSON.stringify(snap, null, 2)], { type: "application/json" });
    dlBlob(blob, `1net-snapshot-${new Date().toISOString().slice(0, 10)}.json`);
  };

  const downloadChangelog = async () => {
    const ovr = await window.PMStore.overrides.list();
    const act = await window.PMStore.activity.list({ limit: 5000 });
    const rows = [["timestamp","actor","kind","entity","idRef","field","before","after","snippet"]];
    for (const a of act) {
      rows.push([
        a.createdAt, a.actor || "", a.kind || "",
        a.entity || "", a.idRef || "", a.field || "",
        stringify(a.before), stringify(a.after), a.snippet || ""
      ]);
    }
    const csv = rows.map((r) => r.map((c) => '"' + String(c).replace(/"/g, '""') + '"').join(",")).join("\n");
    dlBlob(new Blob([csv], { type: "text/csv" }), `1net-changelog-${new Date().toISOString().slice(0, 10)}.csv`);
  };

  const onImportFile = async (file) => {
    if (!file) return;
    try {
      const text = await file.text();
      const snap = JSON.parse(text);
      await PMClient.importSnapshot(snap, { merge: importMode === "merge" });
      onImported && onImported();
      onClose();
    } catch (e) {
      alert("Import failed: " + e.message);
    }
  };

  return (
    <div className="pm-modal-backdrop" onClick={onClose}>
      <div className="pm-modal" onClick={(e) => e.stopPropagation()}>
        <div className="pm-modal-h">
          <div className="title">Sync & export</div>
          <button className="btn ghost" onClick={onClose}><Glyph name="close" size={14}/></button>
        </div>
        <div className="pm-modal-body" style={{ display: "flex", flexDirection: "column", gap: 12 }}>
          <button className="btn primary" disabled={busy} onClick={downloadWorkbook}>
            <Glyph name="file" size={13}/> Download updated workbook (.xlsx)
          </button>
          {pendingCrExport > 0 && (
            <div className="pm-cr-export-state">
              <strong>{pendingCrExport} CR write-back{pendingCrExport === 1 ? "" : "s"} pending workbook export</strong>
              <span>Local PMStore overrides are active in the portal now. Downloading the updated workbook writes those override values into a new Excel file.</span>
            </div>
          )}
          <button className="btn" onClick={downloadSnapshot}>
            <Glyph name="ext" size={13}/> Export snapshot (.json)
          </button>
          <button className="btn" onClick={downloadChangelog}>
            <Glyph name="ext" size={13}/> Export change log (.csv)
          </button>
          <div className="pm-divider"/>
          <div>
            <label className="pm-field-label">Import snapshot (.json)</label>
            <div className="flex gap-sm">
              <select className="pm-input" value={importMode} onChange={(e) => setImportMode(e.target.value)} style={{ width: 140 }}>
                <option value="merge">Merge</option>
                <option value="replace">Replace</option>
              </select>
              <button className="btn" onClick={() => fileRef.current && fileRef.current.click()}>Choose file…</button>
              <input ref={fileRef} type="file" accept=".json" style={{ display: "none" }}
                onChange={(e) => onImportFile(e.target.files[0])}/>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

/* =============================================================
   PageProjectManagement — page entry point
   ============================================================= */
function mcResolveFocusRecord(model, focus) {
  if (!focus) return null;
  if (focus.data) return focus.data;
  const entity = focus.entity;
  const id = focus.idRef;
  if (!model || !id) return null;
  const pool = entity === "overlay" ? model.overlay :
               entity === "underlay" ? model.underlay :
               entity === "isp" ? model.isp :
               entity === "newUnderlay" ? model.newUnderlay :
               [];
  return (pool || []).find((r) =>
    r.siteCode === id || r.linkRef === id || r.siteName === id || r.id === id
  ) || null;
}

function MCInspectorRail({ model, focus, setFocus, tasks, plans, activity }) {
  const record = mcResolveFocusRecord(model, focus);
  const upcoming = useMemo(() => PRData.upcomingEvents(model, 21).slice(0, 5), [model]);
  const openTasks = useMemo(() => (tasks || []).filter((t) => t.status !== "done").slice(0, 5), [tasks]);
  const relatedTasks = useMemo(() => {
    if (!record) return [];
    const siteCode = record.siteCode;
    const linkRef = record.linkRef;
    return (tasks || []).filter((t) =>
      (siteCode && t.linkedSiteId === siteCode) || (linkRef && t.linkedLinkRef === linkRef)
    ).slice(0, 5);
  }, [record, tasks]);
  const relatedPlans = useMemo(() => {
    if (!record) return [];
    const siteCode = record.siteCode;
    return (plans || []).filter((p) => siteCode && p.siteCode === siteCode).slice(0, 5);
  }, [record, plans]);
  const relatedActivity = useMemo(() => {
    if (!focus) return [];
    return (activity || []).filter((a) => a.entity === focus.entity && a.idRef === focus.idRef).slice(0, 5);
  }, [activity, focus]);

  if (!focus || !record) {
    return (
      <div className="mc-insp idle">
        <div className="mc-insp-idle-h">
          <div className="mc-insp-idle-mark">PM</div>
          <div>
            <div className="mc-insp-idle-title">Mission Control inspector</div>
            <div className="mc-insp-idle-sub">Select a row, plan, or activity item to inspect and discuss it.</div>
          </div>
        </div>
        <div className="mc-insp-section-h">Upcoming milestones</div>
        <div className="mc-insp-up">
          {upcoming.length === 0 && <MCEmpty title="No upcoming milestones" sub="The current workbook has no dated events in this window."/>}
          {upcoming.map((u, i) => (
            <div key={i} className="mc-insp-up-row">
              <MCStatusDot tone={u.kind === "isp-term" ? "warn" : u.kind === "migration" ? "accent" : "info"}/>
              <div className="mc-insp-up-body">
                <div className="mc-insp-up-label">{u.title}</div>
                <div className="mc-insp-up-sub">{u.country || "Global"} · {u.region || "All regions"}</div>
              </div>
              <div className="mc-insp-up-date">{PRData.fmtDate(u.date)}</div>
            </div>
          ))}
        </div>
        <div className="mc-insp-section-h">Open tasks</div>
        <div className="mc-insp-tasks">
          {openTasks.length === 0 && <MCEmpty title="No open tasks" sub="Planner and task store are clear."/>}
          {openTasks.map((t) => (
            <div key={t.id} className="mc-insp-task">
              <span className="mc-task-pri" style={{ background: t.priority === "critical" ? "var(--risk)" : t.priority === "high" ? "var(--warn)" : "var(--info)" }}/>
              <span className="mc-insp-task-title">{t.title}</span>
              <span className="mc-insp-task-due">{t.dueDate || ""}</span>
            </div>
          ))}
        </div>
      </div>
    );
  }

  const title = record.siteCode || record.linkRef || record.siteName || focus.idRef;
  const subtitle = [record.siteName, record.country, record.region].filter(Boolean).join(" · ");
  const fields = [
    ["Entity", focus.entity],
    ["Country", record.country],
    ["Region", record.region],
    ["Status", record.migrationStatus || record.circuitStatus || record.terminated || record.hwDelivery],
    ["Owner", record.owner || record.lead],
  ];

  return (
    <div className="mc-insp">
      <div className="mc-insp-h">
        <div className="mc-insp-h-top">
          <div className="mc-insp-kind">{focus.kind || focus.entity}</div>
          <button className="mc-insp-close" onClick={() => setFocus(null)} title="Close inspector">×</button>
        </div>
        <div className="mc-insp-title">{title}</div>
        {subtitle && <div className="mc-insp-sub">{subtitle}</div>}
      </div>
      <div className="mc-insp-tabs">
        <button className="mc-insp-tab active">Context</button>
        <button className="mc-insp-tab">Discussion</button>
      </div>
      <div className="mc-insp-body">
        <div className="mc-insp-group">
          <div className="mc-insp-group-h">Record</div>
          <div className="mc-insp-group-body">
            {fields.map(([label, value]) => (
              <div key={label} className="mc-insp-field">
                <div className="mc-insp-field-label">{label}</div>
                <div className="mc-insp-field-value">{value || <span className="muted">—</span>}</div>
              </div>
            ))}
          </div>
        </div>
        {(relatedTasks.length > 0 || relatedPlans.length > 0) && (
          <div className="mc-insp-group">
            <div className="mc-insp-group-h">Linked work</div>
            <div className="mc-insp-tasks">
              {relatedPlans.map((p) => (
                <div key={p.id} className="mc-insp-task">
                  <span className="mc-task-pri" style={{ background: "var(--accent)" }}/>
                  <span className="mc-insp-task-title">{p.siteCode || p.siteName || p.type}</span>
                  <span className="mc-insp-task-due">{p.targetDate || p.targetWeek || ""}</span>
                </div>
              ))}
              {relatedTasks.map((t) => (
                <div key={t.id} className="mc-insp-task">
                  <span className="mc-task-pri" style={{ background: t.priority === "critical" ? "var(--risk)" : t.priority === "high" ? "var(--warn)" : "var(--info)" }}/>
                  <span className="mc-insp-task-title">{t.title}</span>
                  <span className="mc-insp-task-due">{t.dueDate || ""}</span>
                </div>
              ))}
            </div>
          </div>
        )}
        {relatedActivity.length > 0 && (
          <div className="mc-insp-group">
            <div className="mc-insp-group-h">Recent history</div>
            <div className="mc-insp-history">
              <div className="mc-insp-hist-spine"/>
              {relatedActivity.map((a) => (
                <div key={a.id} className="mc-insp-hist-row">
                  <span className="mc-insp-hist-dot tone-info"/>
                  <MCAvatar name={a.actorName} email={a.actor} size={20}/>
                  <div className="mc-insp-hist-body">
                    <div className="mc-insp-hist-line">
                      <span className="mc-insp-hist-actor">{a.actorName || a.actor || "local"}</span>
                      <span className="mc-event-time">{relTime(a.createdAt)}</span>
                    </div>
                    <div className="mc-insp-hist-detail">{a.kind}{a.field ? ` · ${a.field}` : ""}</div>
                  </div>
                </div>
              ))}
            </div>
          </div>
        )}
        <div className="mc-insp-discuss">
          <PmCommentsRail entity={focus.entity} idRef={focus.idRef} compact/>
        </div>
      </div>
    </div>
  );
}

function PageProjectManagement({ model, role, user, workbookBytesRef, onModelDirty, consumePmFocus }) {
  const [tab, setTab] = useState("editor");
  const [focus, setFocus] = useState(null);

  // PROP-12 + fix: consume any pending focus payload set by openInPM() in app.jsx.
  // The payload is one-shot — it is cleared by consumePmFocus(). We must also
  // resolve the actual data row from the effective model so that EntityEditor
  // gets a real `record` (otherwise it crashes on record.siteCode).
  useEffect(() => {
    if (typeof consumePmFocus !== "function") return;
    const payload = consumePmFocus();
    if (!payload) return;
    const idRef = payload.idRef;
    const idLow = String(idRef || "").toLowerCase();
    const kind = payload.entity === "underlay" ? "underlay"
               : payload.entity === "isp" ? "isp"
               : payload.entity === "newUnderlay" ? "newunderlay"
               : "sites";
    // Resolve the actual row. Match by primary id then by name fallback.
    let data = null;
    if (model) {
      if (payload.entity === "underlay") {
        data = (model.underlay || []).find((r) => r.linkRef === idRef)
            || (model.underlay || []).find((r) => r.linkNum === idRef)
            || (model.underlay || []).find((r) => (r.siteName || "").toLowerCase() === idLow);
      } else if (payload.entity === "isp") {
        data = (model.isp || []).find((r) => r.linkRef === idRef)
            || (model.isp || []).find((r) => r.circuitId === idRef)
            || (model.isp || []).find((r) => (r.siteName || "").toLowerCase() === idLow);
      } else if (payload.entity === "newUnderlay") {
        data = (model.newUnderlay || []).find((r) => r.linkRef === idRef)
            || (model.newUnderlay || []).find((r) => r.siteCode === idRef)
            || (model.newUnderlay || []).find((r) => (r.siteName || "").toLowerCase() === idLow);
      } else {
        // overlay / site → look in canonical sites first, then the overlay tracker rows
        const sites = (model.canonicalSites && model.canonicalSites.length ? model.canonicalSites : model.sites) || [];
        data = sites.find((r) => r.siteCode === idRef)
            || sites.find((r) => (r.siteName || "").toLowerCase() === idLow);
        if (!data) {
          const ov = (model.overlay || []).find((r) => r.siteCode === idRef)
                  || (model.overlay || []).find((r) => (r.siteName || "").toLowerCase() === idLow);
          if (ov) data = ov;
        }
      }
    }
    setTab("editor");
    // GA-11: carry the notification's Discussion-tab request + comment id so the
    // EntityEditor opens the right tab and highlights the referenced comment.
    setFocus({ entity: payload.entity, idRef, kind, data: data || null, tab: payload.tab, commentId: payload.commentId });
  }, [consumePmFocus, model]);
  const [tasks, setTasks] = useState([]);
  const [plans, setPlans] = useState([]);
  const [changeRequests, setChangeRequests] = useState([]);
  const [comments, setComments] = useState([]);
  const [activity, setActivity] = useState([]);
  const [syncOpen, setSyncOpen] = useState(false);
  const asClientList = (value) => Array.isArray(value) ? value : (value && Array.isArray(value.items) ? value.items : []);
  const reloadStores = useCallback(async () => {
    try {
      const [t, a, p, cr, cm] = await Promise.all([
        PMClient.listTasks().catch(() => []),
        PMClient.activityFeed(500).catch(() => []),
        (PMClient.listPlans ? PMClient.listPlans({}) : Promise.resolve([])).catch(() => []),
        (PMClient.listChangeRequests ? PMClient.listChangeRequests({}) : Promise.resolve([])).catch(() => []),
        (PMClient.listComments ? PMClient.listComments().catch(() => []) : Promise.resolve([])),
      ]);
      setTasks(asClientList(t));
      setActivity(asClientList(a));
      setPlans(asClientList(p));
      setChangeRequests(asClientList(cr));
      setComments(asClientList(cm));
    } catch (e) {
      console.warn("PM store reload failed", e);
    }
  }, []);

  useEffect(() => {
    reloadStores();
    const unsub = PMClient.subscribe(() => reloadStores());
    return unsub;
  }, [reloadStores]);

  const isAdmin = role === "admin";
  const totalEditableRows = useMemo(() => {
    const counts = PRData.modelCounts(model);
    return counts.sites + counts.underlay + counts.isp + counts.newUnderlay;
  }, [model]);
  const openPlanCount = plans.filter((p) => p.status !== "done" && p.status !== "cancelled").length;
  const canUseChangeRequests = role === "engineer" || role === "po" || role === "admin";
  const moduleTabs = [
    { id: "editor", label: "Editor", hotkey: "1", count: totalEditableRows },
    { id: "planner", label: "What's next", hotkey: "2", count: openPlanCount },
    { id: "reports", label: "Reports", hotkey: "3" },
    ...(isAdmin ? [{ id: "team", label: "Team", hotkey: "4" }] : []),
    { id: "activity", label: "Activity", hotkey: isAdmin ? "5" : "4", count: activity.length },
    ...(canUseChangeRequests ? [{ id: "change-requests", label: "Change Requests", hotkey: isAdmin ? "6" : "5", count: changeRequests.length }] : []),
  ];

  return (
    <div className="page pm-page mc-pm-page">
      <div className="mc-page-h">
        <div className="mc-page-h-left">
          <div className="mc-eyebrow">MODULE · PROJECT MANAGEMENT</div>
          <h1 className="mc-page-title">Mission control</h1>
          <p className="mc-page-sub">Edit, plan, report and discuss the rollout. Every change persists locally and stays Azure-ready for deployment.</p>
        </div>
        <div className="mc-page-h-right">
          <span className="mc-snapshot">snapshot · {new Date().toISOString().slice(0, 16).replace("T", " ")}</span>
          {user && <MCPill tone={role === "admin" ? "risk" : role === "po" ? "warn" : "accent"} soft>{user.displayName || user.email || role}</MCPill>}
          <button className="btn primary" onClick={() => setSyncOpen(true)}>
            <Glyph name="ext" size={13}/> Sync / Export
          </button>
        </div>
      </div>

      <PmHero model={model} tasks={tasks} activity={activity} plans={plans}/>

      <div className="mc-modnav">
        <div className="mc-modnav-pills">
          {moduleTabs.map((m) => (
            <button key={m.id} className={"mc-modnav-pill" + (tab === m.id ? " active" : "")} onClick={() => setTab(m.id)}>
              <span className="hotkey">{m.hotkey}</span>
              <span>{m.label}</span>
              {m.count !== undefined && <span className="pill-count">{m.count}</span>}
            </button>
          ))}
        </div>
      </div>

      <div className="pm-layout mc-layout">
        <div className="pm-main mc-main">

          {tab === "editor" && <PmEditor model={model} role={role} focus={focus} setFocus={setFocus}/>}
          {tab === "planner" && <PmPlanner model={model} role={role} plans={plans} onPlanChange={reloadStores} setFocus={setFocus}/>}
          {tab === "reports" && <PmReports model={model} plans={plans} changeRequests={changeRequests} comments={comments} activity={activity} role={role} user={user} onReportChange={reloadStores}/>}
          {tab === "team" && isAdmin && <PmTeam user={user}/>}
          {tab === "activity" && <PmActivity activity={activity} model={model} setFocus={setFocus}/>}
          {tab === "change-requests" && canUseChangeRequests && (
            <PmChangeRequests model={model} role={role} user={user} changeRequests={changeRequests} plans={plans} activity={activity} onChange={reloadStores}/>
          )}
        </div>

        <div className="pm-rail mc-rail">
          <MCInspectorRail model={model} focus={focus} setFocus={setFocus} tasks={tasks} plans={plans} activity={activity}/>
        </div>
      </div>

      {syncOpen && (
        <PmSyncMenu workbookBytesRef={workbookBytesRef} onClose={() => setSyncOpen(false)} onImported={onModelDirty}/>
      )}
    </div>
  );
}

window.PageProjectManagement = PageProjectManagement;
window.PmCommentsRail = PmCommentsRail;
// GA-10 test helpers — expose mention logic for validate_dashboard.mjs assertions
window._ga10TestHelpers = {
  canonMentionHandle,
  // mentionHtml: mirrors the @-handle replacement in renderMarkdownSubset for testing.
  // Returns the mention-annotated HTML string (no React wrapper, no full markdown).
  mentionHtml(text, knownHandles) {
    if (!text) return "";
    const esc = (s) => String(s)
      .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
    return esc(text).replace(/(^|\s)@([a-zA-Z0-9._-]+)/g, (full, lead, handle) => {
      if (knownHandles && !knownHandles.has(canonMentionHandle(handle))) return full;
      return `${lead}<span class="pm-mention">@${handle}</span>`;
    });
  },
};
