//! JSON output and comparison functionality for Clippy warnings.
//!
//! This module handles serialization of Clippy warnings to JSON format,
//! loading warnings from JSON files, and generating human-readable diffs
//! between different linting runs.

use std::fs;
use std::path::Path;

use itertools::{EitherOrBoth, Itertools};
use serde::{Deserialize, Serialize};

use crate::ClippyWarning;

/// This is the total number. 300 warnings results in 100 messages per section.
const DEFAULT_LIMIT_PER_LINT: usize = 300;
/// Target for total warnings to display across all lints when truncating output.
const TRUNCATION_TOTAL_TARGET: usize = 1000;

/// Representation of a single Clippy warning for JSON serialization.
#[derive(Debug, Deserialize, Serialize)]
struct LintJson {
    /// The lint name e.g. `clippy::bytes_nth`
    name: String,
    /// The filename and line number e.g. `anyhow-1.0.86/src/error.rs:42`
    file_line: String,
    file_url: String,
    rendered: String,
}

impl LintJson {
    /// Returns a tuple of name and `file_line` for sorting and comparison.
    fn key(&self) -> impl Ord + '_ {
        (self.name.as_str(), self.file_line.as_str())
    }

    /// Formats the warning information with an action verb for display.
    fn info_text(&self, action: &str) -> String {
        format!("{action} `{}` at [`{}`]({})", self.name, self.file_line, self.file_url)
    }
}

/// Creates the log file output for [`crate::config::OutputFormat::Json`]
pub(crate) fn output(clippy_warnings: Vec<ClippyWarning>) -> String {
    let mut lints: Vec<LintJson> = clippy_warnings
        .into_iter()
        .map(|warning| {
            let span = warning.span();
            let file_name = span
                .file_name
                .strip_prefix("target/lintcheck/sources/")
                .unwrap_or(&span.file_name);
            let file_line = format!("{file_name}:{}", span.line_start);
            LintJson {
                name: warning.name,
                file_line,
                file_url: warning.url,
                rendered: warning.diag.rendered.unwrap().trim().to_string(),
            }
        })
        .collect();
    lints.sort_by(|a, b| a.key().cmp(&b.key()));
    serde_json::to_string(&lints).unwrap()
}

/// Loads lint warnings from a JSON file at the given path.
fn load_warnings(path: &Path) -> Vec<LintJson> {
    let file = fs::read(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));

    serde_json::from_slice(&file).unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display()))
}

/// Generates and prints a diff between two sets of lint warnings.
///
/// Compares warnings from `old_path` and `new_path`, then displays a summary table
/// and detailed information about added, removed, and changed warnings.
pub(crate) fn diff(old_path: &Path, new_path: &Path, truncate: bool) {
    let old_warnings = load_warnings(old_path);
    let new_warnings = load_warnings(new_path);

    let mut lint_warnings = vec![];

    for (name, changes) in &itertools::merge_join_by(old_warnings, new_warnings, |old, new| old.key().cmp(&new.key()))
        .chunk_by(|change| change.as_ref().into_left().name.clone())
    {
        let mut added = Vec::new();
        let mut removed = Vec::new();
        let mut changed = Vec::new();
        for change in changes {
            match change {
                EitherOrBoth::Both(old, new) => {
                    if old.rendered != new.rendered {
                        changed.push((old, new));
                    }
                },
                EitherOrBoth::Left(old) => removed.push(old),
                EitherOrBoth::Right(new) => added.push(new),
            }
        }

        if !added.is_empty() || !removed.is_empty() || !changed.is_empty() {
            lint_warnings.push(LintWarnings {
                name,
                added,
                removed,
                changed,
            });
        }
    }

    print_summary_table(&lint_warnings);
    println!();

    if lint_warnings.is_empty() {
        return;
    }

    let truncate_after = if truncate {
        // Max 15 ensures that we at least have five messages per lint
        DEFAULT_LIMIT_PER_LINT
            .min(TRUNCATION_TOTAL_TARGET / lint_warnings.len())
            .max(15)
    } else {
        // No lint should ever each this number of lint emissions, so this is equivialent to
        // No truncation
        usize::MAX
    };

    for lint in lint_warnings {
        print_lint_warnings(&lint, truncate_after);
    }
}

/// Container for grouped lint warnings organized by status (added/removed/changed).
#[derive(Debug)]
struct LintWarnings {
    name: String,
    added: Vec<LintJson>,
    removed: Vec<LintJson>,
    changed: Vec<(LintJson, LintJson)>,
}

/// Prints a formatted report for a single lint type with its warnings.
fn print_lint_warnings(lint: &LintWarnings, truncate_after: usize) {
    let name = &lint.name;
    let html_id = to_html_id(name);

    // The additional anchor is added for non GH viewers that don't prefix ID's
    println!(r#"## `{name}` <a id="user-content-{html_id}"></a>"#);
    println!();

    print!(
        r"{}, {}, {}",
        count_string(name, "added", lint.added.len()),
        count_string(name, "removed", lint.removed.len()),
        count_string(name, "changed", lint.changed.len()),
    );
    println!();

    print_warnings("Added", &lint.added, truncate_after / 3);
    print_warnings("Removed", &lint.removed, truncate_after / 3);
    print_changed_diff(&lint.changed, truncate_after / 3);
}

/// Prints a summary table of all lints with counts of added, removed, and changed warnings.
fn print_summary_table(lints: &[LintWarnings]) {
    println!("| Lint                                       | Added   | Removed | Changed |");
    println!("| ------------------------------------------ | ------: | ------: | ------: |");

    for lint in lints {
        println!(
            "| {:<62} | {:>7} | {:>7} | {:>7} |",
            format!("[`{}`](#user-content-{})", lint.name, to_html_id(&lint.name)),
            lint.added.len(),
            lint.removed.len(),
            lint.changed.len()
        );
    }
}

/// Prints a section of warnings with a header and formatted code blocks.
fn print_warnings(title: &str, warnings: &[LintJson], truncate_after: usize) {
    if warnings.is_empty() {
        return;
    }

    print_h3(&warnings[0].name, title);
    println!();

    let warnings = truncate(warnings, truncate_after);

    for warning in warnings {
        println!("{}", warning.info_text(title));
        println!();
        println!("```");
        println!("{}", warning.rendered);
        println!("```");
        println!();
    }
}

/// Prints a section of changed warnings with unified diff format.
fn print_changed_diff(changed: &[(LintJson, LintJson)], truncate_after: usize) {
    if changed.is_empty() {
        return;
    }

    print_h3(&changed[0].0.name, "Changed");
    println!();

    let changed = truncate(changed, truncate_after);

    for (old, new) in changed {
        println!("{}", new.info_text("Changed"));
        println!();
        println!("```diff");
        for change in diff::lines(&old.rendered, &new.rendered) {
            use diff::Result::{Both, Left, Right};

            match change {
                Both(unchanged, _) => {
                    println!(" {unchanged}");
                },
                Left(removed) => {
                    println!("-{removed}");
                },
                Right(added) => {
                    println!("+{added}");
                },
            }
        }
        println!("```");
    }
}

/// Truncates a list to a maximum number of items and prints a message about truncation.
fn truncate<T>(list: &[T], truncate_after: usize) -> &[T] {
    if list.len() > truncate_after {
        println!(
            "{} warnings have been truncated for this summary.",
            list.len() - truncate_after
        );
        println!();

        list.split_at(truncate_after).0
    } else {
        list
    }
}

/// Prints a level 3 heading with an appropriate HTML ID for linking.
fn print_h3(lint: &str, title: &str) {
    let html_id = to_html_id(lint);
    // We have to use HTML here to be able to manually add an id.
    println!(r#"### {title} <a id="user-content-{html_id}-{title}"></a>"#);
}

/// GitHub's markdown parsers doesn't like IDs with `::` and `_`. This simplifies
/// the lint name for the HTML ID.
fn to_html_id(lint_name: &str) -> String {
    lint_name.replace("clippy::", "").replace('_', "-")
}

/// This generates the `x added` string for the start of the job summery.
/// It linkifies them if possible to jump to the respective heading.
fn count_string(lint: &str, label: &str, count: usize) -> String {
    // Headlines are only added, if anything will be displayed under the headline.
    // We therefore only want to add links to them if they exist
    if count == 0 {
        format!("0 {label}")
    } else {
        let html_id = to_html_id(lint);
        // GitHub's job summaries don't add HTML ids to headings. That's why we
        // manually have to add them. GitHub prefixes these manual ids with
        // `user-content-` and that's how we end up with these awesome links :D
        format!("[{count} {label}](#user-content-{html_id}-{label})")
    }
}
