# Copyright 2016 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


require "stackdriver/core"

module Google
  module Cloud
    module Logging
      class Middleware
        ##
        # The default log name used to instantiate the default logger if one
        # isn't provided.
        DEFAULT_LOG_NAME = "ruby_app_log".freeze

        ##
        # A default value for the log_name_map argument. Directs health check
        # logs to a separate log name so they don't spam the main log.
        DEFAULT_LOG_NAME_MAP =
          { "/_ah/health" => "ruby_health_check_log",
            "/healthz"    => "ruby_health_check_log" }.freeze

        ##
        # The Google::Cloud::Logging::Logger instance
        attr_reader :logger

        ##
        # Create a new AppEngine logging Middleware.
        #
        # @param [Rack Application] app Rack application
        # @param [Google::Cloud::Logging::Logger] logger A logger to be used by
        #   this middleware. The middleware will be interacting with the logger
        #   to track Stackdriver request trace ID. It also properly sets
        #   env["rack.logger"] to this assigned logger for accessing. If not
        #   specified, a default logger with be used.
        # @param [Proc] on_init A callback to be invoked when the middleware is
        #   initialized. The callback takes no arguments. Optional.
        # @param [Hash] kwargs Hash of configuration settings. Used for
        #   backward API compatibility. See the [Configuration
        #   Guide](https://googleapis.dev/ruby/stackdriver/latest/file.INSTRUMENTATION_CONFIGURATION.html)
        #   for the prefered way to set configuration parameters.
        #
        # @return [Google::Cloud::Logging::Middleware] A new
        #   Google::Cloud::Logging::Middleware instance
        #
        def initialize app, logger: nil, on_init: nil, **kwargs
          @app = app

          load_config(**kwargs)

          logger ||= Middleware.logger
          logger ||= begin
            log_name = configuration.log_name
            logging = Logging.new project_id:  configuration.project_id,
                                  credentials: configuration.credentials
            resource = Middleware.build_monitored_resource(
              configuration.monitored_resource.type,
              configuration.monitored_resource.labels
            )
            Middleware.logger = logging.logger log_name, resource
          end

          on_init&.call

          @logger = logger
        end

        ##
        # Rack middleware entry point. In most Rack based frameworks, a request
        # is served by one thread. So entry point, we associate the GCP request
        # trace_id with the current thread's object_id in logger. All the logs
        # written by logger beyond this point will carry this request's
        # trace_id. Untrack the trace_id with this thread upon exiting.
        #
        # @param [Hash] env Rack environment hash
        #
        # @return [Rack::Response] The response from downstream Rack app
        #
        def call env
          env["rack.logger"] = logger
          trace_id = get_trace_id env
          log_name = get_log_name env
          logger.add_request_info trace_id: trace_id, log_name: log_name,
                                  env: env
          begin
            @app.call env
          ensure
            logger.delete_request_info
          end
        end

        ##
        # Determine the trace ID for this request.
        #
        # @private
        # @param [Hash] env The Rack environment.
        # @return [String] The trace ID.
        #
        def get_trace_id env
          trace_context = Stackdriver::Core::TraceContext.parse_rack_env env
          trace_context.trace_id
        end

        ##
        # Determine the log name override for this request, if any.
        #
        # @private
        # @param [Hash] env The Rack environment.
        # @return [String, nil] The log name override, or `nil` if there is
        #     no override.
        #
        def get_log_name env
          log_name_map = configuration.log_name_map
          return nil unless log_name_map
          path = "#{env['SCRIPT_NAME']}#{env['PATH_INFO']}"
          path = "/#{path}" unless path.start_with? "/"
          log_name_map.each do |k, v|
            return v if k === path
          end
          nil
        end

        ##
        # Construct a monitored resource based on the given type and label if
        # both are provided. Otherwise, construct a default monitored resource
        # based on the current environment.
        #
        # @param [String] type Type of Google::Cloud::Logging::Resource
        # @param [Hash<String, String>] labels Metadata lebels of
        #   Google::Cloud::Logging::Resource
        #
        # @return [Google::Cloud::Logging::Resource] An Resource object with
        #   type and labels
        #
        # @see https://cloud.google.com/logging/docs/api/v2/resource-list
        #   Monitored Resources and Services
        #
        # @example If both type and labels are provided, it returns resource:
        #   rc = Google::Cloud::Logging::Middleware.build_monitored_resource(
        #          "aws_ec2_instance",
        #          {
        #            instance_id: "ec2-id",
        #            aws_account: "aws-id"
        #          }
        #        )
        #   rc.type   #=> "aws_ec2_instance"
        #   rc.labels #=> { instance_id: "ec2-id", aws_account: "aws-id" }
        #
        # @example If running from GAE, returns default resource:
        #   rc = Google::Cloud::Logging::Middleware.build_monitored_resource
        #   rc.type   #=> "gae_app"
        #   rc.labels # { module_id: [GAE module name],
        #             #   version_id: [GAE module version] }
        #
        # @example If running from GKE, returns default resource:
        #   rc = Google::Cloud::Logging::Middleware.build_monitored_resource
        #   rc.type   #=> "container"
        #   rc.labels # { cluster_name: [GKE cluster name],
        #             #   namespace_id: [GKE namespace_id] }
        #
        # @example If running from GCE, return default resource:
        #   rc = Google::Cloud::Logging::Middleware.build_monitored_resource
        #   rc.type   #=> "gce_instance"
        #   rc.labels # { instance_id: [GCE VM instance id],
        #             #   zone: [GCE vm group zone] }
        #
        # @example Otherwise default to generic "global" type:
        #   rc = Google::Cloud::Logging::Middleware.build_monitored_resource
        #   rc.type   #=> "global"
        #   rc.labels #=> {}
        #
        def self.build_monitored_resource type = nil, labels = nil
          if type && labels
            Google::Cloud::Logging::Resource.new.tap do |r|
              r.type = type
              r.labels = labels
            end
          else
            default_monitored_resource
          end
        end

        class << self
          ##
          # @private Global logger object
          attr_accessor :logger
        end

        ##
        # @private Extract information from current environment and construct
        # the correct monitoring resource types and labels.
        #
        # @return [Google::Cloud::Logging::Resource] An Resource object with
        #   correct type and labels
        #
        # @see https://cloud.google.com/logging/docs/api/v2/resource-list
        #   Monitored Resources and Services
        #
        # @example If running from GAE, returns default resource:
        #   rc = Google::Cloud::Logging::Middleware.send \
        #          :default_monitored_resource
        #   rc.type   #=> "gae_app"
        #   rc.labels # { module_id: [GAE module name],
        #             #   version_id: [GAE module version] }
        #
        # @example If running from GKE, returns default resource:
        #   rc = Google::Cloud::Logging::Middleware.send \
        #          :default_monitored_resource
        #   rc.type   #=> "container"
        #   rc.labels # { cluster_name: [GKE cluster name],
        #             #   namespace_id: [GKE namespace_id] }
        #
        # @example If running from GCE, return default resource:
        #   rc = Google::Cloud::Logging::Middleware.send \
        #          :default_monitored_resource
        #   rc.type   #=> "gce_instance"
        #   rc.labels # { instance_id: [GCE VM instance id],
        #             #   zone: [GCE vm group zone] }
        #
        # @example Otherwise default to generic "global" type:
        #   rc = Google::Cloud::Logging::Middleware.send \
        #          :default_monitored_resource
        #   rc.type   #=> "global"
        #   rc.labels #=> {}
        #
        def self.default_monitored_resource
          type, labels =
            if Google::Cloud.env.app_engine?
              ["gae_app", {
                module_id:  Google::Cloud.env.app_engine_service_id,
                version_id: Google::Cloud.env.app_engine_service_version
              }]
            elsif Google::Cloud.env.container_engine?
              namespace_id = Google::Cloud.env.container_engine_namespace_id
              namespace_id ||= "default"
              ["container", {
                cluster_name: Google::Cloud.env.container_engine_cluster_name,
                namespace_id: namespace_id
              }]
            elsif Google::Cloud.env.compute_engine?
              ["gce_instance", {
                instance_id: Google::Cloud.env.instance_name,
                zone:        Google::Cloud.env.instance_zone
              }]
            else
              ["global", {}]
            end

          Google::Cloud::Logging::Resource.new.tap do |r|
            r.type = type
            r.labels = labels
          end
        end

        private_class_method :default_monitored_resource

        private

        ##
        # Consolidate configurations from various sources. Also set
        # instrumentation config parameters to default values if not set
        # already.
        #
        def load_config **kwargs
          project_id = kwargs[:project] || kwargs[:project_id]
          configuration.project_id = project_id unless project_id.nil?

          creds = kwargs[:credentials] || kwargs[:keyfile]
          configuration.credentials = creds unless creds.nil?

          log_name_map = kwargs[:log_name_map]
          configuration.log_name_map = log_name_map unless log_name_map.nil?

          init_default_config
        end

        ##
        # Fallback to default configuration values if not defined already
        def init_default_config
          configuration.project_id ||= Cloud.configure.project_id || Logging.default_project_id
          configuration.credentials ||= Cloud.configure.credentials
          configuration.log_name ||= DEFAULT_LOG_NAME
          configuration.log_name_map ||= DEFAULT_LOG_NAME_MAP
        end

        ##
        # @private Get Google::Cloud::Logging.configure
        def configuration
          Google::Cloud::Logging.configure
        end
      end
    end
  end
end
