//! Output rendering helpers for CLI-facing text and shell integration.

use anstyle::{Ansi256Color, Effects, Style};
use clap::builder::styling::Styles;
use serde::Serialize;
use std::fmt::Write;
use std::fs::OpenOptions;
use std::io::Write as _;
use std::io::{IsTerminal, stderr, stdout};
use std::path::Path;

use crate::doctor::{DoctorFinding, DoctorReport, DoctorScope, DoctorSeverity, DoctorSummary};
use crate::types::{ShellKind, WorkspaceListEntry};

const CURRENT_HEADER: &str = "cur";
const WORKSPACE_HEADER: &str = "workspace";
const STATUS_HEADER: &str = "status";
const PATH_HEADER: &str = "path";
const COMMIT_HEADER: &str = "commit";
const SOFT_YELLOW: Ansi256Color = Ansi256Color(179);
const SOFT_GREEN: Ansi256Color = Ansi256Color(108);
const SOFT_BLUE: Ansi256Color = Ansi256Color(110);
const SOFT_TEAL: Ansi256Color = Ansi256Color(73);
const SOFT_RED: Ansi256Color = Ansi256Color(203);
const SOFT_GRAY: Ansi256Color = Ansi256Color(245);
const INFERRED_ISSUE_URL: &str = "https://github.com/eersnington/jj-navi/issues/36";

/// Environment variable used by shell integration to pass a directive file.
pub const DIRECTIVE_FILE_ENV_VAR: &str = "NAVI_DIRECTIVE_FILE";
/// Marker for the start of the managed shell block.
pub const MANAGED_BLOCK_START: &str = "# >>> jj-navi shell init >>>";
/// Marker for the end of the managed shell block.
pub const MANAGED_BLOCK_END: &str = "# <<< jj-navi shell init <<<";

/// Clap styles for restrained help and parser output.
#[must_use]
pub fn clap_styles() -> Styles {
    Styles::styled()
        .header(
            Style::new()
                .fg_color(Some(SOFT_YELLOW.into()))
                .effects(Effects::BOLD),
        )
        .usage(
            Style::new()
                .fg_color(Some(SOFT_YELLOW.into()))
                .effects(Effects::BOLD),
        )
        .literal(Style::new().fg_color(Some(SOFT_GREEN.into())))
        .placeholder(Style::new().fg_color(Some(SOFT_GREEN.into())))
        .error(
            Style::new()
                .fg_color(Some(SOFT_YELLOW.into()))
                .effects(Effects::BOLD),
        )
        .valid(Style::new().fg_color(Some(SOFT_GREEN.into())))
        .invalid(
            Style::new()
                .fg_color(Some(SOFT_YELLOW.into()))
                .effects(Effects::BOLD),
        )
}

/// Render a human-facing error message with restrained semantic colors.
#[must_use]
pub fn render_error_message(message: &str) -> String {
    message
        .lines()
        .map(colorize_error_line)
        .collect::<Vec<_>>()
        .join("\n")
}

/// Render a table of workspaces for `navi list`.
#[must_use]
pub fn render_workspace_table(entries: &[WorkspaceListEntry]) -> String {
    let rendered_entries = entries
        .iter()
        .map(|entry| RenderedWorkspaceEntry {
            is_current: entry.is_current,
            name: render_workspace_name(entry.name.as_str(), entry.is_current),
            status: render_workspace_status(entry),
            path: render_workspace_path(entry),
            commit_id: render_commit_id(entry.commit_id.as_str()),
            message: entry.message.as_str(),
        })
        .collect::<Vec<_>>();

    let workspace_width = rendered_entries
        .iter()
        .map(|entry| entry.name.visible_len)
        .fold(WORKSPACE_HEADER.len(), usize::max);
    let path_width = rendered_entries
        .iter()
        .map(|entry| entry.path.visible_len)
        .fold(PATH_HEADER.len(), usize::max);
    let status_width = rendered_entries
        .iter()
        .map(|entry| entry.status.visible_len)
        .fold(STATUS_HEADER.len(), usize::max);
    let commit_width = rendered_entries
        .iter()
        .map(|entry| entry.commit_id.visible_len)
        .fold(COMMIT_HEADER.len(), usize::max);

    let mut output = String::new();
    writeln!(
        output,
        "{}  {}  {}  {}  {}  message",
        style_table_header(&format!("{CURRENT_HEADER:<3}")),
        style_table_header(&format!("{WORKSPACE_HEADER:<workspace_width$}")),
        style_table_header(&format!("{STATUS_HEADER:<status_width$}")),
        style_table_header(&format!("{PATH_HEADER:<path_width$}")),
        style_table_header(&format!("{COMMIT_HEADER:<commit_width$}")),
    )
    .expect("write table header");

    for entry in rendered_entries {
        let current_marker = if entry.is_current { "@" } else { "" };
        writeln!(
            output,
            "{}  {}  {}  {}  {}  {}",
            pad_visible(current_marker, current_marker.len(), CURRENT_HEADER.len()),
            pad_visible(
                &entry.name.rendered,
                entry.name.visible_len,
                workspace_width
            ),
            pad_visible(
                &entry.status.rendered,
                entry.status.visible_len,
                status_width
            ),
            pad_visible(&entry.path.rendered, entry.path.visible_len, path_width),
            pad_visible(
                &entry.commit_id.rendered,
                entry.commit_id.visible_len,
                commit_width
            ),
            entry.message,
        )
        .expect("write table row");
    }

    output
}

/// Render a doctor report as human-facing text.
#[must_use]
pub fn render_doctor_report(report: &DoctorReport) -> String {
    let summary = report.summary();
    let mut output = String::new();

    writeln!(
        output,
        "{} {}",
        style_doctor_heading("Doctor"),
        style_status_badge(
            render_doctor_headline(summary),
            doctor_headline_severity(summary)
        )
    )
    .expect("write doctor headline");
    writeln!(
        output,
        "{} {}",
        style_section_label("Summary"),
        render_doctor_summary(summary)
    )
    .expect("write doctor summary");
    writeln!(output).expect("write doctor spacing");
    writeln!(output, "{}", style_section_label("Checks")).expect("write doctor checks heading");

    for scope_summary in scope_summaries(report) {
        writeln!(
            output,
            "  {} {}",
            scope_icon(&scope_summary),
            render_scope_line(&scope_summary)
        )
        .expect("write doctor scope summary");
    }

    if !report.is_empty() {
        writeln!(output).expect("write doctor spacing");
        writeln!(output, "{}", style_section_label("Findings"))
            .expect("write doctor findings heading");

        for finding in &report.findings {
            writeln!(
                output,
                "  {} {}  {}",
                finding_icon(finding.severity),
                style_status_badge(finding.severity.label(), finding.severity),
                render_finding_title(&finding.scope, &finding.message)
            )
            .expect("write doctor finding");
            writeln!(
                output,
                "      {} {}",
                style_detail_label("scope"),
                render_finding_scope(&finding.scope)
            )
            .expect("write doctor scope detail");
            if let Some(path) = &finding.path {
                writeln!(output, "      {} {}", style_detail_label("path"), path)
                    .expect("write doctor path");
            }
            if let Some(hint) = &finding.hint {
                writeln!(output, "      {} {}", style_detail_label("hint"), hint)
                    .expect("write doctor hint");
            }
        }
    }

    output
}

/// Render a doctor report as JSON.
///
/// # Errors
///
/// Returns an error if the report cannot be serialized.
pub fn render_doctor_report_json(report: &DoctorReport, compact: bool) -> crate::Result<String> {
    let payload = DoctorJsonOutput {
        summary: report.summary(),
        findings: &report.findings,
    };
    let rendered = if compact {
        serde_json::to_string(&payload)
    } else {
        serde_json::to_string_pretty(&payload)
    }
    .map_err(|error| crate::Error::JsonSerialization(error.to_string()))?;

    Ok(rendered)
}

/// Render shell initialization code for the chosen shell.
#[must_use]
pub fn render_shell_init(command_name: &str, shell: ShellKind) -> String {
    let source_cmd = match shell {
        ShellKind::Bash | ShellKind::Zsh => "source",
    };

    format!(
        "# jj-navi shell integration for {shell}\nif command -v {command_name} >/dev/null 2>&1; then\n    {command_name}() {{\n        local directive_file exit_code=0\n        directive_file=\"$(mktemp)\"\n        {directive_env}=\"$directive_file\" command {command_name} \"$@\" || exit_code=$?\n        if [[ -s \"$directive_file\" ]]; then\n            {source_cmd} \"$directive_file\"\n            if [[ $exit_code -eq 0 ]]; then\n                exit_code=$?\n            fi\n        fi\n        rm -f \"$directive_file\"\n        return \"$exit_code\"\n    }}\nfi\n",
        shell = shell.as_str(),
        command_name = command_name,
        directive_env = DIRECTIVE_FILE_ENV_VAR,
        source_cmd = source_cmd,
    )
}

/// Render the managed shell block inserted into a shell rc file.
#[must_use]
pub fn render_shell_install_block(command_name: &str, shell: ShellKind) -> String {
    format!(
        "{MANAGED_BLOCK_START}\neval \"$(command {command_name} config shell init {shell})\"\n{MANAGED_BLOCK_END}\n",
        command_name = command_name,
        shell = shell.as_str(),
    )
}

/// Write a shell-safe `cd` directive if shell integration is active.
///
/// Returns `true` if a directive was written.
///
/// # Errors
///
/// Returns an error if the directive file path is invalid or writing fails.
pub fn write_cd_directive(path: &Path) -> crate::Result<bool> {
    let Ok(directive_file) = std::env::var(DIRECTIVE_FILE_ENV_VAR) else {
        return Ok(false);
    };

    if directive_file.trim().is_empty() {
        return Ok(false);
    }

    let escaped_path = escape_shell_single_quotes(
        path.to_str()
            .ok_or(crate::Error::ShellDirectivePathNotUtf8)?,
    );
    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(directive_file)?;
    writeln!(file, "cd -- '{escaped_path}'")?;
    Ok(true)
}

/// Escape single quotes for POSIX shell single-quoted strings.
#[must_use]
pub fn escape_shell_single_quotes(value: &str) -> String {
    value.replace('\'', "'\\''")
}

fn colorize_error_line(line: &str) -> String {
    if let Some(rest) = line.strip_prefix("error:") {
        return format!("{}{}", styled_prefix("error:", SOFT_YELLOW), rest);
    }

    if let Some(rest) = line.strip_prefix("warning:") {
        return format!("{}{}", styled_prefix("warning:", SOFT_YELLOW), rest);
    }

    if let Some(rest) = line.strip_prefix("hint:") {
        return format!("{}{}", styled_prefix("hint:", SOFT_GREEN), rest);
    }

    line.to_owned()
}

fn render_doctor_summary(summary: DoctorSummary) -> String {
    let mut parts = Vec::new();
    if summary.errors > 0 {
        parts.push(pluralize(summary.errors, "error"));
    }
    if summary.warnings > 0 {
        parts.push(pluralize(summary.warnings, "warning"));
    }
    if summary.info > 0 {
        parts.push(pluralize(summary.info, "info"));
    }

    if parts.is_empty() {
        String::from("ok")
    } else {
        parts.join(", ")
    }
}

fn render_doctor_headline(summary: DoctorSummary) -> &'static str {
    if summary.errors > 0 {
        "attention needed"
    } else if summary.warnings > 0 {
        "warnings found"
    } else {
        "healthy"
    }
}

fn doctor_headline_severity(summary: DoctorSummary) -> DoctorSeverity {
    if summary.errors > 0 {
        DoctorSeverity::Error
    } else if summary.warnings > 0 {
        DoctorSeverity::Warning
    } else {
        DoctorSeverity::Info
    }
}

fn scope_summaries(report: &DoctorReport) -> [ScopeSummary; 3] {
    let mut repo = ScopeSummary::new("repo");
    let mut workspaces = ScopeSummary::new("workspaces");
    let mut shell = ScopeSummary::new("shell");

    for finding in &report.findings {
        match &finding.scope {
            DoctorScope::Repo => repo.record(finding.severity),
            DoctorScope::Workspace { .. } => workspaces.record(finding.severity),
            DoctorScope::Shell => shell.record(finding.severity),
        }
    }

    [repo, workspaces, shell]
}

fn render_scope_status(summary: &ScopeSummary) -> String {
    match summary.worst {
        None => String::from("ok"),
        Some(severity) => format!(
            "{} ({})",
            severity.label(),
            pluralize(summary.count, "finding")
        ),
    }
}

fn render_scope_line(summary: &ScopeSummary) -> String {
    format!(
        "{:<10} {}",
        summary.label,
        style_status_badge(&render_scope_status(summary), scope_severity(summary))
    )
}

fn scope_severity(summary: &ScopeSummary) -> DoctorSeverity {
    summary.worst.unwrap_or(DoctorSeverity::Info)
}

fn render_finding_scope(scope: &DoctorScope) -> String {
    match scope {
        DoctorScope::Repo => String::from("repo"),
        DoctorScope::Workspace { workspace } => format!("workspace:{workspace}"),
        DoctorScope::Shell => String::from("shell"),
    }
}

fn render_finding_title(scope: &DoctorScope, message: &str) -> String {
    match scope {
        DoctorScope::Repo | DoctorScope::Shell => message.to_owned(),
        DoctorScope::Workspace { workspace } => format!("{workspace} - {message}"),
    }
}

fn pluralize(count: usize, noun: &str) -> String {
    if count == 1 {
        format!("1 {noun}")
    } else {
        format!("{count} {noun}s")
    }
}

fn styled_prefix(prefix: &str, color: Ansi256Color) -> String {
    if !stderr_color_enabled() {
        return prefix.to_owned();
    }

    format!(
        "\u{1b}[38;5;{}m{}\u{1b}[0m",
        ansi_256_color_code(color),
        prefix
    )
}

fn style_doctor_heading(label: &str) -> String {
    styled_stdout_text(label, SOFT_YELLOW, true)
}

fn style_table_header(label: &str) -> String {
    styled_stdout_text(label, SOFT_YELLOW, true)
}

fn style_section_label(label: &str) -> String {
    styled_stdout_text(label, SOFT_GREEN, true)
}

fn style_detail_label(label: &str) -> String {
    styled_stdout_text(&format!("{label}:"), SOFT_GREEN, false)
}

fn style_status_badge(label: &str, severity: DoctorSeverity) -> String {
    let plain = format!("[ {label} ]");
    let color = match severity {
        DoctorSeverity::Error | DoctorSeverity::Warning => SOFT_YELLOW,
        DoctorSeverity::Info => SOFT_GREEN,
    };
    styled_stdout_text(&plain, color, true)
}

fn style_list_badge(label: &str, color: Ansi256Color) -> String {
    styled_stdout_text(&format!("[{label}]"), color, true)
}

fn style_workspace_name(name: &str, is_current: bool) -> String {
    if is_current {
        styled_stdout_text(name, SOFT_GREEN, true)
    } else {
        name.to_owned()
    }
}

fn style_meta(text: &str) -> String {
    if !stdout_color_enabled() {
        return text.to_owned();
    }

    let style = Style::new()
        .fg_color(Some(SOFT_GRAY.into()))
        .effects(Effects::DIMMED);
    format!("{}{}{}", style.render(), text, style.render_reset())
}

fn scope_icon(summary: &ScopeSummary) -> &'static str {
    finding_icon(scope_severity(summary))
}

fn finding_icon(severity: DoctorSeverity) -> &'static str {
    match severity {
        DoctorSeverity::Error => "x",
        DoctorSeverity::Warning => "!",
        DoctorSeverity::Info => "o",
    }
}

fn styled_stdout_text(text: &str, color: Ansi256Color, bold: bool) -> String {
    if !stdout_color_enabled() {
        return text.to_owned();
    }

    let style = if bold {
        Style::new()
            .fg_color(Some(color.into()))
            .effects(Effects::BOLD)
    } else {
        Style::new().fg_color(Some(color.into()))
    };

    format!("{}{}{}", style.render(), text, style.render_reset())
}

fn stdout_color_enabled() -> bool {
    should_color_output(stdout().is_terminal())
}

fn stderr_color_enabled() -> bool {
    should_color_output(stderr().is_terminal())
}

fn should_color_output(stream_is_terminal: bool) -> bool {
    std::env::var_os("NO_COLOR").is_none() && stream_is_terminal
}

fn render_workspace_status(entry: &WorkspaceListEntry) -> RenderedCell {
    let plain = plain_workspace_status(entry);
    let rendered = entry
        .statuses
        .iter()
        .map(|status| render_status_badge(*status))
        .collect::<Vec<_>>()
        .join(" ");

    RenderedCell {
        rendered,
        visible_len: plain.len(),
    }
}

fn plain_workspace_status(entry: &WorkspaceListEntry) -> String {
    entry
        .statuses
        .iter()
        .map(|status| format!("[{}]", status.label()))
        .collect::<Vec<_>>()
        .join(" ")
}

fn render_workspace_path(entry: &WorkspaceListEntry) -> RenderedCell {
    let plain = entry.path.display().to_string();
    let rendered = match entry.path_state {
        crate::types::WorkspacePathState::Confirmed => plain.clone(),
        crate::types::WorkspacePathState::Inferred => styled_stdout_text(&plain, SOFT_BLUE, false),
        crate::types::WorkspacePathState::Missing => styled_stdout_text(&plain, SOFT_YELLOW, false),
        crate::types::WorkspacePathState::Stale => styled_stdout_text(&plain, SOFT_RED, false),
    };
    let visible_len = plain.len();

    RenderedCell {
        rendered,
        visible_len,
    }
}

fn render_workspace_name(name: &str, is_current: bool) -> RenderedCell {
    RenderedCell {
        rendered: style_workspace_name(name, is_current),
        visible_len: name.len(),
    }
}

fn render_commit_id(commit_id: &str) -> RenderedCell {
    let visible_len = commit_id.len();

    RenderedCell {
        rendered: style_meta(commit_id),
        visible_len,
    }
}

fn render_status_badge(status: crate::types::WorkspaceListStatus) -> String {
    let label = status.label();
    let colored = match status {
        crate::types::WorkspaceListStatus::Ok => style_list_badge(label, SOFT_GREEN),
        crate::types::WorkspaceListStatus::Inferred => style_list_badge(label, SOFT_BLUE),
        crate::types::WorkspaceListStatus::Missing => style_list_badge(label, SOFT_YELLOW),
        crate::types::WorkspaceListStatus::Stale => style_list_badge(label, SOFT_RED),
        crate::types::WorkspaceListStatus::JjOnly => style_list_badge(label, SOFT_TEAL),
    };

    if status == crate::types::WorkspaceListStatus::Inferred && hyperlink_enabled() {
        hyperlink(&colored, INFERRED_ISSUE_URL)
    } else {
        colored
    }
}

fn hyperlink_enabled() -> bool {
    if !stdout().is_terminal() {
        return false;
    }

    !matches!(std::env::var("TERM"), Ok(term) if term == "dumb")
}

fn hyperlink(label: &str, url: &str) -> String {
    format!("\u{1b}]8;;{url}\u{1b}\\{label}\u{1b}]8;;\u{1b}\\")
}

fn pad_visible(value: &str, visible_len: usize, width: usize) -> String {
    let padding = width.saturating_sub(visible_len);
    format!("{value}{}", " ".repeat(padding))
}

const fn ansi_256_color_code(color: Ansi256Color) -> u8 {
    color.0
}

struct RenderedWorkspaceEntry<'a> {
    is_current: bool,
    name: RenderedCell,
    status: RenderedCell,
    path: RenderedCell,
    commit_id: RenderedCell,
    message: &'a str,
}

struct RenderedCell {
    rendered: String,
    visible_len: usize,
}

#[derive(Serialize)]
struct DoctorJsonOutput<'a> {
    summary: DoctorSummary,
    findings: &'a [DoctorFinding],
}

struct ScopeSummary {
    label: &'static str,
    worst: Option<DoctorSeverity>,
    count: usize,
}

impl ScopeSummary {
    const fn new(label: &'static str) -> Self {
        Self {
            label,
            worst: None,
            count: 0,
        }
    }

    fn record(&mut self, severity: DoctorSeverity) {
        self.count += 1;
        self.worst = Some(match self.worst {
            None => severity,
            Some(current) => current.min(severity),
        });
    }
}

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

    use crate::types::{
        ShellKind, WorkspaceListEntry, WorkspaceListStatus, WorkspaceName, WorkspacePathState,
    };

    use super::{
        DIRECTIVE_FILE_ENV_VAR, MANAGED_BLOCK_END, MANAGED_BLOCK_START, escape_shell_single_quotes,
        render_error_message, render_shell_init, render_shell_install_block,
        render_workspace_table,
    };

    #[test]
    fn renders_workspace_table() {
        let entries = vec![
            WorkspaceListEntry {
                is_current: true,
                name: WorkspaceName::new("default").expect("valid workspace"),
                statuses: vec![WorkspaceListStatus::Ok],
                path: PathBuf::from("."),
                path_is_inferred: false,
                path_state: WorkspacePathState::Confirmed,
                commit_id: String::from("abc123"),
                message: String::from("Current work"),
            },
            WorkspaceListEntry {
                is_current: false,
                name: WorkspaceName::new("feature-auth").expect("valid workspace"),
                statuses: vec![WorkspaceListStatus::Inferred],
                path: PathBuf::from("../repo.feature-auth"),
                path_is_inferred: true,
                path_state: WorkspacePathState::Inferred,
                commit_id: String::from("def456"),
                message: String::from("Feature auth work"),
            },
        ];

        let rendered = render_workspace_table(&entries);

        assert!(rendered.contains("cur"));
        assert!(rendered.contains("workspace"));
        assert!(rendered.contains("status"));
        assert!(rendered.contains("commit"));
        assert!(rendered.contains("Feature auth work"));
        assert!(rendered.contains("[inferred]"));
        assert!(!rendered.contains("../repo.feature-auth [inferred]"));
    }

    #[test]
    fn renders_bash_shell_init() {
        let rendered = render_shell_init("navi", ShellKind::Bash);

        assert!(rendered.contains("navi()"));
        assert!(rendered.contains(DIRECTIVE_FILE_ENV_VAR));
        assert!(rendered.contains("command navi \"$@\""));
    }

    #[test]
    fn renders_shell_install_block() {
        let rendered = render_shell_install_block("navi", ShellKind::Zsh);

        assert!(rendered.contains(MANAGED_BLOCK_START));
        assert!(rendered.contains("eval \"$(command navi config shell init zsh)\""));
        assert!(rendered.contains(MANAGED_BLOCK_END));
    }

    #[test]
    fn escapes_single_quotes_for_shell_directives() {
        assert_eq!(
            escape_shell_single_quotes("../space dir/feature-auth's"),
            "../space dir/feature-auth'\\''s"
        );
    }

    #[test]
    fn renders_error_message_without_losing_prefixes() {
        let rendered = render_error_message("error: bad\nhint: try again");

        assert!(rendered.contains("error:"));
        assert!(rendered.contains("hint:"));
    }
}
