//! Pipeline rendering: builds the full status text from pipeline items.

use crate::agents::{AgentPool, AgentStatus};
use crate::config::ProjectConfig;
use crate::pipeline_state::{ArchiveReason, Pipeline, PipelineItem, Stage, Status};
use std::collections::{HashMap, HashSet};

/// Map a stage to its display section label, or `None` to skip it entirely.
///
/// This routes through [`Stage::pipeline`] so chat output and the web UI use
/// the same column derivation.  Frozen stories appear in their underlying
/// `resume_to` column (handled inside `Stage::pipeline`) and items in
/// `Stage::Archived` (with non-Blocked reasons) stay hidden.
pub(crate) fn display_section(s: &Stage) -> Option<&'static str> {
    // Archived items with non-Blocked reasons are hidden from chat output.
    if matches!(s, Stage::Archived { reason, .. } if !matches!(reason, ArchiveReason::Blocked { .. }))
    {
        return None;
    }
    Some(match s.pipeline() {
        Pipeline::Backlog => "Backlog",
        Pipeline::Coding => "In Progress",
        Pipeline::Qa => "QA",
        Pipeline::Merge => "Merge",
        Pipeline::Done => "Done",
        Pipeline::Closed => "Closed",
        Pipeline::Archived => return None,
    })
}

/// Check which dependency numbers from `item.depends_on` are unmet.
///
/// A dependency is considered met if the dep is in `Done` or `Archived` stage
/// in `all_items`. If the dep is not found in `all_items` at all (e.g. it was
/// archived before the CRDT migration and has no row), it is treated as met.
pub(crate) fn unmet_deps_from_items(item: &PipelineItem, all_items: &[PipelineItem]) -> Vec<u32> {
    item.depends_on
        .iter()
        .filter_map(|dep_id| {
            // dep_id.0 is the raw number string (e.g. "999") as projected
            // from PipelineItemView.depends_on: Vec<u32>.
            let dep_num: u32 = dep_id.0.parse().ok()?;
            // Find the dep by matching the numeric prefix of its story_id.
            let dep = all_items.iter().find(|i| {
                i.story_id.0 == dep_id.0
                    || i.story_id.0.split('_').next() == Some(dep_id.0.as_str())
            });
            match dep {
                Some(d)
                    if matches!(
                        d.stage,
                        Stage::Done { .. }
                            | Stage::Archived { .. }
                            | Stage::Abandoned { .. }
                            | Stage::Superseded { .. }
                            | Stage::Rejected { .. }
                    ) =>
                {
                    None
                }
                Some(_) => Some(dep_num), // Found but not done = unmet
                None => None,             // Not in CRDT; treat as met
            }
        })
        .collect()
}

/// Extract the first non-empty line from `text`, truncated to `max_len` chars.
pub(crate) fn first_non_empty_snippet(text: &str, max_len: usize) -> String {
    let line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
    let mut chars = line.chars();
    let truncated: String = chars.by_ref().take(max_len).collect();
    if chars.next().is_some() {
        format!("{truncated}…")
    } else {
        truncated
    }
}

/// Build the full pipeline status text formatted for Matrix (markdown).
pub(crate) fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String {
    let items = crate::pipeline_state::read_all_typed();
    build_status_from_items(project_root, agents, &items)
}

/// Inner implementation that accepts pre-loaded items for testability.
pub(crate) fn build_status_from_items(
    project_root: &std::path::Path,
    agents: &AgentPool,
    items: &[PipelineItem],
) -> String {
    // Build a map from story_id → active AgentInfo for quick lookup.
    let active_agents = agents.list_agents_nonblocking();
    let active_map: HashMap<String, &crate::agents::AgentInfo> = active_agents
        .iter()
        .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
        .map(|a| (a.story_id.clone(), a))
        .collect();

    // Build a per-story cost map from the in-memory rollup register.
    // Only completed stories have entries; in-progress stories show no cost.
    let cost_by_story: HashMap<String, f64> =
        crate::service::agents::cost_rollup::all_rollups(project_root)
            .into_iter()
            .map(|r| (r.story_id, r.total_cost_usd))
            .collect();

    let config = ProjectConfig::load(project_root).ok();

    // Pre-fetch working tree state for all Coding-column items whose worktrees exist.
    let dirty_files_by_story: HashMap<String, crate::service::git_ops::DirtyFiles> = items
        .iter()
        .filter(|i| i.stage.pipeline() == Pipeline::Coding && i.stage.status() == Status::Active)
        .filter_map(|i| {
            let wt = crate::worktree::worktree_path(project_root, &i.story_id.0);
            if wt.is_dir() {
                let info = crate::service::git_ops::io::read_dirty_files_sync(&wt);
                Some((i.story_id.0.clone(), info))
            } else {
                None
            }
        })
        .collect();

    // Pre-fetch merge-specific state: deterministic merges in flight and
    // any merge_failure text persisted to the story's front matter.
    let running_merges: HashSet<String> = agents
        .list_running_merges()
        .unwrap_or_default()
        .into_iter()
        .collect();
    // Merge-failure detail now lives on the typed MergeJob CRDT entry
    // (story 929 — CRDT is the sole source of metadata).  Only items in the
    // Merge column with an Active status (i.e. `Stage::Merge { .. }`) need a
    // pre-fetched failure snippet; MergeFailure(Final) items render their
    // own snippet from the typed kind.
    let merge_failures: HashMap<String, String> = items
        .iter()
        .filter(|i| i.stage.pipeline() == Pipeline::Merge && i.stage.status() == Status::Active)
        .filter_map(|i| {
            let job = crate::crdt_state::read_merge_job(&i.story_id.0)?;
            let err = job.error?;
            Some((i.story_id.0.clone(), err))
        })
        .collect();

    let mut out = String::from("**Pipeline Status**\n\n");

    // Render each display section in order.  Blocked items appear in-place
    // under their stage section (determined by `display_section`); there is
    // no separate "Blocked" section.  Frozen items appear under the section
    // their `resume_to` stage maps to.
    let sections = ["Backlog", "In Progress", "QA", "Merge", "Done", "Closed"];

    for label in sections {
        let mut section_items: Vec<&PipelineItem> = items
            .iter()
            .filter(|i| display_section(&i.stage) == Some(label))
            .collect();
        section_items.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0));
        let count = section_items.len();
        out.push_str(&format!("**{label}** ({count})\n"));
        if section_items.is_empty() {
            out.push_str("  *(none)*\n");
        } else {
            let ctx = ItemRenderCtx {
                active_map: &active_map,
                cost_by_story: &cost_by_story,
                config: &config,
                running_merges: &running_merges,
                merge_failures: &merge_failures,
                dirty_files_by_story: &dirty_files_by_story,
            };
            for item in &section_items {
                out.push_str(&render_item_line(item, items, &ctx));
            }
        }
        out.push('\n');
    }

    // Free agents: configured agents not currently running or pending.
    out.push_str("**Free Agents**\n");
    if let Some(cfg) = &config {
        let busy_names: HashSet<String> = active_agents
            .iter()
            .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
            .map(|a| a.agent_name.clone())
            .collect();

        let free: Vec<String> = cfg
            .agent
            .iter()
            .filter(|a| !busy_names.contains(&a.name))
            .map(|a| match &a.model {
                Some(m) => format!("{} ({})", a.name, m),
                None => a.name.clone(),
            })
            .collect();

        if free.is_empty() {
            out.push_str("  *(none — all agents busy)*\n");
        } else {
            for name in &free {
                out.push_str(&format!("  • {name}\n"));
            }
        }
    } else {
        out.push_str("  *(no agent config found)*\n");
    }

    out
}

/// Return an inline working-tree suffix for a story with uncommitted changes.
///
/// Returns an empty string when the working tree is clean. The suffix is
/// appended directly to the coder line, e.g. `, Working tree: 3 modified (uncommitted)`.
/// File paths are not listed here; use `status N` (triage) for the per-file breakdown.
fn working_tree_suffix(info: &crate::service::git_ops::DirtyFiles) -> String {
    if info.is_clean() {
        return String::new();
    }
    let summary = match (info.modified, info.new) {
        (m, 0) => format!("{m} modified"),
        (0, n) => format!("{n} new"),
        (m, n) => format!("{m} modified, {n} new"),
    };
    format!(", Working tree: {summary} (uncommitted)")
}

/// Shared lookup tables passed to [`render_item_line`] to keep the argument count manageable.
struct ItemRenderCtx<'a> {
    active_map: &'a HashMap<String, &'a crate::agents::AgentInfo>,
    cost_by_story: &'a HashMap<String, f64>,
    config: &'a Option<ProjectConfig>,
    running_merges: &'a HashSet<String>,
    merge_failures: &'a HashMap<String, String>,
    dirty_files_by_story: &'a HashMap<String, crate::service::git_ops::DirtyFiles>,
}

/// Render a single status line for one pipeline item.
fn render_item_line(
    item: &PipelineItem,
    all_items: &[PipelineItem],
    ctx: &ItemRenderCtx<'_>,
) -> String {
    let active_map = ctx.active_map;
    let cost_by_story = ctx.cost_by_story;
    let config = ctx.config;
    let running_merges = ctx.running_merges;
    let merge_failures = ctx.merge_failures;
    let dirty_files_by_story = ctx.dirty_files_by_story;
    let story_id = &item.story_id.0;
    let name_opt = if item.name.is_empty() {
        None
    } else {
        Some(item.name.as_str())
    };
    // Use the new Pipeline + Status helpers (story 1085).
    let pipeline = item.stage.pipeline();
    let status = item.stage.status();
    let frozen = status == Status::Frozen;
    let base_label = super::story_short_label(story_id, name_opt);
    let display = if frozen {
        format!("\u{2744}\u{FE0F} {base_label}") // ❄️ prefix
    } else {
        base_label
    };
    let cost_suffix = cost_by_story
        .get(story_id)
        .filter(|&&c| c > 0.0)
        .map(|c| format!(" — ${c:.2}"))
        .unwrap_or_default();
    let agent = active_map.get(story_id);
    let unmet = unmet_deps_from_items(item, all_items);
    let dep_suffix = if unmet.is_empty() {
        String::new()
    } else {
        let nums: Vec<String> = unmet.iter().map(|n| n.to_string()).collect();
        format!(" *(waiting on: {})*", nums.join(", "))
    };

    // Closed-pipeline items (abandoned / superseded / rejected) each get a
    // distinct indicator and optionally display their metadata.
    match status {
        Status::Abandoned => {
            return format!("  \u{1F5D1}\u{FE0F} {display}{cost_suffix}\n"); // 🗑️
        }
        Status::Superseded => {
            let superseded_by = match &item.stage {
                Stage::Superseded { superseded_by, .. } => superseded_by.0.as_str(),
                _ => "",
            };
            return format!(
                "  \u{1F500} {display}{cost_suffix} — superseded by {superseded_by}\n", // 🔀
            );
        }
        Status::Rejected => {
            let reason = match &item.stage {
                Stage::Rejected { reason, .. } => reason.as_str(),
                _ => "",
            };
            let snippet = first_non_empty_snippet(reason, 120);
            return format!("  \u{1F6AB} {display}{cost_suffix} — {snippet}\n"); // 🚫
        }
        _ => {}
    }

    // Merge-column items get dedicated breakdown indicators instead of the
    // generic traffic-light dot.  MergeFailure / MergeFailureFinal items
    // appear in the Merge column (in-place) and are handled by the same arm.
    if pipeline == Pipeline::Merge {
        match status {
            // MergeFailureFinal: mergemaster already tried and gave up — always ⛔.
            Status::MergeFailureFinal => {
                let kind = match &item.stage {
                    Stage::MergeFailureFinal { kind } => kind,
                    _ => unreachable!(),
                };
                let snippet = first_non_empty_snippet(&kind.display_reason(), 120);
                return format!("  \u{26D4} {display}{cost_suffix}{dep_suffix} — {snippet}\n");
            }
            // MergeFailure: a recovery agent may be running or queued.
            Status::MergeFailure => {
                let kind = match &item.stage {
                    Stage::MergeFailure { kind, .. } => kind,
                    _ => unreachable!(),
                };
                return match agent.map(|a| &a.status) {
                    Some(AgentStatus::Running) => format!(
                        "  \u{1F916} {display}{cost_suffix}{dep_suffix} — mergemaster running\n"
                    ),
                    Some(AgentStatus::Pending) => format!(
                        "  \u{23F3} {display}{cost_suffix}{dep_suffix} — mergemaster queued\n"
                    ),
                    _ => {
                        let snippet = first_non_empty_snippet(&kind.display_reason(), 120);
                        format!("  \u{26D4} {display}{cost_suffix}{dep_suffix} — {snippet}\n")
                    }
                };
            }
            _ => {}
        }

        let in_det_merge = running_merges.contains(story_id);
        let merge_failure = merge_failures.get(story_id);
        if in_det_merge {
            // A fresh deterministic merge is in progress — always prefer 🔄,
            // even when a previous attempt recorded a merge_failure.
            return format!(
                "  \u{1F504} {display}{cost_suffix}{dep_suffix} — deterministic-merge running\n"
            );
        } else if agent.is_some() {
            return format!(
                "  \u{1F916} {display}{cost_suffix}{dep_suffix} — mergemaster running\n"
            );
        } else if let Some(mf) = merge_failure {
            let snippet = first_non_empty_snippet(mf, 120);
            return format!("  \u{26D4} {display}{cost_suffix}{dep_suffix} — {snippet}\n");
        } else {
            return format!("  \u{23F3} {display}{cost_suffix}{dep_suffix}\n");
        }
    }

    let blocked = status == Status::Blocked;
    // Blocked items with a recovery agent get differentiated indicators.
    if blocked {
        return match agent.map(|a| &a.status) {
            Some(AgentStatus::Running) => {
                format!("  \u{1F916} {display}{cost_suffix}{dep_suffix} — recovery coder running\n")
            }
            Some(AgentStatus::Pending) => {
                format!("  \u{23F3} {display}{cost_suffix}{dep_suffix} — recovery coder queued\n")
            }
            _ => format!("  \u{1F534} {display}{cost_suffix}{dep_suffix}\n"),
        };
    }
    let throttled = agent
        .and_then(|a| a.throttled)
        .is_some_and(|until| until > chrono::Utc::now());
    let dot = super::traffic_light_dot(blocked, throttled, agent.is_some());
    let wt_suffix = dirty_files_by_story
        .get(story_id)
        .map(working_tree_suffix)
        .unwrap_or_default();
    if let Some(agent) = agent {
        let model_str = config
            .as_ref()
            .and_then(|cfg| cfg.find_agent(&agent.agent_name))
            .and_then(|ac| ac.model.as_ref().map(|m| m.as_str()))
            .unwrap_or("?");
        format!(
            "  {dot}{display}{cost_suffix}{dep_suffix} — {} ({model_str}){wt_suffix}\n",
            agent.agent_name
        )
    } else {
        format!("  {dot}{display}{cost_suffix}{dep_suffix}{wt_suffix}\n")
    }
}
