//! The core validation engine.
//!
//! Implements the parse → plan → validate pipeline using DataFusion.
//! SQL is parsed, a logical plan is built against the schema-only catalog,
//! and any errors are converted to structured ValidationErrors with suggestions.

use crate::errors::fuzzy;
use crate::errors::types::*;
use crate::schema::catalog::{DEFAULT_CATALOG, DEFAULT_SCHEMA, register_schema_catalog};
use crate::schema::types::SchemaDefinition;
use datafusion::execution::context::SessionContext;
use datafusion::logical_expr::LogicalPlan;
use datafusion::prelude::SessionConfig;
use datafusion::sql::parser::DFParser;
use datafusion::sql::sqlparser::dialect::GenericDialect;

/// Validate a SQL query against a schema definition.
///
/// This is the primary entry point for altimate-core validation.
/// It parses the SQL, builds a logical plan using DataFusion,
/// and returns structured errors with fuzzy-matched suggestions.
///
/// # Example
///
/// ```rust,no_run
/// use altimate_core::{validate, SchemaDefinition};
///
/// # async fn example() {
/// let schema = SchemaDefinition::from_yaml_file("schema.yaml").unwrap();
/// let result = validate("SELECT id FROM users WHERE age > 25", &schema).await;
/// if result.valid {
///     println!("Query is valid!");
/// } else {
///     for error in &result.errors {
///         println!("Error {}: {}", error.code, error.message);
///     }
/// }
/// # }
/// ```
pub async fn validate(sql: &str, schema: &SchemaDefinition) -> ValidationResult {
    let (result, _plan) = validate_with_plan(sql, schema).await;
    result
}

/// Validate a SQL query and return both the result and the logical plan.
///
/// Same as [`validate`], but also returns the DataFusion [`LogicalPlan`]
/// when the query is valid. Used by the explainer module to walk the plan
/// and extract structured intelligence.
///
/// Returns `(ValidationResult, Option<LogicalPlan>)` where the plan is
/// `Some` only when the first valid statement produced a plan successfully.
pub async fn validate_with_plan(
    sql: &str,
    schema: &SchemaDefinition,
) -> (ValidationResult, Option<LogicalPlan>) {
    // 1. Create a fresh DataFusion session with default catalog/schema pointing
    //    to our schema-only catalog. Uses the schema's database/schema_name if
    //    provided, falling back to the built-in defaults.
    // Lowercase to match DataFusion's GenericDialect identifier normalization.
    let catalog_name = schema
        .database
        .as_deref()
        .unwrap_or(DEFAULT_CATALOG)
        .to_lowercase();
    let schema_name = schema
        .schema_name
        .as_deref()
        .unwrap_or(DEFAULT_SCHEMA)
        .to_lowercase();

    let config = SessionConfig::new()
        .with_default_catalog_and_schema(&catalog_name, &schema_name)
        .with_create_default_catalog_and_schema(false)
        .with_information_schema(false);

    let ctx = SessionContext::new_with_config(config);
    register_schema_catalog(&ctx, schema);

    // 2. Parse the SQL (catches syntax errors)
    let statements = match DFParser::parse_sql_with_dialect(sql, &GenericDialect {}) {
        Ok(stmts) => stmts,
        Err(parse_err) => {
            return (
                ValidationResult::syntax_error(format!("Syntax error: {parse_err}"), None),
                None,
            );
        }
    };

    if statements.is_empty() {
        return (
            ValidationResult::syntax_error("Empty SQL statement".to_string(), None),
            None,
        );
    }

    // 3. Build logical plan for each statement (catches semantic errors)
    let mut all_errors = Vec::new();
    let mut captured_plan: Option<LogicalPlan> = None;

    for statement in statements {
        match ctx.state().statement_to_plan(statement).await {
            Ok(plan) => {
                // Plan built successfully — query is valid against the schema.
                // Capture the first successful plan for the explainer.
                if captured_plan.is_none() {
                    captured_plan = Some(plan);
                }
            }
            Err(df_error) => {
                // Convert DataFusion error to structured ValidationError(s)
                let errors = convert_datafusion_error(&df_error, schema);
                all_errors.extend(errors);
            }
        }
    }

    if all_errors.is_empty() {
        (
            ValidationResult::ok(QueryMetadata::default()),
            captured_plan,
        )
    } else {
        // If there were errors, discard the plan
        (ValidationResult::with_errors(all_errors), None)
    }
}

/// Convert a DataFusion error into structured ValidationErrors with suggestions.
fn convert_datafusion_error(
    error: &datafusion::common::DataFusionError,
    schema: &SchemaDefinition,
) -> Vec<ValidationError> {
    let error_msg = error.to_string();

    // Try to classify the error and add suggestions
    // DataFusion error messages follow patterns we can match on

    // Pattern: "table 'X' not found"
    if let Some(table_name) = extract_table_not_found(&error_msg) {
        let table_names: Vec<&str> = schema.table_names();
        let suggestions = fuzzy::match_table_name(&table_name, &table_names);

        return vec![ValidationError {
            code: ErrorCode::E001,
            kind: ErrorKind::TableNotFound {
                table: table_name.clone(),
            },
            message: format!("Table '{table_name}' not found"),
            location: None,
            suggestions,
        }];
    }

    // Pattern: "column 'X' not found" or "invalid column 'X'"
    if let Some((table, column)) = extract_column_not_found(&error_msg) {
        let all_columns = schema.all_column_names();
        let suggestions = fuzzy::match_column_name(&column, &all_columns);

        return vec![ValidationError {
            code: ErrorCode::E002,
            kind: ErrorKind::ColumnNotFound {
                table: table.clone(),
                column: column.clone(),
            },
            message: if let Some(ref t) = table {
                format!("Column '{column}' not found in table '{t}'")
            } else {
                format!("Column '{column}' not found")
            },
            location: None,
            suggestions,
        }];
    }

    // Pattern: "ambiguous column"
    if error_msg.to_lowercase().contains("ambiguous") {
        return vec![ValidationError {
            code: ErrorCode::E004,
            kind: ErrorKind::AmbiguousColumn {
                column: "unknown".to_string(),
                candidates: vec![],
            },
            message: format!("Ambiguous column reference: {error_msg}"),
            location: None,
            suggestions: vec![],
        }];
    }

    // Default: wrap as a generic semantic error
    vec![ValidationError {
        code: ErrorCode::E099,
        kind: ErrorKind::Other {
            detail: error_msg.clone(),
        },
        message: error_msg,
        location: None,
        suggestions: vec![],
    }]
}

/// Try to extract a table name from a "table not found" error message.
fn extract_table_not_found(msg: &str) -> Option<String> {
    let lower = msg.to_lowercase();

    // DataFusion format: "table 'catalog.schema.table_name' not found"
    // Also match: "table not found", "relation", "doesn't exist"
    let is_table_error = lower.contains("not found") && lower.contains("table")
        || lower.contains("relation")
        || lower.contains("doesn't exist");

    if !is_table_error {
        return None;
    }

    // Try to extract the table name from quotes
    if let Some(name) = extract_quoted_name(msg) {
        // DataFusion qualifies with catalog.schema.table, extract the last segment
        let bare_name = name.rsplit('.').next().unwrap_or(&name).to_string();
        return Some(bare_name);
    }
    // Try after colon
    if let Some(pos) = msg.find(':') {
        let remainder = msg[pos + 1..].trim();
        let name = remainder
            .split_whitespace()
            .next()
            .unwrap_or(remainder)
            .trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
        if !name.is_empty() {
            return Some(name.to_string());
        }
    }
    None
}

/// Try to extract a column name from a "column not found" error message.
fn extract_column_not_found(msg: &str) -> Option<(Option<String>, String)> {
    let lower = msg.to_lowercase();

    // DataFusion format: "No field named foo_bar. Valid fields are ..."
    if lower.contains("no field named")
        && let Some(pos) = lower.find("no field named")
    {
        let after = &msg[pos + "no field named".len()..].trim_start();
        // Capture alphanumeric, underscore, and dot characters
        let raw: String = after
            .chars()
            .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '.')
            .collect();
        // Trim trailing dots (sentence punctuation, not qualifiers)
        let name = raw.trim_end_matches('.');
        if !name.is_empty() {
            // Check if it's table-qualified (table.column)
            if let Some(dot_pos) = name.find('.') {
                let table = name[..dot_pos].to_string();
                let column = name[dot_pos + 1..].to_string();
                return Some((Some(table), column));
            }
            return Some((None, name.to_string()));
        }
    }

    // Other patterns with quoted names
    for pattern in &["column", "field", "invalid column", "schema has no field"] {
        if lower.contains(pattern)
            && let Some(name) = extract_quoted_name(msg)
        {
            // Check if it's table-qualified (table.column)
            if let Some(dot_pos) = name.find('.') {
                let table = name[..dot_pos].to_string();
                let column = name[dot_pos + 1..].to_string();
                return Some((Some(table), column));
            }
            return Some((None, name));
        }
    }
    None
}

/// Extract a name from single or double quotes in an error message.
fn extract_quoted_name(msg: &str) -> Option<String> {
    // Try double quotes first
    for quote in &['"', '\''] {
        if let Some(start) = msg.find(*quote)
            && let Some(end) = msg[start + 1..].find(*quote)
        {
            let name = &msg[start + 1..start + 1 + end];
            if !name.is_empty() {
                return Some(name.to_string());
            }
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    fn ecommerce_schema() -> SchemaDefinition {
        SchemaDefinition::from_yaml(include_str!(
            "../../../../tests/fixtures/schemas/ecommerce.yaml"
        ))
        .expect("Failed to load ecommerce schema fixture")
    }

    #[tokio::test]
    async fn test_valid_simple_select() {
        let schema = ecommerce_schema();
        let result = validate("SELECT id, email FROM users", &schema).await;
        assert!(
            result.valid,
            "Expected valid, got errors: {:?}",
            result.errors
        );
    }

    #[tokio::test]
    async fn test_valid_select_star() {
        let schema = ecommerce_schema();
        let result = validate("SELECT * FROM users", &schema).await;
        assert!(
            result.valid,
            "Expected valid, got errors: {:?}",
            result.errors
        );
    }

    #[tokio::test]
    async fn test_table_not_found() {
        let schema = ecommerce_schema();
        let result = validate("SELECT * FROM nonexistent", &schema).await;
        assert!(!result.valid);
        assert!(!result.errors.is_empty());
        // Must be classified as E001 (TableNotFound), not E099 (Other)
        assert_eq!(
            result.errors[0].code,
            ErrorCode::E001,
            "Expected E001 TableNotFound, got {:?}: {}",
            result.errors[0].code,
            result.errors[0].message
        );
        assert!(
            matches!(&result.errors[0].kind, ErrorKind::TableNotFound { table } if table == "nonexistent"),
            "Expected TableNotFound with table='nonexistent', got {:?}",
            result.errors[0].kind
        );
    }

    #[tokio::test]
    async fn test_column_not_found_error_code() {
        let schema = ecommerce_schema();
        let result = validate("SELECT foo_bar FROM users", &schema).await;
        assert!(!result.valid);
        assert!(!result.errors.is_empty());
        // Must be classified as E002 (ColumnNotFound), not E099 (Other)
        assert_eq!(
            result.errors[0].code,
            ErrorCode::E002,
            "Expected E002 ColumnNotFound, got {:?}: {}",
            result.errors[0].code,
            result.errors[0].message
        );
    }

    #[test]
    fn test_extract_table_not_found_qualified_name() {
        // DataFusion 46 uses fully-qualified names like "altimate-core.public.nonexistent"
        let msg = r#"Error during planning: table 'altimate-core.public.nonexistent' not found"#;
        let result = extract_table_not_found(msg);
        assert_eq!(result.as_deref(), Some("nonexistent"));
    }

    #[test]
    fn test_extract_table_not_found_bare_name() {
        let msg = r#"table 'users' not found"#;
        let result = extract_table_not_found(msg);
        assert_eq!(result.as_deref(), Some("users"));
    }

    #[test]
    fn test_extract_column_not_found_no_field_named() {
        // DataFusion 46 format: "No field named foo_bar. Valid fields are ..."
        let msg = "Schema error: No field named foo_bar. Valid fields are users.id, users.email";
        let result = extract_column_not_found(msg);
        assert_eq!(result, Some((None, "foo_bar".to_string())));
    }

    #[test]
    fn test_extract_column_not_found_qualified() {
        let msg =
            "Schema error: No field named users.foo_bar. Valid fields are users.id, users.email";
        let result = extract_column_not_found(msg);
        assert_eq!(
            result,
            Some((Some("users".to_string()), "foo_bar".to_string()))
        );
    }

    #[tokio::test]
    async fn test_table_not_found_has_suggestions() {
        let schema = ecommerce_schema();
        // "usrs" is close to "users" — should get a fuzzy suggestion
        let result = validate("SELECT * FROM usrs", &schema).await;
        assert!(!result.valid);
        assert_eq!(result.errors[0].code, ErrorCode::E001);
        assert!(
            !result.errors[0].suggestions.is_empty(),
            "Expected fuzzy suggestions for 'usrs', got none"
        );
    }

    #[tokio::test]
    async fn test_syntax_error() {
        let schema = ecommerce_schema();
        let result = validate("SELECTZ * FROM users", &schema).await;
        assert!(!result.valid);
    }

    #[tokio::test]
    async fn test_empty_sql() {
        let schema = ecommerce_schema();
        let result = validate("", &schema).await;
        assert!(!result.valid);
    }
}
