// Compose a note — modal with mode tabs (text / photo / voice / gif),
// sticker palette, color swatches, author field. Doodles aren't created here:
// the BoardScreen has a draw-on-the-board flow instead.
//
// Photo mode: real <input type="file"> with drag-and-drop, image preview,
// caption. Stores the selected file as an object URL on payload.photoSrc.
//
// Voice mode: real MediaRecorder. Records up to 90s, shows live timer,
// previews via <audio controls>, exposes payload.audioSrc + audioDuration.

// Lazy ESM import of heic-to. esm.sh repackages npm modules as
// browser-ready ES modules, so we sidestep needing a specific IIFE bundle
// path on a CDN. The dynamic import is built via `new Function` so Babel
// Standalone doesn't try to rewrite the `import()` call out from under us.
let __heicModulePromise = null;
async function decodeHeicToJpeg(file) {
  if (!__heicModulePromise) {
    const dyn = new Function("u", "return import(u)");
    __heicModulePromise = dyn("https://esm.sh/heic-to@1.1.13").catch((err) => {
      __heicModulePromise = null;
      throw err;
    });
  }
  const mod = await __heicModulePromise;
  const heicTo = mod.heicTo || (mod.default && mod.default.heicTo);
  if (typeof heicTo !== "function") {
    throw new Error("heic-to module missing heicTo()");
  }
  return heicTo({ blob: file, type: "image/jpeg", quality: 0.85 });
}

function ComposeScreen({ palette, onSubmit, onCancel, recipientName, existingNote, isAuthor = true }) {
  const firstName = (recipientName || "Gill").split(/\s+/)[0];
  const isMobile = useIsMobile();
  // Edit mode: type is fixed, fields pre-filled. The mode tabs are hidden so
  // the user can't change the note type out from under the server record.
  const isEdit = !!existingNote;
  const initialMode = existingNote?.type || "text";
  const [mode, setMode] = React.useState(initialMode);
  const [body, setBody] = React.useState(existingNote?.body || "");
  const [color, setColor] = React.useState(existingNote?.color || palette.notes[0]);
  const [sticker, setSticker] = React.useState(existingNote?.sticker || null);
  const [anonymous, setAnonymous] = React.useState(
    isEdit ? existingNote?.authorName === "Anonymous" : false
  );

  // Photo / GIF — supports multiple photos. The state is a single
  // `photos: Array<{ src, name }>`; legacy single-photo notes load as a
  // 1-item array, and notes with no photos start empty. The first photo
  // doubles as photoSrc/photoLabel for back-compat with any consumer that
  // still reads those fields.
  const [photos, setPhotos] = React.useState(() => {
    if (Array.isArray(existingNote?.photos) && existingNote.photos.length > 0) {
      return existingNote.photos.map((p) => ({ src: p.src, name: p.name || "" }));
    }
    if (existingNote?.photoSrc) {
      return [{ src: existingNote.photoSrc, name: existingNote.photoLabel || "" }];
    }
    return [];
  });
  const [caption, setCaption] = React.useState(existingNote?.caption || "");
  const [photoError, setPhotoError] = React.useState(null);
  const [dragOver, setDragOver] = React.useState(false);
  const fileInputRef = React.useRef(null);
  // Legacy aliases — many places still read `photoSrc` / `photoName`.
  const photoSrc = photos[0]?.src || null;
  const photoName = photos[0]?.name || "";

  // Voice
  const [audioSrc, setAudioSrc] = React.useState(existingNote?.audioSrc || null);
  const [audioDuration, setAudioDuration] = React.useState(existingNote?.duration || 0);

  // Video — single clip per note, picked from the device (no in-browser
  // recording). Stored as an object URL while editing; the parent uploads
  // it on submit. Intrinsic dimensions are captured from the metadata so
  // we can hint at the right aspect ratio on the card.
  const [videoSrc, setVideoSrc] = React.useState(existingNote?.videoSrc || null);
  const [videoName, setVideoName] = React.useState(existingNote?.videoLabel || "");
  const [videoError, setVideoError] = React.useState(null);
  const [videoMeta, setVideoMeta] = React.useState(null); // {w, h, duration}
  const videoInputRef = React.useRef(null);

  const [recording, setRecording] = React.useState(false);
  const [recordSeconds, setRecordSeconds] = React.useState(0);
  const [voiceError, setVoiceError] = React.useState(null);
  // Device picker for voice — macOS will silently route through a paired
  // iPhone (Continuity) when you ask for the system default mic, which has
  // bitten users when the phone was off-screen and "no audio captured".
  // Listing audioinput devices here lets us pin to the laptop mic and shows
  // the user exactly which device is live.
  const [audioInputs, setAudioInputs] = React.useState([]);
  const [audioInputId, setAudioInputId] = React.useState(null);
  const [activeMicLabel, setActiveMicLabel] = React.useState("");
  const recorderRef = React.useRef(null);
  const streamRef = React.useRef(null);
  const chunksRef = React.useRef([]);
  const recordTimerRef = React.useRef(null);
  const recordStartRef = React.useRef(0);
  const submittedRef = React.useRef(false);  // skip URL revoke if submitting

  // Stop the mic + recorder cleanly without firing the onstop handler that
  // would otherwise build a Blob into state on an unmounting component.
  const teardownRecorder = () => {
    const mr = recorderRef.current;
    if (mr) {
      mr.ondataavailable = null;
      mr.onstop = null;
      if (mr.state !== "inactive") {
        try { mr.stop(); } catch (e) { /* ignore */ }
      }
      recorderRef.current = null;
    }
    if (streamRef.current) {
      streamRef.current.getTracks().forEach((t) => t.stop());
      streamRef.current = null;
    }
    if (recordTimerRef.current) {
      clearInterval(recordTimerRef.current);
      recordTimerRef.current = null;
    }
  };

  // Object URLs are NOT revoked on unmount: if the user submitted, the parent
  // owns them; if they cancelled, handleCancel revoked them already.
  React.useEffect(() => () => teardownRecorder(), []);

  // Heuristic: anything we shouldn't auto-pick because it's a phone/tablet
  // handed to the Mac via Continuity, or a wireless headset that often
  // produces low-quality recordings, or a virtual loopback device.
  const isLikelyHandover = (label) => {
    const s = String(label || "").toLowerCase();
    return /iphone|ipad|airpods|continuity|loopback/.test(s);
  };
  const isLikelyBuiltIn = (label) => {
    const s = String(label || "").toLowerCase();
    return /built[\s-]?in|internal|macbook|imac|mac mini|microphone/.test(s)
        && !isLikelyHandover(s);
  };

  // Enumerate audio input devices when the user opens the voice tab. Labels
  // only populate after permission is granted, so we re-run after the first
  // successful getUserMedia (handled below).
  const refreshDevices = React.useCallback(async () => {
    if (!navigator.mediaDevices?.enumerateDevices) return;
    try {
      const all = await navigator.mediaDevices.enumerateDevices();
      const inputs = all.filter((d) => d.kind === "audioinput");
      setAudioInputs(inputs);
      // Auto-pick a built-in mic over a handover device if we haven't yet.
      setAudioInputId((prev) => {
        if (prev && inputs.some((d) => d.deviceId === prev)) return prev;
        const builtIn = inputs.find((d) => isLikelyBuiltIn(d.label));
        if (builtIn) return builtIn.deviceId;
        const nonHandover = inputs.find((d) => d.label && !isLikelyHandover(d.label));
        if (nonHandover) return nonHandover.deviceId;
        return inputs[0]?.deviceId || null;
      });
    } catch (e) {
      // Ignore — we'll fall back to the system default in startRecording.
    }
  }, []);

  React.useEffect(() => {
    if (mode !== "voice") return;
    refreshDevices();
    if (navigator.mediaDevices?.addEventListener) {
      const onChange = () => refreshDevices();
      navigator.mediaDevices.addEventListener("devicechange", onChange);
      return () => navigator.mediaDevices.removeEventListener("devicechange", onChange);
    }
  }, [mode, refreshDevices]);

  const modes = [
    { id: "text",   label: "Note",   icon: IconText },
    { id: "photo",  label: "Photo",  icon: IconImage },
    { id: "video",  label: "Video",  icon: IconVideo },
    { id: "voice",  label: "Voice",  icon: IconMic },
    { id: "gif",    label: "GIF",    icon: IconGif },
  ];

  // Pick a video clip. Single file only, video/* MIME, capped at 60 MB so
  // we stay under the server's 80 MB upload limit with some headroom for
  // the multipart envelope. We also read intrinsic width/height/duration
  // off the loaded metadata so the board card can adopt the clip's aspect.
  const acceptVideoFile = (input) => {
    setVideoError(null);
    if (!input) return;
    const files = input instanceof FileList || Array.isArray(input)
      ? Array.from(input)
      : [input];
    const f = files[0];
    if (!f) return;
    if (!f.type.startsWith("video/")) {
      setVideoError("That's not a video file.");
      return;
    }
    if (f.size > 60 * 1024 * 1024) {
      setVideoError(`"${f.name}" is over 60 MB — try a shorter clip.`);
      return;
    }
    if (videoSrc && videoSrc.startsWith("blob:")) URL.revokeObjectURL(videoSrc);
    const url = URL.createObjectURL(f);
    setVideoSrc(url);
    setVideoName(f.name);
    setVideoMeta(null);
    if (videoInputRef.current) videoInputRef.current.value = "";
  };

  const clearVideo = () => {
    if (videoSrc && videoSrc.startsWith("blob:")) URL.revokeObjectURL(videoSrc);
    setVideoSrc(null);
    setVideoName("");
    setVideoMeta(null);
    setVideoError(null);
    if (videoInputRef.current) videoInputRef.current.value = "";
  };

  // Accept one OR many files. In photo mode we now allow multiple selects;
  // gif mode and the voice-photo attachment slot still work with a single
  // image so we accept whichever shape the caller hands us. iOS-shot HEIC
  // files are converted to JPEG client-side (heic2any) before previewing
  // so other browsers can actually render them.
  const acceptFile = async (input) => {
    setPhotoError(null);
    if (!input) return;
    const files = input instanceof FileList || Array.isArray(input)
      ? Array.from(input)
      : [input];
    if (files.length === 0) return;

    const looksHeic = (f) =>
      f.type === "image/heic" || f.type === "image/heif" ||
      /\.(heic|heif)$/i.test(f.name);

    const accepted = [];
    for (let f of files) {
      if (!f) continue;
      // HEIC/HEIF: browsers can't render natively. Convert to JPEG before
      // validating size/type so the rest of the pipeline sees a normal image.
      if (looksHeic(f)) {
        try {
          const blob = await decodeHeicToJpeg(f);
          const baseName = f.name.replace(/\.(heic|heif)$/i, "") || "photo";
          f = new File([blob], `${baseName}.jpg`, { type: "image/jpeg" });
        } catch (e) {
          console.error("[heic decode failed]", e);
          const msg = (e && (e.message || e.toString())) || "unknown error";
          setPhotoError(`Couldn't decode "${f.name}": ${msg}`);
          return;
        }
      }
      if (!f.type.startsWith("image/")) {
        setPhotoError("That's not an image file.");
        return;
      }
      if (f.size > 12 * 1024 * 1024) {
        setPhotoError(`"${f.name}" is over 12 MB.`);
        return;
      }
      const isGif = f.type === "image/gif";
      if (mode === "gif" && !isGif) {
        setPhotoError(isEdit ? "This note is a GIF — pick a .gif." : "Pick a .gif for GIF mode (or switch tabs).");
        return;
      }
      if (mode === "photo" && isGif) {
        // In edit mode the type is fixed; reject the wrong file rather
        // than silently flipping the note's type out from under the user.
        if (isEdit) {
          setPhotoError("This note is a photo — pick a non-GIF image.");
          return;
        }
        // Auto-switch only when the very first picked file is a GIF; once
        // we're in a multi-photo set we don't allow mixing.
        if (photos.length === 0 && files.length === 1) setMode("gif");
        else { setPhotoError("GIFs can't be mixed into a multi-photo post."); return; }
      }
      accepted.push({ src: URL.createObjectURL(f), name: f.name });
    }

    setPhotos((prev) => {
      // GIF mode = one image only. Voice-attachment also wants single.
      // Photo mode is the only multi-select path.
      if (mode === "gif" || mode === "voice") {
        prev.forEach((p) => p.src && p.src.startsWith("blob:") && URL.revokeObjectURL(p.src));
        return accepted.slice(0, 1);
      }
      return [...prev, ...accepted];
    });
    if (fileInputRef.current) fileInputRef.current.value = "";
  };

  const removePhotoAt = (idx) => {
    setPhotos((prev) => {
      const removed = prev[idx];
      if (removed?.src && removed.src.startsWith("blob:")) URL.revokeObjectURL(removed.src);
      return prev.filter((_, i) => i !== idx);
    });
    setPhotoError(null);
  };

  const clearPhoto = () => {
    photos.forEach((p) => p.src && p.src.startsWith("blob:") && URL.revokeObjectURL(p.src));
    setPhotos([]);
    setPhotoError(null);
    if (fileInputRef.current) fileInputRef.current.value = "";
  };

  const startRecording = async () => {
    setVoiceError(null);
    if (typeof MediaRecorder === "undefined" || !navigator.mediaDevices?.getUserMedia) {
      setVoiceError("Your browser doesn't support recording.");
      return;
    }
    try {
      // Pin to the picked device when we have one — using `exact` so the
      // browser fails fast if it's gone instead of silently falling back to
      // the system default (which on macOS often routes to a paired iPhone
      // via Continuity and yields a "no audio captured" recording).
      const audioConstraint = audioInputId
        ? { deviceId: { exact: audioInputId } }
        : true;
      const stream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraint });
      // Surface which device actually got picked so the user can sanity-check.
      const track = stream.getAudioTracks()[0];
      const settings = track?.getSettings?.() || {};
      const used = audioInputs.find((d) => d.deviceId === settings.deviceId)
                || audioInputs.find((d) => d.deviceId === audioInputId);
      setActiveMicLabel((used?.label || track?.label || "default microphone").trim());
      // First successful permission grant lets us read device labels —
      // re-enumerate so the picker becomes useful next time.
      if (audioInputs.every((d) => !d.label)) refreshDevices();
      // Pick a recorder MIME that survives playback on iOS Safari. Safari
      // can't decode WebM/Opus, so we prefer AAC-in-MP4 when the browser
      // supports it (iOS does natively; Chrome 113+ does too). Fall back to
      // WebM/Opus on Firefox / older Chrome — those users still play back
      // on the same browser they recorded on, just not on iOS Safari.
      const recCandidates = [
        "audio/mp4;codecs=mp4a.40.2",
        "audio/mp4",
        "audio/aac",
        "audio/webm;codecs=opus",
        "audio/webm",
      ];
      const isSupported = (t) =>
        typeof MediaRecorder !== "undefined" &&
        typeof MediaRecorder.isTypeSupported === "function" &&
        MediaRecorder.isTypeSupported(t);
      const chosenMime = recCandidates.find(isSupported) || "";
      const mr = chosenMime ? new MediaRecorder(stream, { mimeType: chosenMime }) : new MediaRecorder(stream);
      chunksRef.current = [];
      mr.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
      mr.onstop = () => {
        if (streamRef.current) {
          streamRef.current.getTracks().forEach((t) => t.stop());
          streamRef.current = null;
        }
        if (recordTimerRef.current) { clearInterval(recordTimerRef.current); recordTimerRef.current = null; }
        const elapsed = Math.max(1, Math.round((Date.now() - recordStartRef.current) / 1000));
        const blob = new Blob(chunksRef.current, { type: mr.mimeType || "audio/webm" });
        if (audioSrc) URL.revokeObjectURL(audioSrc);
        setAudioSrc(URL.createObjectURL(blob));
        setAudioDuration(elapsed);
        setRecording(false);
      };
      recorderRef.current = mr;
      streamRef.current = stream;
      recordStartRef.current = Date.now();
      setRecordSeconds(0);
      setRecording(true);
      mr.start();
      recordTimerRef.current = setInterval(() => {
        const s = Math.floor((Date.now() - recordStartRef.current) / 1000);
        setRecordSeconds(s);
        if (s >= 90) stopRecording();
      }, 200);
    } catch (err) {
      // Picked device went away (unplugged, OS handover) — fall back to the
      // system default so the user isn't blocked, and clear the dead pick.
      if (err.name === "OverconstrainedError" || err.name === "NotFoundError") {
        setAudioInputId(null);
        setVoiceError("That microphone isn't available — falling back to the default. Try again.");
        refreshDevices();
        return;
      }
      setVoiceError(err.name === "NotAllowedError"
        ? "Microphone access was denied."
        : "Couldn't start the recorder.");
    }
  };

  const stopRecording = () => {
    const mr = recorderRef.current;
    if (mr && mr.state !== "inactive") {
      try { mr.stop(); } catch (e) { /* ignore */ }
    }
  };

  const clearAudio = () => {
    if (audioSrc) URL.revokeObjectURL(audioSrc);
    setAudioSrc(null);
    setAudioDuration(0);
    setVoiceError(null);
  };

  const handleCancel = () => {
    teardownRecorder();
    if (!submittedRef.current) {
      if (photoSrc) URL.revokeObjectURL(photoSrc);
      if (audioSrc) URL.revokeObjectURL(audioSrc);
      if (videoSrc && videoSrc.startsWith("blob:")) URL.revokeObjectURL(videoSrc);
    }
    onCancel();
  };

  const canSubmit = !recording && (
    (mode === "photo" || mode === "gif") ? photos.length > 0
    : mode === "voice" ? !!audioSrc
    : mode === "video" ? !!videoSrc
    : true
  );

  const handleSubmit = () => {
    if (!canSubmit) return;
    submittedRef.current = true;  // parent now owns the object URLs
    onSubmit({
      mode, body, color, sticker, anonymous,
      // Pass the full photos array; the parent uploads each blob and
      // also reads photos[0] for the legacy single-photo fields.
      photos,
      photoSrc, photoName, caption,
      audioSrc, audioDuration,
      videoSrc, videoName,
      videoW: videoMeta?.w, videoH: videoMeta?.h, videoDuration: videoMeta?.duration,
    });
  };

  return (
    <div className="modal-backdrop" onClick={handleCancel}>
      <div className="modal-card" onClick={(e) => e.stopPropagation()}
           style={{
             width: isMobile ? "100vw" : 820, maxWidth: isMobile ? "100vw" : "94vw",
             // Explicit height (capped by viewport) gives the grid row a
             // definite size — without it, an image's intrinsic min-content
             // can force the row past max-height limits.
             height: isMobile ? "100dvh" : "min(680px, 92vh)",
             borderRadius: isMobile ? 0 : 22,
             display: "grid",
             // Phone: single column with the styling rail tucked beneath.
             gridTemplateColumns: isMobile ? "1fr" : "1fr 280px",
             gridTemplateRows: isMobile ? "1fr auto" : "1fr",
             overflow: "hidden",
           }}>

        {/* Left: the composer surface */}
        <div style={{
          padding: "26px 26px 22px",
          display: "flex", flexDirection: "column",
          minHeight: 0, minWidth: 0,             // allow shrink inside grid cell
        }}>
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 18 }}>
            <div>
              <h2 style={{ fontFamily: "var(--serif)", fontSize: 26, margin: 0, fontWeight: 400, letterSpacing: "-0.01em" }}>
                {isEdit
                  ? <>Edit your <em>note</em></>
                  : <>Leave a <em>note</em> for {firstName}</>}
              </h2>
              <div style={{ color: "var(--ink-3)", fontSize: 12.5, marginTop: 2 }}>
                {isEdit ? "Tweak away — your changes save on the wall." : "They'll see this when the board is sent."}
              </div>
            </div>
            <button onClick={handleCancel} style={{
              width: 30, height: 30, borderRadius: 8, border: 0, background: "rgba(31,27,23,0.05)",
              cursor: "pointer", display: "grid", placeItems: "center",
            }}><IconClose size={14} /></button>
          </div>

          {/* Mode tabs — hidden in edit mode (type is fixed). */}
          {!isEdit && (
            <div style={{ display: "flex", gap: 4, background: "rgba(31,27,23,0.04)", padding: 4, borderRadius: 10, marginBottom: 18 }}>
              {modes.map((m) => {
                const Ico = m.icon;
                const active = mode === m.id;
                return (
                  <button key={m.id} onClick={() => setMode(m.id)} style={{
                    flex: 1, padding: "8px 10px", borderRadius: 7,
                    background: active ? "#fff" : "transparent",
                    color: active ? "var(--ink)" : "var(--ink-2)",
                    boxShadow: active ? "var(--shadow-1)" : "none",
                    border: 0, cursor: "pointer", display: "inline-flex",
                    alignItems: "center", justifyContent: "center", gap: 6,
                    fontSize: 13, fontWeight: 500, fontFamily: "var(--sans)",
                  }}>
                    <Ico size={13} /> {m.label}
                  </button>
                );
              })}
            </div>
          )}

          {/* Composer surface */}
          <div style={{
            flex: 1, minHeight: 0,               // shrink so img/textarea cap correctly
            // On mobile, cap the textarea/photo area so it doesn't take 60%
            // of the screen and crowd out the styling rail below.
            maxHeight: isMobile ? "44vh" : "none",
            borderRadius: 14,
            background: (mode === "photo" || mode === "gif" || mode === "video") ? "#fff" : color,
            padding: isMobile ? 16 : 22,
            position: "relative", border: "1px solid var(--line)",
            display: "flex", flexDirection: "column", gap: 12,
            transition: "background .2s ease",
            overflow: "hidden",
          }}>
            {mode === "text" && (
              <>
                <textarea autoFocus value={body} onChange={(e) => setBody(e.target.value)}
                          placeholder="Say it warmly. The serious thanks, the inside joke, the thing you never said out loud."
                          style={{
                  flex: 1, border: 0, background: "transparent", resize: "none", outline: 0,
                  fontFamily: "var(--serif)", fontSize: 21, lineHeight: 1.32, color: "var(--ink)",
                  letterSpacing: "-0.005em",
                }} />
                {sticker && (
                  <div style={{ position: "absolute", right: 16, bottom: 12, fontSize: 28, color: "var(--coral)" }}>{sticker}</div>
                )}
              </>
            )}

            {(mode === "photo" || mode === "gif") && (
              <div style={{
                display: "flex", flexDirection: "column", gap: 12,
                height: "100%", minHeight: 0,
              }}>
                <input ref={fileInputRef} type="file"
                       accept={mode === "gif"
                         ? "image/gif"
                         : "image/*,.heic,.heif"}
                       multiple={mode === "photo"}
                       onChange={(e) => acceptFile(e.target.files)}
                       style={{ display: "none" }} />

                {photos.length === 0 ? (
                  <div onClick={() => fileInputRef.current?.click()}
                       onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
                       onDragLeave={() => setDragOver(false)}
                       onDrop={(e) => {
                         e.preventDefault(); setDragOver(false);
                         acceptFile(e.dataTransfer.files);
                       }}
                       style={{
                         flex: 1, borderRadius: 8,
                         border: dragOver ? "1.5px dashed var(--coral)" : "1.5px dashed var(--line-2)",
                         display: "grid", placeItems: "center",
                         background: dragOver ? "rgba(232,126,90,0.06)" : "rgba(31,27,23,0.02)",
                         cursor: "pointer",
                         transition: "background .15s ease, border-color .15s ease",
                       }}>
                    <div style={{ textAlign: "center", color: "var(--ink-2)", fontFamily: "var(--sans)" }}>
                      <IconUpload size={26} />
                      <div style={{ marginTop: 8, fontSize: 14 }}>
                        {mode === "gif"
                          ? "Drop a GIF or click to browse"
                          : "Drop photos or click to pick — multiple welcome"}
                      </div>
                      <div style={{ marginTop: 4, fontSize: 12, color: "var(--ink-3)" }}>up to 12MB each</div>
                      {photoError && (
                        <div style={{ marginTop: 10, fontSize: 12, color: "var(--coral)" }}>{photoError}</div>
                      )}
                    </div>
                  </div>
                ) : photos.length === 1 ? (
                  // Single photo — keep the big-preview layout for parity
                  // with the existing flow.
                  <div style={{
                    flex: 1, minHeight: 0,
                    borderRadius: 8, overflow: "hidden",
                    background: "#1F1B17", position: "relative",
                    display: "flex", alignItems: "center", justifyContent: "center",
                  }}>
                    <img src={photos[0].src} alt={photos[0].name || "preview"} style={{
                      maxWidth: "100%", maxHeight: "100%",
                      objectFit: "contain", display: "block",
                    }} />
                    <div style={{
                      position: "absolute", top: 10, right: 10, display: "flex", gap: 6,
                    }}>
                      {mode === "photo" && (
                        <button onClick={() => fileInputRef.current?.click()} style={composeChipBtn}>
                          Add more
                        </button>
                      )}
                      <button onClick={() => fileInputRef.current?.click()} style={composeChipBtn}>
                        Replace
                      </button>
                      <button onClick={clearPhoto} style={composeChipBtn}>
                        Remove
                      </button>
                    </div>
                    {photos[0].name && (
                      <div style={{
                        position: "absolute", left: 10, bottom: 10,
                        background: "rgba(31,27,23,0.55)", color: "#fff",
                        fontFamily: "var(--mono)", fontSize: 10.5,
                        padding: "4px 8px", borderRadius: 999,
                        letterSpacing: "0.06em", textTransform: "uppercase",
                      }}>{photos[0].name}</div>
                    )}
                  </div>
                ) : (
                  // Multi-photo gallery — primary preview + thumbnail strip.
                  <PhotoGalleryEditor
                    photos={photos}
                    onAddMore={() => fileInputRef.current?.click()}
                    onRemove={removePhotoAt}
                    onClearAll={clearPhoto}
                  />
                )}
                {photoError && photos.length > 0 && (
                  <div style={{ fontSize: 12, color: "var(--coral)" }}>{photoError}</div>
                )}
                <input className="txt" placeholder="Add a caption (optional)"
                       value={caption} onChange={(e) => setCaption(e.target.value)} />
              </div>
            )}

            {mode === "video" && (
              <div style={{
                display: "flex", flexDirection: "column", gap: 12,
                height: "100%", minHeight: 0,
              }}>
                <input ref={videoInputRef} type="file"
                       accept="video/*"
                       onChange={(e) => acceptVideoFile(e.target.files)}
                       style={{ display: "none" }} />

                {!videoSrc ? (
                  <div onClick={() => videoInputRef.current?.click()}
                       onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
                       onDragLeave={() => setDragOver(false)}
                       onDrop={(e) => {
                         e.preventDefault(); setDragOver(false);
                         acceptVideoFile(e.dataTransfer.files);
                       }}
                       style={{
                         flex: 1, borderRadius: 8,
                         border: dragOver ? "1.5px dashed var(--coral)" : "1.5px dashed var(--line-2)",
                         display: "grid", placeItems: "center",
                         background: dragOver ? "rgba(232,126,90,0.06)" : "rgba(31,27,23,0.02)",
                         cursor: "pointer",
                         transition: "background .15s ease, border-color .15s ease",
                       }}>
                    <div style={{ textAlign: "center", color: "var(--ink-2)", fontFamily: "var(--sans)" }}>
                      <IconVideo size={26} />
                      <div style={{ marginTop: 8, fontSize: 14 }}>Drop a video or click to pick</div>
                      <div style={{ marginTop: 4, fontSize: 12, color: "var(--ink-3)" }}>
                        On the board it loops muted — sound plays on the recipient view. Up to 60 MB.
                      </div>
                      {videoError && (
                        <div style={{ marginTop: 10, fontSize: 12, color: "var(--coral)" }}>{videoError}</div>
                      )}
                    </div>
                  </div>
                ) : (
                  <div style={{
                    flex: 1, minHeight: 0,
                    borderRadius: 8, overflow: "hidden",
                    background: "#1F1B17", position: "relative",
                    display: "flex", alignItems: "center", justifyContent: "center",
                  }}>
                    <video
                      src={videoSrc}
                      autoPlay loop muted playsInline
                      onLoadedMetadata={(e) => {
                        const v = e.currentTarget;
                        setVideoMeta({ w: v.videoWidth || 0, h: v.videoHeight || 0, duration: v.duration || 0 });
                      }}
                      style={{ width: "100%", height: "100%", objectFit: "contain", display: "block" }}
                    />
                    <div style={{
                      position: "absolute", top: 10, right: 10,
                      display: "flex", gap: 6,
                    }}>
                      <button onClick={() => videoInputRef.current?.click()}
                              className="btn btn-soft"
                              style={{ padding: "6px 10px", fontSize: 11 }}>
                        Replace
                      </button>
                      <button onClick={clearVideo}
                              className="btn btn-soft"
                              style={{ padding: "6px 10px", fontSize: 11 }}>
                        Discard
                      </button>
                    </div>
                  </div>
                )}

                <input className="txt"
                       placeholder="caption (optional)"
                       maxLength={140}
                       value={caption} onChange={(e) => setCaption(e.target.value)} />
              </div>
            )}

            {mode === "voice" && (
              <div style={{ display: "flex", flexDirection: "column", gap: 18, alignItems: "center", justifyContent: "center", flex: 1 }}>
                {!audioSrc && !recording && (
                  <>
                    <button onClick={startRecording} style={{
                      width: 72, height: 72, borderRadius: "50%", border: 0,
                      background: "var(--coral)", color: "#fff", cursor: "pointer",
                      display: "grid", placeItems: "center",
                      boxShadow: "0 8px 24px rgba(232,126,90,0.32)",
                    }}>
                      <IconMic size={28} />
                    </button>
                    <div style={{ fontFamily: "var(--serif)", fontStyle: "italic", color: "var(--ink-2)", fontSize: 17 }}>
                      Tap to record · up to 90 seconds
                    </div>
                    <MicPicker
                      audioInputs={audioInputs}
                      audioInputId={audioInputId}
                      setAudioInputId={setAudioInputId}
                    />
                    {voiceError && <div style={{ fontSize: 12, color: "var(--coral)" }}>{voiceError}</div>}
                  </>
                )}

                {recording && (
                  <>
                    <button onClick={stopRecording} aria-label="Stop recording" style={{
                      width: 72, height: 72, borderRadius: "50%", border: 0,
                      background: "var(--ink)", color: "#fff", cursor: "pointer",
                      display: "grid", placeItems: "center",
                      boxShadow: "0 0 0 6px rgba(232,126,90,0.22), 0 8px 24px rgba(0,0,0,0.18)",
                    }}>
                      <span style={{ width: 22, height: 22, borderRadius: 4, background: "#fff" }} />
                    </button>
                    <div style={{ fontFamily: "var(--mono)", fontSize: 26, color: "var(--ink)", letterSpacing: "0.04em" }}>
                      {Math.floor(recordSeconds / 60)}:{String(recordSeconds % 60).padStart(2, "0")}
                    </div>
                    <div style={{ fontFamily: "var(--serif)", fontStyle: "italic", color: "var(--ink-2)", fontSize: 14 }}>
                      Tap the square to finish · auto-stops at 1:30
                    </div>
                    {activeMicLabel && (
                      <div style={{
                        fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--ink-3)",
                        letterSpacing: "0.06em", textTransform: "uppercase",
                      }}>
                        from {activeMicLabel}
                      </div>
                    )}
                  </>
                )}

                {audioSrc && !recording && (
                  <div style={{ width: "100%", maxWidth: 360, display: "flex", flexDirection: "column", gap: 14 }}>
                    <audio src={audioSrc} controls style={{ width: "100%" }} />
                    <div style={{
                      fontFamily: "var(--mono)", fontSize: 11, color: "var(--ink-3)",
                      letterSpacing: "0.06em", textTransform: "uppercase", textAlign: "center",
                    }}>
                      {Math.floor(audioDuration / 60)}:{String(audioDuration % 60).padStart(2, "0")} captured
                    </div>
                    <div style={{ display: "flex", justifyContent: "center", gap: 8 }}>
                      <button onClick={() => { clearAudio(); startRecording(); }} className="btn btn-soft">
                        <IconMic size={13} /> Re-record
                      </button>
                      <button onClick={clearAudio} className="btn btn-ghost">Discard</button>
                    </div>
                  </div>
                )}
              </div>
            )}

          </div>

          {/* Secondary attachment — voice for a photo post, photo for a
              voice post. Both reuse the same audio/photo state, so the
              submit payload already carries the extra media. */}
          {(mode === "photo" || mode === "gif") && (
            <SecondaryAudioAttach
              audioSrc={audioSrc}
              recording={recording}
              recordSeconds={recordSeconds}
              voiceError={voiceError}
              activeMicLabel={activeMicLabel}
              onStart={startRecording}
              onStop={stopRecording}
              onClear={clearAudio}
            />
          )}
          {mode === "voice" && (
            <SecondaryPhotoAttach
              photoSrc={photoSrc}
              photoName={photoName}
              photoError={photoError}
              onPick={acceptFile}
              onClear={clearPhoto}
            />
          )}

          {/* Bottom actions */}
          <div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 18 }}>
            {isAuthor ? (
              <label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13, color: "var(--ink-2)", cursor: "pointer" }}>
                <input type="checkbox" checked={anonymous} onChange={(e) => setAnonymous(e.target.checked)} />
                Sign anonymously
              </label>
            ) : (
              <div style={{ fontSize: 12, color: "var(--ink-3)", fontStyle: "italic" }}>
                Editing as admin · author chip stays the same
              </div>
            )}
            <div style={{ flex: 1 }} />
            <button className="btn btn-ghost" onClick={handleCancel}>Cancel</button>
            <button className="btn btn-coral" onClick={handleSubmit}
                    disabled={!canSubmit}
                    style={{
                      opacity: canSubmit ? 1 : 0.45,
                      cursor: canSubmit ? "pointer" : "not-allowed",
                    }}>
              {isEdit ? <>Save changes <IconCheck size={13} /></> : <>Stick it on <IconArrowRight size={13} /></>}
            </button>
          </div>
        </div>

        {/* Right: styling rail. Desktop = vertical column on the right.
            Mobile = horizontal-row strips below the composer, with bigger
            tap targets so the swatches don't end up as 12-px dots. */}
        <div style={{
          background: "rgba(31,27,23,0.025)",
          padding: isMobile ? "10px 12px" : 22,
          borderLeft: isMobile ? 0 : "1px solid var(--line)",
          borderTop: isMobile ? "1px solid var(--line)" : 0,
          display: "flex",
          flexDirection: "column",
          gap: isMobile ? 8 : 22,
          minHeight: 0,
          overflow: "auto",
        }}>
          {(mode === "text" || mode === "voice") && (
            isMobile ? (
              <RailRow label="Paper">
                {palette.notes.map((c) => (
                  <button key={c} onClick={() => setColor(c)} style={{
                    flex: "0 0 auto",
                    width: 34, height: 34, borderRadius: 8, background: c,
                    border: color === c ? "2px solid var(--ink)" : "1px solid rgba(31,27,23,0.1)",
                    cursor: "pointer", padding: 0,
                  }} />
                ))}
                <CustomColorButton
                  value={color}
                  onPick={setColor}
                  style={{ flex: "0 0 auto", width: 34, height: 34, borderRadius: 8 }}
                />
              </RailRow>
            ) : (
              <div>
                <div className="chip" style={{ marginBottom: 10 }}>Paper</div>
                <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8 }}>
                  {palette.notes.map((c) => (
                    <button key={c} onClick={() => setColor(c)} style={{
                      aspectRatio: "1", borderRadius: 8, background: c,
                      border: color === c ? "2px solid var(--ink)" : "1px solid rgba(31,27,23,0.1)",
                      cursor: "pointer", padding: 0,
                    }} />
                  ))}
                  <CustomColorButton
                    value={color}
                    onPick={setColor}
                    style={{ aspectRatio: "1", borderRadius: 8 }}
                  />
                </div>
              </div>
            )
          )}

          {isMobile ? (
            <RailRow label="Stickers">
              {STICKER_PACKS.hearts.map((s) => (
                <button key={s} onClick={() => setSticker(s === sticker ? null : s)} style={{
                  flex: "0 0 auto",
                  width: 38, height: 38, borderRadius: 8,
                  fontSize: 22, cursor: "pointer",
                  border: sticker === s ? "1.5px solid var(--ink)" : "1px solid var(--line)",
                  background: sticker === s ? "rgba(31,27,23,0.04)" : "#fff",
                  color: "var(--coral)",
                  display: "grid", placeItems: "center",
                  padding: 0,
                }}>{s}</button>
              ))}
            </RailRow>
          ) : (
            <div>
              <div className="chip" style={{ marginBottom: 10 }}>Stickers</div>
              <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 6 }}>
                {STICKER_PACKS.hearts.map((s) => (
                  <button key={s} onClick={() => setSticker(s === sticker ? null : s)} style={{
                    aspectRatio: "1", borderRadius: 8, fontSize: 22, cursor: "pointer",
                    border: sticker === s ? "1.5px solid var(--ink)" : "1px solid var(--line)",
                    background: sticker === s ? "rgba(31,27,23,0.04)" : "#fff",
                    color: "var(--coral)",
                    display: "grid", placeItems: "center",
                  }}>{s}</button>
                ))}
              </div>
            </div>
          )}

          {!isMobile && (
            <>
              <div>
                <div className="chip" style={{ marginBottom: 10 }}>Signing as</div>
                <div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 10px", background: "#fff", border: "1px solid var(--line)", borderRadius: 10 }}>
                  <Author author={anonymous ? { name: "Anonymous", hue: "#8A8278" } : AUTHORS.find((a) => a.id === "me")} size={22} />
                </div>
              </div>

              <div style={{ marginTop: "auto", fontFamily: "var(--mono)", fontSize: 10, color: "var(--ink-3)", letterSpacing: "0.06em", textTransform: "uppercase", lineHeight: 1.6 }}>
                tip: rotate & rearrange<br/>after sticking it on
              </div>
            </>
          )}
        </div>
      </div>
    </div>
  );
}

const composeChipBtn = {
  background: "rgba(255,255,255,0.92)", border: 0, borderRadius: 999,
  padding: "6px 11px", fontSize: 11.5, fontFamily: "var(--sans)",
  fontWeight: 500, color: "var(--ink)", cursor: "pointer",
  boxShadow: "0 2px 6px rgba(0,0,0,0.18)",
};

// Multi-photo editor for photo mode. Big preview of the first photo plus
// a horizontal strip of thumbnails. Each thumbnail has a remove button;
// the strip ends with an "Add more" tile that re-opens the file picker.
function PhotoGalleryEditor({ photos, onAddMore, onRemove, onClearAll }) {
  return (
    <div style={{
      flex: 1, minHeight: 0,
      display: "flex", flexDirection: "column", gap: 10,
    }}>
      {/* Primary preview */}
      <div style={{
        flex: 1, minHeight: 0,
        borderRadius: 8, overflow: "hidden",
        background: "#1F1B17", position: "relative",
        display: "flex", alignItems: "center", justifyContent: "center",
      }}>
        <img src={photos[0].src} alt={photos[0].name || "preview"} style={{
          maxWidth: "100%", maxHeight: "100%",
          objectFit: "contain", display: "block",
        }} />
        <div style={{
          position: "absolute", top: 10, right: 10, display: "flex", gap: 6,
        }}>
          <button onClick={onAddMore} style={composeChipBtn}>Add more</button>
          <button onClick={onClearAll} style={composeChipBtn}>Clear all</button>
        </div>
        <div style={{
          position: "absolute", left: 10, bottom: 10,
          background: "rgba(31,27,23,0.55)", color: "#fff",
          fontFamily: "var(--mono)", fontSize: 10.5,
          padding: "4px 8px", borderRadius: 999,
          letterSpacing: "0.06em", textTransform: "uppercase",
        }}>{photos.length} photos · {photos[0].name || "1"}</div>
      </div>
      {/* Thumbnail strip */}
      <div style={{
        display: "flex", gap: 8, overflowX: "auto", paddingBottom: 2,
        WebkitOverflowScrolling: "touch",
      }}>
        {photos.map((p, i) => (
          <div key={i} style={{
            position: "relative", flex: "0 0 auto",
            width: 64, height: 64, borderRadius: 6, overflow: "hidden",
            background: "#1F1B17",
            border: i === 0 ? "2px solid var(--coral)" : "1px solid var(--line)",
          }}>
            <img src={p.src} alt={p.name || `photo ${i + 1}`} style={{
              width: "100%", height: "100%", objectFit: "cover", display: "block",
            }} />
            <button
              onClick={(e) => { e.stopPropagation(); onRemove(i); }}
              aria-label="Remove photo"
              title="Remove"
              style={{
                position: "absolute", top: 2, right: 2,
                width: 18, height: 18, borderRadius: "50%",
                background: "rgba(31,27,23,0.78)", color: "#fff", border: 0,
                display: "grid", placeItems: "center",
                cursor: "pointer", padding: 0,
              }}
            >
              <IconClose size={10} />
            </button>
          </div>
        ))}
        <button onClick={onAddMore} style={{
          flex: "0 0 auto",
          width: 64, height: 64, borderRadius: 6,
          border: "1.5px dashed var(--line-2)",
          background: "rgba(31,27,23,0.02)",
          display: "grid", placeItems: "center",
          color: "var(--ink-2)", cursor: "pointer", padding: 0,
        }} aria-label="Add more photos" title="Add more">
          <IconPlus size={18} />
        </button>
      </div>
    </div>
  );
}

// Voice-attachment widget shown beneath a photo composer. Lets the user
// record a short voice note that travels with the photo as one combined
// post. State (audioSrc, recording, etc.) lives in the parent compose
// component so it round-trips through the existing submit handler.
function SecondaryAudioAttach({
  audioSrc, recording, recordSeconds, voiceError, activeMicLabel,
  onStart, onStop, onClear,
}) {
  const idle = !audioSrc && !recording;
  return (
    <div style={{
      marginTop: 12, padding: "10px 14px",
      borderRadius: 12, background: "rgba(31,27,23,0.04)",
      border: "1px solid var(--line)",
      display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap",
    }}>
      <span className="chip" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
        <IconMic size={11} /> Voice attachment
      </span>
      {idle && (
        <button onClick={onStart} className="btn btn-ghost"
                style={{ padding: "6px 12px", fontSize: 12.5 }}>
          <IconMic size={13} /> Add a voice note
        </button>
      )}
      {recording && (
        <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
          <button onClick={onStop} aria-label="Stop recording"
                  style={{
                    width: 32, height: 32, borderRadius: "50%",
                    background: "var(--ink)", color: "#fff", border: 0,
                    display: "grid", placeItems: "center", cursor: "pointer",
                    boxShadow: "0 0 0 4px rgba(232,126,90,0.18)",
                  }}>
            <span style={{ width: 11, height: 11, borderRadius: 2, background: "#fff" }} />
          </button>
          <span style={{ fontFamily: "var(--mono)", fontSize: 13 }}>
            {Math.floor(recordSeconds / 60)}:{String(recordSeconds % 60).padStart(2, "0")}
          </span>
          <span style={{ fontSize: 12, color: "var(--ink-3)" }}>recording…</span>
        </div>
      )}
      {audioSrc && !recording && (
        <>
          <audio src={audioSrc} controls
                 style={{ flex: 1, minWidth: 200, maxWidth: 360, height: 32 }} />
          <button onClick={onClear} className="btn btn-ghost"
                  style={{ padding: "6px 10px", fontSize: 12 }}>
            Remove
          </button>
        </>
      )}
      {voiceError && (
        <div style={{ flexBasis: "100%", fontSize: 12, color: "var(--coral)" }}>{voiceError}</div>
      )}
      {recording && activeMicLabel && (
        <div style={{ flexBasis: "100%", fontFamily: "var(--mono)", fontSize: 10.5,
                      color: "var(--ink-3)", letterSpacing: "0.06em", textTransform: "uppercase" }}>
          from {activeMicLabel}
        </div>
      )}
    </div>
  );
}

// Photo-attachment widget shown beneath a voice composer. Lets the user
// pin one image to a voice note so the recipient sees both side-by-side.
function SecondaryPhotoAttach({ photoSrc, photoName, photoError, onPick, onClear }) {
  const inputRef = React.useRef(null);
  return (
    <div style={{
      marginTop: 12, padding: "10px 14px",
      borderRadius: 12, background: "rgba(31,27,23,0.04)",
      border: "1px solid var(--line)",
      display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap",
    }}>
      <span className="chip" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
        <IconImage size={11} /> Photo attachment
      </span>
      <input ref={inputRef} type="file" accept="image/*,.heic,.heif"
             onChange={(e) => onPick(e.target.files?.[0])}
             style={{ display: "none" }} />
      {!photoSrc ? (
        <button onClick={() => inputRef.current?.click()} className="btn btn-ghost"
                style={{ padding: "6px 12px", fontSize: 12.5 }}>
          <IconImage size={13} /> Add a photo
        </button>
      ) : (
        <>
          <div style={{
            display: "flex", alignItems: "center", gap: 10,
            flex: 1, minWidth: 0,
          }}>
            <img src={photoSrc} alt={photoName || "preview"}
                 style={{
                   width: 44, height: 44, borderRadius: 6, objectFit: "cover",
                   border: "1px solid var(--line)",
                 }} />
            <div style={{
              fontSize: 12.5, color: "var(--ink-2)",
              overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
              flex: 1, minWidth: 0,
            }}>{photoName || "photo"}</div>
          </div>
          <button onClick={() => inputRef.current?.click()} className="btn btn-ghost"
                  style={{ padding: "6px 10px", fontSize: 12 }}>
            Replace
          </button>
          <button onClick={onClear} className="btn btn-ghost"
                  style={{ padding: "6px 10px", fontSize: 12 }}>
            Remove
          </button>
        </>
      )}
      {photoError && (
        <div style={{ flexBasis: "100%", fontSize: 12, color: "var(--coral)" }}>{photoError}</div>
      )}
    </div>
  );
}

// Mobile rail section: small label on the left, horizontally-scrollable
// row of items on the right. Kept here so the compose markup stays flat.
function RailRow({ label, children }) {
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
      <span className="chip" style={{ flex: "0 0 auto", width: 56 }}>{label}</span>
      <div style={{
        display: "flex", gap: 8, overflowX: "auto",
        flex: 1, minWidth: 0, paddingBottom: 2,
        WebkitOverflowScrolling: "touch",
      }}>
        {children}
      </div>
    </div>
  );
}

// Tiny dropdown for choosing the audio input device. Hidden until labels
// are available (browsers don't reveal device labels until mic permission
// has been granted at least once), and hidden when there's only one option.
function MicPicker({ audioInputs, audioInputId, setAudioInputId }) {
  const named = audioInputs.filter((d) => d.label);
  if (named.length <= 1) {
    // Pre-permission state — surface a soft hint so users know they CAN pick
    // a specific mic once they've granted access.
    if (audioInputs.length > 0) {
      return (
        <div style={{
          fontFamily: "var(--mono)", fontSize: 10, color: "var(--ink-3)",
          letterSpacing: "0.06em", textTransform: "uppercase",
        }}>
          Mic picker unlocks after first record
        </div>
      );
    }
    return null;
  }
  return (
    <label style={{
      display: "flex", alignItems: "center", gap: 8,
      fontSize: 12, color: "var(--ink-2)",
      background: "rgba(255,255,255,0.7)", border: "1px solid var(--line)",
      borderRadius: 999, padding: "6px 12px",
    }}>
      <IconMic size={12} />
      <select
        value={audioInputId || ""}
        onChange={(e) => setAudioInputId(e.target.value || null)}
        style={{
          appearance: "none", border: 0, background: "transparent",
          font: "inherit", color: "inherit", cursor: "pointer",
          maxWidth: 220,
        }}
      >
        {named.map((d) => (
          <option key={d.deviceId} value={d.deviceId}>{d.label}</option>
        ))}
      </select>
    </label>
  );
}

Object.assign(window, { ComposeScreen, MicPicker });
