use crate::graph::{CycleAnalysis, Status, Task, WorkGraph};
use chrono::{DateTime, Utc};
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::Path;

/// Check if a task is past its not_before and ready_after timestamps (or has no timestamps),
/// and if cron-enabled, whether it is due to fire.
pub fn is_time_ready(task: &Task) -> bool {
    let now = Utc::now();

    // Check not_before
    if let Some(timestamp) = &task.not_before
        && let Ok(not_before) = timestamp.parse::<DateTime<Utc>>()
        && now < not_before
    {
        return false;
    }
    // Invalid timestamp = treat as ready (don't block)

    // Check ready_after (set by loop edges with delays)
    if let Some(timestamp) = &task.ready_after
        && let Ok(ready_after) = timestamp.parse::<DateTime<Utc>>()
        && now < ready_after
    {
        return false;
    }
    // Invalid timestamp = treat as ready (don't block)

    // Cron gate: if cron-enabled, only ready when due
    if task.cron_enabled && !crate::cron::is_cron_due(task, now) {
        return false;
    }

    true
}

/// Summary of project status
#[derive(Debug, Clone, Serialize)]
pub struct ProjectSummary {
    pub open: usize,
    pub done: usize,
    pub in_progress: usize,
    pub ready: usize,
    pub blocked: usize,
    pub total_cost: f64,
    pub total_hours: f64,
}

/// Result of fitting tasks within a constraint (budget or hours)
#[derive(Debug, Clone, Serialize)]
pub struct FitResult<'a> {
    pub fits: Vec<TaskFitInfo<'a>>,
    pub exceeds: Vec<TaskFitInfo<'a>>,
    pub remaining: f64,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BlockedOpenCycleDiagnostic {
    pub cycle_members: Vec<String>,
    pub failed_blockers: Vec<String>,
}

impl BlockedOpenCycleDiagnostic {
    pub fn message(&self) -> String {
        format!(
            "blocked-open dependency cycle: open tasks [{}] are mutually waiting while failed blocker(s) [{}] prevent any useful break-in. Retry/fix the failed blocker, remove the failed dependency, or rewire this work as a sequential supervisor with explicit subtasks.",
            self.cycle_members.join(", "),
            self.failed_blockers.join(", ")
        )
    }
}

/// Information about a task and whether it fits the constraint
#[derive(Debug, Clone, Serialize)]
pub struct TaskFitInfo<'a> {
    pub id: &'a str,
    pub title: &'a str,
    pub cost: f64,
    pub hours: f64,
    pub is_ready: bool,
}

/// Get project summary (task counts and totals)
pub fn project_summary(graph: &WorkGraph) -> ProjectSummary {
    let ready = ready_tasks(graph);
    let ready_ids: HashSet<&str> = ready.iter().map(|t| t.id.as_str()).collect();

    let mut open = 0;
    let mut done = 0;
    let mut in_progress = 0;
    let mut blocked_count = 0;
    let mut total_cost = 0.0;
    let mut total_hours = 0.0;

    for task in graph.tasks() {
        match task.status {
            Status::Open => {
                open += 1;
                if !ready_ids.contains(task.id.as_str()) {
                    blocked_count += 1;
                }
                // Add estimates for open tasks
                if let Some(ref est) = task.estimate {
                    total_cost += est.cost.unwrap_or(0.0);
                    total_hours += est.hours.unwrap_or(0.0);
                }
            }
            Status::Done => done += 1,
            Status::InProgress => in_progress += 1,
            Status::Blocked => {
                // Explicit blocked status also counts
                blocked_count += 1;
            }
            Status::Incomplete => {
                open += 1;
            }
            Status::Failed | Status::Abandoned | Status::Waiting | Status::PendingValidation => {
                // Failed, abandoned, and waiting tasks are not counted as open
            }
            Status::PendingEval | Status::FailedPendingEval => {
                // Soft-done/soft-failed: agent finished, awaiting eval. Count as
                // in-progress for board display — work is "in flight" until eval resolves.
                in_progress += 1;
            }
        }
    }

    ProjectSummary {
        open,
        done,
        in_progress,
        ready: ready.len(),
        blocked: blocked_count,
        total_cost,
        total_hours,
    }
}

/// Find tasks that fit within a budget, prioritizing ready tasks
pub fn tasks_within_budget<'a>(graph: &'a WorkGraph, budget: f64) -> FitResult<'a> {
    tasks_within_constraint(graph, budget, |t| {
        t.estimate.as_ref().and_then(|e| e.cost).unwrap_or(0.0)
    })
}

/// Find tasks that fit within available hours, prioritizing ready tasks
pub fn tasks_within_hours<'a>(graph: &'a WorkGraph, hours: f64) -> FitResult<'a> {
    tasks_within_constraint(graph, hours, |t| {
        t.estimate.as_ref().and_then(|e| e.hours).unwrap_or(0.0)
    })
}

/// Generic function to find tasks within a constraint
fn tasks_within_constraint<'a, F>(graph: &'a WorkGraph, limit: f64, get_value: F) -> FitResult<'a>
where
    F: Fn(&Task) -> f64,
{
    let ready = ready_tasks(graph);
    let ready_ids: HashSet<&str> = ready.iter().map(|t| t.id.as_str()).collect();

    // Get all open/incomplete tasks (not done, not in-progress)
    let mut open_tasks: Vec<&Task> = graph
        .tasks()
        .filter(|t| matches!(t.status, Status::Open | Status::Incomplete))
        .collect();

    // Sort: ready tasks first, then by value (cost/hours) ascending
    open_tasks.sort_by(|a, b| {
        let a_ready = ready_ids.contains(a.id.as_str());
        let b_ready = ready_ids.contains(b.id.as_str());
        match (a_ready, b_ready) {
            (true, false) => std::cmp::Ordering::Less,
            (false, true) => std::cmp::Ordering::Greater,
            _ => {
                let a_val = get_value(a);
                let b_val = get_value(b);
                a_val
                    .partial_cmp(&b_val)
                    .unwrap_or(std::cmp::Ordering::Equal)
            }
        }
    });

    let mut fits = Vec::new();
    let mut exceeds = Vec::new();
    let mut remaining = limit;
    let mut completed_in_plan: HashSet<&str> = HashSet::new();

    // First pass: add ready tasks that fit
    for task in &open_tasks {
        let is_ready = ready_ids.contains(task.id.as_str());
        let value = get_value(task);
        let info = TaskFitInfo {
            id: &task.id,
            title: &task.title,
            cost: task.estimate.as_ref().and_then(|e| e.cost).unwrap_or(0.0),
            hours: task.estimate.as_ref().and_then(|e| e.hours).unwrap_or(0.0),
            is_ready,
        };

        if is_ready {
            if value <= remaining {
                remaining -= value;
                completed_in_plan.insert(&task.id);
                fits.push(info);
            } else {
                exceeds.push(info);
            }
        }
    }

    // Second pass: add blocked tasks that become unblocked by completing ready tasks
    // Keep iterating until no more tasks can be added
    let mut changed = true;
    while changed {
        changed = false;
        for task in &open_tasks {
            if completed_in_plan.contains(task.id.as_str()) {
                continue;
            }
            if ready_ids.contains(task.id.as_str()) {
                continue; // Already processed
            }

            // Check if all blockers are now resolved (in our plan or dep-satisfied)
            let blockers_done = task.after.iter().all(|blocker_id| {
                completed_in_plan.contains(blocker_id.as_str())
                    || graph
                        .get_task(blocker_id)
                        .map(|t| t.status.is_dep_satisfied())
                        .unwrap_or(true)
            });

            if blockers_done {
                let value = get_value(task);
                let info = TaskFitInfo {
                    id: &task.id,
                    title: &task.title,
                    cost: task.estimate.as_ref().and_then(|e| e.cost).unwrap_or(0.0),
                    hours: task.estimate.as_ref().and_then(|e| e.hours).unwrap_or(0.0),
                    is_ready: false, // Was blocked, now unblocked by plan
                };

                if value <= remaining {
                    remaining -= value;
                    completed_in_plan.insert(&task.id);
                    fits.push(info);
                    changed = true;
                } else if !exceeds.iter().any(|e| e.id == task.id) {
                    exceeds.push(info);
                }
            }
        }
    }

    FitResult {
        fits,
        exceeds,
        remaining,
    }
}

/// Build a reverse dependency index: maps each task ID to the list of tasks that depend on it.
pub fn build_reverse_index(graph: &WorkGraph) -> HashMap<String, Vec<String>> {
    let mut index: HashMap<String, Vec<String>> = HashMap::new();

    for task in graph.tasks() {
        for blocker_id in &task.after {
            index
                .entry(blocker_id.clone())
                .or_default()
                .push(task.id.clone());
        }
    }

    index
}

/// Returns true if `.evaluate-{blocker_id}` exists in the graph and is non-terminal.
/// This is the eval gate: when an evaluation task is scaffolded for a completed
/// task, downstream dependents wait for the evaluation to finish before unblocking.
/// Agency eval IS the verification — `wg approve`/`wg reject` are no longer
/// routine human gates.
///
/// Returns false (gate satisfied) when:
/// - `.evaluate-{blocker_id}` does not exist (no eval scheduled)
/// - `.evaluate-{blocker_id}` is terminal (Done/Failed/Abandoned)
///
/// Returns true (gate pending) when:
/// - `.evaluate-{blocker_id}` exists and is Open/InProgress/Waiting/PendingValidation
///
/// System tasks (dot-prefixed) are exempt from eval gating to avoid recursive
/// gates on `.evaluate-X`, `.assign-X`, etc.
pub fn is_eval_gate_pending(blocker_id: &str, graph: &WorkGraph) -> bool {
    if blocker_id.starts_with('.') {
        return false;
    }
    let eval_id = format!(".evaluate-{}", blocker_id);
    match graph.get_task(&eval_id) {
        Some(eval_task) => !eval_task.status.is_terminal(),
        None => false,
    }
}

/// Find all tasks that are ready to work on (no open blockers, past not_before)
pub fn ready_tasks(graph: &WorkGraph) -> Vec<&Task> {
    graph
        .tasks()
        .filter(|task| {
            // Must be open or incomplete (retryable)
            if !matches!(task.status, Status::Open | Status::Incomplete) {
                return false;
            }
            // Must not be paused
            if task.paused {
                return false;
            }
            // Must be past not_before timestamp
            if !is_time_ready(task) {
                return false;
            }
            // All blockers must be terminal (done, failed, or abandoned).
            // If a blocker doesn't exist in the graph, treat it as BLOCKED
            // (not satisfied). This prevents premature dispatch when tasks
            // reference dependencies that haven't been created yet during
            // burst graph construction.
            //
            // Eval gate: even when blocker is terminal, wait for `.evaluate-X`
            // to also be terminal — agency eval gates dependent unblocking.
            // System tasks (dot-prefixed) are exempt from this gate.
            task.after.iter().all(|blocker_id| {
                blocker_satisfied_for_dependent(blocker_id, graph, task.id.starts_with('.'))
            })
        })
        .collect()
}

/// Predicate: is `blocker_id` satisfied for a dependent task?
///
/// Handles the new `PendingEval` soft-done state:
/// - System dependents (e.g. `.flip-X`, `.evaluate-X`) treat `PendingEval` and
///   `FailedPendingEval` as "the agent finished, output is captured" and proceed.
///   Without this, the eval pipeline would deadlock because `.evaluate-X` cannot
///   run until `X` is `Done` — but `X` cannot reach `Done` until `.evaluate-X`
///   finishes.
/// - Non-system dependents (regular work) treat `PendingEval`/`FailedPendingEval`
///   as still in flight and block until the dispatcher promotes to `Done`.
fn blocker_satisfied_for_dependent(
    blocker_id: &str,
    graph: &WorkGraph,
    dependent_is_system: bool,
) -> bool {
    let blocker = graph.get_task(blocker_id);
    let blocker_dep_satisfied = blocker
        .map(|t| t.status.is_dep_satisfied())
        .unwrap_or(false);
    let blocker_pending_eval = blocker
        .map(|t| matches!(t.status, Status::PendingEval | Status::FailedPendingEval))
        .unwrap_or(false);

    if !blocker_dep_satisfied {
        // The PendingEval/FailedPendingEval bypass only fires for system dependents.
        // Regular tasks must wait for the dispatcher to promote the source to Done.
        if !(dependent_is_system && blocker_pending_eval) {
            return false;
        }
    }
    // Either the blocker satisfies the dep, or the PendingEval bypass let us through.
    if dependent_is_system {
        // System tasks skip the eval gate too — they ARE the eval gate.
        return true;
    }
    !is_eval_gate_pending(blocker_id, graph)
}

/// Check whether a single after dependency is satisfied.
///
/// Satisfied means Done or Abandoned.  Failed is NOT satisfied — a failed
/// upstream produced no valid output and must be retried before downstream
/// work can proceed.
///
/// Handles both local and remote (`peer:task-id`) references.
/// For remote refs, resolves via federation config using IPC or direct file access.
///
/// Note: this does NOT apply the eval-gate (`.evaluate-X` pending). Use
/// `is_blocker_satisfied_with_eval_gate` to include the eval gate.
pub fn is_blocker_satisfied(
    blocker_id: &str,
    graph: &WorkGraph,
    workgraph_dir: Option<&Path>,
) -> bool {
    if let Some((peer_name, remote_task_id)) = crate::federation::parse_remote_ref(blocker_id) {
        // Cross-repo dependency
        let Some(wg_dir) = workgraph_dir else {
            return false; // Can't resolve without WG dir; treat as blocked
        };
        let remote =
            crate::federation::resolve_remote_task_status(peer_name, remote_task_id, wg_dir);
        remote.status.is_dep_satisfied()
    } else {
        // Local dependency — non-existent blocker blocks (prevents premature
        // dispatch during burst graph construction).
        graph
            .get_task(blocker_id)
            .map(|t| t.status.is_dep_satisfied())
            .unwrap_or(false)
    }
}

/// Like `is_blocker_satisfied` but also applies the eval gate: even when the
/// blocker is terminal, returns false if `.evaluate-{blocker_id}` exists in the
/// graph and is not yet terminal.
///
/// Used by readiness queries so dependents wait for agency evaluation before
/// unblocking. Skip-callers should pass `dependent_is_system=true` when the
/// dependent itself is a system task (dot-prefixed) — system tasks bypass
/// eval gating to avoid `.evaluate-.evaluate-X` recursion.
///
/// Also handles `PendingEval`: a non-terminal soft-done state. System
/// dependents treat it as terminal so the eval pipeline can run; non-system
/// dependents block until the dispatcher promotes the source to `Done`.
pub fn is_blocker_satisfied_with_eval_gate(
    blocker_id: &str,
    graph: &WorkGraph,
    workgraph_dir: Option<&Path>,
    dependent_is_system: bool,
) -> bool {
    let satisfied = is_blocker_satisfied(blocker_id, graph, workgraph_dir);
    if !satisfied {
        // Allow system dependents to advance over a PendingEval/FailedPendingEval source
        // — the agent is done (or exited), only the eval has not scored yet.
        if !(dependent_is_system
            && graph
                .get_task(blocker_id)
                .map(|t| matches!(t.status, Status::PendingEval | Status::FailedPendingEval))
                .unwrap_or(false))
        {
            return false;
        }
    }
    if dependent_is_system {
        return true;
    }
    !is_eval_gate_pending(blocker_id, graph)
}

/// Find all tasks that are ready to work on, resolving cross-repo dependencies.
///
/// This is the cross-repo-aware variant of `ready_tasks()`. The coordinator
/// should use this version so that tasks blocked by remote `peer:task-id`
/// references are correctly resolved.
pub fn ready_tasks_with_peers<'a>(graph: &'a WorkGraph, workgraph_dir: &Path) -> Vec<&'a Task> {
    graph
        .tasks()
        .filter(|task| {
            if !matches!(task.status, Status::Open | Status::Incomplete) {
                return false;
            }
            if task.paused {
                return false;
            }
            if !is_time_ready(task) {
                return false;
            }
            let dependent_is_system = task.id.starts_with('.');
            task.after.iter().all(|blocker_id| {
                is_blocker_satisfied_with_eval_gate(
                    blocker_id,
                    graph,
                    Some(workgraph_dir),
                    dependent_is_system,
                )
            })
        })
        .collect()
}

/// Find all tasks that are ready to work on, with cycle-aware back-edge exemption.
///
/// Same as `ready_tasks()` but structural back-edges (identified by cycle analysis)
/// are ignored when computing readiness. This allows cycle members to become ready
/// based on their forward dependencies only, regardless of iteration count or
/// cycle_config presence. Works uniformly for self-loops, 2-task, and N-task cycles.
pub fn ready_tasks_cycle_aware<'a>(
    graph: &'a WorkGraph,
    cycle_analysis: &CycleAnalysis,
) -> Vec<&'a Task> {
    let mut ready_tasks: Vec<&'a Task> = graph
        .tasks()
        .filter(|task| {
            if !matches!(task.status, Status::Open | Status::Incomplete) {
                return false;
            }
            if task.paused {
                return false;
            }
            if !is_time_ready(task) {
                return false;
            }
            let dependent_is_system = task.id.starts_with('.');
            task.after.iter().all(|blocker_id| {
                let blocker = graph.get_task(blocker_id);
                let blocker_dep_satisfied = blocker
                    .map(|t| t.status.is_dep_satisfied())
                    .unwrap_or(false);
                let blocker_pending_eval = blocker
                    .map(|t| matches!(t.status, Status::PendingEval | Status::FailedPendingEval))
                    .unwrap_or(false);
                if blocker_dep_satisfied {
                    // Eval gate: even when blocker satisfies the dep, wait for
                    // `.evaluate-X` to also be terminal. System dependents
                    // (dot-prefixed) skip the gate.
                    if dependent_is_system {
                        return true;
                    }
                    return !is_eval_gate_pending(blocker_id, graph);
                }
                // PendingEval/FailedPendingEval bypass for system dependents:
                // `.flip-X` and `.evaluate-X` need to run on a soft-done/soft-failed
                // source — without this the eval pipeline deadlocks itself.
                if dependent_is_system && blocker_pending_eval {
                    return true;
                }
                // Back-edge exemption: if this dependency edge is a structural
                // back-edge in the cycle analysis, skip it. Only forward
                // dependencies block readiness. This single check handles
                // self-loops, 2-task cycles, N-task cycles, and all iterations
                // uniformly — no special cases needed.
                if cycle_analysis
                    .back_edges
                    .contains(&(blocker_id.clone(), task.id.clone()))
                {
                    return true;
                }
                false
            })
        })
        .collect();

    // Auto-break-in for unconfigured cycles
    for cycle in &cycle_analysis.cycles {
        // Check if this cycle has any configured tasks
        let has_cycle_config = cycle.members.iter().any(|member_id| {
            graph
                .get_task(member_id)
                .map(|t| t.cycle_config.is_some())
                .unwrap_or(false)
        });

        // Skip if cycle is configured (existing logic handles it)
        if has_cycle_config {
            continue;
        }

        // Check if any cycle member is already ready (existing logic handles it)
        let has_ready_member = cycle
            .members
            .iter()
            .any(|member_id| ready_tasks.iter().any(|t| &t.id == member_id));

        if has_ready_member {
            continue;
        }

        if cycle_has_unsatisfied_external_blocker(graph, &cycle.members) {
            continue;
        }

        // Check if all cycle members are open and time-ready
        let viable_members: Vec<&Task> = cycle
            .members
            .iter()
            .filter_map(|member_id| graph.get_task(member_id))
            .filter(|task| {
                matches!(task.status, Status::Open | Status::Incomplete)
                    && !task.paused
                    && is_time_ready(task)
            })
            .collect();

        if viable_members.is_empty() {
            continue; // No viable members to break in
        }

        // Pick the break-in point: alphabetically first task
        let break_in_task = viable_members
            .iter()
            .min_by(|a, b| a.id.cmp(&b.id))
            .expect("viable_members is not empty");

        eprintln!(
            "Auto-break-in: selecting task '{}' to break cycle deadlock in unconfigured cycle [{}]",
            break_in_task.id,
            cycle.members.join(" → ")
        );

        ready_tasks.push(break_in_task);
    }

    ready_tasks
}

pub fn blocked_open_cycle_diagnostics(
    graph: &WorkGraph,
    cycle_analysis: &CycleAnalysis,
) -> Vec<BlockedOpenCycleDiagnostic> {
    let mut diagnostics = Vec::new();

    for cycle in &cycle_analysis.cycles {
        let member_set: HashSet<&str> = cycle.members.iter().map(String::as_str).collect();
        let Some(member_tasks) = collect_cycle_member_tasks(graph, &cycle.members) else {
            continue;
        };

        if member_tasks.is_empty()
            || member_tasks.iter().any(|task| {
                !matches!(task.status, Status::Open | Status::Incomplete)
                    || task.paused
                    || !is_time_ready(task)
            })
        {
            continue;
        }

        let every_member_waits_on_open_peer = member_tasks.iter().all(|task| {
            task.after.iter().any(|dep_id| {
                member_set.contains(dep_id.as_str())
                    && graph
                        .get_task(dep_id)
                        .map(|dep| matches!(dep.status, Status::Open | Status::Incomplete))
                        .unwrap_or(false)
            })
        });
        if !every_member_waits_on_open_peer {
            continue;
        }

        let mut failed_blockers: Vec<String> = member_tasks
            .iter()
            .flat_map(|task| task.after.iter())
            .filter(|dep_id| !member_set.contains(dep_id.as_str()))
            .filter_map(|dep_id| {
                graph
                    .get_task(dep_id)
                    .filter(|dep| dep.status == Status::Failed)
                    .map(|dep| dep.id.clone())
            })
            .collect();
        failed_blockers.sort();
        failed_blockers.dedup();
        if failed_blockers.is_empty() {
            continue;
        }

        let mut cycle_members = cycle.members.clone();
        cycle_members.sort();
        diagnostics.push(BlockedOpenCycleDiagnostic {
            cycle_members,
            failed_blockers,
        });
    }

    diagnostics.sort_by(|a, b| a.cycle_members.cmp(&b.cycle_members));
    diagnostics
}

fn collect_cycle_member_tasks<'a>(
    graph: &'a WorkGraph,
    members: &[String],
) -> Option<Vec<&'a Task>> {
    members.iter().map(|id| graph.get_task(id)).collect()
}

fn cycle_has_unsatisfied_external_blocker(graph: &WorkGraph, members: &[String]) -> bool {
    let member_set: HashSet<&str> = members.iter().map(String::as_str).collect();
    members
        .iter()
        .filter_map(|member_id| graph.get_task(member_id))
        .flat_map(|task| task.after.iter())
        .filter(|dep_id| !member_set.contains(dep_id.as_str()))
        .any(|dep_id| {
            graph
                .get_task(dep_id)
                .map(|dep| !dep.status.is_dep_satisfied())
                .unwrap_or(true)
        })
}

/// Find all tasks that are ready to work on, resolving cross-repo dependencies,
/// with cycle-aware back-edge exemption.
pub fn ready_tasks_with_peers_cycle_aware<'a>(
    graph: &'a WorkGraph,
    workgraph_dir: &Path,
    cycle_analysis: &CycleAnalysis,
) -> Vec<&'a Task> {
    graph
        .tasks()
        .filter(|task| {
            if !matches!(task.status, Status::Open | Status::Incomplete) {
                return false;
            }
            if task.paused {
                return false;
            }
            if !is_time_ready(task) {
                return false;
            }
            let dependent_is_system = task.id.starts_with('.');
            task.after.iter().all(|blocker_id| {
                if is_blocker_satisfied(blocker_id, graph, Some(workgraph_dir)) {
                    // Eval gate: even when blocker is terminal, wait for
                    // `.evaluate-X` to also be terminal. System dependents
                    // (dot-prefixed) skip the gate.
                    if dependent_is_system {
                        return true;
                    }
                    return !is_eval_gate_pending(blocker_id, graph);
                }
                // PendingEval bypass for system dependents: the agent finished
                // (output captured) but the evaluator hasn't scored yet —
                // `.flip-X` / `.evaluate-X` must run from this state.
                if dependent_is_system
                    && graph
                        .get_task(blocker_id)
                        .map(|t| {
                            matches!(t.status, Status::PendingEval | Status::FailedPendingEval)
                        })
                        .unwrap_or(false)
                {
                    return true;
                }
                // Back-edge exemption: if this dependency edge is a structural
                // back-edge in the cycle analysis, skip it. Only forward
                // dependencies block readiness.
                if cycle_analysis
                    .back_edges
                    .contains(&(blocker_id.clone(), task.id.clone()))
                {
                    return true;
                }
                false
            })
        })
        .collect()
}

/// Find what tasks are blocking a given task.
///
/// A blocker is any upstream that has not yet satisfied its dependency — i.e.
/// not Done and not Abandoned.  Failed upstreams are included because they
/// did not produce valid output.
pub fn after<'a>(graph: &'a WorkGraph, task_id: &str) -> Vec<&'a Task> {
    let Some(task) = graph.get_task(task_id) else {
        return vec![];
    };

    task.after
        .iter()
        .filter_map(|id| graph.get_task(id))
        .filter(|t| !t.status.is_dep_satisfied())
        .collect()
}

/// Return dependency IDs that don't resolve to any task in the graph (phantom edges).
/// Excludes cross-repo remote references, which are validated at resolution time.
pub fn phantom_blockers(task: &Task, graph: &WorkGraph) -> Vec<String> {
    task.after
        .iter()
        .filter(|id| graph.get_task(id).is_none())
        .filter(|id| crate::federation::parse_remote_ref(id).is_none())
        .cloned()
        .collect()
}

/// Calculate total cost of a task and all its transitive dependencies
pub fn cost_of(graph: &WorkGraph, task_id: &str) -> f64 {
    let mut visited = std::collections::HashSet::new();
    cost_of_recursive(graph, task_id, &mut visited)
}

fn cost_of_recursive(
    graph: &WorkGraph,
    task_id: &str,
    visited: &mut std::collections::HashSet<String>,
) -> f64 {
    if visited.contains(task_id) {
        return 0.0;
    }
    visited.insert(task_id.to_string());

    let Some(task) = graph.get_task(task_id) else {
        return 0.0;
    };

    let self_cost = task.estimate.as_ref().and_then(|e| e.cost).unwrap_or(0.0);

    let deps_cost: f64 = task
        .after
        .iter()
        .map(|dep_id| cost_of_recursive(graph, dep_id, visited))
        .sum();

    self_cost + deps_cost
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::graph::{Estimate, Node};

    fn make_task(id: &str, title: &str) -> Task {
        Task {
            id: id.to_string(),
            title: title.to_string(),
            ..Task::default()
        }
    }

    #[test]
    fn test_ready_tasks_empty_graph() {
        let graph = WorkGraph::new();
        let ready = ready_tasks(&graph);
        assert!(ready.is_empty());
    }

    #[test]
    fn test_ready_tasks_single_open_task() {
        let mut graph = WorkGraph::new();
        graph.add_node(Node::Task(make_task("t1", "Task 1")));

        let ready = ready_tasks(&graph);
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].id, "t1");
    }

    #[test]
    fn test_ready_tasks_excludes_done() {
        let mut graph = WorkGraph::new();
        let mut task = make_task("t1", "Task 1");
        task.status = Status::Done;
        graph.add_node(Node::Task(task));

        let ready = ready_tasks(&graph);
        assert!(ready.is_empty());
    }

    #[test]
    fn test_ready_tasks_excludes_blocked() {
        let mut graph = WorkGraph::new();

        let blocker = make_task("blocker", "Blocker");
        let mut blocked = make_task("blocked", "Blocked");
        blocked.after = vec!["blocker".to_string()];

        graph.add_node(Node::Task(blocker));
        graph.add_node(Node::Task(blocked));

        let ready = ready_tasks(&graph);
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].id, "blocker");
    }

    #[test]
    fn test_ready_tasks_unblocked_when_blocker_done() {
        let mut graph = WorkGraph::new();

        let mut blocker = make_task("blocker", "Blocker");
        blocker.status = Status::Done;

        let mut blocked = make_task("blocked", "Blocked");
        blocked.after = vec!["blocker".to_string()];

        graph.add_node(Node::Task(blocker));
        graph.add_node(Node::Task(blocked));

        let ready = ready_tasks(&graph);
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].id, "blocked");
    }

    #[test]
    fn test_after_returns_blockers() {
        let mut graph = WorkGraph::new();

        let blocker = make_task("blocker", "Blocker");
        let mut blocked = make_task("blocked", "Blocked");
        blocked.after = vec!["blocker".to_string()];

        graph.add_node(Node::Task(blocker));
        graph.add_node(Node::Task(blocked));

        let blockers = after(&graph, "blocked");
        assert_eq!(blockers.len(), 1);
        assert_eq!(blockers[0].id, "blocker");
    }

    #[test]
    fn test_after_excludes_done_blockers() {
        let mut graph = WorkGraph::new();

        let mut blocker = make_task("blocker", "Blocker");
        blocker.status = Status::Done;

        let mut blocked = make_task("blocked", "Blocked");
        blocked.after = vec!["blocker".to_string()];

        graph.add_node(Node::Task(blocker));
        graph.add_node(Node::Task(blocked));

        let blockers = after(&graph, "blocked");
        assert!(blockers.is_empty());
    }

    #[test]
    fn test_after_includes_failed_blockers() {
        // A failed upstream is still a blocker — it did not produce valid output.
        // `after()` must return it so callers know the dependency is unresolved.
        let mut graph = WorkGraph::new();

        let mut blocker = make_task("blocker", "Blocker");
        blocker.status = Status::Failed;

        let mut blocked = make_task("blocked", "Blocked");
        blocked.after = vec!["blocker".to_string()];

        graph.add_node(Node::Task(blocker));
        graph.add_node(Node::Task(blocked));

        let blockers = after(&graph, "blocked");
        assert_eq!(
            blockers.len(),
            1,
            "Failed blocker must appear in after() — the dependency is not satisfied"
        );
    }

    #[test]
    fn test_after_excludes_abandoned_blockers() {
        let mut graph = WorkGraph::new();

        let mut blocker = make_task("blocker", "Blocker");
        blocker.status = Status::Abandoned;

        let mut blocked = make_task("blocked", "Blocked");
        blocked.after = vec!["blocker".to_string()];

        graph.add_node(Node::Task(blocker));
        graph.add_node(Node::Task(blocked));

        let blockers = after(&graph, "blocked");
        assert!(
            blockers.is_empty(),
            "Abandoned blockers should not block dependents"
        );
    }

    #[test]
    fn test_cost_of_single_task() {
        let mut graph = WorkGraph::new();
        let mut task = make_task("t1", "Task 1");
        task.estimate = Some(Estimate {
            hours: Some(10.0),
            cost: Some(1000.0),
        });
        graph.add_node(Node::Task(task));

        assert_eq!(cost_of(&graph, "t1"), 1000.0);
    }

    #[test]
    fn test_cost_of_with_dependencies() {
        let mut graph = WorkGraph::new();

        let mut dep = make_task("dep", "Dependency");
        dep.estimate = Some(Estimate {
            hours: None,
            cost: Some(500.0),
        });

        let mut task = make_task("main", "Main task");
        task.after = vec!["dep".to_string()];
        task.estimate = Some(Estimate {
            hours: None,
            cost: Some(1000.0),
        });

        graph.add_node(Node::Task(dep));
        graph.add_node(Node::Task(task));

        assert_eq!(cost_of(&graph, "main"), 1500.0);
    }

    #[test]
    fn test_cost_of_handles_cycles() {
        let mut graph = WorkGraph::new();

        let mut t1 = make_task("t1", "Task 1");
        t1.after = vec!["t2".to_string()];
        t1.estimate = Some(Estimate {
            hours: None,
            cost: Some(100.0),
        });

        let mut t2 = make_task("t2", "Task 2");
        t2.after = vec!["t1".to_string()];
        t2.estimate = Some(Estimate {
            hours: None,
            cost: Some(200.0),
        });

        graph.add_node(Node::Task(t1));
        graph.add_node(Node::Task(t2));

        // Should not infinite loop, should count each once
        let cost = cost_of(&graph, "t1");
        assert_eq!(cost, 300.0);
    }

    #[test]
    fn test_cost_of_nonexistent_task() {
        let graph = WorkGraph::new();
        assert_eq!(cost_of(&graph, "nonexistent"), 0.0);
    }

    #[test]
    fn test_project_summary_empty() {
        let graph = WorkGraph::new();
        let summary = project_summary(&graph);
        assert_eq!(summary.open, 0);
        assert_eq!(summary.done, 0);
        assert_eq!(summary.in_progress, 0);
        assert_eq!(summary.ready, 0);
        assert_eq!(summary.blocked, 0);
        assert_eq!(summary.total_cost, 0.0);
        assert_eq!(summary.total_hours, 0.0);
    }

    #[test]
    fn test_project_summary_with_tasks() {
        let mut graph = WorkGraph::new();

        // Open task with estimate
        let mut t1 = make_task("t1", "Task 1");
        t1.estimate = Some(Estimate {
            hours: Some(10.0),
            cost: Some(1000.0),
        });

        // Done task (should not count in totals)
        let mut t2 = make_task("t2", "Task 2");
        t2.status = Status::Done;
        t2.estimate = Some(Estimate {
            hours: Some(5.0),
            cost: Some(500.0),
        });

        // In-progress task
        let mut t3 = make_task("t3", "Task 3");
        t3.status = Status::InProgress;
        t3.estimate = Some(Estimate {
            hours: Some(8.0),
            cost: Some(800.0),
        });

        // Blocked task (blocked by t1)
        let mut t4 = make_task("t4", "Task 4");
        t4.after = vec!["t1".to_string()];
        t4.estimate = Some(Estimate {
            hours: Some(4.0),
            cost: Some(400.0),
        });

        graph.add_node(Node::Task(t1));
        graph.add_node(Node::Task(t2));
        graph.add_node(Node::Task(t3));
        graph.add_node(Node::Task(t4));

        let summary = project_summary(&graph);
        assert_eq!(summary.open, 2); // t1, t4
        assert_eq!(summary.done, 1);
        assert_eq!(summary.in_progress, 1);
        assert_eq!(summary.ready, 1); // only t1 is ready (t4 is blocked)
        assert_eq!(summary.blocked, 1); // t4
        // Total cost of open tasks: t1 (1000) + t4 (400) = 1400
        assert_eq!(summary.total_cost, 1400.0);
        // Total hours of open tasks: t1 (10) + t4 (4) = 14
        assert_eq!(summary.total_hours, 14.0);
    }

    #[test]
    fn test_tasks_within_budget_empty() {
        let graph = WorkGraph::new();
        let result = tasks_within_budget(&graph, 1000.0);
        assert!(result.fits.is_empty());
        assert!(result.exceeds.is_empty());
        assert_eq!(result.remaining, 1000.0);
    }

    #[test]
    fn test_tasks_within_budget_basic() {
        let mut graph = WorkGraph::new();

        let mut t1 = make_task("t1", "Task 1");
        t1.estimate = Some(Estimate {
            hours: Some(4.0),
            cost: Some(400.0),
        });

        let mut t2 = make_task("t2", "Task 2");
        t2.estimate = Some(Estimate {
            hours: Some(8.0),
            cost: Some(800.0),
        });

        graph.add_node(Node::Task(t1));
        graph.add_node(Node::Task(t2));

        let result = tasks_within_budget(&graph, 1000.0);
        // Should fit t1 (400), leaving 600
        // t2 (800) exceeds remaining 600
        assert_eq!(result.fits.len(), 1);
        assert_eq!(result.fits[0].id, "t1");
        assert_eq!(result.exceeds.len(), 1);
        assert_eq!(result.exceeds[0].id, "t2");
        assert_eq!(result.remaining, 600.0);
    }

    #[test]
    fn test_tasks_within_budget_prioritizes_ready() {
        let mut graph = WorkGraph::new();

        // Blocker task (ready)
        let mut blocker = make_task("blocker", "Blocker");
        blocker.estimate = Some(Estimate {
            hours: Some(4.0),
            cost: Some(400.0),
        });

        // Blocked task (not ready)
        let mut blocked = make_task("blocked", "Blocked");
        blocked.after = vec!["blocker".to_string()];
        blocked.estimate = Some(Estimate {
            hours: Some(2.0),
            cost: Some(200.0),
        });

        graph.add_node(Node::Task(blocker));
        graph.add_node(Node::Task(blocked));

        let result = tasks_within_budget(&graph, 1000.0);
        // blocker should come first (ready), then blocked can be done
        assert_eq!(result.fits.len(), 2);
        assert_eq!(result.fits[0].id, "blocker");
        assert_eq!(result.fits[1].id, "blocked");
        assert_eq!(result.remaining, 400.0);
    }

    #[test]
    fn test_tasks_within_budget_excludes_done() {
        let mut graph = WorkGraph::new();

        let mut done = make_task("done", "Done task");
        done.status = Status::Done;
        done.estimate = Some(Estimate {
            hours: Some(10.0),
            cost: Some(1000.0),
        });

        let mut open = make_task("open", "Open task");
        open.estimate = Some(Estimate {
            hours: Some(5.0),
            cost: Some(500.0),
        });

        graph.add_node(Node::Task(done));
        graph.add_node(Node::Task(open));

        let result = tasks_within_budget(&graph, 1000.0);
        assert_eq!(result.fits.len(), 1);
        assert_eq!(result.fits[0].id, "open");
        assert_eq!(result.remaining, 500.0);
    }

    #[test]
    fn test_tasks_within_hours_basic() {
        let mut graph = WorkGraph::new();

        let mut t1 = make_task("t1", "Task 1");
        t1.estimate = Some(Estimate {
            hours: Some(4.0),
            cost: Some(400.0),
        });

        let mut t2 = make_task("t2", "Task 2");
        t2.estimate = Some(Estimate {
            hours: Some(8.0),
            cost: Some(800.0),
        });

        graph.add_node(Node::Task(t1));
        graph.add_node(Node::Task(t2));

        let result = tasks_within_hours(&graph, 10.0);
        // Should fit t1 (4h), leaving 6h
        // t2 (8h) exceeds remaining 6h
        assert_eq!(result.fits.len(), 1);
        assert_eq!(result.fits[0].id, "t1");
        assert_eq!(result.exceeds.len(), 1);
        assert_eq!(result.exceeds[0].id, "t2");
        assert_eq!(result.remaining, 6.0);
    }

    #[test]
    fn test_is_time_ready_no_timestamp() {
        let task = make_task("t1", "Task 1");
        assert!(is_time_ready(&task));
    }

    #[test]
    fn test_is_time_ready_past_timestamp() {
        let mut task = make_task("t1", "Task 1");
        // Set to a time in the past
        task.not_before = Some("2020-01-01T00:00:00Z".to_string());
        assert!(is_time_ready(&task));
    }

    #[test]
    fn test_is_time_ready_future_timestamp() {
        let mut task = make_task("t1", "Task 1");
        // Set to a time far in the future
        task.not_before = Some("2099-01-01T00:00:00Z".to_string());
        assert!(!is_time_ready(&task));
    }

    #[test]
    fn test_is_time_ready_invalid_timestamp() {
        let mut task = make_task("t1", "Task 1");
        task.not_before = Some("not-a-timestamp".to_string());
        // Invalid timestamp = treat as ready
        assert!(is_time_ready(&task));
    }

    #[test]
    fn test_ready_tasks_excludes_future_not_before() {
        let mut graph = WorkGraph::new();

        let mut t1 = make_task("t1", "Task 1");
        t1.not_before = Some("2099-01-01T00:00:00Z".to_string());

        let t2 = make_task("t2", "Task 2");

        graph.add_node(Node::Task(t1));
        graph.add_node(Node::Task(t2));

        let ready = ready_tasks(&graph);
        // Only t2 should be ready (t1 has future not_before)
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].id, "t2");
    }

    #[test]
    fn test_ready_tasks_includes_past_not_before() {
        let mut graph = WorkGraph::new();

        let mut t1 = make_task("t1", "Task 1");
        t1.not_before = Some("2020-01-01T00:00:00Z".to_string());

        graph.add_node(Node::Task(t1));

        let ready = ready_tasks(&graph);
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].id, "t1");
    }

    // ========== Transitive blocker tests ==========

    #[test]
    fn test_ready_tasks_transitive_blockers_3_levels() {
        // A blocked by B, B blocked by C — only C should be ready
        let mut graph = WorkGraph::new();

        let c = make_task("c", "Level 0 (root)");
        let mut b = make_task("b", "Level 1");
        b.after = vec!["c".to_string()];
        let mut a = make_task("a", "Level 2");
        a.after = vec!["b".to_string()];

        graph.add_node(Node::Task(a));
        graph.add_node(Node::Task(b));
        graph.add_node(Node::Task(c));

        let ready = ready_tasks(&graph);
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].id, "c");
    }

    #[test]
    fn test_ready_tasks_transitive_blockers_4_levels() {
        // d -> c -> b -> a: only d should be ready
        let mut graph = WorkGraph::new();

        let d = make_task("d", "Level 0");
        let mut c = make_task("c", "Level 1");
        c.after = vec!["d".to_string()];
        let mut b = make_task("b", "Level 2");
        b.after = vec!["c".to_string()];
        let mut a = make_task("a", "Level 3");
        a.after = vec!["b".to_string()];

        graph.add_node(Node::Task(a));
        graph.add_node(Node::Task(b));
        graph.add_node(Node::Task(c));
        graph.add_node(Node::Task(d));

        let ready = ready_tasks(&graph);
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].id, "d");
    }

    #[test]
    fn test_ready_tasks_transitive_partial_done() {
        // d(Done) -> c -> b -> a: c should be ready now
        let mut graph = WorkGraph::new();

        let mut d = make_task("d", "Level 0");
        d.status = Status::Done;
        let mut c = make_task("c", "Level 1");
        c.after = vec!["d".to_string()];
        let mut b = make_task("b", "Level 2");
        b.after = vec!["c".to_string()];
        let mut a = make_task("a", "Level 3");
        a.after = vec!["b".to_string()];

        graph.add_node(Node::Task(a));
        graph.add_node(Node::Task(b));
        graph.add_node(Node::Task(c));
        graph.add_node(Node::Task(d));

        let ready = ready_tasks(&graph);
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].id, "c");
    }

    // ========== Multiple blockers with mixed states ==========

    #[test]
    fn test_ready_tasks_multiple_blockers_some_done() {
        // Task blocked by b1(Done) and b2(Open) — should NOT be ready
        let mut graph = WorkGraph::new();

        let mut b1 = make_task("b1", "Blocker 1");
        b1.status = Status::Done;
        let b2 = make_task("b2", "Blocker 2");
        let mut task = make_task("t", "Blocked task");
        task.after = vec!["b1".to_string(), "b2".to_string()];

        graph.add_node(Node::Task(b1));
        graph.add_node(Node::Task(b2));
        graph.add_node(Node::Task(task));

        let ready = ready_tasks(&graph);
        let ready_ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
        assert!(ready_ids.contains(&"b2"), "b2 should be ready");
        assert!(
            !ready_ids.contains(&"t"),
            "t should NOT be ready (b2 still open)"
        );
    }

    #[test]
    fn test_ready_tasks_multiple_blockers_all_done() {
        // Task blocked by b1(Done) and b2(Done) — SHOULD be ready
        let mut graph = WorkGraph::new();

        let mut b1 = make_task("b1", "Blocker 1");
        b1.status = Status::Done;
        let mut b2 = make_task("b2", "Blocker 2");
        b2.status = Status::Done;
        let mut task = make_task("t", "Blocked task");
        task.after = vec!["b1".to_string(), "b2".to_string()];

        graph.add_node(Node::Task(b1));
        graph.add_node(Node::Task(b2));
        graph.add_node(Node::Task(task));

        let ready = ready_tasks(&graph);
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].id, "t");
    }

    #[test]
    fn test_ready_tasks_multiple_blockers_mixed_statuses() {
        // Task blocked by done, in-progress, and failed tasks
        let mut graph = WorkGraph::new();

        let mut b_done = make_task("b-done", "Done blocker");
        b_done.status = Status::Done;
        let mut b_ip = make_task("b-ip", "InProgress blocker");
        b_ip.status = Status::InProgress;
        let mut b_failed = make_task("b-failed", "Failed blocker");
        b_failed.status = Status::Failed;

        let mut task = make_task("t", "Blocked task");
        task.after = vec![
            "b-done".to_string(),
            "b-ip".to_string(),
            "b-failed".to_string(),
        ];

        graph.add_node(Node::Task(b_done));
        graph.add_node(Node::Task(b_ip));
        graph.add_node(Node::Task(b_failed));
        graph.add_node(Node::Task(task));

        // InProgress and Failed both block, so t should NOT be ready.
        let ready = ready_tasks(&graph);
        let ready_ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
        assert!(
            !ready_ids.contains(&"t"),
            "t should NOT be ready (b-ip is in-progress and b-failed is failed)"
        );
    }

    #[test]
    fn test_failed_upstream_blocks_downstream() {
        // A failed upstream must NOT unblock downstream — it should remain blocked
        // until the upstream is retried and reaches done.
        let mut graph = WorkGraph::new();

        let mut b_failed = make_task("b-failed", "Failed blocker");
        b_failed.status = Status::Failed;

        let mut task = make_task("t", "Downstream task");
        task.after = vec!["b-failed".to_string()];

        graph.add_node(Node::Task(b_failed));
        graph.add_node(Node::Task(task));

        let ready = ready_tasks(&graph);
        let ready_ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
        assert!(
            !ready_ids.contains(&"t"),
            "t must NOT be ready when upstream has failed"
        );
    }

    #[test]
    fn test_failed_then_open_upstream_still_blocks() {
        // After wg retry (failed → open), downstream must remain blocked.
        // This validates the inverse: open upstream is still blocking.
        let mut graph = WorkGraph::new();

        let mut upstream = make_task("upstream", "Retried upstream");
        upstream.status = Status::Open;

        let mut downstream = make_task("downstream", "Downstream task");
        downstream.after = vec!["upstream".to_string()];

        graph.add_node(Node::Task(upstream));
        graph.add_node(Node::Task(downstream));

        let ready = ready_tasks(&graph);
        let ready_ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
        assert!(
            !ready_ids.contains(&"downstream"),
            "downstream must NOT be ready when upstream is open (post-retry)"
        );
    }

    #[test]
    fn test_done_upstream_unblocks_downstream() {
        // Once the upstream reaches done, the downstream must become ready.
        let mut graph = WorkGraph::new();

        let mut upstream = make_task("upstream", "Done upstream");
        upstream.status = Status::Done;

        let mut downstream = make_task("downstream", "Downstream task");
        downstream.after = vec!["upstream".to_string()];

        graph.add_node(Node::Task(upstream));
        graph.add_node(Node::Task(downstream));

        let ready = ready_tasks(&graph);
        let ready_ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
        assert!(
            ready_ids.contains(&"downstream"),
            "downstream must be ready when upstream is done"
        );
    }

    #[test]
    fn test_ready_tasks_abandoned_blocker_unblocks() {
        // A task whose only blocker was abandoned should become ready
        let mut graph = WorkGraph::new();

        let mut b_abandoned = make_task("b-abandoned", "Abandoned blocker");
        b_abandoned.status = Status::Abandoned;

        let mut task = make_task("t", "Blocked task");
        task.after = vec!["b-abandoned".to_string()];

        graph.add_node(Node::Task(b_abandoned));
        graph.add_node(Node::Task(task));

        let ready = ready_tasks(&graph);
        let ready_ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
        assert!(
            ready_ids.contains(&"t"),
            "t should be ready when blocker was abandoned"
        );
    }

    // ========== Orphan blocker tests ==========

    #[test]
    fn test_ready_tasks_orphan_blocker_nonexistent() {
        // Task references a blocker that doesn't exist in the graph.
        // Dangling deps BLOCK to prevent premature dispatch during burst
        // graph construction.
        let mut graph = WorkGraph::new();

        let mut task = make_task("t", "Task with ghost blocker");
        task.after = vec!["nonexistent".to_string()];

        graph.add_node(Node::Task(task));

        let ready = ready_tasks(&graph);
        assert_eq!(
            ready.len(),
            0,
            "Task with nonexistent blocker should NOT be ready (dangling deps block)"
        );
    }

    #[test]
    fn test_ready_tasks_mix_real_and_orphan_blockers() {
        // One real open blocker + one nonexistent blocker
        let mut graph = WorkGraph::new();

        let real_blocker = make_task("real", "Real blocker");
        let mut task = make_task("t", "Mixed blockers");
        task.after = vec!["real".to_string(), "ghost".to_string()];

        graph.add_node(Node::Task(real_blocker));
        graph.add_node(Node::Task(task));

        let ready = ready_tasks(&graph);
        let ready_ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
        // "real" is Open so "t" is still blocked
        assert!(ready_ids.contains(&"real"));
        assert!(!ready_ids.contains(&"t"));
    }

    #[test]
    fn test_after_with_orphan_blocker() {
        // after() should silently skip nonexistent blockers
        let mut graph = WorkGraph::new();

        let mut task = make_task("t", "Task");
        task.after = vec!["ghost1".to_string(), "ghost2".to_string()];

        graph.add_node(Node::Task(task));

        let blockers = after(&graph, "t");
        assert!(
            blockers.is_empty(),
            "Nonexistent blockers should be filtered out"
        );
    }

    #[test]
    fn test_after_nonexistent_task() {
        let graph = WorkGraph::new();
        let blockers = after(&graph, "no-such-task");
        assert!(blockers.is_empty());
    }

    // ========== build_reverse_index() direct tests ==========

    #[test]
    fn test_build_reverse_index_empty_graph() {
        let graph = WorkGraph::new();
        let index = build_reverse_index(&graph);
        assert!(index.is_empty());
    }

    #[test]
    fn test_build_reverse_index_no_dependencies() {
        let mut graph = WorkGraph::new();
        graph.add_node(Node::Task(make_task("a", "A")));
        graph.add_node(Node::Task(make_task("b", "B")));

        let index = build_reverse_index(&graph);
        assert!(
            index.is_empty(),
            "No dependencies means empty reverse index"
        );
    }

    #[test]
    fn test_build_reverse_index_linear_chain() {
        // a -> b -> c (c after b, b after a)
        let mut graph = WorkGraph::new();

        let a = make_task("a", "A");
        let mut b = make_task("b", "B");
        b.after = vec!["a".to_string()];
        let mut c = make_task("c", "C");
        c.after = vec!["b".to_string()];

        graph.add_node(Node::Task(a));
        graph.add_node(Node::Task(b));
        graph.add_node(Node::Task(c));

        let index = build_reverse_index(&graph);
        // "a" is depended on by "b"
        assert_eq!(index.get("a").unwrap(), &vec!["b".to_string()]);
        // "b" is depended on by "c"
        assert_eq!(index.get("b").unwrap(), &vec!["c".to_string()]);
        // "c" is not depended on by anything
        assert!(!index.contains_key("c"));
    }

    #[test]
    fn test_build_reverse_index_branching() {
        // "root" has two dependents: "left" and "right"
        let mut graph = WorkGraph::new();

        let root = make_task("root", "Root");
        let mut left = make_task("left", "Left");
        left.after = vec!["root".to_string()];
        let mut right = make_task("right", "Right");
        right.after = vec!["root".to_string()];

        graph.add_node(Node::Task(root));
        graph.add_node(Node::Task(left));
        graph.add_node(Node::Task(right));

        let index = build_reverse_index(&graph);
        let dependents = index.get("root").unwrap();
        assert_eq!(dependents.len(), 2);
        assert!(dependents.contains(&"left".to_string()));
        assert!(dependents.contains(&"right".to_string()));
    }

    #[test]
    fn test_build_reverse_index_diamond() {
        // Diamond: a -> b, a -> c, b -> d, c -> d
        let mut graph = WorkGraph::new();

        let a = make_task("a", "A");
        let mut b = make_task("b", "B");
        b.after = vec!["a".to_string()];
        let mut c = make_task("c", "C");
        c.after = vec!["a".to_string()];
        let mut d = make_task("d", "D");
        d.after = vec!["b".to_string(), "c".to_string()];

        graph.add_node(Node::Task(a));
        graph.add_node(Node::Task(b));
        graph.add_node(Node::Task(c));
        graph.add_node(Node::Task(d));

        let index = build_reverse_index(&graph);
        let a_deps = index.get("a").unwrap();
        assert_eq!(a_deps.len(), 2);
        assert!(a_deps.contains(&"b".to_string()));
        assert!(a_deps.contains(&"c".to_string()));

        assert_eq!(index.get("b").unwrap(), &vec!["d".to_string()]);
        assert_eq!(index.get("c").unwrap(), &vec!["d".to_string()]);
        assert!(!index.contains_key("d"));
    }

    // ========== tasks_within_constraint() edge cases ==========

    #[test]
    fn test_tasks_within_budget_zero_budget() {
        let mut graph = WorkGraph::new();

        let mut t1 = make_task("t1", "Task 1");
        t1.estimate = Some(Estimate {
            hours: Some(1.0),
            cost: Some(100.0),
        });

        graph.add_node(Node::Task(t1));

        let result = tasks_within_budget(&graph, 0.0);
        // Zero-cost tasks would fit (100 > 0), so t1 should exceed
        assert!(result.fits.is_empty());
        assert_eq!(result.exceeds.len(), 1);
        assert_eq!(result.remaining, 0.0);
    }

    #[test]
    fn test_tasks_within_budget_zero_cost_task_zero_budget() {
        // A task with no estimate (defaults to 0 cost) should fit in zero budget
        let mut graph = WorkGraph::new();

        let t1 = make_task("t1", "No estimate task");
        graph.add_node(Node::Task(t1));

        let result = tasks_within_budget(&graph, 0.0);
        assert_eq!(
            result.fits.len(),
            1,
            "Zero-cost task should fit in zero budget"
        );
        assert_eq!(result.fits[0].id, "t1");
        assert_eq!(result.remaining, 0.0);
    }

    #[test]
    fn test_tasks_within_budget_negative_budget() {
        let mut graph = WorkGraph::new();

        let t1 = make_task("t1", "Task");
        graph.add_node(Node::Task(t1));

        let result = tasks_within_budget(&graph, -10.0);
        // Even zero-cost task: 0.0 <= -10.0 is false, so nothing fits
        assert!(result.fits.is_empty());
        assert_eq!(result.remaining, -10.0);
    }

    #[test]
    fn test_tasks_within_budget_none_estimates() {
        // Tasks with None estimates should default to 0 cost and always fit
        let mut graph = WorkGraph::new();

        let t1 = make_task("t1", "No estimate");
        let mut t2 = make_task("t2", "Partial estimate");
        t2.estimate = Some(Estimate {
            hours: Some(5.0),
            cost: None, // cost is None
        });

        graph.add_node(Node::Task(t1));
        graph.add_node(Node::Task(t2));

        let result = tasks_within_budget(&graph, 50.0);
        // Both should fit: t1 costs 0, t2 costs 0 (None -> 0.0)
        assert_eq!(result.fits.len(), 2);
        assert_eq!(result.remaining, 50.0);
    }

    #[test]
    fn test_tasks_within_hours_none_estimates() {
        let mut graph = WorkGraph::new();

        let mut t1 = make_task("t1", "Only cost");
        t1.estimate = Some(Estimate {
            hours: None,
            cost: Some(999.0),
        });

        graph.add_node(Node::Task(t1));

        let result = tasks_within_hours(&graph, 10.0);
        // hours is None -> 0.0, so it fits
        assert_eq!(result.fits.len(), 1);
        assert_eq!(result.remaining, 10.0);
    }

    #[test]
    fn test_tasks_within_budget_exact_fit() {
        // Task cost exactly equals remaining budget
        let mut graph = WorkGraph::new();

        let mut t1 = make_task("t1", "Exact fit");
        t1.estimate = Some(Estimate {
            hours: None,
            cost: Some(500.0),
        });

        graph.add_node(Node::Task(t1));

        let result = tasks_within_budget(&graph, 500.0);
        assert_eq!(result.fits.len(), 1);
        assert_eq!(result.remaining, 0.0);
    }

    #[test]
    fn test_tasks_within_budget_tiny_overshoot() {
        // Budget is just barely less than cost (floating point boundary)
        let mut graph = WorkGraph::new();

        let mut t1 = make_task("t1", "Tiny overshoot");
        t1.estimate = Some(Estimate {
            hours: None,
            cost: Some(100.0),
        });

        graph.add_node(Node::Task(t1));

        // Budget is 99.99999999 — just under 100
        let result = tasks_within_budget(&graph, 99.99999999);
        assert_eq!(
            result.exceeds.len(),
            1,
            "100.0 > 99.99999999, should not fit"
        );
        assert!(result.fits.is_empty());
    }

    #[test]
    fn test_tasks_within_budget_cascading_unblock() {
        // a (ready) -> b (blocked by a) -> c (blocked by b)
        // All should fit if budget allows, since completing a unblocks b, which unblocks c
        let mut graph = WorkGraph::new();

        let mut a = make_task("a", "A");
        a.estimate = Some(Estimate {
            hours: None,
            cost: Some(10.0),
        });
        let mut b = make_task("b", "B");
        b.after = vec!["a".to_string()];
        b.estimate = Some(Estimate {
            hours: None,
            cost: Some(20.0),
        });
        let mut c = make_task("c", "C");
        c.after = vec!["b".to_string()];
        c.estimate = Some(Estimate {
            hours: None,
            cost: Some(30.0),
        });

        graph.add_node(Node::Task(a));
        graph.add_node(Node::Task(b));
        graph.add_node(Node::Task(c));

        let result = tasks_within_budget(&graph, 100.0);
        assert_eq!(
            result.fits.len(),
            3,
            "All three should fit within cascading plan"
        );
        assert_eq!(result.fits[0].id, "a");
        assert_eq!(result.fits[1].id, "b");
        assert_eq!(result.fits[2].id, "c");
        assert_eq!(result.remaining, 40.0);
    }

    // ========== cost_of() edge cases ==========

    #[test]
    fn test_cost_of_nonexistent_dep_in_chain() {
        // Task references a nonexistent dependency
        let mut graph = WorkGraph::new();

        let mut task = make_task("t", "Task");
        task.after = vec!["ghost".to_string()];
        task.estimate = Some(Estimate {
            hours: None,
            cost: Some(100.0),
        });

        graph.add_node(Node::Task(task));

        // "ghost" doesn't exist -> cost_of returns 0 for it
        assert_eq!(cost_of(&graph, "t"), 100.0);
    }

    #[test]
    fn test_cost_of_self_blocking() {
        // Task references itself as a blocker (degenerate cycle of length 1)
        let mut graph = WorkGraph::new();

        let mut task = make_task("self", "Self-blocking");
        task.after = vec!["self".to_string()];
        task.estimate = Some(Estimate {
            hours: None,
            cost: Some(50.0),
        });

        graph.add_node(Node::Task(task));

        // Should not infinite loop; visited set catches it
        let cost = cost_of(&graph, "self");
        assert_eq!(cost, 50.0);
    }

    #[test]
    fn test_cost_of_deep_chain() {
        // Chain of 5: e -> d -> c -> b -> a, each costs 10
        let mut graph = WorkGraph::new();

        let mut a = make_task("a", "A");
        a.estimate = Some(Estimate {
            hours: None,
            cost: Some(10.0),
        });

        let mut b = make_task("b", "B");
        b.after = vec!["a".to_string()];
        b.estimate = Some(Estimate {
            hours: None,
            cost: Some(10.0),
        });

        let mut c = make_task("c", "C");
        c.after = vec!["b".to_string()];
        c.estimate = Some(Estimate {
            hours: None,
            cost: Some(10.0),
        });

        let mut d = make_task("d", "D");
        d.after = vec!["c".to_string()];
        d.estimate = Some(Estimate {
            hours: None,
            cost: Some(10.0),
        });

        let mut e = make_task("e", "E");
        e.after = vec!["d".to_string()];
        e.estimate = Some(Estimate {
            hours: None,
            cost: Some(10.0),
        });

        graph.add_node(Node::Task(a));
        graph.add_node(Node::Task(b));
        graph.add_node(Node::Task(c));
        graph.add_node(Node::Task(d));
        graph.add_node(Node::Task(e));

        assert_eq!(cost_of(&graph, "e"), 50.0);
    }

    #[test]
    fn test_cost_of_diamond_no_double_count() {
        // Diamond: a -> b, a -> c, b -> d, c -> d
        // d should count a,b,c,d each once
        let mut graph = WorkGraph::new();

        let mut a = make_task("a", "A");
        a.estimate = Some(Estimate {
            hours: None,
            cost: Some(10.0),
        });

        let mut b = make_task("b", "B");
        b.after = vec!["a".to_string()];
        b.estimate = Some(Estimate {
            hours: None,
            cost: Some(20.0),
        });

        let mut c = make_task("c", "C");
        c.after = vec!["a".to_string()];
        c.estimate = Some(Estimate {
            hours: None,
            cost: Some(30.0),
        });

        let mut d = make_task("d", "D");
        d.after = vec!["b".to_string(), "c".to_string()];
        d.estimate = Some(Estimate {
            hours: None,
            cost: Some(40.0),
        });

        graph.add_node(Node::Task(a));
        graph.add_node(Node::Task(b));
        graph.add_node(Node::Task(c));
        graph.add_node(Node::Task(d));

        // d(40) + b(20) + c(30) + a(10) = 100 (a counted only once)
        assert_eq!(cost_of(&graph, "d"), 100.0);
    }

    #[test]
    fn test_cost_of_no_estimate() {
        // Task with no estimate should contribute 0
        let mut graph = WorkGraph::new();
        graph.add_node(Node::Task(make_task("t", "No estimate")));
        assert_eq!(cost_of(&graph, "t"), 0.0);
    }

    // ========== ready_tasks with various terminal statuses ==========

    #[test]
    fn test_ready_tasks_excludes_in_progress() {
        let mut graph = WorkGraph::new();
        let mut task = make_task("t", "In progress");
        task.status = Status::InProgress;
        graph.add_node(Node::Task(task));

        let ready = ready_tasks(&graph);
        assert!(ready.is_empty(), "InProgress tasks should not be ready");
    }

    #[test]
    fn test_ready_tasks_excludes_failed() {
        let mut graph = WorkGraph::new();
        let mut task = make_task("t", "Failed");
        task.status = Status::Failed;
        graph.add_node(Node::Task(task));

        let ready = ready_tasks(&graph);
        assert!(ready.is_empty(), "Failed tasks should not be ready");
    }

    #[test]
    fn test_ready_tasks_excludes_abandoned() {
        let mut graph = WorkGraph::new();
        let mut task = make_task("t", "Abandoned");
        task.status = Status::Abandoned;
        graph.add_node(Node::Task(task));

        let ready = ready_tasks(&graph);
        assert!(ready.is_empty(), "Abandoned tasks should not be ready");
    }

    #[test]
    fn test_ready_tasks_excludes_done_status() {
        let mut graph = WorkGraph::new();
        let mut task = make_task("t", "Done task");
        task.status = Status::Done;
        graph.add_node(Node::Task(task));

        let ready = ready_tasks(&graph);
        assert!(ready.is_empty(), "Done tasks should not be ready");
    }

    // ========== is_time_ready with ready_after ==========

    #[test]
    fn test_is_time_ready_future_ready_after() {
        let mut task = make_task("t", "Task");
        task.ready_after = Some("2099-01-01T00:00:00Z".to_string());
        assert!(!is_time_ready(&task), "Future ready_after should block");
    }

    #[test]
    fn test_is_time_ready_past_ready_after() {
        let mut task = make_task("t", "Task");
        task.ready_after = Some("2020-01-01T00:00:00Z".to_string());
        assert!(is_time_ready(&task), "Past ready_after should be ready");
    }

    #[test]
    fn test_is_time_ready_invalid_ready_after() {
        let mut task = make_task("t", "Task");
        task.ready_after = Some("garbage".to_string());
        assert!(
            is_time_ready(&task),
            "Invalid ready_after should be treated as ready"
        );
    }

    #[test]
    fn test_is_time_ready_both_timestamps_past() {
        let mut task = make_task("t", "Task");
        task.not_before = Some("2020-01-01T00:00:00Z".to_string());
        task.ready_after = Some("2020-06-01T00:00:00Z".to_string());
        assert!(is_time_ready(&task));
    }

    #[test]
    fn test_is_time_ready_not_before_past_ready_after_future() {
        let mut task = make_task("t", "Task");
        task.not_before = Some("2020-01-01T00:00:00Z".to_string());
        task.ready_after = Some("2099-01-01T00:00:00Z".to_string());
        assert!(
            !is_time_ready(&task),
            "Future ready_after should still block"
        );
    }

    // -----------------------------------------------------------------------
    // is_blocker_satisfied tests
    // -----------------------------------------------------------------------

    #[test]
    fn is_blocker_satisfied_local_done() {
        let mut graph = WorkGraph::new();
        let mut t = make_task("blocker", "Blocker");
        t.status = Status::Done;
        graph.add_node(Node::Task(t));

        assert!(is_blocker_satisfied("blocker", &graph, None));
    }

    #[test]
    fn is_blocker_satisfied_local_open() {
        let mut graph = WorkGraph::new();
        let t = make_task("blocker", "Blocker");
        graph.add_node(Node::Task(t));

        assert!(!is_blocker_satisfied("blocker", &graph, None));
    }

    #[test]
    fn is_blocker_satisfied_local_missing_treated_as_blocked() {
        // Missing local blockers block dispatch (prevents premature execution
        // when dependencies haven't been created yet in burst graphs)
        let graph = WorkGraph::new();
        assert!(!is_blocker_satisfied("nonexistent", &graph, None));
    }

    #[test]
    fn is_blocker_satisfied_remote_ref_without_dir() {
        let graph = WorkGraph::new();
        // Remote ref without workgraph_dir → treated as blocked
        assert!(!is_blocker_satisfied("peer:task-id", &graph, None));
    }

    // -----------------------------------------------------------------------
    // Cycle bootstrap tests: SCC-aware first-iteration readiness
    // -----------------------------------------------------------------------

    /// Helper to build a CycleConfig with given max_iterations.
    fn make_cycle_config(max_iterations: u32) -> crate::graph::CycleConfig {
        crate::graph::CycleConfig {
            max_iterations,
            guard: None,
            delay: None,
            no_converge: false,
            restart_on_failure: true,
            max_failure_restarts: None,
        }
    }

    #[test]
    fn test_cycle_aware_mutual_dep_both_have_cycle_config() {
        // A↔B cycle, both with max_iterations.
        // Only the header (a, smallest ID) should be ready — the B→A edge
        // is a back-edge (ignored), so A is unblocked. B's blocker A is a
        // forward edge, so B waits for A.
        let mut graph = WorkGraph::new();

        let mut a = make_task("a", "Task A");
        a.after = vec!["b".to_string()];
        a.cycle_config = Some(make_cycle_config(3));

        let mut b = make_task("b", "Task B");
        b.after = vec!["a".to_string()];
        b.cycle_config = Some(make_cycle_config(3));

        graph.add_node(Node::Task(a));
        graph.add_node(Node::Task(b));

        let cycle_analysis = CycleAnalysis::from_graph(&graph);
        let ready = ready_tasks_cycle_aware(&graph, &cycle_analysis);

        let ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
        assert_eq!(ids.len(), 1, "Only the header should be ready");
        assert_eq!(
            ready[0].id, cycle_analysis.cycles[0].header,
            "The ready task should be the cycle header"
        );
    }

    #[test]
    fn test_cycle_aware_mutual_dep_only_one_has_cycle_config() {
        // A↔B cycle, only A has max_iterations.
        // Only the header should be ready — back-edge exemption is structural,
        // not dependent on cycle_config.
        let mut graph = WorkGraph::new();

        let mut a = make_task("a", "Task A");
        a.after = vec!["b".to_string()];
        a.cycle_config = Some(make_cycle_config(3));

        let mut b = make_task("b", "Task B");
        b.after = vec!["a".to_string()];
        // b has no cycle_config

        graph.add_node(Node::Task(a));
        graph.add_node(Node::Task(b));

        let cycle_analysis = CycleAnalysis::from_graph(&graph);
        let ready = ready_tasks_cycle_aware(&graph, &cycle_analysis);

        let ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
        assert_eq!(ids.len(), 1, "Only the header should be ready");
        assert_eq!(
            ready[0].id, cycle_analysis.cycles[0].header,
            "The ready task should be the cycle header"
        );
    }

    #[test]
    fn test_cycle_aware_three_node_cycle_header_only() {
        // A→B→C→A three-node cycle: only the header should be ready.
        // The back-edge (C→A in forward graph) is skipped, making A ready.
        // B waits for A (forward), C waits for B (forward).
        let mut graph = WorkGraph::new();

        let mut a = make_task("a", "Task A");
        a.after = vec!["c".to_string()];
        a.cycle_config = Some(make_cycle_config(3));

        let mut b = make_task("b", "Task B");
        b.after = vec!["a".to_string()];
        b.cycle_config = Some(make_cycle_config(3));

        let mut c = make_task("c", "Task C");
        c.after = vec!["b".to_string()];
        c.cycle_config = Some(make_cycle_config(3));

        graph.add_node(Node::Task(a));
        graph.add_node(Node::Task(b));
        graph.add_node(Node::Task(c));

        let cycle_analysis = CycleAnalysis::from_graph(&graph);
        let ready = ready_tasks_cycle_aware(&graph, &cycle_analysis);

        assert_eq!(ready.len(), 1, "Only the header should be ready");
        assert_eq!(
            ready[0].id, cycle_analysis.cycles[0].header,
            "The ready task should be the cycle header"
        );
    }

    #[test]
    fn test_cycle_aware_external_dep_still_blocks() {
        // A↔B cycle where A also depends on C (external, Open).
        // A is header (has external dep C as entry). Back-edge: B→A.
        // A: back-edge from B skipped, but C is Open → A blocked by C.
        // B: forward dep on A (not terminal) → B blocked.
        // Neither cycle member is ready until C completes.
        let mut graph = WorkGraph::new();

        let mut a = make_task("a", "Task A");
        a.after = vec!["b".to_string(), "c".to_string()];
        a.cycle_config = Some(make_cycle_config(3));

        let mut b = make_task("b", "Task B");
        b.after = vec!["a".to_string()];
        b.cycle_config = Some(make_cycle_config(3));

        let c = make_task("c", "External Task C");
        // c is NOT in the cycle, not done

        graph.add_node(Node::Task(a));
        graph.add_node(Node::Task(b));
        graph.add_node(Node::Task(c));

        let cycle_analysis = CycleAnalysis::from_graph(&graph);
        let ready = ready_tasks_cycle_aware(&graph, &cycle_analysis);

        let ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
        assert!(!ids.contains(&"a"), "A should be blocked by external dep C");
        assert!(
            !ids.contains(&"b"),
            "B should be blocked by A (forward dep)"
        );
    }

    #[test]
    fn test_cycle_aware_external_dep_done_allows_header() {
        // A↔B cycle, A also depends on C (Done).
        // A is header (entry from C). Back-edge: B→A → skipped.
        // A: C Done + B back-edge → READY.
        // B: forward dep on A (Open) → blocked.
        let mut graph = WorkGraph::new();

        let mut a = make_task("a", "Task A");
        a.after = vec!["b".to_string(), "c".to_string()];
        a.cycle_config = Some(make_cycle_config(3));

        let mut b = make_task("b", "Task B");
        b.after = vec!["a".to_string()];
        b.cycle_config = Some(make_cycle_config(3));

        let mut c = make_task("c", "External Task C");
        c.status = Status::Done;

        graph.add_node(Node::Task(a));
        graph.add_node(Node::Task(b));
        graph.add_node(Node::Task(c));

        let cycle_analysis = CycleAnalysis::from_graph(&graph);
        let ready = ready_tasks_cycle_aware(&graph, &cycle_analysis);

        let ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
        assert_eq!(ids.len(), 1, "Only the header should be ready");
        assert!(
            ids.contains(&"a"),
            "A (header) should be ready after C done"
        );
    }

    #[test]
    fn test_cycle_aware_self_loop_bootstraps() {
        // Self-loop: A→A with max_iterations: should be ready on iteration 0
        let mut graph = WorkGraph::new();

        let mut a = make_task("a", "Task A");
        a.after = vec!["a".to_string()];
        a.cycle_config = Some(make_cycle_config(3));

        graph.add_node(Node::Task(a));

        let cycle_analysis = CycleAnalysis::from_graph(&graph);
        let ready = ready_tasks_cycle_aware(&graph, &cycle_analysis);

        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].id, "a", "Self-loop should bootstrap");
    }

    #[test]
    fn test_cycle_aware_iteration_1_header_ready() {
        // After iteration 0 (re-opened), the header is still ready because
        // back-edge exemption is structural — it works on every iteration,
        // not just iteration 0.
        let mut graph = WorkGraph::new();

        let mut a = make_task("a", "Task A");
        a.after = vec!["b".to_string()];
        a.cycle_config = Some(make_cycle_config(3));
        a.loop_iteration = 1; // past first iteration

        let mut b = make_task("b", "Task B");
        b.after = vec!["a".to_string()];
        b.cycle_config = Some(make_cycle_config(3));
        b.loop_iteration = 1;

        graph.add_node(Node::Task(a));
        graph.add_node(Node::Task(b));

        let cycle_analysis = CycleAnalysis::from_graph(&graph);
        let ready = ready_tasks_cycle_aware(&graph, &cycle_analysis);

        // The header's back-edge blocker is skipped; the non-header waits.
        assert_eq!(
            ready.len(),
            1,
            "Exactly one task (the header) should be ready"
        );
        let header_id = &cycle_analysis.cycles[0].header;
        assert_eq!(
            ready[0].id, *header_id,
            "Only the cycle header should be ready"
        );
    }

    #[test]
    fn test_cycle_aware_non_cycle_tasks_unaffected() {
        // Non-cycle tasks with loop_iteration == 0 should NOT get false exemptions
        let mut graph = WorkGraph::new();

        let a = make_task("a", "Task A");
        let mut b = make_task("b", "Task B");
        b.after = vec!["a".to_string()]; // linear dep, no cycle

        graph.add_node(Node::Task(a));
        graph.add_node(Node::Task(b));

        let cycle_analysis = CycleAnalysis::from_graph(&graph);
        let ready = ready_tasks_cycle_aware(&graph, &cycle_analysis);

        // Only A should be ready; B is blocked by A (no SCC membership)
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].id, "a");
    }

    // -----------------------------------------------------------------------
    // Cycle entry tests: external trigger + mixed cycle_config/worker nodes
    // -----------------------------------------------------------------------

    #[test]
    fn test_cycle_entry_external_trigger() {
        // External(done) → Header(cc) ↔ Worker(no cc)
        // Header is the cycle entry (has ext). Back-edge: worker→header.
        // Header: ext Done + worker back-edge skipped → READY.
        // Worker: forward dep on header (Open) → blocked.
        let mut graph = WorkGraph::new();

        let mut ext = make_task("ext", "External trigger");
        ext.status = Status::Done;

        let mut header = make_task("header", "Cycle header");
        header.after = vec!["ext".to_string(), "worker".to_string()];
        header.cycle_config = Some(make_cycle_config(3));

        let mut worker = make_task("worker", "Cycle worker");
        worker.after = vec!["header".to_string()];
        // worker has NO cycle_config

        graph.add_node(Node::Task(ext));
        graph.add_node(Node::Task(header));
        graph.add_node(Node::Task(worker));

        let cycle_analysis = CycleAnalysis::from_graph(&graph);
        let ready = ready_tasks_cycle_aware(&graph, &cycle_analysis);

        let ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
        assert_eq!(ids.len(), 1, "Only header should be ready");
        assert!(
            ids.contains(&"header"),
            "Header should be ready (ext Done, worker back-edge skipped)"
        );
    }

    #[test]
    fn test_cycle_entry_external_not_done_blocks() {
        // External(Open) → Header(cc) ↔ Worker(no cc)
        // Header blocked by ext (Open). Worker blocked by header (forward dep).
        // Neither cycle member is ready.
        let mut graph = WorkGraph::new();

        let ext = make_task("ext", "External trigger"); // Open (not done)

        let mut header = make_task("header", "Cycle header");
        header.after = vec!["ext".to_string(), "worker".to_string()];
        header.cycle_config = Some(make_cycle_config(3));

        let mut worker = make_task("worker", "Cycle worker");
        worker.after = vec!["header".to_string()];

        graph.add_node(Node::Task(ext));
        graph.add_node(Node::Task(header));
        graph.add_node(Node::Task(worker));

        let cycle_analysis = CycleAnalysis::from_graph(&graph);
        let ready = ready_tasks_cycle_aware(&graph, &cycle_analysis);

        let ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
        assert!(
            !ids.contains(&"header"),
            "Header should be blocked by undone external dep"
        );
        assert!(
            !ids.contains(&"worker"),
            "Worker should be blocked by header (forward dep)"
        );
    }

    #[test]
    fn test_cycle_entry_worker_chain() {
        // External(done) → Header(cc) → W1 → W2 → Header
        // SCC = {header, w1, w2}. Header is entry (has ext).
        // Back-edge: w2→header. Forward: header→w1→w2.
        // Header: ext Done + w2 back-edge → READY.
        // W1: forward dep on header (Open) → blocked.
        // W2: forward dep on w1 (Open) → blocked.
        let mut graph = WorkGraph::new();

        let mut ext = make_task("ext", "External trigger");
        ext.status = Status::Done;

        let mut header = make_task("header", "Cycle header");
        header.after = vec!["ext".to_string(), "w2".to_string()];
        header.cycle_config = Some(make_cycle_config(3));

        let mut w1 = make_task("w1", "Worker 1");
        w1.after = vec!["header".to_string()];

        let mut w2 = make_task("w2", "Worker 2");
        w2.after = vec!["w1".to_string()];

        graph.add_node(Node::Task(ext));
        graph.add_node(Node::Task(header));
        graph.add_node(Node::Task(w1));
        graph.add_node(Node::Task(w2));

        let cycle_analysis = CycleAnalysis::from_graph(&graph);
        let ready = ready_tasks_cycle_aware(&graph, &cycle_analysis);

        let ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
        assert_eq!(ids, vec!["header"], "Only header should be ready");
    }

    #[test]
    fn test_cycle_entry_nested_diamond() {
        // Diamond within a cycle: header(cc) → [wA, wB] → join → header
        // External(done) → header
        // SCC = {header, wA, wB, join}. Header is entry (has ext).
        // Back-edge: join→header. Forward: header→wA, header→wB, wA→join, wB→join.
        // Only header is ready (ext Done + join back-edge). All others blocked.
        let mut graph = WorkGraph::new();

        let mut ext = make_task("ext", "External trigger");
        ext.status = Status::Done;

        let mut header = make_task("header", "Cycle header");
        header.after = vec!["ext".to_string(), "join".to_string()];
        header.cycle_config = Some(make_cycle_config(3));

        let mut wa = make_task("wa", "Worker A");
        wa.after = vec!["header".to_string()];

        let mut wb = make_task("wb", "Worker B");
        wb.after = vec!["header".to_string()];

        let mut join = make_task("join", "Join task");
        join.after = vec!["wa".to_string(), "wb".to_string()];

        graph.add_node(Node::Task(ext));
        graph.add_node(Node::Task(header));
        graph.add_node(Node::Task(wa));
        graph.add_node(Node::Task(wb));
        graph.add_node(Node::Task(join));

        let cycle_analysis = CycleAnalysis::from_graph(&graph);
        let ready = ready_tasks_cycle_aware(&graph, &cycle_analysis);

        let ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
        assert_eq!(ids, vec!["header"], "Only header should be ready");
    }

    #[test]
    fn test_cycle_entry_no_false_positives() {
        // A task with cycle_config that is NOT in a cycle (no back-edge) should
        // NOT get bootstrap exemption for its non-SCC blocker.
        let mut graph = WorkGraph::new();

        let blocker = make_task("blocker", "Blocker task"); // Open, no cc

        let mut task_with_cc = make_task("task_cc", "Task with cycle config");
        task_with_cc.after = vec!["blocker".to_string()];
        task_with_cc.cycle_config = Some(make_cycle_config(3));
        // No back-edge from blocker → task_cc, so they're in separate SCCs

        // Also add a linear chain with no cycle_config to verify it's unaffected
        let a = make_task("a", "Linear A");
        let mut b = make_task("b", "Linear B");
        b.after = vec!["a".to_string()];

        graph.add_node(Node::Task(blocker));
        graph.add_node(Node::Task(task_with_cc));
        graph.add_node(Node::Task(a));
        graph.add_node(Node::Task(b));

        let cycle_analysis = CycleAnalysis::from_graph(&graph);
        let ready = ready_tasks_cycle_aware(&graph, &cycle_analysis);

        let mut ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
        ids.sort();
        // Only blocker and a should be ready (they have no deps)
        // task_cc blocked by blocker (not in same SCC), b blocked by a
        assert_eq!(
            ids,
            vec!["a", "blocker"],
            "Non-cycle tasks and cc tasks without actual cycles should not get false exemptions"
        );
    }

    #[test]
    fn test_auto_breakin_unconfigured_cycle() {
        use crate::graph::{CycleAnalysis, Node, Status, Task, WorkGraph};

        // Helper to create a task
        fn make_task(id: &str, title: &str) -> Task {
            Task {
                id: id.to_string(),
                title: title.to_string(),
                ..Task::default()
            }
        }

        // Create a 3-task cycle without any CycleConfig: A → B → C → A
        let mut graph = WorkGraph::new();

        let mut task_a = make_task("a", "Task A");
        task_a.after = vec!["c".to_string()]; // A depends on C (cycle edge)
        task_a.status = Status::Open;

        let mut task_b = make_task("b", "Task B");
        task_b.after = vec!["a".to_string()]; // B depends on A
        task_b.status = Status::Open;

        let mut task_c = make_task("c", "Task C");
        task_c.after = vec!["b".to_string()]; // C depends on B
        task_c.status = Status::Open;

        graph.add_node(Node::Task(task_a));
        graph.add_node(Node::Task(task_b));
        graph.add_node(Node::Task(task_c));

        let cycle_analysis = CycleAnalysis::from_graph(&graph);

        // Verify that a cycle was detected
        assert!(!cycle_analysis.cycles.is_empty(), "Should detect the cycle");
        assert_eq!(
            cycle_analysis.cycles.len(),
            1,
            "Should be exactly one cycle"
        );

        // Get ready tasks - with auto-break-in, at least one should be ready despite the cycle
        let ready_tasks = ready_tasks_cycle_aware(&graph, &cycle_analysis);

        assert!(
            !ready_tasks.is_empty(),
            "Auto-break-in should make at least one task ready"
        );
        assert_eq!(
            ready_tasks.len(),
            1,
            "Exactly one task should be selected for auto-break-in"
        );

        // The break-in task should be deterministic (alphabetically first)
        let break_in_task = &ready_tasks[0];
        assert_eq!(
            break_in_task.id, "a",
            "Task 'a' should be selected for break-in (alphabetically first)"
        );
    }

    #[test]
    fn test_configured_cycle_not_affected_by_auto_breakin() {
        use crate::graph::{CycleAnalysis, CycleConfig, Node, Status, Task, WorkGraph};

        // Helper to create a task
        fn make_task(id: &str, title: &str) -> Task {
            Task {
                id: id.to_string(),
                title: title.to_string(),
                ..Task::default()
            }
        }

        // Create the same 3-task cycle but with CycleConfig on one member
        let mut graph = WorkGraph::new();

        let mut task_a = make_task("a", "Task A");
        task_a.after = vec!["c".to_string()];
        task_a.status = Status::Open;
        // Add cycle config to make this a configured cycle
        task_a.cycle_config = Some(CycleConfig {
            max_iterations: 3,
            guard: None,
            delay: None,
            no_converge: false,
            restart_on_failure: true,
            max_failure_restarts: None,
        });

        let mut task_b = make_task("b", "Task B");
        task_b.after = vec!["a".to_string()];
        task_b.status = Status::Open;

        let mut task_c = make_task("c", "Task C");
        task_c.after = vec!["b".to_string()];
        task_c.status = Status::Open;

        graph.add_node(Node::Task(task_a));
        graph.add_node(Node::Task(task_b));
        graph.add_node(Node::Task(task_c));

        let cycle_analysis = CycleAnalysis::from_graph(&graph);
        let ready_tasks = ready_tasks_cycle_aware(&graph, &cycle_analysis);

        // With proper CycleConfig, the existing logic should handle it normally
        // The cycle header (task with cycle_config) should be ready via back-edge exemption
        assert!(!ready_tasks.is_empty(), "Cycle header should be ready");

        // Find the ready task - should be the cycle header
        let ready_task = ready_tasks.iter().find(|t| t.cycle_config.is_some());
        assert!(
            ready_task.is_some(),
            "The task with cycle_config should be ready"
        );
        assert_eq!(
            ready_task.unwrap().id,
            "a",
            "Task 'a' (with cycle_config) should be ready"
        );
    }
}
