require 'forwardable'

require 'honeybadger/version'
require 'honeybadger/config'
require 'honeybadger/context_manager'
require 'honeybadger/notice'
require 'honeybadger/plugin'
require 'honeybadger/logging'
require 'honeybadger/worker'
require 'honeybadger/breadcrumbs'

module Honeybadger
  # The Honeybadger agent contains all the methods for interacting with the
  # Honeybadger service. It can be used to send notifications to multiple
  # projects in large apps. The global agent instance ({Agent.instance}) should
  # always be accessed through the {Honeybadger} singleton.
  #
  # === Context
  #
  # Context is global by default, meaning agents created via
  # +Honeybadger::Agent.new+ will share context (added via
  # +Honeybadger.context+ or {Honeybadger::Agent#context}) with other agents.
  # This also includes the Rack environment when using the
  # {Honeybadger::Rack::ErrorNotifier} middleware. To localize context for a
  # custom agent, use the +local_context: true+ option when initializing.
  #
  # @example
  #
  #   # Standard usage:
  #   OtherBadger = Honeybadger::Agent.new
  #
  #   # With local context:
  #   OtherBadger = Honeybadger::Agent.new(local_context: true)
  #
  #   OtherBadger.configure do |config|
  #     config.api_key = 'project api key'
  #   end
  #
  #   begin
  #     # Risky operation
  #   rescue => e
  #     OtherBadger.notify(e)
  #   end
  class Agent
    extend Forwardable

    include Logging::Helper

    # @api private
    def self.instance
      @instance
    end

    # @api private
    def self.instance=(instance)
      @instance = instance
    end

    def initialize(opts = {})
      if opts.kind_of?(Config)
        @config = opts
        opts = {}
      end

      @context = opts.delete(:context)
      if opts.delete(:local_context)
        @context ||= ContextManager.new
        @breadcrumbs = Breadcrumbs::Collector.new(config)
      end

      @config ||= Config.new(opts)

      init_worker
    end

    # Sends an exception to Honeybadger. Does not report ignored exceptions by
    # default.
    #
    # @example
    #   # With an exception:
    #   begin
    #     fail 'oops'
    #   rescue => exception
    #     Honeybadger.notify(exception, context: {
    #       my_data: 'value'
    #     }) # => '-1dfb92ae-9b01-42e9-9c13-31205b70744a'
    #   end
    #
    #   # Custom notification:
    #   Honeybadger.notify('Something went wrong.', {
    #     error_class: 'MyClass',
    #     context: {my_data: 'value'}
    #   }) # => '06220c5a-b471-41e5-baeb-de247da45a56'
    #
    # @param [Exception, Hash, Object] exception_or_opts An Exception object,
    #   or a Hash of options which is used to build the notice. All other types
    #   of objects will be converted to a String and used as the :error_message.
    # @param [Hash] opts The options Hash when the first argument is an Exception.
    #
    # @option opts [String]    :error_message The error message.
    # @option opts [String]    :error_class ('Notice') The class name of the error.
    # @option opts [Array]     :backtrace The backtrace of the error (optional).
    # @option opts [String]    :fingerprint The grouping fingerprint of the exception (optional).
    # @option opts [Boolean]   :force (false) Always report the exception when true, even when ignored (optional).
    # @option opts [Boolean]   :sync (false) Send data synchronously (skips the worker) (optional).
    # @option opts [String]    :tags The comma-separated list of tags (optional).
    # @option opts [Hash]      :context The context to associate with the exception (optional).
    # @option opts [String]    :controller The controller name (such as a Rails controller) (optional).
    # @option opts [String]    :action The action name (such as a Rails controller action) (optional).
    # @option opts [Hash]      :parameters The HTTP request paramaters (optional).
    # @option opts [Hash]      :session The HTTP request session (optional).
    # @option opts [String]    :url The HTTP request URL (optional).
    # @option opts [Exception] :cause The cause for this error (optional).
    #
    # @return [String] UUID reference to the notice within Honeybadger.
    # @return [false] when ignored.
    def notify(exception_or_opts, opts = {})
      opts = opts.dup

      if exception_or_opts.is_a?(Exception)
        opts[:exception] = exception_or_opts
      elsif exception_or_opts.respond_to?(:to_hash)
        opts.merge!(exception_or_opts.to_hash)
      else
        opts[:error_message] = exception_or_opts.to_s
      end

      validate_notify_opts!(opts)

      add_breadcrumb(
        "Honeybadger Notice",
        metadata: opts,
        category: "notice"
      ) if config[:'breadcrumbs.enabled']

      opts[:rack_env] ||= context_manager.get_rack_env
      opts[:global_context] ||= context_manager.get_context
      opts[:breadcrumbs] ||= breadcrumbs.dup

      notice = Notice.new(config, opts)

      config.before_notify_hooks.each do |hook|
        break if notice.halted?
        with_error_handling { hook.call(notice) }
      end

      unless notice.api_key =~ NOT_BLANK
        error { sprintf('Unable to send error report: API key is missing. id=%s', notice.id) }
        return false
      end

      if !opts[:force] && notice.ignore?
        debug { sprintf('ignore notice feature=notices id=%s', notice.id) }
        return false
      end

      if notice.halted?
        debug { 'halted notice feature=notices' }
        return false
      end

      info { sprintf('Reporting error id=%s', notice.id) }

      if opts[:sync] || config[:sync]
        send_now(notice)
      else
        push(notice)
      end

      notice.id
    end

    # Perform a synchronous check_in.
    #
    # @example
    #   Honeybadger.check_in('1MqIo1')
    #
    # @param [String] id The unique check in id (e.g. '1MqIo1') or the check in url.
    #
    # @return [Boolean] true if the check in was successful and false
    #   otherwise.
    def check_in(id)
      # this is to allow check ins even if a url is passed
      check_in_id = id.to_s.strip.gsub(/\/$/, '').split('/').last
      response = backend.check_in(check_in_id)
      response.success?
    end

    # Save global context for the current request.
    #
    # @example
    #   Honeybadger.context({my_data: 'my value'})
    #
    #   # Inside a Rails controller:
    #   before_action do
    #     Honeybadger.context({user_id: current_user.id})
    #   end
    #
    #   # Explicit conversion
    #   class User < ActiveRecord::Base
    #     def to_honeybadger_context
    #       { user_id: id, user_email: email }
    #     end
    #   end
    #
    #   user = User.first
    #   Honeybadger.context(user)
    #
    #   # Clearing global context:
    #   Honeybadger.context.clear!
    #
    # @param [Hash] context A Hash of data which will be sent to Honeybadger
    #   when an error occurs. If the object responds to +#to_honeybadger_context+,
    #   the return value of that method will be used (explicit conversion). Can
    #   include any key/value, but a few keys have a special meaning in
    #   Honeybadger.
    #
    # @option context [String] :user_id The user ID used by Honeybadger
    #   to aggregate user data across occurrences on the error page (optional).
    # @option context [String] :user_email The user email address (optional).
    # @option context [String] :tags The comma-separated list of tags.
    #   When present, tags will be applied to errors with this context
    #   (optional).
    #
    # @return [self] so that method calls can be chained.
    def context(context = nil)
      context_manager.set_context(context) unless context.nil?
      self
    end

    # Clear all transaction scoped data.
    def clear!
      context_manager.clear!
      breadcrumbs.clear!
    end

    # Get global context for the current request.
    #
    # @example
    #   Honeybadger.context({my_data: 'my value'})
    #   Honeybadger.get_context # => {my_data: 'my value'}
    #
    # @return [Hash, nil]
    def get_context
      context_manager.get_context
    end

    # @api private
    # Direct access to the Breadcrumbs::Collector instance
    def breadcrumbs
      return @breadcrumbs if @breadcrumbs

      Thread.current[:__hb_breadcrumbs] ||= Breadcrumbs::Collector.new(config)
    end

    # Appends a breadcrumb to the trace. Use this when you want to add some
    # custom data to your breadcrumb trace in effort to help debugging. If a
    # notice is reported to Honeybadger, all breadcrumbs within the execution
    # path will be appended to the notice. You will be able to view the
    # breadcrumb trace in the Honeybadger interface to see what events led up
    # to the notice.
    #
    # @example
    #   Honeybadger.add_breadcrumb("Email Sent", metadata: { user: user.id, message: message })
    #
    # @param message [String] The message you want to send with the breadcrumb
    # @param params [Hash] extra options for breadcrumb building
    # @option params [Hash] :metadata Any metadata that you want to pass along
    #   with the breadcrumb. We only accept a hash with simple primatives as
    #   values (Strings, Numbers, Booleans & Symbols) (optional)
    # @option params [String] :category You can provide a custom category. This
    #   affects how the breadcrumb is displayed, so we recommend that you pick a
    #   known category. (optional)
    #
    # @return self
    def add_breadcrumb(message, metadata: {}, category: "custom")
      params = Util::Sanitizer.new(max_depth: 2).sanitize({
        category: category,
        message: message,
        metadata: metadata
      })

      breadcrumbs.add!(Breadcrumbs::Breadcrumb.new(**params))

      self
    end

    # Flushes all data from workers before returning. This is most useful in
    # tests when using the test backend, where normally the asynchronous nature
    # of this library could create race conditions.
    #
    # @example
    #   # Without a block:
    #   it "sends a notification to Honeybadger" do
    #     expect {
    #       Honeybadger.notify(StandardError.new('test backend'))
    #       Honeybadger.flush
    #     }.to change(Honeybadger::Backend::Test.notifications[:notices], :size).by(0)
    #   end
    #
    #   # With a block:
    #   it "sends a notification to Honeybadger" do
    #     expect {
    #       Honeybadger.flush do
    #         49.times do
    #           Honeybadger.notify(StandardError.new('test backend'))
    #         end
    #       end
    #     }.to change(Honeybadger::Backend::Test.notifications[:notices], :size).by(49)
    #   end
    #
    # @yield An optional block to execute (exceptions will propagate after
    #   data is flushed).
    #
    # @return [Object, Boolean] value of block if block is given, otherwise true
    #   on success or false if Honeybadger isn't running.
    def flush
      return true unless block_given?
      yield
    ensure
      worker.flush
    end

    # Stops the Honeybadger service.
    #
    # @example
    #   Honeybadger.stop # => nil
    def stop(force = false)
      worker.shutdown(force)
      true
    end

    # @api private
    attr_reader :config

    # Configure the Honeybadger agent via Ruby.
    #
    # @example
    #   Honeybadger.configure do |config|
    #     config.api_key = 'project api key'
    #     config.exceptions.ignore += [CustomError]
    #   end
    #
    # @!method configure
    # @yield [Config::Ruby] configuration object.
    def_delegator :config, :configure

    # DEPRECATED: Callback to ignore exceptions.
    #
    # See public API documentation for {Honeybadger::Notice} for available attributes.
    #
    # @example
    #   # Ignoring based on error message:
    #   Honeybadger.exception_filter do |notice|
    #     notice.error_message =~ /sensitive data/
    #   end
    #
    #   # Ignore an entire class of exceptions:
    #   Honeybadger.exception_filter do |notice|
    #     notice.exception.class < MyError
    #   end
    #
    # @!method exception_filter
    # @yieldreturn [Boolean] true (to ignore) or false (to send).
    def_delegator :config, :exception_filter

    # DEPRECATED: Callback to add a custom grouping strategy for exceptions. The return
    # value is hashed and sent to Honeybadger. Errors with the same fingerprint
    # will be grouped.
    #
    # See public API documentation for {Honeybadger::Notice} for available attributes.
    #
    # @example
    #   Honeybadger.exception_fingerprint do |notice|
    #     [notice.error_class, notice.component, notice.backtrace.to_s].join(':')
    #   end
    #
    # @!method exception_fingerprint
    # @yieldreturn [#to_s] The fingerprint of the error.
    def_delegator :config, :exception_fingerprint

    # DEPRECATED: Callback to filter backtrace lines. One use for this is to make
    # additional [PROJECT_ROOT] or [GEM_ROOT] substitutions, which are used by
    # Honeybadger when grouping errors and displaying application traces.
    #
    # @example
    #   Honeybadger.backtrace_filter do |line|
    #     line.gsub(/^\/my\/unknown\/bundle\/path/, "[GEM_ROOT]")
    #   end
    #
    # @!method backtrace_filter
    # @yieldparam  [String] line The backtrace line to modify.
    # @yieldreturn [String] The new (modified) backtrace line.
    def_delegator :config, :backtrace_filter

    # @api private
    def with_rack_env(rack_env, &block)
      context_manager.set_rack_env(rack_env)
      yield
    ensure
      context_manager.set_rack_env(nil)
    end

    # @api private
    attr_reader :worker

    # @api private
    # @!method init!(...)
    # @see Config#init!
    def_delegators :config, :init!

    # @api private
    # @!method backend
    # @see Config#backend
    def_delegators :config, :backend

    private

    def validate_notify_opts!(opts)
      return if opts.has_key?(:exception)
      return if opts.has_key?(:error_message)
      msg = sprintf('`Honeybadger.notify` was called with invalid arguments. You must pass either an Exception or options Hash containing the `:error_message` key. location=%s', caller[caller.size-1])
      raise ArgumentError.new(msg) if config.dev?
      warn(msg)
    end

    def context_manager
      return @context if @context
      ContextManager.current
    end

    def push(object)
      worker.push(object)
      true
    end

    def send_now(object)
      worker.send_now(object)
      true
    end

    def init_worker
      @worker = Worker.new(config)
    end

    def with_error_handling
      yield
    rescue => ex
      error { "Rescued an error in a before notify hook: #{ex.message}" }
    end

    @instance = new(Config.new)
  end
end
