mod asset;
mod config;
mod data;
mod error;
mod fragments;
mod pages;
mod session;

use crate::asset::{guess_mime, serve_asset};
pub use crate::config::Config;
use crate::config::Listen;
use crate::data::demo::{Demo, Filter, ListDemo};
use crate::data::maps::{map_list, MapList};
use crate::data::steam_id::SteamId;
use crate::data::user::User;
use crate::error::SetupError;
use crate::fragments::demo_list::DemoList;
use crate::pages::about::AboutPage;
use crate::pages::api::ApiPage;
use crate::pages::demo::{ClassIconsStyle, DemoPage};
use crate::pages::edit::{EditWasm, EditWorkerScript, EditorPage, EditorScript, EditorStyle};
use crate::pages::index::{DemoListScript, Index};
use crate::pages::profile::Profile;
use crate::pages::upload::{UploadPage, UploadScript};
use crate::pages::uploads::Uploads;
use crate::pages::viewer::{ParseWorkerScript, ParserWasm, ViewerPage, ViewerScript, ViewerStyle};
use crate::pages::{render, GlobalStyle};
use crate::session::{SessionData, COOKIE_NAME};
use async_session::{MemoryStore, Session, SessionStore};
use axum::extract::{connect_info, MatchedPath, Path, Query, RawQuery};
use axum::http::header::{CONTENT_TYPE, ETAG, LOCATION, SET_COOKIE};
use axum::http::{HeaderValue, Request, StatusCode};
use axum::response::IntoResponse;
use axum::{extract::State, routing::get, serve, Router};
use axum_extra::headers::{Cookie, Referer};
use axum_extra::TypedHeader;
use demostf_build::Asset;
pub use error::Error;
use hyper::body::Incoming;
use hyper::header::CACHE_CONTROL;
use hyper_util::{
    rt::{TokioExecutor, TokioIo},
    server,
};
use include_dir::{include_dir, Dir};
use maud::{Markup, Render};
use opentelemetry::trace::TracerProvider;
use opentelemetry::KeyValue;
use opentelemetry_otlp::{SpanExporter, WithExportConfig, WithTonicConfig};
use opentelemetry_sdk::trace::SdkTracerProvider;
use opentelemetry_sdk::Resource;
use reqwest::Url;
use secretfile::load;
use sqlx::PgPool;
use std::convert::Infallible;
use std::env::{args, var};
use std::fs::{remove_file, set_permissions, Permissions};
use std::net::SocketAddr;
use std::os::unix::fs::PermissionsExt;
use std::sync::Arc;
use steam_openid::SteamOpenId;
use tokio::net::unix::UCred;
use tokio::net::{UnixListener, UnixStream};
use tokio::select;
use tokio::signal::ctrl_c;
use tonic::codegen::Service;
use tonic::transport::{ClientTlsConfig, Identity};
use tower_http::trace::TraceLayer;
use tracing::{error, info, info_span, instrument};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer};
use urlencoding::Encoded;

pub type Result<T, E = Error> = std::result::Result<T, E>;

struct App {
    connection: PgPool,
    openid: SteamOpenId,
    api: String,
    maps: String,
    sync: String,
    map_list: MapList,
    url: String,
    pub session_store: MemoryStore,
}

#[derive(Asset)]
#[asset(source = "images/logo.png", url = "/images/logo.png")]
struct LogoPng;
#[derive(Asset)]
#[asset(source = "images/logo.svg", url = "/images/logo.svg")]
struct LogoSvg;

static KILL_ICONS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/images/kill_icons");

fn setup() -> Result<Config, SetupError> {
    let config = args()
        .nth(1)
        .as_deref()
        .map(Config::load)
        .transpose()?
        .or_else(Config::env)
        .ok_or(SetupError::NoConfigProvided)?;

    let open_telemetry = if let Some(tracing_cfg) = config
        .tracing
        .as_ref()
        .filter(|tracing_cfg| !tracing_cfg.endpoint.is_empty())
    {
        let mut tls_config = ClientTlsConfig::new().with_webpki_roots();

        let mut otlp_exporter = SpanExporter::builder()
            .with_tonic()
            .with_endpoint(&tracing_cfg.endpoint);

        if let Some(tracing_ident) = tracing_cfg.tls.as_ref().map(|tracing_tls_cfg| {
            let key = load(&tracing_tls_cfg.key_file)?;
            let cert = load(&tracing_tls_cfg.cert_file)?;
            Result::<_, SetupError>::Ok(Identity::from_pem(cert, key))
        }) {
            tls_config = tls_config.identity(tracing_ident?);
        }
        otlp_exporter = otlp_exporter.with_tls_config(tls_config);

        let tracer = SdkTracerProvider::builder()
            .with_resource(
                Resource::builder()
                    .with_attribute(KeyValue::new("service.name", "demos.tf"))
                    .build(),
            )
            .with_batch_exporter(otlp_exporter.build()?)
            .build()
            .tracer("demos.tf");
        Some(tracing_opentelemetry::layer().with_tracer(tracer))
    } else {
        None
    };

    tracing_subscriber::registry()
        .with(EnvFilter::new(var("RUST_LOG").unwrap_or_else(|_| {
            "demostf_frontend=debug,tower_http=debug,sqlx=debug".into()
        })))
        .with(open_telemetry)
        .with(tracing_subscriber::fmt::layer().with_filter(EnvFilter::new(
            var("RUST_LOG").unwrap_or_else(|_| "warn,demostf_frontend=info".into()),
        )))
        .try_init()?;

    Ok(config)
}

#[tokio::main]
async fn main() -> Result<()> {
    let config = setup()?;
    let connection = config.database.connect().await?;

    let map_list = map_list(&connection).await?;
    let session_store = MemoryStore::new();

    let state = Arc::new(App {
        connection,
        openid: SteamOpenId::new(&config.site.url, "/login/callback")
            .expect("invalid steam login url"),
        api: config.site.api,
        maps: config.site.maps,
        sync: config.site.sync,
        url: config.site.url,
        map_list,
        session_store: session_store.clone(),
    });

    let app = Router::new()
        .route("/", get(index))
        .route("/uploads/{uploader}", get(uploads))
        .route("/profiles/{uploader}", get(profiles))
        .route(GlobalStyle::route(), get(serve_asset::<GlobalStyle>))
        .route(
            ClassIconsStyle::route(),
            get(serve_asset::<ClassIconsStyle>),
        )
        .route(UploadScript::route(), get(serve_asset::<UploadScript>))
        .route(DemoListScript::route(), get(serve_asset::<DemoListScript>))
        .route(ViewerScript::route(), get(serve_asset::<ViewerScript>))
        .route(ViewerStyle::route(), get(serve_asset::<ViewerStyle>))
        .route(
            ParseWorkerScript::route(),
            get(serve_asset::<ParseWorkerScript>),
        )
        .route(ParserWasm::route(), get(serve_asset::<ParserWasm>))
        .route(EditorScript::route(), get(serve_asset::<EditorScript>))
        .route(EditorStyle::route(), get(serve_asset::<EditorStyle>))
        .route(
            EditWorkerScript::route(),
            get(serve_asset::<EditWorkerScript>),
        )
        .route(EditWasm::route(), get(serve_asset::<EditWasm>))
        .route(LogoPng::route(), get(serve_asset::<LogoPng>))
        .route(LogoSvg::route(), get(serve_asset::<LogoSvg>))
        .route("/fragments/demo-list", get(demo_list))
        .route("/about", get(about))
        .route("/api", get(api))
        .route("/login/callback", get(login_callback))
        .route("/login/callback/{return_to}", get(login_callback))
        .route("/login", get(login))
        .route("/logout", get(logout))
        .route("/upload", get(upload))
        .route("/viewer", get(viewer))
        .route("/edit", get(edit))
        .route("/viewer/{id}", get(viewer))
        .route("/{id}", get(demo))
        .route("/images/kill_icons/{icon}", get(kill_icons))
        .layer(
            TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
                let matched_path = request
                    .extensions()
                    .get::<MatchedPath>()
                    .map(MatchedPath::as_str);

                info_span!(
                    "http_request",
                    method = ?request.method(),
                    matched_path,
                    some_other_field = tracing::field::Empty,
                )
            }),
        )
        .fallback(handler_404)
        .with_state(state);

    match config.listen {
        Listen::Tcp { address, port } => {
            let service = app.into_make_service();
            let addr = SocketAddr::from((address, port));
            let listener = tokio::net::TcpListener::bind(addr).await?;
            info!("listening on http://{}", listener.local_addr()?);
            let ctrl_c = async {
                ctrl_c().await.expect("failed to install Ctrl+C handler");
            };
            serve(listener, service)
                .with_graceful_shutdown(ctrl_c)
                .await?;
        }
        Listen::Socket { path } => {
            info!("listening on {}", path.display());
            if path.exists() {
                remove_file(&path)?;
            }
            let uds = UnixListener::bind(path.clone())?;
            set_permissions(&path, Permissions::from_mode(0o666))?;

            #[derive(Clone, Debug)]
            #[allow(dead_code)]
            struct UdsConnectInfo {
                peer_addr: Arc<tokio::net::unix::SocketAddr>,
                peer_cred: UCred,
            }

            impl connect_info::Connected<&UnixStream> for UdsConnectInfo {
                fn connect_info(target: &UnixStream) -> Self {
                    let peer_addr = target.peer_addr().unwrap();
                    let peer_cred = target.peer_cred().unwrap();

                    Self {
                        peer_addr: Arc::new(peer_addr),
                        peer_cred,
                    }
                }
            }

            let mut make_service = app.into_make_service_with_connect_info::<UdsConnectInfo>();

            // See https://github.com/tokio-rs/axum/blob/main/examples/serve-with-hyper/src/main.rs for
            // more details about this setup
            loop {
                let (socket, _remote_addr) = select! {
                    result = uds.accept() => {
                        result?
                    },
                    _ = ctrl_c() => {
                        break;
                    }
                };

                let tower_service = unwrap_infallible(make_service.call(&socket).await);

                tokio::spawn(async move {
                    let socket = TokioIo::new(socket);

                    let hyper_service =
                        hyper::service::service_fn(move |request: Request<Incoming>| {
                            tower_service.clone().call(request)
                        });

                    if let Err(err) = server::conn::auto::Builder::new(TokioExecutor::new())
                        .serve_connection_with_upgrades(socket, hyper_service)
                        .await
                    {
                        eprintln!("failed to serve connection: {err:#}");
                    }
                });
            }
        }
    }

    Ok(())
}

#[instrument(skip(app))]
#[axum::debug_handler]
async fn index(
    State(app): State<Arc<App>>,
    session: SessionData,
    Query(filter): Query<Filter>,
) -> Result<Markup> {
    let demos = ListDemo::list(&app.connection, filter).await?;
    Ok(render(
        Index {
            demos: DemoList::new(&demos),
            maps: &app.map_list,
            api: &app.api,
        },
        session,
    ))
}

#[instrument(skip(_app))]
#[axum::debug_handler]
async fn about(State(_app): State<Arc<App>>, session: SessionData) -> Result<Markup> {
    Ok(render(
        AboutPage {
            key: session.token(),
        },
        session,
    ))
}

#[instrument(skip(app))]
#[axum::debug_handler]
async fn api(State(app): State<Arc<App>>, session: SessionData) -> Result<Markup> {
    Ok(render(
        ApiPage {
            steam_id: session.steam_id().unwrap_or(SteamId::Id(76561198024494988)),
            api_base: &app.api,
        },
        session,
    ))
}

#[instrument(skip(app))]
#[axum::debug_handler]
async fn demo(
    State(app): State<Arc<App>>,
    Path(id): Path<String>,
    session: SessionData,
) -> Result<Markup> {
    let id = id.parse().map_err(|_| Error::NotFound)?;
    let demo = Demo::by_id(&app.connection, id)
        .await?
        .ok_or(Error::NotFound)?;
    Ok(render(DemoPage { demo }, session))
}

#[axum::debug_handler]
async fn login_callback(
    State(app): State<Arc<App>>,
    RawQuery(query): RawQuery,
    return_to: Option<Path<String>>,
) -> Result<impl IntoResponse> {
    let referrer_openid = return_to
        .as_ref()
        .map(|return_to| format!("/login/callback/{}", Encoded(return_to.as_str())))
        .map(|callback| SteamOpenId::new(&app.url, &callback).ok())
        .flatten();
    let openid = referrer_openid.as_ref().unwrap_or(&app.openid);

    let query = query.as_deref().unwrap_or_default();
    let steam_id = openid.verify(query).await.map_err(|e| {
        error!("{e:?}");
        Error::SteamAuth
    })?;
    info!(steam_id, "received steam login callback");
    let steam_id = SteamId::new(steam_id);
    let user = User::get(&app.connection, steam_id).await?;
    let mut session = Session::new();
    session
        .insert("user", user)
        .expect("failed to serialize user");
    let cookie = app
        .session_store
        .store_session(session)
        .await?
        .unwrap_or_default();

    let return_to = return_to
        .as_ref()
        .map(|Path(return_to)| return_to)
        .map(|return_to| HeaderValue::from_str(&return_to).ok())
        .flatten()
        .unwrap_or(HeaderValue::from_static("/"));

    Ok((
        StatusCode::FOUND,
        [
            (
                SET_COOKIE,
                HeaderValue::from_str(&format!(
                    "{}={}; HttpOnly; SameSite=Lax; Path=/",
                    COOKIE_NAME, cookie
                ))
                .expect("invalid cookie"),
            ),
            (LOCATION, return_to),
        ],
    ))
}

#[instrument(skip(app))]
#[axum::debug_handler]
async fn login(
    State(app): State<Arc<App>>,
    referrer: Option<TypedHeader<Referer>>,
) -> impl IntoResponse {
    let referrer = referrer
        .map(|TypedHeader(referrer)| referrer.to_string())
        .map(|referrer| Url::parse(&referrer).ok())
        .flatten();

    let referrer_openid = referrer
        .as_ref()
        .map(|referrer| format!("/login/callback/{}", Encoded(referrer.path())))
        .map(|callback| SteamOpenId::new(&app.url, &callback).ok())
        .flatten();
    let openid = referrer_openid.as_ref().unwrap_or(&app.openid);
    (
        StatusCode::FOUND,
        [(
            LOCATION,
            HeaderValue::from_str(openid.get_redirect_url()).unwrap(),
        )],
        "",
    )
}

#[instrument(skip(app, cookie))]
#[axum::debug_handler]
async fn logout(
    State(app): State<Arc<App>>,
    cookie: Option<TypedHeader<Cookie>>,
    referrer: Option<TypedHeader<Referer>>,
) -> impl IntoResponse {
    let referrer = referrer
        .map(|TypedHeader(referrer)| referrer.to_string())
        .map(|referrer| Url::parse(&referrer).ok())
        .flatten();
    let return_to = referrer.as_ref().map(|url| url.path()).unwrap_or("/");

    if let Some(session_cookie) = cookie.as_deref().and_then(|cookie| cookie.get(COOKIE_NAME)) {
        if let Ok(Some(cookie)) = app.session_store.load_session(session_cookie.into()).await {
            let _ = app.session_store.destroy_session(cookie).await;
        }
    }
    (
        StatusCode::FOUND,
        [
            (
                SET_COOKIE,
                HeaderValue::from_str(&format!(
                    "{}=; HttpOnly; SameSite=Lax; expires=Thu, 01 Jan 1970 00:00:00 GMT",
                    COOKIE_NAME
                ))
                .expect("invalid cookie"),
            ),
            (LOCATION, HeaderValue::from_str(return_to).unwrap()),
        ],
    )
}

#[instrument(skip(app))]
#[axum::debug_handler]
async fn upload(State(app): State<Arc<App>>, session: SessionData) -> impl IntoResponse {
    if let Some(token) = session.token() {
        render(
            UploadPage {
                key: &token,
                api: app.api.as_str(),
            },
            session,
        )
        .into_response()
    } else {
        (
            StatusCode::FOUND,
            [(LOCATION, HeaderValue::from_str("/").unwrap())],
        )
            .into_response()
    }
}

#[instrument(skip(app))]
#[axum::debug_handler]
async fn demo_list(State(app): State<Arc<App>>, Query(filter): Query<Filter>) -> Result<Markup> {
    let demos = ListDemo::list(&app.connection, filter).await?;
    Ok(DemoList { demos: &demos }.render())
}

#[instrument(skip(app))]
#[axum::debug_handler]
async fn uploads(
    State(app): State<Arc<App>>,
    session: SessionData,
    Query(mut filter): Query<Filter>,
    Path(uploader): Path<SteamId>,
) -> Result<Markup> {
    filter.uploader = Some(uploader.clone());

    let demos = ListDemo::list(&app.connection, filter).await?;
    let user = User::get(&app.connection, uploader)
        .await
        .map_err(|_| Error::NotFound)?;
    Ok(render(
        Uploads {
            user,
            demos: DemoList::new(&demos),
            maps: &app.map_list,
            api: &app.api,
        },
        session,
    ))
}

#[instrument(skip(app))]
#[axum::debug_handler]
async fn profiles(
    State(app): State<Arc<App>>,
    session: SessionData,
    Query(mut filter): Query<Filter>,
    Path(profile): Path<SteamId>,
) -> Result<Markup> {
    filter.players.push(profile.clone());

    let demos = ListDemo::list(&app.connection, filter).await?;
    let user = User::get(&app.connection, profile)
        .await
        .map_err(|_| Error::NotFound)?;
    Ok(render(
        Profile {
            user,
            demos: DemoList::new(&demos),
            maps: &app.map_list,
            api: &app.api,
        },
        session,
    ))
}

#[instrument(skip(app))]
#[axum::debug_handler]
async fn viewer(
    State(app): State<Arc<App>>,
    id: Option<Path<String>>,
    session: SessionData,
) -> Result<Markup> {
    let demo = if let Some(Path(id)) = id {
        let id = id.parse().map_err(|_| Error::NotFound)?;
        Some(
            Demo::by_id(&app.connection, id)
                .await?
                .ok_or(Error::NotFound)?,
        )
    } else {
        None
    };
    Ok(render(
        ViewerPage {
            demo,
            maps: &app.maps,
            sync: &app.sync,
        },
        session,
    ))
}

#[axum::debug_handler]
async fn edit(State(_app): State<Arc<App>>, session: SessionData) -> Result<Markup> {
    Ok(render(EditorPage, session))
}

#[axum::debug_handler]
async fn handler_404() -> impl IntoResponse {
    Error::NotFound
}

#[axum::debug_handler]
pub async fn kill_icons(path: Path<String>) -> impl IntoResponse {
    let path = path.as_str();
    match KILL_ICONS.get_file(path) {
        Some(file) => (
            [
                (
                    CONTENT_TYPE,
                    HeaderValue::from_str(guess_mime(path)).unwrap(),
                ),
                (ETAG, HeaderValue::from_static("theseshouldbefullystatic")),
                (
                    CACHE_CONTROL,
                    HeaderValue::from_static("public, max-age=2592000, immutable"),
                ),
            ],
            file.contents(),
        )
            .into_response(),
        None => StatusCode::NOT_FOUND.into_response(),
    }
}

fn unwrap_infallible<T>(result: Result<T, Infallible>) -> T {
    match result {
        Ok(value) => value,
        Err(err) => match err {},
    }
}
