/* =========================================================================
   UI components — pure presentation, no relay logic.
   ========================================================================= */
const { useState, useEffect, useMemo, useRef, useCallback } = React;

// ---------- Avatar ------------------------------------------------------
function Avatar({ profile, pubkey, size = 56 }) {
  const [err, setErr] = useState(false);
  const url = profile?.picture && !err ? profile.picture : null;
  const initials = (profile?.display_name || profile?.name || pubkey || "?")
    .replace(/^npub1/, "").slice(0, 2).toUpperCase();
  return (
    <div className="avatar" style={{ width: size, height: size }}>
      {url
        ? <img src={url} alt="" referrerPolicy="no-referrer" onError={() => setErr(true)} />
        : <div className="placeholder">{initials}</div>}
    </div>
  );
}

// ---------- Topbar ------------------------------------------------------
function Topbar({ me, profile, relayState, scheme, onToggleScheme, onSignout, onOpenActivity, onOpenHelp, onHome, searchProps }) {
  const npub = me ? Nostr.npubEncode(me) : "";
  const short = npub ? npub.slice(0, 12) + "…" + npub.slice(-6) : "";
  const publishing = relayState === "publishing";
  const [menuOpen, setMenuOpen] = useState(false);
  const wrapRef = useRef(null);
  useEffect(() => {
    if (!menuOpen) return;
    const onDoc = (e) => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setMenuOpen(false); };
    const onKey = (e) => { if (e.key === "Escape") setMenuOpen(false); };
    document.addEventListener("mousedown", onDoc);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
  }, [menuOpen]);
  return (
    <div className="topbar">
      <div className="brand">
        {onHome ? (
          <button type="button" className="brand-mark brand-mark-btn" onClick={onHome}>Contacts</button>
        ) : (
          <div className="brand-mark">Contacts</div>
        )}
      </div>
      {searchProps && me && <TopbarSearch {...searchProps} />}
      <div className="topbar-right">
        <div className={"relay-pulse " + (relayState === "idle" ? "idle" : publishing ? "publishing" : "")}>
          {publishing ? <span className="spinner sm" aria-hidden="true"></span> : <span className="dot"></span>}
          {relayState === "idle" ? "idle" : publishing ? "saving…" : "connected"}
        </div>
        {me && onOpenActivity && (
          <button className="topbar-btn" onClick={onOpenActivity} title="Activity">
            <svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
              <path d="M12 8v5l3 2" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
              <circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="2" />
            </svg>
            <span>Activity</span>
          </button>
        )}
        <button
          className="scheme-toggle"
          onClick={onToggleScheme}
          title={scheme === "light" ? "Switch to dark" : "Switch to light"}
          aria-label="Toggle color scheme"
        >
          {scheme === "light" ? (
            // sun
            <svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
              <circle cx="12" cy="12" r="4" fill="currentColor" />
              <g stroke="currentColor" strokeWidth="2" strokeLinecap="round">
                <path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.6 4.6l2.1 2.1M17.3 17.3l2.1 2.1M4.6 19.4l2.1-2.1M17.3 6.7l2.1-2.1" />
              </g>
            </svg>
          ) : (
            // moon
            <svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
              <path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z" fill="currentColor" />
            </svg>
          )}
        </button>
        {me && (
          <div className="user-menu-wrap" ref={wrapRef}>
            <button
              className="user-avatar-btn"
              onClick={() => setMenuOpen(v => !v)}
              aria-expanded={menuOpen}
              aria-label="Account menu"
              title={short}
            >
              <Avatar profile={profile} pubkey={me} size={36} />
            </button>
            {menuOpen && (
              <div className="user-menu">
                <div className="user-menu-head">
                  <div className="name">{profile?.display_name || profile?.name || "Anonymous"}</div>
                  <div className="npub" title={npub}>{short}</div>
                </div>
                <div className="member-menu-divider" />
                <button className="member-menu-item" onClick={() => {
                  navigator.clipboard?.writeText(npub);
                  setMenuOpen(false);
                }}>
                  <span className="check">⧉</span>
                  <span>Copy npub</span>
                </button>
                {onOpenHelp && (
                  <button className="member-menu-item" onClick={() => { setMenuOpen(false); onOpenHelp(); }}>
                    <span className="check">?</span>
                    <span>About &amp; help</span>
                  </button>
                )}
                <button className="member-menu-item" onClick={() => { setMenuOpen(false); onSignout(); }}>
                  <span className="check">↶</span>
                  <span>Sign out</span>
                </button>
              </div>
            )}
          </div>
        )}
      </div>
    </div>
  );
}

// ---------- Sidebar -----------------------------------------------------
function Sidebar({ contactList, followSets, activeId, loading, onSelect, onNew }) {
  const followingCount = contactList ? Nostr.tagsOfList(contactList).length : 0;
  return (
    <aside className="sidebar">
      <div className="sidebar-section">
        <div
          className={"list-row following " + (activeId === "__following__" ? "active" : "")}
          onClick={() => onSelect("__following__")}
        >
          <div className="list-name">Following</div>
          <div className="list-count">{followingCount}</div>
        </div>
      </div>
      <div className="sidebar-section">
        <div className="sidebar-heading">
          <span>Groups</span>
          <button className="btn btn-ghost" style={{ padding: "4px 8px", fontSize: 10 }} onClick={onNew}>+ new</button>
        </div>
        {followSets.length === 0 && (
          loading ? (
            <div style={{ color: "var(--fg-dim)", fontSize: 13, padding: "8px 4px", display: "flex", alignItems: "center", gap: 8 }}>
              <span className="spinner sm" aria-hidden="true"></span>
              <span>Loading groups…</span>
            </div>
          ) : (
            <div style={{ color: "var(--fg-dim)", fontSize: 13, padding: "8px 4px" }}>
              No groups yet. Create one to organise people you follow.
            </div>
          )
        )}
        {[...followSets].sort((a, b) =>
          Nostr.titleOf(a).localeCompare(Nostr.titleOf(b), undefined, { sensitivity: "base" })
        ).map((evt) => {
          const id = Nostr.dTagOf(evt);
          const count = Nostr.tagsOfList(evt).length;
          const hasPriv = evt.content && evt.content.length > 0;
          return (
            <div
              key={id || evt.id}
              className={"list-row " + (activeId === id ? "active" : "")}
              onClick={() => onSelect(id)}
            >
              <div className="list-name">{Nostr.titleOf(evt)}</div>
              <div className="list-count">
                {count}{hasPriv ? <span style={{ color: "var(--private)", marginLeft: 4 }}>•</span> : null}
              </div>
            </div>
          );
        })}
      </div>
    </aside>
  );
}

// ---------- Member dropdown menu ---------------------------------------
function MemberMenu({ pubkey, isFollowing, lists, membershipMap, busyMap, followBusy, onToggleFollow, onToggleInList, onCreateAndAdd, onClose }) {
  const ref = useRef(null);
  useEffect(() => {
    function onDoc(e) { if (ref.current && !ref.current.contains(e.target)) onClose(); }
    function onKey(e) { if (e.key === "Escape") onClose(); }
    document.addEventListener("mousedown", onDoc);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
  }, []);
  return (
    <div className="member-menu" ref={ref}>
      <button className="member-menu-item" onClick={() => { onToggleFollow(); }} disabled={followBusy}>
        <span className="check">
          {followBusy ? <span className="spinner sm" aria-hidden="true"></span> : (isFollowing ? "✓" : "")}
        </span>
        <span>{isFollowing ? "Unfollow" : "Follow"}</span>
      </button>
      {lists.length > 0 && <div className="member-menu-heading">Groups</div>}
      <div className="member-menu-scroll">
        {[...lists].sort((a, b) =>
          Nostr.titleOf(a).localeCompare(Nostr.titleOf(b), undefined, { sensitivity: "base" })
        ).map((evt) => {
          const d = Nostr.dTagOf(evt);
          const m = membershipMap[d] || { public: false, private: false };
          const inList = m.public || m.private;
          const busy = !!busyMap[d];
          return (
            <button key={d || evt.id} className="member-menu-item" onClick={() => { onToggleInList(d, m); }} disabled={busy}>
              <span className="check">
                {busy ? <span className="spinner sm" aria-hidden="true"></span> : (inList ? "✓" : "")}
              </span>
              <span className="label">{Nostr.titleOf(evt)}</span>
              {m.private && <span className="hint priv">private</span>}
            </button>
          );
        })}
      </div>
      <div className="member-menu-divider" />
      <button className="member-menu-item" onClick={() => {
        const name = window.prompt("Name the new group:");
        if (name && name.trim()) { onCreateAndAdd(name.trim()); onClose(); }
      }}>
        <span className="check">⊕</span>
        <span>New group</span>
      </button>
    </div>
  );
}

// ---------- Member row --------------------------------------------------
function MemberRow({
  pubkey, profile,
  isFollowing,
  busyMap, followBusy, removeBusy,
  onOpenContact,
  onRemoveFromActive,
}) {
  const npub = Nostr.npubEncode(pubkey);
  const display = profile?.display_name || profile?.name || npub.slice(0, 16) + "…";
  const rowBusy = followBusy || removeBusy || Object.values(busyMap || {}).some(Boolean);
  return (
    <div
      className={"member member-clickable " + (rowBusy ? "busy" : "")}
      onClick={onOpenContact}
      role="button"
      tabIndex={0}
      onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpenContact(); } }}
    >
      <Avatar profile={profile} pubkey={pubkey} size={48} />
      <div className="member-body">
        <div className="member-name">
          <span>{display}</span>
          {isFollowing && <span className="member-following-pill">Following</span>}
        </div>
      </div>
      <div className="member-actions" onClick={(e) => e.stopPropagation()}>
        <button className="remove-btn" onClick={onRemoveFromActive} title="Remove from this list" disabled={removeBusy}>
          {removeBusy ? <span className="spinner sm" aria-hidden="true"></span> : "Remove"}
        </button>
      </div>
    </div>
  );
}

// ---------- Rich text (bio) — links + nostr mentions -------------------
const RICH_RE = /(?:nostr:)?(npub1[023456789acdefghjklmnpqrstuvwxyz]{50,}|nprofile1[023456789acdefghjklmnpqrstuvwxyz]{50,}|nevent1[023456789acdefghjklmnpqrstuvwxyz]{50,}|note1[023456789acdefghjklmnpqrstuvwxyz]{50,})|(https?:\/\/[^\s<>()]+)|(\b(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.){1,3}(?:com|org|net|io|dev|app|xyz|me|co|info|sh|cc|tv|art|ai|so|nl|de|uk|us|ca|tech|fyi|pub|news|page|blog|run|land|life|store|space|cloud|live|to|tools|world|games|gg|casa|chat|im|wtf|computer|host|site|website|email|fm|today)(?:\/[^\s<>()]*)?)/gi;

function renderRichText(text, { profiles, onSelectContact }) {
  if (!text) return null;
  const out = [];
  let last = 0;
  let m;
  RICH_RE.lastIndex = 0;
  while ((m = RICH_RE.exec(text))) {
    const [match, bech32, url, domain] = m;
    if (m.index > last) out.push(text.slice(last, m.index));
    const key = m.index;
    if (bech32) {
      out.push(renderBechMention(bech32, key, profiles, onSelectContact));
    } else if (url) {
      const trimmed = url.replace(/[.,;:!?)\]]+$/, "");
      out.push(<a key={key} href={trimmed} target="_blank" rel="noopener noreferrer">{trimmed}</a>);
      const tail = url.slice(trimmed.length);
      if (tail) out.push(tail);
    } else if (domain) {
      const trimmed = domain.replace(/[.,;:!?)\]]+$/, "");
      out.push(<a key={key} href={`https://${trimmed}`} target="_blank" rel="noopener noreferrer">{trimmed}</a>);
      const tail = domain.slice(trimmed.length);
      if (tail) out.push(tail);
    }
    last = m.index + match.length;
  }
  if (last < text.length) out.push(text.slice(last));
  return out;
}

function renderBechMention(bech32, key, profiles, onSelectContact) {
  const decoded = Nostr.decodeAny(bech32);
  if (decoded?.type === "hex") {
    const pk = decoded.pubkey;
    const p = profiles?.[pk];
    const label = "@" + (p?.display_name || p?.name || (Nostr.npubEncode(pk).slice(5, 13) + "…"));
    if (onSelectContact) {
      return <button key={key} type="button" className="bio-mention" onClick={() => onSelectContact(pk)}>{label}</button>;
    }
    return <a key={key} href={`https://njump.me/${bech32}`} target="_blank" rel="noopener noreferrer" className="bio-mention">{label}</a>;
  }
  // nevent / note → no in-app viewer, link out
  return (
    <a key={key} href={`https://njump.me/${bech32}`} target="_blank" rel="noopener noreferrer" className="bio-mention">
      <svg width="11" height="11" viewBox="0 0 24 24" aria-hidden="true" style={{ verticalAlign: "-1px", marginRight: 3 }}>
        <path d="M6 4h9l5 5v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" />
      </svg>
      note
    </a>
  );
}

// ---------- Activity item rendering ------------------------------------
function trimText(s, n) {
  if (!s) return "";
  s = String(s).replace(/\s+/g, " ").trim();
  return s.length > n ? s.slice(0, n - 1) + "…" : s;
}
function activityBech(evt) {
  try {
    const NT = window.NostrTools;
    const dTag = evt.tags.find(t => t[0] === "d")?.[1];
    const k = evt.kind;
    const isAddr = (k >= 30000 && k < 40000);
    if (isAddr && dTag) {
      return NT.nip19.naddrEncode({ kind: k, pubkey: evt.pubkey, identifier: dTag, relays: [] });
    }
    return NT.nip19.neventEncode({ id: evt.id, author: evt.pubkey, kind: k, relays: [] });
  } catch { return evt.id; }
}
function parseSatsFromBolt11(invoice) {
  if (!invoice) return null;
  const m = /^lnbc(\d+)([munp]?)/i.exec(invoice);
  if (!m) return null;
  const n = parseInt(m[1], 10);
  const unit = m[2].toLowerCase();
  // amount in BTC; multiplier units
  const mult = unit === "m" ? 1e-3 : unit === "u" ? 1e-6 : unit === "n" ? 1e-9 : unit === "p" ? 1e-12 : 1;
  const sats = Math.round(n * mult * 1e8);
  return sats;
}
function renderActivityItem(evt, key, ctx = {}) {
  const tag = (n) => evt.tags.find(t => t[0] === n)?.[1];
  let icon = "•", label = `kind ${evt.kind}`;
  let snippet = trimText(evt.content, 120);
  let snippetNode = null;
  switch (evt.kind) {
    case 1:    icon = "💬"; label = "Note"; snippet = trimText(evt.content, 140) || "(empty)"; break;
    case 6:    icon = "🔁"; label = "Repost"; snippet = "Reposted a note"; break;
    case 7: {
      icon = "❤"; label = "Reaction";
      snippet = evt.content === "+" ? "Liked a note" : evt.content === "-" ? "Disliked a note" : (evt.content || "Reacted");
      break;
    }
    case 9802: icon = "✏"; label = "Highlight"; snippet = trimText(evt.content, 140); break;
    case 1063: icon = "📎"; label = "File"; snippet = tag("name") || tag("url") || tag("alt") || "Shared a file"; break;
    case 30023: icon = "📰"; label = "Article"; snippet = tag("title") || trimText(evt.content, 140); break;
    case 31922: case 31923: icon = "📅"; label = "Event"; snippet = tag("title") || tag("name") || "Calendar event"; break;
    case 31925: {
      icon = "🗓"; label = "RSVP";
      const status = tag("status");
      snippet = "Replied to an event" + (status ? ` · ${status}` : "");
      break;
    }
    case 39701: icon = "🔖"; label = "Web bookmark"; snippet = tag("d") || tag("title") || tag("url") || trimText(evt.content, 100); break;
    case 30315: icon = "🟢"; label = "Status"; snippet = trimText(evt.content, 140) || "Updated status"; break;
    case 10003: icon = "📑"; label = "Bookmarks"; snippet = "Updated bookmarks"; break;
    case 1985:  icon = "🏷"; label = "Label"; snippet = trimText(evt.content, 100) || tag("L") || "Labeled something"; break;
    case 9735: {
      icon = "⚡";
      const bolt = tag("bolt11");
      const amtTag = tag("amount");
      let sats = null;
      if (amtTag) sats = Math.floor(Number(amtTag) / 1000);
      else sats = parseSatsFromBolt11(bolt);
      const satsStr = sats ? `${sats.toLocaleString()} sats` : "a zap";

      const recipientPk = tag("p");
      let senderPk = tag("P");
      let zapComment = "";
      if (!senderPk || !zapComment) {
        const desc = tag("description");
        if (desc) {
          try {
            const req = JSON.parse(desc);
            if (!senderPk && req.pubkey) senderPk = req.pubkey;
            if (req.content) zapComment = req.content;
          } catch {}
        }
      }
      const dir = evt._zapDir || (recipientPk === ctx.currentPk ? "in" : "out");
      const otherPk = dir === "in" ? senderPk : recipientPk;
      label = dir === "out" ? "Sent zap" : "Received zap";
      const verb = dir === "out" ? "Sent" : "Received";
      const prep = dir === "out" ? "to" : "from";
      const partyName = otherPk
        ? (ctx.profiles?.[otherPk]?.display_name || ctx.profiles?.[otherPk]?.name || ("@" + Nostr.npubEncode(otherPk).slice(5, 13) + "…"))
        : null;
      const partyEl = otherPk ? (
        <button
          className="bio-mention"
          onClick={(e) => { e.preventDefault(); e.stopPropagation(); ctx.onSelectContact?.(otherPk); }}
        >{partyName}</button>
      ) : null;
      snippetNode = (
        <span>
          {`${verb} ${satsStr}`}{partyEl ? <> {prep} {partyEl}</> : null}
          {zapComment ? <span className="activity-zap-comment"> · “{trimText(zapComment, 80)}”</span> : null}
        </span>
      );
      snippet = `${verb} ${satsStr}${partyName ? ` ${prep} ${partyName}` : ""}`;
      break;
    }
  }
  const link = `https://njump.me/${activityBech(evt)}`;
  return (
    <a key={key} className="activity-item" href={link} target="_blank" rel="noopener noreferrer">
      <span className="activity-icon" aria-hidden="true">{icon}</span>
      <div className="activity-text">
        <div className="activity-snippet">{snippetNode || snippet}</div>
        <div className="activity-meta"><span>{label}</span> · <span>{humanTime(evt.created_at)}</span></div>
      </div>
    </a>
  );
}

// ---------- Contact detail (inline panel) ------------------------------
function ContactDetail({
  pubkey, profile, lastSeen,
  isFollowing, lists, membershipMap,
  busyMap, followBusy,
  isFavorite, favoriteBusy, onToggleFavorite,
  onToggleFollow, onToggleInList, onCreateAndAdd,
  onSelectList, profiles, onSelectContact, onToast, profileLoading,
  onNeedProfiles,
}) {
  const [menuOpen, setMenuOpen] = useState(false);
  const [zapOpen, setZapOpen] = useState(false);
  const [activity, setActivity] = useState(null);
  useEffect(() => {
    let cancelled = false;
    setActivity(null);
    Nostr.fetchActivity(pubkey).then(evs => { if (!cancelled) setActivity(evs); })
      .catch(() => { if (!cancelled) setActivity([]); });
    return () => { cancelled = true; };
  }, [pubkey]);
  useEffect(() => {
    if (!activity || !onNeedProfiles) return;
    const need = new Set();
    for (const e of activity) {
      if (e.kind !== 9735) continue;
      const p = e.tags.find(t => t[0] === "p")?.[1];
      let P = e.tags.find(t => t[0] === "P")?.[1];
      if (!P) {
        const desc = e.tags.find(t => t[0] === "description")?.[1];
        if (desc) { try { const r = JSON.parse(desc); if (r.pubkey) P = r.pubkey; } catch {} }
      }
      [p, P].forEach(pk => { if (pk && pk !== pubkey && !profiles?.[pk]) need.add(pk); });
    }
    if (need.size) onNeedProfiles(Array.from(need));
  }, [activity, profiles, pubkey]);
  const npub = Nostr.npubEncode(pubkey);
  const display = profile?.display_name || profile?.name || npub.slice(0, 16) + "…";
  const handle = profile?.name && profile?.name !== display ? "@" + profile.name : null;
  const nip05 = profile?.nip05?.replace(/^_@/, "");
  const bio = profile?.about;
  const lud = profile?.lud16 || profile?.lud06;
  const website = profile?.website;
  const banner = profile?.banner;
  const seen = lastSeen ? humanTime(lastSeen) : null;

  const memberOf = (lists || []).filter(evt => {
    const d = Nostr.dTagOf(evt);
    const m = membershipMap?.[d];
    return m && (m.public || m.private);
  });

  const copyNpub = () => { navigator.clipboard?.writeText(npub); };

  if (profileLoading && !profile) {
    return (
      <div className="contact-detail contact-loading">
        <div className="contact-banner contact-banner-fallback" />
        <div className="contact-loading-body">
          <span className="spinner lg" aria-hidden="true"></span>
          <span>Loading profile…</span>
        </div>
      </div>
    );
  }

  return (
    <div className="contact-detail">
      <div className="contact-banner-wrap">
        {banner ? (
          <div className="contact-banner" style={{ backgroundImage: `url(${banner})` }} />
        ) : (
          <div className="contact-banner contact-banner-fallback" />
        )}
        <div className="contact-banner-actions">
          <button
            className={"contact-banner-follow follow-btn " + (isFollowing ? "is-following" : "")}
            onClick={onToggleFollow}
            disabled={followBusy}
          >
            {followBusy
              ? <span className="spinner sm" aria-hidden="true"></span>
              : (isFollowing ? "Following" : "Follow")}
          </button>
          {onToggleFavorite && (
            <button
              className={"contact-favorite " + (isFavorite ? "is-fav" : "")}
              onClick={onToggleFavorite}
              disabled={favoriteBusy}
              title={isFavorite ? "Remove from Favorites" : "Add to Favorites"}
              aria-label={isFavorite ? "Remove from Favorites" : "Add to Favorites"}
              aria-pressed={isFavorite}
            >
              {favoriteBusy ? (
                <span className="spinner sm" aria-hidden="true"></span>
              ) : (
                <svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
                  <path
                    d="M12 2.5l2.95 5.98 6.6.96-4.78 4.66 1.13 6.57L12 17.6l-5.9 3.07 1.13-6.57L2.45 9.44l6.6-.96L12 2.5z"
                    fill={isFavorite ? "currentColor" : "none"}
                    stroke="currentColor"
                    strokeWidth="2"
                    strokeLinejoin="round"
                  />
                </svg>
              )}
            </button>
          )}
        </div>
      </div>

        <div className="contact-head">
          <div className="contact-avatar"><Avatar profile={profile} pubkey={pubkey} size={144} /></div>
          <div className="contact-name-block">
            <h2>{display}</h2>
            {handle && <div className="contact-handle">{handle}</div>}
            {nip05 && <div className="contact-nip05">{nip05}</div>}
          </div>
          <div className="contact-actions">
            {memberOf.length > 0 && (
              <div className="contact-group-chips">
                {[...memberOf]
                  .sort((a, b) => Nostr.titleOf(a).localeCompare(Nostr.titleOf(b), undefined, { sensitivity: "base" }))
                  .map(evt => {
                    const d = Nostr.dTagOf(evt);
                    const m = membershipMap[d] || {};
                    return (
                      <button
                        key={d || evt.id}
                        className={"contact-group-chip" + (m.private ? " is-private" : "")}
                        onClick={() => onSelectList?.(d)}
                        title={m.private ? "Private group" : "Group"}
                      >
                        {Nostr.titleOf(evt)}
                      </button>
                    );
                  })}
              </div>
            )}
            <div className="lists-btn-wrap">
              <button
                className={"lists-btn " + (memberOf.length > 0 ? "has-lists" : "")}
                onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v); }}
                aria-expanded={menuOpen}
              >
                <svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
                  <path d="M4 6h16M4 12h16M4 18h10" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
                </svg>
                <span>Groups{memberOf.length > 0 ? ` · ${memberOf.length}` : ""}</span>
                <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
                  <path d="M2 3.5l3 3 3-3" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              </button>
              {menuOpen && (
                <MemberMenu
                  pubkey={pubkey}
                  isFollowing={isFollowing}
                  lists={lists}
                  membershipMap={membershipMap}
                  busyMap={busyMap || {}}
                  followBusy={followBusy}
                  onToggleFollow={onToggleFollow}
                  onToggleInList={onToggleInList}
                  onCreateAndAdd={onCreateAndAdd}
                  onClose={() => setMenuOpen(false)}
                />
              )}
            </div>
          </div>
        </div>

        <div className="contact-body">
          {bio && (
            <section className="contact-section">
              <h4>About</h4>
              <p className="contact-bio">{renderRichText(bio, { profiles, onSelectContact })}</p>
            </section>
          )}

          <div className="contact-2col">
          <section className="contact-section">
            <h4>Details</h4>
            <dl className="contact-details">
              {nip05 && (<><dt>NIP-05</dt><dd>{nip05}</dd></>)}
              {lud && (<><dt>Lightning</dt>
                <dd>
                  <span>{lud}</span>
                  <button className="zap-btn" onClick={() => setZapOpen(true)} title="Send a zap" aria-label="Send a zap">
                    <svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
                      <path d="M13 2L4 14h7l-1 8 9-12h-7l1-8z" fill="currentColor" />
                    </svg>
                    <span>Zap</span>
                  </button>
                </dd></>)}
              {website && (
                <><dt>Website</dt><dd>
                  <a href={/^https?:\/\//.test(website) ? website : `https://${website}`}
                     target="_blank" rel="noopener noreferrer">{website}</a>
                </dd></>
              )}
              <dt>npub</dt>
              <dd>
                <span className="contact-npub" title={npub}>{npub}</span>
                <button className="copy-btn" onClick={copyNpub} title="Copy" aria-label="Copy">
                  <svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
                    <rect x="8" y="8" width="12" height="12" rx="2" fill="none" stroke="currentColor" strokeWidth="2" />
                    <path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
                  </svg>
                </button>
              </dd>
              {seen && (<><dt>Last seen</dt><dd>{seen}</dd></>)}
            </dl>
          </section>

          <section className="contact-section">
            <h4>Latest activity</h4>
            {activity === null ? (
              <div className="activity-loading">
                <span className="spinner sm" aria-hidden="true"></span>
                <span>Loading activity…</span>
              </div>
            ) : activity.length === 0 ? (
              <div className="contact-empty">No public activity yet.</div>
            ) : (
              <div className="activity-list">
                {activity.map((e, i) => renderActivityItem(e, e.id || i, { profiles, currentPk: pubkey, onSelectContact }))}
              </div>
            )}
          </section>
          </div>

        </div>
      {zapOpen && lud && (
        <ZapDialog pubkey={pubkey} profile={profile} onClose={() => setZapOpen(false)} onToast={onToast} />
      )}
    </div>
  );
}

function humanTime(ts) {
  const now = Math.floor(Date.now() / 1000);
  const d = now - ts;
  if (d < 60) return `${d}s ago`;
  if (d < 3600) return `${Math.floor(d / 60)}m ago`;
  if (d < 86400) return `${Math.floor(d / 3600)}h ago`;
  if (d < 30 * 86400) return `${Math.floor(d / 86400)}d ago`;
  return new Date(ts * 1000).toLocaleDateString();
}

// ---------- Topbar search ----------------------------------------------
function TopbarSearch({ profiles, followingSet, listedSet, onSearchProfiles, onSelect }) {
  const [q, setQ] = useState("");
  const [results, setResults] = useState([]);
  const [busy, setBusy] = useState(false);
  const [open, setOpen] = useState(false);
  const [verifyMap, setVerifyMap] = useState({});
  const debRef = useRef(null);
  const wrapRef = useRef(null);
  const reqIdRef = useRef(0);

  useEffect(() => {
    function onDoc(e) { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); }
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, []);

  useEffect(() => {
    if (debRef.current) clearTimeout(debRef.current);
    const text = q.trim();
    if (!text) { setResults([]); setBusy(false); setVerifyMap({}); return; }
    setBusy(true);
    debRef.current = setTimeout(async () => {
      const myReq = ++reqIdRef.current;
      let rows = [];
      const direct = Nostr.decodeAny(text);
      if (direct?.type === "hex") {
        rows = [{ pubkey: direct.pubkey, profile: profiles[direct.pubkey] || null }];
      } else if (direct?.type === "nip05") {
        const pk = await Nostr.resolveNip05(direct.value);
        if (pk) rows = [{ pubkey: pk, profile: profiles[pk] || null }];
      }
      if (!rows.length) {
        const evs = await onSearchProfiles(text);
        const byPk = new Map();
        for (const e of evs) {
          const prev = byPk.get(e.pubkey);
          if (prev && prev.created_at >= e.created_at) continue;
          byPk.set(e.pubkey, e);
        }
        rows = Array.from(byPk.values()).map(e => {
          let c = {}; try { c = JSON.parse(e.content); } catch {}
          return { pubkey: e.pubkey, profile: { ...c, pubkey: e.pubkey } };
        });
      }
      const tier = (pk) =>
        followingSet?.has(pk) ? 0 :
        listedSet?.has(pk) ? 1 : 2;
      rows.sort((a, b) => tier(a.pubkey) - tier(b.pubkey));
      rows = rows.slice(0, 12);
      if (myReq !== reqIdRef.current) return;
      setResults(rows);
      setBusy(false);
      setOpen(true);
      // verify nip-05 for each result that has one
      const work = rows
        .filter(r => r.profile?.nip05)
        .map(async r => {
          const verifiedPk = await Nostr.resolveNip05(r.profile.nip05);
          return [r.pubkey, !!verifiedPk && verifiedPk.toLowerCase() === r.pubkey.toLowerCase()];
        });
      const settled = await Promise.allSettled(work);
      if (myReq !== reqIdRef.current) return;
      const next = {};
      for (const s of settled) if (s.status === "fulfilled") { const [pk, ok] = s.value; next[pk] = ok; }
      setVerifyMap(next);
    }, 320);
    return () => clearTimeout(debRef.current);
  }, [q]);

  return (
    <div className="topbar-search" ref={wrapRef}>
      <div className="topbar-search-input">
        <svg className="topbar-search-icon" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
          <circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" strokeWidth="2" />
          <path d="M20 20l-3.5-3.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
        </svg>
        <input
          placeholder="Search globally…"
          value={q}
          onChange={(e) => setQ(e.target.value)}
          onFocus={() => results.length && setOpen(true)}
          onKeyDown={(e) => { if (e.key === "Enter") e.preventDefault(); }}
        />
        {busy && <span className="spinner sm topbar-search-spinner" aria-hidden="true"></span>}
      </div>
      {open && (results.length > 0 || (!busy && q.trim())) && (
        <div className="topbar-search-results">
          {results.length === 0 ? (
            <div className="topbar-search-empty">No matches.</div>
          ) : (
            results.map(r => {
              const npub = Nostr.npubEncode(r.pubkey);
              const display = r.profile?.display_name || r.profile?.name || "Anonymous";
              const nip05 = r.profile?.nip05?.replace(/^_@/, "");
              const verified = verifyMap[r.pubkey];
              return (
                <button
                  key={r.pubkey}
                  className="topbar-search-row"
                  onClick={() => { onSelect(r.pubkey, r.profile); setQ(""); setResults([]); setOpen(false); }}
                >
                  <Avatar profile={r.profile} pubkey={r.pubkey} size={36} />
                  <div className="topbar-search-meta">
                    <div className="topbar-search-name">
                      <span>{display}</span>
                      {nip05 && verified && (
                        <span className="topbar-search-nip05">
                          <svg width="12" height="12" viewBox="0 0 24 24" aria-hidden="true">
                            <path d="M5 12l5 5 9-11" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
                          </svg>
                          {nip05}
                        </span>
                      )}
                    </div>
                    <div className="topbar-search-npub">{npub}</div>
                  </div>
                </button>
              );
            })
          )}
        </div>
      )}
    </div>
  );
}

// ---------- New-list dialog --------------------------------------------
function EditListDialog({ initialTitle, initialDesc, onClose, onSave, onDelete }) {
  const [title, setTitle] = useState(initialTitle || "");
  const [desc, setDesc] = useState(initialDesc || "");
  const [confirmDel, setConfirmDel] = useState(false);
  return (
    <div className="dialog-backdrop" onClick={onClose}>
      <div className="dialog" onClick={(e) => e.stopPropagation()}>
        <h3>Edit group</h3>
        <div className="field">
          <label>Label</label>
          <input value={title} onChange={(e) => setTitle(e.target.value)} autoFocus />
        </div>
        <div className="field">
          <label>Description (optional)</label>
          <textarea value={desc} onChange={(e) => setDesc(e.target.value)} />
        </div>
        <div className="dialog-actions" style={{ justifyContent: "space-between" }}>
          <button
            className={"btn " + (confirmDel ? "btn-danger" : "btn-ghost")}
            onClick={() => {
              if (confirmDel) { onDelete(); onClose(); }
              else setConfirmDel(true);
            }}
            title="Delete group"
            aria-label="Delete group"
          >
            <svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
              <path d="M4 7h16M9 7V4h6v3M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13M10 11v7M14 11v7" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
            </svg>
            <span style={{ marginLeft: 6 }}>{confirmDel ? "Confirm delete" : "Delete"}</span>
          </button>
          <div style={{ display: "flex", gap: 10 }}>
            <button className="btn btn-ghost" onClick={onClose}>Cancel</button>
            <button
              className="btn btn-primary"
              disabled={!title.trim()}
              onClick={() => { onSave(title.trim(), desc.trim()); onClose(); }}
            >
              Save
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

function NewListDialog({ onClose, onCreate }) {
  const [title, setTitle] = useState("");
  const [desc, setDesc] = useState("");
  return (
    <div className="dialog-backdrop" onClick={onClose}>
      <div className="dialog" onClick={(e) => e.stopPropagation()}>
        <h3>New group</h3>
        <div className="field">
          <label>Label</label>
          <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="E.g. Family, Friends, Work…" autoFocus />
        </div>
        <div className="field">
          <label>Description (optional)</label>
          <textarea value={desc} onChange={(e) => setDesc(e.target.value)} placeholder="A short note about this group" />
        </div>
        <div className="dialog-actions">
          <button className="btn btn-ghost" onClick={onClose}>Cancel</button>
          <button className="btn btn-primary" disabled={!title.trim()} onClick={() => onCreate(title.trim(), desc.trim())}>
            Create group
          </button>
        </div>
      </div>
    </div>
  );
}


// ---------- Activity dialog ---------------------------------------------
function ActivityDialog({ entries, profiles, onClose, onClear }) {
  useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, []);
  const actionLabel = (a) => ({
    "follow": "Followed",
    "unfollow": "Unfollowed",
    "added-to-list": "Added to",
    "removed-from-list": "Removed from",
    "list-snapshot": "Latest version of",
  }[a] || a);
  return (
    <div className="dialog-backdrop" onClick={onClose}>
      <div className="dialog activity-dialog" onClick={(e) => e.stopPropagation()}>
        <div className="activity-head">
          <h3>Activity</h3>
          <div style={{ display: "flex", gap: 8 }}>
            {entries.length > 0 && <button className="btn btn-ghost" onClick={onClear}>Clear</button>}
            <button className="btn btn-ghost" onClick={onClose}>Close</button>
          </div>
        </div>
        <div className="activity-list">
          {entries.length === 0 && (
            <div className="empty-state">No activity yet. Follow or list changes will appear here.</div>
          )}
          {entries.map((e, i) => {
            const p = profiles[e.pubkey];
            const isSnap = e.action === "list-snapshot";
            const name = !isSnap && (p?.display_name || p?.name || (Nostr.npubEncode(e.pubkey).slice(0, 16) + "…"));
            return (
              <div key={i} className="activity-row">
                {isSnap ? (
                  <div className="avatar" style={{ width: 36, height: 36, display: "grid", placeContent: "center", background: "var(--bg-elev)" }}>
                    <svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
                      <path d="M4 6h16M4 12h16M4 18h10" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
                    </svg>
                  </div>
                ) : (
                  <Avatar profile={p} pubkey={e.pubkey} size={36} />
                )}
                <div className="activity-body">
                  <div className="activity-line">
                    <span className="activity-action">{actionLabel(e.action)}</span>
                    {!isSnap && <span className="activity-name"> {name} </span>}
                    {(e.action === "added-to-list" || e.action === "removed-from-list") && (
                      <>
                        <span className="activity-action">{e.action === "added-to-list" ? "to" : "from"}</span>
                        <span className="activity-target"> {e.targetTitle}</span>
                        {e.private && <span className="hint priv"> private</span>}
                      </>
                    )}
                    {isSnap && (
                      <>
                        <span className="activity-target"> {e.targetTitle}</span>
                        <span className="activity-action"> · {e.count} {e.count === 1 ? "contact" : "contacts"}</span>
                        {e.private && <span className="hint priv"> has private</span>}
                      </>
                    )}
                  </div>
                </div>
                <div className="activity-time">{humanTime(e.ts)}</div>
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

// ---------- Zap dialog -------------------------------------------------
function ZapDialog({ pubkey, profile, onClose, onToast }) {
  const [amount, setAmount] = useState(21);
  const [comment, setComment] = useState("");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const [ok, setOk] = useState(false);
  useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, []);
  const lud = profile?.lud16 || profile?.lud06;
  const presets = [21, 100, 500, 1000, 5000, 21000];

  async function send() {
    setErr("");
    setBusy(true);
    try {
      const sats = Math.max(1, Math.floor(Number(amount) || 0));
      const msats = sats * 1000;
      const meta = await Nostr.fetchLnurlPay(lud);
      if (!meta.allowsNostr || !meta.nostrPubkey) {
        throw new Error("This wallet doesn't support Nostr zaps. Sending a regular Lightning payment instead.");
      }
      if (msats < (meta.minSendable || 1) || msats > (meta.maxSendable || Infinity)) {
        const min = Math.ceil((meta.minSendable || 1000) / 1000);
        const max = Math.floor((meta.maxSendable || 1000) / 1000);
        throw new Error(`Amount must be between ${min} and ${max} sats.`);
      }
      const tmpl = Nostr.buildZapRequest({ recipientPk: pubkey, amountMsats: msats, comment });
      const signed = await Nostr.signEvent(tmpl);
      const invoice = await Nostr.fetchZapInvoice(meta.callback, msats, signed);
      await Nostr.payInvoice(invoice);
      setOk(true);
      onToast?.(`Zapped ${sats} sats ⚡`, "ok");
      setTimeout(onClose, 900);
    } catch (e) {
      console.error(e);
      // try fallback: regular LN invoice without nostr tag
      try {
        if (e.message?.startsWith("This wallet doesn't")) {
          const sats = Math.max(1, Math.floor(Number(amount) || 0));
          const msats = sats * 1000;
          const meta = await Nostr.fetchLnurlPay(lud);
          const u = new URL(meta.callback);
          u.searchParams.set("amount", String(msats));
          if (comment && (meta.commentAllowed || 0) >= comment.length) u.searchParams.set("comment", comment);
          const r = await fetch(u.toString()); const j = await r.json();
          if (!j.pr) throw new Error(j.reason || "No invoice");
          await Nostr.payInvoice(j.pr);
          setOk(true);
          onToast?.(`Sent ${sats} sats ⚡`, "ok");
          setTimeout(onClose, 900);
          return;
        }
      } catch (e2) { setErr(e2.message || String(e2)); setBusy(false); return; }
      setErr(e.message || String(e));
      setBusy(false);
    }
  }

  return (
    <div className="dialog-backdrop" onClick={onClose}>
      <div className="dialog zap-dialog" onClick={(e) => e.stopPropagation()}>
        <h3>Send a zap</h3>
        <div className="zap-target">
          <Avatar profile={profile} pubkey={pubkey} size={40} />
          <div>
            <div className="zap-target-name">{profile?.display_name || profile?.name || "Anonymous"}</div>
            <div className="zap-target-lud">{lud}</div>
          </div>
        </div>
        <div className="field">
          <label>Amount (sats)</label>
          <div className="zap-presets">
            {presets.map(p => (
              <button
                key={p}
                type="button"
                className={"zap-preset" + (Number(amount) === p ? " active" : "")}
                onClick={() => setAmount(p)}
              >{p.toLocaleString()}</button>
            ))}
          </div>
          <input
            type="number"
            min="1"
            value={amount}
            onChange={(e) => setAmount(e.target.value)}
          />
        </div>
        <div className="field">
          <label>Comment (optional)</label>
          <input value={comment} onChange={(e) => setComment(e.target.value)} placeholder="Thanks!" maxLength={140} />
        </div>
        {err && <div className="zap-error">{err}</div>}
        <div className="dialog-actions">
          <button className="btn btn-ghost" onClick={onClose} disabled={busy}>Cancel</button>
          <button className="btn btn-primary" onClick={send} disabled={busy || ok || !amount}>
            {busy ? "Sending…" : ok ? "Sent ⚡" : `Zap ${Number(amount) || 0} sats`}
          </button>
        </div>
      </div>
    </div>
  );
}

// ---------- Help dialog ------------------------------------------------
function HelpDialog({ onClose }) {
  useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, []);
  return (
    <div className="dialog-backdrop" onClick={onClose}>
      <div className="dialog help-dialog" onClick={(e) => e.stopPropagation()}>
        <div className="activity-head">
          <h3>About &amp; help</h3>
          <button className="btn btn-ghost" onClick={onClose}>Close</button>
        </div>
        <div className="help-body">
          <p>
            <b>Contacts</b> is a manager for your nostr contact list and the groups you organise people into.
            It runs entirely in your browser — your private key never leaves your NIP-07 extension.
          </p>

          <h4>Following</h4>
          <p>
            Your master follow list is a single nostr event of <code>kind 3</code> (NIP-02).
            Every relay you connect to keeps the latest version. When you follow or unfollow someone,
            a new version is signed by your extension and republished.
          </p>

          <h4>Groups</h4>
          <p>
            Groups are categorised people lists, stored as <code>kind 30000</code> events (NIP-51).
            Each group has a <code>d</code> identifier and a title. You can keep members public (visible to anyone)
            or private (NIP-04 encrypted in <code>.content</code> — only you can read them).
          </p>

          <h4>Relays</h4>
          <p>
            Reads and writes happen against a default set of public relays. The pulse in the topbar shows the current state:
            idle, connected, or publishing.
          </p>

          <h4>Privacy</h4>
          <p>
            No keys, profiles, or contact data are ever sent to a server controlled by this app.
            Signing happens locally in your NIP-07 browser extension; everything else is plain nostr traffic to relays.
          </p>

          <h4>Source &amp; license</h4>
          <p>
            Contacts is open source and released under the{" "}
            <a href="https://opensource.org/licenses/MIT" target="_blank" rel="noopener noreferrer">MIT license</a>.
            The code lives at{" "}
            <a href="https://gitlab.com/digitalethicsagency/nostr/contacts" target="_blank" rel="noopener noreferrer">
              gitlab.com/digitalethicsagency/nostr/contacts
            </a>.
          </p>
        </div>
      </div>
    </div>
  );
}

Object.assign(window, { Avatar, Topbar, Sidebar, MemberRow, MemberMenu, TopbarSearch, NewListDialog, EditListDialog, ActivityDialog, ContactDetail, HelpDialog, ZapDialog, humanTime });
