use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, TryRecvError};

use bone_document::Document;
use bone_interop::{CancelFlag, StepError};
use bone_types::StepSchema;

const PROGRESS_DIALOG_THRESHOLD_BYTES: u64 = 1_000_000;

pub struct JobMeta {
    cancel: Arc<AtomicBool>,
    pub file_name: String,
    pub show_progress: bool,
}

impl JobMeta {
    pub fn request_cancel(&self) {
        self.cancel.store(true, Ordering::Relaxed);
    }

    #[must_use]
    pub fn cancel_requested(&self) -> bool {
        self.cancel.load(Ordering::Relaxed)
    }
}

#[derive(Debug)]
pub enum JobResult<T> {
    Finished(T),
    Failed(StepError),
    WorkerLost,
}

pub enum StepJob {
    Import {
        rx: Receiver<Result<Box<Document>, StepError>>,
        baseline: Box<Document>,
        meta: JobMeta,
    },
    Export {
        rx: Receiver<Result<(), StepError>>,
        meta: JobMeta,
    },
}

impl StepJob {
    #[must_use]
    pub fn meta(&self) -> &JobMeta {
        match self {
            Self::Import { meta, .. } | Self::Export { meta, .. } => meta,
        }
    }
}

pub fn poll<T>(rx: &Receiver<Result<T, StepError>>) -> std::task::Poll<JobResult<T>> {
    match rx.try_recv() {
        Ok(Ok(value)) => std::task::Poll::Ready(JobResult::Finished(value)),
        Ok(Err(e)) => std::task::Poll::Ready(JobResult::Failed(e)),
        Err(TryRecvError::Empty) => std::task::Poll::Pending,
        Err(TryRecvError::Disconnected) => std::task::Poll::Ready(JobResult::WorkerLost),
    }
}

#[derive(Debug, thiserror::Error)]
pub enum SpawnError {
    #[error("step worker thread failed to start: {0}")]
    Thread(std::io::Error),
}

pub fn spawn_import(path: PathBuf, baseline: Document) -> Result<StepJob, SpawnError> {
    let show_progress =
        std::fs::metadata(&path).is_ok_and(|m| m.len() > PROGRESS_DIALOG_THRESHOLD_BYTES);
    let (rx, meta) = spawn(display_name(&path), show_progress, move |cancel| {
        bone_interop::read(&path, cancel).map(Box::new)
    })?;
    Ok(StepJob::Import {
        rx,
        baseline: Box::new(baseline),
        meta,
    })
}

pub fn spawn_export(document: Document, path: PathBuf) -> Result<StepJob, SpawnError> {
    let (rx, meta) = spawn(display_name(&path), false, move |cancel| {
        bone_interop::write(&document, &path, StepSchema::Ap214, cancel)
    })?;
    Ok(StepJob::Export { rx, meta })
}

fn display_name(path: &Path) -> String {
    path.file_name()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_default()
}

fn spawn<T: Send + 'static>(
    file_name: String,
    show_progress: bool,
    work: impl FnOnce(CancelFlag<'_>) -> Result<T, StepError> + Send + 'static,
) -> Result<(Receiver<Result<T, StepError>>, JobMeta), SpawnError> {
    let cancel = Arc::new(AtomicBool::new(false));
    let cancel_for_worker = Arc::clone(&cancel);
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::Builder::new()
        .name("bone-step-job".to_owned())
        .spawn(move || {
            let result = work(CancelFlag::new(cancel_for_worker.as_ref()));
            let _ = tx.send(result);
        })
        .map_err(SpawnError::Thread)?;
    Ok((
        rx,
        JobMeta {
            cancel,
            file_name,
            show_progress,
        },
    ))
}

#[cfg(test)]
mod tests {
    use super::{JobResult, StepJob, poll, spawn_export, spawn_import};
    use bone_interop::StepError;
    use std::path::PathBuf;
    use std::sync::mpsc::Receiver;

    fn wait<T>(rx: &Receiver<Result<T, StepError>>) -> JobResult<T> {
        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
        loop {
            match poll(rx) {
                std::task::Poll::Ready(result) => return result,
                std::task::Poll::Pending => {
                    assert!(std::time::Instant::now() < deadline, "step job timed out");
                    std::thread::yield_now();
                }
            }
        }
    }

    #[test]
    fn import_of_missing_file_reports_failure() {
        let baseline =
            bone_document::Document::new(bone_types::DocumentId::default(), "scallop".to_owned());
        let job = match spawn_import(PathBuf::from("/nonexistent/scallop.step"), baseline) {
            Ok(job) => job,
            Err(e) => panic!("spawn import: {e}"),
        };
        let StepJob::Import { rx, .. } = job else {
            panic!("spawn_import yields an import job");
        };
        assert!(matches!(wait(&rx), JobResult::Failed(StepError::Io { .. })));
    }

    #[test]
    fn export_failure_leaves_no_step_file() {
        let dir =
            std::env::temp_dir().join(format!("bone-step-job-export-fail-{}", std::process::id()));
        if let Err(e) = std::fs::create_dir_all(&dir) {
            panic!("temp dir: {e}");
        }
        let target = dir.join("nautilus.step");
        let document =
            bone_document::Document::new(bone_types::DocumentId::default(), "nautilus".to_owned());
        let job = match spawn_export(document, target.clone()) {
            Ok(job) => job,
            Err(e) => panic!("spawn export: {e}"),
        };
        let StepJob::Export { rx, .. } = job else {
            panic!("spawn_export yields an export job");
        };
        match wait(&rx) {
            JobResult::Failed(_) => {}
            other => panic!("empty document export must fail, got {other:?}"),
        }
        assert!(!target.exists());
        if let Err(e) = std::fs::remove_dir_all(&dir) {
            panic!("temp dir cleanup: {e}");
        }
    }

    #[test]
    fn worker_panic_surfaces_as_worker_lost() {
        let (rx, _meta) = match super::spawn("limpet.step".to_owned(), false, |_| {
            panic!("worker dies before reporting")
        }) {
            Ok(spawned) => spawned,
            Err(e) => panic!("spawn: {e}"),
        };
        assert!(matches!(wait::<()>(&rx), JobResult::WorkerLost));
    }
}
