/* global React, ReactDOM, PRQ, API, Notify */
const { useState, useEffect, useMemo, useRef } = React;

/* ----------------------------- helpers ----------------------------- */
function lum(hex) {
  const c = hex.replace('#', '');
  const n = c.length === 3 ? c.split('').map((x) => x + x).join('') : c;
  const r = parseInt(n.slice(0, 2), 16), g = parseInt(n.slice(2, 4), 16), b = parseInt(n.slice(4, 6), 16);
  return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
}
function inkFor(hex) { return lum(hex) > 0.62 ? '#10141c' : '#ffffff'; }

function Avatar({ name, size = 'sm', title }) {
  const hue = PRQ.hueFor(name);
  return (
    <div className={'avatar ' + size}
      title={title || name}
      style={{ background: `hsl(${hue} 52% 48%)` }}>
      {PRQ.initials(name)}
    </div>
  );
}

function Icon({ d, size = 15, sw = 1.8 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none"
      stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
      {Array.isArray(d) ? d.map((p, i) => <path key={i} d={p} />) : <path d={d} />}
    </svg>
  );
}
const ICONS = {
  search: 'M21 21l-4.3-4.3 M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14z',
  arrow: 'M7 17L17 7 M9 7h8v8',
  check: 'M20 6L9 17l-5-5',
  x: 'M18 6L6 18 M6 6l12 12',
  archive: ['M3 8h18v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8z', 'M1 4h22v4H1z', 'M10 12h4'],
  reopen: 'M3 12a9 9 0 1 0 3-6.7L3 8 M3 3v5h5',
  sliders: ['M4 21v-7', 'M4 10V3', 'M12 21v-9', 'M12 8V3', 'M20 21v-5', 'M20 12V3', 'M1 14h6', 'M9 8h6', 'M17 16h6'],
  bell: ['M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9', 'M13.7 21a2 2 0 0 1-3.4 0'],
  edit: ['M12 20h9', 'M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z'],
  comment: 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z',
  bold: ['M6 4h8a4 4 0 0 1 0 8H6z', 'M6 12h9a4 4 0 0 1 0 8H6z'],
  italic: ['M19 4h-9', 'M14 20H5', 'M15 4L9 20'],
  list: ['M8 6h13', 'M8 12h13', 'M8 18h13', 'M3 6h.01', 'M3 12h.01', 'M3 18h.01'],
  link: ['M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.5 1.5', 'M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.5-1.5'],
  trash: ['M3 6h18', 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'],
};

/* ----------------------------- review status ----------------------------- */
const REVIEWS = [
  { v: 'toreview', l: 'À revoir', cls: 'rv-toreview' },
  { v: 'tofix', l: 'À corriger', cls: 'rv-tofix' },
  { v: 'blocked', l: 'Bloquée', cls: 'rv-blocked' },
  { v: 'approved', l: 'Validée', cls: 'rv-approved' },
];
const REVIEW_DEFAULT = 'toreview';
const REVIEW_ORDER = REVIEWS.reduce((m, r, i) => { m[r.v] = i; return m; }, {});
function reviewMeta(v) { return REVIEWS.find((r) => r.v === v) || REVIEWS[0]; }

// Native <select> styled as a coloured pill — escapes table overflow clipping.
function StatusControl({ pr, onChange }) {
  const v = pr.review || REVIEW_DEFAULT;
  const label = reviewMeta(v).l;
  return (
    <select className={'status-select ' + reviewMeta(v).cls} value={v}
      style={{ width: 'calc(' + label.length + 'ch + 30px)' }}
      title="Statut de revue" onChange={(e) => onChange(e.target.value)}
      onClick={(e) => e.stopPropagation()}>
      {REVIEWS.map((r) => <option key={r.v} value={r.v}>{r.l}</option>)}
    </select>
  );
}

/* ----------------------------- onboarding ----------------------------- */
function Onboarding({ onSave, initial }) {
  const [v, setV] = useState(initial || '');
  const ref = useRef(null);
  useEffect(() => { if (ref.current) ref.current.focus(); }, []);
  const clean = v.trim().replace(/^@/, '').replace(/\s+/g, '-');
  return (
    <div className="modal-scrim">
      <div className="modal fade-in">
        <div className="brand-mark" style={{ marginBottom: 16 }}>PR</div>
        <h2>{initial ? 'Changer de profil' : 'Bienvenue sur PR Queue'}</h2>
        <p>Indiquez votre nom d'utilisateur. Pas de mot de passe — il sert juste à savoir qui poste et qui prend les revues.</p>
        <form onSubmit={(e) => { e.preventDefault(); if (clean) onSave(clean); }}>
          <label>Nom d'utilisateur</label>
          <input ref={ref} className="modal-input" value={v} placeholder="ex. maelle"
            onChange={(e) => setV(e.target.value)} />
          <button type="submit" className="btn btn-primary" disabled={!clean}>
            {initial ? 'Mettre à jour' : 'Commencer'}
          </button>
        </form>
        <div className="modal-foot">Enregistré localement sur cet appareil.</div>
      </div>
    </div>
  );
}

/* ----------------------------- product / activity editor ----------------------------- */
function DetailsModal({ pr, onSave, onClose }) {
  const [version, setVersion] = useState(pr.version || '');
  const [activity, setActivity] = useState(pr.activity || '');
  return (
    <div className="modal-scrim" onMouseDown={onClose}>
      <div className="modal fade-in" onMouseDown={(e) => e.stopPropagation()}>
        <h2>Détails de la PR</h2>
        <p><span className="mono">{pr.kind} #{pr.number || '?'}</span> — {pr.repo}</p>
        <form onSubmit={(e) => { e.preventDefault(); onSave({ version: version.trim(), activity: activity.trim() }); }}>
          <label>Version produit</label>
          <VersionSelect value={version} onChange={setVersion} variant="modal" />
          <label style={{ marginTop: 16 }}>Numéro d'activité</label>
          <input className="modal-input" value={activity} placeholder="ex. ACT-1234"
            onChange={(e) => setActivity(e.target.value)} />
          <div className="modal-actions">
            <button type="button" className="btn btn-ghost" onClick={onClose}>Annuler</button>
            <button type="submit" className="btn btn-primary">Enregistrer</button>
          </div>
        </form>
        <div className="modal-foot">Laissez un champ vide pour le retirer.</div>
      </div>
    </div>
  );
}

/* ----------------------------- rich comment editor ----------------------------- */
function RichEditor({ onSubmit }) {
  const edRef = useRef(null);
  const savedRange = useRef(null);
  const gifAvail = typeof API !== 'undefined' && API.gifAvailable && API.gifAvailable();
  const [gifOpen, setGifOpen] = useState(false);
  const [gifMode, setGifMode] = useState(gifAvail ? 'browse' : 'link'); // 'browse' | 'link'
  const [gifUrl, setGifUrl] = useState('');
  const [gifQuery, setGifQuery] = useState('');
  const [gifResults, setGifResults] = useState([]);
  const [gifLoading, setGifLoading] = useState(false);
  const [gifError, setGifError] = useState(false);
  const searchTimer = useRef(null);

  function saveRange() {
    const sel = window.getSelection();
    if (sel && sel.rangeCount && edRef.current && edRef.current.contains(sel.anchorNode)) {
      savedRange.current = sel.getRangeAt(0).cloneRange();
    }
  }
  function cmd(command, value) {
    edRef.current.focus();
    document.execCommand(command, false, value);
    saveRange();
  }
  function insertHTML(html) {
    edRef.current.focus();
    const sel = window.getSelection();
    if (savedRange.current) { sel.removeAllRanges(); sel.addRange(savedRange.current); }
    document.execCommand('insertHTML', false, html);
    saveRange();
  }
  function addLink(e) {
    e.preventDefault();
    const url = window.prompt('Adresse du lien (https://…)');
    if (url && /^https?:\/\//i.test(url)) cmd('createLink', url);
  }
  function insertGif(src) {
    if (!src) return;
    insertHTML('<img src="' + String(src).replace(/"/g, '&quot;') + '" alt="gif" />');
    setGifUrl(''); setGifOpen(false);
  }
  function addGifFromLink() { insertGif(PRQ.giphyImg(gifUrl)); }

  async function loadTrending() {
    setGifLoading(true); setGifError(false);
    try { setGifResults(await API.gifTrending()); }
    catch (e) { setGifError(true); }
    finally { setGifLoading(false); }
  }
  async function runSearch(q) {
    if (!q.trim()) { loadTrending(); return; }
    setGifLoading(true); setGifError(false);
    try { setGifResults(await API.gifSearch(q.trim())); }
    catch (e) { setGifError(true); }
    finally { setGifLoading(false); }
  }
  function onGifQuery(v) {
    setGifQuery(v);
    clearTimeout(searchTimer.current);
    searchTimer.current = setTimeout(() => runSearch(v), 350);
  }
  function toggleGif(e) {
    e.preventDefault();
    const open = !gifOpen;
    setGifOpen(open);
    if (open) {
      const m = gifAvail ? 'browse' : 'link';
      setGifMode(m);
      if (m === 'browse' && gifResults.length === 0) loadTrending();
    }
  }
  function submit() {
    const clean = PRQ.sanitizeHTML(edRef.current.innerHTML);
    const probe = document.createElement('div'); probe.innerHTML = clean;
    const hasContent = (probe.textContent || '').trim() || probe.querySelector('img');
    if (!hasContent) return;
    onSubmit(clean);
    edRef.current.innerHTML = '';
    savedRange.current = null;
  }
  const tb = (e, fn) => { e.preventDefault(); fn(); };
  return (
    <div className="editor">
      <div className="editor-tb">
        <button type="button" className="tb-btn" title="Gras" onMouseDown={(e) => tb(e, () => cmd('bold'))}><Icon d={ICONS.bold} size={14} sw={1.6} /></button>
        <button type="button" className="tb-btn" title="Italique" onMouseDown={(e) => tb(e, () => cmd('italic'))}><Icon d={ICONS.italic} size={14} sw={1.7} /></button>
        <button type="button" className="tb-btn" title="Liste à puces" onMouseDown={(e) => tb(e, () => cmd('insertUnorderedList'))}><Icon d={ICONS.list} size={14} sw={1.7} /></button>
        <button type="button" className="tb-btn" title="Lien" onMouseDown={addLink}><Icon d={ICONS.link} size={14} sw={1.7} /></button>
        <span className="tb-sep" />
        <button type="button" className={'tb-btn tb-gif' + (gifOpen ? ' on' : '')} title="Insérer un GIF"
          onMouseDown={toggleGif}>GIF</button>
      </div>
      {gifOpen && (
        <div className="gif-panel">
          <div className="gif-head">
            {gifMode === 'browse' ? (
              <input className="gif-input" value={gifQuery} autoFocus
                placeholder="Rechercher un GIF…"
                onChange={(e) => onGifQuery(e.target.value)} />
            ) : (
              <>
                <input className="gif-input" value={gifUrl} autoFocus
                  placeholder="Collez un lien Giphy (giphy.com/gifs/…) ou une URL .gif"
                  onChange={(e) => setGifUrl(e.target.value)}
                  onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addGifFromLink(); } }} />
                <button type="button" className="btn btn-ghost" onMouseDown={(e) => e.preventDefault()}
                  onClick={addGifFromLink} disabled={!gifUrl.trim()}>Insérer</button>
              </>
            )}
            {gifAvail && (
              <button type="button" className="gif-switch" onMouseDown={(e) => e.preventDefault()}
                onClick={() => { setGifMode((m) => (m === 'browse' ? 'link' : 'browse')); }}>
                {gifMode === 'browse' ? 'Lien' : 'Rechercher'}
              </button>
            )}
          </div>
          {gifMode === 'browse' && (
            <>
              {gifLoading ? <div className="gif-state">Chargement…</div>
                : gifError ? <div className="gif-state">Recherche indisponible.</div>
                : gifResults.length === 0 ? <div className="gif-state">Aucun résultat.</div>
                : (
                  <div className="gif-grid">
                    {gifResults.map((g) => (
                      <button type="button" key={g.id} className="gif-cell" title={g.title}
                        onMouseDown={(e) => e.preventDefault()} onClick={() => insertGif(g.url)}>
                        <img src={g.preview} alt={g.title} loading="lazy" />
                      </button>
                    ))}
                  </div>
                )}
              <div className="gif-powered">Propulsé par GIPHY</div>
            </>
          )}
          {gifMode === 'link' && !gifAvail && (
            <div className="gif-note">Recherche de GIF non configurée — collez un lien à la place.</div>
          )}
        </div>
      )}
      <div ref={edRef} className="editor-area" contentEditable suppressContentEditableWarning
        onKeyUp={saveRange} onMouseUp={saveRange} onBlur={saveRange} data-ph="Écrire un commentaire…" />
      <div className="editor-foot">
        <button type="button" className="btn btn-primary" onClick={submit}>Commenter</button>
      </div>
    </div>
  );
}

/* ----------------------------- comments modal ----------------------------- */
function CommentsModal({ pr, user, onAdd, onDelete, onClose }) {
  const comments = pr.comments || [];
  const listRef = useRef(null);
  useEffect(() => {
    if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
  }, [comments.length]);
  return (
    <div className="modal-scrim" onMouseDown={onClose}>
      <div className="modal wide fade-in" onMouseDown={(e) => e.stopPropagation()}>
        <div className="cmodal-head">
          <div>
            <h2>Commentaires</h2>
            <p><span className="mono">{pr.kind} #{pr.number || '?'}</span> — {pr.repo}</p>
          </div>
          <button className="btn btn-icon" onClick={onClose} title="Fermer"><Icon d={ICONS.x} size={16} /></button>
        </div>
        <div className="comment-list" ref={listRef}>
          {comments.length === 0
            ? <div className="comment-empty">Aucun commentaire pour l'instant.</div>
            : comments.map((c) => (
              <div className="comment" key={c.id}>
                <Avatar name={c.author} size="sm" />
                <div className="comment-main">
                  <div className="comment-meta">
                    <strong>{c.author}</strong><span>·</span><span>{PRQ.timeAgo(c.createdAt)}</span>
                    {c.author === user && (
                      <button className="comment-del" title="Supprimer mon commentaire" onClick={() => onDelete(c.id)}>
                        <Icon d={ICONS.trash} size={13} sw={1.7} />
                      </button>
                    )}
                  </div>
                  <div className="comment-body" dangerouslySetInnerHTML={{ __html: PRQ.sanitizeHTML(c.html) }} />
                </div>
              </div>
            ))}
        </div>
        <RichEditor onSubmit={onAdd} />
      </div>
    </div>
  );
}

/* ----------------------------- notifications toggle ----------------------------- */
function NotifToggle({ state, mode, onToggle }) {
  const { supported, permission, enabled } = state;
  let dot, label, title;
  if (!supported) {
    dot = 'off'; label = 'Notifications';
    title = 'Notifications non supportées par ce navigateur';
  } else if (permission === 'denied') {
    dot = 'denied'; label = 'Notifications';
    title = 'Notifications bloquées — réactivez-les dans les réglages du navigateur';
  } else if (enabled) {
    dot = 'on'; label = 'Notifications';
    title = 'Notifications activées — cliquez pour couper';
  } else if (permission === 'granted') {
    dot = 'muted'; label = 'Notifications';
    title = 'Notifications en sourdine — cliquez pour réactiver';
  } else {
    dot = 'default'; label = 'Activer les notifications';
    title = 'Cliquez pour autoriser les notifications desktop';
  }
  // Les notifs ne se déclenchent qu'en mode serveur ; on le signale en démo.
  if (supported && permission !== 'denied' && mode !== 'server') {
    title += ' · actives en mode serveur';
  }
  return (
    <button className={'btn btn-ghost notif-toggle' + (enabled ? ' on' : '')}
      onClick={onToggle} disabled={!supported} title={title}>
      <Icon d={ICONS.bell} size={15} sw={1.7} />
      <span className="notif-label">{label}</span>
      <span className={'notif-dot ' + dot} />
    </button>
  );
}

/* ----------------------------- appearance menu ----------------------------- */
const THEMES = [
  { v: 'slate', l: 'Slate', bg: '#ffffff', ac: '#4f5bd5' },
  { v: 'midnight', l: 'Midnight', bg: '#151926', ac: '#6ea8fe' },
  { v: 'paper', l: 'Paper', bg: '#faf7f0', ac: '#c2552f' },
  { v: 'terminal', l: 'Terminal', bg: '#0a1810', ac: '#46e08a' },
];
const ACCENTS = ['#4f5bd5', '#2f6df0', '#7a5af0', '#0f9d8a', '#d2542f'];

function AppearanceMenu({ appr, set }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    function onDoc(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
    if (open) document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [open]);
  return (
    <div className="appr" ref={ref}>
      <button className={'btn btn-ghost appr-btn' + (open ? ' on' : '')} onClick={() => setOpen((o) => !o)}>
        <Icon d={ICONS.sliders} size={15} sw={1.7} /> Apparence
      </button>
      {open && (
        <div className="appr-pop fade-in">
          <p className="appr-sec">Thème</p>
          <div className="theme-grid">
            {THEMES.map((th) => (
              <div key={th.v} className={'theme-opt' + (appr.theme === th.v ? ' active' : '')}
                onClick={() => set('theme', th.v)}>
                <span className="theme-swatch" style={{ background: th.bg }}><i style={{ background: th.ac }} /></span>
                <span>{th.l}</span>
              </div>
            ))}
          </div>
          <p className="appr-sec">Couleur d'accent</p>
          <div className="accent-row">
            {ACCENTS.map((c) => (
              <button key={c} className={'accent-dot' + (appr.accent === c ? ' active' : '')}
                style={{ background: c }} onClick={() => set('accent', c)} title={c} />
            ))}
          </div>
          <p className="appr-sec">Disposition</p>
          <Seg value={appr.layout} onChange={(v) => set('layout', v)} options={[
            { v: 'cards', l: 'Cartes' }, { v: 'table', l: 'Tableau' }, { v: 'board', l: 'Board' },
          ]} />
        </div>
      )}
    </div>
  );
}

/* ----------------------------- version select ----------------------------- */
function VersionSelect({ value, onChange, variant }) {
  const VERSIONS = PRQ.productVersions();
  const [other, setOther] = useState((value || '') !== '' && !VERSIONS.includes(value));
  const selVal = other ? '__other__' : (VERSIONS.includes(value) ? value : '');
  function onSel(e) {
    const v = e.target.value;
    if (v === '__other__') { setOther(true); } // keep current text for editing
    else { setOther(false); onChange(v); }
  }
  return (
    <div className={'version-select ' + (variant === 'bar' ? 'ver-bar' : 'ver-modal')}>
      <select className="ver-sel" value={selVal} onChange={onSel}>
        <option value="">(Vide)</option>
        {VERSIONS.map((v) => <option key={v} value={v}>{v}</option>)}
        <option value="__other__">Autre…</option>
      </select>
      {other && (
        <input className="ver-other" value={value} placeholder="Version personnalisée…"
          onChange={(e) => onChange(e.target.value)} />
      )}
    </div>
  );
}

/* ----------------------------- add bar ----------------------------- */
function AddBar({ onAdd }) {
  const [url, setUrl] = useState('');
  const [ver, setVer] = useState('');
  const [verKey, setVerKey] = useState(0); // bump to reset VersionSelect state
  const [dup, setDup] = useState(false);
  const parsed = useMemo(() => PRQ.parsePR(url), [url]);
  const valid = url.trim() && parsed.ok;
  function submit(e) {
    e.preventDefault();
    if (!valid) return;
    const res = onAdd(url.trim(), ver.trim());
    if (res && res.ok === false && res.reason === 'duplicate') { setDup(true); return; }
    setUrl(''); setVer(''); setVerKey((k) => k + 1); setDup(false);
  }
  function onUrl(e) { setUrl(e.target.value); if (dup) setDup(false); }
  return (
    <div className="addbar">
      <form className="addrow" onSubmit={submit}>
        <input className="url-input" value={url} onChange={onUrl}
          placeholder="Collez le lien d'une pull request…  github.com/org/repo/pull/123" />
        <VersionSelect key={verKey} value={ver} onChange={setVer} variant="bar" />
        <button className="btn btn-primary" disabled={!valid}>
          <Icon d="M12 5v14 M5 12h14" size={16} /> Ajouter
        </button>
      </form>
      <div className="addhint">
        {url.trim() === '' && <span style={{ color: 'var(--ink-3)' }}>Le repo et le numéro sont déduits automatiquement du lien.</span>}
        {url.trim() !== '' && valid && !dup && (
          <span className="ok">
            <Icon d={ICONS.check} size={14} /> <code>{parsed.repo}</code> · {parsed.kind} #{parsed.number || '—'}
          </span>
        )}
        {url.trim() !== '' && !valid && <span className="err">Lien non reconnu — collez une URL de PR/MR complète.</span>}
        {dup && <span className="err">Cette PR est déjà dans la file.</span>}
      </div>
    </div>
  );
}

/* ----------------------------- toolbar ----------------------------- */
function Seg({ value, onChange, options }) {
  return (
    <div className="seg">
      {options.map((o) => (
        <button key={o.v} className={value === o.v ? 'active' : ''} onClick={() => onChange(o.v)}>{o.l}</button>
      ))}
    </div>
  );
}

function Toolbar({ groupBy, setGroupBy, filter, setFilter, version, setVersion, versions, query, setQuery, count }) {
  return (
    <div className="toolbar">
      <div className="tool-group">
        <span className="tool-label">Grouper</span>
        <Seg value={groupBy} onChange={setGroupBy} options={[
          { v: 'repo', l: 'Repo' }, { v: 'assignee', l: 'Assigné' },
          { v: 'sender', l: 'Sender' }, { v: 'review', l: 'Statut' }, { v: 'none', l: 'Aucun' },
        ]} />
      </div>
      <div className="tool-group">
        <span className="tool-label">Filtrer</span>
        <Seg value={filter} onChange={setFilter} options={[
          { v: 'all', l: 'Toutes' }, { v: 'unassigned', l: 'Non assignées' },
          { v: 'assigned', l: 'Assignées' }, { v: 'mine', l: 'Les miennes' },
        ]} />
      </div>
      {versions.length > 0 && (
        <select className="select" value={version} onChange={(e) => setVersion(e.target.value)}>
          <option value="all">Toutes versions</option>
          {versions.map((v) => <option key={v} value={v}>{v}</option>)}
        </select>
      )}
      <div className="search">
        <Icon d={ICONS.search} size={15} />
        <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Rechercher repo, sender…" />
      </div>
      <span className="count-pill">{count} ouverte{count > 1 ? 's' : ''}</span>
    </div>
  );
}

/* ----------------------------- PR card ----------------------------- */
function PRCard({ pr, user, actions }) {
  const mine = pr.assignees.includes(user);
  const cls = 'prcard fade-in' + (mine ? ' mine' : (pr.assignees.length === 0 ? ' unassigned' : ''));
  return (
    <div className={cls}>
      <div className="pr-top">
        <div className="pr-id">
          <span className="pr-repo" title={pr.repo}>{pr.repo}</span>
          <a className="pr-num" href={pr.url} target="_blank" rel="noreferrer">
            <span className="kind-tag">{pr.kind}</span>#{pr.number || '?'}
            <span className="arrow"><Icon d={ICONS.arrow} size={13} /></span>
          </a>
        </div>
        <div className="pr-tags">
          {pr.version ? <span className="badge"><span className="dot" />{pr.version}</span> : null}
          {pr.activity ? <a className="badge muted badge-link" href={PRQ.activityUrl(pr.activity)} target="_blank" rel="noreferrer" title="Ouvrir l'activité dans IGE-XAO">#{pr.activity}</a> : null}
        </div>
      </div>

      <div className="pr-meta">
        <span className="pr-sender"><Avatar name={pr.sender} size="sm" /> par <strong>{pr.sender}</strong></span>
        <span>·</span>
        <span>{PRQ.timeAgo(pr.createdAt)}</span>
        <span className="pr-meta-spacer" />
        <StatusControl pr={pr} onChange={(v) => actions.setReview(pr.id, v)} />
      </div>

      <div className="pr-bottom">
        <div className="assignees">
          {pr.assignees.length === 0
            ? <span className="none">Non assignée</span>
            : (
              <div className="avatar-stack">
                {pr.assignees.map((a) => (
                  <span key={a} className={'assignee-chip' + (a === user ? ' me' : '')}>
                    <Avatar name={a} size="md" title={a} />
                    {a === user && <span className="x" onClick={() => actions.leave(pr.id)}>✕</span>}
                  </span>
                ))}
              </div>
            )}
        </div>
        <div className="pr-actions">
          {pr.status === 'open' && !mine && <button className="btn btn-take" onClick={() => actions.take(pr.id)}>Je prends</button>}
          {pr.status === 'open' && mine && <button className="btn btn-ghost" onClick={() => actions.leave(pr.id)}>Me retirer</button>}
          <button className="btn btn-icon comment-btn" title="Commentaires" onClick={() => actions.openComments(pr)}>
            <Icon d={ICONS.comment} size={15} sw={1.6} />
            {pr.comments && pr.comments.length ? <span className="cc">{pr.comments.length}</span> : null}
          </button>
          {pr.status === 'open' ? (
            <>
              <button className="btn btn-icon" title="Modifier le produit / version" onClick={() => actions.edit(pr)}>
                <Icon d={ICONS.edit} size={14} sw={1.7} />
              </button>
              <button className="btn btn-icon" title="Marquer mergée / fermée" onClick={() => actions.close(pr.id)}>
                <Icon d={ICONS.archive} size={15} sw={1.6} />
              </button>
            </>
          ) : (
            <>
              <button className="btn btn-ghost" onClick={() => actions.reopen(pr.id)}>
                <Icon d={ICONS.reopen} size={14} /> Rouvrir
              </button>
              <button className="btn btn-icon danger" title="Supprimer définitivement" onClick={() => actions.del(pr.id)}>
                <Icon d={ICONS.x} size={15} />
              </button>
            </>
          )}
        </div>
      </div>
    </div>
  );
}

/* ----------------------------- grouping ----------------------------- */
function groupKeyLabel(groupBy, pr) {
  if (groupBy === 'repo') return pr.repo;
  if (groupBy === 'sender') return pr.sender;
  if (groupBy === 'review') return pr.review || REVIEW_DEFAULT;
  return null;
}
function buildGroups(prs, groupBy) {
  if (groupBy === 'none') return [{ key: '__all', label: null, prs }];
  const map = new Map();
  if (groupBy === 'assignee') {
    prs.forEach((pr) => {
      if (pr.assignees.length === 0) {
        if (!map.has('__none')) map.set('__none', []);
        map.get('__none').push(pr);
      } else {
        pr.assignees.forEach((a) => {
          if (!map.has(a)) map.set(a, []);
          map.get(a).push(pr);
        });
      }
    });
  } else {
    prs.forEach((pr) => {
      const k = groupKeyLabel(groupBy, pr);
      if (!map.has(k)) map.set(k, []);
      map.get(k).push(pr);
    });
  }
  let groups = [...map.entries()].map(([key, list]) => ({ key, label: key, prs: list }));
  if (groupBy === 'review') {
    // fixed, meaningful order: À revoir → À corriger → Bloquée → Validée
    groups.sort((a, b) => (REVIEW_ORDER[a.key] ?? 99) - (REVIEW_ORDER[b.key] ?? 99));
    return groups;
  }
  groups.sort((a, b) => {
    if (a.key === '__none') return -1;
    if (b.key === '__none') return 1;
    if (b.prs.length !== a.prs.length) return b.prs.length - a.prs.length;
    return String(a.label).localeCompare(String(b.label));
  });
  return groups;
}

function GroupHead({ groupBy, group, user }) {
  const isNone = group.key === '__none';
  return (
    <div className="group-head">
      <div className="group-title">
        {groupBy === 'repo' && <span className="mono">{group.label}</span>}
        {groupBy === 'sender' && <><Avatar name={group.label} size="sm" /><span>{group.label}</span></>}
        {groupBy === 'review' && <span className={'status-select ' + reviewMeta(group.label).cls} style={{ pointerEvents: 'none' }}>{reviewMeta(group.label).l}</span>}
        {groupBy === 'assignee' && (isNone
          ? <span style={{ color: 'var(--ink-2)' }}>Non assignées</span>
          : <><Avatar name={group.label} size="sm" /><span>{group.label}{group.label === user ? ' (vous)' : ''}</span></>)}
      </div>
      <span className="group-count">{group.prs.length}</span>
      <span className="group-rule" />
    </div>
  );
}

/* ----------------------------- layouts ----------------------------- */
function CardsLayout({ groups, groupBy, user, actions }) {
  return groups.map((g) => (
    <div className="group" key={g.key}>
      {g.label !== null && <GroupHead groupBy={groupBy} group={g} user={user} />}
      <div className="cards">
        {g.prs.map((pr) => <PRCard key={pr.id + (pr.assignees.join(','))} pr={pr} user={user} actions={actions} />)}
      </div>
    </div>
  ));
}

function TableLayout({ groups, groupBy, user, actions }) {
  return (
    <table className="prtable">
      <thead>
        <tr>
          <th style={{ width: '32%' }}>Pull request</th>
          <th>Version</th>
          <th>Statut</th>
          <th>Sender</th>
          <th>Assignés</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        {groups.map((g) => (
          <React.Fragment key={g.key}>
            {g.label !== null && (
              <tr className="grouprow">
                <td colSpan={6}>
                  <span className="gr">
                    {groupBy === 'repo' && <span style={{ fontFamily: 'var(--font-mono)' }}>{g.label}</span>}
                    {groupBy === 'review' && <span className={'status-select ' + reviewMeta(g.label).cls} style={{ pointerEvents: 'none' }}>{reviewMeta(g.label).l}</span>}
                    {groupBy !== 'repo' && groupBy !== 'review' && (g.key === '__none' ? 'Non assignées' : <><Avatar name={g.label} size="sm" />{g.label}</>)}
                    <span className="group-count">{g.prs.length}</span>
                  </span>
                </td>
              </tr>
            )}
            {g.prs.map((pr) => {
              const mine = pr.assignees.includes(user);
              return (
                <tr key={pr.id} className={mine ? 'mine' : ''}>
                  <td>
                    <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
                      <a className="t-num" href={pr.url} target="_blank" rel="noreferrer">{pr.kind} #{pr.number || '?'}</a>
                      <span className="t-repo">{pr.repo}</span>
                    </div>
                  </td>
                  <td>
                    {pr.version || pr.activity ? (
                      <div className="cell-tags">
                        {pr.version ? <span className="badge"><span className="dot" />{pr.version}</span> : null}
                        {pr.activity ? <a className="badge muted badge-link" href={PRQ.activityUrl(pr.activity)} target="_blank" rel="noreferrer" title="Ouvrir l'activité dans IGE-XAO">#{pr.activity}</a> : null}
                      </div>
                    ) : <span style={{ color: 'var(--ink-3)' }}>—</span>}
                  </td>
                  <td><StatusControl pr={pr} onChange={(v) => actions.setReview(pr.id, v)} /></td>
                  <td><span className="pr-sender"><Avatar name={pr.sender} size="sm" />{pr.sender}</span></td>
                  <td>
                    {pr.assignees.length === 0 ? <span style={{ color: 'var(--ink-3)', fontStyle: 'italic', fontSize: 12.5 }}>—</span>
                      : <div className="avatar-stack">{pr.assignees.map((a) => <Avatar key={a} name={a} size="md" />)}</div>}
                  </td>
                  <td className="td-actions">
                    <div style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
                      {pr.status === 'open' && (!mine
                        ? <button className="btn btn-take" onClick={() => actions.take(pr.id)}>Je prends</button>
                        : <button className="btn btn-ghost" onClick={() => actions.leave(pr.id)}>Me retirer</button>)}
                      <button className="btn btn-icon comment-btn" title="Commentaires" onClick={() => actions.openComments(pr)}>
                        <Icon d={ICONS.comment} size={15} sw={1.6} />
                        {pr.comments && pr.comments.length ? <span className="cc">{pr.comments.length}</span> : null}
                      </button>
                      {pr.status === 'open' ? (
                        <div style={{ display: 'inline-flex', gap: 6 }}>
                          <button className="btn btn-icon" title="Modifier produit / activité" onClick={() => actions.edit(pr)}><Icon d={ICONS.edit} size={14} sw={1.7} /></button>
                          <button className="btn btn-icon" title="Mergée / fermée" onClick={() => actions.close(pr.id)}><Icon d={ICONS.archive} size={15} sw={1.6} /></button>
                        </div>
                      ) : (
                        <div style={{ display: 'inline-flex', gap: 6 }}>
                          <button className="btn btn-ghost" onClick={() => actions.reopen(pr.id)}>Rouvrir</button>
                          <button className="btn btn-icon danger" onClick={() => actions.del(pr.id)}><Icon d={ICONS.x} size={15} /></button>
                        </div>
                      )}
                    </div>
                  </td>
                </tr>
              );
            })}
          </React.Fragment>
        ))}
      </tbody>
    </table>
  );
}

function BoardLayout({ prs, groups, groupBy, user, actions }) {
  let cols;
  if (groupBy === 'none') {
    cols = [
      { key: 'u', label: 'Non assignées', prs: prs.filter((p) => p.assignees.length === 0) },
      { key: 'a', label: 'Assignées', prs: prs.filter((p) => p.assignees.length > 0) },
    ];
  } else {
    cols = groups.map((g) => ({
      key: g.key,
      label: g.key === '__none' ? 'Non assignées' : (groupBy === 'review' ? reviewMeta(g.label).l : g.label),
      isAvatar: groupBy !== 'repo' && groupBy !== 'review' && g.key !== '__none',
      isReview: groupBy === 'review',
      reviewCls: groupBy === 'review' ? reviewMeta(g.label).cls : '',
      prs: g.prs,
    }));
  }
  return (
    <div className="board">
      {cols.map((c) => (
        <div className="board-col" key={c.key}>
          <div className="board-col-head">
            {c.isAvatar && <Avatar name={c.label} size="sm" />}
            {c.isReview
              ? <span className={'status-select ' + c.reviewCls} style={{ pointerEvents: 'none' }}>{c.label}</span>
              : <span className={groupBy === 'repo' ? 'mono' : ''}>{c.label}</span>}
            <span className="group-count">{c.prs.length}</span>
          </div>
          {c.prs.length === 0
            ? <div className="board-empty">Aucune PR</div>
            : c.prs.map((pr) => <PRCard key={pr.id + pr.assignees.join(',')} pr={pr} user={user} actions={actions} />)}
        </div>
      ))}
    </div>
  );
}

/* ----------------------------- app ----------------------------- */
const APPR_KEY = 'prq:appearance:v1';
const APPR_DEFAULT = { theme: 'slate', accent: '#4f5bd5', layout: 'cards' };

function App() {
  const [appr, setApprState] = useState(() => ({ ...APPR_DEFAULT, ...PRQ.load(APPR_KEY, {}) }));
  function setAppr(k, v) { setApprState((a) => { const n = { ...a, [k]: v }; PRQ.save(APPR_KEY, n); return n; }); }

  const [user, setUser] = useState(() => PRQ.load(PRQ.KEYS.user, null));
  const [editUser, setEditUser] = useState(false);
  const [editPr, setEditPr] = useState(null); // PR whose product/version is being edited
  const [commentPrId, setCommentPrId] = useState(null); // PR whose comments thread is open
  const [prs, setPrs] = useState([]);
  const [mode, setMode] = useState('demo');
  const [loading, setLoading] = useState(true);

  const prsRef = useRef(prs);
  useEffect(() => { prsRef.current = prs; }, [prs]);

  // current user kept in a ref so the SSE handler reads it fresh without resubscribing
  const userRef = useRef(user);
  useEffect(() => { userRef.current = user; }, [user]);

  // desktop notifications state (permission / enabled), surfaced in the topbar
  const [notif, setNotif] = useState(() => (
    window.Notify ? Notify.snapshot() : { supported: false, permission: 'unsupported', pref: false, enabled: false }
  ));
  // re-read permission when the tab regains focus (it may have changed in browser settings)
  useEffect(() => {
    if (!window.Notify) return;
    const onFocus = () => setNotif(Notify.snapshot());
    window.addEventListener('focus', onFocus);
    return () => window.removeEventListener('focus', onFocus);
  }, []);
  async function toggleNotif() {
    if (!window.Notify || !notif.supported) return;
    const s = notif.enabled ? Notify.disable() : await Notify.enable();
    setNotif(s);
  }

  const [groupBy, setGroupBy] = useState('repo');
  const [filter, setFilter] = useState('all');
  const [version, setVersion] = useState('all');
  const [query, setQuery] = useState('');
  const [showClosed, setShowClosed] = useState(false);

  // initial load: detect backend. In demo mode we read the local list now; in
  // server mode the SSE subscription (below) delivers the first snapshot.
  useEffect(() => {
    let alive = true;
    (async () => {
      await API.detect();
      if (!alive) return;
      const m = API.getMode();
      setMode(m);
      if (m !== 'server') {
        const list = await API.list();
        if (!alive) return;
        setPrs(Array.isArray(list) ? list : []);
        setLoading(false);
      }
    })();
    return () => { alive = false; };
  }, []);

  // live updates via SSE (replaces polling). First message primes the notify
  // baseline (no flood); later messages are diffed for desktop notifications.
  useEffect(() => {
    if (mode !== 'server') return;
    if (typeof EventSource === 'undefined') {
      // very old browser: no live updates, just one fetch
      API.list().then((l) => {
        if (Array.isArray(l)) { setPrs(l); if (window.Notify) Notify.prime(l); }
        setLoading(false);
      }).catch(() => setLoading(false));
      return;
    }
    let first = true;
    const unsub = API.subscribe((list) => {
      if (!Array.isArray(list)) return;
      setPrs(list);
      if (first) {
        first = false;
        if (window.Notify) Notify.prime(list);
        setLoading(false);
      } else if (window.Notify) {
        Notify.review(list, userRef.current);
      }
    });
    return unsub;
  }, [mode]);

  useEffect(() => { if (user) PRQ.save(PRQ.KEYS.user, user); }, [user]);

  const accentInk = inkFor(appr.accent);
  const rootStyle = {
    '--accent': appr.accent,
    '--accent-ink': accentInk,
    '--accent-soft': `color-mix(in srgb, ${appr.accent} 16%, var(--panel))`,
  };

  function mutate(id, fn) {
    const cur = prsRef.current;
    const target = cur.find((x) => x.id === id);
    if (!target) return;
    const next = fn(target);
    setPrs(cur.map((x) => (x.id === id ? next : x)));
    API.update(id, next).catch(() => {});
  }
  const actions = useMemo(() => ({
    take: (id) => mutate(id, (x) => (x.assignees.includes(user) ? x : { ...x, assignees: [...x.assignees, user] })),
    leave: (id) => mutate(id, (x) => ({ ...x, assignees: x.assignees.filter((a) => a !== user) })),
    close: (id) => mutate(id, (x) => ({ ...x, status: 'closed' })),
    reopen: (id) => mutate(id, (x) => ({ ...x, status: 'open' })),
    setMeta: (id, patch) => mutate(id, (x) => ({ ...x, ...patch })),
    setReview: (id, review) => mutate(id, (x) => ({ ...x, review, reviewBy: user, reviewAt: Date.now() })),
    edit: (pr) => setEditPr(pr),
    openComments: (pr) => setCommentPrId(pr.id),
    addComment: (prId, html) => {
      const c = { id: PRQ.uid(), author: user, html, createdAt: Date.now() };
      setPrs(prsRef.current.map((p) => (p.id === prId ? { ...p, comments: [...(p.comments || []), c] } : p)));
      API.addComment(prId, c).catch(() => {
        // persistence failed → roll back the optimistic comment
        setPrs(prsRef.current.map((p) => (p.id === prId ? { ...p, comments: (p.comments || []).filter((x) => x.id !== c.id) } : p)));
      });
    },
    deleteComment: (prId, cid) => {
      setPrs(prsRef.current.map((p) => (p.id === prId ? { ...p, comments: (p.comments || []).filter((c) => c.id !== cid) } : p)));
      API.deleteComment(prId, cid).catch(() => {});
    },
    del: (id) => { setPrs(prsRef.current.filter((x) => x.id !== id)); API.remove(id).catch(() => {}); },
    clearClosed: () => {
      setPrs(prsRef.current.filter((x) => x.status !== 'closed'));
      API.clearClosed().catch(() => {});
    },
  }), [user]);

  function addPR(url, ver) {
    const parsed = PRQ.parsePR(url);
    if (!parsed.ok) return { ok: false, reason: 'invalid' };
    const pr = {
      id: PRQ.uid(), url, host: parsed.host, repo: parsed.repo, number: parsed.number,
      kind: parsed.kind, sender: user, version: ver || '', assignees: [], status: 'open', review: REVIEW_DEFAULT, createdAt: Date.now(),
    };
    // don't queue the same PR twice (matches on host/repo/number)
    if (prsRef.current.some((x) => PRQ.samePR(x, pr))) return { ok: false, reason: 'duplicate' };
    setPrs([pr, ...prsRef.current]); // optimistic
    API.create(pr).then((res) => {
      // server-side de-dupe (race between two clients): roll back our add
      if (res && res.ok === false && res.reason === 'duplicate') {
        setPrs((cur) => cur.filter((x) => x.id !== pr.id));
      }
    }).catch(() => {});
    return { ok: true };
  }

  /* derive */
  const commentPr = commentPrId ? prs.find((p) => p.id === commentPrId) : null;
  const openPrs = prs.filter((p) => p.status === 'open');
  const closedCount = prs.filter((p) => p.status === 'closed').length;
  const versions = useMemo(() => [...new Set(openPrs.map((p) => p.version).filter(Boolean))].sort(), [prs]);

  const base = showClosed ? prs.filter((p) => p.status === 'closed') : openPrs;
  const filtered = base.filter((pr) => {
    if (!showClosed) {
      if (filter === 'unassigned' && pr.assignees.length !== 0) return false;
      if (filter === 'assigned' && pr.assignees.length === 0) return false;
      if (filter === 'mine' && !pr.assignees.includes(user)) return false;
    }
    if (version !== 'all' && pr.version !== version) return false;
    if (query.trim()) {
      const q = query.toLowerCase();
      const hay = (pr.repo + ' ' + pr.sender + ' ' + pr.version + ' ' + pr.number + ' ' + pr.assignees.join(' ')).toLowerCase();
      if (!hay.includes(q)) return false;
    }
    return true;
  });

  const sorted = [...filtered].sort((a, b) => b.createdAt - a.createdAt);
  const groups = buildGroups(sorted, groupBy);

  if (!user) return <Onboarding onSave={(u) => { setUser(u); }} />;

  return (
    <div className="app-root" data-theme={appr.theme} style={rootStyle}>
      {editUser && <Onboarding initial={user} onSave={(u) => { setUser(u); setEditUser(false); }} />}
      {editPr && (
        <DetailsModal
          pr={editPr}
          onClose={() => setEditPr(null)}
          onSave={(patch) => { actions.setMeta(editPr.id, patch); setEditPr(null); }}
        />
      )}
      {commentPr && (
        <CommentsModal
          pr={commentPr}
          user={user}
          onClose={() => setCommentPrId(null)}
          onAdd={(html) => actions.addComment(commentPr.id, html)}
          onDelete={(cid) => actions.deleteComment(commentPr.id, cid)}
        />
      )}

      <div className="topbar">
        <div className="wrap topbar-inner">
          <div className="brand">
            <div className="brand-mark">PR</div>
            <div>
              <div className="brand-name">PR Queue</div>
              <div className="brand-sub">{openPrs.length} en attente de revue</div>
            </div>
          </div>
          <div className="topbar-spacer" />
          <span className={'conn ' + mode} title={mode === 'server' ? 'Connecté au serveur — file partagée' : 'Mode démo — données locales à ce navigateur'}>
            <span className="d" />{mode === 'server' ? 'Serveur' : 'Démo local'}
          </span>
          <NotifToggle state={notif} mode={mode} onToggle={toggleNotif} />
          <AppearanceMenu appr={appr} set={setAppr} />
          <div className="userchip" onClick={() => setEditUser(true)} title="Changer d'utilisateur">
            <Avatar name={user} size="md" />
            <span className="nm">{user}</span>
          </div>
        </div>
      </div>

      <div className="wrap">
        <AddBar onAdd={addPR} />
        <Toolbar
          groupBy={groupBy} setGroupBy={setGroupBy}
          filter={filter} setFilter={setFilter}
          version={version} setVersion={setVersion} versions={versions}
          query={query} setQuery={setQuery}
          count={openPrs.length}
        />

        <div className="content">
          {loading ? (
            <div className="empty"><div className="big">Chargement…</div></div>
          ) : sorted.length === 0 ? (
            <div className="empty">
              <div className="big">{showClosed ? 'Aucune PR fermée' : 'Rien à revoir ici'}</div>
              <div>{showClosed ? '' : 'Collez un lien de PR ci-dessus pour démarrer la file.'}</div>
            </div>
          ) : appr.layout === 'table' ? (
            <TableLayout groups={groups} groupBy={groupBy} user={user} actions={actions} />
          ) : appr.layout === 'board' ? (
            <BoardLayout prs={sorted} groups={groups} groupBy={groupBy} user={user} actions={actions} />
          ) : (
            <CardsLayout groups={groups} groupBy={groupBy} user={user} actions={actions} />
          )}

          {closedCount > 0 && (
            <div className="closed-bar">
              <button className="btn btn-ghost" onClick={() => setShowClosed((s) => !s)}>
                {showClosed ? '← Revenir aux PR ouvertes' : `Voir les PR fermées (${closedCount})`}
              </button>
              {showClosed && (
                <button className="btn btn-ghost danger" onClick={() => {
                  if (window.confirm(`Supprimer définitivement ${closedCount} PR fermée${closedCount > 1 ? 's' : ''} ? Cette action est irréversible.`)) {
                    actions.clearClosed();
                    setShowClosed(false);
                  }
                }}>
                  <Icon d={ICONS.x} size={14} /> Supprimer les PR fermées
                </button>
              )}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
