// Fun-Ture v2 — UI components
const { useState, useEffect, useRef, useMemo, useCallback } = React;

// ---------- Avatar ----------
// Gradient is chosen by the person's INDEX in the partners array (partner 0 = warm/rose,
// partner 1 = cool/violet). This keeps the two-tone look without hardcoding names.
const AVATAR_GRADIENTS = [
  'linear-gradient(135deg,#E8A5C0,#F09090)',
  'linear-gradient(135deg,#B9A4E3,#8BB8B4)',
];
function Avatar({ person, size = 32, onClick, ring = false }){
  const profiles = useProfiles();
  const partners = usePartners();
  const key = (FT.normKey ? FT.normKey(person) : (person||'').toLowerCase().replace(/é/g,'e'));
  const p = profiles[key] || { name: person, photo: null };
  const initial = (p.name || person || '?').charAt(0).toUpperCase();
  const partnerIdx = partners.findIndex(pn => FT.normKey(pn) === key);
  const gradient = AVATAR_GRADIENTS[partnerIdx >= 0 ? partnerIdx : 0];
  const style = {
    width: size, height: size, borderRadius: '50%',
    background: p.photo ? '#333' : gradient,
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    color: '#fff', fontFamily: 'Playfair Display, serif',
    fontSize: size * 0.42, fontWeight: 700,
    overflow: 'hidden', flexShrink: 0,
    cursor: onClick ? 'pointer' : 'default',
    boxShadow: ring ? '0 0 0 2px var(--bg), 0 0 0 3.5px var(--border-accent)' : 'var(--ring)',
    border: '1px solid rgba(255,255,255,0.08)',
    transition: 'transform .15s, box-shadow .2s',
  };
  return (
    <div className="avatar" style={style} onClick={onClick} title={p.name || person}>
      {p.photo ? <img src={p.photo} alt={p.name||person} style={{width:'100%',height:'100%',objectFit:'cover'}} /> : initial}
    </div>
  );
}

function useProfiles(){
  const [p, setP] = useState(FT.profiles);
  useEffect(() => FT.subscribeProfiles(setP), []);
  return p;
}

function usePartners(){
  const [p, setP] = useState(FT.partners || []);
  useEffect(() => FT.subscribePartners ? FT.subscribePartners(setP) : undefined, []);
  return p;
}
// Expose on FT so non-component code (data.js consumers) can still hook in
if(typeof FT !== 'undefined') FT.usePartners = usePartners;

function useData(){
  const [d, setD] = useState(FT.data);
  useEffect(() => FT.subscribe(setD), []);
  return d;
}

function useActivity(){
  const [a, setA] = useState([]);
  useEffect(() => FT.subscribeActivity(setA), []);
  return a;
}

// ---------- Icons (thin, hand-drawn feel) ----------
const Icon = {
  home: (p={}) => <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 11l9-8 9 8v10a2 2 0 0 1-2 2h-4v-7h-6v7H5a2 2 0 0 1-2-2z"/></svg>,
  list: (p={}) => <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M8 6h13M8 12h13M8 18h13"/><circle cx="3.5" cy="6" r="1.2"/><circle cx="3.5" cy="12" r="1.2"/><circle cx="3.5" cy="18" r="1.2"/></svg>,
  chat: (p={}) => <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M21 12a8 8 0 0 1-8 8c-1.6 0-3-.3-4.3-1L3 20l1-4.3A8 8 0 1 1 21 12z"/><circle cx="9" cy="12" r=".8" fill="currentColor"/><circle cx="13" cy="12" r=".8" fill="currentColor"/><circle cx="17" cy="12" r=".8" fill="currentColor"/></svg>,
  heart: (p={}) => <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M12 21s-7-4.5-9.5-9C.5 8 2.5 4 6.5 4c2 0 3.5 1 5.5 3 2-2 3.5-3 5.5-3 4 0 6 4 4 8-2.5 4.5-9.5 9-9.5 9z"/></svg>,
  us: (p={}) => <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="8.5" cy="9" r="3.5"/><circle cx="16" cy="10" r="3"/><path d="M2.5 20c0-3 3-5 6-5s6 2 6 5M14 20c0-2 2-4 4-4s4 2 4 4"/></svg>,
  plus: (p={}) => <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" {...p}><path d="M12 5v14M5 12h14"/></svg>,
  check: (p={}) => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M5 13l5 5L20 7"/></svg>,
  search: (p={}) => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" {...p}><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></svg>,
  close: (p={}) => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" {...p}><path d="M6 6l12 12M18 6L6 18"/></svg>,
  camera: (p={}) => <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 8a2 2 0 0 1 2-2h2l2-2h6l2 2h2a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><circle cx="12" cy="13" r="3.5"/></svg>,
  sparkle: (p={}) => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M5.6 18.4l2.8-2.8M15.6 8.4l2.8-2.8"/></svg>,
  dice: (p={}) => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" {...p}><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8" cy="8" r="1" fill="currentColor"/><circle cx="16" cy="8" r="1" fill="currentColor"/><circle cx="12" cy="12" r="1" fill="currentColor"/><circle cx="8" cy="16" r="1" fill="currentColor"/><circle cx="16" cy="16" r="1" fill="currentColor"/></svg>,
  note: (p={}) => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M4 4h12l4 4v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z"/><path d="M15 4v5h5"/></svg>,
  think: (p={}) => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M4 16a5 5 0 0 1 4-8 6 6 0 0 1 11 2 4 4 0 0 1-2 7H8a4 4 0 0 1-4-4z"/></svg>,
  bell: (p={}) => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M6 10a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6z"/><path d="M10 20a2 2 0 0 0 4 0"/></svg>,
  bolt: (p={}) => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M13 2L4 14h7l-1 8 9-12h-7z"/></svg>,
  gift: (p={}) => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><rect x="3" y="8" width="18" height="4" rx="1"/><path d="M5 12v9a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-9M12 8v14"/><path d="M12 8c-2 0-4-1.2-4-3s1.5-3 3-3 3 3 1 6M12 8c2 0 4-1.2 4-3s-1.5-3-3-3-3 3-1 6"/></svg>,
};

// ---------- Squiggle accent ----------
function Squiggle({ width = 60, color = 'currentColor' }){
  return (
    <svg width={width} height="6" viewBox="0 0 60 6" fill="none" style={{opacity:0.5}}>
      <path d="M1 3 Q 7 0.5, 13 3 T 25 3 T 37 3 T 49 3 T 59 3" stroke={color} strokeWidth="1" fill="none" strokeLinecap="round"/>
    </svg>
  );
}

// ---------- Category chips filter (shared across quick-things) ----------
function CategoryFilter({ d, value, onChange, label='filter by category', collapsed=true }){
  const [open, setOpen] = React.useState(!collapsed);
  if(!d) return null;
  const active = value || [];
  const toggle = (k) => {
    if(active.includes(k)){
      const next = active.filter(x => x!==k);
      onChange(next.length ? next : null);
    } else {
      onChange([...active, k]);
    }
  };
  return (
    <div style={{marginBottom:12}}>
      <div style={{textAlign:'center',marginBottom:open?8:0}}>
        <button onClick={()=>setOpen(o=>!o)}
          style={{fontSize:12,color:'var(--text3)',background:'transparent',border:'none',cursor:'pointer',fontFamily:'inherit',display:'inline-flex',alignItems:'center',gap:6}}>
          <span style={{display:'inline-flex',alignItems:'center',justifyContent:'center',width:20,height:20,borderRadius:'50%',background:'color-mix(in oklab,var(--rose) 12%,var(--card))',border:'1px solid var(--border)',fontSize:11,color:'var(--text2)',fontWeight:600,lineHeight:1}}>{open?'▾':'▸'}</span>
          <span>{label}{active.length?` · ${active.length}`:''}</span>
        </button>
      </div>
      {open && (
        <div style={{display:'flex',gap:5,flexWrap:'wrap',justifyContent:'center'}}>
          <button onClick={()=>onChange(null)}
            style={{padding:'4px 9px',borderRadius:99,fontSize:11,
              background:!active.length?'color-mix(in oklab,var(--rose) 22%,transparent)':'var(--card)',
              border:`1px solid ${!active.length?'var(--border-accent)':'var(--border)'}`,
              color:!active.length?'var(--text)':'var(--text3)',cursor:'pointer',fontFamily:'inherit'}}>all</button>
          {Object.entries(d).map(([k,c]) => {
            const a = active.includes(k);
            return (
              <button key={k} onClick={()=>toggle(k)}
                style={{padding:'4px 9px',borderRadius:99,fontSize:11,
                  background:a?'color-mix(in oklab,var(--rose) 22%,transparent)':'var(--card)',
                  border:`1px solid ${a?'var(--border-accent)':'var(--border)'}`,
                  color:a?'var(--text)':'var(--text3)',cursor:'pointer',fontFamily:'inherit'}}>
                {c.emoji} {c.title?.replace(/^[^\w]+\s*/,'')}
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
}

// ---------- Card ----------
function Card({ children, className='', style={}, onClick, hoverable=true }){
  return (
    <div className={`ft-card ${hoverable?'hoverable':''} ${className}`} style={style} onClick={onClick}>
      {children}
    </div>
  );
}

// ---------- Button ----------
function Btn({ children, variant='solid', size='md', onClick, style={}, ...rest }){
  const cls = `ft-btn ft-btn-${variant} ft-btn-${size}`;
  return <button className={cls} onClick={onClick} style={style} {...rest}>{children}</button>;
}

// ---------- Sheet (bottom sheet, swipe-down to dismiss) ----------
function Sheet({ open, onClose, title, children }){
  const sheetRef = React.useRef(null);
  const ovRef = React.useRef(null);
  const startY = React.useRef(0);
  const dy = React.useRef(0);
  const dragging = React.useRef(false);     // committed to drag-to-dismiss
  const predragging = React.useRef(false);  // finger down at scrollTop=0, direction TBD
  const rafId = React.useRef(0);
  const pendingY = React.useRef(0);
  const [closing, setClosing] = React.useState(false);
  // Track the visualViewport (which shrinks when the iOS keyboard opens) so we
  // can clamp the sheet's max-height to what's actually visible. Without this,
  // .ft-sheet stays 90vh tall, the input sits beneath the keyboard, and iOS
  // pushes the whole sheet up, leaving a giant empty gap above the input.
  const [vpHeight, setVpHeight] = React.useState(() => window.visualViewport?.height || window.innerHeight);
  React.useEffect(() => {
    if(!open) return;
    const vv = window.visualViewport;
    const update = () => setVpHeight(vv ? vv.height : window.innerHeight);
    update();
    if(vv){
      vv.addEventListener('resize', update);
      vv.addEventListener('scroll', update);
      return () => { vv.removeEventListener('resize', update); vv.removeEventListener('scroll', update); };
    }
    window.addEventListener('resize', update);
    return () => window.removeEventListener('resize', update);
  }, [open]);

  // Reset closing state when sheet is reopened
  React.useEffect(() => { if(open) setClosing(false); }, [open]);

  // Lock body scroll while sheet is open — simple overflow:hidden (don't use position:fixed,
  // which causes iOS to shift the layout and add weird empty space around fixed-position children)
  React.useEffect(() => {
    if(!open) return;
    const prevBody = document.body.style.overflow;
    const prevHtml = document.documentElement.style.overflow;
    document.body.style.overflow = 'hidden';
    document.documentElement.style.overflow = 'hidden';
    return () => {
      document.body.style.overflow = prevBody;
      document.documentElement.style.overflow = prevHtml;
    };
  }, [open]);

  if(!open) return null;

  const animateClose = () => {
    if(closing) return;
    setClosing(true);
    const el = sheetRef.current;
    const ov = ovRef.current;
    if(el){
      el.style.willChange = 'transform';
      el.style.transition = 'transform .26s cubic-bezier(.32,.72,.34,1)';
      el.style.transform = 'translate3d(0, 110%, 0)';
    }
    if(ov){
      ov.style.transition = 'background .26s ease';
      ov.style.background = 'rgba(10,8,18,0)';
    }
    setTimeout(()=>onClose?.(), 250);
  };
  const onStart = (e) => {
    const touch = e.touches ? e.touches[0] : e;
    startY.current = touch.clientY;
    dragging.current = true;
    dy.current = 0;
    const el = sheetRef.current;
    if(el){
      el.classList.add('dragging');
      el.style.transition = 'none';
      el.style.willChange = 'transform';
    }
  };
  const onMove = (e) => {
    if(!dragging.current) return;
    const touch = e.touches ? e.touches[0] : e;
    const delta = touch.clientY - startY.current;
    if(delta < 0){
      pendingY.current = 0;
    } else {
      pendingY.current = delta;
    }
    dy.current = pendingY.current;
    // Throttle style writes to rAF for smooth 60fps
    if(!rafId.current){
      rafId.current = requestAnimationFrame(() => {
        rafId.current = 0;
        const el = sheetRef.current;
        if(el) el.style.transform = `translate3d(0, ${pendingY.current}px, 0)`;
        if(ovRef.current) ovRef.current.style.background = `rgba(10,8,18,${Math.max(0.15, 0.65 - pendingY.current/500)})`;
      });
    }
  };
  const onEnd = () => {
    if(!dragging.current) return;
    dragging.current = false;
    if(rafId.current){ cancelAnimationFrame(rafId.current); rafId.current = 0; }
    const el = sheetRef.current;
    if(!el) return;
    el.classList.remove('dragging');
    // Dismiss on either distance past threshold OR a decent flick velocity
    if(dy.current > 40){
      el.style.transition = 'transform .24s cubic-bezier(.32,.72,.34,1)';
      el.style.transform = 'translate3d(0, 110%, 0)';
      if(ovRef.current){
        ovRef.current.style.transition = 'background .22s ease';
        ovRef.current.style.background = 'rgba(10,8,18,0)';
      }
      setTimeout(()=>{ if(el){ el.style.willChange=''; } onClose?.(); }, 230);
    } else {
      // Spring back with bouncier curve
      el.style.transition = 'transform .32s cubic-bezier(.2,1.2,.4,1)';
      el.style.transform = 'translate3d(0, 0, 0)';
      if(ovRef.current){
        ovRef.current.style.transition = 'background .2s ease';
        ovRef.current.style.background = '';
      }
      setTimeout(()=>{ if(el) el.style.willChange=''; }, 350);
    }
  };

  // Pull-down from anywhere in the sheet body, but only if scrolled to top.
  // Uses a pre-drag phase so we can disambiguate "user wants to scroll" vs
  // "user wants to dismiss" based on initial finger direction.
  const onBodyStart = (e) => {
    const sheet = sheetRef.current;
    if(!sheet) return;
    if(sheet.scrollTop > 0) return;
    let n = e.target;
    while(n && n !== sheet){
      if(n.dataset?.noSheetDrag != null) return;
      const ta = n.style?.touchAction;
      if(ta && ta !== 'auto') return;
      if(n.nodeType === 1){
        const cs = window.getComputedStyle(n);
        if(/(auto|scroll)/.test(cs.overflowY) && n.scrollTop > 0) return;
      }
      n = n.parentElement;
    }
    const touch = e.touches ? e.touches[0] : e;
    startY.current = touch.clientY;
    dy.current = 0;
    predragging.current = true;
    dragging.current = false;
  };
  const DRAG_COMMIT_THRESHOLD = 6;
  const DRAG_ABORT_THRESHOLD = -6;
  const onBodyMove = (e) => {
    if(dragging.current){
      const touch = e.touches ? e.touches[0] : e;
      const delta = touch.clientY - startY.current;
      if(delta < 0){
        dragging.current = false;
        const el = sheetRef.current;
        if(el){ el.classList.remove('dragging'); el.style.transition=''; el.style.transform=''; }
        if(ovRef.current){ ovRef.current.style.background=''; }
        return;
      }
      onMove(e);
      return;
    }
    if(predragging.current){
      const touch = e.touches ? e.touches[0] : e;
      const delta = touch.clientY - startY.current;
      if(delta > DRAG_COMMIT_THRESHOLD){
        predragging.current = false;
        dragging.current = true;
        const el = sheetRef.current;
        if(el){
          el.classList.add('dragging');
          el.style.transition = 'none';
          el.style.willChange = 'transform';
        }
        onMove(e);
      } else if(delta < DRAG_ABORT_THRESHOLD){
        predragging.current = false;
      }
    }
  };
  const onBodyEnd = () => {
    if(dragging.current){ onEnd(); return; }
    predragging.current = false;
  };

  // Cap the sheet to the visualViewport (which excludes the iOS keyboard area).
  const kbOffset = Math.max(0, window.innerHeight - vpHeight);
  const sheetMaxH = `${Math.min(vpHeight, window.innerHeight * 0.95)}px`;

  return (
    <div className="ft-sheet-ov" ref={ovRef} onClick={animateClose} style={{ paddingBottom: kbOffset > 80 ? kbOffset : 0 }}>
      <div
        className="ft-sheet" ref={sheetRef}
        style={{ maxHeight: sheetMaxH }}
        onClick={e=>e.stopPropagation()}
        onTouchStart={onBodyStart}
        onTouchMove={onBodyMove}
        onTouchEnd={onBodyEnd}
        onTouchCancel={onBodyEnd}
      >
        <div
          className="ft-sheet-drag-area"
          onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
          onMouseDown={(e)=>{onStart(e); const mv=ev=>onMove(ev); const up=()=>{onEnd(); window.removeEventListener('mousemove',mv); window.removeEventListener('mouseup',up);}; window.addEventListener('mousemove',mv); window.addEventListener('mouseup',up);}}
          style={{padding:'14px 0 8px', cursor:'grab', touchAction:'none', userSelect:'none', WebkitUserSelect:'none'}}
        >
          <div className="ft-sheet-handle" style={{margin:'0 auto 10px',width:56,height:6,borderRadius:99,background:'var(--border2)',opacity:.8}}/>
          {title && <div className="ft-sheet-title" style={{marginBottom:0}}>{title}</div>}
        </div>
        <div>{children}</div>
      </div>
    </div>
  );
}

// ---------- SwipeToDelete (horizontal swipe on a row reveals 🗑 action) ----------
function SwipeToDelete({ children, onDelete, threshold = 80 }){
  const rootRef = React.useRef(null);
  const slideRef = React.useRef(null);
  const state = React.useRef({
    startX: 0, startY: 0, dx: 0, active: false, locked: null, pid: null,
  });
  const [revealed, setRevealed] = React.useState(false);

  const setX = (x) => {
    const el = slideRef.current;
    if(el) el.style.transform = x ? `translateX(${x}px)` : '';
  };

  const onDown = (e) => {
    // ignore non-primary pointers + touches on interactive children that scroll horizontally
    if(e.button !== undefined && e.button !== 0) return;
    const s = state.current;
    s.startX = e.clientX;
    s.startY = e.clientY;
    s.dx = 0;
    s.active = true;
    s.locked = null;
    s.pid = e.pointerId;
    const el = slideRef.current;
    if(el) el.style.transition = 'none';
  };
  const onMove = (e) => {
    const s = state.current;
    if(!s.active || s.pid !== e.pointerId) return;
    const dx = e.clientX - s.startX;
    const dy = e.clientY - s.startY;
    if(s.locked === null){
      if(Math.abs(dx) < 6 && Math.abs(dy) < 6) return;
      s.locked = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y';
      if(s.locked === 'x'){
        try{ e.currentTarget.setPointerCapture(e.pointerId); }catch(_){}
      }
    }
    if(s.locked !== 'x') return;
    e.preventDefault();
    // only allow left swipe (negative dx) — rubber-band on right
    const capped = dx < 0 ? Math.max(dx, -140) : dx * 0.25;
    s.dx = capped;
    setX(capped);
  };
  const finish = (commit) => {
    const s = state.current;
    s.active = false;
    const el = slideRef.current;
    if(el) el.style.transition = 'transform .22s cubic-bezier(.4,0,.2,1)';
    if(commit){
      setX(-600);
      setTimeout(() => { onDelete?.(); }, 180);
    } else {
      setX(0);
      setRevealed(false);
    }
  };
  const onUp = (e) => {
    const s = state.current;
    if(!s.active || s.pid !== e.pointerId) return;
    if(s.locked !== 'x'){
      s.active = false;
      return;
    }
    const el = slideRef.current;
    if(el) el.style.transition = 'transform .22s cubic-bezier(.4,0,.2,1)';
    if(s.dx < -threshold){
      // snap to revealed position; user must tap the delete pill or swipe further to commit
      if(s.dx < -threshold * 2.2){ finish(true); return; }
      setX(-84);
      setRevealed(true);
      s.active = false;
    } else {
      setX(0);
      setRevealed(false);
      s.active = false;
    }
  };
  const cancelReveal = () => {
    const el = slideRef.current;
    if(el){
      el.style.transition = 'transform .2s';
      el.style.transform = '';
    }
    setRevealed(false);
  };

  return (
    <div ref={rootRef} className="ft-swipe-row" style={{position:'relative', touchAction:'pan-y'}}>
      <div
        className="ft-swipe-action"
        style={{
          position:'absolute', top:0, right:0, bottom:0, width:84,
          display:'flex', alignItems:'center', justifyContent:'center',
          background:'linear-gradient(90deg, rgba(214,94,110,0), rgba(214,94,110,.95))',
          color:'#fff', fontSize:22, letterSpacing:'.05em',
          borderRadius:'inherit',
          pointerEvents: revealed ? 'auto' : 'none',
        }}
        onClick={() => finish(true)}
      >🗑</div>
      <div
        ref={slideRef}
        onPointerDown={onDown}
        onPointerMove={onMove}
        onPointerUp={onUp}
        onPointerCancel={onUp}
        onClickCapture={(e) => {
          // if revealed, first tap closes; don't propagate clicks to row
          if(revealed){ e.stopPropagation(); cancelReveal(); }
        }}
        style={{ position:'relative', background:'var(--bg)', borderRadius:'inherit', willChange:'transform' }}
      >
        {children}
      </div>
    </div>
  );
}


function Modal({ open, onClose, title, children, wide=false }){
  const modalRef = React.useRef(null);
  const ovRef = React.useRef(null);
  const startY = React.useRef(0);
  const dy = React.useRef(0);
  const dragging = React.useRef(false);
  const [closing, setClosing] = React.useState(false);

  React.useEffect(() => { if(open) setClosing(false); }, [open]);
  if(!open) return null;

  const animateClose = () => {
    if(closing) return;
    setClosing(true);
    const el = modalRef.current, ov = ovRef.current;
    if(el){
      el.style.transition = 'transform .26s cubic-bezier(.4,0,.2,1), opacity .26s ease';
      el.style.transform = 'translateY(60%) scale(.96)';
      el.style.opacity = '0';
    }
    if(ov){ ov.style.transition = 'background .26s ease'; ov.style.background = 'rgba(10,8,18,0)'; }
    setTimeout(()=>onClose?.(), 240);
  };

  const onStart = (e) => {
    const touch = e.touches ? e.touches[0] : e;
    startY.current = touch.clientY; dragging.current = true; dy.current = 0;
    if(modalRef.current) modalRef.current.style.transition = 'none';
  };
  const onMove = (e) => {
    if(!dragging.current) return;
    const touch = e.touches ? e.touches[0] : e;
    const delta = touch.clientY - startY.current;
    if(delta < 0) return;
    dy.current = delta;
    if(modalRef.current){
      modalRef.current.style.transform = `translateY(${delta}px)`;
      if(ovRef.current) ovRef.current.style.background = `rgba(10,8,18,${Math.max(0.2, 0.65 - delta/600)})`;
    }
  };
  const onEnd = () => {
    if(!dragging.current) return;
    dragging.current = false;
    const el = modalRef.current;
    if(!el) return;
    el.style.transition = 'transform .24s cubic-bezier(.22,1,.36,1), opacity .24s ease';
    if(dy.current > 60){
      el.style.transform = 'translateY(100%)';
      el.style.opacity = '0';
      if(ovRef.current){ ovRef.current.style.transition = 'background .24s ease'; ovRef.current.style.background = 'rgba(10,8,18,0)'; }
      setTimeout(()=>onClose?.(), 220);
    } else {
      el.style.transform = 'translateY(0)';
      if(ovRef.current) ovRef.current.style.background = '';
    }
  };

  return (
    <div className="ft-modal-ov" ref={ovRef} onClick={animateClose}>
      <div
        ref={modalRef}
        className={`ft-modal ${wide?'wide':''}`}
        onClick={e=>e.stopPropagation()}
      >
        <div
          className="ft-modal-head"
          onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
          onMouseDown={(e)=>{onStart(e); const mv=ev=>onMove(ev); const up=()=>{onEnd(); window.removeEventListener('mousemove',mv); window.removeEventListener('mouseup',up);}; window.addEventListener('mousemove',mv); window.addEventListener('mouseup',up);}}
          style={{touchAction:'pan-y',cursor:'grab'}}
        >
          <h3>{title}</h3>
          <button className="ft-close-btn" onClick={animateClose}><Icon.close/></button>
        </div>
        {children}
      </div>
    </div>
  );
}

// ---------- Empty state ----------
function Empty({ glyph='♡', children }){
  return (
    <div className="ft-empty">
      <div className="ft-empty-glyph">{glyph}</div>
      <div className="ft-empty-text">{children}</div>
    </div>
  );
}

// ---------- Markdown (tiny safe renderer for AI responses) ----------
function renderInline(text){
  // Escape HTML first
  const esc = (s) => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
  let out = esc(text);
  // Links [text](url)
  out = out.replace(/\[([^\]]+)\]\((https?:[^\s)]+)\)/g, (m,txt,url) => `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color:var(--lav);text-decoration:underline">${esc(txt)}</a>`);
  // Bold **text** (non-greedy, allows inner asterisks for nested italic)
  // Use a placeholder so italic doesn't eat the bold's asterisks
  const boldMarks = [];
  out = out.replace(/\*\*([\s\S]+?)\*\*/g, (m, inner) => {
    boldMarks.push(inner);
    return `\x00BOLD${boldMarks.length - 1}\x00`;
  });
  // Italic *text* — single asterisks only, won't match multi-asterisk runs
  out = out.replace(/(^|[^*\w])\*([^*\n]+?)\*(?!\*)/g, '$1<em>$2</em>');
  // Italic _text_
  out = out.replace(/(^|[\s(])_([^_\n]+?)_(?=$|[\s.,!?;:)])/g, '$1<em>$2</em>');
  // Restore bold placeholders, recursively rendering inline inside
  out = out.replace(/\x00BOLD(\d+)\x00/g, (m, idx) => {
    const inner = boldMarks[parseInt(idx)];
    // Re-process italic within the bold content
    let innerOut = inner;
    innerOut = innerOut.replace(/(^|[^*\w])\*([^*\n]+?)\*(?!\*)/g, '$1<em>$2</em>');
    innerOut = innerOut.replace(/(^|[\s(])_([^_\n]+?)_(?=$|[\s.,!?;:)])/g, '$1<em>$2</em>');
    return `<strong>${innerOut}</strong>`;
  });
  // Inline code `code`
  out = out.replace(/`([^`]+)`/g, '<code style="background:var(--card);padding:1px 5px;border-radius:4px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em">$1</code>');
  return out;
}
function Markdown({ text }){
  if(!text) return null;
  const lines = text.split('\n');
  const blocks = [];
  let i = 0;
  while(i < lines.length){
    const line = lines[i];
    // Blank line
    if(!line.trim()){ i++; continue; }
    // Heading
    const hm = line.match(/^(#{1,3})\s+(.+)$/);
    if(hm){
      const level = hm[1].length;
      const size = level === 1 ? 18 : level === 2 ? 15 : 13.5;
      blocks.push(<div key={i} style={{fontSize:size,fontWeight:700,margin:'10px 0 4px',color:'var(--text)'}} dangerouslySetInnerHTML={{__html: renderInline(hm[2])}}/>);
      i++; continue;
    }
    // Bulleted list (allow blank lines between items if next non-blank is still a bullet)
    if(/^\s*[-*•]\s+/.test(line)){
      const items = [];
      while(i < lines.length){
        if(/^\s*[-*•]\s+/.test(lines[i])){
          items.push(lines[i].replace(/^\s*[-*•]\s+/, ''));
          i++;
        } else if(lines[i].trim() === '' && i+1 < lines.length && /^\s*[-*•]\s+/.test(lines[i+1])){
          i++; // skip blank line between items
        } else break;
      }
      blocks.push(
        <ul key={'ul'+i} style={{margin:'4px 0 4px 18px',padding:0,listStyle:'disc'}}>
          {items.map((it,j) => <li key={j} style={{margin:'4px 0',lineHeight:1.5}} dangerouslySetInnerHTML={{__html: renderInline(it)}}/>)}
        </ul>
      );
      continue;
    }
    // Numbered list (preserve starting number; allow blank lines between items)
    if(/^\s*\d+\.\s+/.test(line)){
      const items = [];
      let startNum = 0;
      while(i < lines.length){
        const m = lines[i].match(/^\s*(\d+)\.\s+(.+)/);
        if(m){
          if(!startNum) startNum = parseInt(m[1]);
          items.push(m[2]);
          i++;
        } else if(lines[i].trim() === '' && i+1 < lines.length && /^\s*\d+\.\s+/.test(lines[i+1])){
          i++; // skip blank line between items
        } else break;
      }
      blocks.push(
        <ol key={'ol'+i} start={startNum || 1} style={{margin:'4px 0 4px 20px',padding:0}}>
          {items.map((it,j) => <li key={j} style={{margin:'4px 0',lineHeight:1.5}} dangerouslySetInnerHTML={{__html: renderInline(it)}}/>)}
        </ol>
      );
      continue;
    }
    // Paragraph — collect consecutive non-blank, non-structural lines
    const para = [line];
    i++;
    while(i < lines.length && lines[i].trim() && !/^(\s*[-*•]\s+|\s*\d+\.\s+|#{1,3}\s+)/.test(lines[i])){
      para.push(lines[i]); i++;
    }
    blocks.push(<div key={'p'+i} style={{margin:'4px 0',lineHeight:1.55}} dangerouslySetInnerHTML={{__html: renderInline(para.join(' '))}}/>);
  }
  return <div>{blocks}</div>;
}

/* =========================================================
   Draggable FAB — press-and-hold to drag, snaps to nearest
   vertical edge, clamps Y to screen. Session-only (resets
   to default on refresh). Blocks click-through after drag.
========================================================= */
function DraggableFab({ onClick, children, className='', ariaLabel='Add' }){
  const [pos, setPos] = React.useState(null); // session-scoped, not persisted
  const [dragging, setDragging] = React.useState(false);
  const ref = React.useRef(null);
  const stateRef = React.useRef({ down:false, moved:false, startX:0, startY:0, offX:0, offY:0, holdTimer:null, dragStarted:false });

  const clampY = (y)=>{
    const h = ref.current ? ref.current.offsetHeight : 56;
    const top = 72; // clear top bar
    const navH = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')) || 64;
    const bottom = window.innerHeight - navH - 16 - h;
    return Math.max(top, Math.min(bottom, y));
  };

  // Re-clamp on window resize / rotation
  React.useEffect(()=>{
    if(!pos) return;
    const onResize = ()=>{
      setPos(p=>{
        if(!p) return p;
        const y = clampY(p.top);
        if(y === p.top) return p;
        return { ...p, top: y };
      });
    };
    window.addEventListener('resize', onResize);
    return ()=>window.removeEventListener('resize', onResize);
  }, [pos]);

  const onPointerDown = (e)=>{
    if(e.button !== undefined && e.button !== 0) return;
    const rect = ref.current.getBoundingClientRect();
    stateRef.current = {
      down: true, moved: false, dragStarted: false,
      startX: e.clientX, startY: e.clientY,
      offX: e.clientX - rect.left, offY: e.clientY - rect.top,
      rectW: rect.width, rectH: rect.height,
      holdTimer: setTimeout(()=>{
        // long-press arms drag mode (so regular taps stay snappy)
        stateRef.current.dragStarted = true;
        setDragging(true);
        if(navigator.vibrate) navigator.vibrate(8);
      }, 220)
    };
    try { ref.current.setPointerCapture(e.pointerId); } catch(err){}
  };

  const onPointerMove = (e)=>{
    const s = stateRef.current;
    if(!s.down) return;
    const dx = e.clientX - s.startX;
    const dy = e.clientY - s.startY;
    if(!s.moved && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) s.moved = true;
    // If user moves more than 10px without waiting for hold, arm drag too
    if(!s.dragStarted && (Math.abs(dx) > 10 || Math.abs(dy) > 10)){
      clearTimeout(s.holdTimer);
      s.dragStarted = true;
      setDragging(true);
    }
    if(s.dragStarted && ref.current){
      const x = e.clientX - s.offX;
      const y = clampY(e.clientY - s.offY);
      ref.current.style.left = x + 'px';
      ref.current.style.top = y + 'px';
      ref.current.style.right = 'auto';
      ref.current.style.bottom = 'auto';
      if(e.cancelable) e.preventDefault();
    }
  };

  // Prevent the release-on-card-opens-card bug by (1) swallowing the next
  // click at capture phase AND (2) putting an app-wide pointer-events
  // lockout for a brief window so iOS/touch synthesized taps cannot
  // activate cards underneath the released FAB.
  const blockInteractionsBriefly = ()=>{
    document.documentElement.classList.add('ft-fab-just-dragged');
    const swallow = (ev)=>{
      ev.stopPropagation();
      ev.preventDefault();
    };
    // one-shot capture-phase block for click AND touchend
    const onceOpts = { capture: true };
    document.addEventListener('click', swallow, onceOpts);
    document.addEventListener('touchend', swallow, onceOpts);
    document.addEventListener('mousedown', swallow, onceOpts);
    document.addEventListener('mouseup', swallow, onceOpts);
    setTimeout(()=>{
      document.documentElement.classList.remove('ft-fab-just-dragged');
      document.removeEventListener('click', swallow, onceOpts);
      document.removeEventListener('touchend', swallow, onceOpts);
      document.removeEventListener('mousedown', swallow, onceOpts);
      document.removeEventListener('mouseup', swallow, onceOpts);
    }, 450);
  };

  const onPointerUp = (e)=>{
    const s = stateRef.current;
    clearTimeout(s.holdTimer);
    const wasDragging = s.dragStarted;
    s.down = false;
    setDragging(false);

    if(!wasDragging){
      // plain tap
      if(!s.moved && onClick) onClick();
      return;
    }

    // Lock out UI interactions briefly so the release doesn't activate
    // whatever card is beneath the released FAB.
    blockInteractionsBriefly();
    if(e.cancelable) e.preventDefault();

    // Snap to nearest vertical edge
    const rect = ref.current.getBoundingClientRect();
    const vw = window.innerWidth;
    const centerX = rect.left + rect.width / 2;
    const side = centerX < vw / 2 ? 'left' : 'right';
    const top = clampY(rect.top);
    setPos({ side, top });
    // clear inline styles so React-applied pos takes over
    if(ref.current){
      ref.current.style.left = '';
      ref.current.style.top = '';
      ref.current.style.right = '';
      ref.current.style.bottom = '';
    }
  };

  const style = pos ? {
    position: 'fixed',
    top: pos.top + 'px',
    [pos.side]: '18px',
    [pos.side === 'left' ? 'right' : 'left']: 'auto',
    bottom: 'auto'
  } : undefined;

  return (
    <button
      ref={ref}
      className={`ft-fab ${className} ${dragging?'ft-fab-dragging':''}`.trim()}
      style={style}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
      onPointerCancel={onPointerUp}
      aria-label={ariaLabel}
      title="Tap to add · press & hold to move"
    >
      {children}
    </button>
  );
}

window.FTUI = { Avatar, useProfiles, useData, useActivity, Icon, Squiggle, Card, Btn, Sheet, Modal, Empty, CategoryFilter, SwipeToDelete, Markdown, DraggableFab };
