# coding: utf-8

require 'multi_json'

module Guard
  class Jasmine

    # The Jasmine runner handles the execution of the spec through the PhantomJS binary,
    # evaluates the JSON response from the PhantomJS Script `guard_jasmine.coffee`,
    # writes the result to the console and triggers optional system notifications.
    #
    module Runner
      class << self

        # Run the supplied specs.
        #
        # @param [Array<String>] paths the spec files or directories
        # @param [Hash] options the options for the execution
        # @option options [String] :jasmine_url the url of the Jasmine test runner
        # @option options [String] :phantomjs_bin the location of the PhantomJS binary
        # @option options [Integer] :timeout the maximum time in milliseconds to wait for the spec runner to finish
        # @option options [String] :spec_dir the directory with the Jasmine specs
        # @option options [Boolean] :notification show notifications
        # @option options [Boolean] :hide_success hide success message notification
        # @option options [Integer] :max_error_notify maximum error notifications to show
        # @option options [Symbol] :specdoc options for the specdoc output, either :always, :never
        # @option options [Symbol] :console options for the console.log output, either :always, :never or :failure
        # @option options [String] :spec_dir the directory with the Jasmine specs
        # @return [Boolean, Array<String>] the status of the run and the failed files
        #
        def run(paths, options = { })
          return [false, []] if paths.empty?

          notify_start_message(paths, options)

          results = paths.inject([]) do |results, file|
            results << evaluate_response(run_jasmine_spec(file, options), file, options) if File.exist?(file)

            results
          end.compact

          [response_status_for(results), failed_paths_from(results)]
        end

        private

        # Shows a notification in the console that the runner starts.
        #
        # @param [Array<String>] paths the spec files or directories
        # @param [Hash] options the options for the execution
        # @option options [String] :spec_dir the directory with the Jasmine specs
        #
        def notify_start_message(paths, options)
          message = if paths == [options[:spec_dir]]
                      'Run all Jasmine suites'
                    else
                      "Run Jasmine suite#{ paths.size == 1 ? '' : 's' } #{ paths.join(' ') }"
                    end

          Formatter.info(message, :reset => true)
        end

        # Returns the failed spec file names.
        #
        # @param [Array<Object>] results the spec runner results
        # @return [Array<String>] the list of failed spec files
        #
        def failed_paths_from(results)
          results.map { |r| !r['passed'] ? r['file'] : nil }.compact
        end

        # Returns the response status for the given result set.
        #
        # @param [Array<Object>] results the spec runner results
        # @return [Boolean] whether it has passed or not
        #
        def response_status_for(results)
          results.none? { |r| r.has_key?('error') || !r['passed'] }
        end

        # Run the Jasmine spec by executing the PhantomJS script.
        #
        # @param [String] path the path of the spec
        # @param [Hash] options the options for the execution
        # @option options [Integer] :timeout the maximum time in milliseconds to wait for the spec runner to finish
        #
        def run_jasmine_spec(file, options)
          suite = jasmine_suite(file, options)
          Formatter.info("Run Jasmine suite at #{ suite }")
          IO.popen("#{ phantomjs_command(options) } \"#{ suite }\" #{ options[:timeout] } #{ options[:specdoc] } #{ options[:focus] } #{ options[:console] } #{ options[:errors] }")
        end

        # Get the PhantomJS binary and script to execute.
        #
        # @param [Hash] options the options for the execution
        # @option options [String] :phantomjs_bin the location of the PhantomJS binary
        # @return [String] the command
        #
        def phantomjs_command(options)
          options[:phantomjs_bin] + ' ' + phantomjs_script
        end

        # Get the Jasmine test runner URL with the appended suite name
        # that acts as the spec filter.
        #
        # @param [String] file the spec file
        # @param [Hash] options the options for the execution
        # @option options [String] :jasmine_url the url of the Jasmine test runner
        # @return [String] the Jasmine url
        #
        def jasmine_suite(file, options)
          options[:jasmine_url] + query_string_for_suite(file, options)
        end

        # Get the PhantomJS script that executes the spec and extracts
        # the result from the headless DOM.
        #
        # @return [String] the path to the PhantomJS script
        #
        def phantomjs_script
          File.expand_path(File.join(File.dirname(__FILE__), 'phantomjs', 'guard-jasmine.coffee'))
        end

        # The suite name must be extracted from the spec that
        # will be run. This is done by parsing from the head of
        # the spec file until the first `describe` function is
        # found.
        #
        # @param [String] file the spec file
        # @param [Hash] options the options for the execution
        # @option options [String] :spec_dir the directory with the Jasmine specs
        # @return [String] the suite name
        #
        def query_string_for_suite(file, options)
          return '' if file == options[:spec_dir]

          query_string = ''

          File.foreach(file) do |line|
            if line =~ /describe\s*[("']+(.*?)["')]+/
              query_string = "?spec=#{ $1 }"
              break
            end
          end

          URI.encode(query_string)
        end

        # Evaluates the JSON response that the PhantomJS script
        # writes to stdout. The results triggers further notification
        # actions.
        #
        # @param [String] output the JSON output the spec run
        # @param [String] file the file name of the spec
        # @param [Hash] options the options for the execution
        # @return [Hash] the suite result
        #
        def evaluate_response(output, file, options)
          json = output.read

          begin
            result = MultiJson.decode(json)

            if result['error']
              notify_runtime_error(result, options)
            else
              result['file'] = file
              notify_spec_result(result, options)
            end

            result

          rescue => e
            if json == ''
              Formatter.error("No response from the Jasmine runner!")
            else
              Formatter.error("Cannot decode JSON from PhantomJS runner: #{ e.message }")
              Formatter.error('Please report an issue at: https://github.com/netzpirat/guard-jasmine/issues')
              Formatter.error("JSON response: #{ json }")
            end
          ensure
            output.close
          end
        end

        # Notification when a system error happens that
        # prohibits the execution of the Jasmine spec.
        #
        # @param [Hash] the suite result
        # @param [Hash] options the options for the execution
        # @option options [Boolean] :notification show notifications
        #
        def notify_runtime_error(result, options)
          message = "An error occurred: #{ result['error'] }"
          Formatter.error(message)
          Formatter.notify(message, :title => 'Jasmine error', :image => :failed, :priority => 2) if options[:notification]
        end

        # Notification about a spec run, success or failure,
        # and some stats.
        #
        # @param [Hash] result the suite result
        # @param [Hash] options the options for the execution
        # @option options [Boolean] :notification show notifications
        # @option options [Boolean] :hide_success hide success message notification
        #
        def notify_spec_result(result, options)
          specs           = result['stats']['specs']
          failures        = result['stats']['failures']
          time            = result['stats']['time']
          specs_plural    = specs == 1    ? '' : 's'
          failures_plural = failures == 1 ? '' : 's'

          Formatter.info("\nFinished in #{ time } seconds")

          message      = "#{ specs } spec#{ specs_plural }, #{ failures } failure#{ failures_plural }"
          full_message = "#{ message }\nin #{ time } seconds"
          passed       = failures == 0

          if passed
            report_specdoc(result, passed, options)
            Formatter.success(message)
            Formatter.notify(full_message, :title => 'Jasmine suite passed') if options[:notification] && !options[:hide_success]
          else
            report_specdoc(result, passed, options)
            Formatter.error(message)
            notify_errors(result, options)
            Formatter.notify(full_message, :title => 'Jasmine suite failed', :image => :failed, :priority => 2) if options[:notification]
          end

          Formatter.info("Done.\n")
        end

        # Specdoc like formatting of the result.
        #
        # @param [Hash] result the suite result
        # @param [Boolean] passed status
        # @param [Hash] options the options
        # @option options [Symbol] :console options for the console.log output, either :always, :never or :failure
        #
        def report_specdoc(result, passed, options)
          result['suites'].each do |suite|
            report_specdoc_suite(suite, passed, options)
          end
        end

        # Show the suite result.
        #
        # @param [Hash] suite the suite
        # @param [Boolean] passed status
        # @param [Hash] options the options
        # @option options [Symbol] :console options for the console.log output, either :always, :never or :failure
        # @option options [Symbol] :focus options for focus on failures in the specdoc
        # @param [Number] level the indention level
        #
        def report_specdoc_suite(suite, passed, options, level = 0)
          # Print the suite description when the specdoc is shown or there are logs to display
          if (specdoc_shown?(passed, options) || console_logs_shown?(suite, passed, options) || error_logs_shown?(suite, passed, options))
            Formatter.suite_name((' ' * level) + suite['description']) if passed || options[:focus] && contains_failed_spec?(suite)
          end

          suite['specs'].each do |spec|
            if spec['passed']
              if passed || !options[:focus] || console_for_spec?(spec, options) || errors_for_spec?(spec, options)
                Formatter.success(indent("  ✔ #{ spec['description'] }", level)) if description_shown?(passed, spec, options)
                report_specdoc_errors(spec, options, level)
                report_specdoc_logs(spec, options, level)
              end
            else
              Formatter.spec_failed(indent("  ✘ #{ spec['description'] }", level)) if description_shown?(passed, spec, options)
              spec['messages'].each do |message|
                Formatter.spec_failed(indent("    ➤ #{ format_message(message, false) }", level)) if specdoc_shown?(passed, options)
              end
              report_specdoc_errors(spec, options, level)
              report_specdoc_logs(spec, options, level)
            end
          end

          suite['suites'].each { |suite| report_specdoc_suite(suite, passed, options, level + 2) } if suite['suites']
        end

        # Is the specdoc shown for this suite?
        def specdoc_shown?(passed, options = {})
          (options[:specdoc] == :always || (options[:specdoc] == :failure && !passed))
        end

        # Are console logs shown for this suite?
        def console_logs_shown?(suite, passed, options = {})
          # Are console messages displayed?
          console_enabled = (options[:console] == :always || (options[:console] == :failure && !passed))
          # Are there any logs to display at all for this suite?
          logs_for_current_options = suite['specs'].select do |spec|
            spec['logs'] && (options[:console] == :always || (options[:console] == :failure && !spec['passed']))
          end
          any_logs_present = (!logs_for_current_options.empty?)
          (console_enabled && any_logs_present)
        end

        # Are console logs shown for this spec?
        def console_for_spec?(spec, options = {})
          console = (spec['logs'] && ((spec['passed'] && options[:console] == :always) ||
                                      (!spec['passed'] && options[:console] != :never)))
        end

        # Are error logs shown for this suite?
        def error_logs_shown?(suite, passed, options = {})
          # Are error messages displayed?
          errors_enabled = (options[:errors] == :always || (options[:errors] == :failure && !passed))
          # Are there any errors to display at all for this suite?
          errors_for_current_options = suite['specs'].select do |spec|
            spec['errors'] && (options[:errors] == :always || (options[:errors] == :failure && !spec['passed']))
          end
          any_errors_present = (!errors_for_current_options.empty?)
          (errors_enabled && any_errors_present)
        end

        # Are errors shown for this spec?
        def errors_for_spec?(spec, options = {})
          errors = (spec['errors'] && ((spec['passed'] && options[:errors] == :always) ||
                                       (!spec['passed'] && options[:errors] != :never)))
        end

        # Is the description shown for this spec?
        def description_shown?(passed, spec, options = {})
          (specdoc_shown?(passed, options) || console_for_spec?(spec, options) || errors_for_spec?(spec, options))
        end


        # Shows the logs for a given spec.
        #
        # @param [Hash] spec the spec result
        # @param [Hash] options the options
        # @option options [Symbol] :console options for the console.log output, either :always, :never or :failure
        # @param [Number] level the indention level
        #
        def report_specdoc_logs(spec, options, level)
          if spec['logs'] && (options[:console] == :always || (options[:console] == :failure && !spec['passed']))
            spec['logs'].each do |log|
              log.split("\n").each_with_index do |message, index|
                Formatter.info(indent("    #{ index == 0 ? '•' : ' ' } #{ message }", level))
              end
            end
          end
        end

        # Shows the errors for a given spec.
        #
        # @param [Hash] spec the spec result
        # @param [Hash] options the options
        # @option options [Symbol] :errors options for the errors output, either :always, :never or :failure
        # @param [Number] level the indention level
        #
        def report_specdoc_errors(spec, options, level)
          if spec['errors'] && (options[:errors] == :always || (options[:errors] == :failure && !spec['passed']))
            spec['errors'].each do |error|
              if error['trace']
                error['trace'].each do |trace|
                  Formatter.spec_failed(indent("    ➜ Exception: #{ error['msg']  } in #{ trace['file'] } on line #{ trace['line'] }", level))
                end
              else
                Formatter.spec_failed(indent("    ➜ Exception: #{ error['msg']  }", level))
              end
            end
          end
        end

        # Indent a message.
        #
        # @param [String] message the message
        # @param [Number] level the indention level
        #
        def indent(message, level)
          (' ' * level) + message
        end

        # Show system notifications about the occurred errors.
        #
        # @param [Hash] result the suite result
        # @param [Hash] options the options
        # @option options [Integer] :max_error_notify maximum error notifications to show
        # @option options [Boolean] :notification show notifications
        #
        def notify_errors(result, options)
          collect_specs(result['suites']).each_with_index do |spec, index|
            if !spec['passed'] && options[:max_error_notify] > index
              msg = spec['messages'].map { |message| format_message(message, true) }.join(', ')
              Formatter.notify("#{ spec['description'] }: #{ msg }",
                               :title    => 'Jasmine spec failed',
                               :image    => :failed,
                               :priority => 2) if options[:notification]
            end
          end
        end

        # Tests if the given suite has a failing spec underneath.
        #
        # @param [Hash] suite the suite result
        # @return [Boolean] the search result
        #
        def contains_failed_spec?(suite)
          collect_specs([suite]).any? { |spec| !spec['passed'] }
        end

        # Get all specs from the suites and its nested suites.
        #
        # @param suites [Array<Hash>] the suites results
        # @param [Array<Hash>] all specs
        #
        def collect_specs(suites)
          suites.inject([]) do |specs, suite|
            specs = (specs | suite['specs']) if suite['specs']
            specs = (specs | collect_specs(suite['suites'])) if suite['suites']
            specs
          end
        end

        # Formats a message.
        #
        # @param [String] message the error message
        # @param [Boolean] short show a short version of the message
        # @return [String] the cleaned error message
        #
        def format_message(message, short)
          if message =~ /(.*?) in http.+?assets\/(.*)\?body=\d+\s\((line\s\d+)/
            short ? $1 : "#{ $1 } in #{ $2 } on #{ $3 }"
          else
            message
          end
        end

      end
    end
  end
end