use std::path::PathBuf;

pub use corvus_traits::multimedia::AudioHistoryMeta;

/// Maximum audio payload size (25 MiB).
pub const MAX_AUDIO_BYTES: u64 = 25 * 1024 * 1024;

/// Hard ceiling for `max_audio_bytes` config override (100 MiB).
pub const MAX_AUDIO_BYTES_CEILING: u64 = 100 * 1024 * 1024;

/// Maximum audio duration in seconds (10 minutes).
pub const MAX_AUDIO_DURATION_SECS: u64 = 600;

/// Hard ceiling for `max_audio_duration_secs` config override (1 hour).
pub const MAX_AUDIO_DURATION_SECS_CEILING: u64 = 3600;

// ── AllowedAudioMime ──────────────────────────────────────────

/// Allowed audio MIME types for ingress.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AllowedAudioMime {
    /// OGG/Opus — Telegram voice notes.
    OggOpus,
    /// MPEG audio (MP3).
    Mp3,
    /// RIFF WAVE audio.
    Wav,
    /// MPEG-4 audio (M4A/AAC).
    M4a,
}

impl AllowedAudioMime {
    /// Parse from a MIME string (e.g. `"audio/ogg"`).
    pub fn from_mime_str(s: &str) -> Option<Self> {
        match s {
            "audio/ogg" | "audio/opus" | "audio/ogg; codecs=opus" => Some(Self::OggOpus),
            "audio/mpeg" | "audio/mp3" => Some(Self::Mp3),
            "audio/wav" | "audio/wave" | "audio/x-wav" => Some(Self::Wav),
            "audio/mp4" | "audio/m4a" | "audio/x-m4a" | "audio/aac" => Some(Self::M4a),
            _ => None,
        }
    }

    /// Return the canonical MIME string.
    pub fn as_str(&self) -> &str {
        match self {
            Self::OggOpus => "audio/ogg",
            Self::Mp3 => "audio/mpeg",
            Self::Wav => "audio/wav",
            Self::M4a => "audio/mp4",
        }
    }

    /// Return the standard file extension (without leading dot).
    pub fn file_extension(&self) -> &str {
        match self {
            Self::OggOpus => "ogg",
            Self::Mp3 => "mp3",
            Self::Wav => "wav",
            Self::M4a => "m4a",
        }
    }
}

// ── AudioRejectionReason ──────────────────────────────────────

/// Reason an audio turn was rejected.
///
/// Display strings are machine-readable identifiers matching the
/// `ImageRejectionReason` convention. User-facing messages are
/// constructed at the pipeline call-site (Phase 3).
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AudioRejectionReason {
    #[error("disabled")]
    Disabled,
    #[error("channel_not_allowed")]
    ChannelNotAllowed,
    #[error("fetch_failed")]
    FetchFailed,
    #[error("mime_rejected")]
    MimeRejected,
    #[error("oversize")]
    Oversize,
    #[error("too_long")]
    TooLong,
    #[error("corrupted")]
    Corrupted,
    #[error("transcriber_unavailable")]
    TranscriberUnavailable,
    #[error("transcription_failed")]
    TranscriptionFailed,
    #[error("no_speech_detected")]
    NoSpeechDetected,
    #[error("multiple_audio_parts")]
    MultipleAudioParts,
    #[error("system_error")]
    SystemError,
}

// ── Magic-byte MIME sniffing ──────────────────────────────────

/// Validate audio MIME type by sniffing magic bytes first.
///
/// Magic-byte sniffing takes strict precedence over any declared MIME
/// from the channel (same security policy as `media::validate_mime`).
pub fn validate_audio_mime(
    declared: Option<&str>,
    sniffed_bytes: &[u8],
) -> Result<AllowedAudioMime, AudioRejectionReason> {
    // OGG: bytes 0-3 = "OggS" (0x4F 0x67 0x67 0x53)
    if sniffed_bytes.len() >= 4 && &sniffed_bytes[0..4] == b"OggS" {
        return Ok(AllowedAudioMime::OggOpus);
    }

    // MP3: ID3 tag header (0x49 0x44 0x33)
    if sniffed_bytes.len() >= 3 && &sniffed_bytes[0..3] == b"ID3" {
        return Ok(AllowedAudioMime::Mp3);
    }

    // MP3: MPEG sync word — first byte 0xFF, second byte has top 3 bits
    // set (0xE0 mask), non-zero layer bits (bits 1-2 != 0b00 = reserved),
    // and non-reserved version bits (bits 3-4 != 0b01 = reserved).
    // This excludes ADTS AAC headers and reserved MPEG frames.
    if sniffed_bytes.len() >= 2 && sniffed_bytes[0] == 0xFF {
        let b = sniffed_bytes[1];
        let sync_ok = (b & 0xE0) == 0xE0;
        let layer_ok = (b & 0x06) != 0; // layer bits != reserved (0b00)
        let version_ok = ((b >> 3) & 0x03) != 0x01; // version bits != reserved (0b01)
        if sync_ok && layer_ok && version_ok {
            return Ok(AllowedAudioMime::Mp3);
        }
    }

    // WAV: bytes 0-3 = "RIFF", bytes 8-11 = "WAVE"
    if sniffed_bytes.len() >= 12
        && &sniffed_bytes[0..4] == b"RIFF"
        && &sniffed_bytes[8..12] == b"WAVE"
    {
        return Ok(AllowedAudioMime::Wav);
    }

    // M4A: bytes 4-7 = "ftyp" (ISO base media file format)
    if sniffed_bytes.len() >= 8 && &sniffed_bytes[4..8] == b"ftyp" {
        return Ok(AllowedAudioMime::M4a);
    }

    // Magic bytes didn't match any known audio type.
    // Sniffing takes precedence — declared MIME is ignored for security.
    let _ = declared;
    Err(AudioRejectionReason::MimeRejected)
}

/// Validate that the audio size is within the allowed limit.
pub fn validate_audio_size(byte_len: u64, max_bytes: u64) -> Result<(), AudioRejectionReason> {
    if byte_len > max_bytes {
        Err(AudioRejectionReason::Oversize)
    } else {
        Ok(())
    }
}

/// Validate that the audio duration is within the allowed limit.
pub fn validate_audio_duration(
    duration_secs: u64,
    max_duration_secs: u64,
) -> Result<(), AudioRejectionReason> {
    if duration_secs > max_duration_secs {
        Err(AudioRejectionReason::TooLong)
    } else {
        Ok(())
    }
}

// ── StagedAudio ───────────────────────────────────────────────

/// A validated, staged audio file ready for transcription.
#[derive(Debug, Clone)]
pub struct StagedAudio {
    /// SHA-256 hex digest of the raw audio bytes.
    pub sha256: String,
    /// Validated MIME type from magic-byte sniffing.
    pub mime_type: AllowedAudioMime,
    /// Total byte size of the staged file.
    pub byte_len: u64,
    /// Duration if known (channel-declared or post-transcription).
    pub duration_secs: Option<f64>,
    /// Path to the temp file on disk.
    pub temp_path: PathBuf,
    /// Channel name that sourced the audio.
    pub channel_origin: String,
}

impl StagedAudio {
    /// Best-effort cleanup of the staged temp file.
    pub fn cleanup(&self) {
        if self.temp_path.exists() {
            if let Err(e) = std::fs::remove_file(&self.temp_path) {
                tracing::warn!(
                    "Failed to remove staged audio {}: {e}",
                    self.temp_path.display()
                );
            }
        }
    }
}

// ── AudioHistoryMeta ──────────────────────────────────────────

/// Build audio history metadata from the runtime-only staged audio type.
pub fn build_audio_history_meta_from_staged(
    staged: &StagedAudio,
    transcription: &str,
    caption: Option<&str>,
) -> AudioHistoryMeta {
    AudioHistoryMeta::new(
        staged.mime_type.as_str(),
        staged.sha256.clone(),
        staged.byte_len,
        staged.duration_secs,
        staged.channel_origin.clone(),
        transcription,
        caption.map(String::from),
    )
}

// ── stage_audio_from_bytes ────────────────────────────────────

/// Validate, hash, and atomically stage raw audio bytes to a temp file.
///
/// Shared by every ingress channel (Telegram, gateway, CLI).  The caller
/// supplies the channel-specific size/duration ceilings; this function owns
/// the canonical validation order: empty → size → duration → MIME → SHA-256
/// → temp-file write.
pub async fn stage_audio_from_bytes(
    bytes: &[u8],
    channel_abbrev: &str,
    declared_mime: Option<&str>,
    declared_duration_secs: Option<u64>,
    max_bytes: u64,
    max_duration_secs: u64,
    channel_origin: &str,
) -> Result<StagedAudio, AudioRejectionReason> {
    if bytes.is_empty() {
        return Err(AudioRejectionReason::MimeRejected);
    }
    validate_audio_size(bytes.len() as u64, max_bytes)?;
    if let Some(dur) = declared_duration_secs {
        validate_audio_duration(dur, max_duration_secs)?;
    }
    let mime = validate_audio_mime(declared_mime, bytes)?;
    use sha2::Digest;
    let sha256 = {
        let mut hasher = sha2::Sha256::new();
        hasher.update(bytes);
        hex::encode(hasher.finalize())
    };
    let random_suffix: u64 = {
        use std::collections::hash_map::RandomState;
        use std::hash::{BuildHasher, Hasher};
        let s = RandomState::new();
        let mut h = s.build_hasher();
        h.write(sha256.as_bytes());
        h.finish()
    };
    // Use async file creation to avoid blocking, with atomic rename pattern
    let temp_path = std::env::temp_dir().join(format!(
        "corvus-{channel_abbrev}-aud-{random_suffix:016x}.{}",
        mime.file_extension()
    ));
    let temp_path_async = temp_path.clone();
    let bytes_to_write = bytes.to_vec(); // Copy bytes for async write (owned)

    // Write to a temporary file first, then atomically rename
    if let Err(e) = tokio::fs::write(&temp_path_async, &bytes_to_write).await {
        tracing::warn!(
            "Failed to stage audio to {}: {e}",
            temp_path_async.display()
        );
        // Clean up temp file on write failure
        let _ = tokio::fs::remove_file(&temp_path_async).await;
        return Err(AudioRejectionReason::FetchFailed);
    }

    Ok(StagedAudio {
        sha256,
        mime_type: mime,
        byte_len: bytes.len() as u64,
        duration_secs: declared_duration_secs.map(|d| d as f64),
        temp_path,
        channel_origin: channel_origin.to_string(),
    })
}

// ── Tests ─────────────────────────────────────────────────────

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

    // ── AllowedAudioMime round-trip (Task 2.1) ────────────────

    #[test]
    fn from_mime_str_ogg_variants() {
        assert_eq!(
            AllowedAudioMime::from_mime_str("audio/ogg"),
            Some(AllowedAudioMime::OggOpus)
        );
        assert_eq!(
            AllowedAudioMime::from_mime_str("audio/opus"),
            Some(AllowedAudioMime::OggOpus)
        );
        assert_eq!(
            AllowedAudioMime::from_mime_str("audio/ogg; codecs=opus"),
            Some(AllowedAudioMime::OggOpus)
        );
    }

    #[test]
    fn from_mime_str_mp3_variants() {
        assert_eq!(
            AllowedAudioMime::from_mime_str("audio/mpeg"),
            Some(AllowedAudioMime::Mp3)
        );
        assert_eq!(
            AllowedAudioMime::from_mime_str("audio/mp3"),
            Some(AllowedAudioMime::Mp3)
        );
    }

    #[test]
    fn from_mime_str_wav_variants() {
        assert_eq!(
            AllowedAudioMime::from_mime_str("audio/wav"),
            Some(AllowedAudioMime::Wav)
        );
        assert_eq!(
            AllowedAudioMime::from_mime_str("audio/wave"),
            Some(AllowedAudioMime::Wav)
        );
        assert_eq!(
            AllowedAudioMime::from_mime_str("audio/x-wav"),
            Some(AllowedAudioMime::Wav)
        );
    }

    #[test]
    fn from_mime_str_m4a_variants() {
        assert_eq!(
            AllowedAudioMime::from_mime_str("audio/mp4"),
            Some(AllowedAudioMime::M4a)
        );
        assert_eq!(
            AllowedAudioMime::from_mime_str("audio/m4a"),
            Some(AllowedAudioMime::M4a)
        );
        assert_eq!(
            AllowedAudioMime::from_mime_str("audio/x-m4a"),
            Some(AllowedAudioMime::M4a)
        );
        assert_eq!(
            AllowedAudioMime::from_mime_str("audio/aac"),
            Some(AllowedAudioMime::M4a)
        );
    }

    #[test]
    fn from_mime_str_rejects_unknown() {
        assert_eq!(AllowedAudioMime::from_mime_str("audio/flac"), None);
        assert_eq!(AllowedAudioMime::from_mime_str("image/png"), None);
        assert_eq!(AllowedAudioMime::from_mime_str(""), None);
    }

    #[test]
    fn as_str_round_trip() {
        for mime in [
            AllowedAudioMime::OggOpus,
            AllowedAudioMime::Mp3,
            AllowedAudioMime::Wav,
            AllowedAudioMime::M4a,
        ] {
            let s = mime.as_str();
            assert_eq!(
                AllowedAudioMime::from_mime_str(s),
                Some(mime),
                "round-trip failed for {s}"
            );
        }
    }

    #[test]
    fn file_extension_correct() {
        assert_eq!(AllowedAudioMime::OggOpus.file_extension(), "ogg");
        assert_eq!(AllowedAudioMime::Mp3.file_extension(), "mp3");
        assert_eq!(AllowedAudioMime::Wav.file_extension(), "wav");
        assert_eq!(AllowedAudioMime::M4a.file_extension(), "m4a");
    }

    // ── validate_audio_mime magic bytes (Task 2.2) ────────────

    #[test]
    fn validate_audio_mime_detects_ogg() {
        let bytes = b"OggS\x00\x02\x00\x00\x00\x00\x00\x00";
        assert_eq!(
            validate_audio_mime(None, bytes),
            Ok(AllowedAudioMime::OggOpus)
        );
    }

    #[test]
    fn validate_audio_mime_detects_mp3_id3() {
        let bytes = b"ID3\x04\x00\x00\x00\x00";
        assert_eq!(validate_audio_mime(None, bytes), Ok(AllowedAudioMime::Mp3));
    }

    #[test]
    fn validate_audio_mime_detects_mp3_sync_fb() {
        let bytes = [0xFF, 0xFB, 0x90, 0x00];
        assert_eq!(validate_audio_mime(None, &bytes), Ok(AllowedAudioMime::Mp3));
    }

    #[test]
    fn validate_audio_mime_detects_mp3_sync_f3() {
        let bytes = [0xFF, 0xF3, 0x90, 0x00];
        assert_eq!(validate_audio_mime(None, &bytes), Ok(AllowedAudioMime::Mp3));
    }

    #[test]
    fn validate_audio_mime_detects_mp3_sync_f2() {
        let bytes = [0xFF, 0xF2, 0x90, 0x00];
        assert_eq!(validate_audio_mime(None, &bytes), Ok(AllowedAudioMime::Mp3));
    }

    #[test]
    fn validate_audio_mime_detects_mp3_sync_e2() {
        // 0xE2 has top 3 bits set — valid MPEG sync but was previously rejected
        let bytes = [0xFF, 0xE2, 0x90, 0x00];
        assert_eq!(validate_audio_mime(None, &bytes), Ok(AllowedAudioMime::Mp3));
    }

    #[test]
    fn validate_audio_mime_rejects_reserved_mpeg_layer_bits() {
        // 0xE0 has layer bits = 0b00 (reserved) — must be rejected
        let bytes = [0xFF, 0xE0, 0x00, 0x00];
        assert!(validate_audio_mime(None, &bytes).is_err());
    }

    #[test]
    fn validate_audio_mime_rejects_adts_aac_header() {
        // 0xFF 0xF1 is ADTS AAC (layer bits = 0b00), not MP3
        let bytes = [0xFF, 0xF1, 0x00, 0x00];
        assert!(validate_audio_mime(None, &bytes).is_err());
    }

    #[test]
    fn validate_audio_mime_detects_mp3_layer3() {
        // 0xFF 0xFB = MPEG1 Layer3 (valid MP3)
        let bytes = [0xFF, 0xFB, 0x90, 0x00];
        assert_eq!(validate_audio_mime(None, &bytes), Ok(AllowedAudioMime::Mp3));
    }

    #[test]
    fn validate_audio_mime_rejects_mp3_sync_below_e0() {
        // 0xDF does NOT have top 3 bits set — invalid sync
        let bytes = [0xFF, 0xDF, 0x00, 0x00];
        assert_eq!(
            validate_audio_mime(None, &bytes),
            Err(AudioRejectionReason::MimeRejected)
        );
    }

    #[test]
    fn validate_audio_mime_detects_wav() {
        let mut bytes = vec![0u8; 12];
        bytes[0..4].copy_from_slice(b"RIFF");
        bytes[4..8].copy_from_slice(&[0x24, 0x08, 0x00, 0x00]); // file size
        bytes[8..12].copy_from_slice(b"WAVE");
        assert_eq!(validate_audio_mime(None, &bytes), Ok(AllowedAudioMime::Wav));
    }

    #[test]
    fn validate_audio_mime_detects_m4a() {
        let mut bytes = vec![0u8; 12];
        bytes[0..4].copy_from_slice(&[0x00, 0x00, 0x00, 0x20]); // box size
        bytes[4..8].copy_from_slice(b"ftyp");
        bytes[8..12].copy_from_slice(b"M4A ");
        assert_eq!(validate_audio_mime(None, &bytes), Ok(AllowedAudioMime::M4a));
    }

    #[test]
    fn validate_audio_mime_rejects_unknown_bytes() {
        let bytes = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07];
        assert_eq!(
            validate_audio_mime(Some("audio/ogg"), &bytes),
            Err(AudioRejectionReason::MimeRejected)
        );
    }

    #[test]
    fn validate_audio_mime_rejects_empty_bytes() {
        assert_eq!(
            validate_audio_mime(None, &[]),
            Err(AudioRejectionReason::MimeRejected)
        );
    }

    #[test]
    fn validate_audio_mime_rejects_flac_magic() {
        let bytes = b"fLaC\x00\x00\x00\x22";
        assert_eq!(
            validate_audio_mime(None, bytes),
            Err(AudioRejectionReason::MimeRejected)
        );
    }

    #[test]
    fn validate_audio_mime_rejects_midi() {
        let bytes = b"MThd\x00\x00\x00\x06";
        assert_eq!(
            validate_audio_mime(None, bytes),
            Err(AudioRejectionReason::MimeRejected)
        );
    }

    #[test]
    fn validate_audio_mime_ignores_declared_when_sniff_wins() {
        // Declared as MP3, but magic bytes are OGG
        let bytes = b"OggS\x00\x02\x00\x00\x00\x00\x00\x00";
        assert_eq!(
            validate_audio_mime(Some("audio/mpeg"), bytes),
            Ok(AllowedAudioMime::OggOpus)
        );
    }

    #[test]
    fn validate_audio_mime_ignores_declared_when_sniff_fails() {
        let bytes = [0x47, 0x49, 0x46]; // GIF magic — not audio
        assert_eq!(
            validate_audio_mime(Some("audio/ogg"), &bytes),
            Err(AudioRejectionReason::MimeRejected)
        );
    }

    #[test]
    fn validate_audio_mime_rejects_too_short_bytes() {
        assert_eq!(
            validate_audio_mime(None, &[0xFF]),
            Err(AudioRejectionReason::MimeRejected)
        );
    }

    // ── AudioRejectionReason Display (Task 2.3) ───────────────

    #[test]
    fn rejection_reason_display_strings() {
        assert_eq!(AudioRejectionReason::Disabled.to_string(), "disabled");
        assert_eq!(
            AudioRejectionReason::ChannelNotAllowed.to_string(),
            "channel_not_allowed"
        );
        assert_eq!(
            AudioRejectionReason::FetchFailed.to_string(),
            "fetch_failed"
        );
        assert_eq!(
            AudioRejectionReason::MimeRejected.to_string(),
            "mime_rejected"
        );
        assert_eq!(AudioRejectionReason::Oversize.to_string(), "oversize");
        assert_eq!(AudioRejectionReason::TooLong.to_string(), "too_long");
        assert_eq!(AudioRejectionReason::Corrupted.to_string(), "corrupted");
        assert_eq!(
            AudioRejectionReason::TranscriberUnavailable.to_string(),
            "transcriber_unavailable"
        );
        assert_eq!(
            AudioRejectionReason::TranscriptionFailed.to_string(),
            "transcription_failed"
        );
        assert_eq!(
            AudioRejectionReason::NoSpeechDetected.to_string(),
            "no_speech_detected"
        );
        assert_eq!(
            AudioRejectionReason::MultipleAudioParts.to_string(),
            "multiple_audio_parts"
        );
        assert_eq!(
            AudioRejectionReason::SystemError.to_string(),
            "system_error"
        );
    }

    // ── validate_audio_size ───────────────────────────────────

    #[test]
    fn validate_audio_size_accepts_within_limit() {
        assert!(validate_audio_size(1024, MAX_AUDIO_BYTES).is_ok());
        assert!(validate_audio_size(MAX_AUDIO_BYTES, MAX_AUDIO_BYTES).is_ok());
    }

    #[test]
    fn validate_audio_size_rejects_over_limit() {
        assert_eq!(
            validate_audio_size(MAX_AUDIO_BYTES + 1, MAX_AUDIO_BYTES),
            Err(AudioRejectionReason::Oversize)
        );
    }

    // ── validate_audio_duration ───────────────────────────────

    #[test]
    fn validate_audio_duration_accepts_within_limit() {
        assert!(validate_audio_duration(120, MAX_AUDIO_DURATION_SECS).is_ok());
        assert!(validate_audio_duration(MAX_AUDIO_DURATION_SECS, MAX_AUDIO_DURATION_SECS).is_ok());
    }

    #[test]
    fn validate_audio_duration_rejects_over_limit() {
        assert_eq!(
            validate_audio_duration(MAX_AUDIO_DURATION_SECS + 1, MAX_AUDIO_DURATION_SECS),
            Err(AudioRejectionReason::TooLong)
        );
    }

    // ── StagedAudio cleanup (Task 2.4) ────────────────────────

    #[test]
    fn staged_audio_cleanup_removes_temp_file() {
        let dir = tempfile::tempdir().unwrap();
        let tmp = dir.path().join("test_cleanup.ogg");
        std::fs::write(&tmp, b"fake audio").unwrap();

        let staged = StagedAudio {
            sha256: "abc123".into(),
            mime_type: AllowedAudioMime::OggOpus,
            byte_len: 10,
            duration_secs: Some(5.0),
            temp_path: tmp.clone(),
            channel_origin: "telegram".into(),
        };

        assert!(tmp.exists());
        staged.cleanup();
        assert!(!tmp.exists());
    }

    #[test]
    fn staged_audio_cleanup_noop_missing_file() {
        let staged = StagedAudio {
            sha256: "abc123".into(),
            mime_type: AllowedAudioMime::OggOpus,
            byte_len: 10,
            duration_secs: None,
            temp_path: PathBuf::from("/tmp/nonexistent_audio_test_xyz.ogg"),
            channel_origin: "telegram".into(),
        };
        // Should not panic on missing file
        staged.cleanup();
    }

    // ── AudioHistoryMeta (Task 2.4) ───────────────────────────

    #[test]
    fn audio_history_meta_from_staged() {
        let staged = StagedAudio {
            sha256: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6".into(),
            mime_type: AllowedAudioMime::OggOpus,
            byte_len: 50_000,
            duration_secs: Some(15.0),
            temp_path: PathBuf::from("/tmp/test.ogg"),
            channel_origin: "telegram".into(),
        };

        let meta = build_audio_history_meta_from_staged(&staged, "Hola mundo", Some("caption"));

        assert_eq!(meta.mime, "audio/ogg");
        assert_eq!(meta.sha256, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6");
        assert_eq!(meta.byte_len, 50_000);
        assert_eq!(meta.duration_secs, Some(15.0));
        assert_eq!(meta.channel_origin, "telegram");
        assert_eq!(meta.transcription, "Hola mundo");
        assert_eq!(meta.caption, Some("caption".to_string()));
    }

    #[test]
    fn audio_history_meta_from_staged_no_caption() {
        let staged = StagedAudio {
            sha256: "deadbeef12345678".into(),
            mime_type: AllowedAudioMime::Mp3,
            byte_len: 1024,
            duration_secs: None,
            temp_path: PathBuf::from("/tmp/test.mp3"),
            channel_origin: "telegram".into(),
        };

        let meta = build_audio_history_meta_from_staged(&staged, "Hello world", None);

        assert_eq!(meta.caption, None);
        assert_eq!(meta.transcription, "Hello world");
        assert_eq!(meta.duration_secs, None);
    }

    #[test]
    fn audio_history_meta_to_context_string_with_duration() {
        let meta = AudioHistoryMeta {
            mime: "audio/ogg".into(),
            sha256: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6".into(),
            byte_len: 50_000,
            duration_secs: Some(45.0),
            channel_origin: "telegram".into(),
            transcription: "Hola, ¿cómo estás?".into(),
            caption: None,
        };

        let ctx = meta.to_context_string();
        assert!(ctx.starts_with("[Prior audio: audio/ogg"));
        assert!(ctx.contains(", 45s"));
        assert!(ctx.contains("Transcription: Hola, ¿cómo estás?"));
        assert!(ctx.ends_with(']'));
        // sha256 and byte_len should NOT be in model-facing context
        assert!(!ctx.contains("sha256"));
        assert!(!ctx.contains("50000 bytes"));
    }

    #[test]
    fn audio_history_meta_to_context_string_with_caption() {
        let meta = AudioHistoryMeta {
            mime: "audio/mpeg".into(),
            sha256: "deadbeef12345678".into(),
            byte_len: 1024,
            duration_secs: None,
            channel_origin: "telegram".into(),
            transcription: "Hello world".into(),
            caption: Some("translate this".into()),
        };

        let ctx = meta.to_context_string();
        assert!(ctx.contains("Caption: translate this"));
        assert!(ctx.contains("Transcription: Hello world"));
    }

    #[test]
    fn audio_history_meta_to_context_string_no_duration() {
        let meta = AudioHistoryMeta {
            mime: "audio/mpeg".into(),
            sha256: "deadbeef12345678".into(),
            byte_len: 1024,
            duration_secs: None,
            channel_origin: "telegram".into(),
            transcription: "Hello".into(),
            caption: None,
        };

        let ctx = meta.to_context_string();
        // When duration is None, should go straight to transcription
        assert!(ctx.starts_with("[Prior audio: audio/mpeg"));
        assert!(!ctx.contains("sha256"));
        assert!(!ctx.contains("1024 bytes"));
        assert!(ctx.contains("Transcription: Hello"));
        assert!(ctx.ends_with(']'));
    }

    #[test]
    fn audio_history_meta_to_context_string_truncates_long_transcription() {
        let long_text = "a".repeat(300);
        let meta = AudioHistoryMeta {
            mime: "audio/ogg".into(),
            sha256: "a1b2c3d4e5f6a7b8".into(),
            byte_len: 100,
            duration_secs: Some(10.0),
            channel_origin: "telegram".into(),
            transcription: long_text,
            caption: None,
        };

        let ctx = meta.to_context_string();
        // Transcription should be truncated to 200 chars
        let after_label = ctx.split("Transcription: ").nth(1).unwrap();
        let transcription_part = after_label.trim_end_matches(']');
        assert_eq!(transcription_part.len(), 200);
    }

    // ── stage_audio_from_bytes (T1.5) ────────────────────────

    fn ogg_payload() -> Vec<u8> {
        let mut v = b"OggS\x00\x02\x00\x00\x00\x00\x00\x00".to_vec();
        v.resize(128, 0u8);
        v
    }

    fn mp3_payload() -> Vec<u8> {
        let mut v = b"ID3\x04\x00\x00\x00\x00".to_vec();
        v.resize(128, 0u8);
        v
    }

    #[tokio::test]
    async fn stage_audio_from_bytes_rejects_empty() {
        let result =
            stage_audio_from_bytes(&[], "tg", None, None, 1024 * 1024, 600, "telegram").await;
        assert_eq!(result.unwrap_err(), AudioRejectionReason::MimeRejected);
    }

    #[tokio::test]
    async fn stage_audio_from_bytes_rejects_oversized() {
        let payload = ogg_payload();
        let result = stage_audio_from_bytes(&payload, "tg", None, None, 10, 600, "telegram").await;
        assert_eq!(result.unwrap_err(), AudioRejectionReason::Oversize);
    }

    #[tokio::test]
    async fn stage_audio_from_bytes_rejects_long_duration() {
        let payload = ogg_payload();
        let result = stage_audio_from_bytes(
            &payload,
            "tg",
            None,
            Some(601),
            1024 * 1024,
            600,
            "telegram",
        )
        .await;
        assert_eq!(result.unwrap_err(), AudioRejectionReason::TooLong);
    }

    #[tokio::test]
    async fn stage_audio_from_bytes_rejects_unknown_magic() {
        let payload = vec![0x00u8; 64];
        let result =
            stage_audio_from_bytes(&payload, "tg", None, None, 1024 * 1024, 600, "telegram").await;
        assert_eq!(result.unwrap_err(), AudioRejectionReason::MimeRejected);
    }

    #[tokio::test]
    async fn stage_audio_from_bytes_accepts_ogg() {
        let payload = ogg_payload();
        let audio =
            stage_audio_from_bytes(&payload, "tg", None, None, 1024 * 1024, 600, "telegram")
                .await
                .expect("should accept valid OGG");
        assert_eq!(audio.mime_type, AllowedAudioMime::OggOpus);
        assert_eq!(audio.byte_len, payload.len() as u64);
        let _ = std::fs::remove_file(&audio.temp_path);
    }

    #[tokio::test]
    async fn stage_audio_from_bytes_accepts_mp3_id3() {
        let payload = mp3_payload();
        let audio =
            stage_audio_from_bytes(&payload, "tg", None, None, 1024 * 1024, 600, "telegram")
                .await
                .expect("should accept valid MP3 ID3");
        assert_eq!(audio.mime_type, AllowedAudioMime::Mp3);
        assert_eq!(audio.byte_len, payload.len() as u64);
        let _ = std::fs::remove_file(&audio.temp_path);
    }

    #[tokio::test]
    async fn stage_audio_from_bytes_writes_temp_file() {
        let payload = ogg_payload();
        let audio =
            stage_audio_from_bytes(&payload, "tg", None, None, 1024 * 1024, 600, "telegram")
                .await
                .expect("should succeed");
        assert!(
            audio.temp_path.exists(),
            "temp file must exist after staging"
        );
        let on_disk = std::fs::read(&audio.temp_path).expect("temp file must be readable");
        assert_eq!(on_disk, payload);
        let _ = std::fs::remove_file(&audio.temp_path);
    }

    #[tokio::test]
    async fn stage_audio_from_bytes_sets_channel_origin() {
        let payload = ogg_payload();
        let audio = stage_audio_from_bytes(&payload, "gw", None, None, 1024 * 1024, 600, "gateway")
            .await
            .expect("should succeed");
        assert_eq!(audio.channel_origin, "gateway");
        let _ = std::fs::remove_file(&audio.temp_path);
    }

    #[tokio::test]
    async fn stage_audio_from_bytes_sha256_is_correct() {
        let payload = ogg_payload();
        let audio =
            stage_audio_from_bytes(&payload, "tg", None, None, 1024 * 1024, 600, "telegram")
                .await
                .expect("should succeed");
        assert_eq!(audio.sha256.len(), 64);
        assert!(audio.sha256.chars().all(|c| c.is_ascii_hexdigit()));
        use sha2::Digest;
        let mut hasher = sha2::Sha256::new();
        hasher.update(&payload);
        let expected = hex::encode(hasher.finalize());
        assert_eq!(audio.sha256, expected);
        let _ = std::fs::remove_file(&audio.temp_path);
    }

    // ── stage_audio_from_bytes — additional boundary/edge cases ──────────

    /// Duration exactly at the maximum (equal, not over) must be accepted.
    #[tokio::test]
    async fn stage_audio_from_bytes_duration_exactly_at_max_passes() {
        let payload = ogg_payload();
        let audio = stage_audio_from_bytes(
            &payload,
            "tg",
            None,
            Some(600), // exactly equal to max
            1024 * 1024,
            600,
            "telegram",
        )
        .await
        .expect("duration equal to max should be accepted");
        assert_eq!(audio.duration_secs, Some(600.0));
        let _ = std::fs::remove_file(&audio.temp_path);
    }

    /// When declared_duration_secs is None the duration check is skipped entirely.
    #[tokio::test]
    async fn stage_audio_from_bytes_none_duration_skips_check() {
        let payload = ogg_payload();
        // max_duration_secs is 0 (would normally reject anything), but since
        // declared_duration_secs is None the check must be bypassed.
        let audio = stage_audio_from_bytes(&payload, "tg", None, None, 1024 * 1024, 0, "telegram")
            .await
            .expect("None duration should skip duration check even if max is 0");
        assert!(audio.duration_secs.is_none());
        let _ = std::fs::remove_file(&audio.temp_path);
    }

    /// A minimal valid WAV file must be accepted and yield Wav MIME type.
    #[tokio::test]
    async fn stage_audio_from_bytes_accepts_wav() {
        // Minimal WAV: RIFF magic + WAVE marker (44-byte PCM header with 0 data).
        let mut wav: Vec<u8> = Vec::with_capacity(44);
        wav.extend_from_slice(b"RIFF");
        wav.extend_from_slice(&36u32.to_le_bytes());
        wav.extend_from_slice(b"WAVE");
        wav.extend_from_slice(b"fmt ");
        wav.extend_from_slice(&16u32.to_le_bytes());
        wav.extend_from_slice(&1u16.to_le_bytes()); // PCM
        wav.extend_from_slice(&1u16.to_le_bytes()); // mono
        wav.extend_from_slice(&16000u32.to_le_bytes());
        wav.extend_from_slice(&32000u32.to_le_bytes());
        wav.extend_from_slice(&2u16.to_le_bytes());
        wav.extend_from_slice(&16u16.to_le_bytes());
        wav.extend_from_slice(b"data");
        wav.extend_from_slice(&0u32.to_le_bytes());

        let audio = stage_audio_from_bytes(&wav, "gw", None, None, 1024 * 1024, 600, "gateway")
            .await
            .expect("valid WAV should be accepted");
        assert_eq!(audio.mime_type, AllowedAudioMime::Wav);
        assert_eq!(audio.channel_origin, "gateway");
        assert_eq!(audio.byte_len, wav.len() as u64);
        let _ = std::fs::remove_file(&audio.temp_path);
    }

    /// Two identical payloads must produce the same SHA-256 (deterministic).
    #[tokio::test]
    async fn stage_audio_from_bytes_sha256_is_deterministic() {
        let payload = ogg_payload();
        let a1 = stage_audio_from_bytes(&payload, "tg", None, None, 1024 * 1024, 600, "telegram")
            .await
            .expect("first staging should succeed");
        let a2 = stage_audio_from_bytes(&payload, "tg", None, None, 1024 * 1024, 600, "telegram")
            .await
            .expect("second staging should succeed");
        assert_eq!(
            a1.sha256, a2.sha256,
            "same content must produce same SHA-256"
        );
        let _ = std::fs::remove_file(&a1.temp_path);
        let _ = std::fs::remove_file(&a2.temp_path);
    }

    /// channel_abbrev appears in the temp file name (helps debugging).
    #[tokio::test]
    async fn stage_audio_from_bytes_channel_abbrev_in_temp_path() {
        let payload = ogg_payload();
        let audio =
            stage_audio_from_bytes(&payload, "myabbrev", None, None, 1024 * 1024, 600, "test")
                .await
                .expect("should succeed");
        let file_name = audio
            .temp_path
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("");
        assert!(
            file_name.contains("myabbrev"),
            "temp file name should contain the channel abbreviation; got: {file_name}"
        );
        let _ = std::fs::remove_file(&audio.temp_path);
    }
}
