//! Schema diff — compare two schema definitions and detect breaking changes.
//!
//! Compares an "old" and "new" [`SchemaDefinition`] to produce a [`SchemaDiff`]
//! listing every added, removed, or modified table and column. Each change is
//! classified as breaking or non-breaking so CI pipelines can gate on regressions.

use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;

use crate::schema::types::SchemaDefinition;

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/// Result of comparing two schemas.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaDiff {
    /// List of changes between old and new schema.
    pub changes: Vec<SchemaChange>,
    /// Whether any breaking changes were detected.
    pub has_breaking_changes: bool,
    /// Human-readable summary.
    pub summary: String,
}

/// A single schema change.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SchemaChange {
    /// A table was added in the new schema.
    TableAdded { table: String },
    /// A table was removed from the old schema.
    TableRemoved { table: String },
    /// A column was added to an existing table.
    ColumnAdded {
        table: String,
        column: String,
        data_type: String,
    },
    /// A column was removed from an existing table.
    ColumnRemoved { table: String, column: String },
    /// A column's data type changed.
    ColumnTypeChanged {
        table: String,
        column: String,
        old_type: String,
        new_type: String,
    },
    /// A column's nullability changed.
    NullabilityChanged {
        table: String,
        column: String,
        old_nullable: bool,
        new_nullable: bool,
    },
}

impl SchemaChange {
    /// Returns `true` when this change can break existing queries.
    ///
    /// Breaking changes:
    /// - Table removed
    /// - Column removed
    /// - Column type changed
    /// - Nullability changed from nullable to not-null
    pub fn is_breaking(&self) -> bool {
        match self {
            SchemaChange::TableRemoved { .. }
            | SchemaChange::ColumnRemoved { .. }
            | SchemaChange::ColumnTypeChanged { .. } => true,
            SchemaChange::NullabilityChanged {
                old_nullable,
                new_nullable,
                ..
            } => *old_nullable && !*new_nullable,
            SchemaChange::TableAdded { .. } | SchemaChange::ColumnAdded { .. } => false,
        }
    }
}

// ---------------------------------------------------------------------------
// Diff logic
// ---------------------------------------------------------------------------

/// Compare two [`SchemaDefinition`]s and return a [`SchemaDiff`].
///
/// Changes are sorted deterministically by table name, then column name.
pub fn diff_schemas(old: &SchemaDefinition, new: &SchemaDefinition) -> SchemaDiff {
    let mut changes: Vec<SchemaChange> = Vec::new();

    let old_tables: BTreeSet<&String> = old.tables.keys().collect();
    let new_tables: BTreeSet<&String> = new.tables.keys().collect();

    // Tables added
    for table in new_tables.difference(&old_tables) {
        changes.push(SchemaChange::TableAdded {
            table: (*table).clone(),
        });
    }

    // Tables removed
    for table in old_tables.difference(&new_tables) {
        changes.push(SchemaChange::TableRemoved {
            table: (*table).clone(),
        });
    }

    // Tables in both — compare columns
    for table in old_tables.intersection(&new_tables) {
        let old_table_def = &old.tables[*table];
        let new_table_def = &new.tables[*table];

        diff_columns(table, old_table_def, new_table_def, &mut changes);
    }

    // Sort deterministically: by discriminant kind, then table, then column
    changes.sort_by_key(sort_key);

    let breaking_count = changes.iter().filter(|c| c.is_breaking()).count();
    let total = changes.len();
    let has_breaking_changes = breaking_count > 0;
    let summary = format!("{total} changes ({breaking_count} breaking)");

    SchemaDiff {
        changes,
        has_breaking_changes,
        summary,
    }
}

/// Compare columns between two versions of the same table.
fn diff_columns(
    table: &str,
    old_def: &crate::schema::types::TableDef,
    new_def: &crate::schema::types::TableDef,
    changes: &mut Vec<SchemaChange>,
) {
    let old_cols: std::collections::HashMap<&str, &crate::schema::types::ColumnDef> = old_def
        .columns
        .iter()
        .map(|c| (c.name.as_str(), c))
        .collect();
    let new_cols: std::collections::HashMap<&str, &crate::schema::types::ColumnDef> = new_def
        .columns
        .iter()
        .map(|c| (c.name.as_str(), c))
        .collect();

    let old_names: BTreeSet<&str> = old_cols.keys().copied().collect();
    let new_names: BTreeSet<&str> = new_cols.keys().copied().collect();

    // Columns added
    for col in new_names.difference(&old_names) {
        let col_def = new_cols[col];
        changes.push(SchemaChange::ColumnAdded {
            table: table.to_string(),
            column: col.to_string(),
            data_type: col_def.data_type.clone(),
        });
    }

    // Columns removed
    for col in old_names.difference(&new_names) {
        changes.push(SchemaChange::ColumnRemoved {
            table: table.to_string(),
            column: col.to_string(),
        });
    }

    // Columns in both — compare type and nullability
    for col in old_names.intersection(&new_names) {
        let old_col = old_cols[col];
        let new_col = new_cols[col];

        // Case-insensitive type comparison
        if old_col.data_type.to_uppercase() != new_col.data_type.to_uppercase() {
            changes.push(SchemaChange::ColumnTypeChanged {
                table: table.to_string(),
                column: col.to_string(),
                old_type: old_col.data_type.clone(),
                new_type: new_col.data_type.clone(),
            });
        }

        if old_col.nullable != new_col.nullable {
            changes.push(SchemaChange::NullabilityChanged {
                table: table.to_string(),
                column: col.to_string(),
                old_nullable: old_col.nullable,
                new_nullable: new_col.nullable,
            });
        }
    }
}

/// Produce a sort key for deterministic ordering.
///
/// Order: TableAdded, TableRemoved, ColumnAdded, ColumnRemoved,
///        ColumnTypeChanged, NullabilityChanged — then by table, then column.
fn sort_key(change: &SchemaChange) -> (u8, String, String) {
    match change {
        SchemaChange::TableAdded { table } => (0, table.clone(), String::new()),
        SchemaChange::TableRemoved { table } => (1, table.clone(), String::new()),
        SchemaChange::ColumnAdded { table, column, .. } => (2, table.clone(), column.clone()),
        SchemaChange::ColumnRemoved { table, column } => (3, table.clone(), column.clone()),
        SchemaChange::ColumnTypeChanged { table, column, .. } => (4, table.clone(), column.clone()),
        SchemaChange::NullabilityChanged { table, column, .. } => {
            (5, table.clone(), column.clone())
        }
    }
}

// ---------------------------------------------------------------------------
// Text formatter
// ---------------------------------------------------------------------------

/// Format a [`SchemaDiff`] as human-readable text for terminal output.
///
/// Uses `+` for additions, `-` for removals, `~` for modifications.
/// Breaking changes are marked with `[BREAKING]`.
pub fn format_diff_text(diff: &SchemaDiff) -> String {
    let mut lines: Vec<String> = Vec::new();

    lines.push(format!("Schema Diff: {}", diff.summary));
    lines.push(String::new());

    for change in &diff.changes {
        let line = match change {
            SchemaChange::TableAdded { table } => {
                format!("+ Table '{table}' added")
            }
            SchemaChange::TableRemoved { table } => {
                format!("- Table '{table}' removed [BREAKING]")
            }
            SchemaChange::ColumnAdded {
                table,
                column,
                data_type,
            } => {
                format!("+ Column '{table}.{column}' added ({data_type})")
            }
            SchemaChange::ColumnRemoved { table, column } => {
                format!("- Column '{table}.{column}' removed [BREAKING]")
            }
            SchemaChange::ColumnTypeChanged {
                table,
                column,
                old_type,
                new_type,
            } => {
                format!(
                    "~ Column '{table}.{column}' type changed: {old_type} -> {new_type} [BREAKING]"
                )
            }
            SchemaChange::NullabilityChanged {
                table,
                column,
                old_nullable,
                new_nullable,
            } => {
                let old_label = if *old_nullable {
                    "nullable"
                } else {
                    "not null"
                };
                let new_label = if *new_nullable {
                    "nullable"
                } else {
                    "not null"
                };
                let breaking = if change.is_breaking() {
                    " [BREAKING]"
                } else {
                    ""
                };
                format!(
                    "~ Column '{table}.{column}' nullability changed: {old_label} -> {new_label}{breaking}"
                )
            }
        };
        lines.push(line);
    }

    lines.join("\n")
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::schema::types::{ColumnDef, SchemaDefinition, SqlDialect, TableDef};
    use std::collections::HashMap;

    /// Helper to build a minimal schema.
    fn make_schema(tables: Vec<(&str, Vec<ColumnDef>)>) -> SchemaDefinition {
        let mut map = HashMap::new();
        for (name, cols) in tables {
            map.insert(
                name.to_string(),
                TableDef {
                    description: None,
                    database: None,
                    schema: None,
                    columns: cols,
                    primary_key: None,
                    foreign_keys: vec![],
                    statistics: None,
                    clustering_keys: vec![],
                },
            );
        }
        SchemaDefinition {
            version: "1".to_string(),
            dialect: SqlDialect::Generic,
            tables: map,
            database: None,
            schema_name: None,
            glossary: None,
        }
    }

    fn col(name: &str, data_type: &str, nullable: bool) -> ColumnDef {
        ColumnDef {
            name: name.to_string(),
            data_type: data_type.to_string(),
            nullable,
            description: None,
            tags: vec![],
            synonyms: vec![],

            statistics: None,
        }
    }

    #[test]
    fn test_identical_schemas() {
        let schema = make_schema(vec![("users", vec![col("id", "INT", false)])]);
        let diff = diff_schemas(&schema, &schema);
        assert!(diff.changes.is_empty());
        assert!(!diff.has_breaking_changes);
        assert_eq!(diff.summary, "0 changes (0 breaking)");
    }

    #[test]
    fn test_table_added() {
        let old = make_schema(vec![("users", vec![col("id", "INT", false)])]);
        let new = make_schema(vec![
            ("users", vec![col("id", "INT", false)]),
            ("orders", vec![col("id", "INT", false)]),
        ]);
        let diff = diff_schemas(&old, &new);
        assert_eq!(diff.changes.len(), 1);
        assert!(
            matches!(&diff.changes[0], SchemaChange::TableAdded { table } if table == "orders")
        );
        assert!(!diff.has_breaking_changes);
    }

    #[test]
    fn test_table_removed() {
        let old = make_schema(vec![
            ("users", vec![col("id", "INT", false)]),
            ("orders", vec![col("id", "INT", false)]),
        ]);
        let new = make_schema(vec![("users", vec![col("id", "INT", false)])]);
        let diff = diff_schemas(&old, &new);
        assert_eq!(diff.changes.len(), 1);
        assert!(
            matches!(&diff.changes[0], SchemaChange::TableRemoved { table } if table == "orders")
        );
        assert!(diff.has_breaking_changes);
    }

    #[test]
    fn test_column_added() {
        let old = make_schema(vec![("users", vec![col("id", "INT", false)])]);
        let new = make_schema(vec![(
            "users",
            vec![col("id", "INT", false), col("name", "VARCHAR", true)],
        )]);
        let diff = diff_schemas(&old, &new);
        assert_eq!(diff.changes.len(), 1);
        assert!(matches!(
            &diff.changes[0],
            SchemaChange::ColumnAdded { table, column, data_type }
            if table == "users" && column == "name" && data_type == "VARCHAR"
        ));
        assert!(!diff.has_breaking_changes);
    }

    #[test]
    fn test_column_removed() {
        let old = make_schema(vec![(
            "users",
            vec![col("id", "INT", false), col("name", "VARCHAR", true)],
        )]);
        let new = make_schema(vec![("users", vec![col("id", "INT", false)])]);
        let diff = diff_schemas(&old, &new);
        assert_eq!(diff.changes.len(), 1);
        assert!(matches!(
            &diff.changes[0],
            SchemaChange::ColumnRemoved { table, column }
            if table == "users" && column == "name"
        ));
        assert!(diff.has_breaking_changes);
    }

    #[test]
    fn test_column_type_changed() {
        let old = make_schema(vec![("users", vec![col("age", "INT", true)])]);
        let new = make_schema(vec![("users", vec![col("age", "BIGINT", true)])]);
        let diff = diff_schemas(&old, &new);
        assert_eq!(diff.changes.len(), 1);
        assert!(matches!(
            &diff.changes[0],
            SchemaChange::ColumnTypeChanged { table, column, old_type, new_type }
            if table == "users" && column == "age" && old_type == "INT" && new_type == "BIGINT"
        ));
        assert!(diff.has_breaking_changes);
    }

    #[test]
    fn test_nullable_to_not_null() {
        let old = make_schema(vec![("users", vec![col("name", "VARCHAR", true)])]);
        let new = make_schema(vec![("users", vec![col("name", "VARCHAR", false)])]);
        let diff = diff_schemas(&old, &new);
        assert_eq!(diff.changes.len(), 1);
        assert!(matches!(
            &diff.changes[0],
            SchemaChange::NullabilityChanged { table, column, old_nullable: true, new_nullable: false }
            if table == "users" && column == "name"
        ));
        assert!(diff.has_breaking_changes);
    }

    #[test]
    fn test_not_null_to_nullable() {
        let old = make_schema(vec![("users", vec![col("name", "VARCHAR", false)])]);
        let new = make_schema(vec![("users", vec![col("name", "VARCHAR", true)])]);
        let diff = diff_schemas(&old, &new);
        assert_eq!(diff.changes.len(), 1);
        assert!(matches!(
            &diff.changes[0],
            SchemaChange::NullabilityChanged {
                old_nullable: false,
                new_nullable: true,
                ..
            }
        ));
        // not-null -> nullable is NOT breaking
        assert!(!diff.has_breaking_changes);
    }

    #[test]
    fn test_multiple_changes() {
        let old = make_schema(vec![
            (
                "users",
                vec![
                    col("id", "INT", false),
                    col("name", "VARCHAR", true),
                    col("age", "INT", true),
                ],
            ),
            ("legacy", vec![col("id", "INT", false)]),
        ]);
        let new = make_schema(vec![
            (
                "users",
                vec![
                    col("id", "INT", false),
                    col("name", "VARCHAR", false),
                    col("age", "BIGINT", true),
                    col("email", "VARCHAR", false),
                ],
            ),
            ("reviews", vec![col("id", "INT", false)]),
        ]);
        let diff = diff_schemas(&old, &new);

        // TableAdded(reviews), TableRemoved(legacy), ColumnAdded(users.email),
        // ColumnTypeChanged(users.age), NullabilityChanged(users.name)
        assert_eq!(diff.changes.len(), 5);

        let breaking_count = diff.changes.iter().filter(|c| c.is_breaking()).count();
        // breaking: TableRemoved(legacy), ColumnTypeChanged(users.age),
        //           NullabilityChanged(users.name nullable->not null)
        assert_eq!(breaking_count, 3);
        assert!(diff.has_breaking_changes);
        assert_eq!(diff.summary, "5 changes (3 breaking)");
    }

    #[test]
    fn test_ecommerce_v2_diff() {
        let old_yaml = std::fs::read_to_string(
            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
                .join("../../tests/fixtures/schemas/ecommerce.yaml"),
        )
        .expect("failed to read ecommerce.yaml");
        let new_yaml = std::fs::read_to_string(
            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
                .join("../../tests/fixtures/schemas/ecommerce_v2.yaml"),
        )
        .expect("failed to read ecommerce_v2.yaml");

        let old = SchemaDefinition::from_yaml(&old_yaml).expect("parse old");
        let new = SchemaDefinition::from_yaml(&new_yaml).expect("parse new");

        let diff = diff_schemas(&old, &new);

        // Expect: TableAdded(reviews), ColumnAdded(orders.tracking_number),
        //         ColumnRemoved(products.category), ColumnTypeChanged(users.age),
        //         NullabilityChanged(users.name)
        assert_eq!(diff.changes.len(), 5);
        assert!(diff.has_breaking_changes);

        // Verify specific changes exist
        assert!(
            diff.changes
                .iter()
                .any(|c| matches!(c, SchemaChange::TableAdded { table } if table == "reviews"))
        );
        assert!(diff.changes.iter().any(
            |c| matches!(c, SchemaChange::ColumnAdded { table, column, .. } if table == "orders" && column == "tracking_number")
        ));
        assert!(diff.changes.iter().any(
            |c| matches!(c, SchemaChange::ColumnRemoved { table, column } if table == "products" && column == "category")
        ));
        assert!(diff.changes.iter().any(
            |c| matches!(c, SchemaChange::ColumnTypeChanged { table, column, old_type, new_type } if table == "users" && column == "age" && old_type == "INT" && new_type == "BIGINT")
        ));
        assert!(diff.changes.iter().any(
            |c| matches!(c, SchemaChange::NullabilityChanged { table, column, old_nullable: true, new_nullable: false } if table == "users" && column == "name")
        ));
    }

    #[test]
    fn test_format_diff_text() {
        let old = make_schema(vec![
            (
                "users",
                vec![col("id", "INT", false), col("name", "VARCHAR", true)],
            ),
            ("legacy", vec![col("id", "INT", false)]),
        ]);
        let new = make_schema(vec![
            (
                "users",
                vec![col("id", "INT", false), col("email", "VARCHAR", false)],
            ),
            ("reviews", vec![col("id", "INT", false)]),
        ]);
        let diff = diff_schemas(&old, &new);
        let text = format_diff_text(&diff);

        assert!(text.contains("Schema Diff:"));
        assert!(text.contains("[BREAKING]"));
        assert!(text.contains("+ Table 'reviews' added"));
        assert!(text.contains("- Table 'legacy' removed [BREAKING]"));
        assert!(text.contains("+ Column 'users.email' added (VARCHAR)"));
        assert!(text.contains("- Column 'users.name' removed [BREAKING]"));
    }

    #[test]
    fn test_has_breaking_changes_flag() {
        // No breaking changes
        let old = make_schema(vec![("users", vec![col("id", "INT", false)])]);
        let new = make_schema(vec![
            ("users", vec![col("id", "INT", false)]),
            ("orders", vec![col("id", "INT", false)]),
        ]);
        let diff = diff_schemas(&old, &new);
        assert!(!diff.has_breaking_changes);

        // With breaking changes
        let diff2 = diff_schemas(&new, &old);
        assert!(diff2.has_breaking_changes);
    }

    #[test]
    fn test_case_insensitive_type_comparison() {
        let old = make_schema(vec![("users", vec![col("id", "int", false)])]);
        let new = make_schema(vec![("users", vec![col("id", "INT", false)])]);
        let diff = diff_schemas(&old, &new);
        assert!(diff.changes.is_empty(), "int vs INT should be equal");
    }

    #[test]
    fn test_deterministic_ordering() {
        let old = make_schema(vec![
            ("zebra", vec![col("a", "INT", false)]),
            ("alpha", vec![col("z", "INT", false)]),
        ]);
        let new = make_schema(vec![
            ("zebra", vec![col("b", "INT", false)]),
            ("alpha", vec![col("y", "INT", false)]),
        ]);
        let diff = diff_schemas(&old, &new);

        // ColumnAdded should come before ColumnRemoved in sort order,
        // and within each kind, alpha before zebra
        let tables: Vec<String> = diff
            .changes
            .iter()
            .map(|c| match c {
                SchemaChange::ColumnAdded { table, .. } => table.clone(),
                SchemaChange::ColumnRemoved { table, .. } => table.clone(),
                _ => String::new(),
            })
            .collect();

        // Added: alpha.y, zebra.b, Removed: alpha.z, zebra.a
        assert_eq!(tables, vec!["alpha", "zebra", "alpha", "zebra"]);
    }
}
