//! Configuration and request-bound validation for production portal routes.
//!
//! RATIONALE: the portal can aggregate reports, evidence and external service
//! payloads. Query and body limits keep pilot installations responsive and make
//! expensive report routes fail closed instead of exhausting memory or blocking
//! the single-process runtime.

use std::collections::BTreeSet;

use anyhow::{Result, anyhow};
use chrono::NaiveDate;
use serde_json::{Value, json};
use tiny_http::StatusCode;

use crate::{
    Cli, MAX_ALLOWED_PAGE_SIZE, MAX_ALLOWED_REPORT_DATE_RANGE_DAYS, MAX_ALLOWED_REQUEST_BODY_BYTES,
    MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS, query_param,
};

#[derive(Debug)]
pub(crate) struct ApiLimitError {
    pub(crate) status: StatusCode,
    pub(crate) payload: Value,
}

pub(crate) fn validate_portal_config(args: &Cli) -> Result<()> {
    let (host, port) = args
        .bind
        .rsplit_once(':')
        .ok_or_else(|| anyhow!("invalid config bind: expected host:port"))?;
    if host.trim().is_empty() {
        return Err(anyhow!("invalid config host: value is empty"));
    }
    let port = port
        .parse::<u16>()
        .map_err(|_| anyhow!("invalid config port: expected 1..65535"))?;
    if port == 0 {
        return Err(anyhow!("invalid config port: expected 1..65535"));
    }

    // RATIONALE: page and date limits protect heavy report endpoints while
    // preserving monthly pilot reporting. Hard upper bounds prevent accidental
    // production overrides from turning the portal into an unbounded exporter.
    if args.max_page_size == 0 || args.max_page_size > MAX_ALLOWED_PAGE_SIZE {
        return Err(anyhow!(
            "invalid config max_page_size: expected 1..={MAX_ALLOWED_PAGE_SIZE}"
        ));
    }
    if args.default_page_size == 0 || args.default_page_size > args.max_page_size {
        return Err(anyhow!(
            "invalid config default_page_size: expected 1..=max_page_size"
        ));
    }
    if args.max_report_date_range_days <= 0
        || args.max_report_date_range_days > MAX_ALLOWED_REPORT_DATE_RANGE_DAYS
    {
        return Err(anyhow!(
            "invalid config max_report_date_range_days: expected 1..={MAX_ALLOWED_REPORT_DATE_RANGE_DAYS}"
        ));
    }
    if args.request_timeout_seconds == 0
        || args.request_timeout_seconds > MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS
    {
        return Err(anyhow!(
            "invalid config request_timeout_seconds: expected 1..={MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS}"
        ));
    }
    if args.timeout_seconds == 0 || args.timeout_seconds > MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS {
        return Err(anyhow!(
            "invalid config timeout_seconds: expected 1..={MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS}"
        ));
    }
    if args.max_request_body_bytes < 1024
        || args.max_request_body_bytes > MAX_ALLOWED_REQUEST_BODY_BYTES
    {
        return Err(anyhow!(
            "invalid config max_request_body_bytes: expected 1024..={MAX_ALLOWED_REQUEST_BODY_BYTES}"
        ));
    }

    // SECURITY: environment and module names can reach metrics/log labels.
    // Restrict them to short ASCII tokens to avoid label injection and runaway
    // cardinality from free-form deployment names.
    if !is_safe_environment_name(&args.environment) {
        return Err(anyhow!(
            "invalid config environment: use 1..32 chars from A-Z, a-z, 0-9, _, -"
        ));
    }
    let modules = enabled_modules(&args.enabled_modules);
    if modules.is_empty() {
        return Err(anyhow!(
            "invalid config enabled_modules: no modules enabled"
        ));
    }
    let allowed = [
        "executive",
        "workforce",
        "security",
        "forensics",
        "admin",
        "ueba",
        "pfsense",
        "reports",
    ];
    for module in modules {
        if !allowed.contains(&module.as_str()) {
            return Err(anyhow!(
                "invalid config enabled_modules: unsupported module {module}"
            ));
        }
    }
    Ok(())
}

fn is_safe_environment_name(value: &str) -> bool {
    let value = value.trim();
    !value.is_empty()
        && value.len() <= 32
        && value
            .chars()
            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
}

fn enabled_modules(value: &str) -> BTreeSet<String> {
    value
        .split(',')
        .map(|item| item.trim().to_ascii_lowercase())
        .filter(|item| !item.is_empty())
        .collect()
}

pub(crate) fn is_limited_api_route(path: &str) -> bool {
    matches!(
        path,
        "/api/reports"
            | "/api/executive"
            | "/api/workforce"
            | "/api/security"
            | "/api/forensics"
            | "/api/ueba"
            | "/api/pfsense"
            | "/api/workforce/kpi/explain"
            | "/api/risk/narrative"
            | "/api/actions"
    )
}

pub(crate) fn validate_api_query_limits(
    url: &str,
    args: &Cli,
) -> std::result::Result<(), ApiLimitError> {
    for key in ["page_size", "limit"] {
        if let Some(value) = query_param(url, key) {
            let parsed = value.parse::<u32>().ok();
            if parsed.is_none_or(|page_size| page_size == 0 || page_size > args.max_page_size) {
                return Err(api_limit_error(
                    StatusCode(400),
                    "invalid_page_size",
                    &format!("{key} must be between 1 and {}", args.max_page_size),
                ));
            }
        }
    }

    for (from_key, to_key) in [("date_from", "date_to"), ("from", "to"), ("start", "end")] {
        let Some(from) = query_param(url, from_key).and_then(|value| parse_query_date(&value))
        else {
            continue;
        };
        let Some(to) = query_param(url, to_key).and_then(|value| parse_query_date(&value)) else {
            continue;
        };
        let days = (to - from).num_days().abs() + 1;
        if days > args.max_report_date_range_days {
            return Err(api_limit_error(
                StatusCode(400),
                "report_range_too_large",
                &format!(
                    "report date range must be <= {} days",
                    args.max_report_date_range_days
                ),
            ));
        }
    }
    Ok(())
}

pub(crate) fn parse_query_date(value: &str) -> Option<NaiveDate> {
    let date = value.split('T').next().unwrap_or(value);
    NaiveDate::parse_from_str(date, "%Y-%m-%d").ok()
}

fn api_limit_error(status: StatusCode, code: &str, message: &str) -> ApiLimitError {
    ApiLimitError {
        status,
        payload: json!({
            "ok": false,
            "error_code": code,
            "message": message,
        }),
    }
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use super::*;
    use crate::{
        DEFAULT_MAX_PAGE_SIZE, DEFAULT_MAX_REPORT_DATE_RANGE_DAYS, DEFAULT_MAX_REQUEST_BODY_BYTES,
        DEFAULT_PAGE_SIZE, DEFAULT_REQUEST_TIMEOUT_SECONDS, DEFAULT_SLOW_REQUEST_LOG_MS,
    };

    fn test_cli(dir: &Path) -> Cli {
        Cli {
            bind: "127.0.0.1:8720".to_string(),
            status_cmd: "true".to_string(),
            check_cmd: "true".to_string(),
            failed_units_cmd: "true".to_string(),
            worktime_url: "http://127.0.0.1".to_string(),
            one_c_url: "http://127.0.0.1".to_string(),
            workforce_policy_path: dir.join("workforce-policy.json"),
            ueba_policy_path: dir.join("ueba-policy.yaml"),
            timeout_seconds: 1,
            max_page_size: DEFAULT_MAX_PAGE_SIZE,
            default_page_size: DEFAULT_PAGE_SIZE,
            max_report_date_range_days: DEFAULT_MAX_REPORT_DATE_RANGE_DAYS,
            request_timeout_seconds: DEFAULT_REQUEST_TIMEOUT_SECONDS,
            max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES,
            slow_request_log_ms: DEFAULT_SLOW_REQUEST_LOG_MS,
            environment: "test".to_string(),
            enabled_modules: "executive,workforce,security,forensics,admin".to_string(),
            state_dir: dir.join("state"),
            dlp_db_path: dir.join("dlp.sqlite"),
            evidence_root: dir.to_path_buf(),
            readiness_bundle_dir: dir.join("readiness-bundle"),
            evidence_limit: 10,
            evidence_max_bytes: 1024,
            json_smoke: false,
            evidence_only: false,
            evidence_upload_token: None,
            telemetry_api_key: "dummy".to_string(),
            telemetry_store_path: dir.join("telemetry.jsonl"),
            expected_nodes_path: dir.join("expected_nodes.json"),
            security_events_backend: "disabled".to_string(),
            clickhouse_url: "http://127.0.0.1:8123".to_string(),
            clickhouse_database: "analytics_1c".to_string(),
            clickhouse_user: "default".to_string(),
            clickhouse_password: String::new(),
        }
    }

    #[test]
    fn config_validation_rejects_bad_values() {
        let dir = tempfile::tempdir().unwrap();
        let args = test_cli(dir.path());
        assert!(validate_portal_config(&args).is_ok());

        let mut invalid = args.clone();
        invalid.bind = "127.0.0.1:bad".to_string();
        assert!(
            validate_portal_config(&invalid)
                .unwrap_err()
                .to_string()
                .contains("port")
        );

        let mut invalid = args.clone();
        invalid.max_page_size = 0;
        assert!(
            validate_portal_config(&invalid)
                .unwrap_err()
                .to_string()
                .contains("max_page_size")
        );

        let mut invalid = args.clone();
        invalid.max_report_date_range_days = 0;
        assert!(
            validate_portal_config(&invalid)
                .unwrap_err()
                .to_string()
                .contains("max_report_date_range_days")
        );

        let mut invalid = args.clone();
        invalid.request_timeout_seconds = 0;
        assert!(
            validate_portal_config(&invalid)
                .unwrap_err()
                .to_string()
                .contains("request_timeout_seconds")
        );
    }

    #[test]
    fn query_limits_reject_page_size_and_report_range() {
        let dir = tempfile::tempdir().unwrap();
        let args = test_cli(dir.path());
        assert!(validate_api_query_limits("/api/reports?page_size=100", &args).is_ok());
        let page_error =
            validate_api_query_limits("/api/reports?page_size=999999", &args).unwrap_err();
        assert_eq!(page_error.status.0, 400);
        assert_eq!(page_error.payload["error_code"], "invalid_page_size");

        let range_error = validate_api_query_limits(
            "/api/reports?date_from=2026-01-01&date_to=2026-12-31",
            &args,
        )
        .unwrap_err();
        assert_eq!(range_error.status.0, 400);
        assert_eq!(range_error.payload["error_code"], "report_range_too_large");
    }
}
