# frozen_string_literal: true

require 'tegimen/delegator'
require 'tegimen/logging'
require 'tegimen/s3'

require 'hashie'
require 'json'
require 'neatjson'
require 'rest-client'
require 'time'
require 'uri'

module Tegimen
  class Test
    extend Tegimen::Delegator
    extend Tegimen::Logging

    attr_reader :definition, :state, :suite

    delegate_to proc { Tegimen }, :s3_client, singleton: false

    class State < Hashie::Dash
      include Hashie::Extensions::Dash::PropertyTranslation
      include Hashie::Extensions::Dash::IndifferentAccess

      property :id
      property :status, from: :state
      property :created_at, default: -> { Time.now }, transform_with: ->(value) { value.is_a?(Time) ? value : Time.parse(value) }
      property :updated_at, default: -> { Time.now }, transform_with: ->(value) { value.is_a?(Time) ? value : Time.parse(value) }
      property :completed_at, transform_with: ->(value) { value.is_a?(Time) ? value : Time.parse(value) }
      property :failures, default: -> { Hash.new(0) }, transform_with: ->(value) { Hash.new(0).merge(value) }

      def duration
        (completed_at || Time.now) - created_at
      end
    end

    def initialize(suite, definition: nil, s3_obj: nil)
      raise 'Tests require at either a `definition` or `s3_obj` when initialized.' unless definition.nil? ^ s3_obj.nil?

      @suite = suite
      @state = s3_obj.nil? ? State.new(status: :new) : read(s3_obj: s3_obj, as: :state)
      @definition = definition || read(filename: 'definition.json')

      warn "Unable to populate state from #{s3_obj.key}" if @state.nil?
      warn "Unable to populate definition for #{s3_obj.key}" if @definition.nil?
    end

    # Entry Points

    def check
      return debug 'Skipping check step as state is missing.' if state.nil?

      info "Checking for progress of test #{state.id}. Last status was #{state.status}..."
      indent_logs

      # :instrumenting -> :collecting_data -> :creating_video -> :saving_video -> :finalizing -> :completed
      case state.status.to_sym
        when :instrumenting, :submitted
          cycle(filename: 'results.json', route: 'jsonResult.php', test: state.id) { update_state status: :collecting_data }
        when :collecting_data
          cycle(filename: 'video_create.json', route: '/video/create.php', f: :json, id: state.id, tests: "#{state.id}-c:0-i:0") { update_state status: :creating_video }
        when :creating_video
          cycle(filename: 'video.json', route: 'video/view.php', f: :json, id: state.id) { update_state status: :saving_video }
        when :saving_video
          save_video
        when :finalizing, :data_complete
          finalize(storage_folder)
        when :completed, :failed
          update_state completed_at: Time.now if state.completed_at.nil? # was missed in earlier code, so clean up bad state
          info "This test is marked as #{state.status}. It took #{state.duration} seconds to finish."
          finalize(state.status.to_sym == :completed ? storage_folder : dead_letter_folder)
        else
          warn "Test is in unhandled status `#{state.status}`. Skipping..."
      end

      dedent_logs
    end

    def start
      info "Queuing #{definition.url.max_length(47, always_trail: true)}"

      params = {
        f: 'json', # format of the response
        fvonly: 1, # skip the repeat view test
        video: 1, # capture video
        mv: 1, # only store video from the median run
        web10: 1 # stop collecting after onLoad
      }

      # gather at least this many seconds of data
      params.update time: definition.sample_length || suite.definition.sample_length || 20

      location = "#{definition.location}:#{definition.browser}"
      location = "#{location}.#{suite.definition.connectivity}" if suite.definition.connectivity?

      params.update runs: suite.definition.samples, location: location
      params.update url: definition.url, k: suite.api_key, script: script
      params.update mobile: 1, mobileDevice: definition.mobile_browser if definition.mobile_browser?
      params.update fullsizevideo: 1, pngs: 1 # will this do anything?!?!
      params.compact!

      response = request 'runtest.php', params

      if response['statusCode'] != 200
        warn 'WPT returned non-200 status code in json response. Skipping...'
        debug response.to_json
        return
      end

      indent_logs
      info 'Successfully queued. Persisting state...'

      update_state commit: false, id: response['data']['testId']

      write 'definition.json', definition.dup.update(test_params: escape_params(params, as: :hash))
      write 'submitted.json', response

      update_state status: :instrumenting
      dedent_logs
    end

    # Bespoke Status Steps
    def save_video
      info 'Downloading video...'
      response = request 'video/download.php', id: state.id

      if response.is_a?(Hash) || response.include?('<h1>The video requested does not exist.</h1>')
        warn 'Video download has failed unexpectedly!'
        update_state status: :failed, completed_at: Time.now
        return
      end

      info 'Uploading video to s3...'
      write 'video.mp4', response, content_type: 'video/mp4'
      update_state status: :finalizing
    end

    def finalize(target_folder)
      info 'Finalizing test...'
      private_files = ['state.json', 'definition.json']

      s3_client.objects(prefix: state_folder).each do |obj|
        next if private_files.include?(File.basename(obj.key))

        target = s3_client.object(File.join(target_folder, File.basename(obj.key)))
        debug "Moving #{obj.key} to #{target.key}..."

        obj.move_to target, acl: 'public-read'
      end

      private_files.each do |name|
        obj = s3_client.object(File.join(state_folder, name))
        target = s3_client.object(File.join(target_folder, name))

        debug "Moving #{obj.key} to #{target.key}..."
        obj.move_to(target)
      end
    end

    # API Helpers
    def cycle(filename:, route:, **params)
      response = request route, params

      case response['statusCode']
        when 100..199
          if state.created_at < (Time.now.utc - 86_400 * 14)
            debug "Test created at #{state.created_at} has stalled on #{state.status}. Marking as failed."
            update_state status: :failed, completed_at: Time.now
          else
            debug "Waiting on #{state.status} to complete. Created at #{state.created_at}. Last status change at #{state.updated_at}..."
          end
        when 200
          info "Celebrate! For #{state.status} has completed. Moving the status forward."
          write filename, response
          yield if block_given?
        when 400..499
          warn "Test has failed with a #{response['statusCode'].inspect}. Saving response and marking as failed..."
          write 'failure.json', response
          update_state status: :failed, completed_at: Time.now
        when nil
          if state.failures[state.status]  > 10
            warn "Test has failed after attempting to handle #{state.status} #{state.failures[state.status]} times. Marking as failed."
            update_state status: :failed, completed_at: Time.now
          else
            warn 'Test step has failed with a nonspecific error. Will retry this step up to 10 times.'
            state.failures[state.status] += 1
            update_state
          end
        else
          warn "Received unexpected status #{response['statusCode'].inspect} while checking #{state.id}."
      end
    end

    def request(route, **params)
      url = URI.join(suite.definition.server, route).to_s

      debug "URL: #{url}"
      debug "QueryString: #{escape_params(params)}"

      begin
        response = RestClient.get url, params: params
      rescue RestClient::ExceptionWithResponse, SocketError => err
        warn "Error accessing #{url}! Skipping..."
        debug err
        return {}
      end

      if response.code != 200
        warn "Received a non 200 status code for #{url}. Skipping..."
        debug response.body
        return {}
      end

      begin
        case response.headers[:content_type]
          when %r{application/json}
            JSON.parse(response.body)
          else
            response.body
        end
      rescue TypeError, JSON::ParserError => err
        warn "Unable to parse response from #{url}. Skipping..."
        debug response.body.inspect
        debug err
        return {}
      end
    end

    # State Helpers
    def update_state(commit: true, **values)
      # doing it this way because Dashes don't handle update properly.
      values.each { |key, value| state[key] = value }

      # noinspection RubyResolve
      state.updated_at = Time.now
      write 'state.json', state if commit
    end

    # S3 Helpers
    def state_folder
      # @state_folder ||= File.join('/', 'tegimen-state', suite.name.sanitize, definition['url'].sanitize, location.sanitize, id.sanitize).downcase
      @state_folder ||= File.join(suite.state_folder, state.id.sanitize, '/')
    end

    def dead_letter_folder
      @dead_letter_folder || File.join(suite.dead_letter_folder, state.id.sanitize, '/')
    end

    def storage_folder
      return @storage_folder if instance_variable_defined?(:@storage_folder)

      parts = [
        definition.experience,
        definition.platform,
        definition.location,
        definition.mobile_browser || definition.browser,
      ]

      parts = [
        suite.storage_folder,
        parts.collect(&:to_s).collect(&:sanitize).collect(&:downcase),
        state.id.sanitize
      ]

      @storage_folder ||= File.join(parts, '/')
    end

    def read(filename: nil, s3_obj: nil, as: :mash)
      raise 'Reading from s3 requires either a `filename` OR `s3_obj`.' unless filename.nil? ^ s3_obj.nil?
      s3_obj = s3_client.object(File.join(state_folder, filename)) if s3_obj.nil?

      debug "Reading #{s3_obj.key} from s3..."

      body = s3_obj.get.body.read
      case as
        when :hash
          JSON.parse(body)
        when :mash
          Mash.new(JSON.parse(body))
        when :state
          State.new(JSON.parse(body))
        else
          warn "Read does not know how to coerce #{s3_obj.key} into a #{as.name}"
      end
    rescue Aws::S3::Errors::NoSuchKey
      nil
    end

    def write(filename, output, content_type: 'application/json')
      key = File.join(state_folder, filename)
      body = content_type == 'application/json' ? JSON.neat_generate(output, wrap: true, padding: 1, after_colon: 1, sort: ->(sort_key) { sort_key.to_s }) : output

      debug "Writing #{body.length} characters to #{key}"

      s3_client.object(key).put(body: body, content_type: content_type)
    end

    # Helpers
    def escape_params(params, as: :string)
      case as
        when :string
          RestClient::Utils.encode_query_string(params).sub(suite.api_key, '<REDACTED>')
        when :hash
          params.key?(:k) ? params.dup.update(k: '<REDACTED>') : params
        else
          warn "Unable to escape_params as a `#{as}`."
      end
    end

    def script
      return @script if instance_variable_defined? :@script
      parts = []

      definition.cookies&.each { |cookie| parts << "setCookie\t#{cookie['path']}\t#{cookie['value']}" }
      definition.local_storage&.each { |key, value| parts << "exec\twindow.localStorage.set('#{key}', '#{value}');" }

      parts << "navigate\t#{definition.url}" # Always goes last

      @script = parts.length > 1 ? "#{parts.join("\r\n")}\r\n" : nil # requires these line endings, even on the last one.
    end
  end
end
