//! Integration tests asserting that every module's registration surface
//! compiles, that unimplemented methods route through `HostlibError` rather
//! than panicking, and that every registered builtin has a matching schema.
//!
//! These tests are the contract implementation work must keep green:
//! when a module moves beyond scaffolding, the only change here should be
//! that a routed `Unimplemented` becomes a real return value — never a
//! removed builtin.

use harn_hostlib::{
    ast::AstCapability, code_index::CodeIndexCapability, fs::FsCapability,
    fs_snapshot::FsSnapshotCapability, fs_watch::FsWatchCapability, scanner::ScannerCapability,
    schemas, secret_store::SecretStoreCapability, tools::permissions, tools::ToolsCapability,
    BuiltinRegistry, HostlibCapability, HostlibError, HostlibRegistry,
};

fn collect_into_registry<C: HostlibCapability>(cap: C) -> BuiltinRegistry {
    let mut registry = BuiltinRegistry::new();
    cap.register_builtins(&mut registry);
    registry
}

#[test]
fn ast_capability_registers_documented_methods() {
    let registry = collect_into_registry(AstCapability);
    let names: Vec<_> = registry.iter().map(|b| b.name).collect();
    assert_eq!(
        names,
        vec![
            "hostlib_ast_parse_file",
            "hostlib_ast_symbols",
            "hostlib_ast_outline",
            "hostlib_ast_parse_errors",
            "hostlib_ast_undefined_names",
            "hostlib_ast_function_body",
            "hostlib_ast_function_bodies",
            "hostlib_ast_extract_imports",
            "hostlib_ast_symbol_extract",
            "hostlib_ast_symbol_delete",
            "hostlib_ast_symbol_replace",
            "hostlib_ast_bracket_balance",
            "hostlib_ast_apply_node",
            "hostlib_ast_insert_at_anchor",
            "hostlib_ast_batch_apply",
            "hostlib_ast_dry_run",
            "hostlib_ast_search",
            "hostlib_ast_structural_diff",
            "hostlib_ast_capabilities",
        ]
    );
    // Each AST builtin must reject empty input with a structured
    // `MissingParameter`. The required field differs per method:
    // file-based builtins want `path`; analysis builtins (#773) accept
    // either `content` or `path`; the source-mutation builtins (#774/#775)
    // take `source`; function_body takes `function_name`; function_bodies
    // takes `names`.
    let expected_missing: &[(&str, &str)] = &[
        ("hostlib_ast_parse_file", "path"),
        ("hostlib_ast_symbols", "path"),
        ("hostlib_ast_outline", "path"),
        ("hostlib_ast_parse_errors", "content_or_path"),
        ("hostlib_ast_undefined_names", "content_or_path"),
        ("hostlib_ast_function_body", "function_name"),
        ("hostlib_ast_function_bodies", "names"),
        ("hostlib_ast_extract_imports", "source"),
        ("hostlib_ast_symbol_extract", "source"),
        ("hostlib_ast_symbol_delete", "source"),
        ("hostlib_ast_symbol_replace", "source"),
        ("hostlib_ast_bracket_balance", "source"),
        ("hostlib_ast_apply_node", "path"),
        ("hostlib_ast_insert_at_anchor", "path"),
        ("hostlib_ast_batch_apply", "paths"),
        ("hostlib_ast_dry_run", "plan"),
        ("hostlib_ast_search", "query"),
        ("hostlib_ast_structural_diff", "path_a"),
    ];
    // `apply_node` / `insert_at_anchor` write edited source to disk and are
    // gated on the deterministic-tools feature (#2548); enable it so the
    // handlers reach their parameter validation rather than the gate.
    permissions::enable_for_test();
    for (name, expected_param) in expected_missing {
        let entry = registry.find(name).expect("registered");
        let err = (entry.handler)(&[]).expect_err("must reject empty args");
        match err {
            HostlibError::MissingParameter { builtin, param } => {
                assert_eq!(builtin, *name);
                assert_eq!(param, *expected_param);
            }
            other => panic!("expected MissingParameter for {name}, got {other:?}"),
        }
    }
}

#[test]
fn code_index_capability_registers_documented_methods() {
    let registry = collect_into_registry(CodeIndexCapability::new());
    let names: Vec<_> = registry.iter().map(|b| b.name).collect();
    assert_eq!(
        names,
        vec![
            // Workspace queries (the original 5).
            "hostlib_code_index_query",
            "hostlib_code_index_rebuild",
            "hostlib_code_index_stats",
            "hostlib_code_index_imports_for",
            "hostlib_code_index_importers_of",
            // File table accessors (#776).
            "hostlib_code_index_path_to_id",
            "hostlib_code_index_id_to_path",
            "hostlib_code_index_file_ids",
            "hostlib_code_index_file_meta",
            "hostlib_code_index_file_hash",
            // Cached reads (#776).
            "hostlib_code_index_read_range",
            "hostlib_code_index_reindex_file",
            "hostlib_code_index_trigram_query",
            "hostlib_code_index_extract_trigrams",
            "hostlib_code_index_word_get",
            "hostlib_code_index_deps_get",
            "hostlib_code_index_outline_get",
            // Change log (#776).
            "hostlib_code_index_current_seq",
            "hostlib_code_index_changes_since",
            "hostlib_code_index_version_record",
            // Agent registry + locks (#776).
            "hostlib_code_index_agent_register",
            "hostlib_code_index_agent_heartbeat",
            "hostlib_code_index_agent_unregister",
            "hostlib_code_index_lock_try",
            "hostlib_code_index_lock_release",
            "hostlib_code_index_status",
            "hostlib_code_index_current_agent_id",
            // Typed symbol graph + Cypher (#2434).
            "hostlib_code_index_cypher",
            "hostlib_code_index_branch_overlay",
            "hostlib_code_index_freshness",
            // Cross-file safe rename (#2508).
            "hostlib_code_index_rename_symbol",
        ]
    );
    // Without a populated workspace, code-index read methods return empty
    // payloads rather than panicking. Assert that contract here so any
    // regression to `unimplemented!()` fails loudly.
    let stats = registry
        .find("hostlib_code_index_stats")
        .expect("registered");
    let value = (stats.handler)(&[]).expect("stats works on an empty index");
    match value {
        harn_vm::VmValue::Dict(_) => {}
        other => panic!("expected dict response from stats, got {other:?}"),
    }
}

#[test]
fn scanner_capability_registers_documented_methods() {
    let registry = collect_into_registry(ScannerCapability);
    let names: Vec<_> = registry.iter().map(|b| b.name).collect();
    assert_eq!(
        names,
        vec![
            "hostlib_scanner_scan_project",
            "hostlib_scanner_scan_incremental"
        ]
    );
    // Implemented scanner methods should refuse an empty payload with
    // `MissingParameter` rather than routing through `Unimplemented`.
    // The full scanner contract is exercised end-to-end in
    // `tests/scanner_e2e.rs`.
    for name in &[
        "hostlib_scanner_scan_project",
        "hostlib_scanner_scan_incremental",
    ] {
        let entry = registry.find(name).expect("registered");
        let err = (entry.handler)(&[]).expect_err("must reject empty args");
        assert!(
            !matches!(err, HostlibError::Unimplemented { .. }),
            "scanner method {name} should be implemented, got {err:?}"
        );
    }
}

#[test]
fn fs_capability_registers_documented_methods() {
    let registry = collect_into_registry(FsCapability);
    let names: Vec<_> = registry.iter().map(|b| b.name).collect();
    assert_eq!(
        names,
        vec![
            "hostlib_fs_set_mode",
            "hostlib_fs_staged_status",
            "hostlib_fs_commit_staged",
            "hostlib_fs_discard_staged",
            "hostlib_fs_safe_text_patch",
            "hostlib_fs_read_text",
            "hostlib_fs_emit_safe_text_patch_result",
        ]
    );
    let expected_missing: &[(&str, &str)] = &[
        ("hostlib_fs_set_mode", "session_id"),
        ("hostlib_fs_staged_status", "session_id"),
        ("hostlib_fs_commit_staged", "session_id"),
        ("hostlib_fs_discard_staged", "session_id"),
        ("hostlib_fs_safe_text_patch", "path"),
        ("hostlib_fs_read_text", "path"),
        ("hostlib_fs_emit_safe_text_patch_result", "path"),
    ];
    // `safe_text_patch` / `read_text` touch arbitrary host paths and are
    // gated on the deterministic-tools feature (#2548); enable it so the
    // handlers reach their parameter validation rather than the gate.
    permissions::enable_for_test();
    for (name, expected_param) in expected_missing {
        let entry = registry.find(name).expect("registered");
        let err = (entry.handler)(&[]).expect_err("must reject empty args");
        match err {
            HostlibError::MissingParameter { builtin, param } => {
                assert_eq!(builtin, *name);
                assert_eq!(param, *expected_param);
            }
            other => panic!("expected MissingParameter for {name}, got {other:?}"),
        }
    }
}

/// Every hostlib builtin that reads or writes arbitrary host paths must
/// refuse to run before `hostlib_enable("tools:deterministic")`, matching
/// the `tools::*` file I/O gate (#2548). This guards against the asymmetry
/// where the std/edit helpers could mutate files a sandboxed script was
/// denied via the `tools` surface.
#[test]
fn fs_and_ast_edit_primitives_require_deterministic_gate() {
    let mut registry = collect_into_registry(FsCapability);
    AstCapability.register_builtins(&mut registry);
    permissions::reset();
    for name in [
        "hostlib_fs_safe_text_patch",
        "hostlib_fs_read_text",
        "hostlib_ast_apply_node",
        "hostlib_ast_insert_at_anchor",
        "hostlib_ast_batch_apply",
    ] {
        let entry = registry.find(name).expect("registered");
        let err = (entry.handler)(&[]).expect_err("gated before enable");
        match err {
            HostlibError::Backend { builtin, message } => {
                assert_eq!(builtin, name);
                assert!(
                    message.contains("hostlib_enable"),
                    "gating error must point users at hostlib_enable: {message}"
                );
            }
            other => panic!("expected Backend gate error for {name}, got {other:?}"),
        }
    }
    // Telemetry routing cannot mutate files, so it stays un-gated: an empty
    // payload must surface parameter validation, not the feature gate.
    let entry = registry
        .find("hostlib_fs_emit_safe_text_patch_result")
        .expect("registered");
    match (entry.handler)(&[]).expect_err("must reject empty args") {
        HostlibError::MissingParameter { builtin, .. } => {
            assert_eq!(builtin, "hostlib_fs_emit_safe_text_patch_result");
        }
        other => panic!("emit result must stay un-gated, got {other:?}"),
    }
}

#[test]
fn fs_snapshot_capability_registers_documented_methods() {
    let registry = collect_into_registry(FsSnapshotCapability);
    let names: Vec<_> = registry.iter().map(|b| b.name).collect();
    assert_eq!(
        names,
        vec![
            "hostlib_fs_snapshot",
            "hostlib_fs_restore",
            "hostlib_fs_list_snapshots",
            "hostlib_fs_drop_snapshot",
        ]
    );
    let expected_missing: &[(&str, &str)] = &[
        ("hostlib_fs_snapshot", "session_id"),
        ("hostlib_fs_restore", "session_id"),
        ("hostlib_fs_list_snapshots", "session_id"),
        ("hostlib_fs_drop_snapshot", "session_id"),
    ];
    for (name, expected_param) in expected_missing {
        let entry = registry.find(name).expect("registered");
        let err = (entry.handler)(&[]).expect_err("must reject empty args");
        match err {
            HostlibError::MissingParameter { builtin, param } => {
                assert_eq!(builtin, *name);
                assert_eq!(param, *expected_param);
            }
            other => panic!("expected MissingParameter for {name}, got {other:?}"),
        }
    }
}

#[test]
fn fs_watch_capability_registers_documented_methods() {
    let registry = collect_into_registry(FsWatchCapability);
    let names: Vec<_> = registry.iter().map(|b| b.name).collect();
    assert_eq!(
        names,
        vec!["hostlib_fs_watch_subscribe", "hostlib_fs_watch_unsubscribe"]
    );
    for entry in registry.iter() {
        let err = (entry.handler)(&[]).expect_err("handler must reject empty args");
        assert!(
            !matches!(err, HostlibError::Unimplemented { .. }),
            "fs_watch method {} should be implemented, got {err:?}",
            entry.name
        );
    }
}

#[test]
fn tools_capability_registers_documented_methods() {
    let registry = collect_into_registry(ToolsCapability);
    let names: Vec<_> = registry.iter().map(|b| b.name).collect();
    assert_eq!(
        names,
        vec![
            // Deterministic tools — implementations live in
            // `crates/harn-hostlib/src/tools/`. Gated by
            // `hostlib_enable("tools:deterministic")`.
            "hostlib_tools_search",
            "hostlib_tools_read_file",
            "hostlib_tools_write_file",
            "hostlib_tools_delete_file",
            "hostlib_tools_list_directory",
            "hostlib_tools_get_file_outline",
            "hostlib_tools_git",
            // Process tools. Also gated by
            // `hostlib_enable("tools:deterministic")`.
            "hostlib_tools_run_command",
            "hostlib_tools_read_command_output",
            "hostlib_tools_wait_command",
            "hostlib_tools_run_test",
            "hostlib_tools_run_build_command",
            "hostlib_tools_inspect_test_results",
            "hostlib_tools_manage_packages",
            "hostlib_tools_cancel_handle",
            // Per-session opt-in builtin.
            "hostlib_enable",
        ]
    );

    // All implemented tools must refuse to run before
    // `hostlib_enable("tools:deterministic")`. We check each entry so newly
    // wired tools cannot accidentally bypass the opt-in gate.
    harn_hostlib::tools::permissions::reset();
    let gated_methods = [
        "hostlib_tools_search",
        "hostlib_tools_read_file",
        "hostlib_tools_write_file",
        "hostlib_tools_delete_file",
        "hostlib_tools_list_directory",
        "hostlib_tools_get_file_outline",
        "hostlib_tools_git",
        "hostlib_tools_run_command",
        "hostlib_tools_read_command_output",
        "hostlib_tools_wait_command",
        "hostlib_tools_run_test",
        "hostlib_tools_run_build_command",
        "hostlib_tools_inspect_test_results",
        "hostlib_tools_manage_packages",
        "hostlib_tools_cancel_handle",
    ];
    for name in gated_methods {
        let entry = registry.find(name).expect("registered");
        let err = (entry.handler)(&[]).expect_err("disabled by default");
        match err {
            HostlibError::Backend { builtin, message } => {
                assert_eq!(builtin, name);
                assert!(
                    message.contains("hostlib_enable"),
                    "gating error must point users at hostlib_enable: {message}"
                );
            }
            other => panic!("expected Backend gate error for {name}, got {other:?}"),
        }
    }
}

#[test]
fn secret_store_capability_registers_documented_methods() {
    let registry = collect_into_registry(SecretStoreCapability);
    let names: Vec<_> = registry.iter().map(|b| b.name).collect();
    assert_eq!(
        names,
        vec![
            "hostlib_secret_store_get",
            "hostlib_secret_store_set",
            "hostlib_secret_store_delete",
            "hostlib_secret_store_list",
        ]
    );
    // Each entry must refuse an empty payload with a structured
    // `MissingParameter` rather than panicking.
    let expected_missing: &[(&str, &str)] = &[
        ("hostlib_secret_store_get", "account"),
        ("hostlib_secret_store_set", "account"),
        ("hostlib_secret_store_delete", "account"),
        ("hostlib_secret_store_list", "account"),
    ];
    for (name, expected_param) in expected_missing {
        let entry = registry.find(name).expect("registered");
        let err = (entry.handler)(&[]).expect_err("must reject empty args");
        match err {
            HostlibError::MissingParameter { builtin, param } => {
                assert_eq!(builtin, *name);
                assert_eq!(param, *expected_param);
            }
            other => panic!("expected MissingParameter for {name}, got {other:?}"),
        }
    }
}

#[test]
fn install_default_wires_every_module_into_a_vm() {
    let mut vm = harn_vm::Vm::new();
    let registry = harn_hostlib::install_default(&mut vm);

    assert_eq!(
        registry.modules(),
        &[
            "ast",
            "code_index",
            "scanner",
            "fs",
            "fs",
            "fs_watch",
            "tools",
            "secret_store"
        ]
    );
    // Builtin count: 15 ast (incl. apply_node + insert_at_anchor) +
    // 27 code_index + 2 scanner + 4 fs + 4 fs_snapshot + 2 fs_watch
    // + 14 tools + 1 hostlib_enable + 4 secret_store = 73.
    assert!(registry.builtins().len() >= 73);
}

#[test]
fn every_registered_builtin_has_request_and_response_schemas() {
    let registry = HostlibRegistry::new()
        .with(AstCapability)
        .with(CodeIndexCapability::new())
        .with(ScannerCapability)
        .with(FsCapability)
        .with(FsSnapshotCapability)
        .with(FsWatchCapability)
        .with(ToolsCapability)
        .with(SecretStoreCapability);

    for entry in registry.builtins().iter() {
        assert!(
            schemas::lookup(entry.module, entry.method, schemas::SchemaKind::Request).is_some(),
            "missing request schema for {}.{}",
            entry.module,
            entry.method
        );
        assert!(
            schemas::lookup(entry.module, entry.method, schemas::SchemaKind::Response).is_some(),
            "missing response schema for {}.{}",
            entry.module,
            entry.method
        );
    }
}

#[test]
fn every_schema_parses_as_valid_json_schema_2020_12() {
    for (module, method, kind, body) in schemas::SCHEMAS {
        let value: serde_json::Value = serde_json::from_str(body).unwrap_or_else(|err| {
            panic!("schema for {module}.{method} ({kind:?}) is not valid JSON: {err}")
        });
        let dialect = value
            .get("$schema")
            .and_then(|v| v.as_str())
            .expect("every shipped schema must declare its dialect via $schema");
        assert!(
            dialect.contains("draft/2020-12"),
            "schema for {module}.{method} ({kind:?}) declares unexpected dialect: {dialect}"
        );
        // Sanity check on shape: every schema must be an object and either
        // declare a top-level `type` or be a pure `$ref`. This catches
        // accidental empty or malformed files without forcing a full
        // schema-validator dependency at scaffold stage.
        assert!(
            value.is_object(),
            "schema for {module}.{method} ({kind:?}) must be a JSON object"
        );
        let object = value.as_object().unwrap();
        assert!(
            object.contains_key("type") || object.contains_key("$ref"),
            "schema for {module}.{method} ({kind:?}) must declare `type` or `$ref`"
        );
    }
}
