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

use eidos_kernel::schema::{Graph, Node};
use serde::Serialize;

use crate::glyph::coverage::{count_edges_by_relation, count_nodes_by_kind};
use crate::glyph::language::SourceLanguage;
use crate::glyph::project::{
    ProjectZoneAssignment, zone_assignments_for_node, zone_assignments_for_path,
};
use crate::glyph::references::ReferenceEvidence;
use crate::glyph::walk::{CodeFile, ExcludedPath, IgnoredFile, is_ignored_dir_name};

const COMMON_UNSUPPORTED_EXTENSIONS: &[&str] = &[
    "go", "java", "kt", "php", "prisma", "rb", "sql", "svelte", "vue",
];

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GlyphDiagnostics {
    pub files_walked: usize,
    pub files_parsed: usize,
    pub files_ignored_by_extension: BTreeMap<String, usize>,
    #[serde(default)]
    pub files_excluded_by_language: BTreeMap<String, usize>,
    #[serde(default)]
    pub files_excluded_by_language_paths: Vec<GlyphFileDiagnostic>,
    #[serde(default)]
    pub files_excluded_by_scope: usize,
    #[serde(default)]
    pub source_scope_excluded_paths: Vec<String>,
    pub files_failed_to_parse: Vec<GlyphFileDiagnostic>,
    pub languages_by_file_count: BTreeMap<String, usize>,
    pub nodes_by_kind: BTreeMap<String, usize>,
    pub edges_by_relation: BTreeMap<String, usize>,
    pub project_zones_by_node: Vec<NodeZoneDiagnostic>,
    pub project_zones_by_file: Vec<FileZoneDiagnostic>,
    pub unsupported_common_extensions_seen: BTreeMap<String, usize>,
    pub source_roots_with_no_parsed_files: Vec<SourceRootDiagnostic>,
    pub duplicate_node_ids: Vec<DuplicateNodeIdDiagnostic>,
    pub spanless_nodes: Vec<SpanlessNodeDiagnostic>,
    pub findings: Vec<GlyphDiagnosticFinding>,
    pub reference_evidence: Vec<ReferenceEvidence>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GlyphFileDiagnostic {
    pub path: String,
    pub language: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct NodeZoneDiagnostic {
    pub node_id: String,
    pub source: Option<String>,
    pub zones: Vec<ProjectZoneAssignment>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FileZoneDiagnostic {
    pub path: String,
    pub zones: Vec<ProjectZoneAssignment>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SourceRootDiagnostic {
    pub path: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DuplicateNodeIdDiagnostic {
    pub node_id: String,
    pub count: usize,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SpanlessNodeDiagnostic {
    pub node_id: String,
    pub source: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GlyphDiagnosticKind {
    ParseError,
    UnsupportedCommonExtension,
    LanguageExcludedByContract,
    SourceRootNoParsedFiles,
    DuplicateNodeId,
    SpanlessNode,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GlyphDiagnosticFinding {
    pub diagnostic_id: String,
    pub kind: GlyphDiagnosticKind,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub path: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub root: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub extension: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub node_id: Option<String>,
    pub message: String,
}

pub(crate) struct DiagnosticsInput<'a> {
    pub root: &'a Path,
    pub supported_files: &'a [(PathBuf, CodeFile)],
    pub ignored_files: &'a [IgnoredFile],
    pub language_excluded_files: &'a [(PathBuf, SourceLanguage)],
    pub excluded_paths: &'a [ExcludedPath],
    pub parse_error_files: &'a [(PathBuf, SourceLanguage)],
    pub graph: &'a Graph,
    pub reference_evidence: Vec<ReferenceEvidence>,
}

pub(crate) fn build_diagnostics(input: DiagnosticsInput<'_>) -> GlyphDiagnostics {
    let ignored_counts = ignored_counts(input.ignored_files);
    let unsupported_common_extensions_seen = COMMON_UNSUPPORTED_EXTENSIONS
        .iter()
        .filter_map(|ext| {
            ignored_counts
                .get(*ext)
                .copied()
                .map(|count| ((*ext).into(), count))
        })
        .collect::<BTreeMap<String, usize>>();
    let files_failed_to_parse = input
        .parse_error_files
        .iter()
        .map(|(path, language)| GlyphFileDiagnostic {
            path: path_display(path),
            language: language.label().into(),
        })
        .collect::<Vec<_>>();
    let mut files_excluded_by_language = BTreeMap::new();
    let files_excluded_by_language_paths = input
        .language_excluded_files
        .iter()
        .map(|(path, language)| {
            *files_excluded_by_language
                .entry(language.label().to_string())
                .or_insert(0) += 1;
            GlyphFileDiagnostic {
                path: path_display(path),
                language: language.label().into(),
            }
        })
        .collect::<Vec<_>>();

    let mut languages_by_file_count = BTreeMap::new();
    for (_, meta) in input.supported_files {
        *languages_by_file_count
            .entry(meta.language.label().to_string())
            .or_insert(0) += 1;
    }

    let project_zones_by_node = input
        .graph
        .nodes
        .iter()
        .filter_map(|node| {
            let zones = zone_assignments_for_node(node, input.root);
            (!zones.is_empty()).then(|| NodeZoneDiagnostic {
                node_id: node.id.clone(),
                source: node.source_files.first().cloned(),
                zones,
            })
        })
        .collect::<Vec<_>>();

    let mut file_zones = BTreeMap::<String, Vec<ProjectZoneAssignment>>::new();
    for (path, _) in input.supported_files {
        let rel = path.strip_prefix(input.root).unwrap_or(path);
        file_zones.insert(path_display(path), zone_assignments_for_path(rel));
    }
    for ignored in input.ignored_files {
        let rel = ignored
            .path
            .strip_prefix(input.root)
            .unwrap_or(&ignored.path);
        file_zones.insert(path_display(&ignored.path), zone_assignments_for_path(rel));
    }
    let project_zones_by_file = file_zones
        .into_iter()
        .filter(|(_, zones)| !zones.is_empty())
        .map(|(path, zones)| FileZoneDiagnostic { path, zones })
        .collect::<Vec<_>>();

    let duplicate_node_ids = duplicate_node_ids(&input.graph.nodes);
    let spanless_nodes = input
        .graph
        .nodes
        .iter()
        .filter(|node| node.span.is_none())
        .map(|node| SpanlessNodeDiagnostic {
            node_id: node.id.clone(),
            source: node.source_files.first().cloned(),
        })
        .collect::<Vec<_>>();
    let source_roots_with_no_parsed_files =
        source_roots_with_no_parsed_files(input.root, input.supported_files, input.excluded_paths);

    let mut findings = Vec::new();
    for file in &files_failed_to_parse {
        findings.push(finding(
            GlyphDiagnosticKind::ParseError,
            Some(file.path.clone()),
            None,
            Some(file.language.clone()),
            None,
            None,
            format!("{} parsed with tree-sitter error nodes", file.path),
        ));
    }
    for (extension, count) in &unsupported_common_extensions_seen {
        let first_path = input
            .ignored_files
            .iter()
            .find(|ignored| ignored.extension == *extension)
            .map(|ignored| path_display(&ignored.path));
        findings.push(finding(
            GlyphDiagnosticKind::UnsupportedCommonExtension,
            first_path,
            None,
            None,
            Some(extension.clone()),
            None,
            format!("{count} unsupported .{extension} file(s) were skipped"),
        ));
    }
    for file in &files_excluded_by_language_paths {
        findings.push(finding(
            GlyphDiagnosticKind::LanguageExcludedByContract,
            Some(file.path.clone()),
            None,
            Some(file.language.clone()),
            None,
            None,
            format!(
                "{} was skipped because EKF languages excludes {}",
                file.path, file.language
            ),
        ));
    }
    for root in &source_roots_with_no_parsed_files {
        findings.push(finding(
            GlyphDiagnosticKind::SourceRootNoParsedFiles,
            None,
            Some(root.path.clone()),
            None,
            None,
            None,
            format!("source directory {} contains no parsed files", root.path),
        ));
    }
    for duplicate in &duplicate_node_ids {
        findings.push(finding(
            GlyphDiagnosticKind::DuplicateNodeId,
            None,
            None,
            None,
            None,
            Some(duplicate.node_id.clone()),
            format!(
                "node id {} appears {} times",
                duplicate.node_id, duplicate.count
            ),
        ));
    }
    for spanless in &spanless_nodes {
        findings.push(finding(
            GlyphDiagnosticKind::SpanlessNode,
            spanless.source.clone(),
            None,
            None,
            None,
            Some(spanless.node_id.clone()),
            format!("node {} has no source span", spanless.node_id),
        ));
    }
    findings.sort_by(|a, b| a.diagnostic_id.cmp(&b.diagnostic_id));

    GlyphDiagnostics {
        files_walked: input.supported_files.len()
            + input.ignored_files.len()
            + input.language_excluded_files.len(),
        files_parsed: input
            .supported_files
            .len()
            .saturating_sub(files_failed_to_parse.len()),
        files_ignored_by_extension: ignored_counts,
        files_excluded_by_language,
        files_excluded_by_language_paths,
        files_excluded_by_scope: input.excluded_paths.len(),
        source_scope_excluded_paths: input
            .excluded_paths
            .iter()
            .map(|excluded| path_display(&excluded.path))
            .collect(),
        files_failed_to_parse,
        languages_by_file_count,
        nodes_by_kind: count_nodes_by_kind(&input.graph.nodes),
        edges_by_relation: count_edges_by_relation(&input.graph.edges),
        project_zones_by_node,
        project_zones_by_file,
        unsupported_common_extensions_seen,
        source_roots_with_no_parsed_files,
        duplicate_node_ids,
        spanless_nodes,
        findings,
        reference_evidence: input.reference_evidence,
    }
}

fn ignored_counts(ignored_files: &[IgnoredFile]) -> BTreeMap<String, usize> {
    let mut counts = BTreeMap::new();
    for ignored in ignored_files {
        *counts.entry(ignored.extension.clone()).or_insert(0) += 1;
    }
    counts
}

fn duplicate_node_ids(nodes: &[Node]) -> Vec<DuplicateNodeIdDiagnostic> {
    let mut counts = BTreeMap::new();
    for node in nodes {
        *counts.entry(node.id.clone()).or_insert(0usize) += 1;
    }
    counts
        .into_iter()
        .filter(|(_, count)| *count > 1)
        .map(|(node_id, count)| DuplicateNodeIdDiagnostic { node_id, count })
        .collect()
}

fn source_roots_with_no_parsed_files(
    root: &Path,
    supported_files: &[(PathBuf, CodeFile)],
    excluded_paths: &[ExcludedPath],
) -> Vec<SourceRootDiagnostic> {
    let mut parsed_dirs = BTreeSet::new();
    for (path, _) in supported_files {
        let rel = path.strip_prefix(root).unwrap_or(path);
        if let Some(first) = rel.components().next() {
            parsed_dirs.insert(first.as_os_str().to_string_lossy().to_string());
        }
    }
    let mut out = Vec::new();
    let Ok(entries) = fs::read_dir(root) else {
        return out;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let name = entry.file_name().to_string_lossy().to_string();
        if is_ignored_dir_name(&name) {
            continue;
        }
        if excluded_paths.iter().any(|excluded| excluded.path == path) {
            continue;
        }
        if !parsed_dirs.contains(&name) {
            out.push(SourceRootDiagnostic {
                path: path_display(&path),
            });
        }
    }
    out.sort_by(|a, b| a.path.cmp(&b.path));
    out
}

fn finding(
    kind: GlyphDiagnosticKind,
    path: Option<String>,
    root: Option<String>,
    language: Option<String>,
    extension: Option<String>,
    node_id: Option<String>,
    message: String,
) -> GlyphDiagnosticFinding {
    let diagnostic_id = diagnostic_id(
        &kind,
        path.as_deref(),
        root.as_deref(),
        language.as_deref(),
        extension.as_deref(),
        node_id.as_deref(),
    );
    GlyphDiagnosticFinding {
        diagnostic_id,
        kind,
        path,
        root,
        language,
        extension,
        node_id,
        message,
    }
}

fn diagnostic_id(
    kind: &GlyphDiagnosticKind,
    path: Option<&str>,
    root: Option<&str>,
    language: Option<&str>,
    extension: Option<&str>,
    node_id: Option<&str>,
) -> String {
    let mut input = String::new();
    input.push_str(&format!("{kind:?}"));
    input.push('\0');
    input.push_str(path.unwrap_or(""));
    input.push('\0');
    input.push_str(root.unwrap_or(""));
    input.push('\0');
    input.push_str(language.unwrap_or(""));
    input.push('\0');
    input.push_str(extension.unwrap_or(""));
    input.push('\0');
    input.push_str(node_id.unwrap_or(""));

    let mut hash = 0xcbf29ce484222325_u64;
    for byte in input.as_bytes() {
        hash ^= u64::from(*byte);
        hash = hash.wrapping_mul(0x100000001b3);
    }
    format!("glyph-{hash:016x}")
}

fn path_display(path: &Path) -> String {
    path.to_string_lossy().into_owned()
}
