// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.

#![allow(deprecated)]

use std::{borrow::Cow, io::Cursor};

use anyhow::bail;
use camino::Utf8PathBuf;
use ipnetwork::IpNetwork;
use mas_keystore::PrivateKey;
use rustls_pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use url::Url;

use super::ConfigurationSection;

fn default_public_base() -> Url {
    "http://[::]:8080".parse().unwrap()
}

fn http_address_example_1() -> &'static str {
    "[::1]:8080"
}
fn http_address_example_2() -> &'static str {
    "[::]:8080"
}
fn http_address_example_3() -> &'static str {
    "127.0.0.1:8080"
}
fn http_address_example_4() -> &'static str {
    "0.0.0.0:8080"
}

#[cfg(not(any(feature = "docker", feature = "dist")))]
fn http_listener_assets_path_default() -> Utf8PathBuf {
    "./frontend/dist/".into()
}

#[cfg(feature = "docker")]
fn http_listener_assets_path_default() -> Utf8PathBuf {
    "/usr/local/share/mas-cli/assets/".into()
}

#[cfg(feature = "dist")]
fn http_listener_assets_path_default() -> Utf8PathBuf {
    "./share/assets/".into()
}

fn is_default_http_listener_assets_path(value: &Utf8PathBuf) -> bool {
    *value == http_listener_assets_path_default()
}

fn default_trusted_proxies() -> Vec<IpNetwork> {
    vec![
        IpNetwork::new([192, 168, 0, 0].into(), 16).unwrap(),
        IpNetwork::new([172, 16, 0, 0].into(), 12).unwrap(),
        IpNetwork::new([10, 0, 0, 0].into(), 10).unwrap(),
        IpNetwork::new(std::net::Ipv4Addr::LOCALHOST.into(), 8).unwrap(),
        IpNetwork::new([0xfd00, 0, 0, 0, 0, 0, 0, 0].into(), 8).unwrap(),
        IpNetwork::new(std::net::Ipv6Addr::LOCALHOST.into(), 128).unwrap(),
    ]
}

/// Kind of socket
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum UnixOrTcp {
    /// UNIX domain socket
    Unix,

    /// TCP socket
    Tcp,
}

impl UnixOrTcp {
    /// UNIX domain socket
    #[must_use]
    pub const fn unix() -> Self {
        Self::Unix
    }

    /// TCP socket
    #[must_use]
    pub const fn tcp() -> Self {
        Self::Tcp
    }
}

/// Configuration of a single listener
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
#[serde(untagged)]
pub enum BindConfig {
    /// Listen on the specified host and port
    Listen {
        /// Host on which to listen.
        ///
        /// Defaults to listening on all addresses
        #[serde(skip_serializing_if = "Option::is_none")]
        host: Option<String>,

        /// Port on which to listen.
        port: u16,
    },

    /// Listen on the specified address
    Address {
        /// Host and port on which to listen
        #[schemars(
            example = "http_address_example_1",
            example = "http_address_example_2",
            example = "http_address_example_3",
            example = "http_address_example_4"
        )]
        address: String,
    },

    /// Listen on a UNIX domain socket
    Unix {
        /// Path to the socket
        #[schemars(with = "String")]
        socket: Utf8PathBuf,
    },

    /// Accept connections on file descriptors passed by the parent process.
    ///
    /// This is useful for grabbing sockets passed by systemd.
    ///
    /// See <https://www.freedesktop.org/software/systemd/man/sd_listen_fds.html>
    FileDescriptor {
        /// Index of the file descriptor. Note that this is offseted by 3
        /// because of the standard input/output sockets, so setting
        /// here a value of `0` will grab the file descriptor `3`
        #[serde(default)]
        fd: usize,

        /// Whether the socket is a TCP socket or a UNIX domain socket. Defaults
        /// to TCP.
        #[serde(default = "UnixOrTcp::tcp")]
        kind: UnixOrTcp,
    },
}

/// Configuration related to TLS on a listener
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
pub struct TlsConfig {
    /// PEM-encoded X509 certificate chain
    ///
    /// Exactly one of `certificate` or `certificate_file` must be set.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub certificate: Option<String>,

    /// File containing the PEM-encoded X509 certificate chain
    ///
    /// Exactly one of `certificate` or `certificate_file` must be set.
    #[serde(skip_serializing_if = "Option::is_none")]
    #[schemars(with = "Option<String>")]
    pub certificate_file: Option<Utf8PathBuf>,

    /// PEM-encoded private key
    ///
    /// Exactly one of `key` or `key_file` must be set.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub key: Option<String>,

    /// File containing a PEM or DER-encoded private key
    ///
    /// Exactly one of `key` or `key_file` must be set.
    #[serde(skip_serializing_if = "Option::is_none")]
    #[schemars(with = "Option<String>")]
    pub key_file: Option<Utf8PathBuf>,

    /// Password used to decode the private key
    ///
    /// One of `password` or `password_file` must be set if the key is
    /// encrypted.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub password: Option<String>,

    /// Password file used to decode the private key
    ///
    /// One of `password` or `password_file` must be set if the key is
    /// encrypted.
    #[serde(skip_serializing_if = "Option::is_none")]
    #[schemars(with = "Option<String>")]
    pub password_file: Option<Utf8PathBuf>,
}

impl TlsConfig {
    /// Load the TLS certificate chain and key file from disk
    ///
    /// # Errors
    ///
    /// Returns an error if an error was encountered either while:
    ///   - reading the certificate, key or password files
    ///   - decoding the key as PEM or DER
    ///   - decrypting the key if encrypted
    ///   - a password was provided but the key was not encrypted
    ///   - decoding the certificate chain as PEM
    ///   - the certificate chain is empty
    pub fn load(
        &self,
    ) -> Result<(PrivateKeyDer<'static>, Vec<CertificateDer<'static>>), anyhow::Error> {
        let password = match (&self.password, &self.password_file) {
            (None, None) => None,
            (Some(_), Some(_)) => {
                bail!("Only one of `password` or `password_file` can be set at a time")
            }
            (Some(password), None) => Some(Cow::Borrowed(password)),
            (None, Some(path)) => Some(Cow::Owned(std::fs::read_to_string(path)?)),
        };

        // Read the key either embedded in the config file or on disk
        let key = match (&self.key, &self.key_file) {
            (None, None) => bail!("Either `key` or `key_file` must be set"),
            (Some(_), Some(_)) => bail!("Only one of `key` or `key_file` can be set at a time"),
            (Some(key), None) => {
                // If the key was embedded in the config file, assume it is formatted as PEM
                if let Some(password) = password {
                    PrivateKey::load_encrypted_pem(key, password.as_bytes())?
                } else {
                    PrivateKey::load_pem(key)?
                }
            }
            (None, Some(path)) => {
                // When reading from disk, it might be either PEM or DER. `PrivateKey::load*`
                // will try both.
                let key = std::fs::read(path)?;
                if let Some(password) = password {
                    PrivateKey::load_encrypted(&key, password.as_bytes())?
                } else {
                    PrivateKey::load(&key)?
                }
            }
        };

        // Re-serialize the key to PKCS#8 DER, so rustls can consume it
        let key = key.to_pkcs8_der()?;
        let key = PrivatePkcs8KeyDer::from(key.to_vec()).into();

        let certificate_chain_pem = match (&self.certificate, &self.certificate_file) {
            (None, None) => bail!("Either `certificate` or `certificate_file` must be set"),
            (Some(_), Some(_)) => {
                bail!("Only one of `certificate` or `certificate_file` can be set at a time")
            }
            (Some(certificate), None) => Cow::Borrowed(certificate),
            (None, Some(path)) => Cow::Owned(std::fs::read_to_string(path)?),
        };

        let mut certificate_chain_reader = Cursor::new(certificate_chain_pem.as_bytes());
        let certificate_chain: Result<Vec<_>, _> =
            rustls_pemfile::certs(&mut certificate_chain_reader).collect();
        let certificate_chain = certificate_chain?;

        if certificate_chain.is_empty() {
            bail!("TLS certificate chain is empty (or invalid)")
        }

        Ok((key, certificate_chain))
    }
}

/// HTTP resources to mount
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
#[serde(tag = "name", rename_all = "lowercase")]
pub enum Resource {
    /// Healthcheck endpoint (/health)
    Health,

    /// Prometheus metrics endpoint (/metrics)
    Prometheus,

    /// OIDC discovery endpoints
    Discovery,

    /// Pages destined to be viewed by humans
    Human,

    /// GraphQL endpoint
    GraphQL {
        /// Enabled the GraphQL playground
        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
        playground: bool,

        /// Allow access for OAuth 2.0 clients (undocumented)
        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
        undocumented_oauth2_access: bool,
    },

    /// OAuth-related APIs
    OAuth,

    /// Matrix compatibility API
    Compat,

    /// Static files
    Assets {
        /// Path to the directory to serve.
        #[serde(
            default = "http_listener_assets_path_default",
            skip_serializing_if = "is_default_http_listener_assets_path"
        )]
        #[schemars(with = "String")]
        path: Utf8PathBuf,
    },

    /// Admin API, served at `/api/admin/v1`
    AdminApi,

    /// Mount a "/connection-info" handler which helps debugging informations on
    /// the upstream connection
    #[serde(rename = "connection-info")]
    ConnectionInfo,
}

/// Configuration of a listener
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
pub struct ListenerConfig {
    /// A unique name for this listener which will be shown in traces and in
    /// metrics labels
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,

    /// List of resources to mount
    pub resources: Vec<Resource>,

    /// HTTP prefix to mount the resources on
    #[serde(skip_serializing_if = "Option::is_none")]
    pub prefix: Option<String>,

    /// List of sockets to bind
    pub binds: Vec<BindConfig>,

    /// Accept `HAProxy`'s Proxy Protocol V1
    #[serde(default)]
    pub proxy_protocol: bool,

    /// If set, makes the listener use TLS with the provided certificate and key
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tls: Option<TlsConfig>,
}

/// Configuration related to the web server
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct HttpConfig {
    /// List of listeners to run
    #[serde(default)]
    pub listeners: Vec<ListenerConfig>,

    /// List of trusted reverse proxies that can set the `X-Forwarded-For`
    /// header
    #[serde(default = "default_trusted_proxies")]
    pub trusted_proxies: Vec<IpNetwork>,

    /// Public URL base from where the authentication service is reachable
    pub public_base: Url,

    /// OIDC issuer URL. Defaults to `public_base` if not set.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub issuer: Option<Url>,
}

impl Default for HttpConfig {
    fn default() -> Self {
        Self {
            listeners: vec![
                ListenerConfig {
                    name: Some("web".to_owned()),
                    resources: vec![
                        Resource::Discovery,
                        Resource::Human,
                        Resource::OAuth,
                        Resource::Compat,
                        Resource::GraphQL {
                            playground: false,
                            undocumented_oauth2_access: false,
                        },
                        Resource::Assets {
                            path: http_listener_assets_path_default(),
                        },
                    ],
                    prefix: None,
                    tls: None,
                    proxy_protocol: false,
                    binds: vec![BindConfig::Address {
                        address: "[::]:8080".into(),
                    }],
                },
                ListenerConfig {
                    name: Some("internal".to_owned()),
                    resources: vec![Resource::Health],
                    prefix: None,
                    tls: None,
                    proxy_protocol: false,
                    binds: vec![BindConfig::Listen {
                        host: Some("localhost".to_owned()),
                        port: 8081,
                    }],
                },
            ],
            trusted_proxies: default_trusted_proxies(),
            issuer: Some(default_public_base()),
            public_base: default_public_base(),
        }
    }
}

impl ConfigurationSection for HttpConfig {
    const PATH: Option<&'static str> = Some("http");

    fn validate(
        &self,
        figment: &figment::Figment,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
        for (index, listener) in self.listeners.iter().enumerate() {
            let annotate = |mut error: figment::Error| {
                error.metadata = figment
                    .find_metadata(&format!("{root}.listeners", root = Self::PATH.unwrap()))
                    .cloned();
                error.profile = Some(figment::Profile::Default);
                error.path = vec![
                    Self::PATH.unwrap().to_owned(),
                    "listeners".to_owned(),
                    index.to_string(),
                ];
                error
            };

            if listener.resources.is_empty() {
                return Err(
                    annotate(figment::Error::from("listener has no resources".to_owned())).into(),
                );
            }

            if listener.binds.is_empty() {
                return Err(annotate(figment::Error::from(
                    "listener does not bind to any address".to_owned(),
                ))
                .into());
            }

            if let Some(tls_config) = &listener.tls {
                if tls_config.certificate.is_some() && tls_config.certificate_file.is_some() {
                    return Err(annotate(figment::Error::from(
                        "Only one of `certificate` or `certificate_file` can be set at a time"
                            .to_owned(),
                    ))
                    .into());
                }

                if tls_config.certificate.is_none() && tls_config.certificate_file.is_none() {
                    return Err(annotate(figment::Error::from(
                        "TLS configuration is missing a certificate".to_owned(),
                    ))
                    .into());
                }

                if tls_config.key.is_some() && tls_config.key_file.is_some() {
                    return Err(annotate(figment::Error::from(
                        "Only one of `key` or `key_file` can be set at a time".to_owned(),
                    ))
                    .into());
                }

                if tls_config.key.is_none() && tls_config.key_file.is_none() {
                    return Err(annotate(figment::Error::from(
                        "TLS configuration is missing a private key".to_owned(),
                    ))
                    .into());
                }

                if tls_config.password.is_some() && tls_config.password_file.is_some() {
                    return Err(annotate(figment::Error::from(
                        "Only one of `password` or `password_file` can be set at a time".to_owned(),
                    ))
                    .into());
                }
            }
        }

        Ok(())
    }
}
