# Copyright (c) 2015 Sqreen. All Rights Reserved.
# Please refer to our terms for more information: https://www.sqreen.io/terms.html

require 'sqreen/log'
require 'sqreen/serializer'
require 'sqreen/runtime_infos'
require 'sqreen/events/remote_exception'
require 'sqreen/events/attack'
require 'sqreen/events/request_record'
require 'sqreen/exception'
require 'sqreen/safe_json'

require 'net/https'
require 'uri'
require 'openssl'
require 'zlib'

# $ curl -H"x-api-key: ${KEY}"  http://127.0.0.1:5000/sqreen/v0/app-login
# {
#       "session_id": "c9171007c27d4da8906312ff343ed41307f65b2f6fdf4a05a445bb7016186657",
#       "status": true
# }
#
# $ curl -H"x-session-key: ${SESS}" http://127.0.0.1:5000/sqreen/v0/get-rulespack

#
# FIXME: we should be proxy capable
# FIXME: we should be multithread aware (when callbacks perform server requests?)
#

module Sqreen
  class Session
    RETRY_CONNECT_SECONDS = 10
    RETRY_REQUEST_SECONDS = 10

    MAX_DELAY = 60 * 30

    RETRY_LONG = 128

    MUTEX = Mutex.new
    METRICS_KEY = 'metrics'.freeze

    @@path_prefix = '/sqreen/v0/'

    attr_accessor :request_compression

    def initialize(server_url, token, app_name = nil)
      @token = token
      @app_name = app_name
      @session_id = nil
      @server_url = server_url
      @request_compression = false
      @connected = nil
      @con = nil

      uri = parse_uri(server_url)
      use_ssl = (uri.scheme == 'https')

      @req_nb = 0

      @http = Net::HTTP.new(uri.host, uri.port)
      @http.use_ssl = use_ssl
      if use_ssl
        cert_file = File.join(File.dirname(__FILE__), 'ca.crt')
        cert_store = OpenSSL::X509::Store.new
        cert_store.add_file cert_file
        @http.cert_store = cert_store
      end
    end

    def parse_uri(uri)
      # This regexp is the Ruby constant URI::PATTERN::HOSTNAME augmented
      # with the _ character that is frequent in Docker linked containers.
      re = '(?:(?:[a-zA-Z\\d](?:[-_a-zA-Z\\d]*[a-zA-Z\\d])?)\\.)*(?:[a-zA-Z](?:[-_a-zA-Z\\d]*[a-zA-Z\\d])?)\\.?'
      parser = URI::Parser.new :HOSTNAME => re
      parser.parse(uri)
    end

    def prefix_path(path)
      return '/sqreen/v1/' + path if path == 'app-login' || path == 'app-beat'
      @@path_prefix + path
    end

    def connected?
      @con && @con.started?
    end

    def disconnect
      @http.finish if connected?
    end

    NET_ERRORS = [Timeout::Error,
                  Errno::EINVAL,
                  Errno::ECONNRESET,
                  Errno::ECONNREFUSED,
                  EOFError,
                  Net::HTTPBadResponse,
                  Net::HTTPHeaderSyntaxError,
                  SocketError,
                  Net::ProtocolError].freeze

    def connect
      return if connected?
      Sqreen.log.warn "connection to #{@server_url}..."
      @session_id = nil
      @conn_retry = 0
      begin
        @con = @http.start
      rescue *NET_ERRORS
        Sqreen.log.debug "Cannot connect, retry in #{RETRY_CONNECT_SECONDS} seconds"
        sleep RETRY_CONNECT_SECONDS
        @conn_retry += 1
        retry
      else
        Sqreen.log.warn 'connection success.'
      end
    end

    def resilient_post(path, data, headers = {})
      post(path, data, headers, RETRY_LONG)
    end

    def resilient_get(path, headers = {})
      get(path, headers, RETRY_LONG)
    end

    def post(path, data, headers = {}, max_retry = 2)
      do_http_request(:POST, path, data, headers, max_retry)
    end

    def get(path, headers = {}, max_retry = 2)
      do_http_request(:GET, path, nil, headers, max_retry)
    end

    def resiliently(retry_request_seconds, max_retry, current_retry = 0)
      return yield
    rescue => e
      Sqreen.log.debug { e.inspect }

      current_retry += 1

      raise e if current_retry >= max_retry || e.is_a?(Sqreen::NotImplementedYet)

      sleep_delay = [MAX_DELAY, retry_request_seconds * current_retry].min
      Sqreen.log.debug format('Sleeping %ds', sleep_delay)
      sleep(sleep_delay)

      retry
    end

    def thread_id
      th = Thread.current
      return '' unless th
      re = th.to_s.scan(/:(0x.*)>/)
      return '' unless re && !re.empty?
      res = re[0]
      return '' unless res && !res.empty?
      res[0]
    end

    def do_http_request(method, path, data, headers = {}, max_retry = 2)
      connect unless connected?
      now = Time.now.utc
      headers['X-Session-Key'] = @session_id if @session_id
      headers['X-Sqreen-Time'] = now.to_f.to_s
      headers['User-Agent'] = "sqreen-ruby/#{Sqreen::VERSION}"
      headers['X-Sqreen-Beta'] = format('pid=%d;tid=%s;nb=%d;t=%f',
                                        Process.pid,
                                        thread_id,
                                        @req_nb,
                                        Time.now.utc.to_f)
      headers['Content-Type'] = 'application/json'
      if request_compression && !method.casecmp(:GET).zero?
        headers['Content-Encoding'] = 'gzip'
      end

      @req_nb += 1

      path = prefix_path(path)
      Sqreen.log.debug format('%s %s (%s)', method, path, @token)

      res = {}
      resiliently(RETRY_REQUEST_SECONDS, max_retry) do
        json = nil
        MUTEX.synchronize do
          json = case method.upcase
                 when :GET
                   @con.get(path, headers)
                 when :POST
                   json_data = nil
                   unless data.nil?
                     serialized = Serializer.serialize(data)
                     json_data = compress(SafeJSON.dump(serialized))
                   end
                   @con.post(path, json_data, headers)
                 else
                   Sqreen.log.debug format('unknown method %s', method)
                   raise Sqreen::NotImplementedYet
                 end
        end

        if json && json.body
          res = JSON.parse(json.body)
          unless res['status']
            Sqreen.log.debug(format('Cannot %s %s.', method, path))
          end
        else
          Sqreen.log.debug 'warning: empty return value'
        end
      end
      Sqreen.log.debug format('%s %s (DONE in %f ms)', method, path, (Time.now.utc - now) * 1000)
      res
    end

    def compress(data)
      return data unless request_compression
      out = StringIO.new
      w = Zlib::GzipWriter.new(out)
      w.write(data)
      w.close
      out.string
    end

    def login(framework)
      headers = {
        'x-api-key'  => @token,
        'x-app-name' => @app_name || framework.application_name,
      }.reject { |k, v| v ==  nil }

      Sqreen.log.warn "Using app name: #{headers['x-app-name']}"

      res = resilient_post('app-login', RuntimeInfos.all(framework), headers)

      if !res || !res['status']
        public_error = format('Cannot login. Token may be invalid: %s', @token)
        Sqreen.log.error public_error
        raise(Sqreen::TokenInvalidException,
              format('invalid response: %s', res.inspect))
      end
      Sqreen.log.info 'Login success.'
      @session_id = res['session_id']
      Sqreen.log.debug "received session_id #{@session_id}"
      Sqreen.logged_in = true
      res
    end

    def rules
      resilient_get('rulespack')
    end

    def heartbeat(cmd_res = {}, metrics = [])
      payload = {}
      payload['metrics'] = metrics unless metrics.nil? || metrics.empty?
      payload['command_results'] = cmd_res unless cmd_res.nil? || cmd_res.empty?

      post('app-beat', payload.empty? ? nil : payload, {}, 5)
    end

    def post_metrics(metrics)
      return if metrics.nil? || metrics.empty?
      payload = { METRICS_KEY => metrics }
      resilient_post(METRICS_KEY, payload)
    end

    def post_attack(attack)
      resilient_post('attack', attack.to_hash)
    end

    def post_bundle(bundle_sig, dependencies)
      resilient_post('bundle', 'bundle_signature' => bundle_sig,
                               'dependencies' => dependencies)
    end

    def get_actionspack
      resilient_get('actionspack')
    end

    def post_request_record(request_record)
      resilient_post('request_record', request_record.to_hash)
    end

    # Post an exception to Sqreen for analysis
    # @param exception [RemoteException] Exception and context to be sent over
    def post_sqreen_exception(exception)
      post('sqreen_exception', exception.to_hash, {}, 5)
    rescue *NET_ERRORS => e
      Sqreen.log.warn(format('Could not post exception (network down? %s) %s',
                             e.inspect,
                             exception.to_hash.inspect))
      nil
    end

    BATCH_KEY = 'batch'.freeze
    EVENT_TYPE_KEY = 'event_type'.freeze
    def post_batch(events)
      batch = events.map do |event|
        h = event.to_hash
        h[EVENT_TYPE_KEY] = event_kind(event)
        h
      end
      Sqreen.log.debug do
        tally = Hash[events.group_by(&:class).map{ |k,v| [k, v.count] }]
        "Doing batch with the following tally of event types: #{tally}"
      end
      resilient_post(BATCH_KEY, BATCH_KEY => batch)
    end

    # Perform agent logout
    # @param retrying [Boolean] whether to try again on error
    def logout(retrying = true)
      # Do not try to connect if we are not connected
      unless connected?
        Sqreen.log.debug('Not connected: not trying to logout')
        return
      end
      # Perform not very resilient logout not to slow down client app shutdown
      get('app-logout', {}, retrying ? 2 : 1)
      Sqreen.logged_in = false
      disconnect
    end

    protected

    def event_kind(event)
      case event
      when Sqreen::RemoteException then 'sqreen_exception'
      when Sqreen::Attack then 'attack'
      when Sqreen::RequestRecord then 'request_record'
      end
    end
  end
end