/* global React, Icon, Button, Badge, StatusDot, Avatar, Card, IOSScreen, ProgressBar, DATA */
// ============================================================
// Vue device unique — écran live + télécommande + tâches
// ============================================================
function DeviceView({ device, user, onBack, onLogout, admin, embedded }) {
  const { useState, useRef, useEffect } = React;
  if (window.useLang) useLang();
  const [actions, setActions] = useState([
    { t: "ready", label: "Stream connecté", at: now() },
  ]);
  const [busyBtn, setBusyBtn] = useState(null);
  const [restarting, setRestarting] = useState(false);   // relance moteur en cours
  const [engMode, setEngMode] = useState(null);   // "internal"|"hcbox" -> badge MI / HC en haut à gauche
  useEffect(() => {
    let alive = true;
    const tick = () => window.Backend.engineAssignment && window.Backend.engineAssignment().then(a => { if (alive && a) setEngMode(a.devices[device.udid] || a.default); });
    tick(); const id = setInterval(tick, 5000);
    return () => { alive = false; clearInterval(id); };
  }, [device.udid]);
  // FPS : persiste le choix de l'utilisateur TANT QUE l'onglet est ouvert
  // (sessionStorage). Onglet fermé -> retombe à 5 au prochain lancement
  // (économie : et de toute façon, page fermée = plus aucune capture côté backend).
  const [fps, _setFps] = useState(() => {
    if (device.live) { try { const v = parseInt(sessionStorage.getItem("phonelabs.fps"), 10); if (v >= 1 && v <= 30) return v; } catch (e) {} return 15; }
    return offlineFps(device);
  });
  const setFps = (v) => { _setFps(v); try { sessionStorage.setItem("phonelabs.fps", String(v)); } catch (e) {} };
  const swipeRef = useRef(null);
  const draggedRef = useRef(false);
  const [ripples, setRipples] = useState([]);   // retours visuels INSTANTANÉS au clic (avant la réponse du tel)
  const ripIdRef = useRef(0);
  const addRipple = (x, y) => {
    const id = ++ripIdRef.current;
    setRipples(r => [...r.slice(-6), { id, x, y }]);   // garde au plus 7 (rafale)
    setTimeout(() => setRipples(r => r.filter(p => p.id !== id)), 480);
  };
  const locked = device.status === "locked", offline = device.status === "offline";
  const dead = locked || offline;

  // --- LIVE : Ferrari (H.264) en priorité, repli AUTO sur Diesel (MJPEG) ---
  // Ferrari = vraie vidéo H.264 par UDID (le backend résout le port HC BOX tout seul).
  // Si le H.264 échoue (pas d'image en 4,5 s / erreur / navigateur sans MSE) -> bascule Diesel.
  // Diesel = flux MJPEG + image HD "au repos" (le mode hybride d'avant), comme filet de sécurité.
  const liveDev = !!device.live && !!device.udid;
  const [mode, setMode] = useState("h264");        // "h264" (Ferrari) | "diesel" (repli)
  const [h264Up, setH264Up] = useState(false);     // 1re image H.264 reçue
  const [streamSrc, setStreamSrc] = useState(null);
  const [hqSrc, setHqSrc] = useState(null);
  const [feedLost, setFeedLost] = useState(false);
  const videoRef = useRef(null);
  const canvasRef = useRef(null);   // WebCodecs : frames décodées dessinées ici
  const wcFramesRef = useRef(0);    // compteur frames décodées (FPS réel en mode WebCodecs)
  const HAS_WC = typeof VideoDecoder !== "undefined";  // WebCodecs dispo (Electron/Chrome/Edge/Safari16.4+)
  const reconnRef = useRef(0);
  const lastTouchRef = useRef(0);
  const [h264Key, setH264Key] = useState(0);   // bump -> relance l'effet H.264 (nouveau WS)
  const triesRef = useRef(0);                  // nb de tentatives H.264 avant de céder au diesel
  const H264_MAX_TRIES = 5;
  const [realFps, setRealFps] = useState(0);   // FPS vidéo RÉEL (mesuré sur le <video>, pas le réglage)
  const [pingMs, setPingMs] = useState(null);  // latence réseau RÉELLE (RTT vers le backend)

  // FPS RÉEL : delta de frames décodées par le <video> sur 1 s (H.264)
  useEffect(() => {
    let last = null;
    const id = setInterval(() => {
      if (mode === "h264" && HAS_WC) {
        const q = wcFramesRef.current;   // WebCodecs : compteur de frames décodées
        if (last !== null) setRealFps(Math.max(0, Math.round(q - last)));
        last = q;
      } else if (mode === "h264" && videoRef.current) {
        const v = videoRef.current;
        const q = v.getVideoPlaybackQuality ? v.getVideoPlaybackQuality().totalVideoFrames : (v.webkitDecodedFrameCount || 0);
        if (last !== null) setRealFps(Math.max(0, Math.round(q - last)));
        last = q;
      } else { last = null; setRealFps(0); }
    }, 1000);
    return () => clearInterval(id);
  }, [mode]);

  // Latence RÉELLE : RTT vers /api/health du backend, toutes les 2 s
  useEffect(() => {
    if (!liveDev) { setPingMs(null); return; }
    let on = true;
    const ping = async () => {
      const base = window.Backend.devbase && window.Backend.devbase();
      if (!base) return;
      const now = () => (performance && performance.now) ? performance.now() : Date.now();
      const t0 = now();
      try { await fetch(base + "/api/health", { cache: "no-store" }); if (on) setPingMs(Math.round(now() - t0)); }
      catch (e) { if (on) setPingMs(null); }
    };
    ping(); const id = setInterval(ping, 2000);
    return () => { on = false; clearInterval(id); };
  }, [liveDev]);

  // on change de téléphone -> on retente la Ferrari (compteur de tentatives remis à zéro)
  useEffect(() => { triesRef.current = 0; setMode("h264"); setH264Up(false); setH264Key(k => k + 1); }, [device.udid]);
  // AUTO-RÉPARATION : si on est tombé en repli (diesel/éco), on RE-TENTE la Ferrari toutes les ~18 s.
  // Crucial pour les tels HC BOX dont le flux H.264 démarre APRÈS l'ouverture (sinon coincé en éco noir).
  useEffect(() => {
    if (mode !== "diesel" || !liveDev || dead) return;
    const id = setTimeout(() => { triesRef.current = 0; setH264Up(false); setMode("h264"); setH264Key(k => k + 1); }, 18000);
    return () => clearTimeout(id);
  }, [mode, liveDev, dead, device.udid]);
  // audit : trace l'ouverture de l'écran (qui a ouvert quel tel, quand)
  useEffect(() => { if (liveDev && window.Backend.logAction) window.Backend.logAction(device.udid, "Ouverture écran", "", device.tag); }, [device.udid]);

  // PATIENCE : au lieu de tomber direct en éco, on RÉESSAYE la Ferrari plusieurs fois
  // (un drop transitoire du flux se reconnecte) ; éco seulement après plusieurs échecs.
  const retryOrDiesel = () => {
    if (triesRef.current >= H264_MAX_TRIES) { setMode("diesel"); return; }
    triesRef.current++;
    setH264Up(false);
    setH264Key(k => k + 1);   // relance l'effet -> nouveau WS
  };

  // --- Ferrari : H.264 -> jMuxer -> <video> ---
  useEffect(() => {
    if (!liveDev || dead || mode !== "h264") return;
    // ===== WebCodecs (chemin PRINCIPAL) : décode le H.264 DIRECT (GPU) -> <canvas> =====
    // jMuxer/MSE n'initialisait jamais la piste (remuxController.initialized=false) -> écran noir.
    // VideoDecoder décode chaque NAL et on dessine la frame -> aucune latence de buffer MSE.
    if (HAS_WC) {
      let on = true, dec = null, ws = null, buf = new Uint8Array(0), sps = null, pps = null, configured = false, seenKey = false, tsc = 0, frames = 0;
      const annexb = (n) => { const o = new Uint8Array(n.length + 4); o[3] = 1; o.set(n, 4); return o; };
      const concat = (arr) => { let t = 0; arr.forEach(a => t += a.length); const o = new Uint8Array(t); let p = 0; arr.forEach(a => { o.set(a, p); p += a.length; }); return o; };
      const bail = () => { if (!on) return; on = false; try { ws && ws.close(); } catch (e) {} try { if (dec && dec.state !== "closed") dec.close(); } catch (e) {} retryOrDiesel(); };
      const draw = (frame) => {
        try {
          const cv = canvasRef.current;
          if (cv) {
            if (cv.width !== frame.displayWidth || cv.height !== frame.displayHeight) { cv.width = frame.displayWidth; cv.height = frame.displayHeight; }
            cv.getContext("2d").drawImage(frame, 0, 0, cv.width, cv.height);
            frames++; wcFramesRef.current = frames;
            if (!h264Up) setH264Up(true);
            setFeedLost(false);
          }
        } catch (e) {}
        try { frame.close(); } catch (e) {}
      };
      const feedBytes = (value) => {
        const nb = new Uint8Array(buf.length + value.length); nb.set(buf, 0); nb.set(value, buf.length); buf = nb;
        let i = 0;
        while (i + 4 <= buf.length) {
          const len = ((buf[i] << 24) >>> 0) + (buf[i + 1] << 16) + (buf[i + 2] << 8) + buf[i + 3];
          if (len <= 0 || i + 4 + len > buf.length) break;
          const nal = buf.subarray(i + 4, i + 4 + len), nt = nal[0] & 0x1f;
          if (nt === 7) {                       // SPS -> (re)configure le décodeur
            sps = nal.slice();
            if (!configured) {
              try {
                if (!dec || dec.state === "closed") dec = new VideoDecoder({ output: draw, error: () => bail() });
                const codec = "avc1." + [sps[1], sps[2], sps[3]].map(b => ("0" + b.toString(16)).slice(-2)).join("");
                dec.configure({ codec, optimizeForLatency: true });
                configured = true;
              } catch (e) { bail(); return; }
            }
          } else if (nt === 8) { pps = nal.slice(); }   // PPS
          else if (nt === 5 && configured && sps && pps) {   // IDR -> chunk "key" (SPS+PPS+IDR)
            try { dec.decode(new EncodedVideoChunk({ type: "key", timestamp: tsc, data: concat([annexb(sps), annexb(pps), annexb(nal)]) })); tsc += 33333; seenKey = true; } catch (e) { bail(); return; }
          } else if (nt === 1 && seenKey && configured && dec.state === "configured") {   // P -> chunk "delta"
            try { dec.decode(new EncodedVideoChunk({ type: "delta", timestamp: tsc, data: annexb(nal) })); tsc += 33333; } catch (e) { bail(); return; }
          }
          i += 4 + len;
        }
        if (i > 0) buf = buf.slice(i);
      };
      const watchdog = setTimeout(() => { if (frames === 0) bail(); }, 10000);
      (async () => {
        try {
          if (!window.Backend.devbase()) await window.Backend.resolveBackend();
          if (!on) return;
          const wsurl = window.Backend.devbase().replace(/^http/, "ws") + "/api/devices/" + encodeURIComponent(device.udid) + "/h264?ts=" + Date.now();
          ws = new WebSocket(wsurl); ws.binaryType = "arraybuffer";
          ws.onmessage = (ev) => { if (!on) return; try { feedBytes(new Uint8Array(ev.data)); } catch (e) {} };
          ws.onerror = () => {};
          ws.onclose = () => { if (on) bail(); };
        } catch (e) { bail(); }
      })();
      return () => { on = false; clearTimeout(watchdog); try { ws && ws.close(); } catch (e) {} try { if (dec && dec.state !== "closed") dec.close(); } catch (e) {} };
    }
    // ===== jMuxer/MSE (REPLI : navigateurs sans WebCodecs) =====
    if (typeof JMuxer === "undefined") { setMode("diesel"); return; }
    let on = true, jm = null, ws = null, frames = 0, sps = null, pps = null, buf = new Uint8Array(0), lastFeed = 0, emaDur = 0;
    const annexb = (n) => { const o = new Uint8Array(n.length + 4); o[3] = 1; o.set(n, 4); return o; };
    // bail = abandonne CETTE tentative et réessaie la Ferrari (ou tombe en diesel après N essais)
    const bail = () => { if (!on) return; on = false; try { ws && ws.close(); } catch (e) {} try { jm && jm.destroy(); } catch (e) {} retryOrDiesel(); };
    const feedBytes = (value) => {
      const nb = new Uint8Array(buf.length + value.length); nb.set(buf, 0); nb.set(value, buf.length); buf = nb;
      let i = 0; const parts = [];
      while (i + 4 <= buf.length) {
        const len = ((buf[i] << 24) >>> 0) + (buf[i + 1] << 16) + (buf[i + 2] << 8) + buf[i + 3];
        if (len <= 0 || i + 4 + len > buf.length) break;
        const nal = buf.subarray(i + 4, i + 4 + len), nt = nal[0] & 0x1f;
        if (nt === 7) sps = nal.slice(); else if (nt === 8) pps = nal.slice();
        if (nt === 5 && sps && pps) { parts.push(annexb(sps), annexb(pps), annexb(nal)); frames++; }
        else if (nt === 1) { parts.push(annexb(nal)); frames++; }
        else if (nt !== 7 && nt !== 8) { parts.push(annexb(nal)); }
        i += 4 + len;
      }
      if (i > 0) buf = buf.slice(i);
      if (parts.length) { let tot = 0; parts.forEach(p => tot += p.length); const data = new Uint8Array(tot); let o = 0; parts.forEach(p => { data.set(p, o); o += p.length; });
        // DURÉE RÉELLE écoulée depuis le dernier lot -> la timeline avance au VRAI rythme du tel
        // (et plus à un 30 img/s fictif). Indispensable pour les tels au débit bas/irrégulier
        // (iPhone X ~18 img/s) sinon la lecture vide le buffer = micro-gels périodiques.
        const tnow = (typeof performance !== "undefined" && performance.now) ? performance.now() : Date.now();
        const raw = lastFeed ? (tnow - lastFeed) : 45; lastFeed = tnow;
        // CADENCE LISSE (anti-saccade) : on nourrit jMuxer avec une MOYENNE GLISSANTE de
        // l'inter-arrivée, PAS la valeur brute (qui porte toute la gigue de capture -> saccade).
        // La capture du tel est irrégulière (rapide écran figé / lente en mouvement) ; lisser = CFR-like.
        // Le coussin du live-edge keeper absorbe la gigue d'arrivée pendant que la lecture draine régulier.
        emaDur = (emaDur > 0) ? (emaDur * 0.82 + raw * 0.18) : raw;
        let dur = emaDur; if (dur < 22) dur = 22; else if (dur > 140) dur = 140;
        try { jm.feed(dur ? { video: data, duration: dur } : { video: data }); if (!h264Up) { setH264Up(true); try { const v = videoRef.current; if (v) { v.muted = true; const pr = v.play(); if (pr && pr.catch) pr.catch(() => {}); } } catch (e) {} } setFeedLost(false); } catch (e) {} }
    };
    // watchdog : si après 5 s la VIDÉO ne joue pas vraiment (pas que "des octets sont arrivés",
    // mais l'image décode et avance) -> repli diesel. Couvre le cas "données OK mais décodage KO".
    const watchdog = setTimeout(() => {
      const v = videoRef.current;
      const reallyPlaying = v && v.videoWidth > 0 && v.currentTime > 0.1;
      if (!reallyPlaying) bail();
    }, 10000);
    // LIVE-EDGE KEEPER : le MSE bufferise et joue en retard (1-2 s). On garde la lecture
    // collée au direct : si on prend du retard -> on saute au bord live (ou on accélère un poil).
    const edgeTimer = setInterval(() => {
      const v = videoRef.current;
      if (!v || !v.buffered || !v.buffered.length) return;
      try {
        const end = v.buffered.end(v.buffered.length - 1);
        const gap = end - v.currentTime;
        // cible ~0,15 s de retard (BASSE latence) : on colle au direct, on garde juste
        // une petite marge (0,12 s) pour ne pas vider le buffer (sinon ça cale).
        // COUSSIN ANTI-GIGUE ~130 ms : on garde un petit buffer pour lisser les frames
        // irrégulières (la capture testmanagerd ralentit en mouvement). En dessous de
        // ~130 ms de retard -> vitesse normale (on NE vide PAS le buffer = pas de saccade).
        if (gap > 0.45) { v.currentTime = end - 0.12; v.playbackRate = 1.0; }   // cap latence ~450 ms -> retombe à ~120 ms
        else if (gap > 0.22) { v.playbackRate = 1.25; }                         // au-dessus du coussin -> rattrape en douceur
        else if (gap > 0.13) { v.playbackRate = 1.08; }                        // léger -> rattrape très doucement
        else if (v.playbackRate !== 1.0) { v.playbackRate = 1.0; }              // dans le coussin -> vitesse normale
      } catch (e) {}
    }, 120);
    (async () => {
      try {
        if (!window.Backend.devbase()) await window.Backend.resolveBackend();
        if (!on || !videoRef.current) return;
        jm = new JMuxer({ node: videoRef.current, mode: "video", flushingTime: 1, fps: 20, clearBuffer: true, debug: false, onError: () => { try { jm.reset && jm.reset(); } catch (e) {} } });
        // WebSocket : tuyau transparent à travers Cloudflare (le HTTP chunké était bufferisé/saccadé).
        const wsurl = window.Backend.devbase().replace(/^http/, "ws") + "/api/devices/" + encodeURIComponent(device.udid) + "/h264?ts=" + Date.now();
        ws = new WebSocket(wsurl); ws.binaryType = "arraybuffer";
        ws.onmessage = (ev) => { if (!on) return; try { feedBytes(new Uint8Array(ev.data)); } catch (e) {} };
        ws.onerror = () => {};
        ws.onclose = () => { if (on) bail(); };   // flux tombé -> on réessaie (retry) avant l'éco
      } catch (e) { bail(); }
    })();
    return () => { on = false; clearTimeout(watchdog); clearInterval(edgeTimer); try { ws && ws.close(); } catch (e) {} try { jm && jm.destroy(); } catch (e) {} };
  }, [liveDev, dead, mode, device.udid, h264Key]);

  // --- Diesel : MJPEG (repli) + image HD au repos ---
  const buildSrc = () => window.Backend.streamUrl(device.udid, fps) + "&r=" + reconnRef.current;
  useEffect(() => {
    if (!liveDev || dead || mode !== "diesel") { setStreamSrc(null); return; }
    let on = true;
    (async () => {
      if (!window.Backend.devbase()) { try { await window.Backend.resolveBackend(); } catch (e) {} }
      if (on) { setFeedLost(false); reconnRef.current++; setStreamSrc(buildSrc()); }
    })();
    return () => { on = false; };
  }, [liveDev, dead, mode, fps, device.udid]);
  const IDLE_MS = 1200;
  useEffect(() => {
    if (!liveDev || dead || mode !== "diesel") { setHqSrc(h => { if (h) URL.revokeObjectURL(h); return null; }); return; }
    let on = true, fetching = false, doneForThisIdle = false;
    const tick = setInterval(async () => {
      if ((Date.now() - lastTouchRef.current) <= IDLE_MS) { doneForThisIdle = false; return; }
      if (doneForThisIdle || fetching) return;
      fetching = true;
      try {
        if (!window.Backend.devbase()) await window.Backend.resolveBackend();
        const r = await fetch(window.Backend.screenUrl(device.udid, fps, true), { cache: "no-store" });
        if (on && r.ok) { const b = await r.blob(); if (on && b.size > 0) {
          const u = URL.createObjectURL(b); setHqSrc(prev => { if (prev) URL.revokeObjectURL(prev); return u; }); doneForThisIdle = true; } }
      } catch (e) {} finally { fetching = false; }
    }, 500);
    return () => { on = false; clearInterval(tick); };
  }, [liveDev, dead, mode, fps, device.udid]);

  // "Rafraîchir l'écran" / reconnexion : on retente la Ferrari (qui retombera en diesel si besoin)
  const reconnect = async () => {
    if (!liveDev || dead) return;
    try { await window.Backend.resolveBackend(); } catch (e) {}
    try { await window.Backend.resolveH264(); } catch (e) {}   // force le re-mapping -> corrige "mauvais écran"
    triesRef.current = 0; setH264Up(false); setMode("h264"); setH264Key(k => k + 1);
  };
  // "Relancer le moteur" : force le redémarrage de WDA côté moteur (sort de l'état pause/instable),
  // puis on retente le flux. Évite d'avoir à débrancher/rebrancher physiquement l'iPhone.
  const restartEngine = async () => {
    if (!liveDev || restarting) return;
    setRestarting(true);
    setActions(a => [{ t: "wait", label: "Relance du moteur (WDA)…", at: now() }, ...a].slice(0, 30));
    try {
      const r = await window.Backend.restartEngine(device.udid);
      if (r && r.ok) {
        setActions(a => [{ t: "ready", label: "Moteur relancé — reconnexion…", at: now() }, ...a].slice(0, 30));
        setTimeout(() => reconnect(), 6000);   // laisse WDA rebooter (~5-7 s) avant de retenter le flux
      } else {
        setActions(a => [{ t: "error", label: "Relance refusée : " + ((r && (r.error || r.reason)) || "inconnue"), at: now() }, ...a].slice(0, 30));
      }
    } catch (e) {
      setActions(a => [{ t: "error", label: "Relance échouée", at: now() }, ...a].slice(0, 30));
    } finally {
      setTimeout(() => setRestarting(false), 6000);
    }
  };
  const onFeedError = () => { setFeedLost(true); setTimeout(() => { if (mode === "diesel") { reconnRef.current++; setStreamSrc(buildSrc()); } }, 1000); };
  const onFeedOk = () => setFeedLost(false);
  const liveSrc = (liveDev && !dead && mode === "diesel") ? streamSrc : null;
  const connecting = liveDev && !dead && mode === "h264" && !h264Up;

  // une interaction -> on masque la HD (diesel) et on réarme le "repos"
  const touch = () => { lastTouchRef.current = Date.now(); setHqSrc(h => { if (h) URL.revokeObjectURL(h); return null; }); };
  const push = (label, meta) => setActions(a => [{ t: "act", label, meta, at: now() }, ...a].slice(0, 30));

  // mapping d'un bouton de télécommande -> vraie commande HC BOX (via backend)
  const SWIPES = { up: [.5, .7, .5, .3], down: [.5, .3, .5, .7], left: [.8, .5, .2, .5], right: [.2, .5, .8, .5], scrollUp: [.5, .3, .5, .7], scrollDown: [.5, .7, .5, .3], back: [.02, .5, .6, .5] };
  const DIR2 = { "→": [.2, .5, .8, .5], "←": [.8, .5, .2, .5], "↓": [.5, .3, .5, .7], "↑": [.5, .7, .5, .3] };
  // construit un FLICK multi-points (vrai chemin + timing rapide) -> inertie comme HC BOX
  const flickPath = ([x1, y1, x2, y2], ms = 70, n = 6) => {
    const pts = [];
    for (let i = 0; i <= n; i++) { const k = i / n; pts.push({ x: +(x1 + (x2 - x1) * k).toFixed(3), y: +(y1 + (y2 - y1) * k).toFixed(3), t: Math.round(ms * k) }); }
    return pts;
  };
  const backendCtrl = (btnId, meta) => {
    if (!liveDev) return;
    const u = device.udid;
    if (btnId === "home") window.Backend.action(u, "home");
    else if (btnId === "refresh") { reconnect(); }
    else if (SWIPES[btnId]) { window.Backend.action(u, "gesture", { points: flickPath(SWIPES[btnId]) }); }   // flick avec inertie
    else if (btnId === "kb") { if (meta) window.Backend.action(u, "inputText", { content: meta }); }
    else if (meta && /\w+\.\w+/.test(meta)) window.Backend.action(u, "launchApp", { content: meta }); // bundle id d'app
  };

  const act = (label, btnId, meta) => {
    if (dead && label !== "Refresh") return;
    touch();
    if (btnId) { setBusyBtn(btnId); setTimeout(() => setBusyBtn(null), 360); }
    backendCtrl(btnId, meta);
    push(label, meta);
    if (liveDev && window.Backend.logAction) window.Backend.logAction(device.udid, label, meta || "", device.tag); // audit cloud
  };

  // swipe / tap sur l'écran — on capture le CHEMIN exact du curseur (suivi naturel + flick avec inertie)
  const _cl = v => Math.min(1, Math.max(0, v));
  const _now = () => (performance && performance.now) ? performance.now() : Date.now();
  const onDown = (e) => {
    const r = e.currentTarget.getBoundingClientRect();
    swipeRef.current = { r, t0: _now(), pts: [{ x: +_cl((e.clientX - r.left) / r.width).toFixed(3), y: +_cl((e.clientY - r.top) / r.height).toFixed(3), t: 0 }] };
    touch();
  };
  const onMove = (e) => {
    const s = swipeRef.current; if (!s) return; const r = s.r;
    s.pts.push({ x: +_cl((e.clientX - r.left) / r.width).toFixed(3), y: +_cl((e.clientY - r.top) / r.height).toFixed(3), t: Math.round(_now() - s.t0) });
  };
  const onUp = () => {
    const s = swipeRef.current; swipeRef.current = null;
    if (!s) return;
    const a = s.pts[0], b = s.pts[s.pts.length - 1], dist = Math.hypot(b.x - a.x, b.y - a.y);
    if (dist < 0.06 || s.pts.length < 3) return; // c'est un TAP (seuil tolérant : un clic qui traîne un peu reste un tap)
    draggedRef.current = true;   // empêche le tap (onClick) qui suit le drag
    addRipple(b.x, b.y);   // feedback instantané au point de relâchement du swipe
    if (liveDev) window.Backend.action(device.udid, "gesture", { points: s.pts });   // rejoue le chemin -> inertie
    act("Swipe (geste)", null, "geste écran");
  };
  const onTapScreen = (p) => {
    if (draggedRef.current) { draggedRef.current = false; return; }   // c'était un drag (geste), pas un tap
    addRipple(p.x, p.y);   // feedback INSTANTANÉ (avant la réponse du tel)
    if (liveDev) window.Backend.action(device.udid, "click", { x: p.x, y: p.y, duration: 0.1 });
    act("Tap", null, `(${p.x}, ${p.y})`);
  };

  return (
    <div style={{ minHeight: embedded ? 0 : "100%", display: "flex", flexDirection: "column" }}>
      {/* barre device */}
      {!embedded && <header style={{ position: "sticky", top: 0, zIndex: 20, display: "flex", alignItems: "center", gap: 14, padding: "11px 20px",
        background: "color-mix(in srgb, var(--bg) 88%, transparent)", backdropFilter: "blur(14px)", borderBottom: "1px solid var(--border)" }}>
        <button onClick={onBack} style={{ display: "flex", alignItems: "center", gap: 7, background: "var(--surface-2)", border: "1px solid var(--border)", borderRadius: 9, padding: "8px 12px", color: "var(--text)", fontSize: 13, fontWeight: 600 }}>
          {React.createElement(Icon.back, { size: 17 })} {admin ? "Parc" : "Devices"}
        </button>
        <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
          <StatusDot status={device.status} pulse />
          <div style={{ lineHeight: 1.2 }}>
            <div style={{ fontWeight: 700, fontSize: 15, display: "flex", alignItems: "center", gap: 7 }}>
              {engMode && <span title={engMode === "hcbox" ? "Piloté via HC BOX" : "Moteur interne (NKReal PhoneFarm)"} style={{ fontSize: 9.5, fontWeight: 800, padding: "2px 6px", borderRadius: 6, letterSpacing: ".06em", background: engMode === "hcbox" ? "rgba(34,211,238,.18)" : "var(--accent-soft)", color: engMode === "hcbox" ? "#22d3ee" : "var(--accent)", border: "1px solid " + (engMode === "hcbox" ? "rgba(34,211,238,.4)" : "var(--accent-line)") }}>{engMode === "hcbox" ? "HC" : "MI"}</span>}
              <span>{device.tag} <span className="mono" style={{ fontWeight: 400, color: "var(--faint)", fontSize: 12 }}>· {device.name}</span></span>
            </div>
            <div className="mono" style={{ fontSize: 10.5, color: "var(--faint)" }}>{device.udid}</div>
          </div>
        </div>
        <div style={{ flex: 1 }} />
        <div style={{ display: "flex", alignItems: "center", gap: 8 }} className="dv-stats">
          <Stat icon="grid" label="résolution" value="390×844" />
          <Stat icon="bolt" label="latence" value={pingMs == null ? "—" : `${pingMs} ms`} tone={pingMs != null && pingMs > 200 ? "amber" : "green"} />
          <Stat icon="play" label="fps" value={offline ? "0" : ((mode === "h264" && h264Up && realFps) ? realFps : fps)} tone={(!offline && mode === "h264" && h264Up && realFps < 12) ? "amber" : "green"} />
          <Stat icon="battery" label="batterie" value={`${device.battery}%`} tone={device.battery < 35 ? "amber" : "green"} />
        </div>
        <button style={{ background: "var(--surface-2)", border: "1px solid var(--border)", borderRadius: 9, width: 38, height: 38, display: "grid", placeItems: "center", color: "var(--muted)" }}>
          {React.createElement(Icon.fullscreen, { size: 18 })}
        </button>
        {!admin && <OperatorBell user={user} />}
      </header>}

      <div style={{ display: "grid", gridTemplateColumns: embedded ? "minmax(0, 0.92fr) auto minmax(0, 1.25fr)" : "330px minmax(300px, 1fr) 392px", gap: embedded ? 10 : 22, padding: embedded ? 0 : "22px 22px 40px", maxWidth: embedded ? "none" : 1500, margin: "0 auto", width: "100%", alignItems: "start" }} className="dv-grid app-fade">
        {/* gauche : info compte + bloc-note (cloud) + tâches + journal */}
        <div className="dv-tasks">
          {liveDev && <AccountPanel device={device} user={user} />}
          <>
            <div style={{ display: "flex", alignItems: "center", gap: 8, margin: "2px 2px 13px", color: "var(--muted)" }}>
              {React.createElement(Icon.clipboard, { size: 16 })}
              <span style={{ fontSize: 12, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase" }}>{admin && !embedded ? "Tâches de cet iPhone" : "Mes tâches"}</span>
            </div>
            <MyTasks device={device} user={user} compact={embedded} />
          </>
          <div style={{ display: "flex", alignItems: "center", gap: 8, margin: admin ? "2px 2px 13px" : "20px 2px 13px", color: "var(--muted)" }}>
            {React.createElement(Icon.list, { size: 16 })}
            <span style={{ fontSize: 12, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase" }}>Journal</span>
          </div>
          <ActionLog actions={actions} />
        </div>

        {/* centre : écran téléphone */}
        <div className="dv-phone" style={{ display: "grid", placeItems: "center", position: embedded ? "static" : "sticky", top: 78 }}>
          <Phone device={device} w={embedded ? "clamp(180px, 18.5vw, 300px)" : undefined} big={!embedded} mode={mode} videoRef={videoRef} canvasRef={canvasRef} liveSrc={liveSrc} hqSrc={hqSrc} lost={feedLost} connecting={connecting} onDown={onDown} onUp={onUp} onMove={onMove} onTap={onTapScreen} ripples={ripples} onFeedError={onFeedError} onFeedOk={onFeedOk} />
          <div className="mono" style={{ marginTop: 14, fontSize: 11.5, color: "var(--faint)", display: "flex", alignItems: "center", gap: 8, textAlign: "center", maxWidth: 300 }}>
            <span style={{ width: 6, height: 6, borderRadius: 99, background: dead ? "var(--faint)" : "var(--accent)", animation: dead ? "none" : "streamPulse 1.2s infinite", flex: "none" }} />
            {dead ? "flux interrompu" : t("tap_swipe_hint")}
          </div>
          {/* badge de mode : sait-on tout de suite si on est en vidéo HD (H.264) ou en repli image */}
          {liveDev && !dead && (
            <div className="mono" style={{ marginTop: 6, fontSize: 10.5, fontWeight: 700, letterSpacing: ".04em", padding: "3px 9px", borderRadius: 99,
              background: mode === "h264" ? "rgba(0,224,138,.12)" : "rgba(245,185,66,.12)",
              color: mode === "h264" ? "var(--accent)" : "var(--busy)",
              border: "1px solid " + (mode === "h264" ? "rgba(0,224,138,.3)" : "rgba(245,185,66,.3)") }}>
              {mode === "h264" ? (h264Up ? "● HD · vidéo H.264" : "○ connexion vidéo…") : "● éco · image (repli)"}
            </div>
          )}
          <div style={{ marginTop: 12, display: "flex", gap: 8, flexWrap: "wrap", justifyContent: "center" }}>
            {liveDev && <button onClick={() => reconnect()} title="Reconnecter le flux de l'écran"
              style={{ display: "inline-flex", alignItems: "center", gap: 8, background: "var(--surface-2)", border: "1px solid var(--border-2)", borderRadius: 9, padding: "8px 14px", color: "var(--text)", fontSize: 13, fontWeight: 600, cursor: "pointer" }}>
              {React.createElement(Icon.refresh, { size: 16 })} {t("refresh_screen")}</button>}
            {/* RELANCE MOTEUR : sort le tel de l'état "pause / WDA instable" sans débrancher l'iPhone.
                Caché pour les tels sur HC BOX (le moteur interne ne les pilote pas). */}
            {liveDev && engMode !== "hcbox" && <button onClick={() => restartEngine()} disabled={restarting}
              title="Redémarre WebDriverAgent sur le téléphone (corrige l'état « en pause / instable » sans débrancher)"
              style={{ display: "inline-flex", alignItems: "center", gap: 8, background: restarting ? "var(--surface-2)" : "rgba(245,185,66,.14)", border: "1px solid " + (restarting ? "var(--border-2)" : "rgba(245,185,66,.4)"), borderRadius: 9, padding: "8px 14px", color: restarting ? "var(--muted)" : "var(--busy, #f5b942)", fontSize: 13, fontWeight: 600, cursor: restarting ? "default" : "pointer", opacity: restarting ? 0.7 : 1 }}>
              {React.createElement(Icon.refresh, { size: 16 })} {restarting ? "Relance en cours…" : "Relancer le moteur"}</button>}
          </div>
          <div style={{ marginTop: 14, width: embedded ? "100%" : "min(330px, 78vw)" }}>
            <RecordButton device={device} by={user} dead={dead} />
          </div>
        </div>

        {/* droite : télécommande */}
        <div className="dv-remote">
          <div style={{ display: "flex", alignItems: "center", gap: 8, margin: "2px 2px 13px", color: "var(--muted)" }}>
            {React.createElement(Icon.apps, { size: 16 })}
            <span style={{ fontSize: 12, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase" }}>{t("remote")}</span>
          </div>
          <Remote act={act} busyBtn={busyBtn} dead={dead} actions={actions} fps={fps} setFps={setFps} compact={embedded} device={device} mode={mode} />
          {liveDev && <TextSend device={device} dead={dead} />}
          {liveDev && <MediaSend device={device} dead={dead} />}
        </div>
      </div>
    </div>
  );
}

// Saisie de texte (télécommande) : envoie le texte au champ ACTIF du téléphone (moteur /text -> WDA /wda/keys).
// ⚠️ il faut d'abord taper sur un champ du tel (pour le curseur) — WDA tape dans l'élément qui a le focus.
function TextSend({ device, dead }) {
  const { useState } = React;
  const [val, setVal] = useState("");
  const [tick, setTick] = useState(0);
  const send = (text) => {
    if (!text || dead) return;
    window.Backend.action(device.udid, "inputText", { text });
    if (window.Backend.logAction) window.Backend.logAction(device.udid, "Texte", text === "\n" ? "⏎ Entrée" : (text.length > 24 ? text.slice(0, 24) + "…" : text), device.tag);
    setTick(Date.now());
  };
  const card = { background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 12, padding: 12, marginTop: 14 };
  const btn = (extra) => ({ background: "var(--surface-2)", border: "1px solid var(--border)", borderRadius: 9, padding: "9px 12px", color: "var(--text)", fontSize: 13, fontWeight: 600, cursor: dead ? "default" : "pointer", ...extra });
  return (
    <div style={card}>
      <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 9, color: "var(--muted)" }}>
        <span style={{ fontSize: 14 }}>⌨️</span>
        <span style={{ fontSize: 12, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase" }}>Saisie de texte</span>
        {tick > 0 && <span style={{ fontSize: 11, color: "var(--accent)" }}>✓ envoyé</span>}
      </div>
      <textarea value={val} onChange={e => setVal(e.target.value)} rows={2}
        placeholder="Tape ton texte, puis Envoyer…  (Ctrl+Entrée = envoyer)"
        onKeyDown={e => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(val); } }}
        style={{ width: "100%", background: "var(--surface-2)", border: "1px solid var(--border)", borderRadius: 8, padding: "8px 10px", color: "var(--text)", fontSize: 13.5, resize: "vertical", boxSizing: "border-box", fontFamily: "inherit" }} />
      <div style={{ display: "flex", gap: 7, marginTop: 8 }}>
        <button onClick={() => send(val)} disabled={dead || !val} style={btn({ flex: 1, background: "var(--accent)", color: "#04130c", borderColor: "transparent", opacity: (dead || !val) ? 0.5 : 1 })}>Envoyer</button>
        <button onClick={() => send("\n")} disabled={dead} title="Touche Entrée" style={btn()}>⏎</button>
      </div>
      <div className="mono" style={{ fontSize: 10.5, color: "var(--faint)", marginTop: 8, lineHeight: 1.4 }}>
        Tape d'abord sur un champ du téléphone (pour le curseur), puis envoie.
      </div>
    </div>
  );
}

// Envoi de média (télécommande) : pousse une image/vidéo de la bibliothèque du tel vers sa PELLICULE
// (backend /push -> moteur AFC fsync vers /DCIM). ⚠️ apparition dans Photos non garantie sur iOS récent.
function MediaSend({ device, dead }) {
  const { useState, useEffect } = React;
  const [dir, setDir] = useState("");
  const [data, setData] = useState({ folders: [], files: [] });
  const [busy, setBusy] = useState("");
  const [msg, setMsg] = useState(null);
  const load = async (d) => { const r = await window.Backend.media(device.udid, d); if (r) setData({ folders: r.folders || [], files: r.files || [] }); };
  useEffect(() => { load(dir); }, [dir, device.udid]);
  const push = async (f) => {
    if (dead) return;
    setBusy(f.name); setMsg(null);
    const r = await window.Backend.pushMedia(device.udid, f.name, dir);
    setBusy("");
    const ok = r && r.pushed && (!r.result || !r.result.r || r.result.r.ok !== false) && !r.error;
    if (ok) { setMsg({ ok: true, t: "✓ " + f.name + " → pellicule" }); if (window.Backend.logAction) window.Backend.logAction(device.udid, "Média → tel", f.name, device.tag); }
    else setMsg({ ok: false, t: "✗ " + ((r && r.error) || (r && r.result && r.result.r && r.result.r.stderr) || "échec") });
  };
  const card = { background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 12, padding: 12, marginTop: 14 };
  const row = { display: "flex", alignItems: "center", gap: 8, padding: "6px 0", borderBottom: "1px solid var(--border)" };
  return (
    <div style={card}>
      <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 9, color: "var(--muted)" }}>
        <span style={{ fontSize: 14 }}>🖼️</span>
        <span style={{ fontSize: 12, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase" }}>Envoyer un média</span>
      </div>
      {dir && <button onClick={() => setDir(dir.split("/").slice(0, -1).join("/"))} style={{ background: "none", border: "none", color: "var(--accent)", cursor: "pointer", fontSize: 12, padding: "2px 0 8px" }}>← retour</button>}
      <div style={{ maxHeight: 220, overflowY: "auto" }}>
        {data.folders.map(f => (
          <div key={f.name} style={row}>
            <span style={{ fontSize: 14 }}>📁</span>
            <button onClick={() => setDir(dir ? dir + "/" + f.name : f.name)} style={{ flex: 1, textAlign: "left", background: "none", border: "none", color: "var(--text)", cursor: "pointer", fontSize: 13 }}>{f.name}</button>
          </div>
        ))}
        {data.files.map(f => (
          <div key={f.name} style={row}>
            <span style={{ fontSize: 13 }}>{f.kind === "video" ? "🎬" : "🖼️"}</span>
            <span style={{ flex: 1, fontSize: 12.5, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={f.name}>{f.name}</span>
            <button onClick={() => push(f)} disabled={dead || busy === f.name} style={{ background: "var(--surface-2)", border: "1px solid var(--border)", borderRadius: 8, padding: "5px 10px", color: "var(--text)", fontSize: 12, fontWeight: 600, cursor: dead ? "default" : "pointer" }}>{busy === f.name ? "…" : "📤 Envoyer"}</button>
          </div>
        ))}
        {!data.folders.length && !data.files.length && <div className="mono" style={{ fontSize: 11.5, color: "var(--faint)", padding: "8px 0" }}>Aucun média. Ajoute des fichiers dans la bibliothèque (section « Dossiers »).</div>}
      </div>
      {msg && <div style={{ fontSize: 12, marginTop: 8, color: msg.ok ? "var(--accent)" : "var(--danger, #ff6b6b)" }}>{msg.t}</div>}
      <div className="mono" style={{ fontSize: 10, color: "var(--faint)", marginTop: 8, lineHeight: 1.4 }}>⚠️ Si le média n'apparaît pas dans Photos, c'est une limite iOS — à confirmer en live.</div>
    </div>
  );
}

// Carte "Information compte" + "Bloc-note" par téléphone — persistées dans le CLOUD (D1).
// Éditable par l'opérateur ASSIGNÉ au tel (ou manager/owner). Le mot de passe est masqué.
const ACCT_STATUSES = [
  { k: "cree", label: "🆕 Créé", c: "#22d3ee" },
  { k: "echauffement", label: "🔥 Échauffement", c: "#f5b942" },
  { k: "actif", label: "✅ Actif", c: "#00e08a" },
  { k: "suspendu", label: "⛔ Suspendu", c: "#ff6b6b" },
];
function AccountPanel({ device, user }) {
  const { useState, useEffect, useRef } = React;
  const MAX = 3;
  const pad3 = (arr) => {
    const a = (Array.isArray(arr) ? arr : []).slice(0, MAX).map(x => ({ name: x.name || "", pass: x.pass || "", created: x.created || "", status: x.status || "" }));
    while (a.length < MAX) a.push({ name: "", pass: "", created: "", status: "" });
    return a;
  };
  const [accts, setAccts] = useState(() => pad3(device.accounts));
  const [notes, setNotes] = useState(device.notes || "");
  const [open, setOpen] = useState({ 0: true });      // sections compte dépliées (1 ouverte au départ)
  const [grpOpen, setGrpOpen] = useState(true);       // groupe "Information compte" entier
  const [notesOpen, setNotesOpen] = useState(false);
  const [showPass, setShowPass] = useState({});
  const [savedTick, setSavedTick] = useState(0);
  const tA = useRef(null), tN = useRef(null);
  const myRank = window.ROLE_RANK ? window.ROLE_RANK(user && user.role) : 0;
  const canEdit = (myRank >= 2) || (device.operator_id && user && device.operator_id === user.id);

  useEffect(() => { setAccts(pad3(device.accounts)); setNotes(device.notes || ""); }, [device.udid]);

  const saveAccts = (next, immediate) => {
    const run = async () => { const r = await window.Backend.patchMeta(device.udid, { accounts: JSON.stringify(next) }); if (r && r.status === 200) setSavedTick(Date.now()); };
    if (tA.current) clearTimeout(tA.current);
    if (immediate) run(); else tA.current = setTimeout(run, 600);
  };
  const setField = (i, k, v) => setAccts(prev => { const next = prev.map((a, j) => j === i ? { ...a, [k]: v } : a); saveAccts(next); return next; });
  const setStatus = (i, v) => setAccts(prev => { const next = prev.map((a, j) => j === i ? { ...a, status: v } : a); saveAccts(next, true); return next; });
  const saveNotes = (v) => { if (tN.current) clearTimeout(tN.current); tN.current = setTimeout(async () => { const r = await window.Backend.patchMeta(device.udid, { notes: v }); if (r && r.status === 200) setSavedTick(Date.now()); }, 600); };

  const lbl = { fontSize: 10.5, color: "var(--muted)", fontWeight: 600, marginBottom: 4, display: "block" };
  const inp = { width: "100%", background: "var(--surface-2)", border: "1px solid var(--border)", borderRadius: 8, padding: "7px 10px", color: "var(--text)", fontSize: 13, boxSizing: "border-box" };
  const cardSt = { background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 12, overflow: "hidden" };
  const headSt = { display: "flex", alignItems: "center", gap: 8, width: "100%", background: "none", border: "none", cursor: "pointer", color: "var(--text)", padding: "10px 12px", textAlign: "left", boxSizing: "border-box" };
  const chevron = (op) => <span style={{ color: "var(--faint)", fontSize: 12, transform: op ? "rotate(90deg)" : "none", transition: "transform .15s", flex: "none" }}>▸</span>;

  return (
    <div style={{ display: "grid", gap: 9, marginBottom: 18 }}>
      <button onClick={() => setGrpOpen(o => !o)} style={{ display: "flex", alignItems: "center", gap: 8, margin: "2px 2px 0", color: "var(--muted)", background: "none", border: "none", cursor: "pointer", padding: 0, width: "100%", textAlign: "left" }}>
        {React.createElement(Icon.apps, { size: 16 })}
        <span style={{ fontSize: 12, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase" }}>Information compte</span>
        {savedTick > 0 && <span style={{ fontSize: 11, color: "var(--accent)" }}>✓</span>}
        <span style={{ marginLeft: "auto", color: "var(--faint)", fontSize: 12, transform: grpOpen ? "rotate(90deg)" : "none", transition: "transform .15s" }}>▸</span>
      </button>
      {grpOpen && accts.map((acc, i) => {
        const st = ACCT_STATUSES.find(s => s.k === acc.status);
        const isOpen = !!open[i];
        return (
          <div key={i} style={cardSt}>
            <button onClick={() => setOpen(o => ({ ...o, [i]: !o[i] }))} style={headSt}>
              <span style={{ fontSize: 12.5, fontWeight: 700, flex: "none" }}>Compte {i + 1}</span>
              <span className="mono" style={{ fontSize: 11.5, color: "var(--faint)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}>{acc.name || "— vide —"}</span>
              {st && <span title={st.label} style={{ fontSize: 13, flex: "none" }}>{st.label.split(" ")[0]}</span>}
              {chevron(isOpen)}
            </button>
            {isOpen && <div style={{ display: "grid", gap: 9, padding: "0 12px 12px" }}>
              <div><label style={lbl}>Nom du compte</label>
                <input style={inp} value={acc.name} disabled={!canEdit} placeholder="@pseudo…" onChange={e => setField(i, "name", e.target.value)} /></div>
              <div><label style={lbl}>Mot de passe</label>
                <div style={{ display: "flex", gap: 6 }}>
                  <input style={inp} type={showPass[i] ? "text" : "password"} value={acc.pass} disabled={!canEdit} placeholder="••••••••" onChange={e => setField(i, "pass", e.target.value)} />
                  <button onClick={() => setShowPass(s => ({ ...s, [i]: !s[i] }))} title="Afficher / masquer" style={{ background: "var(--surface-2)", border: "1px solid var(--border)", borderRadius: 8, padding: "0 10px", color: "var(--muted)", cursor: "pointer", flex: "none" }}>{showPass[i] ? "🙈" : "👁️"}</button>
                </div></div>
              <div><label style={lbl}>Date de création</label>
                <input style={inp} type="date" value={acc.created} disabled={!canEdit} onChange={e => setField(i, "created", e.target.value)} /></div>
              <div><label style={lbl}>Statut</label>
                <div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
                  {ACCT_STATUSES.map(s => { const on = s.k === acc.status; return <button key={s.k} disabled={!canEdit} onClick={() => setStatus(i, on ? "" : s.k)} style={{ fontSize: 11, fontWeight: 600, padding: "5px 9px", borderRadius: 99, cursor: canEdit ? "pointer" : "default", background: on ? s.c + "22" : "var(--surface-2)", color: on ? s.c : "var(--muted)", border: "1px solid " + (on ? s.c + "66" : "var(--border)") }}>{s.label}</button>; })}
                </div></div>
            </div>}
          </div>
        );
      })}
      {/* ---- Bloc-note repliable ---- */}
      <div style={cardSt}>
        <button onClick={() => setNotesOpen(o => !o)} style={headSt}>
          {React.createElement(Icon.list, { size: 15, color: "var(--muted)" })}
          <span style={{ fontSize: 12.5, fontWeight: 700, flex: "none" }}>Bloc-note</span>
          <span className="mono" style={{ fontSize: 11, color: "var(--faint)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}>{!notesOpen && notes ? notes : ""}</span>
          {chevron(notesOpen)}
        </button>
        {notesOpen && <div style={{ padding: "0 12px 12px", display: "grid", gap: 6 }}>
          <textarea value={notes} disabled={!canEdit} placeholder="Notes sur ce téléphone… (visible par toi et le propriétaire)" onChange={e => { setNotes(e.target.value); saveNotes(e.target.value); }} style={{ ...inp, minHeight: 80, resize: "vertical", lineHeight: 1.5, fontFamily: "inherit" }} />
          {device.notesBy && <div className="mono" style={{ fontSize: 10.5, color: "var(--faint)" }}>✍️ par <b style={{ color: "var(--muted)" }}>{device.notesBy}</b>{device.notesAt ? " · " + device.notesAt : ""}</div>}
        </div>}
      </div>
      {!canEdit && <div style={{ fontSize: 11, color: "var(--faint)" }}>Lecture seule (téléphone non assigné à ton compte).</div>}
    </div>
  );
}

// Journal d'audit CLOUD : qui a fait quoi sur ce téléphone, quand. Filtrable. Preuve d'usage.
function LogsPanel({ device, user }) {
  const { useState, useEffect } = React;
  const [logs, setLogs] = useState([]);
  const [open, setOpen] = useState(false);
  const [onlyMine, setOnlyMine] = useState(false);
  const [loading, setLoading] = useState(false);
  const myRank = window.ROLE_RANK ? window.ROLE_RANK(user && user.role) : 0;
  const load = async () => {
    if (!window.Backend.logs) return;
    setLoading(true);
    try { await window.Backend.flushLogs(); } catch (e) {}   // pour voir ses propres actions récentes tout de suite
    const f = { udid: device.udid, limit: "120" };
    if (onlyMine && user) f.user = user.id;
    const l = await window.Backend.logs(f);
    setLogs(Array.isArray(l) ? l : []); setLoading(false);
  };
  useEffect(() => { if (open) load(); }, [open, onlyMine, device.udid]);
  const cardSt = { background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 12, overflow: "hidden" };
  const headSt = { display: "flex", alignItems: "center", gap: 8, width: "100%", background: "none", border: "none", cursor: "pointer", color: "var(--text)", padding: "10px 12px", textAlign: "left", boxSizing: "border-box" };
  return (
    <div style={{ marginBottom: 18 }}>
      <div style={cardSt}>
        <button onClick={() => setOpen(o => !o)} style={headSt}>
          {React.createElement(Icon.list, { size: 15, color: "var(--muted)" })}
          <span style={{ fontSize: 12.5, fontWeight: 700, flex: "none" }}>Logs (audit)</span>
          <span className="mono" style={{ fontSize: 10.5, color: "var(--faint)", flex: 1 }}>{!open ? "qui a fait quoi · cloud" : ""}</span>
          <span style={{ color: "var(--faint)", fontSize: 12, transform: open ? "rotate(90deg)" : "none", transition: "transform .15s" }}>▸</span>
        </button>
        {open && <div style={{ padding: "0 12px 12px" }}>
          <div style={{ display: "flex", gap: 6, marginBottom: 8, alignItems: "center" }}>
            {myRank >= 2 && <button onClick={() => setOnlyMine(m => !m)} style={{ fontSize: 11, fontWeight: 600, padding: "5px 9px", borderRadius: 99, cursor: "pointer", background: onlyMine ? "var(--accent)22" : "var(--surface-2)", color: onlyMine ? "var(--accent)" : "var(--muted)", border: "1px solid var(--border)" }}>Mes actions</button>}
            <button onClick={load} style={{ fontSize: 11, padding: "5px 9px", borderRadius: 8, cursor: "pointer", background: "var(--surface-2)", color: "var(--muted)", border: "1px solid var(--border)" }}>↻ Rafraîchir</button>
            <span style={{ marginLeft: "auto", fontSize: 10.5, color: "var(--faint)" }}>{logs.length}</span>
          </div>
          <div style={{ maxHeight: 260, overflowY: "auto", display: "grid", gap: 4 }}>
            {loading ? <div style={{ fontSize: 11.5, color: "var(--faint)" }}>chargement…</div>
              : logs.length === 0 ? <div style={{ fontSize: 11.5, color: "var(--faint)" }}>Aucune action enregistrée pour l'instant.</div>
                : logs.map(l => (
                  <div key={l.id} className="mono" style={{ fontSize: 10.8, lineHeight: 1.45, padding: "5px 7px", background: "var(--surface-2)", borderRadius: 7, display: "flex", gap: 6, flexWrap: "wrap" }}>
                    <span style={{ color: "var(--faint)" }}>{(l.at || "").slice(5, 16)}</span>
                    <span style={{ color: "var(--accent)", fontWeight: 700 }}>{l.user_name}</span>
                    <span style={{ color: "var(--text)" }}>{l.action}</span>
                    {l.detail ? <span style={{ color: "var(--muted)" }}>{l.detail}</span> : null}
                  </div>
                ))}
          </div>
        </div>}
      </div>
    </div>
  );
}

// Écran de chargement (logo NKReal + spinner) pendant l'établissement du flux
function LoadingScreen({ label }) {
  return (
    <div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center", background: "#0b0f17", padding: 16 }}>
      <style>{"@keyframes plspin{to{transform:rotate(360deg)}}"}</style>
      <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 16 }}>
        {typeof Logo !== "undefined" ? React.createElement(Logo, { small: true }) : <div style={{ color: "#e6e9ef", fontWeight: 800, fontSize: 18 }}>NKReal</div>}
        <div style={{ width: 30, height: 30, borderRadius: "50%", border: "3px solid rgba(255,255,255,.12)", borderTopColor: "var(--accent)", animation: "plspin .8s linear infinite" }} />
        <div className="mono" style={{ fontSize: 12, color: "#9aa", letterSpacing: ".03em" }}>{label || "Tentative de connexion…"}</div>
      </div>
    </div>
  );
}

function Phone({ device, onDown, onUp, onMove, onTap, ripples, w, big = true, mode, videoRef, canvasRef, liveSrc, hqSrc, lost, connecting, onFeedError, onFeedOk }) {
  const [imgErr, setImgErr] = React.useState(false);
  const HAS_WC = typeof VideoDecoder !== "undefined";
  // expose l'élément affiché (img Diesel OU video Ferrari) pour l'enregistrement (recordings.jsx)
  const regLive = (el) => {
    window.__LIVE_IMG = window.__LIVE_IMG || {};
    if (device && device.udid) { if (el) window.__LIVE_IMG[device.udid] = el; else delete window.__LIVE_IMG[device.udid]; }
  };
  const setLiveImg = regLive;
  const setLiveVid = (el) => { if (videoRef) videoRef.current = el; regLive(el); };
  const setLiveCanvas = (el) => { if (canvasRef) canvasRef.current = el; regLive(el); };
  const imgTap = (e) => {
    if (!onTap) return;
    const r = e.currentTarget.getBoundingClientRect();
    onTap({ x: +((e.clientX - r.left) / r.width).toFixed(3), y: +((e.clientY - r.top) / r.height).toFixed(3) });
  };
  return (
    <div style={{ position: "relative", width: w || "min(330px, 78vw)", aspectRatio: "390 / 844", flex: "none" }}>
      {/* bezel */}
      <div style={{ position: "absolute", inset: 0, borderRadius: 52, background: "linear-gradient(160deg,#2a3247,#11151f)", padding: 12,
        boxShadow: "0 30px 70px rgba(0,0,0,.55), inset 0 0 0 1.5px rgba(255,255,255,.05)" }}>
        <div style={{ position: "relative", width: "100%", height: "100%", borderRadius: 42, overflow: "hidden", background: "#000",
          containerType: "inline-size" }} onMouseDown={onDown} onMouseUp={onUp} onMouseMove={onMove} onMouseLeave={onUp}>
          {/* RETOUR VISUEL INSTANTANÉ : un cercle apparaît au clic AVANT que le tel réponde */}
          <style>{"@keyframes pl-ripple{0%{transform:scale(.2);opacity:.95}100%{transform:scale(1.15);opacity:0}}"}</style>
          {(ripples || []).map(p => (
            <span key={p.id} style={{ position: "absolute", left: (p.x * 100) + "%", top: (p.y * 100) + "%",
              width: 44, height: 44, marginLeft: -22, marginTop: -22, borderRadius: "50%",
              border: "2.5px solid var(--accent, #00e08a)", boxShadow: "0 0 12px var(--accent, #00e08a)",
              pointerEvents: "none", zIndex: 30, animation: "pl-ripple .48s ease-out forwards" }} />
          ))}
          {mode === "h264"
            ? <>
                {/* Ferrari : vraie vidéo H.264. WebCodecs -> <canvas> (principal) ; sinon jMuxer/MSE -> <video> (repli) */}
                {HAS_WC
                  ? <canvas ref={setLiveCanvas} onClick={imgTap}
                      style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", display: "block", cursor: onTap ? "crosshair" : "default", background: "#0b0f17" }} />
                  : <video ref={setLiveVid} muted autoPlay playsInline onClick={imgTap}
                      style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", display: "block", cursor: onTap ? "crosshair" : "default", background: "#0b0f17" }} />}
                {connecting && <LoadingScreen />}
              </>
            : liveSrc
            ? <>
                {/* la dernière frame reste affichée même pendant un raté du tunnel */}
                <img src={liveSrc} draggable={false} crossOrigin="anonymous" ref={setLiveImg} onClick={imgTap}
                  onError={() => { setImgErr(true); if (onFeedError) onFeedError(); }}
                  onLoad={() => { setImgErr(false); if (onFeedOk) onFeedOk(); }}
                  style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", display: "block", cursor: onTap ? "crosshair" : "default", background: "#0b0f17", filter: lost ? "grayscale(.6) brightness(.55)" : "none", transition: "filter .3s" }} />
                {/* "net au repos" : image HD superposée quand on ne bouge pas (clics traversent -> pointerEvents none) */}
                <img src={hqSrc || ""} draggable={false} alt=""
                  style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", display: hqSrc ? "block" : "none", pointerEvents: "none", zIndex: 2, background: "#0b0f17" }} />
                {(lost || imgErr) && <div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center", color: "#cdd3df", textAlign: "center", padding: 16, gap: 8, pointerEvents: "none", zIndex: 3 }}>
                  <div style={{ background: "rgba(7,10,18,.72)", borderRadius: 12, padding: "12px 14px", fontSize: 12, lineHeight: 1.5 }}>
                    {React.createElement(Icon.refresh, { size: 20 })}<br />{t("unstable_conn")}<br /><span style={{ color: "#9aa", fontSize: 11 }}>(si ça persiste : iPhone verrouillé ?)</span>
                  </div>
                </div>}
              </>
            : connecting
              ? <LoadingScreen />
              : <IOSScreen device={device} big={big} onTap={onTap} />}
          {/* dynamic island — UNIQUEMENT sur l'écran fictif (le flux réel contient déjà la vraie barre de statut) */}
          {mode !== "h264" && !liveSrc && !connecting && device.status !== "offline" && (
            <div style={{ position: "absolute", top: big ? 12 : "1.6%", left: "50%", transform: "translateX(-50%)", width: big ? 96 : "28%", height: big ? 28 : "3.8%", background: "#000", borderRadius: 99, zIndex: 8, display: "var(--island, block)" }} />
          )}
        </div>
      </div>
      {/* boutons physiques */}
      <div style={{ position: "absolute", left: -3, top: "20%", width: 3, height: 38, background: "#1b2233", borderRadius: 3 }} />
      <div style={{ position: "absolute", left: -3, top: "30%", width: 3, height: 58, background: "#1b2233", borderRadius: 3 }} />
      <div style={{ position: "absolute", right: -3, top: "26%", width: 3, height: 74, background: "#1b2233", borderRadius: 3 }} />
    </div>
  );
}

function Stat({ icon, label, value, tone }) {
  const c = tone === "amber" ? "var(--busy)" : tone === "green" ? "var(--accent)" : "var(--text)";
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 7, padding: "6px 11px", background: "var(--surface-2)", border: "1px solid var(--border)", borderRadius: 9 }}>
      {React.createElement(Icon[icon], { size: 15, color: "var(--faint)" })}
      <div style={{ lineHeight: 1.15 }}>
        <div className="num" style={{ fontSize: 12.5, fontWeight: 700, color: c }}>{value}</div>
        <div className="mono" style={{ fontSize: 8.5, color: "var(--faint)", letterSpacing: ".06em", textTransform: "uppercase" }}>{label}</div>
      </div>
    </div>
  );
}

function now() {
  const d = new Date();
  return [d.getHours(), d.getMinutes(), d.getSeconds()].map(n => String(n).padStart(2, "0")).join(":");
}

// fps de départ (clampé à la plage du curseur), 0 si hors-ligne
function offlineFps(device) {
  if (device.status === "offline") return 0;
  return Math.min(20, Math.max(1, device.fps || 8));
}

Object.assign(window, { DeviceView, Phone, Stat, now });
