//! Integration tests for the `bce query` CLI contract.

use std::collections::BTreeSet;
use std::path::Path;
use std::process::{Command, Output};

use serde_json::Value;
use tempfile::TempDir;

use bsky_comment_extractor::db::{init_db, open_db, run_migration, upsert_post};

const fn bin_path() -> &'static str {
    env!("CARGO_BIN_EXE_bce")
}

fn seed_db(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let conn = open_db(path)?;
    init_db(&conn)?;

    // Post 004: top-level, English, with image embed
    upsert_post(
        &conn,
        "at://did:plc:alice/app.bsky.feed.post/004",
        "did:plc:alice",
        "post 004",
        "2025-01-04T00:00:00Z",
        r#"{"text":"post 004","createdAt":"2025-01-04T00:00:00Z","langs":["en"],"embed":{"$type":"app.bsky.embed.images"}}"#,
    )?;

    // Post 003: a reply, English
    upsert_post(
        &conn,
        "at://did:plc:alice/app.bsky.feed.post/003",
        "did:plc:alice",
        "post 003",
        "2025-01-03T00:00:00Z",
        r#"{"text":"post 003","createdAt":"2025-01-03T00:00:00Z","langs":["en"],"reply":{"parent":{"uri":"at://did:plc:bob/app.bsky.feed.post/100"},"root":{"uri":"at://did:plc:bob/app.bsky.feed.post/100"}}}"#,
    )?;

    // Post 002: top-level, Portuguese
    upsert_post(
        &conn,
        "at://did:plc:alice/app.bsky.feed.post/002",
        "did:plc:alice",
        "post 002",
        "2025-01-02T00:00:00Z",
        r#"{"text":"post 002","createdAt":"2025-01-02T00:00:00Z","langs":["pt"]}"#,
    )?;

    // Post 001: top-level, English, about Rust programming
    upsert_post(
        &conn,
        "at://did:plc:alice/app.bsky.feed.post/001",
        "did:plc:alice",
        "post 001 about Rust programming",
        "2025-01-01T00:00:00Z",
        r#"{"text":"post 001 about Rust programming","createdAt":"2025-01-01T00:00:00Z","langs":["en"]}"#,
    )?;

    Ok(())
}

fn seed_and_migrate_db(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
    seed_db(path)?;
    let conn = open_db(path)?;
    run_migration(&conn)?;
    Ok(())
}

fn stdout_lines(output: &Output) -> Vec<String> {
    String::from_utf8_lossy(&output.stdout)
        .lines()
        .map(ToOwned::to_owned)
        .collect()
}

fn seeded_db() -> (TempDir, std::path::PathBuf) {
    let tmp = TempDir::new().expect("temp dir");
    let db_path = tmp.path().join("seeded.db");
    seed_db(&db_path).expect("seed database");
    (tmp, db_path)
}

fn parse_json(line: &str) -> Value {
    serde_json::from_str(line).expect("valid json")
}

#[test]
fn query_outputs_jsonl() {
    let (_tmp, db_path) = seeded_db();

    let output = Command::new(bin_path())
        .args(["query", "--db"])
        .arg(&db_path)
        .env_remove("BSKY_APP_PASSWORD")
        .output()
        .expect("run query");

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let lines = stdout_lines(&output);
    assert_eq!(lines.len(), 5);

    let envelope = parse_json(&lines[0]);
    assert_eq!(envelope["total"], 4);
    assert_eq!(envelope["offset"], 0);
    assert_eq!(envelope["limit"], 50);
    assert_eq!(envelope["has_more"], false);

    let expected_keys = BTreeSet::from([
        "author_did".to_string(),
        "created_at".to_string(),
        "text".to_string(),
        "uri".to_string(),
    ]);

    for line in &lines[1..] {
        let post = parse_json(line);
        let keys = post
            .as_object()
            .expect("post object")
            .keys()
            .cloned()
            .collect::<BTreeSet<_>>();
        assert_eq!(keys, expected_keys);
    }
}

#[test]
fn query_limit_controls_page_size() {
    let (_tmp, db_path) = seeded_db();

    let output = Command::new(bin_path())
        .args(["query", "--db"])
        .arg(&db_path)
        .args(["--limit", "2"])
        .env_remove("BSKY_APP_PASSWORD")
        .output()
        .expect("run query");

    assert!(output.status.success());

    let lines = stdout_lines(&output);
    assert_eq!(lines.len(), 3);

    let envelope = parse_json(&lines[0]);
    assert_eq!(envelope["limit"], 2);
    assert_eq!(envelope["has_more"], true);
}

#[test]
fn query_offset_skips_rows() {
    let (_tmp, db_path) = seeded_db();

    let output = Command::new(bin_path())
        .args(["query", "--db"])
        .arg(&db_path)
        .args(["--limit", "2", "--offset", "1"])
        .env_remove("BSKY_APP_PASSWORD")
        .output()
        .expect("run query");

    assert!(output.status.success());

    let lines = stdout_lines(&output);
    assert_eq!(lines.len(), 3);

    let envelope = parse_json(&lines[0]);
    assert_eq!(envelope["offset"], 1);
    assert_eq!(envelope["limit"], 2);
    assert_eq!(envelope["has_more"], true);
    assert!(lines[1].contains("at://did:plc:alice/app.bsky.feed.post/003"));
    assert!(lines[2].contains("at://did:plc:alice/app.bsky.feed.post/002"));
}

#[test]
fn query_db_override_and_missing_db() {
    let (_tmp, db_path) = seeded_db();

    let success = Command::new(bin_path())
        .args(["query", "--db"])
        .arg(&db_path)
        .env_remove("BSKY_APP_PASSWORD")
        .output()
        .expect("run query");
    assert!(success.status.success());

    let missing_path = db_path.with_file_name("missing.db");
    let failure = Command::new(bin_path())
        .args(["query", "--db"])
        .arg(&missing_path)
        .env_remove("BSKY_APP_PASSWORD")
        .output()
        .expect("run query");

    assert!(!failure.status.success());
    let error = parse_json(
        String::from_utf8_lossy(&failure.stderr)
            .trim()
            .trim_end_matches('\n'),
    );
    assert_eq!(error["error"], "db_not_found");
}

#[test]
fn query_does_not_require_bsky_app_password() {
    let (_tmp, db_path) = seeded_db();

    let output = Command::new(bin_path())
        .args(["query", "--db"])
        .arg(&db_path)
        .env_remove("BSKY_APP_PASSWORD")
        .output()
        .expect("run query");

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert_eq!(String::from_utf8_lossy(&output.stderr), "");
}

fn seeded_and_migrated_db() -> (TempDir, std::path::PathBuf) {
    let tmp = TempDir::new().expect("temp dir");
    let db_path = tmp.path().join("migrated.db");
    seed_and_migrate_db(&db_path).expect("seed and migrate database");
    (tmp, db_path)
}

#[test]
fn migrate_command_succeeds() {
    let (_tmp, db_path) = seeded_db();

    let output = Command::new(bin_path())
        .args(["migrate", "--db"])
        .arg(&db_path)
        .output()
        .expect("run migrate");

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Migrated 4 posts"), "stdout was: {stdout}");
}

#[test]
fn migrate_command_idempotent() {
    let (_tmp, db_path) = seeded_and_migrated_db();

    let output = Command::new(bin_path())
        .args(["migrate", "--db"])
        .arg(&db_path)
        .output()
        .expect("run migrate");

    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("Already up to date"),
        "stdout was: {stdout}"
    );
}

#[test]
fn query_filter_fts_text_search() {
    let (_tmp, db_path) = seeded_and_migrated_db();

    let output = Command::new(bin_path())
        .args(["query", "text ~ rust", "--db"])
        .arg(&db_path)
        .env_remove("BSKY_APP_PASSWORD")
        .output()
        .expect("run query");

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let lines = stdout_lines(&output);
    let envelope = parse_json(&lines[0]);
    assert_eq!(envelope["total"], 1);
    assert!(lines[1].contains("Rust programming"));
}

#[test]
fn query_filter_case_insensitive_fts() {
    let (_tmp, db_path) = seeded_and_migrated_db();

    // Search for "RUST" should match "Rust" in post text.
    let output = Command::new(bin_path())
        .args(["query", "text ~ RUST", "--db"])
        .arg(&db_path)
        .env_remove("BSKY_APP_PASSWORD")
        .output()
        .expect("run query");

    assert!(output.status.success());
    let lines = stdout_lines(&output);
    let envelope = parse_json(&lines[0]);
    assert_eq!(envelope["total"], 1, "FTS should be case-insensitive");
}

#[test]
fn query_filter_is_reply() {
    let (_tmp, db_path) = seeded_and_migrated_db();

    let output = Command::new(bin_path())
        .args(["query", "is_reply = true", "--db"])
        .arg(&db_path)
        .env_remove("BSKY_APP_PASSWORD")
        .output()
        .expect("run query");

    assert!(output.status.success());
    let lines = stdout_lines(&output);
    let envelope = parse_json(&lines[0]);
    assert_eq!(envelope["total"], 1, "only post 003 is a reply");
    assert!(lines[1].contains("post 003"));
}

#[test]
fn query_filter_created_at_range() {
    let (_tmp, db_path) = seeded_and_migrated_db();

    let output = Command::new(bin_path())
        .args(["query", "created_at > 2025-01-02T00:00:00Z", "--db"])
        .arg(&db_path)
        .env_remove("BSKY_APP_PASSWORD")
        .output()
        .expect("run query");

    assert!(output.status.success());
    let lines = stdout_lines(&output);
    let envelope = parse_json(&lines[0]);
    assert_eq!(envelope["total"], 2, "posts 003 and 004 are after Jan 2");
}

#[test]
fn query_filter_combined_and() {
    let (_tmp, db_path) = seeded_and_migrated_db();

    let output = Command::new(bin_path())
        .args(["query", "is_reply = false AND lang = en", "--db"])
        .arg(&db_path)
        .env_remove("BSKY_APP_PASSWORD")
        .output()
        .expect("run query");

    assert!(output.status.success());
    let lines = stdout_lines(&output);
    let envelope = parse_json(&lines[0]);
    // Posts 001 (en, not reply) and 004 (en, not reply). Post 003 is en but is a reply.
    assert_eq!(envelope["total"], 2);
}

#[test]
fn query_without_migration_fails_when_filter_used() {
    let (_tmp, db_path) = seeded_db();

    let output = Command::new(bin_path())
        .args(["query", "text ~ rust", "--db"])
        .arg(&db_path)
        .env_remove("BSKY_APP_PASSWORD")
        .output()
        .expect("run query");

    assert!(!output.status.success());
    let error = parse_json(
        String::from_utf8_lossy(&output.stderr)
            .trim()
            .trim_end_matches('\n'),
    );
    assert_eq!(error["error"], "migration_required");
}

#[test]
fn query_without_filter_still_works_without_migration() {
    let (_tmp, db_path) = seeded_db();

    let output = Command::new(bin_path())
        .args(["query", "--db"])
        .arg(&db_path)
        .env_remove("BSKY_APP_PASSWORD")
        .output()
        .expect("run query");

    assert!(
        output.status.success(),
        "unfiltered query should work without migration"
    );
    let lines = stdout_lines(&output);
    let envelope = parse_json(&lines[0]);
    assert_eq!(envelope["total"], 4);
}
