// This file includes a substantial portion of code from
// https://github.com/Zheoni/cooklang-chef
//
// The original code is licensed under the MIT License, a copy of which
// is provided below in addition to our project's license.
//
//

// MIT License

// Copyright (c) 2023 Francisco J. Sanchez

// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:

// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.

// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

use crate::util::resolve_to_absolute_path;
use crate::Context;
use anyhow::{bail, Context as _, Result};
use axum::{
    body::Body,
    extract::{DefaultBodyLimit, Path},
    http::{header, HeaderValue, Method, Response, StatusCode},
    routing::{get, post},
    Router,
};
use camino::Utf8PathBuf;
use clap::Args;
use rust_embed::RustEmbed;
#[cfg(feature = "sync")]
use std::sync::Mutex;
use std::{net::IpAddr, net::SocketAddr, sync::Arc};
use tower_http::{cors::CorsLayer, services::ServeDir};
use tracing::{error, info};

pub mod builders;
mod fs_atomic;
mod handlers;
mod i18n;
pub mod language;
mod lsp_bridge;
mod shopping_list_store;
mod shopping_list_watcher;
#[cfg(feature = "sync")]
pub mod sync;
pub mod templates;
mod ui;

// Embed static files at compile time
#[derive(RustEmbed)]
#[folder = "static/"]
pub struct StaticFiles;

#[derive(Debug, Args)]
pub struct ServerArgs {
    /// Root directory containing your recipe files
    ///
    /// The server will recursively scan this directory for .cook files
    /// and make them available through the web interface. Defaults to
    /// the current directory if not specified.
    #[arg(value_hint = clap::ValueHint::DirPath)]
    base_path: Option<Utf8PathBuf>,

    /// Allow connections from external hosts (not just localhost)
    ///
    /// By default, the server only accepts connections from localhost
    /// for security. Use this flag to allow access from other devices
    /// on your network. If an IP address is provided the server will
    /// only listen on that address. Be cautious when using this flag
    /// on public networks.
    #[arg(long, num_args = 0..=1, value_name = "ADDRESS")]
    host: Option<Option<IpAddr>>,

    /// Port number for the HTTP server
    ///
    /// The server will listen on this port. Make sure the port is not
    /// already in use by another application.
    #[arg(short = 'p', long, default_value_t = 9080)]
    port: u16,

    /// URL prefix for running behind a reverse proxy at a subpath
    ///
    /// When set, all routes will be served under this prefix. For example,
    /// --url-prefix "/cook" will make the server available at /cook/ instead
    /// of /. This is useful when running behind a reverse proxy like nginx
    /// that maps a subpath to this server. The prefix must start with "/".
    #[arg(long)]
    url_prefix: Option<String>,

    /// Automatically open the web interface in your default browser
    ///
    /// When enabled, the server will launch your default web browser
    /// and navigate to the server URL after startup.
    // #[cfg(feature = "ui")]
    #[arg(long, default_value_t = false)]
    open: bool,
}

impl ServerArgs {
    pub fn get_base_path(&self) -> Option<Utf8PathBuf> {
        self.base_path.clone()
    }
}

#[tokio::main]
pub async fn run(ctx: Context, args: ServerArgs) -> Result<()> {
    let addr = match args.host {
        Some(Some(addr)) => addr,
        Some(None) => "::".parse()?,
        None => [127, 0, 0, 1].into(),
    };
    let addr = SocketAddr::from((addr, args.port));
    let open = args.open;

    let state = build_state(ctx, args)?;

    if state.url_prefix.is_empty() {
        println!("Listening on http://{addr}");
    } else {
        println!("Listening on http://{addr}{}", state.url_prefix);
    }

    // #[cfg(feature = "ui")]
    if open {
        let url = format!("http://{addr}{}", state.url_prefix);
        println!("Serving Web UI on {url}");
        tokio::task::spawn(async move {
            tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
            if let Err(e) = open::that(url) {
                tracing::error!("Could not open the web browser: {e}");
            }
        });
    }

    // Start sync if already logged in (non-fatal: server starts regardless)
    #[cfg(feature = "sync")]
    {
        let session_guard = state.sync_session.lock().unwrap();
        if let Some(ref session) = *session_guard {
            match sync::sync_db_path() {
                Ok(db_path) => {
                    match sync::start_sync(session, state.base_path.to_string(), db_path) {
                        Ok(handle) => {
                            // Safe to use try_lock here: no contention before the server accepts connections
                            if let Ok(mut guard) = state.sync_handle.try_lock() {
                                *guard = Some(handle);
                            }
                            tracing::info!("Sync started on server boot");
                        }
                        Err(e) => tracing::warn!("Failed to start sync: {e}"),
                    }
                }
                Err(e) => tracing::warn!("Failed to resolve sync DB path: {e}"),
            }
        }

        // Start token refresh task (cancelled on shutdown via shutdown_token)
        sync::runner::start_token_refresh(
            Arc::clone(&state.sync_session),
            state.session_path.clone(),
            state.shutdown_token.child_token(),
        );
    }

    println!("Serving recipe files from: {:?}", &state.base_path);

    // Maximum request body size: 1MB (reasonable for recipe files)
    const MAX_BODY_SIZE: usize = 1024 * 1024;

    let inner = Router::new()
        .nest("/api", api(&state)?)
        .merge(ui::ui())
        .route("/static/*file", get(serve_static))
        .nest_service("/api/static", ServeDir::new(&state.base_path));

    let app = if state.url_prefix.is_empty() {
        inner
    } else {
        Router::new().nest(&state.url_prefix, inner)
    };

    // Capture url_prefix before state is consumed by with_state.
    let url_prefix_for_features = state.url_prefix.clone();

    #[cfg(feature = "sync")]
    let state_for_shutdown = state.clone();

    let app = app
        .with_state(state)
        .layer(DefaultBodyLimit::max(MAX_BODY_SIZE))
        .layer(axum::middleware::from_fn_with_state(
            url_prefix_for_features,
            language::features_middleware,
        ))
        .layer(axum::middleware::from_fn(language::language_middleware))
        .layer(
            CorsLayer::new()
                .allow_origin("*".parse::<HeaderValue>().unwrap())
                .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]),
        );

    let listener = match tokio::net::TcpListener::bind(&addr).await {
        Ok(listener) => listener,
        Err(e) => {
            if e.kind() == std::io::ErrorKind::AddrInUse {
                error!("Port {} is already in use. Please stop the existing server or use a different port with --port", addr.port());
                return Err(anyhow::anyhow!("Port {} is already in use", addr.port()));
            } else {
                error!("Failed to bind to {}: {}", addr, e);
                return Err(anyhow::anyhow!("Failed to bind to {}: {}", addr, e));
            }
        }
    };

    // NormalizePath trims trailing slashes so /cook/ matches /cook
    let app = tower_http::normalize_path::NormalizePath::trim_trailing_slash(app);

    axum::serve(
        listener,
        axum::ServiceExt::<axum::extract::Request>::into_make_service(app),
    )
    .with_graceful_shutdown(shutdown_signal())
    .await
    .context("Server error")?;

    // Cancel background tasks (token refresh) and stop sync on shutdown
    #[cfg(feature = "sync")]
    {
        state_for_shutdown.shutdown_token.cancel();
        if let Some(handle) = state_for_shutdown.sync_handle.lock().await.take() {
            handle.stop().await;
        }
    }

    info!("Server stopped");

    Ok(())
}

fn build_state(ctx: Context, args: ServerArgs) -> Result<Arc<AppState>> {
    let Context { base_path } = ctx;

    let path = args.base_path.as_ref().unwrap_or(&base_path);
    let absolute_path = resolve_to_absolute_path(path)?;

    if absolute_path.is_file() {
        bail!("Base path {} is not a directory", absolute_path);
    }

    tracing::info!("Using absolute base path: {:?}", absolute_path);

    // Normalize URL prefix: strip trailing slashes, ensure leading slash
    let url_prefix = match args.url_prefix {
        Some(prefix) => {
            let prefix = prefix.trim_end_matches('/').to_string();
            if !prefix.starts_with('/') {
                bail!("URL prefix must start with '/', got: {prefix}");
            }
            if !prefix
                .chars()
                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.'))
            {
                bail!("URL prefix contains invalid characters: {prefix}");
            }
            prefix
        }
        None => String::new(),
    };

    // Create a new Context with the actual base path to properly search for config files
    let server_ctx = Context::new(absolute_path.clone());
    let aisle_path = server_ctx.aisle();
    let pantry_path = server_ctx.pantry();

    tracing::info!("Aisle configuration: {:?}", aisle_path);
    tracing::info!("Pantry configuration: {:?}", pantry_path);

    let shopping_list_events = match shopping_list_watcher::spawn(absolute_path.clone()) {
        Ok(tx) => Some(tx),
        Err(e) => {
            tracing::warn!("Failed to start shopping list watcher; live updates disabled: {e:#}");
            None
        }
    };

    #[cfg(feature = "sync")]
    let (session_path, session) = {
        let path = crate::global_file_path("session.json")
            .map(|p: Utf8PathBuf| p.into_std_path_buf())
            .unwrap_or_else(|_| std::path::PathBuf::from(".cook-session.json"));
        let session = match sync::SyncSession::load(&path) {
            Ok(s) => s,
            Err(e) => {
                tracing::warn!("Failed to load sync session: {e}");
                None
            }
        };
        (path, session)
    };

    Ok(Arc::new(AppState {
        base_path: absolute_path,
        aisle_path,
        pantry_path,
        url_prefix,
        checked_log_lock: Arc::new(tokio::sync::Mutex::new(())),
        shopping_list_events,
        #[cfg(feature = "sync")]
        sync_session: Arc::new(Mutex::new(session)),
        #[cfg(feature = "sync")]
        sync_handle: Arc::new(tokio::sync::Mutex::new(None)),
        #[cfg(feature = "sync")]
        pending_device_flow: Arc::new(tokio::sync::Mutex::new(None)),
        #[cfg(feature = "sync")]
        session_path,
        #[cfg(feature = "sync")]
        shutdown_token: tokio_util::sync::CancellationToken::new(),
    }))
}

async fn shutdown_signal() {
    let ctrl_c = async {
        tokio::signal::ctrl_c()
            .await
            .expect("failed to install Ctrl+C handler");
    };

    #[cfg(unix)]
    let terminate = async {
        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
            .expect("failed to install signal handler")
            .recv()
            .await;
    };

    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    };

    info!("Stopping server");
}

pub struct AppState {
    pub base_path: Utf8PathBuf,
    pub aisle_path: Option<Utf8PathBuf>,
    pub pantry_path: Option<Utf8PathBuf>,
    pub url_prefix: String,
    /// Serializes access to `.shopping-checked` within this process.
    /// File-level `flock` doesn't prevent two tasks in the *same* process
    /// from racing on the file (the kernel treats them as one lock owner),
    /// so we need an in-process mutex on top. All check / uncheck / compact
    /// handlers acquire this before touching the checked log.
    pub checked_log_lock: Arc<tokio::sync::Mutex<()>>,
    /// Broadcasts filesystem changes to `.shopping-list` / `.shopping-checked`
    /// to every open SSE subscriber. `None` means watcher init failed; SSE
    /// clients can still connect but will never receive events.
    pub shopping_list_events: Option<shopping_list_watcher::ChangeSender>,
    #[cfg(feature = "sync")]
    pub sync_session: Arc<Mutex<Option<sync::SyncSession>>>,
    #[cfg(feature = "sync")]
    pub sync_handle: Arc<tokio::sync::Mutex<Option<sync::SyncHandle>>>,
    #[cfg(feature = "sync")]
    pub pending_device_flow: Arc<tokio::sync::Mutex<Option<sync::PendingDeviceFlow>>>,
    #[cfg(feature = "sync")]
    pub session_path: std::path::PathBuf,
    #[cfg(feature = "sync")]
    pub shutdown_token: tokio_util::sync::CancellationToken,
}

#[cfg(feature = "sync")]
impl AppState {
    /// Check sync status: returns (logged_in, email, syncing).
    /// Cleans up finished sync handles as a side effect.
    pub async fn sync_status(&self) -> (bool, Option<String>, bool) {
        let (logged_in, email) = {
            let session = self.sync_session.lock().unwrap();
            (
                session.is_some(),
                session.as_ref().and_then(|s| s.email.clone()),
            )
        };
        let syncing = {
            let mut guard = self.sync_handle.lock().await;
            match guard.as_ref() {
                Some(handle) if handle.is_running() => true,
                Some(_) => {
                    guard.take();
                    false
                }
                None => false,
            }
        };
        (logged_in, email, syncing)
    }
}

fn api(_state: &AppState) -> Result<Router<Arc<AppState>>> {
    let router = Router::new()
        .route("/shopping_list", post(handlers::shopping_list))
        .route(
            "/shopping_list/items",
            get(handlers::get_shopping_list_items),
        )
        .route("/shopping_list/add", post(handlers::add_to_shopping_list))
        .route(
            "/shopping_list/add_menu",
            post(handlers::add_menu_to_shopping_list),
        )
        .route(
            "/shopping_list/remove",
            post(handlers::remove_from_shopping_list),
        )
        .route("/shopping_list/clear", post(handlers::clear_shopping_list))
        .route("/shopping_list/check", post(handlers::check_shopping_item))
        .route(
            "/shopping_list/uncheck",
            post(handlers::uncheck_shopping_item),
        )
        .route("/shopping_list/checked", get(handlers::get_checked_items))
        .route("/shopping_list/compact", post(handlers::compact_checked))
        .route("/shopping_list/events", get(handlers::shopping_list_events))
        .route("/pantry", get(handlers::get_pantry))
        .route("/pantry/add", post(handlers::add_pantry_item))
        .route("/pantry/expiring", get(handlers::get_expiring))
        .route("/pantry/depleted", get(handlers::get_depleted))
        .route(
            "/pantry/:section/:name",
            axum::routing::delete(handlers::remove_pantry_item),
        )
        .route(
            "/pantry/:section/:name",
            axum::routing::put(handlers::update_pantry_item),
        )
        .route("/recipes", get(handlers::all_recipes))
        .route("/recipes/raw/*path", get(handlers::recipe_raw)) // More specific route must come first
        .route(
            "/recipes/*path",
            get(handlers::recipe)
                .put(handlers::recipe_save)
                .delete(handlers::recipe_delete),
        )
        .route("/menus", get(handlers::list_menus))
        .route("/menus/*path", get(handlers::get_menu))
        .route("/search", get(handlers::search))
        .route("/stats", get(handlers::stats))
        .route("/reload", get(handlers::reload).post(handlers::reload))
        .route("/ws/lsp", get(lsp_bridge::lsp_websocket));

    #[cfg(feature = "sync")]
    let router = router
        .route("/sync/status", get(handlers::sync_status))
        .route("/sync/login", post(handlers::sync_login))
        .route("/sync/cancel_login", post(handlers::sync_cancel_login))
        .route("/sync/logout", post(handlers::sync_logout));

    Ok(router)
}

async fn serve_static(Path(path): Path<String>) -> impl axum::response::IntoResponse {
    let path = path.trim_start_matches('/');

    StaticFiles::get(path)
        .map(|content| {
            let mime = mime_guess::from_path(path).first_or_octet_stream();
            Response::builder()
                .status(StatusCode::OK)
                .header(header::CONTENT_TYPE, mime.as_ref())
                .body(Body::from(content.data))
                .unwrap()
        })
        .unwrap_or_else(|| {
            Response::builder()
                .status(StatusCode::NOT_FOUND)
                .body(Body::from("404 Not Found"))
                .unwrap()
        })
}
