use std::path::Path;
use std::time::Duration;

use doctate_common::join_url;
use reqwest::multipart::{Form, Part};
use tracing::debug;

#[derive(Debug)]
pub enum WhisperError {
    Io(std::io::Error),
    Http(reqwest::Error),
    Status { status: u16, body: String },
}

impl std::fmt::Display for WhisperError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Io(e) => write!(f, "whisper io error: {e}"),
            Self::Http(e) => write!(f, "whisper http error: {e}"),
            Self::Status { status, body } => {
                write!(f, "whisper returned {status}: {body}")
            }
        }
    }
}

impl std::error::Error for WhisperError {}

impl WhisperError {
    /// Classify this error as transient (retry will likely succeed later) or
    /// permanent (retrying the same input will hit the same wall).
    ///
    /// Rationale: the transcribe worker uses this to decide whether to leave
    /// the `.m4a` in place (page-load heal re-enqueues it) or rename it to
    /// `.m4a.failed` (terminal; only a manual reset un-fails it).
    ///
    /// - `Http`: reqwest wraps all transport issues (DNS, connect, TLS, read,
    ///   timeout, mid-flight reset) — every one of these is a candidate for
    ///   „Minerva is back in a minute", so treat as transient.
    /// - `Status`: 5xx is server-side trouble; 408 (Request Timeout) and 429
    ///   (Too Many Requests) are explicit retry signals. Everything else in
    ///   the 4xx range (400 Bad Request, 415 Unsupported Media, ...) means
    ///   the payload itself is the problem and replays will fail identically.
    /// - `Io`: local filesystem error while reading the audio file. Either
    ///   the file is gone or the disk is misbehaving; a retry of the same
    ///   path is not the right response.
    pub fn is_transient(&self) -> bool {
        match self {
            Self::Http(_) => true,
            Self::Status { status, .. } => {
                *status == 408 || *status == 429 || (500..=599).contains(status)
            }
            Self::Io(_) => false,
        }
    }
}

/// POST the audio file to whisper-asr-webservice and return the transcript text.
///
/// `output=txt` → plain-text response. `language` pins the language so the
/// model skips detection (defaults to `de` when the caller passes `None` or
/// an empty string). We send AAC-in-mp4 and let the service decode it
/// internally (the default `encode=true`) — passing `encode=false` would
/// tell the service "this is already raw PCM", which ours isn't.
pub async fn transcribe(
    client: &reqwest::Client,
    whisper_url: &str,
    audio: &Path,
    timeout: Duration,
    language: Option<&str>,
) -> Result<String, WhisperError> {
    let filename = audio
        .file_name()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_else(|| "audio.m4a".to_string());

    let bytes = tokio::fs::read(audio).await.map_err(WhisperError::Io)?;

    let part = Part::bytes(bytes)
        .file_name(filename)
        .mime_str("audio/mp4")
        .map_err(WhisperError::Http)?;
    let form = Form::new().part("audio_file", part);

    let language = language.filter(|s| !s.is_empty()).unwrap_or("de");
    let url = format!(
        "{}?output=txt&language={language}",
        join_url(whisper_url, "/asr")
    );
    debug!(%url, "posting to whisper");

    let response = client
        .post(&url)
        .timeout(timeout)
        .multipart(form)
        .send()
        .await
        .map_err(WhisperError::Http)?;

    let status = response.status();
    let body = response.text().await.map_err(WhisperError::Http)?;

    if !status.is_success() {
        return Err(WhisperError::Status {
            status: status.as_u16(),
            body,
        });
    }

    Ok(body)
}

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

    /// Table-driven classification test. `Http` variant is not publicly
    /// constructible (reqwest owns its error ctor), so the transient-Http
    /// path is covered by the integration test in
    /// `tests/transient_failure_retries_test.rs`. Same code path is exercised
    /// here via a 503 Status, which also routes to `is_transient == true`.
    #[test]
    fn is_transient_classifies_status_codes() {
        let cases: &[(u16, bool)] = &[
            (400, false),
            (401, false),
            (403, false),
            (404, false),
            (408, true),
            (415, false),
            (422, false),
            (429, true),
            (500, true),
            (502, true),
            (503, true),
            (504, true),
        ];
        for &(status, expected) in cases {
            let err = WhisperError::Status {
                status,
                body: String::new(),
            };
            assert_eq!(
                err.is_transient(),
                expected,
                "status {status} expected is_transient={expected}"
            );
        }
    }

    #[test]
    fn is_transient_io_is_permanent() {
        let err = WhisperError::Io(std::io::Error::other("disk gone"));
        assert!(!err.is_transient());
    }
}
