# frozen_string_literal: true

require "json"
require "securerandom"
require "stringio"

require "appsignal/logger"
require "appsignal/utils/stdout_and_logger_message"
require "appsignal/helpers/instrumentation"
require "appsignal/helpers/metrics"

# AppSignal for Ruby gem's main module.
#
# Provides method to control the AppSignal instrumentation and the system
# agent. Also provides direct access to instrumentation helpers (from
# {Appsignal::Helpers::Instrumentation}) and metrics helpers (from
# {Appsignal::Helpers::Metrics}) for ease of use.
module Appsignal
  class << self
    include Helpers::Instrumentation
    include Helpers::Metrics

    # The loaded AppSignal configuration.
    # Returns the current AppSignal configuration.
    #
    # Can return `nil` if no configuration has been set or automatically loaded
    # by an automatic integration or by calling {.start}.
    #
    # @example
    #   Appsignal.config
    #
    # @return [Config, nil]
    # @see configure
    # @see Config
    attr_reader :config

    # Accessor for toggle if the AppSignal C-extension is loaded.
    #
    # Can be `nil` if extension has not been loaded yet. See
    # {.extension_loaded?} for a boolean return value.
    #
    # @!visibility private
    # @return [Boolean, nil]
    # @see Extension
    # @see extension_loaded?
    attr_accessor :extension_loaded

    # @!attribute [rw] internal_logger
    #   Accessor for the internal AppSignal logger.
    #
    #   Not to be confused with our logging feature.
    #   This is part of our private internal API. Do not call this method
    #   directly.
    #
    #   If no logger has been set, it will return a "in memory logger", using
    #   {Utils::IntegrationMemoryLogger}. Once AppSignal is started (using
    #   {.start}) the contents of the "in memory logger" is written to the new
    #   logger.
    #
    #   @!visibility private
    #   @return [Utils::IntegrationLogger or Utils::IntegrationMemoryLogger]
    #   @see start
    attr_writer :internal_logger

    # Returns the error that was encountered while loading the `appsignal.rb`
    # config file.
    #
    # It does not include any error that occurred while loading the
    # `appsignal.yml` file.
    #
    # If the value is `nil`, no error was encountered or AppSignal wasn't
    # started yet.
    #
    # @return [nil, Exception]
    attr_reader :config_error
    alias config_error? config_error

    # @!visibility private
    def testing?
      false
    end

    # Start the AppSignal integration.
    #
    # Starts AppSignal with the given configuration. If no configuration is set
    # yet it will try to automatically load the configuration using the
    # environment loaded from environment variables and the currently working
    # directory.
    #
    # This is not required for the automatic integrations AppSignal offers, but
    # this is required for all non-automatic integrations and pure Ruby
    # applications. For more information, see our [integrations
    # list](https://docs.appsignal.com/ruby/integrations/) and our [Integrating
    # AppSignal](https://docs.appsignal.com/ruby/instrumentation/integrating-appsignal.html)
    # guide.
    #
    # @example
    #   Appsignal.start
    #
    # @example with custom loaded configuration
    #   Appsignal.configure(:production) do |config|
    #     config.ignore_actions = ["My action"]
    #   end
    #   Appsignal.start
    #
    # @return [void]
    # @since 0.7.0
    def start # rubocop:disable Metrics/AbcSize
      if ENV.fetch("_APPSIGNAL_DIAGNOSE", false)
        internal_logger.info("Skipping start in diagnose context")
        return
      end

      if started?
        internal_logger.warn("Ignoring call to Appsignal.start after AppSignal has started")
        return
      end

      if config_file_context?
        internal_logger.warn(
          "Ignoring call to Appsignal.start in config file context."
        )
        return
      end

      unless extension_loaded?
        internal_logger.info("Not starting AppSignal, extension is not loaded")
        return
      end

      internal_logger.debug("Loading AppSignal gem")

      _load_config!
      _start_logger

      if config.active_for_env?
        if config.valid?
          @started = true
          internal_logger.info "Starting AppSignal #{Appsignal::VERSION} " \
            "(#{$PROGRAM_NAME}, Ruby #{RUBY_VERSION}, #{RUBY_PLATFORM})"
          config.write_to_environment
          Appsignal::Extension.start
          Appsignal::Hooks.load_hooks
          Appsignal::Loaders.start

          if config[:enable_allocation_tracking] && !Appsignal::System.jruby?
            Appsignal::Extension.install_allocation_event_hook
            Appsignal::Environment.report_enabled("allocation_tracking")
          end

          Appsignal::Probes.start if config[:enable_minutely_probes]

          collect_environment_metadata
          @config.freeze
        else
          internal_logger.info("Not starting, no valid config for this environment")
        end
      else
        internal_logger.info("Not starting, not active for #{config.env}")
      end
      nil
    end

    # PRIVATE METHOD. DO NOT USE.
    #
    # @param env_param [String, nil] Used by diagnose CLI to pass through
    #   the environment CLI option value. If nil, the environment will be
    #   auto-detected.
    # @param block [Proc] Optional block to configure the config object.
    # @return [void]
    # @!visibility private
    def _load_config!(env_param = nil, &block) # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
      # Ensure it's not an empty string if it's a value
      proper_env_param = env_param&.to_s&.strip
      # Unset it if it's an empty string
      proper_env_param = nil if proper_env_param&.empty?

      context = Appsignal::Config::Context.new(
        :env => Config.determine_env(env_param),
        :root_path => Config.determine_root_path
      )
      # If there's a config/appsignal.rb file
      if context.dsl_config_file?
        if config
          # When calling `Appsignal.configure` from an app, not the
          # `config/appsignal.rb` file, with also a Ruby config file present.
          message = "The `Appsignal.configure` helper is called from within an " \
            "app while a `#{context.dsl_config_file}` file is present. " \
            "The `config/appsignal.rb` file is ignored when the " \
            "config is loaded with `Appsignal.configure` from within an app. " \
            "We recommend moving all config to the `config/appsignal.rb` file " \
            "or the `Appsignal.configure` helper in the app."
          Appsignal::Utils::StdoutAndLoggerMessage.warning(message)
        else
          # Load it when no config is present
          #
          # We don't pass the `env_var` or `context.env` here so that the
          # `Appsignal.configure` or `Appsignal.start` can figure out the
          # environment themselves if it was not explicitly set.
          # This way we prevent forcing an environment that's auto loaded on
          # the to-be-loaded config file.
          #
          # The `(proper)_env_param` is only set when it's explicitly set,
          # which means it needs to override any auto detected environment.
          load_dsl_config_file(context.dsl_config_file, proper_env_param)
        end
      else
        # Load config if no config file was found and no config is present yet
        # This will load the config/appsignal.yml file automatically
        @config ||= Config.new(context.root_path, context.env)
      end

      # Allow a block to be given to customize the config and override any
      # loaded config before it's validated.
      block.call(config) if block_given?

      # Apply any config overrides after the user config has been merged
      config&.apply_overrides

      # Skip validation if not configured as active for this environment
      return unless config.active_for_env?

      # Validate the config, if present
      config&.validate
    end

    # Stop AppSignal's agent.
    #
    # Stops the AppSignal agent. Call this before the end of your program to
    # make sure the agent is stopped as well.
    #
    # @example
    #   Appsignal.start
    #   # Run your application
    #   Appsignal.stop
    #
    # @param called_by [String] Name of the thing that requested the agent to
    #   be stopped. Will be used in the AppSignal log file.
    # @return [void]
    # @since 1.0.0
    def stop(called_by = nil)
      Thread.new do
        if called_by
          internal_logger.info("Stopping AppSignal (#{called_by})")
        else
          internal_logger.info("Stopping AppSignal")
        end
        Appsignal::Extension.stop
        Appsignal::Probes.stop
        Appsignal::CheckIn.stop
      end.join
      nil
    end

    # Configure the AppSignal Ruby gem using a DSL.
    #
    # Pass a block to the configure method to configure the Ruby gem.
    #
    # Each config option defined in our docs can be fetched, set and modified
    # via a helper method in the given block.
    #
    # After AppSignal has started using {start}, the configuration can not be
    # modified. Any calls to this helper will be ignored.
    #
    # This helper should not be used to configure multiple environments, like
    # done in the YAML file. Configure the environment you want active when the
    # application starts.
    #
    # @example Configure AppSignal for the application
    #   Appsignal.configure do |config|
    #     config.path = "/the/app/path"
    #     config.active = ENV["APP_ACTIVE"] == "true"
    #     config.push_api_key = File.read("appsignal_key.txt").chomp
    #     config.ignore_actions = ENDPOINTS.select { |e| e.public? }.map(&:name)
    #     config.request_headers << "MY_CUSTOM_HEADER"
    #   end
    #
    # @example Configure AppSignal for the application and select the environment
    #   Appsignal.configure(:production) do |config|
    #     config.active = true
    #   end
    #
    # @example Automatically detects the app environment
    #   # Tries to determine the app environment automatically from the
    #   # environment and the libraries it integrates with.
    #   ENV["RACK_ENV"] = "production"
    #
    #   Appsignal.configure do |config|
    #     config.env # => "production"
    #   end
    #
    # @example Calling configure multiple times for different environments resets the configuration
    #   Appsignal.configure(:development) do |config|
    #     config.ignore_actions = ["My action"]
    #   end
    #
    #   Appsignal.configure(:production) do |config|
    #     config.ignore_actions # => []
    #   end
    #
    # @example Load config without a block
    #   # This will require either ENV vars being set
    #   # or the config/appsignal.yml being present
    #   Appsignal.configure
    #   # Or for the environment given as an argument
    #   Appsignal.configure(:production)
    #
    # @param env_param [String, Symbol] The environment to load.
    # @param root_path [String] The path to look the `config/appsignal.yml` config file in.
    #   Defaults to the current working directory.
    # @yield [config_dsl] Gives the configuration DSL instance to the block.
    # @yieldparam config_dsl [Appsignal::Config::ConfigDSL] The configuration DSL object
    # @return [void]
    # @see config
    # @see Config
    # @see https://docs.appsignal.com/ruby/configuration.html Configuration guide
    # @see https://docs.appsignal.com/ruby/configuration/options.html Configuration options
    def configure(env_param = nil, root_path: nil)
      if Appsignal.started?
        Appsignal.internal_logger
          .warn("AppSignal is already started. Ignoring `Appsignal.configure` call.")
        return
      end

      root_path_param = root_path
      if params_match_loaded_config?(env_param, root_path_param)
        config
      else
        @config = Config.new(
          root_path_param || Config.determine_root_path,
          Config.determine_env(env_param),
          # If in the context of an `config/appsignal.rb` config file, do not
          # load the `config/appsignal.yml` file.
          # The `.rb` file is a replacement for the `.yml` file so it shouldn't
          # load both.
          :load_yaml_file => !config_file_context?
        )
      end

      # When calling `Appsignal.configure` from a Rails initializer and a YAML
      # file is present. We will not load the YAML file in the future.
      if !config_file_context? && config.yml_config_file?
        message = "The `Appsignal.configure` helper is called while a " \
          "`config/appsignal.yml` file is present. In future versions the " \
          "`config/appsignal.yml` file will be ignored when loading the " \
          "config. We recommend moving all config to the " \
          "`config/appsignal.rb` file, or the `Appsignal.configure` helper " \
          "in Rails initializer file, and remove the " \
          "`config/appsignal.yml` file."
        Appsignal::Utils::StdoutAndLoggerMessage.warning(message)
      end

      config_dsl = Appsignal::Config::ConfigDSL.new(config)
      return unless block_given?

      yield config_dsl
      config.merge_dsl_options(config_dsl.dsl_options)
      nil
    end

    # @return [void]
    def forked
      return unless active?

      Appsignal._start_logger
      internal_logger.debug("Forked process, resubscribing and restarting extension")
      Appsignal::Extension.start
      nil
    end

    # Load an AppSignal integration.
    #
    # Load one of the supported integrations via our loader system.
    # This will set config defaults and integratie with the library if
    # AppSignal is active upon start.
    #
    # @example Load Sinatra integrations
    #   # First load the integration
    #   Appsignal.load(:sinatra)
    #   # Start AppSignal
    #   Appsignal.start
    #
    # @example Load Sinatra integrations and define custom config
    #   # First load the integration
    #   Appsignal.load(:sinatra)
    #
    #   # Customize config
    #   Appsignal.configure do |config|
    #     config.ignore_actions = ["GET /ping"]
    #   end
    #
    #
    #   # Start AppSignal
    #   Appsignal.start
    #
    # @param integration_name [String, Symbol] Name of the integration to load.
    # @return [void]
    # @since 3.12.0
    def load(integration_name)
      Loaders.load(integration_name)
      nil
    end

    # @!visibility private
    def get_server_state(key)
      Appsignal::Extension.get_server_state(key)
    end

    # @!visibility private
    def in_memory_logger
      @in_memory_logger ||=
        Appsignal::Utils::IntegrationMemoryLogger.new.tap do |l|
          l.formatter = log_formatter("appsignal")
        end
    end

    # @!visibility private
    def internal_logger
      @internal_logger ||= in_memory_logger
    end

    # @!visibility private
    def log_formatter(prefix = nil)
      pre = "#{prefix}: " if prefix
      proc do |severity, datetime, _progname, msg|
        "[#{datetime.strftime("%Y-%m-%dT%H:%M:%S")} (process) " \
          "##{Process.pid}][#{severity}] #{pre}#{msg}\n"
      end
    end

    # Start the AppSignal internal logger.
    #
    # Sets the log level and sets the logger. Uses a file-based logger or the
    # STDOUT-based logger. See the `:log` configuration option.
    #
    # @!visibility private
    # @return [void]
    def _start_logger
      if config && config[:log] == "file" && config.log_file_path
        start_internal_file_logger(config.log_file_path)
      else
        start_internal_stdout_logger
      end

      internal_logger.level =
        if config
          config.log_level
        else
          Appsignal::Config::DEFAULT_LOG_LEVEL
        end
      return unless @in_memory_logger

      messages = @in_memory_logger.messages_for_level(internal_logger.level)
      internal_logger << messages.join
      @in_memory_logger = nil
    end

    # Returns if the C-extension was loaded properly.
    #
    # @return [Boolean]
    # @see Extension
    # @since 1.0.0
    def extension_loaded?
      !!extension_loaded
    end

    # Returns if {.start} has been called before with a valid config to start
    # AppSignal.
    #
    # @return [Boolean]
    # @see Extension
    # @since 3.12.0
    def started?
      defined?(@started) ? @started : false
    end

    # Returns the active state of the AppSignal integration.
    #
    # Conditions apply for AppSignal to be marked as active:
    #
    # - There is a config set on the {.config} attribute.
    # - The set config is active {Config.active?}.
    # - The AppSignal Extension is loaded {.extension_loaded?}.
    #
    # This logic is used within instrument helper such as {.instrument} so it's
    # not necessary to wrap {.instrument} calls with this method.
    #
    # @example Do this
    #   Appsignal.instrument(..) do
    #     # Do this
    #   end
    #
    # @example Don't do this
    #   if Appsignal.active?
    #     Appsignal.instrument(..) do
    #       # Don't do this
    #     end
    #   end
    #
    # @return [Boolean]
    # @since 0.2.7
    def active?
      config&.active? && extension_loaded?
    end

    # @!visibility private
    def dsl_config_file_loaded?
      defined?(@dsl_config_file_loaded) ? true : false
    end

    # Check if the AppSignal Ruby gem has started successfully.
    #
    # If it has not (yet) started or encountered an error in the
    # `config/appsignal.rb` config file during start up that prevented it from
    # starting, it will raise a {Appsignal::NotStartedError}.
    #
    # If there an error raised from the config file, it will include it as the
    # error cause of the raised error.
    #
    # @raise [Appsignal::NotStartedError]
    # @return [void]
    def check_if_started!
      return if started?

      begin
        raise config_error if config_error?
      rescue
        # Raise the NotStartedError and make the config error the error cause
        raise NotStartedError, config_error
      end

      # Raise the NotStartedError as normal
      raise NotStartedError
    end

    private

    def params_match_loaded_config?(env_param, root_path_param)
      # No config present: can't match any config
      return false unless config

      # Check if the params, if present, match the loaded config
      (env_param.nil? || config.env == env_param.to_s) &&
        (root_path_param.nil? || config.root_path == root_path_param)
    end

    # Load the `config/appsignal.rb` config file, if present.
    #
    # If the config file has already been loaded once and it's trying to be
    # loaded more than once, which should never happen, it will not do
    # anything.
    def load_dsl_config_file(path, env_param = nil)
      return if defined?(@dsl_config_file_loaded)

      begin
        ENV["_APPSIGNAL_CONFIG_FILE_CONTEXT"] = "true"
        ENV["_APPSIGNAL_CONFIG_FILE_ENV"] = env_param if env_param
        @dsl_config_file_loaded = true
        require path
      rescue => error
        @config_error = error
        message = "Not starting AppSignal because an error occurred while " \
          "loading the AppSignal config file.\n" \
          "File: #{path.inspect}\n" \
          "#{error.class.name}: #{error}"
        Kernel.warn "appsignal ERROR: #{message}"
        internal_logger.error "#{message}\n#{error.backtrace.join("\n")}"
      ensure
        unless Appsignal.config
          # Ensure _a config object_ is present, even if something went wrong
          # loading it or the file is empty. In this config file context, see
          # the context env vars, it will intentionally not load the YAML file.
          Appsignal.configure

          # Disable if no config was loaded from the file but it is present
          config[:active] = false
        end

        # Disable on config file error
        config[:active] = false if defined?(@config_error)

        ENV.delete("_APPSIGNAL_CONFIG_FILE_CONTEXT")
        ENV.delete("_APPSIGNAL_CONFIG_FILE_ENV")
      end
    end

    # Returns true if we're currently in the `config/appsignal.rb` file
    # context.
    def config_file_context?
      ENV.fetch("_APPSIGNAL_CONFIG_FILE_CONTEXT", nil) == "true"
    end

    def start_internal_stdout_logger
      @internal_logger = Appsignal::Utils::IntegrationLogger.new($stdout)
      internal_logger.formatter = log_formatter("appsignal")
    end

    def start_internal_file_logger(path)
      @internal_logger = Appsignal::Utils::IntegrationLogger.new(path)
      internal_logger.formatter = log_formatter
    rescue SystemCallError => error
      start_internal_stdout_logger
      internal_logger.warn "Unable to start internal logger with log path '#{path}'."
      internal_logger.warn error
    end

    def collect_environment_metadata
      Appsignal::Environment.report("ruby_version") do
        "#{RUBY_VERSION}-p#{RUBY_PATCHLEVEL}"
      end
      Appsignal::Environment.report("ruby_engine") { RUBY_ENGINE }
      if defined?(RUBY_ENGINE_VERSION)
        Appsignal::Environment.report("ruby_engine_version") do
          RUBY_ENGINE_VERSION
        end
      end
      Appsignal::Environment.report_supported_gems
    end
  end

  # @!visibility private
  module Integrations
  end
end

require "appsignal/internal_errors"
require "appsignal/loaders"
require "appsignal/sample_data"
require "appsignal/environment"
require "appsignal/system"
require "appsignal/utils"
require "appsignal/extension"
require "appsignal/auth_check"
require "appsignal/config"
require "appsignal/event_formatter"
require "appsignal/hooks"
require "appsignal/probes"
require "appsignal/marker"
require "appsignal/custom_marker"
require "appsignal/garbage_collection"
require "appsignal/rack"
require "appsignal/rack/body_wrapper"
require "appsignal/rack/abstract_middleware"
require "appsignal/rack/instrumentation_middleware"
require "appsignal/rack/event_handler"
require "appsignal/rack/event_middleware"
require "appsignal/integrations/railtie" if defined?(::Rails::Railtie)
require "appsignal/transaction"
require "appsignal/version"
require "appsignal/transmitter"
require "appsignal/check_in"
