//! SOAP client and [`WeatherBriefingProvider`] implementation for the Leidos
//! FFSP Pilot Web Service.

use aerocontext_core::{
    Area, AreaBriefingRequest, Briefing, ContextProvider, ProviderError, WeatherBriefingProvider,
};
use async_trait::async_trait;
use chrono::Utc;
use reqwest::header::CONTENT_TYPE;
use tracing::{debug, warn};
use url::Url;

use crate::envelope::{AreaBriefingParams, area_briefing_envelope, ddmm_location};
use crate::response::{Outcome, ParseError, parse_area_briefing};
use crate::simple_briefing;

/// Production SOAP endpoint (the public 1800wxbrief host).
pub const PRODUCTION_ENDPOINT: &str = "https://www.1800wxbrief.com/Website/ws/";
/// Leidos evaluation/test (pelabs) SOAP endpoint.
pub const TEST_ENDPOINT: &str = "https://ffspelabs.leidos.com/Website2/ws/";

/// SOAP 1.1 wants the SOAPAction header present; the FFSP WSDL declares
/// it as the empty string, sent quoted.
const SOAP_ACTION: &str = "\"\"";

/// Configuration for [`LeidosClient`].
#[derive(Debug, Clone)]
pub struct LeidosConfig {
    /// SOAP endpoint URL: [`PRODUCTION_ENDPOINT`], [`TEST_ENDPOINT`], or
    /// a mock server in tests.
    pub endpoint: String,
    /// Web-services vendor ID issued by Leidos Flight Service.
    pub vendor_id: String,
    /// Password for the vendor ID. Sent as pre-emptive HTTP Basic auth.
    pub vendor_password: String,
}

/// Failure constructing a [`LeidosClient`].
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    /// The configured endpoint URL did not parse.
    #[error("invalid Leidos endpoint URL {url:?}")]
    InvalidEndpoint {
        /// The offending URL string.
        url: String,
        /// Parse failure.
        #[source]
        cause: url::ParseError,
    },
    /// The HTTP client could not be constructed.
    #[error("could not build HTTP client")]
    HttpClient(#[source] reqwest::Error),
    /// No vendor credentials were configured. The Pilot Web Service
    /// requires a web-services vendor ID and password (sent as pre-emptive
    /// HTTP Basic auth), so an empty one cannot authenticate.
    #[error("Leidos vendor ID and password are required")]
    MissingCredentials,
}

/// Leidos FFSP Pilot Web Service client.
#[derive(Debug, Clone)]
pub struct LeidosClient {
    http: reqwest::Client,
    endpoint: Url,
    vendor_id: String,
    vendor_password: String,
}

impl LeidosClient {
    /// Build a client from `config`.
    pub fn new(config: LeidosConfig) -> Result<Self, ConfigError> {
        if config.vendor_id.trim().is_empty() || config.vendor_password.trim().is_empty() {
            return Err(ConfigError::MissingCredentials);
        }
        let endpoint =
            Url::parse(&config.endpoint).map_err(|cause| ConfigError::InvalidEndpoint {
                url: config.endpoint.clone(),
                cause,
            })?;
        let http = reqwest::Client::builder()
            .build()
            .map_err(ConfigError::HttpClient)?;
        Ok(Self {
            http,
            endpoint,
            vendor_id: config.vendor_id,
            vendor_password: config.vendor_password,
        })
    }

    async fn call_area_briefing(&self, envelope: String) -> Result<String, ProviderError> {
        let context = || "calling AreaBriefing".to_owned();
        debug!(endpoint = %self.endpoint, "posting AreaBriefing envelope");
        let response = self
            .http
            .post(self.endpoint.clone())
            .basic_auth(&self.vendor_id, Some(&self.vendor_password))
            .header(CONTENT_TYPE, "text/xml; charset=utf-8")
            .header("SOAPAction", SOAP_ACTION)
            .body(envelope)
            .send()
            .await
            .map_err(|cause| ProviderError::Transport {
                context: context(),
                cause: Box::new(cause),
            })?;
        let status = response.status();
        let body = response
            .text()
            .await
            .map_err(|cause| ProviderError::Transport {
                context: context(),
                cause: Box::new(cause),
            })?;
        if !status.is_success() {
            // SOAP faults ride on 500 with a fault envelope; surface the
            // faultstring when one is present.
            let message = match parse_area_briefing(&body) {
                Ok(Outcome::Fault { message }) => message,
                Ok(Outcome::Failure { messages }) => messages.join("; "),
                _ => body.chars().take(200).collect(),
            };
            return Err(ProviderError::Provider {
                context: context(),
                message,
                status: Some(status.as_u16()),
            });
        }
        Ok(body)
    }
}

/// Map the provider-neutral area onto Leidos' point-plus-radius model.
fn center_and_radius(area: &Area) -> Result<(String, u32), ProviderError> {
    let (location, radius_nm) = match area {
        Area::LocationRadius { ident, radius_nm } => (ident.clone(), *radius_nm),
        Area::PointRadius { center, radius_nm } => (ddmm_location(*center), *radius_nm),
        other => {
            return Err(ProviderError::Unsupported {
                reason: format!(
                    "Leidos area briefings cover a circle around a fix or coordinate; \
                     {other:?} has no equivalent"
                ),
            });
        }
    };
    let rounded = radius_nm.round();
    if !(2.0..=100.0).contains(&rounded) {
        return Err(ProviderError::Unsupported {
            reason: format!("Leidos briefingRadius must be 2-100 NM, got {radius_nm}"),
        });
    }
    // Range-checked above, so the cast cannot truncate.
    Ok((location, rounded as u32))
}

impl ContextProvider for LeidosClient {
    fn name(&self) -> &str {
        "leidos"
    }
}

#[async_trait(?Send)]
impl WeatherBriefingProvider for LeidosClient {
    async fn area_briefing(
        &self,
        request: &AreaBriefingRequest,
    ) -> Result<Briefing, ProviderError> {
        if !request.products.is_empty() {
            return Err(ProviderError::Unsupported {
                reason: "product selection is not yet mapped onto Leidos \
                         briefingPreferences; pass an empty product list for the \
                         provider's default set"
                    .to_owned(),
            });
        }
        if request.lookback_hours.is_some() {
            // Leidos derives the briefing window from departureInstant
            // (2 h before departure to 72 h after arrival).
            warn!("lookback_hours has no Leidos equivalent; ignoring");
        }
        let (location_identifier, radius_nm) = center_and_radius(&request.area)?;
        let envelope = area_briefing_envelope(&AreaBriefingParams {
            location_identifier: &location_identifier,
            departure: request.departure_at.unwrap_or_else(Utc::now),
            radius_nm,
        });
        let body = self.call_area_briefing(envelope).await?;
        let outcome = parse_area_briefing(&body).map_err(|cause| ProviderError::Decode {
            context: "parsing AreaBriefing response".to_owned(),
            cause: Box::new(cause),
        })?;
        match outcome {
            Outcome::Success {
                simple_briefing: Some(narrative),
            } => {
                // Surface both the document and the products it contains,
                // so the briefing is directly comparable with a
                // product-oriented source.
                let products = simple_briefing::extract_products(&narrative);
                Ok(Briefing::new(self.name())
                    .with_generated_at(Some(Utc::now()))
                    .with_narrative(Some(narrative))
                    .with_products(products))
            }
            Outcome::Success {
                simple_briefing: None,
            } => Err(ProviderError::Decode {
                context: "parsing AreaBriefing response".to_owned(),
                cause: Box::new(ParseError::MissingBriefing),
            }),
            Outcome::Failure { messages } => Err(ProviderError::Provider {
                context: "calling AreaBriefing".to_owned(),
                message: messages.join("; "),
                status: None,
            }),
            Outcome::Fault { message } => Err(ProviderError::Provider {
                context: "calling AreaBriefing".to_owned(),
                message,
                status: None,
            }),
        }
    }
}
