//! Smoke tests for the Phase 3 issuance scaffold.
//!
//! These tests validate the *shape* of `IssueConfig`, `SigningKey`,
//! `IssueRequest`, and `IssueError`. They do NOT exercise `issue()` —
//! that lands in commit 3.3 along with the Sub-cycle B round-trip tests.
//!
//! The point of running these in isolation: catch surface-area drift the
//! moment the public API breaks (e.g. someone moves a constructor out of
//! lib.rs), independent of the larger round-trip tests.

#![allow(clippy::unwrap_used)]

use ppoppo_token::{IssueConfig, IssueError, IssueRequest, SigningKey};

#[test]
fn issue_config_access_token_pins_typ_and_audience() {
    let cfg = IssueConfig::access_token("https://accounts.ppoppo.com", "ppoppo", "k4.test.0");
    let s = format!("{cfg:?}");
    assert!(s.contains("accounts.ppoppo.com"), "issuer round-trips: {s}");
    assert!(s.contains("ppoppo"), "audience round-trips: {s}");
    assert!(s.contains("at+jwt"), "typ pinned to at+jwt: {s}");
    assert!(s.contains("k4.test.0"), "kid round-trips: {s}");
    assert!(s.contains("access"), "cat pinned to access: {s}");
}

#[test]
fn issue_config_with_audiences_supports_multi_aud() {
    let cfg = IssueConfig::access_token("iss", "primary", "kid")
        .with_audiences(vec!["primary".to_string(), "secondary".to_string()]);
    let s = format!("{cfg:?}");
    assert!(s.contains("primary"));
    assert!(s.contains("secondary"));
}

#[test]
fn signing_key_test_pair_returns_matching_signer_and_key_set() {
    // `test_pair` is the primary entry for integration tests; the returned
    // `KeySet` MUST already carry the corresponding decoding key under
    // `sk.kid()` so callers don't have to wire both halves manually.
    let (sk, _ks) = SigningKey::test_pair();
    assert!(
        !sk.kid().is_empty(),
        "test_pair must return a kid-bearing signer"
    );

    // Round-trip in 3.5 builds an IssueConfig naming the same kid; this
    // test only verifies the surface lets callers do that without a
    // hardcoded constant living on the public API.
    let _cfg = IssueConfig::access_token("iss", "aud", sk.kid());
}

#[test]
fn issue_request_new_carries_required_fields() {
    let req = IssueRequest::new(
        "01HXYZ0000000000000000000",
        "ppoppo-internal",
        std::time::Duration::from_secs(900),
    );
    assert_eq!(req.sub, "01HXYZ0000000000000000000");
    assert_eq!(req.client_id, "ppoppo-internal");
    assert_eq!(req.ttl, std::time::Duration::from_secs(900));
    assert!(
        req.jti.is_none(),
        "jti is opt-in; default to engine-generated"
    );
}

#[test]
fn issue_request_with_jti_overrides_default() {
    let id = ulid::Ulid::new();
    let req = IssueRequest::new("sub", "cid", std::time::Duration::from_secs(60)).with_jti(id);
    assert_eq!(req.jti, Some(id));
}

#[test]
fn issue_request_domain_fields_default_to_unset() {
    // Phase 4 adds 9 domain claim fields. They must default to "absent /
    // empty / 0 / false" so callers that pre-date the expansion still
    // produce identical wire output (claim is omitted unless opt-in).
    let req = IssueRequest::new("sub", "cid", std::time::Duration::from_secs(60));
    assert_eq!(req.account_type, None);
    assert!(!req.admin, "admin defaults to false (default-deny)");
    assert!(req.caps.is_empty(), "caps defaults to empty (default-deny)");
    assert_eq!(req.delegator, None);
    assert_eq!(req.dlg_depth, 0, "dlg_depth defaults to 0 (no delegation)");
    assert_eq!(req.cid, None);
    assert_eq!(req.sv, None);
    assert_eq!(req.active_ppnum, None);
    assert!(
        req.scopes.is_empty(),
        "scopes defaults to empty (default-deny)"
    );
}

#[test]
fn issue_request_with_account_type_sets_field() {
    let req = IssueRequest::new("sub", "cid", std::time::Duration::from_secs(60))
        .with_account_type("human");
    assert_eq!(req.account_type, Some("human".to_string()));
}

#[test]
fn issue_request_with_admin_sets_flag() {
    let req = IssueRequest::new("sub", "cid", std::time::Duration::from_secs(60)).with_admin(true);
    assert!(req.admin);
}

#[test]
fn issue_request_with_caps_sets_list() {
    let caps = vec!["band:open".to_string(), "lifecycle:grant".to_string()];
    let req =
        IssueRequest::new("sub", "cid", std::time::Duration::from_secs(60)).with_caps(caps.clone());
    assert_eq!(req.caps, caps);
}

#[test]
fn issue_request_with_delegator_sets_principal() {
    let req = IssueRequest::new("sub", "cid", std::time::Duration::from_secs(60))
        .with_delegator("01HDELEGATOR0000000000000000");
    assert_eq!(
        req.delegator,
        Some("01HDELEGATOR0000000000000000".to_string())
    );
}

#[test]
fn issue_request_with_dlg_depth_sets_count() {
    let req = IssueRequest::new("sub", "cid", std::time::Duration::from_secs(60)).with_dlg_depth(2);
    assert_eq!(req.dlg_depth, 2);
}

#[test]
fn issue_request_with_credential_id_sets_cid() {
    let req = IssueRequest::new("sub", "cid", std::time::Duration::from_secs(60))
        .with_credential_id("cred-abc-123");
    assert_eq!(req.cid, Some("cred-abc-123".to_string()));
}

#[test]
fn issue_request_with_session_version_sets_sv() {
    let req = IssueRequest::new("sub", "cid", std::time::Duration::from_secs(60))
        .with_session_version(42);
    assert_eq!(req.sv, Some(42));
}

#[test]
fn issue_request_with_active_ppnum_sets_display_id() {
    let req = IssueRequest::new("sub", "cid", std::time::Duration::from_secs(60))
        .with_active_ppnum("123-1234-5678");
    assert_eq!(req.active_ppnum, Some("123-1234-5678".to_string()));
}

#[test]
fn issue_request_with_scopes_sets_list() {
    let scopes = vec!["read".to_string(), "write".to_string()];
    let req = IssueRequest::new("sub", "cid", std::time::Duration::from_secs(60))
        .with_scopes(scopes.clone());
    assert_eq!(req.scopes, scopes);
}

#[test]
fn issue_error_variants_compile() {
    // Every Phase 3 variant must exist. Shape stays 1-variant-per-named-
    // failure-mode (project_jwt_phase2_design Decision 2). KeyMismatch
    // fires when the kid in IssueConfig doesn't match SigningKey::kid();
    // KeyParse wraps PEM decoding failures; JsonEncode wraps the only
    // structural failure jsonwebtoken can return; ClockBackwards fires
    // when system_time falls before UNIX_EPOCH.
    let _ = IssueError::KeyParse("bad pem".into());
    let _ = IssueError::KeyMismatch {
        cfg_kid: "a".into(),
        signer_kid: "b".into(),
    };
    let _ = IssueError::JsonEncode("serde failure".into());
    let _ = IssueError::ClockBackwards;
}

#[test]
fn signing_key_from_ed25519_pem_rejects_garbage() {
    let res = SigningKey::from_ed25519_pem(b"not-a-valid-pem", "kid-x");
    assert!(matches!(res, Err(IssueError::KeyParse(_))));
}
