// Campaign Detail — all tabs.
// `feature` ties a tab to a realm/campaign feature flag. Tabs without a `feature`
// always show. The 'org' bucket covers Guild OR Tribe depending on realm.orgKind.
// 'shops' bucket covers shops/materials/homebrew/forge as a single toggle (per
// the design Q: "Shops + Forge + Homebrew" group).
const TABS = [
  { id: 'overview',   label: 'Overview' },
  { id: 'sessions',   label: 'Sessions' },
  { id: 'lore',       label: 'Lore & Notes' },
  { id: 'quests',     label: 'Requests',         feature: 'association' },
  { id: 'npcs',       label: 'NPCs' },
  { id: 'inventory',  label: 'Party Inventory' },
  { id: 'spellbooks', label: 'Spellbooks' },
  { id: 'guild',      label: 'Guild',            feature: 'org', orgKinds: ['guild'] },
  { id: 'tribe',      label: 'Tribe',            feature: 'org', orgKinds: ['tribe'] },
  { id: 'shops',      label: 'Shops',            feature: 'shops' },
  { id: 'materials',  label: 'Materials',        feature: 'shops' },
  { id: 'homebrew',   label: 'Homebrew',         feature: 'shops' },
  { id: 'forge',      label: 'Forge',            feature: 'shops' },
  { id: 'map',        label: 'World Map',        feature: 'worldMap' },
];

const statusColor = (s) => s === 'active' ? '#22c55e' : s === 'completed' ? '#6b6966' : '#b45309';
const priorityColor = (p) => p === 'critical' ? '#991b1b' : p === 'high' ? '#c53030' : p === 'medium' ? '#b45309' : '#1e6b3c';

// Augment API CampaignDetailDto with derived fields the tab components rely on.
// `members` now carries per-campaign roles ({userId, name, avatar, role}).
// We derive a list of DM ids and player ids for legacy callers, plus expose
// a `players` array so old code that did `campaign.players.map(...)` still works.
const augmentCampaign = (c) => {
  if (!c) return c;
  const members = c.members || [];
  const dms     = members.filter(m => m.role === 'DM' || m.role === 'Co-DM');
  const players = members.filter(m => m.role === 'Player');
  return {
    ...c,
    members,
    dmIds:     dms.map(m => m.userId),
    playerIds: players.map(m => m.userId),
    // Legacy: many UIs read `.players` as a list of player objects (name, avatar, role, id).
    // Map members → player-shape (id field aliased from userId for back-compat).
    players: players.map(m => ({ id: m.userId, userId: m.userId, name: m.name, avatar: m.avatar, role: m.role })),
  };
};

const CampaignView = ({ campaignId, user, tab, setTab, setNav }) => {
  const [campaign, setCampaign] = React.useState(null);
  const [realm, setRealm] = React.useState(null);
  const [loadError, setLoadError] = React.useState(null);
  const [loading, setLoading] = React.useState(true);
  // Edit-meta modal state lives up here (not below the early returns) so the
  // hook count stays stable across "loading" vs "loaded" renders.
  const [editingMeta, setEditingMeta] = React.useState(false);

  // Stable reload helper — passed down to children that mutate roster /
  // invites / campaign meta so they can refresh without going through window
  // events or re-rendering the whole tree.
  const reloadCampaign = React.useCallback(async () => {
    try {
      const fresh = await api.campaigns.get(campaignId);
      setCampaign(augmentCampaign(fresh));
      return fresh;
    } catch (err) {
      console.error('[CampaignView] reload failed:', err);
    }
  }, [campaignId]);

  React.useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setLoadError(null);
    setRealm(null);
    api.campaigns.get(campaignId)
      .then(c => {
        if (cancelled) return;
        setCampaign(augmentCampaign(c));
        setLoading(false);
        // Best-effort realm load — campaign still renders if realm fetch fails (treats as defaults).
        if (c && c.realmId) {
          api.realms.get(c.realmId)
            .then(r => { if (!cancelled) setRealm(r); })
            .catch(err => console.error('[CampaignView] realm load failed:', err));
        }
      })
      .catch(err => {
        if (cancelled) return;
        setLoadError(err.status === 404 ? 'Campaign not found.' : (err.message || 'Failed to load campaign.'));
        setLoading(false);
      });
    return () => { cancelled = true; };
  }, [campaignId]);

  // Effective feature flags = realm defaults overridden by per-campaign flags.
  // Computed unconditionally (campaign/realm may be null pre-load) so the hook
  // count stays stable across renders — required by rules of hooks.
  const features    = api.effectiveFeatures(campaign, realm);
  const orgKind     = api.orgKindFor(realm);
  const visibleTabs = TABS.filter(t => {
    if (t.feature && features[t.feature] === false) return false;
    if (t.orgKinds && !t.orgKinds.includes(orgKind)) return false;
    return true;
  });
  const visibleTabKey = visibleTabs.map(t => t.id).join(',');
  // If the active tab got hidden (e.g. realm was changed and 'shops' is now off),
  // bounce to overview so we don't render a now-disabled tab. Must run BEFORE
  // the early returns below so the hook order stays consistent.
  React.useEffect(() => {
    if (campaign && !visibleTabs.find(t => t.id === tab)) setTab('overview');
  }, [campaign, visibleTabKey, tab]);

  if (loading)   return <div style={{color:'#9a9793',padding:40}}>Loading campaign…</div>;
  if (loadError) return <div style={{color:'#9a9793',padding:40}}>{loadError}</div>;
  if (!campaign) return <div style={{color:'#9a9793',padding:40}}>Campaign not found.</div>;

  const players = campaign.players || [];
  // isDM is true for the campaign's DM/Co-DM or for system Admins. Last-DM
  // checks etc. still happen server-side; this just gates UI affordances.
  const isDM    = user.isAdmin || isDmOf(campaign);
  // Old code referenced `dm` as the single DM object; with multi-DM we expose
  // the list and let consumers pick the first for compact display.
  const dms     = dmsOf(campaign);
  const dm      = dms[0] || null;
  const isArchived = campaign.status === 'archived';

  // Deactivate / reactivate this campaign. DMs and Admins can do it from here.
  // After deactivating, drop back to the dashboard since the campaign view will
  // re-render with a banner indicating the archived state.
  const toggleCampaignActive = async () => {
    const goingInactive = !isArchived;
    if (goingInactive) {
      const ok = await window.dialog.confirm({
        title: 'Deactivate campaign?',
        message: `Mark "${campaign.name}" as deactivated? It stays in the database (sessions, characters, all data preserved) and can be reactivated later. This is reversible.`,
        confirmLabel: 'Deactivate',
        danger: false,
      });
      if (!ok) return;
    }
    try {
      await api.campaigns.update(campaign.id, { status: goingInactive ? 'archived' : 'active' });
      await reloadCampaign();
    } catch (err) {
      console.error('[CampaignView] toggle active failed:', err);
      window.dialog.alert((err.body && err.body.message) || err.message || 'Failed to update', { title:'Error' });
    }
  };

  return (
    <div style={cvStyles.page}>
      <div style={{...cvStyles.banner, background:`linear-gradient(135deg,${campaign.bannerColor} 0%,#111116 100%)`, opacity: isArchived ? 0.7 : 1}}>
        <div>
          <h1 style={cvStyles.title}>
            {campaign.name}
            {isArchived && <span style={{fontSize:11, marginLeft:10, padding:'3px 10px', borderRadius:20, background:'rgba(180,83,9,0.25)', color:'#fbbf24', textTransform:'uppercase', letterSpacing:'0.1em', fontWeight:800, verticalAlign:'middle'}}>Deactivated</span>}
          </h1>
          <div style={cvStyles.metaRow}>
            <span style={{color:'rgba(255,255,255,0.6)',fontSize:13}}>DM: <strong style={{color:'rgba(255,255,255,0.85)'}}>{dmNamesOf(campaign) || '—'}</strong></span>
            <span style={{color:'rgba(255,255,255,0.4)'}}>·</span>
            <span style={{color:'rgba(255,255,255,0.6)',fontSize:13}}>{campaign.sessionCount} Sessions</span>
            <span style={{color:'rgba(255,255,255,0.4)'}}>·</span>
            <span style={{color:'rgba(255,255,255,0.6)',fontSize:13}}>{campaign.setting}</span>
          </div>
        </div>
        <div style={{display:'flex', flexDirection:'column', gap:10, alignItems:'flex-end'}}>
          <div style={{display:'flex',gap:8}}>
            {players.map(p=>(
              <div key={p.id} title={p.name} style={{...cvStyles.playerAvatar,background:roleColor(p.role)}}>{p.avatar}</div>
            ))}
          </div>
          {isDM && (
            <div style={{display:'flex', gap:6}}>
              <button onClick={() => setEditingMeta(true)}
                title="Edit campaign name, setting, description, banner color, realm"
                style={{
                  background: 'rgba(255,255,255,0.06)',
                  border: '1px solid rgba(255,255,255,0.2)',
                  color: '#e8e6e3',
                  borderRadius: 6, padding: '6px 14px', cursor:'pointer',
                  fontSize: 12, fontWeight: 700, fontFamily:"'Nunito',sans-serif",
                }}>
                Edit Campaign
              </button>
              <button onClick={toggleCampaignActive}
                title={isArchived ? 'Restore this campaign to active status' : 'Mark this campaign as deactivated. Reversible.'}
                style={{
                  background: isArchived ? 'rgba(34,197,94,0.15)' : 'rgba(180,83,9,0.15)',
                  border: '1px solid ' + (isArchived ? '#22c55e55' : '#b4530955'),
                  color: isArchived ? '#22c55e' : '#fbbf24',
                  borderRadius: 6, padding: '6px 14px', cursor:'pointer',
                  fontSize: 12, fontWeight: 700, fontFamily:"'Nunito',sans-serif",
                }}>
                {isArchived ? 'Reactivate Campaign' : 'Deactivate Campaign'}
              </button>
            </div>
          )}
        </div>
      </div>
      <div style={cvStyles.tabs}>
        {visibleTabs.map(t=>(
          <button key={t.id} style={{...cvStyles.tab,...(tab===t.id?cvStyles.tabActive:{})}} onClick={()=>setTab(t.id)}>{t.label}</button>
        ))}
      </div>
      <div style={cvStyles.content}>
        {tab==='overview'   && <OverviewTab campaign={campaign} dm={dm} players={players} isDM={isDM} setNav={setNav} setTab={setTab} user={user} features={features} orgKind={orgKind} reloadCampaign={reloadCampaign}/>}
        {tab==='sessions'   && <SessionsTab campaign={campaign} isDM={isDM}/>}
        {tab==='lore'       && <LoreTab campaign={campaign} isDM={isDM} user={user}/>}
        {tab==='quests'     && <RequestsTab campaign={campaign} isDM={isDM}/>}
        {tab==='npcs'       && <NpcsTab campaign={campaign} isDM={isDM} user={user} setNav={setNav}/>}
        {tab==='inventory'  && <InventoryTab campaign={campaign} isDM={isDM}/>}
        {tab==='spellbooks' && <SpellbooksTab campaign={campaign} user={user} isDM={isDM}/>}
        {tab==='guild'      && <GuildTab campaign={campaign} isDM={isDM}/>}
        {tab==='tribe'      && <TribeTab campaign={campaign} isDM={isDM}/>}
        {tab==='shops'      && <ShopsTab campaign={campaign} isDM={isDM} user={user}/>}
        {tab==='materials'  && <MaterialsTab campaign={campaign} isDM={isDM}/>}
        {tab==='homebrew'   && <HomebrewTab campaign={campaign} isDM={isDM} user={user}/>}
        {tab==='forge'      && <ForgeTab campaign={campaign} isDM={isDM} user={user}/>}
        {tab==='map'        && <MapTab campaign={campaign} isDM={isDM} realm={realm}/>}
      </div>
      {editingMeta && (
        <EditCampaignMetaModal
          campaign={campaign}
          onClose={() => setEditingMeta(false)}
          onSaved={async () => { await reloadCampaign(); setEditingMeta(false); }}
        />
      )}
    </div>
  );
};

// ── EditCampaignMetaModal — DM-facing inline editor for the campaign's basics ─
// Same fields the admin uses, but reachable from the campaign view so DMs don't
// need admin access to fix a name typo or change the banner color.
const EditCampaignMetaModal = ({ campaign, onClose, onSaved }) => {
  const [form, setForm] = React.useState({
    name: campaign.name, setting: campaign.setting, description: campaign.description || '',
    bannerColor: campaign.bannerColor || '#6b1a1a',
  });
  const [saving, setSaving] = React.useState(false);
  const save = async () => {
    if (!form.name.trim() || !form.setting.trim()) return;
    setSaving(true);
    try {
      await api.campaigns.update(campaign.id, {
        name: form.name, setting: form.setting, description: form.description,
        bannerColor: form.bannerColor,
      });
      await onSaved();
    } catch (err) {
      console.error('[CampaignMetaModal] save failed:', err);
      window.dialog.alert((err.body && err.body.message) || err.message || 'Failed to save.', { title:'Error' });
    } finally {
      setSaving(false);
    }
  };
  return (
    <div style={dialogStyles.scrim} {...scrimDismiss(() => !saving && onClose())}>
      <div style={{...dialogStyles.box, maxWidth:560}} onClick={e => e.stopPropagation()}>
        <div style={dialogStyles.title}>Edit Campaign</div>
        <div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:10, marginBottom:14}}>
          <div style={{gridColumn:'1/-1'}}>
            <label style={{fontSize:11, color:'#6b6966', fontWeight:700, textTransform:'uppercase', letterSpacing:'0.08em'}}>Name<Req/></label>
            <input style={{width:'100%', background:'#16161b', border:'1px solid #2a2a32', borderRadius:6, color:'#e8e6e3', padding:'8px 10px', fontSize:13, marginTop:4, boxSizing:'border-box', fontFamily:"'Nunito',sans-serif"}}
              value={form.name} onChange={e => setForm(f => ({...f, name:e.target.value}))} />
          </div>
          <div style={{gridColumn:'1/-1'}}>
            <label style={{fontSize:11, color:'#6b6966', fontWeight:700, textTransform:'uppercase', letterSpacing:'0.08em'}}>Setting / region<Req/></label>
            <input style={{width:'100%', background:'#16161b', border:'1px solid #2a2a32', borderRadius:6, color:'#e8e6e3', padding:'8px 10px', fontSize:13, marginTop:4, boxSizing:'border-box', fontFamily:"'Nunito',sans-serif"}}
              value={form.setting} onChange={e => setForm(f => ({...f, setting:e.target.value}))}
              placeholder="e.g. Aldenmoor Continent" />
          </div>
          <div style={{gridColumn:'1/-1'}}>
            <label style={{fontSize:11, color:'#6b6966', fontWeight:700, textTransform:'uppercase', letterSpacing:'0.08em'}}>Description</label>
            <textarea style={{width:'100%', background:'#16161b', border:'1px solid #2a2a32', borderRadius:6, color:'#e8e6e3', padding:'8px 10px', fontSize:13, marginTop:4, boxSizing:'border-box', minHeight:80, resize:'vertical', fontFamily:"'Nunito',sans-serif"}}
              value={form.description} onChange={e => setForm(f => ({...f, description:e.target.value}))} />
          </div>
          <div>
            <label style={{fontSize:11, color:'#6b6966', fontWeight:700, textTransform:'uppercase', letterSpacing:'0.08em'}}>Banner color</label>
            <input type="color" style={{width:'100%', height:36, marginTop:4, background:'#16161b', border:'1px solid #2a2a32', borderRadius:6, padding:0, cursor:'pointer'}}
              value={form.bannerColor} onChange={e => setForm(f => ({...f, bannerColor:e.target.value}))} />
          </div>
        </div>
        <div style={dialogStyles.row}>
          <button style={dialogStyles.btnGhost} onClick={onClose} disabled={saving}>Cancel</button>
          <button style={dialogStyles.btn} onClick={save} disabled={saving || !form.name.trim() || !form.setting.trim()}>
            {saving ? 'Saving…' : 'Save'}
          </button>
        </div>
      </div>
    </div>
  );
};

// ── Overview ─────────────────────────────────────────────────────────────────
const OverviewTab = ({ campaign, dm, players, isDM, setNav, setTab, user, features, orgKind, reloadCampaign }) => {
  // Default features to all-on if not passed (e.g. callers from older code paths).
  const feats = features || { org:true, association:true, worldMap:true, shops:true };
  const showRequests   = feats.association !== false;
  const showWorldEvents = feats.worldMap !== false;
  const showGuildCard  = feats.org !== false && orgKind !== 'tribe' && orgKind !== 'none';

  const [sessions, setSessions] = React.useState([]);
  // Sessions are always loaded — they're a baseline feature, not behind a toggle.
  React.useEffect(() => {
    let cancelled = false;
    api.campaigns.sessions.list(campaign.id)
      .then(list => { if (!cancelled) setSessions(list || []); })
      .catch(err => console.error('[OverviewTab] sessions load failed:', err));
    return () => { cancelled = true; };
  }, [campaign.id]);

  // Quests only loaded when the Association feature is on. Saves an unused
  // request when the realm/campaign has it disabled (e.g. Oat).
  const [quests, setQuests] = React.useState([]);
  React.useEffect(() => {
    if (!showRequests) { setQuests([]); return; }
    let cancelled = false;
    api.requests.list({ campaignId: campaign.id })
      .then(list => { if (!cancelled) setQuests((list || []).filter(r => r.status !== 'completed')); })
      .catch(err => console.error('[OverviewTab] quests load failed:', err));
    return () => { cancelled = true; };
  }, [campaign.id, showRequests]);

  // World events same treatment — only fetch if the worldMap/events feature is on.
  const [events, setEvents] = React.useState([]);
  React.useEffect(() => {
    if (!showWorldEvents) { setEvents([]); return; }
    let cancelled = false;
    api.worldEvents.list().then(list => {
      if (cancelled) return;
      setEvents((list || []).filter(e => (e.affectedCampaigns || []).some(c => c.id === campaign.id)));
    }).catch(err => console.error('[OverviewTab] worldEvents:', err));
    return () => { cancelled = true; };
  }, [campaign.id, showWorldEvents]);

  // Guild only loads on realms whose org kind is Guild and the org feature is on.
  const [myGuild, setMyGuild] = React.useState(null);
  React.useEffect(() => {
    if (!showGuildCard) { setMyGuild(null); return; }
    let cancelled = false;
    api.guilds.get(campaign.id)
      .then(g => { if (!cancelled) setMyGuild(g); })
      .catch(err => { if (err.status !== 404) console.error('[OverviewTab] guild load failed:', err); });
    return () => { cancelled = true; };
  }, [campaign.id, showGuildCard]);
  let rankInfo = null;
  if (myGuild) {
    const idx = GUILD_RANKS.indexOf(myGuild.rank);
    const next = GUILD_RANKS[idx+1];
    const nextT = next ? RANK_THRESHOLDS[next] : null;
    const prevT = RANK_THRESHOLDS[myGuild.rank] || 0;
    const pct = nextT ? Math.min(1, (myGuild.rankPoints - prevT) / (nextT - prevT)) : 1;
    rankInfo = { guild: myGuild, next, nextT, prevT, pct };
  }
  // Recently met NPCs (sorted by lastInteraction desc)
  const [recentNpcs, setRecentNpcs] = React.useState([]);
  React.useEffect(() => {
    let cancelled = false;
    api.npcs.list(campaign.id)
      .then(list => {
        if (cancelled) return;
        const sorted = (list || [])
          .filter(n => n.lastInteraction)
          .sort((a,b)=>(b.lastInteraction||'').localeCompare(a.lastInteraction||''))
          .slice(0,4);
        setRecentNpcs(sorted);
      })
      .catch(err => console.error('[OverviewTab] npcs load failed:', err));
    return () => { cancelled = true; };
  }, [campaign.id]);
  return (
    <div style={{display:'grid',gridTemplateColumns:'2fr 1fr',gap:24}}>
      <div style={{display:'flex',flexDirection:'column',gap:20}}>
        <div style={cvStyles.card}>
          <div style={cvStyles.cardTitle}>About this Campaign</div>
          <p style={{fontSize:14,color:'#c5c3c0',lineHeight:1.7,margin:0}}>{campaign.description}</p>
        </div>
        <div style={cvStyles.card}>
          <div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:12}}>
            <div style={cvStyles.cardTitle}>Recent Sessions</div>
            <button style={cvStyles.tabLinkBtn} onClick={()=>setTab('sessions')}>All sessions →</button>
          </div>
          {sessions.slice(0,2).map(s=>(
            <div key={s.id} style={cvStyles.sessionRow}>
              <div style={cvStyles.sessionNum}>#{s.number}</div>
              <div style={{flex:1}}>
                <div style={{fontSize:14,fontWeight:700,color:'#e8e6e3'}}>{s.title}</div>
                <div style={{fontSize:12,color:'#9a9793',lineHeight:1.5,marginTop:2}}>{s.summary.substring(0,120)}…</div>
              </div>
              <div style={{fontSize:11,color:'#6b6966',flexShrink:0,marginLeft:12}}>{s.date}</div>
            </div>
          ))}
        </div>
      </div>
      <div style={{display:'flex',flexDirection:'column',gap:16}}>
        {isDM && <InvitePanel campaign={campaign} setTab={setTab} reloadCampaign={reloadCampaign}/>}
        <MembersPanel campaign={campaign} isDM={isDM} reloadCampaign={reloadCampaign}/>
        {showGuildCard && rankInfo && (
          <div style={cvStyles.card}>
            <div style={{display:'flex',justifyContent:'space-between',alignItems:'flex-start',marginBottom:10}}>
              <div>
                <div style={cvStyles.cardTitle}>{rankInfo.guild.name}</div>
                <div style={{fontSize:11,color:'#6b6966',marginTop:2}}>Hunters Association</div>
              </div>
              <div style={{textAlign:'right'}}>
                <div style={{fontFamily:"'Cinzel',serif",fontSize:28,fontWeight:900,color:'#c9a227',lineHeight:1}}>Rank {rankInfo.guild.rank}</div>
                <div style={{fontSize:10,color:'#6b6966',marginTop:2}}>{rankInfo.guild.rankPoints.toLocaleString()} pts</div>
              </div>
            </div>
            {rankInfo.next && (
              <>
                <div style={{height:6,background:'#1a1a20',borderRadius:3,overflow:'hidden',marginTop:6}}>
                  <div style={{height:'100%',width:`${rankInfo.pct*100}%`,background:'linear-gradient(90deg,#b45309,#c9a227)',borderRadius:3,transition:'width 0.4s ease'}}></div>
                </div>
                <div style={{fontSize:11,color:'#9a9793',marginTop:6,display:'flex',justifyContent:'space-between'}}>
                  <span>{Math.round(rankInfo.pct*100)}% to Rank {rankInfo.next}</span>
                  <span style={{color:'#6b6966'}}>{(rankInfo.nextT - rankInfo.guild.rankPoints).toLocaleString()} pts left</span>
                </div>
              </>
            )}
            <button style={{...cvStyles.tabLinkBtn,marginTop:8}} onClick={()=>setTab('guild')}>View guild →</button>
          </div>
        )}
        {recentNpcs.length > 0 && (
          <div style={cvStyles.card}>
            <div style={cvStyles.cardTitle}>Recently Met</div>
            <div style={{display:'flex',flexDirection:'column',gap:8,marginTop:6}}>
              {recentNpcs.map(n => (
                <button key={n.id} onClick={()=>setTab('npcs')} style={{display:'flex',alignItems:'center',gap:10,padding:'7px 4px',background:'transparent',border:'none',borderBottom:'1px solid #1a1a20',cursor:'pointer',textAlign:'left',width:'100%'}}>
                  <div style={{width:32,height:32,borderRadius:'50%',background:'linear-gradient(135deg,#3a3328,#2a2520)',display:'flex',alignItems:'center',justifyContent:'center',fontSize:11,fontWeight:800,color:'#c9a227',flexShrink:0,fontFamily:"'Cinzel',serif"}}>{n.avatar||n.name.split(' ').map(w=>w[0]).slice(0,2).join('')}</div>
                  <div style={{flex:1,minWidth:0}}>
                    <div style={{fontSize:13,fontWeight:700,color:'#e8e6e3',whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{n.name}</div>
                    <div style={{fontSize:11,color:'#9a9793',whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{n.title}</div>
                  </div>
                  <div style={{fontSize:10,color:'#6b6966',flexShrink:0}}>{n.lastInteraction}</div>
                </button>
              ))}
            </div>
          </div>
        )}
        {showRequests && (
          <div style={cvStyles.card}>
            <div style={cvStyles.cardTitle}>Active Requests</div>
            {quests.slice(0,3).map(q=>(
              <div key={q.id} style={{display:'flex',gap:8,alignItems:'flex-start',marginBottom:10}}>
                <div style={{width:8,height:8,borderRadius:'50%',background:priorityColor(q.priority||'medium'),marginTop:5,flexShrink:0}}></div>
                <div>
                  <div style={{fontSize:13,fontWeight:700,color:'#e8e6e3'}}>{q.title}</div>
                  <div style={{fontSize:11,color:'#9a9793'}}>{(q.description||'').substring(0,70)}…</div>
                </div>
              </div>
            ))}
            <button style={cvStyles.tabLinkBtn} onClick={()=>setTab('quests')}>View all requests →</button>
          </div>
        )}
        {showWorldEvents && events.length > 0 && (
          <div style={cvStyles.card}>
            <div style={cvStyles.cardTitle}>World Events (Relevant)</div>
            {events.slice(0,2).map(ev=>(
              <div key={ev.id} style={{borderLeft:`3px solid ${severityColor(ev.severity)}`,paddingLeft:10,marginBottom:10}}>
                <div style={{fontSize:12,fontWeight:700,color:'#e8e6e3'}}>{ev.title}</div>
                <div style={{fontSize:11,color:'#9a9793',marginTop:2}}>{ev.description.substring(0,80)}…</div>
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
};

// ── InvitePanel — DM/Admin only. Two paths in one card:
// (1) direct invite by username, (2) shareable links the DM creates + revokes.
const InvitePanel = ({ campaign, setTab, reloadCampaign }) => {
  const [links, setLinks] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  const [username, setUsername] = React.useState('');
  const [inviting, setInviting] = React.useState(false);
  const [creating, setCreating] = React.useState(false);
  const [copied, setCopied] = React.useState(null);
  // Autocomplete state for the username field. `suggestions` is the latest
  // server response; `hilite` is the keyboard-navigation index; `open` controls
  // dropdown visibility (closed on blur/select/escape).
  const [suggestions, setSuggestions] = React.useState([]);
  const [hilite, setHilite] = React.useState(0);
  const [open, setOpen] = React.useState(false);

  const reload = React.useCallback(async () => {
    setLoading(true);
    try { setLinks((await api.invites.list(campaign.id)) || []); }
    catch (err) { console.error('[InvitePanel] list failed:', err); }
    finally { setLoading(false); }
  }, [campaign.id]);
  React.useEffect(() => { reload(); }, [reload]);

  // Fetch suggestions whenever the typed query changes. Debounced 150ms so
  // we don't fire on every keystroke. Empty query → clear suggestions.
  React.useEffect(() => {
    const q = username.trim();
    if (!q) { setSuggestions([]); return; }
    let cancelled = false;
    const handle = setTimeout(async () => {
      try {
        const list = await api.users.search(q, campaign.id);
        if (!cancelled) { setSuggestions(list || []); setHilite(0); }
      } catch (err) {
        if (!cancelled) console.error('[InvitePanel] user search failed:', err);
      }
    }, 150);
    return () => { cancelled = true; clearTimeout(handle); };
  }, [username, campaign.id]);

  // Resolve which username string to invite — either the typed text (must be
  // an exact match server-side) or, preferably, the highlighted suggestion.
  const sendInvite = async (typed) => {
    const value = (typed ?? username).trim();
    if (!value) return;
    setInviting(true);
    try {
      const res = await api.invites.inviteByUsername(campaign.id, value);
      setUsername('');
      setSuggestions([]);
      setOpen(false);
      await reload(); // refresh invite list so the new pending row shows
      window.dialog.alert(
        `Sent ${res.name} a pending invitation. They'll see it on their dashboard and need to accept before joining.`,
        { title:'Invitation sent' });
    } catch (err) {
      console.error('[InvitePanel] inviteByUsername failed:', err);
      window.dialog.alert((err.body && err.body.message) || err.message || 'Invite failed.', { title:'Invite failed' });
    } finally {
      setInviting(false);
    }
  };

  const inviteUser = () => sendInvite();
  const pickSuggestion = (s) => sendInvite(s.username);
  const onKeyDown = (e) => {
    if (!open || suggestions.length === 0) {
      if (e.key === 'Enter') inviteUser();
      return;
    }
    if (e.key === 'ArrowDown') { e.preventDefault(); setHilite(i => Math.min(suggestions.length - 1, i + 1)); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setHilite(i => Math.max(0, i - 1)); }
    else if (e.key === 'Enter') { e.preventDefault(); pickSuggestion(suggestions[hilite]); }
    else if (e.key === 'Escape') { setOpen(false); }
  };

  const createLink = async () => {
    setCreating(true);
    try { const link = await api.invites.create(campaign.id); setLinks(prev => [link, ...prev]); }
    catch (err) {
      console.error('[InvitePanel] create failed:', err);
      window.dialog.alert(err.message || 'Failed to create invite link.', { title:'Error' });
    }
    finally { setCreating(false); }
  };

  const revoke = async (link) => {
    const ok = await window.dialog.confirm({
      title: 'Revoke invite link?',
      message: `Stop accepting new joins via this link? Existing members keep their access. ${link.usesCount} ${link.usesCount === 1 ? 'person has' : 'people have'} used it.`,
      danger: true, confirmLabel: 'Revoke',
    });
    if (!ok) return;
    try { await api.invites.revoke(campaign.id, link.id); reload(); }
    catch (err) {
      console.error('[InvitePanel] revoke failed:', err);
      window.dialog.alert(err.message || 'Failed to revoke.', { title:'Error' });
    }
  };

  // Compute the absolute join URL from the relative one the backend returns.
  const fullUrl = (link) => window.location.origin + link.joinUrl;
  const copyLink = async (link) => {
    try {
      await navigator.clipboard.writeText(fullUrl(link));
      setCopied(link.id);
      setTimeout(() => setCopied(c => c === link.id ? null : c), 1500);
    } catch (err) {
      console.error('[InvitePanel] clipboard failed:', err);
      window.dialog.alert('Could not copy. The URL is: ' + fullUrl(link), { title:'Copy failed' });
    }
  };

  // Bucket the invite rows: pending direct (targetUserName + not closed),
  // active links (no target + not revoked), and the closed history.
  const isClosed = (l) => l.isRevoked || l.isDeclined || l.usesCount > 0;
  const pendingDirect = links.filter(l => l.targetUserName && !isClosed(l));
  const activeLinks   = links.filter(l => !l.targetUserName && !l.isRevoked);
  const closed        = links.filter(l => isClosed(l));

  return (
    <div style={cvStyles.card}>
      <div style={cvStyles.cardTitle}>Invite to Campaign</div>

      <div style={{ fontSize:11, color:'#6b6966', textTransform:'uppercase', letterSpacing:'0.08em', marginTop:8, marginBottom:6 }}>By username</div>
      <div style={{ position:'relative' }}>
        <div style={{ display:'flex', gap:6 }}>
          <input value={username} onChange={e=>{ setUsername(e.target.value); setOpen(true); }}
            onFocus={()=>setOpen(true)}
            onBlur={()=>setTimeout(()=>setOpen(false), 120) /* delay so onMouseDown on a suggestion fires first */}
            onKeyDown={onKeyDown}
            placeholder="name or username (case- and accent-insensitive)"
            autoComplete="off"
            style={{ flex:1, background:'#16161b', border:'1px solid #2a2a32', borderRadius:6, color:'#e8e6e3', padding:'7px 10px', fontSize:13, fontFamily:"'Nunito',sans-serif" }}/>
          <button onClick={inviteUser} disabled={inviting || !username.trim()}
            style={{...cvStyles.addBtn, opacity: inviting || !username.trim() ? 0.5 : 1}}>
            {inviting ? '…' : 'Invite'}
          </button>
        </div>
        {open && suggestions.length > 0 && (
          <div style={{
            position:'absolute', top:'100%', left:0, right:0, marginTop:4, zIndex:10,
            background:'#16161b', border:'1px solid #3a3a42', borderRadius:6,
            boxShadow:'0 8px 24px rgba(0,0,0,0.6)', overflow:'hidden',
          }}>
            {suggestions.map((s, i) => (
              <div key={s.id}
                onMouseDown={e => { e.preventDefault(); pickSuggestion(s); }}
                onMouseEnter={() => setHilite(i)}
                style={{
                  padding:'8px 10px', cursor:'pointer',
                  background: i === hilite ? 'rgba(201,162,39,0.10)' : 'transparent',
                  borderBottom: i < suggestions.length - 1 ? '1px solid #1a1a20' : 'none',
                }}>
                <div style={{ fontSize:13, color:'#e8e6e3', fontWeight:600 }}>{s.name}</div>
                <div style={{ fontSize:11, color:'#9a9793' }}>@{s.username}</div>
              </div>
            ))}
          </div>
        )}
        {open && username.trim().length > 0 && suggestions.length === 0 && (
          <div style={{
            position:'absolute', top:'100%', left:0, right:0, marginTop:4, zIndex:10,
            background:'#16161b', border:'1px solid #3a3a42', borderRadius:6,
            padding:'8px 10px', fontSize:12, color:'#6b6966',
          }}>
            No matches. Pressing <strong>Invite</strong> will look up this username verbatim.
          </div>
        )}
      </div>

      {pendingDirect.length > 0 && (
        <>
          <div style={{ fontSize:11, color:'#6b6966', textTransform:'uppercase', letterSpacing:'0.08em', marginTop:14, marginBottom:6 }}>Pending direct invites</div>
          {pendingDirect.map(l => (
            <div key={l.id} style={{ background:'#111116', border:'1px solid rgba(124,58,237,0.30)', borderRadius:6, padding:'8px 10px', marginBottom:6, fontSize:12 }}>
              <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', gap:6 }}>
                <div style={{ flex:1, minWidth:0 }}>
                  <div style={{ fontSize:13, color:'#e8e6e3', fontWeight:600 }}>{l.targetUserName}</div>
                  <div style={{ fontSize:10, color:'#6b6966', marginTop:2 }}>sent {l.createdAt}{l.createdByName ? ` by ${l.createdByName}` : ''}</div>
                </div>
                <button onClick={()=>revoke(l)} style={{ background:'transparent', border:'1px solid rgba(248,113,113,0.25)', color:'#f87171', borderRadius:4, padding:'2px 8px', cursor:'pointer', fontSize:11 }}>
                  Cancel
                </button>
              </div>
            </div>
          ))}
        </>
      )}

      <div style={{ fontSize:11, color:'#6b6966', textTransform:'uppercase', letterSpacing:'0.08em', marginTop:14, marginBottom:6, display:'flex', justifyContent:'space-between', alignItems:'center' }}>
        <span>Invite links</span>
        <button onClick={createLink} disabled={creating}
          style={{ background:'transparent', border:'1px solid #3a3a42', color:'#9a9793', borderRadius:5, padding:'3px 8px', cursor:'pointer', fontSize:11, fontFamily:"'Nunito',sans-serif" }}>
          {creating ? '…' : '+ New link'}
        </button>
      </div>
      {loading && <div style={{ fontSize:12, color:'#6b6966' }}>Loading…</div>}
      {!loading && activeLinks.length === 0 && <div style={{ fontSize:12, color:'#6b6966' }}>No active invite links. Create one to share.</div>}
      {activeLinks.map(l => (
        <div key={l.id} style={{ background:'#111116', border:'1px solid #2a2a32', borderRadius:6, padding:'8px 10px', marginBottom:6, fontSize:12 }}>
          <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', gap:6 }}>
            <code style={{ color:'#c9a227', fontFamily:'monospace', fontSize:13, flex:1, overflow:'hidden', textOverflow:'ellipsis' }}>{l.code}</code>
            <button onClick={()=>copyLink(l)} title={fullUrl(l)} style={{ background:'transparent', border:'1px solid #2a2a32', color: copied===l.id?'#22c55e':'#9a9793', borderRadius:4, padding:'2px 8px', cursor:'pointer', fontSize:11 }}>
              {copied===l.id?'Copied':'Copy'}
            </button>
            <button onClick={()=>revoke(l)} style={{ background:'transparent', border:'1px solid rgba(248,113,113,0.25)', color:'#f87171', borderRadius:4, padding:'2px 8px', cursor:'pointer', fontSize:11 }}>
              Revoke
            </button>
          </div>
          <div style={{ fontSize:10, color:'#6b6966', marginTop:4 }}>
            {l.usesCount} {l.usesCount === 1 ? 'use' : 'uses'} · created {l.createdAt}{l.createdByName ? ` by ${l.createdByName}` : ''}
          </div>
        </div>
      ))}
      {closed.length > 0 && (
        <div style={{ marginTop:8 }}>
          <div style={{ fontSize:10, color:'#6b6966', textTransform:'uppercase', letterSpacing:'0.08em', marginBottom:4 }}>History ({closed.length})</div>
          {closed.map(l => {
            const status = l.isDeclined ? 'declined'
                          : l.isRevoked  ? (l.targetUserName ? 'cancelled' : 'revoked')
                          : l.usesCount > 0 ? 'accepted'
                          : '';
            const statusColor = status === 'accepted' ? '#22c55e' : status === 'declined' ? '#f87171' : '#6b6966';
            return (
              <div key={l.id} style={{ fontSize:11, color:'#9a9793', padding:'3px 0', display:'flex', justifyContent:'space-between', gap:6 }}>
                <span>
                  {l.targetUserName
                    ? <strong style={{color:'#9a9793'}}>{l.targetUserName}</strong>
                    : <code style={{ fontFamily:'monospace' }}>{l.code}</code>}
                  {!l.targetUserName && <> · {l.usesCount} uses</>}
                </span>
                <span style={{ color: statusColor, textTransform:'uppercase', fontSize:10, letterSpacing:'0.08em', fontWeight:700 }}>{status}</span>
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
};

// ── MembersPanel — roster summary on Overview. Shows each member's role +
// audit ("invited by X" or "joined via code"). DM can remove from here too.
const MembersPanel = ({ campaign, isDM, reloadCampaign }) => {
  const members = campaign.members || [];
  const remove = async (m) => {
    const ok = await window.dialog.confirm({
      title: 'Remove member?',
      message: `Remove ${m.name} (${m.role}) from this campaign?`,
      danger: true, confirmLabel: 'Remove',
    });
    if (!ok) return;
    try {
      await api.admin.campaigns.removePlayer(campaign.id, m.userId);
      await reloadCampaign?.();
    } catch (err) {
      console.error('[MembersPanel] remove failed:', err);
      window.dialog.alert((err.body && err.body.message) || err.message || 'Could not remove.', { title:'Error' });
    }
  };
  return (
    <div style={cvStyles.card}>
      <div style={cvStyles.cardTitle}>Members ({members.length})</div>
      {members.length === 0 && <div style={{ fontSize:12, color:'#6b6966', marginTop:6 }}>No members yet.</div>}
      <div style={{ display:'flex', flexDirection:'column', gap:6, marginTop:6 }}>
        {members.map(m => {
          const c = m.role === 'DM' ? '#f87171' : m.role === 'Co-DM' ? '#fbbf24' : '#22c55e';
          return (
            <div key={m.userId} style={{ display:'flex', alignItems:'center', gap:8, padding:'5px 0', borderBottom:'1px solid #1a1a20' }}>
              <div style={{ width:8, height:8, borderRadius:'50%', background:c, flexShrink:0 }}></div>
              <div style={{ flex:1, minWidth:0 }}>
                <div style={{ fontSize:13, fontWeight:700, color:'#e8e6e3' }}>{m.name}</div>
                <div style={{ fontSize:11, color:'#6b6966' }}>
                  {m.role}
                  {m.invitedByName ? <> · invited by <span style={{color:'#9a9793'}}>{m.invitedByName}</span></>
                    : m.joinedViaCode ? <> · joined via link <code style={{color:'#9a9793'}}>{m.joinedViaCode}</code></>
                    : null}
                </div>
              </div>
              {isDM && m.role !== 'DM' && (
                <button onClick={()=>remove(m)} title="Remove from campaign"
                  style={{ background:'transparent', border:'none', color:'#6b6966', fontSize:14, cursor:'pointer', padding:'2px 6px' }}>×</button>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
};

// ── Sessions ──────────────────────────────────────────────────────────────────
const DIFF_PTS = { Easy: 25, Medium: 75, Hard: 150, Legendary: 500 };

// Map an API SessionDto to the flat shape this component (and its render code) expects.
// Backend stores quest-update map as raw JSON in `questUpdatesJson`; UI uses an object.
const sessionFromApi = (s) => ({
  ...s,
  questUpdates: (() => {
    try { return JSON.parse(s.questUpdatesJson || '{}'); } catch { return {}; }
  })(),
});

const SessionsTab = ({ campaign, isDM }) => {
  const [sessions, setSessions] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  const [quests, setQuests] = React.useState([]);
  const [expanded, setExpanded] = React.useState(null);
  const [addingSession, setAddingSession] = React.useState(false);
  const [sessionForm, setSessionForm] = React.useState({ title:'', date: new Date().toISOString().slice(0,10), summary:'', loot:'', xpGained:0, dmNotes:'', linkedQuestIds:[], questUpdates:{} });
  const [editingId, setEditingId] = React.useState(null);
  const [saving, setSaving] = React.useState(false);

  const refresh = React.useCallback(async () => {
    setLoading(true);
    try {
      const [sessList, qList] = await Promise.all([
        api.campaigns.sessions.list(campaign.id),
        api.requests.list({ campaignId: campaign.id }),
      ]);
      setSessions((sessList || []).map(sessionFromApi));
      setQuests(qList || []);
    } catch (err) {
      console.error('[SessionsTab] failed to load:', err);
    } finally {
      setLoading(false);
    }
  }, [campaign.id]);

  React.useEffect(() => { refresh(); }, [refresh]);

  const nextNum = (sessions[0]?.number||0) + 1;

  const saveSession = async () => {
    if (!sessionForm.title.trim() || saving) return;
    const lootArr = sessionForm.loot.split(',').map(s=>s.trim()).filter(Boolean);

    // Apply quest progress updates via the API. Each linked quest with an entry in
    // questUpdates gets a PATCH; failures are logged but don't block the session save.
    for (const [qid, upd] of Object.entries(sessionForm.questUpdates || {})) {
      if (!upd) continue;
      const patch = {};
      if (upd.progress != null) patch.progress = upd.progress;
      if (upd.complete) { patch.status = 'completed'; patch.column = 'completed'; }
      if (Object.keys(patch).length === 0) continue;
      try { await api.campaigns.quests.update(campaign.id, qid, patch); }
      catch (err) { console.error('[SessionsTab] quest update failed for', qid, err); }
    }
    // Refetch quests so subsequent renders see the new progress/status
    api.requests.list({ campaignId: campaign.id })
      .then(list => setQuests(list || []))
      .catch(err => console.error('[SessionsTab] quest refetch failed:', err));

    const payload = {
      number: editingId ? sessions.find(s=>s.id===editingId)?.number : nextNum,
      title: sessionForm.title,
      date: sessionForm.date,
      summary: sessionForm.summary,
      xpGained: Number(sessionForm.xpGained) || 0,
      loot: lootArr,
      dmNotes: sessionForm.dmNotes || '',
      linkedQuestIds: sessionForm.linkedQuestIds || [],
      questUpdatesJson: JSON.stringify(sessionForm.questUpdates || {}),
    };

    setSaving(true);
    try {
      if (editingId) {
        // PATCH ignores `number` (immutable) but accepts the rest
        const { number, ...patch } = payload;
        await api.campaigns.sessions.update(campaign.id, editingId, patch);
      } else {
        await api.campaigns.sessions.create(campaign.id, payload);
      }
      await refresh();
    } catch (err) {
      console.error('[SessionsTab] save failed:', err);
      window.dialog.alert('Failed to save session: ' + (err.message || 'unknown error'));
      setSaving(false);
      return;
    }

    setSaving(false);
    setAddingSession(false);
    setEditingId(null);
    setSessionForm({ title:'', date: new Date().toISOString().slice(0,10), summary:'', loot:'', xpGained:0, dmNotes:'', linkedQuestIds:[], questUpdates:{} });
  };

  const startEdit = (s) => {
    setSessionForm({ title:s.title, date:s.date, summary:s.summary, loot:(s.loot||[]).join(', '), xpGained:s.xpGained||0, dmNotes:s.dmNotes||'', linkedQuestIds:s.linkedQuestIds||[], questUpdates:s.questUpdates||{} });
    setEditingId(s.id);
    setAddingSession(true);
  };

  const toggleQuestLink = (qid) => {
    setSessionForm(p => {
      const linked = p.linkedQuestIds.includes(qid) ? p.linkedQuestIds.filter(x=>x!==qid) : [...p.linkedQuestIds, qid];
      const updates = { ...p.questUpdates };
      if (!linked.includes(qid)) delete updates[qid];
      return { ...p, linkedQuestIds: linked, questUpdates: updates };
    });
  };

  const setQuestUpdate = (qid, patch) => {
    setSessionForm(p => ({ ...p, questUpdates: { ...p.questUpdates, [qid]: { ...(p.questUpdates[qid]||{}), ...patch } } }));
  };

  return (
    <div style={{maxWidth:760}}>
      {loading && sessions.length === 0 && <div style={{color:'#6b6966',fontSize:12,padding:'8px 0'}}>Loading sessions…</div>}
      {isDM && !addingSession && (
        <div style={{display:'flex',justifyContent:'flex-end',marginBottom:16}}>
          <button style={cvStyles.addBtn} onClick={()=>setAddingSession(true)}>+ Add Session</button>
        </div>
      )}
      {addingSession && (
        <div style={{ background:'#1c1c22', border:'1px solid #c9a22744', borderRadius:10, padding:'20px 24px', marginBottom:20 }}>
          <div style={{ fontFamily:"'Cinzel',serif", fontSize:15, fontWeight:800, color:'#c9a227', marginBottom:16 }}>
            {editingId ? 'Edit Session' : `New Session #${nextNum}`}
          </div>
          <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:12, marginBottom:12 }}>
            <div style={{ gridColumn:'1/-1' }}>
              <label style={cvStyles.editLabel}>Session Title<Req/></label>
              <input style={cvStyles.editInput} value={sessionForm.title} onChange={e=>setSessionForm(p=>({...p,title:e.target.value}))} placeholder="e.g. The Archivist's Debt" />
            </div>
            <div>
              <label style={cvStyles.editLabel}>Date</label>
              <input type="date" style={cvStyles.editInput} value={sessionForm.date} onChange={e=>setSessionForm(p=>({...p,date:e.target.value}))} />
            </div>
            <div>
              <label style={cvStyles.editLabel}>XP Gained</label>
              <input type="number" style={cvStyles.editInput} value={sessionForm.xpGained} onChange={e=>setSessionForm(p=>({...p,xpGained:e.target.value}))} />
            </div>
            <div style={{ gridColumn:'1/-1' }}>
              <label style={cvStyles.editLabel}>Summary</label>
              <textarea style={cvStyles.editTextarea} rows={4} value={sessionForm.summary} onChange={e=>setSessionForm(p=>({...p,summary:e.target.value}))} placeholder="What happened this session?" />
            </div>
            <div style={{ gridColumn:'1/-1' }}>
              <label style={cvStyles.editLabel}>Loot & Rewards (comma-separated)</label>
              <input style={cvStyles.editInput} value={sessionForm.loot} onChange={e=>setSessionForm(p=>({...p,loot:e.target.value}))} placeholder="500 gp (shared), Scroll of Teleportation Circle…" />
            </div>
            {isDM && (
              <div style={{ gridColumn:'1/-1' }}>
                <label style={cvStyles.editLabel}>DM Notes (private)</label>
                <textarea style={{ ...cvStyles.editTextarea, borderColor:'rgba(197,48,48,0.3)' }} rows={2} value={sessionForm.dmNotes} onChange={e=>setSessionForm(p=>({...p,dmNotes:e.target.value}))} placeholder="Private DM notes — not shown to players" />
              </div>
            )}
          </div>

          {/* Quest linking */}
          {quests.filter(q=>q.status!=='completed').length > 0 && (
            <div style={{ marginBottom:14 }}>
              <label style={{ ...cvStyles.editLabel, marginBottom:8 }}>Link Request Updates</label>
              <div style={{ display:'flex', flexDirection:'column', gap:8 }}>
                {quests.filter(q=>q.status!=='completed').map(q => {
                  const linked = sessionForm.linkedQuestIds.includes(q.id);
                  const upd = sessionForm.questUpdates[q.id] || {};
                  return (
                    <div key={q.id} style={{ background:'#111116', borderRadius:8, border:`1px solid ${linked?'rgba(201,162,39,0.35)':'#2a2a32'}`, overflow:'hidden' }}>
                      <div style={{ display:'flex', gap:10, alignItems:'center', padding:'9px 12px', cursor:'pointer' }} onClick={()=>toggleQuestLink(q.id)}>
                        <div style={{ width:16, height:16, borderRadius:4, border:`2px solid ${linked?'#c9a227':'#3a3a42'}`, background: linked?'#c9a227':'transparent', display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0 }}>
                          {linked && <span style={{ fontSize:10, color:'#111116', fontWeight:900 }}>✓</span>}
                        </div>
                        <span style={{ fontSize:13, color: linked?'#e8e6e3':'#9a9793', fontWeight: linked?700:400, flex:1 }}>{q.title}</span>
                        <span style={{ fontSize:11, color:'#6b6966' }}>{q.priority}</span>
                      </div>
                      {linked && (
                        <div style={{ padding:'0 12px 12px', display:'flex', gap:12, alignItems:'center' }}>
                          <div>
                            <label style={{ fontSize:10, color:'#6b6966', fontWeight:700, display:'block', marginBottom:4 }}>Progress (0–3)</label>
                            <div style={{ display:'flex', gap:6 }}>
                              {[0,1,2,3].map(n => (
                                <button key={n} style={{ width:28, height:28, borderRadius:5, border:`1px solid ${(upd.progress??quests.find(x=>x.id===q.id)?.progress)===n?'#c9a227':'#3a3a42'}`, background:(upd.progress??quests.find(x=>x.id===q.id)?.progress)===n?'rgba(201,162,39,0.2)':'none', color: (upd.progress??quests.find(x=>x.id===q.id)?.progress)===n?'#c9a227':'#6b6966', cursor:'pointer', fontSize:12, fontWeight:700, fontFamily:"'Nunito',sans-serif" }}
                                  onClick={()=>setQuestUpdate(q.id,{progress:n})}>
                                  {n}
                                </button>
                              ))}
                            </div>
                          </div>
                          <label style={{ display:'flex', alignItems:'center', gap:6, cursor:'pointer', marginTop:14 }}>
                            <input type="checkbox" checked={!!upd.complete} onChange={e=>setQuestUpdate(q.id,{complete:e.target.checked})} />
                            <span style={{ fontSize:12, color:'#9a9793' }}>Mark completed</span>
                          </label>
                        </div>
                      )}
                    </div>
                  );
                })}
              </div>
            </div>
          )}

          <div style={{ display:'flex', gap:8 }}>
            <button style={cvStyles.addBtn} onClick={saveSession} disabled={!sessionForm.title.trim() || saving}>{saving ? 'Saving…' : (editingId?'Save Changes':'Add Session')}</button>
            <button style={{ ...cvStyles.addBtn, background:'none', borderColor:'#3a3a42', color:'#9a9793' }} onClick={()=>{setAddingSession(false);setEditingId(null);}} disabled={saving}>Cancel</button>
          </div>
        </div>
      )}
      {sessions.map(s=>{
        const linkedQuests = (s.linkedQuestIds||[]).map(id=>quests.find(q=>q.id===id)).filter(Boolean);
        const isExpanded = expanded === s.id;
        return (
          <div key={s.id} style={cvStyles.sessionCard}>
            <div style={{display:'flex',justifyContent:'space-between',alignItems:'flex-start',marginBottom:10}}>
              <div style={{display:'flex',gap:12,alignItems:'center'}}>
                <div style={cvStyles.sessionNumLarge}>#{s.number}</div>
                <div>
                  <div style={{fontSize:16,fontWeight:800,color:'#e8e6e3',fontFamily:"'Cinzel',serif"}}>{s.title}</div>
                  <div style={{fontSize:12,color:'#6b6966'}}>{s.date}{s.xpGained ? ` · ${s.xpGained} XP` : ''}</div>
                </div>
              </div>
              <div style={{ display:'flex', gap:6 }}>
                {isDM && <button style={cvStyles.editIconBtn} onClick={()=>startEdit(s)}>✎</button>}
                <button style={{ ...cvStyles.editIconBtn, fontSize:11 }} onClick={()=>setExpanded(isExpanded?null:s.id)}>{isExpanded?'▲':'▼'}</button>
              </div>
            </div>
            <div style={{fontSize:14,color:'#c5c3c0',lineHeight:1.7,margin:'0 0 12px'}}>{window.renderMarkdown ? window.renderMarkdown(s.summary) : s.summary}</div>

            {/* Quest links */}
            {linkedQuests.length > 0 && (
              <div style={{ marginBottom:10 }}>
                <div style={{ fontSize:10, fontWeight:800, color:'#6b6966', textTransform:'uppercase', letterSpacing:'0.1em', marginBottom:6 }}>Quest Updates</div>
                <div style={{ display:'flex', flexWrap:'wrap', gap:6 }}>
                  {linkedQuests.map(q => {
                    const upd = (s.questUpdates||{})[q.id];
                    return (
                      <div key={q.id} style={{ fontSize:11, background: upd?.complete?'rgba(30,107,60,0.12)':'rgba(201,162,39,0.08)', border:`1px solid ${upd?.complete?'rgba(30,107,60,0.3)':'rgba(201,162,39,0.2)'}`, borderRadius:5, padding:'3px 10px', color: upd?.complete?'#22c55e':'#c9a227', display:'flex', alignItems:'center', gap:6 }}>
                        {upd?.complete ? '✓ Completed' : `Progress → ${upd?.progress??'updated'}`}: {q.title}
                      </div>
                    );
                  })}
                </div>
              </div>
            )}

            <div>
              <div style={{fontSize:11,color:'#9a9793',fontWeight:700,textTransform:'uppercase',letterSpacing:'0.1em',marginBottom:6}}>Loot & Rewards</div>
              <div style={{display:'flex',flexWrap:'wrap',gap:6}}>
                {(s.loot||[]).map((l,i)=><span key={i} style={cvStyles.lootTag}>{l}</span>)}
              </div>
            </div>

            {/* DM Notes — collapsed by default */}
            {isDM && s.dmNotes && isExpanded && (
              <div style={{ marginTop:12, padding:'10px 14px', background:'rgba(197,48,48,0.06)', border:'1px solid rgba(197,48,48,0.2)', borderRadius:7 }}>
                <div style={{ fontSize:10, fontWeight:800, color:'#c53030', textTransform:'uppercase', letterSpacing:'0.1em', marginBottom:4 }}>DM Notes — Private</div>
                <p style={{ fontSize:13, color:'#c5c3c0', lineHeight:1.6, margin:0 }}>{s.dmNotes}</p>
              </div>
            )}
            {isDM && s.dmNotes && !isExpanded && (
              <div style={{ marginTop:8 }}>
                <button style={{ background:'none', border:'none', fontSize:11, color:'#c53030', cursor:'pointer', fontWeight:700, padding:0, fontFamily:"'Nunito',sans-serif" }} onClick={()=>setExpanded(s.id)}>🔒 DM Notes ▼</button>
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
};

// ── Lore ──────────────────────────────────────────────────────────────────────
const LORE_CATEGORIES = ['History','Location','Faction','Threat','Character','World','System','Other'];

const LoreTab = ({ campaign, isDM, user }) => {
  const [items, setItems] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  const [filter, setFilter] = React.useState('all'); // 'all' | 'dm' | 'player' | 'pinned'
  const [editing, setEditing] = React.useState(null); // null | 'new' | id
  const [form, setForm] = React.useState({ title:'', category:'History', content:'', pinned:false, author:'dm' });

  const refresh = React.useCallback(async () => {
    setLoading(true);
    try {
      const list = await api.campaigns.lore.list(campaign.id);
      setItems((list || []).map(l => ({ ...l, author: l.author || 'dm' })));
    } catch (err) {
      console.error('[LoreTab] failed to load:', err);
    } finally {
      setLoading(false);
    }
  }, [campaign.id]);

  React.useEffect(() => { refresh(); }, [refresh]);

  const startNew = (authorType) => {
    setForm({ title:'', category:'History', content:'', pinned:false, author: authorType });
    setEditing('new');
  };

  const startEdit = (item) => { setForm({...item}); setEditing(item.id); };

  const saveEntry = async () => {
    if (!form.title.trim()) return;
    try {
      if (editing === 'new') {
        await api.campaigns.lore.create(campaign.id, {
          title: form.title, category: form.category, content: form.content,
          pinned: !!form.pinned, author: form.author,
        });
      } else {
        await api.campaigns.lore.update(campaign.id, editing, {
          title: form.title, category: form.category, content: form.content,
          pinned: !!form.pinned, author: form.author,
        });
      }
      await refresh();
      setEditing(null);
    } catch (err) {
      console.error('[LoreTab] save failed:', err);
      window.dialog.alert('Failed to save lore entry: ' + (err.message || 'unknown error'));
    }
  };

  const togglePin = async (item) => {
    try {
      await api.campaigns.lore.update(campaign.id, item.id, { pinned: !item.pinned });
      // Optimistic local toggle is fine; refresh would also work
      setItems(items.map(l => l.id === item.id ? { ...l, pinned: !l.pinned } : l));
    } catch (err) {
      console.error('[LoreTab] pin toggle failed:', err);
    }
  };

  const deleteEntry = async (id) => {
    if (!(await window.dialog.confirm({ title:'Delete lore entry?', message:'Delete this lore entry?', danger:true, confirmLabel:'Delete' }))) return;
    try {
      await api.campaigns.lore.delete(campaign.id, id);
      setItems(items.filter(l => l.id !== id));
    } catch (err) {
      console.error('[LoreTab] delete failed:', err);
      window.dialog.alert('Failed to delete: ' + (err.message || 'unknown error'));
    }
  };

  const visible = items.filter(l => {
    if (filter==='pinned') return l.pinned;
    if (filter==='dm') return l.author==='dm';
    if (filter==='player') return l.author==='player';
    return true;
  });

  // Sort: pinned first, then by category
  const pinned = visible.filter(l=>l.pinned);
  const unpinned = visible.filter(l=>!l.pinned);
  const categories = [...new Set(unpinned.map(l=>l.category))];

  // Render-helper (NOT a component) so the parent's re-render doesn't unmount inputs.
  // See: defining a component inside another component creates a new component type per
  // render; React then unmounts/remounts and steals focus. A render function returns
  // raw JSX — same element types each render, just diffed by props/key.
  const renderLoreCard = (item) => {
    const isDMEntry = item.author === 'dm';
    const borderColor = isDMEntry ? 'rgba(197,48,48,0.25)' : 'rgba(96,165,250,0.25)';
    const authorColor = isDMEntry ? '#c53030' : '#60a5fa';
    const authorLabel = isDMEntry ? 'DM' : 'Player Note';
    return (
      <div key={item.id} style={{ ...cvStyles.loreCard, borderLeft:`3px solid ${borderColor}`, position:'relative' }}>
        <div style={{ display:'flex', gap:8, alignItems:'center', marginBottom:8, flexWrap:'wrap' }}>
          {item.pinned && <span style={cvStyles.pinnedBadge}>📌 Pinned</span>}
          <span style={{ fontSize:10, fontWeight:800, color:authorColor, background:`${authorColor}18`, border:`1px solid ${authorColor}33`, borderRadius:3, padding:'1px 6px', textTransform:'uppercase', letterSpacing:'0.08em' }}>{authorLabel}</span>
          <span style={{ fontSize:10, color:'#6b6966', background:'#2a2a32', borderRadius:3, padding:'1px 6px' }}>{item.category}</span>
          <h3 style={{ fontSize:15, fontWeight:800, color:'#e8e6e3', margin:0, fontFamily:"'Cinzel',serif", flex:1 }}>{item.title}</h3>
        </div>
        <div style={{ fontSize:13, color:'#c5c3c0', lineHeight:1.75, margin:0 }}>{window.renderMarkdown ? window.renderMarkdown(item.content) : item.content}</div>
        <div style={{ fontSize:10, color:'#4a4a52', marginTop:6, fontStyle:'italic' }}>Markdown supported — **bold**, *italic*, # headings, &gt; quotes, - lists</div>
        {(isDM || item.author==='player') && (
          <div style={{ display:'flex', gap:6, marginTop:10, justifyContent:'flex-end' }}>
            <button style={{ background:'none', border:'1px solid #2a2a32', color: item.pinned?'#c9a227':'#6b6966', borderRadius:4, padding:'3px 8px', cursor:'pointer', fontSize:11, fontFamily:"'Nunito',sans-serif" }} onClick={()=>togglePin(item)}>
              {item.pinned ? '📌 Unpin' : '📌 Pin'}
            </button>
            {(isDM || item.author==='player') && <button style={{ background:'none', border:'1px solid #2a2a32', color:'#9a9793', borderRadius:4, padding:'3px 8px', cursor:'pointer', fontSize:11, fontFamily:"'Nunito',sans-serif" }} onClick={()=>startEdit(item)}>✎ Edit</button>}
            {isDM && <button style={{ background:'none', border:'1px solid rgba(248,113,113,0.2)', color:'#f87171', borderRadius:4, padding:'3px 8px', cursor:'pointer', fontSize:11, fontFamily:"'Nunito',sans-serif" }} onClick={()=>deleteEntry(item.id)}>Delete</button>}
          </div>
        )}
      </div>
    );
  };

  return (
    <div style={{ maxWidth:820 }}>
      {/* Toolbar */}
      <div style={{ display:'flex', gap:8, marginBottom:20, alignItems:'center', flexWrap:'wrap' }}>
        {['all','pinned','dm','player'].map(f => (
          <button key={f} style={{ background: filter===f?'rgba(201,162,39,0.15)':'none', border:`1px solid ${filter===f?'#c9a227':'#3a3a42'}`, color: filter===f?'#c9a227':'#6b6966', borderRadius:6, padding:'5px 12px', cursor:'pointer', fontSize:12, fontWeight:700, fontFamily:"'Nunito',sans-serif" }}
            onClick={()=>setFilter(f)}>
            {f==='all'?'All':f==='pinned'?'📌 Pinned':f==='dm'?'DM Entries':'Player Notes'}
          </button>
        ))}
        <div style={{ flex:1 }}></div>
        {!editing && <>
          {isDM && <button style={cvStyles.addBtn} onClick={()=>startNew('dm')}>+ DM Entry</button>}
          <button style={{ ...cvStyles.addBtn, borderColor:'#60a5fa', color:'#60a5fa', background:'rgba(96,165,250,0.1)' }} onClick={()=>startNew('player')}>+ Player Note</button>
        </>}
      </div>

      {editing && (
        <div style={{ background:'#1c1c22', border:`1px solid ${form.author==='dm'?'rgba(197,48,48,0.4)':'rgba(96,165,250,0.4)'}`, borderRadius:10, padding:'20px 24px', marginBottom:20 }}>
          <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:16 }}>
            <div style={{ fontFamily:"'Cinzel',serif", fontSize:15, fontWeight:800, color: form.author==='dm'?'#c53030':'#60a5fa' }}>
              {editing==='new' ? (form.author==='dm'?'New DM Entry':'New Player Note') : 'Edit Entry'}
            </div>
            <div style={{ display:'flex', gap:6 }}>
              <button style={{ ...cvStyles.addBtn, background: form.author==='dm'?'rgba(197,48,48,0.15)':'none', borderColor: form.author==='dm'?'#c53030':'#3a3a42', color: form.author==='dm'?'#e8e6e3':'#6b6966', fontSize:11 }} onClick={()=>setForm(p=>({...p,author:'dm'}))}>DM Entry</button>
              <button style={{ ...cvStyles.addBtn, background: form.author==='player'?'rgba(96,165,250,0.15)':'none', borderColor: form.author==='player'?'#60a5fa':'#3a3a42', color: form.author==='player'?'#e8e6e3':'#6b6966', fontSize:11 }} onClick={()=>setForm(p=>({...p,author:'player'}))}>Player Note</button>
            </div>
          </div>
          <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:12, marginBottom:12 }}>
            <div style={{ gridColumn:'1/-1' }}>
              <label style={cvStyles.editLabel}>Title<Req/></label>
              <input style={cvStyles.editInput} value={form.title} onChange={e=>setForm(p=>({...p,title:e.target.value}))} placeholder="Entry title…" />
            </div>
            <div>
              <label style={cvStyles.editLabel}>Category</label>
              <select style={cvStyles.editSelect} value={form.category} onChange={e=>setForm(p=>({...p,category:e.target.value}))}>
                {LORE_CATEGORIES.map(c=><option key={c} value={c}>{c}</option>)}
              </select>
            </div>
            <div style={{ display:'flex', alignItems:'flex-end', gap:8, paddingBottom:2 }}>
              <label style={{ display:'flex', alignItems:'center', gap:6, cursor:'pointer' }}>
                <input type="checkbox" checked={form.pinned} onChange={e=>setForm(p=>({...p,pinned:e.target.checked}))} />
                <span style={{ fontSize:13, color:'#c9a227' }}>📌 Pin this entry</span>
              </label>
            </div>
            <div style={{ gridColumn:'1/-1' }}>
              <label style={cvStyles.editLabel}>Content</label>
              <textarea style={cvStyles.editTextarea} rows={5} value={form.content} onChange={e=>setForm(p=>({...p,content:e.target.value}))} placeholder="Lore content, notes, secrets…" />
            </div>
          </div>
          <div style={{ display:'flex', gap:8 }}>
            <button style={cvStyles.addBtn} onClick={saveEntry} disabled={!form.title.trim()}>{editing==='new'?'Add Entry':'Save'}</button>
            <button style={{ ...cvStyles.addBtn, background:'none', borderColor:'#3a3a42', color:'#9a9793' }} onClick={()=>setEditing(null)}>Cancel</button>
          </div>
        </div>
      )}

      {/* Pinned section */}
      {pinned.length > 0 && (
        <div style={{ marginBottom:28 }}>
          <div style={{ ...cvStyles.loreCatHeader, color:'#c9a227', marginBottom:12 }}>📌 Pinned</div>
          {pinned.map(renderLoreCard)}
        </div>
      )}

      {/* By category */}
      {categories.map(cat => (
        <div key={cat} style={{ marginBottom:28 }}>
          <div style={cvStyles.loreCatHeader}>{cat}</div>
          {unpinned.filter(l=>l.category===cat).map(renderLoreCard)}
        </div>
      ))}

      {loading && items.length === 0 && (
        <div style={{ color:'#6b6966', textAlign:'center', padding:'40px 0', fontSize:13 }}>Loading lore…</div>
      )}
      {!loading && visible.length === 0 && (
        <div style={{ color:'#4a4a52', textAlign:'center', padding:'40px 0', fontSize:14 }}>
          No entries yet. {isDM ? 'Use the buttons above to add DM entries or player notes.' : 'Add a player note above.'}
        </div>
      )}
    </div>
  );
};

// ── Quests ────────────────────────────────────────────────────────────────────
const QUEST_PRIORITIES = ['critical','high','medium','low'];
const PRIORITY_COLOR = { critical:'#991b1b', high:'#c53030', medium:'#b45309', low:'#1e6b3c' };
const PRIORITY_BG = { critical:'rgba(153,27,27,0.15)', high:'rgba(197,48,48,0.12)', medium:'rgba(180,83,9,0.12)', low:'rgba(30,107,60,0.12)' };

const SPACE_TIME_JESTS = [
  "You've assigned {name} to two quests simultaneously. Bold move — the multiverse is watching.",
  "Careful! {name} is already on a quest. Unless they've mastered bilocation, this might get messy.",
  "Two quests? For {name}? The Association's finest time-mages are already sweating.",
  "{name} is a dedicated hunter, not a demigod. They're already occupied. Probably.",
  "We'll allow it — but if {name} meets themselves at a crossroads, that's on you.",
];

// Difficulty/rank colors mirror Association request board
const REQ_DIFF_COLOR = { Easy:'#1e6b3c', Medium:'#b45309', Hard:'#c53030', Legendary:'#7e22ce' };
const REQ_RANK_COLOR = { S:'#c9a227', A:'#c53030', B:'#7e22ce', C:'#1a5c7a', D:'#1e6b3c', E:'#6b6966' };
const REQ_RANK_ORDER = ['S','A','B','C','D','E'];
const REQ_TYPES_LIST = ['Hunting','Gathering','Investigation','Escort','Recovery','Mining','Crafting','Exploration'];

// Backend `Quest` ↔ frontend "request" shape: rename API fields to the legacy
// names the existing render code uses (`assignedMembers`, `claimedBy`).
const requestFromApi = (q) => q && ({
  ...q,
  assignedMembers: q.assignedMemberIds || [],
  claimedBy: q.claimedByGuildId || null,
  // The API never returns null priority/column, but be defensive for old rows.
  priority: q.priority || (q.difficulty==='Legendary'?'critical':q.difficulty==='Hard'?'high':q.difficulty==='Medium'?'medium':'low'),
  column: q.column || (q.status==='completed' ? 'completed' : q.status==='claimed' ? 'guild' : 'unassigned'),
  progress: q.progress ?? 0,
  linkedNpcIds: q.linkedNpcIds || [],
});

const RequestsTab = ({ campaign, isDM }) => {
  const [requests, setRequests] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  const [npcs, setNpcs] = React.useState([]);
  const [guild, setGuild] = React.useState(null);
  const [editing, setEditing] = React.useState(null);
  const [form, setForm] = React.useState(null);
  const [expanded, setExpanded] = React.useState(null);
  const [dragId, setDragId] = React.useState(null);
  const [dragOver, setDragOver] = React.useState(null);
  const [conflictWarning, setConflictWarning] = React.useState(null);

  const guildMembers = guild?.members || [];

  const refresh = React.useCallback(async () => {
    setLoading(true);
    try {
      const [reqs, npcsList, g] = await Promise.all([
        api.requests.list({ campaignId: campaign.id }),
        api.npcs.list(campaign.id),
        api.guilds.get(campaign.id).catch(err => err.status === 404 ? null : Promise.reject(err)),
      ]);
      setRequests((reqs || []).map(requestFromApi));
      setNpcs(npcsList || []);
      setGuild(g);
    } catch (err) {
      console.error('[RequestsTab] load failed:', err);
    } finally {
      setLoading(false);
    }
  }, [campaign.id]);

  React.useEffect(() => { refresh(); }, [refresh]);

  const blankForm = () => ({
    title:'', type:'Investigation', minRank:'D', reward:0, currency:'gp',
    description:'', giver:'', status:'open', claimedBy: guild?.id || null,
    campaignId: campaign.id, date: new Date().toISOString().slice(0,10),
    difficulty:'Medium', priority:'high', progress:0,
    column: guild ? 'guild' : 'unassigned',
    assignedMembers:[], linkedNpcIds:[],
  });

  const startNew = () => { setForm(blankForm()); setEditing('new'); };
  const startEdit = (r) => { setForm({...r, linkedNpcIds:r.linkedNpcIds||[], assignedMembers:r.assignedMembers||[]}); setEditing(r.id); };

  // Build the API payload from the form's legacy-shaped fields.
  const formToPayload = (f) => {
    const status = f.column==='completed' ? 'completed' : f.column==='guild' ? 'claimed' : 'open';
    const claimedByGuildId = status==='open' ? null : (f.claimedBy || guild?.id || null);
    return {
      title: f.title, priority: f.priority, description: f.description, giver: f.giver,
      reward: Number(f.reward) || 0, currency: f.currency || 'gp',
      type: f.type, minRank: f.minRank, difficulty: f.difficulty,
      column: f.column, status,
      date: f.date,
      claimedByGuildId,
      assignedMemberIds: f.assignedMembers || [],
      linkedNpcIds: f.linkedNpcIds || [],
    };
  };

  const saveRequest = async () => {
    if (!form.title.trim()) return;
    try {
      if (editing === 'new') {
        await api.campaigns.quests.create(campaign.id, formToPayload(form));
      } else {
        await api.campaigns.quests.update(campaign.id, editing, formToPayload(form));
      }
      await refresh();
    } catch (err) {
      console.error('[RequestsTab] save failed:', err);
      window.dialog.alert('Failed to save request: ' + (err.message || 'unknown error'));
    }
    setEditing(null); setForm(null);
  };

  const deleteRequest = async (id) => {
    if (!(await window.dialog.confirm({ title:'Delete request?', message:'Delete this request?', danger:true, confirmLabel:'Delete' }))) return;
    try { await api.campaigns.quests.delete(campaign.id, id); await refresh(); }
    catch (err) { console.error('[RequestsTab] delete failed:', err); window.dialog.alert('Failed to delete: ' + (err.message || 'unknown error')); }
  };

  const setProgress = async (id, p) => {
    try {
      await api.campaigns.quests.update(campaign.id, id, { progress: p });
      setRequests(prev => prev.map(r => r.id === id ? { ...r, progress: p } : r));
    } catch (err) { console.error('[RequestsTab] setProgress failed:', err); }
  };

  const toggleNpc = (npcId) => setForm(p=>({ ...p, linkedNpcIds: p.linkedNpcIds.includes(npcId)?p.linkedNpcIds.filter(x=>x!==npcId):[...p.linkedNpcIds,npcId] }));

  // Member assignment with conflict detection
  const toggleMember = async (reqId, memberId, memberName) => {
    const r = requests.find(x=>x.id===reqId);
    if (!r) return;
    const alreadyOn = r.assignedMembers?.includes(memberId);
    if (alreadyOn) {
      const next = r.assignedMembers.filter(m=>m!==memberId);
      try {
        await api.campaigns.quests.update(campaign.id, reqId, { assignedMemberIds: next });
        setRequests(prev => prev.map(x => x.id===reqId ? { ...x, assignedMembers: next } : x));
      } catch (err) { console.error('[RequestsTab] toggleMember failed:', err); }
      return;
    }
    const conflict = requests.find(x=>x.id!==reqId && x.status!=='completed' && x.column!=='completed' && (x.assignedMembers||[]).includes(memberId));
    if (conflict) {
      const jest = SPACE_TIME_JESTS[Math.floor(Math.random()*SPACE_TIME_JESTS.length)].replace('{name}', memberName);
      setConflictWarning({ reqId, memberId, memberName, jest, conflictTitle: conflict.title });
      return;
    }
    const next = [...(r.assignedMembers||[]), memberId];
    try {
      await api.campaigns.quests.update(campaign.id, reqId, { assignedMemberIds: next });
      setRequests(prev => prev.map(x => x.id===reqId ? { ...x, assignedMembers: next } : x));
    } catch (err) { console.error('[RequestsTab] toggleMember failed:', err); }
  };

  const forceAssign = async () => {
    if (!conflictWarning) return;
    const r = requests.find(x => x.id === conflictWarning.reqId);
    const next = [...(r?.assignedMembers||[]), conflictWarning.memberId];
    try {
      await api.campaigns.quests.update(campaign.id, conflictWarning.reqId, { assignedMemberIds: next });
      setRequests(prev => prev.map(x => x.id === conflictWarning.reqId ? { ...x, assignedMembers: next } : x));
    } catch (err) { console.error('[RequestsTab] forceAssign failed:', err); }
    setConflictWarning(null);
  };

  // Drag & drop — moving a card between columns also flips status & claimedBy
  const onDragStart = (e, id) => { setDragId(id); e.dataTransfer.effectAllowed='move'; };
  const onDragOver = (e, col) => { e.preventDefault(); setDragOver(col); };
  const onDrop = async (e, col) => {
    e.preventDefault();
    if (!dragId) { setDragOver(null); return; }
    const r = requests.find(x => x.id === dragId);
    if (!r) { setDragId(null); setDragOver(null); return; }
    const status = col==='completed' ? 'completed' : col==='guild' ? 'claimed' : 'open';
    const claimedByGuildId = status==='open' ? null : (r.claimedBy || guild?.id || null);
    try {
      await api.campaigns.quests.update(campaign.id, dragId, { column: col, status, claimedByGuildId });
      setRequests(prev => prev.map(x => x.id===dragId ? { ...x, column: col, status, claimedBy: claimedByGuildId } : x));
    } catch (err) {
      console.error('[RequestsTab] drop save failed:', err);
      window.dialog.alert('Failed to move request: ' + (err.message || 'unknown error'));
    }
    setDragId(null); setDragOver(null);
  };

  const columns = [
    { id:'unassigned', label:'Open',                  color:'#4a4a52', bg:'rgba(74,74,82,0.1)' },
    ...(guild ? [{ id:'guild', label: guild.name + ' (Claimed)', color:'#c9a227', bg:'rgba(201,162,39,0.08)' }] : []),
    { id:'completed', label:'Completed',              color:'#1e6b3c', bg:'rgba(30,107,60,0.08)' },
  ];

  // Render-functions (NOT components) so the parent's re-render doesn't unmount
  // the form's inputs and steal keyboard focus. See LoreTab for the same pattern.
  const renderRequestForm = () => {
    if (!form) return null;
    return (
    <div style={{ background:'#17171d', border:'1px solid #c9a22744', borderRadius:12, padding:'28px 32px', marginBottom:28 }}>
      <div style={{ fontFamily:"'Cinzel',serif", fontSize:16, fontWeight:800, color:'#c9a227', marginBottom:22 }}>{editing==='new'?'Post New Request':'Edit Request'}</div>
      <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:14, marginBottom:0 }}>
        <div style={{ gridColumn:'1/-1' }}>
          <label style={cvStyles.editLabel}>Title<Req/></label>
          <input style={cvStyles.editInput} value={form.title} onChange={e=>setForm(p=>({...p,title:e.target.value}))} placeholder="e.g. Find the missing acolyte" />
        </div>
        <div>
          <label style={cvStyles.editLabel}>Type</label>
          <select style={cvStyles.editSelect} value={form.type} onChange={e=>setForm(p=>({...p,type:e.target.value}))}>
            {REQ_TYPES_LIST.map(t=><option key={t} value={t}>{t}</option>)}
          </select>
        </div>
        <div>
          <label style={cvStyles.editLabel}>Min Rank</label>
          <select style={cvStyles.editSelect} value={form.minRank} onChange={e=>setForm(p=>({...p,minRank:e.target.value}))}>
            {REQ_RANK_ORDER.map(r=><option key={r} value={r}>Rank {r}</option>)}
          </select>
        </div>
        <div>
          <label style={cvStyles.editLabel}>Difficulty</label>
          <select style={cvStyles.editSelect} value={form.difficulty} onChange={e=>setForm(p=>({...p,difficulty:e.target.value}))}>
            {['Easy','Medium','Hard','Legendary'].map(d=><option key={d} value={d}>{d}</option>)}
          </select>
        </div>
        <div>
          <label style={cvStyles.editLabel}>Reward (gp)</label>
          <input type="number" style={cvStyles.editInput} value={form.reward} onChange={e=>setForm(p=>({...p,reward:Number(e.target.value)}))} />
        </div>
        <div>
          <label style={cvStyles.editLabel}>Quest Giver</label>
          <input style={cvStyles.editInput} value={form.giver} onChange={e=>setForm(p=>({...p,giver:e.target.value}))} placeholder="NPC or 'Self-initiated'" />
        </div>
        <div>
          <label style={cvStyles.editLabel}>Priority</label>
          <select style={cvStyles.editSelect} value={form.priority} onChange={e=>setForm(p=>({...p,priority:e.target.value}))}>
            {QUEST_PRIORITIES.map(p=><option key={p} value={p}>{p}</option>)}
          </select>
        </div>
        <div>
          <label style={cvStyles.editLabel}>Column</label>
          <select style={cvStyles.editSelect} value={form.column} onChange={e=>setForm(p=>({...p,column:e.target.value}))}>
            <option value="unassigned">Open</option>
            {guild && <option value="guild">Claimed by {guild.name}</option>}
            <option value="completed">Completed</option>
          </select>
        </div>
        <div>
          <label style={cvStyles.editLabel}>Progress (0–3)</label>
          <div style={{ display:'flex', gap:6, marginTop:2 }}>
            {[0,1,2,3].map(n=>(
              <button key={n} style={{ flex:1, height:38, borderRadius:7, border:`1px solid ${form.progress===n?PRIORITY_COLOR[form.priority]:'#2a2a32'}`, background:form.progress===n?`${PRIORITY_COLOR[form.priority]}22`:'#111116', color:form.progress===n?PRIORITY_COLOR[form.priority]:'#6b6966', cursor:'pointer', fontSize:13, fontWeight:800, fontFamily:"'Nunito',sans-serif" }} onClick={()=>setForm(p=>({...p,progress:n}))}>{n}</button>
            ))}
          </div>
        </div>
        <div style={{ gridColumn:'1/-1' }}>
          <label style={cvStyles.editLabel}>Description</label>
          <textarea style={cvStyles.editTextarea} rows={4} value={form.description} onChange={e=>setForm(p=>({...p,description:e.target.value}))} placeholder="What needs to be done, and why does it matter?" />
        </div>
        {npcs.length > 0 && (
          <div style={{ gridColumn:'1/-1' }}>
            <label style={cvStyles.editLabel}>Linked NPCs</label>
            <div style={{ display:'flex', flexWrap:'wrap', gap:6, marginTop:2 }}>
              {npcs.map(n=>{
                const linked = form.linkedNpcIds.includes(n.id);
                return (
                  <button key={n.id} style={{ background:linked?'rgba(201,162,39,0.15)':'#111116', border:`1px solid ${linked?'#c9a227':'#2a2a32'}`, color:linked?'#c9a227':'#6b6966', borderRadius:6, padding:'6px 12px', cursor:'pointer', fontSize:12, fontFamily:"'Nunito',sans-serif" }} onClick={()=>toggleNpc(n.id)}>
                    {n.avatar} {n.name}
                  </button>
                );
              })}
            </div>
          </div>
        )}
      </div>
      <div style={{ display:'flex', gap:8, marginTop:20, paddingTop:20, borderTop:'1px solid #2a2a32' }}>
        <button style={cvStyles.addBtn} onClick={saveRequest} disabled={!form.title.trim()}>{editing==='new'?'Post Request':'Save Changes'}</button>
        <button style={{ ...cvStyles.addBtn, background:'none', borderColor:'#3a3a42', color:'#9a9793' }} onClick={()=>{setEditing(null);setForm(null);}}>Cancel</button>
      </div>
    </div>
    );
  };

  const renderRequestCard = (r) => {
    const pc = PRIORITY_COLOR[r.priority] || '#6b6966';
    const rankColor = REQ_RANK_COLOR[r.minRank] || '#6b6966';
    const diffColor = REQ_DIFF_COLOR[r.difficulty] || '#6b6966';
    const isOpen = expanded===r.id;
    const linkedNpcs = (r.linkedNpcIds||[]).map(id=>npcs.find(n=>n.id===id)).filter(Boolean);
    const assigned = (r.assignedMembers||[]).map(id=>guildMembers.find(m=>m.id===id)).filter(Boolean);
    const isGuildCol = r.column==='guild';
    return (
      <div
        key={r.id}
        draggable={isDM}
        onDragStart={e=>onDragStart(e,r.id)}
        style={{ background:'#1c1c22', border:'1px solid #2a2a32', borderRadius:10, overflow:'hidden', cursor:isDM?'grab':'default', opacity: dragId===r.id?0.4:1, transition:'opacity 0.15s, box-shadow 0.15s', boxShadow: dragId===r.id?'none':'0 2px 8px rgba(0,0,0,0.3)' }}
      >
        <div style={{ height:3, background:pc, flexShrink:0 }}></div>
        <div style={{ padding:'12px 14px' }}>
          <div style={{ display:'flex', gap:8, alignItems:'flex-start', marginBottom:6 }}>
            {/* Rank chip */}
            <div style={{ flexShrink:0, width:28, height:28, borderRadius:6, background:`${rankColor}20`, border:`1px solid ${rankColor}66`, display:'flex', alignItems:'center', justifyContent:'center', fontFamily:"'Cinzel',serif", fontSize:12, fontWeight:900, color:rankColor }}>
              {r.minRank}
            </div>
            <div style={{ flex:1, cursor:'pointer', minWidth:0 }} onClick={()=>setExpanded(isOpen?null:r.id)}>
              <div style={{ fontSize:13, fontWeight:800, color:'#e8e6e3', lineHeight:1.4 }}>{r.title}</div>
              <div style={{ display:'flex', gap:6, alignItems:'center', marginTop:3, flexWrap:'wrap' }}>
                <span style={{ fontSize:9, fontWeight:800, textTransform:'uppercase', letterSpacing:'0.08em', color:diffColor, background:`${diffColor}18`, border:`1px solid ${diffColor}33`, borderRadius:3, padding:'1px 6px' }}>{r.difficulty}</span>
                <span style={{ fontSize:10, color:'#6b6966', background:'#2a2a32', borderRadius:3, padding:'1px 6px' }}>{r.type}</span>
                {r.giver && <span style={{ fontSize:10, color:'#6b6966' }}>⟵ <span style={{ color:'#c9a227' }}>{r.giver}</span></span>}
              </div>
            </div>
            <div style={{ display:'flex', flexDirection:'column', alignItems:'flex-end', gap:4, flexShrink:0 }}>
              {r.reward > 0 && <div style={{ fontSize:12, fontWeight:800, color:'#c9a227' }}>{r.reward.toLocaleString()} {r.currency}</div>}
              <div style={{ display:'flex', gap:3 }}>
                {[0,1,2,3].map(n=>(
                  <div key={n} style={{ width:8, height:8, borderRadius:'50%', background:n<r.progress?pc:'#2a2a32', border:`1px solid ${n<r.progress?pc:'#3a3a42'}`, cursor:isDM?'pointer':'default' }}
                    onClick={()=>isDM&&setProgress(r.id, r.progress===n+1?n:n+1)}></div>
                ))}
              </div>
            </div>
          </div>

          {assigned.length > 0 && !isOpen && (
            <div style={{ display:'flex', gap:4, flexWrap:'wrap', marginTop:6 }}>
              {assigned.map(m=>(
                <span key={m.id} style={{ fontSize:10, color:'#c9a227', background:'rgba(201,162,39,0.1)', borderRadius:3, padding:'1px 6px' }}>{m.name.split(' ')[0]}</span>
              ))}
            </div>
          )}

          {isOpen && (
            <div style={{ borderTop:'1px solid #2a2a32', marginTop:10, paddingTop:10 }}>
              {r.description && <p style={{ fontSize:12, color:'#c5c3c0', lineHeight:1.65, margin:'0 0 10px' }}>{r.description}</p>}
              {linkedNpcs.length > 0 && (
                <div style={{ display:'flex', gap:5, flexWrap:'wrap', marginBottom:8 }}>
                  {linkedNpcs.map(n=><span key={n.id} style={{ fontSize:11, background:'rgba(201,162,39,0.08)', border:'1px solid rgba(201,162,39,0.2)', borderRadius:4, padding:'2px 7px', color:'#c9a227' }}>{n.avatar} {n.name}</span>)}
                </div>
              )}

              {isGuildCol && guildMembers.length > 0 && (
                <div style={{ marginBottom:10 }}>
                  <div style={{ fontSize:10, fontWeight:800, color:'#6b6966', textTransform:'uppercase', letterSpacing:'0.1em', marginBottom:6 }}>Assigned Members</div>
                  <div style={{ display:'flex', flexWrap:'wrap', gap:5 }}>
                    {guildMembers.map(m=>{
                      const isAssigned = (r.assignedMembers||[]).includes(m.id);
                      const levelColor = { Apprentice:'#6b6966', Journeyman:'#1e6b3c', Master:'#1a5c7a', Grandmaster:'#c9a227' }[m.specLevel]||'#6b6966';
                      return (
                        <button key={m.id}
                          style={{ display:'flex', alignItems:'center', gap:5, background:isAssigned?'rgba(201,162,39,0.15)':'#111116', border:`1px solid ${isAssigned?'#c9a227':'#2a2a32'}`, color:isAssigned?'#c9a227':'#9a9793', borderRadius:6, padding:'5px 9px', cursor:'pointer', fontSize:11, fontFamily:"'Nunito',sans-serif" }}
                          onClick={()=>isDM&&toggleMember(r.id, m.id, m.name)}>
                          <span style={{ width:6, height:6, borderRadius:'50%', background:levelColor, display:'inline-block', flexShrink:0 }}></span>
                          {m.name.split(' ')[0]}
                        </button>
                      );
                    })}
                  </div>
                </div>
              )}

              {isDM && (
                <div style={{ display:'flex', gap:6, marginTop:6, flexWrap:'wrap' }}>
                  <button style={{ background:'none', border:'1px solid #2a2a32', color:'#9a9793', borderRadius:5, padding:'4px 10px', cursor:'pointer', fontSize:11, fontFamily:"'Nunito',sans-serif" }} onClick={()=>startEdit(r)}>✎ Edit</button>
                  {r.column!=='completed' && <button style={{ background:'rgba(30,107,60,0.1)', border:'1px solid #1e6b3c', color:'#22c55e', borderRadius:5, padding:'4px 10px', cursor:'pointer', fontSize:11, fontWeight:700, fontFamily:"'Nunito',sans-serif" }} onClick={async ()=>{
                    try { await api.campaigns.quests.update(campaign.id, r.id, { status:'completed', column:'completed' }); await refresh(); }
                    catch (err) { console.error('[RequestsTab] complete failed:', err); window.dialog.alert('Failed: ' + (err.message || 'unknown')); }
                  }}>✓ Complete</button>}
                  {r.column==='completed' && <button style={{ background:'none', border:'1px solid #3a3a42', color:'#6b6966', borderRadius:5, padding:'4px 10px', cursor:'pointer', fontSize:11, fontFamily:"'Nunito',sans-serif" }} onClick={async ()=>{
                    try { await api.campaigns.quests.update(campaign.id, r.id, { status:'open', column:'unassigned', claimedByGuildId: null }); await refresh(); }
                    catch (err) { console.error('[RequestsTab] reopen failed:', err); window.dialog.alert('Failed: ' + (err.message || 'unknown')); }
                  }}>Reopen</button>}
                  <button style={{ background:'none', border:'1px solid rgba(248,113,113,0.2)', color:'#f87171', borderRadius:5, padding:'4px 10px', cursor:'pointer', fontSize:11, fontFamily:"'Nunito',sans-serif" }} onClick={()=>deleteRequest(r.id)}>Delete</button>
                </div>
              )}
            </div>
          )}
        </div>
      </div>
    );
  };

  return (
    <div style={{ height:'100%', display:'flex', flexDirection:'column' }}>
      {isDM && !editing && (
        <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:20, flexShrink:0 }}>
          <div style={{ fontSize:11, color:'#6b6966', fontStyle:'italic' }}>
            Requests sync with the Hunters Association board.
          </div>
          <button style={cvStyles.addBtn} onClick={startNew}>+ Post Request</button>
        </div>
      )}
      {editing && renderRequestForm()}

      {/* Conflict warning modal */}
      {conflictWarning && (
        <div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.7)', zIndex:1000, display:'flex', alignItems:'center', justifyContent:'center', backdropFilter:'blur(4px)' }}
          {...scrimDismiss(()=>setConflictWarning(null))}>
          <div style={{ background:'#1c1c22', border:'1px solid #c9a22744', borderRadius:12, padding:'28px 32px', maxWidth:420, width:'100%', boxShadow:'0 20px 60px rgba(0,0,0,0.8)' }} onClick={e=>e.stopPropagation()}>
            <div style={{ fontSize:28, textAlign:'center', marginBottom:12 }}>⚠️</div>
            <div style={{ fontFamily:"'Cinzel',serif", fontSize:16, fontWeight:800, color:'#c9a227', marginBottom:10, textAlign:'center' }}>Space-Time Conflict Detected</div>
            <p style={{ fontSize:13, color:'#c5c3c0', lineHeight:1.7, textAlign:'center', marginBottom:6 }}>{conflictWarning.jest}</p>
            <p style={{ fontSize:11, color:'#6b6966', textAlign:'center', marginBottom:20 }}>Already on: <span style={{ color:'#9a9793' }}>{conflictWarning.conflictTitle}</span></p>
            <div style={{ display:'flex', gap:8, justifyContent:'center' }}>
              <button style={{ ...cvStyles.addBtn, background:'none', borderColor:'#3a3a42', color:'#9a9793' }} onClick={()=>setConflictWarning(null)}>Protect the timeline</button>
              <button style={{ ...cvStyles.addBtn, background:'rgba(197,48,48,0.15)', borderColor:'#c53030', color:'#e8e6e3' }} onClick={forceAssign}>Ignore & assign anyway</button>
            </div>
          </div>
        </div>
      )}

      {/* Board */}
      <div style={{ display:'grid', gridTemplateColumns: `repeat(${columns.length}, 1fr)`, gap:16, flex:1, minHeight:0 }}>
        {columns.map(col => {
          const colReqs = col.id==='completed'
            ? requests.filter(r=>r.status==='completed')
            : requests.filter(r=>r.column===col.id && r.status!=='completed');
          const isDragTarget = dragOver===col.id;
          return (
            <div key={col.id}
              onDragOver={e=>onDragOver(e,col.id)}
              onDrop={e=>onDrop(e,col.id)}
              onDragLeave={()=>setDragOver(null)}
              style={{ display:'flex', flexDirection:'column', background: isDragTarget?col.bg:'transparent', borderRadius:10, border:`2px dashed ${isDragTarget?col.color:'transparent'}`, transition:'all 0.15s', padding:8, minHeight:200 }}
            >
              <div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:14, paddingBottom:10, borderBottom:`2px solid ${col.color}` }}>
                <div style={{ width:8, height:8, borderRadius:'50%', background:col.color }}></div>
                <span style={{ fontSize:12, fontWeight:800, color:col.color, fontFamily:"'Cinzel',serif", letterSpacing:'0.05em' }}>{col.label}</span>
                <span style={{ fontSize:11, color:'#4a4a52', marginLeft:'auto' }}>{colReqs.length}</span>
              </div>
              <div style={{ display:'flex', flexDirection:'column', gap:10, flex:1, overflowY:'auto' }}>
                {colReqs
                  .sort((a,b)=>QUEST_PRIORITIES.indexOf(a.priority)-QUEST_PRIORITIES.indexOf(b.priority))
                  .map(renderRequestCard)}
                {colReqs.length===0 && (
                  <div style={{ fontSize:12, color:'#3a3a42', fontStyle:'italic', textAlign:'center', padding:'20px 0', marginTop:8 }}>
                    {isDM ? 'Drag requests here' : 'Nothing here yet'}
                  </div>
                )}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
};

// ── NPCs ──────────────────────────────────────────────────────────────────────
const NpcsTab = ({ campaign, isDM, user, setNav }) => {
  const [npcs, setNpcs] = React.useState([]);
  const [npcsLoading, setNpcsLoading] = React.useState(true);
  const [editingId, setEditingId] = React.useState(null);
  const [addingNpc, setAddingNpc] = React.useState(false);
  const [npcForm, setNpcForm] = React.useState({ name:'', title:'', race:'Human', age:'', appearance:'', personality:'', goals:'', memoryNotes:'', secrets:'', knownInfo:'', avatar:'', defaultRelationship:'neutral', perCharRelationships:{} });
  const [campaignChars, setCampaignChars] = React.useState([]);
  const myChar = campaignChars.find(c => c.playerId === user.id);
  const REL_OPTIONS = ['close','friendly','neutral','cautious','suspicious','grudge','hostile','blessed','respected','feared'];

  const refreshNpcs = React.useCallback(async () => {
    setNpcsLoading(true);
    try {
      const list = await api.npcs.list(campaign.id);
      setNpcs((list || []).map(api.augmentNpc));
    } catch (err) {
      console.error('[NpcsTab] failed to load NPCs:', err);
    } finally {
      setNpcsLoading(false);
    }
  }, [campaign.id]);

  const refreshCharacters = React.useCallback(async () => {
    try {
      const list = await api.characters.list({ campaignId: campaign.id });
      setCampaignChars((list || []).map(api.augmentCharacter));
    } catch (err) {
      console.error('[NpcsTab] failed to load characters:', err);
    }
  }, [campaign.id]);

  React.useEffect(() => { refreshNpcs(); refreshCharacters(); }, [refreshNpcs, refreshCharacters]);

  const saveEdit = async (id, patch) => {
    try {
      await api.npcs.update(campaign.id, id, patch);
      setNpcs(prev => prev.map(n => n.id === id ? { ...n, ...patch } : n));
    } catch (err) {
      console.error('[NpcsTab] update failed:', err);
      window.dialog.alert('Failed to save NPC: ' + (err.message || 'unknown error'));
    }
    setEditingId(null);
  };

  const saveNewNpc = async () => {
    if (!npcForm.name.trim()) return;
    const payload = {
      name: npcForm.name,
      title: npcForm.title,
      race: npcForm.race,
      age: npcForm.age,
      appearance: npcForm.appearance,
      personality: npcForm.personality,
      goals: npcForm.goals,
      secrets: npcForm.secrets,
      memoryNotes: npcForm.memoryNotes || 'No interactions yet.',
      knownInfo: npcForm.knownInfo,
      avatar: npcForm.avatar || npcForm.name.split(' ').map(w=>w[0]).join('').slice(0,2).toUpperCase(),
    };
    try {
      const newId = await api.npcs.create(campaign.id, payload);
      // Backend has no batch relationship setup — set per-character via PUT calls.
      const tasks = campaignChars.map(ch => {
        const status = npcForm.perCharRelationships[ch.id] || npcForm.defaultRelationship;
        return api.npcs.setRelationship(campaign.id, newId, ch.id, status);
      });
      await Promise.all(tasks);
      await refreshNpcs();
    } catch (err) {
      console.error('[NpcsTab] create failed:', err);
      window.dialog.alert('Failed to create NPC: ' + (err.message || 'unknown error'));
      return;
    }
    setAddingNpc(false);
    setNpcForm({ name:'', title:'', race:'Human', age:'', appearance:'', personality:'', goals:'', memoryNotes:'', secrets:'', knownInfo:'', avatar:'', defaultRelationship:'neutral', perCharRelationships:{} });
  };

  const deleteNpc = async (id) => {
    if (!(await window.dialog.confirm({ title:'Delete NPC?', message:'Delete this NPC? Their conversation history and relationships will be lost.', danger:true, confirmLabel:'Delete' }))) return;
    try {
      await api.npcs.delete(campaign.id, id);
      setNpcs(prev => prev.filter(n => n.id !== id));
    } catch (err) {
      console.error('[NpcsTab] delete failed:', err);
      window.dialog.alert('Failed to delete NPC: ' + (err.message || 'unknown error'));
    }
  };

  // Render-function (NOT a component) to avoid input focus loss on every keystroke.
  const renderNpcAddForm = () => (
    <div style={{ background:'#1c1c22', border:'1px solid #c9a22744', borderRadius:10, padding:'20px 24px', marginBottom:24 }}>
      <div style={{ fontFamily:"'Cinzel',serif", fontSize:15, fontWeight:800, color:'#c9a227', marginBottom:16 }}>Add New NPC</div>
      <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:12, marginBottom:12 }}>
        <div><label style={cvStyles.editLabel}>Name<Req/></label><input style={cvStyles.editInput} value={npcForm.name} onChange={e=>setNpcForm(p=>({...p,name:e.target.value}))} placeholder="Seraphine Coldwater" /></div>
        <div><label style={cvStyles.editLabel}>Title / Role</label><input style={cvStyles.editInput} value={npcForm.title} onChange={e=>setNpcForm(p=>({...p,title:e.target.value}))} placeholder="Head Archivist of Vaultkeep" /></div>
        <div><label style={cvStyles.editLabel}>Race</label><input style={cvStyles.editInput} value={npcForm.race} onChange={e=>setNpcForm(p=>({...p,race:e.target.value}))} placeholder="Human, Elf, Dwarf…" /></div>
        <div><label style={cvStyles.editLabel}>Age</label><input style={cvStyles.editInput} value={npcForm.age} onChange={e=>setNpcForm(p=>({...p,age:e.target.value}))} placeholder="42 / Ancient (appears 30)" /></div>
        <div><label style={cvStyles.editLabel}>Avatar Initials</label><input style={cvStyles.editInput} value={npcForm.avatar} onChange={e=>setNpcForm(p=>({...p,avatar:e.target.value}))} placeholder="SC (auto if blank)" maxLength={2} /></div>
        <div><label style={cvStyles.editLabel}>Appearance</label><input style={cvStyles.editInput} value={npcForm.appearance} onChange={e=>setNpcForm(p=>({...p,appearance:e.target.value}))} placeholder="Brief visual description" /></div>
        <div style={{ gridColumn:'1/-1' }}><label style={cvStyles.editLabel}>Personality</label><textarea style={cvStyles.editTextarea} rows={2} value={npcForm.personality} onChange={e=>setNpcForm(p=>({...p,personality:e.target.value}))} /></div>
        <div style={{ gridColumn:'1/-1' }}><label style={cvStyles.editLabel}>Goals</label><textarea style={cvStyles.editTextarea} rows={2} value={npcForm.goals} onChange={e=>setNpcForm(p=>({...p,goals:e.target.value}))} /></div>
        <div style={{ gridColumn:'1/-1' }}><label style={cvStyles.editLabel}>Secrets (DM only)</label><textarea style={{ ...cvStyles.editTextarea, borderColor:'rgba(197,48,48,0.3)' }} rows={2} value={npcForm.secrets} onChange={e=>setNpcForm(p=>({...p,secrets:e.target.value}))} /></div>
        <div style={{ gridColumn:'1/-1' }}><label style={cvStyles.editLabel}>What Players Know</label><textarea style={cvStyles.editTextarea} rows={2} value={npcForm.knownInfo} onChange={e=>setNpcForm(p=>({...p,knownInfo:e.target.value}))} /></div>

        {/* Relationship presets */}
        <div style={{ gridColumn:'1/-1' }}>
          <label style={cvStyles.editLabel}>Default Relationship with all party members</label>
          <div style={{ display:'flex', flexWrap:'wrap', gap:5, marginTop:4 }}>
            {REL_OPTIONS.map(r=>{
              const colors = { close:'#22c55e', friendly:'#4ade80', neutral:'#9a9793', cautious:'#f59e0b', suspicious:'#b45309', grudge:'#f87171', hostile:'#ef4444', blessed:'#c9a227', respected:'#60a5fa', feared:'#c53030' };
              const c = colors[r]||'#9a9793';
              const active = npcForm.defaultRelationship===r;
              return <button key={r} style={{ fontSize:11, fontWeight:700, color:active?'#111116':c, background:active?c:`${c}18`, border:`1px solid ${c}44`, borderRadius:4, padding:'3px 9px', cursor:'pointer', textTransform:'capitalize', fontFamily:"'Nunito',sans-serif" }} onClick={()=>setNpcForm(p=>({...p,defaultRelationship:r}))}>{r}</button>;
            })}
          </div>
        </div>

        {campaignChars.length > 0 && (
          <div style={{ gridColumn:'1/-1' }}>
            <label style={cvStyles.editLabel}>Per-Character Overrides (optional)</label>
            <div style={{ display:'flex', flexDirection:'column', gap:6, marginTop:6 }}>
              {campaignChars.map(ch=>{
                const val = npcForm.perCharRelationships[ch.id] || npcForm.defaultRelationship;
                const colors = { close:'#22c55e', friendly:'#4ade80', neutral:'#9a9793', cautious:'#f59e0b', suspicious:'#b45309', grudge:'#f87171', hostile:'#ef4444', blessed:'#c9a227', respected:'#60a5fa', feared:'#c53030' };
                return (
                  <div key={ch.id} style={{ display:'flex', alignItems:'center', gap:10 }}>
                    <span style={{ fontSize:12, color:'#9a9793', minWidth:130 }}>{ch.name}</span>
                    <select style={{ ...cvStyles.editSelect, padding:'4px 8px', fontSize:11, color: colors[val]||'#9a9793' }}
                      value={val} onChange={e=>setNpcForm(p=>({...p,perCharRelationships:{...p.perCharRelationships,[ch.id]:e.target.value}}))}>
                      {REL_OPTIONS.map(r=><option key={r} value={r}>{r}</option>)}
                    </select>
                  </div>
                );
              })}
            </div>
          </div>
        )}
      </div>
      <div style={{ display:'flex', gap:8 }}>
        <button style={cvStyles.addBtn} onClick={saveNewNpc} disabled={!npcForm.name.trim()}>Add NPC</button>
        <button style={{ ...cvStyles.addBtn, background:'none', borderColor:'#3a3a42', color:'#9a9793' }} onClick={()=>setAddingNpc(false)}>Cancel</button>
      </div>
    </div>
  );

  return (
    <div style={{display:'flex',flexDirection:'column',gap:20}}>
      {isDM && !addingNpc && (
        <div style={{display:'flex',justifyContent:'flex-end'}}>
          <button style={cvStyles.addBtn} onClick={()=>setAddingNpc(true)}>+ Add NPC</button>
        </div>
      )}
      {addingNpc && renderNpcAddForm()}
      {npcsLoading && npcs.length === 0 && <div style={{color:'#6b6966', fontSize:12}}>Loading NPCs…</div>}
      {!npcsLoading && npcs.length === 0 && (
        <div style={{color:'#4a4a52', fontSize:13, textAlign:'center', padding:'24px 0'}}>No NPCs in this campaign yet.</div>
      )}
      <div style={{display:'grid',gridTemplateColumns:'repeat(auto-fill,minmax(320px,1fr))',gap:20}}>
        {npcs.map(npc=>(
          <NpcCard key={npc.id} npc={npc} isDM={isDM} myChar={myChar}
            campaignId={campaign.id}
            campaignChars={campaignChars}
            editing={editingId===npc.id}
            onEdit={()=>setEditingId(npc.id)}
            onCancel={()=>setEditingId(null)}
            onSave={(patch)=>saveEdit(npc.id,patch)}
            onChat={()=>setNav({view:'npc-chat',npcId:npc.id,campaignId:campaign.id})}
            onDelete={()=>deleteNpc(npc.id)}
          />
        ))}
      </div>
    </div>
  );
};

const NpcCard = ({ npc, isDM, myChar, campaignId, campaignChars = [], editing, onEdit, onCancel, onSave, onChat, onDelete }) => {
  const [form, setForm] = React.useState({ personality:npc.personality, goals:npc.goals, memoryNotes:npc.memoryNotes, secrets:npc.secrets, knownInfo:npc.knownInfo });
  const [relForm, setRelForm] = React.useState(null); // null | { charId, value, note }
  const [relationships, setRelationships] = React.useState(npc.relationship || {});
  const [relHistory, setRelHistory] = React.useState(npc.relationshipHistory || {});
  const myRel = myChar ? relationships[myChar.id] : null;

  const REL_OPTIONS = ['close','friendly','neutral','cautious','suspicious','grudge','hostile','blessed','respected','feared'];
  const REL_COLOR = { close:'#22c55e', friendly:'#4ade80', neutral:'#9a9793', cautious:'#f59e0b', suspicious:'#b45309', grudge:'#f87171', hostile:'#ef4444', blessed:'#c9a227', respected:'#60a5fa', feared:'#c53030' };

  const saveRel = async () => {
    if (!relForm || !campaignId) return;
    const { charId, value, note } = relForm;
    try {
      // Backend stores only the current status; per-change history (date/note) is
      // not yet persisted — captured locally so the in-session UI still shows it.
      // See todo.md "NpcRelationship history" follow-up.
      await api.npcs.setRelationship(campaignId, npc.id, charId, value);
      setRelationships(prev => ({ ...prev, [charId]: value }));
      setRelHistory(prev => ({
        ...prev,
        [charId]: [...(prev[charId] || []), { value, note, date: new Date().toISOString().slice(0,10) }],
      }));
    } catch (err) {
      console.error('[NpcCard] setRelationship failed:', err);
      window.dialog.alert('Failed to update relationship: ' + (err.message || 'unknown error'));
    }
    setRelForm(null);
  };

  if (editing) {
    return (
      <div style={{...cvStyles.npcCard, border:'1px solid #c9a22744', background:'#1a1a20'}}>
        <div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
          <div style={{display:'flex',gap:10,alignItems:'center'}}>
            <div style={cvStyles.npcAvatar}>{npc.avatar}</div>
            <div>
              <div style={{fontSize:15,fontWeight:800,color:'#e8e6e3',fontFamily:"'Cinzel',serif"}}>{npc.name}</div>
              <div style={{fontSize:11,color:'#6b6966'}}>DM Edit Mode</div>
            </div>
          </div>
          <span style={{fontSize:10,color:'#c9a227',fontWeight:700,textTransform:'uppercase',letterSpacing:'0.1em'}}>✎ Editing</span>
        </div>
        {[
          ['personality','Personality','textarea'],
          ['goals','Goals','textarea'],
          ['memoryNotes','Memory Notes','textarea'],
          ['secrets','Secrets (DM only)','textarea'],
          ['knownInfo','What Players Know','textarea'],
        ].map(([key,label])=>(
          <div key={key} style={{marginBottom:10}}>
            <label style={cvStyles.editLabel}>{label}</label>
            <textarea style={cvStyles.editTextarea} value={form[key]} rows={key==='secrets'||key==='personality'?3:2}
              onChange={e=>setForm(p=>({...p,[key]:e.target.value}))} />
          </div>
        ))}
        <div style={{display:'flex',gap:8,marginTop:6}}>
          <button style={cvStyles.addBtn} onClick={()=>onSave(form)}>Save Changes</button>
          <button style={{...cvStyles.addBtn,background:'none',borderColor:'#3a3a42',color:'#9a9793'}} onClick={onCancel}>Cancel</button>
        </div>
      </div>
    );
  }

  return (
    <div style={cvStyles.npcCard}>
      <div style={{display:'flex',gap:12,alignItems:'flex-start',marginBottom:12}}>
        <div style={cvStyles.npcAvatar}>{npc.avatar}</div>
        <div style={{flex:1}}>
          <div style={{fontSize:15,fontWeight:800,color:'#e8e6e3',fontFamily:"'Cinzel',serif"}}>{npc.name}</div>
          <div style={{fontSize:12,color:'#9a9793'}}>{npc.title}</div>
          <div style={{fontSize:11,color:'#6b6966',marginTop:2}}>{npc.race} · Last seen {npc.lastInteraction}</div>
        </div>
        <div style={{display:'flex',flexDirection:'column',gap:4,alignItems:'flex-end'}}>
          {myRel && (
            <span style={{...cvStyles.relBadge, background:`${REL_COLOR[myRel]||'#6b6966'}22`, color:REL_COLOR[myRel]||'#9a9793', border:`1px solid ${REL_COLOR[myRel]||'#6b6966'}44`}}>{myRel}</span>
          )}
          {isDM&&<button style={cvStyles.editIconBtn} onClick={onEdit} title="Edit NPC">✎</button>}
        </div>
      </div>

      {isDM ? (
        <>
          <NpcInfoRow label="Personality" text={npc.personality}/>
          <NpcInfoRow label="Goals" text={npc.goals}/>
          <NpcInfoRow label="Memory" text={npc.memoryNotes}/>
          <NpcInfoRow label="Secrets" text={npc.secrets} secret/>
        </>
      ) : (
        <div style={cvStyles.knownInfoBox}>
          <div style={{fontSize:10,fontWeight:800,color:'#c9a227',textTransform:'uppercase',letterSpacing:'0.1em',marginBottom:5}}>What the party knows</div>
          <p style={{fontSize:13,color:'#c5c3c0',lineHeight:1.6,margin:0}}>{npc.knownInfo}</p>
        </div>
      )}

      {/* Relationship tracker — all characters */}
      <div style={{ marginTop:12, borderTop:'1px solid #2a2a32', paddingTop:10 }}>
        <div style={{ fontSize:10, fontWeight:800, color:'#6b6966', textTransform:'uppercase', letterSpacing:'0.1em', marginBottom:8 }}>Party Relationships</div>
        <div style={{ display:'flex', flexDirection:'column', gap:5 }}>
          {campaignChars.map(ch => {
            const rel = relationships[ch.id];
            const hist = relHistory[ch.id] || [];
            const relColor = REL_COLOR[rel] || '#4a4a52';
            return (
              <div key={ch.id} style={{ display:'flex', alignItems:'center', gap:8 }}>
                <div style={{ width:24, height:24, borderRadius:5, background:'#2a2a32', display:'flex', alignItems:'center', justifyContent:'center', fontSize:9, fontWeight:800, color:'#9a9793', flexShrink:0 }}>
                  {ch.name.split(' ').map(w=>w[0]).join('').slice(0,2)}
                </div>
                <span style={{ fontSize:12, color:'#9a9793', flex:1, minWidth:0, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{ch.name}</span>
                {rel ? (
                  <span style={{ fontSize:11, fontWeight:700, color:relColor, background:`${relColor}18`, border:`1px solid ${relColor}33`, borderRadius:4, padding:'1px 7px', textTransform:'capitalize' }}>{rel}</span>
                ) : (
                  <span style={{ fontSize:11, color:'#3a3a42' }}>unknown</span>
                )}
                {hist.length > 0 && (
                  <span title={hist.map(h=>`${h.date}: ${h.value}${h.note?' — '+h.note:''}`).join('\n')} style={{ fontSize:10, color:'#6b6966', cursor:'help', background:'#2a2a32', borderRadius:3, padding:'1px 5px' }}>{hist.length}×</span>
                )}
                {isDM && (
                  <button style={{ background:'none', border:'1px solid #2a2a32', color:'#6b6966', borderRadius:4, padding:'2px 6px', cursor:'pointer', fontSize:10, fontFamily:"'Nunito',sans-serif" }}
                    onClick={()=>setRelForm({ charId:ch.id, value:rel||'neutral', note:'' })}>
                    ✎
                  </button>
                )}
              </div>
            );
          })}
        </div>
      </div>

      {/* Relationship edit inline */}
      {relForm && (
        <div style={{ marginTop:10, background:'#111116', border:'1px solid #3a3a42', borderRadius:8, padding:'12px' }}>
          <div style={{ fontSize:11, fontWeight:800, color:'#c9a227', textTransform:'uppercase', letterSpacing:'0.1em', marginBottom:8 }}>
            Update relationship — {campaignChars.find(c=>c.id===relForm.charId)?.name}
          </div>
          <div style={{ display:'flex', flexWrap:'wrap', gap:5, marginBottom:10 }}>
            {REL_OPTIONS.map(r => (
              <button key={r} style={{ fontSize:11, fontWeight:700, color: relForm.value===r ? '#111116' : REL_COLOR[r]||'#9a9793', background: relForm.value===r ? REL_COLOR[r]||'#9a9793' : `${REL_COLOR[r]||'#9a9793'}18`, border:`1px solid ${REL_COLOR[r]||'#6b6966'}44`, borderRadius:4, padding:'3px 9px', cursor:'pointer', textTransform:'capitalize', fontFamily:"'Nunito',sans-serif", transition:'all 0.12s' }}
                onClick={()=>setRelForm(p=>({...p,value:r}))}>
                {r}
              </button>
            ))}
          </div>
          <input style={{ ...cvStyles.editInput, marginBottom:10, fontSize:12 }} value={relForm.note} onChange={e=>setRelForm(p=>({...p,note:e.target.value}))} placeholder="Optional note (e.g. 'after saving the village')" />
          <div style={{ display:'flex', gap:6 }}>
            <button style={cvStyles.addBtn} onClick={saveRel}>Save</button>
            <button style={{ ...cvStyles.addBtn, background:'none', borderColor:'#3a3a42', color:'#9a9793' }} onClick={()=>setRelForm(null)}>Cancel</button>
          </div>
        </div>
      )}

          {isDM && (
            <div style={{ display:'flex', gap:6, marginTop:10, justifyContent:'flex-end' }}>
              <button style={{ background:'none', border:'1px solid rgba(248,113,113,0.2)', color:'#f87171', borderRadius:4, padding:'3px 8px', cursor:'pointer', fontSize:11, fontFamily:"'Nunito',sans-serif" }} onClick={onDelete}>Delete NPC</button>
            </div>
          )}

      <button style={{...cvStyles.chatBtn,marginTop:12}} onClick={onChat}>
        💬 Talk to {npc.name.split(' ')[0]}
      </button>
    </div>
  );
};

const NpcInfoRow = ({ label, text, secret }) => (
  <div style={{marginBottom:8}}>
    <div style={{fontSize:10,fontWeight:800,color:secret?'#c53030':'#6b6966',textTransform:'uppercase',letterSpacing:'0.08em',marginBottom:2}}>{label}{secret?' — DM Only':''}</div>
    <p style={{fontSize:12,color:secret?'#f87171aa':'#9a9793',lineHeight:1.5,margin:0}}>{text.substring(0,160)}{text.length>160?'…':''}</p>
  </div>
);

// ── Inventory ─────────────────────────────────────────────────────────────────
const InventoryTab = ({ campaign, isDM }) => {
  const [items, setItems] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  const [adding, setAdding] = React.useState(false);
  const [editingId, setEditingId] = React.useState(null);
  const [form, setForm] = React.useState({ name:'', description:'', quantity:1, value:'', linkedQuestIds:[] });
  const [quests, setQuests] = React.useState([]);

  const refresh = React.useCallback(async () => {
    setLoading(true);
    try {
      const [invList, qList] = await Promise.all([
        api.campaigns.inventory.list(campaign.id),
        api.requests.list({ campaignId: campaign.id }),
      ]);
      setItems((invList || []).map(i => ({ ...i, linkedQuestIds: i.linkedQuestIds || [] })));
      setQuests(qList || []);
    } catch (err) {
      console.error('[InventoryTab] failed to load:', err);
    } finally {
      setLoading(false);
    }
  }, [campaign.id]);

  React.useEffect(() => { refresh(); }, [refresh]);

  const startNew = () => { setForm({ name:'', description:'', quantity:1, value:'', linkedQuestIds:[] }); setAdding(true); setEditingId(null); };
  const startEdit = (item) => { setForm({...item, linkedQuestIds: item.linkedQuestIds||[]}); setEditingId(item.id); setAdding(true); };

  const saveItem = async () => {
    if (!form.name.trim()) return;
    const payload = {
      name: form.name,
      description: form.description || '',
      quantity: Number(form.quantity) || 1,
      value: form.value || 'Unknown',
      linkedQuestIds: form.linkedQuestIds || [],
    };
    try {
      if (editingId) await api.campaigns.inventory.update(campaign.id, editingId, payload);
      else           await api.campaigns.inventory.create(campaign.id, payload);
      await refresh();
      setAdding(false); setEditingId(null);
    } catch (err) {
      console.error('[InventoryTab] save failed:', err);
      window.dialog.alert('Failed to save item: ' + (err.message || 'unknown error'));
    }
  };

  const deleteItem = async (id) => {
    if (!(await window.dialog.confirm({ title:'Remove item?', message:'Remove this item from party inventory?', danger:true, confirmLabel:'Remove' }))) return;
    try {
      await api.campaigns.inventory.delete(campaign.id, id);
      setItems(items.filter(i => i.id !== id));
    } catch (err) {
      console.error('[InventoryTab] delete failed:', err);
      window.dialog.alert('Failed to delete: ' + (err.message || 'unknown error'));
    }
  };

  const toggleQuestLink = (qid) => setForm(p=>({ ...p, linkedQuestIds: p.linkedQuestIds.includes(qid)?p.linkedQuestIds.filter(x=>x!==qid):[...p.linkedQuestIds,qid] }));

  return (
    <div style={{maxWidth:800}}>
      {isDM && !adding && (
        <div style={{display:'flex',justifyContent:'flex-end',marginBottom:16}}>
          <button style={cvStyles.addBtn} onClick={startNew}>+ Add Item</button>
        </div>
      )}
      {adding && (
        <div style={{ background:'#1c1c22', border:'1px solid #c9a22744', borderRadius:10, padding:'20px 24px', marginBottom:20 }}>
          <div style={{ fontFamily:"'Cinzel',serif", fontSize:15, fontWeight:800, color:'#c9a227', marginBottom:16 }}>{editingId?'Edit Item':'Add to Party Inventory'}</div>
          <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:12, marginBottom:12 }}>
            <div style={{ gridColumn:'1/-1' }}><label style={cvStyles.editLabel}>Item Name<Req/></label><input style={cvStyles.editInput} value={form.name} onChange={e=>setForm(p=>({...p,name:e.target.value}))} placeholder="e.g. Thieves' Guild Token" /></div>
            <div><label style={cvStyles.editLabel}>Quantity</label><input type="number" min={1} style={cvStyles.editInput} value={form.quantity} onChange={e=>setForm(p=>({...p,quantity:e.target.value}))} /></div>
            <div><label style={cvStyles.editLabel}>Value</label><input style={cvStyles.editInput} value={form.value} onChange={e=>setForm(p=>({...p,value:e.target.value}))} placeholder="e.g. 500 gp, Unknown, Evidence" /></div>
            <div style={{ gridColumn:'1/-1' }}><label style={cvStyles.editLabel}>Description</label><textarea style={cvStyles.editTextarea} rows={3} value={form.description} onChange={e=>setForm(p=>({...p,description:e.target.value}))} placeholder="What is this item and where did it come from?" /></div>
            {quests.length > 0 && (
              <div style={{ gridColumn:'1/-1' }}>
                <label style={cvStyles.editLabel}>Linked Quests</label>
                <div style={{ display:'flex', flexWrap:'wrap', gap:6, marginTop:4 }}>
                  {quests.map(q=>{
                    const linked = form.linkedQuestIds.includes(q.id);
                    const pc = PRIORITY_COLOR[q.priority]||'#6b6966';
                    return (
                      <button key={q.id} style={{ background:linked?`${pc}22`:'none', border:`1px solid ${linked?pc:'#3a3a42'}`, color:linked?pc:'#6b6966', borderRadius:6, padding:'4px 10px', cursor:'pointer', fontSize:12, fontFamily:"'Nunito',sans-serif" }} onClick={()=>toggleQuestLink(q.id)}>
                        {q.title}
                      </button>
                    );
                  })}
                </div>
              </div>
            )}
          </div>
          <div style={{ display:'flex', gap:8 }}>
            <button style={cvStyles.addBtn} onClick={saveItem} disabled={!form.name.trim()}>{editingId?'Save':'Add Item'}</button>
            <button style={{ ...cvStyles.addBtn, background:'none', borderColor:'#3a3a42', color:'#9a9793' }} onClick={()=>{setAdding(false);setEditingId(null);}}>Cancel</button>
          </div>
        </div>
      )}
      <div style={{display:'flex',flexDirection:'column',gap:10}}>
        {items.map(item=>{
          const linkedQuests = (item.linkedQuestIds||[]).map(id=>quests.find(q=>q.id===id)).filter(Boolean);
          return (
            <div key={item.id} style={cvStyles.inventoryRow}>
              <div style={{flex:1}}>
                <div style={{fontSize:14,fontWeight:700,color:'#e8e6e3',marginBottom:3}}>{item.name} <span style={{color:'#6b6966',fontWeight:400}}>×{item.quantity}</span></div>
                <div style={{fontSize:13,color:'#9a9793',lineHeight:1.5,marginBottom: linkedQuests.length?8:0}}>{item.description}</div>
                {linkedQuests.length > 0 && (
                  <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>
                    {linkedQuests.map(q=>(
                      <span key={q.id} style={{ fontSize:11, color: PRIORITY_COLOR[q.priority]||'#6b6966', background:`${PRIORITY_COLOR[q.priority]||'#6b6966'}18`, border:`1px solid ${PRIORITY_COLOR[q.priority]||'#6b6966'}33`, borderRadius:4, padding:'1px 7px' }}>{q.title}</span>
                    ))}
                  </div>
                )}
              </div>
              <div style={{textAlign:'right',flexShrink:0,marginLeft:16,display:'flex',flexDirection:'column',alignItems:'flex-end',gap:8}}>
                <div style={{fontSize:13,color:'#c9a227',fontWeight:700}}>{item.value}</div>
                {isDM && (
                  <div style={{ display:'flex', gap:6 }}>
                    <button style={{ background:'none', border:'1px solid #2a2a32', color:'#6b6966', borderRadius:4, padding:'2px 7px', cursor:'pointer', fontSize:11, fontFamily:"'Nunito',sans-serif" }} onClick={()=>startEdit(item)}>✎</button>
                    <button style={{ background:'none', border:'1px solid rgba(248,113,113,0.2)', color:'#f87171', borderRadius:4, padding:'2px 7px', cursor:'pointer', fontSize:11, fontFamily:"'Nunito',sans-serif" }} onClick={()=>deleteItem(item.id)}>✕</button>
                  </div>
                )}
              </div>
            </div>
          );
        })}
        {loading && items.length===0 && <div style={{ color:'#6b6966', textAlign:'center', padding:'32px 0', fontSize:13 }}>Loading inventory…</div>}
        {!loading && items.length===0 && <div style={{ color:'#4a4a52', textAlign:'center', padding:'32px 0', fontSize:13 }}>No items in the party inventory yet.</div>}
      </div>
    </div>
  );
};

// ── Spellbooks ────────────────────────────────────────────────────────────────
// Max prepared/known spell level by character level (standard 5e slot table).
// Inlined here when we retired js/spells.js; tiny enough to keep local.
const maxSpellLevel = (charLevel) => {
  if (charLevel >= 17) return 9;
  if (charLevel >= 15) return 8;
  if (charLevel >= 13) return 7;
  if (charLevel >= 11) return 6;
  if (charLevel >= 9)  return 5;
  if (charLevel >= 7)  return 4;
  if (charLevel >= 5)  return 3;
  if (charLevel >= 3)  return 2;
  return 1;
};

const SpellbooksTab = ({ campaign, user, isDM }) => {
  const [characters, setCharacters] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  const [rollingFor, setRollingFor] = React.useState(null);
  const [rollCount, setRollCount] = React.useState(8);
  const [filterClass, setFilterClass] = React.useState('');
  const [rolled, setRolled] = React.useState([]);
  const [editingNameFor, setEditingNameFor] = React.useState(null);
  const [nameInput, setNameInput] = React.useState('');
  const [spellSearch, setSpellSearch] = React.useState('');
  const [addingSpellFor, setAddingSpellFor] = React.useState(null);
  const [busy, setBusy] = React.useState(false);

  const spellLevelColors = {0:'#6b6966',1:'#1e6b3c',2:'#1a5c7a',3:'#b45309',4:'#7c3aed',5:'#c53030',6:'#991b1b',7:'#6b1a1a',8:'#4a0a0a',9:'#c9a227'};

  // Load characters for this campaign (DM/Admin sees all; players see their own only — backend enforces)
  const refresh = React.useCallback(async () => {
    setLoading(true);
    try {
      const list = await api.characters.list({ campaignId: campaign.id });
      setCharacters((list || []).map(api.augmentCharacter));
    } catch (err) {
      console.error('[SpellbooksTab] failed to load characters:', err);
    } finally {
      setLoading(false);
    }
  }, [campaign.id]);

  React.useEffect(() => { refresh(); }, [refresh]);

  // ── Spell search (debounced API call) ────────────────────────────────────
  // Replaces the old sync window.SPELL_DATA filter. Triggered when the search
  // input has 2+ chars and an "add spell" drawer is open for some character.
  const [searchResults, setSearchResults] = React.useState([]);
  React.useEffect(() => {
    if (spellSearch.length <= 1 || !addingSpellFor) { setSearchResults([]); return; }
    const ch = characters.find(c => c.id === addingSpellFor);
    if (!ch) return;
    const handle = setTimeout(async () => {
      try {
        const list = await api.spells.list({
          class: ch.class, maxLevel: maxSpellLevel(ch.level), search: spellSearch,
        });
        setSearchResults((list || []).slice(0, 8));
      } catch (err) {
        console.error('[SpellbooksTab] spell search failed:', err);
        setSearchResults([]);
      }
    }, 200);
    return () => clearTimeout(handle);
  }, [spellSearch, addingSpellFor, characters]);

  // Optimistic merge for a single-character mutation result.
  const replaceChar = (updated) => {
    setCharacters(prev => prev.map(c => c.id === updated.id ? api.augmentCharacter(updated) : c));
  };
  const refetchChar = async (chId) => {
    try {
      const updated = await api.characters.get(chId);
      replaceChar(updated);
    } catch (err) { console.error('[SpellbooksTab] refetch failed:', err); }
  };

  const rollRandomSpellbook = async (ch) => {
    if (busy) return;
    setBusy(true);
    try {
      const suggestions = await api.characters.spells.roll(ch.id, { className: filterClass || ch.class, count: rollCount });
      setRolled((suggestions || []).map(s => ({ name: s.name, level: s.level, prepared: false, source: 'spellbook' })));
    } catch (err) {
      console.error('[SpellbooksTab] roll failed:', err);
      window.dialog.alert('Failed to roll spellbook: ' + (err.message || 'unknown error'));
    } finally {
      setBusy(false);
    }
  };

  const acceptRoll = async (chId) => {
    if (busy) return;
    setBusy(true);
    try {
      const ch = characters.find(c => c.id === chId);
      const existing = new Set((ch?.spellbook || []).map(s => s.name));
      const fresh = rolled.filter(s => !existing.has(s.name));
      // Send adds in series so the backend's order is stable.
      for (const s of fresh) {
        await api.characters.spells.add(chId, { spellName: s.name, level: s.level, prepared: false, source: 'spellbook' });
      }
      await refetchChar(chId);
    } catch (err) {
      console.error('[SpellbooksTab] acceptRoll failed:', err);
      window.dialog.alert('Failed to add spells: ' + (err.message || 'unknown error'));
    } finally {
      setBusy(false);
      setRolled([]);
      setRollingFor(null);
    }
  };

  const saveName = async (chId) => {
    try {
      await api.characters.updateSpellbookName(chId, { name: nameInput });
      setCharacters(prev => prev.map(c => c.id === chId ? { ...c, spellbookName: nameInput } : c));
    } catch (err) {
      console.error('[SpellbooksTab] saveName failed:', err);
      window.dialog.alert('Failed to save name: ' + (err.message || 'unknown error'));
    } finally {
      setEditingNameFor(null);
    }
  };

  const addSpell = async (chId, spell) => {
    const ch = characters.find(c => c.id === chId);
    if (!ch) return;
    if (ch.spellbook.find(s => s.name === spell.name)) { setSpellSearch(''); return; }
    try {
      await api.characters.spells.add(chId, { spellName: spell.name, level: spell.level, prepared: false, source: 'spellbook' });
      await refetchChar(chId);
    } catch (err) {
      console.error('[SpellbooksTab] addSpell failed:', err);
      window.dialog.alert('Failed to add spell: ' + (err.message || 'unknown error'));
    }
    setSpellSearch('');
  };

  const removeSpell = async (chId, spellName) => {
    const ch = characters.find(c => c.id === chId);
    const sp = ch?.spellbook.find(s => s.name === spellName);
    if (!sp) return;
    try {
      await api.characters.spells.delete(chId, sp.id);
      setCharacters(prev => prev.map(c => c.id !== chId ? c : { ...c, spellbook: c.spellbook.filter(s => s.id !== sp.id) }));
    } catch (err) {
      console.error('[SpellbooksTab] removeSpell failed:', err);
      window.dialog.alert('Failed to remove spell: ' + (err.message || 'unknown error'));
    }
  };

  const togglePrepared = async (chId, spellName) => {
    const ch = characters.find(c => c.id === chId);
    const sp = ch?.spellbook.find(s => s.name === spellName);
    if (!sp) return;
    try {
      await api.characters.spells.update(chId, sp.id, { prepared: !sp.prepared });
      setCharacters(prev => prev.map(c => c.id !== chId ? c : { ...c, spellbook: c.spellbook.map(s => s.id === sp.id ? { ...s, prepared: !s.prepared } : s) }));
    } catch (err) {
      console.error('[SpellbooksTab] togglePrepared failed:', err);
    }
  };

  return (
    <div style={{display:'flex',flexDirection:'column',gap:28}}>
      {loading && characters.length === 0 && (
        <div style={{color:'#6b6966', fontSize:12, padding:'8px 0'}}>Loading characters…</div>
      )}
      {!loading && characters.length === 0 && (
        <div style={{color:'#4a4a52', fontSize:13, textAlign:'center', padding:'24px 0'}}>No characters in this campaign yet.</div>
      )}
      {characters.map(ch=>{
        const hasSpells = ch.spellbook.length>0||ch.scrollsCarried.length>0;
        const isRolling = rollingFor===ch.id;
        const isAddingSpell = addingSpellFor===ch.id;
        const maxLvl = maxSpellLevel(ch.level);
        // Search results come from the shared `searchResults` state, populated by the
        // debounced /api/spells fetch above. Only relevant for the open drawer.
        const searchResultsForCh = (isAddingSpell && spellSearch.length > 1) ? searchResults : [];

        return (
          <div key={ch.id} style={cvStyles.card}>
            {/* Book header */}
            <div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:16,flexWrap:'wrap',gap:8}}>
              <div style={{display:'flex',alignItems:'center',gap:12}}>
                {isDM && editingNameFor===ch.id ? (
                  <div style={{display:'flex',gap:8,alignItems:'center'}}>
                    <input style={{...cvStyles.editInput,width:220}} value={nameInput} onChange={e=>setNameInput(e.target.value)}
                      onKeyDown={e=>{if(e.key==='Enter')saveName(ch.id);if(e.key==='Escape')setEditingNameFor(null);}}
                      autoFocus placeholder="Spellbook name…"/>
                    <button style={cvStyles.addBtn} onClick={()=>saveName(ch.id)}>Save</button>
                    <button style={{...cvStyles.addBtn,background:'none',borderColor:'#3a3a42',color:'#9a9793'}} onClick={()=>setEditingNameFor(null)}>✕</button>
                  </div>
                ) : (
                  <div style={{display:'flex',alignItems:'center',gap:8}}>
                    <span style={{fontSize:16,color:'#c9a227'}}>📖</span>
                    <div>
                      {ch.spellbookName
                        ? <span style={{fontFamily:"'Cinzel',serif",fontSize:15,fontWeight:700,color:'#c9a227'}}>{ch.spellbookName}</span>
                        : <span style={{fontSize:13,color:'#4a4a52',fontStyle:'italic'}}>{isDM?'Unnamed spellbook — click ✎ to name':'No spellbook name set'}</span>
                      }
                      <div style={{fontSize:12,color:'#9a9793',marginTop:1}}>{ch.name} · {ch.race} {ch.class} (Lv.{ch.level})</div>
                    </div>
                    {isDM&&<button style={cvStyles.editIconBtn} onClick={()=>{setEditingNameFor(ch.id);setNameInput(ch.spellbookName||'')}} title="Name spellbook">✎</button>}
                  </div>
                )}
              </div>
              {isDM&&!isRolling&&(
                <div style={{display:'flex',gap:8}}>
                  <button style={cvStyles.rollBtn} onClick={()=>{setRollingFor(ch.id);setFilterClass(ch.class);setRolled([]);}}>
                    🎲 Roll Random Spellbook
                  </button>
                  <button style={cvStyles.addBtn} onClick={()=>setAddingSpellFor(isAddingSpell?null:ch.id)}>
                    + Add Spell
                  </button>
                </div>
              )}
            </div>

            {/* Add spell search */}
            {isDM&&isAddingSpell&&(
              <div style={{background:'#111116',border:'1px solid #2a2a32',borderRadius:8,padding:14,marginBottom:16}}>
                <div style={{fontSize:11,color:'#9a9793',fontWeight:700,textTransform:'uppercase',letterSpacing:'0.08em',marginBottom:8}}>Add spell to {ch.name}'s book</div>
                <input style={{...cvStyles.editInput,width:'100%',marginBottom:8}} value={spellSearch}
                  onChange={e=>setSpellSearch(e.target.value)} placeholder={`Search ${ch.class} spells up to level ${maxLvl}…`}/>
                {searchResultsForCh.length>0&&(
                  <div style={{display:'flex',flexDirection:'column',gap:4,maxHeight:200,overflowY:'auto'}}>
                    {searchResultsForCh.map(s=>(
                      <button key={s.id} style={cvStyles.spellSearchRow} onClick={()=>addSpell(ch.id,s)}>
                        <span style={{fontSize:11,fontWeight:800,color:spellLevelColors[s.level],marginRight:6,minWidth:14}}>L{s.level}</span>
                        <span style={{flex:1,textAlign:'left',color:'#e8e6e3',fontSize:13}}>{s.name}</span>
                        <span style={{fontSize:11,color:'#6b6966'}}>{s.school}</span>
                      </button>
                    ))}
                  </div>
                )}
                {spellSearch.length>1&&searchResultsForCh.length===0&&<div style={{fontSize:12,color:'#4a4a52'}}>No matching spells found.</div>}
              </div>
            )}

            {/* Random roll panel */}
            {isDM&&isRolling&&(
              <div style={{background:'#111116',border:'1px solid #c9a22733',borderRadius:8,padding:16,marginBottom:16}}>
                <div style={{fontSize:12,fontWeight:700,color:'#c9a227',marginBottom:12}}>🎲 Random Spellbook Generator — {ch.name}</div>
                <div style={{display:'flex',gap:12,alignItems:'center',marginBottom:14,flexWrap:'wrap'}}>
                  <div>
                    <label style={{...cvStyles.editLabel,display:'block',marginBottom:4}}>Class</label>
                    <select style={cvStyles.editSelect} value={filterClass} onChange={e=>setFilterClass(e.target.value)}>
                      {['Wizard','Sorcerer','Warlock','Cleric','Druid','Bard','Paladin','Ranger','Artificer'].map(c=>(
                        <option key={c} value={c}>{c}</option>
                      ))}
                    </select>
                  </div>
                  <div>
                    <label style={{...cvStyles.editLabel,display:'block',marginBottom:4}}>Number of spells</label>
                    <input style={{...cvStyles.editInput,width:70}} type="number" min={1} max={30} value={rollCount}
                      onChange={e=>setRollCount(Number(e.target.value))}/>
                  </div>
                  <button style={{...cvStyles.rollBtn,alignSelf:'flex-end'}} onClick={()=>rollRandomSpellbook({...ch,class:filterClass})}>Roll</button>
                  <button style={{...cvStyles.addBtn,background:'none',borderColor:'#3a3a42',color:'#9a9793',alignSelf:'flex-end'}} onClick={()=>{setRollingFor(null);setRolled([]);}}>Cancel</button>
                </div>
                {rolled.length>0&&(
                  <>
                    <div style={{display:'flex',flexWrap:'wrap',gap:8,marginBottom:12}}>
                      {rolled.map((sp,i)=>(
                        <div key={i} style={{...cvStyles.spellChip,borderColor:spellLevelColors[sp.level]||'#6b6966'}}>
                          <span style={{fontSize:10,color:spellLevelColors[sp.level],marginRight:4,fontWeight:700}}>L{sp.level}</span>
                          {sp.name}
                        </div>
                      ))}
                    </div>
                    <button style={cvStyles.addBtn} onClick={()=>acceptRoll(ch.id)}>
                      ✓ Add {rolled.length} spells to {ch.name}'s book
                    </button>
                  </>
                )}
              </div>
            )}

            {/* Spellbook list */}
            {ch.spellbook.length>0&&(
              <>
                <div style={cvStyles.spellSection}>Spellbook ({ch.spellbook.length} spells)</div>
                <div style={{display:'flex',flexDirection:'column',gap:6,marginBottom:16}}>
                  {ch.spellbook.map((sp,i)=>(
                    <div key={i} style={{...cvStyles.spellRowFull,opacity:sp.prepared?1:0.6}}>
                      <div style={{...cvStyles.spellLevel,background:(spellLevelColors[sp.level]||'#6b6966')+'22',color:spellLevelColors[sp.level]||'#6b6966'}}>
                        {sp.level===0?'C':sp.level}
                      </div>
                      <div style={{flex:1}}>
                        <span style={{fontSize:14,fontWeight:700,color:'#e8e6e3'}}>{sp.name}</span>
                        <span style={{fontSize:11,color:'#6b6966',marginLeft:8,textTransform:'capitalize'}}>{sp.source}</span>
                      </div>
                      <div style={{display:'flex',alignItems:'center',gap:8}}>
                        {isDM&&(
                          <button style={cvStyles.prepToggle} onClick={()=>togglePrepared(ch.id,sp.name)}>
                            {sp.prepared?'● Prepared':'○ Unprepared'}
                          </button>
                        )}
                        {!isDM&&sp.prepared&&<span style={{fontSize:11,color:'#c9a227',fontWeight:700}}>● Prepared</span>}
                        {!isDM&&!sp.prepared&&<span style={{fontSize:11,color:'#4a4a52'}}>Unprepared</span>}
                        {isDM&&<button style={cvStyles.removeBtn} onClick={()=>removeSpell(ch.id,sp.name)}>✕</button>}
                      </div>
                    </div>
                  ))}
                </div>
              </>
            )}

            {ch.scrollsCarried.length>0&&(
              <>
                <div style={cvStyles.spellSection}>Scrolls Carried ({ch.scrollsCarried.length})</div>
                <div style={{display:'flex',flexWrap:'wrap',gap:8}}>
                  {ch.scrollsCarried.map((sc,i)=>(
                    <div key={i} style={{...cvStyles.spellChip,background:'rgba(201,162,39,0.07)',borderColor:'#b45309'}}>
                      <span style={{fontSize:10,color:'#c9a227',marginRight:4}}>📜</span>
                      {sc.name}
                      <span style={{marginLeft:6,fontSize:10,color:'#6b6966'}}>{sc.source}</span>
                    </div>
                  ))}
                </div>
              </>
            )}

            {!hasSpells&&!isRolling&&(
              <div style={{fontSize:13,color:'#4a4a52',textAlign:'center',padding:'16px 0'}}>
                No spells or scrolls.{isDM&&' Use the Roll or Add Spell buttons above.'}
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
};

// ── Guild ─────────────────────────────────────────────────────────────────────
const GUILD_RANKS = ['E','D','C','B','A','S'];
const RANK_THRESHOLDS = { E:10, D:50, C:250, B:1000, A:5000, S:15000 };
const RANK_FEATURES = {
  E: ['Base of operations','Max 10 guild members'],
  D: ['Build basic facilities','Hire Apprentice specialists','Appoint team leaders','Create teams','Max 20 members'],
  C: ['Build improved facilities','Expeditions','Hire Journeyman specialists','Max 40 members'],
  B: ['Hire Master specialists','Appoint Commanders','Create squads','Start mining operations','Max 80 members'],
  A: ['Build advanced facilities','Hire Grandmaster specialists','Additional base','Large scale explorations','Max 160 members'],
  S: ['Pioneer a new city','Max 320 members'],
};
const FACILITY_TIERS = { basic:'Basic', improved:'Improved', advanced:'Advanced' };
// Per-tier cost in gp. Each tier is rarer than the last; price scales accordingly.
const FACILITY_TIER_COST = { basic: 500, improved: 2000, advanced: 5000 };
// Each tier gets an increasingly rarer colour (uncommon → rare → very-rare).
const FACILITY_TIER_COLOR = { basic: '#22c55e', improved: '#3a8fd9', advanced: '#a05fd9' };
const FACILITIES_META = {
  reception:  { label:'Reception', icon:'🏛', tiers:['basic'] },
  crafting:   { label:'Crafting Facility', icon:'⚒', tiers:['basic','improved','advanced'] },
  butchery:   { label:'Butchery Facility', icon:'🔪', tiers:['basic','improved','advanced'] },
  kitchen:    { label:"Cook's Kitchen", icon:'🍳', tiers:['basic'] },
  sleeping:   { label:'Sleeping Quarters', icon:'🛏', tiers:['basic','improved','advanced'] },
  market:     { label:'Market Stand', icon:'🏪', tiers:['basic','advanced'] },
  apothecary: { label:'Apothecary', icon:'⚗', tiers:['basic','advanced'] },
  restaurant: { label:"Restaurant's Kitchen", icon:'👨‍🍳', tiers:['advanced'] },
  diningHall: { label:'Dining Hall', icon:'🍽', tiers:['advanced'] },
  library:    { label:'Grand Library', icon:'📚', tiers:['advanced'] },
};
const SPEC_LEVELS = ['Apprentice','Journeyman','Master','Grandmaster'];
const SPEC_LEVEL_COLOR = { Apprentice:'#6b6966', Journeyman:'#1e6b3c', Master:'#1a5c7a', Grandmaster:'#c9a227' };
const SPECIALIZATIONS = [
  'Blacksmith','Leatherworker','Woodworker','Stonemason','Alchemist',
  'Herbalist','Beastmaster','Enchanter','Butcher','Cook',
  'Scout','Recruiter','Receptionist','Trader','Fisher',
];

const GuildModal = ({ title, onClose, children }) => (
  <div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.7)', zIndex:1000, display:'flex', alignItems:'center', justifyContent:'center', backdropFilter:'blur(4px)' }}
    {...scrimDismiss(onClose)}>
    <div style={{ background:'#1c1c22', border:'1px solid #3a3a42', borderRadius:12, padding:'24px 28px', width:'100%', maxWidth:480, maxHeight:'85vh', overflowY:'auto', boxShadow:'0 20px 60px rgba(0,0,0,0.8)' }}
      onClick={e=>e.stopPropagation()}>
      <div style={{ fontFamily:"'Cinzel',serif", fontSize:15, fontWeight:800, color:'#c9a227', marginBottom:18 }}>{title}</div>
      {children}
    </div>
  </div>
);

const gLabel = { fontSize:10, fontWeight:800, color:'#6b6966', textTransform:'uppercase', letterSpacing:'0.08em', display:'block', marginBottom:4 };
const gInput = { width:'100%', background:'#111116', border:'1px solid #3a3a42', borderRadius:6, color:'#e8e6e3', padding:'7px 10px', fontSize:13, outline:'none', fontFamily:"'Nunito',sans-serif", boxSizing:'border-box' };
const gSelect = { width:'100%', background:'#111116', border:'1px solid #3a3a42', borderRadius:6, color:'#e8e6e3', padding:'7px 10px', fontSize:13, cursor:'pointer', fontFamily:"'Nunito',sans-serif", boxSizing:'border-box' };
const gTextarea = { width:'100%', background:'#111116', border:'1px solid #3a3a42', borderRadius:6, color:'#e8e6e3', padding:'8px 10px', fontSize:13, outline:'none', fontFamily:"'Nunito',sans-serif", resize:'vertical', boxSizing:'border-box', lineHeight:1.5 };

// JSON-stored guild fields are kept as objects in component state; serialised on save.
const guildFromApi = (g) => g && ({
  ...g,
  facilities: (() => { try { return JSON.parse(g.facilitiesJson || '{}'); } catch { return {}; } })(),
  rankHistory: (() => { try { return JSON.parse(g.rankHistoryJson || '[]'); } catch { return []; } })(),
  expeditions: (() => { try { return JSON.parse(g.expeditionsJson || '[]'); } catch { return []; } })(),
});

const GuildTab = ({ campaign, isDM }) => {
  const [guildData, setGuildData] = React.useState(null);
  const [loading, setLoading] = React.useState(true);
  const [registering, setRegistering] = React.useState(false);
  const [registerName, setRegisterName] = React.useState('');

  const refresh = React.useCallback(async () => {
    setLoading(true);
    try {
      const g = await api.guilds.get(campaign.id);
      setGuildData(guildFromApi(g));
    } catch (err) {
      if (err.status === 404) setGuildData(null);
      else console.error('[GuildTab] load failed:', err);
    } finally {
      setLoading(false);
    }
  }, [campaign.id]);

  React.useEffect(() => { refresh(); }, [refresh]);

  // Cities cached locally for the "Buy Plot" price lookup (the picker has its
  // own copy). Reloads when the campaign changes and whenever a city-changing
  // event fires elsewhere in the app — keeps the plot price honest after a
  // DM tweaks a city on the world map.
  const [cities, setCities] = React.useState([]);
  const reloadCities = React.useCallback(async () => {
    try { setCities((await api.cities.list({ campaignId: campaign.id })) || []); }
    catch (err) { console.error('[GuildTab] cities load failed:', err); }
  }, [campaign.id]);
  React.useEffect(() => { reloadCities(); }, [reloadCities]);
  React.useEffect(() => {
    const onChanged = () => reloadCities();
    window.addEventListener('kamia:cities-changed', onChanged);
    return () => window.removeEventListener('kamia:cities-changed', onChanged);
  }, [reloadCities]);

  // Requests claimed by this guild — used in the "Association Requests" panel below.
  const [guildRequests, setGuildRequests] = React.useState([]);
  const refreshGuildRequests = React.useCallback(async () => {
    if (!guildData) { setGuildRequests([]); return; }
    try {
      const list = await api.requests.list({ campaignId: campaign.id });
      setGuildRequests((list || []).filter(r => r.claimedByGuildId === guildData.id));
    } catch (err) { console.error('[GuildTab] requests load failed:', err); }
  }, [campaign.id, guildData?.id]);
  React.useEffect(() => { refreshGuildRequests(); }, [refreshGuildRequests]);

  const [modal, setModal] = React.useState(null); // null | 'member' | 'facility' | 'expedition' | 'points' | 'plot'

  // ── member form (also used to EDIT an existing member; editingMemberId set when editing)
  const [memberForm, setMemberForm] = React.useState({ name:'', role:'Hunter', specialization:'Blacksmith', specLevel:'Apprentice', playerId:'' });
  const [editingMemberId, setEditingMemberId] = React.useState(null);
  // ── facility form
  const [facilityForm, setFacilityForm] = React.useState({ key:'reception', tier:'basic' });
  // ── buy plot form
  const [buyPlotForm, setBuyPlotForm] = React.useState({ cityId: '' });
  // ── expedition form
  const [expForm, setExpForm] = React.useState({ name:'', date:'', participants:'', outcome:'', reward:'', status:'active' });
  // ── points form
  const [pointsForm, setPointsForm] = React.useState({ amount:100, reason:'', type:'manual' });
  // ── repay-loan form
  const [repayForm, setRepayForm] = React.useState({ amount:0 });
  // ── edit-guild form — name + notes (the always-editable core fields)
  const [editGuildForm, setEditGuildForm] = React.useState({ name:'', notes:'' });

  // Compute auto rank-up + persist via PATCH guild. JSON-stored fields are reserialised.
  // Member changes don't go through `save()` — they hit the member endpoints directly.
  const save = async (patch) => {
    if (!guildData) return;
    const updated = { ...guildData, ...patch };
    let rank = updated.rank;
    let history = updated.rankHistory || [];
    let rankChanged = false;
    const thresholds = [['S',15000],['A',5000],['B',1000],['C',250],['D',50],['E',10]];
    for (const [r, pts] of thresholds) {
      if (updated.rankPoints >= pts && GUILD_RANKS.indexOf(r) > GUILD_RANKS.indexOf(rank)) {
        history = [...history, { rank: r, achieved: new Date().toISOString().slice(0,10) }];
        rank = r;
        rankChanged = true;
      }
    }
    const final = { ...updated, rank, rankHistory: history };
    setGuildData(final);

    const apiPatch = {};
    if ('name' in patch)         apiPatch.name = final.name;
    if (rankChanged)             apiPatch.rank = final.rank;
    if ('rank' in patch)         apiPatch.rank = final.rank;
    if ('rankPoints' in patch)   apiPatch.rankPoints = final.rankPoints;
    if ('baseCityId' in patch)   apiPatch.baseCityId = final.baseCityId;
    if ('basePurchased' in patch) apiPatch.basePurchased = final.basePurchased;
    if ('treasury' in patch)     apiPatch.treasury = final.treasury;
    if ('loanAmount' in patch)   apiPatch.loanAmount = final.loanAmount;
    if ('loanForJson' in patch)  apiPatch.loanForJson = final.loanForJson;
    if ('notes' in patch)        apiPatch.notes = final.notes;
    if ('facilities' in patch)   apiPatch.facilitiesJson = JSON.stringify(final.facilities);
    if (rankChanged || 'rankHistory' in patch) apiPatch.rankHistoryJson = JSON.stringify(final.rankHistory);
    if ('expeditions' in patch)  apiPatch.expeditionsJson = JSON.stringify(final.expeditions);

    if (Object.keys(apiPatch).length === 0) return;
    try { await api.guilds.update(campaign.id, apiPatch); }
    catch (err) {
      console.error('[GuildTab] save failed:', err);
      window.dialog.alert('Failed to save: ' + (err.message || 'unknown error'));
    }
  };

  const registerGuild = async () => {
    if (!registerName.trim()) return;
    try {
      await api.guilds.create(campaign.id, { name: registerName.trim() });
      setRegistering(false); setRegisterName('');
      await refresh();
    } catch (err) {
      console.error('[GuildTab] register failed:', err);
      window.dialog.alert('Failed to register guild: ' + (err.message || 'unknown error'));
    }
  };

  // ── Member CRUD via dedicated endpoints ─────────────────────────────────────
  const addMember = async () => {
    if (!memberForm.name.trim()) return;
    try {
      if (editingMemberId) {
        await api.guilds.members.update(campaign.id, editingMemberId, {
          name: memberForm.name, role: memberForm.role,
          specialization: memberForm.specialization, specLevel: memberForm.specLevel,
          playerId: memberForm.playerId || null,
        });
      } else {
        await api.guilds.members.add(campaign.id, {
          name: memberForm.name, role: memberForm.role,
          specialization: memberForm.specialization, specLevel: memberForm.specLevel,
          playerId: memberForm.playerId || null,
        });
      }
      await refresh();
    } catch (err) {
      console.error('[GuildTab] member save failed:', err);
      window.dialog.alert('Failed to save member: ' + (err.message || 'unknown error'));
    }
    setModal(null); setEditingMemberId(null);
    setMemberForm({ name:'', role:'Hunter', specialization:'Blacksmith', specLevel:'Apprentice', playerId:'' });
  };

  const startEditMember = (m) => {
    setEditingMemberId(m.id);
    setMemberForm({ name: m.name, role: m.role, specialization: m.specialization, specLevel: m.specLevel, playerId: m.playerId || '' });
    setModal('member');
  };

  const removeMember = async (id) => {
    if (!(await window.dialog.confirm({ title:'Remove member?', message:'Remove this member?', danger:true, confirmLabel:'Remove' }))) return;
    try { await api.guilds.members.delete(campaign.id, id); await refresh(); }
    catch (err) {
      console.error('[GuildTab] member remove failed:', err);
      window.dialog.alert('Failed to remove: ' + (err.message || 'unknown error'));
    }
  };

  const buyPlot = async () => {
    if (!buyPlotForm.cityId) return;
    const city = cities.find(c => c.id === buyPlotForm.cityId);
    if (!city) return;
    const treasury = guildData.treasury || 0;
    const price = city.plotPrice;
    const existingLoan = guildData.loanAmount || 0;
    let newTreasury = treasury - price;
    let newLoan = existingLoan;
    let loanForPatch = guildData.loanForJson || '{}';
    let noteSuffix = '';
    if (newTreasury < 0) {
      const deficit = -newTreasury;
      // Spec: "If you don't have a loan, you can take a loan for an upgrade." Same rule for plots.
      if (existingLoan > 0) {
        await window.dialog.alert(
          `Treasury is short by ${deficit.toLocaleString()} gp.\n\n` +
          `You already have an outstanding loan — repay or cancel it before taking another.`,
          { title: 'Loan Already Outstanding' }
        );
        return;
      }
      const ok = await window.dialog.confirm({
        title: 'Take out a loan?',
        message:
          `Treasury is short by ${deficit.toLocaleString()} gp.\n\n` +
          `Take out a loan of ${deficit.toLocaleString()} gp from the Hunters Association to complete the purchase of a plot in ${city.name} (${price.toLocaleString()} gp)?\n\n` +
          `While the loan is outstanding, 50% of every claimed-request reward will auto-route to repayment.`,
        confirmLabel: `Take loan & buy`,
      });
      if (!ok) return;
      newTreasury = 0;
      newLoan = deficit;
      loanForPatch = JSON.stringify({ kind:'plot', cityId: city.id, totalCost: price, takenAt: new Date().toISOString().slice(0,10) });
      noteSuffix = ` (loan: ${deficit.toLocaleString()} gp)`;
    }
    await save({
      baseCityId: city.id,
      basePurchased: true,
      treasury: newTreasury,
      loanAmount: newLoan,
      loanForJson: loanForPatch,
      notes: (guildData.notes ? guildData.notes + '\n\n' : '') + `Purchased a plot in ${city.name} for ${price.toLocaleString()} gp${noteSuffix}.`,
    });
    setModal(null);
    setBuyPlotForm({ cityId: '' });
  };

  const buildFacility = async () => {
    const meta = FACILITIES_META[facilityForm.key];
    if (!meta) return;
    // Sequential check: target must be the next tier in this facility's sequence.
    const current = guildData.facilities[facilityForm.key];
    const expectedNext = meta.tiers[(current ? meta.tiers.indexOf(current) : -1) + 1];
    if (expectedNext !== facilityForm.tier) {
      await window.dialog.alert(`Can only upgrade to the next stage (${FACILITY_TIERS[expectedNext]}). No skipping.`, { title:'Sequential upgrade required' });
      return;
    }
    const cost = FACILITY_TIER_COST[facilityForm.tier] || 0;
    const treasury = guildData.treasury || 0;
    let newTreasury = treasury - cost;
    let newLoan = guildData.loanAmount || 0;
    let loanForPatch = guildData.loanForJson || '{}';
    let noteSuffix = '';
    if (newTreasury < 0) {
      if (newLoan > 0) {
        await window.dialog.alert(
          `Treasury is short by ${(-newTreasury).toLocaleString()} gp.\n\nYou already have an outstanding loan — repay or cancel it before taking another.`,
          { title:'Loan Already Outstanding' }
        );
        return;
      }
      const deficit = -newTreasury;
      const ok = await window.dialog.confirm({
        title: 'Take out a loan?',
        message:
          `Treasury is short by ${deficit.toLocaleString()} gp.\n\n` +
          `Take out a loan of ${deficit.toLocaleString()} gp to build ${FACILITY_TIERS[facilityForm.tier]} ${meta.label} (${cost.toLocaleString()} gp)?\n\n` +
          `While the loan is outstanding, 50% of every claimed-request reward will auto-route to repayment.`,
        confirmLabel: `Take loan & build`,
      });
      if (!ok) return;
      newTreasury = 0;
      newLoan = deficit;
      loanForPatch = JSON.stringify({
        kind:'facility', facilityKey: facilityForm.key,
        previousTier: current, newTier: facilityForm.tier,
        totalCost: cost, takenAt: new Date().toISOString().slice(0,10),
      });
      noteSuffix = ` (loan: ${deficit.toLocaleString()} gp)`;
    }
    await save({
      facilities: { ...guildData.facilities, [facilityForm.key]: facilityForm.tier },
      treasury: newTreasury,
      loanAmount: newLoan,
      loanForJson: loanForPatch,
      notes: (guildData.notes ? guildData.notes + '\n\n' : '') + `Built ${FACILITY_TIERS[facilityForm.tier]} ${meta.label} for ${cost.toLocaleString()} gp${noteSuffix}.`,
    });
    setModal(null);
  };

  const repayLoan = async () => {
    const amt = Math.max(0, Math.min(
      Number(repayForm.amount) || 0,
      guildData.treasury || 0,
      guildData.loanAmount || 0,
    ));
    if (!amt) return;
    await save({
      treasury: (guildData.treasury || 0) - amt,
      loanAmount: (guildData.loanAmount || 0) - amt,
      notes: (guildData.notes ? guildData.notes + '\n\n' : '') + `Repaid ${amt.toLocaleString()} gp toward loan.`,
    });
    setModal(null);
    setRepayForm({ amount: 0 });
  };

  // Cancel an outstanding loan: undo the asset the loan paid for and refund the
  // guild's actual contribution (totalCost - remaining loan) back to treasury.
  // The remaining loan amount is what the lender (Hunters Association) put up;
  // they get nothing back — the asset just unwinds. If `loanForJson` is missing
  // or unparseable we still clear the loan but skip the asset revert (legacy rows).
  const cancelLoan = async () => {
    if (!guildData) return;
    const loan = guildData.loanAmount || 0;
    if (loan <= 0) return;
    let meta = {};
    try { meta = JSON.parse(guildData.loanForJson || '{}'); } catch {}
    const totalCost = Number(meta.totalCost) || 0;
    const refund = Math.max(0, totalCost - loan);

    const lines = [
      `Cancel the outstanding ${loan.toLocaleString()} gp loan?`,
      '',
    ];
    if (meta.kind === 'plot') {
      const cityName = (cities.find(c => c.id === meta.cityId) || {}).name || 'the purchased city';
      lines.push(`This will revert the plot purchase in ${cityName}.`);
    } else if (meta.kind === 'facility') {
      const facMeta = FACILITIES_META[meta.facilityKey];
      const facLabel = facMeta?.label || meta.facilityKey;
      const newLabel = FACILITY_TIERS[meta.newTier] || meta.newTier;
      const prev = meta.previousTier ? `back to ${FACILITY_TIERS[meta.previousTier]}` : 'back to unbuilt';
      lines.push(`This will revert ${newLabel} ${facLabel} ${prev}.`);
    } else {
      lines.push('No asset metadata recorded — the loan will be cleared but no asset will be reverted. (You may need to undo the purchase manually.)');
    }
    lines.push('');
    lines.push(`Refund to treasury: ${refund.toLocaleString()} gp ` +
      `(original cost ${totalCost.toLocaleString()} − loan still owed ${loan.toLocaleString()}).`);

    const ok = await window.dialog.confirm({
      title: 'Cancel Loan & Revert Asset?',
      message: lines.join('\n'),
      danger: true,
      confirmLabel: 'Cancel loan',
    });
    if (!ok) return;

    const patch = {
      treasury: (guildData.treasury || 0) + refund,
      loanAmount: 0,
      loanForJson: '{}',
      notes: (guildData.notes ? guildData.notes + '\n\n' : '') +
        `Cancelled loan (${loan.toLocaleString()} gp) and reverted ${meta.kind || 'asset'}; refunded ${refund.toLocaleString()} gp to treasury.`,
    };
    if (meta.kind === 'plot') {
      patch.basePurchased = false;
      patch.baseCityId = '00000000-0000-0000-0000-000000000000'; // backend treats Empty as null
    } else if (meta.kind === 'facility' && meta.facilityKey) {
      patch.facilities = { ...guildData.facilities, [meta.facilityKey]: meta.previousTier || null };
    }
    await save(patch);
  };

  const removeFacility = (key) => save({ facilities: { ...guildData.facilities, [key]: null } });

  const logExpedition = () => {
    if (!expForm.name.trim()) return;
    const ex = { ...expForm, id:'ex'+Date.now(), participants: expForm.participants.split(',').map(s=>s.trim()).filter(Boolean) };
    save({ expeditions: [...(guildData.expeditions||[]), ex] });
    setModal(null);
    setExpForm({ name:'', date:'', participants:'', outcome:'', reward:'', status:'active' });
  };

  const completeExpedition = (id) => save({ expeditions: guildData.expeditions.map(e=>e.id===id?{...e,status:'completed'}:e) });
  const deleteExpedition = (id) => save({ expeditions: guildData.expeditions.filter(e=>e.id!==id) });

  const awardPoints = () => {
    const amt = Number(pointsForm.amount);
    if (!amt) return;
    save({ rankPoints: (guildData.rankPoints||0) + amt });
    setModal(null);
    setPointsForm({ amount:100, reason:'', type:'manual' });
  };

  if (loading) return <div style={{ color:'#6b6966', padding:'60px 0', textAlign:'center', fontSize:13 }}>Loading guild…</div>;

  if (!guildData) return (
    <div style={{ color:'#4a4a52', textAlign:'center', padding:'60px 0', fontSize:14 }}>
      No guild registered for this campaign.
      {isDM && !registering && <div style={{ marginTop:12 }}><button style={cvStyles.addBtn} onClick={()=>setRegistering(true)}>+ Register Guild</button></div>}
      {isDM && registering && (
        <div style={{ marginTop:14, display:'flex', gap:8, justifyContent:'center' }}>
          <input style={{...cvStyles.editInput, width:240}} value={registerName} onChange={e=>setRegisterName(e.target.value)} placeholder="Guild name…" autoFocus />
          <button style={cvStyles.addBtn} onClick={registerGuild} disabled={!registerName.trim()}>Register</button>
          <button style={{...cvStyles.addBtn, background:'none', borderColor:'#3a3a42', color:'#9a9793'}} onClick={()=>{setRegistering(false); setRegisterName('');}}>Cancel</button>
        </div>
      )}
    </div>
  );

  const guild = guildData;
  const currentRankIdx = GUILD_RANKS.indexOf(guild.rank);
  const nextRank = GUILD_RANKS[currentRankIdx + 1];
  const nextThreshold = nextRank ? RANK_THRESHOLDS[nextRank] : null;
  const prevThreshold = RANK_THRESHOLDS[guild.rank] || 0;
  const progress = nextThreshold ? Math.min(1, (guild.rankPoints - prevThreshold) / (nextThreshold - prevThreshold)) : 1;

  // Buildable facilities — only the NEXT tier in each facility's defined sequence
  // (no skipping basic→advanced for facilities that have an "improved" stage in between).
  const buildableOptions = Object.entries(FACILITIES_META).flatMap(([key, meta]) => {
    const current = guild.facilities[key];
    const currentIdx = current ? meta.tiers.indexOf(current) : -1;
    const next = meta.tiers[currentIdx + 1];
    if (!next) return [];
    return [{
      key, tier: next, label: `${FACILITY_TIERS[next]} ${meta.label}`,
      cost: FACILITY_TIER_COST[next] || 0,
    }];
  });

  return (
    <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:24, maxWidth:1100 }}>

      {/* LEFT COLUMN */}
      <div style={{ display:'flex', flexDirection:'column', gap:20 }}>

        {/* Rank card */}
        <div style={{ ...cvStyles.card, background:'linear-gradient(135deg,#1c1a10 0%,#1c1c22 100%)', border:'1px solid #c9a22733' }}>
          <div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', marginBottom:16 }}>
            <div style={{ display:'flex', alignItems:'center', gap:8 }}>
              <div>
                <div style={{ fontFamily:"'Cinzel',serif", fontSize:22, fontWeight:900, color:'#c9a227' }}>{guild.name}</div>
                <div style={{ fontSize:12, color:'#9a9793', marginTop:3 }}>Base: {guild.baseCityName || (guild.basePurchased ? '—' : 'No base yet')} · Hunters Association</div>
              </div>
              {isDM && (
                <button title="Edit guild name and notes"
                  onClick={() => { setEditGuildForm({ name: guild.name, notes: guild.notes || '' }); setModal('editguild'); }}
                  style={{ background:'transparent', border:'1px solid #c9a22744', color:'#c9a227', borderRadius:5, padding:'3px 8px', cursor:'pointer', fontSize:11, fontFamily:"'Nunito',sans-serif", height:'fit-content' }}>
                  ✎ Edit
                </button>
              )}
            </div>
            <div style={{ textAlign:'center' }}>
              <div style={{ fontFamily:"'Cinzel',serif", fontSize:36, fontWeight:900, color:'#c9a227', lineHeight:1 }}>Rank {guild.rank}</div>
              <div style={{ fontSize:11, color:'#6b6966', marginTop:2 }}>{guild.rankPoints.toLocaleString()} pts</div>
            </div>
          </div>

          {nextRank && (
            <div style={{ marginBottom:14 }}>
              <div style={{ display:'flex', justifyContent:'space-between', fontSize:11, color:'#6b6966', marginBottom:5 }}>
                <span>Rank {guild.rank} ({prevThreshold} pts)</span>
                <span>Rank {nextRank} ({nextThreshold?.toLocaleString()} pts)</span>
              </div>
              <div style={{ height:6, background:'#2a2a32', borderRadius:3, overflow:'hidden' }}>
                <div style={{ height:'100%', width:`${progress*100}%`, background:'linear-gradient(90deg,#b45309,#c9a227)', borderRadius:3, transition:'width 0.4s ease' }}></div>
              </div>
              <div style={{ fontSize:11, color:'#9a9793', marginTop:4 }}>{Math.round(progress*100)}% to Rank {nextRank} · {(nextThreshold - guild.rankPoints).toLocaleString()} pts remaining</div>
            </div>
          )}

          <div style={{ display:'flex', gap:6, flexWrap:'wrap', marginBottom: isDM ? 12 : 0 }}>
            {guild.rankHistory.map(h => (
              <div key={h.rank} style={{ fontSize:11, background:'rgba(201,162,39,0.1)', border:'1px solid rgba(201,162,39,0.25)', borderRadius:5, padding:'3px 10px', color:'#c9a227' }}>
                Rank {h.rank} <span style={{ color:'#6b6966' }}>· {h.achieved}</span>
              </div>
            ))}
          </div>

          {isDM && (
            <button style={{ ...cvStyles.addBtn, marginTop:8, background:'rgba(201,162,39,0.12)', borderColor:'#c9a227', color:'#c9a227' }}
              onClick={() => setModal('points')}>
              + Award Rank Points
            </button>
          )}

          {guild.notes && <p style={{ fontSize:12, color:'#6b6966', marginTop:12, lineHeight:1.6, borderTop:'1px solid #2a2a32', paddingTop:12, margin:'12px 0 0' }}>{guild.notes}</p>}
        </div>

        {/* Treasury / Loan card */}
        <div style={cvStyles.card}>
          <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:10 }}>
            <div style={cvStyles.cardTitle}>Treasury</div>
            {isDM && (guild.loanAmount || 0) > 0 && (guild.treasury || 0) > 0 && (
              <button style={{ ...cvStyles.addBtn, fontSize:11, background:'rgba(30,107,60,0.12)', borderColor:'#1e6b3c', color:'#22c55e' }}
                onClick={() => { setRepayForm({ amount: Math.min(guild.treasury, guild.loanAmount) }); setModal('repay'); }}>
                Repay Loan
              </button>
            )}
          </div>
          <div style={{ display:'flex', gap:14, alignItems:'baseline' }}>
            <div style={{ fontFamily:"'Cinzel',serif", fontSize:24, fontWeight:900, color:'#c9a227' }}>{(guild.treasury||0).toLocaleString()} gp</div>
            <div style={{ fontSize:11, color:'#6b6966' }}>guild treasury</div>
          </div>
          {(guild.loanAmount || 0) > 0 && (
            <div style={{ marginTop:12, padding:'10px 12px', background:'rgba(248,113,113,0.08)', border:'1px solid rgba(248,113,113,0.25)', borderRadius:6 }}>
              <div style={{ display:'flex', justifyContent:'space-between', alignItems:'baseline', marginBottom:4 }}>
                <span style={{ fontSize:11, fontWeight:800, color:'#f87171', textTransform:'uppercase', letterSpacing:'0.1em' }}>⚠ Outstanding loan</span>
                <span style={{ fontSize:18, fontWeight:900, color:'#f87171', fontFamily:"'Cinzel',serif" }}>{guild.loanAmount.toLocaleString()} gp</span>
              </div>
              <div style={{ fontSize:11, color:'#9a9793', lineHeight:1.5 }}>
                While the loan is outstanding, 50% of every claimed-request reward auto-routes to repayment instead of the treasury.
              </div>
              {isDM && (
                <div style={{ marginTop:10, display:'flex', justifyContent:'flex-end' }}>
                  <button style={{ ...cvStyles.addBtn, fontSize:11, background:'rgba(248,113,113,0.10)', borderColor:'#f87171', color:'#f87171' }}
                    onClick={cancelLoan}>
                    Cancel Loan & Revert
                  </button>
                </div>
              )}
            </div>
          )}
        </div>

        {/* Base / Plot card */}
        {isDM && !guild.basePurchased && (
          <div style={{ ...cvStyles.card, border:'1px dashed #c9a22755', background:'rgba(201,162,39,0.04)' }}>
            <div style={cvStyles.cardTitle}>Guild Base</div>
            <div style={{ fontSize:13, color:'#9a9793', lineHeight:1.6, marginBottom:10 }}>
              No permanent base yet. Purchase a plot in a city to host facilities, jobs, and crafting.
            </div>
            <div style={{ background:'#111116', borderRadius:8, padding:'10px 12px', marginBottom:10, fontSize:13 }}>
              <span style={{ color:'#9a9793' }}>Treasury: </span>
              <span style={{ color:'#c9a227', fontWeight:700 }}>{(guild.treasury||0).toLocaleString()} gp</span>
            </div>
            <button style={{ ...cvStyles.addBtn, background:'rgba(201,162,39,0.12)', borderColor:'#c9a227', color:'#c9a227' }} onClick={()=>setModal('buyplot')}>+ Purchase Plot</button>
          </div>
        )}

        {/* Points log */}
        {(guild.pointsLog||[]).length > 0 && (
          <div style={cvStyles.card}>
            <div style={cvStyles.cardTitle}>Points History</div>
            <div style={{ display:'flex', flexDirection:'column', gap:6 }}>
              {[...(guild.pointsLog||[])].reverse().map(entry => (
                <div key={entry.id} style={{ display:'flex', justifyContent:'space-between', alignItems:'center', fontSize:13, padding:'5px 0', borderBottom:'1px solid #1a1a20' }}>
                  <div>
                    <span style={{ color:'#c9a227', fontWeight:700 }}>+{entry.amount} pts</span>
                    <span style={{ color:'#9a9793', marginLeft:8 }}>{entry.reason}</span>
                  </div>
                  <span style={{ fontSize:11, color:'#4a4a52' }}>{entry.date}</span>
                </div>
              ))}
            </div>
          </div>
        )}

        {/* Unlocked features */}
        <div style={cvStyles.card}>
          <div style={cvStyles.cardTitle}>Unlocked Features</div>
          {GUILD_RANKS.slice(0, currentRankIdx + 1).map(r => (
            <div key={r} style={{ marginBottom:12 }}>
              <div style={{ fontSize:11, fontWeight:800, color:'#c9a227', marginBottom:5 }}>Rank {r}</div>
              {RANK_FEATURES[r].map(f => (
                <div key={f} style={{ display:'flex', gap:8, alignItems:'center', fontSize:13, color:'#c5c3c0', marginBottom:2 }}>
                  <span style={{ color:'#1e6b3c', fontSize:11 }}>✓</span>{f}
                </div>
              ))}
            </div>
          ))}
          {nextRank && (
            <div style={{ marginTop:8, borderTop:'1px solid #2a2a32', paddingTop:12 }}>
              <div style={{ fontSize:11, fontWeight:800, color:'#3a3a42', marginBottom:5 }}>Rank {nextRank} (locked)</div>
              {RANK_FEATURES[nextRank].map(f => (
                <div key={f} style={{ display:'flex', gap:8, alignItems:'center', fontSize:13, color:'#4a4a52', marginBottom:2 }}>
                  <span style={{ fontSize:11 }}>○</span>{f}
                </div>
              ))}
            </div>
          )}
        </div>
      </div>

      {/* RIGHT COLUMN */}
      <div style={{ display:'flex', flexDirection:'column', gap:20 }}>

        {/* Guild Members */}
        <div style={cvStyles.card}>
          <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:12 }}>
            <div style={cvStyles.cardTitle}>Guild Members ({guild.members.length})</div>
            {isDM && <button style={cvStyles.addBtn} onClick={()=>setModal('member')}>+ Add Member</button>}
          </div>
          <div style={{ display:'flex', flexDirection:'column', gap:6 }}>
            {guild.members.map(m => {
              const levelColor = SPEC_LEVEL_COLOR[m.specLevel] || '#6b6966';
              return (
                <div key={m.id} style={{ display:'flex', alignItems:'center', gap:10, background:'#111116', borderRadius:8, padding:'9px 12px' }}>
                  <div style={{ width:34, height:34, borderRadius:7, background: m.playerId ? 'rgba(197,48,48,0.2)' : '#2a2a32', display:'flex', alignItems:'center', justifyContent:'center', fontSize:11, fontWeight:800, color: m.playerId ? '#e8e6e3' : '#6b6966', flexShrink:0 }}>
                    {m.name.split(' ').map(w=>w[0]).join('').slice(0,2)}
                  </div>
                  <div style={{ flex:1 }}>
                    <div style={{ fontSize:13, fontWeight:700, color:'#e8e6e3' }}>{m.name}</div>
                    <div style={{ fontSize:11, color:'#6b6966' }}>{m.role}</div>
                  </div>
                  <div style={{ textAlign:'right', flexShrink:0 }}>
                    <div style={{ fontSize:12, color:'#c5c3c0', fontWeight:600 }}>{m.specialization}</div>
                    <div style={{ fontSize:10, fontWeight:800, color:levelColor, textTransform:'uppercase', letterSpacing:'0.06em' }}>{m.specLevel}</div>
                  </div>
                  {isDM && <button style={{ background:'rgba(201,162,39,0.1)', border:'1px solid rgba(201,162,39,0.3)', color:'#c9a227', borderRadius:5, padding:'2px 8px', cursor:'pointer', fontSize:10, fontWeight:700, marginLeft:6 }} onClick={()=>startEditMember(m)}>Edit</button>}
                  {isDM && <button style={{ background:'none', border:'none', color:'#3a3a42', cursor:'pointer', fontSize:14, padding:'0 2px', lineHeight:1, marginLeft:4 }} onClick={()=>removeMember(m.id)} title="Remove">✕</button>}
                </div>
              );
            })}
            {guild.members.length === 0 && <div style={{ fontSize:12, color:'#4a4a52', textAlign:'center', padding:'12px 0' }}>No members yet.</div>}
          </div>
        </div>

        {/* Facilities */}
        <div style={cvStyles.card}>
          <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:12 }}>
            <div style={cvStyles.cardTitle}>Guild Facilities</div>
            {isDM && buildableOptions.length > 0 && <button style={cvStyles.addBtn} onClick={()=>{ setFacilityForm({ key: buildableOptions[0].key, tier: buildableOptions[0].tier }); setModal('facility'); }}>+ Build / Upgrade</button>}
          </div>
          <div style={{ display:'flex', flexDirection:'column', gap:4 }}>
            {Object.entries(FACILITIES_META).map(([key, meta]) => {
              const tier = guild.facilities[key];
              const built = !!tier;
              return (
                <div key={key} style={{ display:'flex', alignItems:'center', gap:10, padding:'7px 0', borderBottom:'1px solid #1a1a20' }}>
                  <span style={{ fontSize:16, opacity: built ? 1 : 0.2 }}>{meta.icon}</span>
                  <div style={{ flex:1, fontSize:13, color: built ? '#c5c3c0' : '#3a3a42', fontWeight: built ? 600 : 400 }}>{meta.label}</div>
                  {built ? (
                    <div style={{ display:'flex', gap:6, alignItems:'center' }}>
                      <span style={{ fontSize:10, fontWeight:800, color: FACILITY_TIER_COLOR[tier] || '#9a9793', background: (FACILITY_TIER_COLOR[tier] || '#6b6966') + '22', border: `1px solid ${(FACILITY_TIER_COLOR[tier] || '#6b6966')}55`, borderRadius:4, padding:'2px 8px', textTransform:'uppercase', letterSpacing:'0.08em' }}>{FACILITY_TIERS[tier]}</span>
                      {isDM && <button style={{ background:'none', border:'none', color:'#3a3a42', cursor:'pointer', fontSize:12, padding:0, lineHeight:1 }} onClick={()=>removeFacility(key)} title="Remove">✕</button>}
                    </div>
                  ) : (
                    <span style={{ fontSize:10, color:'#3a3a42' }}>Not built</span>
                  )}
                </div>
              );
            })}
          </div>
        </div>
      </div>

      {/* Active Requests for this Guild */}
      {guildRequests.length > 0 && (
        <div style={{ gridColumn:'1/-1' }}>
            <div style={cvStyles.card}>
              <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:12 }}>
                <div style={cvStyles.cardTitle}>Association Requests ({guildRequests.length})</div>
                <button style={{ ...cvStyles.addBtn, fontSize:12 }} onClick={()=>window.dispatchEvent(new CustomEvent('kamia-nav',{detail:{view:'association',tab:'requests'}}))}>View all →</button>
              </div>
              <div style={{ display:'flex', flexDirection:'column', gap:8 }}>
                {guildRequests.map(req => {
                  const diffColor = { Easy:'#1e6b3c', Medium:'#b45309', Hard:'#c53030', Legendary:'#7c3aed' }[req.difficulty]||'#6b6966';
                  const pts = { Easy:25, Medium:75, Hard:150, Legendary:500 }[req.difficulty]||0;
                  return (
                    <div key={req.id} style={{ background:'#111116', borderRadius:8, padding:'12px 14px', display:'flex', gap:12, alignItems:'center' }}>
                      <div style={{ flex:1 }}>
                        <div style={{ display:'flex', gap:8, alignItems:'center', marginBottom:3 }}>
                          <span style={{ fontSize:13, fontWeight:700, color: req.status==='completed'?'#6b6966':'#e8e6e3' }}>{req.title}</span>
                          <span style={{ fontSize:10, fontWeight:700, color:diffColor, background:`${diffColor}18`, borderRadius:3, padding:'1px 6px', textTransform:'uppercase' }}>{req.difficulty}</span>
                        </div>
                        <div style={{ fontSize:11, color:'#6b6966' }}>{req.type} · {req.reward.toLocaleString()} gp · {pts} rank pts on completion</div>
                      </div>
                      <div style={{ display:'flex', gap:6, alignItems:'center', flexShrink:0 }}>
                        <span style={{ fontSize:10, fontWeight:700, textTransform:'uppercase', color: req.status==='completed'?'#4a4a52':'#f59e0b' }}>● {req.status}</span>
                        {isDM && req.status === 'claimed' && (
                          <button style={{ background:'rgba(30,107,60,0.15)', border:'1px solid #1e6b3c', color:'#22c55e', borderRadius:5, padding:'4px 10px', cursor:'pointer', fontSize:11, fontWeight:700, fontFamily:"'Nunito',sans-serif" }}
                            onClick={async () => {
                              try {
                                // Backend hook auto-routes 50% of the reward to loan repayment
                                // (if any) and the rest into the guild treasury.
                                await api.campaigns.quests.update(campaign.id, req.id, { status:'completed', column:'completed' });
                                // Award rank points (PATCH guild — separate from the side-effect above).
                                await save({ rankPoints: (guildData.rankPoints||0) + pts });
                                await refresh();              // refetch guild to pick up new treasury/loan values
                                await refreshGuildRequests();
                              } catch (err) {
                                console.error('[GuildTab] complete request failed:', err);
                                window.dialog.alert('Failed to mark complete: ' + (err.message || 'unknown error'));
                              }
                            }}>
                            ✓ Complete (+{pts} pts)
                          </button>
                        )}
                      </div>
                    </div>
                  );
                })}
              </div>
            </div>
          </div>
      )}

      {/* Expedition Log — full width */}
      <div style={{ gridColumn:'1/-1' }}>
        <div style={cvStyles.card}>
          <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:12 }}>
            <div style={cvStyles.cardTitle}>Expedition Log</div>
            {isDM && <button style={cvStyles.addBtn} onClick={()=>setModal('expedition')}>+ Log Expedition</button>}
          </div>
          {(guild.expeditions||[]).length === 0 && (
            <div style={{ fontSize:13, color:'#4a4a52', textAlign:'center', padding:'16px 0' }}>No expeditions logged yet.</div>
          )}
          <div style={{ display:'flex', flexDirection:'column', gap:10 }}>
            {(guild.expeditions||[]).map(ex => (
              <div key={ex.id} style={{ background:'#111116', borderRadius:8, padding:'12px 16px', borderLeft:`3px solid ${ex.status==='completed'?'#1e6b3c':'#b45309'}` }}>
                <div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', marginBottom:6 }}>
                  <div>
                    <div style={{ fontSize:14, fontWeight:700, color:'#e8e6e3' }}>{ex.name}</div>
                    <div style={{ fontSize:11, color:'#6b6966', marginTop:2 }}>{ex.date}{ex.participants?.length > 0 && ` · ${ex.participants.join(', ')}`}</div>
                  </div>
                  <div style={{ display:'flex', gap:6, alignItems:'center' }}>
                    <span style={{ fontSize:10, fontWeight:800, textTransform:'uppercase', letterSpacing:'0.08em', color: ex.status==='completed'?'#22c55e':'#f59e0b', background: ex.status==='completed'?'rgba(34,197,94,0.1)':'rgba(245,158,11,0.1)', border:`1px solid ${ex.status==='completed'?'rgba(34,197,94,0.3)':'rgba(245,158,11,0.3)'}`, borderRadius:4, padding:'2px 8px' }}>{ex.status}</span>
                    {isDM && ex.status==='active' && <button style={{ background:'rgba(30,107,60,0.15)', border:'1px solid #1e6b3c', color:'#22c55e', borderRadius:5, padding:'2px 8px', cursor:'pointer', fontSize:11, fontWeight:700, fontFamily:"'Nunito',sans-serif" }} onClick={()=>completeExpedition(ex.id)}>Complete</button>}
                    {isDM && <button style={{ background:'none', border:'none', color:'#3a3a42', cursor:'pointer', fontSize:13, padding:'0 2px' }} onClick={()=>deleteExpedition(ex.id)}>✕</button>}
                  </div>
                </div>
                {ex.outcome && <p style={{ fontSize:13, color:'#9a9793', lineHeight:1.6, margin:'0 0 6px' }}>{ex.outcome}</p>}
                {ex.reward && <div style={{ fontSize:12, color:'#c9a227' }}>Reward: {ex.reward}</div>}
              </div>
            ))}
          </div>
        </div>
      </div>

      {/* ── Modals ── */}

      {/* Add Member */}
      {modal === 'member' && (
        <GuildModal title="Add Guild Member" onClose={()=>setModal(null)}>
          <div style={{ display:'flex', flexDirection:'column', gap:12, marginBottom:16 }}>
            <div><label style={gLabel}>Name<Req/></label><input style={gInput} value={memberForm.name} onChange={e=>setMemberForm(p=>({...p,name:e.target.value}))} placeholder="e.g. Bram Kettler" /></div>
            <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:10 }}>
              <div><label style={gLabel}>Role</label>
                <select style={gSelect} value={memberForm.role} onChange={e=>setMemberForm(p=>({...p,role:e.target.value}))}>
                  {['Leader','Hunter','NPC Specialist','Commander','Team Leader','Recruit'].map(r=><option key={r} value={r}>{r}</option>)}
                </select>
              </div>
              <div><label style={gLabel}>Link to Player</label>
                <select style={gSelect} value={memberForm.playerId||''} onChange={e=>{
                  const v=e.target.value;
                  const u=v ? (campaign.players||[]).find(p=>p.id===v) : null;
                  setMemberForm(p=>({...p, playerId:v, name: u ? u.name : p.name}));
                }}>
                  <option value="">— NPC / no player —</option>
                  {dmsOf(campaign).map(d => <option key={d.userId} value={d.userId}>{d.name} ({d.role})</option>)}
                  {(campaign.players || []).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}
                </select>
              </div>
              <div><label style={gLabel}>Specialization</label>
                <select style={gSelect} value={memberForm.specialization} onChange={e=>setMemberForm(p=>({...p,specialization:e.target.value}))}>
                  {SPECIALIZATIONS.map(s=><option key={s} value={s}>{s}</option>)}
                </select>
              </div>
              <div><label style={gLabel}>Spec Level</label>
                <select style={gSelect} value={memberForm.specLevel} onChange={e=>setMemberForm(p=>({...p,specLevel:e.target.value}))}>
                  {SPEC_LEVELS.map(l=><option key={l} value={l}>{l}</option>)}
                </select>
              </div>
            </div>
          </div>
          <div style={{ display:'flex', gap:8, justifyContent:'flex-end' }}>
            <button style={{ ...cvStyles.addBtn, background:'none', borderColor:'#3a3a42', color:'#9a9793' }} onClick={()=>setModal(null)}>Cancel</button>
            <button style={cvStyles.addBtn} onClick={addMember} disabled={!memberForm.name.trim()}>Add Member</button>
          </div>
        </GuildModal>
      )}

      {/* Build Facility */}
      {modal === 'facility' && (() => {
        const cost = FACILITY_TIER_COST[facilityForm.tier] || 0;
        const treasury = guild.treasury || 0;
        const tierColor = FACILITY_TIER_COLOR[facilityForm.tier] || '#9a9793';
        const canAfford = treasury >= cost;
        const hasLoanAlready = (guild.loanAmount || 0) > 0;
        const wouldNeedLoan = !canAfford && !hasLoanAlready;
        const blockedByLoan = !canAfford && hasLoanAlready;
        return (
        <GuildModal title="Build / Upgrade Facility" onClose={()=>setModal(null)}>
          <div style={{ display:'flex', flexDirection:'column', gap:12, marginBottom:16 }}>
            <div><label style={gLabel}>Facility (next stage only)</label>
              <select style={gSelect} value={`${facilityForm.key}::${facilityForm.tier}`}
                onChange={e=>{ const [key,tier]=e.target.value.split('::'); setFacilityForm({key,tier}); }}>
                {buildableOptions.map(o=><option key={`${o.key}::${o.tier}`} value={`${o.key}::${o.tier}`}>{o.label} — {o.cost.toLocaleString()} gp</option>)}
              </select>
              {buildableOptions.length === 0 && <div style={{fontSize:12, color:'#6b6966', marginTop:6}}>All facilities are at their highest stage.</div>}
            </div>
            {facilityForm.key && (
              <div style={{ background:'#111116', borderRadius:8, padding:'12px 14px', fontSize:12, color:'#9a9793', lineHeight:1.6, border:`1px solid ${tierColor}33` }}>
                <div style={{display:'flex', alignItems:'center', gap:8, marginBottom:6}}>
                  <span style={{ fontSize:18 }}>{FACILITIES_META[facilityForm.key]?.icon}</span>
                  <strong style={{ color:'#e8e6e3' }}>{FACILITIES_META[facilityForm.key]?.label}</strong>
                  <span style={{ marginLeft:'auto', fontSize:10, fontWeight:800, color:tierColor, background:tierColor+'22', border:`1px solid ${tierColor}55`, borderRadius:4, padding:'2px 8px', textTransform:'uppercase', letterSpacing:'0.08em' }}>{FACILITY_TIERS[facilityForm.tier]}</span>
                </div>
                {guild.facilities[facilityForm.key] && <div style={{ color:'#6b6966', marginBottom:4 }}>Upgrading from: {FACILITY_TIERS[guild.facilities[facilityForm.key]]}</div>}
                <div style={{display:'flex', justifyContent:'space-between', marginTop:8, paddingTop:8, borderTop:'1px solid #2a2a32'}}>
                  <span style={{color:'#9a9793'}}>Cost</span>
                  <span style={{color:'#c9a227', fontWeight:700}}>{cost.toLocaleString()} gp</span>
                </div>
                <div style={{display:'flex', justifyContent:'space-between'}}>
                  <span style={{color:'#9a9793'}}>Treasury</span>
                  <span style={{color: canAfford ? '#c9a227' : '#f87171', fontWeight:700}}>{treasury.toLocaleString()} gp</span>
                </div>
                {wouldNeedLoan && (
                  <div style={{marginTop:8, padding:'8px 10px', background:'rgba(248,113,113,0.08)', border:'1px solid rgba(248,113,113,0.25)', borderRadius:6, color:'#f87171', fontSize:11, lineHeight:1.5}}>
                    Treasury short by <strong>{(cost - treasury).toLocaleString()} gp</strong>. Building will take a loan for the deficit.
                  </div>
                )}
                {blockedByLoan && (
                  <div style={{marginTop:8, padding:'8px 10px', background:'rgba(248,113,113,0.08)', border:'1px solid rgba(248,113,113,0.25)', borderRadius:6, color:'#f87171', fontSize:11, lineHeight:1.5}}>
                    Treasury short by <strong>{(cost - treasury).toLocaleString()} gp</strong>. You already have an outstanding loan — repay it before taking another.
                  </div>
                )}
              </div>
            )}
          </div>
          <div style={{ display:'flex', gap:8, justifyContent:'flex-end' }}>
            <button style={{ ...cvStyles.addBtn, background:'none', borderColor:'#3a3a42', color:'#9a9793' }} onClick={()=>setModal(null)}>Cancel</button>
            <button style={cvStyles.addBtn} onClick={buildFacility} disabled={!facilityForm.key || blockedByLoan}>
              {wouldNeedLoan ? `Build (loan ${(cost - treasury).toLocaleString()} gp)` : `Build (${cost.toLocaleString()} gp)`}
            </button>
          </div>
        </GuildModal>
        );
      })()}

      {/* Log Expedition */}
      {modal === 'expedition' && (
        <GuildModal title="Log Expedition" onClose={()=>setModal(null)}>
          <div style={{ display:'flex', flexDirection:'column', gap:12, marginBottom:16 }}>
            <div><label style={gLabel}>Expedition Name<Req/></label><input style={gInput} value={expForm.name} onChange={e=>setExpForm(p=>({...p,name:e.target.value}))} placeholder="e.g. Greymount Foothills Survey" /></div>
            <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:10 }}>
              <div><label style={gLabel}>Date</label><input type="date" style={gInput} value={expForm.date} onChange={e=>setExpForm(p=>({...p,date:e.target.value}))} /></div>
              <div><label style={gLabel}>Status</label>
                <select style={gSelect} value={expForm.status} onChange={e=>setExpForm(p=>({...p,status:e.target.value}))}>
                  <option value="active">Active / Ongoing</option>
                  <option value="completed">Completed</option>
                </select>
              </div>
            </div>
            <div><label style={gLabel}>Participants (comma-separated)</label><input style={gInput} value={expForm.participants} onChange={e=>setExpForm(p=>({...p,participants:e.target.value}))} placeholder="Syra Dawnmere, Tavish Ironholt" /></div>
            <div><label style={gLabel}>Outcome / Notes</label><textarea style={gTextarea} rows={3} value={expForm.outcome} onChange={e=>setExpForm(p=>({...p,outcome:e.target.value}))} placeholder="What was discovered or accomplished?" /></div>
            <div><label style={gLabel}>Reward</label><input style={gInput} value={expForm.reward} onChange={e=>setExpForm(p=>({...p,reward:e.target.value}))} placeholder="e.g. 200 gp + claim rights" /></div>
          </div>
          <div style={{ display:'flex', gap:8, justifyContent:'flex-end' }}>
            <button style={{ ...cvStyles.addBtn, background:'none', borderColor:'#3a3a42', color:'#9a9793' }} onClick={()=>setModal(null)}>Cancel</button>
            <button style={cvStyles.addBtn} onClick={logExpedition} disabled={!expForm.name.trim()}>Log Expedition</button>
          </div>
        </GuildModal>
      )}

      {/* Edit Guild — name + notes */}
      {modal === 'editguild' && (
        <GuildModal title="Edit Guild" onClose={()=>setModal(null)}>
          <div style={{ display:'flex', flexDirection:'column', gap:12, marginBottom:16 }}>
            <div>
              <label style={gLabel}>Guild Name<Req/></label>
              <input style={gInput} value={editGuildForm.name}
                onChange={e=>setEditGuildForm(p=>({...p, name:e.target.value}))}
                placeholder="e.g. Ironveil Company" />
            </div>
            <div>
              <label style={gLabel}>Notes</label>
              <textarea style={{...gTextarea, minHeight:120}} value={editGuildForm.notes}
                onChange={e=>setEditGuildForm(p=>({...p, notes:e.target.value}))}
                placeholder="Founding history, current goals, internal politics — anything you want to remember." />
            </div>
          </div>
          <div style={{ display:'flex', gap:8, justifyContent:'flex-end' }}>
            <button style={{ ...cvStyles.addBtn, background:'none', borderColor:'#3a3a42', color:'#9a9793' }} onClick={()=>setModal(null)}>Cancel</button>
            <button style={cvStyles.addBtn}
              disabled={!editGuildForm.name.trim()}
              onClick={async () => {
                await save({ name: editGuildForm.name.trim(), notes: editGuildForm.notes });
                setModal(null);
              }}>Save</button>
          </div>
        </GuildModal>
      )}

      {/* Award Points */}
      {modal === 'points' && (
        <GuildModal title="Award Rank Points" onClose={()=>setModal(null)}>
          <div style={{ display:'flex', flexDirection:'column', gap:12, marginBottom:16 }}>
            <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:10 }}>
              <div><label style={gLabel}>Source</label>
                <select style={gSelect} value={pointsForm.type} onChange={e=>setPointsForm(p=>({...p,type:e.target.value,reason:e.target.value==='quest'?'Quest completion':''}))}>
                  <option value="manual">Manual Award</option>
                  <option value="quest">Quest Completion</option>
                  <option value="expedition">Expedition</option>
                  <option value="event">World Event</option>
                  <option value="bonus">Bonus / Special</option>
                </select>
              </div>
              <div><label style={gLabel}>Points to Award</label>
                <input type="number" style={gInput} value={pointsForm.amount} min={1} onChange={e=>setPointsForm(p=>({...p,amount:e.target.value}))} />
              </div>
            </div>
            {pointsForm.type === 'quest' && (
              <div><label style={gLabel}>Quest</label>
                <select style={gSelect} value={pointsForm.reason} onChange={e=>setPointsForm(p=>({...p,reason:e.target.value}))}>
                  <option value="">— Select request —</option>
                  {guildRequests.map(q=><option key={q.id} value={`Request: ${q.title}`}>{q.title}</option>)}
                </select>
              </div>
            )}
            <div><label style={gLabel}>{pointsForm.type==='quest'?'Additional notes':'Reason'}</label>
              <input style={gInput} value={pointsForm.type==='quest'?undefined:pointsForm.reason} placeholder={pointsForm.type==='quest'?'Optional extra notes…':'e.g. Exceptional roleplay, discovered the hidden vault…'}
                onChange={e=>setPointsForm(p=>({...p,reason:p.type==='quest'?p.reason:e.target.value}))} />
            </div>
            <div style={{ background:'rgba(201,162,39,0.08)', border:'1px solid rgba(201,162,39,0.2)', borderRadius:8, padding:'10px 14px', fontSize:13 }}>
              <span style={{ color:'#9a9793' }}>Current: </span><span style={{ color:'#c9a227', fontWeight:700 }}>{guild.rankPoints.toLocaleString()} pts</span>
              <span style={{ color:'#6b6966', margin:'0 8px' }}>→</span>
              <span style={{ color:'#c9a227', fontWeight:800 }}>{(guild.rankPoints + Number(pointsForm.amount)).toLocaleString()} pts</span>
              {nextThreshold && Number(pointsForm.amount) + guild.rankPoints >= nextThreshold && (
                <span style={{ marginLeft:10, fontSize:11, color:'#22c55e', fontWeight:700 }}>🎉 Rank up to {nextRank}!</span>
              )}
            </div>
          </div>
          <div style={{ display:'flex', gap:8, justifyContent:'flex-end' }}>
            <button style={{ ...cvStyles.addBtn, background:'none', borderColor:'#3a3a42', color:'#9a9793' }} onClick={()=>setModal(null)}>Cancel</button>
            <button style={{ ...cvStyles.addBtn, background:'rgba(201,162,39,0.15)', borderColor:'#c9a227', color:'#c9a227' }} onClick={awardPoints} disabled={!pointsForm.amount}>Award Points</button>
          </div>
        </GuildModal>
      )}

      {/* Purchase Plot */}
      {modal === 'buyplot' && (() => {
        const selectedCity = cities.find(c => c.id === buyPlotForm.cityId);
        const price = selectedCity ? selectedCity.plotPrice : 0;
        const treasury = guild.treasury || 0;
        const deficit = Math.max(0, price - treasury);
        const existingLoan = guild.loanAmount || 0;
        const wouldNeedLoan = deficit > 0;
        const loanBlocked = wouldNeedLoan && existingLoan > 0;
        return (
        <GuildModal title="Purchase Guild Plot" onClose={()=>{ setModal(null); setBuyPlotForm({ cityId:'' }); }}>
          <div style={{ display:'flex', flexDirection:'column', gap:14, marginBottom:16 }}>
            <div>
              <label style={gLabel}>City</label>
              <CityPicker value={buyPlotForm.cityId} onChange={id => setBuyPlotForm({ cityId: id || '' })}
                campaignId={campaign.id} placeholder="— Select a city —" style={{ width:'100%' }} />
            </div>
            {selectedCity && (
              <div style={{ background:'#111116', border:'1px solid #2a2a32', borderRadius:8, padding:'12px 14px', fontSize:13 }}>
                <div style={{ display:'flex', justifyContent:'space-between', marginBottom:4 }}>
                  <span style={{ color:'#9a9793' }}>Plot price</span>
                  <span style={{ color:'#c9a227', fontWeight:700, fontFamily:"'Cinzel',serif" }}>{price.toLocaleString()} gp</span>
                </div>
                <div style={{ display:'flex', justifyContent:'space-between', marginBottom:4 }}>
                  <span style={{ color:'#9a9793' }}>Treasury</span>
                  <span style={{ color:'#c9a227', fontWeight:700 }}>{treasury.toLocaleString()} gp</span>
                </div>
                <div style={{ display:'flex', justifyContent:'space-between', borderTop:'1px solid #2a2a32', paddingTop:6, marginTop:6 }}>
                  <span style={{ color:'#9a9793' }}>After purchase</span>
                  <span style={{ color: deficit > 0 ? '#f87171' : '#22c55e', fontWeight:700 }}>
                    {deficit > 0 ? `-${deficit.toLocaleString()} gp shortfall` : `${(treasury - price).toLocaleString()} gp remaining`}
                  </span>
                </div>
              </div>
            )}
            {selectedCity && wouldNeedLoan && (
              <div style={{ background: loanBlocked ? 'rgba(248,113,113,0.08)' : 'rgba(201,162,39,0.08)',
                            border: `1px solid ${loanBlocked ? 'rgba(248,113,113,0.30)' : 'rgba(201,162,39,0.30)'}`,
                            borderRadius:8, padding:'10px 14px', fontSize:12, lineHeight:1.5,
                            color: loanBlocked ? '#f87171' : '#c9a227' }}>
                {loanBlocked ? (
                  <>You already have an outstanding loan. Repay or cancel it before taking another.</>
                ) : (
                  <>You'll be offered a loan of <strong>{deficit.toLocaleString()} gp</strong> from the Hunters Association.
                    50% of every claimed-request reward will auto-route to repayment until cleared.</>
                )}
              </div>
            )}
          </div>
          <div style={{ display:'flex', gap:8, justifyContent:'flex-end' }}>
            <button style={{ ...cvStyles.addBtn, background:'none', borderColor:'#3a3a42', color:'#9a9793' }}
              onClick={()=>{ setModal(null); setBuyPlotForm({ cityId:'' }); }}>Cancel</button>
            <button style={{ ...cvStyles.addBtn, background:'rgba(201,162,39,0.15)', borderColor:'#c9a227', color:'#c9a227' }}
              onClick={buyPlot}
              disabled={!selectedCity || loanBlocked}>
              {wouldNeedLoan ? `Take loan & buy (${price.toLocaleString()} gp)` : `Purchase (${price.toLocaleString()} gp)`}
            </button>
          </div>
        </GuildModal>
        );
      })()}

      {/* Repay Loan */}
      {modal === 'repay' && (
        <GuildModal title="Repay Loan" onClose={()=>setModal(null)}>
          <div style={{ display:'flex', flexDirection:'column', gap:12, marginBottom:16 }}>
            <div style={{ background:'#111116', border:'1px solid #2a2a32', borderRadius:8, padding:'10px 14px', fontSize:13 }}>
              <div style={{ display:'flex', justifyContent:'space-between', marginBottom:4 }}>
                <span style={{ color:'#9a9793' }}>Treasury</span>
                <span style={{ color:'#c9a227', fontWeight:700 }}>{(guild.treasury||0).toLocaleString()} gp</span>
              </div>
              <div style={{ display:'flex', justifyContent:'space-between' }}>
                <span style={{ color:'#9a9793' }}>Outstanding loan</span>
                <span style={{ color:'#f87171', fontWeight:700 }}>{(guild.loanAmount||0).toLocaleString()} gp</span>
              </div>
            </div>
            <div>
              <label style={gLabel}>Amount to repay (max {Math.min(guild.treasury||0, guild.loanAmount||0).toLocaleString()} gp)</label>
              <input type="number" style={gInput} min={0} max={Math.min(guild.treasury||0, guild.loanAmount||0)}
                value={repayForm.amount} onChange={e=>setRepayForm(p=>({...p,amount:e.target.value}))} />
            </div>
            <div style={{ background:'rgba(30,107,60,0.08)', border:'1px solid rgba(30,107,60,0.2)', borderRadius:8, padding:'10px 14px', fontSize:13 }}>
              <span style={{ color:'#9a9793' }}>After repayment: </span>
              <span style={{ color:'#c9a227', fontWeight:700 }}>{((guild.treasury||0) - (Number(repayForm.amount)||0)).toLocaleString()} gp</span>
              <span style={{ color:'#6b6966', margin:'0 8px' }}>treasury,</span>
              <span style={{ color: ((guild.loanAmount||0) - (Number(repayForm.amount)||0)) === 0 ? '#22c55e' : '#f87171', fontWeight:700 }}>
                {((guild.loanAmount||0) - (Number(repayForm.amount)||0)).toLocaleString()} gp
              </span>
              <span style={{ color:'#6b6966', marginLeft:8 }}>loan</span>
              {((guild.loanAmount||0) - (Number(repayForm.amount)||0)) === 0 && Number(repayForm.amount) > 0 && (
                <span style={{ marginLeft:10, fontSize:11, color:'#22c55e', fontWeight:700 }}>🎉 Loan cleared!</span>
              )}
            </div>
          </div>
          <div style={{ display:'flex', gap:8, justifyContent:'flex-end' }}>
            <button style={{ ...cvStyles.addBtn, background:'none', borderColor:'#3a3a42', color:'#9a9793' }} onClick={()=>setModal(null)}>Cancel</button>
            <button style={{ ...cvStyles.addBtn, background:'rgba(30,107,60,0.15)', borderColor:'#1e6b3c', color:'#22c55e' }}
              onClick={repayLoan}
              disabled={!Number(repayForm.amount) || Number(repayForm.amount) > Math.min(guild.treasury||0, guild.loanAmount||0)}>
              Repay {Number(repayForm.amount) > 0 ? `${Number(repayForm.amount).toLocaleString()} gp` : ''}
            </button>
          </div>
        </GuildModal>
      )}

    </div>
  );
};

// ── Map ───────────────────────────────────────────────────────────────────────
const MapTab = ({ campaign, isDM, realm }) => {
  // Realm picks the world-map asset. Fall back to /map.jpg (Kamia's master) if the
  // realm hasn't supplied one or hasn't loaded yet — keeps existing campaigns visually intact.
  const mapSrc = (realm && realm.mapImageUrl) || 'map.jpg';
  const [transform, setTransform] = React.useState({ x: 0, y: 0, scale: 1 });
  const [dragging, setDragging] = React.useState(false);
  const [dragStart, setDragStart] = React.useState(null);
  const [selectedPin, setSelectedPin] = React.useState(null);
  const [placingPin, setPlacingPin] = React.useState(false);
  const [editingPin, setEditingPin] = React.useState(null);
  const [newPinPos, setNewPinPos] = React.useState(null);
  const [sidebarOpen, setSidebarOpen] = React.useState(true);
  const [pins, setPins] = React.useState([]);
  React.useEffect(() => {
    let cancelled = false;
    api.mapPins.list({ campaignId: campaign.id })
      .then(list => { if (!cancelled) setPins(list || []); })
      .catch(err => console.error('[MapTab] pins load failed:', err));
    return () => { cancelled = true; };
  }, [campaign.id]);

  const containerRef = React.useRef(null);
  const imgRef = React.useRef(null);

  const fitToView = () => {
    if (!containerRef.current) return;
    const rect = containerRef.current.getBoundingClientRect();
    const scaleX = rect.width / 820;
    const scaleY = rect.height / 600;
    const scale = Math.min(scaleX, scaleY, 1);
    setTransform({ x: (rect.width - 820 * scale) / 2, y: (rect.height - 600 * scale) / 2, scale });
  };

  const focusPin = (pin) => {
    if (!containerRef.current) return;
    const rect = containerRef.current.getBoundingClientRect();
    const scale = 2.2;
    const pinPixelX = (pin.x / 100) * 820;
    const pinPixelY = (pin.y / 100) * 600;
    setTransform({ scale, x: rect.width / 2 - pinPixelX * scale, y: rect.height / 2 - pinPixelY * scale });
    setSelectedPin(pin);
    setEditingPin(null);
  };

  React.useEffect(() => { fitToView(); }, []);

  const handleWheel = (e) => {
    e.preventDefault();
    const rect = containerRef.current.getBoundingClientRect();
    const mx = e.clientX - rect.left;
    const my = e.clientY - rect.top;
    const delta = e.deltaY > 0 ? 0.85 : 1.18;
    setTransform(prev => {
      const newScale = Math.max(0.3, Math.min(5, prev.scale * delta));
      const r = newScale / prev.scale;
      return { scale: newScale, x: mx - r * (mx - prev.x), y: my - r * (my - prev.y) };
    });
  };

  const handleMouseDown = (e) => {
    if (e.button !== 0 || placingPin) return;
    setDragging(true);
    setDragStart({ x: e.clientX - transform.x, y: e.clientY - transform.y });
  };
  const handleMouseMove = (e) => {
    if (!dragging || !dragStart) return;
    setTransform(prev => ({ ...prev, x: e.clientX - dragStart.x, y: e.clientY - dragStart.y }));
  };
  const handleMouseUp = () => { setDragging(false); setDragStart(null); };

  const handleMapClick = (e) => {
    if (!placingPin || !imgRef.current) return;
    const imgRect = imgRef.current.getBoundingClientRect();
    const xPct = ((e.clientX - imgRect.left) / imgRect.width) * 100;
    const yPct = ((e.clientY - imgRect.top) / imgRect.height) * 100;
    setNewPinPos({ x: parseFloat(xPct.toFixed(2)), y: parseFloat(yPct.toFixed(2)) });
    setEditingPin('new');
    setPlacingPin(false);
  };

  const savePin = (pin) => {
    if (editingPin === 'new') {
      setPins(prev => [...prev, { ...pin, id: 'pin' + Date.now(), x: newPinPos.x, y: newPinPos.y, campaignIds: [campaign.id] }]);
    } else {
      setPins(prev => prev.map(p => p.id === editingPin ? { ...p, ...pin } : p));
    }
    setEditingPin(null); setNewPinPos(null);
  };

  const deletePin = (id) => { setPins(prev => prev.filter(p => p.id !== id)); setSelectedPin(null); };

  return (
    <div style={{ display:'flex', flexDirection:'column', height:'100%', overflow:'hidden', position:'relative', margin:'-24px -32px', marginTop: 0 }}>
      {/* Mini toolbar */}
      <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', padding:'8px 16px', background:'#16161b', borderBottom:'1px solid #2a2a32', flexShrink:0 }}>
        <span style={{ fontFamily:"'Cinzel',serif", fontSize:13, fontWeight:700, color:'#c9a227' }}>🗺 {campaign.name} — World Map</span>
        <div style={{ display:'flex', gap:8 }}>
          <button style={{...mapStyles.toolBtn,...(sidebarOpen?mapStyles.toolBtnActive:{})}} onClick={()=>setSidebarOpen(o=>!o)}>☰ Pins</button>
          <button style={mapStyles.toolBtn} onClick={fitToView}>⊡ Fit</button>
          <button style={mapStyles.toolBtn} onClick={()=>setTransform(p=>({...p,scale:Math.min(5,p.scale*1.3)}))}>+</button>
          <button style={mapStyles.toolBtn} onClick={()=>setTransform(p=>({...p,scale:Math.max(0.3,p.scale*0.77)}))}>−</button>
          {isDM && (
            <button style={{...mapStyles.toolBtn,...(placingPin?mapStyles.toolBtnActive:{})}}
              onClick={()=>{ setPlacingPin(!placingPin); setSelectedPin(null); }}>
              {placingPin ? '✕ Cancel' : '📍 Place Pin'}
            </button>
          )}
        </div>
      </div>

      {placingPin && (
        <div style={mapStyles.placingBanner}>Click anywhere on the map to place a new pin</div>
      )}

      {/* Body: sidebar + map */}
      <div style={{ display:'flex', flex:1, overflow:'hidden', position:'relative' }}>

      {/* Pin list sidebar */}
      <div style={{ ...mapStyles.pinSidebar, width: sidebarOpen ? 200 : 0, minWidth: sidebarOpen ? 200 : 0 }}>
        {sidebarOpen && (
          <>
            <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', padding:'10px 12px 8px', borderBottom:'1px solid #2a2a32', flexShrink:0 }}>
              <span style={{ fontSize:11, fontWeight:800, color:'#6b6966', textTransform:'uppercase', letterSpacing:'0.12em' }}>Pins</span>
              <button style={{ background:'none', border:'none', color:'#4a4a52', cursor:'pointer', fontSize:14, lineHeight:1, padding:0 }} onClick={()=>setSidebarOpen(false)}>✕</button>
            </div>
            <div style={{ overflowY:'auto', flex:1 }}>
              {PIN_TYPE_LABELS.map(type => {
                const group = pins.filter(p => p.type === type);
                if (!group.length) return null;
                const typeInfo = PIN_TYPES[type];
                return (
                  <div key={type}>
                    <div style={{ padding:'8px 12px 4px', fontSize:10, fontWeight:800, color:'#6b6966', textTransform:'uppercase', letterSpacing:'0.12em', display:'flex', alignItems:'center', gap:5 }}>
                      <span>{typeInfo.icon}</span>{type}
                    </div>
                    {group.map(pin => {
                      const sevColor = SEVERITY_COLORS[pin.severity] || SEVERITY_COLORS.neutral;
                      const isActive = selectedPin?.id === pin.id;
                      return (
                        <button key={pin.id}
                          style={{ display:'flex', alignItems:'center', gap:8, width:'100%', background: isActive ? 'rgba(201,162,39,0.08)' : 'none', border:'none', borderLeft: isActive ? `2px solid ${sevColor}` : '2px solid transparent', padding:'7px 12px', cursor:'pointer', textAlign:'left', fontFamily:"'Nunito',sans-serif", transition:'background 0.12s' }}
                          onClick={() => focusPin(pin)}>
                          <span style={{ width:8, height:8, borderRadius:'50%', background:sevColor, flexShrink:0, display:'inline-block' }}></span>
                          <span style={{ fontSize:12, color: isActive ? '#e8e6e3' : '#9a9793', fontWeight: isActive ? 700 : 400, flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{pin.name}</span>
                        </button>
                      );
                    })}
                  </div>
                );
              })}
              {pins.length === 0 && (
                <div style={{ fontSize:12, color:'#4a4a52', padding:'20px 12px', textAlign:'center' }}>No pins yet</div>
              )}
            </div>
          </>
        )}
      </div>

      {/* Map canvas */}
      <div
        ref={containerRef}
        style={{ flex:1, position:'relative', overflow:'hidden', background:'#0d0d10', cursor: placingPin ? 'crosshair' : dragging ? 'grabbing' : 'grab' }}
        onWheel={handleWheel}
        onMouseDown={handleMouseDown}
        onMouseMove={handleMouseMove}
        onMouseUp={handleMouseUp}
        onMouseLeave={handleMouseUp}
        onClick={handleMapClick}
      >
        <div style={{ position:'absolute', transform:`translate(${transform.x}px,${transform.y}px) scale(${transform.scale})`, transformOrigin:'0 0', width:820, height:600, userSelect:'none' }}>
          <img ref={imgRef} src={mapSrc} alt="World Map" style={{ width:820, height:600, display:'block', borderRadius:4 }} draggable={false} />
          {pins.map(pin => {
            const typeInfo = PIN_TYPES[pin.type] || PIN_TYPES.neutral;
            const sevColor = SEVERITY_COLORS[pin.severity] || SEVERITY_COLORS.neutral;
            const isSelected = selectedPin?.id === pin.id;
            return (
              <div key={pin.id} style={{ position:'absolute', left:`${pin.x}%`, top:`${pin.y}%`, transform:'translate(-50%,-100%)', cursor:'pointer', zIndex: isSelected ? 20 : 10, filter: isSelected ? 'drop-shadow(0 0 6px rgba(255,255,255,0.6))' : 'drop-shadow(0 2px 4px rgba(0,0,0,0.8))', transition:'filter 0.15s' }}
                onClick={e => { e.stopPropagation(); setSelectedPin(isSelected ? null : pin); setEditingPin(null); }}>
                <div style={{ background: sevColor, border:'2px solid rgba(255,255,255,0.3)', borderRadius:'50% 50% 50% 0', transform:'rotate(-45deg)', width:28, height:28, display:'flex', alignItems:'center', justifyContent:'center', boxShadow: isSelected ? '0 0 0 3px rgba(255,255,255,0.4)' : 'none' }}>
                  <span style={{ transform:'rotate(45deg)', fontSize:13, lineHeight:1 }}>{typeInfo.icon}</span>
                </div>
                {isSelected && (
                  <div style={{ position:'absolute', bottom:'calc(100% + 6px)', left:'50%', transform:'translateX(-50%)', background:'#1c1c22', border:'1px solid #3a3a42', borderRadius:5, padding:'3px 8px', fontSize:11, color:'#e8e6e3', fontWeight:700, whiteSpace:'nowrap', fontFamily:"'Nunito',sans-serif" }}>{pin.name}</div>
                )}
              </div>
            );
          })}
        </div>
      </div>

      {/* Pin detail */}
      {selectedPin && editingPin === null && (
        <div style={{ ...mapStyles.detailPanel, top:56 }}>
          <div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', marginBottom:12 }}>
            <div style={{ display:'flex', gap:10, alignItems:'center' }}>
              <div style={{ ...mapStyles.pinIconLg, background: SEVERITY_COLORS[selectedPin.severity]||SEVERITY_COLORS.neutral }}>{(PIN_TYPES[selectedPin.type]||PIN_TYPES.neutral).icon}</div>
              <div>
                <div style={{ fontFamily:"'Cinzel',serif", fontSize:15, fontWeight:700, color:'#e8e6e3' }}>{selectedPin.name}</div>
                <div style={{ fontSize:11, color:'#9a9793', textTransform:'capitalize', marginTop:1 }}>{selectedPin.type} · <span style={{ color: SEVERITY_COLORS[selectedPin.severity] }}>{selectedPin.severity}</span></div>
              </div>
            </div>
            <button style={mapStyles.closeBtn} onClick={()=>setSelectedPin(null)}>✕</button>
          </div>
          <p style={{ fontSize:13, color:'#c5c3c0', lineHeight:1.6, margin:'0 0 12px' }}>{selectedPin.description}</p>
          {isDM && (
            <div style={{ display:'flex', gap:8, marginTop:8 }}>
              <button style={mapStyles.editBtn} onClick={()=>setEditingPin(selectedPin.id)}>✎ Edit</button>
              <button style={{ ...mapStyles.editBtn, color:'#f87171', borderColor:'rgba(248,113,113,0.2)' }} onClick={()=>deletePin(selectedPin.id)}>Delete</button>
            </div>
          )}
        </div>
      )}

      {/* Pin editor */}
      {editingPin !== null && (
        <PinEditor
          pin={editingPin === 'new' ? null : pins.find(p=>p.id===editingPin)}
          onSave={savePin}
          onCancel={()=>{ setEditingPin(null); setNewPinPos(null); }}
        />
      )}
      </div>{/* end body flex */}
    </div>
  );
};

// ── Styles ────────────────────────────────────────────────────────────────────
const cvStyles = {
  page: { display:'flex',flexDirection:'column',height:'100%',overflow:'hidden' },
  banner: { padding:'24px 32px',display:'flex',justifyContent:'space-between',alignItems:'center',flexShrink:0 },
  title: { fontFamily:"'Cinzel',serif",fontSize:24,fontWeight:700,color:'#fff',margin:'0 0 6px' },
  metaRow: { display:'flex',gap:12,alignItems:'center',flexWrap:'wrap' },
  playerAvatar: { width:32,height:32,borderRadius:'50%',display:'flex',alignItems:'center',justifyContent:'center',fontSize:11,fontWeight:700,color:'#fff',border:'2px solid rgba(255,255,255,0.15)' },
  tabs: { display:'flex',gap:0,borderBottom:'1px solid #2a2a32',background:'#16161b',overflowX:'auto',flexShrink:0 },
  tab: { background:'none',borderTop:'none',borderLeft:'none',borderRight:'none',borderBottomWidth:2,borderBottomStyle:'solid',borderBottomColor:'transparent',color:'#6b6966',padding:'12px 18px',cursor:'pointer',fontSize:13,fontWeight:600,whiteSpace:'nowrap',fontFamily:"'Nunito',sans-serif",transition:'color 0.15s' },
  tabActive: { color:'#e8e6e3',borderBottomColor:'#c53030' },
  content: { flex:1, overflowY:'auto', padding:'24px 32px', display:'flex', flexDirection:'column' },
  card: { background:'#1c1c22',border:'1px solid #2a2a32',borderRadius:10,padding:'20px 24px' },
  cardTitle: { fontSize:12,fontWeight:800,color:'#9a9793',textTransform:'uppercase',letterSpacing:'0.1em',marginBottom:12,fontFamily:"'Nunito',sans-serif" },
  tabLinkBtn: { background:'none',border:'none',color:'#c9a227',cursor:'pointer',fontSize:12,fontWeight:700 },
  sessionRow: { display:'flex',gap:12,alignItems:'flex-start',paddingBottom:12,marginBottom:12,borderBottom:'1px solid #2a2a32' },
  sessionNum: { fontSize:11,fontWeight:800,color:'#c9a227',background:'rgba(201,162,39,0.1)',padding:'3px 8px',borderRadius:4,flexShrink:0,marginTop:2 },
  addBtn: { background:'rgba(197,48,48,0.15)',border:'1px solid #c53030',color:'#e8e6e3',borderRadius:6,padding:'7px 14px',cursor:'pointer',fontSize:13,fontWeight:700,fontFamily:"'Nunito',sans-serif" },
  rollBtn: { background:'rgba(201,162,39,0.1)',border:'1px solid rgba(201,162,39,0.35)',color:'#c9a227',borderRadius:6,padding:'7px 14px',cursor:'pointer',fontSize:13,fontWeight:700,fontFamily:"'Nunito',sans-serif" },
  sessionCard: { background:'#1c1c22',border:'1px solid #2a2a32',borderRadius:10,padding:'20px 24px',marginBottom:16 },
  sessionNumLarge: { fontSize:20,fontWeight:800,color:'#c9a227',fontFamily:"'Cinzel',serif",minWidth:40 },
  lootTag: { fontSize:11,background:'#2a2a32',color:'#c5c3c0',padding:'3px 10px',borderRadius:20 },
  loreCatHeader: { fontSize:11,fontWeight:800,color:'#6b6966',textTransform:'uppercase',letterSpacing:'0.15em',marginBottom:10 },
  loreCard: { background:'#1c1c22',border:'1px solid #2a2a32',borderRadius:8,padding:'16px 20px',marginBottom:10 },
  pinnedBadge: { fontSize:10,color:'#c9a227',background:'rgba(201,162,39,0.1)',padding:'2px 8px',borderRadius:10 },
  questGroup: { fontSize:11,fontWeight:800,color:'#6b6966',textTransform:'uppercase',letterSpacing:'0.15em',marginBottom:10 },
  questCard: { background:'#1c1c22',border:'1px solid #2a2a32',borderRadius:8,padding:'16px 20px',marginBottom:10 },
  priorityBadge: { fontSize:10,fontWeight:700,textTransform:'uppercase',padding:'2px 8px',borderRadius:20,letterSpacing:'0.08em' },
  npcCard: { background:'#1c1c22',border:'1px solid #2a2a32',borderRadius:10,padding:'18px 20px' },
  npcAvatar: { width:44,height:44,borderRadius:8,background:'#2a2a32',display:'flex',alignItems:'center',justifyContent:'center',fontSize:14,fontWeight:800,color:'#c9a227',flexShrink:0 },
  relBadge: { fontSize:10,fontWeight:700,padding:'2px 8px',borderRadius:20,textTransform:'capitalize',flexShrink:0 },
  relGood: { background:'rgba(30,107,60,0.15)',color:'#22c55e' },
  relNeutral: { background:'rgba(107,105,102,0.15)',color:'#9a9793' },
  relGrudge: { background:'rgba(197,48,48,0.15)',color:'#f87171' },
  editIconBtn: { background:'none',border:'1px solid #3a3a42',color:'#6b6966',borderRadius:5,padding:'3px 8px',cursor:'pointer',fontSize:12,fontFamily:"'Nunito',sans-serif" },
  editLabel: { fontSize:10,fontWeight:800,color:'#6b6966',textTransform:'uppercase',letterSpacing:'0.1em',marginBottom:7,display:'block' },
  editTextarea: { width:'100%',background:'#111116',border:'1px solid #2a2a32',borderRadius:8,color:'#e8e6e3',padding:'11px 14px',fontSize:13,resize:'vertical',outline:'none',fontFamily:"'Nunito',sans-serif",lineHeight:1.6,boxSizing:'border-box' },
  editInput: { width:'100%',background:'#111116',border:'1px solid #2a2a32',borderRadius:8,color:'#e8e6e3',padding:'11px 14px',fontSize:13,outline:'none',fontFamily:"'Nunito',sans-serif",boxSizing:'border-box' },
  editSelect: { width:'100%',background:'#111116',border:'1px solid #2a2a32',borderRadius:8,color:'#e8e6e3',padding:'11px 14px',fontSize:13,cursor:'pointer',fontFamily:"'Nunito',sans-serif",boxSizing:'border-box' },
  knownInfoBox: { background:'rgba(201,162,39,0.06)',border:'1px solid rgba(201,162,39,0.15)',borderRadius:8,padding:'10px 12px',marginBottom:4 },
  chatBtn: { width:'100%',background:'rgba(201,162,39,0.08)',border:'1px solid rgba(201,162,39,0.3)',color:'#c9a227',borderRadius:6,padding:'8px 0',cursor:'pointer',fontSize:13,fontWeight:700,fontFamily:"'Nunito',sans-serif" },
  inventoryRow: { display:'flex',justifyContent:'space-between',alignItems:'flex-start',background:'#1c1c22',border:'1px solid #2a2a32',borderRadius:8,padding:'14px 18px' },
  spellSection: { fontSize:11,fontWeight:700,color:'#6b6966',textTransform:'uppercase',letterSpacing:'0.1em',marginBottom:8 },
  spellChip: { fontSize:12,color:'#c5c3c0',background:'#111116',border:'1px solid #2a2a32',borderRadius:6,padding:'4px 10px',display:'flex',alignItems:'center' },
  spellRowFull: { display:'flex',gap:12,alignItems:'center',background:'#111116',border:'1px solid #2a2a32',borderRadius:8,padding:'9px 12px' },
  spellLevel: { width:26,height:26,borderRadius:5,display:'flex',alignItems:'center',justifyContent:'center',fontSize:11,fontWeight:800,flexShrink:0 },
  prepToggle: { background:'none',border:'1px solid #3a3a42',color:'#9a9793',borderRadius:5,padding:'2px 8px',cursor:'pointer',fontSize:11,fontFamily:"'Nunito',sans-serif" },
  removeBtn: { background:'none',border:'none',color:'#4a4a52',cursor:'pointer',fontSize:13,padding:'2px 4px',lineHeight:1 },
  spellSearchRow: { display:'flex',alignItems:'center',gap:8,background:'none',border:'1px solid #2a2a32',borderRadius:6,padding:'7px 10px',cursor:'pointer',fontFamily:"'Nunito',sans-serif",transition:'background 0.1s' },
};

Object.assign(window, { CampaignView, cvStyles, priorityColor });
