//! Bridge from captured OAuth/OIDC metadata to the auth-hardening
//! check library (WOR-942).
//!
//! WOR-926 v1 shipped six pure check functions in
//! [`crate::auth::conformance`]. v2 (this module) bundles the
//! captured metadata into one [`AuthHardeningProbe`] and runs every
//! check against it, returning a typed [`AuthHardeningReport`] the
//! compliance scorecard can fold into its rubric.
//!
//! The capture step (recording the `iss` callback parameter, the
//! DCR response, the OIDC refresh, the scope step-up, and the
//! `.well-known` URL during a real OAuth handshake) is the
//! connector's job; this module accepts the captured data and runs
//! the checks. Splitting it this way keeps the check library
//! transport-free and lets the same six checks fire against a
//! synthetic fixture in tests + a live capture in `mcptest
//! compliance`.

use serde::{Deserialize, Serialize};

use crate::auth::conformance::{
    check_as_bound_credentials, check_dcr_application_type, check_iss_present, check_oidc_refresh,
    check_scope_step_up_accumulation, check_well_known_suffix, AsBoundCredentialsInput,
    CheckOutcome, DcrApplicationTypeInput, IssInput, OidcRefreshInput, ScopeStepUpInput,
    WellKnownSuffixInput,
};

/// Bundle of metadata the auth-hardening pack runs against.
///
/// One `AuthHardeningProbe` per server under test. Every field is
/// optional so a partial capture still produces a meaningful
/// scorecard: a missing field maps to `NotApplicable` on the
/// corresponding check rather than failing the whole pack.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuthHardeningProbe {
    /// `iss` query parameter from the authorization-server callback,
    /// and the issuer URL the client expected. `None` when the flow
    /// did not include a callback the runner observed.
    pub iss: Option<IssCapture>,
    /// DCR registration response: declared `application_type` and
    /// whether the client is a native (CLI / desktop) app.
    pub dcr: Option<DcrCapture>,
    /// Cached-credentials AS binding: which AS originally issued
    /// the credentials and whether the runner re-registered against
    /// the current AS when the resource migrated.
    pub as_binding: Option<AsBindingCapture>,
    /// OIDC refresh-token flow: did the response include
    /// `offline_access`, did a refresh token come back, and did the
    /// rotated value differ from the original.
    pub oidc_refresh: Option<OidcRefreshCapture>,
    /// Scope step-up flow: previous-grant scopes, requested
    /// step-up scopes, and the scopes the server actually granted.
    pub scope_step_up: Option<ScopeStepUpCapture>,
    /// Resolved `.well-known` URL the runner used during discovery.
    pub well_known_url: Option<String>,
}

/// Captured iss / expected-issuer pair for SEP-2468.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct IssCapture {
    /// `iss` value returned by the authorization endpoint.
    pub iss_from_response: Option<String>,
    /// Issuer URL the client expected.
    pub expected_issuer: String,
}

/// Captured DCR registration response for SEP-837.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DcrCapture {
    /// `application_type` declared in the DCR response.
    pub declared: Option<String>,
    /// Whether the client is a native (CLI / desktop) app.
    pub is_native_client: bool,
}

/// Captured AS-binding context for SEP-2352.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct AsBindingCapture {
    /// AS that issued the cached credentials.
    pub credentials_bound_to: String,
    /// Resource's current AS.
    pub resource_current_as: String,
    /// Whether the runner re-registered against the current AS.
    pub reregistered_against_current_as: bool,
}

/// Captured OIDC refresh response for SEP-2207.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct OidcRefreshCapture {
    /// Whether the grant included `offline_access`.
    pub granted_offline_access: bool,
    /// Whether the response included a refresh token.
    pub refresh_token_present: bool,
    /// The rotated refresh token, if any.
    pub rotated_refresh_token: Option<String>,
}

/// Captured scope step-up flow for SEP-2350.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScopeStepUpCapture {
    /// Scopes granted on the previous grant.
    pub previous_scopes: Vec<String>,
    /// Scopes the client requested on the step-up.
    pub requested_step_up: Vec<String>,
    /// Scopes the server granted on the step-up.
    pub granted: Vec<String>,
}

/// One row of the auth-hardening report.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuthHardeningRow {
    /// SEP identifier (for example `SEP-2468`).
    pub sep: String,
    /// Human-readable check name.
    pub name: String,
    /// Outcome from the underlying check function.
    pub outcome: CheckOutcome,
}

/// Full report from running every check against an
/// [`AuthHardeningProbe`].
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuthHardeningReport {
    /// One row per SEP check.
    pub rows: Vec<AuthHardeningRow>,
}

impl AuthHardeningReport {
    /// True when every row passed.
    pub fn all_passed(&self) -> bool {
        self.rows.iter().all(|r| r.outcome.is_pass())
    }

    /// Count of rows that failed.
    pub fn fail_count(&self) -> usize {
        self.rows.iter().filter(|r| r.outcome.is_fail()).count()
    }
}

/// Run every auth-hardening check against `probe` and return the
/// six-row report. Missing fields on the probe map to
/// `NotApplicable` rather than failures so a partial capture still
/// produces a usable scorecard.
pub fn run_checks(probe: &AuthHardeningProbe) -> AuthHardeningReport {
    AuthHardeningReport {
        rows: vec![
            row_iss(probe),
            row_dcr(probe),
            row_as_binding(probe),
            row_oidc_refresh(probe),
            row_scope_step_up(probe),
            row_well_known(probe),
        ],
    }
}

fn row_iss(probe: &AuthHardeningProbe) -> AuthHardeningRow {
    let outcome = if let Some(c) = &probe.iss {
        check_iss_present(IssInput {
            iss_from_response: c.iss_from_response.as_deref(),
            expected_issuer: c.expected_issuer.as_str(),
        })
    } else {
        CheckOutcome::NotApplicable {
            reason: "no authorization-server callback was captured".into(),
        }
    };
    AuthHardeningRow {
        sep: "SEP-2468".into(),
        name: "iss parameter validation".into(),
        outcome,
    }
}

fn row_dcr(probe: &AuthHardeningProbe) -> AuthHardeningRow {
    let outcome = if let Some(c) = &probe.dcr {
        check_dcr_application_type(DcrApplicationTypeInput {
            declared: c.declared.as_deref(),
            is_native_client: c.is_native_client,
        })
    } else {
        CheckOutcome::NotApplicable {
            reason: "no DCR registration response was captured".into(),
        }
    };
    AuthHardeningRow {
        sep: "SEP-837".into(),
        name: "DCR application_type declared".into(),
        outcome,
    }
}

fn row_as_binding(probe: &AuthHardeningProbe) -> AuthHardeningRow {
    let outcome = if let Some(c) = &probe.as_binding {
        check_as_bound_credentials(AsBoundCredentialsInput {
            credentials_bound_to: c.credentials_bound_to.as_str(),
            resource_current_as: c.resource_current_as.as_str(),
            reregistered_against_current_as: c.reregistered_against_current_as,
        })
    } else {
        CheckOutcome::NotApplicable {
            reason: "no AS-binding context was captured".into(),
        }
    };
    AuthHardeningRow {
        sep: "SEP-2352".into(),
        name: "AS-bound credentials + re-registration".into(),
        outcome,
    }
}

fn row_oidc_refresh(probe: &AuthHardeningProbe) -> AuthHardeningRow {
    let outcome = if let Some(c) = &probe.oidc_refresh {
        check_oidc_refresh(OidcRefreshInput {
            granted_offline_access: c.granted_offline_access,
            refresh_token_present: c.refresh_token_present,
            rotated_refresh_token: c.rotated_refresh_token.as_deref(),
        })
    } else {
        CheckOutcome::NotApplicable {
            reason: "no OIDC refresh response was captured".into(),
        }
    };
    AuthHardeningRow {
        sep: "SEP-2207".into(),
        name: "OIDC refresh-token flow".into(),
        outcome,
    }
}

fn row_scope_step_up(probe: &AuthHardeningProbe) -> AuthHardeningRow {
    let outcome = if let Some(c) = &probe.scope_step_up {
        let prev: Vec<&str> = c.previous_scopes.iter().map(String::as_str).collect();
        let step: Vec<&str> = c.requested_step_up.iter().map(String::as_str).collect();
        let granted: Vec<&str> = c.granted.iter().map(String::as_str).collect();
        check_scope_step_up_accumulation(ScopeStepUpInput {
            previous_scopes: prev.as_slice(),
            requested_step_up: step.as_slice(),
            granted: granted.as_slice(),
        })
    } else {
        CheckOutcome::NotApplicable {
            reason: "no scope step-up flow was captured".into(),
        }
    };
    AuthHardeningRow {
        sep: "SEP-2350".into(),
        name: "scope accumulation on step-up".into(),
        outcome,
    }
}

fn row_well_known(probe: &AuthHardeningProbe) -> AuthHardeningRow {
    let outcome = if let Some(url) = &probe.well_known_url {
        check_well_known_suffix(WellKnownSuffixInput { url: url.as_str() })
    } else {
        CheckOutcome::NotApplicable {
            reason: "no .well-known URL was captured".into(),
        }
    };
    AuthHardeningRow {
        sep: "SEP-2351".into(),
        name: ".well-known URI suffix".into(),
        outcome,
    }
}

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

    fn happy_probe() -> AuthHardeningProbe {
        AuthHardeningProbe {
            iss: Some(IssCapture {
                iss_from_response: Some("https://issuer.example.com".into()),
                expected_issuer: "https://issuer.example.com".into(),
            }),
            dcr: Some(DcrCapture {
                declared: Some("native".into()),
                is_native_client: true,
            }),
            as_binding: Some(AsBindingCapture {
                credentials_bound_to: "https://as.example.com".into(),
                resource_current_as: "https://as.example.com".into(),
                reregistered_against_current_as: true,
            }),
            oidc_refresh: Some(OidcRefreshCapture {
                granted_offline_access: true,
                refresh_token_present: true,
                rotated_refresh_token: Some("new-refresh-token".into()),
            }),
            scope_step_up: Some(ScopeStepUpCapture {
                previous_scopes: vec!["read".into()],
                requested_step_up: vec!["write".into()],
                granted: vec!["read".into(), "write".into()],
            }),
            well_known_url: Some(
                "https://example.com/.well-known/oauth-authorization-server".into(),
            ),
        }
    }

    #[test]
    fn run_checks_emits_one_row_per_sep() {
        let report = run_checks(&happy_probe());
        assert_eq!(report.rows.len(), 6);
        let seps: Vec<&str> = report.rows.iter().map(|r| r.sep.as_str()).collect();
        assert_eq!(
            seps,
            vec!["SEP-2468", "SEP-837", "SEP-2352", "SEP-2207", "SEP-2350", "SEP-2351"]
        );
    }

    #[test]
    fn run_checks_against_a_clean_probe_passes_every_row() {
        let report = run_checks(&happy_probe());
        assert!(report.all_passed(), "report: {report:?}");
        assert_eq!(report.fail_count(), 0);
    }

    #[test]
    fn missing_fields_map_to_not_applicable_rather_than_failure() {
        let probe = AuthHardeningProbe::default();
        let report = run_checks(&probe);
        assert!(!report.all_passed());
        assert_eq!(report.fail_count(), 0);
        for row in &report.rows {
            assert!(
                matches!(row.outcome, CheckOutcome::NotApplicable { .. }),
                "row {} should be NotApplicable; got {:?}",
                row.sep,
                row.outcome
            );
        }
    }

    #[test]
    fn an_iss_mismatch_surfaces_as_a_fail_row() {
        let mut probe = happy_probe();
        probe.iss = Some(IssCapture {
            iss_from_response: Some("https://attacker.example.com".into()),
            expected_issuer: "https://issuer.example.com".into(),
        });
        let report = run_checks(&probe);
        assert!(!report.all_passed());
        assert_eq!(report.fail_count(), 1);
        let iss_row = report.rows.iter().find(|r| r.sep == "SEP-2468").unwrap();
        assert!(iss_row.outcome.is_fail());
    }
}
