//! AgentRepo integration tests against testcontainers Postgres.
//!
//!   cargo test -p tl-storage --features postgres-it --test agent_repo

#![cfg(feature = "postgres-it")]

use std::time::Duration;

use diesel::prelude::*;
use diesel_async::RunQueryDsl;
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::postgres::Postgres as PostgresImage;
use tl_core::{AgentAuthority, AgentProfile, AgentScope, AgentTone, KnowledgeSource};
use tl_storage::{connect_postgres, migrate_postgres, schema::agents, AgentRepo, StorageError};

async fn fresh_repo() -> (AgentRepo, testcontainers::ContainerAsync<PostgresImage>) {
    let container = PostgresImage::default()
        .start()
        .await
        .expect("postgres container");
    let host = container.get_host().await.expect("host");
    let port = container
        .get_host_port_ipv4(5432)
        .await
        .expect("postgres port");
    let url = format!("postgres://postgres:postgres@{host}:{port}/postgres");
    migrate_postgres(&url).await.expect("migrate");
    let pool = connect_postgres(&url, 4).await.expect("connect");
    (AgentRepo::new(pool), container)
}

fn sample_profile(id: &str) -> AgentProfile {
    AgentProfile {
        agent_id: id.into(),
        display_name: format!("{id} display"),
        scope: AgentScope {
            in_scope: vec!["billing".into()],
            out_of_scope: vec!["legal".into()],
        },
        authority: AgentAuthority {
            can_promise: vec!["respond within 24h".into()],
            cannot_promise: vec!["refunds".into()],
        },
        tone: AgentTone {
            target: "warm-professional".into(),
            forbidden: vec!["sarcastic".into()],
        },
        knowledge_sources: vec![KnowledgeSource {
            kb_id: "acme-help".into(),
            kind: Default::default(),
            url: None,
            description: None,
        }],
        escalation_triggers: vec!["self-harm".into()],
        system_prompt: None,
    }
}

const SOURCE_YAML: &str = "id: minimal\n";

#[tokio::test]
async fn upsert_and_get_round_trips() {
    let (repo, _c) = fresh_repo().await;
    let profile = sample_profile("acme-support-v3");
    repo.upsert("default", &profile, SOURCE_YAML)
        .await
        .expect("upsert");

    let fetched = repo.get("default", "acme-support-v3").await.expect("get");
    assert_eq!(fetched.agent_id, "acme-support-v3");
    assert_eq!(fetched.display_name, "acme-support-v3 display");
    assert_eq!(fetched.scope.in_scope, vec!["billing".to_string()]);
    assert_eq!(
        fetched.authority.cannot_promise,
        vec!["refunds".to_string()]
    );
}

#[tokio::test]
async fn upsert_overwrites_existing() {
    let (repo, _c) = fresh_repo().await;
    let mut p = sample_profile("dual");
    repo.upsert("default", &p, SOURCE_YAML)
        .await
        .expect("first");
    p.display_name = "second display".into();
    repo.upsert("default", &p, SOURCE_YAML)
        .await
        .expect("second");
    let got = repo.get("default", "dual").await.expect("get");
    assert_eq!(got.display_name, "second display");
}

#[tokio::test]
async fn missing_agent_returns_not_found() {
    let (repo, _c) = fresh_repo().await;
    match repo.get("default", "nope").await {
        Err(StorageError::NotFound) => {}
        other => panic!("expected NotFound, got {other:?}"),
    }
}

#[tokio::test]
async fn delete_makes_subsequent_get_not_found() {
    let (repo, _c) = fresh_repo().await;
    let p = sample_profile("transient");
    repo.upsert("default", &p, SOURCE_YAML)
        .await
        .expect("upsert");
    assert!(repo.get("default", "transient").await.is_ok());

    repo.delete("default", "transient").await.expect("delete");
    match repo.get("default", "transient").await {
        Err(StorageError::NotFound) => {}
        other => panic!("expected NotFound after delete, got {other:?}"),
    }
}

#[tokio::test]
async fn delete_is_idempotent_on_missing() {
    let (repo, _c) = fresh_repo().await;
    match repo.delete("default", "never-existed").await {
        Err(StorageError::NotFound) => {}
        other => panic!("expected NotFound, got {other:?}"),
    }
}

#[tokio::test]
async fn upsert_after_delete_resurrects_the_agent() {
    let (repo, _c) = fresh_repo().await;
    let p = sample_profile("zombie");
    repo.upsert("default", &p, SOURCE_YAML)
        .await
        .expect("upsert");
    repo.delete("default", "zombie").await.expect("delete");
    repo.upsert("default", &p, SOURCE_YAML)
        .await
        .expect("upsert again");
    let got = repo.get("default", "zombie").await.expect("get");
    assert_eq!(got.agent_id, "zombie");
}

#[tokio::test]
async fn list_returns_only_active_agents() {
    let (repo, _c) = fresh_repo().await;
    repo.upsert("default", &sample_profile("a"), SOURCE_YAML)
        .await
        .unwrap();
    repo.upsert("default", &sample_profile("b"), SOURCE_YAML)
        .await
        .unwrap();
    repo.upsert("default", &sample_profile("c"), SOURCE_YAML)
        .await
        .unwrap();
    repo.delete("default", "b").await.unwrap();

    let all = repo.list("default").await.expect("list");
    let ids: Vec<&str> = all.iter().map(|p| p.agent_id.as_str()).collect();
    assert_eq!(ids, vec!["a", "c"]);
}

#[tokio::test]
async fn second_get_uses_cache() {
    // Verifies cache hit by deleting the underlying row directly via SQL
    // (bypassing the repo so the cache isn't invalidated). If the cache
    // is working, `get` still succeeds; otherwise it would `NotFound`.
    let (repo, _c) = fresh_repo().await;
    let p = sample_profile("cached");
    repo.upsert("default", &p, SOURCE_YAML).await.unwrap();

    // Populate cache.
    let _ = repo.get("default", "cached").await.expect("first");

    // Hard-delete in Postgres without going through repo.delete.
    let mut conn = repo.pool().get().await.expect("connection");
    diesel::delete(agents::table.filter(agents::id.eq("cached")))
        .execute(&mut conn)
        .await
        .expect("hard delete");

    // Cache must still serve the value.
    let cached = repo.get("default", "cached").await.expect("cache hit");
    assert_eq!(cached.agent_id, "cached");
}

#[tokio::test]
async fn capacity_zero_disables_cache() {
    // Same hard-delete trick: with capacity 0 the cache stores nothing,
    // so a hard-deleted row must surface as NotFound on the next get.
    let (_, c) = fresh_repo().await;
    let host = c.get_host().await.unwrap();
    let port = c.get_host_port_ipv4(5432).await.unwrap();
    let url = format!("postgres://postgres:postgres@{host}:{port}/postgres");
    let pool = connect_postgres(&url, 4).await.unwrap();
    let repo = AgentRepo::with_cache(pool.clone(), 0, Duration::from_secs(1));
    repo.upsert("default", &sample_profile("nocache"), SOURCE_YAML)
        .await
        .unwrap();
    let _ = repo.get("default", "nocache").await.unwrap();

    let mut conn = pool.get().await.unwrap();
    diesel::delete(agents::table.filter(agents::id.eq("nocache")))
        .execute(&mut conn)
        .await
        .unwrap();

    match repo.get("default", "nocache").await {
        Err(StorageError::NotFound) => {}
        other => panic!("expected NotFound (no cache), got {other:?}"),
    }
}
