module Airbrake
  # Represents a cross-Ruby backtrace from exceptions (including JRuby Java
  # exceptions). Provides information about stack frames (such as line number,
  # file and method) in convenient for Airbrake format.
  #
  # @example
  #   begin
  #     raise 'Oops!'
  #   rescue
  #     Backtrace.parse($!)
  #   end
  #
  # @api private
  # @since v1.0.0
  module Backtrace
    module Patterns
      # @return [Regexp] the pattern that matches standard Ruby stack frames,
      #   such as ./spec/notice_spec.rb:43:in `block (3 levels) in <top (required)>'
      RUBY = %r{\A
        (?<file>.+)       # Matches './spec/notice_spec.rb'
        :
        (?<line>\d+)      # Matches '43'
        :in\s
        `(?<function>.*)' # Matches "`block (3 levels) in <top (required)>'"
      \z}x.freeze

      # @return [Regexp] the pattern that matches JRuby Java stack frames, such
      #  as org.jruby.ast.NewlineNode.interpret(NewlineNode.java:105)
      JAVA = %r{\A
        (?<function>.+)  # Matches 'org.jruby.ast.NewlineNode.interpret'
        \(
          (?<file>
            (?:uri:classloader:/.+(?=:)) # Matches '/META-INF/jruby.home/protocol.rb'
            |
            (?:uri_3a_classloader_3a_.+(?=:)) # Matches 'uri_3a_classloader_3a_/gems/...'
            |
            [^:]+        # Matches 'NewlineNode.java'
          )
          :?
          (?<line>\d+)?  # Matches '105'
        \)
      \z}x.freeze

      # @return [Regexp] the pattern that tries to assume what a generic stack
      #   frame might look like, when exception's backtrace is set manually.
      GENERIC = %r{\A
        (?:from\s)?
        (?<file>.+)              # Matches '/foo/bar/baz.ext'
        :
        (?<line>\d+)?            # Matches '43' or nothing
        (?:
          in\s`(?<function>.+)'  # Matches "in `func'"
        |
          :in\s(?<function>.+)   # Matches ":in func"
        )?                       # ... or nothing
      \z}x.freeze

      # @return [Regexp] the pattern that matches exceptions from PL/SQL such as
      #   ORA-06512: at "STORE.LI_LICENSES_PACK", line 1945
      # @note This is raised by https://github.com/kubo/ruby-oci8
      OCI = /\A
        (?:
          ORA-\d{5}
          :\sat\s
          (?:"(?<function>.+)",\s)?
          line\s(?<line>\d+)
        |
          #{GENERIC}
        )
      \z/x.freeze

      # @return [Regexp] the pattern that matches CoffeeScript backtraces
      #   usually coming from Rails & ExecJS
      EXECJS = /\A
        (?:
          # Matches 'compile ((execjs):6692:19)'
          (?<function>.+)\s\((?<file>.+):(?<line>\d+):\d+\)
        |
          # Matches 'bootstrap_node.js:467:3'
          (?<file>.+):(?<line>\d+):\d+(?<function>)
        |
          # Matches the Ruby part of the backtrace
          #{RUBY}
        )
      \z/x.freeze
    end

    # @return [Integer] how many first frames should include code hunks
    CODE_FRAME_LIMIT = 10

    # Parses an exception's backtrace.
    #
    # @param [Exception] exception The exception, which contains a backtrace to
    #   parse
    # @return [Array<Hash{Symbol=>String,Integer}>] the parsed backtrace
    def self.parse(exception)
      return [] if exception.backtrace.nil? || exception.backtrace.none?

      parse_backtrace(exception)
    end

    # Checks whether the given exception was generated by JRuby's VM.
    #
    # @param [Exception] exception
    # @return [Boolean]
    def self.java_exception?(exception)
      if defined?(Java::JavaLang::Throwable) &&
         exception.is_a?(Java::JavaLang::Throwable)
        return true
      end

      return false unless exception.respond_to?(:backtrace)

      (Patterns::JAVA =~ exception.backtrace.first) != nil
    end

    class << self
      include Loggable

      private

      def best_regexp_for(exception)
        if java_exception?(exception)
          Patterns::JAVA
        elsif oci_exception?(exception)
          Patterns::OCI
        elsif execjs_exception?(exception)
          Patterns::EXECJS
        else
          Patterns::RUBY
        end
      end

      def oci_exception?(exception)
        defined?(OCIError) && exception.is_a?(OCIError)
      end

      def execjs_exception?(exception)
        return false unless defined?(ExecJS::RuntimeError)
        return true if exception.is_a?(ExecJS::RuntimeError)
        return true if exception.cause && exception.cause.is_a?(ExecJS::RuntimeError)

        false
      end

      def stack_frame(regexp, stackframe)
        if (match = match_frame(regexp, stackframe))
          return {
            file: match[:file],
            line: (Integer(match[:line]) if match[:line]),
            function: match[:function],
          }
        end

        logger.error(
          "can't parse '#{stackframe}' (please file an issue so we can fix " \
          "it: https://github.com/airbrake/airbrake-ruby/issues/new)",
        )
        { file: nil, line: nil, function: stackframe }
      end

      def match_frame(regexp, stackframe)
        match = regexp.match(stackframe)
        return match if match

        Patterns::GENERIC.match(stackframe)
      end

      def parse_backtrace(exception)
        regexp = best_regexp_for(exception)
        root_directory = Airbrake::Config.instance.root_directory.to_s

        exception.backtrace.map.with_index do |stackframe, i|
          frame = stack_frame(regexp, stackframe)
          next(frame) if !Airbrake::Config.instance.code_hunks || frame[:file].nil?

          if !root_directory.empty?
            populate_code(frame) if frame_in_root?(frame, root_directory)
          elsif i < CODE_FRAME_LIMIT
            populate_code(frame)
          end

          frame
        end
      end

      def populate_code(frame)
        code = Airbrake::CodeHunk.new.get(frame[:file], frame[:line])
        frame[:code] = code if code
      end

      def frame_in_root?(frame, root_directory)
        frame[:file].start_with?(root_directory) && frame[:file] !~ %r{vendor/bundle}
      end
    end
  end
end
