#![forbid(unsafe_code)]
#![warn(clippy::todo)]

use std::sync::Arc;

use axum::extract::{FromRef, FromRequestParts, OptionalFromRequestParts};
use axum_extra::extract::{
    cookie::{Cookie, Key},
    SignedCookieJar,
};
use database::{FileStorage, PostgresStorage, Storage};
use indieauth::backend::{AuthBackend, FileBackend as FileAuthBackend};
use kittybox_util::queue::JobQueue;
use media::storage::{file::FileStore as FileMediaStore, MediaStore};
use tokio::{
    sync::{Mutex, RwLock},
    task::JoinSet,
};
use webmentions::queue::PostgresJobQueue;

/// Database abstraction layer for Kittybox, allowing the CMS to work with any kind of database.
pub mod database;
pub mod frontend;
pub mod indieauth;
pub mod login;
pub mod media;
pub mod micropub;
pub mod webmentions;
//pub mod admin;

const OAUTH2_SOFTWARE_ID: &str = "6f2eee84-c22c-4c9e-b900-10d4e97273c8";

#[derive(Clone)]
pub struct AppState<A, S, M, Q>
where
    A: AuthBackend + Sized + 'static,
    S: Storage + Sized + 'static,
    M: MediaStore + Sized + 'static,
    Q: JobQueue<webmentions::Webmention> + Sized,
{
    pub auth_backend: A,
    pub storage: S,
    pub media_store: M,
    pub job_queue: Q,
    pub http: reqwest_middleware::ClientWithMiddleware,
    pub background_jobs: Arc<Mutex<JoinSet<()>>>,
    pub cookie_key: Key,
    pub session_store: SessionStore,
}

pub type SessionStore = Arc<RwLock<std::collections::HashMap<uuid::Uuid, Session>>>;

#[derive(Debug, Clone)]
#[repr(transparent)]
pub struct Session(kittybox_indieauth::ProfileUrl);

impl std::ops::Deref for Session {
    type Target = kittybox_indieauth::ProfileUrl;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

pub struct NoSessionError;
impl axum::response::IntoResponse for NoSessionError {
    fn into_response(self) -> axum::response::Response {
        // TODO: prettier error message
        (
            axum::http::StatusCode::UNAUTHORIZED,
            "You are not logged in, but this page requires a session.",
        )
            .into_response()
    }
}

impl<S> OptionalFromRequestParts<S> for Session
where
    SessionStore: FromRef<S>,
    Key: FromRef<S>,
    S: Send + Sync,
{
    type Rejection = std::convert::Infallible;

    async fn from_request_parts(
        parts: &mut axum::http::request::Parts,
        state: &S,
    ) -> Result<Option<Self>, Self::Rejection> {
        let jar = SignedCookieJar::<Key>::from_request_parts(parts, state)
            .await
            .unwrap();
        let session_store = SessionStore::from_ref(state).read_owned().await;

        Ok(jar
            .get("session_id")
            .as_ref()
            .map(Cookie::value_trimmed)
            .and_then(|id| uuid::Uuid::parse_str(id).ok())
            .and_then(|id| session_store.get(&id).cloned()))
    }
}

// This is really regrettable, but I can't write:
//
// ```compile-error
// impl <A, S, M> FromRef<AppState<A, S, M>> for A
// where A: AuthBackend, S: Storage, M: MediaStore {
//     fn from_ref(input: &AppState<A, S, M>) -> A {
//         input.auth_backend.clone()
//     }
// }
// ```
//
// ...because of the orphan rule.
//
// I wonder if this would stifle external implementations. I think it
// shouldn't, because my AppState type is generic, and since the
// target type is local, the orphan rule will not kick in. You just
// have to repeat this magic invocation.

impl<S, M, Q> FromRef<AppState<Self, S, M, Q>> for FileAuthBackend
where
    S: Storage,
    M: MediaStore,
    Q: JobQueue<webmentions::Webmention>,
{
    fn from_ref(input: &AppState<Self, S, M, Q>) -> Self {
        input.auth_backend.clone()
    }
}

impl<A, M, Q> FromRef<AppState<A, Self, M, Q>> for PostgresStorage
where
    A: AuthBackend,
    M: MediaStore,
    Q: JobQueue<webmentions::Webmention>,
{
    fn from_ref(input: &AppState<A, Self, M, Q>) -> Self {
        input.storage.clone()
    }
}

impl<A, M, Q> FromRef<AppState<A, Self, M, Q>> for FileStorage
where
    A: AuthBackend,
    M: MediaStore,
    Q: JobQueue<webmentions::Webmention>,
{
    fn from_ref(input: &AppState<A, Self, M, Q>) -> Self {
        input.storage.clone()
    }
}

impl<A, S, Q> FromRef<AppState<A, S, Self, Q>> for FileMediaStore
// where A: AuthBackend, S: Storage
where
    A: AuthBackend,
    S: Storage,
    Q: JobQueue<webmentions::Webmention>,
{
    fn from_ref(input: &AppState<A, S, Self, Q>) -> Self {
        input.media_store.clone()
    }
}

impl<A, S, M, Q> FromRef<AppState<A, S, M, Q>> for Key
where
    A: AuthBackend,
    S: Storage,
    M: MediaStore,
    Q: JobQueue<webmentions::Webmention>,
{
    fn from_ref(input: &AppState<A, S, M, Q>) -> Self {
        input.cookie_key.clone()
    }
}

impl<A, S, M, Q> FromRef<AppState<A, S, M, Q>> for reqwest_middleware::ClientWithMiddleware
where
    A: AuthBackend,
    S: Storage,
    M: MediaStore,
    Q: JobQueue<webmentions::Webmention>,
{
    fn from_ref(input: &AppState<A, S, M, Q>) -> Self {
        input.http.clone()
    }
}

impl<A, S, M, Q> FromRef<AppState<A, S, M, Q>> for Arc<Mutex<JoinSet<()>>>
where
    A: AuthBackend,
    S: Storage,
    M: MediaStore,
    Q: JobQueue<webmentions::Webmention>,
{
    fn from_ref(input: &AppState<A, S, M, Q>) -> Self {
        input.background_jobs.clone()
    }
}

#[cfg(feature = "sqlx")]
impl<A, S, M> FromRef<AppState<A, S, M, Self>> for PostgresJobQueue<webmentions::Webmention>
where
    A: AuthBackend,
    S: Storage,
    M: MediaStore,
{
    fn from_ref(input: &AppState<A, S, M, Self>) -> Self {
        input.job_queue.clone()
    }
}

impl<A, S, M, Q> FromRef<AppState<A, S, M, Q>> for SessionStore
where
    A: AuthBackend,
    S: Storage,
    M: MediaStore,
    Q: JobQueue<webmentions::Webmention>,
{
    fn from_ref(input: &AppState<A, S, M, Q>) -> Self {
        input.session_store.clone()
    }
}

pub mod companion {
    use axum::{
        extract::{Extension, Path},
        response::{IntoResponse, Response},
    };
    use std::{collections::HashMap, sync::Arc};

    #[derive(Debug, Clone, Copy)]
    struct Resource {
        data: &'static [u8],
        mime: &'static str,
    }

    impl IntoResponse for &Resource {
        fn into_response(self) -> Response {
            (
                axum::http::StatusCode::OK,
                [("Content-Type", self.mime)],
                self.data,
            )
                .into_response()
        }
    }

    // TODO replace with the "phf" crate someday
    type ResourceTable = Arc<HashMap<&'static str, Resource>>;

    #[tracing::instrument]
    async fn map_to_static(
        Path(name): Path<String>,
        Extension(resources): Extension<ResourceTable>,
    ) -> Response {
        tracing::debug!("Searching for {} in the resource table...", name);
        match resources.get(name.as_str()) {
            Some(res) => res.into_response(),
            None => {
                #[cfg(debug_assertions)]
                tracing::error!("Not found");

                (
                    axum::http::StatusCode::NOT_FOUND,
                    [("Content-Type", "text/plain")],
                    "Not found. Sorry.".as_bytes(),
                )
                    .into_response()
            }
        }
    }

    pub fn router<St: Clone + Send + Sync + 'static>() -> axum::Router<St> {
        let resources: ResourceTable = {
            let mut map = HashMap::new();

            macro_rules! register_resource {
                ($map:ident, $prefix:expr, ($filename:literal, $mime:literal)) => {{
                    $map.insert($filename, Resource {
                        data: include_bytes!(concat!($prefix, $filename)),
                        mime: $mime
                    })
                }};
                ($map:ident, $prefix:expr, ($filename:literal, $mime:literal), $( ($f:literal, $m:literal) ),+) => {{
                    register_resource!($map, $prefix, ($filename, $mime));
                    register_resource!($map, $prefix, $(($f, $m)),+);
                }};
            }

            register_resource! {
                map,
                concat!(env!("OUT_DIR"), "/", "companion", "/"),
                ("index.html", "text/html; charset=\"utf-8\""),
                ("main.js", "text/javascript"),
                ("micropub_api.js", "text/javascript"),
                ("indieauth.js", "text/javascript"),
                ("base64.js", "text/javascript"),
                ("style.css", "text/css")
            };

            Arc::new(map)
        };

        axum::Router::new().route(
            "/{filename}",
            axum::routing::get(map_to_static).layer(Extension(resources)),
        )
    }
}

async fn teapot_route() -> impl axum::response::IntoResponse {
    use axum::http::{header, StatusCode};
    (
        StatusCode::IM_A_TEAPOT,
        [(header::CONTENT_TYPE, "text/plain")],
        "Sorry, can't brew coffee yet!",
    )
}

async fn health_check<D>(
    axum::extract::State(data): axum::extract::State<D>,
) -> impl axum::response::IntoResponse
where
    D: crate::database::Storage,
{
    (axum::http::StatusCode::OK, std::borrow::Cow::Borrowed("OK"))
}

pub async fn compose_kittybox<St, A, S, M, Q>() -> axum::Router<St>
where
    A: AuthBackend + 'static + FromRef<St>,
    S: Storage + 'static + FromRef<St>,
    M: MediaStore + 'static + FromRef<St>,
    Q: kittybox_util::queue::JobQueue<crate::webmentions::Webmention> + FromRef<St>,
    reqwest_middleware::ClientWithMiddleware: FromRef<St>,
    Arc<Mutex<JoinSet<()>>>: FromRef<St>,
    crate::SessionStore: FromRef<St>,
    axum_extra::extract::cookie::Key: FromRef<St>,
    St: Clone + Send + Sync + 'static,
{
    use axum::routing::get;
    axum::Router::new()
        .route("/", get(crate::frontend::homepage::<S>))
        .fallback(get(crate::frontend::catchall::<S>))
        .route("/.kittybox/micropub", crate::micropub::router::<A, S, St>())
        .route(
            "/.kittybox/onboarding",
            crate::frontend::onboarding::router::<St, S>(),
        )
        .nest("/.kittybox/media", crate::media::router::<St, A, M>())
        .merge(crate::indieauth::router::<St, A, S>())
        .merge(crate::webmentions::router::<St, Q>())
        .route("/.kittybox/health", get(health_check::<S>))
        .nest("/.kittybox/login", crate::login::router::<St, S>())
        .route(
            "/.kittybox/static/{*path}",
            axum::routing::get(crate::frontend::statics),
        )
        .route("/.kittybox/coffee", get(teapot_route))
        .nest(
            "/.kittybox/micropub/client",
            crate::companion::router::<St>(),
        )
        .layer(tower_http::trace::TraceLayer::new_for_http())
        .layer(tower_http::catch_panic::CatchPanicLayer::new())
        .layer(
            tower_http::sensitive_headers::SetSensitiveHeadersLayer::new([
                axum::http::header::AUTHORIZATION,
                axum::http::header::COOKIE,
                axum::http::header::SET_COOKIE,
            ]),
        )
        .layer(tower_http::set_header::SetResponseHeaderLayer::appending(
            axum::http::header::CONTENT_SECURITY_POLICY,
            axum::http::HeaderValue::from_static(concat!(
                "default-src 'none';", // Do not allow unknown things we didn't foresee.
                "img-src https:;",     // Allow hotlinking images from anywhere.
                "form-action 'self';", // Only allow sending forms back to us.
                "media-src 'self';",   // Only allow embedding media from us.
                "script-src 'self';",  // Only run scripts we serve.
                "style-src 'self';",   // Only use styles we serve.
                "base-uri 'none';",    // Do not allow to change the base URI.
                "object-src 'none';",  // Do not allow to embed objects (Flash/ActiveX).
                // Allow embedding the Bandcamp player for jam posts.
                // TODO: perhaps make this policy customizable?…
                "frame-src 'self' https://bandcamp.com/EmbeddedPlayer/;"
            )),
        ))
}