# typed: strict
class Domain::Fa::Job::UserPageJob < Domain::Fa::Job::Base
  RECENT_USERS_THRESHOLD = T.let(12, Integer)

  queue_as :fa_user_page
  queue_with_priority do
    T.bind(self, Domain::Fa::Job::UserPageJob)
    user_from_args!.scanned_page_at.nil? ? -14 : -4
  end

  sig { override.params(args: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
  def perform(args)
    user = user_from_args!
    return if buggy_user?(user)

    if !user.page_scan.due? && !force_scan?
      logger.warn("scanned #{user.page_scan.ago_in_words}, skipping")
      return
    end

    response =
      http_client.get("https://www.furaffinity.net/user/#{user.url_name}/")

    page = update_user_from_user_page(user, response)
    user_page = page&.user_page

    if user.state_error? && user_page.nil?
      logger.info("page error / user not found")
      return
    end

    if user.state_ok? && user_page
      check_skip_gallery_scan(user, user_page)
      check_skip_favs_scan(user, user_page)
      check_skip_followed_users_scan(user, user_page)
      check_skip_followed_by_users_scan(user, user_page)
      ReduxApplicationRecord.transaction do
        self.class.update_favs_and_dates(user, user_page)
      end
    end

    enqueue_user_scan(user, at_most_one_scan: false)
    logger.info "completed page scan"
  ensure
    user.save! if user
    if user && response && (response.status_code == 200)
      logger.info("enqueueing link finder jobs")
      enqueue_jobs_from_found_links(response.log_entry)
    end
  end

  sig do
    params(
      user: Domain::User::FaUser,
      user_page: Domain::Fa::Parser::UserPageHelper,
    ).returns(Domain::Fa::Job::FavsJob::FavsAndDatesStats)
  end
  def self.update_favs_and_dates(user, user_page)
    num_updated_with_fav_fa_id = 0
    num_updated_with_date = 0
    num_updated_total = 0

    # upsert faved IDs into the database
    recent_faved_fa_ids = user_page.recent_fav_fa_ids
    fa_id_to_post_id =
      T.let(
        Domain::Post::FaPost
          .where(fa_id: recent_faved_fa_ids)
          .pluck(:fa_id, :id)
          .to_h,
        T::Hash[Integer, Integer],
      )

    recent_faved_fa_ids
      .filter { |fa_id| fa_id_to_post_id[fa_id].nil? }
      .each do |fa_id|
        model = Domain::Post::FaPost.find_or_create_by!(fa_id:)
        fa_id_to_post_id[fa_id] = T.must(model.id)
      end

    user_page
      .submissions_json_data
      .filter { |sub_data| recent_faved_fa_ids.include?(sub_data.fa_id) }
      .each do |fav_data|
        num_updated_total += 1
        num_updated_with_date += 1
        post_id = T.must(fa_id_to_post_id[fav_data.fa_id])
        user.update_fav_model(post_id:, explicit_time: fav_data.faved_at)
      end

    Domain::Fa::Job::FavsJob::FavsAndDatesStats.new(
      num_updated_with_fav_fa_id:,
      num_updated_with_date:,
      num_updated_total:,
    )
  end

  private

  sig do
    params(
      user: Domain::User::FaUser,
      user_page: Domain::Fa::Parser::UserPageHelper,
    ).void
  end
  def check_skip_gallery_scan(user, user_page)
    # if the user has no submissions, we don't need to scan their gallery
    user_page_num_submissions = user_page.num_submissions
    if user_page_num_submissions.nil?
      logger.warn(format_tags("user page num_submissions is nil"))
      return
    end

    if user_page_num_submissions == 0
      logger.info(format_tags("skipping gallery scan, 0 submissions"))
      user.scanned_gallery_at = Time.current
    end

    # create any submissions on the recent gallery page that are not yet known
    recent_gallery_fa_ids = user_page.recent_gallery_fa_ids
    known_posts = user.posts.where(fa_id: recent_gallery_fa_ids).to_a
    unknown_recent_gallery_fa_ids =
      recent_gallery_fa_ids - known_posts.map(&:fa_id)
    known_recent_gallery_posts =
      known_posts.filter { |post| recent_gallery_fa_ids.include?(post.fa_id) }

    if unknown_recent_gallery_fa_ids.any?
      unknown_recent_gallery_fa_ids.each do |fa_id|
        post = Domain::Post::FaPost.find_or_initialize_by(fa_id:)
        post.creator = user
        post.enqueue_job_after_save(
          Domain::Fa::Job::ScanPostJob,
          { post:, caused_by_entry: causing_log_entry },
        )
        post.save!
      end
    end

    # recheck any known posts to see if they should be rescanned
    known_recent_gallery_posts.each do |post|
      post_file = post.file
      if !post.state_ok? || post_file.blank? || !post_file.state_ok?
        # we just saw it, so it must be ok to enqueue
        logger.tagged(make_arg_tag(post)) do
          logger.info(
            "post is in recent gallery section, force enqueue ScanPostJob",
          )
        end
        post.state_ok!
        post.save!
        defer_job(Domain::Fa::Job::ScanPostJob, { post:, force_scan: true })
      end

      if post_file && post_file.url_str.present? &&
           !post_file.state_terminal_error? &&
           (post_file.state_pending? || post_file.log_entry.blank?)
        logger.tagged(make_arg_tag(post_file)) do
          logger.info(
            "post is in recent gallery section, force enqueue ScanFileJob",
          )
        end
        defer_job(Domain::Fa::Job::ScanFileJob, { post_file: })
      end
    end

    # if we know at least as many submissions as are indicated on the user page,
    # then we can skip the gallery scan
    if user_page_num_submissions <= user.posts.count
      logger.info(
        format_tags(
          make_tag("user_page_submissions", user_page_num_submissions),
          make_tag("known_submissions", user.posts.count),
          "skipping gallery scan, all submissions known",
        ),
      )

      user.scanned_gallery_at = Time.current
      Domain::User.reset_counters(user.id, :user_post_creations)
    end
  end

  sig do
    params(
      user: Domain::User::FaUser,
      user_page: Domain::Fa::Parser::UserPageHelper,
    ).void
  end
  def check_skip_favs_scan(user, user_page)
    recent_faved_fa_ids = user_page.recent_fav_fa_ids
    recent_faved_posts =
      Domain::Post::FaPost.where(fa_id: recent_faved_fa_ids).to_a

    # create missing posts in the favorites section
    (recent_faved_fa_ids - recent_faved_posts.map(&:fa_id)).each do |fa_id|
      post = Domain::Post::FaPost.find_or_create_by(fa_id:)
      defer_job(Domain::Fa::Job::ScanPostJob, { post: })
      recent_faved_posts << post
    end

    if recent_faved_fa_ids.empty?
      logger.info(format_tags("skipping favs scan, 0 favorites"))
      user.upsert_new_favs([], log_entry: causing_log_entry!)
    elsif recent_faved_fa_ids.count < 8
      logger.info(
        format_tags(
          "skipping favs scan, #{recent_faved_fa_ids.count} favorites < threshold",
        ),
      )
      user.upsert_new_favs(
        recent_faved_posts.map(&:id).compact,
        log_entry: causing_log_entry!,
      )
    elsif user.scanned_favs_at.present?
      known_faved_post_fa_ids =
        user
          .faved_posts
          .where(id: recent_faved_posts.map(&:id))
          .map(&:fa_id)
          .to_set

      first_known_idx =
        recent_faved_fa_ids.index do |fa_id|
          known_faved_post_fa_ids.include?(fa_id)
        end

      if first_known_idx.nil?
        logger.info("no known favs, full favs scan")
        return
      end

      # are all the favs after the first known fav known?
      any_unknown_after_first_known =
        (recent_faved_fa_ids[first_known_idx..] || []).any? do |fa_id|
          !known_faved_post_fa_ids.include?(fa_id)
        end

      if any_unknown_after_first_known
        logger.info("unknown favs after first known fav, full favs scan")
        return
      end

      unknown_fav_fa_ids =
        (recent_faved_fa_ids[..first_known_idx] || []).filter do |fa_id|
          !known_faved_post_fa_ids.include?(fa_id)
        end

      logger.info(
        "unknown favs are all at beginning, adding fa_ids to favs: #{unknown_fav_fa_ids.join(", ")}",
      )

      user.upsert_new_favs(
        unknown_fav_fa_ids
          .map do |fa_id|
            recent_faved_posts.find { |post| post.fa_id == fa_id }
          end
          .compact
          .map(&:id)
          .compact,
        log_entry: causing_log_entry!,
      )
    end
  end

  sig do
    params(
      user: Domain::User::FaUser,
      user_page: Domain::Fa::Parser::UserPageHelper,
    ).void
  end
  def check_skip_followed_users_scan(user, user_page)
    recent_watching = user_page.recent_watching
    if recent_watching.empty?
      logger.info(format_tags("skipping followed users scan, 0 watching"))
      user.scanned_follows_at = Time.current
    elsif recent_watching.count < RECENT_USERS_THRESHOLD
      logger.info(
        format_tags(
          "skipping followed users scan, #{recent_watching.count} watching < threshold",
        ),
      )
      watched_users =
        recent_watching.map do |recent_user|
          Domain::User::FaUser.find_or_create_by(
            url_name: recent_user.url_name,
          ) { |user| user.name = recent_user.name }
        end

      watched_user_ids = watched_users.map(&:id).compact

      user.user_user_follows_from.upsert_all(
        watched_user_ids.map { |user_id| { to_id: user_id } },
        unique_by: %i[from_id to_id],
      )

      # Use reset_counters to update follows_from for the current user
      Domain::User.reset_counters(user.id, :user_user_follows_from)
      logger.info(
        format_tags(
          make_tag(
            "reset user_user_follows_from counter cache for user",
            user.id,
          ),
        ),
      )

      # Update follows_to counts for users who were followed
      watched_user_ids.each_slice(100) do |batch_ids|
        batch_ids.each do |watched_user_id|
          Domain::User.reset_counters(watched_user_id, :user_user_follows_to)
        end
      end

      user.scanned_follows_at = Time.current
    elsif recent_watching.any?
      # if there are watchers, find the ones we've already recorded. if
      # all of them are known, then we can assume watched users are up to date.
      num_recent_watching = recent_watching.count
      num_recent_watching_known =
        user
          .followed_users
          .where(url_name: recent_watching.map(&:url_name))
          .count

      if (num_recent_watching == num_recent_watching_known) &&
           (user.followed_users.count == user_page.num_watching)
        logger.info(
          format_tags(
            "skipping UserFollowsJob, all watched users already known",
          ),
        )
        user.scanned_follows_at = Time.current
      end
    end
  end

  sig do
    params(
      user: Domain::User::FaUser,
      user_page: Domain::Fa::Parser::UserPageHelper,
    ).void
  end
  def check_skip_followed_by_users_scan(user, user_page)
    recent_watchers = user_page.recent_watchers

    if recent_watchers.empty?
      logger.info(format_tags("skipping followed by scan, 0 watched"))
      user.scanned_followed_by_at = Time.current
      return
    end

    # by now, we know that there is at least one watcher
    if recent_watchers.count < RECENT_USERS_THRESHOLD
      logger.info(
        format_tags(
          "skipping followed by scan, #{recent_watchers.count} watchers < threshold",
        ),
      )
      watched_by_users =
        recent_watchers.map do |recent_user|
          Domain::User::FaUser.find_or_create_by(
            url_name: recent_user.url_name,
          ) { |user| user.name = recent_user.name }
        end

      watcher_ids = watched_by_users.map(&:id).compact

      user.user_user_follows_to.upsert_all(
        watcher_ids.map { |user_id| { from_id: user_id } },
        unique_by: %i[from_id to_id],
      )

      # Use reset_counters to update follows_to for the current user
      Domain::User.reset_counters(user.id, :user_user_follows_to)
      logger.info(
        format_tags(
          make_tag(
            "reset user_user_follows_to counter cache for user",
            user.id,
          ),
        ),
      )

      # Update follows_from counts for watchers
      watcher_ids.each_slice(100) do |batch_ids|
        batch_ids.each do |watcher_id|
          Domain::User.reset_counters(watcher_id, :user_user_follows_from)
        end
      end

      user.scanned_followed_by_at = Time.current
      return
    end

    # if there are watchers, find the ones we've already recorded. if
    # all of them are known, then we can skip scanning favs the next time.
    known_watchers =
      Domain::User::FaUser.where(url_name: recent_watchers.map(&:url_name))
    if known_watchers.count == recent_watchers.count
      logger.info(
        format_tags("skipping followed by scan, all watchers already known"),
      )
      user.scanned_followed_by_at = Time.current
      return
    end

    # is the recent watcher in the last position known?
    last_recent_watcher = T.must(recent_watchers.last)
    last_recent_watcher_is_known =
      known_watchers.any? do |user|
        user.url_name == last_recent_watcher.url_name
      end

    if last_recent_watcher_is_known
      logger.info(
        format_tags("skipping followed by scan, last watcher already known"),
      )
      user.scanned_followed_by_at = Time.current
      return
    end
  end
end
