use std::collections::BTreeSet;
use std::path::Path;

use crate::{
    FolgezettelId, Zettel,
    error::{Diagnostic, DiagnosticSeverity},
};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Analysis {
    notes: Vec<Zettel>,
    diagnostics: Vec<Diagnostic>,
}

impl Analysis {
    pub fn from_files<I, S>(files: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: AsRef<str>,
    {
        let mut notes = Vec::new();
        let mut diagnostics = Vec::new();

        for file in files {
            let file = file.as_ref();

            match Zettel::parse(file) {
                Ok(note) => notes.push(note),
                // A stem that starts with a digit is a malformed zettel (error);
                // anything else is simply not a zettel file (info).
                Err(error) if stem_starts_with_digit(file) => {
                    diagnostics.push(Diagnostic::InvalidFileName {
                        file: file.to_owned(),
                        error,
                    })
                }
                Err(error) => diagnostics.push(Diagnostic::NonZettelFile {
                    file: file.to_owned(),
                    error,
                }),
            }
        }
        notes.sort_by(|left, right| left.id.cmp(&right.id));
        diagnostics.extend(duplicate_id_diagnostics(&notes));
        diagnostics.extend(missing_parent_diagnostics(&notes));

        Self { notes, diagnostics }
    }

    pub fn notes(&self) -> &[Zettel] {
        &self.notes
    }

    pub fn diagnostics(&self) -> &[Diagnostic] {
        &self.diagnostics
    }

    pub fn has_errors(&self) -> bool {
        self.diagnostics
            .iter()
            .any(|diagnostic| diagnostic.severity() == DiagnosticSeverity::Error)
    }
}

/// Whether a file name's stem begins with an ASCII digit — the signal that an
/// unparseable name was *meant* to be a zettel (error) rather than an ordinary
/// non-zettel file like `index.md` (info).
fn stem_starts_with_digit(file: &str) -> bool {
    Path::new(file)
        .file_stem()
        .and_then(|stem| stem.to_str())
        .and_then(|stem| stem.chars().next())
        .is_some_and(|first| first.is_ascii_digit())
}

fn duplicate_id_diagnostics(notes: &[Zettel]) -> impl Iterator<Item = Diagnostic> {
    notes
        .chunk_by(|left, right| left.id == right.id)
        .filter(|group| group.len() > 1)
        .map(|group| Diagnostic::DuplicateId {
            id: group[0].id.clone(),
            files: group.iter().map(|note| note.file.to_owned()).collect(),
        })
}

fn missing_parent_diagnostics(notes: &[Zettel]) -> impl Iterator<Item = Diagnostic> {
    let present_ids: BTreeSet<&FolgezettelId> = notes.iter().map(|zettel| &zettel.id).collect();
    notes.iter().filter_map(move |note| {
        let parent = note.id.parent()?;
        if present_ids.contains(&parent) {
            return None;
        }
        Some(Diagnostic::MissingParent {
            id: note.id.clone(),
            parent,
            file: note.file.clone(),
        })
    })
}
