//! PyO3 façade — exposes `MizanClient` to Python as `PyMizanClient`.
//!
//! Same kernel, same wire. The Python wrapper that the codegen emits
//! adds typed methods on top of this client (Pydantic in / Pydantic
//! out); this module's job is the GIL boundary plus the async-to-sync
//! bridge.
//!
//! Architecture:
//! - One tokio multi-thread runtime owned by the `PyMizanClient`.
//! - `call` / `fetch_context` use `py.allow_threads(|| rt.block_on(...))`
//!   so the GIL is released across the network round-trip.
//! - `subscribe_context` spawns a tokio task that owns a watch
//!   receiver; on each change the task acquires the GIL via
//!   `Python::with_gil` and fires the Python callback. The returned
//!   `CancellationToken` (wrapped as `PyContextSubscription`) lets
//!   Python cancel the watcher.

use std::sync::Arc;

use pyo3::prelude::*;
use pyo3::types::{PyDict};
use pythonize::{depythonize, pythonize};
use serde_json::Value;
use tokio::runtime::Runtime;
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;

use crate::client::{MizanClient, MizanConfig};
use crate::context::{ContextStateRaw, ContextStatus};


#[pyclass]
pub struct PyMizanClient {
    inner: Arc<MizanClient>,
    rt: Arc<Runtime>,
}


#[pyclass]
pub struct PyContextSubscription {
    cancel: CancellationToken,
}


#[pymethods]
impl PyContextSubscription {
    fn cancel(&self) {
        self.cancel.cancel();
    }
}


#[pymethods]
impl PyMizanClient {
    #[new]
    #[pyo3(signature = (base_url, *, session = false, csrf_cookie_name = String::from("csrftoken"), csrf_header_name = String::from("X-CSRFToken")))]
    fn new(
        base_url: String,
        session: bool,
        csrf_cookie_name: String,
        csrf_header_name: String,
    ) -> PyResult<Self> {
        let rt = Runtime::new()
            .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("tokio runtime: {e}")))?;
        let config = MizanConfig {
            base_url,
            session,
            csrf_cookie_name,
            csrf_header_name,
            extra_headers: Vec::new(),
        };
        Ok(Self {
            inner: MizanClient::new(config),
            rt: Arc::new(rt),
        })
    }

    /// Invoke a mutation or plain function. `args` is a Python dict (or
    /// any pythonize-compatible object). Returns the unwrapped `result`
    /// from the server response as a Python object.
    fn call(&self, py: Python<'_>, fn_name: String, args: &Bound<'_, PyDict>) -> PyResult<PyObject> {
        let args_value: Value = depythonize(args.as_any())
            .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("args: {e}")))?;
        let inner = Arc::clone(&self.inner);
        let result: Value = py.allow_threads(|| {
            self.rt.block_on(async move { inner.call(&fn_name, args_value).await })
        })
        .map_err(mizan_err_to_py)?;
        pythonize(py, &result)
            .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("encode result: {e}")))
            .map(|bound| bound.unbind())
    }

    /// One-shot context fetch (does not register a subscription).
    fn fetch_context(&self, py: Python<'_>, name: String, params: &Bound<'_, PyDict>) -> PyResult<PyObject> {
        let params_value: Value = depythonize(params.as_any())
            .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("params: {e}")))?;
        let inner = Arc::clone(&self.inner);
        let result: Value = py.allow_threads(|| {
            self.rt.block_on(async move { inner.fetch_context(&name, &params_value).await })
        })
        .map_err(mizan_err_to_py)?;
        pythonize(py, &result)
            .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("encode result: {e}")))
            .map(|bound| bound.unbind())
    }

    /// Register a subscription. The kernel owns the fetch lifecycle;
    /// `callback` is invoked from the watcher task once per state
    /// change. Returns a handle whose `.cancel()` ends the subscription.
    ///
    /// The callback receives a dict with keys: `data`, `status`,
    /// `error`. Status is one of `"idle"`, `"loading"`, `"success"`,
    /// `"error"`. Error is a dict with `code`, `message`, `status`,
    /// `details` (or None).
    #[pyo3(signature = (name, params, callback))]
    fn subscribe_context(
        &self,
        py: Python<'_>,
        name: String,
        params: &Bound<'_, PyDict>,
        callback: PyObject,
    ) -> PyResult<PyContextSubscription> {
        let params_value: Value = depythonize(params.as_any())
            .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("params: {e}")))?;

        // Build a serde-friendly fetch closure that delegates to the
        // kernel's `fetch_context` (which itself runs the typed HTTP
        // pipeline). The subscription's refetches go through this.
        let inner_for_fetch = Arc::clone(&self.inner);
        let name_for_fetch = name.clone();
        let params_for_fetch = params_value.clone();
        let fetch_fn = Arc::new(move || {
            let inner = Arc::clone(&inner_for_fetch);
            let name = name_for_fetch.clone();
            let params = params_for_fetch.clone();
            Box::pin(async move { inner.fetch_context(&name, &params).await })
                as std::pin::Pin<Box<dyn std::future::Future<Output = _> + Send + 'static>>
        });

        let inner = Arc::clone(&self.inner);
        let handle = py.allow_threads(|| {
            self.rt.block_on(async move {
                inner.register_context(name.clone(), params_value, fetch_fn).await
            })
        });
        let cancel = handle.cancel_token();
        let cancel_for_task = cancel.clone();
        let callback = Arc::new(callback);
        let callback_for_task = Arc::clone(&callback);
        // Drive an initial refetch before destructuring so the first
        // state lands without requiring the caller to invalidate.
        handle.refetch();
        let rx: watch::Receiver<ContextStateRaw> = handle.rx;

        self.rt.spawn(async move {
            let mut rx = rx;
            loop {
                tokio::select! {
                    _ = cancel_for_task.cancelled() => break,
                    res = rx.changed() => {
                        if res.is_err() { break; }
                        let snapshot = rx.borrow_and_update().clone();
                        Python::with_gil(|py| {
                            let dict = match state_to_pydict(py, &snapshot) {
                                Ok(d) => d,
                                Err(e) => { eprintln!("[pyo3_bridge] encode state: {e}"); return; }
                            };
                            if let Err(e) = callback_for_task.call1(py, (dict,)) {
                                eprintln!("[pyo3_bridge] callback raised: {e}");
                            }
                        });
                    }
                }
            }
        });

        Ok(PyContextSubscription { cancel })
    }

    /// Schedule a broad invalidation.
    fn invalidate(&self, py: Python<'_>, name: String) {
        let inner = Arc::clone(&self.inner);
        py.allow_threads(|| {
            self.rt.block_on(async move { inner.invalidate(name).await })
        });
    }

    /// Schedule a scoped invalidation.
    fn invalidate_scoped(&self, py: Python<'_>, name: String, params: &Bound<'_, PyDict>) -> PyResult<()> {
        let params_value: Value = depythonize(params.as_any())
            .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("params: {e}")))?;
        let inner = Arc::clone(&self.inner);
        py.allow_threads(|| {
            self.rt.block_on(async move { inner.invalidate_scoped(name, params_value).await })
        });
        Ok(())
    }
}


fn state_to_pydict<'py>(py: Python<'py>, state: &ContextStateRaw) -> PyResult<Bound<'py, PyDict>> {
    let dict = PyDict::new_bound(py);
    let status = match state.status {
        ContextStatus::Idle => "idle",
        ContextStatus::Loading => "loading",
        ContextStatus::Success => "success",
        ContextStatus::Error => "error",
    };
    dict.set_item("status", status)?;
    match &state.data {
        Some(v) => {
            let obj = pythonize(py, v)
                .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("encode state.data: {e}")))?;
            dict.set_item("data", obj)?;
        }
        None => dict.set_item("data", py.None())?,
    }
    match &state.error {
        Some(err) => {
            let err_dict = PyDict::new_bound(py);
            err_dict.set_item("status", err.status)?;
            err_dict.set_item("code", &err.code)?;
            err_dict.set_item("message", &err.message)?;
            if let Some(details) = &err.details {
                let obj = pythonize(py, details)
                    .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("encode error.details: {e}")))?;
                err_dict.set_item("details", obj)?;
            } else {
                err_dict.set_item("details", py.None())?;
            }
            dict.set_item("error", err_dict)?;
        }
        None => dict.set_item("error", py.None())?,
    }
    Ok(dict)
}


fn mizan_err_to_py(err: crate::MizanError) -> PyErr {
    PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{err}"))
}


/// Python extension module entry point. Wheels built via `maturin
/// develop --features pyo3` import the module as `mizan_rust`.
#[pymodule]
fn mizan_rust(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_class::<PyMizanClient>()?;
    m.add_class::<PyContextSubscription>()?;
    Ok(())
}
