require 'cgi'
require 'zlib'
require 'base64'
require 'nokogiri'
require 'rexml/document'
require 'rexml/xpath'
require 'thread'
require "onelogin/ruby-saml/error_handling"

# Only supports SAML 2.0
module OneLogin
  module RubySaml

    # SAML2 Message
    #
    class SamlMessage
      include REXML

      ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion".freeze
      PROTOCOL  = "urn:oasis:names:tc:SAML:2.0:protocol".freeze

      BASE64_FORMAT = %r(\A([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\Z)
      @@mutex = Mutex.new

      # @return [Nokogiri::XML::Schema] Gets the schema object of the SAML 2.0 Protocol schema
      #
      def self.schema
        @@mutex.synchronize do
          Dir.chdir(File.expand_path("../../../schemas", __FILE__)) do
            ::Nokogiri::XML::Schema(File.read("saml-schema-protocol-2.0.xsd"))
          end
        end
      end

      # @return [String|nil] Gets the Version attribute from the SAML Message if exists.
      #
      def version(document)
        @version ||= begin
          node = REXML::XPath.first(
            document,
            "/p:AuthnRequest | /p:Response | /p:LogoutResponse | /p:LogoutRequest",
            { "p" => PROTOCOL }
          )
          node.nil? ? nil : node.attributes['Version']
        end
      end

      # @return [String|nil] Gets the ID attribute from the SAML Message if exists.
      #
      def id(document)
        @id ||= begin
          node = REXML::XPath.first(
            document,
            "/p:AuthnRequest | /p:Response | /p:LogoutResponse | /p:LogoutRequest",
            { "p" => PROTOCOL }
          )
          node.nil? ? nil : node.attributes['ID']
        end
      end

      # Validates the SAML Message against the specified schema.
      # @param document [REXML::Document] The message that will be validated
      # @param soft [Boolean] soft Enable or Disable the soft mode (In order to raise exceptions when the message is invalid or not)
      # @return [Boolean] True if the XML is valid, otherwise False, if soft=True
      # @raise [ValidationError] if soft == false and validation fails
      #
      def valid_saml?(document, soft = true)
        begin
          xml = Nokogiri::XML(document.to_s) do |config|
            config.options = XMLSecurity::BaseDocument::NOKOGIRI_OPTIONS
          end
        rescue Exception => error
          return false if soft
          raise ValidationError.new("XML load failed: #{error.message}")
        end

        SamlMessage.schema.validate(xml).map do |schema_error|
          return false if soft
          raise ValidationError.new("#{schema_error.message}\n\n#{xml.to_s}")
        end
      end

      private

      # Base64 decode and try also to inflate a SAML Message
      # @param saml [String] The deflated and encoded SAML Message
      # @return [String] The plain SAML Message
      #
      def decode_raw_saml(saml, settings = nil)
        return saml unless base64_encoded?(saml)

        settings = OneLogin::RubySaml::Settings.new if settings.nil?
        if saml.bytesize > settings.message_max_bytesize
          raise ValidationError.new("Encoded SAML Message exceeds " + settings.message_max_bytesize.to_s + " bytes, so was rejected")
        end

        decoded = decode(saml)
        begin
          inflate(decoded)
        rescue
          decoded
        end
      end

      # Deflate, base64 encode and url-encode a SAML Message (To be used in the HTTP-redirect binding)
      # @param saml [String] The plain SAML Message
      # @param settings [OneLogin::RubySaml::Settings|nil] Toolkit settings
      # @return [String] The deflated and encoded SAML Message (encoded if the compression is requested)
      #
      def encode_raw_saml(saml, settings)
        saml = deflate(saml) if settings.compress_request

        CGI.escape(encode(saml))
      end

      # Base 64 decode method
      # @param string [String] The string message
      # @return [String] The decoded string
      #
      def decode(string)
        Base64.decode64(string)
      end

      # Base 64 encode method
      # @param string [String] The string
      # @return [String] The encoded string
      #
      def encode(string)
        if Base64.respond_to?('strict_encode64')
          Base64.strict_encode64(string)
        else
          Base64.encode64(string).gsub(/\n/, "")
        end
      end

      # Check if a string is base64 encoded
      # @param string [String] string to check the encoding of
      # @return [true, false] whether or not the string is base64 encoded
      #
      def base64_encoded?(string)
        !!string.gsub(/[\r\n]|\\r|\\n|\s/, "").match(BASE64_FORMAT)
      end

      # Inflate method
      # @param deflated [String] The string
      # @return [String] The inflated string
      #
      def inflate(deflated)
        Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(deflated)
      end

      # Deflate method
      # @param inflated [String] The string
      # @return [String] The deflated string
      #
      def deflate(inflated)
        Zlib::Deflate.deflate(inflated, 9)[2..-5]
      end
    end
  end
end
