//! dbt project parser.
//!
//! Reads dbt project files, strips Jinja templates, extracts model
//! dependencies (ref/source), and computes a topological build order.

use super::types::*;
use regex::Regex;
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;

/// Parse a dbt project directory and extract models.
pub fn parse_dbt_project(project_dir: &Path) -> Result<DbtProject, String> {
    // Read dbt_project.yml
    let project_yml_path = project_dir.join("dbt_project.yml");
    let project_yml_content = std::fs::read_to_string(&project_yml_path)
        .map_err(|e| format!("Failed to read dbt_project.yml: {e}"))?;

    let project_config: serde_yaml::Value = serde_yaml::from_str(&project_yml_content)
        .map_err(|e| format!("Failed to parse dbt_project.yml: {e}"))?;

    let project_name = project_config
        .get("name")
        .and_then(|v| v.as_str())
        .unwrap_or("unknown")
        .to_string();

    // Determine model directories
    let model_paths = extract_model_paths(&project_config);
    let model_dirs: Vec<_> = model_paths
        .iter()
        .map(|p| project_dir.join(p))
        .filter(|p| p.is_dir())
        .collect();

    if model_dirs.is_empty() {
        return Ok(DbtProject {
            name: project_name,
            models: vec![],
            build_order: vec![],
            warnings: vec!["No model directories found".to_string()],
        });
    }

    // Collect all .sql model files
    let mut model_files = Vec::new();
    for dir in &model_dirs {
        collect_sql_files(dir, project_dir, &mut model_files);
    }

    // Read schema.yml files for metadata
    let schema_metadata = load_schema_metadata(&model_dirs);

    let mut warnings = Vec::new();
    let mut models = Vec::new();

    let ref_re =
        Regex::new(r#"ref\(\s*['"]([^'"]+)['"]\s*\)"#).map_err(|e| format!("Regex error: {e}"))?;
    let source_re = Regex::new(r#"source\(\s*['"]([^'"]+)['"]\s*,\s*['"]([^'"]+)['"]\s*\)"#)
        .map_err(|e| format!("Regex error: {e}"))?;

    for (file_path, relative_path) in &model_files {
        let raw_sql = match std::fs::read_to_string(file_path) {
            Ok(content) => content,
            Err(e) => {
                warnings.push(format!("Failed to read {relative_path}: {e}"));
                continue;
            }
        };

        let model_name = file_path
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("unknown")
            .to_string();

        // Extract refs before stripping Jinja
        let refs: Vec<String> = ref_re
            .captures_iter(&raw_sql)
            .map(|cap| cap[1].to_string())
            .collect();

        // Extract sources before stripping Jinja
        let sources: Vec<DbtSourceRef> = source_re
            .captures_iter(&raw_sql)
            .map(|cap| DbtSourceRef {
                source_name: cap[1].to_string(),
                table_name: cap[2].to_string(),
            })
            .collect();

        // Strip Jinja and replace ref/source calls with parseable table names
        let sql = strip_jinja_and_replace(&raw_sql, &ref_re, &source_re);

        // Look up schema.yml metadata
        let (materialization, description) = schema_metadata
            .get(&model_name)
            .map(|(mat, desc)| (mat.clone(), desc.clone()))
            .unwrap_or((None, None));

        models.push(DbtModel {
            name: model_name,
            path: relative_path.clone(),
            sql,
            raw_sql,
            materialization,
            description,
            refs,
            sources,
        });
    }

    // Compute build order via topological sort
    let build_order = topological_sort(&models, &mut warnings);

    Ok(DbtProject {
        name: project_name,
        models,
        build_order,
        warnings,
    })
}

/// Extract model paths from dbt_project.yml config.
fn extract_model_paths(config: &serde_yaml::Value) -> Vec<String> {
    // Check model-paths (dbt >= 1.0) or source-paths (legacy)
    if let Some(paths) = config.get("model-paths").and_then(|v| v.as_sequence()) {
        return paths
            .iter()
            .filter_map(|v| v.as_str().map(|s| s.to_string()))
            .collect();
    }
    if let Some(paths) = config.get("source-paths").and_then(|v| v.as_sequence()) {
        return paths
            .iter()
            .filter_map(|v| v.as_str().map(|s| s.to_string()))
            .collect();
    }
    // Default to "models"
    vec!["models".to_string()]
}

/// Recursively collect .sql files in a directory.
fn collect_sql_files(
    dir: &Path,
    project_dir: &Path,
    files: &mut Vec<(std::path::PathBuf, String)>,
) {
    let entries = match std::fs::read_dir(dir) {
        Ok(entries) => entries,
        Err(_) => return,
    };

    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            collect_sql_files(&path, project_dir, files);
        } else if path.extension().and_then(|e| e.to_str()) == Some("sql") {
            let relative = path
                .strip_prefix(project_dir)
                .unwrap_or(&path)
                .to_string_lossy()
                .to_string();
            files.push((path, relative));
        }
    }
}

/// Load schema.yml metadata from model directories.
/// Returns a map of model_name -> (materialization, description).
fn load_schema_metadata(
    model_dirs: &[std::path::PathBuf],
) -> HashMap<String, (Option<String>, Option<String>)> {
    let mut metadata = HashMap::new();

    for dir in model_dirs {
        load_schema_yml_recursive(dir, &mut metadata);
    }

    metadata
}

fn load_schema_yml_recursive(
    dir: &Path,
    metadata: &mut HashMap<String, (Option<String>, Option<String>)>,
) {
    let entries = match std::fs::read_dir(dir) {
        Ok(entries) => entries,
        Err(_) => return,
    };

    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            load_schema_yml_recursive(&path, metadata);
        } else if is_schema_yml(&path)
            && let Ok(content) = std::fs::read_to_string(&path)
            && let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&content)
        {
            extract_model_metadata(&yaml, metadata);
        }
    }
}

fn is_schema_yml(path: &Path) -> bool {
    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
    name == "schema.yml" || name == "schema.yaml" || name.ends_with("_schema.yml")
}

fn extract_model_metadata(
    yaml: &serde_yaml::Value,
    metadata: &mut HashMap<String, (Option<String>, Option<String>)>,
) {
    if let Some(models) = yaml.get("models").and_then(|v| v.as_sequence()) {
        for model in models {
            if let Some(name) = model.get("name").and_then(|v| v.as_str()) {
                let description = model
                    .get("description")
                    .and_then(|v| v.as_str())
                    .map(|s| s.to_string());
                let materialization = model
                    .get("config")
                    .and_then(|c| c.get("materialized"))
                    .and_then(|v| v.as_str())
                    .map(|s| s.to_string());
                metadata.insert(name.to_string(), (materialization, description));
            }
        }
    }
}

/// Strip Jinja template tags and replace ref/source calls with table names.
fn strip_jinja_and_replace(raw_sql: &str, ref_re: &Regex, source_re: &Regex) -> String {
    let mut sql = raw_sql.to_string();

    // Replace {{ ref('model_name') }} with model_name
    sql = ref_re
        .replace_all(&sql, |caps: &regex::Captures| caps[1].to_string())
        .to_string();

    // Replace {{ source('src', 'table') }} with src__table
    sql = source_re
        .replace_all(&sql, |caps: &regex::Captures| {
            format!("{}_{}", &caps[1], &caps[2])
        })
        .to_string();

    // Strip remaining Jinja block tags: {% ... %}
    let block_re =
        Regex::new(r"\{%.*?%\}").unwrap_or_else(|_| Regex::new(r"$^").expect("fallback regex"));
    sql = block_re.replace_all(&sql, "").to_string();

    // Strip remaining Jinja expression tags: {{ ... }}
    let expr_re =
        Regex::new(r"\{\{.*?\}\}").unwrap_or_else(|_| Regex::new(r"$^").expect("fallback regex"));
    sql = expr_re.replace_all(&sql, "").to_string();

    // Strip Jinja comments: {# ... #}
    let comment_re =
        Regex::new(r"\{#.*?#\}").unwrap_or_else(|_| Regex::new(r"$^").expect("fallback regex"));
    sql = comment_re.replace_all(&sql, "").to_string();

    sql
}

/// Topological sort of models by their ref dependencies.
fn topological_sort(models: &[DbtModel], warnings: &mut Vec<String>) -> Vec<String> {
    let model_names: HashSet<&str> = models.iter().map(|m| m.name.as_str()).collect();

    // Build adjacency list: model -> models it depends on
    let mut deps: HashMap<&str, Vec<&str>> = HashMap::new();
    let mut in_degree: HashMap<&str, usize> = HashMap::new();

    for model in models {
        deps.entry(model.name.as_str()).or_default();
        in_degree.entry(model.name.as_str()).or_insert(0);

        for r in &model.refs {
            if model_names.contains(r.as_str()) {
                deps.entry(model.name.as_str())
                    .or_default()
                    .push(r.as_str());
                *in_degree.entry(model.name.as_str()).or_insert(0) += 1;
            } else {
                warnings.push(format!(
                    "Model '{}' references '{}' which is not found in the project",
                    model.name, r
                ));
            }
        }
    }

    // Kahn's algorithm
    let mut queue: VecDeque<&str> = VecDeque::new();
    for (&name, &degree) in &in_degree {
        if degree == 0 {
            queue.push_back(name);
        }
    }

    let mut order = Vec::new();
    while let Some(name) = queue.pop_front() {
        order.push(name.to_string());

        // For each model that depends on `name`, reduce its in-degree
        for model in models {
            if model.name == name {
                continue;
            }
            if deps
                .get(model.name.as_str())
                .is_some_and(|d| d.contains(&name))
                && let Some(deg) = in_degree.get_mut(model.name.as_str())
            {
                *deg = deg.saturating_sub(1);
                if *deg == 0 {
                    queue.push_back(model.name.as_str());
                }
            }
        }
    }

    if order.len() != models.len() {
        warnings.push("Circular dependency detected in model refs".to_string());
        // Add remaining models that weren't sorted
        for model in models {
            if !order.contains(&model.name) {
                order.push(model.name.clone());
            }
        }
    }

    order
}
