mod analysis;
mod cli;
mod error;
mod id;
mod zettel;

use std::collections::{BTreeMap, BTreeSet};
use std::io::{self, Write};
use std::process::ExitCode;

use camino::{Utf8Path, Utf8PathBuf};
use clap::Parser;
use csv::WriterBuilder;
use serde::Serialize;
use walkdir::{DirEntry, WalkDir};

pub use analysis::Analysis;
pub use error::ParseFileNameError;
pub use id::{FolgezettelId, Segment};
pub use zettel::Zettel;

use crate::cli::{Cli, Relationship};
use crate::error::{CliError, DiagnosticSeverity, Result};

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ListRecord {
    pub id: String,
    pub title: String,
    pub file: String,
}

impl From<&Zettel> for ListRecord {
    fn from(note: &Zettel) -> Self {
        Self {
            id: (&note.id).into(),
            title: note.title.to_owned(),
            file: note.file.to_owned(),
        }
    }
}

pub fn analyze_cwd() -> Result<Analysis> {
    Ok(Analysis::from_files(collect_files()?))
}

fn collect_files() -> Result<Vec<Utf8PathBuf>> {
    let root = Utf8Path::new(".");
    let mut files = Vec::new();

    for entry in WalkDir::new(root).into_iter().filter_entry(is_visible) {
        let entry = entry?;
        if !entry.file_type().is_file() {
            continue;
        }

        let path = Utf8Path::from_path(entry.path())
            .ok_or_else(|| CliError::NonUtf8Path(entry.path().display().to_string()))?;

        files.push(path.strip_prefix(root).unwrap_or(path).to_owned());
    }

    files.sort();

    Ok(files)
}

fn is_visible(entry: &DirEntry) -> bool {
    entry.depth() == 0
        || entry
            .file_name()
            .to_str()
            .is_some_and(|name| !name.starts_with('.'))
}

fn main() -> ExitCode {
    match run(Cli::parse()) {
        Ok(code) => code,
        Err(error) => {
            eprintln!("error: {error}");
            ExitCode::FAILURE
        }
    }
}

fn run(cli: Cli) -> Result<ExitCode> {
    let target = cli.id.as_deref().map(FolgezettelId::parse).transpose()?;
    let analysis = analyze_cwd()?;

    let matched: Vec<&Zettel> = match &target {
        Some(target) => relatives(
            &analysis,
            target,
            cli.relationship.unwrap_or(Relationship::Chain),
        ),
        None => analysis.notes().iter().collect(),
    };

    if cli.tree {
        render_tree(&mut io::stdout().lock(), &matched)?;
    } else {
        let records: Vec<ListRecord> = matched.iter().copied().map(ListRecord::from).collect();
        write_records(&records)?;
    }

    print_diagnostics(&analysis, cli.verbose);
    Ok(exit_code(&analysis))
}

/// The notes related to `target` by `relationship`, kept in the analysis's
/// sorted order. A relative absent from the directory is simply omitted.
fn relatives<'a>(
    analysis: &'a Analysis,
    target: &FolgezettelId,
    relationship: Relationship,
) -> Vec<&'a Zettel> {
    let notes = analysis.notes().iter();

    match relationship {
        Relationship::Parents => {
            let ancestors = ancestors(target);
            notes.filter(|note| ancestors.contains(&note.id)).collect()
        }
        Relationship::Chain => {
            let mut lineage = ancestors(target);
            lineage.insert(target.clone());
            notes.filter(|note| lineage.contains(&note.id)).collect()
        }
        Relationship::Siblings => {
            let parent = target.parent();
            notes.filter(|note| note.id.parent() == parent).collect()
        }
        Relationship::Children => notes
            .filter(|note| note.id.parent().as_ref() == Some(target))
            .collect(),
    }
}

/// The ancestor ids of `target`, from the root down to its immediate parent.
fn ancestors(target: &FolgezettelId) -> BTreeSet<FolgezettelId> {
    let mut chain = BTreeSet::new();
    let mut current = target.parent();

    while let Some(id) = current {
        current = id.parent();
        chain.insert(id);
    }

    chain
}

/// Renders `notes` as an ASCII forest. A note whose immediate parent is not in
/// the batch anchors its own tree (true roots and orphans alike).
fn render_tree(out: &mut impl Write, notes: &[&Zettel]) -> Result<()> {
    let present: BTreeSet<&FolgezettelId> = notes.iter().map(|note| &note.id).collect();

    let mut children: BTreeMap<FolgezettelId, Vec<&Zettel>> = BTreeMap::new();
    let mut roots: Vec<&Zettel> = Vec::new();

    // Notes are already sorted structurally, so siblings stay in order.
    for &note in notes {
        match note.id.parent() {
            Some(parent) if present.contains(&parent) => {
                children.entry(parent).or_default().push(note);
            }
            _ => roots.push(note),
        }
    }

    for root in roots {
        writeln!(out, "{root}")?;
        write_subtree(out, &children, root, "")?;
    }

    Ok(())
}

fn write_subtree(
    out: &mut impl Write,
    children: &BTreeMap<FolgezettelId, Vec<&Zettel>>,
    parent: &Zettel,
    prefix: &str,
) -> Result<()> {
    let Some(kids) = children.get(&parent.id) else {
        return Ok(());
    };

    for (index, &child) in kids.iter().enumerate() {
        let last = index + 1 == kids.len();
        let (connector, extension) = if last {
            ("└── ", "    ")
        } else {
            ("├── ", "│   ")
        };

        writeln!(out, "{prefix}{connector}{child}")?;
        write_subtree(out, children, child, &format!("{prefix}{extension}"))?;
    }

    Ok(())
}

/// Writes records to stdout as TSV with no header row.
pub fn write_records<T: Serialize>(records: &[T]) -> Result<()> {
    let mut writer = WriterBuilder::new()
        .delimiter(b'\t')
        .has_headers(false)
        .from_writer(io::stdout().lock());

    for record in records {
        writer.serialize(record)?;
    }

    writer.flush()?;
    Ok(())
}

/// Prints diagnostics to stderr — every severity when `verbose`, otherwise only
/// errors — so stdout stays clean for piping.
pub fn print_diagnostics(analysis: &Analysis, verbose: bool) {
    for diagnostic in analysis.diagnostics() {
        if verbose || diagnostic.severity() == DiagnosticSeverity::Error {
            eprintln!("{}: {diagnostic}", diagnostic.severity());
        }
    }
}

pub fn exit_code(analysis: &Analysis) -> ExitCode {
    if analysis.has_errors() {
        ExitCode::FAILURE
    } else {
        ExitCode::SUCCESS
    }
}
