//! # html-asset-hasher
//!
//! Asset cache busting for your static HTML files, with optional Tailwind CSS bundling!
//!
//! ## Usage
//! ```shell
//! html-asset-hasher [OPTIONS] <PATHS>...
//! ```
//!
//! ## More
//! For project information, see: [html-asset-hasher](https://sr.ht/~quaff/html-asset-hasher/).

mod hasher;
mod html;
mod tailwind;

use std::{
    fs::{self, File},
    io::Write,
    path::Path,
    process::exit,
};

use clap::Parser;
use lol_html::html_content::Element;
use regex::Regex;
use reqwest::Client;
use simplelog::{debug, error, info, ColorChoice, Config, LevelFilter, TermLogger, TerminalMode};
use tempfile::tempdir;

use hasher::Hasher;
use html::Html;
use tailwind::{Asset, Tailwind, DEFAULT_TAILWIND_COMMAND, DEFAULT_TAILWIND_VERSION};

#[derive(Parser, Debug)]
#[command(
    version,
    about = "Asset cache busting for your static HTML files\nhttps://sr.ht/~quaff/html-asset-hasher"
)]
struct Arguments {
    // Input HTML files or directories
    #[arg(value_name = "PATHS", required = true)]
    inputs: Vec<String>,

    // Output directory
    #[arg(short, long, value_name = "DIR", default_value = "dist")]
    output: String,

    // SHA2 Hash algorithm
    #[arg(short, long, default_value = "sha384")]
    sha2_hash: String,

    // TailwindCSS version to download
    #[arg(short, long, default_value = DEFAULT_TAILWIND_VERSION)]
    tailwind_version: String,

    // Turn debugging information on
    #[arg(long)]
    debug: bool,
}

#[tokio::main]
async fn main() {
    // parse and destructure arguments
    let Arguments {
        debug: debug_mode,
        inputs: input_paths,
        output: output_path,
        sha2_hash: hash_algorithm,
        tailwind_version,
    } = Arguments::parse();

    // logger initialization
    TermLogger::init(
        if debug_mode {
            LevelFilter::Debug
        } else {
            LevelFilter::Info
        },
        Config::default(),
        TerminalMode::Mixed,
        ColorChoice::Auto,
    )
    .expect("Failed to initialize logger.");

    // create temp dir
    let tmp_dir = tempdir().unwrap();
    let tmp_path = tmp_dir.path().to_str().unwrap().to_string();

    // create output path if it doesn't exist
    fs::create_dir_all(&output_path).unwrap();

    // initialize asset hasher
    let asset_hasher = HtmlAssetHasher {
        output_path,
        hash_algorithm,
        tailwind_version,
        tmp_path,
    };

    // loop through input_paths
    for input_path in input_paths {
        // check if input exists
        let path = Path::new(&input_path);
        if !&path.exists() {
            error!("Path not found. {}", &path.to_str().unwrap());
            exit(1);
        }

        // run asset hasher for path
        asset_hasher.run(path).await;
    }
}

struct HtmlAssetHasher {
    output_path: String,
    hash_algorithm: String,
    tailwind_version: String,
    tmp_path: String,
}

impl HtmlAssetHasher {
    // element handler for generic assets
    fn element_generic_handler(&self, el: &mut Element) {
        // link tag attributes
        if let Some(source_asset_path) = el.get_attribute("data-file") {
            if Path::new(&source_asset_path).exists() {
                let hasher = Hasher::new(
                    &source_asset_path,
                    &self.output_path,
                    Some(&self.hash_algorithm),
                );

                // copy asset file to output directory
                if !Path::new(hasher.output_asset_file().as_str()).exists() {
                    hasher.copy(None);
                }

                // update the <link> tag
                match el.tag_name().as_str() {
                    "img" => {
                        el.set_attribute("src", &hasher.hashed_file).unwrap();
                    }
                    "link" => {
                        el.set_attribute("href", &hasher.hashed_file).unwrap();
                        if el.get_attribute("rel").unwrap_or_default().as_str() == "stylesheet" {
                            el.set_attribute("integrity", &hasher.integrity).unwrap();
                            el.set_attribute("crossorigin", "anonymous").unwrap();
                        }
                    }
                    "script" => {
                        el.set_attribute("src", &hasher.hashed_file).unwrap();
                        el.set_attribute("integrity", &hasher.integrity).unwrap();
                        el.set_attribute("crossorigin", "anonymous").unwrap();
                    }
                    _ => {}
                }
                el.remove_attribute("data-hasher");
                el.remove_attribute("data-file");
                el.remove_attribute("data-url");
            } else {
                println!("No asset found for {}", &source_asset_path);
            }
        }
    }

    // element handler for tailwind assets
    fn element_tailwind_handler(&self, el: &mut Element, tailwind_runs: &[Asset]) {
        if let Some(orig_source_asset_path) = el.get_attribute("data-file") {
            let source_asset = tailwind_runs
                .iter()
                .find(|&asset| asset.input == orig_source_asset_path);

            if let Some(source_asset) = source_asset {
                let source_asset_path = &source_asset.output;

                if Path::new(&source_asset_path).exists() {
                    let hasher = Hasher::new(
                        source_asset_path,
                        &self.output_path,
                        Some(&self.hash_algorithm),
                    );

                    // copy asset file to output directory
                    let output_asset_file = hasher.output_asset_file();
                    if !Path::new(&output_asset_file).exists() {
                        hasher.copy(Some(true));
                        info!(
                            "Copied {} to {}",
                            &orig_source_asset_path, &output_asset_file
                        );
                    }

                    // update the <link> tag
                    el.set_attribute("href", &hasher.hashed_file).unwrap();
                    el.set_attribute("rel", "stylesheet").unwrap();
                    el.set_attribute("integrity", &hasher.integrity).unwrap();
                    el.set_attribute("crossorigin", "anonymous").unwrap();
                    el.remove_attribute("data-hasher");
                    el.remove_attribute("data-file");
                }
            }
        }
    }

    // retrieves remote assets and returns tmp path
    async fn download_remote_assets(&self, remote_run: &mut Asset) {
        let client = Client::new();
        match client.get(&remote_run.input).send().await {
            Ok(response) => {
                let headers = response.headers();
                let file_name =
                    if let Some(content_disposition) = headers.get("content-disposition") {
                        Regex::new(r#"filename="(.+)""#)
                            .unwrap()
                            .captures(content_disposition.to_str().unwrap())
                            .unwrap()
                            .get(1)
                            .unwrap()
                            .as_str()
                    } else {
                        // No content-disposition header found, we'll use the last part of the URL
                        remote_run.input.split("/").last().unwrap()
                    };
                debug!("Remote filename: {:?}", &file_name);

                remote_run.output = format!("{}/{}", &self.tmp_path, file_name.replace(" ", "_"));
                if !Path::new(&remote_run.output).exists() {
                    File::create(&remote_run.output)
                        .unwrap()
                        .write_all(&response.bytes().await.unwrap())
                        .unwrap();

                    if Path::new(&remote_run.output).exists() {
                        info!("Downloaded {} to {}", &remote_run.input, &remote_run.output);
                    } else {
                        error!("Failed to download remote asset {}", &remote_run.input);
                        exit(1);
                    }
                }
            }
            Err(err) => {
                error!("Failed to download remote asset {}", &remote_run.input);
                debug!("{}", err);
                exit(1);
            }
        }
    }

    // run for single html
    async fn run_once(&self, input_path: &str) {
        // initialize html processor
        let mut html = Html {
            contents: fs::read_to_string(input_path).unwrap(),
            input_path: input_path.to_string(),
            output_path: self.output_path.to_owned(),
        };

        // check if tailwindcss is installed, if not, download known version
        let mut tailwind_command = String::from(DEFAULT_TAILWIND_COMMAND);
        if !Tailwind::is_installed(&tailwind_command).await {
            tailwind_command = Tailwind::fetch_release_version(
                &self.tmp_path,
                Some(self.tailwind_version.to_owned()),
            )
            .await;
        }

        // gather remote assets
        let mut remote_runs: Vec<Asset> = vec![];
        html.query_elements("img[data-hasher][data-url], link[data-hasher][data-url], script[data-hasher][data-url]", |el| {
            if let Some(source_asset_url) = el.get_attribute("data-url") {
                // setting output to tmp dir first
                remote_runs.push(Asset {
                    input: source_asset_url.to_owned(),
                    output: "".to_string(),
                });
            }
        })
        .await;

        // download remote assets and update tmp path
        for remote_run in &mut remote_runs {
            self.download_remote_assets(remote_run).await;
        }

        // update remote assets with data-file attribute
        html.mutate_elements("img[data-hasher][data-url], link[data-hasher][data-url], script[data-hasher][data-url]", |el| {
            if let Some(source_asset_url) = el.get_attribute("data-url") {
                let source_asset = remote_runs
                    .iter()
                    .find(|&asset| asset.input == source_asset_url)
                    .unwrap();

                let source_asset_path = &source_asset.output;
                if Path::new(&source_asset_path).exists() {
                    el.set_attribute("data-file", source_asset_path).unwrap();
                    el.remove_attribute("data-url");
                }
            }
        })
        .await;

        // gather tailwind assets
        let mut tailwind_runs: Vec<Asset> = vec![];
        html.query_elements("link[data-hasher][rel=\"tailwind-css\"]", |el| {
            if let Some(source_asset_path) = el.get_attribute("data-file") {
                let path = Path::new(&source_asset_path);
                if path.exists() {
                    // setting output to tmp dir first
                    tailwind_runs.push(Asset {
                        input: source_asset_path.to_owned(),
                        output: format!(
                            "{}/{}",
                            &self.tmp_path,
                            path.file_name().unwrap().to_str().unwrap()
                        ),
                    });
                }
            }
        })
        .await;

        // if any tailwind assets were found, run tailwind
        if !tailwind_runs.is_empty() {
            let tailwind = Tailwind {
                collection: tailwind_runs,
                command: tailwind_command,
            };
            tailwind.run().await;

            // update tailwind assets with output_path
            html.mutate_elements("link[data-hasher][rel=\"tailwind-css\"]", |el| {
                self.element_tailwind_handler(el, &tailwind.collection)
            })
            .await;
        }

        // update all other assets
        html.mutate_elements(
            "img[data-hasher], link[data-hasher]:not([rel=\"tailwind-css\"]), script[data-hasher]",
            |el| self.element_generic_handler(el),
        )
        .await;

        // save updated contents to output directory
        html.save();
        info!("Processed {} to {}", &input_path, html.output_path_file());
    }

    /// runs against path, if directory recursion happens
    pub async fn run(&self, path: &Path) {
        // run against single html
        if path.is_file() && path.extension().unwrap() == "html" {
            Box::pin(self.run_once(path.to_str().unwrap())).await;
        } else if path.is_dir() {
            // loop through directory and recurse
            for entry in fs::read_dir(path).unwrap().flatten() {
                // recursion!
                Box::pin(self.run(&entry.path())).await;
            }
        }
    }
}
