mod error;

use std::{
    borrow::Cow,
    collections::{HashMap, HashSet, VecDeque},
    fs,
    path::{Path, PathBuf},
    time::Instant,
};

use crate::error::{Error, ErrorKind};

#[derive(Debug)]
enum FileKind {
    Static,
    Minifiable,
    Templated(String),
}

const PUBLIC_PATH: &str = "public/";
const DIST_PATH: &str = "dist/";
const TEMPLATES_PATH: &str = "templates/";

fn main() -> Result<(), Error> {
    let start = Instant::now();

    if !fs::exists(PUBLIC_PATH).unwrap_or(false) {
        return Err(Error::NoPublic);
    }

    // Remove previous build.
    let dist_path = Path::new(DIST_PATH);
    if dist_path.is_dir() {
        fs::remove_dir_all(dist_path)
            .map_err(ErrorKind::RemoveDir)
            .map_err(Error::Unspecified)?;
    }
    fs::create_dir(dist_path)
        .map_err(ErrorKind::CreateDir)
        .map_err(Error::Unspecified)?;

    // Process templates.
    let template_map = process_templates().map_err(Error::Templates)?;

    // Build the website.
    process_public(template_map).map_err(Error::Public)?;

    println!("built in {:.1?}", start.elapsed());

    Ok(())
}

/// Compile the website stored in `public/`, takes prebuilt templates as argument.
fn process_public(templates: HashMap<String, String>) -> Result<(), ErrorKind> {
    let dist_path = Path::new(DIST_PATH);

    // Graph traversal approach.
    let mut stack = vec![PathBuf::from(PUBLIC_PATH)];
    while let Some(dir) = stack.pop() {
        for entry in fs::read_dir(dir).map_err(ErrorKind::ReadDir)? {
            let entry = entry.map_err(ErrorKind::ReadDir)?;
            let src = entry.path();
            let dest = dist_path.join(
                src.strip_prefix(PUBLIC_PATH)
                    .map_err(ErrorKind::StripPrefix)?,
            );

            if src.is_dir() {
                stack.push(src);
                fs::create_dir(dest).map_err(ErrorKind::CreateDir)?;
            } else {
                process_file(src, dest, &templates)?;
            }
        }
    }

    Ok(())
}

/// Process templates, it involves minifying all templates and compiling templated templates.
fn process_templates() -> Result<HashMap<String, String>, ErrorKind> {
    let mut template_map = HashMap::<String, String>::new();

    // Does not fail if there are no templates, instead returns an empty template map.
    if !fs::exists(TEMPLATES_PATH).unwrap_or(false) {
        return Ok(template_map);
    }

    // Collect all templates and their content.
    for entry in fs::read_dir(TEMPLATES_PATH).map_err(ErrorKind::ReadDir)? {
        let entry = entry.map_err(ErrorKind::ReadDir)?;
        let src = entry.path();

        let stem = src
            .file_stem()
            .and_then(|s| s.to_str())
            .ok_or(ErrorKind::PathManip)?
            .to_string();

        let content = minify(fs::read_to_string(src).map_err(ErrorKind::ReadFile)?);

        template_map.insert(stem, content);
    }

    // Compile templated templates, this is not very optimized, it just iterates over unprocessed
    // templates until everything is compiled. There is no circular dependency detection.
    let mut stack: VecDeque<String> = template_map
        .keys()
        .filter(|n| n.contains('.')) // only keep templated templates
        .cloned()
        .collect();
    let mut processed = HashSet::new();
    while let Some(current) = stack.pop_front() {
        if processed.contains(&current) {
            continue;
        }

        if let Some((name, template)) = current.rsplit_once('.') {
            if let Some(template_content) = template_map.get(template) {
                let content = compile_templated(&template_map[&current], template_content);
                template_map.remove(&current);
                template_map.insert(name.to_string(), content);
                processed.insert(current.to_string());
            } else {
                stack.push_back(current);
            }
        }
    }

    Ok(template_map)
}

/// Process a file in the `public/` directory.
///
/// If the file is templated, compile it.
/// If the file is minifiable, minify it.
/// Otherwise, just copy the file to its corresponding location in `dist/`.
fn process_file(
    src: PathBuf,
    mut dest: PathBuf,
    templates: &HashMap<String, String>,
) -> Result<(), ErrorKind> {
    let mut kind = FileKind::Static;

    let ext = src.extension().and_then(|e| e.to_str());

    if matches!(ext, Some("html") | Some("css")) {
        kind = FileKind::Minifiable;
    }

    // Check if the file is a templated html file.
    if let Some("html") = ext {
        let stem = src
            .file_stem()
            .and_then(|s| s.to_str())
            .ok_or(ErrorKind::PathManip)?;

        // Do some renaming (removing the template name).
        if let Some((name, template_name)) = stem.rsplit_once('.') {
            dest = dest.with_file_name([name, ".html"].concat());
            kind = FileKind::Templated(template_name.to_string());
        }
    }

    if let FileKind::Static = kind {
        // Copy directly if the file is neither minifiable nor templated.
        fs::copy(src, dest).map_err(ErrorKind::CopyFile)?;
    } else {
        let mut content_buf = fs::read_to_string(src).map_err(ErrorKind::ReadFile)?;

        // Minify before operations to save time (hopefully).
        content_buf = minify(content_buf);

        // Compile templated (if templated).
        if let FileKind::Templated(template_name) = kind {
            content_buf = compile_templated(&content_buf, &templates[&template_name]);
        }

        fs::write(dest, content_buf).map_err(ErrorKind::WriteFile)?;
    }

    Ok(())
}

/// Compile a templated file.
fn compile_templated(content: &str, template_content: &str) -> String {
    let mut blocks = HashMap::with_capacity(10);

    // parse def blocks
    for block in content.split("<!---def:").skip(1) {
        if let Some((label, content)) = block.split_once("--->") {
            blocks.insert(label, Cow::Borrowed(content));
        }
    }

    // parse pipe blocks
    for block in content.split("<!---pipe:").skip(1) {
        if let Some((label, _)) = block.split_once("--->") {
            blocks.insert(label, Cow::Owned(format!("<!---put:{}--->", label)));
        }
    }

    let mut compiled_content = String::new();

    // replace put blocks with associated content from def
    // and pipe blocks
    let mut iter = template_content.split("<!---put:");
    if let Some(first) = iter.next() {
        compiled_content.push_str(first);
    }
    for part in iter {
        if let Some((label, rest)) = part.split_once("--->") {
            if let Some(content) = blocks.get(label) {
                compiled_content.push_str(content);
            }
            compiled_content.push_str(rest);
        } else {
            compiled_content.push_str(part);
        }
    }

    compiled_content
}

/// Minify a string, it consists of replacing all whitespaces (space, tabs, line breaks)
/// with a single space character (" "). Should work relatively fine with html and css.
fn minify(content: String) -> String {
    content
        .split_whitespace()
        .fold(String::with_capacity(content.len()), |mut acc, s| {
            if !acc.is_empty() {
                acc.push(' ');
            }
            acc.push_str(s);
            acc
        })
}
