require "test_helper"

class OidcPkceControllerTest < ActionDispatch::IntegrationTest
  def setup
    @user = User.create!(email_address: "pkce_test@example.com", password: "password123")
    @application = Application.create!(
      name: "PKCE Test App",
      slug: "pkce-test-app",
      app_type: "oidc",
      redirect_uris: ["http://localhost:4000/callback"].to_json,
      active: true
    )

    # Sign in the user using the test helper
    sign_in_as(@user)
  end

  def teardown
    Current.session&.destroy
    # Delete in correct order to avoid foreign key constraints
    OidcRefreshToken.where(application: @application).delete_all
    OidcAccessToken.where(application: @application).delete_all
    OidcAuthorizationCode.where(application: @application).delete_all
    OidcUserConsent.where(application: @application).delete_all
    @user.destroy
    @application.destroy
  end

  test "discovery endpoint includes PKCE support" do
    get "/.well-known/openid-configuration"

    assert_response :success
    config = JSON.parse(@response.body)

    assert config.key?("code_challenge_methods_supported")
    assert_equal ["S256"], config["code_challenge_methods_supported"]
  end

  test "authorization endpoint accepts PKCE parameters (S256)" do
    code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

    auth_params = {
      response_type: "code",
      client_id: @application.client_id,
      redirect_uri: "http://localhost:4000/callback",
      scope: "openid profile",
      state: "test_state",
      nonce: "test_nonce",
      code_challenge: code_challenge,
      code_challenge_method: "S256"
    }

    get "/oauth/authorize", params: auth_params

    # Should show consent page (user is already authenticated)
    assert_response :success
    assert_match(/consent/, @response.body.downcase)
  end

  test "authorization endpoint rejects PKCE plain method" do
    code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

    auth_params = {
      response_type: "code",
      client_id: @application.client_id,
      redirect_uri: "http://localhost:4000/callback",
      scope: "openid profile",
      state: "test_state",
      nonce: "test_nonce",
      code_challenge: code_challenge,
      code_challenge_method: "plain"
    }

    get "/oauth/authorize", params: auth_params

    assert_response :redirect
    assert_match(/error=invalid_request/, @response.location)
    assert_match(/S256/, @response.location)
  end

  test "authorization endpoint rejects invalid code_challenge_method" do
    auth_params = {
      response_type: "code",
      client_id: @application.client_id,
      redirect_uri: "http://localhost:4000/callback",
      scope: "openid profile",
      code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
      code_challenge_method: "invalid_method"
    }

    get "/oauth/authorize", params: auth_params

    # Should redirect back to client with error parameters (OAuth2 spec)
    assert_response :redirect
    assert_match(/error=invalid_request/, @response.location)
    assert_match(/error_description=.*code_challenge_method/, @response.location)
  end

  test "authorization endpoint rejects invalid code_challenge format" do
    # Contains + character which is not base64url
    auth_params = {
      response_type: "code",
      client_id: @application.client_id,
      redirect_uri: "http://localhost:4000/callback",
      scope: "openid profile",
      code_challenge: "invalid+challenge",
      code_challenge_method: "S256"
    }

    get "/oauth/authorize", params: auth_params

    # Should redirect back to client with error parameters (OAuth2 spec)
    assert_response :redirect
    assert_match(/error=invalid_request/, @response.location)
    assert_match(/error_description=.*code_challenge.*format/, @response.location)
  end

  test "token endpoint requires code_verifier when PKCE was used (S256)" do
    # Create consent for token endpoint
    OidcUserConsent.create!(
      user: @user,
      application: @application,
      scopes_granted: "openid profile",
      granted_at: Time.current,
      sid: "test-sid-123"
    )

    # Create authorization code with PKCE S256
    auth_code = OidcAuthorizationCode.create!(
      application: @application,
      user: @user,
      redirect_uri: "http://localhost:4000/callback",
      scope: "openid profile",
      code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
      code_challenge_method: "S256",
      expires_at: 10.minutes.from_now
    )

    token_params = {
      grant_type: "authorization_code",
      code: auth_code.plaintext_code,
      redirect_uri: "http://localhost:4000/callback"
    }

    post "/oauth/token", params: token_params, headers: {
      "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
    }

    assert_response :bad_request
    error = JSON.parse(@response.body)
    assert_equal "invalid_request", error["error"]
    assert_match(/code_verifier is required/, error["error_description"])
  end

  test "token endpoint requires code_verifier when PKCE was used" do
    # Create consent for token endpoint
    OidcUserConsent.create!(
      user: @user,
      application: @application,
      scopes_granted: "openid profile",
      granted_at: Time.current,
      sid: "test-sid-123"
    )

    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)

    auth_code = OidcAuthorizationCode.create!(
      application: @application,
      user: @user,
      redirect_uri: "http://localhost:4000/callback",
      scope: "openid profile",
      code_challenge: code_challenge,
      code_challenge_method: "S256",
      expires_at: 10.minutes.from_now
    )

    token_params = {
      grant_type: "authorization_code",
      code: auth_code.plaintext_code,
      redirect_uri: "http://localhost:4000/callback"
    }

    post "/oauth/token", params: token_params, headers: {
      "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
    }

    assert_response :bad_request
    error = JSON.parse(@response.body)
    assert_equal "invalid_request", error["error"]
    assert_match(/code_verifier is required/, error["error_description"])
  end

  test "token endpoint rejects invalid code_verifier (S256)" do
    # Create consent for token endpoint
    OidcUserConsent.create!(
      user: @user,
      application: @application,
      scopes_granted: "openid profile",
      granted_at: Time.current,
      sid: "test-sid-123"
    )

    # Create authorization code with PKCE S256
    auth_code = OidcAuthorizationCode.create!(
      application: @application,
      user: @user,
      redirect_uri: "http://localhost:4000/callback",
      scope: "openid profile",
      code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
      code_challenge_method: "S256",
      expires_at: 10.minutes.from_now
    )

    token_params = {
      grant_type: "authorization_code",
      code: auth_code.plaintext_code,
      redirect_uri: "http://localhost:4000/callback",
      # Use a properly formatted but wrong verifier (43+ chars, base64url)
      code_verifier: "wrongverifier_with_enough_characters_base64url"
    }

    post "/oauth/token", params: token_params, headers: {
      "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
    }

    assert_response :bad_request
    error = JSON.parse(@response.body)
    assert_equal "invalid_grant", error["error"]
    assert_match(/Invalid code verifier/, error["error_description"])
  end

  test "token endpoint accepts valid code_verifier (S256)" do
    # Create consent for token endpoint
    OidcUserConsent.create!(
      user: @user,
      application: @application,
      scopes_granted: "openid profile",
      granted_at: Time.current,
      sid: "test-sid-123"
    )

    # Generate valid PKCE pair
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    code_challenge = Digest::SHA256.base64digest(code_verifier)
      .tr("+/", "-_")
      .tr("=", "")

    # Create authorization code with PKCE S256
    auth_code = OidcAuthorizationCode.create!(
      application: @application,
      user: @user,
      redirect_uri: "http://localhost:4000/callback",
      scope: "openid profile",
      code_challenge: code_challenge,
      code_challenge_method: "S256",
      expires_at: 10.minutes.from_now
    )

    token_params = {
      grant_type: "authorization_code",
      code: auth_code.plaintext_code,
      redirect_uri: "http://localhost:4000/callback",
      code_verifier: code_verifier
    }

    post "/oauth/token", params: token_params, headers: {
      "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
    }

    assert_response :success
    tokens = JSON.parse(@response.body)
    assert tokens.key?("access_token")
    assert tokens.key?("id_token")
    assert_equal "Bearer", tokens["token_type"]
  end

  test "token endpoint rejects code_verifier with plain challenge method" do
    code_verifier = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

    # Directly insert a plain auth code to simulate legacy data
    # Generate code HMAC manually since save!(validate: false) skips before_validation
    plaintext_code = SecureRandom.urlsafe_base64(32)
    auth_code = OidcAuthorizationCode.new(
      application: @application,
      user: @user,
      redirect_uri: "http://localhost:4000/callback",
      scope: "openid profile",
      code_challenge: code_verifier,
      code_challenge_method: "plain",
      code_hmac: OidcAuthorizationCode.compute_code_hmac(plaintext_code),
      expires_at: 10.minutes.from_now
    )
    auth_code.plaintext_code = plaintext_code
    auth_code.save!(validate: false)

    token_params = {
      grant_type: "authorization_code",
      code: auth_code.plaintext_code,
      redirect_uri: "http://localhost:4000/callback",
      code_verifier: code_verifier
    }

    post "/oauth/token", params: token_params, headers: {
      "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
    }

    assert_response :bad_request
    body = JSON.parse(@response.body)
    assert_equal "invalid_request", body["error"]
  end

  test "token endpoint works without PKCE (backward compatibility)" do
    # Create an application with PKCE not required (legacy behavior)
    legacy_app = Application.create!(
      name: "Legacy App",
      slug: "legacy-app",
      app_type: "oidc",
      redirect_uris: ["http://localhost:5000/callback"].to_json,
      active: true,
      require_pkce: false
    )
    legacy_app.generate_new_client_secret!

    # Create consent for token endpoint
    OidcUserConsent.create!(
      user: @user,
      application: legacy_app,
      scopes_granted: "openid profile",
      granted_at: Time.current,
      sid: "test-sid-123"
    )

    # Create authorization code without PKCE
    auth_code = OidcAuthorizationCode.create!(
      application: legacy_app,
      user: @user,
      redirect_uri: "http://localhost:5000/callback",
      scope: "openid profile",
      expires_at: 10.minutes.from_now
    )

    token_params = {
      grant_type: "authorization_code",
      code: auth_code.plaintext_code,
      redirect_uri: "http://localhost:5000/callback"
    }

    post "/oauth/token", params: token_params, headers: {
      "Authorization" => "Basic " + Base64.strict_encode64("#{legacy_app.client_id}:#{legacy_app.client_secret}")
    }

    assert_response :success
    tokens = JSON.parse(@response.body)
    assert tokens.key?("access_token")
    assert tokens.key?("id_token")
    assert_equal "Bearer", tokens["token_type"]

    # Cleanup
    OidcRefreshToken.where(application: legacy_app).delete_all
    OidcAccessToken.where(application: legacy_app).delete_all
    OidcAuthorizationCode.where(application: legacy_app).delete_all
    OidcUserConsent.where(application: legacy_app).delete_all
    legacy_app.destroy
  end

  # ====================
  # PUBLIC CLIENT TESTS
  # ====================

  test "public client can authenticate with PKCE" do
    # Create a public client (no client_secret)
    public_app = Application.create!(
      name: "Public App",
      slug: "public-app",
      app_type: "oidc",
      redirect_uris: ["http://localhost:6000/callback"].to_json,
      active: true,
      is_public_client: true
    )

    assert public_app.public_client?
    assert public_app.requires_pkce?
    assert_nil public_app.client_secret_digest

    # Create consent
    OidcUserConsent.create!(
      user: @user,
      application: public_app,
      scopes_granted: "openid profile",
      granted_at: Time.current,
      sid: "test-sid-123"
    )

    # PKCE parameters
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

    # Create authorization code with PKCE
    auth_code = OidcAuthorizationCode.create!(
      application: public_app,
      user: @user,
      redirect_uri: "http://localhost:6000/callback",
      scope: "openid profile",
      expires_at: 10.minutes.from_now,
      code_challenge: code_challenge,
      code_challenge_method: "S256"
    )

    # Token request with PKCE but no client_secret
    token_params = {
      grant_type: "authorization_code",
      code: auth_code.plaintext_code,
      redirect_uri: "http://localhost:6000/callback",
      client_id: public_app.client_id,
      code_verifier: code_verifier
    }

    post "/oauth/token", params: token_params

    assert_response :success
    tokens = JSON.parse(@response.body)
    assert tokens.key?("access_token")
    assert tokens.key?("id_token")

    # Cleanup
    OidcRefreshToken.where(application: public_app).delete_all
    OidcAccessToken.where(application: public_app).delete_all
    OidcAuthorizationCode.where(application: public_app).delete_all
    OidcUserConsent.where(application: public_app).delete_all
    public_app.destroy
  end

  test "public client fails without PKCE" do
    # Create a public client (no client_secret)
    public_app = Application.create!(
      name: "Public App No PKCE",
      slug: "public-app-no-pkce",
      app_type: "oidc",
      redirect_uris: ["http://localhost:7000/callback"].to_json,
      active: true,
      is_public_client: true
    )

    assert public_app.public_client?
    assert public_app.requires_pkce?

    # Create consent
    OidcUserConsent.create!(
      user: @user,
      application: public_app,
      scopes_granted: "openid profile",
      granted_at: Time.current,
      sid: "test-sid-123"
    )

    # Create authorization code WITHOUT PKCE
    auth_code = OidcAuthorizationCode.create!(
      application: public_app,
      user: @user,
      redirect_uri: "http://localhost:7000/callback",
      scope: "openid profile",
      expires_at: 10.minutes.from_now
    )

    # Token request without PKCE should fail
    token_params = {
      grant_type: "authorization_code",
      code: auth_code.plaintext_code,
      redirect_uri: "http://localhost:7000/callback",
      client_id: public_app.client_id
    }

    post "/oauth/token", params: token_params

    assert_response :bad_request
    error = JSON.parse(@response.body)
    assert_equal "invalid_request", error["error"]
    assert_match(/PKCE is required for public clients/, error["error_description"])

    # Cleanup
    OidcRefreshToken.where(application: public_app).delete_all
    OidcAccessToken.where(application: public_app).delete_all
    OidcAuthorizationCode.where(application: public_app).delete_all
    OidcUserConsent.where(application: public_app).delete_all
    public_app.destroy
  end

  test "confidential client with require_pkce fails without PKCE" do
    # The default @application has require_pkce: true (default)
    assert @application.confidential_client?
    assert @application.requires_pkce?

    # Create consent
    OidcUserConsent.create!(
      user: @user,
      application: @application,
      scopes_granted: "openid profile",
      granted_at: Time.current,
      sid: "test-sid-pkce-required"
    )

    # Create authorization code WITHOUT PKCE
    auth_code = OidcAuthorizationCode.create!(
      application: @application,
      user: @user,
      redirect_uri: "http://localhost:4000/callback",
      scope: "openid profile",
      expires_at: 10.minutes.from_now
    )

    # Token request without PKCE should fail
    token_params = {
      grant_type: "authorization_code",
      code: auth_code.plaintext_code,
      redirect_uri: "http://localhost:4000/callback"
    }

    post "/oauth/token", params: token_params, headers: {
      "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
    }

    assert_response :bad_request
    error = JSON.parse(@response.body)
    assert_equal "invalid_request", error["error"]
    assert_match(/PKCE is required/, error["error_description"])
  end

  # ====================
  # AUTH_TIME CLAIM TESTS
  # ====================

  test "ID token includes auth_time claim from authorization code" do
    # Create consent
    OidcUserConsent.create!(
      user: @user,
      application: @application,
      scopes_granted: "openid profile",
      granted_at: Time.current,
      sid: "test-sid-auth-time"
    )

    # Generate valid PKCE pair
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    code_challenge = Digest::SHA256.base64digest(code_verifier)
      .tr("+/", "-_")
      .tr("=", "")

    # Get the expected auth_time from the session's created_at
    expected_auth_time = Current.session.created_at.to_i

    # Create authorization code with auth_time
    auth_code = OidcAuthorizationCode.create!(
      application: @application,
      user: @user,
      redirect_uri: "http://localhost:4000/callback",
      scope: "openid profile",
      code_challenge: code_challenge,
      code_challenge_method: "S256",
      auth_time: expected_auth_time,
      expires_at: 10.minutes.from_now
    )

    token_params = {
      grant_type: "authorization_code",
      code: auth_code.plaintext_code,
      redirect_uri: "http://localhost:4000/callback",
      code_verifier: code_verifier
    }

    post "/oauth/token", params: token_params, headers: {
      "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
    }

    assert_response :success
    tokens = JSON.parse(@response.body)
    assert tokens.key?("id_token")

    # Decode and verify auth_time is present and matches what we stored
    decoded = JWT.decode(tokens["id_token"], nil, false).first
    assert_includes decoded.keys, "auth_time", "ID token should include auth_time"
    assert_equal expected_auth_time, decoded["auth_time"], "auth_time should match authorization code"
  end

  test "ID token includes auth_time in refresh token flow" do
    # Create consent
    OidcUserConsent.create!(
      user: @user,
      application: @application,
      scopes_granted: "openid profile offline_access",
      granted_at: Time.current,
      sid: "test-sid-refresh-auth-time"
    )

    # Get the expected auth_time from the session's created_at
    expected_auth_time = Current.session.created_at.to_i

    # Create initial access and refresh tokens with auth_time
    auth_code = OidcAuthorizationCode.create!(
      application: @application,
      user: @user,
      redirect_uri: "http://localhost:4000/callback",
      scope: "openid profile offline_access",
      code_challenge: nil,
      code_challenge_method: nil,
      auth_time: expected_auth_time,
      expires_at: 10.minutes.from_now
    )

    # Update application to not require PKCE for testing
    @application.update!(require_pkce: false)

    token_params = {
      grant_type: "authorization_code",
      code: auth_code.plaintext_code,
      redirect_uri: "http://localhost:4000/callback"
    }

    post "/oauth/token", params: token_params, headers: {
      "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
    }

    assert_response :success
    tokens = JSON.parse(@response.body)
    refresh_token = tokens["refresh_token"]

    # Now use the refresh token
    refresh_params = {
      grant_type: "refresh_token",
      refresh_token: refresh_token
    }

    post "/oauth/token", params: refresh_params, headers: {
      "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
    }

    assert_response :success
    new_tokens = JSON.parse(@response.body)
    assert new_tokens.key?("id_token")

    # Decode and verify auth_time is preserved from original authorization
    decoded = JWT.decode(new_tokens["id_token"], nil, false).first
    assert_includes decoded.keys, "auth_time", "Refreshed ID token should include auth_time"
    assert_equal expected_auth_time, decoded["auth_time"], "auth_time should match original authorization code"
  end

  test "at_hash is correctly computed and included in ID token" do
    # Create consent
    OidcUserConsent.create!(
      user: @user,
      application: @application,
      scopes_granted: "openid profile",
      granted_at: Time.current,
      sid: "test-sid-at-hash"
    )

    # Generate valid PKCE pair
    code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    code_challenge = Digest::SHA256.base64digest(code_verifier)
      .tr("+/", "-_")
      .tr("=", "")

    # Create authorization code
    auth_code = OidcAuthorizationCode.create!(
      application: @application,
      user: @user,
      redirect_uri: "http://localhost:4000/callback",
      scope: "openid profile",
      code_challenge: code_challenge,
      code_challenge_method: "S256",
      expires_at: 10.minutes.from_now
    )

    token_params = {
      grant_type: "authorization_code",
      code: auth_code.plaintext_code,
      redirect_uri: "http://localhost:4000/callback",
      code_verifier: code_verifier
    }

    post "/oauth/token", params: token_params, headers: {
      "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
    }

    assert_response :success
    tokens = JSON.parse(@response.body)
    access_token = tokens["access_token"]
    id_token = tokens["id_token"]

    # Decode ID token
    decoded = JWT.decode(id_token, nil, false).first
    assert_includes decoded.keys, "at_hash", "ID token should include at_hash"

    # Verify at_hash matches the access token hash
    expected_hash = Base64.urlsafe_encode64(Digest::SHA256.digest(access_token)[0..15], padding: false)
    assert_equal expected_hash, decoded["at_hash"], "at_hash should match SHA-256 hash of access token"
  end
end
