use crate::model::client::Client;
use axum::{
    http::HeaderMap,
    response::Redirect,
    routing::{get, post},
    Router,
};
use axum_extra::extract::{
    cookie::{Cookie, Expiration, Key},
    CookieJar, PrivateCookieJar,
};
use serde::{Deserialize, Serialize};
use sqlx::{pool::PoolOptions, sqlite::SqliteConnectOptions, SqlitePool};
use std::{
    collections::HashSet,
    fmt::Display,
    fs,
    path::Path,
    str::FromStr,
    sync::{Arc, LazyLock},
};
use time::{Date, Month, OffsetDateTime, Time};
use tower_http::services::ServeDir;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
use uuid::Uuid;

#[macro_use]
extern crate rust_i18n;

i18n!("locales", fallback = "en");

mod admin;
mod game;
mod index;
pub(crate) mod language;
pub(crate) mod model;
mod page;
pub(crate) mod random_names;

pub(crate) enum Backend {
    Sqlite(SqlitePool),
}

#[derive(Debug)]
enum Language {
    German,
    English,
}

impl Display for Language {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Language::German => f.write_str("de"),
            Language::English => f.write_str("en"),
        }
    }
}

impl Language {
    pub(crate) fn next_language(&self) -> Self {
        match self {
            Language::German => Language::English,
            Language::English => Language::German,
        }
    }

    fn to_locale(&self) -> &'static str {
        match self {
            Language::German => "de",
            Language::English => "en",
        }
    }
}

impl From<String> for Language {
    fn from(value: String) -> Self {
        if value.starts_with("de") {
            Language::German
        } else {
            Language::English
        }
    }
}

#[derive(Debug)]
struct Req {
    client: Client,
    lang: Language,
}

pub(crate) enum NameUpdateError {
    TooLong(usize, usize),
    TooShort(usize, usize),
    ContainsBadWord,
}

static BAD_WORDS: LazyLock<HashSet<String>> = LazyLock::new(|| {
    const BAD_WORDS_FILE: &str = include_str!("../bad/bad-list.txt");

    BAD_WORDS_FILE
        .lines()
        .map(|line| line.trim())
        .map(|word| word.to_lowercase())
        .collect()
});

fn contains_bad_word(text: &str) -> bool {
    // check parts of string, e.g. ABC_DEF checks both ABC and DEF
    let cleaned_text = text.to_lowercase();
    if cleaned_text
        .split(|c: char| !c.is_alphabetic())
        .any(|part| BAD_WORDS.iter().any(|bad| part == bad))
    {
        return true;
    }

    // check string as a whole, e.g. ABC_DEF checks for ABCDEF
    let cleaned_text: String = text
        .to_lowercase()
        .chars()
        .filter(|c| c.is_alphabetic())
        .collect();

    BAD_WORDS.iter().any(|bad_word| &cleaned_text == bad_word)
}

#[cfg(test)]
mod tests {
    use super::contains_bad_word;
    use std::fs;

    #[test]
    fn test_whitelist_words_are_not_flagged() {
        let whitelist_content =
            fs::read_to_string("bad/test-common-names.txt").expect("Failed to read  file");

        for (line_number, line) in whitelist_content.lines().enumerate() {
            let word = line.trim();

            // Skip empty lines
            if word.is_empty() {
                continue;
            }

            assert!(
                !contains_bad_word(word),
                "Word '{}' on line {} should not be flagged as bad but was detected",
                word,
                line_number + 1
            );
        }
    }
}

impl Backend {
    async fn client(&self, cookies: PrivateCookieJar) -> (PrivateCookieJar, Client) {
        let existing_uuid = cookies
            .get("client_id")
            .and_then(|cookie| Uuid::parse_str(cookie.value()).ok());

        match existing_uuid {
            Some(uuid) => (cookies, self.get_client(&uuid).await),
            None => {
                let new_id = Uuid::new_v4();
                let expiration_date = OffsetDateTime::new_utc(
                    Date::from_calendar_date(2025, Month::September, 7).unwrap(),
                    Time::from_hms(20, 0, 0).unwrap(),
                );
                let mut cookie = Cookie::new("client_id", new_id.to_string());
                cookie.set_expires(Expiration::DateTime(expiration_date));
                cookie.set_http_only(true);
                cookie.set_secure(true);

                let updated_cookies = cookies.add(cookie);
                (updated_cookies, self.get_client(&new_id).await)
            }
        }
    }

    // Combined method for getting both client and language
    async fn client_full(
        &self,
        cookies: PrivateCookieJar,
        lang_cookies: &CookieJar,
        headers: &HeaderMap,
    ) -> (PrivateCookieJar, Req) {
        let (cookies, client) = self.client(cookies).await;
        let lang = language::language(lang_cookies, headers);
        (cookies, Req { client, lang })
    }

    async fn set_client_name(&self, client: &Client, name: &str) -> Result<(), NameUpdateError> {
        if name.len() > 25 {
            return Err(NameUpdateError::TooLong(25, name.len()));
        }
        if name.len() < 3 {
            return Err(NameUpdateError::TooShort(3, name.len()));
        }
        if contains_bad_word(name) || self.is_name_banned(name).await {
            return Err(NameUpdateError::ContainsBadWord);
        }

        match self {
            Backend::Sqlite(db) => {
                sqlx::query!(
                    "UPDATE client SET name = ? WHERE uuid = ?;",
                    name,
                    client.uuid
                )
                .execute(db)
                .await
                .unwrap();
            }
        }

        Ok(())
    }

    async fn is_name_banned(&self, name: &str) -> bool {
        match self {
            Backend::Sqlite(db) => {
                let result = sqlx::query!("SELECT name FROM banned_names WHERE name = ?", name)
                    .fetch_optional(db)
                    .await
                    .unwrap();
                result.is_some()
            }
        }
    }

    async fn ban_name(&self, name: &str) -> Result<(), sqlx::Error> {
        match self {
            Backend::Sqlite(db) => {
                sqlx::query!("INSERT OR IGNORE INTO banned_names (name) VALUES (?)", name)
                    .execute(db)
                    .await?;
                Ok(())
            }
        }
    }

    async fn unban_name(&self, name: &str) -> Result<(), sqlx::Error> {
        match self {
            Backend::Sqlite(db) => {
                sqlx::query!("DELETE FROM banned_names WHERE name = ?", name)
                    .execute(db)
                    .await?;
                Ok(())
            }
        }
    }

    async fn get_banned_names(&self) -> Vec<String> {
        match self {
            Backend::Sqlite(db) => {
                let rows = sqlx::query!("SELECT name FROM banned_names ORDER BY banned_at DESC")
                    .fetch_all(db)
                    .await
                    .unwrap();
                rows.into_iter().map(|row| row.name).collect()
            }
        }
    }

    async fn replace_banned_names_with_asterisks(&self) -> Result<(), sqlx::Error> {
        match self {
            Backend::Sqlite(db) => {
                sqlx::query!(
                    "UPDATE client SET name = '***' WHERE name IN (SELECT name FROM banned_names)"
                )
                .execute(db)
                .await?;
                Ok(())
            }
        }
    }
}

#[derive(Clone)]
pub struct AppState {
    pub(crate) backend: Arc<Backend>,
    pub key: Key,
    pub admin_password: String,
}

impl axum::extract::FromRef<AppState> for Key {
    fn from_ref(state: &AppState) -> Self {
        state.key.clone()
    }
}

impl axum::extract::FromRef<AppState> for Arc<Backend> {
    fn from_ref(state: &AppState) -> Self {
        state.backend.clone()
    }
}

#[derive(Serialize, Deserialize)]
struct Config {
    key: Vec<u8>,
    admin_password: String,
}

impl Config {
    fn generate() -> Self {
        use rand::{distributions::Alphanumeric, thread_rng, Rng};
        let admin_password: String = thread_rng()
            .sample_iter(&Alphanumeric)
            .take(15)
            .map(char::from)
            .collect();

        Self {
            key: Key::generate().master().to_vec(),
            admin_password,
        }
    }
}

fn load_or_create_config() -> Result<(Key, Config), Box<dyn std::error::Error>> {
    let config_path = "config.toml";

    // Try to read existing config
    if Path::new(config_path).exists() {
        let content = fs::read_to_string(config_path)?;

        // Try to parse as complete config first
        if let Ok(config) = toml::from_str::<Config>(&content) {
            let key = Key::from(&config.key);
            return Ok((key, config));
        }

        // If that fails, try to parse just the key and generate new admin password
        #[derive(Deserialize)]
        struct PartialConfig {
            key: Vec<u8>,
        }

        if let Ok(partial_config) = toml::from_str::<PartialConfig>(&content) {
            use rand::{distributions::Alphanumeric, thread_rng, Rng};
            let admin_password: String = thread_rng()
                .sample_iter(&Alphanumeric)
                .take(15)
                .map(char::from)
                .collect();

            let config = Config {
                key: partial_config.key,
                admin_password,
            };

            // Write the updated config back
            let toml_string = toml::to_string(&config)?;
            fs::write(config_path, toml_string)?;

            let key = Key::from(&config.key);
            return Ok((key, config));
        }
    }

    // Create new config if file doesn't exist or parsing failed
    let config = Config::generate();
    let toml_string = toml::to_string(&config)?;
    fs::write(config_path, toml_string)?;
    let key = Key::from(&config.key);

    Ok((key, config))
}

async fn delete_personal_data(
    axum::extract::State(state): axum::extract::State<AppState>,
    cookies: PrivateCookieJar,
) -> (PrivateCookieJar, Redirect) {
    let backend = &state.backend;
    // Get the client from cookies
    if let Some(client_cookie) = cookies.get("client_id") {
        if let Ok(uuid) = Uuid::parse_str(client_cookie.value()) {
            // Delete all client data from database
            let _ = backend.delete_client_data(&uuid).await;
        }
    }

    // Remove the client_id cookie by setting an expired cookie
    let expired_cookie = Cookie::build(("client_id", ""))
        .expires(Expiration::DateTime(
            OffsetDateTime::now_utc() - time::Duration::days(1),
        ))
        .http_only(true)
        .secure(true)
        .build();

    let updated_cookies = cookies.add(expired_cookie);

    // Redirect back to privacy page with success message
    (updated_cookies, Redirect::to("/privacy?deleted=1"))
}

#[tokio::main]
async fn main() {
    tracing_subscriber::registry()
        .with(tracing_subscriber::fmt::layer())
        .with(EnvFilter::from_default_env())
        .init();

    let connection_options = SqliteConnectOptions::from_str("sqlite://db.sqlite").unwrap();
    let db: SqlitePool = PoolOptions::new()
        .connect_with(connection_options)
        .await
        .unwrap();

    let (key, config) = load_or_create_config().unwrap();

    // Print admin password for convenience
    tracing::info!("Admin password: {}", config.admin_password);

    let state = AppState {
        backend: Arc::new(Backend::Sqlite(db)),
        key,
        admin_password: config.admin_password,
    };

    let app = Router::new()
        .route("/", get(index::index))
        .route("/privacy", get(index::data))
        .route("/cam", get(index::cam))
        .route("/delete-data", post(delete_personal_data))
        .nest_service("/static", ServeDir::new("./static/serve"))
        .merge(game::routes())
        .merge(admin::routes())
        .with_state(state);

    // run our app with hyper, listening globally on port 3000
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
