use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::process::Command;

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/// A ready-to-file GitHub issue built from a review finding.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueTemplate {
    pub title: String,
    pub body: String,
    pub labels: Vec<String>,
}

/// The outcome of a batch filing run.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilingResult {
    pub filed: Vec<FiledIssue>,
    pub skipped: Vec<SkippedIssue>,
}

/// An issue that was successfully created on GitHub.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FiledIssue {
    pub number: u64,
    pub title: String,
    pub url: String,
}

/// An issue that was not filed (e.g. duplicate detected).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkippedIssue {
    pub title: String,
    pub reason: String,
}

/// Consolidated review finding ready for filing. Self-contained so this
/// module can be developed independently of findings.rs — the types will be
/// unified later.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FindingForFiling {
    pub title: String,
    pub severity: String,
    pub file: String,
    pub line: Option<usize>,
    pub description: String,
    pub suggested_fix: Option<String>,
    pub consensus_count: usize,
}

// ---------------------------------------------------------------------------
// Template building
// ---------------------------------------------------------------------------

/// Build a GitHub issue template from a review finding.
///
/// - Title is prefixed with the severity in brackets.
/// - Body is structured markdown with Description, Location, Suggested Fix,
///   and Metadata sections.
/// - Labels are derived from severity: critical/high -> "bug", medium ->
///   "enhancement", low/info -> "tech-debt". "review-finding" is always added.
#[must_use]
pub fn build_issue_template(finding: &FindingForFiling) -> IssueTemplate {
    let severity_upper = finding.severity.to_uppercase();
    let title = format!("[{}] {}", severity_upper, finding.title);

    let location = finding.line.map_or_else(
        || format!("`{}`", finding.file),
        |line| format!("`{}:{}`", finding.file, line),
    );

    let suggested_fix_section = finding
        .suggested_fix
        .as_ref()
        .map_or_else(String::new, |fix| format!("## Suggested Fix\n\n{fix}\n"));

    let body = format!(
        "## Description\n\n{}\n\n## Location\n\n{}\n\n{}## Metadata\n\n- **Severity**: {}\n- **Consensus count**: {}\n- **Source**: automated swarm review\n",
        finding.description,
        location,
        suggested_fix_section,
        severity_upper,
        finding.consensus_count,
    );

    let severity_label = match severity_upper.as_str() {
        "CRITICAL" | "HIGH" => "bug",
        "MEDIUM" => "enhancement",
        _ => "tech-debt", // LOW, INFO, or anything else
    };

    let labels = vec![severity_label.to_string(), "review-finding".to_string()];

    IssueTemplate {
        title,
        body,
        labels,
    }
}

// ---------------------------------------------------------------------------
// Duplicate detection
// ---------------------------------------------------------------------------

/// Normalize a title for comparison: lowercase, strip leading `[SEVERITY]`
/// prefix brackets, collapse whitespace.
fn normalize_title(title: &str) -> String {
    let trimmed = title.trim().to_lowercase();
    // Strip a leading "[…]" severity prefix if present.
    let without_prefix = if trimmed.starts_with('[') {
        match trimmed.find(']') {
            Some(idx) => trimmed[idx + 1..].trim_start().to_string(),
            None => trimmed,
        }
    } else {
        trimmed
    };
    // Collapse multiple spaces.
    without_prefix
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
}

/// Extract the set of words from a string.
fn word_set(s: &str) -> HashSet<String> {
    s.split_whitespace()
        .map(std::string::ToString::to_string)
        .collect()
}

/// Jaccard similarity between two word sets.
fn jaccard(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
    if a.is_empty() && b.is_empty() {
        return 1.0;
    }
    let intersection = a.intersection(b).count();
    let union = a.union(b).count();
    if union == 0 {
        return 0.0;
    }
    f64::from(u32::try_from(intersection).unwrap_or(u32::MAX))
        / f64::from(u32::try_from(union).unwrap_or(u32::MAX))
}

/// Check whether `title` is a likely duplicate of any entry in
/// `existing_issues`. Uses normalized comparison and word-overlap Jaccard
/// similarity with a threshold of 0.7.
#[must_use]
pub fn check_duplicate(title: &str, existing_issues: &[String]) -> bool {
    let norm = normalize_title(title);
    let norm_words = word_set(&norm);

    for existing in existing_issues {
        let existing_norm = normalize_title(existing);

        // Exact match after normalization.
        if norm == existing_norm {
            return true;
        }

        // Substring containment (either direction).
        if norm.contains(&existing_norm) || existing_norm.contains(&norm) {
            return true;
        }

        // Jaccard word-overlap.
        let existing_words = word_set(&existing_norm);
        if jaccard(&norm_words, &existing_words) > 0.7 {
            return true;
        }
    }

    false
}

// ---------------------------------------------------------------------------
// Filing via gh CLI
// ---------------------------------------------------------------------------

/// Fetch the titles of currently open issues from GitHub via `gh issue list`.
fn fetch_existing_issue_titles() -> Result<Vec<String>> {
    let output = Command::new("gh")
        .args(["issue", "list", "--json", "title", "--limit", "500"])
        .output()
        .context("failed to run `gh issue list`")?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!("gh issue list failed: {stderr}");
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let entries: Vec<serde_json::Value> =
        serde_json::from_str(&stdout).context("failed to parse gh issue list JSON")?;

    let titles: Vec<String> = entries
        .iter()
        .filter_map(|v| v.get("title").and_then(|t| t.as_str()).map(String::from))
        .collect();

    Ok(titles)
}

/// Create a single issue via `gh issue create` and return `(number, url)`.
fn create_issue_via_gh(template: &IssueTemplate) -> Result<(u64, String)> {
    let labels_arg = template.labels.join(",");

    let output = Command::new("gh")
        .args([
            "issue",
            "create",
            "--title",
            &template.title,
            "--body",
            &template.body,
            "--label",
            &labels_arg,
        ])
        .output()
        .context("failed to run `gh issue create`")?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!("gh issue create failed: {stderr}");
    }

    let url = String::from_utf8_lossy(&output.stdout).trim().to_string();

    // gh issue create prints the URL — extract the issue number from the
    // trailing path segment (e.g. …/issues/42).
    let number = url
        .rsplit('/')
        .next()
        .and_then(|seg| seg.parse::<u64>().ok())
        .unwrap_or(0);

    Ok((number, url))
}

/// File issues for the given findings. Existing open issues are fetched first
/// so duplicates can be detected and skipped.
///
/// When `dry_run` is true no issues are actually created — the result shows
/// what *would* happen.
/// # Errors
///
/// Returns an error if fetching existing issues or creating new issues fails.
pub fn file_issues(findings: &[FindingForFiling], dry_run: bool) -> Result<FilingResult> {
    let existing_titles = if dry_run {
        // In dry-run mode we still try to fetch existing issues so we can
        // report duplicates accurately, but we don't fail if gh is
        // unavailable.
        fetch_existing_issue_titles().unwrap_or_default()
    } else {
        fetch_existing_issue_titles()?
    };

    let mut filed: Vec<FiledIssue> = Vec::new();
    let mut skipped: Vec<SkippedIssue> = Vec::new();

    for finding in findings {
        let template = build_issue_template(finding);

        if check_duplicate(&template.title, &existing_titles) {
            skipped.push(SkippedIssue {
                title: template.title,
                reason: "duplicate of existing issue".to_string(),
            });
            continue;
        }

        if dry_run {
            filed.push(FiledIssue {
                number: 0,
                title: template.title,
                url: "(dry run)".to_string(),
            });
        } else {
            let (number, url) = create_issue_via_gh(&template)?;
            filed.push(FiledIssue {
                number,
                title: template.title,
                url,
            });
        }
    }

    Ok(FilingResult { filed, skipped })
}

/// File issues with a summary table printed to stdout afterwards.
/// # Errors
///
/// Returns an error if issue filing or retrieval fails.
pub fn file_issues_batch(findings: &[FindingForFiling], dry_run: bool) -> Result<FilingResult> {
    let result = file_issues(findings, dry_run)?;

    // -- Summary table -------------------------------------------------------
    if dry_run {
        println!("=== Dry Run Summary ===");
    } else {
        println!("=== Filing Summary ===");
    }

    if !result.filed.is_empty() {
        println!("\nFiled ({}):", result.filed.len());
        for issue in &result.filed {
            if dry_run {
                println!("  - {}", issue.title);
            } else {
                println!("  - #{} {} ({})", issue.number, issue.title, issue.url);
            }
        }
    }

    if !result.skipped.is_empty() {
        println!("\nSkipped ({}):", result.skipped.len());
        for issue in &result.skipped {
            println!("  - {} ({})", issue.title, issue.reason);
        }
    }

    println!(
        "\nTotal: {} filed, {} skipped",
        result.filed.len(),
        result.skipped.len()
    );

    Ok(result)
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    // -- build_issue_template ------------------------------------------------

    fn make_finding(severity: &str) -> FindingForFiling {
        FindingForFiling {
            title: "Buffer overflow in parser".to_string(),
            severity: severity.to_string(),
            file: "src/parser.rs".to_string(),
            line: Some(42),
            description: "Unchecked index into byte slice.".to_string(),
            suggested_fix: Some("Add bounds check before indexing.".to_string()),
            consensus_count: 3,
        }
    }

    #[test]
    fn template_title_includes_severity_prefix() {
        let tmpl = build_issue_template(&make_finding("high"));
        assert_eq!(tmpl.title, "[HIGH] Buffer overflow in parser");
    }

    #[test]
    fn template_body_contains_description_section() {
        let tmpl = build_issue_template(&make_finding("high"));
        assert!(tmpl.body.contains("## Description"));
        assert!(tmpl.body.contains("Unchecked index into byte slice."));
    }

    #[test]
    fn template_body_contains_location_with_line() {
        let tmpl = build_issue_template(&make_finding("high"));
        assert!(tmpl.body.contains("`src/parser.rs:42`"));
    }

    #[test]
    fn template_body_location_without_line() {
        let mut f = make_finding("medium");
        f.line = None;
        let tmpl = build_issue_template(&f);
        assert!(tmpl.body.contains("`src/parser.rs`"));
        assert!(!tmpl.body.contains(":42"));
    }

    #[test]
    fn template_body_contains_suggested_fix() {
        let tmpl = build_issue_template(&make_finding("high"));
        assert!(tmpl.body.contains("## Suggested Fix"));
        assert!(tmpl.body.contains("Add bounds check before indexing."));
    }

    #[test]
    fn template_body_omits_suggested_fix_when_none() {
        let mut f = make_finding("high");
        f.suggested_fix = None;
        let tmpl = build_issue_template(&f);
        assert!(!tmpl.body.contains("## Suggested Fix"));
    }

    #[test]
    fn template_body_contains_metadata() {
        let tmpl = build_issue_template(&make_finding("high"));
        assert!(tmpl.body.contains("**Severity**: HIGH"));
        assert!(tmpl.body.contains("**Consensus count**: 3"));
    }

    // -- label mapping -------------------------------------------------------

    #[test]
    fn labels_critical_maps_to_bug() {
        let tmpl = build_issue_template(&make_finding("critical"));
        assert!(tmpl.labels.contains(&"bug".to_string()));
        assert!(tmpl.labels.contains(&"review-finding".to_string()));
    }

    #[test]
    fn labels_high_maps_to_bug() {
        let tmpl = build_issue_template(&make_finding("high"));
        assert!(tmpl.labels.contains(&"bug".to_string()));
    }

    #[test]
    fn labels_medium_maps_to_enhancement() {
        let tmpl = build_issue_template(&make_finding("medium"));
        assert!(tmpl.labels.contains(&"enhancement".to_string()));
    }

    #[test]
    fn labels_low_maps_to_tech_debt() {
        let tmpl = build_issue_template(&make_finding("low"));
        assert!(tmpl.labels.contains(&"tech-debt".to_string()));
    }

    #[test]
    fn labels_info_maps_to_tech_debt() {
        let tmpl = build_issue_template(&make_finding("info"));
        assert!(tmpl.labels.contains(&"tech-debt".to_string()));
    }

    #[test]
    fn labels_always_include_review_finding() {
        for sev in &["critical", "high", "medium", "low", "info"] {
            let tmpl = build_issue_template(&make_finding(sev));
            assert!(
                tmpl.labels.contains(&"review-finding".to_string()),
                "missing review-finding label for severity {sev}"
            );
        }
    }

    // -- check_duplicate -----------------------------------------------------

    #[test]
    fn duplicate_exact_match() {
        let existing = vec!["[HIGH] Buffer overflow in parser".to_string()];
        assert!(check_duplicate(
            "[HIGH] Buffer overflow in parser",
            &existing
        ));
    }

    #[test]
    fn duplicate_ignores_severity_prefix() {
        let existing = vec!["Buffer overflow in parser".to_string()];
        assert!(check_duplicate(
            "[HIGH] Buffer overflow in parser",
            &existing
        ));
    }

    #[test]
    fn duplicate_case_insensitive() {
        let existing = vec!["buffer overflow in parser".to_string()];
        assert!(check_duplicate(
            "[HIGH] Buffer Overflow In Parser",
            &existing
        ));
    }

    #[test]
    fn duplicate_close_match_via_jaccard() {
        // Same core words, minor variation — should exceed 0.7 Jaccard.
        let existing = vec!["Buffer overflow found in the parser module".to_string()];
        assert!(check_duplicate(
            "[HIGH] Buffer overflow in parser module",
            &existing
        ));
    }

    #[test]
    fn no_false_positive_on_unrelated_titles() {
        let existing = vec!["Fix typo in README".to_string()];
        assert!(!check_duplicate(
            "[HIGH] Buffer overflow in parser",
            &existing
        ));
    }

    #[test]
    fn no_false_positive_on_partial_word_overlap() {
        let existing = vec!["Update parser documentation".to_string()];
        assert!(!check_duplicate(
            "[HIGH] Buffer overflow in parser",
            &existing
        ));
    }

    #[test]
    fn duplicate_with_different_severity_prefix() {
        let existing = vec!["[MEDIUM] Buffer overflow in parser".to_string()];
        assert!(check_duplicate(
            "[HIGH] Buffer overflow in parser",
            &existing
        ));
    }

    #[test]
    fn duplicate_empty_existing_list() {
        let existing: Vec<String> = vec![];
        assert!(!check_duplicate("[HIGH] Something", &existing));
    }

    // -- serde roundtrip -----------------------------------------------------

    #[test]
    fn issue_template_serde_roundtrip() {
        let tmpl = build_issue_template(&make_finding("high"));
        let json = serde_json::to_string(&tmpl).unwrap();
        let parsed: IssueTemplate = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.title, tmpl.title);
        assert_eq!(parsed.body, tmpl.body);
        assert_eq!(parsed.labels, tmpl.labels);
    }

    #[test]
    fn filing_result_serde_roundtrip() {
        let result = FilingResult {
            filed: vec![FiledIssue {
                number: 42,
                title: "[HIGH] Test issue".to_string(),
                url: "https://github.com/org/repo/issues/42".to_string(),
            }],
            skipped: vec![SkippedIssue {
                title: "[LOW] Skipped issue".to_string(),
                reason: "duplicate of existing issue".to_string(),
            }],
        };
        let json = serde_json::to_string(&result).unwrap();
        let parsed: FilingResult = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.filed.len(), 1);
        assert_eq!(parsed.filed[0].number, 42);
        assert_eq!(parsed.skipped.len(), 1);
        assert_eq!(parsed.skipped[0].reason, "duplicate of existing issue");
    }

    #[test]
    fn finding_for_filing_serde_roundtrip() {
        let finding = make_finding("critical");
        let json = serde_json::to_string(&finding).unwrap();
        let parsed: FindingForFiling = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.title, finding.title);
        assert_eq!(parsed.severity, finding.severity);
        assert_eq!(parsed.line, Some(42));
    }

    // -- normalize_title (internal) ------------------------------------------

    #[test]
    fn normalize_strips_prefix_and_lowercases() {
        assert_eq!(normalize_title("[HIGH] Buffer Overflow"), "buffer overflow");
    }

    #[test]
    fn normalize_handles_no_prefix() {
        assert_eq!(normalize_title("Buffer Overflow"), "buffer overflow");
    }

    #[test]
    fn normalize_collapses_whitespace() {
        assert_eq!(normalize_title("[HIGH]   extra   spaces  "), "extra spaces");
    }

    #[test]
    fn normalize_unclosed_bracket_treated_as_no_prefix() {
        // A title that starts with '[' but has no ']' should fall through to
        // the None arm and keep the entire (lowercased) string.
        assert_eq!(
            normalize_title("[unclosed bracket title"),
            "[unclosed bracket title"
        );
    }

    #[test]
    fn normalize_empty_string() {
        assert_eq!(normalize_title(""), "");
    }

    #[test]
    fn normalize_only_whitespace() {
        assert_eq!(normalize_title("   "), "");
    }

    // -- jaccard (internal) --------------------------------------------------

    #[test]
    fn jaccard_both_empty_returns_one() {
        let a: HashSet<String> = HashSet::new();
        let b: HashSet<String> = HashSet::new();
        assert!((jaccard(&a, &b) - 1.0).abs() < f64::EPSILON);
    }

    #[test]
    fn jaccard_identical_sets_returns_one() {
        let a: HashSet<String> = ["foo", "bar"]
            .iter()
            .map(std::string::ToString::to_string)
            .collect();
        let b = a.clone();
        assert!((jaccard(&a, &b) - 1.0).abs() < f64::EPSILON);
    }

    #[test]
    fn jaccard_disjoint_sets_returns_zero() {
        let a: HashSet<String> = std::iter::once("foo".to_string()).collect();
        let b: HashSet<String> = std::iter::once("bar".to_string()).collect();
        assert!((jaccard(&a, &b)).abs() < f64::EPSILON);
    }

    #[test]
    fn jaccard_partial_overlap() {
        let a: HashSet<String> = ["a", "b", "c"]
            .iter()
            .map(std::string::ToString::to_string)
            .collect();
        let b: HashSet<String> = ["b", "c", "d"]
            .iter()
            .map(std::string::ToString::to_string)
            .collect();
        // intersection = {b,c} = 2, union = {a,b,c,d} = 4 => 0.5
        let score = jaccard(&a, &b);
        assert!(
            (score - 0.5).abs() < f64::EPSILON,
            "expected 0.5 got {score}"
        );
    }

    #[test]
    fn jaccard_one_empty_returns_zero() {
        let a: HashSet<String> = std::iter::once("foo".to_string()).collect();
        let b: HashSet<String> = HashSet::new();
        assert!((jaccard(&a, &b)).abs() < f64::EPSILON);
    }

    // -- check_duplicate: substring containment paths -----------------------

    #[test]
    fn duplicate_via_substring_norm_contains_existing() {
        // norm ("buffer overflow in parser") contains existing ("buffer overflow")
        let existing = vec!["buffer overflow".to_string()];
        assert!(check_duplicate(
            "[HIGH] Buffer overflow in parser",
            &existing
        ));
    }

    #[test]
    fn duplicate_via_substring_existing_contains_norm() {
        // existing is longer and contains norm
        let existing = vec!["buffer overflow in parser module for all inputs".to_string()];
        assert!(check_duplicate(
            "[HIGH] Buffer overflow in parser",
            &existing
        ));
    }

    #[test]
    fn no_duplicate_when_below_jaccard_threshold() {
        // Only 1 word overlap out of many unique words -> Jaccard < 0.7
        let existing = vec!["completely unrelated title about networking".to_string()];
        assert!(!check_duplicate(
            "[HIGH] Buffer overflow in parser",
            &existing
        ));
    }

    // -- file_issues (dry_run mode, no gh required) --------------------------

    #[test]
    fn file_issues_dry_run_empty_findings() {
        let result = file_issues(&[], true).unwrap();
        assert!(result.filed.is_empty());
        assert!(result.skipped.is_empty());
    }

    #[test]
    fn file_issues_dry_run_files_issue_with_zero_number() {
        let findings = vec![FindingForFiling {
            title: "Unique test finding for dry run".to_string(),
            severity: "high".to_string(),
            file: "src/lib.rs".to_string(),
            line: Some(10),
            description: "Test description.".to_string(),
            suggested_fix: None,
            consensus_count: 1,
        }];
        let result = file_issues(&findings, true).unwrap();
        // gh is unavailable in test env so existing_titles will be empty (unwrap_or_default)
        assert_eq!(result.filed.len(), 1);
        assert_eq!(result.filed[0].number, 0);
        assert_eq!(result.filed[0].url, "(dry run)");
        assert!(result.skipped.is_empty());
    }

    #[test]
    fn file_issues_dry_run_skips_duplicate_against_empty_existing() {
        // With dry_run, existing_titles comes from gh (empty on failure).
        // Two identical findings: first is filed, second is a duplicate of the
        // *template title* already in filed — but note: check_duplicate only
        // compares against `existing_titles` fetched from gh, NOT against
        // already-filed titles in this run.  So both will be filed (number=0).
        let finding = FindingForFiling {
            title: "Duplicate finding".to_string(),
            severity: "low".to_string(),
            file: "src/lib.rs".to_string(),
            line: None,
            description: "Desc".to_string(),
            suggested_fix: None,
            consensus_count: 2,
        };
        let findings = vec![finding.clone(), finding];
        let result = file_issues(&findings, true).unwrap();
        // Both filed because existing_titles is empty (gh unavailable)
        assert_eq!(result.filed.len(), 2);
    }

    #[test]
    fn file_issues_dry_run_multiple_severities() {
        let findings: Vec<FindingForFiling> = ["critical", "high", "medium", "low", "info"]
            .iter()
            .map(|sev| FindingForFiling {
                title: format!("Finding for {sev}"),
                severity: sev.to_string(),
                file: "src/lib.rs".to_string(),
                line: None,
                description: "Desc.".to_string(),
                suggested_fix: Some("Fix it.".to_string()),
                consensus_count: 1,
            })
            .collect();
        let result = file_issues(&findings, true).unwrap();
        assert_eq!(result.filed.len(), 5);
        assert!(result.skipped.is_empty());
    }

    // -- file_issues_batch (dry_run mode, no gh required) -------------------

    #[test]
    fn file_issues_batch_dry_run_empty() {
        // Should succeed with empty filed/skipped and print dry-run summary.
        let result = file_issues_batch(&[], true).unwrap();
        assert!(result.filed.is_empty());
        assert!(result.skipped.is_empty());
    }

    #[test]
    fn file_issues_batch_dry_run_with_findings() {
        let findings = vec![
            FindingForFiling {
                title: "Batch finding one".to_string(),
                severity: "high".to_string(),
                file: "src/a.rs".to_string(),
                line: Some(1),
                description: "Desc one.".to_string(),
                suggested_fix: None,
                consensus_count: 2,
            },
            FindingForFiling {
                title: "Batch finding two".to_string(),
                severity: "medium".to_string(),
                file: "src/b.rs".to_string(),
                line: None,
                description: "Desc two.".to_string(),
                suggested_fix: Some("Fix two.".to_string()),
                consensus_count: 1,
            },
        ];
        let result = file_issues_batch(&findings, true).unwrap();
        assert_eq!(result.filed.len(), 2);
        assert!(result.skipped.is_empty());
    }

    // -- build_issue_template: additional body content assertions -----------

    #[test]
    fn template_body_contains_source_metadata() {
        let tmpl = build_issue_template(&make_finding("high"));
        assert!(tmpl.body.contains("automated swarm review"));
    }

    #[test]
    fn template_body_severity_uppercase_in_metadata() {
        let tmpl = build_issue_template(&make_finding("critical"));
        assert!(tmpl.body.contains("**Severity**: CRITICAL"));
    }

    #[test]
    fn template_body_contains_location_section() {
        let tmpl = build_issue_template(&make_finding("high"));
        assert!(tmpl.body.contains("## Location"));
    }

    #[test]
    fn template_labels_exactly_two_entries() {
        let tmpl = build_issue_template(&make_finding("high"));
        assert_eq!(tmpl.labels.len(), 2);
    }

    #[test]
    fn template_unknown_severity_maps_to_tech_debt() {
        // Any severity that is not critical/high/medium should get "tech-debt"
        let tmpl = build_issue_template(&make_finding("unknown"));
        assert!(tmpl.labels.contains(&"tech-debt".to_string()));
        assert!(tmpl.labels.contains(&"review-finding".to_string()));
    }

    // -- FilingResult / FiledIssue / SkippedIssue field coverage ------------

    #[test]
    fn filed_issue_fields_accessible() {
        let issue = FiledIssue {
            number: 99,
            title: "Title".to_string(),
            url: "https://example.com/issues/99".to_string(),
        };
        assert_eq!(issue.number, 99);
        assert_eq!(issue.title, "Title");
        assert_eq!(issue.url, "https://example.com/issues/99");
    }

    #[test]
    fn skipped_issue_fields_accessible() {
        let skipped = SkippedIssue {
            title: "Some title".to_string(),
            reason: "duplicate".to_string(),
        };
        assert_eq!(skipped.title, "Some title");
        assert_eq!(skipped.reason, "duplicate");
    }

    #[test]
    fn filing_result_fields_accessible() {
        let result = FilingResult {
            filed: vec![],
            skipped: vec![],
        };
        assert!(result.filed.is_empty());
        assert!(result.skipped.is_empty());
    }

    // -- Clone / Debug derives -----------------------------------------------

    #[test]
    fn issue_template_clone_and_debug() {
        let tmpl = build_issue_template(&make_finding("high"));
        let cloned = tmpl.clone();
        assert_eq!(cloned.title, tmpl.title);
        // Debug formatting should not panic
        let _ = format!("{tmpl:?}");
    }

    #[test]
    fn finding_for_filing_clone_and_debug() {
        let f = make_finding("medium");
        let cloned = f.clone();
        assert_eq!(cloned.severity, f.severity);
        let _ = format!("{f:?}");
    }

    #[test]
    fn filed_issue_clone_and_debug() {
        let issue = FiledIssue {
            number: 1,
            title: "t".to_string(),
            url: "u".to_string(),
        };
        let cloned = issue.clone();
        assert_eq!(cloned.number, issue.number);
        let _ = format!("{issue:?}");
    }

    #[test]
    fn skipped_issue_clone_and_debug() {
        let s = SkippedIssue {
            title: "t".to_string(),
            reason: "r".to_string(),
        };
        let cloned = s.clone();
        assert_eq!(cloned.reason, s.reason);
        let _ = format!("{s:?}");
    }

    #[test]
    fn filing_result_clone_and_debug() {
        let r = FilingResult {
            filed: vec![],
            skipped: vec![],
        };
        let cloned = r.clone();
        assert_eq!(cloned.filed.len(), 0);
        let _ = format!("{r:?}");
    }

    // -- file_issues non-dry-run with empty findings (covers line 251, 295) --

    #[test]
    fn file_issues_non_dry_run_empty_findings_succeeds() {
        // With no findings to iterate, file_issues must only call
        // fetch_existing_issue_titles (line 251 branch) and return an empty result
        // without invoking create_issue_via_gh. Requires `gh` to be available.
        let result = file_issues(&[], false);
        if let Ok(r) = result {
            assert!(r.filed.is_empty());
            assert!(r.skipped.is_empty());
        }
        // If gh is unavailable the test is silently skipped; when it is available
        // it exercises the non-dry-run fetch path (line 251).
    }

    #[test]
    fn file_issues_batch_non_dry_run_empty_prints_filing_summary() {
        // Exercises the `else` branch of `if dry_run` in file_issues_batch
        // (line 295: "=== Filing Summary ===") without touching create_issue_via_gh.
        let result = file_issues_batch(&[], false);
        if let Ok(r) = result {
            assert!(r.filed.is_empty());
            assert!(r.skipped.is_empty());
        }
    }

    // -- skipped path via duplicate of existing gh issue (lines 261-263, 310-312) --

    #[test]
    fn file_issues_dry_run_skips_when_matches_existing_gh_issue() {
        // "Release v0.5.0" is an open issue on this repo.  After stripping the
        // severity prefix the normalized title becomes "release v0.5.0" — an exact
        // match against the normalized form of the existing issue title.
        // If that issue no longer exists the finding is simply filed (no panic),
        // so the test is still correct.
        let finding = FindingForFiling {
            title: "Release v0.5.0".to_string(),
            severity: "high".to_string(),
            file: "src/lib.rs".to_string(),
            line: None,
            description: "Desc.".to_string(),
            suggested_fix: None,
            consensus_count: 1,
        };
        let result = file_issues(&[finding], true).unwrap();
        // Either skipped (duplicate matched) or filed (issue was closed); either
        // is acceptable, but one of them must be non-empty.
        assert_eq!(result.filed.len() + result.skipped.len(), 1);
    }

    #[test]
    fn file_issues_batch_dry_run_prints_skipped_section() {
        // Supply a finding that matches an existing open issue so that the skipped
        // section (lines 310-312 in file_issues_batch) is printed.
        let finding = FindingForFiling {
            title: "Release v0.5.0".to_string(),
            severity: "medium".to_string(),
            file: "src/lib.rs".to_string(),
            line: None,
            description: "Desc.".to_string(),
            suggested_fix: None,
            consensus_count: 1,
        };
        let result = file_issues_batch(&[finding], true).unwrap();
        assert_eq!(result.filed.len() + result.skipped.len(), 1);
    }

    // -- file_issues: non-dry-run with all-duplicate findings (covers 261-263) --

    #[test]
    fn file_issues_non_dry_run_all_duplicates_never_calls_create() {
        // When every finding is a duplicate of an existing open issue,
        // create_issue_via_gh is never reached.  This exercises line 251 (non-dry-run
        // fetch) and lines 261-263 (skipped push) without creating any real issues.
        let finding = FindingForFiling {
            title: "Release v0.5.0".to_string(),
            severity: "critical".to_string(),
            file: "src/release.rs".to_string(),
            line: Some(1),
            description: "Planned release.".to_string(),
            suggested_fix: None,
            consensus_count: 2,
        };
        let result = file_issues(&[finding], false);
        // If gh is unavailable or the issue was closed this simply asserts filed+skipped==1.
        if let Ok(r) = result {
            assert_eq!(r.filed.len() + r.skipped.len(), 1);
        }
    }

    // -- jaccard: union==0 guard (line 140) -----------------------------------

    #[test]
    fn jaccard_one_non_empty_one_empty_does_not_divide_by_zero() {
        // When exactly one set is non-empty, union > 0 so the `if union == 0`
        // guard on line 139 is false; intersection is 0; result is 0.
        let a: HashSet<String> = std::iter::once("only".to_string()).collect();
        let b: HashSet<String> = HashSet::new();
        let score = jaccard(&a, &b);
        assert!((score).abs() < f64::EPSILON);
    }
}
