#!/usr/bin/env ruby

# A HTTP-based API server, which provides queries to determine the status of
# entities and the checks that are reported against them.
#
# There's a matching flapjack-diner gem at https://github.com/flapjack/flapjack-diner
# which consumes data from this API.

require 'time'

require 'rack/fiber_pool'
require 'sinatra/base'

require 'flapjack/rack_logger'
require 'flapjack/redis_pool'

require 'flapjack/gateways/jsonapi/rack/json_params_parser'

require 'flapjack/gateways/jsonapi/check_methods'
require 'flapjack/gateways/jsonapi/contact_methods'
require 'flapjack/gateways/jsonapi/entity_methods'
require 'flapjack/gateways/jsonapi/medium_methods'
require 'flapjack/gateways/jsonapi/metrics_methods'
require 'flapjack/gateways/jsonapi/notification_rule_methods'
require 'flapjack/gateways/jsonapi/pagerduty_credential_methods'
require 'flapjack/gateways/jsonapi/report_methods'

module Flapjack

  module Gateways

    class JSONAPI < Sinatra::Base

      include Flapjack::Utility

      JSON_REQUEST_MIME_TYPES = ['application/vnd.api+json', 'application/json', 'application/json-patch+json']
      # http://www.iana.org/assignments/media-types/application/vnd.api+json
      JSONAPI_MEDIA_TYPE = 'application/vnd.api+json'
      # http://tools.ietf.org/html/rfc6902
      JSON_PATCH_MEDIA_TYPE = 'application/json-patch+json'

      class ContactNotFound < RuntimeError
        attr_reader :contact_id
        def initialize(contact_id)
          @contact_id = contact_id
        end
      end

      class ContactsNotFound < RuntimeError
        attr_reader :contact_ids
        def initialize(contact_ids)
          @contact_ids = contact_ids
        end
      end

      class NotificationRuleNotFound < RuntimeError
        attr_reader :notification_rule_id
        def initialize(notification_rule_id)
          @notification_rule_id = notification_rule_id
        end
      end

      class NotificationRulesNotFound < RuntimeError
        attr_reader :notification_rule_ids
        def initialize(notification_rule_ids)
          @notification_rule_ids = notification_rule_ids
        end
      end

      class EntityNotFound < RuntimeError
        attr_reader :entity
        def initialize(entity)
          @entity = entity
        end
      end

      class EntitiesNotFound < RuntimeError
        attr_reader :entity_ids
        def initialize(entity_ids)
          @entity_ids = entity_ids
        end
      end

      class EntityCheckNotFound < RuntimeError
        attr_reader :entity, :check
        def initialize(entity, check)
          @entity = entity
          @check = check
        end
      end

      class EntityChecksNotFound < RuntimeError
        attr_reader :entity_checks
        def initialize(entity_checks)
          @entity_checks = entity_checks
        end
      end

      class ResourceLocked < RuntimeError
        attr_reader :resource
        def initialize(resource)
          @resource = resource
        end
      end

      set :dump_errors, false

      set :protection, :except => :path_traversal

      @rescue_exception = Proc.new {|env, e|

        rescue_error = Proc.new {|status, exception, request_info, *msg|
          if !msg || msg.empty?
            trace = exception.backtrace.join("\n")
            msg = "#{exception.class} - #{exception.message}"
            msg_str = "#{msg}\n#{trace}"
          else
            msg_str = msg.join(", ")
          end
          case
          when status < 500
            @logger.warn "Error: #{msg_str}"
          else
            @logger.error "Error: #{msg_str}"
          end

          response_body = Flapjack.dump_json(:errors => msg)

          query_string = (request_info[:query_string].respond_to?(:length) &&
                          request_info[:query_string].length > 0) ? "?#{request_info[:query_string]}" : ""
          if @logger.debug?
            @logger.debug("Returning #{status} for #{request_info[:request_method]} " +
              "#{request_info[:path_info]}#{query_string}, body: #{response_body}")
          elsif @logger.info?
            @logger.info("Returning #{status} for #{request_info[:request_method]} " +
              "#{request_info[:path_info]}#{query_string}")
          end

          headers = if 'DELETE'.eql?(request_info[:request_method])
            # not set by default for delete, but the error structure is JSON
            {'Content-Type' => "#{JSONAPI_MEDIA_TYPE}; charset=#{Encoding.default_external}"}
          else
            {}
          end

          [status, headers, response_body]
        }

        request_info = {
          :path_info      => env['REQUEST_PATH'],
          :request_method => env['REQUEST_METHOD'],
          :query_string   => env['QUERY_STRING']
        }
        case e
        when Flapjack::Gateways::JSONAPI::ContactNotFound
          rescue_error.call(404, e, request_info, "could not find contact '#{e.contact_id}'")
        when Flapjack::Gateways::JSONAPI::ContactsNotFound
          rescue_error.call(404, e, request_info, "could not find contacts '" + e.contact_ids.join(', ') + "'")
        when Flapjack::Gateways::JSONAPI::NotificationRuleNotFound
          rescue_error.call(404, e, request_info,"could not find notification rule '#{e.notification_rule_id}'")
        when Flapjack::Gateways::JSONAPI::NotificationRulesNotFound
          rescue_error.call(404, e, request_info, "could not find notification rules '" + e.notification_rule_ids.join(', ') + "'")
        when Flapjack::Gateways::JSONAPI::EntityNotFound
          rescue_error.call(404, e, request_info, "could not find entity '#{e.entity}'")
        when Flapjack::Gateways::JSONAPI::EntitiesNotFound
          entity_ids = "'" + e.entity_ids.join("', '") + "'"
          rescue_error.call(404, e, request_info, "could not find entities: #{entity_ids}")
        when Flapjack::Gateways::JSONAPI::EntityCheckNotFound
          rescue_error.call(404, e, request_info, "could not find entity check '#{e.check}'")
        when Flapjack::Gateways::JSONAPI::EntityChecksNotFound
          checks = "'" + e.entity_checks.join("', '") + "'"
          rescue_error.call(404, e, request_info, "could not find entity checks: #{checks}")
        when Flapjack::Gateways::JSONAPI::ResourceLocked
          rescue_error.call(423, e, request_info, "unable to obtain lock for resource '#{e.resource}'")
        else
          rescue_error.call(500, e, request_info)
        end
      }
      use ::Rack::FiberPool, :size => 25, :rescue_exception => @rescue_exception

      use ::Rack::MethodOverride
      use Flapjack::Gateways::JSONAPI::Rack::JsonParamsParser

      class << self
        def start
          @redis = Flapjack::RedisPool.new(:config => @redis_config, :size => 2, :logger => @logger)

          @logger.info "starting jsonapi - class"

          if @config && @config['access_log']
            access_logger = Flapjack::AsyncLogger.new(@config['access_log'])
            use Flapjack::CommonLogger, access_logger
          end

          @base_url = @config['base_url']
          dummy_url = "http://api.example.com"
          if @base_url
            @base_url = $1 if @base_url.match(/^(.+)\/$/)
          else
            @logger.error "base_url must be a valid http or https URI (not configured), setting to dummy value (#{dummy_url})"
            # FIXME: at this point I'd like to stop this pikelet without bringing down the whole
            @base_url = dummy_url
          end
          if (@base_url =~ /^#{URI::regexp(%w(http https))}$/).nil?
            @logger.error "base_url must be a valid http or https URI (#{@base_url}), setting to dummy value (#{dummy_url})"
            # FIXME: at this point I'd like to stop this pikelet without bringing down the whole
            # flapjack process
            # For now, set a dummy value
            @base_url = dummy_url
          end
        end
      end

      def redis
        self.class.instance_variable_get('@redis')
      end

      def logger
        self.class.instance_variable_get('@logger')
      end

      def base_url
        self.class.instance_variable_get('@base_url')
      end

      before do
        input = nil
        query_string = (request.query_string.respond_to?(:length) &&
                         (request.query_string.length > 0)) ? "?#{request.query_string}" : ""
        if logger.debug?
          input = env['rack.input'].read
          logger.debug("#{request.request_method} #{request.path_info}#{query_string} Headers: #{headers.inspect}, Body: #{input}")
        elsif logger.info?
          input = env['rack.input'].read
          input_short = input.gsub(/\n/, '').gsub(/\s+/, ' ')
          logger.info("#{request.request_method} #{request.path_info}#{query_string} #{input_short[0..80]}")
        end
        env['rack.input'].rewind unless input.nil?
      end

      after do
        return if response.status == 500

        query_string = (request.query_string.respond_to?(:length) &&
                        request.query_string.length > 0) ? "?#{request.query_string}" : ""
        if logger.debug?
          body_debug = case
          when response.body.respond_to?(:each)
            response.body.each_with_index {|r, i| "body[#{i}]: #{r}"}.join(', ')
          else
            response.body.to_s
          end
          headers_debug = response.headers.to_s
          logger.debug("Returning #{response.status} for #{request.request_method} " +
            "#{request.path_info}#{query_string}, headers: #{headers_debug}, body: #{body_debug}")
        elsif logger.info?
          logger.info("Returning #{response.status} for #{request.request_method} " +
            "#{request.path_info}#{query_string}")
        end
      end

      module Helpers

        def cors_headers
          allow_headers  = %w(* Content-Type Accept AUTHORIZATION Cache-Control)
          allow_methods  = %w(GET POST PUT PATCH DELETE OPTIONS)
          expose_headers = %w(Cache-Control Content-Language Content-Type Expires Last-Modified Pragma)
          cors_headers   = {
            'Access-Control-Allow-Origin'   => '*',
            'Access-Control-Allow-Methods'  => allow_methods.join(', '),
            'Access-Control-Allow-Headers'  => allow_headers.join(', '),
            'Access-Control-Expose-Headers' => expose_headers.join(', '),
            'Access-Control-Max-Age'        => '1728000'
          }
          headers(cors_headers)
        end

        def err(status, *msg)
          msg_str = msg.join(", ")
          logger.info "Error: #{msg_str}"
          [status, {}, Flapjack.dump_json(:errors => msg)]
        end

        def charset_for_content_type(ct)
          "#{ct}; charset=#{Encoding.default_external}"
        end

        def is_json_request?
          Flapjack::Gateways::JSONAPI::JSON_REQUEST_MIME_TYPES.include?(request.content_type.split(/\s*[;,]\s*/, 2).first)
        end

        def is_jsonapi_request?
          return false if request.content_type.nil?
          'application/vnd.api+json'.eql?(request.content_type.split(/\s*[;,]\s*/, 2).first)
        end

        def is_jsonpatch_request?
          return false if request.content_type.nil?
          'application/json-patch+json'.eql?(request.content_type.split(/\s*[;,]\s*/, 2).first)
        end

        def wrapped_params(name, error_on_nil = true)
          result = params[name.to_sym]
          if result.nil?
            if error_on_nil
              logger.debug("No '#{name}' object found in the following supplied JSON:")
              logger.debug(request.body.is_a?(StringIO) ? request.body.read : request.body)
              halt err(403, "No '#{name}' object received")
            else
              result = [{}]
            end
          end
          unless result.is_a?(Array)
            halt err(403, "The received '#{name}'' object is not an Array")
          end
          result
        end

        def find_contact(contact_id)
          contact = Flapjack::Data::Contact.find_by_id(contact_id, :logger => logger, :redis => redis)
          raise Flapjack::Gateways::JSONAPI::ContactNotFound.new(contact_id) if contact.nil?
          contact
        end

        def find_rule(rule_id)
          rule = Flapjack::Data::NotificationRule.find_by_id(rule_id, :logger => logger, :redis => redis)
          raise Flapjack::Gateways::JSONAPI::NotificationRuleNotFound.new(rule_id) if rule.nil?
          rule
        end

        def find_tags(tags)
          halt err(400, "no tags given") if tags.nil? || tags.empty?
          tags
        end

        def find_entity(entity_name)
          entity = Flapjack::Data::Entity.find_by_name(entity_name, :redis => redis)
          raise Flapjack::Gateways::JSONAPI::EntityNotFound.new(entity_name) if entity.nil?
          entity
        end

        def find_entity_by_id(entity_id)
          entity = Flapjack::Data::Entity.find_by_id(entity_id, :redis => redis)
          raise Flapjack::Gateways::JSONAPI::EntityNotFound.new(entity_id) if entity.nil?
          entity
        end

        def find_entity_check(entity, check_name)
          entity_check = Flapjack::Data::EntityCheck.for_entity(entity, check_name, :redis => redis)
          raise Flapjack::Gateways::JSONAPI::EntityCheckNotFound.new(entity.name, check_name) if entity_check.nil?
          entity_check
        end

        def find_entity_check_by_name(entity_name, check_name)
          entity_check = Flapjack::Data::EntityCheck.for_entity_name(entity_name, check_name, :redis => redis)
          raise Flapjack::Gateways::JSONAPI::EntityCheckNotFound.new(entity_name, check_name) if entity_check.nil?
          entity_check
        end

        def apply_json_patch(object_path, &block)
          ops = params[:ops]

          if ops.nil? || !ops.is_a?(Array)
            halt err(400, "Invalid JSON-Patch request")
          end

          ops.each do |operation|
            linked = nil
            property = nil

            op = operation['op']
            operation['path'] =~ /\A\/#{object_path}\/0\/([^\/]+)(?:\/([^\/]+)(?:\/([^\/]+))?)?\z/
            if 'links'.eql?($1)
              linked = $2

              value = case op
              when 'add'
                operation['value']
              when 'remove'
                $3
              end
            elsif 'replace'.eql?(op)
              property = $1
              value = operation['value']
            else
              next
            end

            yield(op, property, linked, value)
          end
        end

        # NB: casts to UTC before converting to a timestamp
        def validate_and_parsetime(value)
          return unless value
          Time.iso8601(value).getutc.to_i
        rescue ArgumentError => e
          logger.error "Couldn't parse time from '#{value}'"
          nil
        end

      end

      options '*' do
        cors_headers
        204
      end

      # The following catch-all routes act as impromptu filters for their method types
      get '*' do
        content_type charset_for_content_type(JSONAPI_MEDIA_TYPE)
        cors_headers
        pass
      end

      # bare 'params' may have splat/captures for regex route, see
      # https://github.com/sinatra/sinatra/issues/453
      post '*' do
        halt(405) unless request.params.empty? || is_json_request? || is_jsonapi_request?
        content_type charset_for_content_type(JSONAPI_MEDIA_TYPE)
        cors_headers
        pass
      end

      patch '*' do
        halt(405) unless is_jsonpatch_request?
        content_type charset_for_content_type(JSONAPI_MEDIA_TYPE)
        cors_headers
        pass
      end

      delete '*' do
        cors_headers
        pass
      end

      register Flapjack::Gateways::JSONAPI::CheckMethods
      register Flapjack::Gateways::JSONAPI::ContactMethods
      register Flapjack::Gateways::JSONAPI::EntityMethods
      register Flapjack::Gateways::JSONAPI::MediumMethods
      register Flapjack::Gateways::JSONAPI::MetricsMethods
      register Flapjack::Gateways::JSONAPI::NotificationRuleMethods
      register Flapjack::Gateways::JSONAPI::PagerdutyCredentialMethods
      register Flapjack::Gateways::JSONAPI::ReportMethods

      not_found do
        err(404, "not routable")
      end

    end

  end

end