use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

use super::super::hevc_mjpeg_guard;
use super::MjpegSpoolTiming;

static AUDIT_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
static AUDIT_SAVED_FRAMES: AtomicU64 = AtomicU64::new(0);
const DEFAULT_UVC_FRAME_AUDIT_EVERY: u32 = 1;
const DEFAULT_UVC_FRAME_AUDIT_MAX_FRAMES: u32 = 1800;
const DEFAULT_UVC_FRAME_AUDIT_ROLLING: bool = true;
const DEFAULT_UVC_FRAME_AUDIT_CONTROL_PATH: &str = "/tmp/lesavka-uvc-frame-audit.control";
const FNV1A64_OFFSET: u64 = 0xcbf29ce484222325;
const FNV1A64_PRIME: u64 = 0x100000001b3;

#[cfg(test)]
pub(super) fn reset_spool_audit_counter_for_test() {
    AUDIT_SAVED_FRAMES.store(0, Ordering::Relaxed);
}

/// Parse an optional unsigned tuning value.
///
/// Inputs: environment variable name. Output: parsed `u32` when present.
/// Why: audit controls should ignore malformed operator input instead of
/// preventing frame spooling.
fn env_u32_opt(name: &str) -> Option<u32> {
    std::env::var(name)
        .ok()
        .and_then(|value| value.trim().parse::<u32>().ok())
}

fn unix_now_ns() -> u128 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_nanos())
        .unwrap_or(0)
}

fn json_number_or_null(value: Option<u64>) -> String {
    value
        .map(|value| value.to_string())
        .unwrap_or_else(|| "null".to_string())
}

/// Append one audit JSONL row, creating parent directories as needed.
///
/// Inputs: destination path and already formatted JSONL row. Output: success
/// or filesystem error. Why: the audit frame and index should be collected
/// together without changing the normal latest-frame spool file.
fn append_record(path: &Path, record: &str) -> anyhow::Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)?
        .write_all(record.as_bytes())?;
    Ok(())
}

/// Resolve the optional exact UVC-bound MJPEG audit directory.
///
/// Inputs: `LESAVKA_UVC_FRAME_AUDIT_DIR`. Output: a directory when configured.
/// Why: the final RCT recording can be corrupt even when this boundary frame is
/// clean, so debugging needs the exact payloads published to UVC.
pub(super) fn mjpeg_spool_audit_dir() -> Option<PathBuf> {
    std::env::var("LESAVKA_UVC_FRAME_AUDIT_DIR")
        .ok()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
        .or_else(mjpeg_spool_runtime_audit_dir)
}

/// Resolve the runtime control-file audit directory.
///
/// Inputs: `LESAVKA_UVC_FRAME_AUDIT_CONTROL_PATH` or the default control path.
/// Output: an audit directory, unless the file is absent or contains an off
/// value. Why: lab probes need to enable exact UVC-bound captures around one
/// run without restarting or making all installed calls write MJPEG dumps.
fn mjpeg_spool_runtime_audit_dir() -> Option<PathBuf> {
    let control_path = std::env::var("LESAVKA_UVC_FRAME_AUDIT_CONTROL_PATH")
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from(DEFAULT_UVC_FRAME_AUDIT_CONTROL_PATH));
    let value = fs::read_to_string(control_path).ok()?;
    let trimmed = value.trim();
    if trimmed.is_empty()
        || trimmed.eq_ignore_ascii_case("0")
        || trimmed.eq_ignore_ascii_case("false")
        || trimmed.eq_ignore_ascii_case("no")
        || trimmed.eq_ignore_ascii_case("off")
    {
        return None;
    }
    Some(PathBuf::from(trimmed))
}

/// Resolve the UVC-bound audit sampling interval.
///
/// Inputs: `LESAVKA_UVC_FRAME_AUDIT_EVERY`. Output: save every Nth frame. Why:
/// short repros need every frame, while long calls need a disk-safe throttle.
pub(super) fn mjpeg_spool_audit_every() -> u64 {
    u64::from(
        env_u32_opt("LESAVKA_UVC_FRAME_AUDIT_EVERY")
            .unwrap_or(DEFAULT_UVC_FRAME_AUDIT_EVERY)
            .max(1),
    )
}

/// Resolve how many audited frames may be saved.
///
/// Inputs: `LESAVKA_UVC_FRAME_AUDIT_MAX_FRAMES`. Output: max saved frames; zero
/// means unlimited. Why: full MJPEG dumps are powerful but intentionally heavy.
pub(super) fn mjpeg_spool_audit_max_frames() -> u64 {
    u64::from(
        env_u32_opt("LESAVKA_UVC_FRAME_AUDIT_MAX_FRAMES")
            .unwrap_or(DEFAULT_UVC_FRAME_AUDIT_MAX_FRAMES),
    )
}

/// Resolve whether bounded audits keep the latest frames instead of stopping.
///
/// Inputs: `LESAVKA_UVC_FRAME_AUDIT_ROLLING`. Output: true unless explicitly
/// disabled. Why: long live-debug sessions need the frames around the latest
/// recording, not stale startup evidence from hours earlier.
pub(super) fn mjpeg_spool_audit_rolling_enabled() -> bool {
    std::env::var("LESAVKA_UVC_FRAME_AUDIT_ROLLING")
        .ok()
        .map(|value| {
            let trimmed = value.trim();
            !(trimmed.eq_ignore_ascii_case("0")
                || trimmed.eq_ignore_ascii_case("false")
                || trimmed.eq_ignore_ascii_case("no")
                || trimmed.eq_ignore_ascii_case("off"))
        })
        .unwrap_or(DEFAULT_UVC_FRAME_AUDIT_ROLLING)
}

/// Resolve the audit JSONL path.
///
/// Inputs: audit directory plus `LESAVKA_UVC_FRAME_AUDIT_LOG_PATH`. Output:
/// JSONL index path. Why: the frame dump needs compact timing/hash metadata
/// for alignment with eye-recording frame numbers.
pub(super) fn mjpeg_spool_audit_log_path(dir: &Path) -> PathBuf {
    std::env::var("LESAVKA_UVC_FRAME_AUDIT_LOG_PATH")
        .ok()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
        .unwrap_or_else(|| dir.join("spool-audit.jsonl"))
}

/// Hash an audited frame payload into a compact stable identifier.
///
/// Inputs: frame bytes. Output: lowercase FNV-1a hex digest. Why: audit JSONL
/// should correlate frame files without embedding large binary payloads.
fn fnv1a64_hex(data: &[u8]) -> String {
    let mut hash = FNV1A64_OFFSET;
    for byte in data {
        hash ^= u64::from(*byte);
        hash = hash.wrapping_mul(FNV1A64_PRIME);
    }
    format!("{hash:016x}")
}

fn json_escape(value: &str) -> String {
    value
        .replace('\\', "\\\\")
        .replace('"', "\\\"")
        .replace('\n', "\\n")
        .replace('\r', "\\r")
}

pub(super) fn should_sample_spool_audit_frame(sequence: u64, every: u64) -> bool {
    let offset = sequence.saturating_sub(1);
    offset.is_multiple_of(every.max(1))
}

/// Decide where the current spooled frame should be preserved.
///
/// Inputs: global spool sequence. Output: the bounded audit file slot, if
/// sampling and retention allow a save. Why: rolling audits must stay disk
/// bounded while still keeping the frames nearest the user's recording.
pub(super) fn claim_spool_audit_frame(sequence: u64) -> Option<u64> {
    if !should_sample_spool_audit_frame(sequence, mjpeg_spool_audit_every()) {
        return None;
    }
    let max_frames = mjpeg_spool_audit_max_frames();
    let saved = AUDIT_SAVED_FRAMES.fetch_add(1, Ordering::Relaxed);
    if max_frames == 0 {
        return Some(sequence);
    }
    if mjpeg_spool_audit_rolling_enabled() {
        return Some(saved % max_frames + 1);
    }
    (saved < max_frames).then_some(sequence)
}

/// Format one exact-frame audit index record.
///
/// Inputs: sequence, JPEG bytes, optional timing, and saved filename. Output:
/// compact JSONL row. Why: hashes and timestamps let later tooling align a bad
/// RCT frame with the server-boundary payload that preceded it.
fn format_audit_record(
    sequence: u64,
    slot: u64,
    data: &[u8],
    timing: Option<MjpegSpoolTiming>,
    frame_file: &str,
) -> String {
    let profile = timing.map(|value| value.profile).unwrap_or("unknown");
    let source_pts_us = timing.and_then(|value| value.source_pts_us);
    let decoded_pts_us = timing.and_then(|value| value.decoded_pts_us);
    let uvc_width = timing.and_then(|value| value.uvc_width.map(u64::from));
    let uvc_height = timing.and_then(|value| value.uvc_height.map(u64::from));
    let uvc_fps = timing.and_then(|value| value.uvc_fps.map(u64::from));
    let inspection = hevc_mjpeg_guard::inspect_mjpeg_frame(data);
    format!(
        "{{\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\",\"sequence\":{},\"slot\":{},\"profile\":\"{}\",\"bytes\":{},\"jpeg_complete\":{},\"frame_width\":{},\"frame_height\":{},\"entropy_bytes\":{},\"entropy_distinct_bytes\":{},\"entropy_dominant_pct\":{},\"entropy_max_run\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"uvc_width\":{},\"uvc_height\":{},\"uvc_fps\":{},\"spool_unix_ns\":{},\"fnv1a64\":\"{}\",\"file\":\"{}\"}}\n",
        sequence,
        slot,
        json_escape(profile),
        data.len(),
        inspection.complete,
        json_number_or_null(inspection.width.map(u64::from)),
        json_number_or_null(inspection.height.map(u64::from)),
        inspection.entropy_bytes,
        inspection.entropy_distinct_bytes,
        inspection.entropy_dominant_pct,
        inspection.entropy_max_run,
        json_number_or_null(source_pts_us),
        json_number_or_null(decoded_pts_us),
        json_number_or_null(uvc_width),
        json_number_or_null(uvc_height),
        json_number_or_null(uvc_fps),
        unix_now_ns(),
        fnv1a64_hex(data),
        json_escape(frame_file)
    )
}

/// Format one guard-rejected frame audit record.
///
/// Inputs: sequence, saved slot, rejected bytes, timing, filename, and reason.
/// Output: one JSONL row. Why: rejected frames need the same timing/hash
/// evidence as accepted frames so guard tuning can compare both paths.
fn format_rejected_audit_record(
    sequence: u64,
    slot: u64,
    data: &[u8],
    timing: MjpegSpoolTiming,
    frame_file: &str,
    reason: &str,
) -> String {
    let source_pts_us = timing.source_pts_us;
    let decoded_pts_us = timing.decoded_pts_us;
    let inspection = hevc_mjpeg_guard::inspect_mjpeg_frame(data);
    format!(
        "{{\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\",\"sequence\":{},\"slot\":{},\"profile\":\"{}\",\"rejected\":true,\"reason\":\"{}\",\"bytes\":{},\"jpeg_complete\":{},\"frame_width\":{},\"frame_height\":{},\"entropy_bytes\":{},\"entropy_distinct_bytes\":{},\"entropy_dominant_pct\":{},\"entropy_max_run\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"uvc_width\":{},\"uvc_height\":{},\"uvc_fps\":{},\"spool_unix_ns\":{},\"fnv1a64\":\"{}\",\"file\":\"{}\"}}\n",
        sequence,
        slot,
        json_escape(timing.profile),
        json_escape(reason),
        data.len(),
        inspection.complete,
        json_number_or_null(inspection.width.map(u64::from)),
        json_number_or_null(inspection.height.map(u64::from)),
        inspection.entropy_bytes,
        inspection.entropy_distinct_bytes,
        inspection.entropy_dominant_pct,
        inspection.entropy_max_run,
        json_number_or_null(source_pts_us),
        json_number_or_null(decoded_pts_us),
        json_number_or_null(timing.uvc_width.map(u64::from)),
        json_number_or_null(timing.uvc_height.map(u64::from)),
        json_number_or_null(timing.uvc_fps.map(u64::from)),
        unix_now_ns(),
        fnv1a64_hex(data),
        json_escape(frame_file)
    )
}

/// Save one exact UVC-bound MJPEG frame and append its JSONL index row.
///
/// Inputs: audit directory, sequence, JPEG bytes, and timing. Output: success
/// or filesystem error. Why: this captures the server-side boundary evidence
/// needed to tell pre-UVC corruption from downstream UVC/browser corruption.
pub(super) fn write_mjpeg_spool_audit_frame(
    dir: &Path,
    sequence: u64,
    slot: u64,
    data: &[u8],
    timing: Option<MjpegSpoolTiming>,
) -> anyhow::Result<()> {
    fs::create_dir_all(dir)?;
    let frame_file = format!("frame-{slot:012}.mjpg");
    let tmp = dir.join(format!(
        ".{frame_file}.{}.{}.tmp",
        std::process::id(),
        AUDIT_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
    ));
    fs::write(&tmp, data)?;
    fs::rename(&tmp, dir.join(&frame_file))?;

    let record = format_audit_record(sequence, slot, data, timing, &frame_file);
    append_record(&mjpeg_spool_audit_log_path(dir), &record)
}

/// Save one guard-rejected MJPEG frame and append its JSONL index row.
///
/// Inputs: audit directory, sequence, rejected JPEG bytes, timing, and reason.
/// Output: success or filesystem error. Why: rejected-frame payloads are the
/// missing evidence for tuning the guard against real RCT-visible distortion.
pub(super) fn write_mjpeg_spool_rejected_audit_frame(
    dir: &Path,
    sequence: u64,
    slot: u64,
    data: &[u8],
    timing: MjpegSpoolTiming,
    reason: &str,
) -> anyhow::Result<()> {
    fs::create_dir_all(dir)?;
    let frame_file = format!("rejected-frame-{slot:012}.mjpg");
    let tmp = dir.join(format!(
        ".{frame_file}.{}.{}.tmp",
        std::process::id(),
        AUDIT_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
    ));
    fs::write(&tmp, data)?;
    fs::rename(&tmp, dir.join(&frame_file))?;

    let record = format_rejected_audit_record(sequence, slot, data, timing, &frame_file, reason);
    append_record(&mjpeg_spool_audit_log_path(dir), &record)
}
