# frozen_string_literal: true

class TopicUser < ActiveRecord::Base
  belongs_to :user
  belongs_to :topic

  # used for serialization
  attr_accessor :post_action_data

  scope :level, lambda { |topic_id, level|
    where(topic_id: topic_id)
      .where("COALESCE(topic_users.notification_level, :regular) >= :level",
     regular: TopicUser.notification_levels[:regular],
     level: TopicUser.notification_levels[level])
  }

  scope :tracking, lambda { |topic_id|
    level(topic_id, :tracking)
  }

  scope :watching, lambda { |topic_id|
    level(topic_id, :watching)
  }

  # Class methods
  class << self

    # Enums
    def notification_levels
      NotificationLevels.topic_levels
    end

    def notification_reasons
      @notification_reasons ||= Enum.new(created_topic: 1,
                                         user_changed: 2,
                                         user_interacted: 3,
                                         created_post: 4,
                                         auto_watch: 5,
                                         auto_watch_category: 6,
                                         auto_mute_category: 7,
                                         auto_track_category: 8,
                                         plugin_changed: 9,
                                         auto_watch_tag: 10,
                                         auto_mute_tag: 11,
                                         auto_track_tag: 12)
    end

    def auto_notification(user_id, topic_id, reason, notification_level)
      should_change = TopicUser
        .where(user_id: user_id, topic_id: topic_id)
        .where("notifications_reason_id IS NULL OR (notification_level < :max AND notification_level > :min)", max: notification_level, min: notification_levels[:regular])
        .exists?

      change(user_id, topic_id, notification_level: notification_level, notifications_reason_id: reason) if should_change
    end

    def auto_notification_for_staging(user_id, topic_id, reason, notification_level = notification_levels[:watching])
      change(user_id, topic_id, notification_level: notification_level, notifications_reason_id: reason)
    end

    def unwatch_categories!(user, category_ids)
      track_threshold = user.user_option.auto_track_topics_after_msecs

      sql = <<~SQL
        UPDATE topic_users tu
        SET notification_level = CASE
          WHEN t.user_id = :user_id THEN :watching
          WHEN total_msecs_viewed > :track_threshold AND :track_threshold >= 0 THEN :tracking
          ELSE :regular
        end
        FROM topics t
        WHERE t.id = tu.topic_id AND tu.notification_level <> :muted AND category_id IN (:category_ids) AND tu.user_id = :user_id
      SQL

      DB.exec(sql,
        watching: notification_levels[:watching],
        tracking: notification_levels[:tracking],
        regular: notification_levels[:regular],
        muted: notification_levels[:muted],
        category_ids: category_ids,
        user_id: user.id,
        track_threshold: track_threshold
      )
    end

    # Find the information specific to a user in a forum topic
    def lookup_for(user, topics)
      # If the user isn't logged in, there's no last read posts
      return {} if user.blank? || topics.blank?

      topic_ids = topics.map(&:id)
      create_lookup(TopicUser.where(topic_id: topic_ids, user_id: user.id))
    end

    def create_lookup(topic_users)
      topic_users = topic_users.to_a
      result = {}
      return result if topic_users.blank?
      topic_users.each { |ftu| result[ftu.topic_id] = ftu }
      result
    end

    def get(topic, user)
      topic = topic.id if topic.is_a?(Topic)
      user = user.id if user.is_a?(User)
      TopicUser.find_by(topic_id: topic, user_id: user)
    end

    # Change attributes for a user (creates a record when none is present). First it tries an update
    # since there's more likely to be an existing record than not. If the update returns 0 rows affected
    # it then creates the row instead.
    def change(user_id, topic_id, attrs)
      # Sometimes people pass objs instead of the ids. We can handle that.
      topic_id = topic_id.id if topic_id.is_a?(::Topic)
      user_id = user_id.id if user_id.is_a?(::User)

      topic_id = topic_id.to_i
      user_id = user_id.to_i

      TopicUser.transaction do
        attrs = attrs.dup
        if attrs[:notification_level]
          attrs[:notifications_changed_at] ||= DateTime.now
          attrs[:notifications_reason_id] ||= TopicUser.notification_reasons[:user_changed]
        end
        attrs_array = attrs.to_a

        attrs_sql = attrs_array.map { |t| "#{t[0]} = ?" }.join(", ")
        vals = attrs_array.map { |t| t[1] }
        rows = TopicUser.where(topic_id: topic_id, user_id: user_id).update_all([attrs_sql, *vals])

        if rows == 0
          create_missing_record(user_id, topic_id, attrs)
        end
      end

      if attrs[:notification_level]
        notification_level_change(user_id, topic_id, attrs[:notification_level], attrs[:notifications_reason_id])
      end

    rescue ActiveRecord::RecordNotUnique
      # In case of a race condition to insert, do nothing
    end

    def notification_level_change(user_id, topic_id, notification_level, reason_id)
      message = { notification_level_change: notification_level }
      message[:notifications_reason_id] = reason_id if reason_id
      MessageBus.publish("/topic/#{topic_id}", message, user_ids: [user_id])

      DiscourseEvent.trigger(:topic_notification_level_changed,
        notification_level,
        user_id,
        topic_id
      )

    end

    def create_missing_record(user_id, topic_id, attrs)
      now = DateTime.now

      unless attrs[:notification_level]
        category_notification_level = CategoryUser.where(user_id: user_id)
          .where("category_id IN (SELECT category_id FROM topics WHERE id = :id)", id: topic_id)
          .where("notification_level IN (:levels)", levels: [CategoryUser.notification_levels[:watching],
                        CategoryUser.notification_levels[:tracking]])
          .order("notification_level DESC")
          .limit(1)
          .pluck(:notification_level)
          .first

        tag_notification_level = TagUser.where(user_id: user_id)
          .where("tag_id IN (SELECT tag_id FROM topic_tags WHERE topic_id = :id)", id: topic_id)
          .where("notification_level IN (:levels)", levels: [CategoryUser.notification_levels[:watching],
                        CategoryUser.notification_levels[:tracking]])
          .order("notification_level DESC")
          .limit(1)
          .pluck(:notification_level)
          .first

        if category_notification_level && !(tag_notification_level && (tag_notification_level > category_notification_level))
          attrs[:notification_level] = category_notification_level
          attrs[:notifications_changed_at] = DateTime.now
          attrs[:notifications_reason_id] = category_notification_level == CategoryUser.notification_levels[:watching] ?
              TopicUser.notification_reasons[:auto_watch_category] :
              TopicUser.notification_reasons[:auto_track_category]

        elsif tag_notification_level
          attrs[:notification_level] = tag_notification_level
          attrs[:notifications_changed_at] = DateTime.now
          attrs[:notifications_reason_id] = tag_notification_level == TagUser.notification_levels[:watching] ?
              TopicUser.notification_reasons[:auto_watch_tag] :
              TopicUser.notification_reasons[:auto_track_tag]
        end

      end

      unless attrs[:notification_level]
        if Topic.private_messages.where(id: topic_id).exists? &&
           Notification.where(
             user_id: user_id,
             topic_id: topic_id,
             notification_type: Notification.types[:invited_to_private_message]
           ).exists?

          group_notification_level = Group
            .joins("LEFT OUTER JOIN group_users gu ON gu.group_id = groups.id AND gu.user_id = #{user_id}")
            .joins("LEFT OUTER JOIN topic_allowed_groups tag ON tag.topic_id = #{topic_id}")
            .where("gu.id IS NOT NULL AND tag.id IS NOT NULL")
            .pluck(:default_notification_level)
            .first

          if group_notification_level.present?
            attrs[:notification_level] = group_notification_level
          else
            attrs[:notification_level] = notification_levels[:watching]
          end
        else
          auto_track_after = UserOption.where(user_id: user_id).pluck_first(:auto_track_topics_after_msecs)
          auto_track_after ||= SiteSetting.default_other_auto_track_topics_after_msecs

          if auto_track_after >= 0 && auto_track_after <= (attrs[:total_msecs_viewed].to_i || 0)
            attrs[:notification_level] ||= notification_levels[:tracking]
          end
        end
      end

      TopicUser.create!(attrs.merge!(
        user_id: user_id,
        topic_id: topic_id,
        first_visited_at: now ,
        last_visited_at: now
      ))
    end

    def track_visit!(topic_id, user_id)
      now = DateTime.now
      rows = TopicUser.where(topic_id: topic_id, user_id: user_id).update_all(last_visited_at: now)

      if rows == 0
        change(user_id, topic_id, last_visited_at: now, first_visited_at: now)
      end
    end

    # Update the last read and the last seen post count, but only if it doesn't exist.
    # This would be a lot easier if psql supported some kind of upsert
    UPDATE_TOPIC_USER_SQL = "UPDATE topic_users
                                    SET
                                      last_read_post_number = GREATEST(:post_number, tu.last_read_post_number),
                                      highest_seen_post_number = t.highest_post_number,
                                      total_msecs_viewed = LEAST(tu.total_msecs_viewed + :msecs,86400000),
                                      notification_level =
                                         case when tu.notifications_reason_id is null and (tu.total_msecs_viewed + :msecs) >
                                            coalesce(uo.auto_track_topics_after_msecs,:threshold) and
                                            coalesce(uo.auto_track_topics_after_msecs, :threshold) >= 0
                                            and t.archetype = 'regular' then
                                              :tracking
                                         else
                                            tu.notification_level
                                         end
                                  FROM topic_users tu
                                  join topics t on t.id = tu.topic_id
                                  join users u on u.id = :user_id
                                  join user_options uo on uo.user_id = :user_id
                                  WHERE
                                       tu.topic_id = topic_users.topic_id AND
                                       tu.user_id = topic_users.user_id AND
                                       tu.topic_id = :topic_id AND
                                       tu.user_id = :user_id
                                  RETURNING
                                    topic_users.notification_level, tu.notification_level old_level, tu.last_read_post_number
                                "

    UPDATE_TOPIC_USER_SQL_STAFF = UPDATE_TOPIC_USER_SQL.gsub("highest_post_number", "highest_staff_post_number")

    INSERT_TOPIC_USER_SQL = "INSERT INTO topic_users (user_id, topic_id, last_read_post_number, highest_seen_post_number, last_visited_at, first_visited_at, notification_level)
                  SELECT :user_id, :topic_id, :post_number, ft.highest_post_number, :now, :now, :new_status
                  FROM topics AS ft
                  JOIN users u on u.id = :user_id
                  WHERE ft.id = :topic_id
                    AND NOT EXISTS(SELECT 1
                                   FROM topic_users AS ftu
                                   WHERE ftu.user_id = :user_id and ftu.topic_id = :topic_id)"

    INSERT_TOPIC_USER_SQL_STAFF = INSERT_TOPIC_USER_SQL.gsub("highest_post_number", "highest_staff_post_number")

    def update_last_read(user, topic_id, post_number, new_posts_read, msecs, opts = {})
      return if post_number.blank?
      msecs = 0 if msecs.to_i < 0

      args = {
        user_id: user.id,
        topic_id: topic_id,
        post_number: post_number,
        now: DateTime.now,
        msecs: msecs,
        tracking: notification_levels[:tracking],
        threshold: SiteSetting.default_other_auto_track_topics_after_msecs
      }

      # In case anyone seens "highest_seen_post_number" and gets confused, like I do.
      # highest_seen_post_number represents the highest_post_number of the topic when
      # the user visited it. It may be out of alignment with last_read, meaning
      # ... user visited the topic but did not read the posts
      #
      # 86400000 = 1 day
      rows =
        if user.staff?
          DB.query(UPDATE_TOPIC_USER_SQL_STAFF, args)
        else
          DB.query(UPDATE_TOPIC_USER_SQL, args)
        end

      if rows.length == 1
        before = rows[0].old_level.to_i
        after = rows[0].notification_level.to_i
        before_last_read = rows[0].last_read_post_number.to_i

        if before_last_read < post_number
          # The user read at least one new post
          TopicTrackingState.publish_read(topic_id, post_number, user.id, after)
        end

        if new_posts_read > 0
          user.update_posts_read!(new_posts_read, mobile: opts[:mobile])
        end

        if before != after
          notification_level_change(user.id, topic_id, after, nil)
        end
      end

      if rows.length == 0
        # The user read at least one post in a topic that they haven't viewed before.
        args[:new_status] = notification_levels[:regular]
        if (user.user_option.auto_track_topics_after_msecs || SiteSetting.default_other_auto_track_topics_after_msecs) == 0
          args[:new_status] = notification_levels[:tracking]
        end
        TopicTrackingState.publish_read(topic_id, post_number, user.id, args[:new_status])

        user.update_posts_read!(new_posts_read, mobile: opts[:mobile])

        begin
          if user.staff?
            DB.exec(INSERT_TOPIC_USER_SQL_STAFF, args)
          else
            DB.exec(INSERT_TOPIC_USER_SQL, args)
          end
        rescue PG::UniqueViolation
          # if record is inserted between two statements this can happen
          # we retry once to avoid failing the req
          if opts[:retry]
            raise
          else
            opts[:retry] = true
            update_last_read(user, topic_id, post_number, new_posts_read, msecs, opts)
          end
        end

        notification_level_change(user.id, topic_id, args[:new_status], nil)
      end
    end

  end

  def self.update_post_action_cache(opts = {})
    user_id = opts[:user_id]
    post_id = opts[:post_id]
    topic_id = opts[:topic_id]
    action_type = opts[:post_action_type]

    action_type_name = "liked" if action_type == :like
    action_type_name = "bookmarked" if action_type == :bookmark

    raise ArgumentError, "action_type" if action_type && !action_type_name

    unless action_type_name
      update_post_action_cache(opts.merge(post_action_type: :like))
      update_post_action_cache(opts.merge(post_action_type: :bookmark))
      return
    end

    builder = DB.build <<~SQL
      UPDATE topic_users tu
      SET #{action_type_name} = x.state
      FROM (
        SELECT CASE WHEN EXISTS (
          SELECT 1
          FROM post_actions pa
          JOIN posts p on p.id = pa.post_id
          JOIN topics t ON t.id = p.topic_id
          WHERE pa.deleted_at IS NULL AND
                p.deleted_at IS NULL AND
                t.deleted_at IS NULL AND
                pa.post_action_type_id = :action_type_id AND
                tu2.topic_id = t.id AND
                tu2.user_id = pa.user_id
          LIMIT 1
        ) THEN true ELSE false END state, tu2.topic_id, tu2.user_id
        FROM topic_users tu2
        /*where*/
      ) x
      WHERE x.topic_id = tu.topic_id AND x.user_id = tu.user_id AND x.state != tu.#{action_type_name}
    SQL

    if user_id
      builder.where("tu2.user_id = :user_id", user_id: user_id)
    end

    if topic_id
      builder.where("tu2.topic_id = :topic_id", topic_id: topic_id)
    end

    if post_id
      builder.where("tu2.topic_id IN (SELECT topic_id FROM posts WHERE id = :post_id)", post_id: post_id)
      builder.where("tu2.user_id IN (SELECT user_id FROM post_actions
                                     WHERE post_id = :post_id AND
                                           post_action_type_id = :action_type_id)")
    end

    builder.exec(action_type_id: PostActionType.types[action_type])
  end

  # cap number of unread topics at count, bumping up highest_seen / last_read if needed
  def self.cap_unread!(user_id, count)
    sql = <<SQL
    UPDATE topic_users tu
    SET last_read_post_number = max_number,
        highest_seen_post_number = max_number
    FROM (
      SELECT MAX(post_number) max_number, p.topic_id FROM posts p
      WHERE deleted_at IS NULL
      GROUP BY p.topic_id
    ) m
    WHERE tu.user_id = :user_id AND
          m.topic_id = tu.topic_id AND
          tu.topic_id IN (
            #{TopicTrackingState.report_raw_sql(skip_new: true, select: "topics.id")}
            offset :count
          )
SQL

    DB.exec(sql, user_id: user_id, count: count)
  end

  def self.ensure_consistency!(topic_id = nil)
    update_post_action_cache(topic_id: topic_id)

    # TODO this needs some reworking, when we mark stuff skipped
    # we up these numbers so they are not in-sync
    # the simple fix is to add a column here, but table is already quite big
    # long term we want to split up topic_users and allow for this better
    builder = DB.build <<~SQL
      UPDATE topic_users t
        SET
          last_read_post_number = LEAST(GREATEST(last_read, last_read_post_number), max_post_number),
          highest_seen_post_number = LEAST(max_post_number,GREATEST(t.highest_seen_post_number, last_read))
      FROM (
        SELECT topic_id, user_id, MAX(post_number) last_read
        FROM post_timings
        GROUP BY topic_id, user_id
      ) as X
      JOIN (
        SELECT p.topic_id, MAX(p.post_number) max_post_number from posts p
        GROUP BY p.topic_id
      ) as Y on Y.topic_id = X.topic_id
      /*where*/
    SQL

    builder.where <<~SQL
      X.topic_id = t.topic_id AND
      X.user_id = t.user_id AND
      (
        last_read_post_number <> LEAST(GREATEST(last_read, last_read_post_number), max_post_number) OR
        highest_seen_post_number <> LEAST(max_post_number,GREATEST(t.highest_seen_post_number, last_read))
      )
    SQL

    if topic_id
      builder.where("t.topic_id = :topic_id", topic_id: topic_id)
    end

    builder.exec
  end

end

# == Schema Information
#
# Table name: topic_users
#
#  user_id                  :integer          not null
#  topic_id                 :integer          not null
#  posted                   :boolean          default(FALSE), not null
#  last_read_post_number    :integer
#  highest_seen_post_number :integer
#  last_visited_at          :datetime
#  first_visited_at         :datetime
#  notification_level       :integer          default(1), not null
#  notifications_changed_at :datetime
#  notifications_reason_id  :integer
#  total_msecs_viewed       :integer          default(0), not null
#  cleared_pinned_at        :datetime
#  id                       :integer          not null, primary key
#  last_emailed_post_number :integer
#  liked                    :boolean          default(FALSE)
#  bookmarked               :boolean          default(FALSE)
#
# Indexes
#
#  index_topic_users_on_topic_id_and_user_id  (topic_id,user_id) UNIQUE
#  index_topic_users_on_user_id_and_topic_id  (user_id,topic_id) UNIQUE
#
