# frozen_string_literal: true

require "json"
require "uri"

module MCP
  class Client
    module OAuth
      # RFC 8693 token exchange against an enterprise identity provider, turning an IdP-issued ID token into
      # an Identity Assertion Authorization Grant (ID-JAG) per the MCP Enterprise Managed Authorization extension (SEP-990).
      # The returned ID-JAG is an opaque assertion the client then presents to the MCP authorization server with
      # the RFC 7523 `jwt-bearer` grant (see `CrossAppAccessProvider`).
      # Mirrors `requestJwtAuthorizationGrant` in the TypeScript SDK.
      #
      # - https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
      # - https://www.rfc-editor.org/rfc/rfc8693
      module IDJAGTokenExchange
        # Raised when the identity provider's token exchange fails or returns something other than an ID-JAG.
        class ExchangeError < StandardError; end

        GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
        ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token"
        ID_JAG_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id-jag"

        class << self
          # Exchanges `id_token` for an ID-JAG at the IdP's token endpoint and returns the assertion string.
          #
          # @param token_endpoint [String] The identity provider's token endpoint.
          # @param id_token [String] The IdP-issued ID token (the subject token).
          # @param client_id [String] The client's identifier at the IdP.
          # @param audience [String] The MCP authorization server's issuer identifier.
          # @param resource [String] The canonical MCP server URL (RFC 8707).
          # @param http_client [Object, nil] Faraday-compatible client; built lazily by default.
          def request(token_endpoint:, id_token:, client_id:, audience:, resource:, http_client: nil)
            http_client ||= default_http_client

            bounded = BoundedBody.new

            response = begin
              raw_response = http_client.post(token_endpoint) do |req|
                req.headers["Content-Type"] = "application/x-www-form-urlencoded"
                req.headers["Accept"] = "application/json"
                req.options.on_data = bounded.on_data
                req.body = URI.encode_www_form(
                  "grant_type" => GRANT_TYPE,
                  "subject_token" => id_token,
                  "subject_token_type" => ID_TOKEN_TYPE,
                  "requested_token_type" => ID_JAG_TOKEN_TYPE,
                  "audience" => audience,
                  "resource" => resource,
                  "client_id" => client_id,
                )
              end

              bounded.response_for(raw_response)
            rescue BoundedBody::TooLargeError => e
              raise ExchangeError, "#{e.message}."
            rescue Faraday::Error => e
              raise ExchangeError, "Token exchange request to #{token_endpoint} failed: #{e.class}: #{e.message}."
            end

            if response.status < 200 || response.status >= 300
              raise ExchangeError, "Identity provider token exchange returned status #{response.status}."
            end

            parse_id_jag(response)
          end

          private

          def parse_id_jag(response)
            body = response.body.is_a?(String) ? response.body : response.body.to_s
            parsed = begin
              JSON.parse(body)
            rescue JSON::ParserError => e
              raise ExchangeError, "Failed to parse token exchange response: #{e.message}."
            end

            unless parsed.is_a?(Hash)
              raise ExchangeError, "Token exchange response is not a JSON object (got #{parsed.class})."
            end

            issued_token_type = parsed["issued_token_type"]
            unless issued_token_type == ID_JAG_TOKEN_TYPE
              raise ExchangeError,
                "Token exchange did not issue an ID-JAG " \
                  "(expected issued_token_type #{ID_JAG_TOKEN_TYPE.inspect}, got #{issued_token_type.inspect})."
            end

            assertion = parsed["access_token"]
            if assertion.nil? || assertion.to_s.empty?
              raise ExchangeError, "Token exchange response is missing `access_token`."
            end

            assertion
          end

          # `Accept-Encoding` is deliberately left unset, for the same reason as `Flow#default_http_client`:
          # claiming that header turns Net::HTTP's `decode_content` off and would move `BoundedBody`'s cap
          # onto compressed bytes.
          def default_http_client
            require "faraday"

            Faraday.new do |faraday|
              faraday.headers["Accept"] = "application/json"
            end
          end
        end
      end
    end
  end
end
