//! Upload I/O building blocks: `PendingUpload` DTO, atomic sidecar write,
//! pending-dir scan.
//!
//! No network logic here — the actual `POST /api/upload` lives in the
//! `server_sync` worker. This module is only about persistence on the
//! client's disk queue:
//!
//! - `{stem}.m4a`  — the recorded audio (written by the recorder)
//! - `{stem}.meta.json` — the sidecar with `case_id` + `recorded_at`
//!
//! A pair of both is a ready-to-upload work item; singletons are orphans
//! that `pending_cleanup` reaps.

use std::path::{Path, PathBuf};

use doctate_common::ack::AckStatus;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::warn;
use uuid::Uuid;

/// A recording waiting to be uploaded. The `file` path is derived from the
/// sidecar's on-disk location, not from the JSON content itself — so the
/// sidecar stays valid if the pending directory is moved/renamed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingUpload {
    pub case_id: Uuid,
    pub recorded_at: String,
    #[serde(skip, default)]
    pub file: PathBuf,
}

/// Lifecycle events for a single upload attempt. Emitted by the worker,
/// consumed by the UI.
#[derive(Debug)]
pub enum UploadEvent {
    Started(Uuid),
    Succeeded {
        case_id: Uuid,
        status: AckStatus,
    },
    Failed {
        case_id: Uuid,
        reason: String,
        will_retry: bool,
    },
}

#[derive(Debug, Error)]
pub enum UploadIoError {
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),
    #[error("serialize sidecar: {0}")]
    Serialize(#[from] serde_json::Error),
}

/// Sidecar path next to a `.m4a` file: same directory, same stem, `.meta.json`.
pub fn sidecar_path_for(m4a: &Path) -> PathBuf {
    let stem = m4a
        .file_stem()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_default();
    let dir = m4a.parent().unwrap_or_else(|| Path::new("."));
    dir.join(format!("{stem}.meta.json"))
}

/// Persist a pending upload's metadata next to its m4a. Atomic: writes a
/// `*.tmp` sibling and renames it into place. A crash mid-write cannot
/// leave a zero-byte or partial sidecar on disk — `scan_pending_dir`
/// would otherwise skip the pair as an unreadable orphan.
pub fn write_sidecar(upload: &PendingUpload) -> Result<(), UploadIoError> {
    let sidecar = sidecar_path_for(&upload.file);
    if let Some(parent) = sidecar.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let json = serde_json::to_string_pretty(upload)?;
    let tmp = sidecar.with_extension("json.tmp");
    std::fs::write(&tmp, json)?;
    std::fs::rename(&tmp, &sidecar)?;
    Ok(())
}

/// Scan a pending directory for `.m4a` files with matching sidecars.
/// Orphans (no sidecar, unreadable sidecar) are logged and skipped — one
/// broken pair should not stop recovery of the rest.
pub fn scan_pending_dir(pending_dir: &Path) -> Vec<PendingUpload> {
    let entries = match std::fs::read_dir(pending_dir) {
        Ok(e) => e,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
        Err(e) => {
            warn!(dir = ?pending_dir, error = %e, "scan pending dir failed");
            return Vec::new();
        }
    };
    let mut out = Vec::new();
    for entry in entries.flatten() {
        let path = entry.path();
        if path.extension().and_then(|s| s.to_str()) != Some("m4a") {
            continue;
        }
        match read_pending(&path) {
            Ok(upload) => out.push(upload),
            Err(e) => warn!(path = ?path, error = %e, "skipping pending entry"),
        }
    }
    out
}

fn read_pending(m4a: &Path) -> Result<PendingUpload, UploadIoError> {
    let sidecar = sidecar_path_for(m4a);
    let json = std::fs::read_to_string(&sidecar)?;
    let mut upload: PendingUpload = serde_json::from_str(&json)?;
    upload.file = m4a.to_path_buf();
    Ok(upload)
}

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

    fn sample(dir: &Path, stem: &str) -> PendingUpload {
        let file = dir.join(format!("{stem}.m4a"));
        std::fs::write(&file, b"dummy audio").unwrap();
        PendingUpload {
            case_id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
            recorded_at: "2026-04-15T10:32:00Z".into(),
            file,
        }
    }

    #[test]
    fn sidecar_roundtrip_via_scan() {
        let tmp = TempDir::new().unwrap();
        let upload = sample(tmp.path(), "case_a");
        write_sidecar(&upload).unwrap();
        let found = scan_pending_dir(tmp.path());
        assert_eq!(found.len(), 1);
        assert_eq!(found[0].case_id, upload.case_id);
        assert_eq!(found[0].recorded_at, upload.recorded_at);
        assert_eq!(found[0].file, upload.file);
    }

    #[test]
    fn scan_ignores_m4a_without_sidecar() {
        let tmp = TempDir::new().unwrap();
        std::fs::write(tmp.path().join("orphan.m4a"), b"x").unwrap();
        let found = scan_pending_dir(tmp.path());
        assert!(found.is_empty());
    }

    #[test]
    fn scan_returns_empty_for_missing_dir() {
        let tmp = TempDir::new().unwrap();
        let missing = tmp.path().join("does-not-exist");
        assert!(scan_pending_dir(&missing).is_empty());
    }

    #[test]
    fn write_sidecar_leaves_no_tmp_artefact() {
        let tmp = TempDir::new().unwrap();
        let upload = sample(tmp.path(), "case_b");
        write_sidecar(&upload).unwrap();

        let sidecar = sidecar_path_for(&upload.file);
        assert!(sidecar.exists(), "sidecar must be at final path");
        let tmp_path = sidecar.with_extension("json.tmp");
        assert!(!tmp_path.exists(), "tmp artefact must not remain");
    }

    #[test]
    fn scan_returns_pairs_in_arbitrary_order_but_all_present() {
        let tmp = TempDir::new().unwrap();
        for stem in &["case_1", "case_2", "case_3"] {
            let upload = sample(tmp.path(), stem);
            write_sidecar(&upload).unwrap();
        }
        let found = scan_pending_dir(tmp.path());
        assert_eq!(found.len(), 3);
    }
}
