//! Issuer-side invariants — the issuer MUST NEVER emit what the verifier
//! is forbidden from accepting.
//!
//! Phase 1 + 2 nailed down the verifier's reject surface (M01-M34). This
//! file pins the symmetric issuer-side property: every header parameter
//! the verifier rejects (`jku`/`x5u`/`jwk`/`x5c`/`crit`/extras/`b64=false`)
//! is structurally absent from `issue()` output. A regression here means
//! a future change to the encoder accidentally re-introduced an attack
//! surface that the verify side is already defending against.
//!
//! Scope: format + header invariants only. Round-trip tests
//! (`tests/round_trip.rs`, commit 3.5) cover signature-verifies-correctly
//! across the 3 surfaces (PAS / PCS / External).

#![allow(clippy::unwrap_used)]

use std::time::Duration;

use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as B64};
use ppoppo_token::{
    IssueConfig, IssueError, IssueRequest, KeySet, SigningKey, VerifyConfig, issue, verify,
};

fn fixture() -> (SigningKey, IssueConfig, IssueRequest) {
    let (sk, _ks) = SigningKey::test_pair();
    let cfg = IssueConfig::access_token("https://accounts.ppoppo.com", "ppoppo", sk.kid());
    let req = IssueRequest::new(
        // 01HSAB... is a valid Crockford-base32 ULID; the earlier
        // 01HSUB... was decorative-but-broken (U excluded from Crockford).
        // Phase 4 M39 enforces ULID format — this fixture matters.
        "01HSAB00000000000000000000",
        "ppoppo-internal",
        Duration::from_secs(900),
    );
    (sk, cfg, req)
}

fn header_json(token: &str) -> serde_json::Value {
    let header_b64 = token.split('.').next().expect("compact jws has 3 segments");
    let bytes = B64.decode(header_b64).expect("header decodes as base64url");
    serde_json::from_slice(&bytes).expect("header decodes as json")
}

#[test]
fn issue_returns_jws_compact_format() {
    let (sk, cfg, req) = fixture();
    let token = issue(&req, &cfg, &sk).expect("issue should succeed");
    assert_eq!(token.split('.').count(), 3, "JWS Compact has 3 segments");
    assert!(!token.contains(".."), "no empty segments allowed");
}

#[test]
fn issue_emits_only_alg_typ_kid_in_header() {
    // The verifier rejects every header parameter outside `{alg, typ, kid}`
    // (M16). The issuer must symmetrically refuse to emit anything else.
    // If a future encoder change adds `jku`, `x5u`, `jwk`, `x5c`, `crit`,
    // or any other extra parameter, this test fails — *before* the
    // verifier-side regression test catches it on a deployed PCS.
    let (sk, cfg, req) = fixture();
    let token = issue(&req, &cfg, &sk).expect("issue should succeed");
    let header = header_json(&token);
    let obj = header.as_object().expect("header is a JSON object");
    let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
    keys.sort();
    assert_eq!(
        keys,
        vec!["alg", "kid", "typ"],
        "header must carry exactly alg/kid/typ — found {keys:?}"
    );
}

#[test]
fn issue_pins_alg_eddsa() {
    let (sk, cfg, req) = fixture();
    let token = issue(&req, &cfg, &sk).expect("issue should succeed");
    let header = header_json(&token);
    assert_eq!(header["alg"], "EdDSA");
}

#[test]
fn issue_pins_typ_at_jwt() {
    // RFC 9068 §2.1 mandates `typ=at+jwt` for access tokens. Phase 2
    // verify-side enforces it (M13); issuer must emit it.
    let (sk, cfg, req) = fixture();
    let token = issue(&req, &cfg, &sk).expect("issue should succeed");
    let header = header_json(&token);
    assert_eq!(header["typ"], "at+jwt");
}

#[test]
fn issue_emits_kid_from_config() {
    let (sk, cfg, req) = fixture();
    let token = issue(&req, &cfg, &sk).expect("issue should succeed");
    let header = header_json(&token);
    assert_eq!(header["kid"], sk.kid());
}

/// Issue a token using `fixture()`'s signer + cfg + the supplied request,
/// then verify it. Returns the verified Claims so emission tests can
/// assert on the post-verify shape (proving the wire bytes survive both
/// halves of the engine).
async fn round_trip(req: IssueRequest) -> ppoppo_token::Claims {
    let (sk, cfg, _) = fixture();
    let token = issue(&req, &cfg, &sk).expect("issue should succeed");
    let mut key_set = KeySet::new();
    let dec = jsonwebtoken::DecodingKey::from_ed_pem(
        b"-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAh//e6j3It3xhjghg8Kpn2pM0jMCH/cvemGu4vv7D1Q4=
-----END PUBLIC KEY-----
",
    )
    .unwrap();
    key_set.insert(sk.kid(), dec);
    let verify_cfg = VerifyConfig::access_token("https://accounts.ppoppo.com", "ppoppo");
    verify(&token, &verify_cfg, &key_set)
        .await
        .expect("verify should succeed")
}

#[tokio::test]
async fn account_type_round_trips_human() {
    // M40 emission half — both whitelist values must round-trip
    // byte-for-byte and surface as `Some("...")` on the verified Claims.
    // Asserting both human and ai_agent in distinct tests gives audit
    // logs a precise signal when an issuer-side serializer regression
    // breaks one but not the other.
    let req = IssueRequest::new(
        "01HSAB00000000000000000000",
        "ppoppo-internal",
        Duration::from_secs(900),
    )
    .with_account_type("human");
    let claims = round_trip(req).await;
    assert_eq!(claims.account_type.as_deref(), Some("human"));
}

#[tokio::test]
async fn account_type_round_trips_ai_agent() {
    let req = IssueRequest::new(
        "01HSAB00000000000000000000",
        "ppoppo-internal",
        Duration::from_secs(900),
    )
    .with_account_type("ai_agent");
    let claims = round_trip(req).await;
    assert_eq!(claims.account_type.as_deref(), Some("ai_agent"));
}

#[tokio::test]
async fn caps_round_trips_non_empty_list() {
    // M41 emission half — `caps` round-trips byte-for-byte. The list
    // members are arbitrary surface-defined strings; the engine only
    // contracts the wire shape (array of strings).
    let req = IssueRequest::new(
        "01HSAB00000000000000000000",
        "ppoppo-internal",
        Duration::from_secs(900),
    )
    .with_caps(vec!["band:open".to_string(), "lifecycle:grant".to_string()]);
    let claims = round_trip(req).await;
    assert_eq!(
        claims.caps,
        vec!["band:open".to_string(), "lifecycle:grant".to_string()],
    );
}

#[tokio::test]
async fn delegator_and_cid_round_trip_when_set() {
    // M45 emission half + Phase 2 Decision 1 surface check —
    // `delegator` and `cid` are surfaced on Claims (audit / forensics);
    // they emit when set and survive the round-trip.
    let req = IssueRequest::new(
        "01HSAB00000000000000000000",
        "ppoppo-internal",
        Duration::from_secs(900),
    )
    .with_delegator("01HDELEGATOR0000000000000000")
    .with_credential_id("cred-abc-123");
    let claims = round_trip(req).await;
    assert_eq!(
        claims.delegator.as_deref(),
        Some("01HDELEGATOR0000000000000000"),
    );
    assert_eq!(claims.cid.as_deref(), Some("cred-abc-123"));
}

#[tokio::test]
async fn sv_emitted_on_wire_when_set() {
    // sv is HIDDEN on Claims (Phase 5 cache resolves) but MUST appear
    // on the wire — Phase 5's cache port compares the wire value
    // against `sv:{sub}` in KVRocks. Read the payload bytes directly
    // since Claims doesn't surface it.
    let req = IssueRequest::new(
        "01HSAB00000000000000000000",
        "ppoppo-internal",
        Duration::from_secs(900),
    )
    .with_session_version(42);
    let (sk, cfg, _) = fixture();
    let token = issue(&req, &cfg, &sk).expect("issue should succeed");
    let payload_b64 = token.split('.').nth(1).expect("compact jws has 3 segments");
    let bytes = B64
        .decode(payload_b64)
        .expect("payload decodes as base64url");
    let payload: serde_json::Value =
        serde_json::from_slice(&bytes).expect("payload decodes as json");
    assert_eq!(payload["sv"], serde_json::json!(42));
}

#[tokio::test]
async fn issued_payload_carries_only_allowlisted_claims() {
    // M45 emission contract — when every domain field is set, the
    // wire payload contains only allowlisted claim names. Issuer
    // cannot accidentally smuggle a PII claim because `IssuePayload`
    // is typed (the only emission path in the engine). This test is
    // the issue-side mirror of `test_reject_pii_in_payload` —
    // proving the issuer can't even produce a token the verifier
    // would reject under M45.
    const ALLOWED: &[&str] = &[
        "iss",
        "sub",
        "aud",
        "exp",
        "iat",
        "nbf",
        "jti",
        "client_id",
        "cat",
        "account_type",
        "admin",
        "caps",
        "delegator",
        "dlg_depth",
        "cid",
        "sv",
        "active_ppnum",
        "scopes",
    ];
    let req = IssueRequest::new(
        "01HSAB00000000000000000000",
        "ppoppo-internal",
        Duration::from_secs(900),
    )
    .with_account_type("human")
    .with_admin(true)
    .with_active_ppnum("10012345678")
    .with_caps(vec!["band:open".to_string()])
    .with_delegator("01HDELEGATOR0000000000000000")
    .with_dlg_depth(2)
    .with_credential_id("cred-abc")
    .with_session_version(7)
    .with_scopes(vec!["read".to_string()]);
    let (sk, cfg, _) = fixture();
    let token = issue(&req, &cfg, &sk).expect("issue should succeed");
    let payload_b64 = token.split('.').nth(1).expect("compact jws has 3 segments");
    let bytes = B64
        .decode(payload_b64)
        .expect("payload decodes as base64url");
    let payload: serde_json::Value =
        serde_json::from_slice(&bytes).expect("payload decodes as json");
    let obj = payload.as_object().expect("payload is JSON object");
    for key in obj.keys() {
        assert!(
            ALLOWED.contains(&key.as_str()),
            "issuer emitted non-allowlisted claim {key:?} — M45 violation",
        );
    }
}

#[tokio::test]
async fn admin_token_round_trips_with_admin_band_ppnum() {
    // M44 emission half — admin=true tokens carry an admin-banded
    // active_ppnum. The combination round-trips: issued by Phase 3
    // pipeline, verified by Phase 4 engine including the M44 band gate.
    let req = IssueRequest::new(
        "01HSAB00000000000000000000",
        "ppoppo-internal",
        Duration::from_secs(900),
    )
    .with_admin(true)
    .with_active_ppnum("10012345678"); // band = 100 ∈ [100, 109]
    let claims = round_trip(req).await;
    assert!(claims.admin);
    assert_eq!(claims.active_ppnum.as_deref(), Some("10012345678"));
}

#[tokio::test]
async fn admin_false_skipped_on_wire() {
    // The common case (non-admin token) must not see `admin: false` on
    // the wire — that would add a byte to every PAS-issued token and
    // diverge from pre-Phase-4 wire bytes for callers that didn't opt
    // in. Skip-when-default invariant, mirrored across caps/scopes/cid.
    let req = IssueRequest::new(
        "01HSAB00000000000000000000",
        "ppoppo-internal",
        Duration::from_secs(900),
    );
    let (sk, cfg, _) = fixture();
    let token = issue(&req, &cfg, &sk).expect("issue should succeed");
    let payload_b64 = token.split('.').nth(1).expect("compact jws has 3 segments");
    let bytes = B64
        .decode(payload_b64)
        .expect("payload decodes as base64url");
    let payload: serde_json::Value =
        serde_json::from_slice(&bytes).expect("payload decodes as json");
    assert!(
        payload.get("admin").is_none(),
        "admin=false must be absent on wire"
    );
    assert!(
        payload.get("active_ppnum").is_none(),
        "active_ppnum unset must be absent on wire"
    );
}

#[tokio::test]
async fn dlg_depth_round_trips_at_max() {
    // M43 emission half — the inclusive bound (4) round-trips. Engine
    // verifier admits 0..=4; issuer can emit 4 cleanly. `dlg_depth` is
    // HIDDEN on `Claims` (engine fully enforces), so this test
    // inspects the wire bytes directly to prove emission happened.
    let req = IssueRequest::new(
        "01HSAB00000000000000000000",
        "ppoppo-internal",
        Duration::from_secs(900),
    )
    .with_dlg_depth(4);
    let (sk, cfg, _) = fixture();
    let token = issue(&req, &cfg, &sk).expect("issue should succeed");
    let payload_b64 = token.split('.').nth(1).expect("compact jws has 3 segments");
    let bytes = B64
        .decode(payload_b64)
        .expect("payload decodes as base64url");
    let payload: serde_json::Value =
        serde_json::from_slice(&bytes).expect("payload decodes as json");
    assert_eq!(payload["dlg_depth"], serde_json::json!(4));

    // Verifier accepts the bound (positive boundary case mirroring
    // `tests/jwt_negative.rs::test_dlg_depth_at_bound_admitted`).
    let _ = round_trip(req).await;
}

#[tokio::test]
async fn dlg_depth_absent_when_zero() {
    // Skip-when-zero invariant — pre-existing tokens that never carried
    // `dlg_depth` see no wire change. This is the regression guard
    // against accidentally starting to emit `0` for every token (which
    // would technically work but pollutes audit-log diffs and adds wire
    // bytes for the common case).
    let req = IssueRequest::new(
        "01HSAB00000000000000000000",
        "ppoppo-internal",
        Duration::from_secs(900),
    );
    let (sk, cfg, _) = fixture();
    let token = issue(&req, &cfg, &sk).expect("issue should succeed");
    let payload_b64 = token.split('.').nth(1).expect("compact jws has 3 segments");
    let bytes = B64
        .decode(payload_b64)
        .expect("payload decodes as base64url");
    let payload: serde_json::Value =
        serde_json::from_slice(&bytes).expect("payload decodes as json");
    assert!(
        payload.get("dlg_depth").is_none(),
        "dlg_depth=0 must be absent on the wire (skip-when-default invariant)",
    );
}

#[tokio::test]
async fn scopes_round_trips_at_boundary() {
    // M42 emission half — exactly 256 entries (the inclusive bound)
    // round-trips. This is the boundary case the verifier admits;
    // the issuer must be able to emit it. 257 has no emission test
    // because the issuer trusts callers (the verify-side reject is
    // the regression guard).
    let scopes: Vec<String> = (0..256).map(|i| format!("scope-{i}")).collect();
    let req = IssueRequest::new(
        "01HSAB00000000000000000000",
        "ppoppo-internal",
        Duration::from_secs(900),
    )
    .with_scopes(scopes.clone());
    let claims = round_trip(req).await;
    assert_eq!(claims.scopes, scopes);
}

#[tokio::test]
async fn caps_absent_when_request_empty() {
    // Default-deny invariant: an `IssueRequest::new(...)` without
    // `.with_caps(...)` MUST emit no `caps` field. The verifier surfaces
    // the same value (empty Vec) for both "claim absent" and "claim is
    // []" — the issue side proves we never silently start emitting `[]`
    // for callers that didn't opt in.
    let req = IssueRequest::new(
        "01HSAB00000000000000000000",
        "ppoppo-internal",
        Duration::from_secs(900),
    );
    let (sk, cfg, _) = fixture();
    let token = issue(&req, &cfg, &sk).expect("issue should succeed");
    let payload_b64 = token.split('.').nth(1).expect("compact jws has 3 segments");
    let bytes = B64
        .decode(payload_b64)
        .expect("payload decodes as base64url");
    let payload: serde_json::Value =
        serde_json::from_slice(&bytes).expect("payload decodes as json");
    assert!(
        payload.get("caps").is_none(),
        "default-deny: empty caps must be absent on the wire, not `[]`",
    );
}

#[tokio::test]
async fn account_type_absent_when_not_set() {
    // The default issuance path must NOT emit account_type — legacy
    // tokens cannot suddenly grow a claim that pre-rollout consumers
    // never saw. M40 admits absence; the issuer MUST keep emitting
    // absence when the request didn't opt in.
    let req = IssueRequest::new(
        "01HSAB00000000000000000000",
        "ppoppo-internal",
        Duration::from_secs(900),
    );
    let claims = round_trip(req).await;
    assert!(
        claims.account_type.is_none(),
        "absent when caller didn't opt in"
    );
}

#[test]
fn issue_emits_ulid_sub_for_test_pair() {
    // M39 emission half — the verifier-side test
    // (`tests/jwt_negative::test_reject_non_ulid_sub`) covers the reject
    // path; this one pins the issue path. The issuer trusts the caller
    // not to forge a bad sub (it's data, not a derived value), so this
    // test asserts the *contract*: a valid ULID round-trips through
    // `issue` byte-for-byte. If a future serializer ever munged sub
    // (e.g. case-folded, or pretty-printed with whitespace), the
    // verifier's M39 check would reject the engine's own output — this
    // test is the early warning.
    let (sk, cfg, req) = fixture();
    let token = issue(&req, &cfg, &sk).expect("issue should succeed");
    // Decode the payload segment and read sub off the JSON without
    // running the full verifier (which would also enforce M39 — we want
    // to see the wire bytes themselves).
    let payload_b64 = token.split('.').nth(1).expect("compact jws has 3 segments");
    let bytes = B64
        .decode(payload_b64)
        .expect("payload decodes as base64url");
    let payload: serde_json::Value =
        serde_json::from_slice(&bytes).expect("payload decodes as json");
    let sub = payload["sub"].as_str().expect("sub is a string");
    assert_eq!(
        sub, "01HSAB00000000000000000000",
        "sub round-trips byte-for-byte"
    );
    assert!(
        ulid::Ulid::from_string(sub).is_ok(),
        "issued sub must satisfy M39's ULID format check"
    );
}

#[test]
fn issue_rejects_kid_mismatch_between_cfg_and_signer() {
    // Defense in depth: a misconfigured pipeline (e.g. wrong kid baked
    // into IssueConfig but a different signer wired up) would emit
    // tokens with a kid the verifier can't resolve — every token
    // rejected at runtime, no observable issue at issuance. Catching
    // it at issuance time turns a silent runtime failure into an
    // immediate startup failure.
    let (sk, _ks) = SigningKey::test_pair();
    let cfg = IssueConfig::access_token("iss", "aud", "deliberately-wrong-kid");
    let req = IssueRequest::new("sub", "client", Duration::from_secs(60));
    let result = issue(&req, &cfg, &sk);
    let Err(IssueError::KeyMismatch {
        cfg_kid,
        signer_kid,
    }) = result
    else {
        panic!("expected KeyMismatch, got {result:?}");
    };
    assert_eq!(cfg_kid, "deliberately-wrong-kid");
    assert_eq!(signer_kid, sk.kid());
}
