# frozen_string_literal: true

require 'capybara/poltergeist/utility'
require 'cliver'

module Capybara::Poltergeist
  class Client
    PHANTOMJS_SCRIPT  = File.expand_path('client/compiled/main.js', __dir__)
    PHANTOMJS_VERSION = ['>= 1.8.1', '< 3.0'].freeze
    PHANTOMJS_NAME    = 'phantomjs'

    KILL_TIMEOUT = 2 # seconds

    def self.start(*args)
      client = new(*args)
      client.start
      client
    end

    # Returns a proc, that when called will attempt to kill the given process.
    # This is because implementing ObjectSpace.define_finalizer is tricky.
    # Hat-Tip to @mperham for describing in detail:
    # http://www.mikeperham.com/2010/02/24/the-trouble-with-ruby-finalizers/
    def self.process_killer(pid)
      proc do
        begin
          if Capybara::Poltergeist.windows?
            Process.kill('KILL', pid)
          else
            Process.kill('TERM', pid)
            start = Time.now
            while Process.wait(pid, Process::WNOHANG).nil?
              sleep 0.05
              next unless (Time.now - start) > KILL_TIMEOUT
              Process.kill('KILL', pid)
              Process.wait(pid)
              break
            end
          end
        rescue Errno::ESRCH, Errno::ECHILD
          # Zed's dead, baby
        end
      end
    end

    attr_reader :pid, :server, :path, :window_size, :phantomjs_options

    def initialize(server, options = {})
      @server            = server
      @path              = Cliver.detect((options[:path] || PHANTOMJS_NAME), '>=2.1.0', '< 3.0')
      @path            ||= Cliver.detect!((options[:path] || PHANTOMJS_NAME), *PHANTOMJS_VERSION).tap do
        warn "You're running an old version of PhantomJS, update to >= 2.1.1 for a better experience."
      end

      @window_size       = options[:window_size]       || [1024, 768]
      @phantomjs_options = options[:phantomjs_options] || []
      @phantomjs_logger  = options[:phantomjs_logger]  || $stdout
    end

    def start
      @read_io, @write_io = IO.pipe
      @out_thread = Thread.new do
        while !@read_io.eof? && (data = @read_io.readpartial(1024))
          @phantomjs_logger.write(data)
        end
      end

      process_options = { in: File::NULL }
      process_options[:pgroup] = true unless Capybara::Poltergeist.windows?
      process_options[:out] = @write_io if Capybara::Poltergeist.mri?

      redirect_stdout do
        @pid = Process.spawn(*command.map(&:to_s), process_options)
        ObjectSpace.define_finalizer(self, self.class.process_killer(@pid))
      end
    end

    def stop
      return unless pid
      kill_phantomjs
      @out_thread.kill
      close_io
      ObjectSpace.undefine_finalizer(self)
    end

    def restart
      stop
      start
    end

    def command
      parts = [path]
      parts.concat phantomjs_options
      parts << PHANTOMJS_SCRIPT
      parts << server.port
      parts.concat window_size
      parts << server.host
      parts
    end

    private

    # This abomination is because JRuby doesn't support the :out option of
    # Process.spawn. To be honest it works pretty bad with pipes too, because
    # we ought close writing end in parent process immediately but JRuby will
    # lose all the output from child. Process.popen can be used here and seems
    # it works with JRuby but I've experienced strange mistakes on Rubinius.
    def redirect_stdout
      if Capybara::Poltergeist.mri?
        yield
      else
        begin
          prev = STDOUT.dup
          $stdout = @write_io
          STDOUT.reopen(@write_io)
          yield
        ensure
          STDOUT.reopen(prev)
          $stdout = STDOUT
          prev.close
        end
      end
    end

    def kill_phantomjs
      self.class.process_killer(pid).call
      @pid = nil
    end

    # We grab all the output from PhantomJS like console.log in another thread
    # and when PhantomJS crashes we try to restart it. In order to do it we stop
    # server and client and on JRuby see this error `IOError: Stream closed`.
    # It happens because JRuby tries to close pipe and it is blocked on `eof?`
    # or `readpartial` call. The error is raised in the related thread and it's
    # not actually main thread but the thread that listens to the output. That's
    # why if you put some debug code after `rescue IOError` it won't be shown.
    # In fact the main thread will continue working after the error even if we
    # don't use `rescue`. The first attempt to fix it was a try not to block on
    # IO, but looks like similar issue appers after JRuby upgrade. Perhaps the
    # only way to fix it is catching the exception what this method overall does.
    def close_io
      [@write_io, @read_io].each do |io|
        begin
          io.close unless io.closed?
        rescue IOError
          raise unless RUBY_ENGINE == 'jruby'
        end
      end
    end
  end
end
