//
// MARK: metadata
//

use axum::{
	Router,
	extract::{ConnectInfo, Path, State},
	http::{HeaderValue, StatusCode, header},
	response::{IntoResponse, Response},
	routing::get,
};
use chrono::{DateTime, Utc};
use libservice::ServiceConnectInfo;
use lru::LruCache;
use maud::{Markup, PreEscaped, Render, html};
use parking_lot::Mutex;
use serde::Deserialize;
use std::{collections::HashMap, num::NonZero, sync::Arc, time::Duration};
use tracing::{debug, trace};

use crate::components::{
	md::{FrontMatter, Markdown},
	misc::Backlinks,
};

#[derive(Debug, Clone, Hash, PartialEq, Eq, Deserialize)]
pub struct PageMetadata {
	pub title: String,
	pub author: Option<String>,
	pub description: Option<String>,
	pub image: Option<String>,
	pub slug: Option<String>,
}

impl Default for PageMetadata {
	fn default() -> Self {
		Self {
			title: "Untitled page".into(),
			author: None,
			description: None,
			image: None,
			slug: None,
		}
	}
}

impl Render for PageMetadata {
	fn render(&self) -> Markup {
		let empty = String::new();
		let title = &self.title;
		let author = &self.author.as_ref().unwrap_or(&empty);
		let description = &self.description.as_ref().unwrap_or(&empty);
		let image = &self.image.as_ref().unwrap_or(&empty);

		html !(
			meta property="og:site_name" content=(title) {}
			meta name="title" content=(title) {}
			meta property="og:title" content=(title) {}
			meta property="twitter:title" content=(title) {}

			meta name="author" content=(author) {}

			meta name="description" content=(description) {}
			meta property="og:description" content=(description) {}
			meta property="twitter:description" content=(description) {}

			meta content=(image) property="og:image" {}
			link rel="shortcut icon" href=(image) type="image/x-icon" {}
		)
	}
}

//
// MARK: page
//

// Some HTML
pub struct Page {
	pub meta: PageMetadata,

	/// How long this page's html may be cached.
	///
	/// If `None`, this page is always rendered from scratch.
	pub html_ttl: Option<Duration>,

	/// A function that generates this page's html.
	///
	/// This should return the contents of this page's <body> tag,
	/// or the contents of a wrapper element (defined in the page server struct).
	///
	/// This closure must never return `<html>` or `<head>`.
	pub generate_html: Box<dyn Send + Sync + Fn(&Self) -> Markup>,
}

impl Default for Page {
	fn default() -> Self {
		Page {
			meta: Default::default(),
			html_ttl: Some(Duration::from_secs(60 * 24 * 30)),
			//css_ttl: Duration::from_secs(60 * 24 * 30),
			//generate_css: None,
			generate_html: Box::new(|_| html!()),
		}
	}
}

impl Page {
	pub fn generate_html(&self) -> Markup {
		(self.generate_html)(self)
	}

	pub fn from_markdown(md: impl Into<String>, default_image: Option<String>) -> Self {
		let md: String = md.into();
		let md = Markdown::parse(&md);

		let mut meta = md
			.children
			.get(0)
			.map(|x| x.cast::<FrontMatter>())
			.flatten()
			.map(|x| serde_yaml::from_str::<PageMetadata>(&x.content))
			.unwrap_or(Ok(Default::default()))
			.unwrap_or(PageMetadata {
				title: "Invalid frontmatter!".into(),
				..Default::default()
			});

		if meta.image.is_none() {
			meta.image = default_image
		}

		let html = PreEscaped(md.render());

		Page {
			meta,
			generate_html: Box::new(move |page| {
				html! {
					@if let Some(slug) = &page.meta.slug {
						(Backlinks(&[("/", "home")], slug))
					}

					(html)
				}
			}),

			..Default::default()
		}
	}
}

//
// MARK: server
//

pub struct PageServer {
	/// Map of `{ route: page }`
	pages: HashMap<String, Page>,

	/// Map of `{ route: (page data, expire time) }`
	///
	/// We use an LruCache for bounded memory usage.
	html_cache: Mutex<LruCache<String, (String, DateTime<Utc>)>>,

	/// Called whenever we need to render a page.
	/// - this method should call `page.generate_html()`,
	/// - wrap the result in `<html><body>`,
	/// - and add `<head>`
	/// ```
	render_page: Box<dyn Send + Sync + Fn(&Page) -> Markup>,
}

impl PageServer {
	pub fn new(page_wrapper: Box<dyn Send + Sync + Fn(&Page) -> Markup>) -> Self {
		#[expect(clippy::unwrap_used)]
		let cache_size = LruCache::new(NonZero::new(128).unwrap());

		Self {
			pages: HashMap::new(),
			html_cache: Mutex::new(cache_size),
			render_page: Box::new(page_wrapper),
		}
	}

	pub fn add_page(mut self, route: impl Into<String>, page: Page) -> Self {
		#[expect(clippy::expect_used)]
		let route = route
			.into()
			.strip_prefix("/")
			.expect("page route must start with /")
			.to_owned();

		self.pages.insert(route, page);

		self
	}

	async fn handler(
		Path(path): Path<String>,
		State(state): State<Arc<Self>>,
		ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>,
	) -> Response {
		trace!("Serving {path} to {}", addr.addr);

		let page = match state.pages.get(&path) {
			Some(x) => x,

			// TODO: 404 page
			None => return (StatusCode::NOT_FOUND, "page doesn't exist").into_response(),
		};

		let now = Utc::now();
		let headers = [(
			header::CONTENT_TYPE,
			HeaderValue::from_static("text/html; charset=utf-8"),
		)];

		if let Some((html, expires)) = state.html_cache.lock().get(&path)
			&& *expires > now
		{
			// TODO: no clone?
			return (headers, html.clone()).into_response();
		};

		debug!("Rendering {path}");
		let html = (state.render_page)(page).0;

		if let Some(ttl) = page.html_ttl {
			state.html_cache.lock().put(path, (html.clone(), now + ttl));
		}

		return (headers, html.clone()).into_response();
	}

	pub fn into_router(self) -> Router<()> {
		Router::new()
			.route(
				"/",
				get(|state, conn| async { Self::handler(Path(String::new()), state, conn).await }),
			)
			.route("/{*path}", get(Self::handler))
			.with_state(Arc::new(self))
	}
}
