// dashboard-bulk-compose.js — extracted verbatim from dashboard.js (classic script,
// shared global scope). Loaded before dashboard.js. Pure move, no logic change.

// ── Bulk campaign compose modal ───────────────────────────────────────
//
// Closes the "filter by tag → select → send campaign" loop. Opens
// directly from the CRM selection bar — no multi-step wizard.
// User picks channel (email / telegram), composes, sends. Backend
// (handleCrmContactsBulkCampaign) creates the campaign + bulk-inserts
// the messages; the relevant runner picks them up.
//
// Smart prefill: if every selected contact shares a `luma:<event>` or
// `tg:<group>` or `event:*` tag, the modal suggests an opening line
// referencing that source ("Saw you went to BitcoinConnect — wanted to
// connect..."). Skippable; the user can write whatever they want.
async function openBulkCampaignModal(contactIds) {
  // Build the modal's context from the rendered-rows cache (loadCrm
  // stamps _lastCrmContactRows on every CRM list render). This avoids
  // an extra round-trip; the backend re-resolves authoritative state
  // at submit time. If the user's selection spans multiple pages, the
  // modal counts are best-effort — the backend's `inserted`/`skipped`
  // response is the source of truth.
  const safeIds = contactIds.slice(0, 5000);
  const idSet = new Set(safeIds);
  const cachedRows = (_lastCrmContactRows || []).filter((r) => idSet.has(r.id));
  const cachedIds = new Set(cachedRows.map((r) => r.id));
  // For ids we don't have rich data on (different page, stale cache),
  // include placeholder rows so counts don't lie. The backend will
  // filter properly at submit.
  const placeholders = safeIds
    .filter((id) => !cachedIds.has(id))
    .map((id) => ({ id, _resolved: false }));
  const contacts = [...cachedRows, ...placeholders];
  const total = safeIds.length;
  const withEmail = contacts.filter((c) => c.email && !c.email_unsubscribed).length;
  const withTelegram = contacts.filter((c) => c.telegram_user_id).length;

  // Tag-overlap analysis — find tags shared by EVERY selected contact.
  // Used to pick a smart prefill ("you went to BitcoinConnect").
  const sharedTags = computeSharedTags(contacts);
  const prefill = buildSmartPrefill(sharedTags);

  // Default channel: prefer telegram if more contacts have TG than email,
  // else email. The user can flip in the modal.
  let channel = withTelegram >= withEmail ? "telegram" : "email";
  let subject = prefill.subject;
  let bodyText = prefill.body;
  let campaignName = prefill.name;
  let accountId = ""; // for telegram

  // Pull TG accounts for the dropdown.
  const tgAccounts = (dashboardState.cache.accounts?.telegram || []).filter(
    (a) => a.status === "active",
  );

  const eligibleCount = () => (channel === "email" ? withEmail : withTelegram);

  // ── Modal layout ────────────────────────────────────────────────────
  const overlay = el("div", {
    style: [
      "position:fixed",
      "inset:0",
      "z-index:280",
      "background:rgba(0,0,0,0.6)",
      "backdrop-filter:blur(2px)",
      "display:flex",
      "align-items:center",
      "justify-content:center",
      "padding:24px",
    ].join(";"),
    onclick: (e) => {
      if (e.target === e.currentTarget) overlay.remove();
    },
  });

  const close = () => overlay.remove();

  const targetSummary = el("div", {
    style: "font-size:11px;color:#9ca3af;font-family:ui-monospace,monospace;margin-top:4px;",
  });
  const renderTargetSummary = () => {
    targetSummary.innerHTML = "";
    const eligible = eligibleCount();
    const skipped = total - eligible;
    targetSummary.appendChild(
      el(
        "span",
        { style: `color:${eligible > 0 ? "#86efac" : "#fca5a5"};font-weight:600;` },
        `${eligible} of ${total} eligible`,
      ),
    );
    if (skipped > 0) {
      targetSummary.appendChild(
        el(
          "span",
          { style: "color:#9ca3af;margin-left:8px;" },
          `· ${skipped} skipped (no ${channel === "email" ? "email" : "Telegram id"})`,
        ),
      );
    }
  };

  // Channel toggle
  const channelBtn = (key, icon, label, count) =>
    el(
      "button",
      {
        type: "button",
        style: [
          "flex:1",
          "padding:10px 14px",
          "background:" + (channel === key ? "rgba(99,102,241,0.18)" : "rgba(255,255,255,0.02)"),
          "border:1px solid " + (channel === key ? "rgba(99,102,241,0.6)" : "#2a2a2e"),
          "border-radius:8px",
          "cursor:pointer",
          "color:" + (channel === key ? "#a5b4fc" : "#cbd5e1"),
          "font-size:13px",
          "font-weight:" + (channel === key ? "600" : "500"),
          "display:flex",
          "align-items:center",
          "gap:8px",
          "justify-content:center",
          "transition:all 120ms",
        ].join(";"),
        onclick: () => {
          channel = key;
          // Re-render channel toggles + subject visibility + target summary.
          channelRow.innerHTML = "";
          channelRow.appendChild(channelBtn("email", "📧", "Email", withEmail));
          channelRow.appendChild(channelBtn("telegram", "✈️", "Telegram DM", withTelegram));
          subjectRow.style.display = channel === "email" ? "" : "none";
          tgAccountRow.style.display = channel === "telegram" ? "" : "none";
          renderTargetSummary();
        },
      },
      [
        el("span", { style: "font-size:15px;" }, icon),
        el("span", {}, label),
        el(
          "span",
          { style: "color:#71717a;font-size:11px;font-family:ui-monospace,monospace;" },
          `${count}/${total}`,
        ),
      ],
    );

  const channelRow = el("div", { style: "display:flex;gap:10px;" });
  channelRow.appendChild(channelBtn("email", "📧", "Email", withEmail));
  channelRow.appendChild(channelBtn("telegram", "✈️", "Telegram DM", withTelegram));

  const labelStyle =
    "font-size:11px;color:#9ca3af;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:5px;";
  const inputStyle =
    "width:100%;padding:8px 10px;background:#0f0f10;border:1px solid #2a2a2e;border-radius:6px;color:#e5e7eb;font-size:13px;font-family:inherit;box-sizing:border-box;";

  const nameInput = el("input", {
    type: "text",
    style: inputStyle,
    value: campaignName,
    placeholder: "Campaign name",
    oninput: (e) => {
      campaignName = e.target.value;
    },
  });

  const subjectInput = el("input", {
    type: "text",
    style: inputStyle,
    value: subject,
    placeholder: "Email subject",
    oninput: (e) => {
      subject = e.target.value;
    },
  });
  const subjectRow = el("div", { style: channel === "email" ? "" : "display:none;" }, [
    el("div", { style: labelStyle }, "Subject"),
    subjectInput,
  ]);

  const tgAccountSelect = el(
    "select",
    {
      class: "wizard-input",
      style: inputStyle,
      onchange: (e) => {
        accountId = e.target.value;
      },
    },
    [
      el("option", { value: "" }, "Select Telegram account…"),
      ...tgAccounts.map((a) =>
        el(
          "option",
          { value: a.id },
          a.account_identifier ? `@${a.account_identifier}` : a.label || `Account ${a.id}`,
        ),
      ),
    ],
  );
  const tgAccountRow = el("div", { style: channel === "telegram" ? "" : "display:none;" }, [
    el("div", { style: labelStyle }, "Send from"),
    tgAccountSelect,
    tgAccounts.length === 0
      ? el(
          "div",
          { style: "font-size:11px;color:#fbbf24;margin-top:4px;" },
          "⚠ No active Telegram accounts found — connect one in Accounts.",
        )
      : null,
  ]);

  const bodyInput = el(
    "textarea",
    {
      style:
        inputStyle +
        ";min-height:120px;font-family:ui-monospace,monospace;font-size:12px;line-height:1.5;",
      placeholder: "Hi {{first_name}}, ...",
      oninput: (e) => {
        bodyText = e.target.value;
      },
    },
    bodyText,
  );

  // Variable hint
  const varHint = el(
    "div",
    {
      style: "font-size:10px;color:#71717a;margin-top:4px;font-family:ui-monospace,monospace;",
    },
    "Variables: {{name}}, {{first_name}}, {{company}}, {{sector}}, {{role}}",
  );

  // Smart prefill banner
  const prefillBanner =
    sharedTags.length > 0
      ? el(
          "div",
          {
            style: [
              "padding:8px 12px",
              "background:rgba(34,197,94,0.06)",
              "border:1px solid rgba(34,197,94,0.2)",
              "border-radius:6px",
              "font-size:12px",
              "color:#86efac",
              "display:flex",
              "align-items:center",
              "gap:8px",
            ].join(";"),
          },
          [
            el("span", {}, "✓"),
            el(
              "span",
              { style: "flex:1;" },
              sharedTags.length === 1
                ? `All ${total} contacts share the tag "${sharedTags[0]}". Subject + body prefilled with that context.`
                : `All ${total} contacts share ${sharedTags.length} tags (incl. "${sharedTags[0]}").`,
            ),
          ],
        )
      : null;

  const sendBtn = el(
    "button",
    {
      type: "button",
      class: "btn btn-primary",
      style: "padding:10px 18px;font-weight:600;",
      onclick: async () => {
        // Validation
        if (!campaignName.trim()) {
          toast("Campaign name is required", "err");
          return;
        }
        if (!bodyText.trim()) {
          toast("Message body is required", "err");
          return;
        }
        if (channel === "email" && !subject.trim()) {
          toast("Subject is required for email", "err");
          return;
        }
        if (channel === "telegram" && !accountId) {
          toast("Pick a Telegram account to send from", "err");
          return;
        }
        const eligible = eligibleCount();
        if (eligible === 0) {
          toast(`No contacts have a ${channel === "email" ? "valid email" : "Telegram id"}`, "err");
          return;
        }

        sendBtn.disabled = true;
        sendBtn.textContent = "Sending…";

        const payload = {
          channel,
          contactIds: safeIds,
          body: bodyText,
          campaignName: campaignName.trim(),
          targetSource:
            sharedTags.length > 0
              ? `crm-selection:${channel}:${sharedTags[0]}`
              : `crm-selection:${channel}`,
        };
        if (channel === "email") payload.subject = subject;
        if (channel === "telegram") payload.accountId = Number(accountId);

        const r = await api("crm-bulk-campaign", { body: payload }).catch((e) => ({
          error: e?.message || "Network error",
        }));
        if (r?.error) {
          toast(`✗ ${r.error}`, "err", 5000);
          sendBtn.disabled = false;
          sendBtn.textContent = "Send campaign";
          return;
        }
        toast(
          `✓ Campaign created — ${r.inserted} ${r.inserted === 1 ? "message" : "messages"} queued${r.skipped > 0 ? ` (${r.skipped} skipped)` : ""}`,
          "ok",
          4000,
        );
        logTelemetry("crm", "bulk_campaign_created", { channel, inserted: r.inserted, total });
        // Clear the selection so the bar disappears, then jump to the campaign.
        crmSelClear();
        close();
        if (r.campaignRoute) {
          setTimeout(() => navigate(r.campaignRoute), 400);
        }
      },
    },
    "Send campaign",
  );

  const cancelBtn = el(
    "button",
    {
      type: "button",
      class: "btn btn-secondary",
      style: "padding:10px 18px;",
      onclick: close,
    },
    "Cancel",
  );

  const panel = el(
    "div",
    {
      style: [
        "background:#161618",
        "border:1px solid #26262a",
        "border-radius:12px",
        "max-width:640px",
        "width:100%",
        "max-height:90vh",
        "overflow-y:auto",
        "box-shadow:0 24px 48px rgba(0,0,0,0.6)",
        "display:flex",
        "flex-direction:column",
      ].join(";"),
    },
    [
      // Header
      el(
        "div",
        {
          style:
            "padding:16px 20px;border-bottom:1px solid #26262a;display:flex;align-items:center;gap:12px;",
        },
        [
          el("span", { style: "font-size:20px;" }, "💌"),
          el("div", { style: "flex:1;" }, [
            el(
              "div",
              { style: "font-size:15px;font-weight:600;color:#e5e7eb;" },
              "Send campaign to selection",
            ),
            targetSummary,
          ]),
          el("button", { class: "icon-btn", onclick: close, title: "Close" }, "✕"),
        ],
      ),
      // Body
      el("div", { style: "padding:16px 20px;display:flex;flex-direction:column;gap:14px;" }, [
        prefillBanner,
        el("div", {}, [el("div", { style: labelStyle }, "Channel"), channelRow]),
        el("div", {}, [el("div", { style: labelStyle }, "Campaign name"), nameInput]),
        subjectRow,
        tgAccountRow,
        el("div", {}, [el("div", { style: labelStyle }, "Message"), bodyInput, varHint]),
      ]),
      // Footer
      el(
        "div",
        {
          style:
            "padding:14px 20px;border-top:1px solid #26262a;display:flex;justify-content:flex-end;gap:8px;",
        },
        [
          cancelBtn,
          // Phase 6: hand-off to the composer wizard. Closes this modal,
          // stages the selection as a `crm_selected` resolver via the
          // bulk_express template, and opens the composer at the wizard.
          el(
            "button",
            {
              type: "button",
              class: "btn btn-secondary",
              style: "padding:10px 18px;",
              title:
                "Build this as a multi-step workflow with branches, voice picker, and channel fallback chain.",
              onclick: () => {
                close();
                composerHandoffFromCrmSelection(safeIds);
              },
            },
            "🧩 Build as workflow",
          ),
          sendBtn,
        ],
      ),
    ],
  );

  overlay.appendChild(panel);
  document.body.appendChild(overlay);
  renderTargetSummary();
}

// Find tags shared by EVERY contact in the list. Returns sorted by
// preferred order (event/luma/tg sources first since they imply context).
function computeSharedTags(contacts) {
  if (!contacts || contacts.length === 0) return [];
  const tagSets = contacts.map((c) => {
    const raw = (c.tags || "")
      .split(",")
      .map((t) => t.trim())
      .filter(Boolean);
    return new Set(raw);
  });
  if (tagSets.length === 0) return [];
  // Intersection
  let shared = [...tagSets[0]];
  for (let i = 1; i < tagSets.length; i++) {
    shared = shared.filter((t) => tagSets[i].has(t));
    if (shared.length === 0) return [];
  }
  // Prioritize source-style tags first (luma:, tg:, event:, source:)
  const priorityRanks = { "luma:": 0, "event:": 1, "tg:": 2, "x:": 3, "li:": 4, "source:": 5 };
  shared.sort((a, b) => {
    const ra = Object.entries(priorityRanks).find(([p]) => a.startsWith(p))?.[1] ?? 99;
    const rb = Object.entries(priorityRanks).find(([p]) => b.startsWith(p))?.[1] ?? 99;
    return ra - rb;
  });
  return shared;
}

// Build a smart prefill (subject + body + name) from a list of shared
// tags. Recognizes a few common tag patterns; falls back to a generic
// opener otherwise. The user can always overwrite in the modal.
function buildSmartPrefill(sharedTags) {
  // Extract event slug from `luma:bitcoinconnect` or `event:bitcoinconnect`
  for (const t of sharedTags) {
    if (t.startsWith("luma:")) {
      const slug = t.slice(5);
      const pretty = slug.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
      return {
        name: `${pretty} attendees outreach · ${new Date().toLocaleDateString()}`,
        subject: `Saw you at ${pretty}`,
        body: `Hi {{first_name}},

Saw you attended ${pretty} — wanted to connect.

[Your message here]

Best,
[your name]`,
      };
    }
    if (t.startsWith("event:")) {
      const slug = t.slice(6);
      const pretty = slug.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
      return {
        name: `${pretty} attendees · ${new Date().toLocaleDateString()}`,
        subject: `From ${pretty}`,
        body: `Hi {{first_name}},

Saw you at ${pretty} — would love to connect.

[Your message here]

Best,
[your name]`,
      };
    }
    if (t.startsWith("tg:")) {
      const group = t.slice(3).replace(/[-_]+/g, " ");
      return {
        name: `${group} group outreach · ${new Date().toLocaleDateString()}`,
        subject: `From the ${group} group`,
        body: `Hi {{first_name}},

Saw you in the ${group} group — wanted to reach out.

[Your message here]

Best,
[your name]`,
      };
    }
    // li:commenters — engaged with one of YOUR posts (commented). High
    // conversion: they already know you exist and bothered to comment.
    if (t === "li:commenters") {
      return {
        name: `LinkedIn commenter outreach · ${new Date().toLocaleDateString()}`,
        subject: `Thanks for commenting`,
        body: `Hi {{first_name}},

Saw your comment on my recent LinkedIn post — wanted to follow up directly.

[Your message here]

Best,
[your name]`,
      };
    }
    // li:siphon:<competitor-slug> — followers/engagers of a competitor's
    // LinkedIn profile that the X follower-sniper / LI competitor-siphon
    // action pulled in. Generic "saw you follow X" hook works.
    if (t.startsWith("li:siphon:") || t.startsWith("x:siphon:")) {
      const target = t.split(":").slice(2).join(":").replace(/[-_]+/g, " ");
      const platform = t.startsWith("li:") ? "LinkedIn" : "X";
      return {
        name: `${target} audience outreach · ${new Date().toLocaleDateString()}`,
        subject: `Saw you follow ${target}`,
        body: `Hi {{first_name}},

Noticed you follow ${target} on ${platform} — looks like we're in a similar space.

[Your message here]

Best,
[your name]`,
      };
    }
    // li:search:<keyword> — found via LinkedIn keyword search. The keyword
    // itself is the natural opening line ("noticed your work on X").
    if (t.startsWith("li:search:")) {
      const kw = t.split(":").slice(2).join(":").replace(/[-_]+/g, " ");
      return {
        name: `${kw} prospects · ${new Date().toLocaleDateString()}`,
        subject: `Working in ${kw}?`,
        body: `Hi {{first_name}},

Came across your profile while researching ${kw} — wanted to introduce myself.

[Your message here]

Best,
[your name]`,
      };
    }
    // x:engagement_campaign — followed via the systematic engagement-
    // campaign action (consistent likes/replies/RTs to a target).
    if (t === "x:engagement_campaign") {
      return {
        name: `X engagement outreach · ${new Date().toLocaleDateString()}`,
        subject: `Wanted to connect`,
        body: `Hi {{first_name}},

We've been engaging on X — figured I'd reach out directly.

[Your message here]

Best,
[your name]`,
      };
    }
  }
  // Generic fallback
  return {
    name: `Selection campaign · ${new Date().toLocaleDateString()}`,
    subject: "Quick intro",
    body: `Hi {{first_name}},

[Your message here]

Best,
[your name]`,
  };
}
function runBulkGrowFromSelection() {
  toast(
    "Bulk grow wiring lands in the next batch — social-service commands coming soon",
    "info",
    5000,
  );
}

function renderCrmTable(rows, type) {
  const table = el("div", { class: "table-wrap" });
  const tbl = el("table", { class: "table" });
  if (type === "contacts") {
    const ss = dashboardState.sort.crm;
    const columns = [
      { label: "Name", key: "display_name" },
      { label: "Company", key: "company" },
      { label: "Stage", key: "conversation_stage" },
      { label: "Status" },
      { label: "Score", key: "lead_score" },
      { label: "ICP", key: "icp_score" },
      { label: "Outreach" },
      { label: "Last contact", key: "last_contacted_at" },
      { label: "Engagement" },
      { label: "" },
    ];
    // makeSortableHeader builds the <thead> from the columns spec; we prepend
    // the selection-checkbox column manually since it's not sortable.
    const sortableHeader = makeSortableHeader(columns, ss, () => loadCrm());
    const idsOnPage = rows.map((r) => r.id).filter(Number.isFinite);
    const headerRow = sortableHeader.querySelector("tr");
    if (headerRow)
      headerRow.insertBefore(selectionHeaderCheckbox("contacts", idsOnPage), headerRow.firstChild);
    tbl.appendChild(sortableHeader);

    const sorted = ss.key ? sortRows(rows, ss.key, ss.dir) : rows;
    const tbody = el("tbody", {});
    for (const c of sorted) {
      const outreachBadge =
        c.outreach_status === "active"
          ? el("span", { class: "badge badge-green" }, "\u25cf active")
          : c.outreach_status === "replied" || c.outreach_status === "converted"
            ? el("span", { class: "badge badge-blue" }, `\u25cf ${c.outreach_status}`)
            : c.outreach_status
              ? el("span", { class: "badge badge-gray" }, c.outreach_status)
              : el("span", { class: "muted small" }, "\u2014");

      // Name cell with target star
      const nameContent = [];
      if (c.is_target) nameContent.push(el("span", { class: "target-star" }, "\u2605"));
      nameContent.push(el("span", { class: "primary-cell" }, c.display_name || "\u2014"));

      // Stage cell — clickable
      const stageCell = el(
        "td",
        {
          onclick: (e) => {
            e.stopPropagation();
            showInlineStageEditor(c.id, c.conversation_stage || "unknown", e.currentTarget);
          },
          style: "cursor: pointer;",
          title: "Click to change stage",
        },
        [statusDot(c.conversation_stage || "unknown")],
      );

      // Company cell: when the contact is FK-linked to a companies row,
      // render a clickable link that opens the company drawer instead of
      // the contact drawer. e.stopPropagation prevents the row's own
      // onclick from also firing.
      const companyCell = c.company_id
        ? el("td", { class: "truncate" }, [
            el(
              "a",
              {
                class: "company-link",
                "data-company-id": c.company_id,
                title: "Open company",
                onclick: (e) => {
                  e.stopPropagation();
                  openCompanyDrawerById(c.company_id);
                },
              },
              c.company || "\u2014",
            ),
          ])
        : el("td", { class: "truncate" }, c.company || "\u2014");

      const icpCell =
        c.icp_score != null
          ? el("div", { title: c.icp_band ? `Band: ${c.icp_band}` : "" }, [
              renderIcpScoreBar10(c.icp_score, c.icp_band),
            ])
          : el(
              "span",
              { class: "muted small", title: "Run AI lead-analysis to score this contact" },
              "—",
            );

      tbody.appendChild(
        el(
          "tr",
          {
            onclick: (e) => {
              if (
                e.target.closest(".crm-action-menu") ||
                e.target.closest(".inline-stage-picker") ||
                e.target.closest(".company-link") ||
                e.target.closest(".row-checkbox")
              )
                return;
              showContactDrawer(c.id);
            },
          },
          [
            selectionCheckbox("contacts", c.id),
            el("td", {}, nameContent),
            companyCell,
            stageCell,
            renderContactStatusCell(c),
            el("td", {}, [renderScoreBar(c.lead_score)]),
            el("td", {}, [icpCell]),
            el("td", {}, [outreachBadge]),
            el(
              "td",
              { class: "secondary-cell mono" },
              c.last_contacted_at ? fmtDate(c.last_contacted_at) : "never",
            ),
            el("td", {}, [renderEngagement(c)]),
            el("td", {}, [buildContactActionMenu(c)]),
          ],
        ),
      );
    }
    tbl.appendChild(tbody);
  } else {
    // When the operator has a top-N filter active OR any visible row has a
    // rank, surface the rank column on the left so #1 BTC reads at a glance.
    const showRank =
      (typeof crmFilter !== "undefined" && crmFilter.top) ||
      rows.some((c) => c.market_cap_rank != null);
    const idsOnPage = rows.map((r) => r.id).filter(Number.isFinite);
    const headers = [
      selectionHeaderCheckbox("companies", idsOnPage),
      el("th", {}, "★"),
      el("th", {}, "Name"),
      el("th", {}, "Kind"),
      el("th", {}, "Sector"),
      el("th", { title: "CRM contacts at this company" }, "People"),
      el(
        "th",
        {
          title:
            "Populated socials: website / twitter / linkedin / telegram / discord / github (out of 6)",
        },
        "Socials",
      ),
      el("th", {}, "Status"),
      el("th", {}, "Stage"),
      el("th", {}, "Country"),
      el("th", {}, "ICP score"),
      el("th", {}, "Band"),
      el("th", {}, "Raised"),
      el("th", {}, "Token"),
    ];
    // Rank inserts AFTER the star column (idx 2) so layout stays
    // [checkbox, star, rank, name, ...] — rank stays adjacent to name.
    if (showRank) headers.splice(2, 0, el("th", {}, "#"));
    tbl.appendChild(el("thead", {}, [el("tr", {}, headers)]));
    const tbody = el("tbody", {});
    for (const c of rows) {
      const icpCell =
        c.icp_score != null
          ? renderIcpScoreBar10(c.icp_score, c.icp_band)
          : el("span", { class: "muted small" }, "\u2014");
      const bandCell = c.icp_band
        ? renderIcpBandBadge(c.icp_band)
        : el("span", { class: "muted small" }, "\u2014");
      const starCell = el("td", { onclick: (e) => e.stopPropagation() }, [
        bookmarkStarBtn("company", c.id) || el("span", {}, ""),
      ]);
      // Socials cell: small fraction "4/6" colored by completeness.
      // Includes per-channel dots in the title attribute so the user can
      // hover and see which channels are missing.
      const socials = Number(c.social_score) || 0;
      const socialsClr =
        socials >= 5
          ? "badge-green"
          : socials >= 3
            ? "badge-blue"
            : socials >= 1
              ? "badge-yellow"
              : "muted";
      const socialsTitle = [
        c.website ? "\u2713 website" : "\u2717 website",
        c.twitter_handle ? "\u2713 twitter" : "\u2717 twitter",
        c.linkedin_url ? "\u2713 linkedin" : "\u2717 linkedin",
        c.telegram_group_url ? "\u2713 telegram" : "\u2717 telegram",
        c.discord_url ? "\u2713 discord" : "\u2717 discord",
        c.github_url ? "\u2713 github" : "\u2717 github",
      ].join(" \u00b7 ");
      const socialsCell =
        socials > 0
          ? el("td", { title: socialsTitle }, [
              el("span", { class: `badge ${socialsClr}` }, `${socials}/6`),
            ])
          : el("td", { class: "muted", title: socialsTitle }, "0/6");

      const cells = [
        selectionCheckbox("companies", c.id),
        starCell,
        el("td", {}, [el("span", { class: "primary-cell" }, c.name)]),
        el("td", {}, [renderCompanyKindBadges(c)]),
        el("td", {}, c.sector ? el("span", { class: "badge badge-purple" }, c.sector) : "\u2014"),
        el(
          "td",
          {},
          c.contact_count > 0
            ? el("span", { class: "badge badge-blue" }, String(c.contact_count))
            : el("span", { class: "muted" }, "0"),
        ),
        socialsCell,
        renderCompanyStatusCell(c),
        el("td", { class: "muted" }, c.stage || "\u2014"),
        el("td", { class: "muted" }, c.country || "\u2014"),
        el("td", {}, [icpCell]),
        el("td", {}, [bandCell]),
        el("td", { class: "mono" }, c.total_raised ? fmtAmount(c.total_raised) : "\u2014"),
        el("td", { class: "mono muted" }, c.token_symbol || "\u2014"),
      ];
      if (showRank) {
        // Rank column slots between star (idx 1) and name (idx 2).
        cells.splice(
          2,
          0,
          el(
            "td",
            { class: "mono", style: "color:var(--text-3);min-width:42px;" },
            c.market_cap_rank != null ? `#${c.market_cap_rank}` : "\u2014",
          ),
        );
      }
      tbody.appendChild(
        el(
          "tr",
          {
            onclick: (e) => {
              if (e.target.closest(".row-checkbox")) return;
              showCompanyDrawer(c);
            },
          },
          cells,
        ),
      );
    }
    tbl.appendChild(tbody);
  }
  // After rebuilding the table, re-render the floating selection bar so
  // it reflects any selection that survived the re-render (the bar's only
  // state is dashboardState.crmSelection, but it gets removed on every
  // setContentNode).
  setTimeout(renderCrmSelectionBar, 0);
  table.appendChild(tbl);
  return table;
}

// 3-dot action menu for contact rows
function buildContactActionMenu(contact) {
  const btn = el("div", { class: "crm-action-menu row-actions" }, [
    bookmarkStarBtn("contact", contact.id),
    el(
      "button",
      {
        class: "btn btn-tiny btn-secondary",
        title: "Actions",
        onclick: (e) => {
          e.stopPropagation();
          const existing = document.querySelector(".crm-dropdown-open");
          if (existing) existing.remove();
          const menu = buildContactDropdown(contact, btn);
          btn.appendChild(menu);
        },
      },
      "\u22ef",
    ),
  ]);
  return btn;
}

// Per-tab bookmarked-id caches \u2014 populated by loadBookmarkedIdsForTab() before
// each CRM render so star buttons render with correct initial state without
// per-row API calls. Module-level so the toggle handler can mutate without
// passing the Set down through every closure.
const _bookmarkedIds = {
  contact: new Set(),
  company: new Set(),
};

async function loadBookmarkedIdsForTab(tab) {
  // tab is "contacts" or "companies"; the bookmarks API uses singular forms.
  const type = tab === "contacts" ? "contact" : tab === "companies" ? "company" : null;
  if (!type) return;
  try {
    const r = await api("bookmarks-list", { queryString: `?type=${type}&limit=200` });
    if (Array.isArray(r?.bookmarks)) {
      _bookmarkedIds[type] = new Set(
        r.bookmarks.map((b) => Number(b.itemId)).filter(Number.isFinite),
      );
    }
  } catch {
    /* swallow \u2014 star defaults to unfilled, user can still click */
  }
}

// Star button \u2014 toggles a bookmark for (type, id). Optimistic UI: flip the
// star instantly, fire the API, revert on failure. Updates the module-level
// cache so re-renders of the same tab stay in sync without a refetch.
function bookmarkStarBtn(type, id) {
  if (!Number.isFinite(Number(id))) return null;
  const numId = Number(id);
  const isOn = () => _bookmarkedIds[type] && _bookmarkedIds[type].has(numId);
  const btn = el(
    "button",
    {
      class: "btn btn-tiny btn-secondary",
      title: isOn() ? "Bookmarked \u2014 click to remove" : "Bookmark this row",
      style: "padding: 2px 6px;",
      onclick: async (e) => {
        e.stopPropagation();
        const wasOn = isOn();
        // Optimistic flip
        if (wasOn) _bookmarkedIds[type].delete(numId);
        else _bookmarkedIds[type].add(numId);
        btn.textContent = isOn() ? "\u2605" : "\u2606";
        btn.title = isOn() ? "Bookmarked \u2014 click to remove" : "Bookmark this row";
        btn.style.color = isOn() ? "#fbbf24" : "";
        try {
          const r = await api(wasOn ? "bookmark-remove" : "bookmark-add", { type, id: numId });
          if (r?.error) throw new Error(r.error);
        } catch (err) {
          // Revert
          if (wasOn) _bookmarkedIds[type].add(numId);
          else _bookmarkedIds[type].delete(numId);
          btn.textContent = isOn() ? "\u2605" : "\u2606";
          btn.style.color = isOn() ? "#fbbf24" : "";
          toast(`Bookmark failed: ${err.message || err}`, "err");
        }
      },
    },
    isOn() ? "\u2605" : "\u2606",
  );
  if (isOn()) btn.style.color = "#fbbf24";
  return btn;
}

function buildContactDropdown(contact, anchor) {
  const validStages = ["cold", "contacted", "replied", "warm", "hot", "customer", "dead"];
  const dropdown = el(
    "div",
    {
      class: "crm-dropdown-open",
      style: `position: absolute; right: 0; top: 100%; z-index: 50;
            background: var(--bg-1); border: 1px solid var(--border);
            border-radius: var(--radius-md); padding: 6px 0; min-width: 180px;
            box-shadow: 0 12px 32px rgba(0,0,0,0.4);`,
    },
    [
      el(
        "div",
        {
          style:
            "padding: 4px 12px; font-size: 10px; color: var(--text-4); text-transform: uppercase; letter-spacing: 0.06em;",
        },
        "Set stage",
      ),
      el(
        "div",
        { style: "display: flex; flex-wrap: wrap; gap: 3px; padding: 4px 12px 8px;" },
        validStages.map((s) =>
          el(
            "button",
            {
              class: `btn btn-tiny ${contact.conversation_stage === s ? "btn-primary" : "btn-secondary"}`,
              onclick: async (e) => {
                e.stopPropagation();
                dropdown.remove();
                await changeStageWithUndo(contact.id, contact.conversation_stage, s, {
                  onSuccess: () => loadCrm(),
                });
              },
            },
            s,
          ),
        ),
      ),
      el("div", { style: "border-top: 1px solid var(--border); margin: 4px 0;" }),
      contact.linkedin_url
        ? el(
            "button",
            {
              style:
                "display: block; width: 100%; text-align: left; padding: 6px 12px; background: none; border: none; color: var(--text-2); font-size: 12px; cursor: pointer; font-family: inherit;",
              onclick: (e) => {
                e.stopPropagation();
                dropdown.remove();
                window.open(contact.linkedin_url, "_blank");
              },
            },
            "\ud83d\udd17 View on LinkedIn",
          )
        : null,
      contact.twitter_handle
        ? el(
            "button",
            {
              style:
                "display: block; width: 100%; text-align: left; padding: 6px 12px; background: none; border: none; color: var(--text-2); font-size: 12px; cursor: pointer; font-family: inherit;",
              onclick: (e) => {
                e.stopPropagation();
                dropdown.remove();
                window.open(`https://x.com/${contact.twitter_handle}`, "_blank");
              },
            },
            "\ud83d\udc26 View on X",
          )
        : null,
    ],
  );

  // Close on outside click
  setTimeout(() => {
    const handler = (e) => {
      if (!dropdown.contains(e.target)) {
        dropdown.remove();
        document.removeEventListener("click", handler);
      }
    };
    document.addEventListener("click", handler);
  }, 10);

  return dropdown;
}

// Tabbed contact drawer (Activity | Profile | Deals | Sources)
async function showContactDrawer(contactId) {
  // Fetch the full contact detail and the linked deals in parallel so the
  // Deals tab renders without a delay when the user switches to it.
  const [data, dealsResp] = await Promise.all([
    api("crm-contact-detail", { contactId }),
    api("crm-deals", { queryString: `?contact_id=${encodeURIComponent(contactId)}` }).catch(
      () => ({}),
    ),
  ]);
  if (data?.error) {
    toast(`Error: ${data.error}`, "err");
    return;
  }
  const { contact, sources, outreachCampaigns, recentEvents } = data;
  const contactDeals = dealsResp?.deals || [];
  // Track this open in the palette's "Recent" list so users can re-open quickly.
  pushPaletteRecent({
    kind: "contact",
    id: contact.id,
    label: contact.display_name || `Contact #${contact.id}`,
    detail: [contact.company, contact.role].filter(Boolean).join(" · ") || "—",
  });

  let activeTab = "activity";

  function renderDrawerContent() {
    const body = el("div", {});

    // Tab bar (Phase 13: + workflows tab — composition audit history per roadmap §15.2)
    const tabs = el("div", { class: "tabs", style: "margin-bottom: 16px;" }, [
      ...["activity", "profile", "deals", "sources", "workflows"].map((t) =>
        el(
          "button",
          {
            class: `tab${activeTab === t ? " active" : ""}`,
            onclick: () => {
              activeTab = t;
              renderDrawerContent();
            },
          },
          t === "deals" && contactDeals.length
            ? `Deals (${contactDeals.length})`
            : t === "workflows"
              ? "🧩 Workflows"
              : t.charAt(0).toUpperCase() + t.slice(1),
        ),
      ),
    ]);
    body.appendChild(tabs);

    if (activeTab === "activity") {
      // Activity timeline
      if ((recentEvents || []).length > 0) {
        body.appendChild(el("h4", {}, "Recent activity"));
        for (const ev of (recentEvents || []).slice(0, 20)) {
          body.appendChild(
            el("div", { class: "field-row" }, [
              el("div", {}, [
                el("span", { class: "badge badge-blue", style: "margin-right: 6px;" }, ev.platform),
                el("span", { class: "small" }, ev.event_type),
              ]),
              el("div", { class: "muted small" }, fmtDate(ev.created_at)),
            ]),
          );
        }
      } else {
        body.appendChild(el("div", { class: "muted small" }, "No outreach activity recorded yet."));
      }

      if ((outreachCampaigns || []).length > 0) {
        body.appendChild(el("h4", {}, "Campaigns"));
        for (const c of outreachCampaigns) {
          body.appendChild(
            el("div", { class: "field-row" }, [
              el("div", {}, c.company_name || `Campaign #${c.id}`),
              el("span", { class: "badge " + statusClass(c.overall_status) }, c.overall_status),
            ]),
          );
        }
      }
    }

    if (activeTab === "profile") {
      // AI lead analysis — top of the profile, opinionated verdict + reasoning.
      // Renders cached result instantly; button re-runs against latest data.
      body.appendChild(
        buildLeadAnalysisSection(
          "contact",
          contact.id,
          contact.ai_lead_analysis_json,
          contact.ai_lead_analysis_at,
        ),
      );
      body.appendChild(el("h4", {}, "Details"));
      [
        ["Name", contact.display_name],
        ["Company", contact.company],
        ["Role", contact.role],
        ["Sector", contact.sector],
        ["Email", contact.email],
        [
          "LinkedIn",
          contact.linkedin_url
            ? el(
                "a",
                { href: contact.linkedin_url, target: "_blank", style: "color: var(--accent);" },
                "Open \u2192",
              )
            : null,
        ],
        ["Twitter", contact.twitter_handle ? `@${contact.twitter_handle}` : null],
        ["Telegram", contact.telegram_username ? `@${contact.telegram_username}` : null],
        [
          "Relevance",
          contact.relevance_score ? `${Math.round(contact.relevance_score * 100)}%` : null,
        ],
        ["Stage", contact.conversation_stage],
      ].forEach(([label, value]) => {
        const r = fieldRow(label, value);
        if (r) body.appendChild(r);
      });

      // Inline CRM notes editor \u2014 saves on blur via crm-contact-update
      body.appendChild(el("h4", { style: "margin-top:14px;" }, "Notes"));
      const notesArea = el(
        "textarea",
        {
          class: "wizard-textarea",
          rows: "4",
          placeholder:
            "CRM notes \u2014 saved on blur. Use this for context, intent, follow-up reminders, etc.",
          style: "width:100%;",
          onblur: async (e) => {
            const newNotes = e.target.value;
            if ((newNotes || "") === (contact.crm_notes || "")) return;
            const r = await api("crm-contact-update", {
              contactId: contact.id,
              body: { notes: newNotes },
            });
            if (r?.ok) {
              toast("\ud83d\udcdd Notes saved", "ok", 2000);
              contact.crm_notes = newNotes;
            } else toast(`Save failed: ${r?.error || "unknown"}`, "err");
          },
        },
        contact.crm_notes || "",
      );
      body.appendChild(notesArea);

      // Editable tags
      {
        body.appendChild(el("h4", {}, "Tags"));
        const tagList = (contact.tags || "")
          .split(",")
          .map((t) => t.trim())
          .filter(Boolean);
        const tagWrap = el("div", {
          class: "row",
          style: "flex-wrap: wrap; gap: 4px; align-items: center;",
        });
        for (const t of tagList) {
          tagWrap.appendChild(
            el("span", { class: "tag-removable" }, [
              document.createTextNode(t),
              el(
                "button",
                {
                  class: "tag-remove-btn",
                  type: "button",
                  title: `Remove tag "${t}"`,
                  onclick: async (e) => {
                    e.stopPropagation();
                    const r = await api("crm-contact-update", {
                      contactId: contact.id,
                      body: { removeTag: t },
                    });
                    if (r?.ok) {
                      toast(`Removed tag: ${t}`, "ok");
                      contact.tags = (contact.tags || "")
                        .split(",")
                        .map((x) => x.trim())
                        .filter((x) => x.toLowerCase() !== t.toLowerCase())
                        .join(",");
                      renderDrawerContent();
                    } else toast(`Error: ${r?.error || "failed"}`, "err");
                  },
                },
                "\u00d7",
              ),
            ]),
          );
        }
        // Add tag input
        const addInput = el("input", {
          style:
            "background: var(--bg-0); border: 1px solid var(--border); color: var(--text-2); padding: 2px 8px; border-radius: var(--radius-full); font-size: 10px; width: 80px; font-family: inherit;",
          placeholder: "+ tag",
          onkeydown: async (e) => {
            if (e.key === "Enter" && e.target.value.trim()) {
              const newTag = e.target.value.trim();
              const r = await api("crm-contact-update", {
                contactId: contact.id,
                body: { addTag: newTag },
              });
              if (r?.ok) {
                toast(`Added tag: ${newTag}`, "ok");
                contact.tags = contact.tags ? `${contact.tags},${newTag}` : newTag;
                renderDrawerContent();
              } else toast(`Error: ${r?.error || "failed"}`, "err");
            }
          },
        });
        tagWrap.appendChild(addInput);
        body.appendChild(tagWrap);
      }

      if (contact.bio) {
        body.appendChild(el("h4", {}, "Bio"));
        body.appendChild(
          el("div", { class: "muted small", style: "line-height: 1.6;" }, contact.bio),
        );
      }

      if (contact.crm_notes) {
        body.appendChild(el("h4", {}, "Notes"));
        body.appendChild(
          el("div", { class: "muted small", style: "line-height: 1.6;" }, contact.crm_notes),
        );
      }
    }

    if (activeTab === "deals") {
      body.appendChild(el("h4", {}, `Deals (${contactDeals.length})`));
      if (contactDeals.length === 0) {
        body.appendChild(el("div", { class: "muted small" }, "No deals attached to this contact."));
      } else {
        for (const d of contactDeals) {
          body.appendChild(
            el(
              "div",
              {
                class: "field-row",
                style: "cursor: pointer;",
                onclick: () => navigate("deals"),
                title: "Open Deals view",
              },
              [
                el("div", {}, [
                  el(
                    "span",
                    { class: "primary-cell", style: "font-size: 12px;" },
                    d.title || `Deal #${d.id}`,
                  ),
                  d.company_name
                    ? el(
                        "span",
                        { class: "muted small", style: "margin-left: 8px;" },
                        d.company_name,
                      )
                    : null,
                ]),
                el("div", { class: "row", style: "gap: 8px; align-items: center;" }, [
                  d.value_usd != null
                    ? el("span", { class: "mono small" }, fmtAmount(d.value_usd))
                    : null,
                  el("span", { class: "badge " + statusClass(d.stage) }, d.stage || "—"),
                ]),
              ],
            ),
          );
        }
      }
    }

    if (activeTab === "sources") {
      body.appendChild(el("h4", {}, `Sources (${(sources || []).length})`));
      if ((sources || []).length > 0) {
        for (const s of sources) {
          body.appendChild(
            el("div", { class: "field-row" }, [
              el("div", {}, [
                el(
                  "span",
                  { class: "badge badge-gray", style: "margin-right: 6px;" },
                  s.source_type,
                ),
                s.source_group_id ? el("span", { class: "muted small" }, s.source_group_id) : null,
              ]),
              el("div", { class: "muted small" }, fmtDate(s.contributed_at)),
            ]),
          );
        }
      } else {
        body.appendChild(el("div", { class: "muted small" }, "No source data recorded."));
      }
    }

    // Phase 13: workflow history — every composition that targeted this
    // contact, with the matched resolvers + channel chosen + outcome
    // reasons. Powered by composition_audience_log (roadmap §15.2).
    if (activeTab === "workflows") {
      body.appendChild(el("h4", {}, "🧩 Workflow history"));
      const slot = el("div", {});
      slot.appendChild(el("div", { class: "muted small" }, "Loading…"));
      body.appendChild(slot);

      // Lazy fetch — runs only when the user clicks this tab.
      api("composition-contact-audit", { contactId: contact.id })
        .then((r) => {
          slot.innerHTML = "";
          if (r?.error) {
            slot.appendChild(el("div", { class: "muted small" }, `Error: ${r.error}`));
            return;
          }
          const entries = r?.entries || [];
          if (entries.length === 0) {
            slot.appendChild(
              el(
                "div",
                { class: "muted small" },
                "No compositions have targeted this contact yet. Once a workflow runs against an audience including this contact, the entry shows up here with the reason it was matched.",
              ),
            );
            return;
          }
          slot.appendChild(
            el(
              "div",
              { class: "muted small", style: "margin-bottom: 8px;" },
              `${entries.length} workflow touch${entries.length === 1 ? "" : "es"} on this contact:`,
            ),
          );
          for (const a of entries) {
            const reason = a.reason || {};
            const matched = (reason.matched_resolvers || []).map((m) => m.type).join(" + ") || "—";
            const channel = reason.channel_chosen || null;
            const skipped = (reason.channels_skipped || []).join(", ");
            const excludeReasons = (reason.exclusion_reasons || []).join(", ");
            const isInc = a.decision === "included";
            slot.appendChild(
              el(
                "div",
                {
                  style: `padding: 8px 10px; margin-bottom: 6px; background: #0c0c0c; border-left: 3px solid ${isInc ? "#5ada5a" : "#ff6666"}; border-radius: 3px; font-size: 11px; cursor: pointer;`,
                  onclick: () => {
                    // Click row → jump to composition detail
                    dashboardState.composerDraft = {
                      id: a.composition_id,
                      name: a.composition_name,
                    };
                    navigate("workflows/detail");
                  },
                },
                [
                  el(
                    "div",
                    {
                      style: "display: flex; justify-content: space-between; align-items: center;",
                    },
                    [
                      el("span", { style: "font-weight: 500;" }, [
                        `${fmtDate(a.created_at)} — `,
                        el(
                          "span",
                          { style: "color: #5a8aff;" },
                          a.composition_name || `#${a.composition_id}`,
                        ),
                        el(
                          "span",
                          {
                            class: "badge",
                            style: `margin-left: 8px; background: ${isInc ? "#1e3a1e" : "#3a1e1e"}; color: ${isInc ? "#5ada5a" : "#ff8888"}; font-size: 10px; padding: 2px 6px;`,
                          },
                          a.decision,
                        ),
                      ]),
                    ],
                  ),
                  el(
                    "div",
                    { class: "muted small", style: "margin-top: 4px;" },
                    `Matched by: ${matched}`,
                  ),
                  isInc && channel
                    ? el(
                        "div",
                        { class: "muted small", style: "margin-top: 2px;" },
                        `Channel: ${channel}${skipped ? ` · skipped: ${skipped}` : ""}`,
                      )
                    : null,
                  !isInc
                    ? el(
                        "div",
                        { style: "color: #faa; font-size: 11px; margin-top: 2px;" },
                        `Excluded: ${excludeReasons || skipped || "no reachable channel"}`,
                      )
                    : null,
                ].filter(Boolean),
              ),
            );
          }
        })
        .catch((err) => {
          slot.innerHTML = "";
          slot.appendChild(
            el("div", { class: "muted small" }, `Could not load: ${err?.message || err}`),
          );
        });
    }

    // Render into drawer
    const drawerBody = $("#drawer-body");
    drawerBody.innerHTML = "";
    drawerBody.appendChild(body);
  }

  openDrawer(contact.display_name || "Contact", el("div", {}));
  renderDrawerContent();
}
