//! IdP metadata parsing + SP metadata generation.
//!
//! We parse only what the SP flow needs: the IdP Entity ID, its
//! `SingleSignOnService` endpoints (HTTP-Redirect / HTTP-POST), and the
//! X.509 signing certificate(s). Everything else in the document is ignored.

use base64::Engine;

use super::{SamlError, SpParams};
use crate::encoding::xml_escape;

/// SAML 2.0 binding URIs.
pub const BINDING_REDIRECT: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";
pub const BINDING_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";

/// The subset of an IdP's `EntityDescriptor` the SP flow consumes.
#[derive(Debug, Clone)]
pub struct IdpMetadata {
    /// The IdP's Entity ID — we require incoming assertions to be issued by it.
    pub entity_id: String,
    /// SSO endpoint for the HTTP-Redirect binding (where we send AuthnRequests).
    pub sso_redirect_url: Option<String>,
    /// SSO endpoint for the HTTP-POST binding (fallback target).
    pub sso_post_url: Option<String>,
    /// DER-encoded X.509 signing certificate(s). More than one appears during
    /// key rotation; verification tries each.
    pub signing_certs_der: Vec<Vec<u8>>,
}

impl IdpMetadata {
    /// Parse an IdP `EntityDescriptor` document.
    ///
    /// Robust to namespace-prefix variation (matches on local element names),
    /// since IdPs disagree on prefixes (`md:`, `ns0:`, default, …).
    pub fn parse(xml: &str) -> Result<Self, SamlError> {
        let doc = roxmltree::Document::parse(xml).map_err(|e| SamlError::Xml(e.to_string()))?;
        let root = doc.root_element();

        // The signing IDP descriptor. Some metadata wraps multiple
        // descriptors (AA, SP) in one document; we want IDPSSODescriptor.
        let idp_desc = root
            .descendants()
            .find(|n| n.is_element() && n.tag_name().name() == "IDPSSODescriptor")
            .ok_or_else(|| SamlError::Metadata("IDPSSODescriptor".into()))?;

        // Entity ID lives on the EntityDescriptor (root, or an ancestor of the
        // IDPSSODescriptor when several are nested).
        let entity_id = idp_desc
            .ancestors()
            .find_map(|n| {
                if n.tag_name().name() == "EntityDescriptor" {
                    n.attribute("entityID")
                } else {
                    None
                }
            })
            .or_else(|| root.attribute("entityID"))
            .map(str::to_owned)
            .ok_or_else(|| SamlError::Metadata("entityID".into()))?;

        let mut sso_redirect_url = None;
        let mut sso_post_url = None;
        for sso in idp_desc
            .children()
            .filter(|n| n.is_element() && n.tag_name().name() == "SingleSignOnService")
        {
            let binding = sso.attribute("Binding").unwrap_or("");
            let location = sso.attribute("Location").map(str::to_owned);
            match binding {
                BINDING_REDIRECT if sso_redirect_url.is_none() => sso_redirect_url = location,
                BINDING_POST if sso_post_url.is_none() => sso_post_url = location,
                _ => {}
            }
        }

        // Signing certs: KeyDescriptor with use="signing" or no use attribute
        // (a bare KeyDescriptor is valid for both signing and encryption).
        let mut signing_certs_der = Vec::new();
        for kd in idp_desc
            .children()
            .filter(|n| n.is_element() && n.tag_name().name() == "KeyDescriptor")
        {
            match kd.attribute("use") {
                Some("signing") | None => {}
                Some(_) => continue, // encryption-only key — skip
            }
            for cert_node in kd
                .descendants()
                .filter(|n| n.is_element() && n.tag_name().name() == "X509Certificate")
            {
                let b64: String = node_text(&cert_node)
                    .chars()
                    .filter(|c| !c.is_whitespace())
                    .collect();
                if b64.is_empty() {
                    continue;
                }
                let der = base64::engine::general_purpose::STANDARD
                    .decode(b64.as_bytes())
                    .map_err(|e| SamlError::Base64(e.to_string()))?;
                signing_certs_der.push(der);
            }
        }

        if signing_certs_der.is_empty() {
            return Err(SamlError::NoSigningCert);
        }

        Ok(Self {
            entity_id,
            sso_redirect_url,
            sso_post_url,
            signing_certs_der,
        })
    }

    /// Preferred SSO destination for an outbound AuthnRequest: HTTP-Redirect
    /// if advertised, otherwise HTTP-POST.
    pub fn sso_destination(&self) -> Option<&str> {
        self.sso_redirect_url
            .as_deref()
            .or(self.sso_post_url.as_deref())
    }
}

/// Build our SP `EntityDescriptor` XML so an IdP admin can import OpenPXE as a
/// relying party. Advertises the ACS URL (HTTP-POST binding) and an emailAddress
/// NameID format — matching what the response path expects.
pub fn build_sp_metadata(sp: &SpParams) -> String {
    let entity = xml_escape(&sp.entity_id);
    let acs = xml_escape(&sp.acs_url);
    format!(
        r#"<?xml version="1.0" encoding="UTF-8"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{entity}">
  <SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
    <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
    <AssertionConsumerService Binding="{BINDING_POST}" Location="{acs}" index="0" isDefault="true"/>
  </SPSSODescriptor>
</EntityDescriptor>
"#
    )
}

/// Collect the concatenated text of an element's direct text children.
fn node_text(n: &roxmltree::Node<'_, '_>) -> String {
    n.children()
        .filter(roxmltree::Node::is_text)
        .filter_map(|c| c.text())
        .collect()
}

// `xml_escape` now lives in `openpxe_core::encoding` (v0.5.4) — imported above.

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

    // A trimmed-down Keycloak-style IdP descriptor (cert body is a stand-in;
    // signing tests build real certs in the parent module's tests).
    const SAMPLE: &str = r#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
        xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
        entityID="https://idp.example.com/realms/fleet">
      <md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
        <md:KeyDescriptor use="signing">
          <ds:KeyInfo><ds:X509Data><ds:X509Certificate>
            QUJDREVG
          </ds:X509Certificate></ds:X509Data></ds:KeyInfo>
        </md:KeyDescriptor>
        <md:KeyDescriptor use="encryption">
          <ds:KeyInfo><ds:X509Data><ds:X509Certificate>WlpaWg==</ds:X509Certificate></ds:X509Data></ds:KeyInfo>
        </md:KeyDescriptor>
        <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
            Location="https://idp.example.com/realms/fleet/protocol/saml"/>
        <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
            Location="https://idp.example.com/realms/fleet/protocol/saml"/>
      </md:IDPSSODescriptor>
    </md:EntityDescriptor>"#;

    #[test]
    fn parses_entity_sso_and_signing_cert() {
        let m = IdpMetadata::parse(SAMPLE).unwrap();
        assert_eq!(m.entity_id, "https://idp.example.com/realms/fleet");
        assert_eq!(
            m.sso_redirect_url.as_deref(),
            Some("https://idp.example.com/realms/fleet/protocol/saml")
        );
        assert!(m.sso_post_url.is_some());
        // Only the signing KeyDescriptor's cert is collected (ABCDEF), not the
        // encryption one (ZZZZ).
        assert_eq!(m.signing_certs_der.len(), 1);
        assert_eq!(m.signing_certs_der[0], b"ABCDEF");
    }

    #[test]
    fn missing_signing_cert_is_rejected() {
        let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="x">
            <IDPSSODescriptor>
              <SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://x/sso"/>
            </IDPSSODescriptor></EntityDescriptor>"#;
        assert!(matches!(
            IdpMetadata::parse(xml),
            Err(SamlError::NoSigningCert)
        ));
    }

    #[test]
    fn missing_idp_descriptor_is_rejected() {
        let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="x"></EntityDescriptor>"#;
        assert!(matches!(
            IdpMetadata::parse(xml),
            Err(SamlError::Metadata(_))
        ));
    }

    #[test]
    fn sp_metadata_contains_entity_and_acs() {
        let sp = SpParams {
            entity_id: "https://pxe.example.com".into(),
            acs_url: "https://pxe.example.com/api/sso/acs".into(),
        };
        let xml = build_sp_metadata(&sp);
        assert!(xml.contains(r#"entityID="https://pxe.example.com""#));
        assert!(xml.contains("https://pxe.example.com/api/sso/acs"));
        assert!(xml.contains(BINDING_POST));
        // Must be well-formed.
        roxmltree::Document::parse(&xml).unwrap();
    }
}
