use crate::database::{Storage, StorageError};
use axum::{
    extract::{Host, Query, State},
    http::{StatusCode, Uri},
    response::IntoResponse,
};
use futures_util::FutureExt;
use serde::Deserialize;
use std::convert::TryInto;
use tracing::{debug, error};
//pub mod login;
pub mod onboarding;

use kittybox_frontend_renderer::{
    Entry, Feed, VCard,
    ErrorPage, Template, MainPage,
    POSTS_PER_PAGE
};
pub use kittybox_frontend_renderer::assets::statics;

#[derive(Debug, Deserialize)]
pub struct QueryParams {
    after: Option<String>,
}

#[derive(Debug)]
struct FrontendError {
    msg: String,
    source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
    code: StatusCode,
}

impl FrontendError {
    pub fn with_code<C>(code: C, msg: &str) -> Self
    where
        C: TryInto<StatusCode>,
    {
        Self {
            msg: msg.to_string(),
            source: None,
            code: code.try_into().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
        }
    }
    pub fn msg(&self) -> &str {
        &self.msg
    }
    pub fn code(&self) -> StatusCode {
        self.code
    }
}

impl From<StorageError> for FrontendError {
    fn from(err: StorageError) -> Self {
        Self {
            msg: "Database error".to_string(),
            source: Some(Box::new(err)),
            code: StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

impl std::error::Error for FrontendError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        self.source
            .as_ref()
            .map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
    }
}

impl std::fmt::Display for FrontendError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.msg)?;
        if let Some(err) = std::error::Error::source(&self) {
            write!(f, ": {}", err)?;
        }

        Ok(())
    }
}

/// Filter the post according to the value of `user`.
///
/// Anonymous users cannot view private posts and protected locations;
/// Logged-in users can only view private posts targeted at them;
/// Logged-in users can't view private location data
#[tracing::instrument(skip(post), fields(post = %post))]
pub fn filter_post(
    mut post: serde_json::Value,
    user: Option<&url::Url>,
) -> Option<serde_json::Value> {
    if post["properties"]["deleted"][0].is_string() {
        tracing::debug!("Deleted post; returning tombstone instead");
        return Some(serde_json::json!({
            "type": post["type"],
            "properties": {
                "deleted": post["properties"]["deleted"]
            }
        }));
    }
    let empty_vec: Vec<serde_json::Value> = vec![];
    let author_list = post["properties"]["author"]
        .as_array()
        .unwrap_or(&empty_vec)
        .iter()
        .map(|i| -> &str {
            match i {
                serde_json::Value::String(ref author) => author.as_str(),
                mf2 => mf2["properties"]["uid"][0].as_str().unwrap()
            }
        })
        .map(|i| i.parse().unwrap())
        .collect::<Vec<url::Url>>();
    let visibility = post["properties"]["visibility"][0]
        .as_str()
        .unwrap_or("public");
    let audience = {
        let mut audience = author_list.clone();
        audience.extend(post["properties"]["audience"]
            .as_array()
            .unwrap_or(&empty_vec)
            .iter()
            .map(|i| i.as_str().unwrap().parse().unwrap()));

        audience
    };
    tracing::debug!("post audience = {:?}", audience);
    if (visibility == "private" && !audience.iter().any(|i| Some(i) == user))
        || (visibility == "protected" && user.is_none())
    {
        return None;
    }
    if post["properties"]["location"].is_array() {
        let location_visibility = post["properties"]["location-visibility"][0]
            .as_str()
            .unwrap_or("private");
        tracing::debug!("Post contains location, location privacy = {}", location_visibility);
        let mut author = post["properties"]["author"]
            .as_array()
            .unwrap_or(&empty_vec)
            .iter()
            .map(|i| i.as_str().unwrap().parse().unwrap());
        if (location_visibility == "private" && !author.any(|i| Some(&i) == user))
            || (location_visibility == "protected" && user.is_none())
        {
            post["properties"]
                .as_object_mut()
                .unwrap()
                .remove("location");
        }
    }

    match post["properties"]["author"].take() {
        serde_json::Value::Array(children) => {
            post["properties"]["author"] = serde_json::Value::Array(
                children
                    .into_iter()
                    .filter_map(|post| if post.is_string() {
                        Some(post)
                    } else {
                        filter_post(post, user)
                    })
                    .collect::<Vec<serde_json::Value>>()
            );
        },
        serde_json::Value::Null => {},
        other => post["properties"]["author"] = other
    }

    match post["children"].take() {
        serde_json::Value::Array(children) => {
            post["children"] = serde_json::Value::Array(
                children
                    .into_iter()
                    .filter_map(|post| filter_post(post, user))
                    .collect::<Vec<serde_json::Value>>()
            );
        },
        serde_json::Value::Null => {},
        other => post["children"] = other
    }
    Some(post)
}

async fn get_post_from_database<S: Storage>(
    db: &S,
    url: &str,
    after: Option<String>,
    user: Option<&url::Url>,
) -> std::result::Result<(serde_json::Value, Option<String>), FrontendError> {
    match db
        .read_feed_with_cursor(url, after.as_deref(), POSTS_PER_PAGE, user)
        .await
    {
        Ok(result) => match result {
            Some((post, cursor)) => match filter_post(post, user.as_deref()) {
                Some(post) => Ok((post, cursor)),
                None => {
                    // TODO: Authentication
                    if user.is_some() {
                        Err(FrontendError::with_code(
                            StatusCode::FORBIDDEN,
                            "User authenticated AND forbidden to access this resource",
                        ))
                    } else {
                        Err(FrontendError::with_code(
                            StatusCode::UNAUTHORIZED,
                            "User needs to authenticate themselves",
                        ))
                    }
                }
            }
            None => Err(FrontendError::with_code(
                StatusCode::NOT_FOUND,
                "Post not found in the database",
            )),
        },
        Err(err) => match err.kind() {
            crate::database::ErrorKind::PermissionDenied => {
                // TODO: Authentication
                if user.is_some() {
                    Err(FrontendError::with_code(
                        StatusCode::FORBIDDEN,
                        "User authenticated AND forbidden to access this resource",
                    ))
                } else {
                    Err(FrontendError::with_code(
                        StatusCode::UNAUTHORIZED,
                        "User needs to authenticate themselves",
                    ))
                }
            }
            _ => Err(err.into()),
        },
    }
}

#[tracing::instrument(skip(db))]
pub async fn homepage<D: Storage>(
    Host(host): Host,
    Query(query): Query<QueryParams>,
    State(db): State<D>,
) -> impl IntoResponse {
    let user = None; // TODO authentication
    // This is stupid, but there is no other way.
    let hcard_url: url::Url = format!("https://{}/", host).parse().unwrap();
    let feed_path = format!("https://{}/feeds/main", host);

    match tokio::try_join!(
        get_post_from_database(&db, &hcard_url.as_str(), None, user.as_ref()),
        get_post_from_database(&db, &feed_path, query.after, user.as_ref())
    ) {
        Ok(((hcard, _), (hfeed, cursor))) => {
            // Here, we know those operations can't really fail
            // (or it'll be a transient failure that will show up on
            // other requests anyway if it's serious...)
            //
            // btw is it more efficient to fetch these in parallel?
            let (blogname, webring, channels) = tokio::join!(
                db.get_setting::<crate::database::settings::SiteName>(&hcard_url)
                .map(Result::unwrap_or_default),

                db.get_setting::<crate::database::settings::Webring>(&hcard_url)
                .map(Result::unwrap_or_default),

                db.get_channels(&hcard_url).map(|i| i.unwrap_or_default())
            );
            // Render the homepage
            (
                StatusCode::OK,
                [(
                    axum::http::header::CONTENT_TYPE,
                    r#"text/html; charset="utf-8""#,
                )],
                Template {
                    title: blogname.as_ref(),
                    blog_name: blogname.as_ref(),
                    feeds: channels,
                    user: user.as_ref().map(url::Url::to_string),
                    content: MainPage {
                        feed: &hfeed,
                        card: &hcard,
                        cursor: cursor.as_deref(),
                        webring: crate::database::settings::Setting::into_inner(webring)
                    }
                    .to_string(),
                }
                .to_string(),
            )
        }
        Err(err) => {
            if err.code == StatusCode::NOT_FOUND {
                debug!("Transferring to onboarding...");
                // Transfer to onboarding
                (
                    StatusCode::FOUND,
                    [(axum::http::header::LOCATION, "/.kittybox/onboarding")],
                    String::default(),
                )
            } else {
                error!("Error while fetching h-card and/or h-feed: {}", err);
                // Return the error
                let (blogname, channels) = tokio::join!(
                    db.get_setting::<crate::database::settings::SiteName>(&hcard_url)
                    .map(Result::unwrap_or_default),

                    db.get_channels(&hcard_url).map(|i| i.unwrap_or_default())
                );

                (
                    err.code(),
                    [(
                        axum::http::header::CONTENT_TYPE,
                        r#"text/html; charset="utf-8""#,
                    )],
                    Template {
                        title: blogname.as_ref(),
                        blog_name: blogname.as_ref(),
                        feeds: channels,
                        user: user.as_ref().map(url::Url::to_string),
                        content: ErrorPage {
                            code: err.code(),
                            msg: Some(err.msg().to_string()),
                        }
                        .to_string(),
                    }
                    .to_string(),
                )
            }
        }
    }
}

#[tracing::instrument(skip(db))]
pub async fn catchall<D: Storage>(
    State(db): State<D>,
    Host(host): Host,
    Query(query): Query<QueryParams>,
    uri: Uri,
) -> impl IntoResponse {
    let user: Option<url::Url> = None; // TODO authentication
    let host = url::Url::parse(&format!("https://{}/", host)).unwrap();
    let path = host
        .clone()
        .join(uri.path())
        .unwrap();

    match get_post_from_database(&db, path.as_str(), query.after, user.as_ref()).await {
        Ok((post, cursor)) => {
            let (blogname, channels) = tokio::join!(
                db.get_setting::<crate::database::settings::SiteName>(&host)
                .map(Result::unwrap_or_default),

                db.get_channels(&host).map(|i| i.unwrap_or_default())
            );
            // Render the homepage
            (
                StatusCode::OK,
                [(
                    axum::http::header::CONTENT_TYPE,
                    r#"text/html; charset="utf-8""#,
                )],
                Template {
                    title: blogname.as_ref(),
                    blog_name: blogname.as_ref(),
                    feeds: channels,
                    user: user.as_ref().map(url::Url::to_string),
                    content: match post.pointer("/type/0").and_then(|i| i.as_str()) {
                        Some("h-entry") => Entry { post: &post }.to_string(),
                        Some("h-feed") => Feed { feed: &post, cursor: cursor.as_deref() }.to_string(),
                        Some("h-card") => VCard { card: &post }.to_string(),
                        unknown => {
                            unimplemented!("Template for MF2-JSON type {:?}", unknown)
                        }
                    },
                }
                .to_string(),
            )
        }
        Err(err) => {
            let (blogname, channels) = tokio::join!(
                db.get_setting::<crate::database::settings::SiteName>(&host)
                .map(Result::unwrap_or_default),

                db.get_channels(&host).map(|i| i.unwrap_or_default())
            );
            (
                err.code(),
                [(
                    axum::http::header::CONTENT_TYPE,
                    r#"text/html; charset="utf-8""#,
                )],
                Template {
                    title: blogname.as_ref(),
                    blog_name: blogname.as_ref(),
                    feeds: channels,
                    user: user.as_ref().map(url::Url::to_string),
                    content: ErrorPage {
                        code: err.code(),
                        msg: Some(err.msg().to_owned()),
                    }
                    .to_string(),
                }
                .to_string(),
            )
        }
    }
}