/*
 * CoolerControl - monitor and control your cooling and other devices
 * Copyright (c) 2021-2025  Guy Boldon, Eren Simsek and contributors
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

use crate::api::{handle_error, AppState, CCError};
use crate::repositories::service_plugin::service_management::manager::ServiceStatus;
use aide::axum::IntoApiResponse;
use axum::body::Body;
use axum::extract::{Path, Request, State};
use axum::response::Response;
use axum::Json;
use http_body_util::{BodyExt, Full};
use hyper::client::conn::http1;
use hyper_util::rt::TokioIo;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
use tokio::net::TcpStream;
use tower::ServiceExt;
use tower_http::services::ServeFile;
use tower_serve_static::include_file;

/// Content-Security-Policy for plugin UI HTML responses.
/// `connect-src 'none'` forces plugins to use the pluginFetch relay for all network access.
const PLUGIN_CONTENT_SECURITY_POLICY: &str = "default-src 'none'; \
    script-src 'self' 'unsafe-inline'; \
    style-src 'self' 'unsafe-inline'; \
    img-src 'self' data: blob:; \
    connect-src 'none'; \
    font-src 'self' data:; \
    frame-ancestors 'self'; \
    object-src 'none'; \
    base-uri 'none'; \
    form-action 'none'";

pub async fn get_plugins(
    State(AppState { plugin_handle, .. }): State<AppState>,
) -> Result<Json<PluginsDto>, CCError> {
    plugin_handle
        .get_all()
        .await
        .map(Json)
        .map_err(handle_error)
}

pub async fn get_cc_plugin_lib(request: Request) -> Result<impl IntoApiResponse, CCError> {
    tower_serve_static::ServeFile::new(include_file!("/resources/lib/cc-plugin-lib.js"))
        .oneshot(request)
        .await
        .map_err(|_infallible| CCError::InternalError {
            msg: "Failed to serve file".to_string(),
        })
}

pub async fn get_config(
    Path(path): Path<PluginPath>,
    State(AppState { plugin_handle, .. }): State<AppState>,
) -> Result<String, CCError> {
    plugin_handle
        .get_config(path.plugin_id)
        .await
        .map_err(handle_error)
}

pub async fn update_config(
    Path(path): Path<PluginPath>,
    State(AppState { plugin_handle, .. }): State<AppState>,
    config_request_body: String,
) -> Result<(), CCError> {
    plugin_handle
        .update_config(path.plugin_id, config_request_body)
        .await
        .map_err(handle_error)
}

pub async fn has_ui(
    Path(path): Path<PluginPath>,
    State(AppState { plugin_handle, .. }): State<AppState>,
) -> Json<HasUiDto> {
    plugin_handle.get_ui_dir(path.plugin_id).await.map_or_else(
        |_| Json(HasUiDto::default()),
        |plugin_ui_dir| {
            Json(HasUiDto {
                has_ui: plugin_ui_dir.join("index.html").exists(),
            })
        },
    )
}

pub async fn start_plugin(
    Path(path): Path<PluginPath>,
    State(AppState { plugin_handle, .. }): State<AppState>,
) -> Result<(), CCError> {
    plugin_handle
        .start_plugin(path.plugin_id)
        .await
        .map_err(handle_error)
}

pub async fn stop_plugin(
    Path(path): Path<PluginPath>,
    State(AppState { plugin_handle, .. }): State<AppState>,
) -> Result<(), CCError> {
    plugin_handle
        .stop_plugin(path.plugin_id)
        .await
        .map_err(handle_error)
}

pub async fn restart_plugin(
    Path(path): Path<PluginPath>,
    State(AppState { plugin_handle, .. }): State<AppState>,
) -> Result<(), CCError> {
    plugin_handle
        .restart_plugin(path.plugin_id)
        .await
        .map_err(handle_error)
}

pub async fn get_plugin_status(
    Path(path): Path<PluginPath>,
    State(AppState { plugin_handle, .. }): State<AppState>,
) -> Result<Json<PluginStatusDto>, CCError> {
    plugin_handle
        .get_plugin_status(path.plugin_id)
        .await
        .map(Json)
        .map_err(handle_error)
}

pub async fn disable_plugin(
    Path(path): Path<PluginPath>,
    State(AppState { plugin_handle, .. }): State<AppState>,
) -> Result<(), CCError> {
    plugin_handle
        .disable_plugin(path.plugin_id)
        .await
        .map_err(handle_error)
}

pub async fn enable_plugin(
    Path(path): Path<PluginPath>,
    State(AppState { plugin_handle, .. }): State<AppState>,
) -> Result<(), CCError> {
    plugin_handle
        .enable_plugin(path.plugin_id)
        .await
        .map_err(handle_error)
}

pub async fn get_ui_files(
    Path(path): Path<PluginUiPath>,
    State(AppState { plugin_handle, .. }): State<AppState>,
    request: Request,
) -> Result<impl IntoApiResponse, CCError> {
    let safe_path = sanitize_file_path(&path.file_path)?;
    let is_html = safe_path
        .extension()
        .is_some_and(|ext| ext.eq_ignore_ascii_case("html"));
    let plugin_ui_dir = plugin_handle.get_ui_dir(path.plugin_id).await?;
    let mut response = ServeFile::new(plugin_ui_dir.join(safe_path))
        .oneshot(request)
        .await
        .map_err(|_infallible| CCError::InternalError {
            msg: "Failed to serve file".to_string(),
        })?;
    if is_html {
        let headers = response.headers_mut();
        headers.insert(
            axum::http::HeaderName::from_static("content-security-policy"),
            axum::http::HeaderValue::from_static(PLUGIN_CONTENT_SECURITY_POLICY),
        );
        headers.insert(
            axum::http::header::CACHE_CONTROL,
            axum::http::HeaderValue::from_static("no-cache, max-age=60"),
        );
    }
    Ok(response)
}

/// Sanitize a relative file path for safe use in serving plugin UI files.
/// Rejects absolute paths, directory traversal, null bytes, and paths without extensions.
fn sanitize_file_path(file_path: &str) -> Result<PathBuf, CCError> {
    if file_path.contains('\0') {
        return Err(invalid_file_path());
    }
    let path = PathBuf::from(file_path);
    if path.is_absolute() {
        return Err(invalid_file_path());
    }
    // Rebuild the path, rejecting any component that is not a normal file/directory name.
    let mut safe_path = PathBuf::new();
    for component in path.components() {
        match component {
            std::path::Component::Normal(segment) => {
                safe_path.push(segment);
            }
            // Reject .., ., prefix (e.g. C:), and root components.
            _ => return Err(invalid_file_path()),
        }
    }
    if safe_path.as_os_str().is_empty() {
        return Err(invalid_file_path());
    }
    // The final component must have a file extension.
    if safe_path.extension().is_none() {
        return Err(invalid_file_path());
    }
    Ok(safe_path)
}

fn invalid_file_path() -> CCError {
    CCError::UserError {
        msg: "Invalid file path".to_string(),
    }
}

/// Max proxy response body: 10 MB.
const PROXY_MAX_RESPONSE_BYTES: usize = 10 * 1024 * 1024;
/// Max proxy request body: 1 MB.
const PROXY_MAX_REQUEST_BYTES: usize = 1024 * 1024;
/// Proxy operation timeout in seconds.
const PROXY_TIMEOUT_SECS: u64 = 30;

// Compile-time validation of proxy limit invariants.
const _: () = assert!(PROXY_MAX_REQUEST_BYTES > 0);
const _: () = assert!(PROXY_MAX_RESPONSE_BYTES > 0);
const _: () = assert!(PROXY_MAX_REQUEST_BYTES <= PROXY_MAX_RESPONSE_BYTES);
const _: () = assert!(PROXY_TIMEOUT_SECS > 0);

/// Safe response headers to forward from plugin proxy upstream responses.
const PROXY_ALLOWED_RESPONSE_HEADERS: &[&str] = &[
    "content-type",
    "content-length",
    "content-encoding",
    "cache-control",
    "etag",
    "last-modified",
];

/// Reverse-proxy a request to a plugin's local HTTP server.
/// Maps `/plugins/{plugin_id}/data/{*data_path}` to `http://127.0.0.1:{port}/{data_path}`.
/// The plugin must declare `[proxy] enabled = true` and `port = N` in its manifest.
pub async fn proxy_plugin_data(
    Path(path): Path<PluginDataPath>,
    State(AppState { plugin_handle, .. }): State<AppState>,
    request: Request,
) -> Result<Response, CCError> {
    if plugin_handle
        .is_plugin_disabled(path.plugin_id.clone())
        .await
        .map_err(handle_error)?
    {
        return Err(CCError::UserError {
            msg: format!("Plugin '{}' is disabled", path.plugin_id),
        });
    }
    let port = plugin_handle
        .get_proxy_port(path.plugin_id.clone())
        .await
        .map_err(handle_error)?
        .ok_or_else(|| CCError::NotFound {
            msg: format!("Plugin '{}' has no proxy configured", path.plugin_id),
        })?;

    // Build the upstream path, preserving query string from the original request.
    let upstream_path = {
        let query = request
            .uri()
            .query()
            .map(|q| format!("?{q}"))
            .unwrap_or_default();
        format!("/{}{}", path.data_path, query)
    };

    let method = request.method().clone();
    let auth_header = request
        .headers()
        .get(axum::http::header::AUTHORIZATION)
        .cloned();
    let body_bytes = request
        .into_body()
        .collect()
        .await
        .map_err(|e| CCError::InternalError { msg: e.to_string() })?
        .to_bytes();
    check_proxy_body_size(body_bytes.len(), PROXY_MAX_REQUEST_BYTES, "request")?;

    // The upstream connection and response are wrapped in a timeout.
    tokio::time::timeout(
        Duration::from_secs(PROXY_TIMEOUT_SECS),
        proxy_upstream(port, &upstream_path, method, auth_header, body_bytes),
    )
    .await
    .map_err(|_| CCError::InternalError {
        msg: "Plugin proxy request timed out".to_string(),
    })?
}

/// Execute the upstream proxy connection, send the request, and build the response.
async fn proxy_upstream(
    port: u16,
    upstream_path: &str,
    method: axum::http::Method,
    auth_header: Option<axum::http::HeaderValue>,
    body_bytes: hyper::body::Bytes,
) -> Result<Response, CCError> {
    let stream = TcpStream::connect(format!("127.0.0.1:{port}"))
        .await
        .map_err(|e| CCError::InternalError {
            msg: format!("Cannot connect to plugin proxy on port {port}: {e}"),
        })?;
    let io = TokioIo::new(stream);
    let (mut sender, conn) = http1::handshake(io)
        .await
        .map_err(|e| CCError::InternalError { msg: e.to_string() })?;
    // Drive the connection in the background.
    // Uses tokio::spawn (not spawn_local) because axum handlers run outside a LocalSet.
    tokio::spawn(async move {
        let _ = conn.await;
    });

    let mut upstream_req = hyper::Request::builder()
        .method(method)
        .uri(upstream_path)
        .header("host", format!("127.0.0.1:{port}"))
        .body(Full::new(body_bytes))
        .map_err(|e: axum::http::Error| CCError::InternalError { msg: e.to_string() })?;
    if let Some(auth_val) = auth_header {
        upstream_req
            .headers_mut()
            .insert(axum::http::header::AUTHORIZATION, auth_val);
    }

    let upstream_resp =
        sender
            .send_request(upstream_req)
            .await
            .map_err(|e| CCError::InternalError {
                msg: format!("Plugin proxy request failed: {e}"),
            })?;

    let status = upstream_resp.status();
    let headers = upstream_resp.headers().clone();
    let resp_bytes = upstream_resp
        .into_body()
        .collect()
        .await
        .map_err(|e| CCError::InternalError { msg: e.to_string() })?
        .to_bytes();
    check_proxy_body_size(resp_bytes.len(), PROXY_MAX_RESPONSE_BYTES, "response")?;

    let mut response = Response::builder()
        .status(status)
        .body(Body::from(resp_bytes))
        .map_err(|e| CCError::InternalError { msg: e.to_string() })?;
    for name in PROXY_ALLOWED_RESPONSE_HEADERS {
        let header_name = axum::http::HeaderName::from_static(name);
        if let Some(value) = headers.get(&header_name) {
            response.headers_mut().insert(header_name, value.clone());
        }
    }
    Ok(response)
}

fn check_proxy_body_size(len: usize, max: usize, label: &str) -> Result<(), CCError> {
    if len > max {
        return Err(CCError::InternalError {
            msg: format!("Plugin proxy {label} too large: {len} bytes (max {max})"),
        });
    }
    Ok(())
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PluginPath {
    pub plugin_id: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PluginUiPath {
    pub plugin_id: String,
    pub file_path: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PluginDataPath {
    pub plugin_id: String,
    pub data_path: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PluginsDto {
    pub plugins: Vec<PluginDto>,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PluginDto {
    pub id: String,
    pub service_type: String,
    pub description: Option<String>,
    pub version: Option<String>,
    pub url: Option<String>,
    pub address: String,
    pub privileged: bool,
    pub path: String,
    pub disabled: bool,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct HasUiDto {
    pub has_ui: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PluginStatusDto {
    pub status: String,
    pub reason: Option<String>,
}

impl From<ServiceStatus> for PluginStatusDto {
    fn from(status: ServiceStatus) -> Self {
        match status {
            ServiceStatus::Running => Self {
                status: "Running".to_string(),
                reason: None,
            },
            ServiceStatus::Stopped(reason) => Self {
                status: "Stopped".to_string(),
                reason,
            },
            ServiceStatus::Unmanaged => Self {
                status: "Unmanaged".to_string(),
                reason: None,
            },
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_sanitize_file_path_valid_simple() {
        // A simple file at the root of the ui directory.
        let result = sanitize_file_path("index.html");
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), PathBuf::from("index.html"));
    }

    #[test]
    fn test_sanitize_file_path_valid_with_multiple_dots() {
        // Files with multiple dots in the name are valid.
        let result = sanitize_file_path("app.bundle.js");
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), PathBuf::from("app.bundle.js"));
    }

    #[test]
    fn test_sanitize_file_path_valid_nested() {
        // Nested paths inside the ui directory are now supported.
        let result = sanitize_file_path("assets/app.js");
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), PathBuf::from("assets/app.js"));
    }

    #[test]
    fn test_sanitize_file_path_valid_deeply_nested() {
        // Multiple levels of nesting are valid.
        let result = sanitize_file_path("assets/css/style.css");
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), PathBuf::from("assets/css/style.css"));
    }

    #[test]
    fn test_sanitize_file_path_rejects_absolute() {
        // Absolute paths must be rejected to prevent serving arbitrary files.
        assert!(sanitize_file_path("/etc/passwd").is_err());
        assert!(sanitize_file_path("/home/user/file.txt").is_err());
    }

    #[test]
    fn test_sanitize_file_path_rejects_traversal() {
        // Directory traversal must be rejected entirely (not stripped).
        assert!(sanitize_file_path("../../../etc/passwd.txt").is_err());
        assert!(sanitize_file_path("assets/../../../etc/shadow.txt").is_err());
    }

    #[test]
    fn test_sanitize_file_path_rejects_dot_segment() {
        // Current directory segments are rejected to keep paths canonical.
        assert!(sanitize_file_path("./index.html").is_err());
    }

    #[test]
    fn test_sanitize_file_path_rejects_no_extension() {
        // Files without extensions are rejected for safety.
        assert!(sanitize_file_path("noextension").is_err());
        assert!(sanitize_file_path("Makefile").is_err());
    }

    #[test]
    fn test_sanitize_file_path_rejects_empty() {
        // An empty path is invalid.
        assert!(sanitize_file_path("").is_err());
    }

    #[test]
    fn test_sanitize_file_path_rejects_null_bytes() {
        // Null bytes in paths could cause truncation in C-based file operations.
        assert!(sanitize_file_path("index\0.html").is_err());
    }

    #[test]
    fn test_sanitize_file_path_hidden_file_with_extension() {
        // Hidden files with extensions are valid (some build tools produce these).
        let result = sanitize_file_path(".hidden.txt");
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), PathBuf::from(".hidden.txt"));
    }

    #[test]
    fn test_sanitize_file_path_rejects_hidden_file_no_extension() {
        // ".gitignore" has no extension (the dot is part of the stem).
        assert!(sanitize_file_path(".gitignore").is_err());
    }

    #[test]
    fn test_proxy_body_size_under_limit() {
        // Body within limit should succeed.
        assert!(check_proxy_body_size(1024, PROXY_MAX_RESPONSE_BYTES, "response").is_ok());
    }

    #[test]
    fn test_proxy_body_size_at_limit() {
        // Body exactly at limit should succeed.
        assert!(check_proxy_body_size(
            PROXY_MAX_RESPONSE_BYTES,
            PROXY_MAX_RESPONSE_BYTES,
            "response"
        )
        .is_ok());
    }

    #[test]
    fn test_proxy_body_size_over_limit() {
        // Body exceeding limit should fail.
        let result = check_proxy_body_size(
            PROXY_MAX_RESPONSE_BYTES + 1,
            PROXY_MAX_RESPONSE_BYTES,
            "response",
        );
        assert!(result.is_err());
    }

    #[test]
    fn test_proxy_request_body_size_limit() {
        // Request body exceeding the tighter request limit should fail.
        assert!(check_proxy_body_size(
            PROXY_MAX_REQUEST_BYTES + 1,
            PROXY_MAX_REQUEST_BYTES,
            "request"
        )
        .is_err());
        assert!(
            check_proxy_body_size(PROXY_MAX_REQUEST_BYTES, PROXY_MAX_REQUEST_BYTES, "request")
                .is_ok()
        );
    }

    #[test]
    fn test_proxy_response_header_filtering() {
        // Verify that only allowlisted headers pass through the proxy filter.
        use axum::http::header::HeaderName;
        use axum::http::HeaderMap;

        let mut upstream_headers = HeaderMap::new();
        upstream_headers.insert("content-type", "application/json".parse().unwrap());
        upstream_headers.insert("content-length", "42".parse().unwrap());
        upstream_headers.insert("etag", "\"abc123\"".parse().unwrap());
        upstream_headers.insert("set-cookie", "session=evil".parse().unwrap());
        upstream_headers.insert("location", "https://evil.com".parse().unwrap());
        upstream_headers.insert("access-control-allow-origin", "*".parse().unwrap());

        let mut filtered = HeaderMap::new();
        for name in PROXY_ALLOWED_RESPONSE_HEADERS {
            let header_name = HeaderName::from_static(name);
            if let Some(value) = upstream_headers.get(&header_name) {
                filtered.insert(header_name, value.clone());
            }
        }

        assert!(filtered.get("content-type").is_some());
        assert!(filtered.get("content-length").is_some());
        assert!(filtered.get("etag").is_some());
        assert!(filtered.get("set-cookie").is_none());
        assert!(filtered.get("location").is_none());
        assert!(filtered.get("access-control-allow-origin").is_none());
    }

    #[test]
    fn test_plugin_csp_contains_required_directives() {
        // Verify all security-critical CSP directives are present.
        assert!(PLUGIN_CONTENT_SECURITY_POLICY.contains("default-src 'none'"));
        assert!(PLUGIN_CONTENT_SECURITY_POLICY.contains("script-src 'self'"));
        assert!(PLUGIN_CONTENT_SECURITY_POLICY.contains("connect-src 'none'"));
        assert!(PLUGIN_CONTENT_SECURITY_POLICY.contains("frame-ancestors 'self'"));
        assert!(PLUGIN_CONTENT_SECURITY_POLICY.contains("form-action 'none'"));
        assert!(PLUGIN_CONTENT_SECURITY_POLICY.contains("object-src 'none'"));
        assert!(PLUGIN_CONTENT_SECURITY_POLICY.contains("base-uri 'none'"));
    }
}
