# 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 Message < ActiveRecord::Base
  # Included modules
  include Rails.application.routes.url_helpers

  include ERB::Util
  include SendToStream
  include TextHelper
  include HtmlTextHelper
  include Workflow
  include RruleHelper
  include Messages::PeerReviewsHelper
  include Messages::SendStudentNamesHelper

  include CanvasPartman::Concerns::Partitioned

  self.partitioning_strategy = :by_date
  self.partitioning_interval = :weeks

  extend TextHelper

  class QueuedNotFound < StandardError; end

  class Queued
    # use this to queue messages for delivery so we find them using the created_at in the scope
    # instead of using id alone when reconstituting the AR object
    attr_accessor :id, :created_at

    def initialize(id, created_at)
      @id, @created_at = id, created_at
    end

    delegate :dispatch_at, to: :message

    def deliver
      message.deliver
    rescue QueuedNotFound
      raise Delayed::RetriableError, "Message does not (yet?) exist"
    end

    def message
      return @message if @message.present?

      @message = Message.in_partition("id" => id, "created_at" => @created_at).where(id: @id, created_at: @created_at).first || Message.where(id: @id).first
      raise QueuedNotFound if @message.nil?

      @message
    end
  end

  def for_queue
    Queued.new(id, created_at)
  end

  # Associations
  belongs_to :communication_channel
  belongs_to :context, polymorphic: [], exhaustive: false
  include NotificationPreloader

  belongs_to :user
  belongs_to :root_account, class_name: "Account"
  has_many   :attachments, as: :context, inverse_of: :context

  attr_writer :delayed_messages
  attr_accessor :output_buffer

  # Callbacks
  after_save  :stage_message
  before_save :infer_defaults
  before_save :move_dashboard_messages
  before_save :move_messages_for_deleted_users
  before_validation :truncate_invalid_message

  # Validations
  validate :prevent_updates
  validates :body, length: { maximum: maximum_text_length }, allow_blank: true
  validates :html_body, length: { maximum: maximum_text_length }, allow_blank: true
  validates :transmission_errors, length: { maximum: maximum_text_length }, allow_blank: true
  validates :to, length: { maximum: maximum_text_length }, allow_blank: true
  validates :from, length: { maximum: maximum_text_length }, allow_blank: true
  validates :url, length: { maximum: maximum_text_length }, allow_blank: true
  validates :subject, length: { maximum: maximum_text_length }, allow_blank: true
  validates :from_name, length: { maximum: maximum_text_length }, allow_blank: true
  validates :reply_to_name, length: { maximum: maximum_string_length }, allow_blank: true

  def prevent_updates
    unless new_record?
      # e.g. Message.where(:id => self.id, :created_at => self.created_at).update_all(...)
      errors.add(:base, "Regular saving on messages is disabled - use save_using_update_all")
    end
  end

  # Stream policy
  on_create_send_to_streams do
    if to == "dashboard" && Notification.types_to_show_in_feed.include?(notification_name)
      user_id
    else
      []
    end
  end

  # State machine
  workflow do
    state :created do
      event :stage, transitions_to: :staged do
        self.dispatch_at = Time.now.utc + delay_for
        if to != "dashboard"
          MessageDispatcher.dispatch(self)
        end
      end
      event :set_transmission_error, transitions_to: :transmission_error
      event :cancel, transitions_to: :cancelled
      event :close, transitions_to: :closed # needed for dashboard messages
    end

    state :staged do
      event :dispatch, transitions_to: :sending
      event :set_transmission_error, transitions_to: :transmission_error
      event :cancel, transitions_to: :cancelled
      event :close, transitions_to: :closed # needed for dashboard messages
    end

    state :sending do
      event :complete_dispatch, transitions_to: :sent do
        self.sent_at ||= Time.zone.now
      end
      event :set_transmission_error, transitions_to: :transmission_error
      event :cancel, transitions_to: :cancelled
      event :close, transitions_to: :closed
      event :errored_dispatch, transitions_to: :staged do
        # A little delay so we don't churn so much when the server is down.
        self.dispatch_at = Time.now.utc + 5.minutes
      end
    end

    state :sent do
      event :set_transmission_error, transitions_to: :transmission_error
      event :close, transitions_to: :closed
      event :bounce, transitions_to: :bounced do
        # Permenant reminder that this bounced.
        communication_channel.bounce_count += 1
        communication_channel.save!
        self.is_bounced = true
      end
      event :recycle, transitions_to: :staged
    end

    state :bounced do
      event :close, transitions_to: :closed
    end

    state :dashboard do
      event :set_transmission_error, transitions_to: :transmission_error
      event :close, transitions_to: :closed
      event :cancel, transitions_to: :closed
    end

    state :cancelled

    state :transmission_error do
      event :close, transitions_to: :closed
    end

    state :closed do
      event :set_transmission_error, transitions_to: :transmission_error
      event :send_message, transitions_to: :closed do
        self.sent_at ||= Time.zone.now
      end
    end
  end

  # turns out we can override this method inside the workflow gem to get a custom save for workflow transitions
  def persist_workflow_state(new_state)
    self.workflow_state = new_state
    save_using_update_all
  end

  def save_using_update_all
    shard.activate do
      self.updated_at = Time.now.utc
      updates = changes_to_save.transform_values(&:last)
      self.class.in_partition(attributes).where(id:, created_at:).update_all(updates)
      clear_changes_information
    end
  end

  # Named scopes
  scope :for, ->(context) { where(context:) }

  scope :after, ->(date) { where("messages.created_at>?", date) }
  scope :more_recent_than, ->(date) { where("messages.created_at>? AND messages.dispatch_at>?", date, date) }

  scope :to_dispatch, lambda {
    where("messages.workflow_state='staged' AND messages.dispatch_at<=? AND 'messages.to'<>'dashboard'", Time.now.utc)
  }

  scope :to_email, -> { where(path_type: ["email", "sms"]) }

  scope :not_to_email, -> { where("messages.path_type NOT IN ('email', 'sms')") }

  scope :by_name, ->(notification_name) { where(notification_name:) }

  scope :before, ->(date) { where("messages.created_at<?", date) }

  scope :for_user, ->(user) { where(user_id: user) }

  # messages that can be moved to the 'cancelled' state. dashboard messages
  # can be closed by calling 'cancel', but aren't included
  scope :cancellable, -> { where(workflow_state: %w[created staged sending]) }

  # For finding a very particular message:
  # Message.for(context).by_name(name).directed_to(to).for_user(user), or
  # messages.for(context).by_name(name).directed_to(to).for_user(user)
  # Where user can be a User or id, name needs to be the Notification name.
  scope :staged, -> { where("messages.workflow_state='staged' AND messages.dispatch_at>?", Time.now.utc) }

  scope :in_state, ->(state) { where(workflow_state: Array(state).map(&:to_s)) }

  scope :at_timestamp, ->(timestamp) { where(created_at: Time.zone.at(timestamp.to_i)...Time.zone.at(timestamp.to_i + 1)) }

  # an optimization for queries that would otherwise target the main table to
  # make them target the specific partition table. Naturally this only works if
  # the records all reside within the same partition!!!
  #
  # for example, this takes us from:
  #
  #     Message.where(id: 3)
  #     => SELECT "messages".* FROM "messages" WHERE "messages"."id" = 3
  # to:
  #
  #     Message.in_partition(Message.last.attributes).where(id: 3)
  #     => SELECT "messages_2020_35".* FROM "messages_2020_35" WHERE "messages_2020_35"."id" = 3
  #
  scope :in_partition, lambda { |attrs|
    dup.instance_eval do
      tap do
        @table = klass.arel_table_from_key_values(attrs)
        @predicate_builder = predicate_builder.dup
        @predicate_builder.instance_variable_set(:@table, ActiveRecord::TableMetadata.new(klass, @table))
      end
    end
  }

  # Public: Helper methods for grabbing a user via the "from" field and using it to
  # populate the avatar, name, and email in the conversation email notification

  def author
    @_author ||= if author_context.has_attribute?(:user_id)
                   User.find(context.user_id)
                 elsif author_context.has_attribute?(:author_id)
                   User.find(context.author_id)
                 else
                   nil
                 end
  end

  def author_context
    # the user_id on a mention is the user that was mentioned instead of the
    # author of the message.
    context.is_a?(Mention) ? context.discussion_entry : context
  end

  def avatar_enabled?
    return false unless author_account.present?

    author_account.service_enabled?(:avatars)
  end

  def author_account
    # Root account is populated during save
    return nil unless author.present?

    root_account_id ? Account.find(root_account_id) : author.account
  end

  def author_avatar_url
    if context.is_a?(DiscussionEntry) && context.discussion_topic.anonymous?
      return "https://canvas.instructure.com/images/messages/avatar-50.png"
    end

    url = author.try(:avatar_url)
    # The User model currently supports storing either a path or full
    # URL for an avatar. Because of this, alternatives to URI::DEFAULT_PARSER.escape
    # such as CGI.escape end up escaping too much for full URLs. In
    # order to escape just the path, we'd need to utilize URI.parse
    # which can't handle URLs with spaces. As that is the root cause
    # of this change, we'll just use the deprecated URI::DEFAULT_PARSER.escape method.
    #
    URI.join("#{HostUrl.protocol}://#{HostUrl.context_host(author_account)}", URI::DEFAULT_PARSER.escape(url)).to_s if url
  end

  def author_short_name
    if context.is_a?(DiscussionEntry) && context.discussion_topic.anonymous?
      return context.author_name
    end

    author.try(:short_name)
  end

  def author_email_address
    if context.is_a?(DiscussionEntry) && context.discussion_topic.anonymous?
      return nil
    end

    if context_root_account.try(:author_email_in_notifications?)
      author.try(:email)
    end
  end

  # Public: Helper to generate a URI for the given subject. Overrides Rails'
  # built-in ActionController::PolymorphicRoutes#polymorphic_url method because
  # it forces option defaults for protocol and host.
  def default_url_options
    { protocol: HostUrl.protocol, host: HostUrl.context_host(link_root_account, ApplicationController.test_cluster_name) }
  end

  # Public: Helper to generate JSON suitable for publishing via Amazon SNS
  #
  # Currently pulls data from email template contents
  #
  # Returns a JSON string
  def sns_json
    @sns_json ||= begin
      custom_data = {
        html_url: url,
        user_id: user.global_id
      }
      custom_data[:api_url] = content(:api_url) if content(:api_url) # no templates define this right now

      {
        default: subject,
        GCM: {
          data: {
            alert: subject,
          }.merge(custom_data)
        }.to_json,
        APNS_SANDBOX: {
          aps: {
            alert: subject
          }
        }.merge(custom_data).to_json,
        APNS: {
          aps: {
            alert: subject
          }
        }.merge(custom_data).to_json
      }.to_json
    end
  end

  # overwrite existing html_to_text so that messages with links can have the ids
  # translated to be shard aware while preserving the link_root_account for the
  # host.
  def html_to_text(html, *opts)
    super(transpose_url_ids(html), *opts)
  end

  # overwrite existing html_to_simple_html so that messages with links can have
  # the ids translated to be shard aware while preserving the link_root_account
  # for the host.
  def html_to_simple_html(html, *opts)
    super(transpose_url_ids(html), *opts)
  end

  def transpose_url_ids(html)
    url_helper = Api::Html::UrlProxy.new(self,
                                         context,
                                         HostUrl.context_host(link_root_account),
                                         HostUrl.protocol,
                                         target_shard: link_root_account.shard)
    Api::Html::Content.rewrite_outgoing(html, link_root_account, url_helper)
  end

  # infer a root account associated with the context that the user can log in to
  def link_root_account(pre_loaded_account: nil)
    context = pre_loaded_account
    @root_account ||= begin
      context ||= self.context
      if context.is_a?(CommunicationChannel) && @data&.root_account_id
        root_account = Account.where(id: @data.root_account_id).first
        context = root_account if root_account
      end

      # root_account is on lots of objects, use it when we can.
      context = context.root_account if context.respond_to?(:root_account)
      # some of these `context =` may not be relevant now that we have
      # root_account on many classes, but root_account doesn't respond to them
      # and so it's fast, and there are a lot of ways to generate a message.
      context = context.assignment.root_account if context.respond_to?(:assignment) && context.assignment
      context = context.rubric_association.context if context.respond_to?(:rubric_association) && context.rubric_association
      context = context.appointment_group.contexts.first if context.respond_to?(:appointment_group) && context.appointment_group
      context = context.master_template.course if context.respond_to?(:master_template) && context.master_template
      context = context.context if context.respond_to?(:context)
      context = context.account if context.respond_to?(:account)
      context = context.root_account if context.respond_to?(:root_account)

      # Going through SisPseudonym.for is important since the account could change
      if context.respond_to?(:root_account)
        p = SisPseudonym.for(user, context, type: :implicit, require_sis: false)
        context = p.account if p
      else
        # nothing? okay, just something the user can log in to
        context = user.pseudonym.try(:account)
        context ||= self.context
      end
      context
    end
  end

  # infer a root account time zone
  def root_account_time_zone
    link_root_account.time_zone if link_root_account.respond_to?(:time_zone)
  end

  # Internal: Store any transmission errors in the database to help with later
  # debugging.
  #
  # val - An error string.
  #
  # Returns nothing.
  def transmission_errors=(val)
    super(val[0, self.class.maximum_text_length])
  end

  # Public: Custom getter that delegates and caches notification category to
  # associated notification
  #
  # Returns a notification category string.
  def notification_category
    @cat ||= notification.try(:category)
  end

  # Public: Return associated notification's display category.
  #
  # Returns notification display category string.
  def notification_display_category
    notification.try(:display_category)
  end

  # Public: Skip message dispatch during stage transition. Used when batch
  # dispatching.
  #
  # Returns nothing.
  def stage_without_dispatch!
    return if state == :bounced

    self.dispatch_at = Time.now.utc + delay_for
    self.workflow_state = "staged"
  end

  # Public: Stage the message during the dispatch process. Messages travel
  # from created -> staged -> sending -> sent.
  #
  # Returns nothing.
  def stage_message
    stage if state == :created

    if dashboard?
      messages = Message.in_state(:dashboard).where(
        notification_id:,
        context_id:,
        context_type:,
        user_id:
      )

      (messages - [self]).each(&:close)
    end
  end

  # acts like safe buffer except for the actually being safe part
  class UnescapedBuffer
    def initialize(buffer = "")
      @raw_buffer = String.new(buffer)
      @raw_buffer.encode!
    end

    delegate :concat, :<<, :length, :empty?, :blank?, :encoding, :encode!, :force_encoding, to: :@raw_buffer

    def to_s
      @raw_buffer.dup
    end
    alias_method :html_safe, :to_s
    alias_method :to_str, :to_s

    def html_safe?
      true
    end

    alias_method :append=, :<<
    alias_method :safe_concat, :concat
    alias_method :safe_append=, :concat
  end

  module OutputBufferDeleteSuffix
    def delete_suffix(str)
      self.class.new(@raw_buffer.delete_suffix(str))
    end
  end
  UnescapedBuffer.include(OutputBufferDeleteSuffix)
  ActionView::OutputBuffer.include(OutputBufferDeleteSuffix)

  # Public: Store content in a message_content_... instance variable.
  #
  # name  - The symbol name of the content.
  #
  # Returns an empty string.
  def define_content(name)
    if name == :subject || name == :user_name
      old_output_buffer, @output_buffer = [@output_buffer, UnescapedBuffer.new]
    else
      old_output_buffer, @output_buffer = [@output_buffer, @output_buffer.class.new]
    end

    yield

    instance_variable_set(:"@message_content_#{name}",
                          @output_buffer.to_s.strip)
    @output_buffer = old_output_buffer.delete_suffix("\n")

    if old_output_buffer.is_a?(ActiveSupport::SafeBuffer) && old_output_buffer.html_safe?
      @output_buffer = old_output_buffer.class.new(@output_buffer)
    end

    ""
  end

  # Public: Get a message_content_... instance variable.
  #
  # name - The name of the message content variable as a symbol.
  #
  # Returns value of instance variable (should be a string?).
  def content(name)
    instance_variable_get(:"@message_content_#{name}")
  end

  # Public: Custom getter for @message_content_link.
  #
  # Returns string content from @message_content_link.
  def main_link
    content(:link)
  end

  # Public: Load a message template from app/messages. Also sets @i18n_scope.
  #
  # filename - The string path to the template (e.g. "/var/web/canvas/app/messages/template.email.erb")
  #
  # Returns a template string or false if it can't be found.
  def get_template(filename)
    path = Canvas::MessageHelper.find_message_path(filename)

    unless File.exist?(path)
      return false if filename.include?("slack")

      filename = notification.name.downcase.gsub(/\s/, "_") + ".email.erb"
      path = Canvas::MessageHelper.find_message_path(filename)
    end

    @i18n_scope = "messages." + filename.delete_suffix(".erb")

    if File.exist?(path)
      File.read(path)
    else
      false
    end
  end

  # Public: Get the template name based on the path type.
  #
  # path_type - The path to send the message across, e.g, 'email'.
  #
  # Returns file name for erb template
  def template_filename(path_type = nil)
    notification.name.parameterize.underscore + "." + path_type + ".erb"
  end

  # rubocop:disable Security/Eval -- ERB rendering
  # Public: Apply an HTML email template to this message.
  #
  # Returns an HTML template (or nil).
  def apply_html_template(binding)
    orig_i18n_scope = @i18n_scope
    @i18n_scope = "#{@i18n_scope}.html"
    template, template_path = load_html_template
    return nil unless template

    # Add the attribute 'inner_html' with the value of inner_html into the _binding
    @output_buffer = ActionView::OutputBuffer.new
    inner_html = eval(ActionView::Template::Handlers::ERB::Erubi.new(template, bufvar: "@output_buffer").src, binding, template_path)
    setter = eval "inner_html = nil; lambda { |v| inner_html = v }", binding, __FILE__, __LINE__
    setter.call(inner_html)

    layout_path = Canvas::MessageHelper.find_message_path("_layout.email.html.erb")
    @output_buffer = ActionView::OutputBuffer.new
    eval(ActionView::Template::Handlers::ERB::Erubi.new(File.read(layout_path)).src, binding, layout_path)
  ensure
    @i18n_scope = orig_i18n_scope
  end

  def load_html_template
    html_file = template_filename("email.html")
    html_path = Canvas::MessageHelper.find_message_path(html_file)
    [File.read(html_path), html_path] if File.exist?(html_path)
  end

  # Public: Assign the body, subject and url to the message.
  #
  # message_body_template - Raw template body
  # path_type             - Path to send the message across, e.g, 'email'.
  #
  # Returns message body
  def populate_body(message_body_template, path_type, binding, filename)
    # Build the body content based on the path type
    self.body = eval(Erubi::Engine.new(message_body_template, bufvar: "@output_buffer").src, binding, filename)
    self.html_body = apply_html_template(binding) if path_type == "email"

    # Append a footer to the body if the path type is email
    if path_type == "email"
      footer_path = Canvas::MessageHelper.find_message_path("_email_footer.email.erb")
      raw_footer_message = File.read(footer_path)
      footer_message = eval(Erubi::Engine.new(raw_footer_message, bufvar: "@output_buffer").src, nil, footer_path)
      # currently, _email_footer.email.erb only contains a way for users to change notification prefs
      # they can only change it if they are registered in the first place
      # do not show this for emails telling users to register
      if footer_message.present? && !notification&.registration?
        self.body = <<~TEXT
          #{body}





          ________________________________________

          #{footer_message}
        TEXT
      end
    end

    body
  end

  # Public: Prepare a message for delivery by setting body, subject, etc.
  #
  # path_type - The path to send the message across, e.g, 'email'.
  #
  # Returns nothing.
  def parse!(path_type = nil, root_account: nil)
    raise StandardError, "Cannot parse without a context" unless context

    # set @root_account using our pre_loaded_account, because link_root_account
    # is called many times.
    link_root_account(pre_loaded_account: root_account)
    # Get the users timezone but maintain the original timezone in order to set it back at the end
    original_time_zone = Time.zone.name || "UTC"
    user_time_zone     = user.try(:time_zone) || root_account_time_zone || original_time_zone
    Time.zone          = user_time_zone

    # (temporarily) override course name with user's nickname for the course
    hacked_course = apply_course_nickname_to_asset(context, user)

    path_type ||= communication_channel.try(:path_type) || "email"

    # Determine the message template file to be used in the message
    filename = template_filename(path_type)
    message_body_template = get_template(filename)
    if !message_body_template && path_type == "slack"
      filename = template_filename("sms")
      message_body_template = get_template(filename)
    end

    context, asset, user, delayed_messages, data = [self.context,
      self.context,
self.user,
@delayed_messages,
@data]

    link_root_account.shard.activate do
      if message_body_template.present?
        populate_body(message_body_template, path_type, binding, filename)

        # Set the subject and url
        self.subject = @message_content_subject || t("#message.default_subject", "Canvas Alert")
        self.url     = @message_content_link || nil
      else
        # Message doesn't exist so we flag the message as an error
        self.subject = eval(Erubi::Engine.new(subject).src)
        self.body    = eval(Erubi::Engine.new(body).src)
        self.transmission_errors = "couldn't find #{Canvas::MessageHelper.find_message_path(filename)}"
      end
    end

    body
  ensure
    # Set the timezone back to what it originally was
    Time.zone = original_time_zone if original_time_zone.present?

    hacked_course&.apply_nickname_for!(nil)

    @i18n_scope = nil
  end
  # rubocop:enable Security/Eval

  # Public: Deliver this message.
  #
  # Returns nothing.
  def deliver
    # don't dispatch canceled or already-sent messages.
    return nil unless dispatch

    unless path_type.present?
      logger.warn "Could not find a path type for #{inspect}"
      return nil
    end

    if path_type == "slack" && !context_root_account.settings[:encrypted_slack_key]
      logger.warn("Could not send slack message without configured key")
      return nil
    end

    check_acct = infer_feature_account

    return skip_and_cancel if path_type == "sms"

    if path_type == "push" &&
       (Notification.types_to_send_in_push.exclude?(notification_name) || !check_acct.enable_push_notifications?)
      return skip_and_cancel
    end

    InstStatsd::Statsd.distributed_increment("message.deliver.#{path_type}.#{notification_name}",
                                             short_stat: "message.deliver",
                                             tags: { path_type:, notification_name: })

    global_account_id = Shard.global_id_for(root_account_id, shard)
    InstStatsd::Statsd.increment("message.deliver.#{path_type}.#{global_account_id}",
                                 short_stat: "message.deliver_per_account",
                                 tags: { path_type:, root_account_id: global_account_id })

    if check_acct.feature_enabled?(:notification_service)
      enqueue_to_sqs
    else
      delivery_method = :"deliver_via_#{path_type}"
      if !delivery_method || !respond_to?(delivery_method, true)
        logger.warn("Could not set delivery_method from #{path_type}")
        return nil
      end
      send(delivery_method)
    end
  end

  def skip_and_cancel
    InstStatsd::Statsd.distributed_increment("message.skip.#{path_type}.#{notification_name}",
                                             short_stat: "message.skip",
                                             tags: { path_type:, notification_name: })
    cancel
  end

  # Public: Enqueues a message to the notification_service's sqs queue
  #
  # Returns nothing
  def enqueue_to_sqs
    targets = notification_targets
    if targets.empty?
      # Log no_targets_specified error to DataDog
      InstStatsd::Statsd.distributed_increment("message.no_targets_specified",
                                               short_stat: "message.no_targets_specified",
                                               tags: { path_type: })

      self.transmission_errors = "No notification targets specified"
      set_transmission_error
    else
      targets.each do |target|
        Services::NotificationService.process(
          notification_service_id,
          notification_message,
          path_type,
          target,
          notification&.priority?
        )
      end
      complete_dispatch
    end
  rescue => e
    Canvas::Errors.capture(
      e,
      message: "Message delivery failed",
      to:,
      object: inspect
    )
    error_string = "Exception: #{e.class}: #{e.message}\n\t#{e.backtrace.join("\n\t")}"
    self.transmission_errors = error_string
    errored_dispatch
    raise
  end

  # Public: Determines the message body for a notification endpoint
  #
  # Returns target notification message body
  def notification_message
    case path_type
    when "email"
      Mailer.create_message(self).to_s
    when "push"
      sns_json
    else
      if to =~ /^\+[0-9]+$/ || path_type == "slack"
        body
      else
        Mailer.create_message(self).to_s
      end
    end
  end

  # Public: Returns all notification_service targets to send to
  #
  # Returns the targets in which to send the notification to
  def notification_targets
    case path_type
    when "push"
      # get all unique tokens/arns for the user, preferring the most recently updated ones
      # without the order_by, DISTINCT ON would be non-deterministic
      user.notification_endpoints
          .select("DISTINCT ON (token, arn) *")
          .order(:token, :arn, updated_at: :desc)
          .map(&:arn)
    when "slack"
      [
        "recipient" => to,
        "access_token" => Canvas::Security.decrypt_password(context_root_account.settings[:encrypted_slack_key],
                                                            context_root_account.settings[:encrypted_slack_key_salt],
                                                            "instructure_slack_encrypted_key")
      ]
    else
      [to]
    end
  end

  # Public: Fetch the dashboard messages for the given messages.
  #
  # messages - An array of message objects.
  #
  # Returns an array of dashboard messages.
  def self.dashboard_messages(messages)
    message_types = messages.inject({}) do |types, message|
      type = message.notification&.category || "Other"

      if type.present?
        types[type] ||= []
        types[type] << message
      end

      hash
    end

    # not sure what this is even doing?
    message_types.to_a.sort_by { |m| (m[0] == "Other") ? CanvasSort::Last : m[0] }
  end

  # Public: Message to use if the message is unavailable to send.
  #
  # Returns a string
  def self.unavailable_message
    I18n.t("message preview unavailable")
  end

  # Public: Get the root account of this message's context.
  #
  # Returns an account.
  def context_root_account
    if context.is_a?(AccountNotification)
      return context.account.root_account
    end

    unbounded_loop_paranoia_counter = 10
    current_context                 = context

    until current_context.respond_to?(:root_account)
      return nil if unbounded_loop_paranoia_counter <= 0 || current_context.nil?
      return nil unless current_context.respond_to?(:context)

      current_context = current_context.context
      unbounded_loop_paranoia_counter -= 1
    end

    current_context.root_account
  end

  # This is a dumb name, but it's the context (course/group/account/user) of
  # the message.context (which should really be message.asset)
  def context_context
    @context_context ||= begin
      unbounded_loop_paranoia_counter = 10
      current_context = context

      loop do
        break if unbounded_loop_paranoia_counter.zero? ||
                 current_context.nil? ||
                 current_context.is_a_context?

        current_context = current_context.try(:context)
        unbounded_loop_paranoia_counter -= 1
      end

      current_context
    end
  end

  def media_context
    context = self.context
    context = context.context if context.respond_to?(:context)
    return context if context.is_a?(Course)

    (context.respond_to?(:course) && context.course) ? context.course : link_root_account
  end

  def notification_service_id
    "#{global_id}+#{created_at.to_i}"
  end

  def self.parse_notification_service_id(service_id)
    if service_id.to_s.include?("+")
      service_id.split("+")
    else
      [service_id, nil]
    end
  end

  def custom_logo
    context_root_account && context_root_account.settings[:email_logo]
  end

  # Internal: Set default values before save.
  #
  # Returns true.
  def infer_defaults
    if notification
      self.notification_name ||= notification.name
    end

    self.path_type ||= communication_channel.try(:path_type)
    self.path_type = "summary" if to == "dashboard"
    self.path_type = "email"   if context_type == "ErrorReport"

    self.to_email  = true if %w[email sms].include?(path_type)

    root_account = context_root_account
    self.root_account_id ||= root_account.try(:id)

    self.from_name = infer_from_name
    self.reply_to_name = name_helper.reply_to_name

    true
  end

  # Public: Convenience method for translation calls.
  #
  # key     - The translation key.
  # default - The English default of the key.
  # options - An options hash passed to translate (default: {}).
  #
  # Returns a translated string.
  def translate(*args)
    key, options = I18nliner::CallHelpers.infer_arguments(args)

    # Add scope if it's present in the model and missing from the key.
    if !options[:i18nliner_inferred_key] && @i18n_scope && key !~ /\A#/
      key = "##{@i18n_scope}.#{key}"
    end

    super(key, options)
  end
  alias_method :t, :translate

  # Public: Store data on the message for use at delivery-time.
  #
  # values_hash - A hash of values to store in the model's data attribute.
  #
  # Returns nothing.
  def data=(values_hash)
    @data = OpenStruct.new(values_hash) # rubocop:disable Style/OpenStructUse
  end

  # Public: Before save, close this message if it has no user or a deleted
  # user and isn't for an ErrorReport.
  #
  # Returns nothing.
  def move_messages_for_deleted_users
    if context_type != "ErrorReport" && (!user || user.deleted?)
      self.workflow_state = "closed"
    end
  end

  # Public: Truncate the message if it exceeds 64kb
  #
  # Returns nothing.
  def truncate_invalid_message
    [:body, :html_body].each do |attr|
      if send(attr) && send(attr).bytesize > self.class.maximum_text_length
        send(:"#{attr}=", Message.unavailable_message)
      end
    end
  end

  # Public: Before save, prepare dashboard messages for display on dashboard.
  #
  # Returns nothing.
  def move_dashboard_messages
    if to == "dashboard" && !cancelled? && !closed?
      self.workflow_state = "dashboard"
    end
  end

  # Public: Return the message as JSON filtered to selected fields and
  # flattened appropriately.
  #
  # Returns json hash.
  def as_json(*)
    super(only: %i[id created_at sent_at workflow_state from from_name to reply_to subject body html_body])["message"]
  end

  protected

  # Internal: Choose account to check feature flags on.
  #
  # used to choose which account to trust for inspecting
  # feature state to decide how to send messages.  In general
  # the root account is a good choice, but for a user-context
  # message (which would intentionally have a dummy root account),
  # we want to make sure we aren't inspecting
  # features on the dummy account
  def infer_feature_account
    root_account&.unless_dummy || user&.account || Account.site_admin
  end

  # Internal: Deliver the message through email.
  #
  # Returns nothing.
  # Raises Net::SMTPServerBusy if the message cannot be sent.
  # Raises Timeout::Error if the remote server times out.
  def deliver_via_email
    res = nil
    logger.info "Delivering mail: #{inspect}"
    begin
      res = Mailer.create_message(self).deliver_now
    rescue Net::SMTPServerBusy => e
      @exception = e
      logger.error "Exception: #{e.class}: #{e.message}\n\t#{e.backtrace.join("\n\t")}"
      cancel if e.message.try(:match, /Bad recipient/)
    rescue => e
      @exception = e
      logger.error "Exception: #{e.class}: #{e.message}\n\t#{e.backtrace.join("\n\t")}"
    end
    if res
      complete_dispatch
    elsif @exception
      raise_error = @exception.to_s !~ /^450/
      log_error = raise_error && !@exception.is_a?(Timeout::Error)
      if log_error
        Canvas::Errors.capture(
          @exception,
          message: "Message delivery failed",
          to:,
          object: inspect
        )
      end

      errored_dispatch
      if raise_error
        raise @exception
      else
        return false
      end
    end

    true
  end

  # Internal: Send the message through SMS. This currently sends it via Twilio if the recipient is a E.164 phone
  # number, or via email otherwise.
  #
  # Returns nothing.
  def deliver_via_sms
    if /^\+[0-9]+$/.match?(to)
      begin
        unless user.account.feature_enabled?(:international_sms)
          raise "International SMS is currently disabled for this user's account"
        end

        if Canvas::Twilio.enabled?
          Canvas::Twilio.deliver(
            to,
            body,
            from_recipient_country: true
          )
        end
      rescue => e
        logger.error "Exception: #{e.class}: #{e.message}\n\t#{e.backtrace.join("\n\t")}"
        Canvas::Errors.capture(
          e,
          message: "SMS delivery failed",
          to:,
          object: inspect,
          tags: {
            type: :sms_message
          }
        )
        cancel
      else
        complete_dispatch
      end
    else
      deliver_via_email
    end
  end

  # Internal: Deliver the message using AWS SNS.
  #
  # Returns nothing.
  def deliver_via_push
    user.notification_endpoints.each do |notification_endpoint|
      notification_endpoint.destroy unless notification_endpoint.push_json(sns_json)
    end
    complete_dispatch
  rescue => e
    @exception = e
    error_string = "Exception: #{e.class}: #{e.message}\n\t#{e.backtrace.join("\n\t")}"
    logger.error error_string
    cancel
    raise e
  end

  private

  def outgoing_email_default_name_for_messages
    if root_account && root_account.settings[:outgoing_email_default_name]
      root_account.settings[:outgoing_email_default_name]
    else
      HostUrl.outgoing_email_default_name
    end
  end

  def infer_from_name
    if notification_category == "Summaries"
      return outgoing_email_default_name_for_messages
    end

    if context.is_a?(DiscussionEntry) && context.discussion_topic.anonymous?
      return context.author_name
    end

    return name_helper.from_name if name_helper.from_name.present?

    if name_helper.asset.is_a?(AppointmentGroup) && !(names = name_helper.asset.contexts_for_user(user)).nil?
      names = names.map(&:name).join(", ")
      if names == ""
        return name_helper.asset.context.name
      else
        return names
      end
    end
    return context_context.nickname_for(user) if can_use_name_for_from?(context_context)

    outgoing_email_default_name_for_messages
  end

  def can_use_name_for_from?(c)
    c && !c.is_a?(Account) && notification&.dashboard? &&
      c.respond_to?(:name) && c.name.present?
  end

  def name_helper
    @name_helper ||= Messages::NameHelper.new(
      asset: context,
      message_recipient: user,
      notification_name:
    )
  end

  def apply_course_nickname_to_asset(asset, user)
    hacked_course = if asset.is_a?(Course)
                      asset
                    elsif asset.respond_to?(:context) && asset.context.is_a?(Course)
                      asset.context
                    elsif asset.respond_to?(:course) && asset.course.is_a?(Course)
                      asset.course
                    end
    hacked_course&.apply_nickname_for!(user)
    hacked_course
  end
end
