// qbt-app.jsx — flat dashboard: floating rail, pills, list, fixed detail panel
const { useState, useEffect, useCallback, useMemo, useRef } = React;
const { fmtB, fmtSp, fmtSpShort, fmtETA, fmtDate, fmtDur, fmtFlag, fmtRatio, stOf, normState, Ic,
        ChartCard, LoginScreen, AddModal, SpeedModal, DeleteModal } = window;

// Sortable columns for the torrents list (key + value accessor)
const LIST_COLS = [
  { key:'name',     label:'Nom',          get:t => (t.name||'').toLowerCase() },
  { key:'progress', label:'Progression',  get:t => t.progress },
  { key:'size',     label:'Taille',       get:t => t.size },
  { key:'speed',    label:'Vitesse',      get:t => Math.max(t.dlspeed, t.upspeed) },
  { key:'peers',    label:'Peers',        get:t => (t.num_seeds||0) + (t.num_leechs||0) },
  { key:'ratio',    label:'Ratio',        get:t => t.ratio },
];

// ─── PERSISTENT SPEED HISTORY ─────────────────────────────────────────────────
const HIST_KEY = 'qbt_speedhist_v1';
const RANGE_SEC = { '1h': 3600, '1j': 86400, '7j': 604800, 'all': Infinity };
const loadHist = () => { try { const a = JSON.parse(localStorage.getItem(HIST_KEY) || '[]'); return Array.isArray(a) ? a.map(([t, dl, ul]) => ({ t, dl, ul })) : []; } catch { return []; } };
const saveHist = a => { try { localStorage.setItem(HIST_KEY, JSON.stringify(a.map(p => [p.t, Math.round(p.dl), Math.round(p.ul)]))); } catch {} };
// tiered retention so storage stays tiny: 10s spacing < 2h, 60s < 24h, 300s < 7d
const compactHist = (arr, now) => {
  const tiers = [[7200, 10], [86400, 60], [604800, 300]];
  const out = []; let lastT = -Infinity;
  for (const p of arr) {
    const age = now - p.t; if (age > 604800) continue;
    const tier = tiers.find(([a]) => age <= a); const gap = tier ? tier[1] : 300;
    if (p.t - lastT >= gap) { out.push(p); lastT = p.t; }
  }
  return out;
};
// average into ~target buckets for rendering
const downsample = (arr, target) => {
  if (arr.length <= target) return arr.map(p => ({ dl: p.dl, ul: p.ul }));
  const out = [], b = arr.length / target;
  for (let i = 0; i < target; i++) {
    const s = Math.floor(i * b), e = Math.floor((i + 1) * b); let dl = 0, ul = 0, c = 0;
    for (let j = s; j < e && j < arr.length; j++) { dl += arr[j].dl; ul += arr[j].ul; c++; }
    if (c) out.push({ dl: dl / c, ul: ul / c });
  }
  return out;
};
const fmtAgo = ts => {
  if (!ts) return 'jamais';
  const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
  if (s < 60) return `il y a ${s}s`;
  if (s < 3600) return `il y a ${Math.floor(s / 60)} min`;
  if (s < 86400) return `il y a ${Math.floor(s / 3600)} h`;
  return `il y a ${Math.floor(s / 86400)} j`;
};

// ─── FLOATING NAV RAIL ───────────────────────────────────────────────────────
const NavRail = ({ activeView, setActiveView, onLogout, onLimits }) => (
  <nav className="rail">
    <div className="rail-logo">⚡</div>
    <div className={`rail-btn${activeView==='dashboard'?' on':''}`} data-tip="Tableau de bord" onClick={()=>setActiveView('dashboard')}><Ic n="gr" s={19}/></div>
    <div className={`rail-btn${activeView==='torrents'?' on':''}`} data-tip="Torrents" onClick={()=>setActiveView('torrents')}><Ic n="dn" s={20}/></div>
    <div className={`rail-btn${activeView==='logs'?' on':''}`} data-tip="Journaux" onClick={()=>setActiveView('logs')}><Ic n="tm" s={20}/></div>
    <div className="rail-sp"/>
    <div className="rail-btn" data-tip="Limites de vitesse" onClick={onLimits}><Ic n="li" s={20}/></div>
    <div className="rail-btn" data-tip="Déconnecter" onClick={onLogout}><Ic n="lo" s={20}/></div>
  </nav>
);

// ─── TOP-N BANDWIDTH LIST (dashboard) ────────────────────────────────────────
const TopList = ({ title, icon, color, items, metric, onSelect }) => (
  <section className="card top-card">
    <div className="top-head">
      <span className="top-ic" style={{background:color+'22',color}}><Ic n={icon} s={15}/></span>
      <span className="top-title">{title}</span>
      <span className="top-count">{items.length}</span>
    </div>
    <div className="top-body">
      {items.length===0
        ? <div className="top-empty">Aucun torrent actif</div>
        : items.map(t=>{
            const st = stOf(t.state);
            const pct = t.progress*100;
            return (
              <div key={t.hash} className="top-row" onClick={()=>onSelect(t.hash)}>
                <div className="top-row-ic" style={{background:st.col+'22',color:st.col}}><Ic n={st.ic} s={13}/></div>
                <div className="top-row-main">
                  <div className="top-row-name" title={t.name}>{t.name}</div>
                  <div className="top-bar"><div className="top-fill" style={{width:`${pct.toFixed(1)}%`,background:color}}/></div>
                </div>
                <div className="top-row-spd console" style={{color}}>
                  {metric==='dl' ? '↓ ' : '↑ '}{fmtSpShort(metric==='dl'?t.dlspeed:t.upspeed)}
                </div>
              </div>
            );
          })}
    </div>
  </section>
);

// ─── STAT PILLS ──────────────────────────────────────────────────────────────
const StatPills = ({ torrents, info }) => {
  const active = torrents.filter(t => ['downloading','seeding'].includes(normState(t.state))).length;
  return (
    <div className="pills">
      <div className="pill"><Ic n="gr" s={13} style={{color:'var(--t3)'}}/>Actifs&nbsp;<b>{active}/{torrents.length}</b></div>
      <div className="pill"><Ic n="dn" s={13} style={{color:'var(--acc2)'}}/><b>{fmtSp(info?.dl_info_speed||0)}</b></div>
      <div className="pill"><Ic n="up" s={13} style={{color:'var(--green)'}}/><b>{fmtSp(info?.up_info_speed||0)}</b></div>
      <div className="pill"><Ic n="db" s={13} style={{color:'var(--t3)'}}/>Session&nbsp;<b>{fmtB(info?.dl_info_data||0)}</b></div>
    </div>
  );
};

// ─── METRIC CARDS (dashboard hero KPIs) ──────────────────────────────────────
const MetricCards = ({ torrents, info, srv, tracker }) => {
  const active = torrents.filter(t => ['downloading','seeding'].includes(normState(t.state))).length;
  const sessDl = fmtB(info?.dl_info_data||0);
  const sessUl = fmtB(info?.up_info_data||0);
  const cards = [
    { label:'Téléchargement',  value:fmtSp(info?.dl_info_speed||0), icon:'dn', color:'#818cf8' },
    { label:'Upload',          value:fmtSp(info?.up_info_speed||0), icon:'up', color:'#34d399' },
    { label:'Torrents actifs', value:`${active}/${torrents.length}`, icon:'gr', color:'#fbbf24' },
    { label:'Reçu (total)',    value:srv ? fmtB(srv.alltime_dl||0) : sessDl,
      sub:`session ${sessDl}`, icon:'db', color:'#6366f1' },
    { label:'Envoyé (total)',  value:srv ? fmtB(srv.alltime_ul||0) : sessUl,
      sub:`session ${sessUl}`, icon:'db', color:'#34d399' },
  ];
  if (tracker && tracker.ratio != null) {
    const col = tracker.ratio >= 1 ? '#34d399' : tracker.ratio >= 0.5 ? '#fbbf24' : '#f87171';
    cards.push({ label:'Ratio tracker', value:tracker.ratio.toFixed(2),
      sub:(tracker.up || tracker.down) ? `↑ ${tracker.up||'—'} · ↓ ${tracker.down||'—'}` : null,
      icon:'ac', color:col });
  }
  return (
    <div className="metrics">
      {cards.map(c=>(
        <div key={c.label} className="metric">
          <div className="metric-top">
            <span className="metric-ic" style={{background:c.color+'22',color:c.color}}><Ic n={c.icon} s={15}/></span>
            <span className="metric-label">{c.label}</span>
          </div>
          <div className="metric-val console">{c.value}</div>
          {c.sub && <div className="metric-sub console">{c.sub}</div>}
        </div>
      ))}
    </div>
  );
};

// ─── VPN / NETWORK-BINDING STATUS ────────────────────────────────────────────
// Reads qBittorrent's bound network interface (app/preferences →
// current_network_interface). Binding qBittorrent to the OpenVPN interface
// (e.g. tun0) means all torrent traffic goes through the tunnel, with a
// kill-switch if the VPN drops — the real "am I protected?" signal.
const VPN_IFACE_RE = /tun|tap|wg\d|wireguard|ovpn|vpn|proton|mullvad|nordlynx|ipvanish|expressvpn/i;
const VpnStatus = ({ api }) => {
  const [d, setD] = useState(null); // { iface, addr } | { error:true }
  useEffect(() => {
    let alive = true, tid;
    const poll = async () => {
      try {
        const p = await api.getPreferences();
        if (!alive) return;
        setD({ iface: p.current_network_interface || '', addr: p.current_interface_address || '' });
      } catch (e) { if (alive) setD({ error: true }); }
      if (alive) tid = setTimeout(poll, 30000);
    };
    poll();
    return () => { alive = false; clearTimeout(tid); };
  }, [api]);

  const iface = d && !d.error ? d.iface : '';
  const isVpn = !!iface && VPN_IFACE_RE.test(iface);
  const state = !d ? 'load' : d.error ? 'load' : isVpn ? 'ok' : 'warn';
  const cls = state === 'ok' ? 'vpn-ok' : state === 'warn' ? 'vpn-warn' : 'vpn-load';
  const label = !d ? 'Vérification…'
    : d.error ? 'État VPN inconnu'
    : isVpn ? 'Protégé (VPN)'
    : iface ? 'Interface non-VPN'
    : 'Non protégé';

  return (
    <div className={`vpn ${cls}`}>
      <Ic n="sh" s={13}/>
      <span className="vpn-txt">{label}</span>
      {d && !d.error && <span className="vpn-ip console">{iface || 'route par défaut'}</span>}
      <div className="vpn-bubble">
        <div className="vpn-b-head">
          <Ic n="sh" s={15}/>
          {state === 'ok' ? 'qBittorrent protégé' : state === 'warn' ? 'Protection à vérifier' : 'Statut réseau'}
        </div>
        <div className="vpn-b-row"><span>Interface liée</span><b className="console">{d && !d.error ? (iface || '— (toutes)') : '…'}</b></div>
        {d && !d.error && d.addr && <div className="vpn-b-row"><span>Adresse</span><b className="console">{d.addr}</b></div>}
        <div className="vpn-b-msg">
          {state === 'load'
            ? (d && d.error ? 'Impossible de lire les préférences qBittorrent.' : 'Lecture des préférences qBittorrent…')
            : isVpn
            ? `✓ qBittorrent est lié à l'interface « ${iface} ». Tout le trafic torrent passe par ton tunnel OpenVPN, et il est coupé si le VPN tombe (kill-switch). Tu es protégé.`
            : iface
            ? `⚠ qBittorrent est lié à « ${iface} », qui ne ressemble pas à une interface VPN. Vérifie que c'est bien ton tunnel OpenVPN (sinon ton IP réelle est exposée aux pairs).`
            : '⚠ Aucune interface VPN liée — qBittorrent utilise la route par défaut. Dans qBittorrent → Options → Avancé → « Interface réseau », choisis ton interface OpenVPN (ex. tun0) pour garantir la protection et le kill-switch.'}
        </div>
      </div>
    </div>
  );
};

// ─── FILTER PILLS ────────────────────────────────────────────────────────────
const FilterPills = ({ torrents, activeFilter, setActiveFilter }) => {
  const counts = useMemo(() => {
    const c = { all:torrents.length, downloading:0, seeding:0, paused:0, stalled:0, error:0, completed:0 };
    torrents.forEach(t => { const n = normState(t.state); if (c[n]!==undefined) c[n]++; });
    return c;
  }, [torrents]);
  const tabs = [
    {id:'all',lbl:'Tous',col:null},
    {id:'downloading',lbl:'Téléchargement',col:'#818cf8'},
    {id:'seeding',lbl:'Partage',col:'#34d399'},
    {id:'paused',lbl:'Pausé',col:'#5b5f70'},
    {id:'stalled',lbl:'Bloqué',col:'#fbbf24'},
    {id:'error',lbl:'Erreur',col:'#f87171'},
    {id:'completed',lbl:'Terminé',col:'#34d399'},
  ];
  return (
    <div className="fpills">
      {tabs.map(t => (
        <div key={t.id} className={`fpill${activeFilter===t.id?' on':''}`} onClick={()=>setActiveFilter(t.id)}>
          {t.col && <span className="fpill-dot" style={{background:t.col}}/>}
          {t.lbl}
          {counts[t.id]>0 && <span className="fpill-n">{counts[t.id]}</span>}
        </div>
      ))}
    </div>
  );
};

// ─── TORRENT ROW ─────────────────────────────────────────────────────────────
const TorrentRow = ({ t, selected, onSelect, onPause, onResume, onDeleteClick }) => {
  const st = stOf(t.state);
  const ns = normState(t.state);
  const shortLbl = {downloading:'DL',seeding:'Seed',paused:'Pause',stalled:'Bloqué',error:'Erreur',completed:'Terminé'}[ns] || st.lbl;
  const isPaused = ns === 'paused';
  const isDown = ns === 'downloading';
  const pct = t.progress * 100;
  return (
    <div className={`trow${selected?' sel':''}`} onClick={()=>onSelect(t.hash)}>
      <div className="tn">
        <div className="tn-icon" style={{background:st.col+'22', color:st.col}}><Ic n={st.ic} s={15}/></div>
        <span className="tn-title" title={t.name}>{t.name}</span>
        <span className={`chip ${st.cls}`}>{shortLbl}</span>
        {t.category && <span className="chip chip-cat">{t.category}</span>}
      </div>
      <div className="tprog">
        <div className="tn-bar"><div className="tn-fill" style={{width:`${pct.toFixed(1)}%`,background:st.col}}/></div>
        <span className="pct">{pct.toFixed(0)}%</span>
      </div>
      <div className="tcell dim">{fmtB(t.size)}</div>
      <div className="tcell tspeed">
        <span style={{color: isDown ? '#818cf8' : 'var(--t3)'}}>↓ {isDown ? fmtSpShort(t.dlspeed) : '—'}</span>
        <span style={{color: t.upspeed>0 ? '#34d399' : 'var(--t3)'}}>↑ {t.upspeed>0 ? fmtSpShort(t.upspeed) : '—'}</span>
      </div>
      <div className="tcell dim">{t.num_seeds}/{t.num_leechs}</div>
      <div className="tcell dim">{fmtRatio(t.ratio)}</div>
      <div className="tacts">
        {isPaused
          ? <button className="abtn" title="Reprendre" onClick={e=>{e.stopPropagation();onResume(t.hash)}}><Ic n="pl" s={13}/></button>
          : <button className="abtn" title="Pause"     onClick={e=>{e.stopPropagation();onPause(t.hash)}}><Ic n="pa" s={13}/></button>}
        <button className="abtn del" title="Supprimer" onClick={e=>{e.stopPropagation();onDeleteClick(t)}}><Ic n="tr" s={13}/></button>
      </div>
    </div>
  );
};

// ─── DETAIL PANEL (fixed slide-in) ───────────────────────────────────────────
const DetailPanel = ({ torrent, detailData, tab, setTab, onClose }) => {
  const st = torrent ? stOf(torrent.state) : null;
  return (
    <aside className={`detail${torrent?' open':''}`}>
      {torrent && (
        <>
          <div className="det-hdr">
            <div className="det-top">
              <div className="det-icon" style={{background:st.col+'22',color:st.col}}><Ic n={st.ic} s={18}/></div>
              <div className="det-name" title={torrent.name}>{torrent.name}</div>
              <button className="det-close" onClick={onClose}><Ic n="xx" s={14}/></button>
            </div>
            <div className="det-pbar"><div className="det-fill" style={{width:`${(torrent.progress*100).toFixed(1)}%`,background:st.col}}/></div>
            <div className="det-meta">
              <span className={`chip ${st.cls}`}>{st.lbl}</span>
              <span className="pct">{(torrent.progress*100).toFixed(1)}%</span>
              {torrent.category && <span className="chip chip-cat">{torrent.category}</span>}
            </div>
          </div>

          <div className="det-tabs">
            {['info','fichiers','trackers','pairs'].map(tb=>(
              <div key={tb} className={`det-tab${tab===tb?' on':''}`} onClick={()=>setTab(tb)}>
                {tb.charAt(0).toUpperCase()+tb.slice(1)}
              </div>
            ))}
          </div>

          <div className="det-body">
            {tab==='info' && (()=>{
              const p = detailData.props || {};
              const pieces = p.pieces_num ? `${p.pieces_have||0} / ${p.pieces_num} (${fmtB(p.piece_size||0)})` : null;
              const sections = [
                ['Transfert', [
                  ['Taille',fmtB(torrent.size)],
                  ['Téléchargé',fmtB(p.total_downloaded!=null?p.total_downloaded:torrent.downloaded)],
                  ['Uploadé',fmtB(p.total_uploaded!=null?p.total_uploaded:torrent.uploaded)],
                  ['Ratio',fmtRatio(torrent.ratio)],
                  ['Gaspillé',p.total_wasted!=null?fmtB(p.total_wasted):null],
                  ['Disponibilité',torrent.availability!=null?`${torrent.availability.toFixed(2)}×`:null],
                ]],
                ['Vitesse', [
                  ['Vitesse DL',fmtSp(torrent.dlspeed)],
                  ['Vitesse UL',fmtSp(torrent.upspeed)],
                  ['Moy. DL',p.dl_speed_avg!=null?fmtSp(p.dl_speed_avg):null],
                  ['Moy. UL',p.up_speed_avg!=null?fmtSp(p.up_speed_avg):null],
                  ['Limite DL',p.dl_limit!=null?(p.dl_limit<=0?'∞':fmtSp(p.dl_limit)):null],
                  ['Limite UL',p.up_limit!=null?(p.up_limit<=0?'∞':fmtSp(p.up_limit)):null],
                  ['ETA',fmtETA(torrent.eta)],
                ]],
                ['Connexions', [
                  ['Seeds / Pairs',`${torrent.num_seeds} / ${torrent.num_leechs}`],
                  ['Connexions',p.nb_connections!=null?`${p.nb_connections} / ${p.nb_connections_limit||'∞'}`:null],
                  ['Vu pour la dernière fois',p.last_seen!=null&&p.last_seen>0?fmtDate(p.last_seen):null],
                ]],
                ['Temps', [
                  ['Ajouté le',fmtDate(p.addition_date||torrent.added_on)],
                  ['Terminé le',p.completion_date!=null?fmtDate(p.completion_date):null],
                  ['Temps actif',p.time_elapsed!=null?fmtDur(p.time_elapsed):null],
                  ['Temps de partage',p.seeding_time!=null?fmtDur(p.seeding_time):null],
                ]],
                ['Informations', [
                  ['Catégorie',torrent.category||'Aucune'],
                  ['Pièces',pieces],
                  ['Chemin',p.save_path||torrent.save_path||'—'],
                  ['Créé par',p.created_by||null],
                  ['Créé le',p.creation_date!=null?fmtDate(p.creation_date):null],
                  ['Commentaire',p.comment||null],
                  ['Hash',torrent.hash],
                ]],
              ];
              return sections.map(([title,rows])=>{
                const visible = rows.filter(([,v])=>v!=null && v!=='');
                if (!visible.length) return null;
                return (
                  <div key={title} className="ig-sec">
                    <div className="ig-sec-h">{title}</div>
                    {visible.map(([k,v])=>(
                      <div key={k} className="ig-row"><span className="ig-k">{k}</span><span className="ig-v">{v}</span></div>
                    ))}
                  </div>
                );
              });
            })()}

            {tab==='fichiers' && (!detailData.files
              ? <div className="empty"><p>Chargement…</p></div>
              : detailData.files.map((f,i)=>(
                  <div key={i} className="f-row">
                    <span className="f-ic"><Ic n="fi" s={14}/></span>
                    <span className="f-name" title={f.name}>{f.name.split('/').pop()}</span>
                    <span className="f-sz">{fmtB(f.size)}</span>
                    <div className="f-pb"><div className="f-pb-f" style={{width:`${(f.progress*100).toFixed(0)}%`,opacity:f.priority===0?0.35:1}}/></div>
                  </div>
                )))}

            {tab==='trackers' && (!detailData.trackers
              ? <div className="empty"><p>Chargement…</p></div>
              : detailData.trackers.map((tk,i)=>(
                  <div key={i} className="tk-row">
                    <div className="tk-url">{tk.url}</div>
                    <div className="tk-stats">
                      <span className={tk.status===2?'tk-ok':'tk-err'}>{tk.status===2?'✓ OK':'✗ KO'}</span>
                      <span className="tk-dim">Pairs : {tk.num_peers||0}</span>
                      {tk.msg && <span className="tk-err">{tk.msg}</span>}
                    </div>
                  </div>
                )))}

            {tab==='pairs' && (!detailData.peers
              ? <div className="empty"><p>Chargement…</p></div>
              : detailData.peers.length===0
                ? <div className="empty"><Ic n="us" s={32}/><p>Aucun pair connecté</p></div>
                : detailData.peers.map((p,i)=>{
                    const cc = (p.country_code || '').toLowerCase();
                    const ccOk = /^[a-z]{2}$/.test(cc);
                    return (
                    <div key={i} className="pr-row">
                      <div className="pr-top">
                        <span className="pr-ip">{ccOk && <img className="pr-flag" src={`https://flagcdn.com/20x15/${cc}.png`} srcSet={`https://flagcdn.com/40x30/${cc}.png 2x`} width="20" height="15" alt={cc.toUpperCase()} loading="lazy"/>}{p.ip}:{p.port}</span>
                        <span className="pr-cli">{p.client}{p.country&&` · ${p.country}`}</span>
                      </div>
                      <div className="pr-sp">
                        <span className="pr-dl">↓ {fmtSp(p.dl_speed)}</span>
                        <span className="pr-ul">↑ {fmtSp(p.up_speed)}</span>
                        <span style={{color:'var(--t3)'}}>{(p.progress*100).toFixed(0)}%</span>
                      </div>
                    </div>
                    );
                  }))}
          </div>
        </>
      )}
    </aside>
  );
};

// ─── LOGS VIEW ───────────────────────────────────────────────────────────────
const LogsView = ({ logs }) => (
  <section className="card logs-card">
    <div className="logs-head"><Ic n="tm" s={15}/><span>Journaux système</span></div>
    <div className="tlist">
      {logs.length===0
        ? <div className="empty"><Ic n="tm" s={36}/><p>Aucun journal disponible</p></div>
        : logs.map(l=>(
            <div key={l.id} className="log-row">
              <span className="log-time">{new Date(l.timestamp*1000).toLocaleTimeString()}</span>
              <span className={`log-msg${l.type===2?' log-2':l.type===4?' log-4':l.type===8?' log-8':''}`}>{l.message}</span>
            </div>
          ))}
    </div>
  </section>
);

// ─── DASHBOARD ───────────────────────────────────────────────────────────────
const Dashboard = ({ api, onLogout, host }) => {
  const [torrents,     setTorrents]     = useState([]);
  const [transferInfo, setTransferInfo] = useState(null);
  const [serverState,  setServerState]  = useState(null);
  const [trackerStats, setTrackerStats] = useState(null);
  const [speedHist,    setSpeedHist]    = useState(() => loadHist());
  const [chartRange,   setChartRange]   = useState('1h');
  const transferInfoRef = useRef(null);
  const [sort,         setSort]         = useState({ key:null, dir:1 });
  const [selectedHash, setSelectedHash] = useState(null);
  const [activeFilter, setActiveFilter] = useState('all');
  const [searchQuery,  setSearchQuery]  = useState('');
  const [activeView,   setActiveView]   = useState('dashboard');
  const [detailTab,    setDetailTab]    = useState('info');
  const [detailData,   setDetailData]   = useState({});
  const [logs,         setLogs]         = useState([]);
  const [toast,        setToast]        = useState(null);
  const [showAdd,      setShowAdd]      = useState(false);
  const [showSpeed,    setShowSpeed]    = useState(false);
  const [delTarget,    setDelTarget]    = useState(null);
  const [autoRefresh,  setAutoRefresh]  = useState(true);
  const [lastReannounce, setLastReannounce] = useState(null);

  const notify = useCallback((msg, type='o') => {
    setToast({msg,type}); setTimeout(()=>setToast(null), 2800);
  }, []);

  // main poll
  useEffect(() => {
    if (!autoRefresh) return;
    let alive=true, tid;
    const poll = async () => {
      try {
        const [ts,ti] = await Promise.all([api.getTorrents(), api.getTransferInfo()]);
        if (!alive) return;
        setTorrents(ts); setTransferInfo(ti); transferInfoRef.current = ti;
      } catch(e) { console.warn('poll:', e.message); }
      if (alive) tid = setTimeout(poll, 2000);
    };
    poll();
    return () => { alive=false; clearTimeout(tid); };
  }, [api, autoRefresh]);

  // persistent speed history — sample every 10s, kept across refreshes
  useEffect(() => {
    if (!autoRefresh) return;
    const id = setInterval(() => {
      const ti = transferInfoRef.current; if (!ti) return;
      setSpeedHist(prev => {
        const now = Math.floor(Date.now() / 1000);
        const next = compactHist([...prev, { t: now, dl: ti.dl_info_speed || 0, ul: ti.up_info_speed || 0 }], now);
        saveHist(next);
        return next;
      });
    }, 10000);
    return () => clearInterval(id);
  }, [autoRefresh]);

  // all-time stats poll (heavier payload → slower cadence)
  useEffect(() => {
    if (!autoRefresh) return;
    let alive=true, tid;
    const poll = async () => {
      try { const d = await api.getMainData(0); if (alive && d?.server_state) setServerState(d.server_state); }
      catch(e) {}
      if (alive) tid = setTimeout(poll, 12000);
    };
    poll();
    return () => { alive=false; clearTimeout(tid); };
  }, [api, autoRefresh]);

  // private-tracker ratio poll (external fetch → slow cadence)
  useEffect(() => {
    if (!autoRefresh) return;
    let alive=true, tid;
    const poll = async () => {
      try { const d = await api.getTrackerRatio(); if (alive && d && d.ratio != null) setTrackerStats(d); }
      catch(e) {}
      if (alive) tid = setTimeout(poll, 60000);
    };
    poll();
    return () => { alive=false; clearTimeout(tid); };
  }, [api, autoRefresh]);

  // logs poll
  useEffect(() => {
    if (activeView !== 'logs') return;
    let alive=true, tid, lastId=-1;
    const poll = async () => {
      try {
        const nl = await api.getLogs(lastId);
        if (alive && nl?.length) { lastId = Math.max(...nl.map(l=>l.id)); setLogs(prev => [...nl.slice().reverse(), ...prev].slice(0,300)); }
      } catch(e) {}
      if (alive) tid = setTimeout(poll, 4000);
    };
    poll();
    return () => { alive=false; clearTimeout(tid); };
  }, [api, activeView]);

  // detail poll
  useEffect(() => {
    if (!selectedHash) { setDetailData({}); return; }
    let alive=true, tid;
    const fetch = async () => {
      try {
        const [props,files,trackers,peers] = await Promise.all([
          api.getProperties(selectedHash).catch(()=>null),
          api.getFiles(selectedHash), api.getTrackers(selectedHash), api.getPeers(selectedHash)]);
        if (alive) setDetailData({props,files,trackers,peers});
      } catch(e) {}
      if (alive) tid = setTimeout(fetch, 5000);
    };
    fetch();
    return () => { alive=false; clearTimeout(tid); };
  }, [api, selectedHash]);

  const handleSelect = hash => { setSelectedHash(prev => prev===hash ? null : hash); setDetailTab('info'); };
  const handlePause  = useCallback(async h => { try{await api.pauseTorrents(h);notify('Torrent mis en pause');}catch{notify('Erreur','e');} }, [api]);
  const handleResume = useCallback(async h => { try{await api.resumeTorrents(h);notify('Torrent repris');}catch{notify('Erreur','e');} }, [api]);
  const handleDelete = useCallback(async (h,wf) => { try{await api.deleteTorrents(h,wf);if(selectedHash===h)setSelectedHash(null);notify('Torrent supprimé');}catch{notify('Erreur','e');} }, [api, selectedHash]);
  const handleAdd    = useCallback(async (type,data,opts) => { try{ type==='url'?await api.addUrls(data,opts):await api.addFile(data,opts); notify('Torrent ajouté !'); }catch{notify("Erreur lors de l'ajout",'e');} }, [api]);
  const handleReannounce = useCallback(async (auto=false) => {
    const hashes = torrents.filter(t => { const s=(t.state||'').toLowerCase(); return t.progress>=1 && !s.includes('paused') && !s.includes('error') && !s.includes('checking') && !s.includes('missing'); }).map(t=>t.hash);
    if (!hashes.length) { if (!auto) notify('Aucun torrent en partage', 'e'); return; }
    try { await api.reannounceTorrents(hashes); setLastReannounce(Date.now()); notify(`Réannonce ${auto?'auto ':''}· ${hashes.length} torrents`); }
    catch { if (!auto) notify('Erreur lors de la réannonce', 'e'); }
  }, [api, torrents]);

  // auto-reannounce every 30 min — stable interval that calls the latest closure
  const reannounceRef = useRef(handleReannounce);
  reannounceRef.current = handleReannounce;
  useEffect(() => {
    const id = setInterval(() => reannounceRef.current(true), 30 * 60 * 1000);
    return () => clearInterval(id);
  }, []);

  const switchView = v => { setActiveView(v); if (v==='logs') setSelectedHash(null); };
  const toggleSort = key => setSort(s => s.key===key ? { key, dir:-s.dir } : { key, dir:1 });

  const filtered = useMemo(() => {
    let r = torrents;
    if (activeFilter !== 'all') r = r.filter(t => normState(t.state) === activeFilter);
    if (searchQuery) { const q = searchQuery.toLowerCase(); r = r.filter(t => t.name.toLowerCase().includes(q) || (t.category||'').toLowerCase().includes(q)); }
    if (sort.key) {
      const col = LIST_COLS.find(c => c.key === sort.key);
      r = [...r].sort((a,b) => {
        const va = col.get(a), vb = col.get(b);
        const c = typeof va === 'string' ? va.localeCompare(vb) : (va - vb);
        return c * sort.dir;
      });
    }
    return r;
  }, [torrents, activeFilter, searchQuery, sort]);

  const activeCount = torrents.filter(t => ['downloading','seeding'].includes(normState(t.state))).length;
  const selectedTorrent = torrents.find(t=>t.hash===selectedHash) || null;
  const today = new Date().toLocaleDateString('fr-FR', {weekday:'long', day:'numeric', month:'long', year:'numeric'});
  const chartData = useMemo(() => {
    const now = Math.floor(Date.now() / 1000);
    const sec = RANGE_SEC[chartRange];
    const win = sec === Infinity ? speedHist : speedHist.filter(p => now - p.t <= sec);
    return downsample(win, 360);
  }, [speedHist, chartRange]);
  const topDl = useMemo(()=>[...torrents].filter(t=>t.dlspeed>0).sort((a,b)=>b.dlspeed-a.dlspeed).slice(0,60), [torrents]);
  const topUl = useMemo(()=>[...torrents].filter(t=>t.upspeed>0).sort((a,b)=>b.upspeed-a.upspeed).slice(0,60), [torrents]);
  const titles = { dashboard:'Tableau de bord', torrents:'Torrents', logs:'Journaux' };

  return (
    <div className="app">
      <NavRail activeView={activeView} setActiveView={switchView} onLogout={onLogout} onLimits={()=>setShowSpeed(true)}/>

      <div className={`content${selectedTorrent?' detail-open':''}`}>
        {/* top utility bar */}
        <div className="topbar">
          <VpnStatus api={api}/>
          {activeView!=='dashboard' && <StatPills torrents={torrents} info={transferInfo}/>}
          <div className="tb-gap"/>
          <span className="reann-time" title={lastReannounce ? `Dernière réannonce : ${new Date(lastReannounce).toLocaleTimeString('fr-FR')}` : 'Réannonce auto toutes les 30 min'}>
            <Ic n="rf" s={12}/>Réannonce {fmtAgo(lastReannounce)}
          </span>
          <button className="btn btn-g" onClick={()=>handleReannounce(false)} title="Forcer le réannonce de tous les torrents en partage · auto toutes les 30 min"><Ic n="rf"/>Réannoncer</button>
          <button className="btn btn-p" onClick={()=>setShowAdd(true)}><Ic n="pl2"/>Ajouter</button>
        </div>

        {/* title bar */}
        <div className="title-bar">
          <div>
            <h1 className="title-h">{titles[activeView]}</h1>
            <p className="title-sub" style={{textTransform:'capitalize'}}>{today} · {torrents.length} torrents · {activeCount} actifs</p>
          </div>
          <div className="title-right">
            <div className="conn"><span className="conn-dot"/>{host}</div>
            <div className="toggle-wrap">
              <span>Actualisation</span>
              <div className={`toggle${autoRefresh?' on':''}`} onClick={()=>setAutoRefresh(v=>!v)}/>
            </div>
          </div>
        </div>

        {activeView==='dashboard' && (
          <div className="dash">
            <MetricCards torrents={torrents} info={transferInfo} srv={serverState} tracker={trackerStats}/>
            <ChartCard info={transferInfo} data={chartData} range={chartRange} setRange={setChartRange}/>
            <div className="dash-tops">
              <TopList title="En téléchargement" icon="dn" color="#818cf8" items={topDl} metric="dl" onSelect={handleSelect}/>
              <TopList title="En partage" icon="up" color="#34d399" items={topUl} metric="ul" onSelect={handleSelect}/>
            </div>
          </div>
        )}

        {activeView==='torrents' && (
          <>
            <div className="filter-bar">
              <FilterPills torrents={torrents} activeFilter={activeFilter} setActiveFilter={setActiveFilter}/>
              <div className="fb-gap"/>
              <div className="search search-lg">
                <span className="search-ic"><Ic n="se" s={15}/></span>
                <input value={searchQuery} onChange={e=>setSearchQuery(e.target.value)} placeholder="Rechercher un torrent…"/>
              </div>
            </div>
            <section className="card list-card">
              <div className="list-head">
                {LIST_COLS.map(c=>(
                  <span key={c.key} className={`lh lh-sort${sort.key===c.key?' on':''}`} onClick={()=>toggleSort(c.key)}>
                    {c.label}<span className="lh-arr">{sort.key===c.key ? (sort.dir>0?'▲':'▼') : ''}</span>
                  </span>
                ))}
                <span className="lh" style={{textAlign:'right'}}>Actions</span>
              </div>
              <div className="tlist">
                {filtered.length===0
                  ? <div className="empty"><Ic n="fo" s={38}/><p>{searchQuery?'Aucun résultat':'Aucun torrent'}</p></div>
                  : filtered.map(t=>(
                      <TorrentRow key={t.hash} t={t} selected={selectedHash===t.hash}
                        onSelect={handleSelect} onPause={handlePause}
                        onResume={handleResume} onDeleteClick={t=>setDelTarget(t)}/>
                    ))}
              </div>
            </section>
          </>
        )}

        {activeView==='logs' && <LogsView logs={logs}/>}
      </div>

      <DetailPanel torrent={selectedTorrent} detailData={detailData}
        tab={detailTab} setTab={setDetailTab} onClose={()=>setSelectedHash(null)}/>

      {showAdd   && <AddModal    onClose={()=>setShowAdd(false)}   onAdd={handleAdd}/>}
      {showSpeed && <SpeedModal  onClose={()=>setShowSpeed(false)} api={api}/>}
      {delTarget && <DeleteModal onClose={()=>setDelTarget(null)}  torrent={delTarget} onConfirm={handleDelete}/>}
      {toast     && <div className={`toast toast-${toast.type}`}><Ic n={toast.type==='e'?'al':'ck'} s={15}/>{toast.msg}</div>}
    </div>
  );
};

// ─── APP ─────────────────────────────────────────────────────────────────────
const App = () => {
  const [api, setApi] = useState(null);
  const [host, setHost] = useState('');
  const handleConnect = (a, h) => { setApi(a); setHost(h); };
  const handleLogout  = () => { if (api) api.logout().catch(()=>{}); setApi(null); setHost(''); };
  return api
    ? <Dashboard api={api} onLogout={handleLogout} host={host}/>
    : <LoginScreen onConnect={handleConnect}/>;
};

ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(App));
