//! Integration tests for agnix-lsp.
//!
//! These tests verify that the LSP server correctly processes
//! requests and returns appropriate responses.

use agnix_core::{Diagnostic, DiagnosticLevel};
use std::path::PathBuf;

// Re-export the diagnostic mapper for testing
mod diagnostic_mapper_tests {
    use super::*;

    fn make_diagnostic(
        level: DiagnosticLevel,
        message: &str,
        line: usize,
        column: usize,
        rule: &str,
    ) -> Diagnostic {
        Diagnostic {
            level,
            message: message.to_string(),
            file: PathBuf::from("test.md"),
            line,
            column,
            rule: rule.to_string(),
            suggestion: None,
            fixes: vec![],
            assumption: None,
            metadata: None,
        }
    }

    #[test]
    fn test_diagnostic_creation() {
        let diag = make_diagnostic(DiagnosticLevel::Error, "Test error", 10, 5, "AS-001");
        assert_eq!(diag.level, DiagnosticLevel::Error);
        assert_eq!(diag.message, "Test error");
        assert_eq!(diag.line, 10);
        assert_eq!(diag.column, 5);
        assert_eq!(diag.rule, "AS-001");
    }

    #[test]
    fn test_all_diagnostic_levels() {
        let error = make_diagnostic(DiagnosticLevel::Error, "Error", 1, 1, "AS-001");
        let warning = make_diagnostic(DiagnosticLevel::Warning, "Warning", 1, 1, "AS-002");
        let info = make_diagnostic(DiagnosticLevel::Info, "Info", 1, 1, "AS-003");

        assert_eq!(error.level, DiagnosticLevel::Error);
        assert_eq!(warning.level, DiagnosticLevel::Warning);
        assert_eq!(info.level, DiagnosticLevel::Info);
    }
}

mod validation_tests {
    use agnix_core::LintConfig;
    use tempfile::NamedTempFile;

    #[test]
    fn test_validate_valid_skill_file() {
        // Create a skill inside a directory whose name matches the skill name.
        // This avoids AS-017 (name/directory mismatch). The file also includes
        // a description to avoid AS-003, and omits non-standard fields
        // (version, model) to avoid CC-SK-017 / XP-SK-001.
        let skill_dir = tempfile::tempdir().unwrap();
        let named_dir = skill_dir.path().join("test-skill");
        std::fs::create_dir(&named_dir).unwrap();
        let skill_path = named_dir.join("SKILL.md");
        std::fs::write(
            &skill_path,
            "---\nname: test-skill\ndescription: Use when running test skill\n---\n\n# Test Skill\n\nThis is a valid skill file.\n",
        )
        .unwrap();

        let config = LintConfig::default();
        let result = agnix_core::validate_file(&skill_path, &config);
        assert!(result.is_ok());
        let outcome = result.unwrap();
        assert!(outcome.is_success());
        let diags = outcome.into_diagnostics();
        let errors: Vec<_> = diags
            .iter()
            .filter(|d| d.level == agnix_core::DiagnosticLevel::Error)
            .collect();
        assert!(
            errors.is_empty(),
            "Valid skill file should produce no error-level diagnostics, got: {:?}",
            errors
        );
    }

    #[test]
    fn test_validate_invalid_skill_name() {
        let skill_dir = tempfile::tempdir().unwrap();
        let skill_path = skill_dir.path().join("SKILL.md");

        std::fs::write(
            &skill_path,
            r#"---
name: Invalid Name With Spaces
version: 1.0.0
model: sonnet
---

# Invalid Skill

This skill has an invalid name.
"#,
        )
        .unwrap();

        let config = LintConfig::default();
        let result = agnix_core::validate_file(&skill_path, &config);
        assert!(result.is_ok());

        let diagnostics = result.unwrap().into_diagnostics();
        // Should have at least one error for invalid name
        assert!(!diagnostics.is_empty());
        assert!(
            diagnostics
                .iter()
                .any(|d| d.rule.contains("AS-004") || d.rule.contains("CC-SK"))
        );
    }

    #[test]
    fn test_validate_unknown_file_type() {
        let file = NamedTempFile::with_suffix(".txt").unwrap();
        std::fs::write(file.path(), "Some random content").unwrap();

        let config = LintConfig::default();
        let result = agnix_core::validate_file(file.path(), &config);
        assert!(result.is_ok());

        // Unknown file types should return Skipped
        let outcome = result.unwrap();
        assert!(outcome.is_skipped());
    }
}

mod server_capability_tests {
    use tower_lsp::lsp_types::*;

    #[test]
    fn test_server_capabilities_are_reasonable() {
        // Verify that the capabilities we advertise are what we expect
        let capabilities = ServerCapabilities {
            text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)),
            code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
                code_action_kinds: Some(vec![CodeActionKind::QUICKFIX]),
                ..Default::default()
            })),
            hover_provider: Some(HoverProviderCapability::Simple(true)),
            ..Default::default()
        };

        match capabilities.text_document_sync {
            Some(TextDocumentSyncCapability::Kind(kind)) => {
                assert_eq!(kind, TextDocumentSyncKind::FULL);
            }
            _ => panic!("Expected FULL text document sync"),
        }

        // Verify code action capability
        match capabilities.code_action_provider {
            Some(CodeActionProviderCapability::Options(ref opts)) => {
                let kinds = opts
                    .code_action_kinds
                    .as_ref()
                    .expect("Expected code action kinds");
                assert!(
                    kinds.contains(&CodeActionKind::QUICKFIX),
                    "Expected QUICKFIX kind"
                );
            }
            _ => panic!("Expected code action provider with options"),
        }

        // Verify hover capability
        match capabilities.hover_provider {
            Some(HoverProviderCapability::Simple(true)) => {}
            _ => panic!("Expected hover provider"),
        }
    }
}

mod code_action_tests {
    use agnix_core::Fix;

    #[test]
    fn test_fix_with_safe_flag() {
        let fix = Fix {
            start_byte: 0,
            end_byte: 5,
            replacement: "hello".to_string(),
            description: "Test fix".to_string(),
            safe: true,
            confidence: None,
            group: None,
            depends_on: None,
        };

        assert!(fix.safe);
        assert_eq!(fix.start_byte, 0);
        assert_eq!(fix.end_byte, 5);
    }

    #[test]
    fn test_fix_with_unsafe_flag() {
        let fix = Fix {
            start_byte: 10,
            end_byte: 20,
            replacement: "world".to_string(),
            description: "Unsafe fix".to_string(),
            safe: false,
            confidence: None,
            group: None,
            depends_on: None,
        };

        assert!(!fix.safe);
    }

    #[test]
    fn test_fix_insertion() {
        // Insertion is when start == end
        let fix = Fix {
            start_byte: 5,
            end_byte: 5,
            replacement: "inserted text".to_string(),
            description: "Insert text".to_string(),
            safe: true,
            confidence: None,
            group: None,
            depends_on: None,
        };

        assert_eq!(fix.start_byte, fix.end_byte);
        assert!(!fix.replacement.is_empty());
    }

    #[test]
    fn test_fix_deletion() {
        // Deletion is when replacement is empty
        let fix = Fix {
            start_byte: 0,
            end_byte: 10,
            replacement: String::new(),
            description: "Delete text".to_string(),
            safe: true,
            confidence: None,
            group: None,
            depends_on: None,
        };

        assert!(fix.replacement.is_empty());
        assert!(fix.start_byte < fix.end_byte);
    }
}

mod did_change_tests {
    use agnix_core::{Diagnostic, DiagnosticLevel, Fix};
    use std::path::PathBuf;

    #[test]
    fn test_diagnostic_with_multiple_fixes() {
        let fixes = vec![
            Fix {
                start_byte: 0,
                end_byte: 5,
                replacement: "fix1".to_string(),
                description: "First fix".to_string(),
                safe: true,
                confidence: None,
                group: None,
                depends_on: None,
            },
            Fix {
                start_byte: 10,
                end_byte: 15,
                replacement: "fix2".to_string(),
                description: "Second fix".to_string(),
                safe: false,
                confidence: None,
                group: None,
                depends_on: None,
            },
        ];

        let diag = Diagnostic {
            level: DiagnosticLevel::Error,
            message: "Multiple fixes available".to_string(),
            file: PathBuf::from("test.md"),
            line: 1,
            column: 1,
            rule: "AS-001".to_string(),
            suggestion: None,
            fixes,
            assumption: None,
            metadata: None,
        };

        assert_eq!(diag.fixes.len(), 2);
        assert!(diag.fixes[0].safe);
        assert!(!diag.fixes[1].safe);
    }

    #[test]
    fn test_diagnostic_has_fixes_method() {
        let diag_with_fixes = Diagnostic {
            level: DiagnosticLevel::Error,
            message: "Error".to_string(),
            file: PathBuf::from("test.md"),
            line: 1,
            column: 1,
            rule: "AS-001".to_string(),
            suggestion: None,
            fixes: vec![Fix {
                start_byte: 0,
                end_byte: 1,
                replacement: "x".to_string(),
                description: "Fix".to_string(),
                safe: true,
                confidence: None,
                group: None,
                depends_on: None,
            }],
            assumption: None,
            metadata: None,
        };

        let diag_without_fixes = Diagnostic {
            level: DiagnosticLevel::Error,
            message: "Error".to_string(),
            file: PathBuf::from("test.md"),
            line: 1,
            column: 1,
            rule: "AS-001".to_string(),
            suggestion: None,
            fixes: vec![],
            assumption: None,
            metadata: None,
        };

        assert!(diag_with_fixes.has_fixes());
        assert!(!diag_without_fixes.has_fixes());
    }
}

mod code_action_fix_tests {
    use agnix_core::Fix;
    use tower_lsp::lsp_types::*;

    /// Verify that LSP code actions are returned for a diagnostic with serialized fix data.
    ///
    /// This exercises the full code-action pipeline: the client sends back a diagnostic
    /// whose `.data` contains serialized `Fix` structs, and the server converts them
    /// into proper CodeAction responses with workspace edits.
    #[tokio::test]
    async fn test_code_action_returns_fix_for_diagnostic_with_data() {
        use agnix_lsp::Backend;
        use tower_lsp::{LanguageServer, LspService};

        let (service, _socket) = LspService::new(Backend::new);

        let temp_dir = tempfile::tempdir().unwrap();
        let skill_path = temp_dir.path().join("SKILL.md");

        // Content with an invalid name (AS-004 will fire with a fix)
        let content = "---\nname: Bad_Name\ndescription: Use when testing\n---\nBody";
        std::fs::write(&skill_path, content).unwrap();

        let uri = Url::from_file_path(&skill_path).unwrap();

        // Open the document so it's cached
        service
            .inner()
            .did_open(DidOpenTextDocumentParams {
                text_document: TextDocumentItem {
                    uri: uri.clone(),
                    language_id: "markdown".to_string(),
                    version: 1,
                    text: content.to_string(),
                },
            })
            .await;

        // Construct a diagnostic with serialized fix data, as the server would publish.
        // Calculate byte offsets dynamically from the content string to avoid
        // brittle hardcoded values that break if the content changes.
        let bad_name = "Bad_Name";
        let start_byte = content
            .find(bad_name)
            .expect("content should contain 'Bad_Name'");
        let end_byte = start_byte + bad_name.len();
        let fix = Fix {
            start_byte,
            end_byte,
            replacement: "bad-name".to_string(),
            description: "Convert to kebab-case".to_string(),
            safe: true,
            confidence: None,
            group: None,
            depends_on: None,
        };
        let fix_data = serde_json::to_value(vec![&fix]).unwrap();

        let lsp_diagnostic = Diagnostic {
            range: Range {
                start: Position {
                    line: 1,
                    character: 0,
                },
                end: Position {
                    line: 1,
                    character: 0,
                },
            },
            severity: Some(DiagnosticSeverity::ERROR),
            code: Some(NumberOrString::String("AS-004".to_string())),
            source: Some("agnix".to_string()),
            message: "Name must be kebab-case".to_string(),
            data: Some(fix_data),
            ..Default::default()
        };

        // Request code actions with this diagnostic
        let result = service
            .inner()
            .code_action(CodeActionParams {
                text_document: TextDocumentIdentifier { uri },
                range: Range {
                    start: Position {
                        line: 1,
                        character: 0,
                    },
                    end: Position {
                        line: 1,
                        character: 20,
                    },
                },
                context: CodeActionContext {
                    diagnostics: vec![lsp_diagnostic],
                    only: None,
                    trigger_kind: None,
                },
                work_done_progress_params: WorkDoneProgressParams::default(),
                partial_result_params: PartialResultParams::default(),
            })
            .await;

        assert!(result.is_ok());
        let actions = result.unwrap();
        assert!(
            actions.is_some(),
            "Should return code actions for diagnostic with fix data"
        );

        let actions = actions.unwrap();
        assert_eq!(
            actions.len(),
            1,
            "Expected exactly one code action for the single fix"
        );

        // Verify the code action has the right structure
        match &actions[0] {
            CodeActionOrCommand::CodeAction(action) => {
                assert_eq!(action.title, "Convert to kebab-case");
                assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
                assert_eq!(action.is_preferred, Some(true));
                assert!(
                    action.edit.is_some(),
                    "Code action should have a workspace edit"
                );
            }
            _ => panic!("Expected CodeAction, got Command"),
        }
    }
}

mod hover_tests {
    use tower_lsp::lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position};

    #[test]
    fn test_hover_content_structure() {
        let hover = Hover {
            contents: HoverContents::Markup(MarkupContent {
                kind: MarkupKind::Markdown,
                value: "**field** documentation".to_string(),
            }),
            range: None,
        };

        match hover.contents {
            HoverContents::Markup(markup) => {
                assert_eq!(markup.kind, MarkupKind::Markdown);
                assert!(markup.value.contains("field"));
            }
            _ => panic!("Expected markup content"),
        }
    }

    #[test]
    fn test_position_creation() {
        let pos = Position {
            line: 10,
            character: 5,
        };

        assert_eq!(pos.line, 10);
        assert_eq!(pos.character, 5);
    }

    #[test]
    fn test_position_zero() {
        let pos = Position {
            line: 0,
            character: 0,
        };

        assert_eq!(pos.line, 0);
        assert_eq!(pos.character, 0);
    }
}

mod lsp_handler_integration_tests {
    use agnix_lsp::Backend;
    use tower_lsp::lsp_types::*;
    use tower_lsp::{LanguageServer, LspService};

    #[tokio::test]
    async fn test_did_change_triggers_validation() {
        let (service, _socket) = LspService::new(Backend::new);

        let temp_dir = tempfile::tempdir().unwrap();
        let skill_path = temp_dir.path().join("SKILL.md");
        std::fs::write(
            &skill_path,
            r#"---
name: test-skill
version: 1.0.0
model: sonnet
---

# Test Skill
"#,
        )
        .unwrap();

        let uri = Url::from_file_path(&skill_path).unwrap();

        service
            .inner()
            .did_open(DidOpenTextDocumentParams {
                text_document: TextDocumentItem {
                    uri: uri.clone(),
                    language_id: "markdown".to_string(),
                    version: 1,
                    text: r#"---
name: test-skill
version: 1.0.0
model: sonnet
---

# Test Skill
"#
                    .to_string(),
                },
            })
            .await;

        service
            .inner()
            .did_change(DidChangeTextDocumentParams {
                text_document: VersionedTextDocumentIdentifier {
                    uri: uri.clone(),
                    version: 2,
                },
                content_changes: vec![TextDocumentContentChangeEvent {
                    range: None,
                    range_length: None,
                    text: r#"---
name: updated-skill
version: 1.0.0
model: sonnet
---

# Updated Skill
"#
                    .to_string(),
                }],
            })
            .await;
    }

    #[tokio::test]
    async fn test_did_change_with_invalid_content_produces_diagnostics() {
        let (service, _socket) = LspService::new(Backend::new);

        let temp_dir = tempfile::tempdir().unwrap();
        let skill_path = temp_dir.path().join("SKILL.md");
        std::fs::write(&skill_path, "# Empty skill").unwrap();

        let uri = Url::from_file_path(&skill_path).unwrap();

        service
            .inner()
            .did_open(DidOpenTextDocumentParams {
                text_document: TextDocumentItem {
                    uri: uri.clone(),
                    language_id: "markdown".to_string(),
                    version: 1,
                    text: "# Empty skill".to_string(),
                },
            })
            .await;

        service
            .inner()
            .did_change(DidChangeTextDocumentParams {
                text_document: VersionedTextDocumentIdentifier {
                    uri: uri.clone(),
                    version: 2,
                },
                content_changes: vec![TextDocumentContentChangeEvent {
                    range: None,
                    range_length: None,
                    text: r#"---
name: Invalid Name With Spaces
version: 1.0.0
model: invalid-model
---

# Invalid Skill
"#
                    .to_string(),
                }],
            })
            .await;
    }

    #[tokio::test]
    async fn test_code_action_returns_none_when_no_fixes() {
        let (service, _socket) = LspService::new(Backend::new);

        let temp_dir = tempfile::tempdir().unwrap();
        let skill_path = temp_dir.path().join("SKILL.md");
        std::fs::write(
            &skill_path,
            r#"---
name: test-skill
version: 1.0.0
model: sonnet
---

# Test Skill
"#,
        )
        .unwrap();

        let uri = Url::from_file_path(&skill_path).unwrap();

        service
            .inner()
            .did_open(DidOpenTextDocumentParams {
                text_document: TextDocumentItem {
                    uri: uri.clone(),
                    language_id: "markdown".to_string(),
                    version: 1,
                    text: r#"---
name: test-skill
version: 1.0.0
model: sonnet
---

# Test Skill
"#
                    .to_string(),
                },
            })
            .await;

        let result = service
            .inner()
            .code_action(CodeActionParams {
                text_document: TextDocumentIdentifier { uri },
                range: Range {
                    start: Position {
                        line: 0,
                        character: 0,
                    },
                    end: Position {
                        line: 0,
                        character: 10,
                    },
                },
                context: CodeActionContext {
                    diagnostics: vec![],
                    only: None,
                    trigger_kind: None,
                },
                work_done_progress_params: WorkDoneProgressParams::default(),
                partial_result_params: PartialResultParams::default(),
            })
            .await;

        assert!(result.is_ok());
        assert!(result.unwrap().is_none());
    }

    #[tokio::test]
    async fn test_code_action_returns_none_for_uncached_document() {
        let (service, _socket) = LspService::new(Backend::new);

        let uri = Url::parse("file:///nonexistent/SKILL.md").unwrap();

        let result = service
            .inner()
            .code_action(CodeActionParams {
                text_document: TextDocumentIdentifier { uri },
                range: Range {
                    start: Position {
                        line: 0,
                        character: 0,
                    },
                    end: Position {
                        line: 0,
                        character: 10,
                    },
                },
                context: CodeActionContext {
                    diagnostics: vec![],
                    only: None,
                    trigger_kind: None,
                },
                work_done_progress_params: WorkDoneProgressParams::default(),
                partial_result_params: PartialResultParams::default(),
            })
            .await;

        assert!(result.is_ok());
        assert!(result.unwrap().is_none());
    }

    #[tokio::test]
    async fn test_hover_returns_documentation_for_known_field() {
        let (service, _socket) = LspService::new(Backend::new);

        let temp_dir = tempfile::tempdir().unwrap();
        let skill_path = temp_dir.path().join("SKILL.md");
        std::fs::write(
            &skill_path,
            r#"---
name: test-skill
version: 1.0.0
model: sonnet
---

# Test Skill
"#,
        )
        .unwrap();

        let uri = Url::from_file_path(&skill_path).unwrap();

        service
            .inner()
            .did_open(DidOpenTextDocumentParams {
                text_document: TextDocumentItem {
                    uri: uri.clone(),
                    language_id: "markdown".to_string(),
                    version: 1,
                    text: r#"---
name: test-skill
version: 1.0.0
model: sonnet
---

# Test Skill
"#
                    .to_string(),
                },
            })
            .await;

        let result = service
            .inner()
            .hover(HoverParams {
                text_document_position_params: TextDocumentPositionParams {
                    text_document: TextDocumentIdentifier { uri },
                    position: Position {
                        line: 3,
                        character: 0,
                    },
                },
                work_done_progress_params: WorkDoneProgressParams::default(),
            })
            .await;

        assert!(result.is_ok());
        let hover = result.unwrap();
        assert!(hover.is_some());

        let hover = hover.unwrap();
        match hover.contents {
            HoverContents::Markup(markup) => {
                assert_eq!(markup.kind, MarkupKind::Markdown);
                assert!(markup.value.contains("model"));
            }
            _ => panic!("Expected markup content"),
        }
    }

    #[tokio::test]
    async fn test_hover_returns_none_for_unknown_field() {
        let (service, _socket) = LspService::new(Backend::new);

        let temp_dir = tempfile::tempdir().unwrap();
        let skill_path = temp_dir.path().join("SKILL.md");
        std::fs::write(
            &skill_path,
            r#"---
unknownfield: value
---

# Test
"#,
        )
        .unwrap();

        let uri = Url::from_file_path(&skill_path).unwrap();

        service
            .inner()
            .did_open(DidOpenTextDocumentParams {
                text_document: TextDocumentItem {
                    uri: uri.clone(),
                    language_id: "markdown".to_string(),
                    version: 1,
                    text: r#"---
unknownfield: value
---

# Test
"#
                    .to_string(),
                },
            })
            .await;

        let result = service
            .inner()
            .hover(HoverParams {
                text_document_position_params: TextDocumentPositionParams {
                    text_document: TextDocumentIdentifier { uri },
                    position: Position {
                        line: 1,
                        character: 0,
                    },
                },
                work_done_progress_params: WorkDoneProgressParams::default(),
            })
            .await;

        assert!(result.is_ok());
        assert!(result.unwrap().is_none());
    }

    #[tokio::test]
    async fn test_hover_returns_none_for_uncached_document() {
        let (service, _socket) = LspService::new(Backend::new);

        let uri = Url::parse("file:///nonexistent/SKILL.md").unwrap();

        let result = service
            .inner()
            .hover(HoverParams {
                text_document_position_params: TextDocumentPositionParams {
                    text_document: TextDocumentIdentifier { uri },
                    position: Position {
                        line: 0,
                        character: 0,
                    },
                },
                work_done_progress_params: WorkDoneProgressParams::default(),
            })
            .await;

        assert!(result.is_ok());
        assert!(result.unwrap().is_none());
    }

    #[tokio::test]
    async fn test_document_cache_lifecycle() {
        let (service, _socket) = LspService::new(Backend::new);

        let temp_dir = tempfile::tempdir().unwrap();
        let skill_path = temp_dir.path().join("SKILL.md");
        std::fs::write(&skill_path, "# Initial").unwrap();

        let uri = Url::from_file_path(&skill_path).unwrap();

        service
            .inner()
            .did_open(DidOpenTextDocumentParams {
                text_document: TextDocumentItem {
                    uri: uri.clone(),
                    language_id: "markdown".to_string(),
                    version: 1,
                    text: "# Initial".to_string(),
                },
            })
            .await;

        let hover_result = service
            .inner()
            .hover(HoverParams {
                text_document_position_params: TextDocumentPositionParams {
                    text_document: TextDocumentIdentifier { uri: uri.clone() },
                    position: Position {
                        line: 0,
                        character: 0,
                    },
                },
                work_done_progress_params: WorkDoneProgressParams::default(),
            })
            .await;
        assert!(hover_result.is_ok());

        service
            .inner()
            .did_change(DidChangeTextDocumentParams {
                text_document: VersionedTextDocumentIdentifier {
                    uri: uri.clone(),
                    version: 2,
                },
                content_changes: vec![TextDocumentContentChangeEvent {
                    range: None,
                    range_length: None,
                    text: "# Changed".to_string(),
                }],
            })
            .await;

        service
            .inner()
            .did_close(DidCloseTextDocumentParams {
                text_document: TextDocumentIdentifier { uri: uri.clone() },
            })
            .await;

        let hover_after_close = service
            .inner()
            .hover(HoverParams {
                text_document_position_params: TextDocumentPositionParams {
                    text_document: TextDocumentIdentifier { uri },
                    position: Position {
                        line: 0,
                        character: 0,
                    },
                },
                work_done_progress_params: WorkDoneProgressParams::default(),
            })
            .await;
        assert!(hover_after_close.is_ok());
        assert!(hover_after_close.unwrap().is_none());
    }

    #[tokio::test]
    async fn test_initialize_advertises_code_action_capability() {
        let (service, _socket) = LspService::new(Backend::new);

        let result = service
            .inner()
            .initialize(InitializeParams::default())
            .await
            .unwrap();

        match result.capabilities.code_action_provider {
            Some(CodeActionProviderCapability::Options(ref opts)) => {
                let kinds = opts
                    .code_action_kinds
                    .as_ref()
                    .expect("Expected code action kinds");
                assert!(
                    kinds.contains(&CodeActionKind::QUICKFIX),
                    "Expected QUICKFIX kind"
                );
            }
            _ => panic!("Expected code action capability with options"),
        }
    }

    #[tokio::test]
    async fn test_initialize_advertises_hover_capability() {
        let (service, _socket) = LspService::new(Backend::new);

        let result = service
            .inner()
            .initialize(InitializeParams::default())
            .await
            .unwrap();

        match result.capabilities.hover_provider {
            Some(HoverProviderCapability::Simple(true)) => {}
            _ => panic!("Expected hover capability"),
        }
    }

    #[tokio::test]
    async fn test_initialize_advertises_completion_capability() {
        let (service, _socket) = LspService::new(Backend::new);

        let result = service
            .inner()
            .initialize(InitializeParams::default())
            .await
            .unwrap();

        assert!(
            result.capabilities.completion_provider.is_some(),
            "Expected completion capability"
        );
    }

    #[tokio::test]
    async fn test_rapid_document_changes() {
        // Test that rapid document changes don't cause issues
        let (service, _socket) = LspService::new(Backend::new);

        let temp_dir = tempfile::tempdir().unwrap();
        let skill_path = temp_dir.path().join("SKILL.md");
        std::fs::write(&skill_path, "# Initial").unwrap();

        let uri = Url::from_file_path(&skill_path).unwrap();

        service
            .inner()
            .did_open(DidOpenTextDocumentParams {
                text_document: TextDocumentItem {
                    uri: uri.clone(),
                    language_id: "markdown".to_string(),
                    version: 1,
                    text: "# Initial".to_string(),
                },
            })
            .await;

        // Rapid-fire changes
        for i in 2..=10 {
            service
                .inner()
                .did_change(DidChangeTextDocumentParams {
                    text_document: VersionedTextDocumentIdentifier {
                        uri: uri.clone(),
                        version: i,
                    },
                    content_changes: vec![TextDocumentContentChangeEvent {
                        range: None,
                        range_length: None,
                        text: format!(
                            "---\nname: skill-{}\ndescription: Version {}\n---\n# Skill",
                            i, i
                        ),
                    }],
                })
                .await;
        }

        // Final state should be accessible
        let hover = service
            .inner()
            .hover(HoverParams {
                text_document_position_params: TextDocumentPositionParams {
                    text_document: TextDocumentIdentifier { uri },
                    position: Position {
                        line: 1,
                        character: 0,
                    },
                },
                work_done_progress_params: WorkDoneProgressParams::default(),
            })
            .await;

        assert!(hover.is_ok());
    }

    #[tokio::test]
    async fn test_multiple_documents_concurrent() {
        // Test handling multiple documents simultaneously
        let (service, _socket) = LspService::new(Backend::new);

        let temp_dir = tempfile::tempdir().unwrap();

        // Create and open multiple documents
        let mut uris = Vec::new();
        for i in 0..5 {
            let skill_dir = temp_dir.path().join(format!("skill-{}", i));
            std::fs::create_dir_all(&skill_dir).unwrap();
            let skill_path = skill_dir.join("SKILL.md");
            std::fs::write(
                &skill_path,
                format!(
                    "---\nname: skill-{}\ndescription: Test skill {}\n---\n# Skill {}",
                    i, i, i
                ),
            )
            .unwrap();

            let uri = Url::from_file_path(&skill_path).unwrap();
            uris.push(uri.clone());

            service
                .inner()
                .did_open(DidOpenTextDocumentParams {
                    text_document: TextDocumentItem {
                        uri,
                        language_id: "markdown".to_string(),
                        version: 1,
                        text: format!(
                            "---\nname: skill-{}\ndescription: Test skill {}\n---\n# Skill {}",
                            i, i, i
                        ),
                    },
                })
                .await;
        }

        // Query hover on all documents
        for uri in &uris {
            let hover = service
                .inner()
                .hover(HoverParams {
                    text_document_position_params: TextDocumentPositionParams {
                        text_document: TextDocumentIdentifier { uri: uri.clone() },
                        position: Position {
                            line: 1,
                            character: 0,
                        },
                    },
                    work_done_progress_params: WorkDoneProgressParams::default(),
                })
                .await;

            assert!(hover.is_ok());
        }

        // Close all documents
        for uri in uris {
            service
                .inner()
                .did_close(DidCloseTextDocumentParams {
                    text_document: TextDocumentIdentifier { uri },
                })
                .await;
        }
    }

    #[tokio::test]
    async fn test_completion_for_agent_file_type() {
        let (service, _socket) = LspService::new(Backend::new);

        let temp_dir = tempfile::tempdir().unwrap();
        let agent_dir = temp_dir.path().join(".claude").join("agents");
        std::fs::create_dir_all(&agent_dir).unwrap();
        let agent_path = agent_dir.join("test-agent.md");
        let content = "---\nmod\n---\n";
        std::fs::write(&agent_path, content).unwrap();

        let uri = Url::from_file_path(&agent_path).unwrap();

        service
            .inner()
            .did_open(DidOpenTextDocumentParams {
                text_document: TextDocumentItem {
                    uri: uri.clone(),
                    language_id: "markdown".to_string(),
                    version: 1,
                    text: content.to_string(),
                },
            })
            .await;

        let result = service
            .inner()
            .completion(CompletionParams {
                text_document_position: TextDocumentPositionParams {
                    text_document: TextDocumentIdentifier { uri },
                    position: Position {
                        line: 1,
                        character: 2,
                    },
                },
                work_done_progress_params: WorkDoneProgressParams::default(),
                partial_result_params: PartialResultParams::default(),
                context: None,
            })
            .await;

        assert!(result.is_ok());
        let response = result.unwrap();
        assert!(
            response.is_some(),
            "Should return completions for agent file"
        );

        match response.unwrap() {
            CompletionResponse::Array(items) => {
                assert!(
                    items.iter().any(|item| item.label == "model"),
                    "Agent completions should include 'model', got: {:?}",
                    items.iter().map(|i| &i.label).collect::<Vec<_>>()
                );
            }
            _ => panic!("Expected CompletionResponse::Array"),
        }
    }

    #[tokio::test]
    async fn test_code_action_with_fix_available() {
        let (service, _socket) = LspService::new(Backend::new);

        let temp_dir = tempfile::tempdir().unwrap();
        let skill_path = temp_dir.path().join("SKILL.md");

        // Create skill with trailing whitespace (AS-001 provides fix)
        let content = "---\nname: test-skill\ndescription: Test   \n---\n# Test";
        std::fs::write(&skill_path, content).unwrap();

        let uri = Url::from_file_path(&skill_path).unwrap();

        service
            .inner()
            .did_open(DidOpenTextDocumentParams {
                text_document: TextDocumentItem {
                    uri: uri.clone(),
                    language_id: "markdown".to_string(),
                    version: 1,
                    text: content.to_string(),
                },
            })
            .await;

        // Request code actions
        let result = service
            .inner()
            .code_action(CodeActionParams {
                text_document: TextDocumentIdentifier { uri },
                range: Range {
                    start: Position {
                        line: 0,
                        character: 0,
                    },
                    end: Position {
                        line: 5,
                        character: 0,
                    },
                },
                context: CodeActionContext {
                    diagnostics: vec![],
                    only: None,
                    trigger_kind: None,
                },
                work_done_progress_params: WorkDoneProgressParams::default(),
                partial_result_params: PartialResultParams::default(),
            })
            .await;

        assert!(result.is_ok());
        // May or may not have actions depending on validation results
    }
}

/// Tests verifying how the LSP layer handles `IoError` outcomes from the core
/// validation pipeline.
///
/// The LSP calls `validate_file` / `validate_file_with_registry` and then
/// converts the resulting `ValidationOutcome` into LSP diagnostics. When the
/// file is unreadable, the core returns `ValidationOutcome::IoError` and
/// `into_diagnostics()` must produce a single `file::read` diagnostic at
/// position line 0 / column 0 (the convention used by `Diagnostic::error` for
/// non-line-specific errors).
///
/// These tests pin that contract so that LSP position mapping (which converts
/// 1-indexed `Diagnostic.line` to 0-indexed LSP positions) cannot regress.
mod lsp_io_error_outcome_tests {
    #[cfg(unix)]
    use agnix_core::{DiagnosticLevel, LintConfig};

    /// Unix-only: make a SKILL.md unreadable, call `validate_file`, assert
    /// `IoError`, then assert `into_diagnostics()` produces a single
    /// `file::read` error at line 0 / column 0.
    ///
    /// The test is skipped when running as root because root can read files
    /// regardless of permission bits.
    #[cfg(unix)]
    #[test]
    fn test_lsp_io_error_produces_file_read_diagnostic_at_zero_position() {
        use std::fs;
        use std::os::unix::fs::PermissionsExt;

        let temp_dir = tempfile::tempdir().unwrap();
        let skill_path = temp_dir.path().join("SKILL.md");

        fs::write(
            &skill_path,
            "---\nname: test-skill\ndescription: Use when testing\n---\n\n# Test\n",
        )
        .unwrap();

        // Make the file unreadable.
        let original_mode = fs::metadata(&skill_path).unwrap().permissions().mode();
        fs::set_permissions(&skill_path, fs::Permissions::from_mode(0o000)).unwrap();

        // Probe whether the permission change took effect. On systems where the
        // process runs as root, chmod(0o000) does not prevent reads, so we skip
        // rather than produce a false failure.
        let probe_readable = fs::read(&skill_path).is_ok();
        if probe_readable {
            // Running as root or on a filesystem that ignores permission bits.
            // Restore and skip.
            fs::set_permissions(&skill_path, fs::Permissions::from_mode(original_mode)).unwrap();
            return;
        }

        let config = LintConfig::default();
        let result = agnix_core::validate_file(&skill_path, &config);

        // Restore permissions before any assertions so the temp dir can be
        // cleaned up even if an assertion panics.
        fs::set_permissions(&skill_path, fs::Permissions::from_mode(original_mode)).unwrap();

        // The call must succeed at the Result level (IoError is not an Err).
        assert!(
            result.is_ok(),
            "validate_file should return Ok(IoError), not Err: {:?}",
            result
        );

        let outcome = result.unwrap();
        assert!(
            outcome.is_io_error(),
            "Expected ValidationOutcome::IoError for unreadable file, got success/skipped"
        );

        // `into_diagnostics()` must produce exactly one `file::read` error at
        // line 0 / column 0 - the LSP maps these to position (0, 0) in the
        // document (first character).
        let diags = outcome.into_diagnostics();
        assert_eq!(
            diags.len(),
            1,
            "IoError should produce exactly one diagnostic, got: {:?}",
            diags
        );

        let diag = &diags[0];
        assert_eq!(
            diag.rule, "file::read",
            "IoError diagnostic rule should be 'file::read', got: {}",
            diag.rule
        );
        assert_eq!(
            diag.level,
            DiagnosticLevel::Error,
            "IoError diagnostic should have Error level"
        );
        assert_eq!(
            diag.line, 0,
            "IoError diagnostic should be at line 0 (non-line-specific error position)"
        );
        assert_eq!(
            diag.column, 0,
            "IoError diagnostic should be at column 0 (non-line-specific error position)"
        );
        assert_eq!(
            diag.file, skill_path,
            "IoError diagnostic file path should match the input path"
        );
    }
}

mod project_level_validation_tests {
    use agnix_lsp::Backend;
    use tower_lsp::lsp_types::*;
    use tower_lsp::{LanguageServer, LspService};

    /// Integration test: project-level validation runs without error on a
    /// workspace with nested AGENTS.md files.
    #[tokio::test]
    async fn test_project_level_validation_on_initialize() {
        let (service, _socket) = LspService::new(Backend::new);

        let temp_dir = tempfile::tempdir().unwrap();

        // Create nested AGENTS.md files (triggers AGM-006)
        std::fs::write(temp_dir.path().join("AGENTS.md"), "# Root AGENTS").unwrap();
        let sub = temp_dir.path().join("sub");
        std::fs::create_dir(&sub).unwrap();
        std::fs::write(sub.join("AGENTS.md"), "# Sub AGENTS").unwrap();

        let root_uri = Url::from_file_path(temp_dir.path()).unwrap();
        service
            .inner()
            .initialize(InitializeParams {
                root_uri: Some(root_uri),
                ..Default::default()
            })
            .await
            .unwrap();

        // Trigger initialized (which spawns project validation)
        service.inner().initialized(InitializedParams {}).await;

        // Give the spawned task a moment to run
        tokio::time::sleep(std::time::Duration::from_millis(200)).await;

        // Validation should complete without panic
    }

    /// Integration test: project-level validation responds to executeCommand.
    #[tokio::test]
    async fn test_execute_command_validate_project_rules() {
        let (service, _socket) = LspService::new(Backend::new);

        let temp_dir = tempfile::tempdir().unwrap();

        // Create instruction files
        std::fs::write(
            temp_dir.path().join("CLAUDE.md"),
            "# Project\n\nUse `npm install` to set up.\n",
        )
        .unwrap();

        let root_uri = Url::from_file_path(temp_dir.path()).unwrap();
        service
            .inner()
            .initialize(InitializeParams {
                root_uri: Some(root_uri),
                ..Default::default()
            })
            .await
            .unwrap();

        // Execute the command
        let result = service
            .inner()
            .execute_command(ExecuteCommandParams {
                command: "agnix.validateProjectRules".to_string(),
                arguments: vec![],
                work_done_progress_params: WorkDoneProgressParams::default(),
            })
            .await;

        assert!(result.is_ok(), "executeCommand should succeed");
    }

    /// Integration test: did_save on an instruction file triggers project validation.
    #[tokio::test]
    async fn test_did_save_triggers_project_validation() {
        let (service, _socket) = LspService::new(Backend::new);

        let temp_dir = tempfile::tempdir().unwrap();
        let agents_path = temp_dir.path().join("AGENTS.md");
        std::fs::write(&agents_path, "# Instructions").unwrap();

        let root_uri = Url::from_file_path(temp_dir.path()).unwrap();
        service
            .inner()
            .initialize(InitializeParams {
                root_uri: Some(root_uri),
                ..Default::default()
            })
            .await
            .unwrap();

        let uri = Url::from_file_path(&agents_path).unwrap();

        // Open the document
        service
            .inner()
            .did_open(DidOpenTextDocumentParams {
                text_document: TextDocumentItem {
                    uri: uri.clone(),
                    language_id: "markdown".to_string(),
                    version: 1,
                    text: "# Instructions".to_string(),
                },
            })
            .await;

        // Save -- should trigger project-level validation for AGENTS.md
        service
            .inner()
            .did_save(DidSaveTextDocumentParams {
                text_document: TextDocumentIdentifier { uri },
                text: None,
            })
            .await;

        // Give spawned tasks time to run
        tokio::time::sleep(std::time::Duration::from_millis(200)).await;

        // Should complete without error
    }

    /// Integration test: non-instruction file save does not trigger project validation.
    #[tokio::test]
    async fn test_did_save_non_instruction_file_no_project_validation() {
        let (service, _socket) = LspService::new(Backend::new);

        let temp_dir = tempfile::tempdir().unwrap();
        let skill_path = temp_dir.path().join("SKILL.md");
        std::fs::write(
            &skill_path,
            "---\nname: test\nversion: 1.0.0\nmodel: sonnet\n---\n# Test",
        )
        .unwrap();

        let root_uri = Url::from_file_path(temp_dir.path()).unwrap();
        service
            .inner()
            .initialize(InitializeParams {
                root_uri: Some(root_uri),
                ..Default::default()
            })
            .await
            .unwrap();

        let uri = Url::from_file_path(&skill_path).unwrap();

        service
            .inner()
            .did_open(DidOpenTextDocumentParams {
                text_document: TextDocumentItem {
                    uri: uri.clone(),
                    language_id: "markdown".to_string(),
                    version: 1,
                    text: "---\nname: test\nversion: 1.0.0\nmodel: sonnet\n---\n# Test".to_string(),
                },
            })
            .await;

        // Save SKILL.md -- is NOT a project-level trigger
        service
            .inner()
            .did_save(DidSaveTextDocumentParams {
                text_document: TextDocumentIdentifier { uri },
                text: None,
            })
            .await;

        // Should complete without triggering project validation
    }
}

mod document_version_tests {
    use agnix_lsp::Backend;
    use tower_lsp::lsp_types::*;
    use tower_lsp::{LanguageServer, LspService};

    /// Integration test: document version is correctly tracked through
    /// the full open -> change -> close lifecycle via LSP events.
    #[tokio::test]
    async fn test_document_version_lifecycle_through_events() {
        let (service, _socket) = LspService::new(Backend::new);

        let temp_dir = tempfile::tempdir().unwrap();
        let skill_path = temp_dir.path().join("SKILL.md");
        std::fs::write(
            &skill_path,
            "---\nname: ver-test\ndescription: Use when testing versions\n---\n# Test\n",
        )
        .unwrap();

        let uri = Url::from_file_path(&skill_path).unwrap();

        // This test exercises the version lifecycle through the LanguageServer
        // trait boundary and uses hover as a behavioral indicator of document
        // cache state (hover returns Some when cached, None after close).
        //
        // Note: This test verifies that versions are tracked and available
        // for publishing. The actual assertion that diagnostics are published
        // with the correct version field requires consuming LSP notifications
        // from the socket. See backend/tests.rs for unit tests that verify
        // version lifecycle (test_document_version_tracked_on_open, etc.).
        // Phase 1: Open with version 1
        service
            .inner()
            .did_open(DidOpenTextDocumentParams {
                text_document: TextDocumentItem {
                    uri: uri.clone(),
                    language_id: "markdown".to_string(),
                    version: 1,
                    text:
                        "---\nname: ver-test\ndescription: Use when testing versions\n---\n# Test\n"
                            .to_string(),
                },
            })
            .await;

        // Verify content is cached (hover should work)
        let hover = service
            .inner()
            .hover(HoverParams {
                text_document_position_params: TextDocumentPositionParams {
                    text_document: TextDocumentIdentifier { uri: uri.clone() },
                    position: Position {
                        line: 1,
                        character: 0,
                    },
                },
                work_done_progress_params: WorkDoneProgressParams::default(),
            })
            .await;
        assert!(hover.is_ok());
        assert!(
            hover.unwrap().is_some(),
            "Hover should return content for opened document"
        );

        // Phase 2: Change to version 3 (versions may skip)
        service
            .inner()
            .did_change(DidChangeTextDocumentParams {
                text_document: VersionedTextDocumentIdentifier {
                    uri: uri.clone(),
                    version: 3,
                },
                content_changes: vec![TextDocumentContentChangeEvent {
                    range: None,
                    range_length: None,
                    text: "---\nname: ver-test-updated\ndescription: Use when testing versions\n---\n# Updated\n"
                        .to_string(),
                }],
            })
            .await;

        // Phase 3: Close the document
        service
            .inner()
            .did_close(DidCloseTextDocumentParams {
                text_document: TextDocumentIdentifier { uri: uri.clone() },
            })
            .await;

        // Verify content cache is cleared (hover should return None)
        let hover_after = service
            .inner()
            .hover(HoverParams {
                text_document_position_params: TextDocumentPositionParams {
                    text_document: TextDocumentIdentifier { uri },
                    position: Position {
                        line: 1,
                        character: 0,
                    },
                },
                work_done_progress_params: WorkDoneProgressParams::default(),
            })
            .await;
        assert!(hover_after.is_ok());
        assert!(
            hover_after.unwrap().is_none(),
            "Hover should return None after document is closed"
        );
    }
}
