use std::{fs, net::SocketAddr};

use clap::Parser;

use color_eyre::eyre::{Context, Result};
#[cfg(feature = "mysql")]
use diesel::mysql::MysqlConnection;
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
use futures_channel::mpsc::unbounded;
use log::LevelFilter;
use simplelog::{
    ColorChoice, CombinedLogger, Config as LogConfig, TermLogger, TerminalMode, WriteLogger,
};
use tokio::net::{TcpListener, TcpStream};

use futures_util::StreamExt;
use mendia::api::{MendiaReply, MendiaRequest, Session};

#[cfg(feature = "mysql")]
use mendia::db::{Movie, User};
use mendia::{
    notify::NotifySender,
    routes::{add_movies, login, movies},
    settings::{DatabaseConfig, Settings},
    stats, telegram, whatsapp,
};

#[cfg(feature = "mysql")]
const MYSQL_MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
const SQLITE_MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations-sqlite");

#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
    #[arg(long, short)]
    config: String,

    /// Enable debug-level logging to the console (always written to log file).
    #[arg(long, short = 'd')]
    debug: bool,

    /// Migrate all data from the configured `MySQL` database to a new `SQLite` file.
    /// After migration, update the config to use `database: !sqlite <path>`
    /// and restart the server.
    #[cfg(feature = "mysql")]
    #[arg(long, value_name = "OUTPUT_PATH")]
    migrate_to_sqlite: Option<String>,
}

#[tokio::main]
async fn main() -> Result<()> {
    color_eyre::install()?;

    let args = Args::parse();
    init_logging(args.debug, &args.config)?;
    let settings: Settings = serde_yaml::from_str(
        &fs::read_to_string(&args.config)
            .wrap_err(format!("The file '{}' does not exist.", args.config))?,
    )
    .wrap_err("Failed to read settings from file.")?;

    #[cfg(feature = "mysql")]
    if let Some(sqlite_path) = args.migrate_to_sqlite {
        return migrate_to_sqlite(&settings, &sqlite_path);
    }

    #[cfg(feature = "mysql")]
    if matches!(settings.database, DatabaseConfig::Mysql(_)) {
        eprintln!(
            "WARNING: MySQL support is deprecated and will be removed in a future release. \
             Please migrate to SQLite using --migrate-to-sqlite."
        );
    }

    // Run pending migrations before starting the server.
    run_migrations(&settings.database)?;

    let connection_string = format!("{}:{}", settings.websocket.host, settings.websocket.port);
    let listener = TcpListener::bind(connection_string.clone()).await?;

    // Start optional notification backends.
    let main_languages = settings.notifications.main_languages.clone();
    let telegram_tx: Option<NotifySender> = settings
        .telegram
        .clone()
        .map(|cfg| telegram::start(cfg, settings.tmdb.clone(), main_languages.clone()));

    let whatsapp_tx: Option<NotifySender> = if let Some(cfg) = settings.whatsapp.clone() {
        match whatsapp::start(cfg, settings.tmdb.clone(), main_languages).await {
            Ok(tx) => Some(tx),
            Err(e) => {
                log::error!("Failed to start WhatsApp bot: {e}");
                None
            }
        }
    } else {
        None
    };

    stats::start(settings.clone(), telegram_tx.clone(), whatsapp_tx.clone()).await;

    log::info!("Awaiting connections on ws://{}", connection_string);
    while let Ok((stream, addr)) = listener.accept().await {
        tokio::spawn(handle_connection(
            settings.clone(),
            stream,
            addr,
            telegram_tx.clone(),
            whatsapp_tx.clone(),
        ));
    }
    log::info!("Shutting down");
    Ok(())
}

fn run_migrations(config: &DatabaseConfig) -> Result<()> {
    match config {
        #[cfg(feature = "mysql")]
        DatabaseConfig::Mysql(endpoint) => {
            let url = format!(
                "mysql://{}:{}@{}:{}/{}",
                endpoint.credentials.user,
                endpoint.credentials.password,
                endpoint.host,
                endpoint.port,
                endpoint.database,
            );
            MysqlConnection::establish(&url)
                .wrap_err("Failed to connect to MySQL for migrations")?
                .run_pending_migrations(MYSQL_MIGRATIONS)
                .map_err(|e| color_eyre::eyre::eyre!("MySQL migration failed: {e}"))?;
        }
        DatabaseConfig::Sqlite(path) => {
            SqliteConnection::establish(path)
                .wrap_err("Failed to open SQLite DB for migrations")?
                .run_pending_migrations(SQLITE_MIGRATIONS)
                .map_err(|e| color_eyre::eyre::eyre!("SQLite migration failed: {e}"))?;
        }
    }
    Ok(())
}

/// Copy all movies and users from the `MySQL` database (as configured) into a new `SQLite` file.
#[cfg(feature = "mysql")]
fn migrate_to_sqlite(settings: &Settings, sqlite_path: &str) -> Result<()> {
    let DatabaseConfig::Mysql(_) = &settings.database else {
        color_eyre::eyre::bail!("--migrate-to-sqlite requires the config to use a MySQL database");
    };

    println!("Connecting to MySQL…");
    let mut mysql_conn = connect_mysql_blocking(&settings.database)?;

    use mendia::schema::{movies, users};
    let all_movies: Vec<Movie> = movies::table
        .load::<Movie>(&mut mysql_conn)
        .wrap_err("Failed to load movies from MySQL")?;
    let all_users: Vec<User> = users::table
        .load::<User>(&mut mysql_conn)
        .wrap_err("Failed to load users from MySQL")?;

    println!(
        "Loaded {} movie(s) and {} user(s) from MySQL.",
        all_movies.len(),
        all_users.len()
    );

    println!("Setting up SQLite at '{sqlite_path}'…");
    let mut sqlite_conn =
        SqliteConnection::establish(sqlite_path).wrap_err("Failed to open SQLite file")?;
    sqlite_conn
        .run_pending_migrations(SQLITE_MIGRATIONS)
        .map_err(|e| color_eyre::eyre::eyre!("SQLite migration failed: {e}"))?;

    diesel::insert_or_ignore_into(movies::table)
        .values(&all_movies)
        .execute(&mut sqlite_conn)
        .wrap_err("Failed to insert movies into SQLite")?;
    diesel::replace_into(users::table)
        .values(&all_users)
        .execute(&mut sqlite_conn)
        .wrap_err("Failed to insert users into SQLite")?;

    println!("Migration complete.");
    println!("Update your config to:");
    println!("  database: !sqlite {sqlite_path}");
    println!("Then restart the server.");
    Ok(())
}

#[cfg(feature = "mysql")]
fn connect_mysql_blocking(config: &DatabaseConfig) -> Result<MysqlConnection> {
    let DatabaseConfig::Mysql(endpoint) = config else {
        unreachable!()
    };
    let url = format!(
        "mysql://{}:{}@{}:{}/{}",
        endpoint.credentials.user,
        endpoint.credentials.password,
        endpoint.host,
        endpoint.port,
        endpoint.database,
    );
    MysqlConnection::establish(&url).wrap_err("Failed to connect to MySQL")
}

fn init_logging(debug: bool, config_path: &str) -> Result<()> {
    let log_dir = std::path::Path::new(config_path)
        .parent()
        .filter(|p| !p.as_os_str().is_empty())
        .unwrap_or(std::path::Path::new("."));
    std::fs::create_dir_all(log_dir)?;

    let log_file = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(log_dir.join("mendia.log"))?;

    let console_level = if debug {
        LevelFilter::Debug
    } else {
        LevelFilter::Info
    };

    CombinedLogger::init(vec![
        WriteLogger::new(LevelFilter::Debug, LogConfig::default(), log_file),
        TermLogger::new(
            console_level,
            LogConfig::default(),
            TerminalMode::Mixed,
            ColorChoice::Auto,
        ),
    ])?;

    Ok(())
}

async fn handle_connection(
    settings: Settings,
    stream: TcpStream,
    addr: SocketAddr,
    telegram_tx: Option<NotifySender>,
    whatsapp_tx: Option<NotifySender>,
) {
    let Ok(stream) = tokio_tungstenite::accept_async(stream).await else {
        log::debug!("Ignoring non-WS connection from {addr}");
        return;
    };

    let (websocket_input, mut incoming) = stream.split();

    use tokio_tungstenite::tungstenite::Message::*;
    let init_msg = loop {
        let Some(Ok(init_msg)) = incoming.next().await else {
            log::warn!("Connection from {addr} closed before login.");
            return;
        };

        if let Text(txt) = init_msg {
            break txt;
        };
    };

    let Ok(MendiaRequest::LoginCredentials(credentials)) =
        serde_json::from_str::<MendiaRequest>(&init_msg)
    else {
        log::warn!("Failed to parse login credentials from {addr}");
        return;
    };

    let (outgoing, to_websocket) = unbounded();
    // Spawn task to forward all messages from the internal channel to the websocket.
    // It is important to do this before trying to log in, so any potential login failed messages
    // are also forwarded.
    // serialization here should not fail; tungstenite::Error is large but we can't change it
    #[allow(clippy::result_large_err)]
    fn wrap_text(
        msg: Result<String, serde_json::Error>,
    ) -> Result<tokio_tungstenite::tungstenite::Message, tokio_tungstenite::tungstenite::Error>
    {
        Ok(Text(
            msg.expect("Failed to serialize message to json").into(),
        ))
    }
    tokio::spawn(
        to_websocket
            .map(|msg| serde_json::to_string(&msg))
            .map(wrap_text)
            .forward(websocket_input),
    );

    let Ok(api_key) = login(&settings, &credentials).await else {
        log::warn!(
            "User '{}' failed to log in from {addr}.",
            credentials.username
        );
        return;
    };

    log::info!("Starting connection with '{}'.", credentials.username);

    let msg = MendiaReply::Session(Session {
        username: credentials.username.clone(),
        api_key: api_key.clone(),
    });
    outgoing
        .unbounded_send(msg)
        .expect("Failed to send a message.");

    while let Some(msg) = incoming.next().await {
        let Ok(Text(txt)) = msg else {
            continue;
        };
        // JSON received

        // Deserialize
        let data = serde_json::from_str(&txt);
        let data = match data {
            Ok(data) => data,
            Err(err) => {
                outgoing
                    .unbounded_send(MendiaReply::InvalidData(InvalidData {
                        data: txt.to_string(),
                        error_msg: err.to_string(),
                    }))
                    .expect("Failed to send message.");
                continue;
            } // Send error message back
        };

        use MendiaRequest::*;
        use mendia::api::*;

        match data {
            LoginCredentials(_) => continue,
            GetMovies { user: _ } => {
                let movies = movies(&settings, api_key.clone()).await;
                outgoing
                    .unbounded_send(MendiaReply::Movies { movies })
                    .expect("Failed to send message.");
            }
            PushMovies(push_cmd) => {
                // TODO This should be tested once for all authorized commands.
                // For now we do this here as the PushMovies command must not be made public.
                if push_cmd.username != credentials.username || push_cmd.api_key != api_key {
                    outgoing
                        .unbounded_send(MendiaReply::PushMoviesResult(PushMoviesResult {
                            success: false,
                            reason: "Credential mismatch, abort.".to_owned(),
                        }))
                        .expect("Failed to send message.");
                    return;
                }
                add_movies(
                    &settings,
                    credentials.username.clone(),
                    &push_cmd.movies,
                    push_cmd.silent,
                    telegram_tx.clone(),
                    whatsapp_tx.clone(),
                )
                .await;
                outgoing
                    .unbounded_send(MendiaReply::PushMoviesResult(PushMoviesResult {
                        success: true,
                        reason: "".to_owned(),
                    }))
                    .expect("Failed to send message.");
            }
            GetTMDbApiKey(cmd) => {
                if cmd.username != credentials.username || cmd.api_key != api_key {
                    outgoing
                        .unbounded_send(MendiaReply::GetTMDbApiKeyResult(GetTMDbApiKeyResult {
                            tmdb_api_key: None,
                        }))
                        .expect("Failed to send message.");
                    return;
                }
                outgoing
                    .unbounded_send(MendiaReply::GetTMDbApiKeyResult(GetTMDbApiKeyResult {
                        tmdb_api_key: Some(settings.tmdb.api_key.clone()),
                    }))
                    .expect("Failed to send message.");
            }
        }
    }
    log::info!("Ending connection with '{}'.", credentials.username);
}
