Skip to content
#![warn(clippy::pedantic)]
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::missing_panics_doc)]
#![allow(clippy::module_name_repetitions)]
#![allow(clippy::must_use_candidate)]

use actix_web::{App, HttpResponse, HttpServer, Responder, get, web};

mod assets;
mod auth;
mod battle_ticket;
mod config;
mod db;
mod models;
mod pokeapi;
mod session;
mod view;

use db::{DuckDb, SqliteDb};
use session::SessionStore;

#[get("/up")]
async fn up() -> impl Responder {
    HttpResponse::Ok().body("OK")
}

fn init_logging() {
    let mut builder = env_logger::Builder::from_env(
        env_logger::Env::default().default_filter_or("info,duckdb=warn,libsql=warn,turso=warn"),
    );
    let env_logger = builder.build();
    let _ = log::set_boxed_logger(Box::new(env_logger));
    log::set_max_level(log::LevelFilter::Debug);
}

fn spawn_duckdb_init(db: DuckDb) {
    actix_web::rt::spawn(async move {
        loop {
            match db.init().await {
                Ok(()) => {
                    log::info!("DuckDB initialized successfully");
                    if let Err(e) = db.seed_if_empty().await {
                        log::warn!("DuckDB seed warning: {e}");
                    }
                    return;
                }
                Err(e) => {
                    log::warn!("DuckDB init failed (retrying in 1s): {e}");
                    actix_web::rt::time::sleep(std::time::Duration::from_secs(1)).await;
                }
            }
        }
    });
}

fn spawn_sqlite_init(db: SqliteDb) {
    actix_web::rt::spawn(async move {
        loop {
            match db.init().await {
                Ok(()) => {
                    log::info!("SQLite initialized successfully");
                    return;
                }
                Err(e) => {
                    log::warn!("SQLite init failed (retrying in 1s): {e}");
                    actix_web::rt::time::sleep(std::time::Duration::from_secs(1)).await;
                }
            }
        }
    });
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    init_logging();
    let config = config::from_env();
    config.maybe_reset_databases();

    let duck_db = DuckDb::new(&config.duckdb_path).map_err(std::io::Error::other)?;
    spawn_duckdb_init(duck_db.clone());

    let sqlite_db = SqliteDb::new(&config.sqlite_path);
    spawn_sqlite_init(sqlite_db.clone());

    let port = config.port;
    let config_data = web::Data::new(config);
    let session_store = web::Data::new(SessionStore::new());
    let duck_data = web::Data::new(duck_db);
    let sqlite_data = web::Data::new(sqlite_db);

    log::info!("Starting Pokédex & AutoBattler server on port {port}...");

    HttpServer::new(move || {
        App::new()
            .app_data(config_data.clone())
            .app_data(session_store.clone())
            .app_data(duck_data.clone())
            .app_data(sqlite_data.clone())
            .service(up)
            .service(assets::serve_asset)
            .service(view::index)
            .service(view::pokedex_tab)
            .service(view::pokedex_search)
            .service(view::pokedex_page)
            .service(view::pokemon_detail)
            .service(view::battler_tab)
            .service(view::battler_ticket)
            .service(view::battle_entry)
            .service(view::debug_tab)
            .service(view::verify_pin)
            .service(view::logout)
            .service(view::debug_sync)
    })
    .bind(("0.0.0.0", port))?
    .run()
    .await
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::session::SESSION_COOKIE_NAME;
    use actix_web::test as aw_test;

    async fn create_test_databases() -> (config::Config, DuckDb, SqliteDb) {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let config = config::Config::new(
            8090,
            None,
            ":memory:".to_string(),
            format!("/tmp/test_main_{nanos}.db"),
            "123456".to_string(),
        );

        let duck_db = DuckDb::new(":memory:").unwrap();
        duck_db.init().await.unwrap();
        duck_db.seed_test_data().await.unwrap();

        let sqlite_db = SqliteDb::new(&config.sqlite_path);
        sqlite_db.init().await.unwrap();

        (config, duck_db, sqlite_db)
    }

    macro_rules! init_test_app {
        ($config:expr, $duck_db:expr, $sqlite_db:expr) => {
            aw_test::init_service(
                App::new()
                    .app_data(web::Data::new($config.clone()))
                    .app_data(web::Data::new(SessionStore::new()))
                    .app_data(web::Data::new($duck_db.clone()))
                    .app_data(web::Data::new($sqlite_db.clone()))
                    .service(up)
                    .service(assets::serve_asset)
                    .service(view::index)
                    .service(view::pokedex_tab)
                    .service(view::pokedex_search)
                    .service(view::pokedex_page)
                    .service(view::pokemon_detail)
                    .service(view::battler_tab)
                    .service(view::battler_ticket)
                    .service(view::battle_entry)
                    .service(view::debug_tab)
                    .service(view::verify_pin)
                    .service(view::logout)
                    .service(view::debug_sync),
            )
            .await
        };
    }

    #[actix_web::test]
    async fn test_up_endpoint() {
        let (config, duck_db, sqlite_db) = create_test_databases().await;
        let app = init_test_app!(config, duck_db, sqlite_db);
        let req = aw_test::TestRequest::get().uri("/up").to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert!(resp.status().is_success());
    }

    #[actix_web::test]
    async fn test_home_and_pokedex_view() {
        let (config, duck_db, sqlite_db) = create_test_databases().await;
        let app = init_test_app!(config, duck_db, sqlite_db);

        let req = aw_test::TestRequest::get().uri("/").to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert!(resp.status().is_success());

        let body = aw_test::read_body(resp).await;
        let body_str = String::from_utf8(body.to_vec()).unwrap();
        assert!(body_str.contains("Pokédex"));
        assert!(body_str.contains("Bulbasaur"));
        assert!(body_str.contains("/assets/h.js"));
        assert!(body_str.contains("/assets/app.css"));
    }

    #[actix_web::test]
    async fn test_pokedex_search_endpoint() {
        let (config, duck_db, sqlite_db) = create_test_databases().await;
        let app = init_test_app!(config, duck_db, sqlite_db);

        let req = aw_test::TestRequest::get()
            .uri("/pokedex/search?q=pikachu")
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert!(resp.status().is_success());

        let body = aw_test::read_body(resp).await;
        let body_str = String::from_utf8(body.to_vec()).unwrap();
        assert!(body_str.contains("Pikachu"));
        assert!(!body_str.contains("Bulbasaur"));
    }

    #[actix_web::test]
    async fn test_pokemon_detail_modal() {
        let (config, duck_db, sqlite_db) = create_test_databases().await;
        let app = init_test_app!(config, duck_db, sqlite_db);

        // Direct full page load
        let req = aw_test::TestRequest::get().uri("/pokemon/1").to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert!(resp.status().is_success());

        let body = aw_test::read_body(resp).await;
        let body_str = String::from_utf8(body.to_vec()).unwrap();
        assert!(body_str.contains("Bulbasaur"));
        assert!(body_str.contains("Seed Pokémon"));
        assert!(body_str.contains("Combat Characteristics"));
        assert!(body_str.contains("Level-Up Moves"));
        assert!(body_str.contains("Back to Pokédex"));

        // HTMX fragment request
        let req_htmx = aw_test::TestRequest::get()
            .uri("/pokemon/1")
            .insert_header(("hx-request", "true"))
            .to_request();
        let resp_htmx = aw_test::call_service(&app, req_htmx).await;
        assert!(resp_htmx.status().is_success());
        let body_htmx = aw_test::read_body(resp_htmx).await;
        let body_htmx_str = String::from_utf8(body_htmx.to_vec()).unwrap();
        assert!(body_htmx_str.contains("Bulbasaur"));
        assert!(body_htmx_str.contains("Back to Pokédex"));
        // Fragment should not contain DOCTYPE or full page shell
        assert!(!body_htmx_str.contains("<!DOCTYPE html>"));
    }

    #[actix_web::test]
    async fn test_battler_coming_soon() {
        let (config, duck_db, sqlite_db) = create_test_databases().await;
        let app = init_test_app!(config, duck_db, sqlite_db);

        let req = aw_test::TestRequest::get().uri("/battler").to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert!(resp.status().is_success());

        let body = aw_test::read_body(resp).await;
        let body_str = String::from_utf8(body.to_vec()).unwrap();
        assert!(body_str.contains("AutoBattler Arena"));
        assert!(
            body_str.to_lowercase().contains("coming soon") || body_str.contains("Comming soon")
        );
        // Generated QR code and player PIN under it
        assert!(body_str.contains("<svg"));
        assert!(body_str.contains("Player Battle PIN"));

        // Test dynamic ticket generation
        let req_ticket = aw_test::TestRequest::get()
            .uri("/battler/ticket")
            .to_request();
        let resp_ticket = aw_test::call_service(&app, req_ticket).await;
        assert!(resp_ticket.status().is_success());
        let body_ticket = aw_test::read_body(resp_ticket).await;
        let body_ticket_str = String::from_utf8(body_ticket.to_vec()).unwrap();
        assert!(body_ticket_str.contains("<svg"));
        assert!(body_ticket_str.contains("Player Battle PIN"));

        // Test player arriving at battle entry says coming soon
        let req_entry = aw_test::TestRequest::get()
            .uri("/battle?pin=987654")
            .to_request();
        let resp_entry = aw_test::call_service(&app, req_entry).await;
        assert!(resp_entry.status().is_success());
        let body_entry = aw_test::read_body(resp_entry).await;
        let body_entry_str = String::from_utf8(body_entry.to_vec()).unwrap();
        assert!(body_entry_str.to_lowercase().contains("coming soon"));
        assert!(body_entry_str.contains("987654"));
    }

    #[actix_web::test]
    async fn test_debug_guard_and_unlock() {
        let (config, duck_db, sqlite_db) = create_test_databases().await;
        let sqlite_path = config.sqlite_path.clone();
        let app = init_test_app!(config, duck_db, sqlite_db);

        // Unauthenticated debug returns guard
        let req = aw_test::TestRequest::get().uri("/debug").to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert!(resp.status().is_success());

        let body = aw_test::read_body(resp).await;
        let body_str = String::from_utf8(body.to_vec()).unwrap();
        assert!(body_str.contains("Admin Access"));
        assert!(body_str.contains("Unlock Admin Area"));

        // Valid pin unlocks debug
        let req_pin = aw_test::TestRequest::post()
            .uri("/pin")
            .set_form(view::PinForm {
                pin: "123456".to_string(),
            })
            .to_request();
        let resp_pin = aw_test::call_service(&app, req_pin).await;
        assert!(resp_pin.status().is_success());

        let cookie = resp_pin
            .response()
            .cookies()
            .find(|c| c.name() == SESSION_COOKIE_NAME);
        assert!(cookie.is_some());

        let body_pin = aw_test::read_body(resp_pin).await;
        let body_pin_str = String::from_utf8(body_pin.to_vec()).unwrap();
        assert!(body_pin_str.contains("Architecture Explorer"));
        assert!(body_pin_str.contains("DuckDB Database Engine"));
        assert!(body_pin_str.contains("SQLite Database Engine"));

        let _ = std::fs::remove_file(&sqlite_path);
    }
}