# 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/>.

Rails.application.config.after_initialize do
  # WillPaginate needs to allow args to Relation#to_a
  WillPaginate::ActiveRecord::RelationMethods.class_eval do
    def to_a(*args)
      if current_page.nil? then super # workaround for Active Record 3.0
      else
        WillPaginate::Collection.create(current_page, limit_value) do |col|
          col.replace super
          col.next_page = nil if total_entries.nil? && col.respond_to?(:length) && col.length < col.per_page # don't return a next page if there's nothing to get next
          col.total_entries ||= total_entries
        end
      end
    end
  end

  module Canvas # rubocop:disable Lint/ConstantDefinitionInBlock
    module Shard
      module IncludedClassMethods
        def birth
          default
        end
      end

      def settings
        return {} unless self.class.columns_hash.key?("settings")

        s = super
        # Am not sure how this happens
        if s.is_a?(String)
          s = JSON.parse(s)
        end
        if s.nil?
          self.settings = s = {}
        end

        salt = s.delete(:encryption_key_salt)
        secret = s.delete(:encryption_key_enc)
        if secret || salt
          if secret && salt
            s[:encryption_key] = Canvas::Security.decrypt_password(secret, salt, "shard_encryption_key")
          end
          self.settings = s
        end

        s
      end

      def encrypt_settings
        s = settings.dup
        if (encryption_key = s.delete(:encryption_key))
          secret, salt = Canvas::Security.encrypt_password(encryption_key, "shard_encryption_key")
          s[:encryption_key_enc] = secret
          s[:encryption_key_salt] = salt
        end
        if s != settings
          self.settings = s
        end
        s
      end
    end

    module DisableActivateBang
      if ::Rails.env.test?
        def activate!(*)
          raise NotImplementedError # if you're getting this, you really should be using activate instead of activate!
        end
      end
    end
  end

  Switchman::Shard.prepend(Canvas::Shard)
  Switchman::Shard.singleton_class.include(Canvas::Shard::IncludedClassMethods)
  Switchman::Shard.prepend(Canvas::DisableActivateBang)
  Switchman::DefaultShard.prepend(Canvas::DisableActivateBang)

  Switchman::Shard.class_eval do
    self.primary_key = "id"
    reset_column_information if connected? # make sure that the id column object knows it is the primary key

    before_save :encrypt_settings
  end

  Switchman::DatabaseServer.class_eval do
    def next_maintenance_window
      return nil unless maintenance_window_start_hour

      now = Time.now.utc
      date = nil
      weekday = maintenance_window_weekday
      weeks = maintenance_window_weeks_of_month
      loop do
        first_date = first_given_weekday_of_month(weekday, now)
        # look at the 1st, 3rd, etc. weekday of this month, and see if it's in the future
        weeks.each do |i|
          new_date = first_date.advance(weeks: i - 1)
          # make sure we didn't overrun the current month, like if there's not a 5th thursday this month
          if new_date.future? && new_date.month == now.month
            date = new_date
            break
          end
        end

        break if date

        # search the next month
        now = now.next_month
      end

      # Time offsets are strange
      start_at = date.beginning_of_day - maintenance_window_start_hour.hours + maintenance_window_offset.minutes
      end_at = start_at + maintenance_window_duration

      [start_at, end_at]
    end

    # Finds the first day of the month that is a given weekday
    #
    # @param [Integer] which weekday we're looking for
    # @param [Time] start_ref the month to look in
    def first_given_weekday_of_month(weekday, start_ref)
      date = start_ref.beginning_of_month
      date = date.next_day until date.wday == weekday
      date
    end

    def maintenance_window_start_hour
      Setting.get("maintenance_window_start_hour", nil)&.to_i
    end

    def maintenance_window_offset
      Setting.get("maintenance_window_offset", "0").to_i
    end

    def maintenance_window_duration
      # ISO 8601 duration
      ActiveSupport::Duration.parse(Setting.get("maintenance_window_duration", "PT2H"))
    end

    # @return [Integer] the weekday of the maintenance window
    def maintenance_window_weekday
      Date::DAYNAMES.index(Setting.get("maintenance_window_weekday", "thursday").capitalize)
    end

    # @return [Array<Integer>]
    #   the weeks of the month that the maintenance window occurs on, sorted and 1-indexed
    def maintenance_window_weeks_of_month
      Setting.get("maintenance_window_weeks_of_month", "1,3").split(",").map(&:to_i).sort
    end

    def self.send_in_each_region(klass, method, enqueue_args, *, **)
      run_current_region_asynchronously = enqueue_args.delete(:run_current_region_asynchronously)

      return klass.send(method, *, **) if DatabaseServer.all.all? { |db| !db.config[:region] }

      regions = Set.new
      unless run_current_region_asynchronously
        klass.send(method, *, **)
        regions << Shard.current.database_server.config[:region]
      end

      all.each do |db|
        next if regions.include?(db.config[:region]) || !db.config[:region]
        next if db.shards.empty?

        regions << db.config[:region]
        db.shards.first.activate do
          klass.delay(**enqueue_args).__send__(method, *, **)
        end
      end
    end

    def self.send_in_region(region, klass, method, enqueue_args, *, **)
      return klass.delay(**enqueue_args).__send__(method, *, **) if region.nil?

      shard = nil
      all.find { |db| db.config[:region] == region && (shard = db.shards.first) }

      # the app server knows what region it's in, but the database servers don't?
      # just send locally
      if shard.nil? && all.all? { |db| db.config[:region].nil? }
        return klass.delay(**enqueue_args).__send__(method, *, **)
      end

      raise "Could not find a shard in region #{region}" unless shard

      shard.activate do
        klass.delay(**enqueue_args).__send__(method, *, **)
      end
    end
  end

  Object.send(:remove_const, :Shard) if defined?(Shard)
  Object.send(:remove_const, :DatabaseServer) if defined?(DatabaseServer)
  # rubocop:disable Lint/ConstantDefinitionInBlock
  Shard = Switchman::Shard
  DatabaseServer = Switchman::DatabaseServer
  # rubocop:enable Lint/ConstantDefinitionInBlock

  Switchman::DefaultShard.class_eval do
    attr_writer :settings

    def settings
      {}
    end
  end

  if !Shard.default.is_a?(Shard) && Switchman.config[:force_sharding] && !ENV["SKIP_FORCE_SHARDING"]
    raise "Sharding is supposed to be set up, but is not! Use SKIP_FORCE_SHARDING=1 to ignore"
  end

  if Shard.default.is_a?(Shard)
    # otherwise the serialized settings attribute method won't be properly defined
    Shard.define_attribute_methods
    Shard.default.instance_variable_set(:@attributes, Shard.attributes_builder.build_from_database(Shard.default.attributes_before_type_cast))
  end

  Switchman::Deprecation.behavior = [
    :log,
    lambda do |message, callstack, _deprecation_horizon, _gem_name|
      e = ActiveSupport::DeprecationException.new(message)
      e.set_backtrace(callstack.map(&:to_s))
      Sentry.capture_exception(e, level: :warning)
    end
  ]

  Switchman.config[:region] = Canvas.region

  Canvas::CrossRegionQueryMetrics.attach_to(:active_record)
end
