//! Python bindings for the [`ingredients`] crate
//!
//! This file contains a thin [`pyo3`] wrapper around the public API of the
//! [`ingredients`] crate, which is used to implement the actual Python API.
//!
//! This crate has no public API and is only supposed to be built as a Python
//! extension module.

use once_cell::sync::OnceCell;

use tokio::runtime::{Builder, Runtime};

static RUNTIME: OnceCell<Runtime> = OnceCell::new();

/// Initialize and spin up tokio runtime on-demand
fn tokio_rt() -> std::io::Result<Runtime> {
    // the "io" and "time" features are required by reqwest and tokio::process
    Builder::new_current_thread().enable_io().enable_time().build()
}

#[pyo3::pymodule]
#[pyo3(name = "_rust")]
mod crate_ingredients {
    use std::collections::HashMap;

    use pyo3::exceptions::{PyException, PyOSError, PyValueError};
    use pyo3::{IntoPyObjectExt, prelude::*};

    use ingredients::{Crate, Diff, DiffItem, Report, ReportItem, ReportOptions};

    // this exact function signature is expected
    #[expect(clippy::unnecessary_wraps)]
    #[pymodule_init]
    fn init(_m: &Bound<'_, PyModule>) -> PyResult<()> {
        pyo3_log::init();
        Ok(())
    }

    #[pyclass(frozen)]
    struct RustCrate {
        inner: Crate,
    }

    #[pymethods]
    impl RustCrate {
        fn name(&self) -> &str {
            self.inner.name()
        }

        fn version(&self) -> String {
            self.inner.version()
        }

        #[staticmethod]
        fn download(py: Python, name: &str, version: &str) -> PyResult<RustCrate> {
            let rt = super::RUNTIME.get_or_try_init(super::tokio_rt)?;
            match rt.block_on(Crate::download(name, version)) {
                Ok(krate) => Ok(RustCrate { inner: krate }),
                Err(err) => Err(into_py_err(err, py)?),
            }
        }

        #[staticmethod]
        fn local(py: Python, name: &str, version: &str, path: &str) -> PyResult<RustCrate> {
            match Crate::local(name, version, path) {
                Ok(krate) => Ok(RustCrate { inner: krate }),
                Err(err) => Err(into_py_err(err, py)?),
            }
        }

        #[pyo3(signature = (options=None))]
        fn report(&self, py: Python, options: Option<&Bound<'_, RustReportOptions>>) -> PyResult<RustReport> {
            let rt = super::RUNTIME.get_or_try_init(super::tokio_rt)?;
            let result = if let Some(opts) = options {
                rt.block_on(self.inner.report_with_options(opts.as_unbound().get().inner.clone()))
            } else {
                rt.block_on(self.inner.report())
            };

            match result {
                Ok(report) => Ok(RustReport { inner: report }),
                Err(err) => Err(into_py_err(err, py)?),
            }
        }

        fn diff(&self, py: Python, other: &RustCrate) -> PyResult<RustDiff> {
            let result = self.inner.diff(&other.inner);

            match result {
                Ok(diff) => Ok(RustDiff { inner: diff }),
                Err(err) => Err(into_py_err(err, py)?),
            }
        }
    }

    #[pyclass(frozen)]
    struct RustReport {
        inner: Report,
    }

    #[pymethods]
    impl RustReport {
        fn items(&self) -> Vec<RustReportItem> {
            self.inner
                .items()
                .iter()
                .map(|inner| RustReportItem { inner: inner.clone() })
                .collect()
        }
    }

    #[pyclass(frozen)]
    struct RustDiff {
        inner: Diff,
    }

    #[pymethods]
    impl RustDiff {
        fn items(&self) -> Vec<RustDiffItem> {
            self.inner
                .items()
                .iter()
                .map(|inner| RustDiffItem { inner: inner.clone() })
                .collect()
        }
    }

    #[pyclass(frozen)]
    struct RustReportOptions {
        inner: ReportOptions,
    }

    #[pymethods]
    impl RustReportOptions {
        #[staticmethod]
        #[pyo3(signature = (repository=None, path_in_vcs=None, vcs_ref=None))]
        fn from_options(
            repository: Option<String>,
            path_in_vcs: Option<String>,
            vcs_ref: Option<String>,
        ) -> RustReportOptions {
            let mut inner = ReportOptions::default();
            inner.repository = repository;
            inner.path_in_vcs = path_in_vcs;
            inner.vcs_ref = vcs_ref;

            RustReportOptions { inner }
        }

        const fn is_empty(&self) -> bool {
            self.inner.is_empty()
        }
    }

    #[pyclass(frozen)]
    struct RustReportItem {
        inner: ReportItem,
    }

    #[pymethods]
    impl RustReportItem {
        fn severity(&self) -> String {
            self.inner.severity().to_string()
        }

        fn kind(&self) -> String {
            String::from(self.inner.kind())
        }

        fn message(&self) -> String {
            String::from(self.inner.message())
        }

        fn extra(&self) -> Option<String> {
            self.inner.extra()
        }

        fn data(&self, py: Python) -> PyResult<Py<PyAny>> {
            json_to_dict(py, self.inner.data())
        }
    }

    #[pyclass(frozen)]
    struct RustDiffItem {
        inner: DiffItem,
    }

    #[pymethods]
    impl RustDiffItem {
        fn severity(&self) -> String {
            self.inner.severity().to_string()
        }

        fn kind(&self) -> String {
            String::from(self.inner.kind())
        }

        fn message(&self) -> String {
            String::from(self.inner.message())
        }

        fn extra(&self) -> Option<String> {
            self.inner.extra()
        }

        fn data(&self, py: Python) -> PyResult<Py<PyAny>> {
            json_to_dict(py, self.inner.data())
        }
    }

    // https://stackoverflow.com/questions/79299476
    #[pyclass(extends=PyException)]
    struct SubprocessError {
        #[pyo3(get)]
        cmd: String,
        #[pyo3(get)]
        stdout: String,
        #[pyo3(get)]
        stderr: String,
    }

    impl<'py> IntoPyObject<'py> for SubprocessError {
        type Target = PyAny;
        type Output = Bound<'py, Self::Target>;
        type Error = PyErr;

        fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
            py.get_type::<SubprocessError>()
                .call1((self.cmd, self.stdout, self.stderr))
        }
    }

    #[pymethods]
    impl SubprocessError {
        #[new]
        const fn new(cmd: String, stdout: String, stderr: String) -> Self {
            SubprocessError { cmd, stdout, stderr }
        }

        fn __repr__(&self) -> String {
            format!(
                "SubprocessError(cmd=\"{}\", stdout={:?}, stderr={:?})",
                self.cmd, self.stdout, self.stderr
            )
        }

        fn __str__(&self) -> String {
            format!("SubprocessError ({})\n{}\n{}", self.cmd, self.stdout, self.stderr)
        }
    }

    impl SubprocessError {
        fn into_py_err(self, py: Python) -> PyResult<PyErr> {
            let obj = self.into_pyobject(py)?;
            Ok(PyErr::from_value(obj.into_any()))
        }
    }

    fn into_py_err(err: ingredients::Error, py: Python) -> PyResult<PyErr> {
        use ingredients::Error;

        Ok(match err {
            // SubprocessError
            Error::Subprocess { cmd, stdout, stderr } => SubprocessError::new(cmd, stdout, stderr).into_py_err(py)?,

            // IOError / OSError
            Error::Download { .. } | Error::IO { .. } | Error::Walk { .. } => PyOSError::new_err(err.to_string()),

            // ValueError
            Error::CrateNotFound { .. }
            | Error::VersionNotFound { .. }
            | Error::InvalidRepoUrl { .. }
            | Error::InvalidGitRef { .. }
            | Error::PathNotDeterminable { .. }
            | Error::Metadata { .. }
            | Error::VcsInfo { .. } => PyValueError::new_err(err.to_string()),
        })
    }

    fn json_to_dict(py: Python, value: serde_json::Value) -> PyResult<Py<PyAny>> {
        match value {
            serde_json::Value::Null => Ok(py.None()),
            serde_json::Value::Bool(b) => b.into_py_any(py),
            serde_json::Value::Number(_) => Err(PyValueError::new_err(
                "Converting JSON numbers to Python is not implemented.",
            )),
            serde_json::Value::String(s) => s.into_py_any(py),
            serde_json::Value::Array(a) => {
                let mut l = Vec::new();
                for v in a {
                    l.push(json_to_dict(py, v)?);
                }
                l.into_py_any(py)
            },
            serde_json::Value::Object(o) => {
                let mut m = HashMap::new();
                for (k, v) in o {
                    m.insert(k, json_to_dict(py, v)?);
                }
                m.into_py_any(py)
            },
        }
    }
}
