const WS_URL = "wss://api.usegreenroom.app/ws/interview";

// Pro/Premium can pick which GitHub repos Ari draws questions from.
// Free users always get the full auto-picked list (selection is ignored
// gracefully on free, with an upgrade nudge in the UI).
function effectiveRepos(jobContext, subscriptionPlan) {
  const all = jobContext?.githubTopRepos || [];
  if (subscriptionPlan === "free") return all;
  const selected = jobContext?.selectedRepos;
  if (!Array.isArray(selected) || selected.length === 0) return all;
  const set = new Set(selected);
  const filtered = all.filter(r => set.has(r.name));
  return filtered.length ? filtered : all;
}

function useMobileView(bp = 768) {
  const [mobile, setMobile] = React.useState(() => window.innerWidth < bp);
  React.useEffect(() => {
    const fn = () => setMobile(window.innerWidth < bp);
    window.addEventListener("resize", fn, { passive: true });
    return () => window.removeEventListener("resize", fn);
  }, [bp]);
  return mobile;
}

const FREE_TIER_LIMIT = 600; // 10 minutes
const PRO_MAX_MIN = 30;
const PREMIUM_MAX_MIN = 45;

function planMaxMinutes(plan) {
  if (plan === "premium") return PREMIUM_MAX_MIN;
  if (plan === "pro") return PRO_MAX_MIN;
  return 10;
}

function MockRoom({ onEnd, onHome, jobContext = {}, token = "", onUpgrade, subscriptionPlan = "free" }) {
  const isFree = subscriptionPlan === "free";
  const maxMinutes = planMaxMinutes(subscriptionPlan);
  const [selectedMinutes, setSelectedMinutes] = React.useState(maxMinutes);
  const [seconds, setSeconds] = React.useState(() => isFree ? FREE_TIER_LIMIT : 0);
  const [questions, setQuestions] = React.useState([]);
  const [qLoading, setQLoading] = React.useState(true);
  const [hasStream, setHasStream] = React.useState(false);
  const wsStartedRef = React.useRef(false);
  const [muted, setMuted] = React.useState(false);
  const [agentSpeaking, setAgentSpeaking] = React.useState(false);
  const [hasCamera, setHasCamera] = React.useState(false);
  const [camError, setCamError] = React.useState(null);
  const [micDenied, setMicDenied] = React.useState(false);
  const [requestingMic, setRequestingMic] = React.useState(false);
  const [videoOff, setVideoOff] = React.useState(false);
  const [started, setStarted] = React.useState(false);
  const [ending, setEnding] = React.useState(false);
  const [githubClaimed, setGithubClaimed] = React.useState(false);
  const [capHit, setCapHit] = React.useState(null); // { used, limit }
  const [status, setStatus] = React.useState("connecting");
  const [connectionState, setConnectionState] = React.useState("ok"); // ok | reconnecting | lost | stuck
  const [transcript, setTranscript] = React.useState([]);
  const [typingText, setTypingText] = React.useState("");
  const [voice, setVoice] = React.useState("female");
  const [language, setLanguage] = React.useState("python");
  const [showRating, setShowRating]       = React.useState(false);
  const [ratingStep, setRatingStep]       = React.useState(1); // 1 = realism, 2 = accuracy
  const [sessionRating, setSessionRating] = React.useState(0);
  const [accuracyRating, setAccuracyRating] = React.useState(0);
  const [mobileTab, setMobileTab] = React.useState("transcript");
  const isMobile = useMobileView(768);
  const editorRef = React.useRef(null);
  const monacoInstanceRef = React.useRef(null);

  const videoRef = React.useRef(null);
  const streamRef = React.useRef(null);       // audio-only, used for WS mic capture
  const recorderRef       = React.useRef(null);
  const recordedChunksRef = React.useRef([]);
  const videoStreamRef = React.useRef(null);  // video-only, managed separately
  const videoOffMounted = React.useRef(false); // skip effect on first render
  const wsRef = React.useRef(null);
  const isEndingRef = React.useRef(false);   // mirrors `ending` state for ws.onclose closure
  const sessionEndedRef = React.useRef(false); // true once "ended" received — prevents double onEnd
  const audioCtxRef = React.useRef(null);
  const processorRef = React.useRef(null);
  const sourceRef = React.useRef(null);
  const transcriptEndRef = React.useRef(null);
  const feedbackRef = React.useRef(null);
  const pendingFeedbackRef = React.useRef(null);
  const pingRef = React.useRef(null);
  const interviewStartRef = React.useRef(null);
  const isSpeakingRef = React.useRef(false);    // true while TTS is playing — pause mic send
  const currentTTSRef = React.useRef(null);     // current AudioBufferSourceNode — stop before new one
  const outputCtxRef = React.useRef(null);      // dedicated playback AudioContext (separate from mic)
  const nextPlayTimeRef = React.useRef(0);      // scheduled end of last queued chunk (Web Audio clock)
  const pendingChunksRef = React.useRef(0);    // count of chunks scheduled but not yet ended
  const readyToListenRef = React.useRef(false); // don't send mic audio until backend says "listening"
  const playQueueRef = React.useRef(Promise.resolve()); // serialise playWav to prevent nextPlayTimeRef race
  const interviewEndAtRef = React.useRef(null);  // wall-clock deadline — survives tab backgrounding
  const reconnectAttemptsRef = React.useRef(0);
  const reconnectTimerRef = React.useRef(null);
  const listenWatchdogRef = React.useRef(null);
  const wsStartSentRef = React.useRef(false);    // true after first "start" message accepted by server

  // ── Output AudioContext helpers (defined early so useEffects can call them) ──
  function getOutputCtx() {
    if (!outputCtxRef.current) {
      outputCtxRef.current = new AudioContext();
    }
    return outputCtxRef.current;
  }

  async function unlockOutputCtx() {
    const ctx = getOutputCtx();
    if (ctx.state === "suspended") {
      try { await ctx.resume(); } catch (e) { console.warn("AudioContext resume:", e); }
    }
    return ctx;
  }

  // ── Timer (wall-clock — resilient to tab throttling / sleep) ──
  const endingRef = React.useRef(false);
  React.useEffect(() => {
    // Free tier starts counting down immediately on mount (matches prior behaviour).
    if (isFree && interviewEndAtRef.current == null) {
      interviewEndAtRef.current = Date.now() + FREE_TIER_LIMIT * 1000;
    }
    const tick = () => {
      const endAt = interviewEndAtRef.current;
      if (endAt == null) return; // paid: not started yet
      const remaining = Math.max(0, Math.round((endAt - Date.now()) / 1000));
      setSeconds(remaining);
      if (remaining === 0 && !endingRef.current) {
        endingRef.current = true;
        handleEnd();
      }
    };
    tick();
    const id = setInterval(tick, 1000);
    return () => clearInterval(id);
  }, [isFree]);

  const mm = String(Math.floor(seconds / 60)).padStart(2, "0");
  const ss_str = String(seconds % 60).padStart(2, "0");

  // ── Fetch personalised questions from scraper ────────────
  React.useEffect(() => {
    if (!token) return;
    setQLoading(true);
    // Hard 12s ceiling so the pre-start screen can't hang forever on a stalled fetch.
    const ctrl = new AbortController();
    const timeoutId = setTimeout(() => ctrl.abort(), 12000);
    fetch("https://api.usegreenroom.app/api/questions/generate", {
      method: "POST",
      signal: ctrl.signal,
      headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
      body: JSON.stringify({
        role: jobContext.role || "Software Engineer",
        company: jobContext.company || "",
        seniority: jobContext.seniority || "mid",
        job_description: jobContext.jobDescription || "",
        github_languages: jobContext.githubLanguages || [],
        github_top_repos: effectiveRepos(jobContext, subscriptionPlan).map(r => ({
          name: r.name, language: r.language, description: r.description || "",
        })),
      }),
    })
      .then(r => r.json())
      .then(data => {
        const valid = (data.questions || []).filter(q => q && (q.prompt || q.text || "").trim());
        if (valid.length > 0) setQuestions(valid);
      })
      .catch(() => { })
      .finally(() => { clearTimeout(timeoutId); setQLoading(false); });
  }, [token]);

  // ── Mic is requested only after the candidate taps "Allow microphone" in the
  //    lobby (a user gesture) — never cold on mount. Firing the native browser
  //    prompt before we've explained what it's for is the main reason people
  //    bail, so we prime them with the trust card first. Camera is requested
  //    later, when they click Start. ──
  React.useEffect(() => {
    return () => cleanup();
  }, []);

  async function requestMic() {
    setMicDenied(false);
    setRequestingMic(true);
    try {
      const stream = await navigator.mediaDevices.getUserMedia({
        audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
      });
      streamRef.current = stream;
      setHasStream(true);
    } catch {
      // Denied or unavailable — show recovery guidance instead of a dead end.
      setMicDenied(true);
    } finally {
      setRequestingMic(false);
    }
  }

  // ── Tell the backend if the candidate closes the tab mid-interview ──
  // Avoids zombie sessions on the server (counter drain, hung resources).
  React.useEffect(() => {
    const onBeforeUnload = () => {
      const ws = wsRef.current;
      if (ws && ws.readyState === WebSocket.OPEN && !sessionEndedRef.current) {
        try { ws.send(JSON.stringify({ type: "end" })); } catch {}
      }
    };
    window.addEventListener("beforeunload", onBeforeUnload);
    return () => window.removeEventListener("beforeunload", onBeforeUnload);
  }, []);

  // ── Connect WS after Start is clicked AND questions have finished loading ──
  // Waiting for !qLoading ensures questions are always included in the start
  // message, preventing the server-side 8s question-wait deadlock.
  React.useEffect(() => {
    if (!hasStream || !started || wsStartedRef.current || qLoading) return;
    wsStartedRef.current = true;
    connectWS(streamRef.current);
  }, [hasStream, started, qLoading]);

  // ── Assign video stream to <video> once interview screen mounts ─────────────
  React.useEffect(() => {
    if (!started) return;
    if (videoStreamRef.current && videoRef.current) {
      videoRef.current.srcObject = videoStreamRef.current;
    }
  }, [started]);

  // ── Push questions to WS the moment they finish loading ──────────────────
  React.useEffect(() => {
    if (qLoading || !questions.length) return;
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify({ type: "questions_update", questions }));
    }
  }, [qLoading, questions]);

  React.useEffect(() => {
    streamRef.current?.getAudioTracks().forEach(t => { t.enabled = !muted; });
  }, [muted]);

  React.useEffect(() => {
    if (!videoOffMounted.current) { videoOffMounted.current = true; return; }
    if (videoOff) {
      videoStreamRef.current?.getTracks().forEach(t => t.stop());
      if (videoRef.current) videoRef.current.srcObject = null;
    } else {
      navigator.mediaDevices.getUserMedia({ video: true })
        .then(vs => {
          videoStreamRef.current = vs;
          if (videoRef.current) videoRef.current.srcObject = vs;
        })
        .catch(() => setCamError("Camera unavailable"));
    }
  }, [videoOff]);

  // ── Monaco Editor ────────────────────────────────────────
  // Depends on `started` because editorRef.current is null until the interview
  // screen renders (the div is inside the !started early-return branch).
  React.useEffect(() => {
    if (!started || !editorRef.current) return;
    const MONACO_CDN = "https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.44.0/min/vs";

    function initEditor() {
      if (monacoInstanceRef.current) return;
      window.require.config({ paths: { vs: MONACO_CDN } });
      window.require(["vs/editor/editor.main"], () => {
        if (!editorRef.current || monacoInstanceRef.current) return;
        monacoInstanceRef.current = monaco.editor.create(editorRef.current, {
          value: "# Write your solution here\n",
          language: "python",
          theme: "vs-dark",
          fontSize: 14,
          minimap: { enabled: false },
          scrollBeyondLastLine: false,
          lineNumbers: "on",
          automaticLayout: true,
          padding: { top: 12 },
        });
      });
    }

    if (window.require) {
      initEditor();
    } else {
      const script = document.createElement("script");
      script.src = `${MONACO_CDN}/loader.min.js`;
      script.onload = initEditor;
      document.head.appendChild(script);
    }

    return () => { monacoInstanceRef.current?.dispose(); monacoInstanceRef.current = null; };
  }, [started]);

  React.useEffect(() => {
    if (!monacoInstanceRef.current) return;
    const model = monacoInstanceRef.current.getModel();
    if (model) monaco.editor.setModelLanguage(model, language);
  }, [language]);

  // ── Auto-scroll transcript ───────────────────────────────
  React.useEffect(() => {
    transcriptEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [transcript]);

  // ── WebSocket ────────────────────────────────────────────
  async function submitRating(realism, accuracy) {
    const publicId = pendingFeedbackRef.current?.public_id;
    if (publicId && (realism > 0 || accuracy > 0)) {
      try {
        await fetch("https://api.usegreenroom.app/api/session/rating", {
          method: "POST",
          headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
          body: JSON.stringify({ public_id: publicId, rating: realism, accuracy_rating: accuracy }),
        });
      } catch {}
    }
    setShowRating(false);
    onEnd(pendingFeedbackRef.current);
  }

  function connectWS(stream) {
    if (!token) { setStatus("error"); return; }
    const ws = new WebSocket(`${WS_URL}?token=${encodeURIComponent(token)}`);
    ws.binaryType = "arraybuffer";
    wsRef.current = ws;

    ws.onopen = () => {
      try {
        // After a reconnect the server already has session state; ask it to
        // resume instead of restarting from question 1. Server may ignore
        // "resume" — if so, the candidate can still End to capture feedback.
        if (wsStartSentRef.current) {
          ws.send(JSON.stringify({ type: "resume" }));
          startAudioCapture(stream);
          setConnectionState("ok");
          // Re-arm watchdog: if server doesn't restore "listening" within 15s,
          // surface the stall instead of letting the candidate speak into void.
          clearTimeout(listenWatchdogRef.current);
          listenWatchdogRef.current = setTimeout(() => {
            if (!readyToListenRef.current && !isEndingRef.current) {
              setConnectionState("stuck");
            }
          }, 15000);
          // Re-arm ping so the new socket isn't reaped by an idle proxy.
          clearInterval(pingRef.current);
          pingRef.current = setInterval(() => {
            if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
          }, 20000);
          return;
        }
        wsStartSentRef.current = true;
        ws.send(JSON.stringify({
          type: "start",
          role: jobContext.role || "Software Engineer",
          company: jobContext.company || "",
          questions: questions.length > 0 ? questions : [],
          session_minutes: selectedMinutes,
          voice,
          github: jobContext.github || "",
          github_bio: jobContext.githubBio || "",
          github_company: jobContext.githubCompany || "",
          github_location: jobContext.githubLocation || "",
          github_top_repos: effectiveRepos(jobContext, subscriptionPlan),
          github_languages: jobContext.githubLanguages || [],
          linkedin_name: jobContext.linkedinName || "",
          linkedin_first_name: jobContext.linkedinFirstName || "",
          linkedin_last_name: jobContext.linkedinLastName || "",
          linkedin_email: jobContext.linkedinEmail || "",
          linkedin_headline: jobContext.linkedinHeadline || "",
          linkedin_bio: jobContext.linkedinBio || "",
          linkedin_experiences: jobContext.linkedinExperiences || [],
          linkedin_skills: jobContext.linkedinSkills || [],
          linkedin_posts: jobContext.linkedinPosts || [],
          job_description: jobContext.jobDescription || "",
        }));
        startAudioCapture(stream);
        interviewStartRef.current = Date.now();
        window.posthog?.capture('interview_started', {
          role: jobContext.role || "Software Engineer",
          company: jobContext.company || "",
          voice,
          has_github: !!(jobContext.github),
          has_linkedin: !!(jobContext.linkedinName),
          has_job_desc: !!(jobContext.jobDescription),
        });
        pingRef.current = setInterval(() => {
          if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
        }, 20000);
        // Watchdog: if backend hasn't said "listening" in 15s, the session is
        // wedged — surface it so the candidate isn't speaking into the void.
        clearTimeout(listenWatchdogRef.current);
        listenWatchdogRef.current = setTimeout(() => {
          if (!readyToListenRef.current && !isEndingRef.current) {
            setConnectionState("stuck");
          }
        }, 15000);
        // Start audio recording for replay (separate stream from WS pipeline)
        try {
          const recStream = new MediaStream(stream.getAudioTracks());
          const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus"
                    : MediaRecorder.isTypeSupported("audio/webm")               ? "audio/webm"
                    : "";
          const rec = mime ? new MediaRecorder(recStream, { mimeType: mime }) : new MediaRecorder(recStream);
          rec.ondataavailable = (e) => { if (e.data.size > 0) recordedChunksRef.current.push(e.data); };
          rec.start(1000);
          recorderRef.current = rec;
        } catch (err) { console.warn("MediaRecorder unavailable:", err); }
      } catch (e) { console.error("WS onopen error:", e); setStatus("error"); }
    };

    ws.onmessage = async (ev) => {
      if (typeof ev.data === "string") {
        const msg = JSON.parse(ev.data);
        switch (msg.type) {
          case "status":
            setStatus(msg.value);
            if (msg.value === "starting") setAgentSpeaking(true);
            if (msg.value === "listening") {
              readyToListenRef.current = true;
              setAgentSpeaking(false);
              clearTimeout(listenWatchdogRef.current);
              setConnectionState("ok");
              reconnectAttemptsRef.current = 0;
              // Safety net: backend says mic should be open — always honour it
              pendingChunksRef.current = 0;
              isSpeakingRef.current = false;
              // Sync latest editor code to backend
              const code = monacoInstanceRef.current?.getValue() || "";
              if (wsRef.current?.readyState === WebSocket.OPEN) {
                wsRef.current.send(JSON.stringify({ type: "code", content: code }));
              }
            }
            break;
          case "transcript":
            if (msg.speaker === "interviewer") {
              // Reveal words one-by-one so the wait feels intentional
              const words = msg.text.split(" ");
              setTypingText("");
              setTranscript(t => [...t, { speaker: "interviewer", text: "" }]);
              words.forEach((word, i) => {
                setTimeout(() => {
                  setTypingText(words.slice(0, i + 1).join(" "));
                  if (i === words.length - 1) {
                    setTranscript(t => {
                      const next = [...t];
                      next[next.length - 1] = { speaker: "interviewer", text: msg.text };
                      return next;
                    });
                    setTypingText("");
                  }
                }, i * 60);
              });
            } else {
              setTranscript(t => [...t, { speaker: msg.speaker, text: msg.text }]);
            }
            setAgentSpeaking(msg.speaker === "interviewer");
            break;
          case "feedback":
            feedbackRef.current = msg.data;
            // Upload the audio recording (fire and forget — don't block the UI)
            if (msg.data?.public_id && recorderRef.current) {
              const rec = recorderRef.current;
              rec.onstop = async () => {
                try {
                  const blob = new Blob(recordedChunksRef.current, { type: rec.mimeType || "audio/webm" });
                  if (recordedChunksRef.current.length > 0 && blob.size > 5000) {
                    const fd = new FormData();
                    fd.append("file", blob, "mock.webm");
                    fetch(`https://api.usegreenroom.app/api/recording/interview/${msg.data.public_id}`, { method: "POST", body: fd }).catch(() => {});
                  }
                } catch {}
              };
              try { rec.stop(); } catch {}
            }
            break;
          case "session_lost":
            // Server tells us the in-memory session is gone (e.g. our reconnect
            // landed on a different machine, or the Python process restarted).
            // Trigger the end flow immediately so the candidate gets feedback
            // for the work they already did, instead of seeing the 15s
            // "Coach Ari hasn't responded" warning.
            console.warn("[ws] session_lost from server — auto-ending");
            clearTimeout(listenWatchdogRef.current);
            if (!isEndingRef.current && !sessionEndedRef.current) {
              handleEnd();
            }
            break;
          case "ended":
            sessionEndedRef.current = true; // prevent ws.onclose double-navigation
            setStatus("ended");
            cleanup();
            window.posthog?.capture('interview_completed', {
              role: jobContext.role || "Software Engineer",
              company: jobContext.company || "",
              questions_covered: feedbackRef.current?.questions_covered || 0,
              duration_seconds: interviewStartRef.current ? Math.round((Date.now() - interviewStartRef.current) / 1000) : 0,
            });
            pendingFeedbackRef.current = feedbackRef.current;
            setShowRating(true);
            break;
          case "gate":
            cleanup();
            if (msg.reason === "free_used") {
              if (onUpgrade) onUpgrade("pro");
              else setCapHit({ used: 2, limit: 2 }); // payments hidden: surface the same "limit reached" screen
            }
            else if (msg.reason === "monthly_cap") setCapHit({ used: msg.used, limit: msg.limit });
            else if (msg.reason === "github_claimed") setGithubClaimed(true);
            else window.location.reload(); // no_github — reload to show the connect gate
            break;
          case "error":
            console.error("Agent error:", msg.message);
            break;
        }
      } else {
        // Binary: WAV audio from piper TTS — block mic immediately before decoding
        isSpeakingRef.current = true;
        await playWav(ev.data);
      }
    };

    ws.onerror = (e) => {
      console.error("WS error", e);
      // Don't set status="error" here — onclose fires immediately after and
      // handles retry logic. Setting error here would flash the error screen
      // before reconnect attempts even begin.
    };
    ws.onclose = () => {
      clearInterval(pingRef.current);
      clearTimeout(listenWatchdogRef.current);
      stopAudioCapture();
      readyToListenRef.current = false;
      // If the user clicked End and the server disconnected without sending "ended"
      // (e.g. server crash, LLM timeout, exception), navigate to feedback now.
      if (isEndingRef.current && !sessionEndedRef.current) {
        sessionEndedRef.current = true;
        onEnd(feedbackRef.current);
        return;
      }
      if (sessionEndedRef.current) return;
      // Unexpected drop mid-interview — attempt up to 3 reconnects with backoff.
      if (reconnectAttemptsRef.current < 3) {
        reconnectAttemptsRef.current += 1;
        const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current - 1), 8000);
        setConnectionState("reconnecting");
        clearTimeout(reconnectTimerRef.current);
        reconnectTimerRef.current = setTimeout(() => {
          if (isEndingRef.current || sessionEndedRef.current) return;
          try { connectWS(stream); } catch (e) { console.error("reconnect failed:", e); }
        }, delay);
      } else {
        setConnectionState("lost");
        setStatus("error");
      }
    };
  }

  // ── Mic → 16kHz PCM → WebSocket ──────────────────────────
  async function startAudioCapture(stream) {
    // Prefer the AudioContext pre-created inside handleStart (still inside
    // the user-gesture stack). Fall back to creating one here only if needed.
    const ctx = audioCtxRef.current || new AudioContext({ sampleRate: 16000 });
    audioCtxRef.current = ctx;
    if (ctx.state === "suspended") {
      try { await ctx.resume(); } catch (e) { console.warn("input ctx resume (late):", e); }
    }
    const source = ctx.createMediaStreamSource(stream);
    sourceRef.current = source;
    const processor = ctx.createScriptProcessor(4096, 1, 1);
    processorRef.current = processor;
    processor.onaudioprocess = (e) => {
      if (wsRef.current?.readyState !== WebSocket.OPEN) return;
      if (!readyToListenRef.current) return; // don't send until backend is ready for input
      if (isSpeakingRef.current) return;     // don't send while TTS is playing
      const f32 = e.inputBuffer.getChannelData(0);
      const i16 = new Int16Array(f32.length);
      for (let i = 0; i < f32.length; i++) {
        i16[i] = Math.max(-32768, Math.min(32767, f32[i] * 32768));
      }
      wsRef.current.send(i16.buffer);
    };
    source.connect(processor);
    processor.connect(ctx.destination);
  }

  function stopAudioCapture() {
    try { processorRef.current?.disconnect(); } catch { }
    try { sourceRef.current?.disconnect(); } catch { }
    try { audioCtxRef.current?.close(); } catch { }
    // Null the refs — startAudioCapture() reuses audioCtxRef truthy-check,
    // and createMediaStreamSource on a closed context throws and kills the mic.
    processorRef.current = null;
    sourceRef.current = null;
    audioCtxRef.current = null;
  }

  // ── WAV bytes → speaker (chunk-scheduled for streaming TTS) ─
  // playWav serialises through playQueueRef so that concurrent onmessage
  // invocations never race on nextPlayTimeRef. Each chunk awaits the previous
  // decode+schedule before reading/writing the shared time cursor.
  function playWav(arrayBuffer) {
    playQueueRef.current = playQueueRef.current
      .catch(() => {})
      .then(() => _doPlayWav(arrayBuffer));
    return playQueueRef.current;
  }

  async function _doPlayWav(arrayBuffer) {
    if (!arrayBuffer || arrayBuffer.byteLength < 44) {
      if (pendingChunksRef.current === 0) isSpeakingRef.current = false;
      return;
    }
    let incremented = false;
    try {
      const ctx = await unlockOutputCtx();
      const decoded = await ctx.decodeAudioData(arrayBuffer.slice(0));
      const src = ctx.createBufferSource();
      src.buffer = decoded;
      src.connect(ctx.destination);
      isSpeakingRef.current = true;
      pendingChunksRef.current += 1;
      incremented = true;

      const now = ctx.currentTime;
      const startAt = Math.max(now, nextPlayTimeRef.current);
      nextPlayTimeRef.current = startAt + decoded.duration;

      // Belt-and-braces: if onended never fires (rare AudioContext quirk),
      // release the speaking lock after the scheduled end + 600ms cooldown.
      const fallbackMs = Math.max(0, (startAt - now + decoded.duration) * 1000 + 600);
      const fallbackTimer = setTimeout(() => {
        pendingChunksRef.current = Math.max(0, pendingChunksRef.current - 1);
        if (pendingChunksRef.current === 0) {
          currentTTSRef.current = null;
          setAgentSpeaking(false);
          isSpeakingRef.current = false;
        }
      }, fallbackMs);

      src.onended = () => {
        clearTimeout(fallbackTimer);
        pendingChunksRef.current = Math.max(0, pendingChunksRef.current - 1);
        if (pendingChunksRef.current === 0) {
          currentTTSRef.current = null;
          setAgentSpeaking(false);
          setTimeout(() => { isSpeakingRef.current = false; }, 400);
        }
      };
      currentTTSRef.current = src;
      src.start(startAt);
    } catch (e) {
      console.error("Playback error:", e);
      if (incremented) {
        pendingChunksRef.current = Math.max(0, pendingChunksRef.current - 1);
      }
      if (pendingChunksRef.current === 0) {
        isSpeakingRef.current = false;
        setAgentSpeaking(false);
      }
    }
  }

  // ── Cleanup ───────────────────────────────────────────────
  function cleanup() {
    clearInterval(pingRef.current);
    clearTimeout(reconnectTimerRef.current);
    clearTimeout(listenWatchdogRef.current);
    stopAudioCapture();
    try { currentTTSRef.current?.stop(); } catch { }
    currentTTSRef.current = null;
    nextPlayTimeRef.current = 0;
    pendingChunksRef.current = 0;
    playQueueRef.current = Promise.resolve();
    try { outputCtxRef.current?.close(); } catch { }
    outputCtxRef.current = null;
    isSpeakingRef.current = false;
    streamRef.current?.getTracks().forEach(t => t.stop());
    videoStreamRef.current?.getTracks().forEach(t => t.stop());
  }

  async function handleStart() {
    if (started) return; // guard against double-click during async permission prompts
    // Unlock AudioContext inside this user-gesture stack so Ari can speak
    // and the mic ScriptProcessor fires without being suspended in Chrome.
    await unlockOutputCtx();
    if (!audioCtxRef.current) {
      const ctx = new AudioContext({ sampleRate: 16000 });
      audioCtxRef.current = ctx;
      if (ctx.state === "suspended") {
        try { await ctx.resume(); } catch (e) { console.warn("input ctx resume:", e); }
      }
    }
    // Request camera inside the user-gesture — avoids turning on the camera
    // indicator before the candidate is ready to start.
    try {
      const vs = await navigator.mediaDevices.getUserMedia({ video: true });
      videoStreamRef.current = vs;
      setHasCamera(true);
    } catch (err) {
      setCamError(err.name === "NotAllowedError" ? "Permission denied" : "Camera unavailable");
    }
    if (!isFree) {
      const m = Math.min(Math.max(parseInt(selectedMinutes, 10) || maxMinutes, 1), maxMinutes);
      interviewEndAtRef.current = Date.now() + m * 60 * 1000;
      setSeconds(m * 60);
    }
    setStarted(true);
  }

  function handleEnd() {
    if (ending) return;
    isEndingRef.current = true;
    setEnding(true);
    clearTimeout(reconnectTimerRef.current);
    clearTimeout(listenWatchdogRef.current);
    // Stop mic + camera immediately — hardware indicator turns off at once.
    try { stopAudioCapture(); } catch {}
    try { streamRef.current?.getTracks().forEach(t => t.stop()); } catch {}
    try { videoStreamRef.current?.getTracks().forEach(t => t.stop()); } catch {}
    try {
      if (recorderRef.current && recorderRef.current.state !== "inactive") {
        recorderRef.current.stop();
      }
    } catch {}
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify({ type: "end" }));
      // Safety net: if the server never sends "ended" (crash, timeout, slow LLM),
      // navigate to feedback after 40 seconds so the candidate isn't stuck forever.
      setTimeout(() => {
        if (!sessionEndedRef.current) {
          sessionEndedRef.current = true;
          cleanup();
          onEnd(feedbackRef.current);
        }
      }, 40000);
    } else {
      // WS already closed — navigate immediately, no point waiting.
      if (!sessionEndedRef.current) {
        sessionEndedRef.current = true;
        cleanup();
        onEnd(feedbackRef.current);
      }
    }
  }

  // ── Code block renderer ───────────────────────────────────
  function renderText(text) {
    const parts = text.split(/(```[\s\S]*?```)/g);
    return parts.map((part, i) => {
      if (part.startsWith("```")) {
        const match = part.match(/```(\w*)\n?([\s\S]*?)```/);
        const lang = match?.[1] || "";
        const code = match?.[2]?.trim() || part.replace(/```/g, "").trim();
        return (
          <div key={i} style={{ marginTop: 6, marginBottom: 2 }}>
            <div style={{
              display: "flex", alignItems: "center", justifyContent: "space-between",
              background: "#1e1e2e", borderRadius: "8px 8px 0 0",
              padding: "6px 12px",
            }}>
              <span style={{ font: "500 11px var(--font-mono)", color: "#7c7c9c", textTransform: "lowercase" }}>{lang || "code"}</span>
              <button onClick={() => navigator.clipboard.writeText(code)} style={{
                background: "none", border: "none", cursor: "pointer",
                color: "#7c7c9c", font: "500 11px var(--font-sans)",
                display: "flex", alignItems: "center", gap: 4, padding: 0,
              }}>
                <Icon name="copy" size={12} color="#7c7c9c" /> Copy
              </button>
            </div>
            <pre style={{
              margin: 0, padding: "12px 14px",
              background: "#13131f", borderRadius: "0 0 8px 8px",
              font: "13px/1.6 var(--font-mono)", color: "#cdd6f4",
              overflowX: "auto", whiteSpace: "pre",
            }}>{code}</pre>
          </div>
        );
      }
      return <span key={i}>{part}</span>;
    });
  }

  const statusLabel = {
    connecting: "Connecting…",
    loading: "Loading AI models…",
    starting: "Starting interview…",
    listening: "Listening…",
    processing: "Processing…",
    ended: "Interview ended",
    error: "Connection failed",
    reconnecting: "Reconnecting…",
  }[status] || status;

  const statusDot = {
    listening: "var(--gr-green-500)",
    processing: "var(--gr-warning)",
    starting: "var(--gr-green-300)",
    loading: "var(--gr-green-300)",
  }[status] || "var(--fg-4)";

  if (showRating) {
    const realismLabels   = ["", "Not at all", "Somewhat", "Mostly", "Very much", "Exactly like one"];
    const accuracyLabels  = ["", "Way off", "Partially right", "Mostly right", "Pretty accurate", "Spot on"];

    function StarRow({ value, onChange }) {
      return (
        <div style={{ display: "flex", gap: 10 }}>
          {[1, 2, 3, 4, 5].map(n => (
            <button key={n} onClick={() => onChange(n)} style={{
              width: 52, height: 52, borderRadius: 999,
              background: value >= n ? "var(--gr-green-500)" : "var(--bg-subtle)",
              border: `1.5px solid ${value >= n ? "var(--gr-green-500)" : "var(--border-1)"}`,
              color: value >= n ? "#fff" : "var(--fg-3)",
              font: "600 18px var(--font-sans)", cursor: "pointer",
              transition: "all 120ms ease",
            }}>{n}</button>
          ))}
        </div>
      );
    }

    // Step indicator
    const StepDots = () => (
      <div style={{ display: "flex", gap: 6 }}>
        {[1, 2].map(s => (
          <div key={s} style={{
            width: s === ratingStep ? 20 : 8, height: 8, borderRadius: 999,
            background: s === ratingStep ? "var(--gr-green-500)" : "var(--border-1)",
            transition: "all 200ms ease",
          }} />
        ))}
      </div>
    );

    return (
      <div style={{ height: "100vh", background: "var(--bg-app)", display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: 32 }}>
        <img src="../../assets/logo.svg" width="32" height="32" alt="Greenroom" />
        <StepDots />

        {ratingStep === 1 && (
          <>
            <div style={{ textAlign: "center", maxWidth: 400 }}>
              <div style={{ font: "400 28px/1 var(--font-display)", letterSpacing: "-0.02em", color: "var(--fg-1)", marginBottom: 8 }}>Session complete</div>
              <div style={{ font: "var(--text-body)", color: "var(--fg-3)" }}>Did that feel like a real interview?</div>
            </div>
            <StarRow value={sessionRating} onChange={setSessionRating} />
            {sessionRating > 0 && (
              <div style={{ font: "var(--text-body-sm)", color: "var(--fg-3)", marginTop: -16 }}>
                {realismLabels[sessionRating]}
              </div>
            )}
            <div style={{ display: "flex", gap: 12 }}>
              <Button variant="primary" onClick={() => setRatingStep(2)} disabled={sessionRating === 0}>
                Next →
              </Button>
              <Button variant="secondary" onClick={() => submitRating(0, 0)}>
                Skip both
              </Button>
            </div>
          </>
        )}

        {ratingStep === 2 && (
          <>
            <div style={{ textAlign: "center", maxWidth: 440 }}>
              <div style={{ font: "400 28px/1 var(--font-display)", letterSpacing: "-0.02em", color: "var(--fg-1)", marginBottom: 8 }}>One more question</div>
              <div style={{ font: "var(--text-body)", color: "var(--fg-3)", lineHeight: 1.6 }}>
                How accurately did the feedback reflect what you actually know?<br />
                <span style={{ font: "var(--text-body-sm)", color: "var(--fg-4)" }}>Be honest — this improves the AI for everyone.</span>
              </div>
            </div>
            <StarRow value={accuracyRating} onChange={setAccuracyRating} />
            {accuracyRating > 0 && (
              <div style={{ font: "var(--text-body-sm)", color: "var(--fg-3)", marginTop: -16 }}>
                {accuracyLabels[accuracyRating]}
              </div>
            )}
            <div style={{ display: "flex", gap: 12 }}>
              <Button variant="primary" onClick={() => submitRating(sessionRating, accuracyRating)} disabled={accuracyRating === 0}>
                Submit and see feedback
              </Button>
              <Button variant="secondary" onClick={() => submitRating(sessionRating, 0)}>
                Skip this one
              </Button>
            </div>
          </>
        )}
      </div>
    );
  }

  // Free-tier time warning banner — shown at ≤2 min, urgent at ≤60s
  const FreeBanner = isFree && seconds <= 120 && seconds > 0 && started ? (
    <div style={{
      position: "fixed", top: 64, left: 0, right: 0, zIndex: 200,
      background: seconds <= 60 ? "rgba(220,38,38,0.94)" : "rgba(217,119,6,0.92)",
      backdropFilter: "blur(6px)",
      padding: "10px 20px",
      display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12,
      font: "500 13px var(--font-sans)", color: "#fff",
    }}>
      <span>
        {seconds <= 60
          ? `⚡ Less than 1 minute left on your free session — wrapping up now.`
          : `⏱ 2 minutes remaining on your free 10-min session.`}
      </span>
      {onUpgrade && (
        <button onClick={() => onUpgrade("pro")} style={{
          padding: "5px 14px", borderRadius: 8, border: "1.5px solid rgba(255,255,255,0.5)",
          background: "rgba(255,255,255,0.15)", color: "#fff",
          font: "600 12px var(--font-sans)", cursor: "pointer", flexShrink: 0,
          transition: "background 150ms",
        }}>
          Upgrade for unlimited →
        </button>
      )}
    </div>
  ) : null;

  const ConnectionBanner = started && connectionState !== "ok" ? (
    <div style={{
      position: "fixed", top: 64, left: 0, right: 0, zIndex: 199,
      background: connectionState === "lost" ? "rgba(220,38,38,0.94)"
                : connectionState === "stuck" ? "rgba(217,119,6,0.92)"
                : "rgba(82,82,91,0.94)",
      backdropFilter: "blur(6px)",
      padding: "10px 20px",
      display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12,
      font: "500 13px var(--font-sans)", color: "#fff",
    }}>
      <span>
        {connectionState === "reconnecting" && "⟳ Reconnecting to Coach Ari…"}
        {connectionState === "stuck" && "⚠ Coach Ari hasn't responded — try ending the interview to save progress."}
        {connectionState === "lost" && "⚠ Connection lost. End the interview to save your feedback so far."}
      </span>
      <button onClick={handleEnd} disabled={ending} style={{
        padding: "5px 14px", borderRadius: 8, border: "1.5px solid rgba(255,255,255,0.5)",
        background: "rgba(255,255,255,0.15)", color: "#fff",
        font: "600 12px var(--font-sans)", cursor: ending ? "default" : "pointer", flexShrink: 0,
      }}>End interview</button>
    </div>
  ) : null;

  // GitHub account already used on a different Greenroom account
  if (capHit) {
    return (
      <div style={{ height: "100vh", background: "var(--bg-app)", display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: 24 }}>
        <img src="../../assets/logo.svg" width="36" height="36" alt="Greenroom" onClick={onHome} style={{ cursor: onHome ? "pointer" : "default" }} />
        <div style={{ textAlign: "center", maxWidth: 380 }}>
          <div style={{ font: "400 28px/1 var(--font-display)", letterSpacing: "-0.02em", color: "var(--fg-1)", marginBottom: 8 }}>Monthly limit reached</div>
          <div style={{ font: "var(--text-body)", color: "var(--fg-3)", lineHeight: 1.6 }}>
            You've used {capHit.used} of {capHit.limit} sessions this month. Your sessions reset on the 1st. Come back then, or contact support if you need more.
          </div>
        </div>
        <Button variant="secondary" onClick={onHome}>Go back</Button>
      </div>
    );
  }

  if (githubClaimed) {
    return (
      <div style={{ height: "100vh", background: "var(--bg-app)", display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: 24 }}>
        <img src="../../assets/logo.svg" width="36" height="36" alt="Greenroom" onClick={onHome} style={{ cursor: onHome ? "pointer" : "default" }} />
        <div style={{ textAlign: "center", maxWidth: 380 }}>
          <div style={{ font: "400 28px/1 var(--font-display)", letterSpacing: "-0.02em", color: "var(--fg-1)", marginBottom: 8 }}>GitHub already in use</div>
          <div style={{ font: "var(--text-body)", color: "var(--fg-3)" }}>
            This GitHub account has already been used for a free mock on another Greenroom account. Each GitHub account is limited to one free session. Upgrade to Pro for unlimited access.
          </div>
        </div>
        <div style={{ display: "flex", gap: 12 }}>
          {onUpgrade && <Button variant="primary" onClick={() => onUpgrade("pro")}>Upgrade to Pro — $15/mo</Button>}
          <Button variant="secondary" onClick={onHome}>Go back</Button>
        </div>
      </div>
    );
  }

  if (!started) {
    const canStart = hasStream && !qLoading;

    // One row of the "what we'll ask for" checklist — granted rows get the
    // green tick, pending rows get a hollow circle.
    const permRow = (icon, title, subtitle, granted) => (
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <div style={{
          width: 38, height: 38, borderRadius: 10, flexShrink: 0,
          display: "flex", alignItems: "center", justifyContent: "center",
          background: granted ? "var(--bg-brand-soft)" : "var(--bg-subtle)",
          border: `1px solid ${granted ? "var(--gr-green-100)" : "var(--border-1)"}`,
        }}>
          <Icon name={icon} size={18} color={granted ? "var(--gr-green-600)" : "var(--fg-3)"} />
        </div>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ font: "500 14px var(--font-sans)", color: "var(--fg-1)" }}>{title}</div>
          <div style={{ font: "var(--text-caption)", color: "var(--fg-4)", lineHeight: 1.45 }}>{subtitle}</div>
        </div>
        <Icon name={granted ? "check-circle-2" : "circle"} size={20} color={granted ? "var(--gr-green-500)" : "var(--fg-4)"} />
      </div>
    );

    return (
      <div style={{ height: "100vh", background: "var(--bg-app)", display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: 24, padding: 20 }}>
        <img src="../../assets/logo.svg" width="36" height="36" alt="Greenroom" onClick={onHome} style={{ cursor: onHome ? "pointer" : "default" }} />
        <div style={{ textAlign: "center" }}>
          <div style={{ font: "400 28px/1 var(--font-display)", letterSpacing: "-0.02em", color: "var(--fg-1)", marginBottom: 8 }}>
            {hasStream ? "Ready to start?" : "Quick setup"}
          </div>
          <div style={{ font: "var(--text-body)", color: "var(--fg-3)", maxWidth: 360 }}>
            {hasStream
              ? (qLoading ? "Preparing your personalised questions…" : "Mic ready. Your camera will turn on when you start.")
              : "This is a spoken interview, so we'll ask for your microphone next. Your camera is optional."}
          </div>
        </div>

        {/* Permission priming card — shown until the mic is granted. The native
            browser prompt only fires when they tap the button below. */}
        {!hasStream && (
          <Card raised style={{ width: "100%", maxWidth: 400, display: "flex", flexDirection: "column", gap: 16 }}>
            {permRow("mic", "Microphone", "Required — you'll answer out loud, just like a real interview.", true)}
            {permRow("video", "Camera", "Optional — turn it on or off anytime. You can start with it off.", false)}

            {micDenied && (
              <div style={{ display: "flex", gap: 8, padding: "10px 12px", borderRadius: 8, background: "var(--bg-subtle)", border: "1px solid var(--border-1)" }}>
                <Icon name="alert-circle" size={16} color="var(--gr-danger)" style={{ marginTop: 2 }} />
                <div style={{ font: "var(--text-caption)", color: "var(--fg-2)", lineHeight: 1.5 }}>
                  Microphone access is blocked. Click the camera/lock icon in your browser's address bar, allow the microphone, then tap “Try again”.
                </div>
              </div>
            )}

            <div style={{ display: "flex", alignItems: "flex-start", gap: 8, font: "var(--text-caption)", color: "var(--fg-4)", lineHeight: 1.5 }}>
              <Icon name="lock" size={14} color="var(--fg-4)" style={{ marginTop: 1 }} />
              <span>Used only to run your mock interview. Nothing here is shared with employers.</span>
            </div>

            <Button variant="primary" size="lg" onClick={requestMic} disabled={requestingMic} style={{ width: "100%", justifyContent: "center" }}>
              {requestingMic ? "Waiting for permission…" : micDenied ? "Try again" : "Allow microphone & continue"}
            </Button>
          </Card>
        )}

        {hasStream && !isFree && (
          <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 10 }}>
            <div style={{ font: "var(--text-body-sm)", color: "var(--fg-2)" }}>
              Interview length — up to {maxMinutes} minutes
            </div>
            <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
              <input
                type="range"
                min="5"
                max={maxMinutes}
                step="5"
                value={selectedMinutes}
                onChange={(e) => setSelectedMinutes(parseInt(e.target.value, 10))}
                style={{ width: 220 }}
              />
              <span style={{ font: "500 14px var(--font-sans)", color: "var(--fg-1)", minWidth: 56 }}>
                {selectedMinutes} min
              </span>
            </div>
            <div style={{ font: "var(--text-caption)", color: "var(--fg-4)" }}>
              Once started, the timer runs to 0:00 with no pauses.
            </div>
          </div>
        )}
        {hasStream && (
          <Button variant="primary" onClick={handleStart} disabled={!canStart}>
            {qLoading ? "Loading questions…" : "Start Interview"}
          </Button>
        )}
      </div>
    );
  }

  // ── Shared sub-panels (used in both mobile and desktop) ─────────────────

  const transcriptPanel = (
    <Card padding={0} style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minHeight: 0, borderRadius: isMobile ? 0 : undefined, border: isMobile ? "none" : undefined }}>
      {!isMobile && (
        <div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border-1)", flexShrink: 0 }}>
          <Eyebrow>Live transcript</Eyebrow>
        </div>
      )}
      <div style={{
        flex: 1, minHeight: 0, overflowY: "auto", padding: "12px 16px",
        display: "flex", flexDirection: "column", gap: 10,
        WebkitOverflowScrolling: "touch",
      }}>
        {transcript.length === 0 && status !== "processing" && (
          <div style={{ font: "var(--text-body-sm)", color: "var(--fg-4)", textAlign: "center", padding: "32px 0" }}>
            Transcript will appear here…
          </div>
        )}
        {transcript.map((entry, i) => {
          const isLast = i === transcript.length - 1;
          const displayText = isLast && entry.speaker === "interviewer" && typingText ? typingText : entry.text;
          if (!displayText && !typingText) return null;
          return (
            <div key={i} style={{
              display: "flex", flexDirection: "column", gap: 2,
              alignItems: entry.speaker === "candidate" ? "flex-end" : "flex-start",
            }}>
              <span style={{ font: "var(--text-caption)", color: "var(--fg-4)", textTransform: "uppercase", letterSpacing: "0.06em" }}>
                {entry.speaker === "candidate" ? "You" : "Coach Ari"}
              </span>
              <div style={{
                maxWidth: "90%", padding: "8px 12px", borderRadius: 10,
                font: "var(--text-body-sm)", lineHeight: 1.55,
                background: entry.speaker === "candidate" ? "var(--bg-brand-soft)" : "var(--bg-subtle)",
                color: entry.speaker === "candidate" ? "var(--gr-green-700)" : "var(--fg-1)",
                border: `1px solid ${entry.speaker === "candidate" ? "var(--gr-green-100)" : "var(--border-1)"}`,
              }}>
                {renderText(displayText)}
                {isLast && entry.speaker === "interviewer" && typingText && (
                  <span style={{ display: "inline-block", width: 2, height: "1em", background: "var(--gr-green-500)", marginLeft: 2, verticalAlign: "text-bottom", animation: "blink 700ms step-end infinite" }} />
                )}
              </div>
            </div>
          );
        })}
        {status === "processing" && (
          <div style={{ display: "flex", flexDirection: "column", gap: 2, alignItems: "flex-start" }}>
            <span style={{ font: "var(--text-caption)", color: "var(--fg-4)", textTransform: "uppercase", letterSpacing: "0.06em" }}>Coach Ari</span>
            <div style={{
              padding: "10px 14px", borderRadius: 10, background: "var(--bg-subtle)",
              border: "1px solid var(--border-1)", display: "flex", alignItems: "center", gap: 6,
            }}>
              {[0, 1, 2].map(i => (
                <span key={i} style={{
                  width: 7, height: 7, borderRadius: 999,
                  background: "var(--fg-3)", display: "inline-block",
                  animation: `thinking 1.2s ease-in-out ${i * 0.2}s infinite`,
                }} />
              ))}
            </div>
          </div>
        )}
        <div ref={transcriptEndRef} />
      </div>
    </Card>
  );

  const editorPanel = (
    <section style={{
      display: "flex", flexDirection: "column",
      minWidth: 0, minHeight: 0, flex: 1,
      borderRadius: isMobile ? 0 : 12,
      overflow: "hidden",
      border: isMobile ? "none" : "1px solid var(--border-1)",
    }}>
      <div style={{ background: "#1e1e2e", padding: "8px 12px", flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "space-between", borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
        <div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
          {["python", "javascript", "java", "cpp"].map(lang => (
            <button key={lang} onClick={() => setLanguage(lang)} style={{
              padding: "3px 10px", borderRadius: 4, border: "none", cursor: "pointer",
              font: "500 12px var(--font-mono)",
              background: language === lang ? "rgba(255,255,255,0.12)" : "transparent",
              color: language === lang ? "#cdd6f4" : "#7c7c9c",
              transition: "all 100ms",
            }}>{lang}</button>
          ))}
        </div>
        <button onClick={() => {
          const code = monacoInstanceRef.current?.getValue() || "";
          navigator.clipboard.writeText(code);
        }} style={{ background: "none", border: "none", cursor: "pointer", color: "#7c7c9c", display: "flex", alignItems: "center", gap: 4, font: "12px var(--font-sans)", flexShrink: 0 }}>
          <Icon name="copy" size={12} color="#7c7c9c" /> Copy
        </button>
      </div>
      {/* touch-action: none lets Monaco capture touch scroll events on mobile */}
      <div ref={editorRef} style={{ flex: 1, minHeight: 0, touchAction: "none" }} />
    </section>
  );

  // ── Mobile layout — split view ───────────────────────────────────────────
  if (isMobile) {
    return (
      <div data-screen-label="Mock interview room" style={{ height: "100dvh", background: "var(--bg-app)", display: "flex", flexDirection: "column", overflow: "hidden" }}>
        {FreeBanner}
        {ConnectionBanner}

        {/* Slim header: logo | status | timer | end */}
        <header style={{
          padding: "8px 14px", borderBottom: "1px solid var(--border-1)", background: "var(--bg-surface)",
          display: "flex", alignItems: "center", gap: 10, flexShrink: 0, height: 44,
        }}>
          <img src="../../assets/logo.svg" width="18" height="18" alt="Greenroom" onClick={onHome} style={{ cursor: onHome ? "pointer" : "default", flexShrink: 0 }} />
          <div style={{ display: "flex", alignItems: "center", gap: 5, flex: 1, minWidth: 0 }}>
            <div style={{ width: 6, height: 6, borderRadius: 999, background: statusDot, flexShrink: 0 }} />
            <span style={{ font: "500 12px var(--font-sans)", color: "var(--fg-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
              {qLoading ? "Loading…" : statusLabel}
            </span>
          </div>
          <span style={{ font: "500 15px var(--font-mono)", color: isFree && seconds <= 60 ? "var(--gr-red-500, #ef4444)" : "var(--fg-1)", fontVariantNumeric: "tabular-nums", flexShrink: 0 }}>{mm}:{ss_str}</span>
          <Button variant="danger" onClick={handleEnd} disabled={ending} style={{ padding: "5px 10px", font: "500 12px var(--font-sans)", flexShrink: 0 }}>
            {ending ? "…" : "End session"}
          </Button>
        </header>

        {/* ── Top half: transcript with floating video thumbnail ── */}
        <div style={{ flex: 1, minHeight: 0, position: "relative", background: "var(--bg-surface)", borderBottom: "1px solid var(--border-1)", display: "flex", flexDirection: "column" }}>

          {/* Video thumbnail — top-left floating */}
          <div style={{
            position: "absolute", top: 10, left: 10, zIndex: 20,
            width: 88, height: 66, borderRadius: 10, overflow: "hidden",
            border: "1.5px solid var(--border-1)",
            background: "#111113",
            boxShadow: "0 2px 12px rgba(0,0,0,0.25)",
          }}>
            <video ref={videoRef} autoPlay playsInline muted style={{
              width: "100%", height: "100%", objectFit: "cover",
              display: hasCamera && !videoOff ? "block" : "none",
            }} />
            {(!hasCamera || videoOff) && (
              <div style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center" }}>
                <Avatar initials="JL" size={28} brand />
              </div>
            )}
            {/* Ari waveform badge */}
            {agentSpeaking && (
              <div style={{
                position: "absolute", bottom: 4, left: 0, right: 0,
                display: "flex", justifyContent: "center",
              }}>
                <div style={{ background: "rgba(0,0,0,0.55)", borderRadius: 999, padding: "2px 6px", display: "flex", alignItems: "center", gap: 3 }}>
                  <Avatar initials="AI" size={12} brand />
                  <Waveform active />
                </div>
              </div>
            )}
          </div>

          {/* Transcript scroll area — left-padded so first messages clear the video */}
          <div style={{
            flex: 1, minHeight: 0, overflowY: "auto", WebkitOverflowScrolling: "touch",
            padding: "10px 12px 10px 12px",
            display: "flex", flexDirection: "column", gap: 8,
          }}>
            {/* Spacer so the first message doesn't go under the video */}
            <div style={{ height: 72, flexShrink: 0 }} />

            {transcript.length === 0 && status !== "processing" && (
              <div style={{ font: "var(--text-body-sm)", color: "var(--fg-4)", textAlign: "center", padding: "16px 0" }}>
                Transcript will appear here…
              </div>
            )}
            {transcript.map((entry, i) => {
              const isLast = i === transcript.length - 1;
              const displayText = isLast && entry.speaker === "interviewer" && typingText ? typingText : entry.text;
              if (!displayText && !typingText) return null;
              return (
                <div key={i} style={{
                  display: "flex", flexDirection: "column", gap: 2,
                  alignItems: entry.speaker === "candidate" ? "flex-end" : "flex-start",
                }}>
                  <span style={{ font: "var(--text-caption)", color: "var(--fg-4)", textTransform: "uppercase", letterSpacing: "0.06em" }}>
                    {entry.speaker === "candidate" ? "You" : "Ari"}
                  </span>
                  <div style={{
                    maxWidth: "88%", padding: "7px 11px", borderRadius: 10,
                    font: "var(--text-body-sm)", lineHeight: 1.5,
                    background: entry.speaker === "candidate" ? "var(--bg-brand-soft)" : "var(--bg-subtle)",
                    color: entry.speaker === "candidate" ? "var(--gr-green-700)" : "var(--fg-1)",
                    border: `1px solid ${entry.speaker === "candidate" ? "var(--gr-green-100)" : "var(--border-1)"}`,
                  }}>
                    {renderText(displayText)}
                    {isLast && entry.speaker === "interviewer" && typingText && (
                      <span style={{ display: "inline-block", width: 2, height: "1em", background: "var(--gr-green-500)", marginLeft: 2, verticalAlign: "text-bottom", animation: "blink 700ms step-end infinite" }} />
                    )}
                  </div>
                </div>
              );
            })}
            {status === "processing" && (
              <div style={{ display: "flex", flexDirection: "column", gap: 2, alignItems: "flex-start" }}>
                <span style={{ font: "var(--text-caption)", color: "var(--fg-4)", textTransform: "uppercase", letterSpacing: "0.06em" }}>Ari</span>
                <div style={{ padding: "8px 12px", borderRadius: 10, background: "var(--bg-subtle)", border: "1px solid var(--border-1)", display: "flex", alignItems: "center", gap: 6 }}>
                  {[0, 1, 2].map(i => (
                    <span key={i} style={{ width: 6, height: 6, borderRadius: 999, background: "var(--fg-3)", display: "inline-block", animation: `thinking 1.2s ease-in-out ${i * 0.2}s infinite` }} />
                  ))}
                </div>
              </div>
            )}
            <div ref={transcriptEndRef} />
          </div>
        </div>

        {/* ── Bottom half: Monaco editor ── */}
        <div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", background: "#1e1e2e" }}>

          {/* Editor toolbar */}
          <div style={{ padding: "6px 10px", flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "space-between", borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
            <div style={{ display: "flex", gap: 3 }}>
              {["python", "javascript", "java", "cpp"].map(lang => (
                <button key={lang} onClick={() => setLanguage(lang)} style={{
                  padding: "3px 8px", borderRadius: 4, border: "none", cursor: "pointer",
                  font: "500 11px var(--font-mono)",
                  background: language === lang ? "rgba(255,255,255,0.12)" : "transparent",
                  color: language === lang ? "#cdd6f4" : "#7c7c9c",
                }}>{lang}</button>
              ))}
            </div>
            <button onClick={() => { const code = monacoInstanceRef.current?.getValue() || ""; navigator.clipboard.writeText(code); }} style={{ background: "none", border: "none", cursor: "pointer", color: "#7c7c9c", display: "flex", alignItems: "center", gap: 4, font: "11px var(--font-sans)", flexShrink: 0 }}>
              <Icon name="copy" size={11} color="#7c7c9c" /> Copy
            </button>
          </div>

          {/* Monaco mount */}
          <div ref={editorRef} style={{ flex: 1, minHeight: 0, touchAction: "none" }} />

          {/* Mute button — bottom centre */}
          <div style={{ padding: "10px 16px", flexShrink: 0, display: "flex", justifyContent: "center", background: "#16161f", borderTop: "1px solid rgba(255,255,255,0.06)" }}>
            <button onClick={() => setMuted(m => !m)} style={{
              width: 52, height: 52, borderRadius: 999,
              background: muted ? "var(--gr-danger)" : "rgba(255,255,255,0.10)",
              border: muted ? "none" : "1.5px solid rgba(255,255,255,0.15)",
              cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center",
              transition: "background 150ms ease",
            }}>
              <Icon name={muted ? "mic-off" : "mic"} size={22} color="#fff" />
            </button>
          </div>
        </div>

      </div>
    );
  }

  // ── Desktop layout ───────────────────────────────────────────────────────
  return (
    <div data-screen-label="Mock interview room" style={{ height: "100vh", background: "var(--bg-app)", display: "flex", flexDirection: "column", overflow: "hidden" }}>
      {FreeBanner}
      {ConnectionBanner}
      <header style={{
        padding: "12px 24px", borderBottom: "1px solid var(--border-1)", background: "var(--bg-surface)",
        display: "flex", alignItems: "center", gap: 16, flexShrink: 0,
      }}>
        <img src="../../assets/logo.svg" width="22" height="22" alt="Greenroom" onClick={onHome} style={{ cursor: onHome ? "pointer" : "default", flexShrink: 0 }} />
        <div>
          <div style={{ font: "500 14px var(--font-sans)" }}>Mock interview · AI Voice</div>
          <div style={{ font: "var(--text-body-sm)", color: "var(--fg-3)", display: "flex", alignItems: "center", gap: 6 }}>
            {qLoading && <div style={{ width: 10, height: 10, borderRadius: 999, border: "1.5px solid var(--gr-green-200)", borderTopColor: "var(--gr-green-500)", animation: "spin 700ms linear infinite", flexShrink: 0 }} />}
            {qLoading ? "Generating personalised questions…" : statusLabel}
          </div>
        </div>
        <div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 18 }}>
          {status === "connecting" && (
            <div style={{ display: "flex", alignItems: "center", gap: 4, background: "var(--bg-subtle)", borderRadius: 8, padding: 4, border: "1px solid var(--border-1)" }}>
              {["female", "male"].map(v => (
                <button key={v} onClick={() => setVoice(v)} style={{
                  padding: "5px 12px", borderRadius: 6, border: "none", cursor: "pointer",
                  font: "500 13px var(--font-sans)",
                  background: voice === v ? "#fff" : "transparent",
                  color: voice === v ? "var(--fg-1)" : "var(--fg-3)",
                  boxShadow: voice === v ? "0 1px 3px rgba(0,0,0,0.08)" : "none",
                  display: "flex", alignItems: "center", gap: 6,
                  transition: "all 120ms ease",
                }}>
                  <Icon name={v === "female" ? "user-round" : "user"} size={13} />
                  {v.charAt(0).toUpperCase() + v.slice(1)}
                </button>
              ))}
            </div>
          )}
          <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
            <Icon name="clock" size={16} color="var(--fg-3)" />
            <span style={{ font: "500 22px var(--font-mono)", color: isFree && seconds <= 60 ? "var(--gr-red-500, #ef4444)" : "var(--fg-1)", fontVariantNumeric: "tabular-nums" }}>{mm}:{ss_str}</span>
          </div>
          <Button variant="danger" onClick={handleEnd} disabled={ending}>
            {ending ? "Ending…" : "End session"}
          </Button>
        </div>
      </header>

      <main style={{ flex: 1, minHeight: 0, padding: 24, display: "grid", gridTemplateColumns: "260px 1fr 320px", gap: 16, overflow: "hidden" }}>
        {/* Video + status */}
        <section style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0, minHeight: 0 }}>
          <div style={{
            position: "relative", flex: 1, minHeight: 200,
            background: "linear-gradient(180deg, #1a1a1c 0%, #0a0a0b 100%)",
            borderRadius: 14, overflow: "hidden", border: "1px solid var(--gr-stone-800)",
          }}>
            <video ref={videoRef} autoPlay playsInline muted style={{
              position: "absolute", inset: 0, width: "100%", height: "100%",
              objectFit: "cover", display: hasCamera && !videoOff ? "block" : "none",
            }} />
            {!hasCamera && (
              <div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
                <div style={{ textAlign: "center", color: "var(--gr-stone-500)" }}>
                  <Icon name={camError ? "video-off" : "loader"} size={32} color="var(--gr-stone-500)" />
                  <div style={{ marginTop: 10, font: "var(--text-body-sm)" }}>{camError || "Starting camera…"}</div>
                </div>
              </div>
            )}
            {hasCamera && videoOff && (
              <div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", background: "#111113" }}>
                <Avatar initials="JL" size={72} brand />
              </div>
            )}
            <div style={{
              position: "absolute", bottom: 16, left: 16,
              padding: "8px 12px", borderRadius: 999,
              background: "rgba(255,255,255,0.92)", backdropFilter: "blur(6px)",
              display: "flex", alignItems: "center", gap: 10,
              border: "1px solid rgba(0,0,0,0.04)",
            }}>
              <Avatar initials="AI" size={24} brand />
              <span style={{ font: "500 13px var(--font-sans)" }}>Coach Ari</span>
              <Waveform active={agentSpeaking} />
            </div>
            <div style={{
              position: "absolute", bottom: 16, left: "50%", transform: "translateX(-50%)",
              display: "flex", gap: 8,
              background: "rgba(0,0,0,0.55)", backdropFilter: "blur(8px)",
              padding: "6px 10px", borderRadius: 999,
              border: "1px solid rgba(255,255,255,0.08)",
            }}>
              <button onClick={() => setVideoOff(v => !v)} style={{
                width: 40, height: 40, borderRadius: 999,
                background: videoOff ? "var(--gr-danger)" : "rgba(255,255,255,0.14)",
                border: "none", cursor: "pointer",
                display: "flex", alignItems: "center", justifyContent: "center",
                transition: "background 120ms ease",
              }}><Icon name={videoOff ? "video-off" : "video"} size={18} color="#fff" /></button>
              <button onClick={() => setMuted(m => !m)} style={{
                width: 40, height: 40, borderRadius: 999,
                background: muted ? "var(--gr-danger)" : "rgba(255,255,255,0.14)",
                border: "none", cursor: "pointer",
                display: "flex", alignItems: "center", justifyContent: "center",
                transition: "background 120ms ease",
              }}><Icon name={muted ? "mic-off" : "mic"} size={18} color="#fff" /></button>
            </div>
          </div>

          <Card padding={20}>
            <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
              <div style={{ width: 8, height: 8, borderRadius: 999, background: statusDot }} />
              <Eyebrow>{statusLabel}</Eyebrow>
            </div>
            {status === "loading" ? (
              <div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 4 }}>
                <div style={{
                  width: 18, height: 18, borderRadius: 999, flexShrink: 0,
                  border: "2px solid var(--gr-green-200)",
                  borderTopColor: "var(--gr-green-500)",
                  animation: "spin 700ms linear infinite",
                }} />
                <span style={{ font: "var(--text-body)", color: "var(--fg-2)" }}>
                  Loading Whisper speech model — this takes a few seconds on first connect…
                </span>
              </div>
            ) : (
              <p style={{ font: "var(--text-body)", color: "var(--fg-2)", margin: 0 }}>
                {status === "listening" && "Speak naturally — the interviewer is listening."}
                {status === "processing" && "Processing your answer…"}
                {status === "starting" && "The interviewer is introducing the session."}
                {status === "connecting" && "Connecting to voice agent…"}
                {status === "error" && "Could not connect to Greenroom. Please check your internet connection and try again."}
              </p>
            )}
          </Card>
        </section>

        {/* Code Editor — fixed Monaco scroll */}
        {editorPanel}

        {/* Transcript */}
        <aside style={{ display: "flex", flexDirection: "column", minWidth: 0, minHeight: 0 }}>
          {transcriptPanel}
        </aside>
      </main>
    </div>
  );
}

if (!document.getElementById("gr-wave-kf")) {
  const _s = document.createElement("style");
  _s.id = "gr-wave-kf";
  _s.textContent = `
    @keyframes wave { from { transform: scaleY(0.5); } to { transform: scaleY(1.2); } }
    @keyframes thinking { 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }
    @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
  `;
  document.head.appendChild(_s);
}

function Waveform({ active }) {
  return (
    <div style={{ display: "inline-flex", alignItems: "center", gap: 2, height: 14 }}>
      {[6, 12, 8, 14, 6, 10, 4].map((h, i) => (
        <span key={i} style={{
          width: 3, borderRadius: 2,
          background: "var(--gr-green-500)",
          height: active ? h : 3,
          transition: "height 200ms var(--ease-out)",
          animation: active ? `wave ${800 + i * 70}ms ease-in-out infinite alternate` : "none",
        }}></span>
      ))}
    </div>
  );
}

window.MockRoom = MockRoom;
