//! Review command — comprehensive diff review context
//!
//! Composes impact analysis + gather context + notes + risk scoring
//! into a single structured review payload.

use std::collections::HashSet;
use std::path::{Path, PathBuf};

use crate::AnalysisError;

use crate::diff_parse::parse_unified_diff;
use crate::impact::{
    analyze_diff_impact_with_graph, compute_risk_batch, map_hunks_to_functions, CallerDetail,
    DiffTestInfo, RiskLevel, RiskScore,
};
use crate::note::path_matches_mention;
use crate::Store;

/// Result of a comprehensive diff review.
#[derive(Debug, Clone, serde::Serialize)]
pub struct ReviewResult {
    /// Functions changed by the diff
    pub changed_functions: Vec<ReviewedFunction>,
    /// All callers affected by the changes (uses impact's CallerDetail directly)
    pub affected_callers: Vec<CallerDetail>,
    /// Tests affected by or suggested for the changes (uses impact's DiffTestInfo directly)
    pub affected_tests: Vec<DiffTestInfo>,
    /// Notes relevant to changed files
    pub relevant_notes: Vec<ReviewNoteEntry>,
    /// Aggregated risk summary
    pub risk_summary: RiskSummary,
    /// Files that are stale in the index (if any)
    pub stale_warning: Option<Vec<String>>,
    /// Non-fatal warnings encountered during review
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub warnings: Vec<String>,
}

/// A changed function with its risk assessment.
#[derive(Debug, Clone, serde::Serialize)]
pub struct ReviewedFunction {
    pub name: String,
    #[serde(serialize_with = "crate::serialize_path_normalized")]
    pub file: PathBuf,
    pub line_start: u32,
    pub risk: RiskScore,
}

/// A note relevant to the review.
/// Named `ReviewNoteEntry` to avoid collision with `note::NoteEntry`
/// (parsed note from TOML) which is a different type.
#[derive(Debug, Clone, serde::Serialize)]
pub struct ReviewNoteEntry {
    pub text: String,
    pub sentiment: f32,
    pub matching_files: Vec<String>,
}

/// Aggregated risk counts.
#[derive(Debug, Clone, serde::Serialize)]
pub struct RiskSummary {
    pub high: usize,
    pub medium: usize,
    pub low: usize,
    pub overall: RiskLevel,
}

/// Analyze a unified diff and produce a comprehensive review.
/// Steps:
/// 1. Parse diff -> changed functions
/// 2. Load call graph + test chunks (once, shared by impact + risk)
/// 3. Impact analysis -> callers + tests
/// 4. Risk scoring -> per-function risk
/// 5. Note matching -> relevant notes for changed files (non-fatal)
/// 6. Staleness check -> warn if changed files are stale (non-fatal)
pub fn review_diff<Mode>(
    store: &Store<Mode>,
    diff_text: &str,
    root: &Path,
) -> Result<Option<ReviewResult>, AnalysisError> {
    let _span = tracing::info_span!("review_diff").entered();
    let mut warnings: Vec<String> = Vec::new();

    // 1. Parse hunks
    let hunks = parse_unified_diff(diff_text);
    if hunks.is_empty() {
        return Ok(None);
    }

    // 2. Map hunks to functions
    let changed = map_hunks_to_functions(store, &hunks);
    if changed.is_empty() {
        return Ok(None);
    }

    // 3. Load call graph and test chunks once — used by both impact and risk
    let graph = store.get_call_graph()?;
    let test_chunks = store.find_test_chunks()?;

    // 4. Impact analysis (reuses pre-loaded graph + test_chunks)
    let impact = analyze_diff_impact_with_graph(store, changed, &graph, &test_chunks, root)?;

    // 5. Compute risk scores for changed functions (reuses same graph + test_chunks)
    let changed_names: Vec<&str> = impact
        .changed_functions
        .iter()
        .map(|f| f.name.as_str())
        .collect();
    let risk_scores = compute_risk_batch(&changed_names, &graph, &test_chunks);

    // 6. Build reviewed functions with risk
    let reviewed_functions: Vec<ReviewedFunction> = impact
        .changed_functions
        .iter()
        .zip(risk_scores)
        .map(|(cf, risk)| ReviewedFunction {
            name: cf.name.clone(),
            file: cf.file.clone(),
            line_start: cf.line_start,
            risk,
        })
        .collect();

    // 7. Match notes to changed files (non-fatal: warning on failure)
    let changed_file_strings: Vec<String> = impact
        .changed_functions
        .iter()
        .map(|f| f.file.to_string_lossy().into_owned())
        .collect();
    let changed_files: HashSet<&str> = changed_file_strings.iter().map(|s| s.as_str()).collect();
    let relevant_notes = match match_notes(store, &changed_files) {
        Ok(notes) => notes,
        Err(e) => {
            tracing::warn!(error = %e, "Failed to load notes for review");
            warnings.push(format!("Failed to load notes for review: {e}"));
            Vec::new()
        }
    };

    // 8. Staleness check (non-fatal: warning on failure)
    let origins: Vec<&str> = changed_files.iter().copied().collect();
    let stale_warning = match store.check_origins_stale(&origins, root) {
        Ok(stale) if stale.is_empty() => None,
        Ok(stale) => Some(stale.into_iter().collect()),
        Err(e) => {
            tracing::warn!(error = %e, "Failed to check staleness");
            warnings.push(format!("Failed to check staleness: {e}"));
            None
        }
    };

    // 9. Build risk summary
    let risk_summary = build_risk_summary(&reviewed_functions);

    // 10. Relativize paths in impact types for display
    let affected_callers: Vec<CallerDetail> = impact
        .all_callers
        .into_iter()
        .map(|mut c| {
            c.file = PathBuf::from(crate::rel_display(&c.file, root));
            c
        })
        .collect();

    let affected_tests: Vec<DiffTestInfo> = impact
        .all_tests
        .into_iter()
        .map(|mut t| {
            t.file = PathBuf::from(crate::rel_display(&t.file, root));
            t
        })
        .collect();

    Ok(Some(ReviewResult {
        changed_functions: reviewed_functions,
        affected_callers,
        affected_tests,
        relevant_notes,
        risk_summary,
        stale_warning,
        warnings,
    }))
}

/// Match notes to a set of changed file paths.
/// Returns an error if notes cannot be loaded (caller decides how to handle).
fn match_notes<Mode>(
    store: &Store<Mode>,
    changed_files: &HashSet<&str>,
) -> Result<Vec<ReviewNoteEntry>, AnalysisError> {
    let _span = tracing::info_span!("match_notes").entered();

    let notes = store.list_notes_summaries()?;

    Ok(notes
        .into_iter()
        .filter_map(|note| {
            let matching: Vec<String> = changed_files
                .iter()
                .filter(|file| {
                    note.mentions
                        .iter()
                        .any(|mention| path_matches_mention(file, mention))
                })
                .map(|f| f.to_string())
                .collect();

            if matching.is_empty() {
                None
            } else {
                Some(ReviewNoteEntry {
                    text: note.text,
                    sentiment: note.sentiment,
                    matching_files: matching,
                })
            }
        })
        .collect())
}

/// Build aggregated risk summary from reviewed functions.
fn build_risk_summary(functions: &[ReviewedFunction]) -> RiskSummary {
    let high = functions
        .iter()
        .filter(|f| f.risk.risk_level == RiskLevel::High)
        .count();
    let medium = functions
        .iter()
        .filter(|f| f.risk.risk_level == RiskLevel::Medium)
        .count();
    let low = functions
        .iter()
        .filter(|f| f.risk.risk_level == RiskLevel::Low)
        .count();

    let overall = if high > 0 {
        RiskLevel::High
    } else if medium > 0 {
        RiskLevel::Medium
    } else {
        RiskLevel::Low
    };

    RiskSummary {
        high,
        medium,
        low,
        overall,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::impact::RiskScore;
    use crate::note::path_matches_mention;

    /// Creates a mock ReviewedFunction with minimal default values for testing purposes.
    /// # Arguments
    /// * `name` - The name to assign to the reviewed function
    /// * `level` - The RiskLevel to use for both risk_level and blast_radius
    /// # Returns
    /// A ReviewedFunction instance with the provided name and risk level, hardcoded file path of "src/lib.rs", line_start of 1, and all numeric risk scores initialized to zero.
    fn mock_reviewed(name: &str, level: RiskLevel) -> ReviewedFunction {
        ReviewedFunction {
            name: name.to_string(),
            file: PathBuf::from("src/lib.rs"),
            line_start: 1,
            risk: RiskScore {
                risk_level: level,
                blast_radius: level,
                caller_count: 0,
                test_count: 0,
                test_ratio: 0.0,
                score: 0.0,
            },
        }
    }

    // TC-4: build_risk_summary tests

    #[test]
    fn test_risk_summary_empty() {
        let summary = build_risk_summary(&[]);
        assert_eq!(summary.high, 0);
        assert_eq!(summary.medium, 0);
        assert_eq!(summary.low, 0);
        assert!(matches!(summary.overall, RiskLevel::Low));
    }

    #[test]
    fn test_risk_summary_all_high() {
        let funcs = vec![
            mock_reviewed("a", RiskLevel::High),
            mock_reviewed("b", RiskLevel::High),
        ];
        let summary = build_risk_summary(&funcs);
        assert_eq!(summary.high, 2);
        assert!(matches!(summary.overall, RiskLevel::High));
    }

    #[test]
    fn test_risk_summary_mixed() {
        let funcs = vec![
            mock_reviewed("a", RiskLevel::Low),
            mock_reviewed("b", RiskLevel::Medium),
            mock_reviewed("c", RiskLevel::Low),
        ];
        let summary = build_risk_summary(&funcs);
        assert_eq!(summary.high, 0);
        assert_eq!(summary.medium, 1);
        assert_eq!(summary.low, 2);
        assert!(matches!(summary.overall, RiskLevel::Medium));
    }

    // ─── TC-4: match_notes partial-match edge cases ───────────────────────────

    /// path_matches_mention: exact match always succeeds.
    #[test]
    fn test_path_matches_exact() {
        assert!(path_matches_mention("src/gather.rs", "src/gather.rs"));
    }

    /// path_matches_mention: suffix match at a component boundary.
    /// "gather.rs" should match "src/gather.rs" because the remaining prefix
    /// is "src/" (ends with '/').
    #[test]
    fn test_path_matches_suffix_component_boundary() {
        assert!(path_matches_mention("src/gather.rs", "gather.rs"));
    }

    /// path_matches_mention: suffix match must be component-aligned.
    /// "gather.rs" must NOT match "src/gatherer.rs" (the stripped prefix
    /// "src/gather" does not end with '/').
    #[test]
    fn test_path_does_not_match_mid_component_suffix() {
        assert!(!path_matches_mention("src/gatherer.rs", "gather.rs"));
    }

    /// path_matches_mention: prefix match at a component boundary.
    /// "src/store" should match "src/store/chunks.rs" because the remaining
    /// suffix starts with '/'.
    #[test]
    fn test_path_matches_prefix_component_boundary() {
        assert!(path_matches_mention("src/store/chunks.rs", "src/store"));
    }

    /// path_matches_mention: prefix match must be component-aligned.
    /// "src/store" must NOT match "my_src/store/chunks.rs" (does not start
    /// with the mention prefix).
    #[test]
    fn test_path_does_not_match_non_prefix_path() {
        assert!(!path_matches_mention("my_src/store/chunks.rs", "src/store"));
    }

    /// path_matches_mention: mention longer than path never matches.
    #[test]
    fn test_path_does_not_match_longer_mention() {
        assert!(!path_matches_mention("store.rs", "src/store.rs"));
    }

    /// match_notes filters to only notes whose mentions match at least one
    /// changed file, and populates matching_files correctly.
    #[test]
    fn test_match_notes_returns_matching_notes() {
        use crate::store::ModelInfo;
        use tempfile::TempDir;

        let dir = TempDir::new().unwrap();
        let db_path = dir.path().join(crate::INDEX_DB_FILENAME);
        let store = crate::Store::open(&db_path).unwrap();
        store.init(&ModelInfo::default()).unwrap();

        // Insert a note that mentions "gather.rs" (file mention)
        store
            .replace_notes_for_file(
                &[crate::note::Note {
                    id: "note:gather".to_string(),
                    text: "gather needs review".to_string(),
                    sentiment: -0.5,
                    mentions: vec!["gather.rs".to_string()],
                    kind: None,
                }],
                &dir.path().join("notes.toml"),
                0,
            )
            .unwrap();

        // Insert a second note that mentions an unrelated file
        store
            .replace_notes_for_file(
                &[crate::note::Note {
                    id: "note:other".to_string(),
                    text: "unrelated note".to_string(),
                    sentiment: 0.0,
                    mentions: vec!["src/other.rs".to_string()],
                    kind: None,
                }],
                &dir.path().join("notes2.toml"),
                0,
            )
            .unwrap();

        // Changed files include the full path that "gather.rs" should suffix-match
        let changed_files: HashSet<&str> =
            ["src/gather.rs", "src/index.rs"].iter().copied().collect();

        let notes = match_notes(&store, &changed_files).unwrap();

        // Only the "gather.rs" note should be returned
        assert_eq!(
            notes.len(),
            1,
            "Expected exactly 1 matching note for changed files {:?}, got: {:?}",
            changed_files,
            notes.iter().map(|n| &n.text).collect::<Vec<_>>()
        );
        assert_eq!(notes[0].text, "gather needs review");
        assert!(
            notes[0]
                .matching_files
                .contains(&"src/gather.rs".to_string()),
            "matching_files should include src/gather.rs, got {:?}",
            notes[0].matching_files
        );
    }

    /// match_notes: a note whose mention is a directory prefix matches all
    /// files under that directory.
    #[test]
    fn test_match_notes_directory_prefix_match() {
        use crate::store::ModelInfo;
        use tempfile::TempDir;

        let dir = TempDir::new().unwrap();
        let db_path = dir.path().join(crate::INDEX_DB_FILENAME);
        let store = crate::Store::open(&db_path).unwrap();
        store.init(&ModelInfo::default()).unwrap();

        // Note mentions the directory "src/store"
        store
            .replace_notes_for_file(
                &[crate::note::Note {
                    id: "note:store-dir".to_string(),
                    text: "store module has schema issues".to_string(),
                    sentiment: -0.5,
                    mentions: vec!["src/store".to_string()],
                    kind: None,
                }],
                &dir.path().join("notes.toml"),
                0,
            )
            .unwrap();

        // Changed file is inside that directory
        let changed_files: HashSet<&str> = ["src/store/chunks.rs"].iter().copied().collect();

        let notes = match_notes(&store, &changed_files).unwrap();

        assert_eq!(
            notes.len(),
            1,
            "Directory-prefix mention 'src/store' should match 'src/store/chunks.rs'"
        );
        assert!(notes[0]
            .matching_files
            .contains(&"src/store/chunks.rs".to_string()));
    }

    /// match_notes: returns empty when no note mentions any changed file.
    #[test]
    fn test_match_notes_no_match() {
        use crate::store::ModelInfo;
        use tempfile::TempDir;

        let dir = TempDir::new().unwrap();
        let db_path = dir.path().join(crate::INDEX_DB_FILENAME);
        let store = crate::Store::open(&db_path).unwrap();
        store.init(&ModelInfo::default()).unwrap();

        store
            .replace_notes_for_file(
                &[crate::note::Note {
                    id: "note:unrelated".to_string(),
                    text: "unrelated note".to_string(),
                    sentiment: 0.0,
                    mentions: vec!["src/unrelated.rs".to_string()],
                    kind: None,
                }],
                &dir.path().join("notes.toml"),
                0,
            )
            .unwrap();

        let changed_files: HashSet<&str> = ["src/other.rs"].iter().copied().collect();

        let notes = match_notes(&store, &changed_files).unwrap();
        assert!(
            notes.is_empty(),
            "Expected no matches for unrelated changed file, got: {:?}",
            notes.iter().map(|n| &n.text).collect::<Vec<_>>()
        );
    }
}
