// Copyright (c) 2026 Joydev GmbH (joydev.com)
// SPDX-License-Identifier: MIT

use anyhow::Result;
use clap::Args;

use joy_core::filter::{self, FilterSpec};
use joy_core::items;
use joy_core::milestones;
use joy_core::model::item::{Item, ItemType, Status, Validity};
use joy_core::store;

use crate::color;
use crate::commands::filter_args::FilterArgs;

#[derive(Args)]
pub struct LsArgs {
    #[command(flatten)]
    filter: FilterArgs,

    /// Show all items (including closed and deferred)
    #[arg(short, long)]
    all: bool,

    /// Show hierarchical tree view
    #[arg(long)]
    tree: bool,

    /// Extra columns to show (comma-separated: milestone, assignee, parent, validity)
    #[arg(short, long, value_delimiter = ',')]
    columns: Vec<String>,

    /// Group tree view by: parent (default), milestone
    #[arg(short, long, default_value = "parent")]
    group: String,

    /// Reverse sort order (newest first instead of oldest first)
    #[arg(short, long)]
    reverse: bool,

    /// Compact output: emoji-only or abbreviations
    #[arg(short = 'S', long)]
    pub short: bool,

    /// Passphrase (non-interactive); without it, encrypted items are skipped.
    #[arg(long)]
    pub passphrase: Option<String>,
}

impl LsArgs {
    pub fn roadmap(all: bool) -> Self {
        Self {
            filter: FilterArgs::default(),
            all,
            tree: true,
            columns: Vec::new(),
            group: "milestone".to_string(),
            reverse: false,
            short: false,
            passphrase: None,
        }
    }
}

pub use joy_core::filter::effective_milestone;

/// Which extra columns to display in table mode.
#[derive(Debug)]
struct ExtraColumns {
    milestone: bool,
    assignee: bool,
    parent: bool,
    validity: bool,
}

/// Valid column keys accepted by `-c, --columns`.
/// `ms` is a short alias for `milestone`; `members` is an alias for
/// `assignee` matching the `-m, --members` filter vocabulary.
const VALID_COLUMN_KEYS: &[&str] = &[
    "milestone",
    "ms",
    "assignee",
    "members",
    "parent",
    "validity",
    "val",
];

impl ExtraColumns {
    /// `decisions` is the `-D` / `--decisions` view flag: it forces the
    /// validity column on, since that is the whole point of the view.
    fn from_args(columns: &[String], decisions: bool) -> Result<Self> {
        for key in columns {
            if !VALID_COLUMN_KEYS.contains(&key.as_str()) {
                anyhow::bail!(
                    "unknown column '{key}'. Valid columns: {}",
                    VALID_COLUMN_KEYS.join(", ")
                );
            }
        }
        Ok(Self {
            milestone: columns.iter().any(|s| s == "milestone" || s == "ms"),
            assignee: columns.iter().any(|s| s == "assignee" || s == "members"),
            parent: columns.iter().any(|s| s == "parent"),
            validity: decisions || columns.iter().any(|s| s == "validity" || s == "val"),
        })
    }
}

pub fn run(args: LsArgs) -> Result<()> {
    let cwd = std::env::current_dir()?;
    let root = store::find_project_root(&cwd).ok_or(joy_core::error::JoyError::NotInitialized)?;

    // ADR-040: install zone keys upfront so encrypted items decrypt
    // transparently. No-op when the project has no Crypt activity.
    crate::crypt_session::ensure_zone_keys(args.passphrase.as_deref())?;

    let (all_items, locked) = items::load_items_with_locked(&root)?;

    if all_items.is_empty() && locked.is_empty() {
        if crate::output::is_json() {
            return crate::output::emit(LsPayload {
                items: Vec::new(),
                total: 0,
                locked: Vec::new(),
            });
        }
        println!("No items. Run `joy add` to create one.");
        return Ok(());
    }

    let spec: FilterSpec = args
        .filter
        .to_spec(&root, args.all || !args.filter.status.is_empty())?;
    let mut filtered: Vec<&Item> = filter::apply(&all_items, &spec);

    if crate::output::is_json() {
        if args.reverse {
            filtered.reverse();
        }
        let total = filtered.len();
        return crate::output::emit(LsPayload {
            items: filtered.into_iter().cloned().collect(),
            total,
            locked: locked
                .iter()
                .map(|l| LockedRow {
                    id: l.id.clone(),
                    zone: l.zone.clone(),
                })
                .collect(),
        });
    }

    if filtered.is_empty() && locked.is_empty() {
        println!("No matching items.");
        return Ok(());
    }

    if args.reverse {
        filtered.reverse();
    }

    let extras = ExtraColumns::from_args(&args.columns, args.filter.decisions)?;

    if args.tree {
        match args.group.as_str() {
            "milestone" | "ms" => {
                let ms_list = milestones::load_milestones(&root)?;
                let project =
                    store::read_project(&store::joy_dir(&root).join(store::PROJECT_FILE))?;
                print_tree_by_milestone(&filtered, &ms_list, &all_items, &project);
            }
            "parent" => print_tree_by_parent(&filtered, &all_items),
            other => anyhow::bail!("unknown group: {other} (use: parent, milestone)"),
        }
    } else {
        print_table(&filtered, &all_items, &extras, &locked);
    }

    Ok(())
}

#[derive(serde::Serialize)]
struct LsPayload {
    items: Vec<Item>,
    total: usize,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    locked: Vec<LockedRow>,
}

#[derive(serde::Serialize)]
struct LockedRow {
    id: String,
    zone: String,
}

/// Detect terminal width: crossterm -> COLUMNS env var -> 80.
fn terminal_width() -> usize {
    #[cfg(feature = "tui")]
    {
        if let Ok((cols, _)) = crossterm::terminal::size() {
            return cols as usize;
        }
    }
    std::env::var("COLUMNS")
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(80)
}

/// Truncate a string to `max_len` display columns, appending "..." if truncated.
fn truncate_title(s: &str, max_len: usize) -> String {
    if display_width(s) <= max_len {
        return s.to_string();
    }
    if max_len <= 3 {
        return ".".repeat(max_len);
    }
    let target = max_len - 3;
    let mut width = 0;
    let mut end = 0;
    for c in s.chars() {
        let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
        if width + w > target {
            break;
        }
        width += w;
        end += c.len_utf8();
    }
    format!("{}...", &s[..end])
}

/// The `val` column cell. A decision with no recorded validity reads as
/// `proposed` (still being decided); a non-decision shows `-` unless one
/// was explicitly set.
fn validity_cell(item: &Item) -> (String, String) {
    if matches!(item.item_type, ItemType::Decision) {
        color::validity_display(&item.validity.unwrap_or(Validity::Proposed))
    } else if let Some(v) = item.validity {
        color::validity_display(&v)
    } else {
        ("-".to_string(), "-".to_string())
    }
}

fn print_table(
    items: &[&Item],
    all_items: &[Item],
    extras: &ExtraColumns,
    locked: &[items::LockedItem],
) {
    let term_width = terminal_width();

    // Pass 1: compute dynamic column widths from data
    let col_raw = |header: &str, f: &dyn Fn(&Item) -> String| -> usize {
        items
            .iter()
            .map(|i| display_width(&f(i)))
            .max()
            .unwrap_or(0)
            .max(display_width(header))
    };

    let h_type = if color::is_short() { "TYP" } else { "TYPE" };
    let h_status = if color::is_short() { "STA" } else { "STATUS" };
    let h_prio = if color::is_short() { "PRI" } else { "PRIO" };
    let h_eff = if color::is_short() { "EFF" } else { "EFFORT" };
    let h_enc = if color::is_short() {
        "ENC"
    } else {
        "ENCRYPTION"
    };

    let locked_id_max = locked.iter().map(|l| l.id.len()).max().unwrap_or(0);
    let w_id = col_raw("ID", &|i| i.id.clone()).max(locked_id_max);
    let w_type = col_raw(h_type, &|i| color::item_type_display(&i.item_type).0);
    let w_status = col_raw(h_status, &|i| color::status_display(&i.status).0);
    let w_prio = col_raw(h_prio, &|i| color::priority_display(&i.priority).0);
    let w_eff = col_raw(h_eff, &|i| color::effort_label(i.effort));

    // ENC column appears only when at least one item in the project is
    // encrypted (own-zone marker on a plaintext or accessible item, or
    // a locked placeholder). Otherwise it is hidden entirely so the
    // table stays compact for plain projects.
    let any_encrypted = items.iter().any(|i| i.crypt_zone.is_some()) || !locked.is_empty();
    let w_enc = if any_encrypted {
        let from_items = items
            .iter()
            .map(|i| i.crypt_zone.as_deref().unwrap_or("-").len())
            .max()
            .unwrap_or(0);
        let from_locked = locked.iter().map(|l| l.zone.len()).max().unwrap_or(0);
        from_items.max(from_locked).max(h_enc.len())
    } else {
        0
    };

    let w_parent = if extras.parent {
        col_raw("PARENT", &|i| {
            i.parent.as_deref().unwrap_or("-").to_string()
        })
    } else {
        0
    };
    let w_ms = if extras.milestone {
        col_raw("MS", &|i| match i.milestone.as_deref() {
            Some(ms) => ms.to_string(),
            None => match effective_milestone(i, all_items) {
                Some(ms) => format!("{ms}*"),
                None => "-".to_string(),
            },
        })
    } else {
        0
    };
    let w_assignee = if extras.assignee {
        col_raw("ASSIGNEE", &|i| {
            if i.assignees.is_empty() {
                "-".to_string()
            } else {
                i.assignees
                    .iter()
                    .map(|a| a.member.as_str())
                    .collect::<Vec<_>>()
                    .join(", ")
            }
        })
    } else {
        0
    };

    let w_val = if extras.validity {
        col_raw("VAL", &|i| validity_cell(i).0)
    } else {
        0
    };

    // Total fixed width (2 spaces between every column).
    let fixed_width = w_id
        + 2
        + w_type
        + 2
        + w_status
        + 2
        + w_prio
        + 2
        + w_eff
        + 2
        + if extras.validity { w_val + 2 } else { 0 }
        + if extras.parent { w_parent + 2 } else { 0 }
        + if extras.milestone { w_ms + 2 } else { 0 }
        + if extras.assignee { w_assignee + 2 } else { 0 }
        + if any_encrypted { w_enc + 2 } else { 0 };

    let min_title_width = 20;
    let title_width = if term_width > fixed_width {
        (term_width - fixed_width).max(min_title_width)
    } else {
        min_title_width
    };

    // Pass 2: print header
    let sep_len = term_width.min(fixed_width + title_width);
    println!("{}", color::label(&"-".repeat(sep_len)));

    let mut header = format!(
        "{}  {}  {}  {}  {}",
        pad_colored(&color::label("ID"), "ID", w_id),
        pad_colored(&color::label(h_type), h_type, w_type),
        pad_colored(&color::label(h_status), h_status, w_status),
        pad_colored(&color::label(h_prio), h_prio, w_prio),
        pad_colored(&color::label(h_eff), h_eff, w_eff),
    );
    if extras.validity {
        header.push_str(&format!(
            "  {}",
            pad_colored(&color::label("VAL"), "VAL", w_val)
        ));
    }
    if extras.parent {
        header.push_str(&format!(
            "  {}",
            pad_colored(&color::label("PARENT"), "PARENT", w_parent)
        ));
    }
    if extras.milestone {
        header.push_str(&format!(
            "  {}",
            pad_colored(&color::label("MS"), "MS", w_ms)
        ));
    }
    if extras.assignee {
        header.push_str(&format!(
            "  {}",
            pad_colored(&color::label("ASSIGNEE"), "ASSIGNEE", w_assignee)
        ));
    }
    if any_encrypted {
        header.push_str(&format!(
            "  {}",
            pad_colored(&color::label(h_enc), h_enc, w_enc)
        ));
    }
    header.push_str(&format!("  {}", color::label("TITLE")));
    println!("{header}");

    println!("{}", color::label(&"-".repeat(sep_len)));

    // Pass 2: print rows
    for item in items {
        let blocked_str = if item.is_blocked_by(all_items) {
            " [blocked]".to_string()
        } else {
            String::new()
        };

        let raw_title = format!("{}{}", item.title, blocked_str);
        let display_title = truncate_title(&raw_title, title_width);

        let colored_title = if !blocked_str.is_empty() && display_title.ends_with("[blocked]") {
            let prefix_len = display_title.len() - "[blocked]".len();
            let prefix = &display_title[..prefix_len];
            format!("{}{}", prefix, color::blocked("[blocked]"))
        } else {
            display_title
        };

        let id_str = color::id(&item.id);
        let (type_raw, type_str) = color::item_type_display(&item.item_type);
        let (status_raw, status_str) = color::status_display(&item.status);
        let (priority_raw, priority_str) = color::priority_display(&item.priority);

        let eff_str = color::effort_indicator(item.effort);
        let eff_raw = color::effort_label(item.effort);

        let mut line = format!(
            "{}  {}  {}  {}  {}",
            pad_colored(&id_str, &item.id, w_id),
            pad_colored(&type_str, &type_raw, w_type),
            pad_colored(&status_str, &status_raw, w_status),
            pad_colored(&priority_str, &priority_raw, w_prio),
            pad_colored(&eff_str, &eff_raw, w_eff),
        );

        if extras.validity {
            let (raw, colored) = validity_cell(item);
            line.push_str(&format!("  {}", pad_colored(&colored, &raw, w_val)));
        }
        if extras.parent {
            let parent_val = item.parent.as_deref().unwrap_or("-");
            line.push_str(&format!(
                "  {}",
                pad_colored(&color::id(parent_val), parent_val, w_parent)
            ));
        }
        if extras.milestone {
            let (ms_val, inherited) = match item.milestone.as_deref() {
                Some(ms) => (ms, false),
                None => match effective_milestone(item, all_items) {
                    Some(ms) => (ms, true),
                    None => ("-", false),
                },
            };
            let display = if inherited {
                format!("{ms_val}*")
            } else {
                ms_val.to_string()
            };
            line.push_str(&format!(
                "  {}",
                pad_colored(&color::id(&display), &display, w_ms)
            ));
        }
        if extras.assignee {
            let assignee_val = if item.assignees.is_empty() {
                "-".to_string()
            } else {
                item.assignees
                    .iter()
                    .map(|a| a.member.as_str())
                    .collect::<Vec<_>>()
                    .join(", ")
            };
            let assignee_val = assignee_val.as_str();
            line.push_str(&format!(
                "  {}",
                pad_colored(assignee_val, assignee_val, w_assignee)
            ));
        }
        if any_encrypted {
            let zone_val = item.crypt_zone.as_deref().unwrap_or("-");
            line.push_str(&format!(
                "  {}",
                pad_colored(&color::inactive(zone_val), zone_val, w_enc)
            ));
        }

        line.push_str(&format!("  {}", colored_title));
        println!("{line}");
    }

    // Locked-encrypted-item rows: stars in the typed columns, the
    // zone in the title slot. Filename gives us the ID, JOYCRYPT magic
    // header gives us the zone -- nothing is decrypted. See JOY-0174-D3.
    for l in locked {
        let stars = "***";
        let mut line = format!(
            "{}  {}  {}  {}  {}",
            pad_colored(&color::inactive(&l.id), &l.id, w_id),
            pad_colored(&color::inactive(stars), stars, w_type),
            pad_colored(&color::inactive(stars), stars, w_status),
            pad_colored(&color::inactive(stars), stars, w_prio),
            pad_colored(&color::inactive(stars), stars, w_eff),
        );
        if extras.validity {
            line.push_str(&format!(
                "  {}",
                pad_colored(&color::inactive(stars), stars, w_val)
            ));
        }
        if extras.parent {
            line.push_str(&format!(
                "  {}",
                pad_colored(&color::inactive(stars), stars, w_parent)
            ));
        }
        if extras.milestone {
            line.push_str(&format!(
                "  {}",
                pad_colored(&color::inactive(stars), stars, w_ms)
            ));
        }
        if extras.assignee {
            line.push_str(&format!(
                "  {}",
                pad_colored(&color::inactive(stars), stars, w_assignee)
            ));
        }
        // ENC column always present here (locked items imply
        // any_encrypted = true), so render the zone name and let the
        // title carry the "no access" hint without repeating the zone.
        line.push_str(&format!(
            "  {}",
            pad_colored(&color::inactive(&l.zone), &l.zone, w_enc)
        ));
        let title = "[encrypted, no access]";
        line.push_str(&format!("  {}", color::inactive(title)));
        println!("{line}");
    }

    let w = terminal_width();
    println!("{}", color::label(&"-".repeat(w)));
    let total = items.len() + locked.len();
    println!("{}", color::label(&color::plural(total, "item")));
}

fn display_width(s: &str) -> usize {
    unicode_width::UnicodeWidthStr::width(s)
}

fn pad_colored(colored: &str, raw: &str, width: usize) -> String {
    let padding = width.saturating_sub(display_width(raw));
    format!("{}{}", colored, " ".repeat(padding))
}

// -- Tree by parent hierarchy (recursive) --

/// Walk up the parent chain of every primary item and pull in any
/// ancestor that is not already in the visible set, so the hierarchy
/// stays connected even when an ancestor is excluded by the current
/// filter or sits in a different milestone bucket.
///
/// Returns the expanded item list and the set of ancestor IDs that
/// were added as context (rendered greyed-out by callers).
fn expand_with_ancestors<'a>(
    primary: &[&'a Item],
    all_items: &'a [Item],
) -> (Vec<&'a Item>, std::collections::HashSet<String>) {
    use std::collections::HashSet;
    let primary_ids: HashSet<String> = primary.iter().map(|i| i.id.clone()).collect();
    let mut visible_ids: HashSet<String> = primary_ids.clone();
    let mut context_ids: HashSet<String> = HashSet::new();
    let mut to_walk: Vec<&'a Item> = primary.to_vec();
    while let Some(item) = to_walk.pop() {
        if let Some(pid) = item.parent.as_deref() {
            if !visible_ids.contains(pid) {
                if let Some(ancestor) = all_items.iter().find(|i| i.id == pid) {
                    visible_ids.insert(pid.to_string());
                    context_ids.insert(pid.to_string());
                    to_walk.push(ancestor);
                }
            }
        }
    }
    let mut expanded: Vec<&'a Item> = primary.to_vec();
    for ctx_id in &context_ids {
        if let Some(item) = all_items.iter().find(|i| &i.id == ctx_id) {
            expanded.push(item);
        }
    }
    (expanded, context_ids)
}

fn print_tree_by_parent(items: &[&Item], all_items: &[Item]) {
    let term_width = terminal_width();

    let (expanded, context_ids) = expand_with_ancestors(items, all_items);
    let primary_count = items.len();

    let item_ids: std::collections::HashSet<&str> =
        expanded.iter().map(|i| i.id.as_str()).collect();

    let roots: Vec<&&Item> = expanded
        .iter()
        .filter(|i| match i.parent.as_deref() {
            None => true,
            Some(pid) => !item_ids.contains(pid),
        })
        .collect();

    // Pass 1: walk the tree to build a flat ordered list of (prefix, item, is_dim).
    let mut rows: Vec<(String, &Item, bool)> = Vec::new();
    for (i, root) in roots.iter().enumerate() {
        let is_last = i == roots.len() - 1;
        collect_tree_rows(root, &expanded, &context_ids, "", is_last, &mut rows);
    }

    // Pass 2: compute column widths from the flat row list.
    let w_id = rows
        .iter()
        .map(|(prefix, item, _)| display_width(&format!("{}{}", prefix, item.id)))
        .max()
        .unwrap_or(0)
        .max(display_width("ID"));
    let h_type = if color::is_short() { "TYP" } else { "TYPE" };
    let h_status = if color::is_short() { "STA" } else { "STATUS" };
    let h_prio = if color::is_short() { "PRI" } else { "PRIO" };
    let h_eff = if color::is_short() { "EFF" } else { "EFFORT" };

    let w_type = rows
        .iter()
        .map(|(_, item, _)| display_width(&color::item_type_display(&item.item_type).0))
        .max()
        .unwrap_or(0)
        .max(display_width(h_type));
    let w_status = rows
        .iter()
        .map(|(_, item, _)| display_width(&color::status_display(&item.status).0))
        .max()
        .unwrap_or(0)
        .max(display_width(h_status));
    let w_prio = rows
        .iter()
        .map(|(_, item, _)| display_width(&color::priority_display(&item.priority).0))
        .max()
        .unwrap_or(0)
        .max(display_width(h_prio));
    let w_eff = rows
        .iter()
        .map(|(_, item, _)| display_width(&color::effort_label(item.effort)))
        .max()
        .unwrap_or(0)
        .max(display_width(h_eff));

    let fixed_width = w_id + 2 + w_type + 2 + w_status + 2 + w_prio + 2 + w_eff + 2;
    let title_width = term_width.saturating_sub(fixed_width);
    let sep_len = term_width.min(fixed_width + title_width);

    // Print header (matches joy ls).
    println!("{}", color::label(&"-".repeat(sep_len)));
    println!(
        "{}  {}  {}  {}  {}  {}",
        pad_colored(&color::label("ID"), "ID", w_id),
        pad_colored(&color::label(h_type), h_type, w_type),
        pad_colored(&color::label(h_status), h_status, w_status),
        pad_colored(&color::label(h_prio), h_prio, w_prio),
        pad_colored(&color::label(h_eff), h_eff, w_eff),
        color::label("TITLE"),
    );
    println!("{}", color::label(&"-".repeat(sep_len)));

    // Print rows.
    for (prefix, item, dim) in &rows {
        let id_with_prefix_raw = format!("{}{}", prefix, item.id);
        let id_with_prefix_colored = if *dim {
            color::inactive(&id_with_prefix_raw)
        } else {
            format!("{}{}", color::label(prefix), color::id(&item.id))
        };

        let (type_raw, type_colored) = color::item_type_display(&item.item_type);
        let (status_raw, status_colored) = color::status_display(&item.status);
        let (priority_raw, priority_colored) = color::priority_display(&item.priority);
        let eff_str = color::effort_indicator(item.effort);
        let eff_raw = color::effort_label(item.effort);

        let title = truncate_title(&item.title, title_width);
        let title_colored = if *dim {
            color::inactive(&title)
        } else {
            title.clone()
        };
        let (type_str, status_str, priority_str) = if *dim {
            (
                color::inactive(&type_raw),
                color::inactive(&status_raw),
                color::inactive(&priority_raw),
            )
        } else {
            (type_colored, status_colored, priority_colored)
        };

        println!(
            "{}  {}  {}  {}  {}  {}",
            pad_colored(&id_with_prefix_colored, &id_with_prefix_raw, w_id),
            pad_colored(&type_str, &type_raw, w_type),
            pad_colored(&status_str, &status_raw, w_status),
            pad_colored(&priority_str, &priority_raw, w_prio),
            pad_colored(&eff_str, &eff_raw, w_eff),
            title_colored,
        );
    }

    // Print footer.
    println!("{}", color::label(&"-".repeat(sep_len)));
    println!("{}", color::label(&color::plural(primary_count, "item")));
}

/// Compute the available title width for a milestone-grouped tree row.
fn tree_title_width(prefix: &str, connector: &str, item: &Item) -> usize {
    let term_width = terminal_width();
    let (type_raw, _) = color::item_type_display(&item.item_type);
    let (status_raw, _) = color::status_display(&item.status);
    let (prio_raw, _) = color::priority_display(&item.priority);

    let chrome_width = display_width(prefix)
        + display_width(connector)
        + display_width(&item.id)
        + 1
        + 3
        + display_width(&type_raw)
        + 3
        + display_width(&status_raw)
        + 3
        + display_width(&prio_raw);

    term_width.saturating_sub(chrome_width)
}

fn collect_tree_rows<'a>(
    item: &'a Item,
    all_items: &[&'a Item],
    context_ids: &std::collections::HashSet<String>,
    prefix: &str,
    is_last: bool,
    rows: &mut Vec<(String, &'a Item, bool)>,
) {
    let connector = if prefix.is_empty() {
        String::new()
    } else if is_last {
        "└── ".to_string()
    } else {
        "├── ".to_string()
    };

    let child_prefix = if prefix.is_empty() {
        "  ".to_string()
    } else if is_last {
        format!("{prefix}    ")
    } else {
        format!("{prefix}│   ")
    };

    let dim = !item.is_active() || context_ids.contains(&item.id);
    rows.push((format!("{prefix}{connector}"), item, dim));

    let children: Vec<&&Item> = all_items
        .iter()
        .filter(|i| i.parent.as_deref() == Some(&item.id))
        .collect();

    for (ci, child) in children.iter().enumerate() {
        let child_is_last = ci == children.len() - 1;
        collect_tree_rows(
            child,
            all_items,
            context_ids,
            &child_prefix,
            child_is_last,
            rows,
        );
    }
}

// -- Tree by milestone (with parent sub-grouping) --

/// Count all items (including closed) belonging to a milestone via effective_milestone.
fn milestone_counts(ms_id: &str, all_items: &[Item]) -> (usize, usize) {
    let linked: Vec<_> = all_items
        .iter()
        .filter(|i| effective_milestone(i, all_items) == Some(ms_id))
        .collect();
    let closed = linked.iter().filter(|i| !i.is_active()).count();
    (closed, linked.len())
}

fn print_tree_by_milestone(
    items: &[&Item],
    ms_list: &[joy_core::model::Milestone],
    all_items: &[Item],
    project: &joy_core::model::Project,
) {
    use std::collections::{BTreeMap, HashSet};

    // Header (matches board style)
    let total = all_items.len();
    let closed = all_items.iter().filter(|i| !i.is_active()).count();
    let ms_count = ms_list.len();
    let header_info = format!(
        "{} · {} milestones · {}/{} items closed",
        project.name, ms_count, closed, total
    );
    let w = terminal_width();
    let sep = color::label(&"-".repeat(w));
    println!("{}", sep);
    println!("{}", color::label(&header_info));
    println!("{}", sep);

    // Collect all milestone IDs using effective milestone (own or inherited from parent)
    let mut groups: BTreeMap<String, Vec<&&Item>> = BTreeMap::new();
    let mut no_milestone: Vec<&&Item> = Vec::new();

    for item in items {
        match effective_milestone(item, all_items) {
            Some(ms_id) => groups.entry(ms_id.to_string()).or_default().push(item),
            None => no_milestone.push(item),
        }
    }

    let known_ids: HashSet<&str> = ms_list.iter().map(|ms| ms.id.as_str()).collect();
    let mut first = true;

    // Print known milestones first (in their defined order)
    for ms in ms_list {
        let children = groups.remove(&ms.id);
        let (closed, total) = milestone_counts(&ms.id, all_items);
        if !first {
            println!();
        }
        first = false;
        let empty = vec![];
        print_milestone_group(
            &ms.id,
            Some(&ms.title),
            ms.date.as_ref(),
            closed,
            total,
            children.as_deref().unwrap_or(&empty),
            all_items,
        );
    }

    // Print unknown milestone IDs (referenced but no .yaml file)
    for (ms_id, children) in &groups {
        if !known_ids.contains(ms_id.as_str()) {
            if !first {
                println!();
            }
            first = false;
            let (closed, total) = milestone_counts(ms_id, all_items);
            print_milestone_group(ms_id, None, None, closed, total, children, all_items);
        }
    }

    // Items without milestone
    if !no_milestone.is_empty() {
        if !first {
            println!();
        }
        let no_ms_total: Vec<_> = all_items
            .iter()
            .filter(|i| effective_milestone(i, all_items).is_none())
            .collect();
        let no_ms_closed = no_ms_total.iter().filter(|i| !i.is_active()).count();
        println!(
            "{} [{}/{}]",
            color::label("(no milestone)"),
            no_ms_closed,
            no_ms_total.len()
        );
        print_parent_grouped_children(&no_milestone, all_items);
    }

    // Footer with statistics (matches board style)
    let new = all_items.iter().filter(|i| i.status == Status::New).count();
    let open = all_items
        .iter()
        .filter(|i| i.status == Status::Open)
        .count();
    let wip = all_items
        .iter()
        .filter(|i| i.status == Status::InProgress)
        .count();
    let review = all_items
        .iter()
        .filter(|i| i.status == Status::Review)
        .count();
    let deferred = all_items
        .iter()
        .filter(|i| i.status == Status::Deferred)
        .count();
    let blocked_count = all_items
        .iter()
        .filter(|i| i.is_blocked_by(all_items))
        .count();

    let mut stats = Vec::new();
    if new > 0 {
        stats.push(format!("{new} new"));
    }
    if open > 0 {
        stats.push(format!("{open} open"));
    }
    if wip > 0 {
        stats.push(format!("{wip} in-progress"));
    }
    if review > 0 {
        stats.push(format!("{review} review"));
    }
    if closed > 0 {
        stats.push(format!("{closed} closed"));
    }
    if deferred > 0 {
        stats.push(format!("{deferred} deferred"));
    }
    if blocked_count > 0 {
        stats.push(format!("{blocked_count} blocked"));
    }

    println!("{}", sep);
    println!("{}", color::label(&stats.join(" · ")));
}

fn print_milestone_group(
    id: &str,
    title: Option<&str>,
    date: Option<&chrono::NaiveDate>,
    closed: usize,
    total: usize,
    children: &[&&Item],
    all_items: &[Item],
) {
    let date_str = date.map(|d| format!(" ({})", d)).unwrap_or_default();
    let title_str = title.unwrap_or("(undefined)");

    println!(
        "{} {}{} [{}/{}]",
        color::id(id),
        color::label(title_str),
        color::label(&date_str),
        closed,
        total
    );
    print_parent_grouped_children(children, all_items);
}

/// Print children grouped by parent hierarchy within a milestone group.
/// Ancestors of primary items that live outside this bucket are pulled
/// in as context (rendered greyed-out) so the hierarchy stays visible.
fn print_parent_grouped_children(items: &[&&Item], all_items: &[Item]) {
    let primary_refs: Vec<&Item> = items.iter().map(|r| **r).collect();
    let (expanded, context_ids) = expand_with_ancestors(&primary_refs, all_items);

    let item_ids: std::collections::HashSet<&str> =
        expanded.iter().map(|i| i.id.as_str()).collect();

    let roots: Vec<&&Item> = expanded
        .iter()
        .filter(|i| match i.parent.as_deref() {
            None => true,
            Some(pid) => !item_ids.contains(pid),
        })
        .collect();

    for (i, root) in roots.iter().enumerate() {
        let is_last = i == roots.len() - 1;
        print_ms_tree_node(root, &expanded, &context_ids, "  ", is_last);
    }
}

fn print_ms_tree_node(
    item: &Item,
    group: &[&Item],
    context_ids: &std::collections::HashSet<String>,
    prefix: &str,
    is_last: bool,
) {
    let connector = if is_last { "└── " } else { "├── " };
    let child_prefix = if is_last {
        format!("{prefix}    ")
    } else {
        format!("{prefix}│   ")
    };

    let tree_chrome = color::label(&format!("{prefix}{connector}"));

    let children: Vec<&&Item> = group
        .iter()
        .filter(|i| i.parent.as_deref() == Some(&item.id))
        .collect();

    let title_width = tree_title_width(prefix, connector, item);
    let title = truncate_title(&item.title, title_width);

    let (type_raw, type_colored) = color::item_type_display(&item.item_type);
    let (prio_raw, prio_colored) = color::priority_display(&item.priority);

    // Right-align attributes
    let left_raw = format!("{}{}{} {}", prefix, connector, item.id, title);
    let left_width = display_width(&left_raw);
    let attr_raw = format!(
        " [{}] [{}] [{}]",
        type_raw,
        color::status_display(&item.status).0,
        prio_raw
    );
    let attr_width = display_width(&attr_raw);
    let term_width = terminal_width();
    let padding = term_width.saturating_sub(left_width + attr_width);
    let spacer = " ".repeat(padding);

    let dim = !item.is_active() || context_ids.contains(&item.id);
    if dim {
        let (status_raw, _) = color::status_display(&item.status);
        println!(
            "{}{}",
            tree_chrome,
            color::inactive(&format!(
                "{} {}{} [{}] [{}] [{}]",
                item.id, title, spacer, type_raw, status_raw, prio_raw
            ))
        );
    } else {
        let (_, status_colored) = color::status_display(&item.status);
        println!(
            "{}{} {}{} [{}] [{}] [{}]",
            tree_chrome,
            color::id(&item.id),
            title,
            spacer,
            type_colored,
            status_colored,
            prio_colored
        );
    }

    for (ci, child) in children.iter().enumerate() {
        let child_is_last = ci == children.len() - 1;
        print_ms_tree_node(child, group, context_ids, &child_prefix, child_is_last);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn cols(keys: &[&str]) -> Vec<String> {
        keys.iter().map(|s| (*s).to_string()).collect()
    }

    #[test]
    fn columns_accepts_known_keys_and_aliases() {
        let extras =
            ExtraColumns::from_args(&cols(&["milestone", "assignee", "parent"]), false).unwrap();
        assert!(extras.milestone);
        assert!(extras.assignee);
        assert!(extras.parent);

        let extras = ExtraColumns::from_args(&cols(&["ms", "members"]), false).unwrap();
        assert!(extras.milestone);
        assert!(extras.assignee);
        assert!(!extras.parent);
    }

    #[test]
    fn columns_rejects_unknown_key() {
        let err = ExtraColumns::from_args(&cols(&["bogus"]), false).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("unknown column 'bogus'"), "got: {msg}");
        assert!(msg.contains("milestone"), "got: {msg}");
        assert!(msg.contains("assignee"), "got: {msg}");
    }

    #[test]
    fn columns_rejects_when_one_value_in_a_list_is_unknown() {
        let err = ExtraColumns::from_args(&cols(&["assignee", "wrong"]), false).unwrap_err();
        assert!(err.to_string().contains("unknown column 'wrong'"));
    }

    #[test]
    fn empty_columns_is_ok() {
        let extras = ExtraColumns::from_args(&[], false).unwrap();
        assert!(!extras.milestone);
        assert!(!extras.assignee);
        assert!(!extras.parent);
        assert!(!extras.validity);
    }

    #[test]
    fn validity_column_via_key_or_decisions_flag() {
        // Explicit -c validity / -c val turns the column on.
        assert!(
            ExtraColumns::from_args(&cols(&["validity"]), false)
                .unwrap()
                .validity
        );
        assert!(
            ExtraColumns::from_args(&cols(&["val"]), false)
                .unwrap()
                .validity
        );
        // The -D / --decisions view forces it on without -c.
        assert!(ExtraColumns::from_args(&[], true).unwrap().validity);
    }
}
