use std::collections::{HashMap, HashSet};

use crate::schema::{BlockedByRef, Sprint, Task, TaskStatus};
use crate::sprint::numeric_prefix;
use crate::state::{active_blockers, classify, done_ids, TaskState};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NextOptions<'a> {
    pub sprint: Option<&'a str>,
    pub include_area_conflicts: bool,
    /// When true, backlog tasks are evaluated alongside todo tasks (appear in ready/blocked per
    /// their blockers). Default false: backlog tasks are completely excluded (icebox).
    pub include_backlog: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NextTask {
    pub id: String,
    pub title: String,
    pub status: TaskStatus,
    pub sprint: Option<String>,
    pub area: Vec<String>,
    pub gh_issue: Vec<String>,
    pub filename: String,
    pub blockers: Vec<BlockedByRef>,
    /// IDs of in-progress tasks whose area this task shares (resource busy).
    pub area_conflicts: Vec<String>,
    /// IDs of earlier ready tasks selected this run whose area this task shares
    /// (would collide if both started together).
    pub selected_conflicts: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Bottleneck {
    pub id: String,
    pub title: String,
    pub blocked_count: usize,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NextReport {
    pub ready: Vec<NextTask>,
    pub blocked: Vec<NextTask>,
    pub bottleneck: Option<Bottleneck>,
}

pub fn compute_next(tasks: &[Task], sprints: &[Sprint], options: NextOptions<'_>) -> NextReport {
    let task_by_id: HashMap<&str, &Task> = tasks
        .iter()
        .map(|task| (task.frontmatter.id.as_str(), task))
        .collect();
    let done = done_ids(tasks);
    let active_area_tasks = active_area_tasks(tasks);
    let sprint_task_ids = sprint_task_ids(sprints, options.sprint);
    let ordered = ordered_tasks(tasks, sprints, options.sprint);

    let mut ready = Vec::new();
    let mut blocked = Vec::new();
    // area name -> id of the ready task that first claimed it this run.
    let mut claimed_areas: HashMap<String, String> = HashMap::new();

    for task in ordered {
        if !is_candidate(task, sprint_task_ids.as_ref(), options.include_backlog) {
            continue;
        }

        let blockers = active_blockers(task, &done);
        let mut area_conflicts = area_conflicts(task, &active_area_tasks);
        area_conflicts.sort();
        area_conflicts.dedup();

        let mut selected_conflicts: Vec<String> = task
            .frontmatter
            .area
            .iter()
            .filter_map(|area| claimed_areas.get(area).cloned())
            .collect();
        selected_conflicts.sort();
        selected_conflicts.dedup();

        let row = NextTask {
            id: task.frontmatter.id.clone(),
            title: task.frontmatter.title.clone(),
            status: task.frontmatter.status.clone(),
            sprint: task.frontmatter.sprint.clone(),
            area: task.frontmatter.area.clone(),
            gh_issue: task.frontmatter.gh_issue.clone(),
            filename: task.filename.clone(),
            blockers: blockers.clone(),
            area_conflicts: area_conflicts.clone(),
            selected_conflicts: selected_conflicts.clone(),
        };

        if !blockers.is_empty() {
            blocked.push(row);
            continue;
        }

        if !options.include_area_conflicts
            && (!area_conflicts.is_empty() || !selected_conflicts.is_empty())
        {
            blocked.push(row);
            continue;
        }

        for area in &task.frontmatter.area {
            claimed_areas
                .entry(area.clone())
                .or_insert_with(|| task.frontmatter.id.clone());
        }
        ready.push(row);
    }

    NextReport {
        ready,
        blocked,
        bottleneck: bottleneck(tasks, &task_by_id, &done, sprint_task_ids.as_ref()),
    }
}

fn is_candidate(
    task: &Task,
    sprint_task_ids: Option<&HashSet<String>>,
    include_backlog: bool,
) -> bool {
    let status_ok = if include_backlog {
        matches!(
            task.frontmatter.status,
            TaskStatus::Backlog | TaskStatus::Todo
        )
    } else {
        matches!(task.frontmatter.status, TaskStatus::Todo)
    };
    if !status_ok {
        return false;
    }
    if let Some(sprint_task_ids) = sprint_task_ids {
        return sprint_task_ids.contains(&task.frontmatter.id);
    }
    true
}

fn active_area_tasks(tasks: &[Task]) -> HashMap<String, Vec<String>> {
    let mut by_area: HashMap<String, Vec<String>> = HashMap::new();
    for task in tasks {
        if !matches!(task.frontmatter.status, TaskStatus::InProgress) {
            continue;
        }
        for area in &task.frontmatter.area {
            by_area
                .entry(area.clone())
                .or_default()
                .push(task.frontmatter.id.clone());
        }
    }
    by_area
}

fn area_conflicts(task: &Task, active_area_tasks: &HashMap<String, Vec<String>>) -> Vec<String> {
    task.frontmatter
        .area
        .iter()
        .filter_map(|area| active_area_tasks.get(area))
        .flatten()
        .cloned()
        .collect()
}

fn ordered_tasks<'a>(tasks: &'a [Task], sprints: &[Sprint], sprint: Option<&str>) -> Vec<&'a Task> {
    let task_by_id: HashMap<&str, &Task> = tasks
        .iter()
        .map(|task| (task.frontmatter.id.as_str(), task))
        .collect();
    let selected_sprint = sprint
        .and_then(|id| sprints.iter().find(|s| s.header.id == id))
        .or_else(|| latest_sprint(sprints));

    let mut ordered = Vec::new();
    let mut seen = HashSet::new();
    if let Some(sprint) = selected_sprint {
        for entry in &sprint.task_ids {
            let id = numeric_prefix(entry);
            if let Some(task) = task_by_id.get(id) {
                ordered.push(*task);
                seen.insert(task.frontmatter.id.as_str());
            }
        }
    }
    for task in tasks {
        if seen.insert(task.frontmatter.id.as_str()) {
            ordered.push(task);
        }
    }
    ordered
}

fn sprint_task_ids(sprints: &[Sprint], sprint: Option<&str>) -> Option<HashSet<String>> {
    let sprint = sprint.and_then(|id| sprints.iter().find(|s| s.header.id == id))?;
    Some(
        sprint
            .task_ids
            .iter()
            .map(|entry| numeric_prefix(entry).to_owned())
            .collect(),
    )
}

fn latest_sprint(sprints: &[Sprint]) -> Option<&Sprint> {
    sprints
        .iter()
        .max_by_key(|sprint| sprint_number(&sprint.header.id))
}

fn sprint_number(id: &str) -> u64 {
    id.trim_start_matches('s').parse().unwrap_or(0)
}

fn bottleneck(
    tasks: &[Task],
    task_by_id: &HashMap<&str, &Task>,
    done_ids: &HashSet<&str>,
    sprint_task_ids: Option<&HashSet<String>>,
) -> Option<Bottleneck> {
    let mut counts: HashMap<String, usize> = HashMap::new();
    for task in tasks {
        // Only Blocked tasks (todo with an active blocker) are genuinely
        // waiting. Backlog dependents are iced; in-progress dependents are
        // already moving and should never have been started while blocked.
        if classify(task, done_ids) != TaskState::Blocked {
            continue;
        }
        if let Some(sprint_task_ids) = sprint_task_ids {
            if !sprint_task_ids.contains(&task.frontmatter.id) {
                continue;
            }
        }
        let mut counted_for_task = HashSet::new();
        for blocker in active_blockers(task, done_ids) {
            let BlockedByRef::LocalTask(id) = blocker else {
                continue;
            };
            let Some(blocker_task) = task_by_id.get(id.as_str()) else {
                continue;
            };
            // A bottleneck must itself be resolvable next: todo or backlog.
            // In-progress work is the current focus, not a bottleneck.
            if matches!(
                classify(blocker_task, done_ids),
                TaskState::Active | TaskState::Done | TaskState::Archived
            ) {
                continue;
            }
            if counted_for_task.insert(id.clone()) {
                *counts.entry(id).or_default() += 1;
            }
        }
    }

    counts
        .into_iter()
        .max_by(|(left_id, left_count), (right_id, right_count)| {
            left_count
                .cmp(right_count)
                .then_with(|| right_id.cmp(left_id))
        })
        .and_then(|(id, count)| {
            task_by_id.get(id.as_str()).map(|task| Bottleneck {
                id: task.frontmatter.id.clone(),
                title: task.frontmatter.title.clone(),
                blocked_count: count,
            })
        })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::parse::parse_task;
    use crate::sprint::parse_sprint;

    fn task(id: &str, title: &str, status: &str, extra: &str) -> Task {
        let content =
            format!("---\nid: \"{id}\"\ntitle: \"{title}\"\nstatus: {status}\n{extra}\n---\n");
        parse_task(&content, &format!("{id}-task.md")).unwrap()
    }

    #[test]
    fn ready_excludes_unfinished_local_blockers() {
        let tasks = vec![
            task("0001", "A", "todo", ""),
            task("0002", "B", "todo", "blocked_by: [\"0001\"]"),
        ];
        let report = compute_next(
            &tasks,
            &[],
            NextOptions {
                sprint: None,
                include_area_conflicts: false,
                include_backlog: false,
            },
        );
        assert_eq!(
            report
                .ready
                .iter()
                .map(|t| t.id.as_str())
                .collect::<Vec<_>>(),
            vec!["0001"]
        );
        assert_eq!(report.blocked[0].id, "0002");
    }

    #[test]
    fn done_local_blockers_make_task_ready() {
        let tasks = vec![
            task("0001", "A", "done", ""),
            task("0002", "B", "todo", "blocked_by: [\"0001\"]"),
        ];
        let report = compute_next(
            &tasks,
            &[],
            NextOptions {
                sprint: None,
                include_area_conflicts: false,
                include_backlog: false,
            },
        );
        assert_eq!(report.ready[0].id, "0002");
    }

    #[test]
    fn area_conflict_excludes_ready_task_by_default() {
        let tasks = vec![
            task("0001", "A", "in-progress", "area: [cli]"),
            task("0002", "B", "todo", "area: [cli]"),
            task("0003", "C", "todo", "area: [docs]"),
        ];
        let report = compute_next(
            &tasks,
            &[],
            NextOptions {
                sprint: None,
                include_area_conflicts: false,
                include_backlog: false,
            },
        );
        assert_eq!(
            report
                .ready
                .iter()
                .map(|t| t.id.as_str())
                .collect::<Vec<_>>(),
            vec!["0003"]
        );
        assert_eq!(report.blocked[0].area_conflicts, vec!["0001"]);
    }

    #[test]
    fn area_conflict_with_selected_ready_task_is_attributed() {
        // No in-progress work: 0001 and 0002 share area cli. 0001 is selected
        // ready; 0002 is blocked, attributed to 0001 (not an in-progress task).
        let tasks = vec![
            task("0001", "A", "todo", "area: [cli]"),
            task("0002", "B", "todo", "area: [cli]"),
        ];
        let report = compute_next(
            &tasks,
            &[],
            NextOptions {
                sprint: None,
                include_area_conflicts: false,
                include_backlog: false,
            },
        );
        assert_eq!(report.ready[0].id, "0001");
        assert_eq!(report.blocked[0].id, "0002");
        assert!(report.blocked[0].area_conflicts.is_empty());
        assert_eq!(report.blocked[0].selected_conflicts, vec!["0001"]);
    }

    #[test]
    fn sprint_order_wins() {
        let tasks = vec![task("0001", "A", "todo", ""), task("0002", "B", "todo", "")];
        let sprint = parse_sprint("# Sprint 1 · Jun 1-14\n\n- 0002\n- 0001\n").unwrap();
        let report = compute_next(
            &tasks,
            &[sprint],
            NextOptions {
                sprint: Some("s1"),
                include_area_conflicts: false,
                include_backlog: false,
            },
        );
        assert_eq!(
            report
                .ready
                .iter()
                .map(|t| t.id.as_str())
                .collect::<Vec<_>>(),
            vec!["0002", "0001"]
        );
    }

    #[test]
    fn bottleneck_counts_direct_dependents() {
        let tasks = vec![
            task("0001", "A", "todo", ""),
            task("0002", "B", "todo", "blocked_by: [\"0001\"]"),
            task("0003", "C", "todo", "blocked_by: [\"0001\"]"),
        ];
        let report = compute_next(
            &tasks,
            &[],
            NextOptions {
                sprint: None,
                include_area_conflicts: false,
                include_backlog: false,
            },
        );
        assert_eq!(report.bottleneck.unwrap().blocked_count, 2);
    }

    #[test]
    fn in_progress_blocker_is_not_a_bottleneck() {
        // Regression: an in-progress task that another (illegally in-progress)
        // task lists as a blocker must not surface as a bottleneck.
        let tasks = vec![
            task("0001", "A", "in-progress", ""),
            task("0002", "B", "in-progress", "blocked_by: [\"0001\"]"),
        ];
        let report = compute_next(
            &tasks,
            &[],
            NextOptions {
                sprint: None,
                include_area_conflicts: false,
                include_backlog: false,
            },
        );
        assert_eq!(report.bottleneck, None);
    }

    #[test]
    fn bottleneck_ignores_done_blockers() {
        let tasks = vec![
            task("0001", "A", "done", ""),
            task("0002", "B", "todo", "blocked_by: [\"0001\"]"),
        ];
        let report = compute_next(
            &tasks,
            &[],
            NextOptions {
                sprint: None,
                include_area_conflicts: false,
                include_backlog: false,
            },
        );
        assert_eq!(report.bottleneck, None);
    }

    #[test]
    fn bottleneck_respects_selected_sprint() {
        let tasks = vec![
            task("0001", "A", "todo", ""),
            task("0002", "B", "todo", "blocked_by: [\"0001\"]"),
            task("0003", "C", "todo", "blocked_by: [\"0001\"]"),
        ];
        let sprint = parse_sprint("# Sprint 1 · Jun 1-14\n\n- 0002\n").unwrap();
        let report = compute_next(
            &tasks,
            &[sprint],
            NextOptions {
                sprint: Some("s1"),
                include_area_conflicts: false,
                include_backlog: false,
            },
        );
        assert_eq!(report.bottleneck.unwrap().blocked_count, 1);
    }

    #[test]
    fn bottleneck_ignores_unknown_local_blockers() {
        let tasks = vec![task(
            "0001",
            "A",
            "todo",
            "blocked_by: [\"9999\", \"9999\"]",
        )];
        let report = compute_next(
            &tasks,
            &[],
            NextOptions {
                sprint: None,
                include_area_conflicts: false,
                include_backlog: false,
            },
        );
        assert_eq!(report.bottleneck, None);
    }

    #[test]
    fn bottleneck_ties_choose_lowest_id() {
        let tasks = vec![
            task("0001", "A", "todo", ""),
            task("0002", "B", "todo", ""),
            task("0003", "C", "todo", "blocked_by: [\"0002\"]"),
            task("0004", "D", "todo", "blocked_by: [\"0001\"]"),
        ];
        let report = compute_next(
            &tasks,
            &[],
            NextOptions {
                sprint: None,
                include_area_conflicts: false,
                include_backlog: false,
            },
        );
        assert_eq!(report.bottleneck.unwrap().id, "0001");
    }

    #[test]
    fn backlog_excluded_from_ready_and_blocked_by_default() {
        let tasks = vec![
            task("0001", "A", "backlog", ""),
            task("0002", "B", "backlog", "blocked_by: [\"0001\"]"),
        ];
        let report = compute_next(
            &tasks,
            &[],
            NextOptions {
                sprint: None,
                include_area_conflicts: false,
                include_backlog: false,
            },
        );
        assert!(report.ready.is_empty());
        assert!(report.blocked.is_empty());
    }

    #[test]
    fn backlog_included_when_flag_set() {
        let tasks = vec![
            task("0001", "A", "backlog", ""),
            task("0002", "B", "backlog", "blocked_by: [\"0001\"]"),
        ];
        let report = compute_next(
            &tasks,
            &[],
            NextOptions {
                sprint: None,
                include_area_conflicts: false,
                include_backlog: true,
            },
        );
        assert_eq!(report.ready[0].id, "0001");
        assert_eq!(report.blocked[0].id, "0002");
    }

    #[test]
    fn bottleneck_excludes_backlog_dependents() {
        let tasks = vec![
            task("0001", "A", "todo", ""),
            task("0002", "B", "backlog", "blocked_by: [\"0001\"]"),
            task("0003", "C", "backlog", "blocked_by: [\"0001\"]"),
        ];
        let report = compute_next(
            &tasks,
            &[],
            NextOptions {
                sprint: None,
                include_area_conflicts: false,
                include_backlog: false,
            },
        );
        assert_eq!(report.bottleneck, None);
    }

    #[test]
    fn bottleneck_counts_todo_dependents() {
        let tasks = vec![
            task("0001", "A", "todo", ""),
            task("0002", "B", "todo", "blocked_by: [\"0001\"]"),
            task("0003", "C", "backlog", "blocked_by: [\"0001\"]"),
        ];
        let report = compute_next(
            &tasks,
            &[],
            NextOptions {
                sprint: None,
                include_area_conflicts: false,
                include_backlog: false,
            },
        );
        let bn = report.bottleneck.unwrap();
        assert_eq!(bn.id, "0001");
        assert_eq!(bn.blocked_count, 1);
    }
}
