use std::io::SeekFrom;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use axum::body::Body;
use axum::extract::{Path as AxumPath, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::Response;
use doctate_common::TranscriptState;
use doctate_common::timestamp::filename_stem_to_recorded_at;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio::task::JoinSet;

use crate::config::Config;
use crate::error::AppError;
use crate::paths;
use crate::transcribe::ffmpeg;

pub(crate) struct RecordingView {
    pub(crate) filename: String,
    /// RFC3339 UTC timestamp derived from the filename stem. Empty only
    /// on malformed filenames, which should not occur in production.
    pub(crate) recorded_at_iso: String,
    /// Container duration in whole seconds, read from the `<stem>.json`
    /// sidecar's `duration_seconds` field. `None` means the worker
    /// hasn't finished yet (no sidecar) and the in-flight lazy ffprobe
    /// could not produce one either — the UI then falls back to
    /// browser `preload="metadata"`.
    pub(crate) duration_seconds: Option<u32>,
    /// Three-way transcript state — `Pending` (not yet transcribed),
    /// `Silent` (transcriber classified as silence), or `Content(text)`.
    /// Consumers must not collapse `Silent` and `Content` onto the same
    /// "is there text?" branch — use [`TranscriptState::has_file`] for
    /// "transcription finished" checks and [`TranscriptState::as_content`]
    /// for actual text lookup.
    pub(crate) transcript: TranscriptState,
    /// True if the file has been renamed to `<ts>.m4a.failed` by the worker
    /// after a non-recoverable ffmpeg or whisper error.
    pub(crate) failed: bool,
    /// Replay-gain in dB to apply on the client. Pulled from
    /// `<stem>.json`'s `loudness.gain_db`. `None` means no gain — pre-
    /// loudness recordings, silent files (`-inf` measurement), or
    /// recordings whose analyze step failed. The template renders this
    /// as `data-gain-db` on the player and the JS multiplies the audio
    /// signal accordingly via a Web Audio `GainNode`.
    pub(crate) gain_db: Option<f64>,
}

pub async fn handle_audio(
    State(config): State<Arc<Config>>,
    AxumPath((user, case_id, filename)): AxumPath<(String, String, String)>,
    headers: HeaderMap,
) -> Result<Response, AppError> {
    validate_user_slug(&user)?;
    uuid::Uuid::parse_str(&case_id)
        .map_err(|_| AppError::BadRequest("Invalid case_id (not a UUID)".into()))?;
    validate_filename(&filename)?;

    let case_path = crate::paths::case_dir(&config.data_path, &user, &case_id);
    if crate::paths::is_closed(&case_path).await {
        return Err(AppError::NotFound("Audio file not found".into()));
    }
    let file_path = case_path.join(&filename);
    let metadata = match tokio::fs::metadata(&file_path).await {
        Ok(m) => m,
        Err(_) => return Err(AppError::NotFound("Audio file not found".into())),
    };
    let file_size = metadata.len();

    // Range-conditional path — Chrome and friends send `Range: bytes=X-Y` after
    // `audio.currentTime = …`, and if we respond with the full file they treat
    // the seek as failed and reset currentTime to 0.
    if let Some(range_str) = headers.get(header::RANGE).and_then(|v| v.to_str().ok()) {
        return match parse_range(range_str, file_size) {
            Some((start, end)) => serve_range(&file_path, start, end, file_size).await,
            None => Response::builder()
                .status(StatusCode::RANGE_NOT_SATISFIABLE)
                .header(header::CONTENT_RANGE, format!("bytes */{file_size}"))
                .body(Body::empty())
                .map_err(|e| AppError::Internal(e.to_string())),
        };
    }

    let bytes = tokio::fs::read(&file_path).await?;
    Response::builder()
        .header(header::CONTENT_TYPE, "audio/mp4")
        .header(header::ACCEPT_RANGES, "bytes")
        .header(header::CONTENT_LENGTH, file_size)
        .body(Body::from(bytes))
        .map_err(|e| AppError::Internal(e.to_string()))
}

async fn serve_range(
    file_path: &Path,
    start: u64,
    end: u64,
    file_size: u64,
) -> Result<Response, AppError> {
    let length = end - start + 1;
    let mut file = tokio::fs::File::open(file_path).await?;
    file.seek(SeekFrom::Start(start)).await?;
    let mut buf = vec![0u8; length as usize];
    file.read_exact(&mut buf).await?;
    Response::builder()
        .status(StatusCode::PARTIAL_CONTENT)
        .header(header::CONTENT_TYPE, "audio/mp4")
        .header(header::ACCEPT_RANGES, "bytes")
        .header(header::CONTENT_LENGTH, length)
        .header(
            header::CONTENT_RANGE,
            format!("bytes {start}-{end}/{file_size}"),
        )
        .body(Body::from(buf))
        .map_err(|e| AppError::Internal(e.to_string()))
}

/// Parse a single-range `Range: bytes=...` header against the given file size.
/// Returns `(start, end_inclusive)` or `None` for unsatisfiable / unsupported
/// forms. Multi-range requests take the first range only — Multipart responses
/// aren't worth the complexity for dictation audio.
fn parse_range(header: &str, file_size: u64) -> Option<(u64, u64)> {
    if file_size == 0 {
        return None;
    }
    let spec = header.strip_prefix("bytes=")?;
    let first = spec.split(',').next()?.trim();
    let (start_str, end_str) = first.split_once('-')?;
    if start_str.is_empty() {
        // "-N" → last N bytes
        let suffix: u64 = end_str.parse().ok()?;
        if suffix == 0 {
            return None;
        }
        let start = file_size.saturating_sub(suffix);
        return Some((start, file_size - 1));
    }
    let start: u64 = start_str.parse().ok()?;
    if start >= file_size {
        return None;
    }
    let end: u64 = if end_str.is_empty() {
        file_size - 1
    } else {
        end_str.parse::<u64>().ok()?.min(file_size - 1)
    };
    if start > end {
        return None;
    }
    Some((start, end))
}

fn validate_user_slug(user: &str) -> Result<(), AppError> {
    if user.is_empty() || user.contains('/') || user.contains('\\') || user.contains("..") {
        return Err(AppError::BadRequest("Invalid user slug".into()));
    }
    Ok(())
}

pub(crate) fn validate_filename(filename: &str) -> Result<(), AppError> {
    let ok_suffix = filename.ends_with(".m4a") || filename.ends_with(".m4a.failed");
    if !ok_suffix || filename.contains('/') || filename.contains('\\') || filename.contains("..") {
        return Err(AppError::BadRequest("Invalid filename".into()));
    }
    Ok(())
}

/// Extract the timestamp stem (`YYYY-MM-DDTHH-MM-SSZ`) from a recording
/// filename of the form `<stem>.m4a[.failed]`.
fn stem_of(filename: &str) -> &str {
    filename
        .trim_end_matches(".failed")
        .trim_end_matches(".m4a")
}

/// One entry in progress between the directory enumeration and the
/// in-flight ffprobe backfill. The audio path is kept around so the
/// spawned task can probe directly without re-deriving the location.
struct RawRecording {
    filename: String,
    failed: bool,
    transcript: TranscriptState,
    duration_seconds: Option<u32>,
    gain_db: Option<f64>,
    audio_path: PathBuf,
}

pub(crate) async fn scan_recordings(case_dir: &Path) -> Vec<RecordingView> {
    let mut entries = match tokio::fs::read_dir(case_dir).await {
        Ok(r) => r,
        Err(_) => return Vec::new(),
    };

    let mut raws: Vec<RawRecording> = Vec::new();
    while let Ok(Some(entry)) = entries.next_entry().await {
        let filename = match entry.file_name().into_string() {
            Ok(s) => s,
            Err(_) => continue,
        };
        let failed = filename.ends_with(".m4a.failed");
        let is_audio = filename.ends_with(".m4a") || failed;
        if !is_audio {
            continue;
        }
        // Transcript and duration both come from the per-recording
        // metadata sidecar; a `.failed` file never produced one, but
        // the lookup still works (returns None → Pending state).
        let stem_file = filename.trim_end_matches(".failed");
        let stem_path = case_dir.join(stem_file);
        let meta = paths::read_recording_meta(&stem_path).await;
        let duration_seconds = meta.as_ref().and_then(|m| m.duration_seconds);
        let gain_db = meta
            .as_ref()
            .and_then(|m| m.loudness.as_ref())
            .map(|l| l.gain_db);
        let transcript = TranscriptState::from_meta(meta.as_ref());

        let audio_path = case_dir.join(&filename);
        raws.push(RawRecording {
            filename,
            failed,
            transcript,
            duration_seconds,
            gain_db,
            audio_path,
        });
    }

    // Read-only lazy backfill: while the transcribe worker is still in
    // flight (no sidecar yet), run ffprobe in parallel for the UI render
    // so the recordings list shows a duration during the transcription
    // window. The result is *not* persisted — the worker is the single
    // source of truth for `<stem>.json` and writes both transcript and
    // duration in one atomic step at the end of its pipeline.
    // `.failed` entries are skipped: ffprobe on a corrupt file is
    // unlikely to succeed and the UI doesn't need a duration there.
    let mut js: JoinSet<Option<(usize, u32)>> = JoinSet::new();
    for (idx, r) in raws.iter().enumerate() {
        if r.duration_seconds.is_some() || r.failed {
            continue;
        }
        let audio_path = r.audio_path.clone();
        js.spawn(async move {
            let secs = ffmpeg::probe_duration_seconds(&audio_path).await.ok()?;
            Some((idx, secs))
        });
    }
    while let Some(res) = js.join_next().await {
        if let Ok(Some((idx, secs))) = res
            && let Some(raw) = raws.get_mut(idx)
        {
            raw.duration_seconds = Some(secs);
        }
    }

    let mut recordings: Vec<RecordingView> = raws
        .into_iter()
        .map(|r| {
            let recorded_at_iso = filename_stem_to_recorded_at(stem_of(&r.filename));
            RecordingView {
                filename: r.filename,
                recorded_at_iso,
                duration_seconds: r.duration_seconds,
                transcript: r.transcript,
                failed: r.failed,
                gain_db: r.gain_db,
            }
        })
        .collect();

    recordings.sort_by(|a, b| a.filename.cmp(&b.filename));
    recordings
}

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

    #[tokio::test]
    async fn scan_reads_meta_sidecar_and_derives_iso() {
        let dir = tempdir().unwrap();
        tokio::fs::write(dir.path().join("2026-04-19T10-00-00Z.m4a"), b"audio")
            .await
            .unwrap();
        crate::paths::write_recording_meta_sync(
            dir.path(),
            "2026-04-19T10-00-00Z",
            doctate_common::Transcript::Content {
                text: "hallo".into(),
            },
            Some(42),
        );

        let got = scan_recordings(dir.path()).await;
        assert_eq!(got.len(), 1);
        assert_eq!(got[0].filename, "2026-04-19T10-00-00Z.m4a");
        assert_eq!(got[0].recorded_at_iso, "2026-04-19T10:00:00Z");
        assert_eq!(got[0].duration_seconds, Some(42));
        assert_eq!(got[0].transcript.as_content(), Some("hallo"));
        assert!(!got[0].failed);
    }

    #[tokio::test]
    async fn scan_handles_failed_suffix() {
        let dir = tempdir().unwrap();
        tokio::fs::write(dir.path().join("2026-04-19T11-00-00Z.m4a.failed"), b"x")
            .await
            .unwrap();
        let got = scan_recordings(dir.path()).await;
        assert_eq!(got.len(), 1);
        assert!(got[0].failed);
        assert_eq!(got[0].recorded_at_iso, "2026-04-19T11:00:00Z");
        assert!(got[0].duration_seconds.is_none());
    }

    #[test]
    fn parse_range_explicit_bounds() {
        assert_eq!(parse_range("bytes=0-99", 1000), Some((0, 99)));
        assert_eq!(parse_range("bytes=500-599", 1000), Some((500, 599)));
    }

    #[test]
    fn parse_range_open_end_clamped_to_size() {
        assert_eq!(parse_range("bytes=100-", 1000), Some((100, 999)));
        assert_eq!(parse_range("bytes=0-99999", 1000), Some((0, 999)));
    }

    #[test]
    fn parse_range_suffix_last_n_bytes() {
        assert_eq!(parse_range("bytes=-50", 1000), Some((950, 999)));
        assert_eq!(parse_range("bytes=-2000", 1000), Some((0, 999)));
    }

    #[test]
    fn parse_range_rejects_out_of_bounds_start() {
        assert!(parse_range("bytes=1000-", 1000).is_none());
        assert!(parse_range("bytes=2000-3000", 1000).is_none());
    }

    #[test]
    fn parse_range_rejects_malformed() {
        assert!(parse_range("items=0-99", 1000).is_none());
        assert!(parse_range("bytes=abc-99", 1000).is_none());
        assert!(parse_range("bytes=-0", 1000).is_none());
    }

    /// Recording with a `loudness` block in its sidecar surfaces
    /// `gain_db` on the `RecordingView`. Seeding a full
    /// `RecordingMeta` directly via serde_json instead of going
    /// through `write_recording_meta_sync`, since that helper does
    /// not (yet) carry a loudness parameter.
    #[tokio::test]
    async fn scan_reads_gain_db_from_loudness() {
        let dir = tempdir().unwrap();
        let stem = "2026-04-19T13-00-00Z";
        tokio::fs::write(dir.path().join(format!("{stem}.m4a")), b"audio")
            .await
            .unwrap();
        let meta = doctate_common::RecordingMeta {
            transcript: doctate_common::Transcript::Content { text: "hi".into() },
            duration_seconds: Some(30),
            loudness: Some(doctate_common::Loudness {
                mean_db: -22.0,
                max_db: -15.0,
                gain_db: 6.0,
            }),
        };
        let bytes = serde_json::to_vec(&meta).unwrap();
        tokio::fs::write(dir.path().join(format!("{stem}.json")), bytes)
            .await
            .unwrap();

        let got = scan_recordings(dir.path()).await;
        assert_eq!(got.len(), 1);
        assert_eq!(got[0].gain_db, Some(6.0));
        assert_eq!(got[0].duration_seconds, Some(30));
    }

    /// Pre-loudness sidecars (worker wrote meta before this feature
    /// existed) carry no `loudness` field, deserialize as `None`
    /// (thanks to `#[serde(default)]`), and surface `gain_db: None`
    /// on the view. The frontend then renders `data-gain-db="0"` and
    /// the player skips the Web-Audio routing.
    #[tokio::test]
    async fn scan_yields_none_gain_db_for_meta_without_loudness() {
        let dir = tempdir().unwrap();
        tokio::fs::write(dir.path().join("2026-04-19T14-00-00Z.m4a"), b"audio")
            .await
            .unwrap();
        crate::paths::write_recording_meta_sync(
            dir.path(),
            "2026-04-19T14-00-00Z",
            doctate_common::Transcript::Silent,
            Some(7),
        );

        let got = scan_recordings(dir.path()).await;
        assert_eq!(got.len(), 1);
        assert_eq!(got[0].gain_db, None);
    }

    #[tokio::test]
    async fn scan_handles_malformed_meta_sidecar() {
        let dir = tempdir().unwrap();
        tokio::fs::write(dir.path().join("2026-04-19T12-00-00Z.m4a"), b"audio")
            .await
            .unwrap();
        // Garbage JSON: read_recording_meta logs a warning and returns
        // None, so the recording surfaces as Pending with no duration.
        // The lazy ffprobe backfill would normally produce a duration,
        // but ffprobe on the placeholder bytes fails — leaving None,
        // exactly what we want to assert.
        tokio::fs::write(dir.path().join("2026-04-19T12-00-00Z.json"), "not-a-json")
            .await
            .unwrap();
        let got = scan_recordings(dir.path()).await;
        assert_eq!(got.len(), 1);
        assert!(got[0].duration_seconds.is_none());
        assert!(got[0].transcript.is_pending());
    }
}
