//! Regression tests for post-convergence audit findings (C1/C2/FTS5
//! injection/M3/TEXT-cap/M2). Each test names the finding it pins.

use kei_entity_store::error::VerbError;
use kei_entity_store::schema::{EntitySchema, FieldDef};
use kei_entity_store::verbs::{create, search, update};
use kei_entity_store::verbs::validate::MAX_TEXT_BYTES;
use kei_entity_store::Store;
use rusqlite::Connection;
use serde_json::json;

static FIELDS: &[FieldDef] = &[
    FieldDef::pk("id"),
    FieldDef::text_nn("title"),
    FieldDef::text("description"),
    FieldDef::text_default("status", "pending"),
    FieldDef::integer("parent_id"),
    FieldDef::created_at(),
    FieldDef::updated_at(),
];

static SCHEMA: EntitySchema = EntitySchema {
    name: "item",
    table: "items",
    fields: FIELDS,
    enabled_verbs: &["create", "get", "list", "search", "update", "delete"],
    fts_columns: Some(&["title", "description"]),
    edge_table: None,
    edge_key_kind: kei_entity_store::EdgeKeyKind::IntegerPair,
    archived_field: None,
    custom_migrations: &[],
};

fn mk() -> Store { Store::open_memory(&[&SCHEMA]).unwrap() }

// ---------- C1 — silent type coercion ----------

fn expect_invalid_type(err: VerbError, expected_field: &str) {
    match err {
        VerbError::InvalidType { ref field, .. } if field == expected_field => {}
        other => panic!("expected InvalidType on `{expected_field}`, got {other:?}"),
    }
}

#[test]
fn c1_create_rejects_integer_for_text_field() {
    let s = mk();
    let err = create::run(s.conn(), &SCHEMA, json!({ "title": 42 })).unwrap_err();
    assert_eq!(err.exit_code(), 2);
    expect_invalid_type(err, "title");
}

#[test]
fn c1_create_rejects_string_for_integer_field() {
    let s = mk();
    let err = create::run(
        s.conn(),
        &SCHEMA,
        json!({ "title": "ok", "parent_id": "not-a-number" }),
    )
    .unwrap_err();
    expect_invalid_type(err, "parent_id");
}

#[test]
fn c1_update_rejects_integer_for_text_field() {
    let s = mk();
    let id = create::run(s.conn(), &SCHEMA, json!({ "title": "orig" }))
        .unwrap()["id"]
        .as_i64()
        .unwrap();
    let err = update::run(s.conn(), &SCHEMA, json!({ "id": id, "status": 7 })).unwrap_err();
    expect_invalid_type(err, "status");
}

// ---------- C2 — FTS transaction ----------

#[test]
fn c2_update_fts_failure_rolls_back_row_update() {
    // Fresh DB, then manually drop the FTS table so the next update's
    // DELETE-INTO-FTS fails mid-flight. The row UPDATE that ran first
    // in the SAME transaction must roll back.
    let s = mk();
    let id = create::run(s.conn(), &SCHEMA, json!({ "title": "before" }))
        .unwrap()["id"]
        .as_i64()
        .unwrap();

    // Sabotage: drop the fts virtual table.
    s.conn().execute_batch("DROP TABLE fts_items;").unwrap();

    let result = update::run(
        s.conn(),
        &SCHEMA,
        json!({ "id": id, "title": "after" }),
    );
    assert!(result.is_err(), "update should fail when FTS is missing");

    // Row must still read as `before` — the UPDATE was rolled back.
    let title: String = s
        .conn()
        .query_row(
            "SELECT title FROM items WHERE id=?1",
            rusqlite::params![id],
            |r| r.get(0),
        )
        .unwrap();
    assert_eq!(title, "before", "update must have rolled back on FTS failure");
}

// ---------- FTS5 injection ----------

fn count_hits(s: &Store, q: &str) -> usize {
    let v = search::run(s.conn(), &SCHEMA, json!({ "query": q })).unwrap();
    v["results"].as_array().unwrap().len()
}

#[test]
fn fts5_injection_neutralized_by_phrase_quoting() {
    // Column-prefix / NEAR / wildcard all become literal tokens when
    // wrapped in the FTS5 double-quoted phrase. None should match the
    // seeded rows — no doc contains the literal text `title:secret`.
    let s = mk();
    create::run(s.conn(), &SCHEMA, json!({
        "title": "ordinary record", "description": "nothing special"
    })).unwrap();
    create::run(s.conn(), &SCHEMA, json!({
        "title": "secret handshake", "description": "hidden"
    })).unwrap();

    assert_eq!(count_hits(&s, "title:secret"), 0, "column-prefix leaked");
    assert_eq!(count_hits(&s, "NEAR(secret hidden, 5)"), 0, "NEAR leaked");
    assert_eq!(count_hits(&s, "secr*"), 0, "wildcard leaked");
}

// ---------- TEXT size cap ----------

#[test]
fn text_cap_create_rejects_oversize() {
    let s = mk();
    let oversize: String = "a".repeat(MAX_TEXT_BYTES + 1);
    let err = create::run(s.conn(), &SCHEMA, json!({ "title": oversize })).unwrap_err();
    expect_invalid_type(err, "title");
}

#[test]
fn text_cap_update_rejects_oversize() {
    let s = mk();
    let id = create::run(s.conn(), &SCHEMA, json!({ "title": "ok" }))
        .unwrap()["id"]
        .as_i64()
        .unwrap();
    let oversize: String = "a".repeat(MAX_TEXT_BYTES + 1);
    let err = update::run(
        s.conn(),
        &SCHEMA,
        json!({ "id": id, "description": oversize }),
    )
    .unwrap_err();
    expect_invalid_type(err, "description");
}

// ---------- M2 — migration version ----------

#[test]
fn m2_user_version_stamped_on_fresh_db() {
    let s = mk();
    let v: u32 = s
        .conn()
        .pragma_query_value(None, "user_version", |r| r.get(0))
        .unwrap();
    assert_eq!(v, kei_entity_store::engine::CURRENT_USER_VERSION);
}

#[test]
fn m2_user_version_applied_once_idempotent() {
    // Open twice — second open must leave user_version unchanged (not
    // bumped past CURRENT).
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("store.db");
    {
        let _s = Store::open(&path, &[&SCHEMA]).unwrap();
    }
    {
        let _s = Store::open(&path, &[&SCHEMA]).unwrap();
        let conn = Connection::open(&path).unwrap();
        let v: u32 = conn
            .pragma_query_value(None, "user_version", |r| r.get(0))
            .unwrap();
        assert_eq!(v, kei_entity_store::engine::CURRENT_USER_VERSION);
    }
}
