class Pseud < ApplicationRecord
  include Searchable
  include WorksOwner
  include Justifiable
  include AfterCommitEverywhere

  has_one_attached :icon do |attachable|
    attachable.variant(:standard, resize_to_limit: [100, 100], loader: { n: -1 })
  end

  # i18n-tasks-use t("errors.attributes.icon.invalid_format")
  # i18n-tasks-use t("errors.attributes.icon.too_large")
  validates :icon, attachment: {
    allowed_formats: %w[image/gif image/jpeg image/png],
    maximum_size: ArchiveConfig.ICON_SIZE_KB_MAX.kilobytes
  }

  NAME_LENGTH_MIN = 1
  NAME_LENGTH_MAX = 40
  DESCRIPTION_MAX = 500

  belongs_to :user

  delegate :login, to: :user, prefix: true, allow_nil: true
  alias user_name user_login

  has_many :bookmarks, dependent: :destroy
  has_many :recs, -> { where(rec: true) }, class_name: 'Bookmark'
  has_many :comments

  has_many :creatorships, dependent: :destroy
  has_many :approved_creatorships, -> { Creatorship.approved }, class_name: "Creatorship"

  has_many :works, through: :approved_creatorships, source: :creation, source_type: "Work"
  has_many :chapters, through: :approved_creatorships, source: :creation, source_type: "Chapter"
  has_many :series, through: :approved_creatorships, source: :creation, source_type: "Series"

  has_many :tags, through: :works
  has_many :filters, through: :works
  has_many :direct_filters, through: :works
  has_many :collection_participants, dependent: :destroy
  has_many :collections, through: :collection_participants
  has_many :tag_set_ownerships, dependent: :destroy
  has_many :tag_sets, through: :tag_set_ownerships
  has_many :challenge_signups, dependent: :destroy
  has_many :gifts, -> { where(rejected: false) }, inverse_of: :pseud, dependent: :destroy
  has_many :gift_works, through: :gifts, source: :work
  has_many :rejected_gifts, -> { where(rejected: true) }, class_name: "Gift", inverse_of: :pseud, dependent: :destroy
  has_many :rejected_gift_works, through: :rejected_gifts, source: :work

  has_many :offer_assignments, -> { where("challenge_assignments.sent_at IS NOT NULL") }, through: :challenge_signups
  has_many :pinch_hit_assignments, -> { where("challenge_assignments.sent_at IS NOT NULL") }, class_name: "ChallengeAssignment", foreign_key: "pinch_hitter_id"

  has_many :prompts, dependent: :destroy

  before_validation :clear_icon

  validates_presence_of :name
  validates_length_of :name,
    within: NAME_LENGTH_MIN..NAME_LENGTH_MAX,
    too_short: ts("is too short (minimum is %{min} characters)", min: NAME_LENGTH_MIN),
    too_long: ts("is too long (maximum is %{max} characters)", max: NAME_LENGTH_MAX)
  validates :name, uniqueness: { scope: :user_id }
  validates_format_of :name,
    message: ts('can contain letters, numbers, spaces, underscores, and dashes.'),
    with: /\A[\p{Word} -]+\Z/u
  validates_format_of :name,
    message: ts('must contain at least one letter or number.'),
    with: /\p{Alnum}/u
  validates_length_of :description, allow_blank: true, maximum: DESCRIPTION_MAX,
    too_long: ts("must be less than %{max} characters long.", max: DESCRIPTION_MAX)
  validates_length_of :icon_alt_text, allow_blank: true, maximum: ArchiveConfig.ICON_ALT_MAX,
    too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.ICON_ALT_MAX)
  validates_length_of :icon_comment_text, allow_blank: true, maximum: ArchiveConfig.ICON_COMMENT_MAX,
    too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.ICON_COMMENT_MAX)

  after_create :reindex_user
  after_update :check_default_pseud
  after_update :expire_caches
  after_update :reindex_user, if: :name_changed?
  after_destroy :reindex_user
  after_commit :reindex_creations, :touch_comments

  scope :alphabetical, -> { order(:name) }
  scope :default_alphabetical, -> { order(is_default: :desc).alphabetical }
  scope :abbreviated_list, -> { default_alphabetical.limit(ArchiveConfig.ITEMS_PER_PAGE) }
  scope :for_search, -> { includes(:user).with_attached_icon }

  def self.not_orphaned
    where("user_id != ?", User.orphan_account)
  end

  # Enigel Dec 12 08: added sort method
  # sorting by pseud name or by login name in case of equality
  def <=>(other)
    (self.name.downcase <=> other.name.downcase) == 0 ? (self.user_name.downcase <=> other.user_name.downcase) : (self.name.downcase <=> other.name.downcase)
  end

  def to_param
    name
  end

  scope :public_work_count_for, -> (pseud_ids) {
    select('pseuds.id, count(pseuds.id) AS work_count')
      .joins(:works)
      .where(
        pseuds: { id: pseud_ids }, works: { posted: true, hidden_by_admin: false, restricted: false }
      ).group('pseuds.id')
  }

  scope :posted_work_count_for, -> (pseud_ids) {
    select('pseuds.id, count(pseuds.id) AS work_count')
      .joins(:works)
      .where(
        pseuds: { id: pseud_ids }, works: { posted: true, hidden_by_admin: false }
      ).group('pseuds.id')
  }

  scope :public_rec_count_for, -> (pseud_ids) {
    select('pseuds.id, count(pseuds.id) AS rec_count')
    .joins(:bookmarks)
    .where(
      pseuds: { id: pseud_ids }, bookmarks: { private: false, hidden_by_admin: false, rec: true }
    )
    .group('pseuds.id')
  }

  def self.rec_counts_for_pseuds(pseuds)
    if pseuds.blank?
      {}
    else
      pseuds_with_counts = Pseud.public_rec_count_for(pseuds.collect(&:id))
      count_hash = {}
      pseuds_with_counts.each {|p| count_hash[p.id] = p.rec_count.to_i}
      count_hash
    end
  end

  def self.work_counts_for_pseuds(pseuds)
    if pseuds.blank?
      {}
    else
      if User.current_user.nil?
        pseuds_with_counts = Pseud.public_work_count_for(pseuds.collect(&:id))
      else
        pseuds_with_counts = Pseud.posted_work_count_for(pseuds.collect(&:id))
      end

      count_hash = {}
      pseuds_with_counts.each {|p| count_hash[p.id] = p.work_count.to_i}
      count_hash
    end
  end

  def unposted_works
    @unposted_works = self.works.where(posted: false).order(created_at: :desc)
  end

  # Produces a byline that indicates the user's name if pseud is not unique
  def byline
    (name != user_name) ? "#{name} (#{user_name})" : name
  end

  # get the former byline
  def byline_was
    past_name = name_was.blank? ? name : name_was
    # if we have a user and their login has changed get the old one
    past_user_name = user.blank? ? "" : (user.login_was.blank? ? user.login : user.login_was)
    (past_name != past_user_name) ? "#{past_name} (#{past_user_name})" : past_name
  end

  # Parse a string of the "pseud.name (user.login)" format into an array
  # [pseud.name, user.login]. If there is no parenthesized login after the
  # pseud name, returns [pseud.name, nil].
  def self.split_byline(byline)
    pseud_name, login = byline.split("(", 2)
    [pseud_name&.strip, login&.strip&.delete_suffix(")")]
  end

  # Parse a string of the "pseud.name (user.login)" format into a pseud. If the
  # form is just "pseud.name" with no parenthesized login, assumes that
  # pseud.name = user.login and goes from there.
  def self.parse_byline(byline)
    pseud_name, login = split_byline(byline)
    login ||= pseud_name

    Pseud.joins(:user).find_by(pseuds: { name: pseud_name }, users: { login: login })
  end

  # Parse a string of the "pseud.name (user.login)" format into a list of
  # pseuds. Usually this will be just one pseud, but if the byline is of the
  # form "pseud.name" with no parenthesized username, it'll look for any pseud
  # with that name.
  def self.parse_byline_ambiguous(byline)
    pseud_name, login = split_byline(byline)

    if login
      Pseud.joins(:user).where(pseuds: { name: pseud_name }, users: { login: login })
    else
      Pseud.where(name: pseud_name)
    end
  end

  # Takes a comma-separated list of bylines
  # Returns a hash containing an array of pseuds and an array of bylines that couldn't be found
  def self.parse_bylines(bylines)
    valid_pseuds = []
    failures = []
    banned_pseuds = []

    bylines.split(",").each do |byline|
      pseud = parse_byline(byline)
      if pseud.nil?
        failures << byline.strip
      elsif pseud.user.banned? || pseud.user.suspended?
        banned_pseuds << pseud
      else
        valid_pseuds << pseud
      end
    end

    {
      pseuds: valid_pseuds.flatten.uniq,
      invalid_pseuds: failures,
      banned_pseuds: banned_pseuds.flatten.uniq.map(&:byline)
    }
  end

  ## AUTOCOMPLETE
  # set up autocomplete and override some methods
  include AutocompleteSource
  def autocomplete_prefixes
    [ "autocomplete_pseud" ]
  end

  def autocomplete_value
    "#{id}#{AUTOCOMPLETE_DELIMITER}#{byline}"
  end

  # This method is for use in before_* callbacks
  def autocomplete_value_was
    "#{id}#{AUTOCOMPLETE_DELIMITER}#{byline_was}"
  end

  # See byline_before_last_save for the reasoning behind why both this and
  # autocomplete_value_was exist in this model
  #
  # This method is for use in after_* callbacks
  def autocomplete_value_before_last_save
    "#{id}#{AUTOCOMPLETE_DELIMITER}#{byline_before_last_save}"
  end

  def byline_before_last_save
    past_name = name_before_last_save.blank? ? name : name_before_last_save

    # In this case, self belongs to a user that has already been saved
    # during it's (self's) callback cycle, which means we need to
    # look *back* at the user's [attributes]_before_last_save, since
    # [attribute]_was for the pseud's user will behave as if this were an
    # after_* callback on the user, instead of a before_* callback on self.
    #
    # see psued_sweeper.rb:13 for more context
    #
    past_user_name = user.blank? ? "" : (user.login_before_last_save.blank? ? user.login : user.login_before_last_save)
    (past_name != past_user_name) ? "#{past_name} (#{past_user_name})" : past_name
  end

  # This method is for removing stale autocomplete records in a before_*
  # callback, such as the one used in PseudSweeper
  #
  # This is a particular case for the Pseud model
  def remove_stale_from_autocomplete_before_save
    self.class.remove_from_autocomplete(self.autocomplete_search_string_was, self.autocomplete_prefixes, self.autocomplete_value_was)
  end


  ## END AUTOCOMPLETE

  def replace_me_with_default
    replacement = user.default_pseud

    # We don't use change_ownership here because we want to transfer both
    # approved and unapproved creatorships.
    self.creatorships.includes(:creation).each do |creatorship|
      next if creatorship.creation.nil?

      existing =
        replacement.creatorships.find_by(creation: creatorship.creation)

      if existing
        existing.update(approved: existing.approved || creatorship.approved)
      else
        creatorship.update(pseud: replacement)
      end
    end

    # Update the pseud ID for all comments. Also updates the timestamp, so that
    # the cache is invalidated and the pseud change will be visible.
    Comment.where(pseud_id: self.id).update_all(pseud_id: replacement.id,
                                                updated_at: Time.now)
    change_collections_membership
    change_gift_recipients
    change_challenge_participation
    self.destroy
  end

  # Change the ownership of a creation from one pseud to another
  def change_ownership(creation, pseud, options={})
    transaction do
      # Update children before updating the creation itself, since deleting
      # creatorships from the creation will also delete them from the creation's
      # children.
      unless options[:skip_children]
        children = if creation.is_a?(Work)
                     creation.chapters
                   elsif creation.is_a?(Series)
                     creation.works
                   else
                     []
                   end

        children.each do |child|
          change_ownership(child, pseud, options)
        end
      end

      # Should only add new creatorships if we're an approved co-creator.
      if creation.creatorships.approved.where(pseud: self).exists?
        creation.creatorships.find_or_create_by(pseud: pseud)
      end

      # But we should delete all creatorships, even invited ones:
      creation.creatorships.where(pseud: self).destroy_all

      if creation.is_a?(Work)
        creation.series.each do |series|
          if series.work_pseuds.where(id: id).exists?
            series.creatorships.find_or_create_by(pseud: pseud)
          else
            change_ownership(series, pseud, options.merge(skip_children: true))
          end
        end
        comments = creation.total_comments.where("comments.pseud_id = ?", self.id)
        comments.each do |comment|
          comment.update_attribute(:pseud_id, pseud.id)
        end
      end

      # make sure changes affect caching/search/author fields
      creation.save
    end
  end

  def change_membership(collection, new_pseud)
    self.collection_participants.in_collection(collection).each do |cparticipant|
      cparticipant.pseud = new_pseud
      cparticipant.save
    end
  end

  def change_challenge_participation
    # We want to update all prompts associated with this pseud, but although
    # each prompt contains a pseud_id column, they're not indexed on it. That
    # means doing the search Prompt.where(pseud_id: self.id) would require
    # searching all rows of the prompts table. So instead, we do a join on the
    # challenge_signups table and look up prompts whose ChallengeSignup has the
    # pseud_id that we want to change.
    Prompt.joins(:challenge_signup).
      where("challenge_signups.pseud_id = #{id}").
      update_all("prompts.pseud_id = #{user.default_pseud.id}")

    ChallengeSignup.where("pseud_id = #{self.id}").update_all("pseud_id = #{self.user.default_pseud.id}")
    ChallengeAssignment.where("pinch_hitter_id = #{self.id}").update_all("pinch_hitter_id = #{self.user.default_pseud.id}")
    return
  end

  def change_gift_recipients
    Gift.where("pseud_id = #{self.id}").update_all("pseud_id = #{self.user.default_pseud.id}")
  end

  def change_bookmarks_ownership
    Bookmark.where("pseud_id = #{self.id}").update_all("pseud_id = #{self.user.default_pseud.id}")
  end

  def change_collections_membership
    CollectionParticipant.where("pseud_id = #{self.id}").update_all("pseud_id = #{self.user.default_pseud.id}")
  end

  def check_default_pseud
    if !self.is_default? && self.user.pseuds.to_enum.find(&:is_default?) == nil
      default_pseud = self.user.pseuds.select{|ps| ps.name.downcase == self.user_name.downcase}.first
      default_pseud.update_attribute(:is_default, true)
    end
  end

  def expire_caches
    if saved_change_to_name?
      works.touch_all
      series.each(&:expire_byline_cache)
      chapters.each(&:expire_byline_cache)
    end
  end

  def touch_comments
    comments.touch_all
  end

  # Delete current icon (thus reverting to archive default icon)
  def delete_icon=(value)
    @delete_icon = !value.to_i.zero?
  end

  def delete_icon
    !!@delete_icon
  end
  alias_method :delete_icon?, :delete_icon

  def clear_icon
    return unless delete_icon?

    self.icon.purge
    self.icon_alt_text = nil
    self.icon_comment_text = nil
  end

  #################################
  ## SEARCH #######################
  #################################

  def collection_ids
    collections.pluck(:id)
  end

  def document_json
    PseudIndexer.new({}).document(self)
  end

  def should_reindex_creations?
    pertinent_attributes = %w[id name]
    destroyed? || (saved_changes.keys & pertinent_attributes).present?
  end

  # If the pseud gets renamed, anything indexed with the old name needs to be reindexed:
  # works, series, bookmarks.
  def reindex_creations
    return unless should_reindex_creations?
    IndexQueue.enqueue_ids(Work, works.pluck(:id), :main)
    IndexQueue.enqueue_ids(Bookmark, bookmarks.pluck(:id), :main)
    IndexQueue.enqueue_ids(Series, series.pluck(:id), :main)
  end

  def reindex_user
    after_commit { user.enqueue_to_index }
  end
end
