//! Build outbound `<samlp:AuthnRequest>` per SAML 2.0 Core §3.4.1.
//!
//! Used by `ServiceProvider::start_login` (RFC-003 §3). The output of
//! `build_authn_request_element` is an `Element` ready to be wrapped in a
//! `Document` (and optionally signed via `dsig::sign::sign_element`) before
//! being handed to the binding layer.
//!
//! The XML shape produced is intentionally minimal: only the attributes /
//! children that the SP is required (or has explicitly asked) to send. SAML
//! defines `ForceAuthn` and `IsPassive` to default to `false`, so we omit them
//! unless the caller actually requested them — emitting `IsPassive="false"`
//! on every request is legal but adds wire-format noise that some IdPs log
//! as a request shape change. Defaults are quiet.
//!
//! `AssertionConsumerServiceIndex` and `AssertionConsumerServiceURL` are
//! mutually exclusive by SAML schema; the `AcsRequest` enum makes that
//! mutual exclusion structural.

use crate::authn::{SAML_NS, SAMLP_NS, saml_qname, samlp_qname};
use crate::authn_context::{AuthnContextComparison, RequestedAuthnContext};
use crate::binding::SsoResponseBinding;
use crate::error::Error;
use crate::nameid::NameIdFormat;
use crate::time::format_xs_datetime;
use crate::xml::emit::emit_document;
use crate::xml::parse::{Document, Element, Node, QName};

/// Inputs for building a `<samlp:AuthnRequest>` element.
pub(crate) struct BuildAuthnRequest<'a> {
    /// `"_<hex16>"` generated by the caller. SAML IDs must start with a
    /// non-digit; `_` is conventional.
    pub id: &'a str,
    pub issue_instant: std::time::SystemTime,
    pub issuer_entity_id: &'a str,
    /// IdP SSO endpoint URL.
    pub destination: &'a str,
    pub force_authn: bool,
    pub is_passive: bool,
    /// ACS endpoint nomination. The library always uses either Index or URL,
    /// never both. Per RFC-003 §3, when an `acs_index` is supplied we emit
    /// `AssertionConsumerServiceIndex`; otherwise we may emit
    /// `AssertionConsumerServiceURL`.
    pub acs_selection: AcsRequest<'a>,
    /// `ProtocolBinding` (the response binding the SP wants).
    pub protocol_binding: Option<SsoResponseBinding>,
    /// `NameIDPolicy/@Format` if requested.
    pub requested_name_id_format: Option<NameIdFormat>,
    pub requested_authn_context: Option<&'a RequestedAuthnContext>,
}

/// How the SP nominates its `AssertionConsumerService` to the IdP.
pub(crate) enum AcsRequest<'a> {
    /// Use IdP/SP default ACS (omits both attributes from the AuthnRequest).
    Default,
    /// Emit `AssertionConsumerServiceIndex="<n>"`.
    Index(u16),
    /// Emit `AssertionConsumerServiceURL="<u>"`. Only used in special cases;
    /// per RFC-003 §3 the SP normally nominates by index for security.
    Url(&'a str),
}

/// Build the `<samlp:AuthnRequest>` element. The returned [`Element`] is NOT
/// yet wrapped in a [`Document`]; the caller wraps via `Document::new` and may
/// then call `dsig::sign::sign_element` before emitting.
pub(crate) fn build_authn_request_element(input: &BuildAuthnRequest<'_>) -> Result<Element, Error> {
    // Element ordering inside <samlp:AuthnRequest> is fixed by the schema:
    //   <saml:Issuer> → <samlp:NameIDPolicy> → <samlp:RequestedAuthnContext>.
    // (Other optional children exist — Conditions, Scoping, Extensions — but
    // RFC-003 §3 says we don't emit them at v0.1.)

    let mut builder = Element::build(samlp_qname("AuthnRequest"))
        .with_namespace(Some("samlp".to_owned()), SAMLP_NS)
        .with_namespace(Some("saml".to_owned()), SAML_NS)
        .with_attribute(QName::new(None, "ID"), input.id.to_owned())
        .with_attribute(QName::new(None, "Version"), "2.0")
        .with_attribute(
            QName::new(None, "IssueInstant"),
            format_xs_datetime(input.issue_instant)?,
        )
        .with_attribute(
            QName::new(None, "Destination"),
            input.destination.to_owned(),
        );

    // Mutually exclusive ACS attributes. The schema permits at most one.
    match &input.acs_selection {
        AcsRequest::Default => {}
        AcsRequest::Index(n) => {
            builder = builder.with_attribute(
                QName::new(None, "AssertionConsumerServiceIndex"),
                n.to_string(),
            );
        }
        AcsRequest::Url(u) => {
            builder = builder.with_attribute(
                QName::new(None, "AssertionConsumerServiceURL"),
                (*u).to_owned(),
            );
        }
    }

    if let Some(binding) = input.protocol_binding {
        builder = builder.with_attribute(
            QName::new(None, "ProtocolBinding"),
            binding.uri().to_owned(),
        );
    }

    // ForceAuthn / IsPassive: only emit when true. SAML 2.0 default is false,
    // and emitting the default value is noise some IdPs log as a request
    // shape change.
    if input.force_authn {
        builder = builder.with_attribute(QName::new(None, "ForceAuthn"), "true");
    }
    if input.is_passive {
        builder = builder.with_attribute(QName::new(None, "IsPassive"), "true");
    }

    // <saml:Issuer>
    let issuer = Element::build(saml_qname("Issuer"))
        .with_text(input.issuer_entity_id.to_owned())
        .finish();
    builder = builder.with_child(Node::Element(issuer));

    // <samlp:NameIDPolicy Format="..." AllowCreate="true"/>
    if let Some(fmt) = &input.requested_name_id_format {
        let mut pol = Element::build(samlp_qname("NameIDPolicy"))
            .with_attribute(QName::new(None, "Format"), fmt.as_uri().to_owned());
        // Most IdPs (Okta, Azure AD, Auth0, OneLogin) expect AllowCreate="true"
        // for persistent / emailAddress requests. Without it they will refuse
        // to mint a new pairwise identifier for a previously-unseen user.
        if matches!(fmt, NameIdFormat::Persistent | NameIdFormat::EmailAddress) {
            pol = pol.with_attribute(QName::new(None, "AllowCreate"), "true");
        }
        builder = builder.with_child(Node::Element(pol.finish()));
    }

    // <samlp:RequestedAuthnContext Comparison="...">
    if let Some(rac) = input.requested_authn_context {
        let mut rac_el = Element::build(samlp_qname("RequestedAuthnContext")).with_attribute(
            QName::new(None, "Comparison"),
            comparison_attr(&rac.comparison),
        );
        for class_ref in &rac.class_refs {
            rac_el = rac_el.with_child(Node::Element(
                Element::build(saml_qname("AuthnContextClassRef"))
                    .with_text(class_ref.as_uri().to_owned())
                    .finish(),
            ));
        }
        builder = builder.with_child(Node::Element(rac_el.finish()));
    }

    Ok(builder.finish())
}

/// Build the `<samlp:AuthnRequest>` element, wrap it in a [`Document`], and
/// serialize. Returns the raw XML bytes — NOT yet base64-encoded; the binding
/// layer is responsible for any wire encoding (DEFLATE+base64 for Redirect,
/// base64 for POST).
pub(crate) fn build_authn_request_xml(input: &BuildAuthnRequest<'_>) -> Result<Vec<u8>, Error> {
    let element = build_authn_request_element(input)?;
    let doc = Document::new(element)?;
    let xml = emit_document(&doc)?;
    Ok(xml.into_bytes())
}

fn comparison_attr(c: &AuthnContextComparison) -> &'static str {
    match c {
        AuthnContextComparison::Exact => "exact",
        AuthnContextComparison::Minimum => "minimum",
        AuthnContextComparison::Maximum => "maximum",
        AuthnContextComparison::Better => "better",
    }
}

// =============================================================================
// Tests
// =============================================================================

#[cfg(test)]
mod tests {
    use super::*;
    use crate::authn_context::AuthnContextClassRef;
    use std::time::{Duration, UNIX_EPOCH};

    fn fixed_instant() -> std::time::SystemTime {
        // 2026-05-26T12:34:56Z, matches time.rs round-trip vector.
        UNIX_EPOCH
            .checked_add(Duration::from_secs(1_779_798_896))
            .expect("UNIX_EPOCH + small duration fits in SystemTime")
    }

    fn minimal_input<'a>() -> BuildAuthnRequest<'a> {
        BuildAuthnRequest {
            id: "_abc123",
            issue_instant: fixed_instant(),
            issuer_entity_id: "https://sp.example.com/saml",
            destination: "https://idp.example.com/sso",
            force_authn: false,
            is_passive: false,
            acs_selection: AcsRequest::Default,
            protocol_binding: None,
            requested_name_id_format: None,
            requested_authn_context: None,
        }
    }

    /// Emit and re-parse so we can interrogate attributes and children
    /// structurally — string `.contains` on the raw XML would be brittle
    /// against attribute-order or prefix-choice changes in the emitter.
    fn emit_and_reparse(input: &BuildAuthnRequest<'_>) -> Document {
        let xml = build_authn_request_xml(input).expect("build");
        Document::parse(&xml).expect("re-parse")
    }

    #[test]
    fn minimal_request_carries_required_attributes_and_issuer() {
        let doc = emit_and_reparse(&minimal_input());
        let root = doc.root();
        assert_eq!(root.qname().namespace(), Some(SAMLP_NS));
        assert_eq!(root.qname().local(), "AuthnRequest");

        assert_eq!(root.attribute(None, "ID"), Some("_abc123"));
        assert_eq!(root.attribute(None, "Version"), Some("2.0"));
        assert_eq!(
            root.attribute(None, "IssueInstant"),
            Some("2026-05-26T12:34:56Z")
        );
        assert_eq!(
            root.attribute(None, "Destination"),
            Some("https://idp.example.com/sso")
        );

        // Defaults: ForceAuthn / IsPassive absent.
        assert_eq!(root.attribute(None, "ForceAuthn"), None);
        assert_eq!(root.attribute(None, "IsPassive"), None);

        // ACS attributes absent (Default selection).
        assert_eq!(root.attribute(None, "AssertionConsumerServiceIndex"), None);
        assert_eq!(root.attribute(None, "AssertionConsumerServiceURL"), None);
        assert_eq!(root.attribute(None, "ProtocolBinding"), None);

        let issuer = root.child_element(Some(SAML_NS), "Issuer").unwrap();
        assert_eq!(issuer.text_content(), "https://sp.example.com/saml");

        // No NameIDPolicy / RequestedAuthnContext at minimum.
        assert!(root.child_element(Some(SAMLP_NS), "NameIDPolicy").is_none());
        assert!(
            root.child_element(Some(SAMLP_NS), "RequestedAuthnContext")
                .is_none()
        );
    }

    #[test]
    fn acs_index_and_protocol_binding_post() {
        let mut input = minimal_input();
        input.acs_selection = AcsRequest::Index(0);
        input.protocol_binding = Some(SsoResponseBinding::HttpPost);

        let doc = emit_and_reparse(&input);
        let root = doc.root();
        assert_eq!(
            root.attribute(None, "AssertionConsumerServiceIndex"),
            Some("0")
        );
        assert_eq!(root.attribute(None, "AssertionConsumerServiceURL"), None);
        assert_eq!(
            root.attribute(None, "ProtocolBinding"),
            Some("urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST")
        );
    }

    #[test]
    fn acs_url_variant() {
        let mut input = minimal_input();
        input.acs_selection = AcsRequest::Url("https://sp.example.com/acs/post");

        let doc = emit_and_reparse(&input);
        let root = doc.root();
        assert_eq!(
            root.attribute(None, "AssertionConsumerServiceURL"),
            Some("https://sp.example.com/acs/post")
        );
        assert_eq!(root.attribute(None, "AssertionConsumerServiceIndex"), None);
    }

    #[test]
    fn requested_authn_context_mfa_exact() {
        let rac = RequestedAuthnContext {
            class_refs: vec![AuthnContextClassRef::MultiFactorAuth],
            comparison: AuthnContextComparison::Exact,
        };
        let mut input = minimal_input();
        input.requested_authn_context = Some(&rac);

        let doc = emit_and_reparse(&input);
        let rac_el = doc
            .root()
            .child_element(Some(SAMLP_NS), "RequestedAuthnContext")
            .expect("RequestedAuthnContext present");
        assert_eq!(rac_el.attribute(None, "Comparison"), Some("exact"));
        let cref = rac_el
            .child_element(Some(SAML_NS), "AuthnContextClassRef")
            .unwrap();
        assert_eq!(
            cref.text_content(),
            "urn:oasis:names:tc:SAML:2.0:ac:classes:MultiFactorAuthentication"
        );
    }

    #[test]
    fn force_authn_and_is_passive_only_when_true() {
        // First: both false → both omitted (covered by minimal test).
        // Now flip to true and check we now see them.
        let mut input = minimal_input();
        input.force_authn = true;
        input.is_passive = true;

        let doc = emit_and_reparse(&input);
        assert_eq!(doc.root().attribute(None, "ForceAuthn"), Some("true"));
        assert_eq!(doc.root().attribute(None, "IsPassive"), Some("true"));
    }

    #[test]
    fn name_id_policy_persistent_emits_allow_create() {
        let mut input = minimal_input();
        input.requested_name_id_format = Some(NameIdFormat::Persistent);

        let doc = emit_and_reparse(&input);
        let pol = doc
            .root()
            .child_element(Some(SAMLP_NS), "NameIDPolicy")
            .unwrap();
        assert_eq!(
            pol.attribute(None, "Format"),
            Some("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent")
        );
        assert_eq!(pol.attribute(None, "AllowCreate"), Some("true"));
    }

    #[test]
    fn name_id_policy_email_emits_allow_create() {
        let mut input = minimal_input();
        input.requested_name_id_format = Some(NameIdFormat::EmailAddress);

        let doc = emit_and_reparse(&input);
        let pol = doc
            .root()
            .child_element(Some(SAMLP_NS), "NameIDPolicy")
            .unwrap();
        assert_eq!(pol.attribute(None, "AllowCreate"), Some("true"));
    }

    #[test]
    fn name_id_policy_transient_omits_allow_create() {
        let mut input = minimal_input();
        input.requested_name_id_format = Some(NameIdFormat::Transient);

        let doc = emit_and_reparse(&input);
        let pol = doc
            .root()
            .child_element(Some(SAMLP_NS), "NameIDPolicy")
            .unwrap();
        assert_eq!(pol.attribute(None, "AllowCreate"), None);
    }

    #[test]
    fn xml_is_well_formed_and_round_trips() {
        let rac = RequestedAuthnContext {
            class_refs: vec![
                AuthnContextClassRef::PasswordProtectedTransport,
                AuthnContextClassRef::MultiFactorAuth,
            ],
            comparison: AuthnContextComparison::Minimum,
        };
        let mut input = minimal_input();
        input.acs_selection = AcsRequest::Index(2);
        input.protocol_binding = Some(SsoResponseBinding::HttpArtifact);
        input.requested_name_id_format = Some(NameIdFormat::Persistent);
        input.requested_authn_context = Some(&rac);
        input.force_authn = true;

        let xml = build_authn_request_xml(&input).expect("build");
        // Re-parse and re-emit; the structural content must be stable.
        let doc = Document::parse(&xml).expect("re-parse");
        let root = doc.root();
        assert_eq!(root.attribute(None, "ID"), Some("_abc123"));
        assert_eq!(
            root.attribute(None, "AssertionConsumerServiceIndex"),
            Some("2")
        );
        assert_eq!(
            root.attribute(None, "ProtocolBinding"),
            Some("urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact")
        );
        assert_eq!(root.attribute(None, "ForceAuthn"), Some("true"));

        let rac_el = root
            .child_element(Some(SAMLP_NS), "RequestedAuthnContext")
            .unwrap();
        assert_eq!(rac_el.attribute(None, "Comparison"), Some("minimum"));
        let class_refs: Vec<String> = rac_el
            .all_child_elements(Some(SAML_NS), "AuthnContextClassRef")
            .map(Element::text_content)
            .collect();
        assert_eq!(class_refs.len(), 2);
        assert!(class_refs[0].ends_with("PasswordProtectedTransport"));
        assert!(class_refs[1].ends_with("MultiFactorAuthentication"));
    }
}
