use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

use crate::glyph::language::{SourceLanguage, language_for_path};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct CodeFile {
    pub(crate) language: SourceLanguage,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct IgnoredFile {
    pub(crate) path: PathBuf,
    pub(crate) extension: String,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CodeWalkOptions {
    pub excludes: Vec<String>,
    /// Declared extraction languages (Glyph labels: "rust", "python", "typescript", "tsx").
    /// Empty = parse everything Glyph supports. Non-empty = a contract: files in other
    /// languages are excluded from extraction and counted, never silently parsed.
    pub languages: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ExcludedPath {
    pub(crate) path: PathBuf,
}

/// Recursively collect supported code files under `dir`, skipping generated/package/cache
/// directories, and count regular files whose extension Glyph does not currently parse.
pub(crate) fn walk_code(
    dir: &Path,
    out: &mut Vec<(PathBuf, CodeFile)>,
    ignored: &mut BTreeMap<String, usize>,
    ignored_files: &mut Vec<IgnoredFile>,
    excluded_paths: &mut Vec<ExcludedPath>,
    options: &CodeWalkOptions,
) {
    walk_code_inner(
        dir,
        dir,
        out,
        ignored,
        ignored_files,
        excluded_paths,
        options,
    );
}

fn walk_code_inner(
    root: &Path,
    dir: &Path,
    out: &mut Vec<(PathBuf, CodeFile)>,
    ignored: &mut BTreeMap<String, usize>,
    ignored_files: &mut Vec<IgnoredFile>,
    excluded_paths: &mut Vec<ExcludedPath>,
    options: &CodeWalkOptions,
) {
    let Ok(entries) = fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if matching_exclude(root, &path, &options.excludes).is_some() {
            excluded_paths.push(ExcludedPath { path });
            continue;
        }
        if path.is_dir() {
            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
            if is_ignored_dir_name(name) {
                excluded_paths.push(ExcludedPath { path });
                continue;
            }
            walk_code_inner(
                root,
                &path,
                out,
                ignored,
                ignored_files,
                excluded_paths,
                options,
            );
        } else if let Some(language) = language_for_path(&path) {
            out.push((path, CodeFile { language }));
        } else {
            let ext = path
                .extension()
                .and_then(|x| x.to_str())
                .filter(|x| !x.is_empty())
                .unwrap_or("(none)")
                .to_ascii_lowercase();
            *ignored.entry(ext.clone()).or_insert(0) += 1;
            ignored_files.push(IgnoredFile {
                path,
                extension: ext,
            });
        }
    }
}

pub(crate) fn is_ignored_dir_name(name: &str) -> bool {
    matches!(
        name,
        "target" | "node_modules" | "dist" | "build" | "coverage" | "vendor"
    ) || matches!(name, ".git" | ".next" | ".turbo")
        || name == ".eidos"
        || name.starts_with("graph.tmp-")
        || (name.starts_with('.') && name != ".")
}

fn matching_exclude<'a>(root: &Path, path: &Path, excludes: &'a [String]) -> Option<&'a str> {
    let abs = normalize_path(path);
    let rel = path
        .strip_prefix(root)
        .ok()
        .map_or_else(|| abs.clone(), normalize_path);
    excludes
        .iter()
        .map(|pattern| pattern.trim())
        .filter(|pattern| !pattern.is_empty())
        .find(|pattern| path_matches_pattern(&abs, &rel, pattern))
}

fn path_matches_pattern(abs: &str, rel: &str, pattern: &str) -> bool {
    let pattern = normalize_pattern(pattern);
    if pattern.is_empty() {
        return false;
    }
    if pattern.contains('*') {
        return wildcard_match(rel, &pattern) || wildcard_match(abs, &pattern);
    }
    if pattern.starts_with('/') {
        return path_prefix_matches(abs, &pattern);
    }
    if path_prefix_matches(rel, &pattern) {
        return true;
    }
    if !pattern.contains('/') {
        return rel.split('/').any(|part| part == pattern);
    }
    false
}

fn path_prefix_matches(path: &str, pattern: &str) -> bool {
    let pattern = pattern.trim_end_matches('/');
    path == pattern || path.starts_with(&format!("{pattern}/"))
}

fn wildcard_match(path: &str, pattern: &str) -> bool {
    let mut remaining = path;
    let mut first = true;
    for part in pattern.split('*') {
        if part.is_empty() {
            continue;
        }
        if first && !pattern.starts_with('*') {
            let Some(stripped) = remaining.strip_prefix(part) else {
                return false;
            };
            remaining = stripped;
        } else if let Some(index) = remaining.find(part) {
            remaining = &remaining[index + part.len()..];
        } else {
            return false;
        }
        first = false;
    }
    pattern.ends_with('*') || remaining.is_empty()
}

fn normalize_path(path: &Path) -> String {
    path.to_string_lossy().replace('\\', "/")
}

fn normalize_pattern(pattern: &str) -> String {
    pattern
        .trim()
        .trim_start_matches("./")
        .trim_end_matches('/')
        .replace('\\', "/")
}
