# frozen_string_literal: true

#
# Copyright (C) 2011 - 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/>.

class AccountNotification < ActiveRecord::Base
  include Canvas::SoftDeletable
  include LinkedAttachmentHandler

  validates :start_at, :end_at, :subject, :message, :account_id, presence: true
  validate :validate_dates
  validate :send_message_not_set_for_site_admin
  belongs_to :account, touch: true
  belongs_to :user
  has_many :account_notification_roles, -> { active }, dependent: :destroy
  has_many :attachment_associations, as: :context, inverse_of: :context
  validates :message, length: { maximum: maximum_text_length, allow_blank: false }
  validates :subject, length: { maximum: maximum_string_length }
  sanitize_field :message, CanvasSanitize::SANITIZE

  after_save :create_alert, unless: -> { saved_change_to_workflow_state?(to: "deleted") }
  after_save :queue_message_broadcast, unless: -> { saved_change_to_workflow_state?(to: "deleted") }
  after_save :clear_cache

  USERS_PER_MESSAGE_BATCH = 1000

  ACCOUNT_SERVICE_NOTIFICATION_FLAGS = %w[account_survey_notifications].freeze
  validates :required_account_service, inclusion: { in: ACCOUNT_SERVICE_NOTIFICATION_FLAGS, allow_nil: true }

  validates :months_in_display_cycle, inclusion: { in: 1..48, allow_nil: true }

  delegate :root_account_id, to: :account
  delegate :root_account, to: :account

  def self.html_fields
    %w[message]
  end

  def access_for_attachment_association?(user, _session, association)
    notification_ids = AccountNotification.for_user_and_account_cached(user, association.root_account).pluck(:id)
    notification_ids.include?(id)
  end

  def validate_dates
    if start_at && end_at && end_at < start_at
      errors.add(:end_at, t("errors.invalid_account_notification_end_at", "Account notification end time precedes start time"))
    end
  end

  def create_alert
    if start_at > Time.zone.now
      delay(run_at: start_at,
            on_conflict: :overwrite,
            singleton: "create_notification_alert:#{global_id}")
        .create_alert
      return
    end

    roles = account_notification_roles.map(&:role_name)
    return if roles.count > 0 && !roles.intersect?(["StudentEnrollment", "ObserverEnrollment"])

    thresholds = ObserverAlertThreshold.active.where(observer: User.of_account(account), alert_type: "institution_announcement")
                                       .where.not(id: ObserverAlert.where(context: self).select(:observer_alert_threshold_id))
    thresholds.find_each do |threshold|
      ObserverAlert.create(student: threshold.student,
                           observer: threshold.observer,
                           observer_alert_threshold: threshold,
                           context: self,
                           alert_type: "institution_announcement",
                           action_date: start_at,
                           title: subject)
    end
  end

  def current?
    end_at >= Time.zone.now
  end
  alias_method :current, :current?

  def past?
    end_at < Time.zone.now
  end
  alias_method :past, :past?

  def self.for_user_and_account_cached(user, root_account)
    Rails.cache.fetch(["account_notifications_for_u_and_r", user, root_account].cache_key, expires_in: 5.minutes) do
      for_user_and_account(user, root_account)
    end
  end

  def self.for_user_and_account(user, root_account, include_near_past: false)
    GuardRail.activate(:secondary) do
      if root_account.site_admin?
        current = for_account(root_account, include_near_past:)
      else
        course_ids = user.enrollments.active_or_pending_by_date.shard(user.in_region_associated_shards).distinct.pluck(:course_id) # fetch sharded course ids
        # and then fetch account_ids separately - using pluck on a joined column doesn't give relative ids
        all_account_ids = Course.where(id: course_ids).not_deleted
                                .distinct.pluck(:account_id, :root_account_id).flatten.uniq
        all_account_ids += user.account_users.active.shard(user.in_region_associated_shards)
                               .joins(:account).where(accounts: { workflow_state: "active" })
                               .distinct.pluck(:account_id).uniq
        all_account_ids = Account.multi_account_chain_ids(all_account_ids) # get all parent sub-accounts too
        current = for_account(root_account, all_account_ids, include_near_past:)
      end

      user_role_ids = {}
      # because we are going through all the current announcements, we cache the
      # results of the sub_account chain to not have to calculate ids again.
      sub_account_ids_map = {}

      current.select! do |announcement|
        # use role.id instead of role_id to trigger Role#id magic for built in
        # roles. try(:id) because the AccountNotificationRole may have an
        # explicitly nil role_id to indicate the announcement's intended for
        # users not enrolled in any courses
        role_ids = announcement.account_notification_roles.map { |anr| anr.role&.role_for_root_account_id(root_account.id)&.id }
        global_account_id = Shard.global_id_for(announcement.account_id, announcement.shard)

        unless role_ids.empty? || user_role_ids.key?(announcement.account_id)
          unless announcement.account.root_account?
            announcement.shard.activate do
              # we need to store the local account ids. The ids for the
              # sub_accounts are relative to the announcements shard, but we use
              # store the announcements for the user's shards which could be
              # many. This also avoids storing the same local_id and using the
              # wrong chain.
              sub_account_ids_map[global_account_id] ||=
                Account.sub_account_ids_recursive(announcement.account_id) + [announcement.account_id]
            end
          end

          # choose enrollments and account users to inspect
          account_users = announcement.account_user_roles(user)
          enrollments = announcement.shard.activate do
            announcement.enrollment_role_ids(user, account_ids: sub_account_ids_map[global_account_id])
          end
          # preload role objects for those enrollments and account users
          ActiveRecord::Associations.preload(enrollments, [:role])
          ActiveRecord::Associations.preload(account_users, [:role])

          # map to role ids. user role.id instead of role_id to trigger Role#id
          # magic for built in roles. announcements intended for users not
          # enrolled in any courses have the NilEnrollment role type
          user_role_ids[announcement.account_id] = enrollments.map { |e| e.role.role_for_root_account_id(root_account.id).id }
          user_role_ids[announcement.account_id] = [nil] if user_role_ids[announcement.account_id].empty?
          user_role_ids[announcement.account_id] |= account_users.map { |au| au.role.role_for_root_account_id(root_account.id).id }
        end

        role_ids.empty? || role_ids.intersect?(user_role_ids[announcement.account_id])
      end

      user.shard.activate do
        unless include_near_past
          closed_ids = user.get_preference(:closed_notifications) || []
          # If there are ids marked as 'closed' that are no longer
          # applicable, they probably need to be cleared out.
          current_ids = current.map(&:id)
          unless (closed_ids - current_ids).empty?
            GuardRail.activate(:primary) do
              user.set_preference(:closed_notifications, closed_ids & current_ids)
            end
          end
          current.reject! { |announcement| closed_ids.include?(announcement.id) }
        end

        # filter out announcements that have a periodic cycle of display,
        # and the user isn't in the set of users to display it to this month (based
        # on user id)
        current.reject! do |announcement|
          if (months_in_period = announcement.months_in_display_cycle)
            !display_for_user?(user.id, months_in_period)
          end
        end

        if !root_account.include_students_in_global_survey? && current.any? { |a| a.required_account_service == "account_survey_notifications" }
          roles = user.enrollments.shard(user.in_region_associated_shards).active_or_pending_by_date.distinct.pluck(:type)
          if roles == ["StudentEnrollment"]
            current.reject! { |announcement| announcement.required_account_service == "account_survey_notifications" }
          end
        end
      end

      current.sort_by { |item| item[:end_at] }.reverse
    end
  end

  def enrollment_role_ids(user, account_ids:)
    if account.site_admin?
      scope = user.enrollments.shard(user.in_region_associated_shards)
    else
      scope = account.root_account.all_enrollments.where(user_id: user)
      unless account.root_account?
        scope = scope.where(courses: { account_id: account_ids })
                     .joins(:course)
      end
    end
    scope.active_or_pending_by_date.distinct.select(:role_id).to_a
  end

  def account_user_roles(user)
    if account.site_admin?
      user.account_users.shard(user.in_region_associated_shards)
          .distinct.select(:role_id).to_a
    else
      account.root_account.cached_all_account_users_for(user)
    end
  end

  def self.cache_key_for_root_account(root_account_id, date, include_all: false)
    # This is a semantic sugar for the cache key, because this key also
    # present when calling #for_user_and_account
    # using include_all=false could sound we filter important records
    map_key_for_include_all = include_all ? "extended-for-admins" : "filtered-by-date"
    ["root_account_notifications2", Shard.global_id_for(root_account_id), date.strftime("%Y-%m-%d"), map_key_for_include_all].cache_key
  end

  def self.for_account(root_account, all_visible_account_ids = nil, include_near_past: false, include_all: false)
    account_ids = root_account_ids = root_account.account_chain(include_site_admin: true).map(&:id)
    if all_visible_account_ids
      account_ids += all_visible_account_ids
      account_ids.uniq!
    end
    all_visible_account_ids = nil if account_ids == root_account_ids

    block = proc do
      now = Time.now.utc
      start_at = include_near_past ? now : now.end_of_day

      end_at = now - 4.months if include_near_past
      end_at ||= start_at
      end_at = end_at.beginning_of_day

      # we always check the given account for the flag, even if the announcement is from the site_admin account
      # this allows us to make a global announcement that is filtered to only accounts with this flag
      enabled_flags = ACCOUNT_SERVICE_NOTIFICATION_FLAGS & root_account.allowed_services_hash.keys.map(&:to_s)

      base_shard = Shard.current
      result = Shard.partition_by_shard(account_ids) do |sharded_account_ids|
        load_by_account = lambda do |slice_account_ids|
          scope = AccountNotification.active
                                     .where(account_id: slice_account_ids)
                                     .order(start_at: :desc)
                                     .preload({ account: :root_account }, account_notification_roles: :role)
          scope = if include_all
                    scope.preload(:user)
                  else
                    scope.where(start_at: ..start_at)
                         .where(end_at: end_at..)
                  end

          if Shard.current == root_account.shard
            if slice_account_ids != [root_account.id]
              scope = scope.joins(:account).where("domain_specific=? OR #{Account.resolved_root_account_id_sql}=?", false, root_account.id)
            end
          else
            scope = scope.where(domain_specific: false)
          end
          scope.to_a
        end

        # all root accounts; do them one by one, and MultiCache them
        this_shard_root_account_ids = if Shard.current == base_shard
                                        root_account_ids
                                      else
                                        root_account_ids.map { |id| Shard.relative_id_for(id, base_shard, Shard.current) }
                                      end
        if (sharded_account_ids - this_shard_root_account_ids).empty? && !include_near_past
          sharded_account_ids.map do |single_root_account_id|
            MultiCache.fetch(cache_key_for_root_account(single_root_account_id, end_at, include_all:)) do
              load_by_account.call([single_root_account_id])
            end
          end.flatten
        else
          load_by_account.call(sharded_account_ids)
        end
      end

      # need to post-process, since the cache covers all enabled flags
      result.select! { |n| n.required_account_service.nil? || enabled_flags.include?(n.required_account_service) }

      # need to post-process since the cache covers the entire day
      unless include_near_past || include_all
        result.select! { |n| now.between?(n.start_at, n.end_at) }
      end
      result
    end

    if all_visible_account_ids || include_near_past
      # Refreshes every 10 minutes at the longest
      all_account_ids_hash = Digest::SHA256.hexdigest all_visible_account_ids.try(:sort).to_s
      Rails.cache.fetch(["account_notifications5", root_account, all_account_ids_hash, include_near_past].cache_key, expires_in: 10.minutes, &block)
    else
      # no point in doing an additional layer of caching for _only_ root accounts when root accounts are explicitly cached
      block.call
    end
  end

  def self.default_months_in_display_cycle
    Setting.get("account_notification_default_months_in_display_cycle", "9").to_i
  end

  # private
  def self.display_for_user?(user_id, months_in_period, current_time = Time.now.utc)
    # we just need a stable reference point, doesn't matter what it is, so
    # let's use unix epoch
    start_time = Time.at(0).utc
    months_since_start_time = ((current_time.year - start_time.year) * 12) + (current_time.month - start_time.month)
    periods_since_start_time = months_since_start_time / months_in_period
    months_into_current_period = months_since_start_time % months_in_period
    mod_value = (Random.new(periods_since_start_time).rand(months_in_period) + months_into_current_period) % months_in_period
    user_id % months_in_period == mod_value
  end

  attr_accessor :message_recipients

  has_a_broadcast_policy

  set_broadcast_policy do |p|
    p.dispatch :account_notification
    p.to { message_recipients }
    p.whenever do |record|
      record.should_send_message? && record.message_recipients.present?
    end
  end

  def send_message_not_set_for_site_admin
    if send_message? && account.site_admin?
      # i mean maybe we could try but there are almost certainly better ways to send mass emails than this
      errors.add(:send_message, "Cannot send messages for site admin accounts")
    end
  end

  def should_send_message?
    send_message? && !messages_sent_at &&
      (start_at.nil? || (start_at < Time.now.utc)) &&
      (end_at.nil? || (end_at > Time.now.utc)) &&
      workflow_state != "deleted"
  end

  def queue_message_broadcast
    if send_message? && !messages_sent_at && !message_recipients
      delay(run_at: start_at || Time.now.utc,
            on_conflict: :overwrite,
            singleton: "account_notification_broadcast_messages:#{global_id}").broadcast_messages
    end
  end

  def clear_cache
    # yes, I could be smarter about only clearing this if the date is in range, and a relevant field changed,
    # but that is several complicated conditions, and these rarely change, so shouldn't be a big deal
    # to just let the cache clear
    if account.root_account?
      MultiCache.delete(self.class.cache_key_for_root_account(account_id, Time.now.utc, include_all: true))
      MultiCache.delete(self.class.cache_key_for_root_account(account_id, Time.now.utc, include_all: false))
    end
  end

  def broadcast_messages
    return unless should_send_message? # sanity check before we start grabbing user ids

    # don't try to send a message to an entire account in one job
    applicable_user_ids.each_slice(USERS_PER_MESSAGE_BATCH) do |sliced_user_ids|
      self.message_recipients = sliced_user_ids.map { |id| "user_#{id}" }
      save # trigger the broadcast policy
    ensure
      self.message_recipients = nil
    end
    update_attribute(:messages_sent_at, Time.now.utc)
  end

  def applicable_user_ids
    roles = account_notification_roles.preload(:role).to_a.map(&:role)
    GuardRail.activate(:secondary) do
      self.class.applicable_user_ids_for_account_and_roles(account, roles)
    end
  end

  def self.applicable_user_ids_for_account_and_roles(account, roles)
    account.shard.activate do
      all_account_ids = Account.sub_account_ids_recursive(account.id) + [account.id]
      user_ids = Set.new
      get_everybody = roles.empty?

      course_roles = roles.compact.select(&:course_role?).map { |r| r.role_for_root_account_id(account.resolved_root_account_id) }
      if get_everybody || course_roles.any?
        Course.find_ids_in_ranges do |min_id, max_id|
          course_ids = Course.active.where(id: min_id..max_id, account_id: all_account_ids).pluck(:id)
          next unless course_ids.any?

          course_ids.each_slice(50) do |sliced_course_ids|
            scope = Enrollment.active_or_pending_by_date.where(course_id: sliced_course_ids)
            scope = scope.where(role_id: course_roles) unless get_everybody
            user_ids += scope.distinct.pluck(:user_id)
          end
        end
      end

      account_roles = roles.compact.select(&:account_role?).map { |r| r.role_for_root_account_id(account.resolved_root_account_id) }
      if get_everybody || account_roles.any?
        AccountUser.find_ids_in_ranges do |min_id, max_id|
          scope = AccountUser.where(id: min_id..max_id).active.where(account_id: all_account_ids)
          scope = scope.where(role_id: account_roles) unless get_everybody
          user_ids += scope.distinct.pluck(:user_id)
        end
      end

      if roles.include?(nil)
        users_with_no_enrollments_from_given_accounts = User.joins(:user_account_associations).where(user_account_associations: { account_id: all_account_ids }).where.not(id: Enrollment.active_or_pending_by_date.select(:user_id))
        user_ids += users_with_no_enrollments_from_given_accounts.pluck(:id)
      end

      suspended_user_ids = User.where(id: user_ids)
                               .joins(:active_pseudonyms)
                               .group("users.id")
                               .having("COUNT(pseudonyms.id) = COUNT(CASE WHEN pseudonyms.workflow_state = 'suspended' THEN 1 END)")
                               .pluck(:id)
      user_ids -= suspended_user_ids
      user_ids.to_a.sort
    end
  end
end
