#![doc = include_str!("../docs/cli-reference.md")]
#![warn(clippy::all, clippy::pedantic)]
#![allow(clippy::missing_errors_doc)]

// Copyright (C) 2026 Ordinary Labs, LLC.
//
// SPDX-License-Identifier: AGPL-3.0-only

pub mod cmds;

use clap::{Args, Parser, Subcommand};
use ordinary_config::OrdinaryConfig;
use std::fmt::Display;
use std::fs::DirEntry;
use std::path::{Path, PathBuf};
use tracing::level_filters::LevelFilter;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;

#[derive(Clone, Debug)]
pub enum LogLevel {
    Error,
    Warn,
    Info,
    Debug,
    Trace,
}

impl LogLevel {
    fn to_level_filter(&self) -> LevelFilter {
        match self {
            Self::Error => LevelFilter::ERROR,
            Self::Warn => LevelFilter::WARN,
            Self::Info => LevelFilter::INFO,
            Self::Debug => LevelFilter::DEBUG,
            Self::Trace => LevelFilter::TRACE,
        }
    }
}

impl clap::ValueEnum for LogLevel {
    fn value_variants<'a>() -> &'a [Self] {
        &[
            Self::Error,
            Self::Warn,
            Self::Info,
            Self::Debug,
            Self::Trace,
        ]
    }

    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
        match self {
            Self::Error => Some(clap::builder::PossibleValue::new("error")),
            Self::Warn => Some(clap::builder::PossibleValue::new("warn")),
            Self::Info => Some(clap::builder::PossibleValue::new("info")),
            Self::Debug => Some(clap::builder::PossibleValue::new("debug")),
            Self::Trace => Some(clap::builder::PossibleValue::new("trace")),
        }
    }
}

impl Display for LogLevel {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let str = match self {
            Self::Error => String::from("error"),
            Self::Warn => String::from("warn"),
            Self::Info => String::from("info"),
            Self::Debug => String::from("debug"),
            Self::Trace => String::from("trace"),
        };
        write!(f, "{str}")
    }
}

#[derive(Clone, Debug)]
pub enum LogFileRotation {
    Day,
    Hour,
    Minute,
    Never,
}

impl clap::ValueEnum for LogFileRotation {
    fn value_variants<'a>() -> &'a [Self] {
        &[Self::Day, Self::Hour, Self::Minute, Self::Never]
    }

    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
        match self {
            Self::Day => Some(clap::builder::PossibleValue::new("day")),
            Self::Hour => Some(clap::builder::PossibleValue::new("hour")),
            Self::Minute => Some(clap::builder::PossibleValue::new("minute")),
            Self::Never => Some(clap::builder::PossibleValue::new("never")),
        }
    }
}

impl Display for LogFileRotation {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let str = match self {
            Self::Day => String::from("day"),
            Self::Hour => String::from("hour"),
            Self::Minute => String::from("minute"),
            Self::Never => String::from("never"),
        };
        write!(f, "{str}")
    }
}

#[derive(Clone, Debug)]
pub enum ProvisionMode {
    Localhost,
    Staging,
    Production,
}

impl clap::ValueEnum for ProvisionMode {
    fn value_variants<'a>() -> &'a [Self] {
        &[Self::Staging, Self::Production, Self::Localhost]
    }

    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
        match self {
            Self::Localhost => Some(clap::builder::PossibleValue::new("localhost")),
            Self::Staging => Some(clap::builder::PossibleValue::new("staging")),
            Self::Production => Some(clap::builder::PossibleValue::new("production")),
        }
    }
}

impl Display for ProvisionMode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let str = match self {
            Self::Localhost => String::from("localhost"),
            Self::Staging => String::from("staging"),
            Self::Production => String::from("production"),
        };
        write!(f, "{str}")
    }
}

#[allow(clippy::struct_excessive_bools)]
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
#[command(propagate_version = true)]
pub struct Cli {
    #[command(subcommand)]
    pub commands: Commands,

    /// specify the data directory
    #[arg(long, global = true, default_value_t = std::env::home_dir().expect("failed to get home dir").join(".ordinary").to_str().expect("failed to convert to str").to_string())]
    pub data_dir: String,

    /// logs events to stdio
    #[arg(long, global = true, default_value_t = false)]
    pub stdio_logs: bool,

    /// whether to pretty print stdio logs
    #[arg(long, global = true, default_value_t = false)]
    pub stdio_pretty: bool,

    /// whether to print stdio logs as json
    #[arg(long, global = true, default_value_t = false)]
    pub stdio_json: bool,

    /// persists JSON formatted log lines to <data-dir>/logs/
    #[arg(long, global = true, default_value_t = false)]
    pub json_logs: bool,

    // todo: allow setting log levels for api, app, storage, templates, actions and integrations
    /// base log level for every component
    #[arg(long, global = true, default_value_t = LogLevel::Info)]
    pub log_level: LogLevel,

    /// dictates whether timing information should be logged
    #[arg(long, global = true, default_value_t = false)]
    pub with_span_timing: bool,

    /// rate at which new log files are generated
    #[arg(long, global = true, default_value_t = LogFileRotation::Minute)]
    pub log_file_rotation: LogFileRotation,

    /// whether storage sizes are logged
    #[arg(long, global = true, default_value_t = false)]
    pub log_size: bool,
}

#[derive(Subcommand, Debug)]
pub enum Commands {
    /// initialize the environment for an Ordinary API server
    Init {
        #[command(flatten)]
        api_init: ApiInit,

        /// domain name for API console
        #[arg(long)]
        api_domain: String,

        /// instance user password
        #[arg(long, default_value_t = String::from("password"))]
        password: String,

        /// store the MFA key locally instead of copying a QR code
        #[arg(long, default_value_t = false)]
        mfa_stored: bool,

        /// contacts for the API domain cert provisioning
        #[arg(long, value_delimiter = ',', num_args = 1..)]
        api_contacts: Vec<String>,

        /// domains that apps can subdomain off of.
        ///
        /// i.e. when `example.com` is passed, `my.example.com` is
        /// considered a valid app domain.
        #[arg(long, value_delimiter = ',', num_args = 1..)]
        app_domains: Vec<String>,

        /// list of applications that have access to API server
        /// level commands (i.e. API server invite token generation)
        ///
        /// currently intended to enable API server admins to set up a
        /// web-based registration portal.
        #[arg(long, value_delimiter = ',', num_args = 0..)]
        privileged_domains: Option<Vec<String>>,
    },
    /// start the Ordinary API server
    Api {
        #[command(flatten)]
        api_init: ApiInit,

        #[command(flatten)]
        app_api: AppApi,

        /// give each app its own port
        #[arg(long, default_value_t = false)]
        dedicated_ports: bool,

        /// whether to index logs so they can be queried
        #[arg(long, default_value_t = false)]
        log_index: bool,

        /// min duration between log indexing operations. (seconds)
        #[arg(long, default_value_t = 15)]
        log_sync_interval_min: u64,

        /// duration between log indexing operations. (seconds)
        #[arg(long, default_value_t = 45)]
        log_sync_interval_max: u64,

        /// max period of time logs are indexed/stored
        #[arg(long, default_value_t = 7)]
        log_max_ttl_days: u16,

        /// Storage size in bytes (rounded up to nearest OS page size).
        #[arg(long, default_value_t = false)]
        swagger: bool,
    },
    /// start an Ordinary Application server
    App {
        #[command(flatten)]
        app_api: AppApi,

        /// for running a standalone project. (project must already be built)
        #[arg(long)]
        project_dir: String,

        /// use a different domain than what's in the `ordinary.json`
        #[arg(long)]
        domain_override: Option<String>,
    },
}

#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Args)]
pub struct AppApi {
    /// what mode TLS certs should be provisioned in
    #[arg(long, default_value_t = ProvisionMode::Localhost)]
    pub provision: ProvisionMode,

    /// specify HTTP(s) port for server
    #[arg(long)]
    pub port: Option<u16>,

    /// specify HTTP port for server when
    /// running in secure mode.
    #[arg(long)]
    pub redirect_port: Option<u16>,

    /// run without HTTPS
    #[arg(long, default_value_t = false)]
    pub insecure: bool,

    /// run with insecure cookies
    #[arg(long, default_value_t = false)]
    pub insecure_cookies: bool,

    /// whether HTTP request and response headers are logged
    #[arg(long, default_value_t = false)]
    pub log_headers: bool,

    /// whether IP Addresses are logged with HTTP requests
    #[arg(long, default_value_t = false)]
    pub log_ips: bool,

    // todo: document and use enum
    /// "none" | "blake2" | "blake3"
    #[arg(long, default_value_t = String::from("none"))]
    pub redacted_header_hash: String,
}

#[derive(Debug, Args)]
pub struct ApiInit {
    /// environment (e.g production, development, staging)
    #[arg(long, default_value_t = String::from("staging"))]
    pub environment: String,

    /// Storage size in bytes (rounded up to nearest OS page size).
    #[arg(long)]
    pub storage_size: usize,
}

fn traverse(dir: &Path, cb: &dyn Fn(&DirEntry)) -> std::io::Result<()> {
    if dir.is_dir() {
        for entry in std::fs::read_dir(dir)? {
            let entry = entry?;
            let path = entry.path();
            if path.is_dir() {
                traverse(&path, cb)?;
            } else {
                cb(&entry);
            }
        }
    }
    Ok(())
}

#[allow(clippy::too_many_lines)]
pub fn setup(cli: &Cli) -> anyhow::Result<PathBuf> {
    let logs_dir = match &cli.commands {
        Commands::App { project_dir, .. } => {
            let config_str = std::fs::read_to_string(Path::new(project_dir).join("ordinary.json"))?;
            let config: OrdinaryConfig = serde_json::from_str(&config_str)?;

            Path::new(&cli.data_dir)
                .join("apps")
                .join(config.domain)
                .join("logs")
        }
        Commands::Init { api_init, .. } | Commands::Api { api_init, .. } => {
            Path::new(&cli.data_dir)
                .join("environments")
                .join(&api_init.environment)
                .join("logs")
        }
    };

    std::fs::create_dir_all(&logs_dir)?;

    let json_log = if cli.json_logs {
        let appender = match cli.log_file_rotation {
            LogFileRotation::Day => tracing_appender::rolling::daily(logs_dir.join("files"), "log"),
            LogFileRotation::Hour => {
                tracing_appender::rolling::hourly(logs_dir.join("files"), "log")
            }
            LogFileRotation::Minute => {
                tracing_appender::rolling::minutely(logs_dir.join("files"), "log")
            }
            LogFileRotation::Never => {
                tracing_appender::rolling::never(logs_dir.join("files"), "log")
            }
        };

        let json_log = tracing_subscriber::fmt::layer()
            .json()
            .with_writer(appender)
            .with_target(false)
            .with_current_span(false)
            .with_span_events(if cli.with_span_timing {
                FmtSpan::CLOSE
            } else {
                FmtSpan::NONE
            });

        Some(json_log)
    } else {
        None
    };

    let (base, pretty, json) = if cli.stdio_logs {
        if cli.stdio_pretty {
            (
                None,
                Some(tracing_subscriber::fmt::layer().pretty().with_span_events(
                    if cli.with_span_timing {
                        FmtSpan::CLOSE
                    } else {
                        FmtSpan::NONE
                    },
                )),
                None,
            )
        } else if cli.stdio_json {
            (
                None,
                None,
                Some(
                    tracing_subscriber::fmt::layer()
                        .json()
                        .with_target(false)
                        .with_current_span(false)
                        .with_span_events(if cli.with_span_timing {
                            FmtSpan::CLOSE
                        } else {
                            FmtSpan::NONE
                        }),
                ),
            )
        } else {
            (
                Some(
                    tracing_subscriber::fmt::layer()
                        .with_target(false)
                        .with_span_events(if cli.with_span_timing {
                            FmtSpan::CLOSE
                        } else {
                            FmtSpan::NONE
                        }),
                ),
                None,
                None,
            )
        }
    } else {
        (None, None, None)
    };

    let log_level_str = cli.log_level.to_string();

    let directives = [
        ("ordinary_studio", &log_level_str),      // studio
        ("ordinary_utils", &log_level_str),       // utils
        ("ordinary_auth", &log_level_str),        // auth
        ("ordinary_api", &log_level_str),         // api
        ("ordinary_app", &log_level_str),         // app
        ("ordinary_template", &log_level_str),    // templates
        ("ordinary_action", &log_level_str),      // actions
        ("ordinary_integration", &log_level_str), // integrations
        ("ordinary_storage", &log_level_str),     // storage
        ("ordinary_monitor", &log_level_str),     // storage
        ("tower_http", &log_level_str),           // http
        ("axum::rejection", &"trace".into()),     // http
        ("axum::serve", &log_level_str),          // http
    ];

    let mut directives_string = format!("ordinaryd={}", &log_level_str);

    for (lib, lvl) in directives {
        directives_string = format!("{directives_string},{lib}={lvl}",);
    }

    let filter = tracing_subscriber::EnvFilter::builder()
        .with_default_directive(cli.log_level.to_level_filter().into())
        .parse(directives_string)?;

    tracing_subscriber::registry()
        .with(filter)
        .with(base)
        .with(pretty)
        .with(json)
        .with(json_log)
        .init();

    std::panic::set_hook(Box::new(|info| {
        if let Some(msg) = info.payload_as_str()
            && let Some(loc) = info.location()
        {
            tracing::error!(%loc, msg, "panic");
        } else if let Some(loc) = info.location() {
            tracing::error!(%loc, "panic");
        }
    }));

    Ok(logs_dir)
}

#[allow(clippy::too_many_lines, clippy::missing_panics_doc)]
pub async fn run(cli: &Cli, logs_dir: &Path) -> anyhow::Result<()> {
    match &cli.commands {
        Commands::App {
            app_api,
            project_dir,
            domain_override,
        } => {
            cmds::app::run(
                project_dir,
                domain_override,
                &cli.data_dir,
                cli.log_size,
                app_api.insecure,
                app_api.insecure_cookies,
                app_api.log_headers,
                app_api.log_ips,
                app_api.port,
                app_api.redirect_port,
                &app_api.provision,
            )
            .await?;
        }
        Commands::Init {
            api_init,
            api_domain,
            password,
            mfa_stored,
            api_contacts,
            app_domains,
            privileged_domains,
        } => {
            cmds::init::run(
                &api_init.environment,
                api_domain,
                password,
                &cli.data_dir,
                api_init.storage_size,
                *mfa_stored,
                api_contacts,
                app_domains,
                privileged_domains,
                logs_dir,
            )?;
        }
        Commands::Api {
            api_init,
            app_api,
            dedicated_ports,
            log_index,
            log_sync_interval_min,
            log_sync_interval_max,
            log_max_ttl_days,
            swagger,
        } => {
            cmds::api::run(
                &api_init.environment,
                &cli.data_dir,
                api_init.storage_size,
                cli.log_size,
                app_api.insecure,
                app_api.insecure_cookies,
                app_api.log_headers,
                app_api.log_ips,
                app_api.port,
                app_api.redirect_port,
                &app_api.provision,
                *log_index,
                *log_sync_interval_min,
                *log_sync_interval_max,
                *log_max_ttl_days,
                logs_dir,
                &app_api.redacted_header_hash,
                *dedicated_ports,
                *swagger,
            )
            .await?;
        }
    }

    Ok(())
}
