# frozen_string_literal: true

class PostMover
  attr_reader :original_topic, :destination_topic, :user, :post_ids

  def self.move_types
    @move_types ||= Enum.new(:new_topic, :existing_topic)
  end

  def initialize(original_topic, user, post_ids, move_to_pm: false)
    @original_topic = original_topic
    @user = user
    @post_ids = post_ids
    @move_to_pm = move_to_pm
  end

  def to_topic(id, participants: nil, chronological_order: false)
    @move_type = PostMover.move_types[:existing_topic]
    @chronological_order = chronological_order

    topic = Topic.find_by_id(id)
    if topic.archetype != @original_topic.archetype &&
         [@original_topic.archetype, topic.archetype].include?(Archetype.private_message)
      raise Discourse::InvalidParameters
    end

    Topic.transaction { move_posts_to topic }
    add_allowed_users(participants) if participants.present? && @move_to_pm
    enqueue_jobs(topic)
    topic
  end

  def to_new_topic(title, category_id = nil, tags = nil)
    @move_type = PostMover.move_types[:new_topic]

    post = Post.find_by(id: post_ids.first)
    raise Discourse::InvalidParameters unless post
    archetype = @move_to_pm ? Archetype.private_message : Archetype.default

    topic =
      Topic.transaction do
        new_topic =
          Topic.create!(
            user: post.user,
            title: title,
            category_id: category_id,
            created_at: post.created_at,
            archetype: archetype,
          )
        DiscourseTagging.tag_topic_by_names(new_topic, Guardian.new(user), tags)
        move_posts_to new_topic
        watch_new_topic
        update_topic_excerpt new_topic
        new_topic
      end
    enqueue_jobs(topic)
    topic
  end

  private

  def update_topic_excerpt(topic)
    topic.update_excerpt(topic.first_post.excerpt_for_topic)
  end

  def move_posts_to(topic)
    Guardian.new(user).ensure_can_see! topic
    @destination_topic = topic

    # when a topic contains some posts after moving posts to another topic we shouldn't close it
    # two types of posts should prevent a topic from closing:
    #   1. regular posts
    #   2. almost all whispers
    # we should only exclude whispers with action_code: 'split_topic'
    # because we use such whispers as a small-action posts when moving posts to the secret message
    # (in this case we don't want everyone to see that posts were moved, that's why we use whispers)
    original_topic_posts_count =
      @original_topic
        .posts
        .where(
          "post_type = ? or (post_type = ? and action_code != 'split_topic')",
          Post.types[:regular],
          Post.types[:whisper],
        )
        .count
    moving_all_posts = original_topic_posts_count == posts.length

    @first_post_number_moved =
      posts.first.is_first_post? ? posts[1]&.post_number : posts.first.post_number

    create_temp_table
    move_each_post
    handle_moved_references

    create_moderator_post_in_original_topic
    update_statistics
    update_user_actions
    update_last_post_stats
    update_upload_security_status
    update_bookmarks

    close_topic_and_schedule_deletion if moving_all_posts

    destination_topic.reload
    destination_topic
  end

  def create_temp_table
    DB.exec("DROP TABLE IF EXISTS moved_posts") if Rails.env.test?

    DB.exec <<~SQL
      CREATE TEMPORARY TABLE moved_posts (
        old_topic_id INTEGER,
        old_post_id INTEGER,
        old_post_number INTEGER,
        new_topic_id INTEGER,
        new_topic_title VARCHAR,
        new_post_id INTEGER,
        new_post_number INTEGER
      ) ON COMMIT DROP;

      CREATE INDEX moved_posts_old_post_number ON moved_posts(old_post_number);
      CREATE INDEX moved_posts_old_post_id ON moved_posts(old_post_id);
    SQL
  end

  def handle_moved_references
    move_incoming_emails
    move_notifications
    update_reply_counts
    update_quotes
    move_first_post_replies
    delete_post_replies
    copy_shifted_post_timings_to_temp
    delete_invalid_post_timings
    copy_shifted_post_timings_from_temp
    move_post_timings
    copy_first_post_timings
    copy_topic_users
  end

  def move_each_post
    if @chronological_order
      move_each_post_chronological
    else
      move_each_post_sequential
    end
  end

  def move_each_post_sequential
    max_post_number = destination_topic.max_post_number + 1

    @post_creator = nil
    @move_map = {}
    @reply_count = {}
    posts.each_with_index do |post, offset|
      @move_map[post.post_number] = offset + max_post_number

      if post.reply_to_post_number.present?
        @reply_count[post.reply_to_post_number] = (@reply_count[post.reply_to_post_number] || 0) + 1
      end
    end

    posts.each do |post|
      metadata = movement_metadata(post, new_post_number: @move_map[post.post_number])
      new_post = post.is_first_post? ? create_first_post(post) : move(post)

      store_movement(metadata, new_post)

      if @move_to_pm && !destination_topic.topic_allowed_users.exists?(user_id: post.user_id)
        destination_topic.topic_allowed_users.create!(user_id: post.user_id)
      end
    end
  end

  def move_each_post_chronological
    destination_posts = destination_topic.ordered_posts.with_deleted

    # drops posts from destination_topic until it finds one that was created after posts.first
    min_created_at = posts.first.created_at
    moved_posts = destination_posts.drop_while { |post| post.created_at <= min_created_at }

    # if no post in destination_topic was created after posts.first it's equal to sequential
    if moved_posts.empty?
      initial_post_number = destination_topic.max_post_number + 1
    else
      initial_post_number = moved_posts.first.post_number
    end

    last_index = 0
    posts.each do |post|
      while last_index < moved_posts.length && moved_posts[last_index].created_at <= post.created_at
        last_index += 1
      end

      moved_posts.insert(last_index, post)
    end

    @post_creator = nil
    @move_map = {}
    @shift_map = {}
    @reply_count = {}
    next_post_number = initial_post_number
    moved_posts.each do |post|
      if post.topic_id == destination_topic.id
        # avoid shifting to a lower post number
        next_post_number = post.post_number if post.post_number > next_post_number

        @shift_map[post.post_number] = next_post_number
      else
        @move_map[post.post_number] = next_post_number

        if post.reply_to_post_number.present?
          @reply_count[post.reply_to_post_number] = (@reply_count[post.reply_to_post_number] || 0) +
            1
        end
      end

      next_post_number += 1
    end

    moved_posts.reverse_each do |post|
      if post.topic_id == destination_topic.id
        metadata = movement_metadata(post, new_post_number: @shift_map[post.post_number])
        new_post = move_same_topic(post)
      else
        metadata = movement_metadata(post, new_post_number: @move_map[post.post_number])
        new_post = post.is_first_post? ? create_first_post(post) : move(post)

        if @move_to_pm && !destination_topic.topic_allowed_users.exists?(user_id: post.user_id)
          destination_topic.topic_allowed_users.create!(user_id: post.user_id)
        end
      end

      store_movement(metadata, new_post)
    end

    # change topic owner if there's a new first post
    destination_topic.update_column(:user_id, posts.first.user_id) if initial_post_number == 1
  end

  def create_first_post(post)
    @post_creator =
      PostCreator.new(
        post.user,
        raw: post.raw,
        topic_id: destination_topic.id,
        acting_user: user,
        cook_method: post.cook_method,
        via_email: post.via_email,
        raw_email: post.raw_email,
        skip_validations: true,
        created_at: post.created_at,
        guardian: Guardian.new(user),
        skip_jobs: true,
      )
    new_post = @post_creator.create!

    move_email_logs(post, new_post)

    PostAction.copy(post, new_post)

    PostRevision.copy(post, new_post)

    attrs_to_update = {
      reply_count: @reply_count[1] || 0,
      version: post.version,
      public_version: post.public_version,
    }

    if new_post.post_number != @move_map[post.post_number]
      attrs_to_update[:post_number] = @move_map[post.post_number]
      attrs_to_update[:sort_order] = @move_map[post.post_number]
    end

    new_post.update_columns(attrs_to_update)
    new_post.custom_fields = post.custom_fields
    new_post.save_custom_fields

    DiscourseEvent.trigger(:first_post_moved, new_post, post)
    DiscourseEvent.trigger(:post_moved, new_post, original_topic.id)

    # we don't want to keep the old topic's OP bookmarked when we are
    # moving it into a new topic
    Bookmark.where(bookmarkable: post).update_all(bookmarkable_id: new_post.id)

    new_post
  end

  def move(post)
    update = {
      reply_count: @reply_count[post.post_number] || 0,
      post_number: @move_map[post.post_number],
      reply_to_post_number: @move_map[post.reply_to_post_number],
      topic_id: destination_topic.id,
      sort_order: @move_map[post.post_number],
      baked_version: nil,
    }

    update[:reply_to_user_id] = nil unless @move_map[post.reply_to_post_number]

    post.attributes = update
    post.save(validate: false)

    DiscourseEvent.trigger(:post_moved, post, original_topic.id)

    # Move any links from the post to the new topic
    post.topic_links.update_all(topic_id: destination_topic.id)

    post
  end

  def move_same_topic(post)
    update = {
      post_number: @shift_map[post.post_number],
      sort_order: @shift_map[post.post_number],
      baked_version: nil,
    }

    if @shift_map[post.reply_to_post_number]
      update[:reply_to_post_number] = @shift_map[post.reply_to_post_number]
    end

    post.attributes = update
    post.save(validate: false)

    post
  end

  def movement_metadata(post, new_post_number: nil)
    {
      old_topic_id: post.topic_id,
      old_post_id: post.id,
      old_post_number: post.post_number,
      new_topic_id: destination_topic.id,
      new_post_number: new_post_number,
      new_topic_title: destination_topic.title,
    }
  end

  def store_movement(metadata, new_post)
    metadata[:new_post_id] = new_post.id

    DB.exec(<<~SQL, metadata)
      INSERT INTO moved_posts(old_topic_id, old_post_id, old_post_number, new_topic_id, new_topic_title, new_post_id, new_post_number)
      VALUES (:old_topic_id, :old_post_id, :old_post_number, :new_topic_id, :new_topic_title, :new_post_id, :new_post_number)
    SQL
  end

  def move_incoming_emails
    DB.exec <<~SQL
      UPDATE incoming_emails ie
      SET topic_id = mp.new_topic_id,
          post_id = mp.new_post_id
      FROM moved_posts mp
      WHERE ie.topic_id = mp.old_topic_id AND ie.post_id = mp.old_post_id
        AND mp.old_topic_id <> mp.new_topic_id
    SQL
  end

  def move_email_logs(old_post, new_post)
    EmailLog.where(post_id: old_post.id).update_all(post_id: new_post.id)
  end

  def move_notifications
    DB.exec <<~SQL
      UPDATE notifications n
      SET topic_id  = mp.new_topic_id,
        post_number = mp.new_post_number,
        data        = (data :: JSONB ||
          jsonb_strip_nulls(
              jsonb_build_object(
                  'topic_title', CASE WHEN data :: JSONB ->> 'topic_title' IS NULL
                                        THEN NULL
                                      ELSE mp.new_topic_title END
                )
            )) :: JSON
      FROM moved_posts mp
      WHERE n.topic_id = mp.old_topic_id AND n.post_number = mp.old_post_number
        AND n.notification_type <> #{Notification.types[:watching_first_post]}
    SQL
  end

  def update_reply_counts
    DB.exec <<~SQL
      UPDATE posts p
      SET reply_count = GREATEST(0, reply_count - x.moved_reply_count)
      FROM (
        SELECT r.post_id, mp.new_topic_id, COUNT(1) AS moved_reply_count
        FROM moved_posts mp
               JOIN post_replies r ON (mp.old_post_id = r.reply_post_id)
        GROUP BY r.post_id, mp.new_topic_id
      ) x
      WHERE x.post_id = p.id AND x.new_topic_id <> p.topic_id
    SQL
  end

  def update_quotes
    DB.exec <<~SQL
      UPDATE posts p
      SET raw = REPLACE(p.raw,
                        ', post:' || mp.old_post_number || ', topic:' || mp.old_topic_id,
                        ', post:' || mp.new_post_number || ', topic:' || mp.new_topic_id),
          baked_version = NULL
      FROM moved_posts mp, quoted_posts qp
      WHERE p.id = qp.post_id AND mp.old_post_id = qp.quoted_post_id
    SQL
  end

  def move_first_post_replies
    DB.exec <<~SQL
      UPDATE post_replies pr
      SET post_id = mp.new_post_id
      FROM moved_posts mp
      WHERE mp.old_post_id <> mp.new_post_id AND pr.post_id = mp.old_post_id AND
        EXISTS (SELECT 1 FROM moved_posts mr WHERE mr.new_post_id = pr.reply_post_id)
    SQL
  end

  def delete_post_replies
    DB.exec <<~SQL
      DELETE FROM post_replies pr USING moved_posts mp
      WHERE (SELECT topic_id FROM posts WHERE id = pr.post_id) <>
            (SELECT topic_id FROM posts WHERE id = pr.reply_post_id)
        AND (pr.reply_post_id = mp.old_post_id OR pr.post_id = mp.old_post_id)
    SQL
  end

  def copy_shifted_post_timings_to_temp
    DB.exec("DROP TABLE IF EXISTS temp_post_timings") if Rails.env.test?

    # copy post_timings for shifted posts to a temp table using the new_post_number
    # they'll be copied back after delete_invalid_post_timings makes room for them
    DB.exec <<~SQL
      CREATE TEMPORARY TABLE temp_post_timings ON COMMIT DROP
        AS (
          SELECT pt.topic_id, mp.new_post_number as post_number, pt.user_id, pt.msecs
          FROM post_timings pt
          JOIN moved_posts mp
            ON mp.old_topic_id = pt.topic_id
              AND mp.old_post_number = pt.post_number
              AND mp.old_topic_id = mp.new_topic_id
        )
    SQL
  end

  def copy_shifted_post_timings_from_temp
    DB.exec <<~SQL
      INSERT INTO post_timings (topic_id, user_id, post_number, msecs)
      SELECT topic_id, user_id, post_number, msecs FROM temp_post_timings
    SQL
  end

  def copy_first_post_timings
    DB.exec <<~SQL
      INSERT INTO post_timings (topic_id, user_id, post_number, msecs)
      SELECT mp.new_topic_id, pt.user_id, mp.new_post_number, pt.msecs
      FROM post_timings pt
           JOIN moved_posts mp ON (pt.topic_id = mp.old_topic_id AND pt.post_number = mp.old_post_number)
      WHERE mp.old_post_id <> mp.new_post_id
      ON CONFLICT (topic_id, post_number, user_id) DO UPDATE
        SET msecs = GREATEST(post_timings.msecs, excluded.msecs)
    SQL
  end

  def delete_invalid_post_timings
    DB.exec <<~SQL
      DELETE
      FROM post_timings pt
      USING moved_posts mp
      WHERE pt.topic_id = mp.new_topic_id
        AND pt.post_number = mp.new_post_number
    SQL
  end

  def move_post_timings
    DB.exec <<~SQL
      UPDATE post_timings pt
      SET topic_id    = mp.new_topic_id,
          post_number = mp.new_post_number
      FROM moved_posts mp
      WHERE pt.topic_id = mp.old_topic_id
        AND pt.post_number = mp.old_post_number
        AND mp.old_post_id = mp.new_post_id
        AND mp.old_topic_id <> mp.new_topic_id
    SQL
  end

  def copy_topic_users
    params = {
      old_topic_id: original_topic.id,
      new_topic_id: destination_topic.id,
      old_highest_post_number: destination_topic.highest_post_number,
      old_highest_staff_post_number: destination_topic.highest_staff_post_number,
    }

    DB.exec(<<~SQL, params)
      INSERT INTO topic_users(user_id, topic_id, posted, last_read_post_number,
                              last_emailed_post_number, first_visited_at, last_visited_at, notification_level,
                              notifications_changed_at, notifications_reason_id)
      SELECT tu.user_id,
             :new_topic_id                               AS topic_id,
               EXISTS(
                 SELECT 1
                 FROM posts p
                 WHERE p.topic_id = :new_topic_id
                   AND p.user_id = tu.user_id
                 LIMIT 1
               )                                         AS posted,
             (
               SELECT MAX(lr.new_post_number)
               FROM moved_posts lr
               WHERE lr.old_topic_id = tu.topic_id
                 AND lr.old_post_number <= tu.last_read_post_number
                 AND lr.old_topic_id <> lr.new_topic_id
             )                                           AS last_read_post_number,
             (
               SELECT MAX(le.new_post_number)
               FROM moved_posts le
               WHERE le.old_topic_id = tu.topic_id
                 AND le.old_post_number <= tu.last_emailed_post_number
                 AND le.old_topic_id <> le.new_topic_id
             )                                           AS last_emailed_post_number,
             GREATEST(tu.first_visited_at, t.created_at) AS first_visited_at,
             GREATEST(tu.last_visited_at, t.created_at)  AS last_visited_at,
             tu.notification_level,
             tu.notifications_changed_at,
             tu.notifications_reason_id
      FROM topic_users tu
           JOIN topics t ON (t.id = :new_topic_id)
      WHERE tu.topic_id = :old_topic_id
        AND GREATEST(
                tu.last_read_post_number,
                tu.last_emailed_post_number
              ) >= (SELECT MIN(mp.old_post_number) FROM moved_posts mp WHERE mp.old_topic_id <> mp.new_topic_id)
      ON CONFLICT (topic_id, user_id) DO UPDATE
        SET posted                   = excluded.posted,
            last_read_post_number    = CASE
                                         WHEN topic_users.last_read_post_number = :old_highest_staff_post_number OR (
                                             :old_highest_post_number < :old_highest_staff_post_number
                                             AND topic_users.last_read_post_number = :old_highest_post_number
                                             AND NOT EXISTS(SELECT 1
                                                            FROM users u
                                                            WHERE u.id = topic_users.user_id
                                                              AND (admin OR moderator))
                                           ) THEN
                                           GREATEST(topic_users.last_read_post_number,
                                                    excluded.last_read_post_number)
                                         ELSE topic_users.last_read_post_number END,
            last_emailed_post_number = CASE
                                         WHEN topic_users.last_emailed_post_number = :old_highest_staff_post_number OR (
                                             :old_highest_post_number < :old_highest_staff_post_number
                                             AND topic_users.last_emailed_post_number = :old_highest_post_number
                                             AND NOT EXISTS(SELECT 1
                                                            FROM users u
                                                            WHERE u.id = topic_users.user_id
                                                              AND (admin OR moderator))
                                           ) THEN
                                           GREATEST(topic_users.last_emailed_post_number,
                                                    excluded.last_emailed_post_number)
                                         ELSE topic_users.last_emailed_post_number END,
            first_visited_at         = LEAST(topic_users.first_visited_at, excluded.first_visited_at),
            last_visited_at          = GREATEST(topic_users.last_visited_at, excluded.last_visited_at)
    SQL
  end

  def update_statistics
    destination_topic.update_statistics
    original_topic.update_statistics
    TopicUser.update_post_action_cache(
      topic_id: [original_topic.id, destination_topic.id],
      post_id: @post_ids,
    )
  end

  def update_user_actions
    UserAction.synchronize_target_topic_ids(posts.map(&:id))
  end

  def create_moderator_post_in_original_topic
    move_type_str = PostMover.move_types[@move_type].to_s
    move_type_str.sub!("topic", "message") if @move_to_pm

    message =
      I18n.with_locale(SiteSetting.default_locale) do
        I18n.t(
          "move_posts.#{move_type_str}_moderator_post",
          count: posts.length,
          topic_link:
            (
              if posts.first.is_first_post?
                "[#{destination_topic.title}](#{destination_topic.relative_url})"
              else
                "[#{destination_topic.title}](#{posts.first.relative_url})"
              end
            ),
        )
      end

    post_type = @move_to_pm ? Post.types[:whisper] : Post.types[:small_action]
    original_topic.add_moderator_post(
      user,
      message,
      post_type: post_type,
      action_code: "split_topic",
      post_number: @first_post_number_moved,
    )
  end

  def posts
    @posts ||=
      begin
        Post
          .where(topic: @original_topic, id: post_ids)
          .where.not(post_type: Post.types[:small_action])
          .where.not(raw: "")
          .order(:created_at)
          .tap { |posts| raise Discourse::InvalidParameters.new(:post_ids) if posts.empty? }
      end
  end

  def update_last_post_stats
    post = destination_topic.ordered_posts.where.not(post_type: Post.types[:whisper]).last
    if post && post_ids.include?(post.id)
      attrs = {}
      attrs[:last_posted_at] = post.created_at
      attrs[:last_post_user_id] = post.user_id
      attrs[:bumped_at] = Time.now
      attrs[:updated_at] = Time.now
      destination_topic.update_columns(attrs)
    end
  end

  def update_upload_security_status
    DB.after_commit { Jobs.enqueue(:update_topic_upload_security, topic_id: @destination_topic.id) }
  end

  def update_bookmarks
    DB.after_commit do
      Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: @original_topic.id)
      Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: @destination_topic.id)
    end
  end

  def watch_new_topic
    if @destination_topic.archetype == Archetype.private_message
      if @original_topic.archetype == Archetype.private_message
        notification_levels =
          TopicUser
            .where(topic_id: @original_topic.id, user_id: posts.pluck(:user_id))
            .pluck(:user_id, :notification_level)
            .to_h
      else
        notification_levels =
          posts
            .pluck(:user_id)
            .uniq
            .map { |user_id| [user_id, TopicUser.notification_levels[:watching]] }
            .to_h
      end
    else
      notification_levels = [[@destination_topic.user_id, TopicUser.notification_levels[:watching]]]
    end

    notification_levels.each do |user_id, notification_level|
      TopicUser.change(
        user_id,
        @destination_topic.id,
        notification_level: notification_level,
        notifications_reason_id:
          TopicUser.notification_reasons[
            destination_topic.user_id == user_id ? :created_topic : :created_post
          ],
      )
    end
  end

  def add_allowed_users(usernames)
    return if usernames.blank?

    names = usernames.split(",").flatten
    User
      .where(username: names)
      .find_each do |user|
        unless destination_topic.topic_allowed_users.where(user_id: user.id).exists?
          destination_topic.topic_allowed_users.build(user_id: user.id)
        end
      end
    destination_topic.save!
  end

  def enqueue_jobs(topic)
    @post_creator.enqueue_jobs if @post_creator

    Jobs.enqueue(:notify_moved_posts, post_ids: post_ids, moved_by_id: user.id)

    Jobs.enqueue(:delete_inaccessible_notifications, topic_id: topic.id)
  end

  def close_topic_and_schedule_deletion
    @original_topic.update_status("closed", true, @user)

    days_to_deleting = SiteSetting.delete_merged_stub_topics_after_days
    if days_to_deleting > 0
      @original_topic.set_or_create_timer(
        TopicTimer.types[:delete],
        days_to_deleting * 24,
        by_user: @user,
      )
    end
  end
end