# frozen_string_literal: true

#
# Copyright (C) 2013 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.

require_relative "boot"

require "active_record/railtie"
require "action_controller/railtie"
require "action_mailer/railtie"
# require "sprockets/railtie" # Do not enable the Rails Asset Pipeline
require "rails/test_unit/railtie"

Bundler.require(*Rails.groups)

debug_launch = lambda do
  if ENV["RUBY_DEBUG_OPEN"]
    require "debug/session"
    next unless defined?(DEBUGGER__)

    DEBUGGER__.open(nonstop: ENV["RUBY_DEBUG_NONSTOP"])
  elsif ENV["RUBY_DEBUG_START"]
    require "debug/start" # rubocop:disable Lint/Debugger
  end
end

Spring.after_fork(&debug_launch) if defined?(Spring)
debug_launch.call if !defined?(Passenger) && Rails.env.development?

module CanvasRails
  class Application < Rails::Application
    config.autoloader = :zeitwerk

    config.add_autoload_paths_to_load_path = false

    config.encoding = "utf-8"
    require "logging_filter"
    config.filter_parameters.concat LoggingFilter.filtered_parameters
    config.action_dispatch.rescue_responses["AuthenticationMethods::RevokedAccessTokenError"] = 401
    config.action_dispatch.rescue_responses["AuthenticationMethods::ExpiredAccessTokenError"] = 401
    config.action_dispatch.rescue_responses["AuthenticationMethods::AccessTokenError"] = 401
    config.action_dispatch.rescue_responses["AuthenticationMethods::AccessTokenScopeError"] = 401
    config.action_dispatch.rescue_responses["AuthenticationMethods::LoggedOutError"] = 401
    config.action_dispatch.rescue_responses["CanvasHttp::CircuitBreakerError"] = 502
    config.action_dispatch.default_headers.delete("X-Frame-Options")
    config.action_dispatch.default_headers["Referrer-Policy"] = "no-referrer-when-downgrade"
    config.action_controller.forgery_protection_origin_check = true
    ActiveSupport.to_time_preserves_timezone = true
    # Ensure switchman gets the new version before the main initialize_cache initializer runs
    config.active_support.cache_format_version = ActiveSupport.cache_format_version = 7.0

    config.app_generators do |c|
      c.test_framework :rspec
      c.integration_tool :rspec
      c.performance_tool :rspec
    end

    # Settings in config/environments/* take precedence over those specified here.
    # Application configuration should go into files in config/initializers
    # -- all .rb files in that directory are automatically loaded.
    # See Rails::Configuration for more options.

    # Make Time.zone default to the specified zone, and make Active Record store time values
    # in the database in UTC, and return them converted to the specified local zone.
    # Run "rake -D time" for a list of tasks for finding time zone names. Comment line to use default local time.
    config.time_zone = "UTC"

    log_config = Rails.root.join("config/logging.yml").file? && Rails.application.config_for(:logging).with_indifferent_access
    log_config = { "logger" => "rails", "log_level" => "debug" }.merge(log_config || {})

    config.log_level = log_config["log_level"]
    log_level = Logger.const_get(config.log_level.to_s.upcase)

    case log_config["logger"]
    when "syslog"
      require "syslog/logger"
      log_config["app_ident"] ||= "canvas-lms"
      log_config["daemon_ident"] ||= "canvas-lms-daemon"
      facilities = 0
      (log_config["facilities"] || []).each do |facility|
        facilities |= Syslog.const_get :"LOG_#{facility.to_s.upcase}"
      end
      ident = (ENV["RUNNING_AS_DAEMON"] == "true") ? log_config["daemon_ident"] : log_config["app_ident"]

      config.logger = Syslog::Logger.new(ident, facilities)

      syslog_options = (log_config["include_pid"] == true) ? Syslog::LOG_PID : 0
      if (Syslog.instance.options & Syslog::LOG_PID) != syslog_options
        config.logger.syslog = Syslog.reopen(Syslog.instance.ident,
                                             (Syslog.instance.options & ~Syslog::LOG_PID) | syslog_options,
                                             Syslog.instance.facility)
      end
    else
      require "canvas_logger"
      log_path = config.paths["log"].first

      if ENV["RUNNING_AS_DAEMON"] == "true"
        log_path = Rails.root.join("log/delayed_job.log")
      end

      FileUtils.mkdir_p(File.dirname(log_path))
      config.logger = CanvasLogger.new(log_path, log_level)
    end
    config.logger.level = log_level
    unless log_config["log_context"] == false
      class ContextFormatter < Logger::Formatter
        def initialize(parent_formatter)
          super()

          @parent_formatter = parent_formatter
        end

        def call(severity, time, progname, msg)
          msg = @parent_formatter.call(severity, time, progname, msg)
          context = Thread.current[:context] || {}
          "[#{context[:session_id] || "-"} #{context[:request_id] || "-"}] #{msg}"
        end
      end

      config.logger.formatter = ContextFormatter.new(config.logger.formatter)
    end

    config.active_record.automatic_scope_inversing = true

    # Activate observers that should always be running
    config.active_record.observers ||= []
    config.active_record.observers << %i[cacher stream_item_cache live_events_observer]

    config.active_support.encode_big_decimal_as_string = false
    config.active_support.remove_deprecated_time_with_zone_name = true

    # Skip loading generators at boot - they're only needed for `rails generate` commands
    Rails.autoloaders.main.ignore(Rails.root.join("lib/generators"))

    config.paths["lib"].eager_load!
    config.paths.add("app/middleware", eager_load: true, autoload_once: true)
    # The main autoloader should ignore it so the `once` autoloader can happily load it
    Rails.autoloaders.main.ignore("#{__dir__}/../lib/base")
    config.paths.add("lib/base", eager_load: true, autoload_once: true)
    $LOAD_PATH << "#{__dir__}/../lib/base"

    # This needs to be set for things in the `once` autoloader really early
    Rails.autoloaders.each do |autoloader|
      autoloader.inflector.inflect(
        "csv_with_i18n" => "CSVWithI18n"
      )
    end

    # prevent directory->module inference in these directories from wreaking
    # havoc on the app (e.g. stylesheets/base -> ::Base)
    config.eager_load_paths -= [Rails.root.join("app/coffeescripts"),
                                Rails.root.join("app/stylesheets"),
                                Rails.root.join("ui")]

    config.middleware.insert_before Rack::ETag, Rack::Deflater, if: lambda { |*|
      ::DynamicSettings.find(tree: :private)["enable_rack_deflation", failsafe: true]
    }
    config.middleware.insert_before Rack::ETag, Rack::Brotli, if: lambda { |*|
      ::DynamicSettings.find(tree: :private)["enable_rack_brotli", failsafe: true]
    }

    config.i18n.load_path << Rails.root.join("config/locales/locales.yml")
    config.i18n.load_path << Rails.root.join("config/locales/community.csv")

    config.to_prepare do
      Canvas::Plugins::DefaultPlugins.apply_all
      ActiveSupport::JSON::Encoding.escape_html_entities_in_json = true
    end

    module PostgreSQLEarlyExtensions
      def initialize(config)
        unless config.key?(:prepared_statements)
          config = config.dup
          config[:prepared_statements] = false
        end
        super
      end

      def connect
        hosts = Array(@connection_parameters[:host]).presence || [nil]
        hosts.each_with_index do |host, index|
          connection_parameters = @connection_parameters.dup
          connection_parameters[:host] = host

          begin
            @raw_connection = self.class.new_client(connection_parameters)
          rescue ::ActiveRecord::ActiveRecordError, ::ActiveRecord::ConnectionFailed, ::ActiveRecord::ConnectionNotEstablished, ::PG::Error => e
            # If exception occurs using parameters from a predefined pg service, retry without
            if connection_parameters.key?(:service)
              CanvasErrors.capture(e, { tags: { pg_service: connection_parameters[:service] } }, :warn)
              Rails.logger.warn("Error connecting to database using pg service `#{connection_parameters[:service]}`; retrying without... (error: #{e.message})")
              connection_parameters.delete(:service)
              connection_parameters[:sslmode] = "disable"
              retry
            else
              raise
            end
          end

          raise "Canvas requires PostgreSQL 14 or newer" unless postgresql_version >= 14_00_00 # rubocop:disable Style/NumericLiterals

          break
          # we _shouldn't_ be catching a NoDatabaseError, but that's what Rails raises
          # for an error where the database name is in the message (i.e. a hostname lookup failure)
        rescue ActiveRecord::NoDatabaseError, ::ActiveRecord::ConnectionFailed, ActiveRecord::ConnectionNotEstablished, ::PG::Error => e
          if e.is_a?(::PG::Error) && e.message.include?("does not exist")
            raise ActiveRecord::NoDatabaseError, e.message
          elsif index == hosts.length - 1
            raise
          end
          # else try next host
        end
      end

      def client_min_messages=(level)
        return if level == "preset"

        super
      end

      def set_standard_conforming_strings
        # this has been the default since 9.1
      end

      def reconfigure_connection_timezone
        return if @config[:timezone] == "preset"

        super
      end

      def internal_execute(sql, name = "SCHEMA", *, **)
        return super unless name == "SCHEMA"

        if sql == "SET intervalstyle = iso_8601" && @config[:interval_style] == "preset"
          return
        end

        super
      end
    end

    module TypeMapInitializerExtensions
      def query_conditions_for_initial_load
        known_type_names = @store.keys.map { |n| "'#{n}'" } + @store.keys.map { |n| "'_#{n}'" }
        <<~SQL.squish % [known_type_names.join(", "),]
          WHERE
            t.typname IN (%s)
        SQL
      end
    end

    Autoextend.hook(:"ActiveRecord::ConnectionAdapters::PostgreSQLAdapter",
                    PostgreSQLEarlyExtensions,
                    method: :prepend)
    Autoextend.hook(:"ActiveRecord::ConnectionAdapters::PostgreSQL::OID::TypeMapInitializer",
                    TypeMapInitializerExtensions,
                    method: :prepend)

    module RailsCacheShim
      def delete(key, options = nil)
        if options&.[](:unprefixed_key)
          super
        else
          # Any is eager, so we must map first or we won't run on all keys
          SUPPORTED_RAILS_VERSIONS.map do |version|
            super(key, (options || {}).merge(explicit_version: version.delete(".")))
          end.any?
        end
      end

      private

      def namespace_key(key, options)
        # Purge all rails versions at once if deleting based on a pattern
        if caller_locations(1, 1).first.base_label == "delete_matched"
          return "rails??:#{super}"
        end

        if options&.[](:unprefixed_key)
          super
        elsif options&.[](:explicit_version)
          "rails#{options[:explicit_version]}:#{super}"
        else
          "rails#{Rails::VERSION::MAJOR}#{Rails::VERSION::MINOR}:#{super}"
        end
      end
    end

    Autoextend.hook(:"ActiveSupport::Cache::Store",
                    RailsCacheShim,
                    method: :prepend)

    module PatchThorWarning
      # active_model_serializers should be passing `type: :boolean` here:
      # https://github.com/rails-api/active_model_serializers/blob/v0.9.0.alpha1/lib/active_model/serializer/generators/serializer/scaffold_controller_generator.rb#L10
      # but we don't really care about the warning, it only affects using the rails
      # generator for a resource
      #
      # Easiest way to avoid the warning for now is to patch thor
      def validate_default_type!
        return if switch_name == "--serializer"

        super
      end
    end

    Autoextend.hook(:"Thor::Option", PatchThorWarning, method: :prepend)

    # tell Rails to use the native XML parser instead of REXML
    ActiveSupport::XmlMini.backend = "Nokogiri"

    class NotImplemented < StandardError; end

    if defined?(PhusionPassenger)
      PhusionPassenger.on_event(:after_installing_signal_handlers) do
        Canvas::Reloader.trap_signal
      end
    else
      config.to_prepare do
        Canvas::Reloader.trap_signal
      end
    end

    # Ensure that the automatic redis reconnection on fork works
    # This is the default in redis-rb, but for some reason rails overrides it
    # See e.g. https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22704
    ActiveSupport::Cache::RedisCacheStore::DEFAULT_REDIS_OPTIONS[:reconnect_attempts] = 1

    # don't wrap fields with errors with a <div class="fieldWithErrors" />,
    # since that could leak information (e.g. valid vs invalid username on
    # login page)
    config.action_view.field_error_proc = proc { |html_tag, _instance| html_tag }

    class ExceptionsApp
      def call(env)
        req = ActionDispatch::Request.new(env)
        res = ApplicationController.make_response!(req)
        ApplicationController.dispatch("rescue_action_dispatch_exception", req, res)
      end
    end

    config.exceptions_app = ExceptionsApp.new

    config.before_initialize do
      config.action_controller.asset_host = lambda do |source, *_|
        ::Canvas::Cdn.asset_host_for(source)
      end
    end

    if config.action_dispatch.rack_cache != false
      config.action_dispatch.rack_cache[:ignore_headers] =
        %w[Set-Cookie X-Request-Context-Id X-Canvas-User-Id X-Canvas-Meta]
    end

    def secret_key_base
      # we don't use Rails' CookieStore session middleware, so we
      # don't care about secret_key_base
    end

    def secret_key_base=(_)
      # we don't use Rails' CookieStore session middleware, so we
      # don't care about secret_key_base
    end

    class DummyKeyGenerator
      def self.generate_key(*); end
    end

    def key_generator(...)
      DummyKeyGenerator
    end

    # This also depends on secret_key_base and is not a feature we use or currently intend to support
    initializer "canvas.ignore_generated_token_verifier", before: "active_record.generated_token_verifier" do
      config.after_initialize do
        ActiveSupport.on_load(:active_record) do
          self.generated_token_verifier = "UNUSED"
        end
      end
    end

    initializer "canvas.init_dynamic_settings", before: "canvas.extend_shard" do
      settings = ConfigFile.load("consul")
      if settings.present?
        # this is not just for speed in non-consul installations^
        # We also do things like building javascript assets with the base
        # container that only has as many ruby assets as strictly necessary,
        # and these resources actually aren't even on disk in those cases.
        # do not remove this conditional until the asset build no longer
        # needs the rails app for anything.

        # Do it early with the wrong cache for things super early in boot
        reloader = DynamicSettingsInitializer.bootstrap!
        # Do it at the end when the autoloader is set up correctly
        config.to_prepare do
          reloader.call
        end
      end
    end

    # ensure configure after dynamic settings is configured before yjit is managed
    initializer :enable_yjit_check, before: "enable_yjit" do
      config.yjit = ActiveModel::Type::Boolean.new.cast(::DynamicSettings.find(tree: :private)["enable_yjit", failsafe: "false"])
    end

    initializer "canvas.extend_shard", before: "active_record.initialize_database" do
      # have to do this before the default shard loads
      Switchman::Shard.serialize :settings, type: Hash
      Switchman.cache = -> { MultiCache.cache }
    end

    # Newer rails has this in rails proper
    attr_writer :credentials

    initializer "canvas.init_credentials", before: "active_record.initialize_database" do
      self.credentials = Canvas::Credentials.new(credentials)
      # Ensure we load credentials at initailization time to avoid overloading vault
      credentials.config
    end

    # we don't know what middleware to make SessionsTimeout follow until after
    # we've loaded config/initializers/session_store.rb
    initializer("extend_middleware_stack", after: :load_config_initializers) do |app|
      app.config.middleware.insert_before(config.session_store, LoadAccount)
      app.config.middleware.swap(ActionDispatch::RequestId, RequestContext::Generator)
      app.config.middleware.insert_after(config.session_store, RequestContext::Session)
      app.config.middleware.insert_before(Rack::Head, RequestThrottle)
      app.config.middleware.insert_before(Rack::MethodOverride, PreventNonMultipartParse)
      app.config.middleware.insert_before(Sentry::Rails::CaptureExceptions, SentryTraceScrubber)
    end
  end
end
