use crate::error::{TuskError, TuskResult};

/// Cross-flavor whitelist guard for read-only SQL execution.
/// Allows: SELECT, WITH ... SELECT, SHOW, EXPLAIN, DESCRIBE.
/// Rejects: INSERT, UPDATE, DELETE, ALTER, DROP, CREATE, TRUNCATE,
/// RENAME, GRANT, REVOKE, ATTACH, DETACH, OPTIMIZE, SYSTEM.
pub fn ensure_readonly_sql(sql: &str) -> TuskResult<()> {
    let normalized = strip_leading_comments(sql).to_ascii_uppercase();
    let trimmed = normalized.trim();
    if trimmed.is_empty() {
        return Err(TuskError::Validation("Empty SQL statement".into()));
    }
    let allowed_starts = ["SELECT", "WITH", "SHOW", "EXPLAIN", "DESCRIBE", "DESC ", "DESC\n", "VALUES"];
    let starts_ok = allowed_starts
        .iter()
        .any(|p| trimmed.starts_with(p) || trimmed == p.trim());
    if !starts_ok {
        return Err(TuskError::ReadOnly);
    }

    // Reject if any forbidden keyword appears as a top-level token
    let forbidden = [
        "INSERT", "UPDATE", "DELETE", "ALTER", "DROP", "CREATE", "TRUNCATE",
        "RENAME", "GRANT", "REVOKE", "ATTACH", "DETACH", "OPTIMIZE", "SYSTEM",
        "REPLACE", "MERGE",
    ];
    for kw in forbidden {
        if contains_keyword(&normalized, kw) {
            return Err(TuskError::ReadOnly);
        }
    }
    Ok(())
}

fn strip_leading_comments(sql: &str) -> &str {
    let mut s = sql.trim_start();
    loop {
        if let Some(rest) = s.strip_prefix("--") {
            // line comment — skip to newline
            match rest.find('\n') {
                Some(idx) => s = rest[idx + 1..].trim_start(),
                None => return "",
            }
        } else if let Some(rest) = s.strip_prefix("/*") {
            match rest.find("*/") {
                Some(idx) => s = rest[idx + 2..].trim_start(),
                None => return "",
            }
        } else {
            break;
        }
    }
    s
}

fn contains_keyword(haystack: &str, kw: &str) -> bool {
    let bytes = haystack.as_bytes();
    let needle = kw.as_bytes();
    let mut i = 0;
    while i + needle.len() <= bytes.len() {
        if &bytes[i..i + needle.len()] == needle {
            let before_ok = i == 0 || !is_word_char(bytes[i - 1]);
            let after = i + needle.len();
            let after_ok = after == bytes.len() || !is_word_char(bytes[after]);
            if before_ok && after_ok {
                return true;
            }
        }
        i += 1;
    }
    false
}

fn is_word_char(b: u8) -> bool {
    b.is_ascii_alphanumeric() || b == b'_'
}

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

    #[test]
    fn allows_select() {
        assert!(ensure_readonly_sql("SELECT 1").is_ok());
        assert!(ensure_readonly_sql("  SELECT * FROM t").is_ok());
        assert!(ensure_readonly_sql("select 1").is_ok());
    }

    #[test]
    fn allows_with_select() {
        assert!(ensure_readonly_sql("WITH x AS (SELECT 1) SELECT * FROM x").is_ok());
    }

    #[test]
    fn allows_explain_show() {
        assert!(ensure_readonly_sql("EXPLAIN SELECT 1").is_ok());
        assert!(ensure_readonly_sql("SHOW TABLES").is_ok());
        assert!(ensure_readonly_sql("DESCRIBE t").is_ok());
    }

    #[test]
    fn rejects_dml_ddl() {
        assert!(ensure_readonly_sql("INSERT INTO t VALUES (1)").is_err());
        assert!(ensure_readonly_sql("UPDATE t SET a=1").is_err());
        assert!(ensure_readonly_sql("DELETE FROM t").is_err());
        assert!(ensure_readonly_sql("DROP TABLE t").is_err());
        assert!(ensure_readonly_sql("CREATE TABLE t(a int)").is_err());
        assert!(ensure_readonly_sql("TRUNCATE TABLE t").is_err());
    }

    #[test]
    fn rejects_writable_cte() {
        // PG writable CTE — looks like WITH but contains INSERT
        assert!(
            ensure_readonly_sql("WITH x AS (INSERT INTO t VALUES (1) RETURNING *) SELECT * FROM x")
                .is_err()
        );
    }

    #[test]
    fn rejects_select_with_drop_chain() {
        assert!(ensure_readonly_sql("SELECT 1; DROP TABLE t").is_err());
    }

    #[test]
    fn allows_select_with_keyword_in_string() {
        // Real-world: column names containing forbidden keywords should pass; string literals
        // containing them should also pass. Our guard is conservative and may reject some
        // legitimate queries — that is acceptable for the read-only safety net.
        // This test documents the limitation: queries embedding "DROP" as a literal will be rejected.
        // The user can disable read-only mode to run them.
    }

    #[test]
    fn strips_leading_comments() {
        assert!(ensure_readonly_sql("-- comment\nSELECT 1").is_ok());
        assert!(ensure_readonly_sql("/* block */ SELECT 1").is_ok());
        assert!(ensure_readonly_sql("/* multi\nline */\n SELECT 1").is_ok());
    }
}
