require 'thread'

# https://github.com/puppeteer/puppeteer/blob/master/lib/LifecycleWatcher.js
class Puppeteer::LifecycleWatcher
  include Puppeteer::IfPresent

  class ExpectedLifecycle
    PUPPETEER_TO_PROTOCOL_LIFECYCLE = {
      'load' => 'load',
      'domcontentloaded' => 'DOMContentLoaded',
      'networkidle0' => 'networkIdle',
      'networkidle2' => 'networkAlmostIdle',
    }

    def initialize(wait_until)
      if wait_until.is_a?(Enumerable)
        @wait_until = wait_until.map do |value|
          unless PUPPETEER_TO_PROTOCOL_LIFECYCLE.has_key?(value.to_s)
            raise ArgumentError.new("Unknown value for options.waitUntil: #{value}")
          end
          value.to_s
        end
      elsif wait_until.is_a?(String)
        unless PUPPETEER_TO_PROTOCOL_LIFECYCLE.has_key?(wait_until)
          raise ArgumentError.new("Unknown value for options.waitUntil: #{wait_until}")
        end
        @wait_until = [wait_until]
      else
        raise ArgumentError.new('wait_until should be a Array<String> or String')
      end
    end

    private def expected_lifecycle
      @expected_lifecycle ||= @wait_until.map do |value|
        PUPPETEER_TO_PROTOCOL_LIFECYCLE[value]
      end
    end

    # Check if navigation lifecycle has experienced the expected_lifecycle events.
    #
    # @param frame [Puppeteer::Frame]
    def completed?(frame)
      if expected_lifecycle.any? { |event| !frame.lifecycle_events.include?(event) }
        return false
      end
      if frame.child_frames.any? { |child| !completed?(child) }
        return false
      end
      true
    end
  end

  class FrameDetachedError < StandardError
    def initialize
      super('Navigating frame was detached')
    end
  end
  class TerminatedError < StandardError; end

  #  * @param {!Puppeteer.FrameManager} frameManager
  #  * @param {!Puppeteer.Frame} frame
  #  * @param {string|!Array<string>} waitUntil
  #  * @param {number} timeout
  def initialize(frame_manager, frame, wait_until, timeout)
    @expected_lifecycle = ExpectedLifecycle.new(wait_until)
    @frame_manager = frame_manager
    @frame = frame
    @initial_loader_id = frame.loader_id
    @timeout = timeout

    @listener_ids = {}
    @listener_ids['client'] = @frame_manager.client.add_event_listener(CDPSessionEmittedEvents::Disconnected) do
      terminate(TerminatedError.new('Navigation failed because browser has disconnected!'))
    end
    @listener_ids['frame_manager'] = [
      @frame_manager.add_event_listener(FrameManagerEmittedEvents::LifecycleEvent) do |_|
        check_lifecycle_complete
      end,
      @frame_manager.add_event_listener(FrameManagerEmittedEvents::FrameNavigatedWithinDocument, &method(:navigated_within_document)),
      @frame_manager.add_event_listener(FrameManagerEmittedEvents::FrameDetached, &method(:handle_frame_detached)),
    ]
    @listener_ids['network_manager'] = @frame_manager.network_manager.add_event_listener(NetworkManagerEmittedEvents::Request, &method(:handle_request))

    @same_document_navigation_promise = resolvable_future
    @lifecycle_promise = resolvable_future
    @new_document_navigation_promise = resolvable_future
    @termination_promise = resolvable_future
    check_lifecycle_complete
  end

  # @param [Puppeteer::HTTPRequest] request
  def handle_request(request)
    return if request.frame != @frame || !request.navigation_request?
    @navigation_request = request
  end

  # @param frame [Puppeteer::Frame]
  def handle_frame_detached(frame)
    if @frame == frame
      @termination_promise.reject(FrameDetachedError.new)
      return
    end
    check_lifecycle_complete
  end

  # @return [Puppeteer::HTTPResponse]
  def navigation_response
    if_present(@navigation_request) do |request|
      request.response
    end
  end

  # @param error [TerminatedError]
  private def terminate(error)
    @termination_promise.reject(error)
  end

  attr_reader(
    :same_document_navigation_promise,
    :new_document_navigation_promise,
    :lifecycle_promise,
  )

  def timeout_or_termination_promise
    if @timeout > 0
      future do
        Timeout.timeout(@timeout / 1000.0) do
          @termination_promise.value!
        end
      rescue Timeout::Error
        raise Puppeteer::TimeoutError.new("Navigation timeout of #{@timeout}ms exceeded")
      end
    else
      @termination_promise
    end
  end

  # @param frame [Puppeteer::Frame]
  private def navigated_within_document(frame)
    return if frame != @frame
    @has_same_document_navigation = true
    check_lifecycle_complete
  end

  private def check_lifecycle_complete
    # We expect navigation to commit.
    return unless @expected_lifecycle.completed?(@frame)
    @lifecycle_promise.fulfill(true) if @lifecycle_promise.pending?
    if @frame.loader_id == @initial_loader_id && !@has_same_document_navigation
      return
    end
    if @has_same_document_navigation && @same_document_navigation_promise.pending?
      @same_document_navigation_promise.fulfill(true)
    end
    if @frame.loader_id != @initial_loader_id && @new_document_navigation_promise.pending?
      @new_document_navigation_promise.fulfill(true)
    end
  end

  def dispose
    if_present(@listener_ids['client']) do |id|
      @frame_manager.client.remove_event_listener(id)
    end
    if_present(@listener_ids['frame_manager']) do |ids|
      @frame_manager.remove_event_listener(*ids)
    end
    if_present(@listener_ids['network_manager']) do |id|
      @frame_manager.network_manager.remove_event_listener(id)
    end
  end
end
