// Copyright (C) 2022-2023 Enrico Guiraud
//
// This file is part of Kobold, a static site generator with a friendly CLI.
//
// Kobold is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Kobold is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kobold. If not, see <http://www.gnu.org/licenses/>.

use super::utils;
use crate::log;
use linked_hash_map::LinkedHashMap;
use log::Lvl;
use pulldown_cmark as md;
use serde::Serialize;
use std::borrow::Cow;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::str;
use std::vec::Vec;
use tera::Tera;
use yaml_rust::{yaml::Yaml, YamlLoader};

type Result<T> = std::result::Result<T, Cow<'static, str>>;

struct FrontMatter {
    title: Option<String>,
    layout: Option<String>,
    tags: Vec<String>,
    data: LinkedHashMap<Yaml, Yaml>,
}

/// A Markdown page.
/// It's transformed into an HTMLPage with md_to_html().
struct Page {
    title: String,
    layout: String,
    tags: Vec<String>,
    /// Path of the page relative to the root of the kobold site directory.
    path: PathBuf,
    /// Extra metadata coming from the frontmatter.
    data: LinkedHashMap<Yaml, Yaml>,
    /// Markdown/HTML body with page layout unapplied. Macros will be expanded in-place.
    body: PageBody,
}

enum PageBody {
    MarkdownWithMacros(String),
    Markdown(String),
    Html(String),
}

enum SiteItem {
    MDPage(Page),
    OtherItem { path: PathBuf },
}

/// Given the path `dir` to a directory with the structure of a valid Kobold site, create
/// sub-directory `dir/site` with the generated Kobold site contents.
///
/// If `dir/site` already exists, it is backed up to `dir/site.old` before proceeding.
pub fn build(project_dir: &Path, _do_not_build_tags: bool, _build_search_bar: bool) -> Result<()> {
    assert_valid_kobold_project(project_dir)?;

    let site_dir = project_dir.join("site");

    log::log(
        Lvl::Info,
        &format!("Generating site at '{}'...", site_dir.display()),
    );

    // this tempdir will automatically be removed when we leave this scope.
    // we use it as a staging area for the generated content, so in case of
    // error rolling back the build is a no-op.
    let scratch_dir = tempfile::Builder::new()
        .prefix("site.tmp")
        .tempdir_in(project_dir)
        .map_err(|msg| format!("Could not create temporary working directory for your Kobold site:\n\t{}\n\tMake sure you have write permissions to '{}' (and the disk is not full).",
                               msg,
                               project_dir.display()))?;

    let tmp_site_dir = scratch_dir.path().join("site");
    fs::create_dir(&tmp_site_dir)
        .map_err(|msg| format!("Could not create temporary site directory '{}':\n\t{}\n\tMake sure you have write permissions to '{}'.",
                               tmp_site_dir.display(), msg, scratch_dir.path().display()))?;

    generate_site(project_dir, &tmp_site_dir)?;
    copy_styles(project_dir, &tmp_site_dir)?;
    backup_prev_build(&site_dir, scratch_dir.path())?;

    std::fs::rename(&tmp_site_dir, &site_dir)
        .map_err(|msg| format!("Could not move generated site directory from workspace '{}' to '{}':{}\n\tMake sure you have permission to rename that directory.",
                               tmp_site_dir.display(), site_dir.display(), msg))?;

    log::log(Lvl::Info, "Site generation completed successfully.");
    Ok(())
}

fn assert_valid_kobold_project(project_dir: &Path) -> Result<()> {
    if !project_dir.exists() {
        Err(format!(
            "Directory '{}' does not exist.",
            project_dir.display()
        ))?;
    }

    if !project_dir.is_dir() {
        Err(format!(
            "Path '{}' does not correspond to a directory.",
            project_dir.display()
        ))?;
    }

    // a valid kobold project must have the `content` sub-directory
    let mut contents = project_dir.read_dir()
        .map_err(|msg| format!("Could not read contents of directory '{}':\n\t{}\n\tMake sure you have read permission for that directory.", project_dir.display(), msg))?;
    let maybe = contents.find(|item| match item {
        Ok(x) => x.file_name() == "content" && x.file_type().unwrap().is_dir(),
        Err(_) => false,
    });

    if maybe.is_none() {
        Err(format!(
            "Project directory '{}' does not contain a sub-directory 'content', which is mandatory.",
            project_dir.display()
        ))?;
    }

    Ok(())
}

fn generate_site(project_dir: &Path, site_dir: &Path) -> Result<()> {
    let content_dir = project_dir.join("content");
    let contents = collect_contents(&content_dir, &content_dir)?;
    let mut layouts = load_layouts(&project_dir.join("layouts"))?;
    let mut context = pages_context(&contents);

    let macros_path =
        make_tera_macros_file(&project_dir.join("macros"), site_dir.parent().unwrap())?;
    layouts
        .add_template_file(&macros_path, None)
        .map_err(|msg| format!("Tera could not load macros:\n\t{}", msg))?;

    contents
        .into_iter()
        .map(|item| -> Result<()> {
            match item {
                SiteItem::MDPage(page) => {
                    let html_page = md_to_html(page, &mut layouts, &mut context, &macros_path);

                    match html_page {
                        Ok(page) => {
                            let html_page_path = site_dir.join(page.path.with_extension("html"));
                            let output_dir = html_page_path.parent().unwrap();

                            utils::create_dir(output_dir)?;
                            let PageBody::Html(body) = page.body else { panic!("Unreachable"); };
                            fs::write(html_page_path, body).map_err(|msg| {
                                Cow::from(format!(
                                    "Something went wrong when writing HTML output:\n\t{}",
                                    msg
                                ))
                            })
                        }
                        Err(msg) => Err(msg),
                    }
                }
                SiteItem::OtherItem { path } => {
                    let output_dir = site_dir.join(path.parent().unwrap());
                    utils::create_dir(&output_dir)?;
                    std::fs::copy(
                        project_dir.join("content").join(&path),
                        site_dir.join(&path),
                    )
                    .map_err(|msg| {
                        Cow::from(format!(
                            "Could not copy file '{}' to site:\n\t{}",
                            path.display(),
                            msg
                        ))
                    })?;
                    Ok(())
                }
            }
        })
        .collect::<Result<Vec<()>>>()?;

    Ok(())
}

fn copy_styles(project_dir: &Path, site_dir: &Path) -> Result<()> {
    let styles_dir = site_dir.join("css");
    utils::create_dir(&styles_dir)?;
    utils::copy_dir_contents(&project_dir.join("css"), &site_dir.join("css"))?;

    Ok(())
}

/// Move site_dir/ to site_dir.old/, first moving existing site_dir.old/ to scratch_dir/site_dir.old if needed.
fn backup_prev_build(site_dir: &Path, scratch_dir: &Path) -> Result<()> {
    let mut bak_file_name = site_dir.file_name().unwrap().to_os_string();
    bak_file_name.push(".old");
    let bak_dir = site_dir.with_file_name(bak_file_name);
    let new_bak_dir = scratch_dir.join(bak_dir.file_name().unwrap());

    if bak_dir.exists() {
        std::fs::rename(&bak_dir, &new_bak_dir)
            .map_err(|msg| format!("Could not remove old back-up directory '{}':\n\t{}\n\tMake sure you have permission to remove that directory.",
                                   bak_dir.display(),
                                   msg))?;
    }

    if site_dir.exists() {
        return match std::fs::rename(site_dir, &bak_dir) {
            Ok(()) => {
                log::log(
                    Lvl::Info,
                    &format!("Previous build backed up to '{}'.", bak_dir.display()),
                );
                Ok(())
            }
            Err(msg) => {
                let mut error_msg = format!("Could not back up old site directory '{}' to '{}':\n\t{}\n\tMake sure you have permission to rename that directory.", site_dir.display(), bak_dir.display(), msg);

                // try to at least roll back the removal of site_dir.old
                let rollback_res = std::fs::rename(&new_bak_dir, &bak_dir);
                if let Err(inner_msg) = rollback_res {
                    error_msg += &format!(" The old backup directory '{}' could not be restored:\n\t{}\n\tHowever, the last successful site build was preserved at '{}'.", bak_dir.display(), inner_msg, site_dir.display());
                }

                Err(error_msg)?
            }
        };
    }

    Ok(())
}

fn load_layouts(templates_dir: &Path) -> Result<Tera> {
    let templates_blob = std::fs::canonicalize(templates_dir)
        .expect("Could not canonicalize templates directory!")
        .to_str()
        .expect("The templates dir cannot be represented as a String!")
        .to_string()
        + "/**/*.html";
    let mut templates = Tera::new(&templates_blob).map_err(|msg| {
        format!(
            "One or more errors were encountered while parsing templates in directory '{}':\n\t{}",
            templates_dir.display(),
            msg
        )
    })?;
    templates.autoescape_on(vec![]);

    Ok(templates)
}

/// Convert the body of a Page from MarkDownWithMacros to Html.
fn md_to_html(
    page: Page,
    tera: &mut Tera,
    context: &mut tera::Context,
    macros_path: &Path,
) -> Result<Page> {
    context.insert("title", &page.title);
    context.insert("path", &page.path);
    context.insert("tags", &page.tags);
    context.insert("data", &serializable_yaml::from_map_ref(&page.data));

    // evaluate macros
    let body = eval_macros(page.body, tera, context, macros_path)?;
    let PageBody::Markdown(body) = body else { panic!("Unreachable"); };

    // convert markdown to HTML
    let events = md::Parser::new_ext(&body, md::Options::all());
    let events = highlight_pulldown::highlight(events).map_err(|msg| {
        format!(
            "Could not apply syntax highlighting to page '{}':\n\t{}",
            page.path.display(),
            msg
        )
    })?;
    let mut html_body = String::new();
    md::html::push_html(&mut html_body, events.into_iter());

    context.insert("body", &html_body);

    // inject HTML into page layout (i.e. a Tera template)
    let template_name = page.layout.clone() + ".html";
    let html_body = tera.render(&template_name, context).map_err(|msg| {
        format!(
            "Could not apply layout '{}' to page '{}':\n\t{}",
            page.layout,
            page.path.display(),
            msg
        )
    })?;

    Ok(Page {
        body: PageBody::Html(html_body),
        ..page
    })
}

/// Expand all Tera macro invocations present in the body of a page.
fn eval_macros(
    body: PageBody,
    tera: &mut Tera,
    context: &mut tera::Context,
    macros_path: &Path,
) -> Result<PageBody> {
    let PageBody::MarkdownWithMacros(mut body) = body else { panic!("Should never happen.") };
    let mut invocation_start = body.find("{{ macros::");
    while invocation_start.is_some() {
        let start = invocation_start.unwrap();
        let end = body[start..].find("}}");
        if end.is_none() {
            return Err(Cow::from("Macro invocation opened and never closed."));
        }
        let end = end.unwrap() + start + 2;
        let invocation = &body[start..end];
        let template = format!(
            "{{% import \"{}\" as macros %}}\n{}",
            macros_path.display(),
            invocation
        );

        let expanded_macro = tera.render_str(&template, context);

        if let Err(ref msg) = expanded_macro {
            let macro_name = &invocation[3..invocation.len() - 3];
            let macro_path = context.get("title").unwrap().as_str().unwrap();
            let mut msg =
                format!("Could not evaluate macro invocation '{}' when processing content page '{}'. The Tera error was:\n\t{}",
                    macro_name,
                    macro_path,
                    msg,
                );
            let has_equals = macro_name.find('=').is_some(); // as a proxy for whether keyword args are being used
            if !has_equals {
                msg.push_str("\n\tMake sure you are using keyword args in the invocation (`macro(arg=value)` rather than `macro(value)`).");
            }
            Err(msg)?;
        }
        let expanded_macro = expanded_macro.unwrap();

        body = body.replace(invocation, &expanded_macro);
        let new_start = start + expanded_macro.len();
        invocation_start = body[new_start..].find("{{ macros::");
    }

    Ok(PageBody::Markdown(body))
}

/// Take the contents of an input file coming from a kobold site directory, make a MDPage.
fn parse_page(file_contents: &str, path: &Path) -> Result<Page> {
    let mut front_matter_yaml = String::new();
    let mut body = String::new(); // everything that comes after the front matter

    enum ParseState {
        Start,
        FrontMatter,
        Body,
    }

    let mut state = ParseState::Start;
    // TODO we could avoid copying all file contents into `rest` little by little, we could just slice file_contents
    for line in file_contents.lines() {
        match state {
            ParseState::Start => {
                if line.trim() == "---" {
                    state = ParseState::FrontMatter;
                } else if line.find(|c: char| !c.is_whitespace()).is_some() {
                    // just an empty line: keep looking for the start of the front matter
                    continue;
                } else {
                    // a non-empty line that was not "---" was encountered: there is no front matter
                    state = ParseState::Body;
                    body += line;
                    body.push('\n');
                }
            }
            ParseState::FrontMatter => {
                if line.trim() == "---" {
                    state = ParseState::Body;
                } else {
                    front_matter_yaml += line;
                    front_matter_yaml.push('\n');
                }
            }
            ParseState::Body => {
                body += line;
                body.push('\n');
            }
        }
    }

    if let ParseState::FrontMatter = state {
        Err("The front matter section was opened but never closed (unmatched `---`).")?;
    }

    let front_matter = parse_front_matter(&front_matter_yaml)?;
    let page = Page {
        title: front_matter
            .title
            .unwrap_or_else(|| page_title_from_path(path)),
        layout: front_matter.layout.unwrap_or("default".to_owned()),
        tags: front_matter.tags,
        path: path.to_owned(),
        data: front_matter.data,
        body: PageBody::MarkdownWithMacros(body),
    };
    Ok(page)
}

fn parse_front_matter(front_matter_str: &str) -> Result<FrontMatter> {
    let mut front_matter_yaml =
        YamlLoader::load_from_str(front_matter_str).map_err(|scan_error| scan_error.to_string())?;
    assert_eq!(front_matter_yaml.len(), 1); // TODO this and the next assert should not just be there in debug mode
    let front_matter_yaml = front_matter_yaml.pop().unwrap();
    assert!(matches!(front_matter_yaml, Yaml::Hash(_)));
    let Yaml::Hash(mut hash_map) = front_matter_yaml else {
        return Err(Cow::from("The front matter does not seem to have the right format. Make sure it is a simple YAML mapping (a sequence of key-value pairs)."));
    };

    let title = hash_map.remove(&Yaml::String("title".to_string()));
    let title = match title {
        Some(Yaml::String(title)) => Some(title),
        Some(_) => Err("The `title` entry in the frontmatter does not look like a string.")?,
        None => None,
    };

    let layout = hash_map.remove(&Yaml::String("layout".to_string()));
    let layout = match layout {
        Some(Yaml::String(layout)) => Some(layout),
        Some(_) => Err("The `layout` entry in the front matter does not look like a string.")?,
        None => None,
    };

    let tags = hash_map
        .remove(&Yaml::String("tags".to_string()))
        .map_or_else(
            || Ok(Vec::new()),
            |tags| match tags {
                Yaml::Array(t) => Ok(t),
                _ => Err("The `tags` entry in the front matter does not seem to be a YAML array."),
            },
        )
        .and_then(|tags| {
            tags.into_iter()
                .map(|t| match t {
                    Yaml::String(s) => Ok(s),
                    _ => Err(""),
                })
                .collect::<std::result::Result<Vec<String>, &str>>()
        })?;

    Ok(FrontMatter {
        title,
        layout,
        tags,
        data: hash_map,
    })
}

/// Recursively explore site_dir and return a flat collection of SiteItem.
fn collect_contents(parent_content_dir: &Path, current_dir: &Path) -> Result<Vec<SiteItem>> {
    let mut items = Vec::new();

    let contents = current_dir.read_dir().map_err(|msg| {
        format!(
            "Could not read contents of directory '{}':\n\t{}",
            current_dir.display(),
            msg
        )
    })?;

    for entry in contents {
        match entry {
            Ok(dir) if dir.file_type().map(|d| d.is_dir()).unwrap_or(false) => {
                let dir_path = dir.path();
                let subdir = dir_path.file_name().unwrap();
                items.extend(collect_contents(
                    parent_content_dir,
                    current_dir.join(subdir).as_path(),
                )?);
            }
            Ok(file) if file.path().extension() == Some("md".as_ref()) => {
                let file_contents = fs::read_to_string(file.path()).map_err(|msg| {
                    format!(
                        "Could not read file '{}':\n\t{}",
                        file.path().display(),
                        msg
                    )
                })?;
                let path = file
                    .path()
                    .strip_prefix(parent_content_dir)
                    .unwrap()
                    .to_owned();
                let page = parse_page(&file_contents, &path)?;
                items.push(SiteItem::MDPage(page));
            }
            Ok(file) => items.push(SiteItem::OtherItem {
                path: file
                    .path()
                    .strip_prefix(parent_content_dir)
                    .unwrap()
                    .to_path_buf(),
            }),
            Err(msg) => {
                Err(format!(
                    "Could not read contents of directory '{}':\n\t{}",
                    current_dir.display(),
                    msg
                ))?;
            }
        }
    }

    Ok(items)
}

fn page_title_from_path(path: &Path) -> String {
    path.file_stem()
        .unwrap()
        .to_str()
        .unwrap_or_else(|| {
            panic!(
                "The path to page '{}' cannot be represented as a string?!",
                path.display(),
            );
        })
        .to_owned()
}

/// Take all files under `<project>/macros` and concatenate them into a temporary file.
/// In order to evaluate macros in Tera we'll {% import that_file as macros %}.
fn make_tera_macros_file(macro_dir: &Path, out_dir: &Path) -> Result<PathBuf> {
    let mut all_macros = String::new();

    let contents = macro_dir.read_dir().map_err(|msg| {
        format!(
            "Could not read contents of directory '{}':\n\t{}",
            macro_dir.display(),
            msg
        )
    })?;

    for entry in contents {
        match entry {
            Ok(dir) if dir.file_type().map(|ft| ft.is_dir()).unwrap_or(false) => {
                Err(format!(
                    "Found a sub-directory in macros directory {}. This is not supported.",
                    macro_dir.display()
                ))?;
            }
            Ok(file) => {
                let contents = fs::read(file.path()).map_err(|msg| {
                    format!(
                        "Could not read the contents of macro '{}':\n\t{}",
                        file.path().display(),
                        msg
                    )
                })?;
                let contents = String::from_utf8_lossy(&contents);
                all_macros.push_str(&contents);
            }
            Err(msg) => {
                Err(format!(
                    "Could not read contents of directory '{}':\n\t{}",
                    macro_dir.display(),
                    msg
                ))?;
            }
        }
    }

    let macros_file_path = out_dir.join("kobold_macros.html");
    fs::write(&macros_file_path, all_macros).map_err(|msg| {
        format!(
            "Could not write out macros file '{}':\n\t{}",
            macros_file_path.display(),
            msg
        )
    })?;

    Ok(macros_file_path)
}

fn pages_context<'a>(contents: &'a [SiteItem]) -> tera::Context {
    #[derive(Serialize)]
    struct PageData<'b> {
        path: PathBuf,
        tags: &'b [String],
        layout: &'b str,
        data: LinkedHashMap<String, serializable_yaml::Yaml>,
    }

    let mut pages_data: HashMap<&'a str, PageData> = HashMap::new();

    contents
        .iter()
        .filter_map(|item| {
            if let SiteItem::MDPage(p) = item {
                Some(p)
            } else {
                None
            }
        })
        .for_each(|p| {
            let mut path = p.path.clone();
            path.set_extension("html");
            let data = PageData {
                path,
                tags: &p.tags,
                layout: &p.layout,
                data: serializable_yaml::from_map_ref(&p.data),
            };
            pages_data.insert(&p.title, data);
        });

    let mut context = tera::Context::new();
    context.insert("pages", &pages_data);

    context
}
