use super::*;

// ── Schema Description Tests ────────────────────────────────────────
//
// These tests verify that agent-visible schema descriptions (generated by
// schemars from doc comments) contain the required semantic information.
// They catch regressions where a doc comment change silently drops
// important guidance from the MCP-facing parameter schema.

/// Finding 6: `exclude_glob` schema description must include brace expansion docs.
///
/// Agents see the `description` field from the JSON Schema generated by schemars.
/// Without this, agents don't know they can pass `{**/*.spec.*,**/*.test.*}` to
/// exclude multiple patterns in one call, and will incorrectly believe only a
/// single glob pattern is supported.
#[test]
fn test_search_params_exclude_glob_schema_documents_brace_expansion() {
    let schema = schemars::schema_for!(SearchParams);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    let description = schema_json["properties"]["exclude_glob"]["description"]
        .as_str()
        .expect("`exclude_glob` property must have a `description` field in the generated schema");

    assert!(
        description.contains("brace expansion"),
        "`exclude_glob` schema description must mention brace expansion so agents know \
         they can use `{{**/*.spec.*,**/*.test.*}}` syntax.\nGot description: {description:?}"
    );

    assert!(
        description.contains('{') && description.contains('}'),
        "`exclude_glob` schema description must include a brace expansion example.\n\
         Got description: {description:?}"
    );
}

#[test]
fn test_read_params_max_lines_per_file_schema_documents_truncation() {
    let schema = schemars::schema_for!(ReadParams);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    let description = schema_json["properties"]["max_lines_per_file"]["description"]
        .as_str()
        .expect(
            "`max_lines_per_file` property must have a `description` field in the generated schema",
        );

    assert!(
        description.contains("batch mode and config/raw files"),
        "`max_lines_per_file` schema description must document that it applies to batch mode and config/raw files.\nGot description: {description:?}"
    );
}

/// Finding 2: `FindCallersCalleesMetadata.incoming` schema description must document
/// the null vs [] contract so agents can distinguish unknown from confirmed-zero.
///
/// MCP exposes `inputSchema` but not `outputSchema`. Agents learn the response
/// contract from the tool description field. The doc comment on the struct field
/// IS included in the schemars-generated schema (via `JsonSchema` derive) and
/// available as `structured_content`, so it must be correct and complete.
///
/// PATCH-003: The doc now references `incoming_verified` (the machine-readable
/// per-field uncertainty flag) instead of the global `degraded` boolean, so the
/// assertion accepts either term.
#[test]
fn test_find_callers_callees_metadata_incoming_schema_documents_null_vs_empty() {
    let schema = schemars::schema_for!(FindCallersCalleesMetadata);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    let description = schema_json["properties"]["incoming"]["description"]
        .as_str()
        .expect("`incoming` property must have a `description` field in the generated schema");

    assert!(
        description.contains("null") || description.contains("`null`"),
        "`incoming` schema description must explicitly mention `null` so agents know \
         the field is absent when degraded, not just empty.\nGot: {description:?}"
    );

    assert!(
        description.contains("[]") || description.contains("empty array"),
        "`incoming` schema description must distinguish `[]` (confirmed zero) from `null` \
         (unknown/degraded).\nGot: {description:?}"
    );

    // PATCH-003: the doc may now reference `incoming_verified` (the
    // machine-readable per-field uncertainty flag) instead of the global
    // `degraded` boolean. Accept either reference — both convey when null
    // is returned.
    assert!(
        description.contains("degraded") || description.contains("incoming_verified"),
        "`incoming` schema description must reference either `degraded` or \
         `incoming_verified` to explain when null is returned.\nGot: {description:?}"
    );
}

/// Companion check for `outgoing` — same null vs [] contract as `incoming`.
#[test]
fn test_find_callers_callees_metadata_outgoing_schema_documents_null_vs_empty() {
    let schema = schemars::schema_for!(FindCallersCalleesMetadata);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    let description = schema_json["properties"]["outgoing"]["description"]
        .as_str()
        .expect("`outgoing` property must have a `description` field in the generated schema");

    assert!(
        description.contains("null") || description.contains("`null`"),
        "`outgoing` schema description must mention `null`.\nGot: {description:?}"
    );

    assert!(
        description.contains("[]") || description.contains("empty array"),
        "`outgoing` schema description must distinguish `[]` from `null`.\nGot: {description:?}"
    );
}

/// `FindAllReferencesMetadata.references` must document the same contract.
#[test]
fn test_find_all_references_metadata_references_schema_documents_null_vs_empty() {
    let schema = schemars::schema_for!(FindAllReferencesMetadata);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    let description = schema_json["properties"]["references"]["description"]
        .as_str()
        .expect("`references` property must have a `description` field in the generated schema");

    assert!(
        description.contains("null") || description.contains("`null`"),
        "`references` schema description must mention `null`.\nGot: {description:?}"
    );

    assert!(
        description.contains("[]") || description.contains("empty array"),
        "`references` schema description must distinguish `[]` from `null`.\nGot: {description:?}"
    );
}

/// `ImpactSummary.incoming` (used by `SymbolOverviewResponse`) has `skip_serializing_if`.
/// Its doc must clarify the field is **absent** (not null) when unavailable.
#[test]
fn test_impact_summary_incoming_schema_documents_absent_semantics() {
    let schema = schemars::schema_for!(ImpactSummary);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    let description = schema_json["properties"]["incoming"]["description"]
        .as_str()
        .expect("`incoming` property must have a `description` field");

    assert!(
        description.contains("absent") || description.contains("omitted"),
        "`ImpactSummary.incoming` uses `skip_serializing_if` — doc must say \
         the field is absent (not null) when unavailable.\nGot: {description:?}"
    );
}

/// Same check for `ImpactSummary.outgoing`.
#[test]
fn test_impact_summary_outgoing_schema_documents_absent_semantics() {
    let schema = schemars::schema_for!(ImpactSummary);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    let description = schema_json["properties"]["outgoing"]["description"]
        .as_str()
        .expect("`outgoing` property must have a `description` field");

    assert!(
        description.contains("absent") || description.contains("omitted"),
        "`ImpactSummary.outgoing` uses `skip_serializing_if` — doc must say \
         the field is absent (not null) when unavailable.\nGot: {description:?}"
    );
}

/// `SymbolOverviewResponse.references` has `skip_serializing_if` — doc must
/// clarify the field is absent when unavailable, and present as `[]` when
/// LSP confirmed zero references.
#[test]
fn test_symbol_overview_response_references_schema_documents_absent_semantics() {
    let schema = schemars::schema_for!(SymbolOverviewResponse);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    let description = schema_json["properties"]["references"]["description"]
        .as_str()
        .expect("`references` property must have a `description` field");

    assert!(
        description.contains("absent") || description.contains("omitted"),
        "`SymbolOverviewResponse.references` uses `skip_serializing_if` — doc \
         must say the field is absent when unavailable.\nGot: {description:?}"
    );

    assert!(
        description.contains("[]") || description.contains("empty array"),
        "`SymbolOverviewResponse.references` must document that `[]` means \
         LSP confirmed zero references.\nGot: {description:?}"
    );
}

/// `FindCallersCalleesMetadata.degraded` doc says "When `true`, `incoming`
/// and `outgoing` are `null` (not empty arrays)" — but code can produce
/// `degraded=true` with `Some(vec)` from grep fallback. The doc must
/// accurately reflect the actual code behavior.
#[test]
fn test_find_callers_callees_degraded_doc_is_accurate() {
    let schema = schemars::schema_for!(FindCallersCalleesMetadata);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    let description = schema_json["properties"]["degraded"]["description"]
        .as_str()
        .expect("`degraded` property must have a `description` field");

    // The doc must NOT claim incoming/outgoing are always null when degraded,
    // because grep fallback can produce Some(results) while degraded.
    assert!(
        !description.contains("are `null` (not empty arrays)"),
        "`degraded` doc must not claim incoming/outgoing are always null when degraded — \
         grep fallback can produce heuristic results while degraded=true.\nGot: {description:?}"
    );
}

/// `GetRepoMapMetadata.visibility_degraded` doc says "always true" but the
/// code always sets it to `None`. The doc must match the implementation.
#[test]
fn test_visibility_degraded_doc_matches_code() {
    let schema = schemars::schema_for!(GetRepoMapMetadata);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    // The field is Option<bool> with skip_serializing_if — may not have a description
    // if the doc comment is removed. But if it exists, it must not say "always true".
    if let Some(description) =
        schema_json["properties"]["visibility_degraded"]["description"].as_str()
    {
        assert!(
            !description.contains("Always `true`") && !description.contains("always true"),
            "`visibility_degraded` doc says 'always true' but code always sets None.\n\
             Got: {description:?}"
        );
    }
}

// ── Serialization Behavior Tests ────────────────────────────────────
//
// These verify that the JSON serialization of None vs Some(vec![])
// matches what the doc comments promise agents will see.

// ── PATCH-003: incoming_verified / outgoing_verified schema tests ────────

/// `FindCallersCalleesMetadata.incoming_verified` must appear in the schema
/// (so MCP clients can discover it) and the doc must mention the
/// `incoming_verified` field to explain the per-field uncertainty flag.
#[test]
fn test_find_callers_callees_incoming_verified_schema_documents_per_field_flag() {
    let schema = schemars::schema_for!(FindCallersCalleesMetadata);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    assert!(
        schema_json["properties"]["incoming_verified"].is_object(),
        "`incoming_verified` must appear in the generated schema as a property"
    );
    let description = schema_json["properties"]["incoming_verified"]["description"]
        .as_str()
        .expect("`incoming_verified` must have a `description` field");

    assert!(
        description.contains("UNKNOWN") || description.contains("Do NOT"),
        "`incoming_verified` doc must warn against treating null as zero, got: {description}"
    );
    assert!(
        description.contains("Some(false)")
            && description.contains("Some(true)")
            && description.contains("None"),
        "`incoming_verified` doc must document the three Option<bool> states \
         (Some(true) / Some(false) / None), got: {description}"
    );
}

/// `FindCallersCalleesMetadata.outgoing_verified` must also be documented.
#[test]
fn test_find_callers_callees_outgoing_verified_schema_documents_per_field_flag() {
    let schema = schemars::schema_for!(FindCallersCalleesMetadata);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    assert!(
        schema_json["properties"]["outgoing_verified"].is_object(),
        "`outgoing_verified` must appear in the generated schema as a property"
    );
}

/// `incoming_verified: None` must be ABSENT from JSON serialization
/// (`skip_serializing_if`). This is important so the field is opt-in
/// for future extensibility.
#[test]
fn test_find_callers_callees_incoming_verified_serialization_omits_none() {
    let meta = FindCallersCalleesMetadata {
        incoming: None,
        outgoing: None,
        incoming_verified: None,
        outgoing_verified: None,
        ..Default::default()
    };
    let json = serde_json::to_value(&meta).expect("serialize");
    let obj = json.as_object().expect("object");
    assert!(
        !obj.contains_key("incoming_verified"),
        "`incoming_verified: None` must be ABSENT from JSON, but it was present: {json}"
    );
    assert!(
        !obj.contains_key("outgoing_verified"),
        "`outgoing_verified: None` must be ABSENT from JSON, but it was present: {json}"
    );
}

/// `incoming_verified: Some(true)` and `Some(false)` must BOTH appear in JSON
/// (no `skip_serializing_if` for Some values). Agents must be able to read
/// both states.
#[test]
fn test_find_callers_callees_incoming_verified_serialization_includes_some() {
    let meta_true = FindCallersCalleesMetadata {
        incoming_verified: Some(true),
        ..Default::default()
    };
    let json_true = serde_json::to_value(&meta_true).expect("serialize");
    assert_eq!(
        json_true["incoming_verified"],
        serde_json::json!(true),
        "`incoming_verified: Some(true)` must serialize as JSON `true`"
    );

    let meta_false = FindCallersCalleesMetadata {
        incoming_verified: Some(false),
        ..Default::default()
    };
    let json_false = serde_json::to_value(&meta_false).expect("serialize");
    assert_eq!(
        json_false["incoming_verified"],
        serde_json::json!(false),
        "`incoming_verified: Some(false)` must serialize as JSON `false`"
    );
}

// ── PATCH-003: existing P2-7 serialization tests ────────────────────────

/// `FindCallersCalleesMetadata.incoming` serializes as JSON `null` when `None`
/// (no `skip_serializing_if`), and as `[]` when `Some(vec![])`.
#[test]
fn test_find_callers_callees_incoming_serialization_null_vs_empty() {
    // None → JSON null
    let meta_none = FindCallersCalleesMetadata {
        incoming: None,
        ..Default::default()
    };
    let json = serde_json::to_value(&meta_none).expect("serialize");
    assert!(
        json["incoming"].is_null(),
        "`incoming: None` must serialize as JSON `null`, got: {}",
        json["incoming"]
    );

    // Some(vec![]) → JSON []
    let meta_empty = FindCallersCalleesMetadata {
        incoming: Some(vec![]),
        ..Default::default()
    };
    let json = serde_json::to_value(&meta_empty).expect("serialize");
    assert!(
        json["incoming"].is_array() && json["incoming"].as_array().unwrap().is_empty(),
        "`incoming: Some(vec![])` must serialize as JSON `[]`, got: {}",
        json["incoming"]
    );
}

/// `ImpactSummary.incoming` (with `skip_serializing_if`) is ABSENT when None.
#[test]
fn test_impact_summary_incoming_serialization_absent_vs_empty() {
    // None → field absent
    let summary = ImpactSummary {
        incoming: None,
        outgoing: None,
        degraded: true,
    };
    let json = serde_json::to_value(&summary).expect("serialize");
    let obj = json.as_object().expect("object");
    assert!(
        !obj.contains_key("incoming"),
        "`ImpactSummary.incoming: None` must be ABSENT from JSON, \
         but it was present: {json}"
    );

    // Some(vec![]) → present as []
    let summary_empty = ImpactSummary {
        incoming: Some(vec![]),
        outgoing: None,
        degraded: false,
    };
    let json = serde_json::to_value(&summary_empty).expect("serialize");
    assert!(
        json["incoming"].is_array() && json["incoming"].as_array().unwrap().is_empty(),
        "`ImpactSummary.incoming: Some(vec![])` must serialize as `[]`, got: {}",
        json["incoming"]
    );
}

/// `SymbolOverviewResponse.references` (with `skip_serializing_if`) is ABSENT when None.
#[test]
fn test_symbol_overview_response_references_serialization_absent_vs_empty() {
    let resp = SymbolOverviewResponse {
        references: None,
        source: None,
        impact: None,
        files_referenced: 0,
        degraded: true,
        impact_degraded: false,
        references_degraded: false,
        degraded_reason: None,
        actionable_guidance: None,
        lsp_readiness: None,
        warm_start_in_progress: None,
    };
    let json = serde_json::to_value(&resp).expect("serialize");
    let obj = json.as_object().expect("object");
    assert!(
        !obj.contains_key("references"),
        "`SymbolOverviewResponse.references: None` must be ABSENT from JSON, \
         but it was present: {json}"
    );
}

#[test]
fn test_default_value_helpers() {
    assert_eq!(default_path_glob(), "**/*");
    assert_eq!(default_max_results(), 50);
    assert_eq!(default_context_lines(), 2);
    assert_eq!(default_repo_map_path(), ".");
    assert_eq!(default_max_tokens(), 16_000);
    assert_eq!(default_max_tokens_per_file(), 2_000);
    assert_eq!(default_max_depth(), 3);
    assert_eq!(default_max_references(), 50);
    assert_eq!(default_max_dependencies(), 50);
    assert_eq!(default_start_line(), 1);
    assert_eq!(default_max_lines(), 500);
    assert_eq!(default_detail_level(), "compact");
    assert!(default_group_by_file());
    assert_eq!(default_filter_mode(), FilterMode::CodeOnly);
}

#[test]
fn test_filepath_alias_deserialization() {
    let json_data = serde_json::json!({
        "path": "src/lib.rs",
        "start_line": 10,
    });
    let read_params: ReadParams = serde_json::from_value(json_data).unwrap();
    assert_eq!(read_params.filepath, Some("src/lib.rs".to_string()));
}

// ── Serde Roundtrip Tests ───────────────────────────────────────────

#[test]
fn test_degraded_reason_serde_roundtrip() {
    use pathfinder_common::types::DegradedReason;

    let variants = vec![
        (DegradedReason::NoLsp, "\"no_lsp\""),
        (
            DegradedReason::LspWarmupEmptyUnverified,
            "\"lsp_warmup_empty_unverified\"",
        ),
        (
            DegradedReason::LspWarmupGrepFallback,
            "\"lsp_warmup_grep_fallback\"",
        ),
        (
            DegradedReason::LspTimeoutGrepFallback,
            "\"lsp_timeout_grep_fallback\"",
        ),
        (
            DegradedReason::LspErrorGrepFallback,
            "\"lsp_error_grep_fallback\"",
        ),
        (
            DegradedReason::NoLspGrepFallback,
            "\"no_lsp_grep_fallback\"",
        ),
        (
            DegradedReason::GrepFallbackFileScoped,
            "\"grep_fallback_file_scoped\"",
        ),
        (
            DegradedReason::GrepFallbackImplScoped,
            "\"grep_fallback_impl_scoped\"",
        ),
        (
            DegradedReason::GrepFallbackGlobal,
            "\"grep_fallback_global\"",
        ),
        (
            DegradedReason::GrepFallbackDependencies,
            "\"grep_fallback_dependencies\"",
        ),
        (
            DegradedReason::UnsupportedLanguageFilterBypassed,
            "\"unsupported_language_filter_bypassed\"",
        ),
        (
            DegradedReason::UnsupportedLanguage,
            "\"unsupported_language\"",
        ),
        (DegradedReason::GitError, "\"git_error\""),
    ];

    for (variant, expected_json) in variants {
        let serialized = serde_json::to_string(&variant).expect("serialize DegradedReason");
        assert_eq!(
            serialized, expected_json,
            "serialize mismatch for {variant:?}"
        );

        let deserialized: DegradedReason =
            serde_json::from_str(&serialized).expect("deserialize DegradedReason");
        assert_eq!(deserialized, variant, "roundtrip mismatch for {variant:?}");
    }
}

#[test]
fn test_detail_enum_serde() {
    let cases = vec![
        ("\"structure\"", "structure"),
        ("\"files\"", "files"),
        ("\"symbols\"", "symbols"),
    ];

    for (json_str, label) in cases {
        let detail: Detail = serde_json::from_str(json_str)
            .unwrap_or_else(|e| panic!("failed to deserialize Detail from {json_str}: {e}"));
        match (label, &detail) {
            ("structure", Detail::Structure)
            | ("files", Detail::Files)
            | ("symbols", Detail::Symbols) => {}
            _ => panic!("unexpected Detail variant for {label}: {detail:?}"),
        }
    }
}

#[test]
fn test_search_mode_serde() {
    let cases = vec![
        ("\"text\"", "text"),
        ("\"symbol\"", "symbol"),
        ("\"regex\"", "regex"),
    ];

    for (json_str, label) in cases {
        let mode: SearchMode = serde_json::from_str(json_str)
            .unwrap_or_else(|e| panic!("failed to deserialize SearchMode from {json_str}: {e}"));
        match (label, &mode) {
            ("text", SearchMode::Text)
            | ("symbol", SearchMode::Symbol)
            | ("regex", SearchMode::Regex) => {}
            _ => panic!("unexpected SearchMode variant for {label}: {mode:?}"),
        }
    }
}

#[test]
fn test_trace_scope_serde() {
    let cases = vec![
        ("\"callers\"", "callers"),
        ("\"references\"", "references"),
        ("\"overview\"", "overview"),
    ];

    for (json_str, label) in cases {
        let scope: TraceScope = serde_json::from_str(json_str)
            .unwrap_or_else(|e| panic!("failed to deserialize TraceScope from {json_str}: {e}"));
        match (label, &scope) {
            ("callers", TraceScope::Callers)
            | ("references", TraceScope::References)
            | ("overview", TraceScope::Overview) => {}
            _ => panic!("unexpected TraceScope variant for {label}: {scope:?}"),
        }
    }
}

// ── Default Impl Tests ──────────────────────────────────────────────

#[test]
fn test_search_params_default() {
    let params = SearchParams::default();
    assert_eq!(params.query, "");
    assert!(matches!(params.mode, SearchMode::Text));
    assert_eq!(params.path_glob, "**/*");
    assert_eq!(params.max_results, 50);
    assert_eq!(params.context_lines, 2);
    assert!(params.known_files.is_empty());
    assert_eq!(params.exclude_glob, ExcludeGlob::default());
    assert_eq!(params.offset, 0);
    assert!(params.kind.is_none());
    assert!(params.group_by_file);
    assert!(matches!(params.filter_mode, FilterMode::CodeOnly));
}

#[test]
fn test_inspect_params_default() {
    let params = InspectParams::default();
    assert_eq!(params.semantic_path, None);
    assert_eq!(params.semantic_paths, None); // new batch field must also default to None
    assert!(!params.include_dependencies);
    assert_eq!(params.max_dependencies, 50);
    assert!(!params.include_imports);
}

#[test]
fn test_trace_params_default() {
    let params = TraceParams::default();
    assert_eq!(params.semantic_path, "");
    assert!(matches!(params.scope, TraceScope::Callers));
    assert_eq!(params.max_depth, 3);
    assert_eq!(params.max_references, 50);
    assert_eq!(params.offset, 0);
}

#[test]
fn test_explore_depth_default() {
    assert_eq!(default_explore_depth(), 3);
}

// ── is_false Helper Tests ───────────────────────────────────────────

#[test]
fn test_is_false_true_value() {
    // is_false returns !b, so is_false(true) == false
    assert!(!is_false(&true));
}

#[test]
fn test_is_false_false_value() {
    // is_false returns !b, so is_false(false) == true
    assert!(is_false(&false));
}

// ── skip_serializing_if Behavior Tests ──────────────────────────────

#[test]
fn test_symbol_overview_response_skip_empty_fields() {
    let resp = SymbolOverviewResponse {
        source: None,
        impact: None,
        references: None,
        files_referenced: 0,
        degraded: false,
        impact_degraded: false,
        references_degraded: false,
        degraded_reason: None,
        actionable_guidance: None,
        lsp_readiness: None,
        warm_start_in_progress: None,
    };

    let json = serde_json::to_value(&resp).expect("serialize SymbolOverviewResponse");
    let obj = json.as_object().expect("should be JSON object");

    // Fields with skip_serializing_if = Option::is_none should be absent
    assert!(
        !obj.contains_key("source"),
        "source should be absent when None"
    );
    assert!(
        !obj.contains_key("impact"),
        "impact should be absent when None"
    );
    assert!(
        !obj.contains_key("references"),
        "references should be absent when None"
    );
    assert!(
        !obj.contains_key("degraded_reason"),
        "degraded_reason should be absent when None"
    );
    assert!(
        !obj.contains_key("actionable_guidance"),
        "actionable_guidance should be absent when None"
    );
    assert!(
        !obj.contains_key("lsp_readiness"),
        "lsp_readiness should be absent when None"
    );
    assert!(
        !obj.contains_key("warm_start_in_progress"),
        "warm_start_in_progress should be absent when None"
    );

    // Fields with skip_serializing_if = Not::not should be absent when false
    assert!(
        !obj.contains_key("impact_degraded"),
        "impact_degraded should be absent when false"
    );
    assert!(
        !obj.contains_key("references_degraded"),
        "references_degraded should be absent when false"
    );

    // Always-present fields should exist
    assert!(obj.contains_key("files_referenced"));
    assert!(obj.contains_key("degraded"));
}

#[test]
fn test_find_all_references_metadata_default_roundtrip() {
    let meta = FindAllReferencesMetadata::default();
    let json = serde_json::to_value(&meta).expect("serialize FindAllReferencesMetadata");
    let obj = json.as_object().expect("should be JSON object");

    // Always-present fields
    assert!(
        obj.contains_key("references"),
        "references should be present (defaults to None/null)"
    );
    assert!(obj.contains_key("files_referenced"));
    assert!(obj.contains_key("degraded"));

    // Optional fields with skip_serializing_if should be absent when default
    assert!(
        !obj.contains_key("truncated"),
        "truncated should be absent when false (default)"
    );
    assert!(
        !obj.contains_key("degraded_reason"),
        "degraded_reason should be absent when None"
    );
    assert!(!obj.contains_key("actionable_guidance"));
    assert!(!obj.contains_key("lsp_readiness"));
    assert!(!obj.contains_key("duration_ms"));
    assert!(!obj.contains_key("resolution_strategy"));
    assert!(!obj.contains_key("hint"));
}

#[test]
fn test_source_symbol_serde_missing_children() {
    let json_str = r#"{"name":"foo","semantic_path":"foo::bar","kind":"function","start_line":10,"end_line":20}"#;
    let symbol: SourceSymbol = serde_json::from_str(json_str).expect("deserialize SourceSymbol");
    assert_eq!(symbol.name, "foo");
    assert_eq!(symbol.semantic_path, "foo::bar");
    assert_eq!(symbol.kind, "function");
    assert_eq!(symbol.start_line, 10);
    assert_eq!(symbol.end_line, 20);
    assert!(symbol.children.is_empty());

    // Serialize it back and make sure children is not serialized when empty
    let serialized = serde_json::to_string(&symbol).expect("serialize SourceSymbol");
    assert!(!serialized.contains("children"));
}

/// Verify that `ExploreParams.visibility` has a valid schema description.
#[test]
fn test_explore_params_visibility_schema_description() {
    let schema = schemars::schema_for!(ExploreParams);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    let description = schema_json["properties"]["visibility"]["description"]
        .as_str()
        .expect("`visibility` property must have a `description` field");

    assert!(
        description.contains("public") || description.contains("all"),
        "`visibility` schema description must document the possible options.\nGot: {description:?}"
    );
}

/// Verify that `ExploreParams.include_extensions` and `exclude_extensions`
/// document raw format examples and mutual exclusion error behavior.
#[test]
fn test_explore_params_extensions_schema_descriptions() {
    let schema = schemars::schema_for!(ExploreParams);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    // include_extensions
    let include_desc = schema_json["properties"]["include_extensions"]["description"]
        .as_str()
        .expect("`include_extensions` must have a description");
    assert!(
        include_desc.contains("\"rs\"") && include_desc.contains("\".rs\""),
        "`include_extensions` description must contrast raw extensions (e.g. \"rs\") with dotted ones.\nGot: {include_desc:?}"
    );
    assert!(
        include_desc.to_lowercase().contains("mutually exclusive") && include_desc.contains("error"),
        "`include_extensions` description must explain mutual exclusion and error behavior.\nGot: {include_desc:?}"
    );

    // exclude_extensions
    let exclude_desc = schema_json["properties"]["exclude_extensions"]["description"]
        .as_str()
        .expect("`exclude_extensions` must have a description");
    assert!(
        exclude_desc.contains("\"rs\"") && exclude_desc.contains("\".rs\""),
        "`exclude_extensions` description must contrast raw extensions (e.g. \"rs\") with dotted ones.\nGot: {exclude_desc:?}"
    );
    assert!(
        exclude_desc.to_lowercase().contains("mutually exclusive") && exclude_desc.contains("error"),
        "`exclude_extensions` description must explain mutual exclusion and error behavior.\nGot: {exclude_desc:?}"
    );
}

/// Verify `SearchParams.path_glob` documents brace expansion.
#[test]
fn test_search_params_path_glob_schema_documents_brace_expansion() {
    let schema = schemars::schema_for!(SearchParams);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    let description = schema_json["properties"]["path_glob"]["description"]
        .as_str()
        .expect("`path_glob` must have a description");

    assert!(
        description.contains("brace expansion"),
        "`path_glob` description must mention brace expansion.\nGot: {description:?}"
    );
    assert!(
        description.contains('{') && description.contains('}'),
        "`path_glob` description must include a brace expansion example.\nGot: {description:?}"
    );
}

/// Verify `ExploreParams.changed_since` documents empty-string behavior.
#[test]
fn test_explore_params_changed_since_schema_documents_empty_string_behavior() {
    let schema = schemars::schema_for!(ExploreParams);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    let description = schema_json["properties"]["changed_since"]["description"]
        .as_str()
        .expect("`changed_since` must have a description");

    assert!(
        description.contains("empty") && description.contains("entire"),
        "`changed_since` description must document empty string / full repository mapping behavior.\nGot: {description:?}"
    );
}

/// Verify `FindCallersCalleesMetadata.test_callers` documents absent vs empty array semantics.
#[test]
fn test_find_callers_callees_test_callers_schema_documents_absent_vs_empty() {
    let schema = schemars::schema_for!(FindCallersCalleesMetadata);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    let description = schema_json["properties"]["test_callers"]["description"]
        .as_str()
        .expect("`test_callers` must have a description");

    assert!(
        description.contains("absent") || description.contains("omitted"),
        "`test_callers` description must mention absent/omitted semantics.\nGot: {description:?}"
    );
    assert!(
        description.contains("[]") || description.contains("empty array"),
        "`test_callers` description must mention `[]`/empty array semantics.\nGot: {description:?}"
    );
}

/// `GetSemanticPathResult.semantic_path` and `.symbol` use `skip_serializing_if`.
/// When `None`, both fields must be **absent** from JSON (not `null`).
#[test]
fn test_get_semantic_path_result_serialization_absent_when_none() {
    let result = GetSemanticPathResult {
        semantic_path: None,
        symbol: None,
        file: "src/lib.rs".to_string(),
        line: 42,
    };
    let json = serde_json::to_value(&result).expect("serialize");
    let obj = json.as_object().expect("object");

    assert!(
        !obj.contains_key("semantic_path"),
        "`semantic_path: None` must be ABSENT from JSON, but it was present: {json}"
    );
    assert!(
        !obj.contains_key("symbol"),
        "`symbol: None` must be ABSENT from JSON, but it was present: {json}"
    );

    // Always-present fields
    assert_eq!(json["file"], "src/lib.rs");
    assert_eq!(json["line"], 42);
}

/// When `Some`, `GetSemanticPathResult` fields must be present.
#[test]
fn test_get_semantic_path_result_serialization_present_when_some() {
    let result = GetSemanticPathResult {
        semantic_path: Some("src/lib.rs::MyStruct.method".to_string()),
        symbol: Some("MyStruct.method".to_string()),
        file: "src/lib.rs".to_string(),
        line: 42,
    };
    let json = serde_json::to_value(&result).expect("serialize");

    assert_eq!(
        json["semantic_path"], "src/lib.rs::MyStruct.method",
        "`semantic_path` must be present when Some"
    );
    assert_eq!(
        json["symbol"], "MyStruct.method",
        "`symbol` must be present when Some"
    );
}

/// `GetSemanticPathResult.semantic_path` doc must say "absent" or "omitted"
/// (not "null") since the field uses `skip_serializing_if`.
#[test]
fn test_get_semantic_path_result_schema_documents_absent_semantics() {
    let schema = schemars::schema_for!(GetSemanticPathResult);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    let sp_desc = schema_json["properties"]["semantic_path"]["description"]
        .as_str()
        .expect("`semantic_path` must have a description");
    assert!(
        sp_desc.contains("absent") || sp_desc.contains("omitted"),
        "`semantic_path` doc must say absent/omitted (not null) since it uses \
         skip_serializing_if.\nGot: {sp_desc:?}"
    );

    let sym_desc = schema_json["properties"]["symbol"]["description"]
        .as_str()
        .expect("`symbol` must have a description");
    assert!(
        sym_desc.contains("absent") || sym_desc.contains("omitted"),
        "`symbol` doc must say absent/omitted (not null) since it uses \
         skip_serializing_if.\nGot: {sym_desc:?}"
    );
}

/// `FileResult.version_hash` doc must describe the short format (7-char hex),
/// not claim it's a full SHA-256 hash.
#[test]
fn test_file_result_version_hash_schema_describes_short_format() {
    let schema = schemars::schema_for!(FileResult);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    let description = schema_json["properties"]["version_hash"]["description"]
        .as_str()
        .expect("`version_hash` must have a description");

    assert!(
        !description.contains("SHA-256 hash"),
        "`FileResult.version_hash` doc must NOT say 'SHA-256 hash' — the actual \
         value is a 7-char truncated hex. Got: {description:?}"
    );
    assert!(
        description.contains("7-char") || description.contains("short") || description.contains("fingerprint"),
        "`FileResult.version_hash` doc must describe the short/fingerprint format. Got: {description:?}"
    );
}

/// `SearchResultGroup.version_hash` doc must describe the short format.
#[test]
fn test_search_result_group_version_hash_schema_describes_short_format() {
    let schema = schemars::schema_for!(SearchResultGroup);
    let schema_json = serde_json::to_value(&schema).expect("schema to JSON");

    let description = schema_json["properties"]["version_hash"]["description"]
        .as_str()
        .expect("`version_hash` must have a description");

    assert!(
        !description.contains("SHA-256 hash"),
        "`SearchResultGroup.version_hash` doc must NOT say 'SHA-256 hash' — the actual \
         value is a 7-char truncated hex. Got: {description:?}"
    );
    assert!(
        description.contains("7-char") || description.contains("short") || description.contains("fingerprint"),
        "`SearchResultGroup.version_hash` doc must describe the short/fingerprint format. Got: {description:?}"
    );
}
