//! Fixer for `capitalization-error-in-description`.
//!
//! lintian's `fields/description` check runs `check_spelling_picky` over
//! the *extended* part of a binary package's `Description` and emits this
//! tag for every word matching an entry in
//! `data/spelling/corrections-case` (e.g. `linux` ⇒ `Linux`). This fixer
//! mirrors that logic and rewrites the offending words. The synopsis is
//! covered by the separate `capitalization-error-in-description-synopsis`
//! tag and is left untouched here.

use crate::declare_detector;
use crate::diagnostic::{Action, Deb822Action, Diagnostic, ParagraphSelector};
use crate::{Certainty, FixerError, FixerPreferences, LintianIssue, Visibility};
use debian_workspace::Workspace;
use std::collections::{HashMap, HashSet};
use std::ops::Range;
use std::path::PathBuf;

// Generated by build.rs from /usr/share/lintian/data/spelling/corrections-case:
//   pub static SPELLING_CORRECTIONS_CASE: &[(&str, &str)] = &[...];
include!(concat!(env!("OUT_DIR"), "/spelling_corrections_case.rs"));

const LABEL: &str = "Fix capitalization errors in package description.";

/// A single capitalization correction located in an extended description.
struct Correction {
    /// Byte range in the extended description covering the text to replace.
    span: Range<usize>,
    /// The word as it appears (lintian's reported misspelling).
    word: String,
    /// The corrected spelling.
    correction: String,
}

/// Byte ranges of `[...]` regions, mirroring lintian's `s/\[.+?\]//sg`:
/// each `[` pairs with the next `]` that has at least one character
/// between them. lintian drops these regions before the picky spell
/// check because they often hold package lists where lowercasing is
/// legitimate. `[` and `]` are ASCII, so a byte scan is safe.
fn bracket_spans(text: &str) -> Vec<Range<usize>> {
    let bytes = text.as_bytes();
    let mut spans = Vec::new();
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'[' {
            if let Some(rel) = bytes[i + 1..].iter().position(|&b| b == b']') {
                let close = i + 1 + rel;
                // `.+?` requires at least one character between the brackets.
                if close > i + 1 {
                    spans.push(i..close + 1);
                    i = close + 1;
                    continue;
                }
            }
        }
        i += 1;
    }
    spans
}

/// Whitespace-delimited tokens of `text`, treating bracket regions as
/// separators (their contents are excluded from the picky check).
fn word_tokens(text: &str, brackets: &[Range<usize>]) -> Vec<Range<usize>> {
    let mut tokens = Vec::new();
    let mut start: Option<usize> = None;
    for (idx, ch) in text.char_indices() {
        let boundary = ch.is_whitespace() || brackets.iter().any(|b| b.contains(&idx));
        match (boundary, start) {
            (true, Some(s)) => {
                tokens.push(s..idx);
                start = None;
            }
            (false, None) => start = Some(idx),
            _ => {}
        }
    }
    if let Some(s) = start {
        tokens.push(s..text.len());
    }
    tokens
}

/// Strip a token down to the word lintian checks: one optional leading
/// `(` and a trailing run of `).,?!:;` are removed (lintian's
/// `s/^\(|[).,?!:;]+$//g`). Returns the byte range of the surviving core.
fn core_range(text: &str, token: &Range<usize>) -> Range<usize> {
    let mut start = token.start;
    if text[token.clone()].starts_with('(') {
        start += 1; // '(' is one byte
    }
    let trimmed = text[start..token.end].trim_end_matches([')', '.', ',', '?', '!', ':', ';']);
    start..start + trimmed.len()
}

/// Find every capitalization correction lintian's `check_spelling_picky`
/// would report in `extended` (the part of a Description after the
/// synopsis line), in lintian's emission order. Occurrences are *not*
/// deduplicated: every span is needed to rewrite the text.
fn find_corrections(extended: &str, map: &HashMap<&str, &str>) -> Vec<Correction> {
    let mut corrections = Vec::new();

    // lintian checks `meta package` first, on the raw text, so the
    // square-bracket exclusion below cannot hide it.
    //
    // TODO: replace this bespoke regex with a shared meta-package helper
    // from debian-workspace once one exists, rather than hard-coding the
    // pattern here.
    for m in lazy_regex::regex!(r"meta\s+package").find_iter(extended) {
        corrections.push(Correction {
            span: m.start()..m.end(),
            word: "meta package".to_string(),
            correction: "metapackage".to_string(),
        });
    }

    let brackets = bracket_spans(extended);
    for token in word_tokens(extended, &brackets) {
        let core = core_range(extended, &token);
        if core.is_empty() {
            continue;
        }
        let word = &extended[core.clone()];
        if let Some(&correction) = map.get(word) {
            corrections.push(Correction {
                span: core,
                word: word.to_string(),
                correction: correction.to_string(),
            });
        }
    }
    corrections
}

/// Apply every correction to `extended`, returning the rewritten text.
fn apply_corrections(extended: &str, corrections: &[Correction]) -> String {
    // Replace right-to-left so earlier byte offsets stay valid. The spans
    // never overlap: word cores live in disjoint tokens, `meta package`
    // matches do not overlap each other, and neither `meta` nor `package`
    // is itself a correction key.
    let mut ordered: Vec<&Correction> = corrections.iter().collect();
    ordered.sort_by(|a, b| b.span.start.cmp(&a.span.start));
    let mut result = extended.to_string();
    for c in ordered {
        result.replace_range(c.span.clone(), &c.correction);
    }
    result
}

pub fn detect(
    ws: &dyn Workspace,
    _preferences: &FixerPreferences,
) -> Result<Vec<Diagnostic>, FixerError> {
    let control_rel = PathBuf::from("debian/control");
    let control = match ws.parsed_control() {
        Ok(c) => c,
        Err(debian_workspace::Error::NotFound) => return Ok(Vec::new()),
        Err(e) => return Err(e.into()),
    };

    let map: HashMap<&str, &str> = SPELLING_CORRECTIONS_CASE.iter().copied().collect();
    let mut diagnostics = Vec::new();

    for binary in control.binaries() {
        let Some(description) = binary.description() else {
            continue;
        };
        // This tag covers only the extended description; the synopsis
        // (first line) has its own tag.
        let Some((synopsis, extended)) = description.split_once('\n') else {
            continue;
        };
        let corrections = find_corrections(extended, &map);
        if corrections.is_empty() {
            continue;
        }
        let Some(package) = binary.name() else {
            continue;
        };

        let new_description = format!("{synopsis}\n{}", apply_corrections(extended, &corrections));
        let set_field = Action::Deb822(Deb822Action::SetField {
            file: control_rel.clone(),
            paragraph: ParagraphSelector::Binary {
                package: package.clone(),
            },
            field: "Description".into(),
            value: new_description,
        });

        // lintian emits one tag per distinct misspelling. Mirror that with
        // one diagnostic per misspelling, each carrying the full rewrite;
        // re-applying an identical SetField is a no-op, so the resulting
        // FixerResult records every fixed tag while the tree is rewritten
        // exactly once.
        let mut seen = HashSet::new();
        for correction in &corrections {
            if !seen.insert(correction.word.as_str()) {
                continue;
            }
            let issue = LintianIssue::binary_with_info(
                &package,
                "capitalization-error-in-description",
                Visibility::Info,
                vec![correction.word.clone(), correction.correction.clone()],
            );
            diagnostics.push(
                Diagnostic::with_actions(
                    issue,
                    format!(
                        "Description contains a capitalization error: {} should be {}.",
                        correction.word, correction.correction
                    ),
                    LABEL,
                    vec![set_field.clone()],
                )
                .with_certainty(Certainty::Possible),
            );
        }
    }

    Ok(diagnostics)
}

declare_detector! {
    name: "capitalization-error-in-description",
    tags: ["capitalization-error-in-description"],
    triggers: [
        debian_workspace::Trigger::Deb822Field {
            file: "debian/control",
            paragraph_key: "Package",
            field: "Description",
        },
    ],
    detect: |ws, prefs| detect(ws, prefs),
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::detector::Detector;
    use crate::Version;
    use std::fs;
    use std::path::Path;
    use tempfile::TempDir;

    fn run_apply(base: &Path) -> Result<crate::FixerResult, FixerError> {
        let version: Version = "1.0".parse().unwrap();
        let adapter = DetectorImpl;
        let ws = debian_workspace::fs_workspace::FsWorkspace::new(
            base,
            Some("test".into()),
            Some(version),
        );
        adapter.apply(&ws, &FixerPreferences::default())
    }

    fn corrections(extended: &str) -> Vec<(String, String)> {
        let map: HashMap<&str, &str> = SPELLING_CORRECTIONS_CASE.iter().copied().collect();
        find_corrections(extended, &map)
            .into_iter()
            .map(|c| (c.word, c.correction))
            .collect()
    }

    #[test]
    fn test_bracket_spans() {
        let empty: Vec<Range<usize>> = vec![];
        assert_eq!(bracket_spans("a [bc] d"), vec![2..6]);
        // Empty brackets are not a match (`.+?` needs a character).
        assert_eq!(bracket_spans("a [] b"), empty);
        // Non-greedy: the first `]` closes the region.
        assert_eq!(bracket_spans("[a] [b]"), vec![0..3, 4..7]);
        assert_eq!(bracket_spans("no brackets"), empty);
    }

    #[test]
    fn test_core_range() {
        let strip = |s: &str| {
            let r = core_range(s, &(0..s.len()));
            s[r].to_string()
        };
        assert_eq!(strip("linux"), "linux");
        assert_eq!(strip("(linux)"), "linux");
        assert_eq!(strip("linux."), "linux");
        assert_eq!(strip("linux),"), "linux");
        assert_eq!(strip("(linux"), "linux");
    }

    #[test]
    fn test_find_corrections_simple() {
        assert_eq!(
            corrections("This runs on linux systems."),
            vec![("linux".to_string(), "Linux".to_string())]
        );
    }

    #[test]
    fn test_find_corrections_punctuation() {
        // A trailing period and surrounding parens must not hide the word.
        assert_eq!(
            corrections("Built with (gnome)."),
            vec![("gnome".to_string(), "GNOME".to_string())]
        );
    }

    #[test]
    fn test_find_corrections_case_sensitive() {
        // `Linux` is already correct; only the lower-case form is a key.
        assert!(corrections("Runs on Linux.").is_empty());
    }

    #[test]
    fn test_find_corrections_skips_brackets() {
        // Words inside square brackets are excluded, like in lintian.
        assert!(corrections("Install one of [linux gnome].").is_empty());
    }

    #[test]
    fn test_find_corrections_meta_package() {
        assert_eq!(
            corrections("This is a meta package."),
            vec![("meta package".to_string(), "metapackage".to_string())]
        );
    }

    #[test]
    fn test_find_corrections_order_and_dedup_input() {
        // `meta package` is reported first, then words in text order;
        // every occurrence is returned (dedup happens later).
        let found = corrections("A meta package using linux and more linux.");
        assert_eq!(
            found,
            vec![
                ("meta package".to_string(), "metapackage".to_string()),
                ("linux".to_string(), "Linux".to_string()),
                ("linux".to_string(), "Linux".to_string()),
            ]
        );
    }

    #[test]
    fn test_apply_corrections_rewrites_all() {
        let map: HashMap<&str, &str> = SPELLING_CORRECTIONS_CASE.iter().copied().collect();
        let extended = "Built for linux with gnome; a meta package for linux.";
        let found = find_corrections(extended, &map);
        assert_eq!(
            apply_corrections(extended, &found),
            "Built for Linux with GNOME; a metapackage for Linux."
        );
    }

    #[test]
    fn test_fix_extended_description() {
        let tmp = TempDir::new().unwrap();
        let debian = tmp.path().join("debian");
        fs::create_dir(&debian).unwrap();
        let control = debian.join("control");
        fs::write(
            &control,
            "Source: test\n\nPackage: test\nDescription: A test package\n It runs on linux.\n",
        )
        .unwrap();

        let result = run_apply(tmp.path()).unwrap();
        assert_eq!(result.description, LABEL);
        assert_eq!(result.certainty, Some(Certainty::Possible));
        assert_eq!(
            fs::read_to_string(&control).unwrap(),
            "Source: test\n\nPackage: test\nDescription: A test package\n It runs on Linux.\n",
        );
    }

    #[test]
    fn test_fix_multiple_corrections() {
        let tmp = TempDir::new().unwrap();
        let debian = tmp.path().join("debian");
        fs::create_dir(&debian).unwrap();
        let control = debian.join("control");
        fs::write(
            &control,
            "Source: test\n\nPackage: test\nDescription: A test package\n Built for linux using gnome.\n",
        )
        .unwrap();

        let result = run_apply(tmp.path()).unwrap();
        let tags = result.fixed_lintian_tags();
        assert_eq!(tags, vec!["capitalization-error-in-description"; 2]);
        assert_eq!(
            fs::read_to_string(&control).unwrap(),
            "Source: test\n\nPackage: test\nDescription: A test package\n Built for Linux using GNOME.\n",
        );
    }

    #[test]
    fn test_synopsis_not_touched() {
        // The synopsis has its own tag; a misspelling there is left alone.
        let tmp = TempDir::new().unwrap();
        let debian = tmp.path().join("debian");
        fs::create_dir(&debian).unwrap();
        let original =
            "Source: test\n\nPackage: test\nDescription: tool for linux\n A clean extended line.\n";
        fs::write(debian.join("control"), original).unwrap();

        assert!(matches!(run_apply(tmp.path()), Err(FixerError::NoChanges)));
        assert_eq!(
            fs::read_to_string(debian.join("control")).unwrap(),
            original
        );
    }

    #[test]
    fn test_no_correction() {
        let tmp = TempDir::new().unwrap();
        let debian = tmp.path().join("debian");
        fs::create_dir(&debian).unwrap();
        let original =
            "Source: test\n\nPackage: test\nDescription: A test package\n A clean extended line.\n";
        fs::write(debian.join("control"), original).unwrap();

        assert!(matches!(run_apply(tmp.path()), Err(FixerError::NoChanges)));
    }

    #[test]
    fn test_no_control_file() {
        let tmp = TempDir::new().unwrap();
        assert!(matches!(run_apply(tmp.path()), Err(FixerError::NoChanges)));
    }
}
