//! Integration tests for the m:n convenience command (C4 / ADR-0045):
//! `create m:n relationship from <T1> to <T2> [as <name>]`.
//!
//! Covers parse, junction generation (columns / compound PK / two
//! enforced FKs), the `as <name>` override, a compound-PK parent,
//! CASCADE delete, one-undo-step, self-m:n refusal, and the PK-less
//! parent guard.

use rdbms_playground::db::Database;
use rdbms_playground::dsl::command::RowFilter;
use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{self, PLAYGROUND_DB};

fn rt() -> tokio::runtime::Runtime {
    tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("tokio rt")
}

fn open() -> (project::Project, Database, tempfile::TempDir) {
    let dir = tempfile::tempdir().expect("tempdir");
    let project = project::open_or_create(None, Some(dir.path())).expect("project");
    let db = Database::open_with_persistence(
        project.db_path(),
        Persistence::new(project.path().to_path_buf()),
    )
    .expect("db");
    (project, db, dir)
}

fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) {
    let dir = tempfile::tempdir().expect("tempdir");
    let project = project::open_or_create(None, Some(dir.path())).expect("project");
    let db = Database::open_with_persistence_and_undo(
        project.db_path(),
        Persistence::new(project.path().to_path_buf()),
        true,
    )
    .expect("db");
    (project, db, dir)
}

/// A parent table `(id serial PK, label text)` — the `label` gives an
/// insertable non-PK column (a serial-PK-only table has nothing to put
/// in a short-form INSERT).
async fn serial_pk_table(db: &Database, name: &str) {
    db.create_table(
        name.to_string(),
        vec![
            ColumnSpec::new("id", Type::Serial),
            ColumnSpec::new("label", Type::Text),
        ],
        vec!["id".to_string()],
        None,
    )
    .await
    .unwrap_or_else(|e| panic!("create {name}: {e}"));
}

/// Insert one row into a `serial_pk_table`, returning its auto-assigned id.
async fn add_row(db: &Database, table: &str, label: &str) {
    db.insert(
        table.to_string(),
        Some(vec!["label".to_string()]),
        vec![Value::Text(label.to_string())],
        None,
    )
    .await
    .unwrap_or_else(|e| panic!("insert into {table}: {e}"));
}

// ---- parse layer -----------------------------------------------

#[test]
fn parses_to_create_m2n_relationship() {
    match parse_command("create m:n relationship from Students to Courses").expect("parses") {
        Command::CreateM2nRelationship { t1, t2, name } => {
            assert_eq!(t1, "Students");
            assert_eq!(t2, "Courses");
            assert_eq!(name, None);
        }
        other => panic!("expected CreateM2nRelationship, got {other:?}"),
    }
}

#[test]
fn parses_with_as_name() {
    match parse_command("create m:n relationship from Students to Courses as Enrollments")
        .expect("parses")
    {
        Command::CreateM2nRelationship { name, .. } => {
            assert_eq!(name.as_deref(), Some("Enrollments"))
        }
        other => panic!("expected CreateM2nRelationship, got {other:?}"),
    }
}

// ---- junction generation ---------------------------------------

#[test]
fn generates_junction_with_compound_pk_and_two_enforced_fks() {
    let (_p, db, _d) = open();
    rt().block_on(async {
        serial_pk_table(&db, "Students").await;
        serial_pk_table(&db, "Courses").await;

        db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
            .await
            .expect("create m:n");

        // Auto-named `Students_Courses` exists.
        let tables = db.list_tables().await.unwrap();
        assert!(
            tables.contains(&"Students_Courses".to_string()),
            "tables: {tables:?}"
        );

        // Two FK columns, both part of the compound PK.
        let desc = db
            .describe_table("Students_Courses".to_string())
            .await
            .unwrap();
        let cols: Vec<(&str, bool)> = desc
            .columns
            .iter()
            .map(|c| (c.name.as_str(), c.primary_key))
            .collect();
        assert_eq!(
            cols,
            vec![("Students_id", true), ("Courses_id", true)],
            "expected two FK columns forming the compound PK"
        );
        // Two outbound relationships (one per parent).
        assert_eq!(desc.outbound_relationships.len(), 2, "expected two FKs");

        // FK enforcement: a junction row needs existing parents.
        add_row(&db, "Students", "s1").await;
        add_row(&db, "Courses", "c1").await;
        db.insert(
            "Students_Courses".to_string(),
            Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
            vec![
                Value::Number("1".to_string()),
                Value::Number("1".to_string()),
            ],
            None,
        )
        .await
        .expect("valid link");
        // Duplicate link refused by the compound PK.
        let dup = db
            .insert(
                "Students_Courses".to_string(),
                Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
                vec![
                    Value::Number("1".to_string()),
                    Value::Number("1".to_string()),
                ],
                None,
            )
            .await;
        assert!(
            dup.is_err(),
            "duplicate (Students_id, Courses_id) must be refused"
        );
        // A link to a non-existent parent is refused by the FK.
        let orphan = db
            .insert(
                "Students_Courses".to_string(),
                Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
                vec![
                    Value::Number("1".to_string()),
                    Value::Number("99".to_string()),
                ],
                None,
            )
            .await;
        assert!(
            orphan.is_err(),
            "link to a non-existent Course must be refused"
        );
    });
}

#[test]
fn as_name_overrides_the_junction_table_name() {
    let (_p, db, _d) = open();
    rt().block_on(async {
        serial_pk_table(&db, "Students").await;
        serial_pk_table(&db, "Courses").await;
        db.create_m2n_relationship(
            "Students".to_string(),
            "Courses".to_string(),
            Some("Enrollments".to_string()),
            None,
        )
        .await
        .expect("create m:n as Enrollments");
        let tables = db.list_tables().await.unwrap();
        assert!(
            tables.contains(&"Enrollments".to_string()),
            "tables: {tables:?}"
        );
        assert!(!tables.contains(&"Students_Courses".to_string()));
    });
}

#[test]
fn compound_parent_pk_contributes_one_fk_column_each() {
    let (_p, db, _d) = open();
    rt().block_on(async {
        // Sections has a 2-column PK (course_id, term).
        db.create_table(
            "Sections".to_string(),
            vec![
                ColumnSpec::new("course_id", Type::Int),
                ColumnSpec::new("term", Type::Int),
            ],
            vec!["course_id".to_string(), "term".to_string()],
            None,
        )
        .await
        .unwrap();
        serial_pk_table(&db, "Students").await;

        db.create_m2n_relationship("Students".to_string(), "Sections".to_string(), None, None)
            .await
            .expect("create m:n");

        let desc = db
            .describe_table("Students_Sections".to_string())
            .await
            .unwrap();
        let names: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
        assert_eq!(
            names,
            vec!["Students_id", "Sections_course_id", "Sections_term"]
        );
        // All three form the compound PK.
        assert!(
            desc.columns.iter().all(|c| c.primary_key),
            "all columns are PK: {names:?}"
        );
    });
}

#[test]
fn deleting_a_parent_cascades_to_the_junction() {
    let (_p, db, _d) = open();
    rt().block_on(async {
        serial_pk_table(&db, "Students").await;
        serial_pk_table(&db, "Courses").await;
        db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
            .await
            .unwrap();
        add_row(&db, "Students", "s1").await;
        add_row(&db, "Courses", "c1").await;
        db.insert(
            "Students_Courses".to_string(),
            Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
            vec![
                Value::Number("1".to_string()),
                Value::Number("1".to_string()),
            ],
            None,
        )
        .await
        .unwrap();

        // Deleting the student cascades to the junction (ON DELETE CASCADE).
        db.delete("Students".to_string(), RowFilter::AllRows, None)
            .await
            .unwrap();
        let rows = db
            .query_data("Students_Courses".to_string(), None, None)
            .await
            .unwrap();
        assert!(
            rows.rows.is_empty(),
            "junction rows should cascade-delete, got {:?}",
            rows.rows
        );
    });
}

#[test]
fn create_m2n_is_one_undo_step() {
    let (_p, db, _d) = open_with_undo();
    rt().block_on(async {
        serial_pk_table(&db, "Students").await;
        serial_pk_table(&db, "Courses").await;
        // A real source makes the command undoable (a source-less call is
        // treated as an internal, non-undoable op).
        db.create_m2n_relationship(
            "Students".to_string(),
            "Courses".to_string(),
            None,
            Some("create m:n relationship from Students to Courses".to_string()),
        )
        .await
        .unwrap();
        assert!(
            db.list_tables()
                .await
                .unwrap()
                .contains(&"Students_Courses".to_string())
        );

        // One undo removes the junction table AND both relationships.
        db.undo().await.unwrap();
        let tables = db.list_tables().await.unwrap();
        assert!(
            !tables.contains(&"Students_Courses".to_string()),
            "undo should remove the junction: {tables:?}"
        );
        // The parents' relationships are gone too (the junction held them).
        let students = db.describe_table("Students".to_string()).await.unwrap();
        assert!(
            students.inbound_relationships.is_empty(),
            "no leftover relationship after undo"
        );
    });
}

// ---- guards ----------------------------------------------------

#[test]
fn self_referential_m2n_is_refused() {
    let (_p, db, _d) = open();
    rt().block_on(async {
        serial_pk_table(&db, "Users").await;
        let err = db
            .create_m2n_relationship("Users".to_string(), "Users".to_string(), None, None)
            .await
            .expect_err("self m:n must be refused");
        assert!(
            format!("{err}").contains("two different tables"),
            "got: {err}"
        );
    });
}

#[test]
fn missing_parent_table_is_refused() {
    let (_p, db, _d) = open();
    rt().block_on(async {
        serial_pk_table(&db, "Students").await;
        let err = db
            .create_m2n_relationship(
                "Students".to_string(),
                "Nonexistent".to_string(),
                None,
                None,
            )
            .await
            .expect_err("a missing parent table must be refused");
        // The standard "no such table" guard (require_canonical_table).
        assert!(
            format!("{err}").to_lowercase().contains("no such table"),
            "got: {err}"
        );
    });
}

#[test]
fn junction_name_collision_is_refused() {
    let (_p, db, _d) = open();
    rt().block_on(async {
        serial_pk_table(&db, "Students").await;
        serial_pk_table(&db, "Courses").await;
        db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
            .await
            .expect("first m:n");
        // A second identical m:n collides on the auto-name `Students_Courses`.
        let err = db
            .create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
            .await
            .expect_err("a junction-name collision must be refused");
        assert!(
            format!("{err}").to_lowercase().contains("exist"),
            "got: {err}"
        );
    });
}

// ---- the junction is a normal table ----------------------------

#[test]
fn the_junction_can_be_renamed() {
    // C4 requirement text: "an auto-named junction table the user can
    // rename." It is a normal table, so `rename table` works.
    let (_p, db, _d) = open();
    rt().block_on(async {
        serial_pk_table(&db, "Students").await;
        serial_pk_table(&db, "Courses").await;
        db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
            .await
            .unwrap();
        db.rename_table(
            "Students_Courses".to_string(),
            "Enrollments".to_string(),
            None,
        )
        .await
        .expect("rename the junction");
        let tables = db.list_tables().await.unwrap();
        assert!(
            tables.contains(&"Enrollments".to_string()),
            "tables: {tables:?}"
        );
        assert!(!tables.contains(&"Students_Courses".to_string()));
        // Both relationships survive the rename (rebuild-preserving).
        let desc = db.describe_table("Enrollments".to_string()).await.unwrap();
        assert_eq!(
            desc.outbound_relationships.len(),
            2,
            "FKs preserved across rename"
        );
    });
}

#[test]
fn junction_survives_save_and_rebuild() {
    // Persistence round-trip: the junction + both relationships are
    // reconstructed from project.yaml after the .db is discarded.
    let dir = tempfile::tempdir().expect("tempdir");
    let project_path = {
        let project = project::open_or_create(None, Some(dir.path())).unwrap();
        let path = project.path().to_path_buf();
        let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
            .unwrap();
        rt().block_on(async {
            serial_pk_table(&db, "Students").await;
            serial_pk_table(&db, "Courses").await;
            db.create_m2n_relationship(
                "Students".to_string(),
                "Courses".to_string(),
                None,
                Some("create m:n relationship from Students to Courses".to_string()),
            )
            .await
            .unwrap();
        });
        drop(db);
        drop(project);
        path
    };
    // Discard the derived .db so the next open rebuilds from text.
    std::fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
    let project = project::Project::open(&project_path).unwrap();
    let db = Database::open_with_persistence(
        project.db_path(),
        Persistence::new(project.path().to_path_buf()),
    )
    .unwrap();
    rt().block_on(async {
        db.rebuild_from_text(project.path().to_path_buf(), None)
            .await
            .expect("rebuild");
        let tables = db.list_tables().await.unwrap();
        assert!(
            tables.contains(&"Students_Courses".to_string()),
            "junction survived: {tables:?}"
        );
        let desc = db
            .describe_table("Students_Courses".to_string())
            .await
            .unwrap();
        assert_eq!(
            desc.outbound_relationships.len(),
            2,
            "both FKs reconstructed"
        );
        assert!(
            desc.columns.iter().all(|c| c.primary_key),
            "compound PK reconstructed"
        );
    });
}

#[test]
fn as_an_internal_name_is_refused() {
    // The junction must be a real, listable table — an `as __rdbms_*`
    // name would be filtered out of `list_tables` (a hidden orphan).
    // Guarded in the shared `do_create_table` (ADR-0045 /runda finding).
    let (_p, db, _d) = open();
    rt().block_on(async {
        serial_pk_table(&db, "Students").await;
        serial_pk_table(&db, "Courses").await;
        let err = db
            .create_m2n_relationship(
                "Students".to_string(),
                "Courses".to_string(),
                Some("__rdbms_evil".to_string()),
                None,
            )
            .await
            .expect_err("an internal junction name must be refused");
        assert!(format!("{err}").contains("no such table"), "got: {err}");
        assert!(
            !db.list_tables()
                .await
                .unwrap()
                .contains(&"__rdbms_evil".to_string())
        );
    });
}

#[test]
fn pk_less_parent_is_refused() {
    let (_p, db, _d) = open();
    rt().block_on(async {
        serial_pk_table(&db, "Students").await;
        // A PK-less table via the advanced SQL path.
        db.sql_create_table(
            "Loose".to_string(),
            vec![ColumnSpec::new("a", Type::Int)],
            vec![],
            vec![],
            vec![],
            vec![],
            false,
            None,
        )
        .await
        .unwrap();
        let err = db
            .create_m2n_relationship("Students".to_string(), "Loose".to_string(), None, None)
            .await
            .expect_err("a PK-less parent must be refused");
        assert!(format!("{err}").contains("no primary key"), "got: {err}");
    });
}

/// ADR-0046 DB2: the worker's `read_all_relationships` returns full
/// schema records (name, parent/child tables + columns, actions) — the
/// data source for the sidebar relationships panel. Exercised through
/// the real worker thread after an m:n junction creates two of them.
#[test]
fn read_all_relationships_returns_the_junction_relationships() {
    let (_project, db, _dir) = open();
    rt().block_on(async {
        serial_pk_table(&db, "Students").await;
        serial_pk_table(&db, "Courses").await;
        db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
            .await
            .expect("create m:n");

        let rels = db
            .read_all_relationships()
            .await
            .expect("read all relationships");
        assert_eq!(
            rels.len(),
            2,
            "the m:n junction creates two relationships: {rels:?}"
        );
        // Both have the junction (Students_Courses) as their child.
        for r in &rels {
            assert_eq!(
                r.child_table, "Students_Courses",
                "child is the junction: {r:?}"
            );
        }
        // One points back to each parent.
        let parents: std::collections::BTreeSet<&str> =
            rels.iter().map(|r| r.parent_table.as_str()).collect();
        assert!(
            parents.contains("Students") && parents.contains("Courses"),
            "one relationship per parent: {rels:?}"
        );
    });
}
