//! SARIF (Static Analysis Results Interchange Format) output support.
//!
//! Implements SARIF 2.1.0 specification for CI/CD integration.
//! https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
//!
//! Rules are loaded from the agnix-rules crate at compile time.

use agnix_core::diagnostics::{Diagnostic, DiagnosticLevel};
use agnix_rules::RULES_DATA;
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;

const SARIF_SCHEMA: &str = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json";
const SARIF_VERSION: &str = "2.1.0";
const TOOL_NAME: &str = "agnix";
const TOOL_INFO_URI: &str = "https://github.com/agent-sh/agnix";

#[derive(Debug, Serialize)]
pub struct SarifLog {
    #[serde(rename = "$schema")]
    pub schema: String,
    pub version: String,
    pub runs: Vec<Run>,
}

#[derive(Debug, Serialize)]
pub struct Run {
    pub tool: Tool,
    pub results: Vec<SarifResult>,
}

#[derive(Debug, Serialize)]
pub struct Tool {
    pub driver: Driver,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Driver {
    pub name: String,
    pub version: String,
    pub information_uri: String,
    pub rules: Vec<ReportingDescriptor>,
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReportingDescriptor {
    pub id: String,
    pub short_description: Message,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub help_uri: Option<String>,
    /// Custom properties bag for rule metadata (SARIF 2.1.0 extension point).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub properties: Option<ReportingDescriptorProperties>,
}

/// Properties bag for a reporting descriptor, carrying rule metadata.
#[derive(Debug, Clone, Serialize)]
pub struct ReportingDescriptorProperties {
    /// Rule category (e.g., "agent-skills").
    #[serde(skip_serializing_if = "Option::is_none")]
    pub category: Option<String>,
    /// Rule severity from the rules catalog (e.g., "HIGH").
    #[serde(skip_serializing_if = "Option::is_none")]
    pub severity: Option<String>,
    /// Tool this rule applies to (e.g., "claude-code").
    #[serde(rename = "appliesToTool", skip_serializing_if = "Option::is_none")]
    pub applies_to_tool: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct Message {
    pub text: String,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifResult {
    pub rule_id: String,
    pub level: String,
    pub message: Message,
    pub locations: Vec<Location>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Location {
    pub physical_location: PhysicalLocation,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PhysicalLocation {
    pub artifact_location: ArtifactLocation,
    pub region: Region,
}

#[derive(Debug, Serialize)]
pub struct ArtifactLocation {
    pub uri: String,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Region {
    pub start_line: usize,
    pub start_column: usize,
}

fn level_to_sarif(level: DiagnosticLevel) -> &'static str {
    match level {
        DiagnosticLevel::Error => "error",
        DiagnosticLevel::Warning => "warning",
        DiagnosticLevel::Info => "note",
    }
}

fn path_to_uri(path: &Path, base_path: &Path) -> String {
    // Convert to relative path if possible, otherwise keep absolute
    let uri_path = path
        .strip_prefix(base_path)
        .unwrap_or(path)
        .to_string_lossy()
        .replace('\\', "/");
    uri_path
}

static RULES: LazyLock<Vec<ReportingDescriptor>> = LazyLock::new(|| {
    // Rules loaded from knowledge-base/rules.json at compile time via build.rs
    RULES_DATA
        .iter()
        .map(|(id, desc)| {
            let properties =
                agnix_rules::get_rule_metadata(id).map(|(category, severity, tool)| {
                    ReportingDescriptorProperties {
                        category: (!category.is_empty()).then_some(category.to_string()),
                        severity: (!severity.is_empty()).then_some(severity.to_string()),
                        applies_to_tool: (!tool.is_empty()).then_some(tool.to_string()),
                    }
                });

            ReportingDescriptor {
                id: id.to_string(),
                short_description: Message {
                    text: desc.to_string(),
                },
                help_uri: Some(format!(
                    "https://avifenesh.github.io/agnix/docs/rules/generated/{}",
                    id.to_lowercase()
                )),
                properties,
            }
        })
        .collect()
});

fn get_all_rules() -> &'static [ReportingDescriptor] {
    &RULES
}

pub fn diagnostics_to_sarif(diagnostics: &[Diagnostic], base_path: &Path) -> SarifLog {
    let results: Vec<SarifResult> = diagnostics
        .iter()
        .map(|diag| SarifResult {
            rule_id: diag.rule.clone(),
            level: level_to_sarif(diag.level).to_string(),
            message: Message {
                text: diag.message.clone(),
            },
            locations: vec![Location {
                physical_location: PhysicalLocation {
                    artifact_location: ArtifactLocation {
                        uri: path_to_uri(&diag.file, base_path),
                    },
                    region: Region {
                        // SARIF requires 1-based positions; clamp to 1 for diagnostics without location
                        start_line: diag.line.max(1),
                        start_column: diag.column.max(1),
                    },
                },
            }],
        })
        .collect();

    SarifLog {
        schema: SARIF_SCHEMA.to_string(),
        version: SARIF_VERSION.to_string(),
        runs: vec![Run {
            tool: Tool {
                driver: Driver {
                    name: TOOL_NAME.to_string(),
                    version: env!("CARGO_PKG_VERSION").to_string(),
                    information_uri: TOOL_INFO_URI.to_string(),
                    rules: get_all_rules().to_vec(),
                },
            },
            results,
        }],
    }
}

/// Walk ancestors of `start` looking for a `.git` directory or file (worktrees/submodules).
///
/// Prefers the canonical path (resolves symlinks for better IDE integration),
/// but falls back to walking the original path if canonicalization fails.
/// Returns the repository root path, or `None` if no git marker is found.
pub fn find_git_root(start: &Path) -> Option<PathBuf> {
    // Try canonical path first (resolves symlinks for better IDE integration)
    if let Ok(canonical) = std::fs::canonicalize(start) {
        for ancestor in canonical.ancestors() {
            if ancestor.join(".git").exists() {
                return Some(ancestor.to_path_buf());
            }
        }
    }

    // Fall back to walking the original path if canonicalization fails.
    // Canonicalize the result for consistency with the primary path above.
    for ancestor in start.ancestors() {
        if ancestor.join(".git").exists() {
            return Some(
                std::fs::canonicalize(ancestor).unwrap_or_else(|_| ancestor.to_path_buf()),
            );
        }
    }

    None
}

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

    #[test]
    fn test_find_git_root_finds_repo() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir(tmp.path().join(".git")).unwrap();
        let result = find_git_root(tmp.path());
        assert!(result.is_some());
        assert_eq!(result.unwrap(), std::fs::canonicalize(tmp.path()).unwrap());
    }

    #[test]
    fn test_find_git_root_nested() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir(tmp.path().join(".git")).unwrap();
        let nested = tmp.path().join("a").join("b").join("c");
        std::fs::create_dir_all(&nested).unwrap();
        let result = find_git_root(&nested);
        assert!(result.is_some());
        assert_eq!(result.unwrap(), std::fs::canonicalize(tmp.path()).unwrap());
    }

    #[test]
    fn test_find_git_root_worktree_file() {
        let tmp = tempfile::tempdir().unwrap();
        // Git worktrees use a .git *file* pointing to the main repo
        std::fs::write(tmp.path().join(".git"), "gitdir: /some/other/path").unwrap();
        let result = find_git_root(tmp.path());
        assert!(result.is_some());
        assert_eq!(result.unwrap(), std::fs::canonicalize(tmp.path()).unwrap());
    }

    #[test]
    fn test_find_git_root_no_repo() {
        let tmp = tempfile::tempdir().unwrap();
        // Create a nested dir with its own .git to isolate from the host repo,
        // then test a sibling dir that has no .git ancestor within tmp.
        let repo = tmp.path().join("repo");
        let orphan = tmp.path().join("orphan");
        std::fs::create_dir(&repo).unwrap();
        std::fs::create_dir(repo.join(".git")).unwrap();
        std::fs::create_dir(&orphan).unwrap();

        // The orphan dir has no .git marker. If the test host's /tmp is inside
        // a git repo the function may still return Some, so we verify that any
        // result points outside our tmp dir (i.e. it didn't pick up repo/.git).
        let result = find_git_root(&orphan);
        if let Some(ref root) = result {
            assert!(
                !root.starts_with(&repo),
                "Should not find repo/.git from orphan dir, got: {}",
                root.display()
            );
        }
    }

    #[test]
    fn test_find_git_root_from_subdirectory() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir(tmp.path().join(".git")).unwrap();
        let nested = tmp.path().join("sub");
        std::fs::create_dir(&nested).unwrap();
        let root = find_git_root(&nested);
        assert_eq!(root, Some(std::fs::canonicalize(tmp.path()).unwrap()));
    }

    #[test]
    fn test_find_git_root_nested_repos_returns_innermost() {
        // Simulate a submodule: outer/.git + outer/inner/.git
        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir(tmp.path().join(".git")).unwrap();
        let inner = tmp.path().join("inner");
        std::fs::create_dir(&inner).unwrap();
        std::fs::create_dir(inner.join(".git")).unwrap();
        let deep = inner.join("src");
        std::fs::create_dir(&deep).unwrap();

        let result = find_git_root(&deep).unwrap();
        assert_eq!(
            result,
            std::fs::canonicalize(&inner).unwrap(),
            "Should return the innermost git root (submodule), not the outer repo"
        );
    }

    #[test]
    fn test_find_git_root_returns_canonical_from_fallback() {
        // Verify that find_git_root returns a canonical (absolute) path even
        // when the primary canonicalize-then-walk path is used.
        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir(tmp.path().join(".git")).unwrap();
        let result = find_git_root(tmp.path()).unwrap();
        assert!(
            result.is_absolute(),
            "find_git_root should return an absolute path, got: {}",
            result.display()
        );
    }

    #[test]
    fn test_find_git_root_none_fallback_to_cwd() {
        // When find_git_root returns None (no git repo), callers fall back
        // to CWD canonicalization. Verify the fallback pattern works.
        #[allow(clippy::unnecessary_literal_unwrap)] // intentional: pinning the fallback shape
        let fallback: PathBuf = Option::<PathBuf>::None
            .unwrap_or_else(|| std::fs::canonicalize(".").unwrap_or_else(|_| PathBuf::from(".")));
        assert!(
            fallback.is_absolute(),
            "CWD fallback should always be absolute, got: {}",
            fallback.display()
        );
    }

    #[test]
    fn test_sarif_version() {
        let sarif = diagnostics_to_sarif(&[], Path::new("."));
        assert_eq!(sarif.version, "2.1.0");
    }

    #[test]
    fn test_sarif_schema() {
        let sarif = diagnostics_to_sarif(&[], Path::new("."));
        assert!(sarif.schema.contains("sarif-schema-2.1.0"));
    }

    #[test]
    fn test_level_mapping_error() {
        assert_eq!(level_to_sarif(DiagnosticLevel::Error), "error");
    }

    #[test]
    fn test_level_mapping_warning() {
        assert_eq!(level_to_sarif(DiagnosticLevel::Warning), "warning");
    }

    #[test]
    fn test_level_mapping_info() {
        assert_eq!(level_to_sarif(DiagnosticLevel::Info), "note");
    }

    #[test]
    fn test_path_normalization_forward_slashes() {
        let path = Path::new("foo\\bar\\baz.md");
        let base = Path::new(".");
        let uri = path_to_uri(path, base);
        assert!(!uri.contains('\\'), "URI should use forward slashes");
        assert!(uri.contains('/') || !uri.contains('\\'));
    }

    #[test]
    fn test_path_normalization_relative() {
        let path = PathBuf::from("/project/src/file.md");
        let base = Path::new("/project");
        let uri = path_to_uri(&path, base);
        assert_eq!(uri, "src/file.md");
    }

    #[test]
    fn test_empty_diagnostics_produces_valid_sarif() {
        let sarif = diagnostics_to_sarif(&[], Path::new("."));
        assert_eq!(sarif.version, "2.1.0");
        assert_eq!(sarif.runs.len(), 1);
        assert!(sarif.runs[0].results.is_empty());
        assert_eq!(sarif.runs[0].tool.driver.name, "agnix");
    }

    #[test]
    fn test_rules_array_populated() {
        let sarif = diagnostics_to_sarif(&[], Path::new("."));
        let rules = &sarif.runs[0].tool.driver.rules;
        // Keep SARIF rule metadata in sync with rule registry data.
        assert_eq!(
            rules.len(),
            agnix_rules::rule_count(),
            "Expected {} rules in SARIF driver",
            agnix_rules::rule_count()
        );

        // Verify some specific rules exist
        let rule_ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
        assert!(rule_ids.contains(&"AS-001"));
        assert!(rule_ids.contains(&"CC-HK-001"));
        assert!(rule_ids.contains(&"MCP-001"));
        assert!(rule_ids.contains(&"COP-001"));
        assert!(rule_ids.contains(&"CUR-001"));
        assert!(rule_ids.contains(&"XML-001"));
        assert!(rule_ids.contains(&"XP-003"));
    }

    #[test]
    fn test_diagnostic_conversion() {
        let diag = Diagnostic::error(
            PathBuf::from("/project/test.md"),
            10,
            5,
            "AS-001",
            "Missing frontmatter".to_string(),
        );

        let sarif = diagnostics_to_sarif(&[diag], Path::new("/project"));

        assert_eq!(sarif.runs[0].results.len(), 1);
        let result = &sarif.runs[0].results[0];
        assert_eq!(result.rule_id, "AS-001");
        assert_eq!(result.level, "error");
        assert_eq!(result.message.text, "Missing frontmatter");
        assert_eq!(result.locations[0].physical_location.region.start_line, 10);
        assert_eq!(result.locations[0].physical_location.region.start_column, 5);
        assert_eq!(
            result.locations[0].physical_location.artifact_location.uri,
            "test.md"
        );
    }

    #[test]
    fn test_sarif_json_serialization() {
        let sarif = diagnostics_to_sarif(&[], Path::new("."));
        let json = serde_json::to_string(&sarif);
        assert!(json.is_ok(), "SARIF should serialize to JSON");

        let json_str = json.unwrap();
        assert!(json_str.contains("\"$schema\""));
        assert!(json_str.contains("\"version\":\"2.1.0\""));
        assert!(json_str.contains("\"driver\""));
        assert!(json_str.contains("\"rules\""));
    }

    #[test]
    fn test_path_to_uri_fallback_when_not_prefix() {
        let path = PathBuf::from("/different/absolute/path.md");
        let base = Path::new("/project");
        let uri = path_to_uri(&path, base);
        // Should return full path when base is not a prefix
        assert!(uri.contains("different/absolute/path.md"));
    }

    #[test]
    fn test_diagnostic_single_location() {
        let diag = Diagnostic::error(
            PathBuf::from("/project/test.md"),
            10,
            5,
            "AS-001",
            "Test".to_string(),
        );
        let sarif = diagnostics_to_sarif(&[diag], Path::new("/project"));
        assert_eq!(
            sarif.runs[0].results[0].locations.len(),
            1,
            "Each diagnostic should produce exactly one location"
        );
    }

    #[test]
    fn test_warning_level_conversion() {
        let diag = Diagnostic::warning(
            PathBuf::from("/project/test.md"),
            5,
            1,
            "CC-SK-006",
            "Warning message".to_string(),
        );
        let sarif = diagnostics_to_sarif(&[diag], Path::new("/project"));
        assert_eq!(sarif.runs[0].results[0].level, "warning");
    }

    #[test]
    fn test_info_level_conversion() {
        let diag = Diagnostic {
            level: DiagnosticLevel::Info,
            message: "Info message".to_string(),
            file: PathBuf::from("/project/test.md"),
            line: 1,
            column: 1,
            rule: "info".to_string(),
            suggestion: None,
            fixes: vec![],
            assumption: None,
            metadata: None,
        };
        let sarif = diagnostics_to_sarif(&[diag], Path::new("/project"));
        assert_eq!(sarif.runs[0].results[0].level, "note");
    }

    #[test]
    fn test_multiple_diagnostics_different_files() {
        let diags = vec![
            Diagnostic::error(PathBuf::from("/p/a.md"), 1, 1, "AS-001", "A".to_string()),
            Diagnostic::warning(PathBuf::from("/p/b.md"), 2, 2, "AS-002", "B".to_string()),
            Diagnostic::error(PathBuf::from("/p/c.md"), 3, 3, "AS-003", "C".to_string()),
        ];
        let sarif = diagnostics_to_sarif(&diags, Path::new("/p"));
        assert_eq!(sarif.runs[0].results.len(), 3);
        assert_eq!(
            sarif.runs[0].results[0].locations[0]
                .physical_location
                .artifact_location
                .uri,
            "a.md"
        );
        assert_eq!(
            sarif.runs[0].results[1].locations[0]
                .physical_location
                .artifact_location
                .uri,
            "b.md"
        );
        assert_eq!(
            sarif.runs[0].results[2].locations[0]
                .physical_location
                .artifact_location
                .uri,
            "c.md"
        );
    }

    #[test]
    fn test_no_duplicate_rule_ids() {
        let rules = get_all_rules();
        let mut ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
        let original_len = ids.len();
        ids.sort();
        ids.dedup();
        assert_eq!(ids.len(), original_len, "Should have no duplicate rule IDs");
    }

    #[test]
    fn test_help_uri_format_and_anchor() {
        let rules = get_all_rules();
        const BASE_URL: &str = "https://avifenesh.github.io/agnix/docs/rules/generated/";

        for rule in rules {
            let uri = rule
                .help_uri
                .as_ref()
                .expect("All rules should have help_uri");

            assert!(
                uri.starts_with(BASE_URL),
                "Rule {} has invalid help_uri base: {}",
                rule.id,
                uri
            );

            let anchor = uri
                .strip_prefix(BASE_URL)
                .expect("Anchor should be present");

            assert_eq!(
                anchor,
                rule.id.to_lowercase(),
                "Anchor for rule {} should be its lowercase ID, but was '{}'",
                rule.id,
                anchor
            );
        }
    }

    #[test]
    fn test_rules_have_properties_with_metadata() {
        let sarif = diagnostics_to_sarif(&[], Path::new("."));
        let rules = &sarif.runs[0].tool.driver.rules;

        // Find AS-001 - should have properties
        let as001 = rules.iter().find(|r| r.id == "AS-001");
        assert!(as001.is_some(), "AS-001 should exist in SARIF rules");
        let props = as001.unwrap().properties.as_ref();
        assert!(props.is_some(), "AS-001 should have properties");
        let props = props.unwrap();
        assert_eq!(props.category, Some("agent-skills".to_string()));
        assert_eq!(props.severity, Some("HIGH".to_string()));
        // AS-001 is generic
        assert!(props.applies_to_tool.is_none());
    }

    #[test]
    fn test_tool_specific_rule_has_tool_in_properties() {
        let sarif = diagnostics_to_sarif(&[], Path::new("."));
        let rules = &sarif.runs[0].tool.driver.rules;

        let cc_hk_001 = rules.iter().find(|r| r.id == "CC-HK-001");
        assert!(cc_hk_001.is_some());
        let props = cc_hk_001.unwrap().properties.as_ref().unwrap();
        assert_eq!(props.applies_to_tool, Some("claude-code".to_string()));
    }

    #[test]
    fn test_sarif_properties_serialize_correctly() {
        let sarif = diagnostics_to_sarif(&[], Path::new("."));
        let json = serde_json::to_string_pretty(&sarif).unwrap();
        // The JSON should contain "properties" with "category"
        assert!(
            json.contains("\"properties\""),
            "SARIF JSON should contain properties"
        );
        assert!(
            json.contains("\"category\""),
            "SARIF properties should contain category"
        );
    }

    #[test]
    fn test_zero_line_column_clamped_to_one() {
        // SARIF 2.1.0 requires 1-based positions, so 0 values must be clamped
        let diag = Diagnostic {
            level: DiagnosticLevel::Error,
            message: "Test error".to_string(),
            file: PathBuf::from("/project/test.md"),
            line: 0,
            column: 0,
            rule: "AS-001".to_string(),
            suggestion: None,
            fixes: vec![],
            assumption: None,
            metadata: None,
        };

        let sarif = diagnostics_to_sarif(&[diag], Path::new("/project"));

        let region = &sarif.runs[0].results[0].locations[0]
            .physical_location
            .region;

        assert_eq!(
            region.start_line, 1,
            "Line 0 should be clamped to 1 for SARIF compatibility"
        );
        assert_eq!(
            region.start_column, 1,
            "Column 0 should be clamped to 1 for SARIF compatibility"
        );
    }
}
