use super::*;
use crate::config::{AiCapability, FeatureCandidate, TEST_ENV_LOCK, resolve_capability_binding};
use std::sync::MutexGuard;

struct EnvGuard {
    lock: MutexGuard<'static, ()>,
}

impl EnvGuard {
    fn new() -> Self {
        let guard = Self {
            lock: TEST_ENV_LOCK
                .lock()
                .unwrap_or_else(|poisoned| poisoned.into_inner()),
        };
        guard.clear();
        guard
    }

    fn clear(&self) {
        let _held_env_lock = &self.lock;
        for key in [
            "GOBBY_FALKORDB_HOST",
            "GOBBY_FALKORDB_PORT",
            "GOBBY_FALKORDB_PASSWORD",
            "GOBBY_POSTGRES_DSN",
            "GOBBY_QDRANT_URL",
            "GOBBY_QDRANT_API_KEY",
        ] {
            // SAFETY: TEST_ENV_LOCK must guard every test environment mutation
            // in this crate. This loop only removes the fixed key list above.
            unsafe { std::env::remove_var(key) };
        }
    }
}

impl Drop for EnvGuard {
    fn drop(&mut self) {
        self.clear();
    }
}

fn write_services_stack(home: &Path) {
    fs::create_dir_all(services_dir(home)).expect("create services dir");
    fs::write(compose_file_path(home), "services: {}\n").expect("write compose file");
}

#[test]
fn gcore_yaml_reads_flat_and_nested_keys() {
    let config = StandaloneConfig::from_yaml_str(&format!(
        r#"
databases.postgres.dsn: postgresql://flat/db
databases:
  falkordb:
    port: 16379
ai.embeddings:
  provider: ollama
{api_key}: local-api-key
"#,
        api_key = embedding_keys::AI_API_KEY,
    ))
    .expect("parse config");

    assert_eq!(
        config.get("databases.postgres.dsn"),
        Some("postgresql://flat/db")
    );
    assert_eq!(config.get("databases.falkordb.port"), Some("16379"));
    assert_eq!(config.get("ai.embeddings.provider"), Some("ollama"));
    assert_eq!(
        config.get(embedding_keys::AI_API_KEY),
        Some("local-api-key")
    );
    assert_eq!(config.get(ai_keys::TEXT_GENERATE_ROUTING), Some("direct"));
    assert_eq!(
        config.get(ai_keys::TEXT_GENERATE_API_BASE),
        Some("http://localhost:1234/v1")
    );
    assert_eq!(
        config.get(ai_keys::TEXT_GENERATE_MODEL),
        Some(DEFAULT_LM_STUDIO_TEXT_MODEL)
    );
    assert_eq!(
        config.get(ai_keys::TEXT_GENERATE_API_KEY),
        Some("local-api-key")
    );
}

#[test]
fn gcore_yaml_reads_dotted_mapping_prefixes() {
    let config = StandaloneConfig::from_yaml_str(
        r#"
ai.embeddings:
  provider: ollama
  model: nomic-embed-text
"#,
    )
    .expect("parse config");

    assert_eq!(config.get("ai.embeddings.provider"), Some("ollama"));
    assert_eq!(config.get("ai.embeddings.model"), Some("nomic-embed-text"));
}

#[test]
fn gcore_yaml_text_generation_defaults_do_not_override_explicit_values() {
    let config = StandaloneConfig::from_yaml_str(
        r#"
ai.embeddings.api_key: embedding-key
ai.text_generate.routing: auto
ai.text_generate.api_base: http://text.example/v1
ai.text_generate.model: text-model
"#,
    )
    .expect("parse config");

    assert_eq!(config.get(ai_keys::TEXT_GENERATE_ROUTING), Some("auto"));
    assert_eq!(
        config.get(ai_keys::TEXT_GENERATE_API_BASE),
        Some("http://text.example/v1")
    );
    assert_eq!(config.get(ai_keys::TEXT_GENERATE_MODEL), Some("text-model"));
    assert_eq!(config.get(ai_keys::TEXT_GENERATE_API_KEY), None);
}

#[test]
fn gcore_yaml_text_generate_candidates_resolve_from_json_string() {
    let mut config = StandaloneConfig::from_yaml_str(
        r#"
ai:
  text_generate:
    routing: daemon
    candidates: '[{"candidate":"codex/gpt-5.5","reasoning_effort":"high"}]'
    reasoning_effort: medium
"#,
    )
    .expect("parse config");

    assert_eq!(
        config.get(ai_keys::TEXT_GENERATE_CANDIDATES),
        Some(r#"[{"candidate":"codex/gpt-5.5","reasoning_effort":"high"}]"#)
    );

    let binding = resolve_capability_binding(&mut config, AiCapability::TextGenerate);
    assert_eq!(
        binding.candidates,
        Some(vec![FeatureCandidate {
            candidate: "codex/gpt-5.5".to_string(),
            reasoning_effort: Some("high".to_string()),
        }])
    );
    assert_eq!(binding.reasoning_effort.as_deref(), Some("medium"));
}

#[test]
fn gcore_yaml_writes_nested_keys() {
    let dir = tempfile::tempdir().expect("tempdir");
    let path = dir.path().join(GCORE_CONFIG_FILENAME);
    let mut config = StandaloneConfig::empty();
    config.set("databases.postgres.dsn", "postgresql://local/db");
    config.set(embedding_keys::AI_DIM, "768");

    config.write_at(&path).expect("write config");
    let raw = fs::read_to_string(&path).expect("read config");
    let yaml: serde_yaml::Value = serde_yaml::from_str(&raw).expect("parse written yaml");

    assert!(!raw.contains("databases.postgres.dsn:"));
    assert_eq!(
        nested_yaml_str(&yaml, &["databases", "postgres", "dsn"]),
        Some("postgresql://local/db")
    );
    assert_eq!(
        nested_yaml_str(&yaml, &["ai", "embeddings", "dim"]),
        Some("768")
    );
    assert_eq!(
        StandaloneConfig::read_at(&path)
            .expect("read config")
            .expect("config present")
            .get(embedding_keys::AI_DIM),
        Some("768")
    );
}

#[test]
fn gcore_yaml_write_rejects_scalar_to_nested_mapping_collision() {
    let dir = tempfile::tempdir().expect("tempdir");
    let path = dir.path().join(GCORE_CONFIG_FILENAME);
    let mut config = StandaloneConfig::empty();
    config.set("ai", "scalar");
    config.set("ai.embeddings.provider", "ollama");

    let err = config.write_at(&path).expect_err("collision rejected");

    assert!(
        err.to_string()
            .contains("cannot be nested under scalar YAML key"),
        "unexpected error: {err}"
    );
}

#[test]
fn gcore_yaml_rejects_excessive_nesting() {
    let mut yaml = String::new();
    for depth in 0..=64 {
        yaml.push_str(&"  ".repeat(depth));
        yaml.push_str(&format!("k{depth}:\n"));
    }
    yaml.push_str(&"  ".repeat(65));
    yaml.push_str("value: too-deep\n");

    let error = StandaloneConfig::from_yaml_str(&yaml).expect_err("too-deep YAML must fail");

    assert!(error.to_string().contains("exceeds maximum depth of 64"));
}

#[test]
fn gcore_yaml_rejects_sequence_scalar_values() {
    let err = StandaloneConfig::from_yaml_str(
        r#"
ai:
  embeddings:
    provider:
      - ollama
"#,
    )
    .expect_err("sequence scalar rejected");

    assert!(
        err.to_string()
            .contains("gcore.yaml path `ai.embeddings.provider` cannot be a sequence"),
        "unexpected error: {err}"
    );
}

#[test]
fn gcore_yaml_stringifies_tagged_scalar_values() {
    let config = StandaloneConfig::from_yaml_str(
        r#"
ai:
  embeddings:
    provider: !provider ollama
"#,
    )
    .expect("tagged scalar is stringified");
    let tagged: serde_yaml::Value = serde_yaml::from_str("!provider ollama").expect("tagged yaml");
    let expected = serde_yaml::to_string(&tagged)
        .expect("stringify tagged yaml")
        .trim()
        .to_string();

    assert_eq!(
        config.get("ai.embeddings.provider"),
        Some(expected.as_str())
    );
}

#[test]
fn gcore_yaml_stringifies_tagged_sequence_values() {
    let config = StandaloneConfig::from_yaml_str(
        r#"
ai:
  embeddings:
    provider: !providers
      - ollama
      - lmstudio
"#,
    )
    .expect("tagged sequence is stringified");
    let tagged: serde_yaml::Value =
        serde_yaml::from_str("!providers\n- ollama\n- lmstudio").expect("tagged yaml");
    let expected = serde_yaml::to_string(&tagged)
        .expect("stringify tagged yaml")
        .trim()
        .to_string();

    assert_eq!(
        config.get("ai.embeddings.provider"),
        Some(expected.as_str())
    );
}

fn nested_yaml_str<'a>(value: &'a serde_yaml::Value, path: &[&str]) -> Option<&'a str> {
    let mut current = value;
    for key in path {
        current = current
            .as_mapping()?
            .get(serde_yaml::Value::String((*key).to_string()))?;
    }
    current.as_str()
}

#[test]
fn standalone_config_resolves_service_keys_and_plain_api_key() {
    let _env = EnvGuard::new();
    let mut config = StandaloneConfig::from_yaml_str(&format!(
        r#"
databases.falkordb.host: 127.0.0.1
databases.falkordb.port: "16379"
databases.falkordb.password: falkor-pass
databases.qdrant.url: http://localhost:6333
{api_base}: http://localhost:1234/v1
{model}: text-embedding-nomic-embed-text-v1.5@f16
{api_key}: test-key
"#,
        api_base = embedding_keys::AI_API_BASE,
        model = embedding_keys::AI_MODEL,
        api_key = embedding_keys::AI_API_KEY,
    ))
    .expect("parse config");

    let falkor = crate::config::resolve_falkordb_config(&mut config).expect("falkor");
    assert_eq!(falkor.password.as_deref(), Some("falkor-pass"));
    let qdrant = crate::config::resolve_qdrant_config(&mut config).expect("qdrant");
    assert_eq!(qdrant.url.as_deref(), Some("http://localhost:6333"));
    let embedding = crate::config::resolve_embedding_config(&mut config).expect("embedding");
    assert_eq!(embedding.api_key.as_deref(), Some("test-key"));
}

#[test]
fn writes_ai_embeddings_standalone_api_key() {
    let dir = tempfile::tempdir().expect("tempdir");
    let path = dir.path().join(GCORE_CONFIG_FILENAME);
    let options = DockerServiceOptions::new(dir.path().join(".gobby"));
    let embedding = EmbeddingBootstrap {
        provider: "openai-compatible".to_string(),
        api_base: "http://localhost:1234/v1".to_string(),
        model: "embed-small".to_string(),
        vector_dim: 1024,
        query_prefix: Some("query: ".to_string()),
        api_key: Some("local-api-key".to_string()),
    };

    let config = write_standalone_bootstrap(
        &path,
        "postgresql://localhost/gobby",
        &options,
        None,
        Some(&embedding),
    )
    .expect("write standalone bootstrap");

    assert_eq!(
        config.get(embedding_keys::AI_PROVIDER),
        Some("openai-compatible")
    );
    assert_eq!(
        config.get(embedding_keys::AI_API_BASE),
        Some("http://localhost:1234/v1")
    );
    assert_eq!(config.get(embedding_keys::AI_MODEL), Some("embed-small"));
    assert_eq!(config.get(embedding_keys::AI_DIM), Some("1024"));
    assert_eq!(config.get(embedding_keys::AI_QUERY_PREFIX), Some("query: "));
    assert_eq!(
        config.get(embedding_keys::AI_API_KEY),
        Some("local-api-key")
    );
}

#[test]
fn compose_template_matches_daemon_checkout_when_present() {
    let Some(daemon) = daemon_compose_template_path() else {
        return;
    };
    if !daemon.exists() {
        return;
    }
    let daemon_template = fs::read_to_string(daemon).expect("read daemon compose template");
    assert_eq!(COMPOSE_TEMPLATE, daemon_template);
}

fn daemon_compose_template_path() -> Option<PathBuf> {
    if let Ok(path) = std::env::var("GOBBY_DAEMON_COMPOSE_PATH") {
        let path = path.trim();
        if !path.is_empty() {
            return Some(PathBuf::from(path));
        }
    }

    let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
    let repo_root = manifest_dir.parent()?.parent()?;
    Some(
        repo_root
            .parent()?
            .join("gobby/src/gobby/data/docker-compose.services.yml"),
    )
}

#[test]
fn docker_provisioning_prepares_assets_runs_compose_and_health_checks() {
    let dir = tempfile::tempdir().expect("tempdir");
    let mut runner = RecordingRunner::default();
    let mut health = RecordingHealth::default();
    let options = DockerServiceOptions::new(dir.path().join(".gobby"));

    let report = provision_docker_services_with(&options, &mut runner, &mut health)
        .expect("provision services");

    assert_eq!(runner.commands.len(), 1);
    assert_eq!(runner.commands[0].program, "docker");
    assert!(runner.commands[0].args.contains(&"--profile".to_string()));
    assert!(runner.commands[0].args.contains(&"all".to_string()));
    assert_eq!(health.checks, vec!["postgres", "qdrant", "falkordb"]);
    assert!(health.endpoints.contains(&(
        "qdrant",
        DEFAULT_QDRANT_HOST.to_string(),
        DEFAULT_QDRANT_HTTP_PORT
    )));
    assert_eq!(report.started_profiles, vec!["all"]);
    assert_eq!(report.health_checks, vec!["postgres", "qdrant", "falkordb"]);
    assert_eq!(
        fs::read_to_string(&report.compose_file).expect("read compose"),
        COMPOSE_TEMPLATE
    );
    assert!(
        report
            .services_dir
            .join("postgres-pgsearch")
            .join("Dockerfile")
            .exists()
    );
    assert!(
        fs::read_to_string(&report.env_file)
            .expect("read env")
            .contains("GOBBY_PG_SEARCH_VERSION=0.23.4")
    );
}

#[test]
fn ensure_hub_reuses_then_provisions() {
    let _env = EnvGuard::new();
    let dir = tempfile::tempdir().expect("tempdir");
    let home = dir.path().join(".gobby");
    fs::create_dir_all(&home).expect("create gobby home");
    write_services_stack(&home);
    let mut config = StandaloneConfig::empty();
    config.set("databases.postgres.dsn", "postgresql://reachable/gobby");
    config
        .write_at(&gcore_config_path(&home))
        .expect("write gcore config");

    let options = EnsureHubOptions::new(home.clone());
    let (database_url, report) = ensure_hub_with(
        &options,
        |_| None,
        |url| url == "postgresql://reachable/gobby",
        |_| panic!("reachable DSN should not provision services"),
    )
    .expect("reuse reachable DSN");
    assert_eq!(database_url, "postgresql://reachable/gobby");
    assert!(report.is_none());

    let mut options = EnsureHubOptions::new(home);
    options.service_options.postgres_port = 15432;
    options.candidate_database_urls = vec!["postgresql://unreachable/gobby".to_string()];
    let (database_url, report) = ensure_hub_with(
        &options,
        |_| None,
        |_| false,
        |service_options| {
            Ok(DockerProvisioningReport {
                services_dir: service_options.gobby_home.join("services"),
                compose_file: compose_file_path(&service_options.gobby_home),
                env_file: service_options.gobby_home.join("services/.env"),
                started_profiles: vec!["all".to_string()],
                health_checks: vec!["postgres".to_string()],
            })
        },
    )
    .expect("provision fallback hub");
    #[cfg(feature = "postgres")]
    {
        assert_eq!(database_url, default_database_url(15432));
        assert_eq!(
            report.expect("provisioning report").health_checks,
            vec!["postgres"]
        );
    }
    #[cfg(not(feature = "postgres"))]
    {
        assert_eq!(database_url, "postgresql://unreachable/gobby");
        assert!(report.is_none());
    }
}

#[test]
fn gcore_yaml_database_url_requires_services_stack() {
    let _env = EnvGuard::new();
    let dir = tempfile::tempdir().expect("tempdir");
    let home = dir.path().join(".gobby");
    fs::create_dir_all(&home).expect("create gobby home");
    let mut config = StandaloneConfig::empty();
    config.set("databases.postgres.dsn", "postgresql://recorded/gobby");
    config
        .write_at(&gcore_config_path(&home))
        .expect("write gcore config");

    let mut options = EnsureHubOptions::new(home);
    options.service_options.postgres_port = 15432;
    let (database_url, report) = ensure_hub_with(
        &options,
        |_| None,
        |url| url == "postgresql://recorded/gobby",
        |service_options| {
            Ok(DockerProvisioningReport {
                services_dir: service_options.gobby_home.join("services"),
                compose_file: compose_file_path(&service_options.gobby_home),
                env_file: service_options.gobby_home.join("services/.env"),
                started_profiles: vec!["all".to_string()],
                health_checks: vec!["postgres".to_string()],
            })
        },
    )
    .expect("missing services stack should provision fallback hub");

    assert_eq!(database_url, default_database_url(15432));
    assert!(report.is_some());
}

#[test]
fn no_double_provision_when_reachable() {
    let _env = EnvGuard::new();
    let dir = tempfile::tempdir().expect("tempdir");
    let home = dir.path().join(".gobby");
    fs::create_dir_all(&home).expect("create gobby home");
    write_services_stack(&home);
    let mut config = StandaloneConfig::empty();
    config.set("databases.postgres.dsn", "postgresql://recorded/gobby");
    config
        .write_at(&gcore_config_path(&home))
        .expect("write gcore config");

    let mut options = EnsureHubOptions::new(home);
    options.service_options.postgres_port = 15432;
    let (database_url, report) = ensure_hub_with_identity(
        &options,
        |_| None,
        |url| url == "postgresql://recorded/gobby",
        |_| {
            Ok(HubIdentityProbeResult::Known(HubIdentity {
                system_identifier: "cluster-a".to_string(),
                database_name: "gobby".to_string(),
            }))
        },
        |_| panic!("reachable recorded DSN should not provision services"),
    )
    .expect("reuse reachable recorded hub");

    assert_eq!(database_url, "postgresql://recorded/gobby");
    assert!(report.is_none());
}

#[test]
fn divergent_hubs_surface_conflict() {
    let _env = EnvGuard::new();
    let dir = tempfile::tempdir().expect("tempdir");
    let home = dir.path().join(".gobby");
    fs::create_dir_all(&home).expect("create gobby home");
    write_services_stack(&home);
    let mut config = StandaloneConfig::empty();
    config.set(
        "databases.postgres.dsn",
        "postgresql://standalone:secret@standalone/gobby?sslmode=require",
    );
    config
        .write_at(&gcore_config_path(&home))
        .expect("write gcore config");
    fs::write(
            home.join("bootstrap.yaml"),
            "hub_backend: postgres\ndatabase_url: postgresql://daemon:secret@daemon/gobby?application_name=gobby\n",
        )
        .expect("write bootstrap");

    let err = ensure_hub_with_identity(
        &EnsureHubOptions::new(home),
        |_| None,
        |url| {
            matches!(
                url,
                "postgresql://standalone:secret@standalone/gobby?sslmode=require"
                    | "postgresql://daemon:secret@daemon/gobby?application_name=gobby"
            )
        },
        |url| {
            let system_identifier = if url.starts_with("postgresql://standalone:secret@standalone/")
            {
                "cluster-a"
            } else {
                "cluster-b"
            };
            Ok(HubIdentityProbeResult::Known(HubIdentity {
                system_identifier: system_identifier.to_string(),
                database_name: "gobby".to_string(),
            }))
        },
        |_| panic!("conflicting reachable hubs should not provision services"),
    )
    .expect_err("surface divergent hub conflict");

    let message = err.to_string();
    assert!(message.contains("system_identifier=cluster-a, database=gobby"));
    assert!(message.contains("system_identifier=cluster-b, database=gobby"));
    assert!(!message.contains("postgresql://"));
    assert!(!message.contains("secret"));
    assert!(!message.contains("sslmode"));
    assert!(!message.contains("application_name"));
}

#[test]
fn reachable_env_database_url_conflicts_with_recorded_hub() {
    let _env = EnvGuard::new();
    let dir = tempfile::tempdir().expect("tempdir");
    let home = dir.path().join(".gobby");
    fs::create_dir_all(&home).expect("create gobby home");
    write_services_stack(&home);
    let mut config = StandaloneConfig::empty();
    config.set("databases.postgres.dsn", "postgresql://recorded/gobby");
    config
        .write_at(&gcore_config_path(&home))
        .expect("write gcore config");

    let err = ensure_hub_with_identity(
        &EnsureHubOptions::new(home),
        |name| (name == "GOBBY_POSTGRES_DSN").then(|| "postgresql://env/gobby".to_string()),
        |url| {
            matches!(
                url,
                "postgresql://recorded/gobby" | "postgresql://env/gobby"
            )
        },
        |url| {
            let system_identifier = if url == "postgresql://recorded/gobby" {
                "cluster-a"
            } else {
                "cluster-b"
            };
            Ok(HubIdentityProbeResult::Known(HubIdentity {
                system_identifier: system_identifier.to_string(),
                database_name: "gobby".to_string(),
            }))
        },
        |_| panic!("conflicting reachable hubs should not provision services"),
    )
    .expect_err("env DSN should be validated against recorded hub");

    let message = err.to_string();
    assert!(message.contains("system_identifier=cluster-a, database=gobby"));
    assert!(message.contains("system_identifier=cluster-b, database=gobby"));
    assert!(!message.contains("postgresql://"));
}

#[test]
fn insufficient_identity_privilege_preserves_hub() {
    let _env = EnvGuard::new();
    let dir = tempfile::tempdir().expect("tempdir");
    let home = dir.path().join(".gobby");
    fs::create_dir_all(&home).expect("create gobby home");
    write_services_stack(&home);
    let mut config = StandaloneConfig::empty();
    config.set("databases.postgres.dsn", "postgresql://standalone/gobby");
    config
        .write_at(&gcore_config_path(&home))
        .expect("write gcore config");
    fs::write(
        home.join("bootstrap.yaml"),
        "hub_backend: postgres\ndatabase_url: postgresql://daemon/gobby\n",
    )
    .expect("write bootstrap");

    let (database_url, report) = ensure_hub_with_identity(
        &EnsureHubOptions::new(home),
        |_| None,
        |url| {
            matches!(
                url,
                "postgresql://standalone/gobby" | "postgresql://daemon/gobby"
            )
        },
        |_| {
            Ok(HubIdentityProbeResult::UnknownInsufficientPrivilege {
                message: "identity_unknown_insufficient_privilege".to_string(),
            })
        },
        |_| panic!("unknown identity for reachable hubs should not provision services"),
    )
    .expect("preserve existing recorded hub");

    assert_eq!(database_url, "postgresql://standalone/gobby");
    assert!(report.is_none());

    let resolution = resolve_recorded_hub_database_url(
        Some("postgresql://standalone:secret@standalone/gobby"),
        Some("postgresql://daemon:secret@daemon/gobby"),
        |url| {
            matches!(
                url,
                "postgresql://standalone:secret@standalone/gobby"
                    | "postgresql://daemon:secret@daemon/gobby"
            )
        },
        |_| {
            Ok(HubIdentityProbeResult::UnknownInsufficientPrivilege {
                message: "identity_unknown_insufficient_privilege".to_string(),
            })
        },
    )
    .expect("resolve unknown identity")
    .expect("resolution");
    let RecordedHubIdentityStatus::IdentityUnknownInsufficientPrivilege { message } =
        resolution.identity_status
    else {
        panic!("expected insufficient privilege status");
    };
    assert!(!message.contains("postgresql://"));
    assert!(!message.contains("secret"));
}

#[test]
fn override_plus_recorded_hub_preserves_recorded_when_identity_unknown() {
    let _env = EnvGuard::new();
    let dir = tempfile::tempdir().expect("tempdir");
    let home = dir.path().join(".gobby");
    fs::create_dir_all(&home).expect("create gobby home");
    write_services_stack(&home);
    let mut config = StandaloneConfig::empty();
    config.set("databases.postgres.dsn", "postgresql://recorded/gobby");
    config
        .write_at(&gcore_config_path(&home))
        .expect("write gcore config");

    let (database_url, report) = ensure_hub_with_identity(
        &EnsureHubOptions::new(home),
        |name| (name == "GOBBY_POSTGRES_DSN").then(|| "postgresql://override/gobby".to_string()),
        |url| {
            matches!(
                url,
                "postgresql://recorded/gobby" | "postgresql://override/gobby"
            )
        },
        |_| {
            Ok(HubIdentityProbeResult::UnknownInsufficientPrivilege {
                message: "identity_unknown_insufficient_privilege".to_string(),
            })
        },
        |_| panic!("reachable hubs with unknown identity should not provision services"),
    )
    .expect("preserve recorded hub when override identity cannot be verified");

    assert_eq!(database_url, "postgresql://recorded/gobby");
    assert!(report.is_none());
}

#[derive(Default)]
struct RecordingRunner {
    commands: Vec<CommandSpec>,
}

impl CommandRunner for RecordingRunner {
    fn run(&mut self, spec: &CommandSpec) -> std::io::Result<CommandOutput> {
        self.commands.push(spec.clone());
        Ok(CommandOutput {
            status: 0,
            stdout: String::new(),
            stderr: String::new(),
        })
    }
}

#[derive(Default)]
struct RecordingHealth {
    checks: Vec<&'static str>,
    endpoints: Vec<(&'static str, String, u16)>,
}

impl DockerHealthChecker for RecordingHealth {
    fn wait_postgres(&mut self, host: &str, port: u16) -> anyhow::Result<()> {
        self.checks.push("postgres");
        self.endpoints.push(("postgres", host.to_string(), port));
        Ok(())
    }

    fn wait_qdrant(&mut self, host: &str, port: u16) -> anyhow::Result<()> {
        self.checks.push("qdrant");
        self.endpoints.push(("qdrant", host.to_string(), port));
        Ok(())
    }

    fn wait_falkordb(&mut self, host: &str, port: u16) -> anyhow::Result<()> {
        self.checks.push("falkordb");
        self.endpoints.push(("falkordb", host.to_string(), port));
        Ok(())
    }
}
