enum CameraSink {
    Uvc(WebcamSink),
    Hdmi(HdmiSink),
    UvcWithHdmiMirror {
        uvc: WebcamSink,
        hdmi: HdmiMirrorSink,
    },
    #[cfg(coverage)]
    Noop,
}

impl CameraSink {
    fn push(&self, pkt: VideoPacket) {
        match self {
            CameraSink::Uvc(sink) => sink.push(pkt),
            CameraSink::Hdmi(sink) => sink.push(pkt),
            CameraSink::UvcWithHdmiMirror { uvc, hdmi } => {
                // UVC is the browser-facing contract. The optional HDMI mirror
                // must never hold this primary handoff behind KMS/display work.
                uvc.push(pkt.clone());
                hdmi.push(pkt);
            }
            #[cfg(coverage)]
            CameraSink::Noop => {
                let _ = pkt;
            }
        }
    }
}

#[derive(Default)]
struct HdmiMirrorState {
    pending: Option<VideoPacket>,
    stopped: bool,
}

/// Latest-only handoff for the optional HDMI mirror.
///
/// KMS/display sinks may block while modesetting or waiting for a connector.
/// Keeping that work on a dedicated thread prevents the secondary output from
/// accumulating latency in the primary UVC camera path.
struct HdmiMirrorSink {
    state: Arc<(std::sync::Mutex<HdmiMirrorState>, std::sync::Condvar)>,
    dropped_frames: AtomicU64,
}

impl HdmiMirrorSink {
    fn new(cfg: &CameraConfig) -> anyhow::Result<Self> {
        let sink = HdmiSink::new(cfg)?;
        let state = Arc::new((
            std::sync::Mutex::new(HdmiMirrorState::default()),
            std::sync::Condvar::new(),
        ));
        let worker_state = Arc::clone(&state);
        std::thread::Builder::new()
            .name("lesavka-hdmi-mirror".to_string())
            .spawn(move || run_hdmi_mirror(worker_state, sink))
            .context("starting HDMI mirror handoff worker")?;
        Ok(Self {
            state,
            dropped_frames: AtomicU64::new(0),
        })
    }

    fn push(&self, pkt: VideoPacket) {
        let (lock, ready) = &*self.state;
        let Ok(mut state) = lock.lock() else {
            return;
        };
        if state.stopped {
            return;
        }
        if state.pending.replace(pkt).is_some() {
            let dropped = self.dropped_frames.fetch_add(1, Ordering::Relaxed) + 1;
            if dropped == 1 || dropped.is_multiple_of(60) {
                warn!(
                    target: "lesavka_server::video",
                    dropped,
                    "HDMI mirror replaced a stale pending frame without delaying UVC"
                );
            }
        }
        ready.notify_one();
    }
}

impl Drop for HdmiMirrorSink {
    fn drop(&mut self) {
        let (lock, ready) = &*self.state;
        if let Ok(mut state) = lock.lock() {
            state.stopped = true;
            state.pending = None;
            ready.notify_one();
        }
    }
}

fn run_hdmi_mirror(
    state: Arc<(std::sync::Mutex<HdmiMirrorState>, std::sync::Condvar)>,
    sink: HdmiSink,
) {
    loop {
        let pkt = {
            let (lock, ready) = &*state;
            let Ok(mut state) = lock.lock() else {
                return;
            };
            while state.pending.is_none() && !state.stopped {
                let Ok(next) = ready.wait(state) else {
                    return;
                };
                state = next;
            }
            if state.stopped {
                return;
            }
            state.pending.take()
        };
        if let Some(pkt) = pkt {
            sink.push(pkt);
        }
    }
}

/// Forward camera packets from gRPC into either a UVC or HDMI sink.
///
/// Inputs: packets received from the client camera stream.
/// Outputs: none; packets are forwarded to the configured sink.
/// Why: camera sessions share the same logging and dev-mode dump behavior even
/// though their physical sinks differ.
pub struct CameraRelay {
    sink: CameraSink,
    id: u32,
    frames: AtomicU64,
}

impl CameraRelay {
    /// Build a relay that targets the USB UVC gadget.
    ///
    /// Inputs: the logical camera id, UVC device node, and camera config.
    /// Outputs: a relay that writes frames into the gadget pipeline.
    /// Why: keeping constructors explicit avoids accidental sink mismatches.
    pub fn new_uvc(id: u32, uvc_dev: &str, cfg: &CameraConfig) -> anyhow::Result<Self> {
        Ok(Self {
            sink: CameraSink::Uvc(WebcamSink::new(uvc_dev, cfg)?),
            id,
            frames: AtomicU64::new(0),
        })
    }

    /// Build a relay that writes the same camera stream to UVC and HDMI.
    ///
    /// Inputs: the logical camera id, UVC device node, UVC profile, and HDMI
    /// mirror display profile.
    /// Outputs: a relay that fans out each client frame to both physical sinks.
    /// Why: this lets the capture-card bypass be evaluated without removing the
    /// current UVC gadget path from Tethys.
    pub fn new_uvc_with_hdmi_mirror(
        id: u32,
        uvc_dev: &str,
        uvc_cfg: &CameraConfig,
        hdmi_cfg: &CameraConfig,
    ) -> anyhow::Result<Self> {
        Ok(Self {
            sink: CameraSink::UvcWithHdmiMirror {
                uvc: WebcamSink::new(uvc_dev, uvc_cfg)?,
                hdmi: HdmiMirrorSink::new(hdmi_cfg)?,
            },
            id,
            frames: AtomicU64::new(0),
        })
    }

    /// Build a relay that targets the HDMI output pipeline.
    ///
    /// Inputs: the logical camera id plus the camera config.
    /// Outputs: a relay that writes frames into the display pipeline.
    /// Why: the camera runtime reuses this constructor when the negotiated
    /// output mode selects HDMI instead of UVC.
    pub fn new_hdmi(id: u32, cfg: &CameraConfig) -> anyhow::Result<Self> {
        Ok(Self {
            sink: CameraSink::Hdmi(HdmiSink::new(cfg)?),
            id,
            frames: AtomicU64::new(0),
        })
    }

    #[cfg(coverage)]
    pub fn new_noop(id: u32) -> Self {
        Self {
            sink: CameraSink::Noop,
            id,
            frames: AtomicU64::new(0),
        }
    }

    /// Push one `VideoPacket` coming from the client.
    ///
    /// Inputs: the next packet from the camera stream.
    /// Outputs: none; the packet is logged and forwarded to the sink.
    /// Why: centralizing frame logging and dev-mode dump behavior keeps the
    /// transport session logic separate from media sink mechanics.
    #[cfg(coverage)]
    pub fn feed(&self, pkt: VideoPacket) {
        let frame = self
            .frames
            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);

        if dev_mode_enabled() && contains_idr(&pkt.data) {
            let path = format!("/tmp/eye3-cli-{frame:05}.h264");
            let _ = std::fs::write(&path, &pkt.data);
        }

        self.sink.push(pkt);
    }

    #[cfg(not(coverage))]
    pub fn feed(&self, pkt: VideoPacket) {
        let frame = self
            .frames
            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        if frame < 10 || frame.is_multiple_of(60) {
            tracing::debug!(
                target:"lesavka_server::video",
                cam_id = self.id,
                frame,
                bytes = pkt.data.len(),
                pts = pkt.pts,
                "📸 srv  webcam frame"
            );
        } else if frame.is_multiple_of(10) {
            tracing::trace!(
                target:"lesavka_server::video",
                cam_id = self.id,
                bytes = pkt.data.len(),
                "📸📥 srv  pkt"
            );
        }

        if dev_mode_enabled()
            && (cfg!(debug_assertions) || tracing::enabled!(tracing::Level::TRACE))
            && contains_idr(&pkt.data)
        {
            let path = format!("/tmp/eye3-cli-{frame:05}.h264");
            if let Err(error) = std::fs::write(&path, &pkt.data) {
                warn!("📸💾 dump failed: {error}");
            } else {
                tracing::debug!("📸💾 wrote {}", path);
            }
        }

        self.sink.push(pkt);
    }
}

#[cfg(test)]
mod camera_relay_tests {
    use super::*;

    #[test]
    fn hdmi_mirror_state_keeps_only_the_latest_pending_frame() {
        let mut state = HdmiMirrorState::default();
        state.pending = Some(VideoPacket {
            id: 1,
            ..Default::default()
        });
        let replaced = state.pending.replace(VideoPacket {
            id: 2,
            ..Default::default()
        });

        assert_eq!(replaced.map(|packet| packet.id), Some(1));
        assert_eq!(state.pending.map(|packet| packet.id), Some(2));
    }
}
