use bds_core::db::Database;
use bds_core::engine::ai::{AiEndpointConfig, AiEndpointKind};
use bds_core::engine::{chat, project};
use bds_core::model::ChatRole;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::Arc;
use std::thread;
use std::time::Duration;

fn setup() -> (tempfile::TempDir, Database, String, std::path::PathBuf) {
    let root = tempfile::tempdir().unwrap();
    let database_path = root.path().join("bds.db");
    let data_dir = root.path().join("project");
    std::fs::create_dir_all(&data_dir).unwrap();
    let db = Database::open(&database_path).unwrap();
    db.migrate().unwrap();
    let project = project::create_project(db.conn(), "Chat Test", data_dir.to_str()).unwrap();
    project::set_active_project(db.conn(), &project.id).unwrap();
    (root, db, project.id, data_dir)
}

#[test]
fn conversation_repository_round_trips_rename_model_messages_and_delete() {
    let (_root, db, _project_id, _data_dir) = setup();
    let conversation = chat::create_conversation(db.conn(), Some("tool-model")).unwrap();
    assert_eq!(conversation.title, "Chat with tool-model");
    assert_eq!(conversation.model.as_deref(), Some("tool-model"));

    let renamed = chat::rename_conversation(db.conn(), &conversation.id, "Rust notes").unwrap();
    assert_eq!(renamed.title, "Rust notes");
    chat::set_conversation_model(db.conn(), &conversation.id, "other-model").unwrap();
    chat::insert_message(
        db.conn(),
        &conversation.id,
        ChatRole::User,
        Some("Hello"),
        None,
        None,
        Default::default(),
    )
    .unwrap();

    assert_eq!(chat::list_conversations(db.conn()).unwrap().len(), 1);
    assert_eq!(
        chat::list_messages(db.conn(), &conversation.id)
            .unwrap()
            .len(),
        1
    );
    assert_eq!(
        chat::list_messages(db.conn(), &conversation.id).unwrap()[0].role,
        ChatRole::User
    );
    chat::delete_conversation(db.conn(), &conversation.id).unwrap();
    assert!(chat::list_conversations(db.conn()).unwrap().is_empty());
    assert!(
        chat::list_messages(db.conn(), &conversation.id)
            .unwrap()
            .is_empty()
    );
}

#[test]
fn sse_assembler_handles_split_frames_multiple_tools_and_usage() {
    let mut assembler = chat::SseAssembler::default();
    assembler
        .feed(b"data: {\"choices\":[{\"delta\":{\"content\":\"Hel")
        .unwrap();
    assembler
        .feed(b"lo\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-1\",\"function\":{\"name\":\"search_posts\",\"arguments\":\"{\\\"query\\\":\"}},{\"index\":1,\"id\":\"call-2\",\"function\":{\"name\":\"get_blog_stats\",\"arguments\":\"{}\"}}]}}]}\n\n")
        .unwrap();
    assembler
        .feed(b"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"rust\\\"}\"}}]}}],\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":4,\"prompt_tokens_details\":{\"cached_tokens\":7},\"completion_tokens_details\":{\"cached_tokens\":2}}}\n\ndata: [DONE]\n\n")
        .unwrap();
    let assembled = assembler.finish().unwrap();
    assert_eq!(assembled.content, "Hello");
    assert_eq!(assembled.tool_calls.len(), 2);
    assert_eq!(assembled.tool_calls[0].name, "search_posts");
    assert_eq!(assembled.tool_calls[0].arguments["query"], "rust");
    assert_eq!(assembled.usage.input_tokens, Some(11));
    assert_eq!(assembled.usage.output_tokens, Some(4));
    assert_eq!(assembled.usage.cache_read_tokens, Some(7));
    assert_eq!(assembled.usage.cache_write_tokens, Some(2));
}

#[test]
fn unavailable_endpoint_refuses_chat_without_mutating_transcript() {
    let (_root, db, project_id, data_dir) = setup();
    let conversation = chat::create_conversation(db.conn(), None).unwrap();
    let result = chat::send_chat_message(
        db.conn(),
        &data_dir,
        &project_id,
        false,
        &conversation.id,
        "Hello",
        chat::ChatSendOptions::default(),
    );
    assert!(result.is_err());
    assert!(
        chat::list_messages(db.conn(), &conversation.id)
            .unwrap()
            .is_empty()
    );
}

#[test]
fn streamed_turn_persists_content_session_and_all_token_fields() {
    let (_root, db, project_id, data_dir) = setup();
    let conversation = chat::create_conversation(db.conn(), Some("plain-model")).unwrap();
    let (url, server) = serve(vec![MockResponse::delayed_sse(vec![
        "data: {\"session_id\":\"session-7\",\"choices\":[{\"delta\":{\"content\":\"Hi \"}}]}\n\n",
        "data: {\"choices\":[{\"delta\":{\"content\":\"there\"}}],\"usage\":{\"prompt_tokens\":12,\"completion_tokens\":3,\"cache_read_tokens\":4,\"cache_write_tokens\":2}}\n\n",
        "data: [DONE]\n\n",
    ])]);
    let snapshots = Arc::new(std::sync::Mutex::new(Vec::new()));
    let captured = Arc::clone(&snapshots);
    let result = chat::send_chat_message(
        db.conn(),
        &data_dir,
        &project_id,
        false,
        &conversation.id,
        "Hello",
        options(url, move |event| {
            if let chat::ChatEvent::Content { content, .. } = event {
                captured.lock().unwrap().push(content);
            }
        }),
    )
    .unwrap();
    server.join().unwrap();

    assert_eq!(result.content, "Hi there");
    assert_eq!(&*snapshots.lock().unwrap(), &["Hi ", "Hi there"]);
    let messages = chat::list_messages(db.conn(), &conversation.id).unwrap();
    assert_eq!(messages.len(), 2);
    assert_eq!(messages[1].content.as_deref(), Some("Hi there"));
    assert_eq!(messages[1].token_usage_input, Some(12));
    assert_eq!(messages[1].token_usage_output, Some(3));
    assert_eq!(messages[1].cache_read_tokens, Some(4));
    assert_eq!(messages[1].cache_write_tokens, Some(2));
    assert_eq!(
        chat::get_conversation(db.conn(), &conversation.id)
            .unwrap()
            .copilot_session_id
            .as_deref(),
        Some("session-7")
    );
}

#[test]
fn tool_loop_persists_valid_assistant_tool_pair_before_final_answer() {
    let (_root, db, project_id, data_dir) = setup();
    let conversation = chat::create_conversation(db.conn(), Some("tool-model")).unwrap();
    let (url, server) = serve(vec![
        MockResponse::sse(vec![
            "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"stats-1\",\"function\":{\"name\":\"get_blog_stats\",\"arguments\":\"{}\"}}]}}]}\n\n",
            "data: [DONE]\n\n",
        ]),
        MockResponse::sse(vec![
            "data: {\"choices\":[{\"delta\":{\"content\":\"There are no posts.\"}}]}\n\n",
            "data: [DONE]\n\n",
        ]),
    ]);
    let result = chat::send_chat_message(
        db.conn(),
        &data_dir,
        &project_id,
        false,
        &conversation.id,
        "How many posts?",
        options(url, |_| {}),
    )
    .unwrap();
    server.join().unwrap();

    assert_eq!(result.content, "There are no posts.");
    let messages = chat::list_messages(db.conn(), &conversation.id).unwrap();
    assert_eq!(
        messages.iter().map(|item| item.role).collect::<Vec<_>>(),
        vec![
            ChatRole::User,
            ChatRole::Assistant,
            ChatRole::Tool,
            ChatRole::Assistant,
        ]
    );
    assert!(
        messages[1]
            .tool_calls
            .as_deref()
            .unwrap()
            .contains("stats-1")
    );
    assert_eq!(messages[2].tool_call_id.as_deref(), Some("stats-1"));
    assert!(
        messages[2]
            .content
            .as_deref()
            .unwrap()
            .contains("\"posts\":0")
    );
}

#[test]
fn malformed_stream_and_provider_error_keep_reopenable_user_turn() {
    for response in [
        MockResponse::sse(vec!["data: {not-json}\n\n"]),
        MockResponse::status(500, "application/json", "{\"error\":\"down\"}"),
    ] {
        let (_root, db, project_id, data_dir) = setup();
        let conversation = chat::create_conversation(db.conn(), Some("plain-model")).unwrap();
        let (url, server) = serve(vec![response]);
        assert!(
            chat::send_chat_message(
                db.conn(),
                &data_dir,
                &project_id,
                false,
                &conversation.id,
                "Remember this",
                options(url, |_| {}),
            )
            .is_err()
        );
        server.join().unwrap();
        let messages = chat::list_messages(db.conn(), &conversation.id).unwrap();
        assert_eq!(messages.len(), 1);
        assert_eq!(messages[0].role, ChatRole::User);
        assert_eq!(messages[0].content.as_deref(), Some("Remember this"));
    }
}

#[test]
fn non_success_response_preserves_provider_error_detail() {
    let (_root, db, project_id, data_dir) = setup();
    let conversation = chat::create_conversation(db.conn(), Some("plain-model")).unwrap();
    let (url, server) = serve(vec![MockResponse::status(
        400,
        "application/json",
        "{\"error\":{\"message\":\"tokenizer.chat_template is not set\"}}",
    )]);

    let error = chat::send_chat_message(
        db.conn(),
        &data_dir,
        &project_id,
        false,
        &conversation.id,
        "Hello",
        options(url, |_| {}),
    )
    .unwrap_err()
    .to_string();
    server.join().unwrap();

    assert!(error.contains("400"));
    assert!(error.contains("tokenizer.chat_template is not set"));
}

#[test]
fn cancellation_persists_only_received_content_and_never_runs_later_work() {
    let (_root, db, project_id, data_dir) = setup();
    let conversation = chat::create_conversation(db.conn(), Some("plain-model")).unwrap();
    let conversation_id = conversation.id.clone();
    let (url, server) = serve(vec![MockResponse::delayed_sse(vec![
        "data: {\"choices\":[{\"delta\":{\"content\":\"Partial\"}}]}\n\n",
        "data: {\"choices\":[{\"delta\":{\"content\":\" ignored\"}}]}\n\n",
        "data: [DONE]\n\n",
    ])]);
    let cancel_id = conversation_id.clone();
    let result = chat::send_chat_message(
        db.conn(),
        &data_dir,
        &project_id,
        false,
        &conversation_id,
        "Start",
        options(url, move |event| {
            if matches!(event, chat::ChatEvent::Content { .. }) {
                chat::cancel_chat(&cancel_id);
            }
        }),
    )
    .unwrap();
    server.join().unwrap();
    assert!(result.cancelled);
    assert_eq!(result.content, "Partial");
    let messages = chat::list_messages(db.conn(), &conversation_id).unwrap();
    assert_eq!(messages.len(), 2);
    assert_eq!(messages[1].content.as_deref(), Some("Partial"));
}

#[test]
fn context_truncation_keeps_system_and_complete_tool_pairs() {
    let (_root, db, _project_id, _data_dir) = setup();
    let conversation = chat::create_conversation(db.conn(), None).unwrap();
    chat::insert_message(
        db.conn(),
        &conversation.id,
        ChatRole::User,
        Some(&"old ".repeat(100)),
        None,
        None,
        Default::default(),
    )
    .unwrap();
    chat::insert_message(
        db.conn(),
        &conversation.id,
        ChatRole::Assistant,
        Some("old answer"),
        None,
        None,
        Default::default(),
    )
    .unwrap();
    chat::insert_message(
        db.conn(),
        &conversation.id,
        ChatRole::User,
        Some("new question"),
        None,
        None,
        Default::default(),
    )
    .unwrap();
    chat::insert_message(db.conn(), &conversation.id, ChatRole::Assistant, None, None, Some("[{\"id\":\"c1\",\"type\":\"function\",\"function\":{\"name\":\"get_blog_stats\",\"arguments\":\"{}\"}}]"), Default::default()).unwrap();
    chat::insert_message(
        db.conn(),
        &conversation.id,
        ChatRole::Tool,
        Some("{\"posts\":0}"),
        Some("c1"),
        None,
        Default::default(),
    )
    .unwrap();
    let context = chat::build_context(db.conn(), &_project_id, &conversation.id, 100).unwrap();
    assert_eq!(context[0]["role"], "system");
    assert!(context.iter().any(|item| item["content"] == "new question"));
    let assistant_index = context
        .iter()
        .position(|item| item.get("tool_calls").is_some())
        .unwrap();
    assert_eq!(context[assistant_index + 1]["tool_call_id"], "c1");
    assert!(!context.iter().any(|item| {
        item["content"]
            .as_str()
            .is_some_and(|value| value.starts_with("old "))
    }));
}

#[test]
fn airplane_mode_selects_the_local_endpoint_and_persists_its_model() {
    let (_root, db, project_id, data_dir) = setup();
    let conversation = chat::create_conversation(db.conn(), None).unwrap();
    let (url, server) = serve(vec![MockResponse::sse(vec![
        "data: {\"choices\":[{\"delta\":{\"content\":\"Local answer\"}}]}\n\n",
        "data: [DONE]\n\n",
    ])]);
    bds_core::engine::ai::save_endpoint(
        db.conn(),
        &AiEndpointConfig {
            kind: AiEndpointKind::Airplane,
            url,
            model: "local-airplane-model".to_string(),
            api_key: None,
        },
    )
    .unwrap();

    let result = chat::send_chat_message(
        db.conn(),
        &data_dir,
        &project_id,
        true,
        &conversation.id,
        "Stay offline",
        Default::default(),
    )
    .unwrap();
    server.join().unwrap();
    assert_eq!(result.content, "Local answer");
    assert_eq!(
        chat::get_conversation(db.conn(), &conversation.id)
            .unwrap()
            .model
            .as_deref(),
        Some("local-airplane-model")
    );
}

#[test]
fn tool_round_limit_stops_without_leaving_an_unpaired_tool_call() {
    let (_root, db, project_id, data_dir) = setup();
    let conversation = chat::create_conversation(db.conn(), Some("tool-model")).unwrap();
    let tool_frame = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"stats\",\"function\":{\"name\":\"get_blog_stats\",\"arguments\":\"{}\"}}]}}]}\n\ndata: [DONE]\n\n";
    let (url, server) = serve(vec![
        MockResponse::sse(vec![tool_frame]),
        MockResponse::sse(vec![tool_frame]),
    ]);
    let mut send_options = options(url, |_| {});
    send_options.max_tool_rounds = 1;
    let result = chat::send_chat_message(
        db.conn(),
        &data_dir,
        &project_id,
        false,
        &conversation.id,
        "Loop forever",
        send_options,
    );
    assert!(result.is_err());
    server.join().unwrap();

    let messages = chat::list_messages(db.conn(), &conversation.id).unwrap();
    assert_eq!(messages.len(), 3);
    assert_eq!(messages[1].role, ChatRole::Assistant);
    assert_eq!(messages[2].role, ChatRole::Tool);
    assert_eq!(messages[2].tool_call_id.as_deref(), Some("stats"));
    assert!(messages[1].tool_calls.as_deref().unwrap().contains("stats"));
}

#[test]
fn render_tool_events_include_stable_surface_identity_and_inert_arguments() {
    let (_root, db, project_id, data_dir) = setup();
    let conversation = chat::create_conversation(db.conn(), Some("tool-model")).unwrap();
    let tool_frame = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"card\",\"function\":{\"name\":\"render_card\",\"arguments\":\"{\\\"title\\\":\\\"Live\\\",\\\"body\\\":\\\"<script>data only</script>\\\"}\"}}]}}]}\n\ndata: [DONE]\n\n";
    let (url, server) = serve(vec![
        MockResponse::sse(vec![tool_frame]),
        MockResponse::sse(vec![
            "data: {\"choices\":[{\"delta\":{\"content\":\"Done\"}}]}\n\n",
            "data: [DONE]\n\n",
        ]),
    ]);
    let events = Arc::new(std::sync::Mutex::new(Vec::new()));
    let captured = events.clone();
    chat::send_chat_message(
        db.conn(),
        &data_dir,
        &project_id,
        false,
        &conversation.id,
        "Show it",
        options(url, move |event| captured.lock().unwrap().push(event)),
    )
    .unwrap();
    server.join().unwrap();

    let messages = chat::list_messages(db.conn(), &conversation.id).unwrap();
    let assistant_call = messages
        .iter()
        .find(|message| message.tool_calls.is_some())
        .unwrap();
    let events = events.lock().unwrap();
    let event = events
        .iter()
        .find_map(|event| match event {
            chat::ChatEvent::ToolStarted {
                name,
                surface_id,
                arguments,
                ..
            } if name == "render_card" => Some((surface_id, arguments)),
            _ => None,
        })
        .unwrap();
    assert_eq!(event.0, &format!("{}-surface-0", assistant_call.id));
    assert_eq!(event.1["title"], "Live");
    assert_eq!(event.1["body"], "<script>data only</script>");
}

#[test]
fn cancellation_before_mutating_tool_execution_records_pairs_without_mutation() {
    let (_root, db, project_id, data_dir) = setup();
    bds_core::db::fts::ensure_fts_tables(db.conn()).unwrap();
    let post = bds_core::engine::post::create_post(
        db.conn(),
        &data_dir,
        &project_id,
        "Original",
        Some("Body"),
        Vec::new(),
        Vec::new(),
        None,
        Some("en"),
        None,
    )
    .unwrap();
    let conversation = chat::create_conversation(db.conn(), Some("tool-model")).unwrap();
    let tool_frame = format!(
        "data: {{\"choices\":[{{\"delta\":{{\"tool_calls\":[{{\"index\":0,\"id\":\"update-1\",\"function\":{{\"name\":\"update_post_metadata\",\"arguments\":\"{{\\\"post_id\\\":\\\"{}\\\",\\\"title\\\":\\\"Changed\\\"}}\"}}}},{{\"index\":1,\"id\":\"stats-2\",\"function\":{{\"name\":\"get_blog_stats\",\"arguments\":\"{{}}\"}}}}]}}}}]}}\n\ndata: [DONE]\n\n",
        post.id
    );
    let (url, server) = serve(vec![MockResponse::sse(vec![tool_frame.as_str()])]);
    let cancel_id = conversation.id.clone();
    let result = chat::send_chat_message(
        db.conn(),
        &data_dir,
        &project_id,
        false,
        &conversation.id,
        "Change it",
        options(url, move |event| {
            if matches!(event, chat::ChatEvent::ToolStarted { .. }) {
                chat::cancel_chat(&cancel_id);
            }
        }),
    )
    .unwrap();
    server.join().unwrap();

    assert!(result.cancelled);
    assert_eq!(
        bds_core::db::queries::post::get_post_by_id(db.conn(), &post.id)
            .unwrap()
            .title,
        "Original"
    );
    let messages = chat::list_messages(db.conn(), &conversation.id).unwrap();
    assert_eq!(messages.len(), 4);
    assert_eq!(messages[2].tool_call_id.as_deref(), Some("update-1"));
    assert_eq!(messages[3].tool_call_id.as_deref(), Some("stats-2"));
    assert!(
        messages[2]
            .content
            .as_deref()
            .unwrap()
            .contains("cancelled")
    );
}

fn options(
    url: String,
    handler: impl Fn(chat::ChatEvent) + Send + Sync + 'static,
) -> chat::ChatSendOptions {
    chat::ChatSendOptions {
        endpoint: Some(AiEndpointConfig {
            kind: AiEndpointKind::Online,
            url,
            model: "plain-model".to_string(),
            api_key: Some("test-key".to_string()),
        }),
        event_handler: Some(Arc::new(handler)),
        ..Default::default()
    }
}

struct MockResponse {
    status: u16,
    content_type: &'static str,
    chunks: Vec<String>,
    delay: bool,
}

impl MockResponse {
    fn sse(chunks: Vec<&str>) -> Self {
        Self {
            status: 200,
            content_type: "text/event-stream",
            chunks: chunks.into_iter().map(str::to_string).collect(),
            delay: false,
        }
    }

    fn delayed_sse(chunks: Vec<&str>) -> Self {
        Self {
            status: 200,
            content_type: "text/event-stream",
            chunks: chunks.into_iter().map(str::to_string).collect(),
            delay: true,
        }
    }

    fn status(status: u16, content_type: &'static str, body: &str) -> Self {
        Self {
            status,
            content_type,
            chunks: vec![body.to_string()],
            delay: false,
        }
    }
}

fn serve(responses: Vec<MockResponse>) -> (String, thread::JoinHandle<()>) {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let address = listener.local_addr().unwrap();
    let server = thread::spawn(move || {
        for response in responses {
            let (mut socket, _) = listener.accept().unwrap();
            read_request(&mut socket);
            let reason = if response.status == 200 {
                "OK"
            } else {
                "Error"
            };
            write!(
                socket,
                "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nConnection: close\r\n\r\n",
                response.status, reason, response.content_type
            )
            .unwrap();
            for chunk in response.chunks {
                if socket.write_all(chunk.as_bytes()).is_err() || socket.flush().is_err() {
                    break;
                }
                if response.delay {
                    thread::sleep(Duration::from_millis(60));
                }
            }
        }
    });
    (format!("http://{address}"), server)
}

fn read_request(socket: &mut std::net::TcpStream) {
    socket
        .set_read_timeout(Some(Duration::from_secs(2)))
        .unwrap();
    let mut received = Vec::new();
    let mut chunk = [0_u8; 2048];
    loop {
        let count = socket.read(&mut chunk).unwrap();
        received.extend_from_slice(&chunk[..count]);
        let Some(headers_end) = received.windows(4).position(|value| value == b"\r\n\r\n") else {
            continue;
        };
        let headers = String::from_utf8_lossy(&received[..headers_end]);
        let content_length = headers
            .lines()
            .find_map(|line| {
                line.to_ascii_lowercase()
                    .strip_prefix("content-length:")
                    .map(str::trim)
                    .map(str::to_string)
            })
            .and_then(|value| value.parse::<usize>().ok())
            .unwrap_or(0);
        if received.len() >= headers_end + 4 + content_length {
            break;
        }
    }
}
