#![feature(crate_visibility_modifier)]
#![feature(proc_macro_hygiene)]

use comrak::{self, Arena, ComrakOptions};
use comrak::nodes::{AstNode, NodeCodeBlock, NodeHeading, NodeHtmlBlock, NodeLink, NodeValue};
use serde_json;
use std::error::Error;
use std::env;
use std::fs::{self, File};
use std::io::{self, BufReader};
use std::mem;
use std::path::Path;
use std::string::FromUtf8Error;
use syntect::parsing::SyntaxSet;
use syntect::highlighting::{Color, ThemeSet};
use syntect::html::highlighted_html_for_string;

mod string_writer;
mod views;

fn main() -> Result<(), Box<dyn Error>> {
    let args = env::args().collect::<Vec<_>>();
    if args.len() >= 3 && &args[1] == "build-nav" && args[3..].iter().all(|arg| arg.contains(":")) {
        let entries = args[3..].iter().map(|arg| {
            let mut splits = arg.splitn(2, ":");
            let slug = splits.next().unwrap();
            let input_path = splits.next().unwrap();
            (slug, input_path)
        }).collect::<Vec<_>>();
        build_nav(&entries, &args[2])
    } else if args.len() == 6 && &args[1] == "build-page" {
        build_page(&args[2], &args[3], &args[4], &args[5])
    } else {
        Err("invalid arguments".into())
    }
}

fn build_nav(entries: &[(&str, &str)], nav_path: &str) -> Result<(), Box<dyn Error>> {
    let arena = Arena::new();
    let options = comrak_options();

    let nav = entries.iter().map(|&(slug, input_path)| {
        let title = load_page_title(&arena, &options, input_path)?;
        Ok((slug, title))
    }).collect::<io::Result<Vec<_>>>()?;

    // Only write if different to avoid spurious rebuilds
    let old_string = fs::read_to_string(nav_path).unwrap_or(String::new());
    let new_string = serde_json::to_string_pretty(&nav)?;
    if old_string != new_string {
        fs::create_dir_all(Path::new(nav_path).parent().unwrap())?;
        fs::write(nav_path, new_string)?;
    }

    Ok(())
}

fn build_page(
    output_path: &str,
    slug: &str,
    input_path: &str,
    nav_path: &str,
) -> Result<(), Box<dyn Error>> {
    let nav: Vec<(String, Option<String>)> = {
        let file = File::open(nav_path)?;
        let reader = BufReader::new(file);
        serde_json::from_reader(reader)?
    };

    let arena = Arena::new();
    let options = comrak_options();

    let nav = nav.iter().filter_map(|(slug, title)| {
        title.as_ref().map(|title| {
            let title = comrak::parse_document(&arena, title, &options);
            (slug.as_str(), title)
        })
    }).collect::<Vec<_>>();

    let page = load_page(&arena, &options, input_path)?;
    let markup = views::main(&options, slug, page, &nav);

    fs::create_dir_all(Path::new(output_path).parent().unwrap())?;
    fs::write(output_path, markup.into_string())?;

    Ok(())
}

struct Page<'a> {
    title: Option<&'a AstNode<'a>>,
    content: &'a AstNode<'a>,
}

fn load_page<'a>(
    arena: &'a Arena<AstNode<'a>>,
    options: &ComrakOptions,
    path: impl AsRef<Path>,
) -> io::Result<Page<'a>> {
    let page = load_page_raw(arena, options, path)?;

    lower_headings(page.content);
    rewrite_md_links(page.content)
        .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
    highlight_code(page.content)
        .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;

    Ok(page)
}

fn load_page_title<'a>(
    arena: &'a Arena<AstNode<'a>>,
    options: &ComrakOptions,
    path: impl AsRef<Path>,
) -> io::Result<Option<String>> {
    let page = load_page_raw(arena, options, path)?;
    let title = page.title.map(|title| {
        let mut buffer = String::new();
        comrak::format_commonmark(
            title,
            options,
            &mut string_writer::StringWriter(&mut buffer),
        ).unwrap();
        buffer
    });
    Ok(title)
}

fn load_page_raw<'a>(
    arena: &'a Arena<AstNode<'a>>,
    options: &ComrakOptions,
    path: impl AsRef<Path>,
) -> io::Result<Page<'a>> {
    let buffer = fs::read_to_string(path)?;
    let content = comrak::parse_document(arena, &buffer, options);

    let title = content
        .first_child()
        .filter(|node| {
            let mut data = node.data.borrow_mut();
            if let NodeValue::Heading(NodeHeading { level: 1, .. }) = data.value {
                node.detach();
                data.value = NodeValue::Document;
                true
            } else {
                false
            }
        });

    Ok(Page { title, content })
}

fn lower_headings<'a>(root: &'a AstNode<'a>) {
    for node in root.descendants() {
        let mut data = node.data.borrow_mut();
        if let NodeValue::Heading(NodeHeading { level, .. }) = &mut data.value {
            *level += 1;
        }
    }
}

fn rewrite_md_links<'a>(root: &'a AstNode<'a>) -> Result<(), FromUtf8Error> {
    for node in root.descendants() {
        let mut data = node.data.borrow_mut();
        if let NodeValue::Link(NodeLink { url, .. }) = &mut data.value {
            let mut url_string = String::from_utf8(mem::replace(url, Vec::new()))?;
            if url_string.ends_with(".md") {
                url_string.truncate(url_string.len() - ".md".len());
                url_string.push_str(".html");
            }
            *url = url_string.into_bytes();
        }
    }
    Ok(())
}

fn highlight_code<'a>(root: &'a AstNode<'a>) -> Result<(), FromUtf8Error> {
    let ss = SyntaxSet::load_defaults_newlines();
    let ts = ThemeSet::load_defaults();
    let mut theme = ts.themes["InspiredGitHub"].clone();
    theme.settings.background = Some(Color { r: 0xee, g: 0xee, b: 0xee, a: 0xff });
    for node in root.descendants() {
        let mut data = node.data.borrow_mut();
        if let NodeValue::CodeBlock(NodeCodeBlock { info, literal, ..  }) = &mut data.value {
            let info = String::from_utf8(mem::replace(info, Vec::new()))?;
            let syntax = ss.find_syntax_by_token(&info)
                .unwrap_or_else(|| ss.find_syntax_plain_text());
            let mut literal = String::from_utf8(mem::replace(literal, Vec::new()))?;
            if !literal.ends_with('\n') {
                // Syntect expects a trailing newline
                literal.push('\n');
            }
            let html = highlighted_html_for_string(&literal, &ss, syntax, &theme);
            data.value = NodeValue::HtmlBlock(NodeHtmlBlock {
                block_type: 0,
                literal: html.into_bytes(),
            });
        }
    }
    Ok(())
}

fn comrak_options() -> ComrakOptions {
    ComrakOptions {
        ext_header_ids: Some("".to_string()),
        unsafe_: true,
        ..ComrakOptions::default()
    }
}