//! Format `Outcome`s into human-readable lines.

use crate::format::strip_control_chars;
use crate::lint::{self, Diagnosis, Outcome, Status};

/// Convenience: strip control chars from a slice of strings, then
/// join with ", ". Used wherever report renders user-supplied
/// names (matched_sources, prefer, avoid).
fn join_clean(values: &[String]) -> String {
    values
        .iter()
        .map(|v| strip_control_chars(v).into_owned())
        .collect::<Vec<_>>()
        .join(", ")
}

#[derive(Debug, Clone, Copy)]
pub struct Style {
    pub no_glyphs: bool,
    pub verbose: bool,
    pub quiet: bool,
    /// `--explain`: when true, NG outcomes get a multi-line breakdown
    /// of resolved / matched / prefer / avoid / diagnosis / hint
    /// instead of the one-line detail.
    pub explain: bool,
    /// Whether to wrap status tags in ANSI colour escapes. The CLI
    /// layer resolves `--color auto|always|never` against
    /// `is_terminal::IsTerminal` and passes the bool here. 0.0.17
    /// promoted this from a parsed-but-ignored flag to an
    /// effective contract.
    pub color: bool,
}

impl Style {
    /// Should the given outcome appear in the rendered output?
    /// Pure visibility predicate, separated so the rule (`quiet`
    /// drops non-failures, default-mode hides NotApplicable) is
    /// unit-testable without going through `render`.
    pub fn should_show(&self, o: &Outcome) -> bool {
        if self.quiet && !lint::is_failure(&o.status) {
            return false;
        }
        if !self.verbose && matches!(o.status, Status::NotApplicable) {
            return false;
        }
        true
    }
}

pub fn render(outcomes: &[Outcome], style: Style) -> String {
    let mut buf = String::new();
    for o in outcomes.iter().filter(|o| style.should_show(o)) {
        buf.push_str(&render_one(o, style));
        buf.push('\n');
    }
    buf
}

fn render_one(o: &Outcome, style: Style) -> String {
    let raw_tag = status_tag(o, style.no_glyphs);
    let tag = colourize_tag(raw_tag, &o.status, style.color, o.severity);
    // command flows from CLI args / [[expect]] entries which can
    // be arbitrary user strings; strip control chars so a hostile
    // pathlint.toml cannot inject ANSI escapes via `command = "..."`.
    let mut line = format!("{tag} {command}", command = strip_control_chars(&o.command));

    // --explain mode swaps the one-line detail for the multi-line
    // breakdown. Only failure statuses produce explain content;
    // anything else (Ok / Skip / NotApplicable) falls through to
    // the regular detail line so behaviour is unchanged.
    if style.explain && lint::is_failure(&o.status) {
        for ex in explain_lines(o) {
            line.push('\n');
            line.push_str("    ");
            line.push_str(&ex);
        }
        return line;
    }

    if let Some(d) = detail_line(o) {
        line.push('\n');
        line.push_str("    ");
        line.push_str(&d);
    }
    line
}

fn detail_line(o: &Outcome) -> Option<String> {
    // Non-failure statuses don't go through `Diagnosis`; their
    // detail is direct projection of the Outcome.
    match &o.status {
        Status::Ok => {
            return o.resolved.as_ref().map(|p| {
                let sources = if o.matched_sources.is_empty() {
                    String::from("(no source matched)")
                } else {
                    format!("source: {}", join_clean(&o.matched_sources))
                };
                format!(
                    "{} — {}",
                    strip_control_chars(&p.to_string_lossy()),
                    sources
                )
            });
        }
        Status::Skip => return Some("optional; not on PATH".into()),
        Status::NotApplicable => return Some("excluded by os filter".into()),
        _ => {}
    }
    // Failure statuses: render the one-liner from the Diagnosis so
    // the explain view and the default view share a single source
    // of truth.
    lint::diagnose(o).map(|d| detail_one_liner(o, &d))
}

fn detail_one_liner(o: &Outcome, diagnosis: &Diagnosis) -> String {
    let resolved = resolved_or_placeholder(o);
    match diagnosis {
        Diagnosis::WrongSource {
            matched,
            prefer_missed,
            avoid_hits: _,
        } => format!(
            "resolved: {resolved} — matched sources: [{}], prefer: [{}], avoid: [{}]",
            join_clean(matched),
            join_clean(prefer_missed),
            join_clean(&o.avoid),
        ),
        Diagnosis::UnknownSource { prefer } => format!(
            "resolved: {resolved} — matched no defined source; prefer: [{}]",
            join_clean(prefer),
        ),
        Diagnosis::NotFound { .. } => "not found on PATH".into(),
        Diagnosis::NotExecutable { reason, .. } => format!(
            "resolved: {resolved} — not executable: {}",
            strip_control_chars(reason)
        ),
        Diagnosis::Config { message } => strip_control_chars(message).into_owned(),
    }
}

fn status_tag(o: &Outcome, no_glyphs: bool) -> &'static str {
    use crate::config::Severity as RuleSeverity;
    // NG with severity=Warn is rendered as [warn] / warn so the
    // user can tell at a glance whether the failure will block CI.
    let is_warn_failure = lint::is_failure(&o.status) && o.severity == RuleSeverity::Warn;
    match (&o.status, no_glyphs, is_warn_failure) {
        (Status::Ok, false, _) => "[OK]  ",
        (
            Status::NgWrongSource
            | Status::NgUnknownSource
            | Status::NgNotFound
            | Status::NgNotExecutable,
            false,
            true,
        ) => "[warn]",
        (
            Status::NgWrongSource
            | Status::NgUnknownSource
            | Status::NgNotFound
            | Status::NgNotExecutable,
            false,
            false,
        ) => "[NG]  ",
        (Status::Skip, false, _) => "[skip]",
        (Status::NotApplicable, false, _) => "[n/a] ",
        (Status::ConfigError, false, _) => "[ERR] ",
        (Status::Ok, true, _) => "OK   ",
        (
            Status::NgWrongSource
            | Status::NgUnknownSource
            | Status::NgNotFound
            | Status::NgNotExecutable,
            true,
            true,
        ) => "warn ",
        (
            Status::NgWrongSource
            | Status::NgUnknownSource
            | Status::NgNotFound
            | Status::NgNotExecutable,
            true,
            false,
        ) => "NG   ",
        (Status::Skip, true, _) => "skip ",
        (Status::NotApplicable, true, _) => "n/a  ",
        (Status::ConfigError, true, _) => "ERR  ",
    }
}

/// Wrap the raw status tag in an ANSI colour escape when `color`
/// is true; pass through unchanged otherwise. Colours follow the
/// usual lint-output convention: green for OK, red for NG /
/// config error, yellow for warn / skip / n/a.
///
/// 0.0.17 introduced this so the global `--color` flag actually
/// affects output. Pre-0.0.17 the flag was parsed by clap and then
/// silently ignored.
fn colourize_tag(
    raw: &'static str,
    status: &Status,
    color: bool,
    severity: crate::config::Severity,
) -> String {
    if !color {
        return raw.to_string();
    }
    use crate::config::Severity as RuleSeverity;
    let is_warn_failure = lint::is_failure(status) && severity == RuleSeverity::Warn;
    let code = match (status, is_warn_failure) {
        (Status::Ok, _) => "32", // green
        (_, true) => "33",       // yellow (warn-severity NG)
        (Status::ConfigError, _) => "31",
        (
            Status::NgWrongSource
            | Status::NgUnknownSource
            | Status::NgNotFound
            | Status::NgNotExecutable,
            false,
        ) => "31", // red
        (Status::Skip | Status::NotApplicable, _) => "33", // yellow
    };
    format!("\x1b[{code}m{raw}\x1b[0m")
}

/// Render a structured, multi-line diagnosis for a single Outcome.
/// Used by `pathlint check --explain` to expand the one-line detail
/// into resolved / matched / prefer / avoid / diagnosis / hint rows.
///
/// Pure function. Goes through `lint::diagnose` so the human
/// rendering and the JSON rendering share a single source of truth
/// (the `Diagnosis` value). Non-failure statuses return an empty
/// vec; callers fall back to `detail_line`.
pub fn explain_lines(o: &Outcome) -> Vec<String> {
    let Some(diagnosis) = lint::diagnose(o) else {
        return Vec::new();
    };
    explain_lines_from(o, &diagnosis)
}

fn explain_lines_from(o: &Outcome, diagnosis: &Diagnosis) -> Vec<String> {
    let resolved = resolved_or_placeholder(o);
    match diagnosis {
        Diagnosis::WrongSource {
            matched,
            prefer_missed,
            avoid_hits,
        } => vec![
            format!("resolved:        {resolved}"),
            format!("matched sources: {}", join_or_none(matched)),
            format!("prefer:          {}", join_or_none(prefer_missed)),
            format!("avoid:           {}", join_or_none(&o.avoid)),
            format!(
                "diagnosis:       {}",
                wrong_source_sentence(matched, prefer_missed, avoid_hits)
            ),
            format!(
                "hint:            run `pathlint trace {}` for uninstall guidance.",
                strip_control_chars(&o.command)
            ),
        ],
        Diagnosis::UnknownSource { prefer } => vec![
            format!("resolved:        {resolved}"),
            "matched sources: (none — path is outside every defined source)".into(),
            format!("prefer:          {}", join_or_none(prefer)),
            format!("avoid:           {}", join_or_none(&o.avoid)),
            "diagnosis:       command resolves from a directory not declared in any \
                [source.<name>]. Either add a source for that directory or remove the \
                directory from PATH."
                .into(),
            format!(
                "hint:            run `pathlint trace {}` to see the full path; \
                add `[source.X]` matching it if you want this case to pass.",
                strip_control_chars(&o.command)
            ),
        ],
        Diagnosis::NotFound { prefer } => vec![
            format!("command:         {}", strip_control_chars(&o.command)),
            format!("prefer:          {}", join_or_none(prefer)),
            "diagnosis:       command not found on any PATH entry.".into(),
            "hint:            install it via one of the prefer sources, \
                or pass `optional = true` if the rule should accept absence."
                .into(),
        ],
        Diagnosis::NotExecutable { reason, matched } => vec![
            format!("resolved:        {resolved}"),
            format!("matched sources: {}", join_or_none(matched)),
            format!(
                "diagnosis:       not executable — {}",
                strip_control_chars(reason)
            ),
            "hint:            another file/directory of the same name shadows the binary, \
                or the file lost its +x bit / became a broken symlink."
                .into(),
        ],
        Diagnosis::Config { message } => vec![
            format!("config error:    {}", strip_control_chars(message)),
            "hint:            check spelling against `pathlint catalog list --names-only`.".into(),
        ],
    }
}

fn resolved_or_placeholder(o: &Outcome) -> String {
    o.resolved
        .as_ref()
        .map(|p| strip_control_chars(&p.to_string_lossy()).into_owned())
        .unwrap_or_else(|| "<unresolved>".into())
}

fn join_or_none(v: &[String]) -> String {
    if v.is_empty() {
        "(none)".into()
    } else {
        join_clean(v)
    }
}

fn wrong_source_sentence(
    matched: &[String],
    prefer_missed: &[String],
    avoid_hits: &[String],
) -> String {
    if !avoid_hits.is_empty() {
        return format!(
            "resolved path matched `avoid` source(s) [{}]; rule forbids these.",
            join_clean(avoid_hits)
        );
    }
    if prefer_missed.is_empty() {
        return "resolved path matched a source the rule rejects.".into();
    }
    format!(
        "resolved path matched [{}], none of which are in `prefer` [{}].",
        join_clean(matched),
        join_clean(prefer_missed)
    )
}

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

    fn ng_wrong_source(matched: &[&str], prefer: &[&str], avoid: &[&str]) -> Outcome {
        Outcome {
            command: "rg".into(),
            status: Status::NgWrongSource,
            resolved: Some(PathBuf::from("/usr/local/bin/rg")),
            matched_sources: matched.iter().map(|s| s.to_string()).collect(),
            prefer: prefer.iter().map(|s| s.to_string()).collect(),
            avoid: avoid.iter().map(|s| s.to_string()).collect(),
            severity: crate::config::Severity::Error,
            reason: None,
        }
    }

    #[test]
    fn explain_lines_empty_for_ok_status() {
        let o = Outcome {
            command: "rg".into(),
            status: Status::Ok,
            resolved: Some(PathBuf::from("/home/u/.cargo/bin/rg")),
            matched_sources: vec!["cargo".into()],
            prefer: vec!["cargo".into()],
            avoid: vec![],
            severity: crate::config::Severity::Error,
            reason: None,
        };
        assert!(explain_lines(&o).is_empty());
    }

    #[test]
    fn explain_lines_wrong_source_lists_resolved_matched_prefer_avoid() {
        let o = ng_wrong_source(&["scoop"], &["cargo"], &[]);
        let lines = explain_lines(&o);
        assert_eq!(lines[0], "resolved:        /usr/local/bin/rg");
        assert_eq!(lines[1], "matched sources: scoop");
        assert_eq!(lines[2], "prefer:          cargo");
        assert_eq!(lines[3], "avoid:           (none)");
        assert!(lines[4].starts_with("diagnosis:"));
        assert!(lines[4].contains("none of which are in `prefer`"));
        assert!(lines[5].starts_with("hint:"));
        assert!(lines[5].contains("pathlint trace rg"));
    }

    #[test]
    fn explain_lines_wrong_source_calls_out_avoid_hit_explicitly() {
        // When matched intersects avoid, the diagnosis should name
        // the offending avoid source — that's the load-bearing fact.
        let o = ng_wrong_source(&["winget"], &["cargo"], &["winget"]);
        let lines = explain_lines(&o);
        let diagnosis = lines.iter().find(|l| l.starts_with("diagnosis:")).unwrap();
        assert!(
            diagnosis.contains("matched `avoid` source(s) [winget]"),
            "diagnosis: {diagnosis}"
        );
    }

    #[test]
    fn explain_lines_unknown_source_says_path_outside_every_source() {
        let o = Outcome {
            command: "rg".into(),
            status: Status::NgUnknownSource,
            resolved: Some(PathBuf::from("/opt/custom/bin/rg")),
            matched_sources: vec![],
            prefer: vec!["cargo".into()],
            avoid: vec![],
            severity: crate::config::Severity::Error,
            reason: None,
        };
        let lines = explain_lines(&o);
        assert!(
            lines
                .iter()
                .any(|l| l.contains("path is outside every defined source")),
        );
        assert!(
            lines
                .iter()
                .any(|l| l.starts_with("hint:") && l.contains("[source.X]")),
        );
    }

    #[test]
    fn explain_lines_not_found_advises_install_or_optional() {
        let o = Outcome {
            command: "ghost".into(),
            status: Status::NgNotFound,
            resolved: None,
            matched_sources: vec![],
            prefer: vec!["cargo".into()],
            avoid: vec![],
            severity: crate::config::Severity::Error,
            reason: None,
        };
        let lines = explain_lines(&o);
        assert!(lines.iter().any(|l| l.contains("not found on any PATH")));
        assert!(lines.iter().any(|l| l.contains("optional = true")));
    }

    #[test]
    fn explain_lines_not_executable_carries_reason_and_shadow_hint() {
        let o = Outcome {
            command: "rg".into(),
            status: Status::NgNotExecutable,
            resolved: Some(PathBuf::from("/tmp/rg")),
            matched_sources: vec!["custom".into()],
            prefer: vec!["custom".into()],
            avoid: vec![],
            severity: crate::config::Severity::Error,
            reason: Some("is a directory".into()),
        };
        let lines = explain_lines(&o);
        assert!(
            lines
                .iter()
                .any(|l| l.contains("not executable — is a directory"))
        );
        assert!(lines.iter().any(|l| l.contains("shadows the binary")));
    }

    fn style(explain: bool) -> Style {
        Style {
            no_glyphs: false,
            verbose: false,
            quiet: false,
            explain,
            color: false,
        }
    }

    #[test]
    fn render_without_explain_keeps_one_line_detail() {
        let outcomes = vec![ng_wrong_source(&["scoop"], &["cargo"], &[])];
        let out = render(&outcomes, style(false));
        // existing one-line detail format
        assert!(out.contains("matched sources: [scoop]"), "out: {out}");
        // and only one detail row (header + one indented line)
        let n_lines = out.trim_end().lines().count();
        assert_eq!(n_lines, 2, "out:\n{out}");
    }

    #[test]
    fn render_with_explain_emits_multiline_breakdown() {
        let outcomes = vec![ng_wrong_source(&["scoop"], &["cargo"], &[])];
        let out = render(&outcomes, style(true));
        // header + 6 explain rows = 7 lines
        let n_lines = out.trim_end().lines().count();
        assert_eq!(n_lines, 7, "out:\n{out}");
        assert!(out.contains("    resolved:        /usr/local/bin/rg"));
        assert!(out.contains("    diagnosis:"));
        assert!(out.contains("    hint:            run `pathlint trace rg`"));
    }

    #[test]
    fn warn_severity_failure_uses_warn_tag_instead_of_ng() {
        let warn_outcome = Outcome {
            severity: crate::config::Severity::Warn,
            ..ng_wrong_source(&["scoop"], &["cargo"], &[])
        };
        let out = render(&[warn_outcome], style(false));
        assert!(out.contains("[warn]"), "out: {out}");
        assert!(!out.contains("[NG]"), "warn must not also tag NG: {out}");
    }

    #[test]
    fn warn_severity_no_glyph_uses_lower_warn_tag() {
        let warn_outcome = Outcome {
            severity: crate::config::Severity::Warn,
            ..ng_wrong_source(&["scoop"], &["cargo"], &[])
        };
        let s = Style {
            no_glyphs: true,
            ..style(false)
        };
        let out = render(&[warn_outcome], s);
        assert!(out.contains("warn "), "out: {out}");
        assert!(!out.contains("NG   "), "out: {out}");
    }

    #[test]
    fn render_explain_skips_ok_outcomes_unchanged() {
        let ok = Outcome {
            command: "rg".into(),
            status: Status::Ok,
            resolved: Some(PathBuf::from("/home/u/.cargo/bin/rg")),
            matched_sources: vec!["cargo".into()],
            prefer: vec!["cargo".into()],
            avoid: vec![],
            severity: crate::config::Severity::Error,
            reason: None,
        };
        let out = render(&[ok], style(true));
        assert!(out.contains("[OK]"), "out: {out:?}");
        assert!(out.contains("rg"), "out: {out:?}");
        assert!(out.contains("source: cargo"));
        // Just header + detail = 2 lines, no explain block.
        assert_eq!(out.trim_end().lines().count(), 2);
    }

    #[test]
    fn render_strips_control_chars_from_command() {
        let evil = Outcome {
            command: "rg\x1b[31m".into(),
            ..ng_wrong_source(&["scoop"], &["cargo"], &[])
        };
        let out = render(&[evil], style(false));
        assert!(!out.contains('\x1b'), "raw escape leaked: {out:?}");
        assert!(out.contains("rg?[31m"), "expected stripped form: {out:?}");
    }

    #[test]
    fn render_strips_control_chars_from_matched_sources() {
        let evil = Outcome {
            command: "rg".into(),
            status: Status::Ok,
            resolved: Some(PathBuf::from("/usr/bin/rg")),
            matched_sources: vec!["evil\x1b[31m".into()],
            prefer: vec![],
            avoid: vec![],
            severity: crate::config::Severity::Error,
            reason: None,
        };
        let out = render(&[evil], style(false));
        assert!(!out.contains('\x1b'));
        assert!(out.contains("source: evil?[31m"));
    }

    #[test]
    fn explain_lines_config_error_quotes_the_underlying_message() {
        let o = Outcome {
            command: "rg".into(),
            status: Status::ConfigError,
            resolved: None,
            matched_sources: vec![],
            prefer: vec![],
            avoid: vec![],
            severity: crate::config::Severity::Error,
            reason: Some("undefined source name: typo".into()),
        };
        let lines = explain_lines(&o);
        assert!(lines[0].contains("undefined source name: typo"));
        assert!(lines[1].contains("catalog list"));
    }

    // ---- Style::should_show ----------------------------------------

    fn outcome_with(status: Status) -> Outcome {
        Outcome {
            command: "rg".into(),
            status,
            resolved: None,
            matched_sources: vec![],
            prefer: vec![],
            avoid: vec![],
            severity: crate::config::Severity::Error,
            reason: None,
        }
    }

    #[test]
    fn should_show_default_style_keeps_ok_skip_failure_drops_not_applicable() {
        let s = style(false);
        assert!(s.should_show(&outcome_with(Status::Ok)));
        assert!(s.should_show(&outcome_with(Status::Skip)));
        assert!(s.should_show(&outcome_with(Status::NgNotFound)));
        assert!(!s.should_show(&outcome_with(Status::NotApplicable)));
    }

    #[test]
    fn should_show_verbose_keeps_not_applicable() {
        let s = Style {
            verbose: true,
            ..style(false)
        };
        assert!(s.should_show(&outcome_with(Status::NotApplicable)));
    }

    #[test]
    fn should_show_quiet_drops_non_failures() {
        let s = Style {
            quiet: true,
            ..style(false)
        };
        assert!(!s.should_show(&outcome_with(Status::Ok)));
        assert!(!s.should_show(&outcome_with(Status::Skip)));
        assert!(!s.should_show(&outcome_with(Status::NotApplicable)));
        // Failures still print under quiet — that's the whole point.
        assert!(s.should_show(&outcome_with(Status::NgNotFound)));
        assert!(s.should_show(&outcome_with(Status::NgWrongSource)));
    }

    #[test]
    fn should_show_quiet_overrides_verbose_for_non_failures() {
        // If the user passes both --quiet and --verbose (clap allows
        // it), quiet wins on the visibility question. The CLI may
        // tighten this later; this test pins the current contract.
        let s = Style {
            quiet: true,
            verbose: true,
            ..style(false)
        };
        assert!(!s.should_show(&outcome_with(Status::Ok)));
        // NotApplicable: verbose would keep it, but quiet drops
        // anything non-failure.
        assert!(!s.should_show(&outcome_with(Status::NotApplicable)));
    }
}
