"use client"

import type React from "react"

import { useCallback, useEffect, useRef, useState } from "react"
import type { RealtimeChannel } from "@supabase/supabase-js"
import {
  Mic,
  MicOff,
  Video,
  VideoOff,
  SkipForward,
  Phone,
  PhoneOff,
  Loader2,
  ExternalLink,
  SwitchCamera,
  Wand2,
  LogOut,
  ShieldCheck,
  UserCircle2,
  LogIn,
  Send,
  MessageCircle,
  MessageCircleOff,
  UserRound,
} from "lucide-react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { BlueTick } from "@/components/ui/blue-tick"
import { ShaderButton } from "@/components/ui/shader-button"
import { FilterTray } from "@/components/video/filter-tray"
import { useAdGate } from "@/components/ads/ad-gate"
import { getBrowserSupabase } from "@/lib/supabase/client"
import { cn } from "@/lib/utils"
import { getFilter, type VideoFilterId } from "@/lib/video-filters"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

type Status = "idle" | "requesting-media" | "waiting" | "connecting" | "connected" | "error"
type FacingMode = "user" | "environment"

type MatchResponse = {
  status: "waiting" | "matched"
  room_id?: string | null
  partner_id?: string | null
  role?: "offer" | "answer" | null
}

// Multiple public STUN endpoints improve the odds that ICE candidate
// gathering succeeds quickly from any given network — if one server is
// slow or blocked, the browser races the others. All are anonymous
// Google / Twilio / Cloudflare STUN endpoints (no TURN credentials
// required).
const ICE_SERVERS: RTCIceServer[] = [
  { urls: "stun:stun.l.google.com:19302" },
  { urls: "stun:stun1.l.google.com:19302" },
  { urls: "stun:stun2.l.google.com:19302" },
  { urls: "stun:stun3.l.google.com:19302" },
  { urls: "stun:stun4.l.google.com:19302" },
  { urls: "stun:global.stun.twilio.com:3478" },
  { urls: "stun:stun.cloudflare.com:3478" },
]

function randomId() {
  if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID()
  return Math.random().toString(36).slice(2) + Date.now().toString(36)
}

// Tune the outgoing video encoder for LOW-LATENCY random chat instead
// of maximum sharpness. The previous 2.5 Mbps cap + `maintain-resolution`
// defaults caused the encoder to hoard bandwidth on weak links, which
// showed up as visible freezes/lag on the receiving side. The numbers
// below are tuned for "video-call smoothness first, sharpness second":
//
// - `maxBitrate: 1.1 Mbps` is plenty for the 960x540@24 stream we
//   actually request (see getUserMedia below) and stays under the
//   typical upload ceiling of a mobile connection, so the encoder
//   never has to claw bandwidth back when the link dips.
// - `maxFramerate: 24` matches the source, avoiding frame-duplication
//   work at the encoder.
// - `degradationPreference: "maintain-framerate"` is the key fix: when
//   CPU or bandwidth tighten, the encoder drops RESOLUTION instead of
//   skipping frames, so motion stays smooth (the hallmark of "no lag").
async function tuneOutgoingVideo(pc: RTCPeerConnection) {
  const sender = pc.getSenders().find((s) => s.track?.kind === "video")
  if (!sender) return
  try {
    const params = sender.getParameters()
    if (!params.encodings || params.encodings.length === 0) {
      params.encodings = [{}]
    }
    for (const enc of params.encodings) {
      enc.maxBitrate = 1_100_000
      enc.maxFramerate = 24
      ;(enc as RTCRtpEncodingParameters & { networkPriority?: string })
        .networkPriority = "high"
      ;(enc as RTCRtpEncodingParameters & { priority?: string }).priority =
        "high"
    }
    ;(params as RTCRtpSendParameters & {
      degradationPreference?: RTCDegradationPreference
    }).degradationPreference = "maintain-framerate"
    await sender.setParameters(params)
  } catch (e) {
    console.log("[v0] setParameters failed:", e)
  }

  // Prefer hardware-friendly video codecs so the encode path stays off
  // the main thread and latency drops dramatically. We walk the list
  // from most-preferred (H264 — HW-accel on virtually every device) to
  // least. If `setCodecPreferences` isn't supported we silently no-op.
  try {
    const transceiver = pc
      .getTransceivers()
      .find((t) => t.sender === sender)
    const caps = (
      RTCRtpSender as unknown as {
        getCapabilities?: (kind: "video") => RTCRtpCapabilities | null
      }
    ).getCapabilities?.("video")
    if (transceiver && caps?.codecs && "setCodecPreferences" in transceiver) {
      const PREF = ["video/H264", "video/VP9", "video/VP8", "video/AV1"]
      const ordered = [...caps.codecs].sort((a, b) => {
        const ia = PREF.indexOf(a.mimeType)
        const ib = PREF.indexOf(b.mimeType)
        return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
      })
      transceiver.setCodecPreferences(ordered)
    }
  } catch (e) {
    console.log("[v0] setCodecPreferences failed:", e)
  }
}

export function VideoChat({
  userEmail,
  userId,
  displayName,
  userGender,
  userAvatarUrl,
  userVerified,
  isAdmin,
}: {
  /** null for guests (unauthenticated visitors) */
  userEmail: string | null
  /** Supabase auth.users.id, null for guests */
  userId: string | null
  displayName: string | null
  /** One of the profile gender enum values, or null for guests. */
  userGender: string | null
  /** Public URL of the user's avatar from Supabase Storage, or null. */
  userAvatarUrl: string | null
  /** Whether the signed-in user has been granted the blue tick. Drives
   *  the badge next to their name in the header AND is broadcast in
   *  `meta` so the stranger's UI can render the same badge for them. */
  userVerified: boolean
  isAdmin: boolean
}) {
  const isGuest = !userEmail
  const router = useRouter()
  const [status, setStatus] = useState<Status>("idle")
  const [error, setError] = useState<string | null>(null)
  const [errorKind, setErrorKind] = useState<"permission" | "device" | "insecure" | "other" | null>(null)
  const [muted, setMuted] = useState(false)
  const [cameraOff, setCameraOff] = useState(false)
  const [waitingCount, setWaitingCount] = useState(0)
  const [liveUsers, setLiveUsers] = useState(0)
  const [facingMode, setFacingMode] = useState<FacingMode>("user")
  const [remoteFacingMode, setRemoteFacingMode] = useState<FacingMode>("user")
  const [hasMultipleCameras, setHasMultipleCameras] = useState(false)
  const [switchingCamera, setSwitchingCamera] = useState(false)
  // True when the browser blocked autoplay of the remote stream's audio.
  // The user then has to tap "Enable sound" to start audio playback.
  const [audioBlocked, setAudioBlocked] = useState(false)
  // Admin-controlled Adsterra gate. `afterChat()` no-ops instantly when
  // ads are disabled or the user hasn't hit the frequency threshold —
  // it's safe to call from every code path that ends a chat.
  const adGate = useAdGate()

  // --- Video filters (Snapchat-style) -------------------------------------
  // The selected filter is applied to BOTH the local preview (via CSS
  // `filter` on the <video> element) AND the outgoing track that the
  // stranger receives (via a hidden-canvas pipeline — see the effect
  // below). `none` disables the canvas pipeline entirely so there's
  // zero overhead on the default/unfiltered path.
  const [videoFilter, setVideoFilter] = useState<VideoFilterId>("none")
  const [filterTrayOpen, setFilterTrayOpen] = useState(false)
  // Bumps every time `localStreamRef.current` is swapped (initial
  // getUserMedia, permission retry, camera switch). Serves as a
  // dependency trigger so the filter pipeline effect re-runs and
  // re-binds to the newest source track.
  const [localStreamVersion, setLocalStreamVersion] = useState(0)
  // Bumps every time a fresh RTCPeerConnection is built (new match /
  // skip). The filter pipeline depends on this so it can re-attach to
  // the new sender and keep the filter applied across reconnections.
  const [pcVersion, setPcVersion] = useState(0)

  // --- In-call text chat ---------------------------------------------------
  // Each peer in the room broadcasts its display name + chat messages via
  // the existing Supabase realtime `room:*` channel (same one used for WebRTC
  // signaling). This avoids the complexity of a WebRTC DataChannel while
  // reusing the already-working transport.
  type ChatMsg = {
    id: string
    // "me" for locally-sent, "them" for received from the stranger
    from: "me" | "them"
    text: string
    ts: number
  }
  const [chatMessages, setChatMessages] = useState<ChatMsg[]>([])
  const [chatInput, setChatInput] = useState("")
  const [strangerName, setStrangerName] = useState<string | null>(null)
  const [strangerGender, setStrangerGender] = useState<string | null>(null)
  const [strangerAvatarUrl, setStrangerAvatarUrl] = useState<string | null>(null)
  // Whether the stranger has the blue tick. Driven by the `verified`
  // field in their broadcast meta — defaults to false so nothing is
  // rendered until we receive a confirmed value.
  const [strangerVerified, setStrangerVerified] = useState<boolean>(false)
  const [chatOpen, setChatOpen] = useState(true) // mobile show/hide toggle
  const chatScrollRef = useRef<HTMLDivElement>(null)
  // Height (in px) of the floating controls bar, measured dynamically so the
  // chat overlay can always sit directly above it on mobile regardless of
  // how many control buttons are actually rendered.
  const [controlsHeight, setControlsHeight] = useState(0)
  const controlsBarRef = useRef<HTMLDivElement>(null)

  // Permission preflight modal — shown before the first call so the user
  // knows the browser is about to ask for camera + microphone, and can
  // see a clear per-device status + recovery steps if either is blocked.
  type PermStage = "intro" | "requesting" | "denied"
  type DeviceState = "ok" | "denied" | "failed" | "unknown"
  type PermModal = {
    stage: PermStage
    micState: DeviceState
    camState: DeviceState
    inIframe: boolean
    // True when we detected the embedding iframe is suppressing the
    // permission prompt (see acquireMedia docs). When true, the modal
    // surfaces "Open in new tab" as the primary recovery action.
    iframeBlocked: boolean
    errorMsg: string | null
  } | null
  const [permModal, setPermModal] = useState<PermModal>(null)

  // PiP draggable position (mobile only). null = default anchored bottom-right.
  // NOTE: PiP position is stored in refs only (not React state) so dragging
  // does not trigger re-renders. This keeps motion perfectly smooth.

  const userIdRef = useRef<string>("")
  // Flipped to `true` the moment a call actually reaches "connected"
  // (either via pc.connectionState, iceConnectionState, or the remote
  // <video> firing `playing`). We check this in skip/leave so the ad
  // gate only counts CHATS THAT ACTUALLY HAPPENED — if the user spams
  // "Next" while searching, no ad is triggered.
  const wasConnectedRef = useRef(false)
  // The name we broadcast to strangers we match with. Kept in a ref so the
  // value is always fresh inside memoized callbacks without adding it to
  // dependency arrays.
  const myDisplayNameRef = useRef<string>("")
  // The gender we broadcast alongside the display name. Normalized to one
  // of the profile enum values ('male' | 'female' | 'non-binary' |
  // 'prefer-not-to-say') or null for guests.
  const myGenderRef = useRef<string | null>(null)
  // Public URL of the user's avatar (from Supabase Storage) that we
  // broadcast to the stranger so their UI can display our framed photo.
  // Guests always broadcast null.
  const myAvatarUrlRef = useRef<string | null>(null)
  // Whether the local user has the verified blue tick. Broadcast in
  // the `meta` payload so the stranger's UI can render the badge.
  const myVerifiedRef = useRef<boolean>(false)
  const localVideoRef = useRef<HTMLVideoElement>(null)
  const remoteVideoRef = useRef<HTMLVideoElement>(null)
  const localStreamRef = useRef<MediaStream | null>(null)
  const pcRef = useRef<RTCPeerConnection | null>(null)
  const channelRef = useRef<RealtimeChannel | null>(null)
  const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
  // Realtime "lobby" channel the waiter subscribes to so it gets notified
  // instantly when another user pairs with it — without waiting for the
  // next /api/check-match poll.
  const lobbyChannelRef = useRef<RealtimeChannel | null>(null)
  const roomIdRef = useRef<string | null>(null)
  const roleRef = useRef<"offer" | "answer" | null>(null)
  const facingModeRef = useRef<FacingMode>("user")
  // Buffer ICE candidates that arrive before remoteDescription is set
  const pendingCandidatesRef = useRef<RTCIceCandidateInit[]>([])
  // Ref to call latest startMatch from signal handlers without dependency cycles
  const startMatchRef = useRef<() => Promise<void>>(async () => {})
  // Swipe-to-skip gesture state on the stranger video surface. Stores the
  // touch start position + a `fired` guard so a single long drag can
  // only trigger `skip()` once per gesture.
  const swipeRef = useRef<{ x: number; y: number; fired: boolean } | null>(
    null,
  )

  // PiP drag + pinch refs
  const pipCardRef = useRef<HTMLDivElement>(null)
  // Top-left position in viewport (px). Visual position == this exactly,
  // because we use transform-origin: 0 0.
  const pipPosRef = useRef<{ x: number; y: number } | null>(null)
  // Current applied scale (1.0 → 1.3). Persists across interactions.
  const pipScaleRef = useRef<number>(1)
  const dragRafRef = useRef<number | null>(null)
  // Active pointers on the card (by pointerId → last known position)
  const pointersRef = useRef<Map<number, { x: number; y: number }>>(new Map())
  // Single explicit mode so transitions can never leave stale state behind
  type GestureMode = "idle" | "drag" | "pinch"
  const modeRef = useRef<GestureMode>("idle")
  // Baseline captured at the start of the current gesture. Refreshed every
  // time the active mode (or its set of pointers) changes.
  const baselineRef = useRef<{
    // Pointer that owns the gesture (for drag) or pinch primary (unused for pinch math)
    pointerId: number
    // Finger position at baseline
    fingerX: number
    fingerY: number
    // Card top-left at baseline
    posX: number
    posY: number
    // For pinch:
    distance: number
    scale: number
  } | null>(null)
  // Has the user actually moved past the slop threshold yet?
  const movedRef = useRef(false)

  // --- helpers ---------------------------------------------------------------

  const clearPolling = useCallback(() => {
    if (pollTimerRef.current) {
      clearInterval(pollTimerRef.current)
      pollTimerRef.current = null
    }
  }, [])

  // Synchronous teardown — closes the peer connection and clears the remote
  // video instantly so the UI reflects the change on the very next frame.
  const closeLobbyChannel = useCallback(() => {
    const ch = lobbyChannelRef.current
    lobbyChannelRef.current = null
    if (ch) {
      try {
        void Promise.resolve(ch.unsubscribe()).catch(() => {})
      } catch {}
    }
  }, [])

  const teardownPeerSync = useCallback(() => {
    clearPolling()
    closeLobbyChannel()
    if (pcRef.current) {
      pcRef.current.onicecandidate = null
      pcRef.current.ontrack = null
      pcRef.current.onconnectionstatechange = null
      try {
        pcRef.current.close()
      } catch {}
      pcRef.current = null
    }
    // Detach the realtime channel ref immediately; unsubscribe runs in the
    // background (it can take hundreds of ms over a slow connection).
    const ch = channelRef.current
    channelRef.current = null
    if (ch) {
      void Promise.resolve(ch.unsubscribe()).catch(() => {})
    }
    pendingCandidatesRef.current = []
    roomIdRef.current = null
    roleRef.current = null
    setRemoteFacingMode("user")
    setAudioBlocked(false)
    // Wipe per-call chat state so the next match starts fresh.
    setChatMessages([])
    setStrangerName(null)
    setStrangerGender(null)
    setStrangerAvatarUrl(null)
    setStrangerVerified(false)
    setChatInput("")
    if (remoteVideoRef.current) {
      remoteVideoRef.current.srcObject = null
    }
  }, [clearPolling, closeLobbyChannel])

  // Kept for API compatibility — just delegates to the synchronous version.
  const teardownPeer = useCallback(async () => {
    teardownPeerSync()
  }, [teardownPeerSync])

  const stopLocalMedia = useCallback(() => {
    if (localStreamRef.current) {
      localStreamRef.current.getTracks().forEach((t) => t.stop())
      localStreamRef.current = null
    }
    if (localVideoRef.current) localVideoRef.current.srcObject = null
  }, [])

  const getUserId = () => {
    if (!userIdRef.current) userIdRef.current = randomId()
    return userIdRef.current
  }

  const sendSignal = useCallback((type: string, payload: unknown) => {
    const ch = channelRef.current
    if (!ch) return
    ch.send({
      type: "broadcast",
      event: "signal",
      payload: { type, from: userIdRef.current, payload },
    })
  }, [])

  // Send a chat message over the active room channel AND append it to our
  // own message list so the user sees their own message immediately (we
  // don't wait for an echo because `broadcast.self` is false).
  const sendChat = useCallback(
    (text: string) => {
      const clean = text.trim().slice(0, 500)
      if (!clean) return
      const ch = channelRef.current
      if (!ch) return
      const id = `me-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
      const ts = Date.now()
      ch.send({
        type: "broadcast",
        event: "signal",
        payload: { type: "chat", from: userIdRef.current, payload: { text: clean, id, ts } },
      })
      // Dedup by id so rapid clicks / double-submits don't double-render on
      // the sender side either.
      setChatMessages((prev) => {
        if (prev.some((m) => m.id === id)) return prev
        return [...prev, { id, from: "me", text: clean, ts }]
      })
    },
    [],
  )

  // --- WebRTC flow -----------------------------------------------------------

  const createPeerConnection = useCallback(() => {
    // Pre-gather 10 ICE candidates so they're ready the instant the
    // offer/answer exchange finishes (cuts a noticeable chunk off the
    // time-to-connected). `max-bundle` + `require` RTCP-mux keep audio
    // + video multiplexed on a single transport for fewer round-trips.
    const pc = new RTCPeerConnection({
      iceServers: ICE_SERVERS,
      iceCandidatePoolSize: 10,
      bundlePolicy: "max-bundle",
      rtcpMuxPolicy: "require",
    })

    pc.onicecandidate = (event) => {
      if (event.candidate) {
        sendSignal("ice", event.candidate.toJSON())
      }
    }

    pc.ontrack = (event) => {
      const [remoteStream] = event.streams
      const videoEl = remoteVideoRef.current
      if (!videoEl || !remoteStream) return

      // Only attach + (re)start playback the first time the stream changes.
      // ontrack fires once per remote track (audio AND video each fire it),
      // but they share the same MediaStream so we only need to react once.
      const isNewStream = videoEl.srcObject !== remoteStream
      if (isNewStream) {
        videoEl.srcObject = remoteStream
        // Hook the element's `playing` event to force the UI to
        // "connected" the moment frames actually arrive — this is the
        // MOST reliable signal (if we're seeing pixels, we're connected)
        // and unblocks the UI even when connectionState lags behind.
        const onPlaying = () => {
          const mc = (pc as RTCPeerConnection & { __markConnected?: () => void })
            .__markConnected
          if (mc) mc()
        }
        videoEl.addEventListener("playing", onPlaying, { once: true })
        videoEl.addEventListener("loadeddata", onPlaying, { once: true })
      }

      // Log what we received so audio issues are easy to diagnose.
      const audioTracks = remoteStream.getAudioTracks()
      const videoTracks = remoteStream.getVideoTracks()
      console.log(
        "[v0] ontrack — kind:",
        event.track.kind,
        "audioTracks:",
        audioTracks.length,
        "videoTracks:",
        videoTracks.length,
        "audioMuted:",
        audioTracks[0]?.muted,
      )

      // Explicitly ensure the element is unmuted at full volume. Some
      // browsers carry over a previous muted state from earlier playbacks.
      videoEl.volume = 1.0
      videoEl.muted = false
      setAudioBlocked(false)

      // Try to play with audio. If the browser blocks autoplay-with-sound
      // (rare here because Start was a user gesture, but still possible
      // with strict autoplay policies) we fall back to muted playback so
      // the VIDEO at least appears, then surface a "Tap to enable sound"
      // button that the user can press to unmute.
      const tryPlay = async () => {
        try {
          await videoEl.play()
          console.log("[v0] remote video playing with audio")
        } catch (err) {
          console.log(
            "[v0] remote play() with audio blocked:",
            (err as Error)?.name,
            (err as Error)?.message,
          )
          // Fallback: muted playback so the video appears immediately.
          try {
            videoEl.muted = true
            await videoEl.play()
            setAudioBlocked(true)
          } catch (err2) {
            console.log(
              "[v0] remote video muted-play also failed:",
              (err2 as Error)?.name,
              (err2 as Error)?.message,
            )
            setAudioBlocked(true)
          }
        }
      }
      void tryPlay()
    }

    // `connectionState` is the "official" signal for a live call but it
    // isn't reliably reached on every browser/network combo: on some
    // Safari + restrictive NAT setups, media flows fine but
    // `connectionState` never progresses past "connecting". We treat
    // EITHER of:
    //   - pc.connectionState === "connected"
    //   - pc.iceConnectionState === "connected" | "completed"
    //   - remote <video> element firing `playing` (see its onPlaying)
    // as "connected" so the UI never gets stuck showing "connecting"
    // while the user is already seeing the stranger's video.
    let announced = false
    const markConnected = () => {
      if (announced) return
      announced = true
      // Remember that this chat reached a real connected state, so the
      // ad gate only counts genuine completed chats (not idle skips).
      wasConnectedRef.current = true
      setStatus("connected")
      // Once connected, share our display name + current facing mode with
      // the peer so the stranger's UI can label this side of the call.
      sendSignal("meta", {
        facingMode: facingModeRef.current,
        displayName: myDisplayNameRef.current,
        gender: myGenderRef.current,
        avatarUrl: myAvatarUrlRef.current,
        verified: myVerifiedRef.current,
      })
      // Apply low-latency encoder settings + codec preferences.
      void tuneOutgoingVideo(pc)
    }
    // Expose on the pc instance so `ontrack` / `onplaying` can call it
    // without needing to thread a ref through every handler.
    ;(pc as RTCPeerConnection & { __markConnected?: () => void }).__markConnected = markConnected
    pc.onconnectionstatechange = () => {
      const st = pc.connectionState
      if (st === "connected") markConnected()
    }
    pc.oniceconnectionstatechange = () => {
      const st = pc.iceConnectionState
      if (st === "connected" || st === "completed") markConnected()
    }

    // Add local tracks. Hint that video is full-motion content so browsers
    // optimise encoding for smoothness + detail rather than still-image.
    if (localStreamRef.current) {
      for (const track of localStreamRef.current.getTracks()) {
        if (track.kind === "video") {
          try {
            ;(track as MediaStreamTrack & { contentHint?: string }).contentHint = "motion"
          } catch {}
        }
        pc.addTrack(track, localStreamRef.current)
      }
    }

    pcRef.current = pc
    // Notify the filter pipeline that a new pc (and therefore a new
    // video sender) is now live, so it can rebind its canvas stream.
    setPcVersion((v) => v + 1)
    return pc
  }, [sendSignal])

  const startSignaling = useCallback(
    async (roomId: string, role: "offer" | "answer") => {
      const supabase = getBrowserSupabase()
      const channel = supabase.channel(`room:${roomId}`, {
        config: { broadcast: { self: false, ack: false } },
      })

      channel.on("broadcast", { event: "signal" }, async (msg) => {
        const payload = (msg as { payload: { type: string; from: string; payload: unknown } }).payload
        if (!payload || payload.from === userIdRef.current) return
        const pc = pcRef.current
        if (!pc && payload.type !== "bye" && payload.type !== "meta") return

        try {
          if (payload.type === "offer") {
            await pc!.setRemoteDescription(payload.payload as RTCSessionDescriptionInit)
            for (const c of pendingCandidatesRef.current) {
              try {
                await pc!.addIceCandidate(c)
              } catch (e) {
                console.log("[v0] addIceCandidate (buffered) failed:", e)
              }
            }
            pendingCandidatesRef.current = []
            const answer = await pc!.createAnswer()
            await pc!.setLocalDescription(answer)
            sendSignal("answer", answer)
          } else if (payload.type === "answer") {
            await pc!.setRemoteDescription(payload.payload as RTCSessionDescriptionInit)
            for (const c of pendingCandidatesRef.current) {
              try {
                await pc!.addIceCandidate(c)
              } catch {}
            }
            pendingCandidatesRef.current = []
          } else if (payload.type === "ice") {
            const cand = payload.payload as RTCIceCandidateInit
            if (pc!.remoteDescription && pc!.remoteDescription.type) {
              try {
                await pc!.addIceCandidate(cand)
              } catch (e) {
                console.log("[v0] addIceCandidate failed:", e)
              }
            } else {
              pendingCandidatesRef.current.push(cand)
            }
          } else if (payload.type === "meta") {
            const meta = payload.payload as {
              facingMode?: FacingMode
              displayName?: string
              gender?: string | null
              avatarUrl?: string | null
              verified?: boolean
            }
            if (meta?.facingMode === "user" || meta?.facingMode === "environment") {
              setRemoteFacingMode(meta.facingMode)
            }
            if (typeof meta?.displayName === "string") {
              const clean = meta.displayName.trim().slice(0, 40)
              if (clean) setStrangerName(clean)
            }
            if (typeof meta?.gender === "string") {
              const g = meta.gender.trim().toLowerCase()
              // Accept only the known profile enum values; drop anything else.
              if (
                g === "male" ||
                g === "female" ||
                g === "non-binary" ||
                g === "prefer-not-to-say"
              ) {
                setStrangerGender(g)
              }
            } else if (meta?.gender === null) {
              setStrangerGender(null)
            }
            // Accept absolute http(s) URLs only — reject anything else so a
            // malicious peer can't push non-image schemes (javascript:, data:
            // with HTML, file:, etc.) into an <img src>.
            if (typeof meta?.avatarUrl === "string") {
              const u = meta.avatarUrl.trim()
              if (/^https?:\/\//i.test(u) && u.length <= 2048) {
                setStrangerAvatarUrl(u)
              }
            } else if (meta?.avatarUrl === null) {
              setStrangerAvatarUrl(null)
            }
            // Blue tick state. Cast through `boolean` so stray non-bool
            // payloads can never flip the flag on.
            if (typeof meta?.verified === "boolean") {
              setStrangerVerified(meta.verified)
            }
          } else if (payload.type === "chat") {
            // Incoming text message from the stranger.
            const chat = payload.payload as { text?: string; id?: string; ts?: number }
            const text = typeof chat?.text === "string" ? chat.text.trim().slice(0, 500) : ""
            if (!text) return
            const id =
              chat.id ??
              `${payload.from}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
            // Dedup by id. Guards against Supabase redelivery, React strict-
            // mode double-mounts, or any other source of duplicate events —
            // the sender always includes a stable `id` so repeated arrivals
            // can be collapsed into a single rendered message.
            setChatMessages((prev) => {
              if (prev.some((m) => m.id === id)) return prev
              return [
                ...prev,
                { id, from: "them", text, ts: chat.ts ?? Date.now() },
              ]
            })
          } else if (payload.type === "bye") {
            // Partner left (Next/Stop). Tear down peer, keep local media,
            // and automatically look for a new stranger.
            await teardownPeer()
            try {
              await fetch("/api/leave", {
                method: "POST",
                headers: { "content-type": "application/json" },
                body: JSON.stringify({ userId: getUserId() }),
              })
            } catch {}
            // Rotate id so we don't get paired with the same person
            userIdRef.current = randomId()
            setStatus("idle")
            setTimeout(() => {
              void startMatchRef.current()
            }, 250)
          }
        } catch (e) {
          console.log("[v0] signal handler error:", e)
        }
      })

      await new Promise<void>((resolve, reject) => {
        channel.subscribe((st) => {
          if (st === "SUBSCRIBED") resolve()
          if (st === "CHANNEL_ERROR" || st === "TIMED_OUT") reject(new Error("channel error"))
        })
      })

      channelRef.current = channel

      // Now create peer connection and initiate if offerer
      const pc = createPeerConnection()

      if (role === "offer") {
        const offer = await pc.createOffer()
        await pc.setLocalDescription(offer)
        sendSignal("offer", offer)
      }
    },
    [createPeerConnection, sendSignal, teardownPeer],
  )

  const pollForMatch = useCallback(async () => {
    try {
      const res = await fetch("/api/check-match", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ userId: getUserId() }),
      })
      const data = (await res.json()) as MatchResponse
      if (data.status === "matched" && data.room_id && data.role) {
        clearPolling()
        roomIdRef.current = data.room_id
        roleRef.current = data.role
        setStatus("connecting")
        await startSignaling(data.room_id, data.role)
      }
    } catch (e) {
      console.log("[v0] poll error:", e)
    }
  }, [clearPolling, startSignaling])

  // Classify a getUserMedia error into a human-friendly message + category.
  const classifyMediaError = useCallback(
    (err: unknown): { kind: "permission" | "device" | "insecure" | "other"; msg: string } => {
      const name = (err as { name?: string })?.name ?? ""
      const rawMsg = (err as { message?: string })?.message ?? "Could not access device"
      if (name === "NotAllowedError" || name === "SecurityError" || /permission/i.test(rawMsg)) {
        return { kind: "permission", msg: "Access was denied." }
      }
      if (name === "NotFoundError" || name === "OverconstrainedError") {
        return { kind: "device", msg: "No matching device was found." }
      }
      if (name === "NotReadableError") {
        return {
          kind: "device",
          msg: "The device is in use by another app. Close it and try again.",
        }
      }
      return { kind: "other", msg: rawMsg }
    },
    [],
  )

  // Query the current permission state (granted / denied / prompt / unknown).
  // Not all browsers support Permissions API for camera/microphone — in that
  // case we return "unknown" and fall back to calling getUserMedia directly.
  const queryPermissionState = useCallback(
    async (name: "camera" | "microphone"): Promise<"granted" | "denied" | "prompt" | "unknown"> => {
      try {
        // @ts-expect-error — "camera" / "microphone" aren't in TS lib yet
        const p = await navigator.permissions.query({ name })
        return p.state as "granted" | "denied" | "prompt"
      } catch {
        return "unknown"
      }
    },
    [],
  )

  // Acquire camera + microphone.
  //
  // Return shape:
  //   micState / camState:
  //     "ok"     — got the track successfully
  //     "denied" — user explicitly blocked the permission in site settings
  //     "failed" — getUserMedia failed for a non-permission reason
  //   iframeBlocked:
  //     true — getUserMedia was silently rejected BUT the Permissions API
  //            says the state is still "prompt" (the user has never been
  //            asked). That combination only happens when an embedding
  //            iframe is suppressing the prompt via its `allow` attribute.
  //            JS cannot unblock this — the user MUST open the page in a
  //            top-level browser tab.
  // Strategy:
  //   1. Query the Permissions API first. If a permission is explicitly
  //      "denied", calling getUserMedia will silently reject (NO popup),
  //      so we skip the call and report `denied: true` so the UI can show
  //      browser-specific unblock instructions.
  //   2. For permissions in "prompt" (or "unknown") state, we call
  //      getUserMedia — that's what actually triggers the browser popup.
  //   3. Mic is requested BEFORE camera so the prompts appear in a
  //      predictable order and the mic prompt is never skipped.
  //   4. If either permission is missing in the end we return an error
  //      describing exactly which one(s) the user still needs to allow.
  const acquireMedia = useCallback(async (): Promise<
    | { ok: true; stream: MediaStream }
    | {
        ok: false
        micState: "denied" | "failed" | "ok"
        camState: "denied" | "failed" | "ok"
        inIframe: boolean
        iframeBlocked: boolean
        errorMsg: string
      }
  > => {
    const inIframe = typeof window !== "undefined" && window.self !== window.top

    if (typeof window !== "undefined" && !window.isSecureContext) {
      return {
        ok: false,
        micState: "failed",
        camState: "failed",
        inIframe,
        iframeBlocked: false,
        errorMsg: "Camera and microphone require HTTPS. Please open this page over https://.",
      }
    }
    if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
      return {
        ok: false,
        micState: "failed",
        camState: "failed",
        inIframe,
        iframeBlocked: false,
        errorMsg: "Your browser does not support camera or microphone access.",
      }
    }

    // NOTE: we DO NOT short-circuit based on `document.featurePolicy.allowsFeature`
    // or `navigator.permissions.query`. Both are unreliable in practice —
    // `featurePolicy` can return `false` inside iframes even when the parent
    // frame's `allow` attribute DOES include the feature (e.g. v0's preview),
    // and Chrome's `permissions.query({name:'microphone'})` sometimes returns
    // a stale "denied" state even though the user was never prompted. If we
    // trusted either of those we'd skip the real `getUserMedia` call and the
    // mic popup would never appear — which is exactly the bug the user hit.
    //
    // The ONLY reliable way to trigger the permission popup is to actually
    // call `getUserMedia`. We do that unconditionally and rely on the real
    // outcome. The Permissions API is consulted afterwards only to make the
    // error message more specific (e.g. "permanently blocked in site settings").
    console.log("[v0] acquireMedia: inIframe =", inIframe)

    // Step 1 — MICROPHONE (always call, this is what actually prompts)
    let audioStream: MediaStream | null = null
    let micState: "denied" | "failed" | "ok" = "ok"
    let micMsg: string | null = null
    try {
      audioStream = await navigator.mediaDevices.getUserMedia({
        audio: {
          echoCancellation: true,
          noiseSuppression: true,
          autoGainControl: true,
        },
      })
    } catch (err) {
      const c = classifyMediaError(err)
      micState = c.kind === "permission" ? "denied" : "failed"
      micMsg = c.msg
      console.log(
        "[v0] mic getUserMedia failed:",
        (err as Error)?.name,
        (err as Error)?.message,
      )
    }

    // Step 2 — CAMERA (always call, this is what actually prompts)
    let videoStream: MediaStream | null = null
    let camState: "denied" | "failed" | "ok" = "ok"
    let camMsg: string | null = null
    try {
      // Low-latency default: request 540p@24 (ideal) with a 720p ceiling
      // rather than 720p@30/1080p. At random-chat scale this looks just
      // as good full-screen on a phone while cutting encode work by
      // ~40%, which is the single biggest win against the laggy feel
      // users reported on mid-range devices and weak uploads.
      videoStream = await navigator.mediaDevices.getUserMedia({
        video: {
          facingMode: { ideal: facingModeRef.current },
          width: { ideal: 960, max: 1280 },
          height: { ideal: 540, max: 720 },
          frameRate: { ideal: 24, max: 30 },
        },
      })
    } catch (err) {
      const c = classifyMediaError(err)
      camState = c.kind === "permission" ? "denied" : "failed"
      camMsg = c.msg
      console.log(
        "[v0] camera getUserMedia failed:",
        (err as Error)?.name,
        (err as Error)?.message,
      )
    }

    // Abort if either failed — clean up any partial stream first.
    if (!audioStream || !videoStream) {
      if (audioStream) audioStream.getTracks().forEach((t) => t.stop())
      if (videoStream) videoStream.getTracks().forEach((t) => t.stop())

      // Enrich the error with Permissions API hints so the UI can tell the
      // user whether retrying will help (state: "prompt") or whether they
      // need to change site settings first (state: "denied").
      const [micPerm, camPerm] = await Promise.all([
        queryPermissionState("microphone"),
        queryPermissionState("camera"),
      ])
      console.log("[v0] after-failure permission states — mic:", micPerm, "cam:", camPerm)
      if (!audioStream && micPerm === "denied") micState = "denied"
      if (!videoStream && camPerm === "denied") camState = "denied"

      // Detect the "iframe is suppressing the prompt" case. If gUM threw a
      // permission error but the Permissions API still reports "prompt"
      // (i.e. the user was never asked), the browser silently blocked the
      // request because the embedding iframe's `allow` attribute doesn't
      // include that feature. The ONLY fix is to open the page in a
      // top-level tab — JS cannot force the prompt to appear.
      const micIframeBlocked = !audioStream && micState === "denied" && micPerm === "prompt"
      const camIframeBlocked = !videoStream && camState === "denied" && camPerm === "prompt"
      const iframeBlocked = inIframe && (micIframeBlocked || camIframeBlocked)

      const combinedMsg = iframeBlocked
        ? "This embedded preview can't open the permission popup for your " +
          (micIframeBlocked && camIframeBlocked
            ? "camera or microphone"
            : micIframeBlocked
              ? "microphone"
              : "camera") +
          ". Open this page in a new browser tab to allow access."
        : [micMsg && `Microphone: ${micMsg}`, camMsg && `Camera: ${camMsg}`].filter(Boolean).join(" ") ||
          "Could not access camera or microphone."
      return {
        ok: false,
        micState: audioStream ? "ok" : micState,
        camState: videoStream ? "ok" : camState,
        inIframe,
        iframeBlocked,
        errorMsg: combinedMsg,
      }
    }

    const stream = new MediaStream()
    videoStream.getVideoTracks().forEach((t) => stream.addTrack(t))
    audioStream.getAudioTracks().forEach((t) => stream.addTrack(t))
    return { ok: true, stream }
  }, [classifyMediaError, queryPermissionState])

  // Actually start the matchmaking flow — assumes media is already acquired.
  const proceedToMatch = useCallback(async () => {
    try {

      // 2) Request a match
      setStatus("waiting")
      const res = await fetch("/api/match", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ userId: getUserId() }),
      })
      const data = (await res.json()) as MatchResponse & { error?: string }
      if (data.error) throw new Error(data.error)

      if (data.status === "matched" && data.room_id && data.role) {
        roomIdRef.current = data.room_id
        roleRef.current = data.role
        setStatus("connecting")
        // Instantly poke the waiting peer's lobby so they can skip their
        // next poll and start signaling right away.
        if (data.partner_id) {
          try {
            const supabase = getBrowserSupabase()
            const lobby = supabase.channel(`lobby:${data.partner_id}`)
            await new Promise<void>((resolve) => {
              lobby.subscribe((st) => {
                if (st === "SUBSCRIBED") resolve()
              })
              // Safety — never block more than 800ms for channel join
              setTimeout(resolve, 800)
            })
            await lobby.send({
              type: "broadcast",
              event: "matched",
              payload: { room_id: data.room_id },
            })
            void lobby.unsubscribe()
          } catch (e) {
            console.log("[v0] lobby notify failed:", e)
          }
        }
        await startSignaling(data.room_id, data.role)
      } else {
        // Waiter — subscribe to our own lobby channel so when a peer
        // pairs with us we pick it up in <100ms instead of the next poll.
        clearPolling()
        try {
          const supabase = getBrowserSupabase()
          const lobby = supabase.channel(`lobby:${getUserId()}`)
          lobby.on("broadcast", { event: "matched" }, () => {
            void pollForMatch()
          })
          lobby.subscribe()
          lobbyChannelRef.current = lobby
        } catch (e) {
          console.log("[v0] lobby subscribe failed:", e)
        }
        // Keep polling as a fallback at a faster interval.
        pollTimerRef.current = setInterval(pollForMatch, 1000)
      }
    } catch (err) {
      const msg = err instanceof Error ? err.message : "failed to start"
      console.log("[v0] proceedToMatch error:", msg)
      setError(msg)
      setStatus("error")
    }
  }, [clearPolling, pollForMatch, startSignaling])

  // User clicked "Enable Camera & Microphone" in the preflight modal.
  // Runs the real getUserMedia requests. If both succeed we close the modal
  // and proceed to matchmaking; if either fails we keep the modal open and
  // show clear recovery instructions for each specific device.
  const confirmPermissions = useCallback(async () => {
    setPermModal((prev) =>
      prev
        ? {
            ...prev,
            stage: "requesting",
            errorMsg: null,
            micState: "unknown",
            camState: "unknown",
            iframeBlocked: false,
          }
        : prev,
    )
    const result = await acquireMedia()
    if (!result.ok) {
      setPermModal({
        stage: "denied",
        micState: result.micState,
        camState: result.camState,
        inIframe: result.inIframe,
        iframeBlocked: result.iframeBlocked,
        errorMsg: result.errorMsg,
      })
      return
    }

    // Success — store the stream, close the modal, then start matching.
    localStreamRef.current = result.stream
    if (localVideoRef.current) localVideoRef.current.srcObject = result.stream
    // Notify the filter pipeline that the source stream changed so it
    // can (re-)bind to the new video track if a filter is active.
    setLocalStreamVersion((v) => v + 1)

    // Detect if multiple video input devices exist (so we can show switch btn)
    try {
      const devices = await navigator.mediaDevices.enumerateDevices()
      const cams = devices.filter((d) => d.kind === "videoinput")
      setHasMultipleCameras(cams.length > 1)
    } catch {}

    setPermModal(null)
    void proceedToMatch()
  }, [acquireMedia, proceedToMatch])

  // The user-facing entry point (Start button, Skip, etc).
  //
  // Decision tree:
  //   1. Already have a live local stream → skip everything, just match.
  //   2. Permissions API says BOTH camera + mic are already "granted" →
  //      silently acquire the stream (no popup will appear because they're
  //      already granted) and start matching. The preflight modal is NOT
  //      shown — there's nothing to ask the user.
  //   3. Otherwise (state is "prompt", "denied", or unknown) → show the
  //      preflight modal so the user understands what's about to happen
  //      and gets a clear recovery path if anything fails.
  const startMatch = useCallback(async () => {
    setError(null)
    setErrorKind(null)
    if (isGuest) {
      router.push("/auth/login?redirect=/")
      return
    }
    if (localStreamRef.current) {
      void proceedToMatch()
      return
    }

    // Fast path: if the browser has already granted both permissions, we
    // can request the stream without showing any UI — getUserMedia will
    // resolve immediately without a popup. This avoids the "every time I
    // press Start I see the same Allow dialog" annoyance.
    const [micPerm, camPerm] = await Promise.all([
      queryPermissionState("microphone"),
      queryPermissionState("camera"),
    ])
    console.log("[v0] startMatch — mic:", micPerm, "cam:", camPerm)
    if (micPerm === "granted" && camPerm === "granted") {
      const result = await acquireMedia()
      if (result.ok) {
        localStreamRef.current = result.stream
        if (localVideoRef.current) localVideoRef.current.srcObject = result.stream
        setLocalStreamVersion((v) => v + 1)
        try {
          const devices = await navigator.mediaDevices.enumerateDevices()
          const cams = devices.filter((d) => d.kind === "videoinput")
          setHasMultipleCameras(cams.length > 1)
        } catch {}
        void proceedToMatch()
        return
      }
      // Granted state was stale — fall through to the modal so the user
      // sees a proper error UI with recovery instructions.
    }

    const inIframe = typeof window !== "undefined" && window.self !== window.top
    setPermModal({
      stage: "intro",
      micState: "unknown",
      camState: "unknown",
      inIframe,
      iframeBlocked: false,
      errorMsg: null,
    })
  }, [isGuest, router, proceedToMatch, queryPermissionState, acquireMedia])

  const leave = useCallback(
    async (andStop = true) => {
      // 1) Instantly update UI + close peer so the End button feels snappy.
      const wasConnected = wasConnectedRef.current
      wasConnectedRef.current = false
      try {
        sendSignal("bye", {})
      } catch {}
      teardownPeerSync()
      if (andStop) {
        stopLocalMedia()
      }
      setStatus("idle")
      // 2) Notify the backend in the background (non-blocking).
      const uid = getUserId()
      void fetch("/api/leave", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ userId: uid }),
        keepalive: true,
      }).catch(() => {})
      // 3) Give the admin-controlled ad gate a chance to show an ad.
      //    No-ops instantly if ads are off / threshold not reached /
      //    the chat never actually connected.
      if (wasConnected) {
        void adGate.afterChat()
      }
    },
    [sendSignal, stopLocalMedia, teardownPeerSync, adGate],
  )

  const skip = useCallback(async () => {
    // 1) Instant UI response: close current peer and mark as idle.
    const wasConnected = wasConnectedRef.current
    wasConnectedRef.current = false
    try {
      sendSignal("bye", {})
    } catch {}
    teardownPeerSync()
    setStatus("idle")
    // 2) Rotate our user id so we don't get re-matched with the same person.
    const oldUid = getUserId()
    userIdRef.current = randomId()
    // 3) Background cleanup of the old match, then start a new search.
    void fetch("/api/leave", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ userId: oldUid }),
      keepalive: true,
    }).catch(() => {})
    // 4) If this was a real completed chat, let the ad gate run BEFORE
    //    we start searching again. The ad modal blocks matchmaking
    //    until dismissed, so the user clearly sees the ad and then
    //    "continues to next chat" — far better UX than starting the
    //    new search behind an ad modal.
    if (wasConnected) {
      await adGate.afterChat()
    }
    // 5) Kick off a new search.
    void startMatch()
  }, [sendSignal, startMatch, teardownPeerSync, adGate])

  // Keep startMatch ref in sync for handlers captured in long-lived closures
  useEffect(() => {
    startMatchRef.current = startMatch
  }, [startMatch])

  useEffect(() => {
    facingModeRef.current = facingMode
  }, [facingMode])

  // Auto-scroll the chat overlay to the newest message whenever it changes.
  useEffect(() => {
    const el = chatScrollRef.current
    if (!el) return
    el.scrollTop = el.scrollHeight
  }, [chatMessages])

  // Measure the floating controls bar so the chat overlay can sit directly
  // above it on mobile. The bar's height depends on how many buttons are
  // rendered (Switch-camera may or may not appear), so hard-coding would
  // occasionally clip the composer. ResizeObserver keeps this in sync if
  // buttons appear/disappear mid-call.
  useEffect(() => {
    const el = controlsBarRef.current
    if (!el) return
    const update = () => setControlsHeight(el.getBoundingClientRect().height)
    update()
    const ro = new ResizeObserver(update)
    ro.observe(el)
    return () => ro.disconnect()
  }, [])

  // Keep the broadcast display-name ref up to date. For signed-in users we
  // use their configured display name (falling back to the part of the email
  // before the "@"). Guests get a short friendly "Guest-XXXX" label derived
  // from their random session id so the other side still sees a name.
  useEffect(() => {
    const emailName = userEmail ? userEmail.split("@")[0] : ""
    const fromProfile = (displayName || emailName || "").trim()
    if (fromProfile) {
      myDisplayNameRef.current = fromProfile.slice(0, 40)
    } else {
      const id = userIdRef.current || getUserId()
      myDisplayNameRef.current = `Guest-${id.slice(0, 4).toUpperCase()}`
    }
    myGenderRef.current = userGender && userGender.trim() ? userGender : null
    myAvatarUrlRef.current =
      userAvatarUrl && userAvatarUrl.trim() ? userAvatarUrl : null
    myVerifiedRef.current = !!userVerified
  }, [displayName, userEmail, userGender, userAvatarUrl, userVerified])

  // --- Video filter pipeline -------------------------------------------------
  // When the user picks a filter other than "none", we:
  //   1. Create a hidden <video> that plays the RAW local camera track.
  //   2. Create a hidden <canvas> the size of that track.
  //   3. Every rAF tick, draw the hidden video into the canvas with
  //      `ctx.filter = <css>` set — this bakes the effect into pixels.
  //   4. Take `canvas.captureStream(30)` and swap the outgoing
  //      RTCRtpSender video track for the canvas's filtered track via
  //      `sender.replaceTrack()`.
  // On "none" (or on cleanup) we restore the raw camera track on the
  // sender and tear everything down so there's zero overhead when the
  // user hasn't picked a filter.
  useEffect(() => {
    const filter = getFilter(videoFilter)
    const pc = pcRef.current
    const stream = localStreamRef.current
    const sender = pc?.getSenders().find((s) => s.track?.kind === "video")
    const rawTrack = stream?.getVideoTracks()[0]
    if (!pc || !sender || !stream || !rawTrack) return

    // Helpers scoped to this pipeline instance so cleanup can reach them.
    let rafId: number | null = null
    let srcVideo: HTMLVideoElement | null = null
    let canvas: HTMLCanvasElement | null = null
    let outStream: MediaStream | null = null
    let disposed = false

    const restoreRawTrack = () => {
      if (sender.track !== rawTrack) {
        void sender.replaceTrack(rawTrack).catch(() => {})
      }
    }

    if (filter.css === "none") {
      // Fast path — ensure the sender is back on the raw camera.
      restoreRawTrack()
      return
    }

    // Build the off-DOM source video from the raw camera track. Using a
    // single-track MediaStream isolates it from audio so the draw loop
    // stays purely visual.
    srcVideo = document.createElement("video")
    srcVideo.autoplay = true
    srcVideo.playsInline = true
    srcVideo.muted = true
    srcVideo.srcObject = new MediaStream([rawTrack])
    void srcVideo.play().catch(() => {})

    const settings = rawTrack.getSettings()
    canvas = document.createElement("canvas")
    canvas.width = settings.width ?? 1280
    canvas.height = settings.height ?? 720
    const ctx = canvas.getContext("2d")
    if (!ctx) {
      restoreRawTrack()
      return
    }

    // Capture the canvas as a MediaStream and swap it onto the existing
    // sender — the peer won't renegotiate because the track kind stays
    // the same (video), they just start receiving filtered frames.
    const captureFn = (
      canvas as HTMLCanvasElement & {
        captureStream?: (fps?: number) => MediaStream
      }
    ).captureStream
    if (typeof captureFn !== "function") {
      restoreRawTrack()
      return
    }
    outStream = captureFn.call(canvas, 30) as MediaStream
    const outTrack = outStream.getVideoTracks()[0]
    if (!outTrack) {
      restoreRawTrack()
      return
    }
    void sender.replaceTrack(outTrack).catch(() => {})

    const draw = () => {
      if (disposed) return
      if (srcVideo && canvas && ctx && srcVideo.readyState >= 2) {
        // `ctx.filter` is well-supported in Chromium/Firefox/Safari 16+
        // and handles our entire preset catalog (saturate, sepia, blur,
        // hue-rotate, etc.).
        ctx.filter = filter.css
        ctx.drawImage(srcVideo, 0, 0, canvas.width, canvas.height)
      }
      rafId = requestAnimationFrame(draw)
    }
    rafId = requestAnimationFrame(draw)

    return () => {
      disposed = true
      if (rafId != null) cancelAnimationFrame(rafId)
      if (outStream) outStream.getTracks().forEach((t) => t.stop())
      if (srcVideo) {
        srcVideo.srcObject = null
        srcVideo.pause()
      }
      // Put the raw camera track back on the sender for the next cycle
      // (filter change or unmount). If the peer connection is already
      // closed this no-ops safely via the `.catch`.
      restoreRawTrack()
    }
  }, [videoFilter, localStreamVersion, pcVersion])

  // --- Live user count via Supabase Realtime Presence ------------------------
  // Every mounted client joins a single global presence channel. The size of
  // the presence state reflects the total number of people currently on the
  // site — updated live as people join and leave.
  useEffect(() => {
    const supabase = getBrowserSupabase()
    const channel = supabase.channel("presence:lobby", {
      config: { presence: { key: getUserId() } },
    })

    const updateCount = () => {
      const state = channel.presenceState() as Record<string, unknown[]>
      // Count unique presence keys (each tab/device = one key)
      setLiveUsers(Object.keys(state).length)
    }

    channel
      .on("presence", { event: "sync" }, updateCount)
      .on("presence", { event: "join" }, updateCount)
      .on("presence", { event: "leave" }, updateCount)
      .subscribe(async (status) => {
        if (status === "SUBSCRIBED") {
          try {
            await channel.track({ online_at: Date.now() })
          } catch {}
        }
      })

    return () => {
      try {
        void channel.untrack()
      } catch {}
      void supabase.removeChannel(channel)
    }
  }, [])

  // --- Camera switching ------------------------------------------------------

  const switchCamera = useCallback(async () => {
    if (switchingCamera) return
    setSwitchingCamera(true)
    const next: FacingMode = facingModeRef.current === "user" ? "environment" : "user"
    try {
      // Match the low-latency constraints used for the initial capture
      // (540p@24 with a 720p ceiling) so flipping between front/back
      // cameras doesn't silently bump the outgoing resolution back up
      // to 1080p and re-introduce the lag we just fixed.
      const newStream = await navigator.mediaDevices.getUserMedia({
        video: {
          facingMode: { ideal: next },
          width: { ideal: 960, max: 1280 },
          height: { ideal: 540, max: 720 },
          frameRate: { ideal: 24, max: 30 },
        },
        audio: false,
      })
      const newTrack = newStream.getVideoTracks()[0]
      if (!newTrack) throw new Error("no video track")

      // Apply current cameraOff state to the new track
      newTrack.enabled = !cameraOff

      // Hint the new track as motion content before sending
      try {
        ;(newTrack as MediaStreamTrack & { contentHint?: string }).contentHint = "motion"
      } catch {}

      // Replace track on the active peer connection (if any)
      const pc = pcRef.current
      if (pc) {
        const sender = pc.getSenders().find((s) => s.track?.kind === "video")
        if (sender) {
          try {
            await sender.replaceTrack(newTrack)
          } catch (e) {
            console.log("[v0] replaceTrack failed:", e)
          }
        }
        void tuneOutgoingVideo(pc)
      }

      // Swap track in our local stream and stop the old one
      const stream = localStreamRef.current
      if (stream) {
        stream.getVideoTracks().forEach((t) => {
          stream.removeTrack(t)
          t.stop()
        })
        stream.addTrack(newTrack)
      } else {
        localStreamRef.current = newStream
      }

      if (localVideoRef.current) {
        localVideoRef.current.srcObject = localStreamRef.current
      }
      // Notify the filter pipeline so it re-binds to the new camera.
      setLocalStreamVersion((v) => v + 1)

      setFacingMode(next)
      facingModeRef.current = next
      // Tell the peer so they can mirror appropriately
      sendSignal("meta", { facingMode: next })
    } catch (e) {
      console.log("[v0] switchCamera failed:", e)
    } finally {
      setSwitchingCamera(false)
    }
  }, [cameraOff, sendSignal, switchingCamera])

  // --- Media toggles ---------------------------------------------------------

  const toggleMute = () => {
    const stream = localStreamRef.current
    if (!stream) return
    const next = !muted
    stream.getAudioTracks().forEach((t) => (t.enabled = !next))
    setMuted(next)
  }

  const toggleCamera = () => {
    const stream = localStreamRef.current
    if (!stream) return
    const next = !cameraOff
    stream.getVideoTracks().forEach((t) => (t.enabled = !next))
    setCameraOff(next)
  }

  // --- PiP draggable + pinch-to-zoom (mobile) --------------------------------
  //
  // Design:
  // - Card uses position:absolute by default (Tailwind bottom-3 right-3).
  //   On the first touch we pin it to the viewport with position:fixed and
  //   explicit left/top, so viewport-space pointer coords map 1:1 to CSS.
  // - We use transform-origin: 0 0 so the visual top-left ALWAYS equals the
  //   CSS left/top, regardless of scale. This avoids "card jumps when
  //   touched" bugs that come from center-origin scaling.
  // - A single explicit `modeRef` ('idle' | 'drag' | 'pinch') controls the
  //   gesture state machine. Every transition refreshes `baselineRef` so
  //   1-finger movement after a pinch never leaks into the scale and vice
  //   versa.

  const PIP_MIN_SCALE = 1
  const PIP_MAX_SCALE = 1.3 // 30% larger max, per spec

  const isMobileViewport = () =>
    typeof window !== "undefined" && window.innerWidth < 768

  // Switch the card to fixed positioning at its current viewport location.
  // Idempotent — only runs the first time.
  const pinCardToViewport = () => {
    const card = pipCardRef.current
    if (!card) return
    if (card.dataset.pipPinned === "1") return
    const rect = card.getBoundingClientRect()
    card.style.position = "fixed"
    card.style.left = rect.left + "px"
    card.style.top = rect.top + "px"
    card.style.right = "auto"
    card.style.bottom = "auto"
    card.style.transformOrigin = "0 0"
    card.dataset.pipPinned = "1"
    pipPosRef.current = { x: rect.left, y: rect.top }
  }

  const applyPipTransform = () => {
    const card = pipCardRef.current
    if (!card) return
    const p = pipPosRef.current
    const s = pipScaleRef.current
    if (p) {
      card.style.left = p.x + "px"
      card.style.top = p.y + "px"
    }
    card.style.transform = s === 1 ? "" : `scale(${s})`
  }

  const scheduleApply = () => {
    if (dragRafRef.current != null) return
    dragRafRef.current = requestAnimationFrame(() => {
      dragRafRef.current = null
      applyPipTransform()
    })
  }

  // Clamp PiP position so the *visually scaled* card stays inside the viewport.
  // With transform-origin 0 0, the visual box = (left, top, w*s, h*s).
  const clampPos = (x: number, y: number) => {
    const card = pipCardRef.current
    if (!card) return { x, y }
    const margin = 8
    const w = card.offsetWidth * pipScaleRef.current
    const h = card.offsetHeight * pipScaleRef.current
    const maxX = Math.max(margin, window.innerWidth - w - margin)
    const maxY = Math.max(margin, window.innerHeight - h - margin)
    return {
      x: Math.max(margin, Math.min(x, maxX)),
      y: Math.max(margin, Math.min(y, maxY)),
    }
  }

  const distance = (a: { x: number; y: number }, b: { x: number; y: number }) =>
    Math.hypot(a.x - b.x, a.y - b.y)

  // Capture a fresh baseline for the current mode + active pointer set.
  const captureBaseline = () => {
    const pos = pipPosRef.current ?? { x: 0, y: 0 }
    const pts = Array.from(pointersRef.current.entries())
    if (modeRef.current === "drag" && pts.length >= 1) {
      const [pid, pt] = pts[0]
      baselineRef.current = {
        pointerId: pid,
        fingerX: pt.x,
        fingerY: pt.y,
        posX: pos.x,
        posY: pos.y,
        distance: 0,
        scale: pipScaleRef.current,
      }
    } else if (modeRef.current === "pinch" && pts.length >= 2) {
      const [, a] = pts[0]
      const [, b] = pts[1]
      baselineRef.current = {
        pointerId: pts[0][0],
        fingerX: 0,
        fingerY: 0,
        posX: pos.x,
        posY: pos.y,
        distance: Math.max(1, distance(a, b)),
        scale: pipScaleRef.current,
      }
    } else {
      baselineRef.current = null
    }
    movedRef.current = false
  }

  // Recompute mode based on the current pointer count and refresh baseline.
  const updateMode = () => {
    const count = pointersRef.current.size
    const next: GestureMode = count >= 2 ? "pinch" : count === 1 ? "drag" : "idle"
    if (next !== modeRef.current) {
      modeRef.current = next
    }
    if (next === "idle") {
      baselineRef.current = null
      movedRef.current = false
    } else {
      // Always refresh baseline on mode change so deltas restart from zero.
      captureBaseline()
    }
  }

  const onPipPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    if (!isMobileViewport()) return
    const card = pipCardRef.current
    if (!card) return

    pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
    pinCardToViewport()
    card.style.transition = "none"
    card.style.willChange = "left, top, transform"

    // Release any previous capture; we don't capture on multi-touch so both
    // fingers continue delivering events to this element.
    if (pointersRef.current.size === 1) {
      try {
        card.setPointerCapture(e.pointerId)
      } catch {}
    }
    updateMode()
  }

  const onPipPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    if (!pointersRef.current.has(e.pointerId)) return
    pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY })

    const base = baselineRef.current
    if (!base) return
    const mode = modeRef.current

    if (mode === "pinch" && pointersRef.current.size >= 2) {
      const pts = Array.from(pointersRef.current.values()).slice(0, 2)
      const d = Math.max(1, distance(pts[0], pts[1]))
      const raw = base.scale * (d / base.distance)
      pipScaleRef.current = Math.max(PIP_MIN_SCALE, Math.min(PIP_MAX_SCALE, raw))
      // Re-clamp the existing position against the new visual size.
      if (pipPosRef.current) {
        pipPosRef.current = clampPos(pipPosRef.current.x, pipPosRef.current.y)
      }
      scheduleApply()
      return
    }

    if (mode === "drag" && pointersRef.current.size === 1) {
      // Use the active pointer (might have changed when fingers swapped)
      const pt = pointersRef.current.get(base.pointerId) ?? { x: e.clientX, y: e.clientY }
      const dx = pt.x - base.fingerX
      const dy = pt.y - base.fingerY
      if (!movedRef.current && Math.hypot(dx, dy) > 6) movedRef.current = true
      if (!movedRef.current) return
      pipPosRef.current = clampPos(base.posX + dx, base.posY + dy)
      scheduleApply()
      return
    }
  }

  const onPipPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
    pointersRef.current.delete(e.pointerId)
    const card = pipCardRef.current

    if (pointersRef.current.size === 0) {
      modeRef.current = "idle"
      baselineRef.current = null
      movedRef.current = false
      if (card) {
        try {
          card.releasePointerCapture(e.pointerId)
        } catch {}
        card.style.willChange = ""
        card.style.transition = ""
      }
      if (dragRafRef.current != null) {
        cancelAnimationFrame(dragRafRef.current)
        dragRafRef.current = null
      }
      return
    }

    // Pointer count dropped (e.g. 2 → 1). Re-establish the new mode and
    // capture a fresh baseline against the remaining finger so the next
    // movement starts from zero — this is what keeps single-finger drag
    // working cleanly after a pinch.
    if (card && pointersRef.current.size === 1) {
      const remainingId = Array.from(pointersRef.current.keys())[0]
      try {
        card.setPointerCapture(remainingId)
      } catch {}
    }
    updateMode()
  }

  // Reset PiP state when window resizes past the desktop breakpoint
  useEffect(() => {
    const onResize = () => {
      if (window.innerWidth >= 768) {
        const card = pipCardRef.current
        if (card) {
          card.style.position = ""
          card.style.left = ""
          card.style.top = ""
          card.style.right = ""
          card.style.bottom = ""
          card.style.transform = ""
          card.style.transformOrigin = ""
          delete card.dataset.pipPinned
        }
        pipPosRef.current = null
        pipScaleRef.current = 1
        pointersRef.current.clear()
        modeRef.current = "idle"
        baselineRef.current = null
        movedRef.current = false
      }
    }
    window.addEventListener("resize", onResize)
    return () => window.removeEventListener("resize", onResize)
  }, [])

  // --- Lifecycle -------------------------------------------------------------

  useEffect(() => {
    getUserId()

    const handleUnload = () => {
      const uid = getUserId()
      const body = JSON.stringify({ userId: uid })
      if (navigator.sendBeacon) {
        navigator.sendBeacon("/api/leave", new Blob([body], { type: "application/json" }))
      }
    }
    window.addEventListener("beforeunload", handleUnload)

    return () => {
      window.removeEventListener("beforeunload", handleUnload)
      clearPolling()
      if (channelRef.current) {
        try {
          channelRef.current.unsubscribe()
        } catch {}
      }
      if (pcRef.current) {
        try {
          pcRef.current.close()
        } catch {}
      }
      if (localStreamRef.current) {
        localStreamRef.current.getTracks().forEach((t) => t.stop())
      }
    }
  }, [clearPolling])

  // Cosmetic search timer
  useEffect(() => {
    if (status !== "waiting") {
      setWaitingCount(0)
      return
    }
    let i = 0
    const id = setInterval(() => {
      i += 1
      setWaitingCount(i)
    }, 1000)
    return () => clearInterval(id)
  }, [status])

  const isActive = status === "waiting" || status === "connecting" || status === "connected"
  const showStrangerOverlay = status !== "connected"

  // Mirror logic: front camera (user) is mirrored, back camera (environment) is not
  const localMirrored = facingMode === "user"
  const remoteMirrored = remoteFacingMode === "user"

  // --- UI --------------------------------------------------------------------

  return (
    // Root of the app surface. Intentionally transparent so the ambient
    // Three.js background mounted at the layout root is visible through
    // any gaps (idle/landing state, gutters between the video cards on
    // desktop, etc.). Only the INNER surfaces that need opacity for
    // content legibility (stranger video, PiP, dialogs) keep solid bgs.
    <div className="relative flex h-dvh flex-col overflow-hidden">
      {permModal && (
        <PermissionModal
          modal={permModal}
          onCancel={() => setPermModal(null)}
          onConfirm={() => void confirmPermissions()}
        />
      )}
      {/* Header — live count + user menu on the sides, brand centered.
          The header is intentionally HIDDEN once a call flow starts
          (requesting-media → waiting → connecting → connected) so the
          stranger video gets maximum vertical real estate, and it
          returns to view as soon as the user lands back on the idle
          state (hangup or error). Using `hidden` instead of omitting the
          element keeps the component tree stable so React doesn't tear
          down the `UserMenu` + its dropdown state. */}
      <header
        className={cn(
          "relative z-30 flex items-center justify-center border-b border-border bg-background/80 px-3 py-3 backdrop-blur md:px-6",
          status === "idle" || status === "error" ? "flex" : "hidden",
        )}
      >
        <div className="absolute left-3 top-1/2 flex -translate-y-1/2 items-center gap-2 md:left-6">
          <LiveUsersBadge count={liveUsers} />
          <div className="hidden sm:block">
            <StatusPill status={status} waitingCount={waitingCount} />
          </div>
        </div>
        <div className="flex items-center">
          {/* Brand wordmark — uses the full ZunoChat lockup (icon +
              wordmark) from `/public/brand`. The asset is transparent so
              it sits naturally on the translucent header. Kept as an
              <img> (not a decorative background) so it's announced to
              screen readers as the site name. */}
          <h1 className="sr-only">ZunoChat</h1>
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            src="/brand/zunochat-logo.png"
            alt="ZunoChat"
            // The updated ZunoChat PNG is tightly cropped (minimal
            // internal whitespace), so the rendered size equals the
            // visible lockup. Back at the baseline "1x" size — h-12 on
            // mobile, md:h-16 on desktop — with a small negative
            // vertical margin so the logo just slightly overflows the
            // header chrome without forcing it to grow.
            className="-my-2 h-12 w-auto select-none md:-my-3 md:h-16"
            draggable={false}
          />
        </div>
        <div className="absolute right-3 top-1/2 -translate-y-1/2 md:right-6">
          <UserMenu
            email={userEmail}
            displayName={displayName}
            avatarUrl={userAvatarUrl}
            verified={userVerified}
            isAdmin={isAdmin}
          />
        </div>
      </header>

      {/* Main video area */}
      <main className="relative flex-1 overflow-hidden p-0 md:grid md:grid-cols-2 md:gap-4 md:p-4">
        {/* Stranger video — full area on mobile, left column on desktop.
            A right→left swipe on this surface skips to a new random
            stranger (same action as the "Next" button). We only arm the
            gesture while `status === "connected"`; during matchmaking or
            idle it's a no-op so users don't accidentally re-queue. */}
        <section
          className={cn(
            "relative h-full w-full touch-pan-y overflow-hidden md:rounded-2xl md:border md:border-border md:shadow-sm",
            // Only show a solid backdrop for the <video> area when a
            // remote stream is (or is about to be) attached. During idle
            // we leave it transparent so the ambient Three.js background
            // is visible behind the floating idle hero card.
            status === "idle" ? "bg-transparent md:border-transparent md:shadow-none" : "bg-secondary",
          )}
          aria-label="Stranger video"
          onTouchStart={(e) => {
            if (status !== "connected") return
            const t = e.touches[0]
            if (!t) return
            swipeRef.current = { x: t.clientX, y: t.clientY, fired: false }
          }}
          onTouchMove={(e) => {
            const s = swipeRef.current
            if (!s || s.fired) return
            if (status !== "connected") return
            const t = e.touches[0]
            if (!t) return
            const dx = t.clientX - s.x
            const dy = t.clientY - s.y
            // Fire once the finger has travelled enough to the LEFT and
            // the motion is predominantly horizontal (so vertical
            // scrolling / zoom / drag interactions aren't hijacked).
            if (dx < -60 && Math.abs(dx) > Math.abs(dy) * 1.5) {
              s.fired = true
              void skip()
            }
          }}
          onTouchEnd={() => {
            swipeRef.current = null
          }}
          onTouchCancel={() => {
            swipeRef.current = null
          }}
        >
          <video
            ref={remoteVideoRef}
            autoPlay
            playsInline
            className={cn(
              "h-full w-full object-cover transition-transform",
              remoteMirrored ? "scale-x-[-1]" : "scale-x-100",
            )}
          />

          {/* "Tap to enable sound" button — shown if the browser blocked
              autoplay of the remote stream. A single user gesture unblocks it. */}
          {audioBlocked && status === "connected" && (
            <button
              type="button"
              onClick={() => {
                const el = remoteVideoRef.current
                if (!el) return
                el.muted = false
                const p = el.play()
                if (p && typeof p.catch === "function") {
                  p.then(() => setAudioBlocked(false)).catch(() => {})
                } else {
                  setAudioBlocked(false)
                }
              }}
              className="absolute left-1/2 top-4 z-20 flex -translate-x-1/2 items-center gap-2 rounded-full bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-lg ring-1 ring-black/10 backdrop-blur"
            >
              <Mic className="h-4 w-4" aria-hidden="true" />
              Tap to enable sound
            </button>
          )}

          {/* Stranger overlay (waiting / connecting / error / idle).
              Idle state is translucent so the Three.js background is
              visible behind the landing hero — waiting/connecting/error
              keep a stronger wash so status text is readable. */}
          {showStrangerOverlay && (
            <div
              className={cn(
                "absolute inset-0 z-10 flex flex-col items-center justify-center gap-4 px-6 text-center backdrop-blur-sm",
                status === "idle"
                  ? "bg-background/30"
                  : "bg-secondary/95",
              )}
            >
              {status === "waiting" || status === "connecting" ? (
                <SearchingState
                  status={status}
                  waitingCount={waitingCount}
                />
              ) : status === "error" ? (
                <ErrorState
                  errorKind={errorKind}
                  error={error}
                  onRetry={() => {
                    setError(null)
                    setErrorKind(null)
                    setStatus("idle")
                    void startMatch()
                  }}
                />
              ) : status === "requesting-media" ? (
                <div className="flex flex-col items-center gap-3">
                  <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" aria-hidden="true" />
                  <p className="text-sm text-muted-foreground">Requesting camera & mic…</p>
                </div>
              ) : (
                <IdleState />
              )}
            </div>
          )}

          {/* Stranger name badge — top-center of the stranger video.
              Shows the real name once the peer broadcasts its "meta", and
              falls back to a generic "Stranger" label until it arrives.
              If the peer also shared a gender (and it isn't the generic
              "prefer-not-to-say"), we render it as a compact chip after
              the name with a gender-specific color for quick recognition. */}
          {status === "connected" && (
            <div
              // Compact pill padding: keeps the badge visually tight while
              // leaving just enough room for the avatar's frame to render
              // at its natural 40px size without layout tricks that would
              // offset its center (see FramedAvatar comment below).
              className="pointer-events-none absolute left-1/2 top-3 z-20 flex -translate-x-1/2 items-center gap-2 rounded-full bg-black/55 py-1 pl-1 pr-3 text-xs font-medium text-white shadow-lg ring-1 ring-white/10 backdrop-blur-md md:top-4"
            >
              {/* Framed stranger avatar — SAME contract as the header
                  avatar (same size, no negative margin) so the gold ring
                  centers on the photo identically in both places.
                  Previously this used `-my-1.5` to compact the pill, but
                  that shrank the avatar's flex margin-box while its content
                  area stayed the full 44px, which shifted the frame's
                  visual center relative to where `items-center` placed it
                  — the symptom the user reported as "frame not exactly
                  centered on the photo in video chat". Letting the pill
                  grow to the avatar's real size fixes the misalignment. */}
              <FramedAvatar
                src={strangerAvatarUrl}
                name={strangerName || "Stranger"}
                size={40}
              />
              <span
                className="h-1.5 w-1.5 rounded-full bg-emerald-400"
                aria-hidden="true"
              />
              <span className="max-w-[10rem] truncate">
                {strangerName || "Stranger"}
              </span>
              {/* Verified blue tick — appears on the stranger's badge
                  whenever their broadcast meta marks them as verified. */}
              {strangerVerified && <BlueTick size={14} title="Verified" />}
              {strangerGender && strangerGender !== "prefer-not-to-say" && (
                <GenderChip value={strangerGender} />
              )}
            </div>
          )}

          {/* In-call text chat overlay — visible only while connected.
              Messages float on top of the stranger video using color-coded
              names (so each side is visually distinct) and a subtle gradient
              backdrop on the video so text is always legible without a
              solid bubble. The composer is a pill input docked above the
              footer. */}
          {status === "connected" && (
            <ChatOverlay
              open={chatOpen}
              messages={chatMessages}
              input={chatInput}
              onInputChange={setChatInput}
              onSend={() => {
                const val = chatInput
                setChatInput("")
                sendChat(val)
              }}
              onToggleOpen={() => setChatOpen((v) => !v)}
              strangerName={strangerName || "Stranger"}
              myName={myDisplayNameRef.current || "You"}
              // Verified flags drive the blue tick rendered after each
              // sender's name inside chat message bubbles — the stranger
              // state already syncs via the `meta` signal; for us we read
              // from props (the server-passed `userVerified`).
              strangerVerified={strangerVerified}
              myVerified={userVerified}
              scrollRef={chatScrollRef}
              // Exact measured height of the floating controls bar on
              // mobile. The overlay uses this to offset its bottom so the
              // composer is never covered by the controls.
              controlsHeight={controlsHeight}
            />
          )}
        </section>

        {/* You — Mobile: floating draggable PiP. Desktop: equal-size right column.
            The MOBILE floating card is intentionally hidden until the user
            starts a call (status !== "idle"). Before Start, the landing UI
            shows its own hero/preview and a floating self-cam would be an
            empty noise element. Desktop keeps the static column visible
            since it IS the layout's right half — hiding it would leave a
            blank gap. */}
        <section
          ref={pipCardRef}
          onPointerDown={onPipPointerDown}
          onPointerMove={onPipPointerMove}
          onPointerUp={onPipPointerUp}
          onPointerCancel={onPipPointerUp}
          className={cn(
            // Mobile: floating PiP, default anchored bottom-right above the footer.
            // During drag the handlers overwrite left/top directly on the DOM.
            "absolute bottom-24 right-3 z-30 h-40 w-28 cursor-grab touch-none select-none overflow-hidden rounded-xl border border-border bg-secondary shadow-xl ring-1 ring-black/5 active:cursor-grabbing sm:h-48 sm:w-32",
            // Mobile visibility: only show once a call has been started.
            // `md:!block` forces the desktop column to always render.
            status === "idle" ? "hidden md:!block" : "block",
            // Desktop overrides — static, full column, no drag visuals.
            // `!md:*` is used so drag-applied inline left/top is neutralised.
            "md:static md:z-auto md:h-full md:w-full md:cursor-default md:rounded-2xl md:shadow-sm md:ring-0 md:[touch-action:auto] md:![left:auto] md:![top:auto] md:![right:auto] md:![bottom:auto]",
          )}
          aria-label="Your video"
        >
          <video
            ref={localVideoRef}
            autoPlay
            playsInline
            muted
            className={cn(
              "h-full w-full object-cover transition-transform",
              localMirrored ? "scale-x-[-1]" : "scale-x-100",
            )}
            // Apply the selected filter directly to the preview element
            // for instant, zero-latency feedback to the user. The
            // outgoing stream for the stranger is filtered by the
            // canvas pipeline above so both sides see the same effect.
            style={
              videoFilter !== "none"
                ? { filter: getFilter(videoFilter).css }
                : undefined
            }
          />

          {!localStreamRef.current && status === "idle" && (
            <div className="absolute inset-0 flex items-center justify-center px-2 text-center text-[11px] text-muted-foreground md:text-sm">
              Your camera
            </div>
          )}

          {cameraOff && localStreamRef.current && (
            <div className="absolute inset-0 flex flex-col items-center justify-center gap-1 bg-secondary text-muted-foreground">
              <VideoOff className="h-5 w-5" aria-hidden="true" />
              <span className="text-[10px] md:text-xs">Camera off</span>
            </div>
          )}

          <span className="pointer-events-none absolute left-2 top-2 rounded-md bg-background/85 px-1.5 py-0.5 text-[10px] font-medium text-foreground shadow-sm backdrop-blur md:left-3 md:top-3 md:px-2 md:text-xs">
            You
          </span>

          {/* Drag affordance dots — mobile only */}
          <span
            className="pointer-events-none absolute right-2 top-2 flex gap-0.5 opacity-70 md:hidden"
            aria-hidden="true"
          >
            <span className="h-1 w-1 rounded-full bg-foreground/70" />
            <span className="h-1 w-1 rounded-full bg-foreground/70" />
            <span className="h-1 w-1 rounded-full bg-foreground/70" />
          </span>
        </section>
      </main>

      {/*
        Controls — on mobile: absolutely positioned over the bottom of the
        video (no dedicated footer strip). On desktop: sits below the video
        grid in the normal flow.
      */}
      <div
        ref={controlsBarRef}
        className={cn(
          "pointer-events-none z-40 flex justify-center",
          // Mobile: fixed to the viewport bottom so controls are always visible
          // regardless of parent transforms or mobile browser chrome.
          "fixed inset-x-0 bottom-0 px-2 pb-[max(10px,env(safe-area-inset-bottom))] pt-2",
          // Desktop: return to normal flow below the video grid
          "md:static md:inset-auto md:px-3 md:pb-6 md:pt-4",
        )}
      >
        {!isActive ? (
          isGuest ? (
            <div className="pointer-events-auto flex flex-col items-center gap-2 rounded-2xl border border-border bg-background/90 p-3 shadow-xl ring-1 ring-black/5 backdrop-blur-md md:flex-row md:gap-3 md:p-2 md:pl-4">
              <p className="text-sm font-medium text-foreground md:text-[13px]">
                Sign in to start a random video chat
              </p>
              <div className="flex items-center gap-2">
                <Button asChild size="sm" className="h-10 rounded-full px-5 font-semibold">
                  <Link href="/auth/login?redirect=/">
                    <LogIn className="mr-1.5 h-4 w-4" aria-hidden="true" />
                    Sign in
                  </Link>
                </Button>
                <Button asChild size="sm" variant="outline" className="h-10 rounded-full px-5">
                  <Link href="/auth/sign-up">Sign up</Link>
                </Button>
              </div>
            </div>
          ) : (
            // Animated shader Start button — recreation of the v0
            // "Shader Button" template. The live mesh-gradient background
            // draws attention to the primary CTA without competing with
            // the rest of the UI; on click it kicks off media permissions
            // + matchmaking just like the original button.
            <ShaderButton
              onClick={() => void startMatch()}
              disabled={status === "requesting-media"}
              className="pointer-events-auto"
              aria-label="Start video chat"
            >
              {status === "requesting-media" ? (
                <Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" />
              ) : (
                <Phone className="h-5 w-5" aria-hidden="true" />
              )}
              Start
            </ShaderButton>
          )
        ) : (
          <div className="flex w-full flex-col items-center gap-2 md:w-auto">
            {/* Snapchat-style filter tray — rendered above the controls
                bar when the user has opened it. Stays mounted across
                filter changes so its scroll position is preserved. */}
            {filterTrayOpen && (
              <FilterTray
                active={videoFilter}
                onChange={(id) => setVideoFilter(id)}
                className="w-full max-w-[min(100%,calc(100vw-16px))] md:max-w-2xl"
              />
            )}
            <div
              className="pointer-events-auto flex max-w-full items-center gap-1.5 overflow-x-auto rounded-full border border-border bg-background/90 p-1 shadow-xl ring-1 ring-black/5 backdrop-blur-md [scrollbar-width:none] [&::-webkit-scrollbar]:hidden md:gap-2.5 md:overflow-visible md:p-2"
              role="toolbar"
              aria-label="Call controls"
            >
            <IconControl
              active={muted}
              activeTone="destructive"
              onClick={toggleMute}
              ariaLabel={muted ? "Unmute microphone" : "Mute microphone"}
              ariaPressed={muted}
            >
              {muted ? <MicOff className="h-5 w-5" aria-hidden="true" /> : <Mic className="h-5 w-5" aria-hidden="true" />}
            </IconControl>

            <IconControl
              active={cameraOff}
              activeTone="destructive"
              onClick={toggleCamera}
              ariaLabel={cameraOff ? "Turn camera on" : "Turn camera off"}
              ariaPressed={cameraOff}
            >
              {cameraOff ? (
                <VideoOff className="h-5 w-5" aria-hidden="true" />
              ) : (
                <Video className="h-5 w-5" aria-hidden="true" />
              )}
            </IconControl>

            {hasMultipleCameras && (
              <IconControl
                onClick={() => void switchCamera()}
                disabled={switchingCamera}
                ariaLabel="Switch camera"
                title={facingMode === "user" ? "Switch to back camera" : "Switch to front camera"}
              >
                {switchingCamera ? (
                  <Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" />
                ) : (
                  <SwitchCamera className="h-5 w-5" aria-hidden="true" />
                )}
              </IconControl>
            )}

            {/* Filters — toggles the Snapchat-style filter tray. Shown as
                "primary" active state when a filter is in use so users
                can see at a glance that their camera is stylised. */}
            <IconControl
              active={filterTrayOpen || videoFilter !== "none"}
              activeTone={videoFilter !== "none" ? "primary" : undefined}
              onClick={() => setFilterTrayOpen((v) => !v)}
              ariaLabel={filterTrayOpen ? "Close filters" : "Open filters"}
              ariaPressed={filterTrayOpen}
            >
              <Wand2 className="h-5 w-5" aria-hidden="true" />
            </IconControl>

            {/* Next only while connected to a stranger */}
            {status === "connected" && (
              <Button
                onClick={() => void skip()}
                aria-label="Next stranger"
                className="h-10 w-10 shrink-0 gap-2 rounded-full p-0 font-medium md:h-12 md:w-auto md:px-5"
              >
                <SkipForward className="h-4 w-4" aria-hidden="true" />
                <span className="hidden md:inline">Next</span>
              </Button>
            )}

            <Button
              onClick={() => void leave(true)}
              aria-label="End call"
              className="h-10 w-10 shrink-0 rounded-full bg-destructive p-0 text-white shadow-md hover:bg-destructive/90 md:h-12 md:w-auto md:gap-2 md:px-5"
            >
              <PhoneOff className="h-5 w-5" aria-hidden="true" />
              <span className="hidden md:inline">End</span>
            </Button>
            </div>
          </div>
        )}
      </div>
    </div>
  )
}

// ---- Sub-components ---------------------------------------------------------

function IconControl({
  children,
  onClick,
  ariaLabel,
  ariaPressed,
  disabled,
  title,
  active,
  activeTone,
}: {
  children: React.ReactNode
  onClick: () => void
  ariaLabel: string
  ariaPressed?: boolean
  disabled?: boolean
  title?: string
  active?: boolean
  activeTone?: "destructive" | "primary"
}) {
  const activeClasses =
    active && activeTone === "destructive"
      ? "bg-destructive text-white hover:bg-destructive/90"
      : active && activeTone === "primary"
        ? "bg-primary text-primary-foreground hover:bg-primary/90"
        : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      aria-label={ariaLabel}
      aria-pressed={ariaPressed}
      title={title ?? ariaLabel}
      className={cn(
        "flex h-10 w-10 shrink-0 items-center justify-center rounded-full transition-colors md:h-12 md:w-12",
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        "disabled:cursor-not-allowed disabled:opacity-60",
        activeClasses,
      )}
    >
      {children}
    </button>
  )
}

function UserMenu({
  email,
  displayName,
  avatarUrl,
  verified,
  isAdmin,
}: {
  email: string | null
  displayName: string | null
  /** Public URL of the user's avatar from Supabase Storage, or null. */
  avatarUrl: string | null
  /** Whether to render the verified blue tick next to the user's name. */
  verified: boolean
  isAdmin: boolean
}) {
  // Guest variant — a single primary "Sign in" CTA. The sign-up flow is
  // reachable from the sign-in screen itself (and from the inline idle
  // hero card), so the header stays focused on one clear action for
  // visitors who land on the homepage while signed out.
  if (!email) {
    return (
      <Button
        asChild
        size="sm"
        className="h-9 rounded-full px-4 text-sm font-medium shadow-sm"
      >
        <Link href="/auth/login?redirect=/">Sign in</Link>
      </Button>
    )
  }

  const label = displayName || email
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <button
          type="button"
          aria-label="Account menu"
          // No border / background / shadow — the ornamental avatar frame
          // IS the visual treatment. Any additional pill border competes
          // with the gold frame and looks like a stray outline.
          className="flex items-center gap-2 rounded-full bg-transparent p-0 transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
        >
          <FramedAvatar
            src={avatarUrl}
            name={label || "User"}
            size={40}
          />
          <span className="hidden max-w-[140px] items-center gap-1 truncate pr-2 text-sm font-medium text-foreground md:inline-flex">
            <span className="truncate">{label}</span>
            {verified && <BlueTick size={14} title="Verified" />}
          </span>
        </button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end" className="w-56">
        <DropdownMenuLabel>
          <div className="flex flex-col">
            <span className="inline-flex items-center gap-1 text-sm font-medium text-foreground">
              <span className="truncate">{displayName || "Signed in"}</span>
              {verified && <BlueTick size={12} title="Verified" />}
            </span>
            <span className="truncate text-xs font-normal text-muted-foreground">{email}</span>
          </div>
        </DropdownMenuLabel>
        <DropdownMenuSeparator />
        <DropdownMenuItem asChild>
          <Link href="/profile" className="cursor-pointer">
            <UserCircle2 className="mr-2 h-4 w-4" aria-hidden="true" />
            My profile
          </Link>
        </DropdownMenuItem>
        {isAdmin && (
          <DropdownMenuItem asChild>
            <Link href="/admin" className="cursor-pointer">
              <ShieldCheck className="mr-2 h-4 w-4" aria-hidden="true" />
              Admin panel
            </Link>
          </DropdownMenuItem>
        )}
        <DropdownMenuSeparator />
        <DropdownMenuItem
          onSelect={async (e) => {
            e.preventDefault()
            try {
              await fetch("/auth/sign-out", { method: "POST" })
            } catch {}
            window.location.href = "/"
          }}
          className="cursor-pointer"
        >
          <LogOut className="mr-2 h-4 w-4" aria-hidden="true" />
          Sign out
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

// --- In-call text chat -----------------------------------------------------

// Deterministic, color-blind-friendly palette for chat name labels. We pick
// a color from a short palette based on a hash of the name so the same
// name always gets the same color while being clearly distinct from the
// other side of the conversation.
const CHAT_COLORS = [
  "#60a5fa", // blue-400
  "#34d399", // emerald-400
  "#fbbf24", // amber-400
  "#f472b6", // pink-400
  "#a78bfa", // violet-400
  "#fb7185", // rose-400
  "#22d3ee", // cyan-400
]

function hashString(s: string): number {
  let h = 0
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
  return Math.abs(h)
}

function colorForName(name: string): string {
  return CHAT_COLORS[hashString(name) % CHAT_COLORS.length]
}

// Compact, color-coded chip used inside the stranger's name badge to
// surface the peer's gender at a glance. Values come from the profile
// enum ('male' | 'female' | 'non-binary'); the 'prefer-not-to-say' case
// is filtered out by the caller so this component doesn't need to handle
// it. Colors are chosen for strong contrast against the dark badge.
// Avatar wrapped in the ornamental gold laurel frame. The frame PNG has a
// transparent interior and decorative laurel/gem elements that extend
// slightly BELOW the circle, so we render the frame as an absolutely-
// positioned layer on top of a perfectly-circular photo underneath. The
// wrapper is sized normally (width == height) and the frame is scaled up
// ~30% so its ornaments hang outside the photo ring, which is what makes
// it look like a frame rather than a plain border. If no photo URL is
// provided we fall back to a neutral placeholder with the user's initial.
// Layout model (the PHOTO is the anchor — frame wraps AROUND it):
//
//   [wrapper: size × size]  ← matches the photo's diameter
//     ├── [photo circle: 100% of wrapper]     ← rendered first
//     └── [frame PNG: ~1.8× wrapper, shifted up ~25%]
//         (gold ring overlaps the photo's circumference from OUTSIDE;
//          laurel + gem hang below; ring center aligns with photo center)
//
// `size` is the visible photo diameter the caller reasons about. The frame
// deliberately overflows the wrapper — `overflow-visible` (default) lets
// the laurel + upper gold arch spill outside the bounding box, which is
// what makes it look like a crest AROUND the photo instead of a border
// INSIDE it.
// Tuned against the supplied asset so the gold ring hugs the photo's
// circumference exactly (ring's inner edge aligns with photo's outer edge)
// and the laurel + gem hang just below the photo.
const FRAME_SCALE = 1.55 // frame diameter / photo diameter — sized so the
                          // gold ring sits DIRECTLY on the photo's
                          // circumference (not floating away from it)
const FRAME_TOP_OFFSET = -0.33 // vertical shift of frame top, as × photo
                                // size. Tuned so the gold ring is centered
                                // on the photo's upper border: far enough
                                // up that it doesn't clip into the photo,
                                // but not so far that it floats above it.

function FramedAvatar({
  src,
  name,
  size = 40,
  className,
}: {
  src: string | null
  name: string
  size?: number
  className?: string
}) {
  const initial = (name || "?").trim().charAt(0).toUpperCase() || "?"
  const frameSize = Math.round(size * FRAME_SCALE)
  const frameTop = Math.round(size * FRAME_TOP_OFFSET)
  const frameLeft = Math.round((size - frameSize) / 2)

  return (
    <span
      className={cn("relative inline-block shrink-0", className)}
      style={{ width: size, height: size }}
      aria-hidden="true"
    >
      {/* Circular photo — the anchor. Fills the wrapper exactly. No extra
          ring/border: the gold frame above it is the ONLY border. */}
      <span
        className="absolute inset-0 overflow-hidden rounded-full bg-muted"
      >
        {src ? (
          // eslint-disable-next-line @next/next/no-img-element
          <img
            src={src || "/placeholder.svg"}
            alt=""
            className="h-full w-full object-cover"
            crossOrigin="anonymous"
            referrerPolicy="no-referrer"
          />
        ) : (
          <span className="flex h-full w-full items-center justify-center bg-primary text-[11px] font-semibold text-primary-foreground">
            {initial}
          </span>
        )}
      </span>

      {/* Decorative frame — scaled LARGER than the photo and shifted UP so
          the gold ring wraps around the photo's circumference from the
          outside while the laurel + gem hang below. Rendered AFTER the
          photo so the ring sits visually in front of the photo edge. */}
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img
        src="/frames/gold-laurel.png"
        alt=""
        className="pointer-events-none absolute max-w-none select-none"
        style={{
          width: frameSize,
          height: frameSize,
          top: frameTop,
          left: frameLeft,
        }}
        draggable={false}
      />
    </span>
  )
}

function GenderChip({ value }: { value: string }) {
  const label =
    value === "male"
      ? "Male"
      : value === "female"
        ? "Female"
        : value === "non-binary"
          ? "Non-binary"
          : value
  const tone =
    value === "male"
      ? "bg-sky-400/90 text-slate-950"
      : value === "female"
        ? "bg-pink-400/90 text-slate-950"
        : "bg-violet-400/90 text-slate-950"
  return (
    <span
      className={cn(
        "ml-0.5 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none tracking-tight",
        tone,
      )}
    >
      {label}
    </span>
  )
}

function ChatOverlay({
  open,
  messages,
  input,
  onInputChange,
  onSend,
  onToggleOpen,
  strangerName,
  myName,
  strangerVerified,
  myVerified,
  scrollRef,
  controlsHeight,
}: {
  open: boolean
  messages: { id: string; from: "me" | "them"; text: string; ts: number }[]
  input: string
  onInputChange: (v: string) => void
  onSend: () => void
  onToggleOpen: () => void
  strangerName: string
  myName: string
  /** If true, a blue tick is rendered after the stranger's name in each
   *  message they send. */
  strangerVerified: boolean
  /** If true, a blue tick is rendered after the user's own name in each
   *  message they send. */
  myVerified: boolean
  scrollRef: React.RefObject<HTMLDivElement | null>
  controlsHeight: number
}) {
  const strangerColor = colorForName(strangerName)
  // Ensure "me" gets a different hue than the stranger by nudging the hash
  // until we pick a different palette slot.
  const myColor = (() => {
    let c = colorForName(myName)
    if (c === strangerColor) c = colorForName(myName + "_")
    return c
  })()

  return (
    <>
      {/* Toggle button — lets the user hide/show the MESSAGE COMPOSER (text
          input) without ending the call. Incoming + outgoing messages remain
          visible at all times so the user never misses what the stranger
          said while their keyboard was hidden. */}
      <button
        type="button"
        onClick={onToggleOpen}
        aria-pressed={open}
        aria-label={open ? "Hide message box" : "Show message box"}
        className="absolute right-3 top-3 z-30 inline-flex h-9 w-9 items-center justify-center rounded-full bg-black/55 text-white shadow-lg ring-1 ring-white/10 backdrop-blur-md transition hover:bg-black/70 md:right-4 md:top-4"
      >
        {open ? (
          <MessageCircleOff className="h-4 w-4" aria-hidden="true" />
        ) : (
          <MessageCircle className="h-4 w-4" aria-hidden="true" />
        )}
      </button>

      {/* Chat column — the MESSAGE LIST is rendered unconditionally (per the
          product requirement "chat off hone pe bhi message show hona chahiye")
          and the COMPOSER is conditionally rendered based on `open`. */}
      <div
        // Anchor above the floating controls bar on mobile using the exact
        // measured controls height (via a CSS custom property). On desktop
        // the controls sit in normal flow below the video, so we force
        // `bottom: 0` to anchor cleanly against the video bottom edge.
        className="pointer-events-none absolute inset-x-0 bottom-[var(--chat-bottom)] z-20 flex flex-col md:!bottom-0"
        style={
          {
            "--chat-bottom":
              controlsHeight > 0
                ? `calc(${controlsHeight}px + 8px)`
                : "calc(80px + env(safe-area-inset-bottom))",
            // Gradient mask fades the video behind the chat so colored text
            // always has enough contrast without solid bubbles.
            maskImage:
              "linear-gradient(to top, black 60%, rgba(0,0,0,0.85) 80%, transparent 100%)",
            WebkitMaskImage:
              "linear-gradient(to top, black 60%, rgba(0,0,0,0.85) 80%, transparent 100%)",
          } as React.CSSProperties
        }
      >
        {/* Messages list — always visible while connected. */}
        <div
          ref={scrollRef}
          className="pointer-events-auto flex max-h-[38vh] flex-col gap-1.5 overflow-y-auto px-3 pb-2 pt-8 md:max-h-[46vh] md:px-4 md:pb-3"
          style={{
            // Subtle dark gradient behind the text for readability.
            background:
              "linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.35) 55%, transparent 100%)",
          }}
        >
          {messages.length === 0 && open && (
            <p className="self-center rounded-full bg-black/35 px-3 py-1 text-[11px] text-white/80 shadow-sm backdrop-blur-sm">
              Say hi — messages appear here
            </p>
          )}
          {messages.map((m) => {
            const color = m.from === "me" ? myColor : strangerColor
            const senderName = m.from === "me" ? myName : strangerName
            const senderVerified =
              m.from === "me" ? myVerified : strangerVerified
            return (
              <div
                key={m.id}
                className={cn(
                  "flex w-full",
                  m.from === "me" ? "justify-end" : "justify-start",
                )}
              >
                <div
                  className={cn(
                    "max-w-[85%] rounded-2xl px-3 py-1 text-sm leading-snug text-white shadow-md backdrop-blur-[2px]",
                    // Very subtle tinted background just for legibility —
                    // the TEXT color is the primary signal, per the brief.
                    "bg-black/25",
                  )}
                >
                  <span
                    className="mr-1.5 inline-flex items-center gap-1 text-[11px] font-semibold tracking-tight align-baseline"
                    style={{ color }}
                  >
                    <span>{senderName}</span>
                    {/* Verified badge sits at the END of the sender name
                        so it reads like a signature. Sized to match the
                        name's x-height. */}
                    {senderVerified && <BlueTick size={12} title="Verified" />}
                  </span>
                  <span
                    className="font-medium"
                    style={{ color, textShadow: "0 1px 2px rgba(0,0,0,0.6)" }}
                  >
                    {m.text}
                  </span>
                </div>
              </div>
            )
          })}
        </div>

        {/* Composer — hidden when the user toggles chat off. */}
        {open && (
          <form
            className="pointer-events-auto flex items-center gap-2 px-3 pb-3 pt-1 md:px-4 md:pb-4"
            onSubmit={(e) => {
              e.preventDefault()
              onSend()
            }}
          >
            <div className="flex w-full items-center gap-1.5 rounded-full bg-black/55 px-3 py-1.5 shadow-lg ring-1 ring-white/10 backdrop-blur-md focus-within:ring-white/25">
              <input
                type="text"
                value={input}
                onChange={(e) => onInputChange(e.target.value)}
                maxLength={500}
                placeholder="Type a message…"
                aria-label="Message"
                className="h-8 w-full bg-transparent text-sm text-white placeholder:text-white/60 focus:outline-none"
              />
              <Button
                type="submit"
                size="icon"
                disabled={!input.trim()}
                aria-label="Send message"
                className="h-8 w-8 shrink-0 rounded-full bg-white text-black shadow-sm hover:bg-white/90 disabled:opacity-50"
              >
                <Send className="h-4 w-4" aria-hidden="true" />
              </Button>
            </div>
          </form>
        )}
      </div>
    </>
  )
}

function PermissionModal({
  modal,
  onCancel,
  onConfirm,
}: {
  modal: {
    stage: "intro" | "requesting" | "denied"
    micState: "ok" | "denied" | "failed" | "unknown"
    camState: "ok" | "denied" | "failed" | "unknown"
    inIframe: boolean
    iframeBlocked: boolean
    errorMsg: string | null
  }
  onCancel: () => void
  onConfirm: () => void
}) {
  const { stage, micState, camState, inIframe, iframeBlocked, errorMsg } = modal
  const isRequesting = stage === "requesting"
  const isDenied = stage === "denied"

  // Distinguish two very different denied cases:
  //   - iframeBlocked: the embedding iframe suppressed the permission prompt
  //                    entirely. Changing site settings won't help. The user
  //                    MUST open this page in a top-level browser tab.
  //   - site-blocked : the browser has permanently blocked it in site
  //                    settings. User needs to unblock it via address-bar
  //                    lock icon and reload.
  const micBlocked = micState === "denied"
  const camBlocked = camState === "denied"
  const anyBlocked = micBlocked || camBlocked

  const title = iframeBlocked
    ? "Open in a new tab to continue"
    : isDenied
      ? anyBlocked
        ? micBlocked && camBlocked
          ? "Camera & microphone blocked"
          : micBlocked
            ? "Microphone blocked"
            : "Camera blocked"
        : "Couldn't access your device"
      : "Allow camera & microphone"

  const description = iframeBlocked
    ? errorMsg ??
      "This embedded preview is blocking the permission popup. Open the page in a new browser tab to allow camera and microphone access."
    : isDenied
      ? anyBlocked
        ? "Your browser is blocking this site from using your " +
          (micBlocked && camBlocked
            ? "camera and microphone"
            : micBlocked
              ? "microphone"
              : "camera") +
          ". Unblock it in site settings, then reload."
        : errorMsg ?? "We couldn't access your camera or microphone. Please try again."
      : "To start a random video chat we need access to your camera and microphone. Your browser will ask for permission next."

  const openInNewTab = () => {
    if (typeof window !== "undefined") {
      window.open(window.location.href, "_blank", "noopener,noreferrer")
    }
  }
  const reloadPage = () => {
    if (typeof window !== "undefined") window.location.reload()
  }

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="perm-title"
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm animate-in fade-in duration-150"
    >
      <div className="relative w-full max-w-md overflow-hidden rounded-2xl border border-border bg-background shadow-2xl ring-1 ring-black/5">
        <div className="flex flex-col items-center gap-5 px-6 pb-6 pt-8 text-center">
          {/* Icon row — each device is red if blocked, green if ok, primary otherwise */}
          <div className="flex items-center gap-3">
            <DeviceBadge icon="cam" state={camState} />
            <DeviceBadge icon="mic" state={micState} />
          </div>

          <div className="flex flex-col gap-2">
            <h2 id="perm-title" className="text-lg font-semibold tracking-tight text-foreground">
              {title}
            </h2>
            <p className="text-pretty text-sm leading-relaxed text-muted-foreground">
              {description}
            </p>
          </div>

          {/* Per-device status list (only shown after an attempt so the user
              knows exactly which permission still needs action) */}
          {isDenied && (
            <ul className="flex w-full flex-col gap-2 rounded-xl border border-border bg-muted/30 p-3 text-left">
              <DeviceRow icon="cam" label="Camera" state={camState} />
              <DeviceRow icon="mic" label="Microphone" state={micState} />
            </ul>
          )}

          {/* Proactive iframe warning — BEFORE the user clicks Enable, so
              they know the mic prompt may never appear inside an embedded
              preview and can pre-empt the problem by opening in a new tab.
              This specifically addresses the common case where a browser's
              microphone permission is set to "Ask every time" — the prompt
              simply cannot surface inside restricted iframes, regardless of
              the site's own code or settings. */}
          {!isDenied && inIframe && (
            <div className="w-full rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 text-left text-xs leading-relaxed text-foreground">
              <p className="mb-1 font-medium">Running in a preview?</p>
              <p className="mb-2 text-muted-foreground">
                Embedded previews sometimes block the microphone permission popup from appearing.
                If you don&apos;t see a prompt after tapping Enable, open the page in its own tab.
              </p>
              <Button
                type="button"
                size="sm"
                variant="outline"
                onClick={() => {
                  if (typeof window !== "undefined") {
                    window.open(window.location.href, "_blank", "noopener,noreferrer")
                  }
                }}
                className="h-8 gap-1.5 rounded-full text-xs"
              >
                <ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
                Open in new tab
              </Button>
            </div>
          )}

          {/* Iframe-is-blocking case (after a failed attempt): explain the
              cause and recommend opening in a new browser tab. */}
          {iframeBlocked && (
            <div className="w-full rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 text-left text-xs leading-relaxed text-foreground">
              <p className="mb-1 font-medium">Why this happens</p>
              <p className="text-muted-foreground">
                The embedded preview frame is suppressing the browser&apos;s permission popup for
                your microphone, so no prompt ever appears even when your browser setting is
                &ldquo;Ask every time.&rdquo; Opening this page in its own browser tab removes the
                restriction and the popup will show up normally.
              </p>
            </div>
          )}

          {/* Site-settings-blocked case: walk the user through unblocking
              via the browser's address-bar lock icon. */}
          {isDenied && !iframeBlocked && anyBlocked && (
            <div className="w-full rounded-xl border border-border bg-muted/20 p-3 text-left text-xs leading-relaxed text-muted-foreground">
              <p className="mb-2 font-medium text-foreground">How to unblock:</p>
              <ol className="list-decimal space-y-1 pl-4">
                <li>
                  Tap the <span className="font-medium text-foreground">lock</span> or{" "}
                  <span className="font-medium text-foreground">camera</span> icon in your
                  browser&apos;s address bar.
                </li>
                <li>
                  Set{" "}
                  {camBlocked && micBlocked
                    ? "Camera and Microphone"
                    : camBlocked
                      ? "Camera"
                      : "Microphone"}{" "}
                  to <span className="font-medium text-foreground">Allow</span>.
                </li>
                <li>Reload this page, then tap Start again.</li>
              </ol>
            </div>
          )}

          {/* Actions — primary CTA depends on the failure mode */}
          <div className="mt-1 flex w-full flex-col gap-2 sm:flex-row-reverse">
            {iframeBlocked ? (
              <Button
                type="button"
                onClick={openInNewTab}
                className="h-11 w-full gap-1.5 rounded-full text-sm font-semibold sm:flex-1"
              >
                <ExternalLink className="h-4 w-4" aria-hidden="true" />
                Open in new tab
              </Button>
            ) : isDenied && anyBlocked ? (
              <Button
                type="button"
                onClick={reloadPage}
                className="h-11 w-full rounded-full text-sm font-semibold sm:flex-1"
              >
                Reload page
              </Button>
            ) : (
              <Button
                type="button"
                onClick={onConfirm}
                disabled={isRequesting}
                className="h-11 w-full rounded-full text-sm font-semibold sm:flex-1"
              >
                {isRequesting ? (
                  <>
                    <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
                    Requesting access...
                  </>
                ) : isDenied ? (
                  "Try again"
                ) : (
                  "Enable camera & microphone"
                )}
              </Button>
            )}
            <Button
              type="button"
              variant="ghost"
              onClick={onCancel}
              disabled={isRequesting}
              className="h-11 w-full rounded-full text-sm sm:flex-1"
            >
              Cancel
            </Button>
          </div>
        </div>
      </div>
    </div>
  )
}

function DeviceBadge({
  icon,
  state,
}: {
  icon: "mic" | "cam"
  state: "ok" | "denied" | "failed" | "unknown"
}) {
  const Icon = icon === "mic" ? Mic : Video
  const tone =
    state === "ok"
      ? "bg-emerald-500/15 text-emerald-600"
      : state === "denied" || state === "failed"
        ? "bg-destructive/15 text-destructive"
        : "bg-primary/15 text-primary"
  return (
    <div className={cn("flex h-14 w-14 items-center justify-center rounded-2xl shadow-sm", tone)}>
      <Icon className="h-7 w-7" aria-hidden="true" />
    </div>
  )
}

function DeviceRow({
  icon,
  label,
  state,
}: {
  icon: "mic" | "cam"
  label: string
  state: "ok" | "denied" | "failed" | "unknown"
}) {
  const Icon = icon === "mic" ? Mic : Video
  const tone =
    state === "ok"
      ? "bg-emerald-500/15 text-emerald-600"
      : state === "denied" || state === "failed"
        ? "bg-destructive/15 text-destructive"
        : "bg-muted text-muted-foreground"
  const statusLabel =
    state === "ok"
      ? "Allowed"
      : state === "denied"
        ? "Blocked"
        : state === "failed"
          ? "Unavailable"
          : "Not requested"
  return (
    <li className="flex items-center gap-2 text-sm">
      <span
        className={cn("flex h-6 w-6 items-center justify-center rounded-full", tone)}
        aria-hidden="true"
      >
        <Icon className="h-3.5 w-3.5" />
      </span>
      <span className="font-medium text-foreground">{label}</span>
      <span className="ml-auto text-xs text-muted-foreground">{statusLabel}</span>
    </li>
  )
}

function LiveUsersBadge({ count }: { count: number }) {
  const label = count === 1 ? "user" : "users"
  const display = count > 0 ? count.toLocaleString() : "—"
  return (
    <div
      className="flex items-center gap-1.5 rounded-full border border-border bg-background/90 px-2.5 py-1 text-xs font-medium text-foreground shadow-sm backdrop-blur md:gap-2 md:px-3 md:py-1.5"
      aria-live="polite"
      aria-label={`${count} live ${label}`}
      title={`${count} live ${label}`}
    >
      <span className="relative flex h-2 w-2" aria-hidden="true">
        <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-500 opacity-75" />
        <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
      </span>
      <span className="tabular-nums">{display}</span>
      <span className="hidden text-muted-foreground sm:inline">live</span>
    </div>
  )
}

function StatusPill({ status, waitingCount }: { status: Status; waitingCount: number }) {
  let label = "Idle"
  let tone = "bg-secondary text-secondary-foreground"
  let dotTone = "bg-muted-foreground"
  let pulse = false
  if (status === "requesting-media") {
    label = "Requesting camera"
    pulse = true
  } else if (status === "waiting") {
    label = `Searching${waitingCount ? ` ${waitingCount}s` : ""}`
    pulse = true
  } else if (status === "connecting") {
    label = "Connecting"
    pulse = true
  } else if (status === "connected") {
    label = "Live"
    tone = "bg-primary text-primary-foreground"
    dotTone = "bg-primary-foreground"
  } else if (status === "error") {
    label = "Error"
    tone = "bg-destructive text-destructive-foreground"
    dotTone = "bg-destructive-foreground"
  }
  return (
    <span
      className={cn(
        "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium shadow-sm",
        tone,
      )}
      role="status"
      aria-live="polite"
    >
      <span className={cn("h-1.5 w-1.5 rounded-full", dotTone, pulse && "animate-pulse")} aria-hidden="true" />
      {label}
    </span>
  )
}

function SearchingState({ status, waitingCount }: { status: Status; waitingCount: number }) {
  return (
    <div className="flex max-w-xs flex-col items-center gap-3">
      <div className="relative flex h-16 w-16 items-center justify-center">
        <span className="absolute inset-0 animate-ping rounded-full bg-primary/15" aria-hidden="true" />
        <span className="absolute inset-2 rounded-full bg-primary/10" aria-hidden="true" />
        <Loader2 className="relative h-7 w-7 animate-spin text-foreground/80" aria-hidden="true" />
      </div>
      <div>
        <p className="text-base font-medium text-foreground">
          {status === "waiting" ? "Looking for a stranger" : "Connecting"}
        </p>
        <p className="mt-1 text-pretty text-sm text-muted-foreground">
          {status === "waiting"
            ? waitingCount > 8
              ? "Still searching, hang tight…"
              : "Pairing you with someone new"
            : "Setting up a secure peer-to-peer link"}
        </p>
      </div>
    </div>
  )
}

function IdleState() {
  return (
    <div className="flex max-w-xs flex-col items-center gap-3">
      <div className="flex h-14 w-14 items-center justify-center rounded-full bg-background shadow-sm ring-1 ring-border">
        <Video className="h-6 w-6 text-foreground/70" aria-hidden="true" />
      </div>
      <div>
        <p className="text-base font-medium text-foreground">Ready when you are</p>
        <p className="mt-1 text-pretty text-sm text-muted-foreground">
          Press Start to meet a random stranger over video.
        </p>
      </div>
    </div>
  )
}

function ErrorState({
  errorKind,
  error,
  onRetry,
}: {
  errorKind: "permission" | "device" | "insecure" | "other" | null
  error: string | null
  onRetry: () => void
}) {
  return (
    <div className="flex max-w-md flex-col items-center gap-3 text-center">
      <p className="text-base font-medium text-destructive">
        {errorKind === "permission"
          ? "Camera access blocked"
          : errorKind === "device"
            ? "Camera unavailable"
            : errorKind === "insecure"
              ? "Insecure context"
              : "Something went wrong"}
      </p>
      <p className="text-pretty text-sm text-muted-foreground">{error ?? "Please try again"}</p>

      {errorKind === "permission" && (
        <div className="w-full rounded-lg border border-border bg-background/60 p-3 text-left text-xs text-muted-foreground">
          <p className="mb-1.5 font-medium text-foreground">How to unblock</p>
          <ol className="list-inside list-decimal space-y-1 leading-relaxed">
            <li>Click the lock icon in the address bar (left of the URL).</li>
            <li>
              Find <span className="font-medium text-foreground">Camera</span> and{" "}
              <span className="font-medium text-foreground">Microphone</span> and set both to{" "}
              <span className="font-medium text-foreground">Allow</span>.
            </li>
            <li>Reload the page, then press Start again.</li>
          </ol>
        </div>
      )}

      <div className="flex flex-wrap items-center justify-center gap-2">
        {errorKind === "permission" && (
          <>
            <Button
              variant="secondary"
              size="sm"
              className="gap-2"
              onClick={() => window.open(window.location.href, "_blank", "noopener,noreferrer")}
            >
              <ExternalLink className="h-4 w-4" aria-hidden="true" />
              Open in new tab
            </Button>
            <Button variant="outline" size="sm" onClick={() => window.location.reload()}>
              Reload page
            </Button>
          </>
        )}
        <Button size="sm" onClick={onRetry}>
          Try again
        </Button>
      </div>
    </div>
  )
}
