require_dependency 'jobs/base'
require_dependency 'pretty_text'
require_dependency 'rate_limiter'
require_dependency 'post_revisor'
require_dependency 'enum'
require_dependency 'post_analyzer'
require_dependency 'validators/post_validator'
require_dependency 'plugin/filter'

require 'archetype'
require 'digest/sha1'

class Post < ActiveRecord::Base
  include RateLimiter::OnCreateRecord
  include Trashable
  include HasCustomFields

  rate_limit
  rate_limit :limit_posts_per_day

  belongs_to :user
  belongs_to :topic, counter_cache: :posts_count
  belongs_to :reply_to_user, class_name: "User"

  has_many :post_replies
  has_many :replies, through: :post_replies
  has_many :post_actions
  has_many :topic_links

  has_many :post_uploads
  has_many :uploads, through: :post_uploads

  has_one :post_search_data

  has_many :post_details

  has_many :post_revisions
  has_many :revisions, foreign_key: :post_id, class_name: 'PostRevision'

  validates_with ::Validators::PostValidator

  # We can pass several creating options to a post via attributes
  attr_accessor :image_sizes, :quoted_post_numbers, :no_bump, :invalidate_oneboxes, :cooking_options, :skip_unique_check

  SHORT_POST_CHARS = 1200

  scope :by_newest, -> { order('created_at desc, id desc') }
  scope :by_post_number, -> { order('post_number ASC') }
  scope :with_user, -> { includes(:user) }
  scope :created_since, lambda { |time_ago| where('posts.created_at > ?', time_ago) }
  scope :public_posts, -> { joins(:topic).where('topics.archetype <> ?', Archetype.private_message) }
  scope :private_posts, -> { joins(:topic).where('topics.archetype = ?', Archetype.private_message) }
  scope :with_topic_subtype, ->(subtype) { joins(:topic).where('topics.subtype = ?', subtype) }

  delegate :username, to: :user

  def self.hidden_reasons
    @hidden_reasons ||= Enum.new(:flag_threshold_reached, :flag_threshold_reached_again, :new_user_spam_threshold_reached)
  end

  def self.types
    @types ||= Enum.new(:regular, :moderator_action)
  end

  def self.cook_methods
    @cook_methods ||= Enum.new(:regular, :raw_html)
  end

  def self.find_by_detail(key, value)
    includes(:post_details).where(post_details: { key: key, value: value }).first
  end

  def add_detail(key, value, extra = nil)
    post_details.build(key: key, value: value, extra: extra)
  end

  def limit_posts_per_day
    if user.created_at > 1.day.ago && post_number > 1
      RateLimiter.new(user, "first-day-replies-per-day:#{Date.today.to_s}", SiteSetting.max_replies_in_first_day, 1.day.to_i)
    end
  end

  def trash!(trashed_by=nil)
    self.topic_links.each(&:destroy)
    super(trashed_by)
  end

  def recover!
    super
    update_flagged_posts_count
    TopicLink.extract_from(self)
    if topic && topic.category_id && topic.category
      topic.category.update_latest
    end
  end

  # The key we use in redis to ensure unique posts
  def unique_post_key
    "post-#{user_id}:#{raw_hash}"
  end

  def store_unique_post_key
    if SiteSetting.unique_posts_mins > 0
      $redis.setex(unique_post_key, SiteSetting.unique_posts_mins.minutes.to_i, id)
    end
  end

  def matches_recent_post?
    post_id = $redis.get(unique_post_key)
    post_id != nil and post_id != id
  end

  def raw_hash
    return if raw.blank?
    Digest::SHA1.hexdigest(raw.gsub(/\s+/, ""))
  end

  def self.white_listed_image_classes
    @white_listed_image_classes ||= ['avatar', 'favicon', 'thumbnail']
  end

  def post_analyzer
    @post_analyzers ||= {}
    @post_analyzers[raw_hash] ||= PostAnalyzer.new(raw, topic_id)
  end

  %w{raw_mentions linked_hosts image_count attachment_count link_count raw_links}.each do |attr|
    define_method(attr) do
      post_analyzer.send(attr)
    end
  end

  def cook(*args)
    # For some posts, for example those imported via RSS, we support raw HTML. In that
    # case we can skip the rendering pipeline.
    return raw if cook_method == Post.cook_methods[:raw_html]

    # Default is to cook posts
    cooked = if !self.user || !self.user.has_trust_level?(:leader)
      post_analyzer.cook(*args)
    else
      # At trust level 3, we don't apply nofollow to links
      cloned = args.dup
      cloned[1] ||= {}
      cloned[1][:omit_nofollow] = true
      post_analyzer.cook(*cloned)
    end
    Plugin::Filter.apply( :after_post_cook, self, cooked )
  end

  # Sometimes the post is being edited by someone else, for example, a mod.
  # If that's the case, they should not be bound by the original poster's
  # restrictions, for example on not posting images.
  def acting_user
    @acting_user || user
  end

  def acting_user=(pu)
    @acting_user = pu
  end

  def whitelisted_spam_hosts

    hosts = SiteSetting
              .white_listed_spam_host_domains
              .split('|')
              .map{|h| h.strip}
              .reject{|h| !h.include?('.')}

    hosts << GlobalSetting.hostname
    hosts << RailsMultisite::ConnectionManagement.current_hostname

  end

  def total_hosts_usage
    hosts = linked_hosts.clone
    whitelisted = whitelisted_spam_hosts

    hosts.reject! do |h|
      whitelisted.any? do |w|
        h.end_with?(w)
      end
    end

    return hosts if hosts.length == 0

    TopicLink.where(domain: hosts.keys, user_id: acting_user.id)
             .group(:domain, :post_id)
             .count.keys.each do |tuple|
      domain = tuple[0]
      hosts[domain] = (hosts[domain] || 0) + 1
    end

    hosts
  end

  # Prevent new users from posting the same hosts too many times.
  def has_host_spam?
    return false if acting_user.present? && acting_user.has_trust_level?(:basic)

    total_hosts_usage.each do |host, count|
      return true if count >= SiteSetting.newuser_spam_host_threshold
    end

    false
  end

  def archetype
    topic.archetype
  end

  def self.regular_order
    order(:sort_order, :post_number)
  end

  def self.reverse_order
    order('sort_order desc, post_number desc')
  end

  def self.summary
    where(["(post_number = 1) or (percent_rank <= ?)", SiteSetting.summary_percent_filter.to_f / 100.0])
  end

  def update_flagged_posts_count
    PostAction.update_flagged_posts_count
  end

  def filter_quotes(parent_post = nil)
    return cooked if parent_post.blank?

    # We only filter quotes when there is exactly 1
    return cooked unless (quote_count == 1)

    parent_raw = parent_post.raw.sub(/\[quote.+\/quote\]/m, '')

    if raw[parent_raw] || (parent_raw.size < SHORT_POST_CHARS)
      return cooked.sub(/\<aside.+\<\/aside\>/m, '')
    end

    cooked
  end

  def external_id
    "#{topic_id}/#{post_number}"
  end

  def quoteless?
    (quote_count == 0) && (reply_to_post_number.present?)
  end

  def reply_to_post
    return if reply_to_post_number.blank?
    @reply_to_post ||= Post.where("topic_id = :topic_id AND post_number = :post_number",
                                  topic_id: topic_id,
                                  post_number: reply_to_post_number).first
  end

  def reply_notification_target
    return if reply_to_post_number.blank?
    Post.where("topic_id = :topic_id AND post_number = :post_number AND user_id <> :user_id",
                topic_id: topic_id,
                post_number: reply_to_post_number,
                user_id: user_id).first.try(:user)
  end

  def self.excerpt(cooked, maxlength = nil, options = {})
    maxlength ||= SiteSetting.post_excerpt_maxlength
    PrettyText.excerpt(cooked, maxlength, options)
  end

  # Strip out most of the markup
  def excerpt(maxlength = nil, options = {})
    Post.excerpt(cooked, maxlength, options)
  end

  def is_first_post?
    post_number == 1
  end

  def is_flagged?
    post_actions.where(post_action_type_id: PostActionType.flag_types.values, deleted_at: nil).count != 0
  end

  def unhide!
    self.hidden = false
    self.hidden_reason_id = nil
    self.topic.update_attributes(visible: true)
    save
  end

  def url
    Post.url(topic.slug, topic.id, post_number)
  end

  def self.url(slug, topic_id, post_number)
    "/t/#{slug}/#{topic_id}/#{post_number}"
  end

  def self.urls(post_ids)
    ids = post_ids.map{|u| u}
    if ids.length > 0
      urls = {}
      Topic.joins(:posts).where('posts.id' => ids).
        select(['posts.id as post_id','post_number', 'topics.slug', 'topics.title', 'topics.id']).
      each do |t|
        urls[t.post_id.to_i] = url(t.slug, t.id, t.post_number)
      end
      urls
    else
      {}
    end
  end

  def revise(updated_by, new_raw, opts = {})
    PostRevisor.new(self).revise!(updated_by, new_raw, opts)
  end

  def set_owner(new_user, actor)
    revise(actor, self.raw, {
        new_user: new_user,
        changed_owner: true,
        edit_reason: I18n.t('change_owner.post_revision_text',
                            old_user: self.user.username_lower,
                            new_user: new_user.username_lower)
    })
  end

  before_create do
    PostCreator.before_create_tasks(self)
  end

  # This calculates the geometric mean of the post timings and stores it along with
  # each post.
  def self.calculate_avg_time(min_topic_age=nil)
    retry_lock_error do
      builder = SqlBuilder.new("UPDATE posts
                SET avg_time = (x.gmean / 1000)
                FROM (SELECT post_timings.topic_id,
                             post_timings.post_number,
                             round(exp(avg(ln(msecs)))) AS gmean
                      FROM post_timings
                      INNER JOIN posts AS p2
                        ON p2.post_number = post_timings.post_number
                          AND p2.topic_id = post_timings.topic_id
                          AND p2.user_id <> post_timings.user_id
                      GROUP BY post_timings.topic_id, post_timings.post_number) AS x
                /*where*/")

      builder.where("x.topic_id = posts.topic_id
                  AND x.post_number = posts.post_number
                  AND (posts.avg_time <> (x.gmean / 1000)::int OR posts.avg_time IS NULL)")

      if min_topic_age
        builder.where("posts.topic_id IN (SELECT id FROM topics where bumped_at > :bumped_at)",
                     bumped_at: min_topic_age)
      end

      builder.exec
    end
  end

  before_save do
    self.last_editor_id ||= user_id
    self.cooked = cook(raw, topic_id: topic_id) unless new_record?
  end

  after_save do
    save_revision if self.version_changed?
  end

  after_update do
    update_revision if self.changed?
  end

  def advance_draft_sequence
    return if topic.blank? # could be deleted
    DraftSequence.next!(last_editor_id, topic.draft_key)
  end

  # TODO: move to post-analyzer?
  # Determine what posts are quoted by this post
  def extract_quoted_post_numbers
    temp_collector = []

    # Create relationships for the quotes
    raw.scan(/\[quote=\"([^"]+)"\]/).each do |quote|
      args = parse_quote_into_arguments(quote)
      # If the topic attribute is present, ensure it's the same topic
      temp_collector << args[:post] unless (args[:topic].present? && topic_id != args[:topic])
    end

    temp_collector.uniq!
    self.quoted_post_numbers = temp_collector
    self.quote_count = temp_collector.size
  end


  def save_reply_relationships
    add_to_quoted_post_numbers(reply_to_post_number)
    return if self.quoted_post_numbers.blank?

    # Create a reply relationship between quoted posts and this new post
    self.quoted_post_numbers.each do |p|
      post = Post.where(topic_id: topic_id, post_number: p).first
      create_reply_relationship_with(post)
    end
  end

  # Enqueue post processing for this post
  def trigger_post_process(bypass_bump = false)
    args = {
      post_id: id,
      bypass_bump: bypass_bump
    }
    args[:image_sizes] = image_sizes if image_sizes.present?
    args[:invalidate_oneboxes] = true if invalidate_oneboxes.present?
    Jobs.enqueue(:process_post, args)
  end

  def self.public_posts_count_per_day(since_days_ago=30)
    public_posts.where('posts.created_at > ?', since_days_ago.days.ago).group('date(posts.created_at)').order('date(posts.created_at)').count
  end

  def self.private_messages_count_per_day(since_days_ago, topic_subtype)
    private_posts.with_topic_subtype(topic_subtype).where('posts.created_at > ?', since_days_ago.days.ago).group('date(posts.created_at)').order('date(posts.created_at)').count
  end


  def reply_history
    post_ids = Post.exec_sql("WITH RECURSIVE breadcrumb(id, reply_to_post_number) AS (
                              SELECT p.id, p.reply_to_post_number FROM posts AS p
                                WHERE p.id = :post_id
                              UNION
                                 SELECT p.id, p.reply_to_post_number FROM posts AS p, breadcrumb
                                   WHERE breadcrumb.reply_to_post_number = p.post_number
                                     AND p.topic_id = :topic_id
                            ) SELECT id from breadcrumb ORDER by id", post_id: id, topic_id: topic_id).to_a

    post_ids.map! {|r| r['id'].to_i }.reject! {|post_id| post_id == id}
    Post.where(id: post_ids).includes(:user, :topic).order(:id).to_a
  end

  def revert_to(number)
    return if number >= version
    post_revision = PostRevision.where(post_id: id, number: number + 1).first
    post_revision.modifications.each do |attribute, change|
      attribute = "version" if attribute == "cached_version"
      write_attribute(attribute, change[0])
    end
  end

  def edit_time_limit_expired?
    if created_at && SiteSetting.post_edit_time_limit.to_i > 0
      created_at < SiteSetting.post_edit_time_limit.to_i.minutes.ago
    else
      false
    end
  end

  private

  def parse_quote_into_arguments(quote)
    return {} unless quote.present?
    args = {}
    quote.first.scan(/([a-z]+)\:(\d+)/).each do |arg|
      args[arg[0].to_sym] = arg[1].to_i
    end
    args
  end

  def add_to_quoted_post_numbers(num)
    return unless num.present?
    self.quoted_post_numbers ||= []
    self.quoted_post_numbers << num
  end

  def create_reply_relationship_with(post)
    return if post.nil?
    post_reply = post.post_replies.new(reply_id: id)
    if post_reply.save
      Post.where(id: post.id).update_all ['reply_count = reply_count + 1']
    end
  end

  def save_revision
    modifications = changes.extract!(:raw, :cooked, :edit_reason, :user_id)
    # make sure cooked is always present (oneboxes might not change the cooked post)
    modifications["cooked"] = [self.cooked, self.cooked] unless modifications["cooked"].present?
    PostRevision.create!(
      user_id: last_editor_id,
      post_id: id,
      number: version,
      modifications: modifications
    )
  end

  def update_revision
    revision = PostRevision.where(post_id: id, number: version).first
    return unless revision
    revision.user_id = last_editor_id
    modifications = changes.extract!(:raw, :cooked, :edit_reason)
    [:raw, :cooked, :edit_reason].each do |field|
      if modifications[field].present?
        old_value = revision.modifications[field].try(:[], 0) || ""
        new_value = modifications[field][1]
        revision.modifications[field] = [old_value, new_value]
      end
    end
    revision.save
  end

end

# == Schema Information
#
# Table name: posts
#
#  id                      :integer          not null, primary key
#  user_id                 :integer
#  topic_id                :integer          not null
#  post_number             :integer          not null
#  raw                     :text             not null
#  cooked                  :text             not null
#  created_at              :datetime         not null
#  updated_at              :datetime         not null
#  reply_to_post_number    :integer
#  reply_count             :integer          default(0), not null
#  quote_count             :integer          default(0), not null
#  deleted_at              :datetime
#  off_topic_count         :integer          default(0), not null
#  like_count              :integer          default(0), not null
#  incoming_link_count     :integer          default(0), not null
#  bookmark_count          :integer          default(0), not null
#  avg_time                :integer
#  score                   :float
#  reads                   :integer          default(0), not null
#  post_type               :integer          default(1), not null
#  vote_count              :integer          default(0), not null
#  sort_order              :integer
#  last_editor_id          :integer
#  hidden                  :boolean          default(FALSE), not null
#  hidden_reason_id        :integer
#  notify_moderators_count :integer          default(0), not null
#  spam_count              :integer          default(0), not null
#  illegal_count           :integer          default(0), not null
#  inappropriate_count     :integer          default(0), not null
#  last_version_at         :datetime         not null
#  user_deleted            :boolean          default(FALSE), not null
#  reply_to_user_id        :integer
#  percent_rank            :float            default(1.0)
#  notify_user_count       :integer          default(0), not null
#  like_score              :integer          default(0), not null
#  deleted_by_id           :integer
#  edit_reason             :string(255)
#  word_count              :integer
#  version                 :integer          default(1), not null
#  cook_method             :integer          default(1), not null
#
# Indexes
#
#  idx_posts_created_at_topic_id            (created_at,topic_id)
#  idx_posts_user_id_deleted_at             (user_id)
#  index_posts_on_reply_to_post_number      (reply_to_post_number)
#  index_posts_on_topic_id_and_post_number  (topic_id,post_number) UNIQUE
#