require "json"
require "securerandom"
require "forwardable"

require "honeybadger/version"
require "honeybadger/backtrace"
require "honeybadger/conversions"
require "honeybadger/util/stats"
require "honeybadger/util/sanitizer"
require "honeybadger/util/request_hash"
require "honeybadger/util/request_payload"

module Honeybadger
  # @api private
  NOTIFIER = {
    name: "honeybadger-ruby".freeze,
    url: "https://github.com/honeybadger-io/honeybadger-ruby".freeze,
    version: VERSION,
    language: "ruby".freeze
  }.freeze

  # @api private
  # Substitution for gem root in backtrace lines.
  GEM_ROOT = "[GEM_ROOT]".freeze

  # @api private
  # Substitution for project root in backtrace lines.
  PROJECT_ROOT = "[PROJECT_ROOT]".freeze

  # @api private
  # Empty String (used for equality comparisons and assignment).
  STRING_EMPTY = "".freeze

  # @api private
  # A Regexp which matches non-blank characters.
  NOT_BLANK = /\S/

  # @api private
  # Matches lines beginning with ./
  RELATIVE_ROOT = Regexp.new('^\.\/').freeze

  # @api private
  MAX_EXCEPTION_CAUSES = 5

  # @api private
  # Binding#source_location was added in Ruby 2.6.
  BINDING_HAS_SOURCE_LOCATION = Binding.method_defined?(:source_location)

  class Notice
    extend Forwardable

    include Conversions

    # @api private
    # The String character used to split tag strings.
    TAG_SEPERATOR = /,|\s/

    # @api private
    # The Regexp used to strip invalid characters from individual tags.
    TAG_SANITIZER = /\s/

    # @api private
    class Cause
      attr_accessor :error_class, :error_message, :backtrace

      def initialize(cause)
        self.error_class = cause.class.name
        self.error_message = cause.message
        self.backtrace = cause.backtrace
      end
    end

    # The unique ID of this notice which can be used to reference the error in
    # Honeybadger.
    attr_reader :id

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

    # The exception cause if available.
    attr_reader :cause
    def cause=(cause)
      @cause = cause
      @causes = unwrap_causes(cause)
    end

    # @return [Cause] A list of exception causes (see {Cause})
    attr_reader :causes

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

    # Custom fingerprint for error, used to group similar errors together.
    attr_accessor :fingerprint

    # Tags which will be applied to error.
    attr_reader :tags
    def tags=(tags)
      @tags = construct_tags(tags)
    end

    # The name of the class of error (example: RuntimeError).
    attr_accessor :error_class

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

    # The context Hash.
    attr_accessor :context

    # CGI variables such as HTTP_METHOD.
    attr_accessor :cgi_data

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

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

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

    # A hash of session data from the request.
    attr_accessor :session

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

    # Local variables are extracted from first frame of backtrace.
    attr_accessor :local_variables

    # The API key used to deliver this notice.
    attr_accessor :api_key

    # Deprecated: Excerpt from source file.
    attr_reader :source

    # @return [Breadcrumbs::Collector] The collection of captured breadcrumbs
    attr_accessor :breadcrumbs

    # Custom details data
    attr_accessor :details

    # The ID of the request which caused this notice.
    attr_accessor :request_id

    # The parsed exception backtrace. Lines in this backtrace that are from installed gems
    # have the base path for gem installs replaced by "[GEM_ROOT]", while those in the project
    # have "[PROJECT_ROOT]".
    # @return [Array<{:number, :file, :method => String}>]
    def parsed_backtrace
      @parsed_backtrace ||= parse_backtrace(backtrace)
    end

    # @api private
    # Cache project path substitutions for backtrace lines.
    PROJECT_ROOT_CACHE = {}

    # @api private
    # Cache gem path substitutions for backtrace lines.
    GEM_ROOT_CACHE = {}

    # @api private
    # A list of backtrace filters to run all the time.
    BACKTRACE_FILTERS = [
      lambda { |line|
        return line unless defined?(Gem)
        GEM_ROOT_CACHE[line] ||= Gem.path.reduce(line) do |line, path|
          line.sub(path, GEM_ROOT)
        end
      },
      lambda { |line, config|
        return line unless config
        c = (PROJECT_ROOT_CACHE[config[:root]] ||= {})
        return c[line] if c.has_key?(line)
        c[line] ||= if config.root_regexp
          line.sub(config.root_regexp, PROJECT_ROOT)
        else
          line
        end
      },
      lambda { |line| line.sub(RELATIVE_ROOT, STRING_EMPTY) },
      lambda { |line| line if !%r{lib/honeybadger}.match?(line) }
    ].freeze

    # @api private
    def initialize(config, opts = {})
      @now = Time.now.utc
      @pid = Process.pid
      @id = SecureRandom.uuid
      @stats = Util::Stats.all

      @opts = opts
      @config = config

      @rack_env = opts.fetch(:rack_env, nil)
      @request_sanitizer = Util::Sanitizer.new(filters: params_filters)

      @exception = unwrap_exception(opts[:exception])

      self.error_class = exception_attribute(:error_class, "Notice") { |exception| exception.class.name }
      self.error_message = exception_attribute(:error_message, "No message provided") do |exception|
        message = exception.respond_to?(:detailed_message) ?
          exception.detailed_message(highlight: false).sub(" (#{exception.class.name})", "") # Gems like error_highlight append the exception class name
          : exception.message
        "#{exception.class.name}: #{message}"
      end
      self.backtrace = exception_attribute(:backtrace, caller)
      self.cause = opts.key?(:cause) ? opts[:cause] : (exception_cause(@exception) || $!)

      self.context = construct_context_hash(opts, exception)
      self.local_variables = local_variables_from_exception(exception, config)
      self.api_key = opts[:api_key] || config[:api_key]
      self.tags = construct_tags(opts[:tags]) | construct_tags(context[:tags])

      self.url = opts[:url] || request_hash[:url] || nil
      self.action = opts[:action] || context[:_action] || request_hash[:action] || nil
      self.component = opts[:controller] || opts[:component] || context[:_component] || request_hash[:component] || nil
      self.params = opts[:parameters] || opts[:params] || request_hash[:params] || {}
      self.session = opts[:session] || request_hash[:session] || {}
      self.cgi_data = opts[:cgi_data] || request_hash[:cgi_data] || {}
      self.details = opts[:details] || {}
      self.request_id = opts[:request_id] || nil

      self.session = opts[:session][:data] if opts[:session] && opts[:session][:data]

      self.breadcrumbs = opts[:breadcrumbs] || Breadcrumbs::Collector.new(config)

      # Fingerprint must be calculated last since callback operates on `self`.
      self.fingerprint = fingerprint_from_opts(opts)
    end

    # @api private
    # Template used to create JSON payload.
    #
    # @return [Hash] JSON representation of notice.
    def as_json(*args)
      request = construct_request_hash
      request[:context] = s(context)
      request[:local_variables] = local_variables if local_variables

      {
        api_key: s(api_key),
        notifier: NOTIFIER,
        breadcrumbs: sanitized_breadcrumbs,
        error: {
          token: id,
          class: s(error_class),
          message: s(error_message),
          backtrace: s(parsed_backtrace),
          fingerprint: fingerprint_hash,
          tags: s(tags),
          causes: s(prepare_causes(causes))
        },
        details: s(details),
        request: request,
        server: {
          project_root: s(config[:root]),
          revision: s(config[:revision]),
          environment_name: s(config[:env]),
          hostname: s(config[:hostname]),
          stats: stats,
          time: now,
          pid: pid
        },
        correlation_context: {
          request_id: s(request_id)
        }
      }
    end

    # Converts the notice to JSON.
    #
    # @return [Hash] The JSON representation of the notice.
    def to_json(*a)
      ::JSON.generate(as_json(*a))
    end

    # @api private
    # Determines if this notice should be ignored.
    def ignore?
      ignore_by_origin? || ignore_by_class? || ignore_by_callbacks?
    end

    # Halts the notice and the before_notify callback chain.
    #
    # Returns nothing.
    def halt!
      @halted ||= true
    end

    # @api private
    # Determines if this notice will be discarded.
    def halted?
      !!@halted
    end

    private

    attr_reader :config, :opts, :stats, :now, :pid, :request_sanitizer,
      :rack_env

    def ignore_by_origin?
      return false if opts[:origin] != :rake
      return false if config[:"exceptions.rescue_rake"]
      true
    end

    def ignore_by_callbacks?
      config.exception_filter&.call(self)
    end

    # Gets a property named "attribute" of an exception, either from
    # the #args hash or actual exception (in order of precedence).
    #
    # attribute - A Symbol existing as a key in #args and/or attribute on
    #             Exception.
    # default   - Default value if no other value is found (optional).
    # block     - An optional block which receives an Exception and returns the
    #             desired value.
    #
    # Returns attribute value from args or exception, otherwise default.
    def exception_attribute(attribute, default = nil, &block)
      opts[attribute] || (exception && from_exception(attribute, &block)) || 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)
      return unless exception

      if block_given?
        yield(exception)
      else
        exception.send(attribute)
      end
    end

    # Determines if error class should be ignored.
    #
    # ignored_class_name - The name of the ignored class. May be a
    # string or regexp (optional).
    #
    # Returns true or false.
    def ignore_by_class?(ignored_class = nil)
      @ignore_by_class ||= proc do |ignored_class|
        case error_class
        when (ignored_class.respond_to?(:name) ? ignored_class.name : ignored_class)
          true
        else
          exception && ignored_class.is_a?(Class) && exception.class < ignored_class
        end
      end

      ignored_class ? @ignore_by_class.call(ignored_class) : config.ignored_classes.any?(&@ignore_by_class)
    end

    def construct_backtrace_filters(opts)
      [
        config.backtrace_filter
      ].compact | BACKTRACE_FILTERS
    end

    def request_hash
      @request_hash ||= Util::RequestHash.from_env(rack_env)
    end

    # Construct the request data.
    #
    # Returns Hash request data.
    def construct_request_hash
      request = {
        url: url,
        component: component,
        action: action,
        params: params,
        session: session,
        cgi_data: cgi_data,
        sanitizer: request_sanitizer
      }
      request.delete_if { |k, v| config.excluded_request_keys.include?(k) }
      Util::RequestPayload.build(request)
    end

    # Get optional context from exception.
    #
    # Returns the Hash context.
    def exception_context(exception)
      # This extra check exists because the exception itself is not expected to
      # convert to a hash.
      object = exception if exception.respond_to?(:to_honeybadger_context)
      object ||= {}.freeze

      Context(object)
    end

    # Sanitize metadata to keep it at a single level and remove any filtered
    # parameters
    def sanitized_breadcrumbs
      sanitizer = Util::Sanitizer.new(max_depth: 1, filters: params_filters)
      breadcrumbs.each do |breadcrumb|
        breadcrumb.metadata = sanitizer.sanitize(breadcrumb.metadata)
      end

      breadcrumbs.to_h
    end

    def construct_context_hash(opts, exception)
      context = {}
      context.merge!(Context(opts[:global_context]))
      context.merge!(exception_context(exception))
      context.merge!(Context(opts[:context]))
      context
    end

    def fingerprint_from_opts(opts)
      callback = opts[:fingerprint]
      callback ||= config.exception_fingerprint

      if callback.respond_to?(:call)
        callback.call(self)
      else
        callback
      end
    end

    def fingerprint_hash
      return unless fingerprint
      Digest::SHA1.hexdigest(fingerprint.to_s)
    end

    def construct_tags(tags)
      ret = []
      Array(tags).flatten.each do |val|
        val.to_s.split(TAG_SEPERATOR).each do |tag|
          tag.gsub!(TAG_SANITIZER, STRING_EMPTY)
          ret << tag if NOT_BLANK.match?(tag)
        end
      end

      ret
    end

    def s(data)
      Util::Sanitizer.sanitize(data)
    end

    # Fetch local variables from first frame of backtrace.
    #
    # exception - The Exception containing the bindings stack.
    #
    # Returns a Hash of local variables.
    def local_variables_from_exception(exception, config)
      return nil unless send_local_variables?(config)
      return {} unless Exception === exception
      return {} unless exception.respond_to?(:__honeybadger_bindings_stack)
      return {} if exception.__honeybadger_bindings_stack.empty?

      if config[:root]
        binding = exception.__honeybadger_bindings_stack.find { |b|
          if BINDING_HAS_SOURCE_LOCATION
            b.source_location[0]
          else
            b.eval("__FILE__")
          end =~ /^#{Regexp.escape(config[:root].to_s)}/
        }
      end

      binding ||= exception.__honeybadger_bindings_stack[0]

      vars = binding.eval("local_variables")
      results =
        vars.each_with_object([]) { |arg, acc|
          begin
            result = binding.eval(arg.to_s)
            acc << [arg, result]
          rescue NameError
            # Do Nothing
          end
        }

      result_hash = results.to_h
      request_sanitizer.sanitize(result_hash)
    end

    # Should local variables be sent?
    #
    # Returns true to send local_variables.
    def send_local_variables?(config)
      config[:"exceptions.local_variables"]
    end

    # Parse Backtrace from exception backtrace.
    #
    # backtrace - The Array backtrace from exception.
    #
    # Returns the Backtrace.
    def parse_backtrace(backtrace)
      Backtrace.parse(
        backtrace,
        filters: construct_backtrace_filters(opts),
        config: config,
        source_radius: config[:"exceptions.source_radius"]
      ).to_a
    end

    # Unwrap the exception so that original exception is ignored or
    # reported.
    #
    # exception - The exception which was rescued.
    #
    # Returns the Exception to report.
    def unwrap_exception(exception)
      return exception unless config[:"exceptions.unwrap"]
      exception_cause(exception) || exception
    end

    # Fetch cause from exception.
    #
    # exception - Exception to fetch cause from.
    #
    # Returns the Exception cause.
    def exception_cause(exception)
      e = exception
      if e.respond_to?(:cause) && e.cause&.is_a?(Exception)
        e.cause
      elsif e.respond_to?(:original_exception) && e.original_exception&.is_a?(Exception)
        e.original_exception
      elsif e.respond_to?(:continued_exception) && e.continued_exception&.is_a?(Exception)
        e.continued_exception
      end
    end

    # Create a list of causes.
    #
    # cause - The first cause to unwrap.
    #
    # Returns the Array of Cause instances.
    def unwrap_causes(cause)
      causes, c, i = [], cause, 0

      while c && i < MAX_EXCEPTION_CAUSES
        causes << Cause.new(c)
        i += 1
        c = exception_cause(c)
      end

      causes
    end

    # Convert list of causes into payload format.
    #
    # causes - Array of Cause instances.
    #
    # Returns the Array of causes in Hash payload format.
    def prepare_causes(causes)
      causes.map { |c|
        {
          class: c.error_class,
          message: c.error_message,
          backtrace: parse_backtrace(c.backtrace)
        }
      }
    end

    def params_filters
      config.params_filters + rails_params_filters
    end

    def rails_params_filters
      rack_env && Array(rack_env["action_dispatch.parameter_filter"]) or []
    end
  end
end
