require 'builder'
require 'socket'

module Airbrake
  class Notice

    class << self
      def attr_reader_with_tracking(*names)
        attr_readers.concat(names)
        attr_reader_without_tracking(*names)
      end

      alias_method :attr_reader_without_tracking, :attr_reader
      alias_method :attr_reader, :attr_reader_with_tracking


      def attr_readers
        @attr_readers ||= []
      end
    end

    # The exception that caused this notice, if any
    attr_reader :exception

    # The API key for the project to which this notice should be sent
    attr_reader :api_key

    # The backtrace from the given exception or hash.
    attr_reader :backtrace

    # The name of the class of error (such as RuntimeError)
    attr_reader :error_class

    # The name of the server environment (such as "production")
    attr_reader :environment_name

    # CGI variables such as HTTP_METHOD
    attr_reader :cgi_data

    # The message from the exception, or a general description of the error
    attr_reader :error_message

    # See Configuration#backtrace_filters
    attr_reader :backtrace_filters

    # See Configuration#params_filters
    attr_reader :params_filters

    # A hash of parameters from the query string or post body.
    attr_reader :parameters
    alias_method :params, :parameters

    # The component (if any) which was used in this request (usually the controller)
    attr_reader :component
    alias_method :controller, :component

    # The action (if any) that was called in this request
    attr_reader :action

    # A hash of session data from the request
    attr_reader :session_data

    # The path to the project that caused the error (usually Rails.root)
    attr_reader :project_root

    # The URL at which the error occurred (if any)
    attr_reader :url

    # See Configuration#ignore
    attr_reader :ignore

    # See Configuration#ignore_by_filters
    attr_reader :ignore_by_filters

    # The name of the notifier library sending this notice, such as "Airbrake Notifier"
    attr_reader :notifier_name

    # The version number of the notifier library sending this notice, such as "2.1.3"
    attr_reader :notifier_version

    # A URL for more information about the notifier library sending this notice
    attr_reader :notifier_url

    # The host name where this error occurred (if any)
    attr_reader :hostname

    # Details about the user who experienced the error
    attr_reader :user

    private

    # Private writers for all the attributes
    attr_writer :exception, :api_key, :backtrace, :error_class, :error_message,
      :backtrace_filters, :parameters, :params_filters,
      :session_data, :project_root, :url, :ignore,
      :ignore_by_filters, :notifier_name, :notifier_url, :notifier_version,
      :component, :action, :cgi_data, :environment_name, :hostname, :user

    # Arguments given in the initializer
    attr_accessor :args

    public

    def initialize(args)
      self.args         = args
      self.exception    = args[:exception]
      self.api_key      = args[:api_key]
      self.project_root = args[:project_root]
      self.url          = args[:url] || rack_env(:url)

      self.notifier_name    = args[:notifier_name]
      self.notifier_version = args[:notifier_version]
      self.notifier_url     = args[:notifier_url]

      self.ignore              = args[:ignore]              || []
      self.ignore_by_filters   = args[:ignore_by_filters]   || []
      self.backtrace_filters   = args[:backtrace_filters]   || []
      self.params_filters      = args[:params_filters]      || []
      self.parameters          = args[:parameters] ||
                                   action_dispatch_params ||
                                   rack_env(:params) ||
                                   {}
      self.component           = args[:component] || args[:controller] || parameters['controller']
      self.action              = args[:action] || parameters['action']

      self.environment_name = args[:environment_name]
      self.cgi_data         = args[:cgi_data] || args[:rack_env]
      self.backtrace        = Backtrace.parse(exception_attribute(:backtrace, caller), :filters => self.backtrace_filters)
      self.error_class      = exception_attribute(:error_class) {|exception| exception.class.name }
      self.error_message    = exception_attribute(:error_message, 'Notification') do |exception|
        "#{exception.class.name}: #{args[:error_message] || exception.message}"
      end

      self.hostname        = local_hostname
      self.user = args[:user]

      also_use_rack_params_filters
      find_session_data
      clean_params
      clean_rack_request_data
    end

    # Converts the given notice to XML
    def to_xml
      builder = Builder::XmlMarkup.new
      builder.instruct!
      xml = builder.notice(:version => Airbrake::API_VERSION) do |notice|
        notice.tag!("api-key", api_key)
        notice.notifier do |notifier|
          notifier.name(notifier_name)
          notifier.version(notifier_version)
          notifier.url(notifier_url)
        end
        notice.error do |error|
          error.tag!('class', error_class)
          error.message(error_message)
          error.backtrace do |backtrace|
            self.backtrace.lines.each do |line|
              backtrace.line(:number => line.number,
                             :file   => line.file,
                             :method => line.method)
            end
          end
        end
        if url ||
            controller ||
            action ||
            !parameters.blank? ||
            !cgi_data.blank? ||
            !session_data.blank?
          notice.request do |request|
            request.url(url)
            request.component(controller)
            request.action(action)
            unless parameters.blank?
              request.params do |params|
                xml_vars_for(params, parameters)
              end
            end
            unless session_data.blank?
              request.session do |session|
                xml_vars_for(session, session_data)
              end
            end
            unless cgi_data.blank?
              request.tag!("cgi-data") do |cgi_datum|
                xml_vars_for(cgi_datum, cgi_data)
              end
            end
          end
        end
        notice.tag!("server-environment") do |env|
          env.tag!("project-root", project_root)
          env.tag!("environment-name", environment_name)
          env.tag!("hostname", hostname)
        end
        unless user.blank?
          notice.tag!("current-user") do |u|
            u.tag!("id",user[:id])
            u.tag!("name",user[:name])
            u.tag!("email",user[:email])
            u.tag!("username",user[:username])
          end
        end
        unless framework.blank?
          notice.tag!("framework", framework)
        end
      end
      xml.to_s
    end

    def to_json
      {
        'notifier' => {
          'name'    => 'airbrake',
          'version' => Airbrake::VERSION,
          'url'     => 'https://github.com/airbrake/airbrake'
          },
        'errors' => [{
            'type'       => error_class,
            'message'    => error_message,
            'backtrace'  => backtrace.lines.map do |line|
                {
                  'file'     => line.file,
                  'line'     => line.number.to_i,
                  'function' => line.method
                }
            end
          }],
         'context' => {}.tap do |hash|
            if url || controller || action || !parameters.blank? || !cgi_data.blank? || !session_data.blank?
              hash['url']           = url
              hash['component']     = controller
              hash['action']        = action
              hash['rootDirectory'] = File.dirname(project_root)
              hash['environment']   = environment_name
            end
           end.tap do |hash|
            next if user.blank?

            hash['userId']    = user[:id]
            hash['userName']  = user[:name]
            hash['userEmail'] = user[:email]
          end

      }.tap do |hash|
          hash['environment'] = cgi_data     unless cgi_data.blank?
          hash['params']      = parameters   unless parameters.blank?
          hash['session']     = session_data unless session_data.blank?
      end.to_json
    end

    # Determines if this notice should be ignored
    def ignore?
      ignored_class_names.include?(error_class) ||
        ignore_by_filters.any? {|filter| filter.call(self) }
    end

    # Allows properties to be accessed using a hash-like syntax
    #
    # @example
    #   notice[:error_message]
    # @param [String] method The given key for an attribute
    # @return The attribute value, or self if given +:request+
    def [](method)
      case method
      when :request
        self
      else
        send(method)
      end
    end

    private


    # Gets a property named +attribute+ of an exception, either from an actual
    # exception or a hash.
    #
    # If an exception is available, #from_exception will be used. Otherwise,
    # a key named +attribute+ will be used from the #args.
    #
    # If no exception or hash key is available, +default+ will be used.
    def exception_attribute(attribute, default = nil, &block)
      (exception && from_exception(attribute, &block)) || args[attribute] || default
    end

    # Gets a property named +attribute+ from an exception.
    #
    # If a block is given, it will be used when getting the property from an
    # exception. The block should accept and exception and return the value for
    # the property.
    #
    # If no block is given, a method with the same name as +attribute+ will be
    # invoked for the value.
    def from_exception(attribute)
      if block_given?
        yield(exception)
      else
        exception.send(attribute)
      end
    end

    # Removes non-serializable data from the given attribute.
    # See #clean_unserializable_data
    def clean_unserializable_data_from(attribute)
      self.send(:"#{attribute}=", clean_unserializable_data(send(attribute)))
    end

    # Removes non-serializable data. Allowed data types are strings, arrays,
    # and hashes. All other types are converted to strings.
    # TODO: move this onto Hash
    def clean_unserializable_data(data, stack = [])
      return "[possible infinite recursion halted]" if stack.any?{|item| item == data.object_id }

      if data.respond_to?(:to_hash)
        data.to_hash.inject({}) do |result, (key, value)|
          result.merge(key => clean_unserializable_data(value, stack + [data.object_id]))
        end
      elsif data.respond_to?(:to_ary)
        data.to_ary.collect do |value|
          clean_unserializable_data(value, stack + [data.object_id])
        end
      else
        data.to_s
      end
    end

    # Replaces the contents of params that match params_filters.
    # TODO: extract this to a different class
    def clean_params
      clean_unserializable_data_from(:parameters)
      filter(parameters)
      if cgi_data
        clean_unserializable_data_from(:cgi_data)
        filter(cgi_data)
      end
      if session_data
        clean_unserializable_data_from(:session_data)
        filter(session_data)
      end
    end

    def clean_rack_request_data
      if cgi_data
        cgi_data.delete("rack.request.form_vars")
        cgi_data.delete("action_dispatch.secret_token")
      end
    end

    def filter(hash)
      if params_filters
        hash.each do |key, value|
          if filter_key?(key)
            hash[key] = "[FILTERED]"
          elsif value.respond_to?(:to_hash)
            filter(hash[key])
          end
        end
      end
    end

    def filter_key?(key)
      params_filters.any? do |filter|
        key.to_s.eql?(filter.to_s)
      end
    end

    def find_session_data
      self.session_data = args[:session_data] || args[:session] || rack_session || {}
      self.session_data = session_data[:data] if session_data[:data]
    end

    # Converts the mixed class instances and class names into just names
    # TODO: move this into Configuration or another class
    def ignored_class_names
      ignore.collect do |string_or_class|
        if string_or_class.respond_to?(:name)
          string_or_class.name
        else
          string_or_class
        end
      end
    end

    def xml_vars_for(builder, hash)
      hash.each do |key, value|
        if value.respond_to?(:to_hash)
          builder.var(:key => key){|b| xml_vars_for(b, value.to_hash) }
        else
          builder.var(value.to_s, :key => key)
        end
      end
    end

    def rack_env(method)
      rack_request.send(method) if rack_request
    rescue
      {:message => "failed to call #{method} on Rack::Request -- #{$!.message}"}
    end

    def rack_request
      @rack_request ||= if args[:rack_env]
        ::Rack::Request.new(args[:rack_env])
      end
    end

    def action_dispatch_params
      args[:rack_env]['action_dispatch.request.parameters'] if args[:rack_env]
    end

    def rack_session
      args[:rack_env]['rack.session'] if args[:rack_env]
    end

    def also_use_rack_params_filters
      if args[:rack_env]
        @params_filters ||= []
        @params_filters += rack_request.env["action_dispatch.parameter_filter"] || []
      end
    end

    def local_hostname
      Socket.gethostname
    end

    def framework
      Airbrake.configuration.framework
    end

    def to_s
      content = []
      self.class.attr_readers.each do |attr|
        content << "  #{attr}: #{send(attr)}"
      end
      content.join("\n")
    end
  end
end