//! Distributed execution adapters
//!
//! Provides [`LocalWorkQueue`](distributed::LocalWorkQueue) (in-process, for single-node and testing) and
//! [`DistributedDagExecutor`](distributed::DistributedDagExecutor) (wraps any [`WorkQueuePort`](crate::ports::work_queue::WorkQueuePort) to distribute DAG
//! waves across workers).
//!
//! # Design
//!
//! ```text
//! DistributedDagExecutor
//!    │
//!    ├─ resolve wave N (topological sort already done by DagExecutor)
//!    ├─ enqueue every node in the wave as a WorkTask
//!    ├─ spawn worker tasks that call try_dequeue + service.execute
//!    └─ collect_results when all tasks in wave are Completed
//! ```

use crate::domain::error::{Result, ServiceError, StygianError};
use crate::ports::work_queue::{TaskStatus, WorkQueuePort, WorkTask};
use crate::ports::{ScrapingService, ServiceInput};
use async_trait::async_trait;
use dashmap::DashMap;
use std::collections::VecDeque;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{debug, error, info, warn};

// ─────────────────────────────────────────────────────────────────────────────
// LocalWorkQueue
// ─────────────────────────────────────────────────────────────────────────────

/// In-memory work queue for single-node deployments and unit tests.
///
/// All state is stored in `Arc`-wrapped structures so the queue can be cheaply
/// cloned and shared across worker tasks.
///
/// # Example
///
/// ```
/// use stygian_graph::adapters::distributed::LocalWorkQueue;
/// use stygian_graph::ports::work_queue::{WorkQueuePort, WorkTask};
/// use serde_json::json;
///
/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
/// let queue = LocalWorkQueue::new();
/// assert_eq!(queue.pending_count().await.unwrap(), 0);
///
/// let task = WorkTask {
///     id: "t-1".to_string(),
///     pipeline_id: "p-1".to_string(),
///     node_name: "fetch".to_string(),
///     input: json!({"url": "https://example.com"}),
///     wave: 0,
///     attempt: 0,
///     idempotency_key: "ik-t1".to_string(),
/// };
/// queue.enqueue(task).await.unwrap();
/// assert_eq!(queue.pending_count().await.unwrap(), 1);
///
/// let dequeued = queue.try_dequeue().await.unwrap().unwrap();
/// assert_eq!(dequeued.node_name, "fetch");
/// # });
/// ```
#[derive(Clone)]
pub struct LocalWorkQueue {
    pending: Arc<Mutex<VecDeque<WorkTask>>>,
    state: Arc<DashMap<String, TaskStatus>>,
    /// Max retries before a task moves to the dead-letter state
    max_retries: u32,
}

impl LocalWorkQueue {
    /// Create a new `LocalWorkQueue` with default settings (`max_retries = 3`).
    pub fn new() -> Self {
        Self {
            pending: Arc::new(Mutex::new(VecDeque::new())),
            state: Arc::new(DashMap::new()),
            max_retries: 3,
        }
    }

    /// Create a `LocalWorkQueue` with a custom retry limit.
    ///
    /// # Example
    ///
    /// ```
    /// use stygian_graph::adapters::distributed::LocalWorkQueue;
    ///
    /// let queue = LocalWorkQueue::with_max_retries(5);
    /// ```
    pub fn with_max_retries(max_retries: u32) -> Self {
        Self {
            pending: Arc::new(Mutex::new(VecDeque::new())),
            state: Arc::new(DashMap::new()),
            max_retries,
        }
    }
}

impl Default for LocalWorkQueue {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl WorkQueuePort for LocalWorkQueue {
    async fn enqueue(&self, task: WorkTask) -> Result<()> {
        debug!(task_id = %task.id, node = %task.node_name, "enqueuing task");
        self.state.insert(task.id.clone(), TaskStatus::Pending);
        self.pending.lock().await.push_back(task);
        Ok(())
    }

    async fn try_dequeue(&self) -> Result<Option<WorkTask>> {
        let task = self.pending.lock().await.pop_front();
        if let Some(ref t) = task {
            debug!(task_id = %t.id, "dequeued task");
            self.state.insert(
                t.id.clone(),
                TaskStatus::InProgress {
                    worker_id: "local".to_string(),
                },
            );
        }
        Ok(task)
    }

    async fn acknowledge(&self, task_id: &str, output: serde_json::Value) -> Result<()> {
        info!(task_id = %task_id, "task acknowledged (completed)");
        self.state
            .insert(task_id.to_string(), TaskStatus::Completed { output });
        Ok(())
    }

    async fn fail(&self, task_id: &str, error: &str) -> Result<()> {
        let attempt = self
            .state
            .get(task_id)
            .map_or(0, |status| match status.value() {
                TaskStatus::Failed { attempt, .. } => *attempt,
                _ => 0,
            });

        if attempt >= self.max_retries {
            warn!(task_id = %task_id, %error, "task dead-lettered after max retries");
            self.state.insert(
                task_id.to_string(),
                TaskStatus::DeadLetter {
                    error: error.to_string(),
                },
            );
        } else {
            error!(task_id = %task_id, attempt, %error, "task failed, will retry");
            self.state.insert(
                task_id.to_string(),
                TaskStatus::Failed {
                    error: error.to_string(),
                    attempt: attempt + 1,
                },
            );
        }
        Ok(())
    }

    async fn status(&self, task_id: &str) -> Result<Option<TaskStatus>> {
        Ok(self.state.get(task_id).map(|s| s.value().clone()))
    }

    async fn collect_results(&self, pipeline_id: &str) -> Result<Vec<(String, serde_json::Value)>> {
        // We need to find tasks by pipeline_id — the state map is keyed by
        // task_id so we collect all Completed entries whose pipeline_id matches.
        // LocalWorkQueue stores the task in the pending queue; once dequeued
        // we lose the pipeline_id mapping. We use a secondary index maintained
        // in the pipeline_tasks map instead.
        //
        // For simplicity in the local adapter, we scan all state entries and
        // match on pipeline_id encoded in the task_id prefix convention
        // "pipeline_id::node_name::task_id".
        let mut results = Vec::new();
        for entry in self.state.iter() {
            let key = entry.key();
            // Convention: task_id == "{pipeline_id}::{node_name}::{ulid}"
            if !key.starts_with(pipeline_id) {
                continue;
            }
            if let TaskStatus::Completed { ref output } = *entry.value() {
                // Extract node_name from the middle segment
                let node_name = key.split("::").nth(1).unwrap_or(key).to_string();
                results.push((node_name, output.clone()));
            }
        }
        Ok(results)
    }

    async fn pending_count(&self) -> Result<usize> {
        Ok(self.pending.lock().await.len())
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// DistributedDagExecutor
// ─────────────────────────────────────────────────────────────────────────────

/// Executes a DAG wave using a [`WorkQueuePort`] to distribute node-level tasks
/// across workers.
///
/// Workers are spawned as Tokio tasks that pull from the queue, call the
/// appropriate service, and acknowledge results.  For local development the
/// [`LocalWorkQueue`] is used; in production any queue backend can be plugged
/// in without changing this executor.
///
/// # Example
///
/// ```
/// use stygian_graph::adapters::distributed::{DistributedDagExecutor, LocalWorkQueue};
/// use stygian_graph::ports::work_queue::WorkTask;
///
/// use stygian_graph::adapters::noop::NoopService;
/// use serde_json::json;
/// use std::sync::Arc;
/// use std::collections::HashMap;
///
/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
/// let queue = Arc::new(LocalWorkQueue::new());
/// let executor = DistributedDagExecutor::new(queue, 4);
///
/// let mut services: HashMap<String, Arc<dyn stygian_graph::ports::ScrapingService>> =
///     HashMap::new();
/// services.insert("noop".to_string(), Arc::new(NoopService));
///
/// let tasks = vec![WorkTask {
///     id: "p1::fetch::01".to_string(),
///     pipeline_id: "p1".to_string(),
///     node_name: "fetch".to_string(),
///     input: json!({"url": "https://example.com"}),
///     wave: 0,
///     attempt: 0,
///     idempotency_key: "ik-01".to_string(),
/// }];
///
/// let results = executor.execute_wave("p1", tasks, &services).await.unwrap();
/// assert!(!results.is_empty() || results.is_empty()); // noop returns empty
/// # });
/// ```
pub struct DistributedDagExecutor<Q: WorkQueuePort> {
    queue: Arc<Q>,
    worker_concurrency: usize,
}

impl<Q: WorkQueuePort + 'static> DistributedDagExecutor<Q> {
    /// Create a new executor with the given work queue and worker concurrency.
    ///
    /// `worker_concurrency` controls how many parallel worker tasks drain the
    /// queue.
    pub fn new(queue: Arc<Q>, worker_concurrency: usize) -> Self {
        Self {
            queue,
            worker_concurrency: worker_concurrency.max(1),
        }
    }

    /// Execute a single wave of tasks, distributing them across workers.
    ///
    /// Returns `(node_name, output)` pairs for all tasks in the wave.
    pub async fn execute_wave(
        &self,
        pipeline_id: &str,
        tasks: Vec<WorkTask>,
        services: &std::collections::HashMap<String, Arc<dyn ScrapingService>>,
    ) -> Result<Vec<(String, serde_json::Value)>> {
        let expected = tasks.len();
        if expected == 0 {
            return Ok(Vec::new());
        }

        // Enqueue all tasks in this wave
        for task in tasks {
            self.queue.enqueue(task).await?;
        }

        // Spawn workers to drain the queue
        let queue = Arc::clone(&self.queue);
        let services: Arc<std::collections::HashMap<String, Arc<dyn ScrapingService>>> =
            Arc::new(services.clone());

        let concurrency = self.worker_concurrency.min(expected);
        let mut handles = tokio::task::JoinSet::new();

        for _ in 0..concurrency {
            let q = Arc::clone(&queue);
            let svcs = Arc::clone(&services);
            handles.spawn(async move {
                // Each worker drains the queue until it finds nothing
                let mut worked = 0usize;
                loop {
                    match q.try_dequeue().await {
                        Ok(Some(task)) => {
                            let service_input = ServiceInput {
                                url: task
                                    .input
                                    .get("url")
                                    .and_then(serde_json::Value::as_str)
                                    .unwrap_or("")
                                    .to_string(),
                                params: task.input.clone(),
                            };
                            let output = match svcs.get(&task.node_name) {
                                Some(svc) => svc.execute(service_input.clone()).await,
                                None => {
                                    // Fallback: look for a service named "default"
                                    match svcs.get("default") {
                                        Some(svc) => svc.execute(service_input).await,
                                        None => Err(StygianError::Service(
                                            ServiceError::Unavailable(format!(
                                                "service '{}' not registered",
                                                task.node_name
                                            )),
                                        )),
                                    }
                                }
                            };
                            match output {
                                Ok(out) => {
                                    let val = serde_json::json!({
                                        "data": out.data,
                                        "metadata": out.metadata,
                                    });
                                    let _ = q.acknowledge(&task.id, val).await;
                                }
                                Err(e) => {
                                    let _ = q.fail(&task.id, &e.to_string()).await;
                                }
                            }
                            worked += 1;
                        }
                        Ok(None) => break, // queue empty
                        Err(e) => {
                            error!(error = %e, "worker dequeue error");
                            break;
                        }
                    }
                }
                worked
            });
        }

        // Wait for all workers
        while handles.join_next().await.is_some() {}

        // Collect results
        self.queue.collect_results(pipeline_id).await
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
mod tests {
    use super::*;
    use serde_json::json;

    fn make_task(pipeline_id: &str, node_name: &str, seq: u32) -> WorkTask {
        WorkTask {
            id: format!("{pipeline_id}::{node_name}::{seq:04}"),
            pipeline_id: pipeline_id.to_string(),
            node_name: node_name.to_string(),
            input: json!({"url": "https://example.com"}),
            wave: 0,
            attempt: 0,
            idempotency_key: format!("ik-{seq}"),
        }
    }

    #[tokio::test]
    async fn enqueue_dequeue_roundtrip() {
        let queue = LocalWorkQueue::new();
        assert_eq!(queue.pending_count().await.unwrap(), 0);

        queue.enqueue(make_task("p1", "fetch", 1)).await.unwrap();
        queue.enqueue(make_task("p1", "parse", 2)).await.unwrap();
        assert_eq!(queue.pending_count().await.unwrap(), 2);

        let t1 = queue.try_dequeue().await.unwrap().unwrap();
        assert_eq!(t1.node_name, "fetch");
        assert_eq!(queue.pending_count().await.unwrap(), 1);

        let t2 = queue.try_dequeue().await.unwrap().unwrap();
        assert_eq!(t2.node_name, "parse");
        assert_eq!(queue.pending_count().await.unwrap(), 0);

        // Queue empty — returns None
        let empty = queue.try_dequeue().await.unwrap();
        assert!(empty.is_none());
    }

    #[tokio::test]
    async fn acknowledge_records_completed_status() {
        let queue = LocalWorkQueue::new();
        queue.enqueue(make_task("p1", "fetch", 1)).await.unwrap();
        let task = queue.try_dequeue().await.unwrap().unwrap();
        queue
            .acknowledge(&task.id, json!({"data": "hello", "status": 200}))
            .await
            .unwrap();

        let status = queue.status(&task.id).await.unwrap().unwrap();
        assert!(matches!(status, TaskStatus::Completed { .. }));
    }

    #[tokio::test]
    async fn fail_dead_letters_after_max_retries() {
        let queue = LocalWorkQueue::with_max_retries(2);
        queue.enqueue(make_task("p1", "fetch", 1)).await.unwrap();
        let task = queue.try_dequeue().await.unwrap().unwrap();

        queue.fail(&task.id, "err 1").await.unwrap();
        queue.fail(&task.id, "err 2").await.unwrap();
        // attempt 2 == max_retries → dead-letter
        queue.fail(&task.id, "err 3").await.unwrap();

        let status = queue.status(&task.id).await.unwrap().unwrap();
        assert!(matches!(status, TaskStatus::DeadLetter { .. }));
    }

    #[tokio::test]
    async fn collect_results_filters_by_pipeline_id() {
        let queue = LocalWorkQueue::new();

        // Two pipelines, one task each
        let t1 = make_task("pipeline-A", "node1", 1);
        let t2 = make_task("pipeline-B", "node1", 2);

        queue.enqueue(t1.clone()).await.unwrap();
        queue.enqueue(t2.clone()).await.unwrap();

        // Both dequeued and acknowledged
        let deq1 = queue.try_dequeue().await.unwrap().unwrap();
        let deq2 = queue.try_dequeue().await.unwrap().unwrap();

        queue
            .acknowledge(&deq1.id, json!({"data": "A-result"}))
            .await
            .unwrap();
        queue
            .acknowledge(&deq2.id, json!({"data": "B-result"}))
            .await
            .unwrap();

        let results_a = queue.collect_results("pipeline-A").await.unwrap();
        assert_eq!(results_a.len(), 1);
        assert_eq!(results_a[0].1["data"], "A-result");

        let results_b = queue.collect_results("pipeline-B").await.unwrap();
        assert_eq!(results_b.len(), 1);
        assert_eq!(results_b[0].1["data"], "B-result");
    }

    #[tokio::test]
    async fn distributed_executor_runs_tasks() {
        use crate::adapters::noop::NoopService;
        use std::collections::HashMap;

        let queue = Arc::new(LocalWorkQueue::new());
        let executor = DistributedDagExecutor::new(Arc::clone(&queue), 2);

        let mut services: HashMap<String, Arc<dyn ScrapingService>> = HashMap::new();
        services.insert("noop".to_string(), Arc::new(NoopService));

        let tasks = vec![
            make_task("p1", "noop", 1),
            make_task("p1", "noop", 2),
            make_task("p1", "noop", 3),
        ];

        // Execute wave — NoopService returns empty data, so results may be empty
        // but the call must succeed without panic/error
        let results = executor.execute_wave("p1", tasks, &services).await.unwrap();
        // 3 tasks were acknowledged; results will contain completed ones
        assert!(results.len() <= 3);
    }
}
