mod atom;
mod db;
mod error;
mod fs;
mod gatherer;
mod griph;
mod ifc;
mod markup;
mod settings;
mod templated;
mod timeparse;
mod util;

use std::{
	fs::File,
	io::{BufRead, BufReader, Write},
	os::unix::fs::MetadataExt,
	str::FromStr,
	sync::{
		atomic::{AtomicUsize, Ordering},
		Arc,
	},
	time::Duration,
};

use ::time::OffsetDateTime;
use axum::{
	body::Body,
	extract::{Path, State},
	http::{header, StatusCode},
	response::Response,
	routing::get,
	Extension, Router,
};
use bempline::{variables, Document, Options};
use camino::Utf8PathBuf;
use confindent::{Confindent, Node};
use db::Database;
pub use error::RuntimeError;
use fs::Filesystem;
use gatherer::Gatherer;
use settings::Settings;
use templated::Frontmatter;
use tokio_util::io::ReaderStream;
use tracing_subscriber::{fmt::time, prelude::*, EnvFilter};
use util::{Referer, RemoteIp, SessionId};

use crate::{
	fs::{PathResolution, Webpath},
	templated::Templated,
};

#[derive(Clone)]
pub struct AwakeState {
	pub do_statistics: bool,
	pub database: Arc<Database>,
	pub cache_path: Utf8PathBuf,
	pub netinfo_upper_bound: Arc<AtomicUsize>,
}

#[tokio::main]
async fn main() {
	match std::env::args().nth(1).as_deref() {
		Some("atomizer") => atom::main(),
		/* fallthrough*/
		Some("serve") => (),
		_ => (),
	}

	tracing_subscriber::registry()
		.with(tracing_subscriber::fmt::layer())
		.with(
			EnvFilter::try_from_default_env()
				.or_else(|_| EnvFilter::try_new("info"))
				.unwrap(),
		)
		.init();

	let conf = Confindent::from_file(std::env::args().nth(2).unwrap()).unwrap();
	let webroot: Utf8PathBuf = conf.child_parse("Webroot").unwrap();
	let templates = conf.child_value("Templates").unwrap();
	let hostname = conf.child_owned("Hostname").unwrap();
	let dbpath = conf.child_owned("Database").unwrap();
	let cache = conf.child_owned("Cache").unwrap();
	let statistics = match conf.child_value("Statistics") {
		None | Some("true") | Some("yes") | Some("collect") => true,
		_ => false,
	};

	let database = Database::new(dbpath.into());
	database.create_tables();

	let fs = Filesystem::new(&webroot);

	let state = AwakeState {
		do_statistics: statistics,
		database: Arc::new(database),
		cache_path: cache.into(),
		netinfo_upper_bound: Arc::new(AtomicUsize::new(256)),
	};

	match std::env::args().nth(1).as_deref() {
		Some("graph") => {
			gatherer::make_mem_graph(&state);
		}
		_ => (),
	}

	let mut gatherer = Gatherer::new(state.clone());
	gatherer.start();

	let settings = Settings {
		template_dir: Utf8PathBuf::from(webroot.join(templates))
			.canonicalize_utf8()
			.unwrap(),
		hostname,
	};

	let app = Router::new()
		.route("/", get(index_handler))
		.route("/api/stats/:name", get(stats))
		.route("/*path", get(handler))
		.layer(Extension(fs))
		.layer(Extension(settings))
		.with_state(state);

	let listener = tokio::net::TcpListener::bind("0.0.0.0:2560").await.unwrap();
	axum::serve(listener, app).await.unwrap()
}

async fn index_handler(
	state: State<AwakeState>,
	fse: Extension<Filesystem>,
	se: Extension<Settings>,
	sid: SessionId,
	rfr: Option<Referer>,
) -> Response {
	handler(state, fse, se, sid, rfr, Path(String::from("/"))).await
}

async fn handler(
	State(state): State<AwakeState>,
	Extension(fs): Extension<Filesystem>,
	Extension(settings): Extension<Settings>,
	sid: SessionId,
	rfr: Option<Referer>,
	Path(path): Path<String>,
) -> Response {
	match falible_handler(state, fs, settings, sid, rfr, path).await {
		Ok(resp) => resp,
		Err(RuntimeError::NotFound { source: _, path }) => {
			tracing::warn!("[{sid}] 404 on {path}");

			Response::builder()
				.body(Body::from(format!("the path was not found: {path}")))
				.unwrap()
		}
		Err(re) => Response::builder()
			.body(Body::from(re.to_string()))
			.unwrap(),
	}
}

async fn falible_handler(
	state: AwakeState,
	fs: Filesystem,
	settings: Settings,
	sid: SessionId,
	rfr: Option<Referer>,
	path: String,
) -> Result<Response, RuntimeError> {
	tracing::debug!("webpath = {path}");

	let webpath: Webpath = path.parse()?;
	let resolve = fs.resolve(&webpath)?;

	if !webpath.is_dir() && resolve.is_dirfile {
		return Ok(redirect(webpath.as_dir()));
	}

	let ext = resolve.filepath.extension().unwrap_or_default();

	tracing::trace!("[{sid}] referer = {rfr:?}");
	tracing::trace!("[{sid}] webpath = {webpath}");

	if ext != "html" {
		// Logging a non-html asset without a referrer. Let's pretend like
		// that means it's the primary resource.
		if rfr.is_none() {
			tracing::info!("[{sid}] serving {webpath}");
		}

		send_file(resolve.filepath).await
	} else {
		match rfr {
			None => {
				tracing::info!("[{sid}] serving {webpath}");
			}
			Some(referer) => {
				tracing::info!("[{sid}] (refer {referer}) serving {webpath}");
			}
		}

		let content = Filesystem::read_to_string(&resolve.filepath).await?;
		let result = Templated::from_str(&content);

		match result {
			Ok(templated) => send_template(templated, resolve, webpath, state, settings, sid).await,
			Err(e) => {
				tracing::warn!("error sending template {e}");

				Ok(Response::builder()
					.header(header::CONTENT_TYPE, "text/html")
					.body(Body::from(content))
					.unwrap())
			}
		}
	}
}

fn redirect<S: Into<String>>(redirection: S) -> Response {
	let location = redirection.into();
	tracing::info!("redirect to {location}");
	Response::builder()
		.status(StatusCode::TEMPORARY_REDIRECT)
		.header(header::LOCATION, &location)
		.body(Body::new(format!("redirecting to {location}")))
		.unwrap()
}

// 20 megabytes
const STREAM_AFTER: u64 = 20 * 1024 * 1024;

async fn send_file(filepath: Utf8PathBuf) -> Result<Response, RuntimeError> {
	tracing::debug!(target: "send_file", filepath = ?filepath);
	let ext = filepath.extension().unwrap_or_default();
	let stem = filepath.file_stem().unwrap_or_default();

	let mime = match ext {
		// Text
		"css" => "text/css",
		"html" => "text/html",
		"js" => "text/javascript",
		"txt" => "text/plain",
		"xml" if stem.ends_with("atom") => "application/atom+xml",
		"xml" => "application/xml",

		// Multimedia
		"gif" => "image/gif",
		"jpg" | "jpeg" => "image/jpeg",
		"mp4" => "video/mp4",
		"png" => "image/png",
		_ => "",
	};
	tracing::debug!(target: "send_file", mime = ?mime);

	let mut response = Response::builder();
	if !mime.is_empty() {
		response = response.header(header::CONTENT_TYPE, mime);
	}

	let metadata = Filesystem::metadata(&filepath)?;
	if metadata.size() > STREAM_AFTER {
		tracing::debug!("large file, streaming to client");

		let file = Filesystem::open(filepath).await?;
		let stream = ReaderStream::new(file);
		Ok(response.body(Body::from_stream(stream)).unwrap())
	} else {
		tracing::debug!("small file, sending entirely at once");

		let content = Filesystem::read(filepath).await?;
		Ok(response.body(Body::from(content)).unwrap())
	}
}

async fn send_template(
	templated: Templated,
	resolve: PathResolution,
	webpath: Webpath,
	state: AwakeState,
	settings: Settings,
	sid: SessionId,
) -> Result<Response, RuntimeError> {
	tracing::trace!("[{sid}] sending template");
	let template_stem = templated.frontmatter.get("template").expect("no template");
	let template_name = Utf8PathBuf::from(format!("{template_stem}.html"));
	let template_path = settings.template_dir.join(template_name);

	let filename = resolve
		.filepath
		.file_name()
		.expect("template has no filename");

	let mut template = Document::from_file(
		template_path,
		Options::default().include_path(bempline::options::IncludeMethod::Path(
			settings.template_dir.as_std_path().to_owned(),
		)),
	)
	.unwrap();

	let title = templated.frontmatter.get("title").unwrap_or(filename);

	template.set("title", title);

	tracing::trace!("doing opengraph stuff!");
	if let Some(og_description) = templated.frontmatter.get("description") {
		let og_title = title;
		let og_url = format!("https://{}{}", &settings.hostname, webpath);

		if let Some(art_relpath) = templated.frontmatter.get("art") {
			let serving_dir = Utf8PathBuf::from(webpath.first_dir());
			let art_path = serving_dir.join(art_relpath);

			let og_image_alt = match templated.frontmatter.get("art_alt") {
				Some(alt) => alt,
				None => {
					tracing::warn!("[{sid}] {} has art but no alt", resolve.filepath);
					""
				}
			};

			let og_image = format!("https://{}/{}", &settings.hostname, art_path);

			variables!(template, og_image, og_image_alt);
		}

		let og_site_name = &settings.hostname;

		variables!(template, og_title, og_url, og_description, og_site_name);
	}

	tracing::trace!("stylin'!");
	// styles the templated stuff wants
	let style_pattern = template.get_pattern("styles").unwrap();
	for style in templated.frontmatter.get_many("style") {
		let mut pat = style_pattern.clone();
		pat.set("style", style);
		template.set_pattern(pat);
	}

	// path to the file for navigation
	let mut path: Vec<&str> = webpath.webcanon.iter().collect();
	// we don't want the directory/filename itself
	path.pop();

	if let Some(path_pattern) = template.get_pattern("path") {
		let offset = match templated
			.frontmatter
			.get("path-offset")
			.map(|raw| raw.parse::<usize>())
		{
			Some(Ok(offset)) => offset,
			None => 0,
			Some(Err(_)) => {
				tracing::error!(
					"[{sid}] path-offset in template {} is not an integer",
					resolve.filepath
				);

				0
			}
		};

		for _ in 0..offset {
			path.pop();
		}

		let mut link = Utf8PathBuf::from("/");

		let mut pat = path_pattern.clone();
		pat.set("path_link", "/");
		pat.set("path_name", "home");
		template.set_pattern(pat);

		for part in path {
			link.push(part);

			let mut pat = path_pattern.clone();
			pat.set("path_link", &link);
			pat.set("path_name", part);
			template.set_pattern(pat);
		}
	}

	tracing::trace!("starting published block");
	'published: {
		if let Some(mut published_pattern) = template.get_pattern("published") {
			let publish_date_result = templated
				.frontmatter
				.get("published")
				.map(|ts| timeparse::parse(ts));

			match publish_date_result {
				None => break 'published,
				Some(Err(_e)) => {
					tracing::warn!("template {resolve} has malformed `published` frontmatter");
					break 'published;
				}
				Some(Ok(datetime)) => {
					let published_human = timeparse::format_long(&datetime);
					let published_machine = timeparse::iso8601(&datetime);

					variables!(published_pattern, published_human, published_machine);
					template.set_pattern(published_pattern);
				}
			}
		}
	}
	tracing::trace!("finished published block");

	// insert the page content itself
	let mut markedup = markup::process(&templated.content);
	if templated.frontmatter.get("use-template").is_some() {
		markedup = template_content(state, &templated.frontmatter, markedup);
	}

	template.set("main", markedup);

	Ok(Response::builder()
		.header(header::CONTENT_TYPE, "text/html")
		.body(Body::from(template.compile()))
		.unwrap())
}

fn template_content(state: AwakeState, frontmatter: &Frontmatter, marked: String) -> String {
	let Ok(mut doc) = Document::from_str(&marked, Options::default()) else {
		return marked;
	};

	if frontmatter.get("system-stats").is_some() {
		let mem = state.database.get_last_host_meminfo();

		doc.set("stats.mem.total", mem.total_kb / 1000);
		doc.set("stats.mem.usage", mem.usage() / 1000);

		let netinfo_upper = state.netinfo_upper_bound.load(Ordering::Relaxed);
		doc.set("stats.net.max_bound", netinfo_upper);
	}

	doc.compile()
}

async fn stats(State(state): State<AwakeState>, Path(name): Path<String>) -> Response {
	const NAMES: &[&'static str] = &["current_hostmeminfo.gif", "current_hostnetinfo.gif"];

	if NAMES.contains(&name.as_str()) {
		let path = state.cache_path.join(name);
		match send_file(path).await {
			Ok(resp) => resp,
			Err(e) => Response::builder().body(Body::from(e.to_string())).unwrap(),
		}
	} else {
		tracing::debug!("invalid stat requested: {name}");

		Response::builder()
			.body(Body::from("that stat is not real"))
			.unwrap()
	}
}