text:
// Place all the styles related to the DomainTags controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/

.no-tags {
  margin-right: 1em;
}

.tag-label {
  font-size: 0.9em;
}


text:
// Place all the styles related to the Developer controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/

repo_name: Charcoal-SE/metasmoke

repo_language: Ruby

mime_type: text/plain


text:
// Place all the styles related to the AbuseContacts controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/

.abuse-contact h3 {
  margin-top: 5px;
}


text:
// Place all the styles related to the API controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/

label code {
  font-weight: normal;
}


text:
// Place all the styles related to the emails/preferences controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: https://sass-lang.com/

.preference-title {
  display: flex;
  align-items: center;
}

.switch-container {
  margin: 0 1em;
  display: flex;
  align-items: center;
}

.switch-label {
  font-variant: small-caps;
  margin: 0 0.25em;
}

.switch {
  position: relative;
  display: inline-block;
  width: 60px;
  height: 34px;
}

.switch input {
  display: none;
}

.slider {
  position: absolute;
  cursor: pointer;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: #ccc;
  transition: all 0.3s ease;
  border-radius: 34px;
}

.slider:before {
  position: absolute;
  content: "";
  height: 26px;
  width: 26px;
  left: 4px;
  bottom: 4px;
  background-color: white;
  transition: all 0.3s ease;
  border-radius: 50%;
}

input:checked + .slider {
  background-color: #3EB33E;
}

input:focus + .slider {
  box-shadow: 0 0 1px #3EB33E;
}

input:checked + .slider:before {
  transform: translateX(26px);
}

.form-inline {
  display: inline;
}


text:
# Place all the behaviors and hooks related to the matching controller here.
# All this logic will automatically be available in application.js.
# You can use CoffeeScript in this file: http://coffeescript.org/


text:
/* eslint-disable spaced-comment */
//= link_directory ../images
//= link application.css
//= link graphiql/rails/application.js


text:
# frozen_string_literal: true

MetasmokeSchema = GraphQL::Schema.define do
  max_depth 15
  max_complexity 5000
  # mutation(Types::MutationType)
  query(Types::QueryType)
end


text:
# frozen_string_literal: true

Types::FlagConditionType = GraphQL::ObjectType.define do
  name 'FlagCondition'
  field :flags_enabled, types.Boolean
  field :min_weight, types.Int
  field :max_poster_rep, types.Int
  field :min_reason_count, types.Int
  field :user, Types::UserType do
    complexity ->(_ctx, _args, child_complexity) do
      (BASE * 25) + (child_complexity > 1 ? child_complexity : 1)
    end
  end
  field :sites, types[Types::SiteType] do
    complexity ->(_ctx, _args, child_complexity) do
      (BASE * 25) + (child_complexity > 1 ? child_complexity : 1)
    end
  end

  field :created_at, Types::DateTimeType
  field :updated_at, Types::DateTimeType
  field :id, types.ID
end


text:
# frozen_string_literal: true

Types::FeedbackType = GraphQL::ObjectType.define do
  name 'Feedback'
  field :message_link, types.String
  field :user_name, types.String
  field :user_link, types.String
  field :feedback_type, types.String
  field :post, Types::PostType
  field :user, Types::UserType
  field :chat_user_id, types.Int
  field :chat_host, types.String

  field :created_at, Types::DateTimeType
  field :updated_at, Types::DateTimeType
  field :id, types.ID
end


text:
# frozen_string_literal: true

Types::DomainTagType = GraphQL::ObjectType.define do
  name 'DomainTag'
  field :description, types.String
  field :name, types.String
  field :special, types.Boolean
  field :posts, types[Types::PostType]
  field :spam_domains, types[Types::SpamDomainType]

  field :created_at, Types::DateTimeType
  field :updated_at, Types::DateTimeType
  field :id, types.ID
end


text:
# frozen_string_literal: true

Types::UserType = GraphQL::ObjectType.define do
  name 'User'
  field :username, types.String
  field :stackexchange_chat_id, types.Int
  field :meta_stackexchange_chat_id, types.Int
  field :stackoverflow_chat_id, types.Int
  field :stack_exchange_account_id, types.Int
  field :feedbacks, types[Types::FeedbackType] do
    complexity ->(_ctx, _args, child_complexity) do
      (BASE * 25) + (child_complexity > 1 ? child_complexity : 1)
    end
  end
  field :post_comments, types[Types::PostCommentType] do
    complexity ->(_ctx, _args, child_complexity) do
      (BASE * 25) + (child_complexity > 1 ? child_complexity : 1)
    end
  end
  field :smoke_detectors, types[Types::SmokeDetectorType] do
    complexity ->(_ctx, _args, child_complexity) do
      (BASE * 25) + (child_complexity > 1 ? child_complexity : 1)
    end
  end
  field :flag_logs, types[Types::FlagLogType] do
    complexity ->(_ctx, _args, child_complexity) do
      (BASE * 25) + (child_complexity > 1 ? child_complexity : 1)
    end
  end

  field :id, types.ID
end


text:
# frozen_string_literal: true

Types::ReasonType = GraphQL::ObjectType.define do
  name 'Reason'
  field :reason_name, types.String
  field :last_post_title, types.String
  field :inactive, types.Boolean
  field :weight, types.Int
  field :maximum_weight, types.Int
  field :posts, types[Types::PostType]

  field :id, types.ID
end


text:
# frozen_string_literal: true

Types::FlagLogType = GraphQL::ObjectType.define do
  name 'FlagLog'
  field :success, types.Boolean
  field :error_message, types.String
  field :is_dry_run, types.Boolean
  field :backoff, types.Int
  field :is_auto, types.Boolean
  field :user, Types::UserType do
    complexity ->(_ctx, _args, child_complexity) do
      (BASE * 25) + (child_complexity > 1 ? child_complexity : 1)
    end
  end
  field :post, Types::PostType do
    complexity ->(_ctx, _args, child_complexity) do
      (BASE * 25) + (child_complexity > 1 ? child_complexity : 1)
    end
  end
  field :site, Types::SiteType do
    complexity ->(_ctx, _args, child_complexity) do
      (BASE * 25) + (child_complexity > 1 ? child_complexity : 1)
    end
  end
  field :flag_condition, Types::FlagConditionType do
    complexity ->(_ctx, _args, child_complexity) do
      (BASE * 25) + (child_complexity > 1 ? child_complexity : 1)
    end
  end

  field :created_at, Types::DateTimeType
  field :updated_at, Types::DateTimeType
  field :id, types.ID
end


text:
# frozen_string_literal: true

Types::StackExchangeUserType = GraphQL::ObjectType.define do
  name 'StackExchangeUser'
  field :user_id, types.Int
  field :username, types.String
  field :last_api_update, Types::DateTimeType
  field :still_alive, types.Boolean
  field :answer_count, types.Int
  field :question_count, types.Int
  field :reputation, types.Int
  field :site, Types::SiteType do
    complexity ->(_ctx, _args, child_complexity) do
      (BASE * 25) + (child_complexity > 1 ? child_complexity : 1)
    end
  end
  field :posts, types[Types::PostType] do
    complexity ->(_ctx, _args, child_complexity) do
      (BASE * 25) + (child_complexity > 1 ? child_complexity : 1)
    end
  end

  field :created_at, Types::DateTimeType
  field :updated_at, Types::DateTimeType
  field :id, types.ID
end


text:
# frozen_string_literal: true

Types::SiteType = GraphQL::ObjectType.define do
  name 'Site'
  field :site_name, types.String
  field :site_url, types.String
  field :site_logo, types.String
  field :site_domain, types.String
  field :flags_enabled, types.Boolean
  field :max_flags_per_post, types.Int
  field :is_child_meta, types.Boolean
  field :last_users_update, Types::DateTimeType
  field :posts, types[Types::PostType]
  field :stack_exchange_users, types[Types::StackExchangeUserType]
  field :flag_logs, types[Types::FlagLogType]
  field :flag_conditions, types[Types::FlagConditionType]

  field :created_at, Types::DateTimeType
  field :updated_at, Types::DateTimeType
  field :id, types.ID
end


text:
# frozen_string_literal: true

Types::PostCommentType = GraphQL::ObjectType.define do
  name 'PostComment'
  field :text, types.String
  field :user, Types::UserType
  field :post, Types::PostType

  field :created_at, Types::DateTimeType
  field :updated_at, Types::DateTimeType
  field :id, types.ID
end


text:
# frozen_string_literal: true

Types::SpamDomainType = GraphQL::ObjectType.define do
  name 'SpamDomain'
  field :domain, types.String
  field :whois, types.String
  field :posts, types[Types::PostType]
  field :domain_tags, types[Types::DomainTagType]

  field :created_at, Types::DateTimeType
  field :updated_at, Types::DateTimeType
  field :id, types.ID
end


text:
# frozen_string_literal: true

Types::QueryType = GraphQL::ObjectType.define do
  name 'Query'
  # Add root-level fields here.
  # They will be entry points for queries on your schema.

  BASE = 8

  field :post do
    type Types::PostType
    argument :id, types.ID
    argument :uid, types.ID
    argument :site, types.String
    argument :link, types.String
    resolve ->(_obj, args, _ctx) do
      return GraphQL::ExecutionError.new('Invalid argument grouping') if [%w[uid site], ['id'], ['link']].include? args
      if args['link']
        Post.find(link: args['link'])
      elsif args['uid'] && args['site']
        posts = Post.includes(:site).where(sites: { api_parameter: args['site'] }, native_id: args['uid'])
        GraphQL::ExecutionError.new('More than one post matches those parameters') if posts.length > 1
        post.empty? ? nil : post.first
      elsif args['id']
        Post.find(args['id'])
      else
        Post.last
      end
    end
    complexity ->(_ctx, _args, child_complexity) do
      (BASE * 25) + (child_complexity > 1 ? child_complexity : 1)
    end
  end

  field :posts do
    type types[Types::PostType]
    argument :ids, types[types.ID], 'A list of Metasmoke IDs'
    argument :uids, types[types.String], "A list of on-site id and site name pairs, e.g. ['stackunderflow:12345', 'superloser:54321']"
    argument :urls, types[types.String], 'A list of urls to posts, of the form //sitename.stackexchange.com/a/id or /questions/id'
    argument :last, types.Int, "Last n items from selection. Can be used in conjunction with any other options except 'first'"
    argument :first, types.Int, "First n items from selection. Can be used in conjunction with any other options except 'last'"
    argument :offset, types.Int, 'Number of items to offset by. Offset counted from start unless ' \
                                 "the 'last' option is used, in which case offset is counted from the end.", default_value: 0
    description 'Find Posts, with a maximum of 100 to be returned'
    resolve ->(_obj, args, _ctx) do
      posts = Post.all
      posts = posts.where(link: args['urls']) if args['urls']
      if args['uids']
        all_sites = Site.all.select(:id, :api_parameter).map { |site| [site.id, site.api_parameter] }
        uids = args['uids'].map do |uid|
          site, native_id = uid.split(':')
          site_id = all_sites.select { |_site_id, api_parameter| api_parameter == site }[0][0]
          [site_id, native_id].map(&:to_i)
        end
        all_permutations = posts.where(site_id: uids.map(&:first), native_id: uids.map(&:last))
        posts = uids.map do |site_id, native_id|
          all_permutations.select do |post|
            post.site_id == site_id && post.native_id == native_id
          end
        end.flatten.reject(&:nil?)
        posts = Post.where(id: posts.map(&:id))
      end
      posts = posts.find(args['ids']) if args['ids']
      return GraphQL::ExecutionError.new("You can't use 'last' and 'first' together") if args['first'] && args['last']
      posts = posts.offset(args['offset']).first(args['first']) if args['first']
      posts = posts.reverse_order.offset(args['offset']).first(args['last']) if args['last']
      posts = posts.limit(100) if posts.respond_to? :limit
      Array(posts)
    end
    complexity ->(_ctx, args, child_complexity) do
      children = 0
      children += (args['last'] || args['first'] || 0)
      children += args['uids'].length if args['uids']
      children += args['ids'].length if args['ids']
      children += args['urls'].length if args['urls']
      (BASE * 25) + children * (child_complexity > 1 ? child_complexity : 1)
    end
  end

  # deletion_logs domain_tags flag_logs moderator_sites

  def gen_field(name)
    ar_class = name.camelize.constantize
    graphql_type = "Types::#{name.camelize}Type".constantize

    field name.to_sym do
      type graphql_type
      argument :id, types.ID, "Get one #{ar_class} by id"
      description "Find one #{ar_class}"
      resolve ->(_obj, args, _ctx) do
        ar_class.find(args['id']) if args['id']
      end
      complexity ->(_ctx, _args, child_complexity) do
        (BASE * 25) + (child_complexity > 1 ? child_complexity : 1)
      end
    end

    field name.pluralize.to_sym do
      type types[graphql_type]
      argument :ids, types[types.ID], 'A list of Metasmoke IDs'
      argument :last, types.Int, "Last n items from selection. Can be used in conjunction with any other options except 'first'"
      argument :first, types.Int, "First n items from selection. Can be used in conjunction with any other options except 'last'"
      argument :offset, types.Int, 'Number of items to offset by. Offset counted from start unless ' \
                                   "the 'last' option is used, in which case offset is counted from the end.", default_value: 0
      description "Find multiple #{ar_class.to_s.pluralize}. Maximum of 200 returned."
      resolve ->(_obj, args, _ctx) do
        things = ar_class.all
        things = things.where(id: args['ids']) if args['ids']
        return GraphQL::ExecutionError.new("You can't use 'last' and 'first' together") if args['first'] && args['last']
        things = things.offset(args['offset']).first(args['first']) if args['first']
        things = things.reverse_order.offset(args['offset']).first(args['last']) if args['last']
        things = things.limit(200) if things.respond_to? :limit
        Array(things)
      end
      complexity ->(_ctx, args, child_complexity) do
        children = 0
        children += args['ids'].length if args['ids']
        children += (args['last'] || args['first'] || 0)
        children = 200 if children == 0
        (BASE * 25) + children * (child_complexity > 1 ? child_complexity : 1)
      end
    end
  end

  def gen_fields(*names)
    names.flatten.each { |name| gen_field(name) }
  end

  gen_fields %w[feedback smoke_detector site stack_exchange_user announcement
                reason user flag_log flag_condition spam_domain domain_tag]
end


text:
# frozen_string_literal: true

Types::MutationType = GraphQL::ObjectType.define do
  name 'Mutation'

  # # TODO: Remove me
  # field :testField, types.String do
  #   description 'An example field added by the generator'
  #   resolve lambda(_obj, _args, _ctx) {
  #     'Hello World!'
  #   }
  # end
end


text:
# frozen_string_literal: true

Types::AnnouncementType = GraphQL::ObjectType.define do
  name 'Announcement'
  field :text, types.String
  field :expiry, Types::DateTimeType

  field :created_at, Types::DateTimeType
  field :updated_at, Types::DateTimeType
end


text:
# frozen_string_literal: true

Types::SmokeDetectorType = GraphQL::ObjectType.define do
  name 'SmokeDetector'
  field :last_ping, Types::DateTimeType
  field :name, types.String
  field :location, types.String
  field :is_standby, types.Boolean
  field :force_failover, types.Boolean
  field :user, Types::UserType

  field :created_at, Types::DateTimeType
  field :updated_at, Types::DateTimeType
  field :id, types.ID
end


text:
# frozen_string_literal: true

Types::DateTimeType = GraphQL::ScalarType.define do
  name 'DateTime'

  coerce_input ->(value, _ctx) { Time.zone.parse(value) }
  coerce_result ->(value, _ctx) { value.utc.iso8601 }
end


text:
# frozen_string_literal: true

Types::PostType = GraphQL::ObjectType.define do
  name 'Post'
  field :title, types.String
  field :body, types.String
  field :link, types.String
  field :post_creation_date, Types::DateTimeType
  field :site, Types::SiteType
  field :user_link, types.String
  field :username, types.String
  field :why, types.String
  field :user_reputation, types.Int
  field :upvote_count, types.Int
  field :downvote_count, types.Int
  field :score, types.Int
  field :feedbacks, types[Types::FeedbackType]
  field :stack_exchange_user, Types::StackExchangeUserType
  field :is_tp, types.Boolean
  field :is_fp, types.Boolean
  field :is_naa, types.Boolean
  field :revision_count, types.Int
  field :deleted_at, Types::DateTimeType
  field :smoke_detector, Types::SmokeDetectorType
  field :autoflagged, types.Boolean
  field :tags, types.String
  field :feedbacks_count, types.Int
  field :native_id, types.Int
  field :reasons, types[Types::ReasonType]
  field :spam_domains, types[Types::SpamDomainType]
  field :flag_logs, types[Types::FlagLogType]
  field :comments, types[Types::PostCommentType]
  field :post_tags, types[Types::DomainTagType]

  field :created_at, Types::DateTimeType
  field :updated_at, Types::DateTimeType
  field :id, types.ID
end


text:
# frozen_string_literal: true

class SiteSetting < ApplicationRecord
  validates :name, uniqueness: true

  after_save :populate_redis

  def populate_redis
    redis.set "site_setting/#{name}", value
  end

  after_destroy :remove_from_redis

  def remove_from_redis(ss)
    redis.del "site_setting/#{ss.name}"
  end

  def self.[](key)
    inst = find_by name: key
    case inst&.value_type
    when 'boolean'
      inst&.value&.to_i == 1
    when 'number'
      inst&.value&.to_i
    when 'float'
      inst&.value&.to_f
    else
      inst&.value
    end
  end

  def self.[]=(key, val)
    inst = find_by name: key
    db_val = case inst&.value_type
             when 'boolean'
               val == 'true' ? 1 : 0
             when 'number'
               val.to_s
             when 'float'
               val.to_s
             else
               val
             end
    inst&.update(value: db_val)
  end
end


text:
# frozen_string_literal: true

module Emails
  def self.table_name_prefix
    'emails_'
  end
end


text:
# frozen_string_literal: true

class UsersRole < ApplicationRecord
  belongs_to :user
  belongs_to :role
end


text:
# frozen_string_literal: true

class CommitStatus < ApplicationRecord
end


text:
# frozen_string_literal: true

class AbuseComment < ApplicationRecord
  include Websocket

  belongs_to :user
  belongs_to :report, class_name: 'AbuseReport', foreign_key: 'abuse_report_id'
end


text:
# frozen_string_literal: true

class ChannelsUser < ApplicationRecord
  belongs_to :user
end


text:
# frozen_string_literal: true

include ActionView::Helpers::NumberHelper

class FlagCondition < ApplicationRecord
  include Websocket

  belongs_to :user
  has_and_belongs_to_many :sites
  has_many :flag_logs, dependent: :nullify

  validate :accuracy_and_post_count

  def accuracy_and_post_count
    return unless flags_enabled
    post_feedback_results = posts.pluck(:is_tp)

    if accuracy < FlagSetting['min_accuracy'].to_f
      errors.add(:accuracy, "must be over #{number_to_percentage(FlagSetting['min_accuracy'].to_f, precision: 2)}")
    end

    return unless post_feedback_results.count < FlagSetting['min_post_count'].to_i
    errors.add(:post_count, "must be over  #{FlagSetting['min_post_count']}")
  end

  def accuracy
    post_feedback_results = posts.pluck(:is_tp)
    true_positive_count = post_feedback_results.count(true)

    true_positive_count.to_f * 100 / post_feedback_results.count.to_f
  end

  def self.overall_accuracy(user)
    query = File.read(Rails.root.join('lib/queries/overall_accuracy.sql'))
    sanitized = ActiveRecord::Base.sanitize_sql([query, user_id: user.id])
    result = ActiveRecord::Base.connection.execute sanitized
    result.to_a[0][0]
  end

  def self.validate_for_user(user, disabler)
    accuracy = overall_accuracy user

    unless accuracy.present? && accuracy < FlagSetting['min_accuracy'].to_f #
      return accuracy
    end

    user.flag_conditions.update_all(flags_enabled: false)
    username = user&.username&.tr(' ', '')
    admin = disabler&.username&.tr(' ', '')
    SmokeDetector.send_message_to_charcoal "@#{username} Your flag conditions were manually disabled by @#{admin}: " \
                                           "Total accuracy must be >= #{FlagSetting['min_accuracy']}%."
    accuracy
  end

  def validate!(post)
    post.total_weight >= min_weight &&
      post.user_reputation <= max_poster_rep &&
      post.reason_count >= min_reason_count
  end

  def posts
    Post.joins(:reasons)
        .group(Arel.sql('posts.id'))
        .where('posts.user_reputation <= ?', max_poster_rep)
        .where(site_id: site_ids)
        .having('count(reasons.id) >= ?', min_reason_count)
        .having('sum(reasons.weight) >= ?', min_weight)
  end
end


text:
# frozen_string_literal: true

class Role < ApplicationRecord
  include Websocket

  has_and_belongs_to_many :users, join_table: :users_roles

  belongs_to :resource,
             polymorphic: true,
             optional: true

  validates :resource_type,
            inclusion: { in: Rolify.resource_types },
            allow_nil: true

  scopify

  def self.names
    %i[reviewer flagger core smoke_detector_runner blacklist_manager admin developer]
  end
end


text:
# frozen_string_literal: true

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  def self.fuzzy_search(term, **cols)
    sanitized = sanitize_for_search term, **cols
    select(Arel.sql("`#{table_name}`.*, #{sanitized} AS search_score"))
  end

  def self.match_search(term, with_search_score: true, **cols)
    sanitized = sanitize_for_search term, **cols
    if with_search_score
      select(Arel.sql("`#{table_name}`.*, #{sanitized} AS search_score")).where(sanitized)
    else
      where(sanitized)
    end
  end

  def self.sanitize_name(name)
    name.to_s.delete('`').insert(0, '`').insert(-1, '`')
  end

  def match_search(term, **cols)
    ApplicationRecord.match_search(term, **cols)
  end

  def self.sanitize_for_search(term, **cols)
    cols = cols.map do |k, v|
      if v.is_a?(Array)
        v.map { |vv| "#{sanitize_name k}.#{sanitize_name vv}" }.join(', ')
      else
        "#{sanitize_name k}.#{sanitize_name v}"
      end
    end.join(', ')

    ActiveRecord::Base.send(:sanitize_sql_array, ["MATCH (#{cols}) AGAINST (? IN BOOLEAN MODE)", term])
  end

  # From http://stackoverflow.com/questions/6591722/how-to-generate-fixtures-based-on-my-development-database
  def dump_fixture
    fixture_file = "#{Rails.root}/test/fixtures/#{self.class.table_name}.yml"
    File.open(fixture_file, 'a') do |f|
      f.puts({ "#{self.class.table_name.singularize}_#{id}" => attributes }
        .to_yaml.sub!(/---\s?/, "\n"))
    end
  end

  def self.sanitize_like(unsafe, *args)
    sanitize_sql_like unsafe, *args
  end

  def self.mass_habtm(join_table, first_type, second_type, record_pairs)
    first_ids = record_pairs.map { |p| p[0].id }.join(', ')
    second_ids = record_pairs.map { |p| p[1].id }.join(', ')
    pre_existing = connection.execute("SELECT #{first_type}_id, #{second_type}_id FROM #{join_table} " \
                                      "WHERE #{first_type}_id IN (#{first_ids}) AND #{second_type}_id IN (#{second_ids})").to_a

    record_ids = record_pairs.map do |pair|
      pair_ids = [pair[0].id, pair[1].id]
      pre_existing.include?(pair_ids) ? nil : pair_ids
    end.compact

    values = record_ids.map { |p| "(#{p[0]}, #{p[1]})" }.join(', ')
    query = "INSERT INTO #{join_table} (#{first_type}_id, #{second_type}_id) VALUES #{values};"
    connection.execute query
  end

  def self.fields(*names)
    names.map { |n| "#{table_name}.#{n}" }
  end

  def self.full_dump
    username = Rails.configuration.database_configuration[Rails.env]['username']
    password = Rails.configuration.database_configuration[Rails.env]['password']
    host = Rails.configuration.database_configuration[Rails.env]['host']
    `#{Rails.root}/dump/dump.sh "#{username}" "#{password}" "#{host}"`

    Dump.destroy_all
    Dump.create file: File.open(Dir.glob('dumps/*')[0])
  end
end


text:
# frozen_string_literal: true

class Statistic < ApplicationRecord
  include Websocket

  belongs_to :smoke_detector

  after_create do
    ActionCable.server.broadcast 'api_statistics', statistic: as_json
  end
end


text:
# frozen_string_literal: true

class AnnouncementScrubber < Rails::Html::PermitScrubber
  def initialize
    super
    self.tags = %w[a p b i em strong hr h1 h2 h3 h4 h5 h6 blockquote img strike del code pre br ul ol li]
    self.attributes = %w[href title src height width]
  end

  def skip_node?(node)
    node.text?
  end
end

class Announcement < ApplicationRecord
  include Websocket

  def current?
    expiry > DateTime.now
  end

  def self.current
    Announcement.where('expiry > ?', DateTime.now)
  end

  def self.scrubber
    AnnouncementScrubber.new
  end
end


text:
# frozen_string_literal: true

class ModeratorSite < ApplicationRecord
  include Websocket

  belongs_to :site
  belongs_to :user
end


text:
# frozen_string_literal: true

class PostScrubber < Rails::Html::PermitScrubber
  def initialize
    super
    self.tags = %w[a b blockquote code del dd dl dt em h1 h2 h3 i img kbd li ol p pre s sup sub strong strike ul br hr]
    self.attributes = %w[href title src height width alt]
  end

  def skip_node?(node)
    node.text?
  end
end

class Post < ApplicationRecord
  include Websocket
  include PostConcerns::Autoflagging
  include PostConcerns::Review

  validate :reject_recent_duplicates
  validates :link, format: { with: %r{\A\/\/(.*?)\/(questions|a)\/(\d+)\Z} }, allow_blank: true, on: :create

  serialize :tags, JSON

  belongs_to :site
  belongs_to :stack_exchange_user
  belongs_to :smoke_detector
  has_and_belongs_to_many :reasons
  has_and_belongs_to_many :post_tags, class_name: 'DomainTag'
  has_many :post_spam_domains
  has_many :spam_domains, through: :post_spam_domains
  has_many :feedbacks, dependent: :destroy
  has_many :deletion_logs, dependent: :destroy
  has_many :flag_logs, dependent: :destroy
  has_many :flags, dependent: :destroy
  has_many :comments, class_name: 'PostComment', dependent: :destroy
  has_many :abuse_reports, as: :reportable
  has_one :review_item, as: :reviewable

  scope(:includes_for_post_row, -> do
    includes(:stack_exchange_user).includes(:reasons).includes(:site)
           .includes(feedbacks: %i[user api_key]).includes(:comments)
  end)

  scope(:without_feedback, -> { where(feedbacks_count: 0).or(where(feedbacks_count: nil)) })

  scope(:today, -> { where('created_at > ?', Date.today) })

  scope(:tp, -> { where(is_tp: true) })
  scope(:fp, -> { where(is_fp: true) })
  scope(:naa, -> { where(is_naa: true) })

  scope(:undeleted, -> { where(deleted_at: nil) })

  after_commit :parse_domains, on: :create

  after_create do
    match = %r{\/(?:q(?:uestions)?|a(?:nswers)?)\/(\d+)}.match(link)
    update(native_id: match[1]) if match
  end

  after_create do
    ActionCable.server.broadcast 'posts_realtime', row: PostsController.render(locals: { post: Post.last }, partial: 'post').html_safe
    ActionCable.server.broadcast 'topbar', review: ReviewItem.active.count
  end

  after_save :populate_redis
  after_destroy :remove_from_redis

  def reject_recent_duplicates
    # If a different SmokeDetector has reported the same post in the last 5 minutes, reject it

    return unless respond_to?(:smoke_detector_id) && smoke_detector.present?

    conflict = Post.where(link: link)
                   .where('created_at > ?', 5.minutes.ago)
                   .where.not(smoke_detector: smoke_detector)
                   .last

    return if conflict.blank?

    errors.add(:base, "Reported in the last 5 minutes by a different instance: #{conflict.id}")
  end

  def populate_redis
    Redis::Post.to_redis(self)

    reason_names = reasons.map(&:reason_name)
    reason_weights = reasons.map(&:weight)
    redis.zadd("posts/#{id}/reasons", reason_weights.zip(reason_names)) unless reasons.empty?

    feedbacks.each(&:populate_redis)
    deletion_logs.each(&:update_deletion_data)

    reasons.each do |reason|
      redis.sadd "reasons/#{reason.id}", id
    end

    redis.sadd 'all_posts', id
    redis.zadd 'posts', created_at.to_i, id

    redis.sadd 'tps', id if is_tp
    redis.sadd 'fps', id if is_fp
    redis.sadd 'naas', id if is_naa
    if link.nil?
      redis.sadd 'nolink', id
    else
      redis.sadd 'questions', id if question?
      redis.sadd 'answers', id if answer?
    end
    redis.sadd 'autoflagged', id if flagged?
    redis.sadd 'deleted', id unless deleted_at.nil?
    redis.sadd "sites/#{site_id}/posts", id
  end

  def remove_from_redis
    redis.del "posts/#{id}"
    redis.del "posts/#{id}/reasons"
    # Test this one:
    Reason.find_each do |reason|
      redis.srem "reasons/#{reason.id}", id
    end
    redis.srem 'all_posts', id
    redis.zrem 'posts', id
    redis.srem 'tps', id
    redis.srem 'fps', id
    redis.srem 'naas', id
    redis.srem 'nolink', id
    redis.srem 'questions', id
    redis.srem 'answers', id
    redis.srem 'autoflagged', id
    redis.srem 'deleted', id
    redis.srem "sites/#{site_id}/posts", id
  end

  def self.populate_redis_meta
    progressbar = ProgressBar.create total: Post.count
    ilevel = ActiveRecord::Base.logger.level
    ActiveRecord::Base.logger.level = 1
    Post.all.eager_load(:reasons).eager_load(:flag_logs).find_each(batch_size: 50_000) do |post|
      # tps, fps, naas, reasons/id, questions, answers, autoflagged
      redis.pipelined do
        redis.sadd 'tps', post.id if post.is_tp
        redis.sadd 'fps', post.id if post.is_fp
        redis.sadd 'naas', post.id if post.is_naa
        post.reasons.each do |reason|
          redis.sadd "reasons/#{reason.id}", post.id
        end
        if post.link.nil?
          redis.sadd 'nolink', post.id
        else
          redis.sadd 'questions', post.id if post.question?
          redis.sadd 'answers', post.id if post.answer?
        end
        redis.sadd 'autoflagged', post.id if post.flagged?
        redis.sadd "sites/#{post.site_id}/posts", post.id
        redis.sadd 'all_posts', post.id
        redis.sadd 'deleted', post.id unless post.deleted_at.nil?
        redis.zadd 'posts', post.created_at.to_i, post.id
      end
      progressbar.increment
    end
    ActiveRecord::Base.logger.level = ilevel
  end

  def update_feedback_cache
    feedbacks = self.feedbacks.to_a

    self.is_tp = feedbacks.any?(&:is_positive?)
    self.is_fp = feedbacks.any?(&:is_negative?)
    self.is_naa = feedbacks.any?(&:is_naa?)

    is_feedback_changed = is_tp_changed? || is_fp_changed? || is_naa_changed?

    save!

    conflicting_revisions = Post.where(link: link)
                                .where.not(id: id)
                                .where('is_tp != ? or is_fp != ?', is_tp, is_fp)

    if conflicting_revisions.count > 0
      msg = "Conflicting feedback across revisions: [current](//metasmoke.erwaysoftware.com/post/#{id})"

      conflicting_revisions.each_with_index do |post, i|
        msg += ", [##{i + 1}](//metasmoke.erwaysoftware.com/post/#{post.id})"
      end

      # SmokeDetector.send_message_to_charcoal msg
    end

    if is_tp && is_fp
      # SmokeDetector.send_message_to_charcoal "Conflicting feedback on [#{title}](//metasmoke.erwaysoftware.com/post/#{id})."
    end

    if is_fp_changed? && is_fp && flagged?
      SmokeDetector.send_message_to_charcoal "**fp on autoflagged post**: #{title}](//metasmoke.erwaysoftware.com/post/#{id})"
    end

    if is_feedback_changed
      ActionCable.server.broadcast 'topbar', review: ReviewItem.active.count
    end

    is_feedback_changed
  end

  def question?
    link.include? '/questions/'
  end

  def answer?
    link.include? '/a/'
  end

  def deleted?
    deletion_logs.where(is_deleted: true).any?
  end

  def conflicted?
    is_tp && (is_fp || is_naa)
  end

  def stack_id
    native_id
  end

  def total_weight
    @weight ||= reasons.sum(:weight)
  end

  def reason_count
    @reason_count ||= reasons.count
  end

  # Called get_revision_count with the predicate because the model already has an attribute in the DB called revision_count.
  def get_revision_count # rubocop:disable Style/AccessorMethodName
    post = if respond_to? :revision_count
             self
           else
             Post.find id
           end

    if post.revision_count.present?
      post.revision_count
    else
      fetch_revision_count(respond_to?(:revision_count) ? nil : post)
    end
  end

  def parse_domains
    hosts = part_to_extract_from_domains.map do |uri|
      begin
        # Escape (URI-encode) hostname first, otherwise we get "URI must be ascii-only" on cases like
        # hxxps://supplémentsavis.fr/spammy-products-here
        regexed_hostname = uri.match(%r{(?<=\/\/)[^\/]+})&.try(:[], 0)
        uri = uri.gsub(regexed_hostname, CGI.escape(regexed_hostname))

        # DON'T unescape anything before parsing it, or we get "bad URI(is not URI?)"
        hostname = URI.parse(uri).hostname

        # Now unescape (URI-decode) the parsed hostname, otherwise we create domains that look like
        # hxxps://suppl%C3%A9mentsavis.fr/ (see #615)
        # We also don't want to create separate domain entries for www. subdomains,
        # nor to be paying attention to mixed case differences.
        CGI.unescape(hostname).gsub(/^www\./, '').downcase
      rescue
        nil
      end
    end.compact.uniq

    hosts.each do |h|
      next if h.length >= 255
      domain = SpamDomain.find_or_create_by domain: h
      domain.posts << self unless domain.posts.include? self
      Rails.cache.delete "spam_domain_post_counts_##{domain.id}"
    end
  end

  def self.scrubber
    PostScrubber.new
  end

  private

  def part_to_extract_from_domains
    if answer?
      URI.extract(body || '')
    else
      (URI.extract(body || '') + URI.extract(title || ''))
    end
  end
end


text:
# frozen_string_literal: true

class DomainLink < ApplicationRecord
  include Websocket

  belongs_to :left, class_name: 'SpamDomain'
  belongs_to :right, class_name: 'SpamDomain'
  belongs_to :creator, class_name: 'User'

  def self.link_types
    Rails.cache.fetch :domain_link_types do
      DomainLink.select(Arel.sql('DISTINCT link_type')).map(&:link_type)
    end
  end
end


text:
# frozen_string_literal: true

class SmokeDetector < ApplicationRecord
  include Websocket

  audited on: [:destroy]

  belongs_to :user
  has_many :statistics
  has_many :posts

  scope(:active, -> { where('last_ping > ?', 3.minutes.ago) })

  def should_failover
    force_failover || (is_standby && SmokeDetector.where(is_standby: false).where('last_ping > ?', 3.minutes.ago).empty?)
  end

  def should_pull
    force_pull
  end

  def as_json(options = {})
    opts = {
      except: [:access_token],
      methods: [:status_color]
    }
    opts.deep_merge!(options) do |_key, a, b|
      if a.instance_of? Array
        a + b
      elsif a.instance_of? Hash
        a.deep_merge b
      else
        b
      end
    end
    super(opts)
  end

  def self.status_color
    SmokeDetector.select(Arel.sql('last_ping')).order(Arel.sql('last_ping DESC')).first&.status_color
  end

  def status_color
    if last_ping > 90.seconds.ago
      'good'
    elsif last_ping >= 3.minutes.ago
      'warning'
    elsif last_ping < 3.minutes.ago
      'critical'
    end
  end

  def self.check_smokey_status
    smoke_detector = SmokeDetector.order(Arel.sql('last_ping DESC')).first

    return unless smoke_detector.last_ping < 3.minutes.ago && (smoke_detector.email_date || 1.week.ago) < smoke_detector.last_ping
    HairOnFireMailer.smokey_down_email(smoke_detector).deliver_now

    smoke_detector.email_date = DateTime.now
    smoke_detector.save!
  end

  def self.send_message_to_charcoal(message)
    ActionCable.server.broadcast 'smokedetector_messages', message: message
  end
end


text:
# frozen_string_literal: true

class UserSiteSetting < ApplicationRecord
  include Websocket

  belongs_to :user
  has_and_belongs_to_many :sites

  validate :site_count

  private

  def site_count
    errors.add(:sites, 'must contain at least one site') if sites.blank?
  end
end


text:
# frozen_string_literal: true

class AbuseReport < ApplicationRecord
  include Websocket

  belongs_to :user
  belongs_to :reportable, polymorphic: true
  belongs_to :contact, class_name: 'AbuseContact', foreign_key: 'abuse_contact_id'
  belongs_to :status, class_name: 'AbuseReportStatus', foreign_key: 'abuse_report_status_id'
  has_many :comments, class_name: 'AbuseComment', dependent: :destroy

  validates :reportable_type, presence: true, inclusion: { in: %w[SpamDomain Post DomainTag] }
  validates_presence_of :reportable, message: proc { |s| "cannot be invalid item #{s.reportable_type}##{s.reportable_id}" }

  before_validation do
    unless status.present?
      self.status = AbuseReportStatus[AbuseReportStatus::DEFAULT_STATUS]
    end

    self.uuid = SecureRandom.uuid unless uuid.present?
  end

  def self.update_stale_reports
    stale_status = AbuseReportStatus['Stale']
    open_status = AbuseReportStatus['Open']

    AbuseReport.where(status: open_status).each do |ar|
      last_update = (ar.comments.map(&:created_at) + [ar.created_at]).max
      ar.update(status: stale_status) if last_update <= 2.weeks.ago
    end
  end
end


text:
# frozen_string_literal: true

class ReviewQueue < ApplicationRecord
  include Websocket

  has_many :items, class_name: 'ReviewItem'
  has_many :results, class_name: 'ReviewResult', through: :items

  serialize :responses, JSON

  def self.[](key)
    find_by name: key
  end

  def should_dq?(item)
    item.reviewable.should_dq?(self) if item.reviewable.respond_to? :should_dq?
    false
  end

  def next_items(user)
    unreviewed_by = ReviewItem.unreviewed_by(self, user)
    unreviewed_by = yield unreviewed_by if block_given?
    unreviewed_by
  end
end


text:
# frozen_string_literal: true

class Feedback < ApplicationRecord
  include Websocket
  delegate :url_helpers, to: 'Rails.application.routes'

  default_scope { where(is_invalidated: false) }
  scope(:invalid, -> { unscoped.where(is_invalidated: true) })
  scope(:via_api, -> { unscoped.where.not(api_key: nil) })
  scope(:today, -> { where('created_at > ?', Date.today) })

  belongs_to :post, counter_cache: true
  belongs_to :user
  belongs_to :invalidated_by, class_name: 'User', foreign_key: 'invalidated_by'
  belongs_to :api_key

  before_save :check_for_user_assoc
  before_save :check_for_dupe_feedback

  after_create :send_to_chat
  after_create :send_blacklist_request

  VALID_TYPES = %w[tp tpu fp fpu naa ignore]
                .map { |f| [f, "#{f}-"] }.flatten

  validates :feedback_type, inclusion: { in: VALID_TYPES }

  after_save :populate_redis
  after_commit :check_for_dupe_feedback_again, on: %i[create update]

  def populate_redis
    redis.sadd "post/#{post.id}/feedbacks", id.to_s
    redis.hmset "feedbacks/#{id}", *{
      feedback_type: feedback_type,
      username: user.try(:username) || user_name,
      app_name: api_key.try(:app_name),
      invalidated: is_invalidated
    }
  end

  after_destroy :destroy_redis

  def destroy_redis
    redis.srem "post/#{post.id}/feedbacks", id.to_s
    redis.del "feedbacks/#{id}"
  end

  def self.populate_redis_meta
    keys = []
    prefix = 'feedbacks_populate'
    eager_load(:user).eager_load(:api_key).find_each(batch_size: 10_000) do |fb|
      redis.pipelined do
        next if fb.post_id.nil?
        key = "post/#{fb.post_id}/feedbacks"
        keys.push(key)
        redis.sadd "#{prefix}/#{key}", fb.id
        redis.hmset "feedbacks/#{fb.id}", *{
          feedback_type: fb.feedback_type,
          username: fb.user.try(:username) || fb.user_name,
          app_name: fb.api_key.try(:app_name),
          invalidated: fb.is_invalidated
        }
      end
    end
    redis.pipelined do
      keys.uniq.each do |key|
        redis.rename "#{prefix}/#{key}", key
      end
    end
  end

  after_save do
    if update_post_feedback_cache # if post feedback cache was changed
      if post.flagged? && !is_positive?
        message = "fp feedback on autoflagged post: [#{post.title}](#{post.link}) \\[[MS](//metasmoke.erwaysoftware.com/post/#{post_id})]"
        ActionCable.server.broadcast 'smokedetector_messages', autoflag_fp: { message: message, site: post.site.site_domain }

        names = post.flaggers.map { |u| '@' + u.username.tr(' ', '') }
        user_msg = "Autoflagged FP: flagged by #{names.join(', ')}"
        ActionCable.server.broadcast 'smokedetector_messages', autoflag_fp: { message: user_msg, site: post.site.site_domain }
        ValidateEligableFlaggersJob.perform_later(post)
      end

      post.stack_exchange_user&.unblacklist_user if post.is_fp
    end
  end

  after_create do
    ActionCable.server.broadcast "posts_#{post_id}", feedback: FeedbacksController.render(locals: { feedback: self }, partial: 'feedback').html_safe
    feedback = FeedbacksController.render(locals: { feedback: self }, partial: 'feedback.json')
    ActionCable.server.broadcast 'api_feedback', feedback: JSON.parse(feedback)

    update(user_name: user.username) if user_id.present? && user_name.nil?
  end

  # Drop a user's earlier feedback if it's not invalidated
  # and less than a day old

  after_create do
    next if user_id.nil?

    num_deleted = post.feedbacks.where(user_id: user_id)
                      .where('created_at > ?', 24.hours.ago)
                      .where.not(id: id)
                      .destroy_all

    post.reload.update_feedback_cache unless num_deleted.empty?
  end

  after_create do
    DeletionLog.auto_other_flag(nil, post)
  end

  # Keep this block last to make sure any corrections or deletions have been made before we check count
  after_create do
    if post.feedbacks.count >= 2 && post.review_item&.completed == false
      post.review_item.update(completed: true)
    end
  end

  def is_positive? # rubocop:disable Style/PredicateName
    feedback_type.include? 't'
  end

  def is_negative? # rubocop:disable Style/PredicateName
    feedback_type.include? 'f'
  end

  def is_naa? # rubocop:disable Style/PredicateName
    feedback_type.include? 'naa'
  end

  def does_affect_user?
    feedback_type.ends_with?('u') || feedback_type.ends_with?('u-')
  end

  def update_post_feedback_cache
    if saved_changes?
      return post.reload.update_feedback_cache # Returns whether the post feedback cache has been changed
    end
    false
  end

  def select_without_nil
    select(Feedback.attribute_names - ['message_link'])
  end

  def send_to_chat
    return if chat_user_id.present?
    return if Feedback.where(post: post).where('feedback_type LIKE ?', "#{feedback_type[0]}%").where.not(id: id).exists?

    message = "#{feedback_type} feedback received"
    host = 'metasmoke.erwaysoftware.com'
    link = url_helpers.url_for controller: :posts, action: :show, id: post.id, host: host, protocol: 'https'
    message += " on \\[[MS](#{link})] [#{SmokeDetectorsHelper.escape_markdown post.title}](#{post.link})"
    ActionCable.server.broadcast 'smokedetector_messages', message: message
  end

  def send_blacklist_request
    return if chat_user_id.present?

    return unless is_positive? && does_affect_user?

    post.stack_exchange_user&.blacklist_for_post(post)
  end

  private

  def check_for_dupe_feedback
    duplicate = if user_id.present?
                  Feedback.where(user_id: user_id, post_id: post_id, feedback_type: feedback_type).where.not(id: id)
                else
                  Feedback.where(user_name: user_name, post_id: post_id, feedback_type: feedback_type).where.not(id: id)
                end

    throw :abort if duplicate.exists? && !is_invalidated
  end

  def check_for_dupe_feedback_again
    return if is_invalidated
    duplicate = if user_id.present?
                  Feedback.where(user_id: user_id, post_id: post_id)
                else
                  Feedback.where(user_name: user_name, post_id: post_id)
                end.where(is_invalidated: false).where.not(id: id)
    return unless duplicate.exists?
    user = if user_id.present?
             User.find user_id
           else
             User.where(user_name: user_name)
           end
    duplicate.each do |d|
      if d.created_at > 1.day.ago
        d.destroy
      else
        d.update(is_invalidated: true, invalidated_by: user, invalidated_at: DateTime.now)
      end
    end
  end

  def check_for_user_assoc
    return if chat_host.nil? || chat_user_id.nil?

    chat_id_field = case chat_host
                    when 'stackexchange.com'
                      :stackexchange_chat_id
                    when 'stackoverflow.com'
                      :stackoverflow_chat_id
                    when 'meta.stackexchange.com'
                      :meta_stackexchange_chat_id
                    end

    return unless chat_id_field

    self.user = User.where(chat_id_field => chat_user_id).try(:first)
  end
end


text:
# frozen_string_literal: true

class AbuseReportStatus < ApplicationRecord
  include Websocket

  has_many :reports, class_name: 'AbuseReport', dependent: :nullify

  DEFAULT_STATUS = 'Open'

  def self.[](key)
    find_by name: key
  end
end


text:
# frozen_string_literal: true

class SpamWave < ApplicationRecord
  belongs_to :user
  has_and_belongs_to_many :sites

  serialize :conditions, JSON

  validates :name, presence: true
  validates :max_flags, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 6 }
  validate :check_min_accuracy
  validate :max_expiry

  scope(:active, -> { where('expiry >= ?', DateTime.now) })
  scope(:expired, -> { where('expiry < ?', DateTime.now) })

  # The regex_cache won't be consistent across threads, but we don't need it
  # to be. We only need it to not be corrupted from uses across threads, which it
  # shouldn't be, as threads don't share class variables.
  #
  # In addition, a Rails.cache entry is created for each Regexp. That entry should
  # be available across threads, but will be a bit slower due to the
  # serialization/deserialization steps.
  @@regex_cache = {} # rubocop:disable Style/ClassVars
  MAX_REGEX_CACHE_SIZE = 100

  def post_matches?(post, site_ids = nil)
    Rails.logger.debug "[spam-wave] id: #{id}: #{name}:: post_matches?: post id: #{post.id}: #{post.title}"
    site_ids = site_ids.nil? ? sites.map(&:id) : site_ids
    matches = []
    matches << site_ids.include?(post.site_id)
    if conditions['max_user_rep'].present?
      matches << (post.user_reputation <= conditions['max_user_rep'].to_i)
    end

    # If everything doesn't already match, there's no reason to run the regex tests.
    return false unless matches.all?

    %w[username title body].each do |f|
      Rails.logger.debug "[spam-wave] id: #{id}: #{name}:: post_matches?: #{f}: post id: #{post.id}: #{post.title}"
      # Try using an already existing regex from the regex_cache.
      regex_text = conditions["#{f}_regex"]
      Rails.logger.debug "[spam-wave] id: #{id}: #{name}:: #{f}_regex.encoding: #{regex_text.encoding}"
      regex = @@regex_cache[regex_text]
      if regex.nil?
        # There wasn't an entry in the regex_cache for this regex text, so check Rails.cache.
        Rails.logger.debug "[spam-wave] id: #{id}: #{name}:: regex_cache miss: checking Rails.cache #{f}_regex"
        regex = Rails.cache.fetch("SPAM_WAVE_REGEXP_CACHE: #{regex_text}", expires_in: 6.hours) do
          # There's no entry for the regex in Rails.cache, so create it.
          Rails.logger.debug "[spam-wave] id: #{id}: #{name}:: Rails.cache: REGEXP_CACHE miss: compiling #{f}_regex"
          Regexp.new(regex_text)
        end
        Rails.logger.debug "[spam-wave] id: #{id}: #{name}:: #{f}_regex compiled or in Rails.cache"
        @@regex_cache[regex_text] = regex
        if @@regex_cache.length > MAX_REGEX_CACHE_SIZE
          # There are too many entries in the regex_cache, so delete the least recent two.
          # This is only sufficient because this is the only place where we're adding to the cache.
          # If the regex_cache is to be interacted with in more than this method, then we should
          # break the regex_cache out at least into its own methods, if not into its own class.
          regex_cache_keys = @@regex_cache.keys
          @@regex_cache.delete regex_cache_keys[0]
          @@regex_cache.delete regex_cache_keys[1]
        end
      end
      # We only care about everything matching.
      # Returning here saves testing the longer strings if a shorter one doesn't match.
      post_text = post.send(f.to_sym)
      if post_text.nil?
        post_text = ''
      else
        # UTF-8 -> UTF-16 -> UTF-8 idea and code from [answer to: "ruby 1.9: invalid byte sequence in UTF-8"](https://stackoverflow.com/a/8873922)
        # by [RubenLaguna](https://stackoverflow.com/users/90580/rubenlaguna), which is under a CC BY-SA 3.0 license.
        post_text.encode!('UTF-16', 'UTF-8', invalid: :replace, replace: '')
        post_text.encode!('UTF-8', 'UTF-16')
      end
      Rails.logger.debug "[spam-wave] id: #{id}: #{name}:: post #{f}: encoding: #{post_text.encoding}"
      return false unless regex.match?(post_text)
      Rails.logger.debug "[spam-wave] id: #{id}: #{name}:: post_matches?: #{f}: MATCHES: post id: #{post.id}: #{post.title}"
    end

    # If we get here, then everything matches. We've already tested matches.all? for the
    # non-regex conditions, and we return immediately if any of the regexes don't match.
    true
  end

  def posts_matching_sites_and_reputation
    site_ids = sites.map(&:id)
    posts = Post.where('created_at >= ?', created_at - 1.month).where(site_id: site_ids)
    if conditions['max_user_rep'].present?
      posts = posts.where('user_reputation <= ?', conditions['max_user_rep'])
    end
    Rails.logger.debug "[spam-wave] id: #{id}: #{name}:: posts_matching_site_and_reputation.size: #{posts.size}"
    posts
  end

  def posts
    site_ids = sites.map(&:id)
    filtered_count = 0
    posts_to_be_filtered = posts_matching_sites_and_reputation
    posts_to_be_filtered_size = posts_to_be_filtered.size
    posts_to_be_filtered.select do |p|
      filtered_state = "(#{filtered_count}/#{posts_to_be_filtered_size})"
      Rails.logger.debug "[spam-wave] id: #{id}: #{name}:: filtering: #{filtered_state}: post id: #{p.id}: #{p.title}"
      matches_result = post_matches?(p, site_ids)
      filtered_count += 1
      filtered_state = "(#{filtered_count}/#{posts_to_be_filtered_size})"
      Rails.logger.debug "[spam-wave] id: #{id}: #{name}:: filtered: #{filtered_state}: result: #{matches_result}: post id: #{p.id}: #{p.title}"
      matches_result
    end
  end

  def accuracy
    tps = posts.select(&:is_tp)
    (tps.size.to_f / posts.size) * 100
  end

  protected

  def check_min_accuracy
    post_count = SiteSetting['min_wave_post_count']
    min_accuracy = SiteSetting['min_wave_accuracy']
    return if accuracy >= min_accuracy && posts.count >= post_count
    errors.add(:conditions, "must result in post count >= #{post_count} and accuracy >= #{min_accuracy}")
  end

  def max_expiry
    return if expiry <= 2.day.from_now
    errors.add(:expiry, 'must be no more than 48 hours from now')
  end
end


text:
# frozen_string_literal: true

class DomainTag < ApplicationRecord
  include Websocket

  has_and_belongs_to_many :spam_domains, after_add: :send_whitelist
  has_and_belongs_to_many :posts
  has_many :abuse_reports, as: :reportable

  scope(:standard, -> { where(special: false) })
  scope(:special, -> { where(special: true) })

  def send_whitelist
    ActionCable.server.broadcast('smokedetector_messages', domain_whitelist: 'refresh') if name == 'whitelisted'
  end

  def merge_into(master)
    master.spam_domains += (spam_domains.to_a - master.spam_domains.to_a)
    master.posts += (posts.to_a - master.posts.to_a)

    spam_domains.clear
    posts.clear

    destroy
  end
end


text:
# frozen_string_literal: true

class SpamDomain < ApplicationRecord
  include Websocket

  has_many :post_spam_domains
  has_many :posts, through: :post_spam_domains, after_add: :setup_review
  has_and_belongs_to_many :domain_tags, after_add: :check_dq
  has_and_belongs_to_many :domain_groups
  has_one :review_item, as: :reviewable
  has_many :abuse_reports, as: :reportable
  has_many :left_links, class_name: 'DomainLink', foreign_key: :left_id
  has_many :right_links, class_name: 'DomainLink', foreign_key: :right_id

  validates :domain, uniqueness: true

  after_create :fix_asn_tags

  def fix_asn_tags
    asn_query = `dig +short "$(dig +short '#{domain.tr("'", '')}' | awk -F. '/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/ {print $4"."$3"." $2"."$1;exit}').origin.asn.cymru.com" TXT` # rubocop:disable Metrics/LineLength
    asn = asn_query.strip.tr('"', '').split('|')[0]&.strip
    return unless asn.present?
    prev_domain_tags = domain_tags.where(special: true).select(:name).map(&:name).select { |dt| dt.start_with?('AS-') }
    asn.split.each do |as|
      desc = `dig +short AS#{as}.asn.cymru.com TXT`.strip.tr('"', '').split('|')[-1]&.strip
      tag = DomainTag.find_or_create_by(name: "AS-#{as}", special: true)
      tag.update(description: "Domains under the Autonomous System Number #{as} - #{desc}.") unless tag.description.present?
      if prev_domain_tags.include? "AS-#{as}"
        prev_domain_tags -= ["AS-#{as}"]
      else
        domain_tags << tag
      end
    end
    prev_domain_tags.each do |dtn|
      domain_tags.delete(DomainTag.find_by(name: dtn))
    end
  end

  after_create do
    groups = Rails.cache.fetch 'domain_groups' do
      DomainGroup.all.map { |dg| !dg.regex ? nil : [Regexp.new(dg.regex), dg.id] }.compact.to_h
    end
    groups.keys.each do |r|
      DomainGroup.find(groups[r]).spam_domains << self if r.match? domain
    end
  end

  def links
    left_links.or right_links
  end

  def linked_domains
    SpamDomain.where(id: links.select(Arel.sql("IF(left_id = #{id}, right_id, left_id)")))
  end

  def should_dq?(_item)
    domain_tags.count > 0
  end

  def review_item_name
    domain
  end

  def post_counts
    Rails.cache.fetch "spam_domain_post_counts_##{id}" do
      { all: posts.count, tp: posts.where(is_tp: true).count, naa: posts.where(is_naa: true).count, fp: posts.where(is_fp: true).count }
    end
  end

  def self.preload_post_counts(domains)
    domains = domains.reject { |d| Rails.cache.exist? "spam_domain_post_counts_##{d.id}" }
    SpamDomain.joins(:posts).select(Arel.sql('spam_domains.id'),
                                    Arel.sql('COUNT(DISTINCT posts.id) AS all_count'),
                                    Arel.sql('COUNT(DISTINCT IF(posts.is_tp = TRUE, posts.id, NULL)) AS tp_count'),
                                    Arel.sql('COUNT(DISTINCT IF(posts.is_naa = TRUE, posts.id, NULL)) AS naa_count'),
                                    Arel.sql('COUNT(DISTINCT IF(posts.is_fp = TRUE, posts.id, NULL)) AS fp_count'))
              .group(Arel.sql('spam_domains.id')).where(spam_domains: { id: domains.map(&:id) }).each do |d|
      Rails.cache.write "spam_domain_post_counts_##{d.id}",
                        all: d.all_count, tp: d.tp_count, naa: d.naa_count, fp: d.fp_count
    end
  end

  private

  def setup_review(*_args)
    return unless posts.count >= 3 && domain_tags.count == 0 && !review_item.present?
    if posts.map(&:is_fp).any?(&:!)
      ReviewItem.create(reviewable: self, queue: ReviewQueue['untagged-domains'], completed: false)
    else
      domain_tags << DomainTag.find_or_create_by(name: 'notspam')
    end
  end

  def check_dq(*_args)
    return unless review_item.present? && should_dq?(review_item)
    review_item.update(completed: true)
  end
end


text:
# frozen_string_literal: true

class Flag < ApplicationRecord
  include Websocket

  belongs_to :post
  belongs_to :user, required: false

  after_create do
    ReviewItem.create queue: ReviewQueue['admin-flags'], reviewable: self, completed: false
  end

  def review_item_name
    "Flag on post #{post.id}, '#{post.title}'"
  end

  def custom_review_action(_queue, _item, _user, response)
    update(is_completed: true) if response == 'dismiss'
  end

  def should_dq?(_queue)
    is_completed?
  end
end


text:
# frozen_string_literal: true

class StackExchangeUser < ApplicationRecord
  include Websocket

  belongs_to :site
  has_many :posts
  has_many :feedbacks, through: :posts

  def stack_link
    "#{site.site_url}/users/#{user_id}"
  end

  def flair_link
    "#{site.site_url}/users/flair/#{user_id}.png"
  end

  def blacklist_for_post(post)
    ActionCable.server.broadcast 'smokedetector_messages', blacklist: {
      uid: user_id.to_s,
      site: URI.parse(site.site_url).host,
      post: post.link
    }
  end

  def unblacklist_user
    ActionCable.server.broadcast 'smokedetector_messages', unblacklist: {
      uid: user_id.to_s,
      site: URI.parse(site.site_url).host
    }
  end
end


text:
# frozen_string_literal: true

class QueryAverage < ApplicationRecord
end


text:
# frozen_string_literal: true

class APIToken < ApplicationRecord
  include Websocket

  belongs_to :api_key
  belongs_to :user

  validates :code, uniqueness: true, length: { minimum: 7 }
  validates :token, uniqueness: true, length: { minimum: 48 }
end


text:
# frozen_string_literal: true

class PostSpamDomain < ApplicationRecord
  self.table_name = 'posts_spam_domains'
  belongs_to :post
  belongs_to :spam_domain
  belongs_to :added_by, class_name: 'User'

  def custom_delete
    self.class.where(attributes).delete_all
  end
end


text:
# frozen_string_literal: true

class ReviewItem < ApplicationRecord
  include Websocket

  belongs_to :user
  belongs_to :queue, class_name: 'ReviewQueue', foreign_key: 'review_queue_id'
  belongs_to :reviewable, polymorphic: true
  has_many :results, class_name: 'ReviewResult'

  %i[post spam_domain flag].each do |t|
    belongs_to(t, -> { where(review_items: { reviewable_type: t.to_s.pluralize.classify }) }, foreign_key: 'reviewable_id')

    define_method t do
      return unless reviewable_type == t.to_s.classify
      super
    end
  end

  validates :reviewable_type, inclusion: { in: %w[Post SpamDomain Flag] }

  scope(:active, -> { where(completed: false) })
  scope(:completed, -> { where(completed: true) })

  after_save :populate_redis
  after_destroy :remove_from_redis

  def populate_redis
    redis.sadd 'review_queues', queue.id
    if completed
      redis.srem "review_queue/#{queue.id}/unreviewed", id
    else
      redis.sadd "review_queue/#{queue.id}/unreviewed", id
    end
  end

  def remove_from_redis(ri)
    redis.srem "review_queue/#{ri.queue.id}/unreviewed", ri.id
  end

  def self.populate_redis_meta
    eager_load(:queue).find_each(&:populate_redis)
  end

  def self.unreviewed_by(queue, user)
    if queue.name == 'posts'
      joins("LEFT JOIN review_results rr ON rr.review_item_id = review_items.id AND rr.user_id = #{user.id}")
        .joins("LEFT JOIN feedbacks f ON f.post_id = review_items.reviewable_id AND f.user_id = #{user.id}")
        .where(review_items: { queue: queue, completed: false }, rr: { id: nil }, f: { id: nil })
    else
      joins("LEFT JOIN review_results rr ON rr.review_item_id = review_items.id AND rr.user_id = #{user.id}")
        .where(review_items: { queue: queue, completed: false }, rr: { id: nil })
    end
  end
end


text:
# frozen_string_literal: true

class GithubToken < ApplicationRecord
  include Websocket
end


text:
# frozen_string_literal: true

class FlagLog < ApplicationRecord
  include Websocket

  belongs_to :flag_condition, optional: true
  belongs_to :user, optional: true
  belongs_to :post
  belongs_to :site
  belongs_to :api_key, optional: true

  scope(:auto, -> { where(is_auto: true) })
  scope(:manual, -> { where(is_auto: false) })
  scope(:dry_run, -> { where(is_dry_run: true) })
  scope(:successful, -> { where(success: true) })
  scope(:failed, -> { where(success: false) })
  scope(:today, -> { where('created_at > ?', Date.today) })
  scope(:tp, -> { joins(:post).where(posts: { is_tp: true }) })
  scope(:fp, -> { joins(:post).where(posts: { is_fp: true }) })
  scope(:spam, -> { where(flag_type: 'spam') })
  scope(:abusive, -> { where(flag_type: 'abusive') })
  scope(:other, -> { where(flag_type: 'other') })

  default_scope { where(flag_type: 'spam').or(where(flag_type: 'abusive')) }

  def flag_icon
    if is_auto && flag_type == 'spam'
      '⚑'
    elsif is_auto && flag_type == 'abusive'
      '🏁'
    else
      '⚐'
    end
  end
end


text:
# frozen_string_literal: true

require 'open-uri'

class User < ApplicationRecord
  include Websocket

  rolify
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  has_many :feedbacks, dependent: :nullify
  has_many :api_tokens, dependent: :destroy
  has_many :api_keys, dependent: :nullify
  has_many :user_site_settings, dependent: :destroy
  has_many :flag_conditions, dependent: :destroy
  has_many :flag_logs, dependent: :nullify
  has_many :smoke_detectors, dependent: :destroy
  has_many :moderator_sites, dependent: :destroy
  has_many :reviews, class_name: 'ReviewResult', dependent: :nullify
  has_many :post_comments, dependent: :nullify
  has_many :post_spam_domains, foreign_key: 'added_by_id', dependent: :nullify

  has_one :channels_user, required: false, dependent: :destroy

  validate :gdpr_bollocks

  after_create do
    # All accounts start with flagger role enabled, if that site setting is active.
    add_role :flagger if SiteSetting['auto_flagger_role']

    # Prepare a message for SmokeDetector to forward to chat.
    # A link to a filtered list of users on MS, with the user ID in a tooltip.
    # We don't need the filter to return only one result, just make it easier to find the user on MS.
    username_filter = CGI.escape(username.first(10))
    message = "New [metasmoke user](//metasmoke.erwaysoftware.com/users?filter=#{username_filter} \"user ID: #{id}\")"
    # Redact long names. 40 characters is the limit for SE usernames.
    name = username.length > 40 ? '\\[long username redacted\\]' : SmokeDetectorsHelper.escape_markdown(username)
    # Add the username to the message. If we know it, link the username to the user's Stack Exchange account.
    message += if stack_exchange_account_id.present?
                 " '[#{name}](//stackexchange.com/users/#{stack_exchange_account_id})'"
               else
                 ' without an associated SE account'
               end
    message += ' created'
    # Actually send the messsage to SmokeDetector, unless that's turned off.
    if SiteSetting['new_account_messages_enabled']
      SmokeDetector.send_message_to_charcoal message
    end
  end

  before_save do
    # Retroactively update
    (changed & %w[stackexchange_chat_id meta_stackexchange_chat_id stackoverflow_chat_id]).each do
      # todo
    end
  end

  def self.smokey
    # Return the account matching SmokeDetector
    User.where(stack_exchange_account_id: 4606062).first # rubocop:disable Style/NumericLiterals
  end

  def active_for_authentication?
    super && roles.present?
  end

  def inactive_message
    if !has_role?(:reviewer)
      :not_approved
    else
      super # Use whatever other message
    end
  end

  def update_chat_ids
    return if stack_exchange_account_id.nil?

    ids = [[:stackexchange_chat_id, 'stackexchange.com'], [:stackoverflow_chat_id, 'stackoverflow.com'],
           [:meta_stackexchange_chat_id, 'meta.stackexchange.com']].map do |s|
      res = Net::HTTP.get_response(URI.parse("https://chat.#{s[1]}/accounts/#{stack_exchange_account_id}"))
      begin
        chat_id = res['location'].scan(%r{/users/(\d+)/})[0][0]
      rescue
        chat_id = nil
      end
      [s[0], chat_id]
    end.to_h

    update ids
  end

  def get_username(readonly_api_token = nil)
    if readonly_api_token.nil?
      Rails.logger.error 'User#get_username called without readonly_api_token'
      Rails.logger.error caller.join("\n")
      return
    end

    begin
      config = AppConfig['stack_exchange']
      auth_string = "key=#{config['key']}&access_token=#{readonly_api_token}"

      resp = Net::HTTP.get_response(URI.parse("https://api.stackexchange.com/2.2/me/associated?pagesize=1&filter=!ms3d6aRI6N&#{auth_string}"))
      resp = JSON.parse(resp.body)

      first_site = URI.parse(resp['items'][0]['site_url']).host

      resp = Net::HTTP.get_response(URI.parse("https://api.stackexchange.com/2.2/me?site=#{first_site}&filter=!-.wwQ56Mfo3J&#{auth_string}"))
      resp = JSON.parse(resp.body)

      return resp['items'][0]['display_name']
    rescue => ex
      Rails.logger.error "Error raised while fetching username: #{ex.message}"
      Rails.logger.error ex.backtrace
    end
  end

  def self.blacklist_managers
    Role.where(name: :blacklist_manager).first.users
  end

  def remember_me
    true
  end

  # Flagging

  def update_moderator_sites
    return if stack_exchange_account_id.nil?

    page = 1
    has_more = true
    new_moderator_sites = []
    auth_string = "key=#{AppConfig['stack_exchange']['key']}"
    while has_more
      params = "?page=#{page}&pagesize=100&filter=!6OrReH6NRZrmc&#{auth_string}"
      url = "https://api.stackexchange.com/2.2/users/#{stack_exchange_account_id}/associated" + params

      response = JSON.parse(Net::HTTP.get_response(URI.parse(url)).body)
      has_more = response['has_more']
      page += 1

      response['items'].each do |network_account|
        next unless network_account['user_type'] == 'moderator'
        domain = Addressable::URI.parse(network_account['site_url']).host
        new_moderator_sites << ModeratorSite.find_or_create_by(site_id: Site.find_by(site_domain: domain).id,
                                                               user_id: id)
      end

      sleep response['backoff'].to_i if response.include?('backoff')
    end

    self.moderator_sites = new_moderator_sites

    save!
  end

  def flag(flag_type, post, dry_run = false, **opts)
    if moderator_sites.pluck(:site_id).include? post.site_id
      return false, 'User is a moderator on this site'
    end

    return false, 'Flags not enabled for this account' unless flags_enabled

    path = post.answer? ? 'answers' : 'questions'
    site = post.site

    tstore = AppConfig['token_store']
    acct_id = stack_exchange_account_id
    post_id = post.native_id
    post_type = path
    r = HTTParty.get("#{tstore['host']}/autoflag/options",
                     headers: {
                       'X-API-Key': tstore['key']
                     },
                     query: {
                       account_id: acct_id,
                       site: site.api_parameter,
                       post_id: post_id,
                       post_type: post_type[0..-2]
                     })
    return false, "[beta] /autoflag/options #{r.code}\n#{r.headers}\n#{r.body}" if r.code != 200
    response = JSON.parse(r.body)

    flag_options = response['items']

    if flag_options.blank?
      return false,
        if response['error_message'] == 'The account associated with the access_token does not have a user on the site'
          'No account on this site.'
        else
          'Flag options not present'
        end
    end

    flag_strings = {
      spam: ['spam', 'contenido no deseado', 'スパム', 'спам'],
      abusive: ['rude or abusive', 'rude ou abusivo', 'irrespetuoso o abusivo', '失礼又は暴言', 'невежливый или оскорбительный'],
      other: ['in need of moderator intervention', 'precisa de atenção dos moderadores', 'se necesita la intervención de un moderador',
              'モデレーターによる対応が必要です', 'требуется вмешательство модератора']
    }

    unless flag_strings.keys.include? flag_type.to_sym
      return false, "Unrecognized flag type #{flag_type} specified in call to User#flag"
    end

    flag_option = flag_options.find do |fo|
      flag_strings[flag_type.to_sym].include? fo['title'].downcase
    end

    return false, 'Flag option not present' if flag_option.blank?

    return true, 0 if dry_run
    tstore = AppConfig['token_store']
    acct_id = stack_exchange_account_id
    post_id = post.native_id
    flag_option_id = flag_option['option_id']
    Rails.logger.info "[Autoflagging][Flag-Option][#{flag_option['title']}]: #{flag_option_id}"
    post_type = path
    comment = opts[:comment]
    req = HTTParty.post("#{tstore['host']}/autoflag",
                        headers: {
                          'X-API-Key': tstore['key']
                        }, query: {
                          account_id: acct_id,
                          site: site.api_parameter,
                          post_id: post_id,
                          post_type: post_type[0..-2],
                          flag_option_id: flag_option_id,
                          comment: comment
                        })
    return false, "[beta] /autoflag #{req.code}\n#{req.headers}\n#{req.body}" if req.code != 200
    flag_response = JSON.parse(req.body)

    # rubocop:disable Style/GuardClause
    if flag_response.include?('error_id') || flag_response.include?('error_message')
      return false, flag_response['error_message']
    else
      return true, (flag_response.include?('backoff') ? flag_response['backoff'] : 0)
    end
    # rubocop:enable Style/GuardClause
  end

  def spam_flag(post, dry_run = false)
    flag :spam, post, dry_run
  end

  def abusive_flag(post, dry_run = false)
    flag :abusive, post, dry_run
  end

  def other_flag(post, comment)
    flag :other, post, false, comment: comment
  end

  def moderator?
    moderator_sites.any?
  end

  def add_pinned_role(name)
    role = Role.find_by name: name
    if UsersRole.where(user: self, role: role).exists?
      UsersRole.where(user: self, role: role).order(:user_id).last.update(pinned: true)
    else
      UsersRole.create(user: self, role: role, pinned: true)
    end
  end

  # rubocop:disable Style/PredicateName
  def has_pinned_role?(role)
    UsersRole.where(user: self, role: Role.find_by(name: role), pinned: true).exists?
  end

  def can_use_regex_search?
    (has_role? :reviewer) || moderator_sites.any?
  end

  private

  def gdpr_bollocks
    errors.add(:privacy_accepted, "must be agreed to if you're an EU resident") unless !eu_resident || privacy_accepted
  end
end


text:
# frozen_string_literal: true

class ReviewResult < ApplicationRecord
  include Websocket

  belongs_to :user
  belongs_to :item, class_name: 'ReviewItem', foreign_key: 'review_item_id'
end


text:
# frozen_string_literal: true

class Reason < ApplicationRecord
  include Websocket

  has_and_belongs_to_many :posts
  has_many :feedbacks, through: :posts

  def self.populate_redis_meta
    redis.pipelined do
      find_each do |reason|
        redis.sadd 'reasons', reason.id
        reason.posts.select(:id).each do |post|
          redis.sadd "reasons/#{reason.id}", post.id
        end
      end
    end
  end

  def tp_percentage
    # I don't like the .count.count, but it does get the job done

    count = posts.where(is_tp: true, is_fp: false).count

    count.to_f / posts.count
  end

  def fp_percentage
    count = posts.where(is_fp: true, is_tp: false).count

    count.to_f / posts.count
  end

  def both_percentage
    count = posts.where(is_fp: true, is_tp: true).count
    count += posts.includes(:feedbacks)
                  .where(is_tp: false, is_fp: false)
                  .where.not(feedbacks: { post_id: nil }).count

    count.to_f / posts.count
  end

  # Attempt to use cached post_count if it's available (included in the dashboard/index query)
  def fast_post_count
    try(:post_count) || posts.count
  end
end


text:
# frozen_string_literal: true

class APIKey < ApplicationRecord
  include Websocket

  validates :key, length: { minimum: 10, maximum: 100 }
  validates :key, uniqueness: true

  validates :app_name, length: { minimum: 1 }, uniqueness: true

  has_many :feedbacks
  has_many :api_tokens
  has_many :deletion_logs
  has_many :flag_logs

  # APIKey.user is the *owner* of the API application that the key belongs to.
  belongs_to :user
end


text:
# frozen_string_literal: true

class Site < ApplicationRecord
  include Websocket

  has_many :stack_exchange_users, dependent: :destroy
  has_many :posts, dependent: :nullify
  has_many :flag_logs, dependent: :nullify
  has_many :moderator_sites, dependent: :destroy
  has_and_belongs_to_many :user_site_settings
  has_and_belongs_to_many :flag_conditions

  scope(:mains, -> { where(is_child_meta: false, closed: false) })
  scope(:metas, -> { where(is_child_meta: true, closed: false) })
end


text:
# frozen_string_literal: true

class DeletionLog < ApplicationRecord
  include Websocket

  belongs_to :post
  belongs_to :api_key
  belongs_to :deletion_log
  validates :post_id, presence: true

  after_create do
    if is_deleted
      deletion_log = DeletionLogsController.render(
        locals: {
          deletion_log: self
        },
        partial: 'deletion_log.json'
      )
      ActionCable.server.broadcast 'api_deletion_logs', deletion_log: JSON.parse(deletion_log)
    end
  end

  after_create :update_deletion_data

  def update_deletion_data
    return unless is_deleted
    post.update(deleted_at: created_at) if post.deleted_at.nil?
    redis.hset("posts/#{post.id}", 'deleted_at', created_at.to_s)
  end

  def self.from_redis(post_id)
    del_log = new
    str = redis.hget("posts/#{post_id}", 'deleted_at')
    del_log.created_at = str
    del_log.is_deleted = !str.nil?
    [del_log]
  end

  after_create do
    DeletionLog.auto_other_flag(self, post)
  end

  def self.auto_other_flag(dl = nil, post = nil)
    return if post.nil? || !post.site&.auto_disputed_flags_enabled
    deleted = dl.present? ? dl.is_deleted : !post.deleted_at.nil?
    return unless deleted && (post.is_fp || post.is_naa) && post.flag_logs.manual.successful.count > 0
    previous_flags = FlagLog.unscoped.where(post: post).other.auto
    return unless previous_flags.count == 0 || (DateTime.now.to_i - previous_flags.map(&:created_at).max.to_i) >= 24.hours

    comment_template = "Charcoal project members cast {flagtype} flags on this post, but at least one subsequent reviewer isn't confident "\
                       "that's the right decision. Please review and undelete the post if necessary; see "\
                       'https://charcoal-se.org/smokey/Auto-Mod-Flags for more details about this flag.'
    flag_type = post.flag_logs.manual.successful.map(&:flag_type).uniq.join(' & ')
    comment = comment_template.gsub('{flagtype}', flag_type)
    smokey = User.find(-1)
    status, message = smokey.other_flag(post, comment)
    FlagLog.create(
      success: status,
      error_message: status.present? ? nil : message,
      is_dry_run: false,
      user: smokey,
      post: post,
      backoff: status.present? ? message : 0,
      site_id: post.site_id,
      is_auto: true,
      flag_type: 'other',
      comment: comment
    )
  end
end


text:
# frozen_string_literal: true

class FlagSetting < ApplicationRecord
  include Websocket

  audited
  validates :name, uniqueness: true

  def self.[](key)
    find_by(name: key).try(:value)
  end
end


text:
# frozen_string_literal: true

class AbuseContact < ApplicationRecord
  include Websocket

  has_many :reports, class_name: 'AbuseReport', dependent: :nullify
end


text:
# frozen_string_literal: true

class CommentScrubber < Rails::Html::PermitScrubber
  def initialize
    super
    self.tags = %w[a p b i em strong hr h1 h2 h3 h4 h5 h6 blockquote img strike del code pre br ul ol li]
    self.attributes = %w[href title src height width]
  end

  def skip_node?(node)
    node.text?
  end
end

class PostComment < ApplicationRecord
  include Websocket

  belongs_to :post
  belongs_to :user

  after_create do
    redis.hincrby "posts/#{post.id}", 'post_comments_count', 1
  end

  after_destroy do
    redis.hincrby "posts/#{post.id}", 'post_comments_count', -1 unless redis.hget("posts/#{post.id}", 'post_comments_count').to_s == '0'
  end

  def self.scrubber
    CommentScrubber.new
  end
end


text:
# frozen_string_literal: true

class DomainGroup < ApplicationRecord
  has_and_belongs_to_many :spam_domains

  def preference_ref_name
    name
  end
end


text:
# frozen_string_literal: true

class Redis::SiteSetting
  PREFIX = 'site_setting'

  def self.bool(name)
    redis.get("#{PREFIX}/#{name}").nil? ? false : true
  end

  def self.get(name)
    redis.get "#{PREFIX}/#{name}"
  end

  def self.set(name, val)
    v = 1 if val == true
    v = nil if val == false
    redis.set "#{PREFIX}/#{name}", v
  end
end


text:
# frozen_string_literal: true

class Redis::CI
  PREFIX = 'ci'
  CI_COUNTER_PREFIX = 'sucessful_ci_count'

  def initialize(sha)
    @sha = sha
  end

  def sucess_count_incr(sha = @sha)
    key = "#{PREFIX}/#{CI_COUNTER_PREFIX}/#{sha}"
    redis.multi do
      redis.incr key
      redis.expire key, 1200
    end[0]
  end

  def sucess_count_reset(sha = @sha)
    redis.del "#{PREFIX}/#{CI_COUNTER_PREFIX}/#{sha}"
  end

  def sucess_count(sha = @sha)
    redis.get("#{PREFIX}/#{CI_COUNTER_PREFIX}/#{sha}").to_i
  end

  private

  attr_accessor :sha
end


text:
# frozen_string_literal: true

class Redis::Post
  attr_reader :fields, :id

  FIELD_LIST = %i[body title link username why stack_exchange_user_id site_id created_at].freeze
  attr_reader(*FIELD_LIST)

  def initialize(id, **overrides)
    @id = id
    @fields = fetch_redis_fields.merge(overrides)
    FIELD_LIST.each do |field|
      instance_variable_set("@#{field}", @fields[field.to_s] || @fields[field])
    end
    @created_at = @created_at&.to_time
  end

  def fetch_redis_fields
    fields = redis.hgetall("posts/#{@id}")
    return fields unless fields.empty?
    self.class.to_redis(Post.find(@id))
  end

  private :fetch_redis_fields
  def self.to_redis(post)
    post_hsh = {
      'body' => post.body,
      'title' => post.title,
      'reason_weight' => post.reasons.map(&:weight).reduce(:+),
      'created_at' => post.created_at,
      'username' => post.username,
      'link' => post.link,
      'site_site_logo' => post.site.try(:site_logo),
      'stack_exchange_user_username' => post.stack_exchange_user.try(:username),
      'stack_exchange_user_id' => post.stack_exchange_user.try(:id),
      'flagged' => post.flagged?,
      'site_id' => post.site_id,
      'post_comments_count' => post.comments.count,
      'why' => post.why
    }
    redis.hmset("posts/#{post.id}", *post_hsh.to_a)
    redis.expire("posts/#{post.id}", 1.hour)
    post_hsh
  end

  def self.all(type:)
    case type
    when :set
      Redis::Base::Collection.new('all_posts', type: type)
    when :zset
      Redis::Base::Collection.new('posts', type: type)
    end
  end

  def stack_exchange_user
    @stack_exchange_user ||= Redis::StackExchangeUser.new(
      @stack_exchange_user_id,
      username: @fields['stack_exchange_user_username'],
      still_alive: @fields['stack_exchange_user_still_alive']
    )
  end

  def cachebreak
    Rack::MiniProfiler.step("Generating cachebreaker for Post##{id}") do
      "#{id}:#{feedbacks.map(&:id).join(',')}:#{comments.count}"
    end
  end

  def comments
    if @comments.nil?
      comments_count = @fields['post_comments_count']
      if comments_count.to_i < 0
        debug_prefix = "[Redis::Post comments] SE ID: #{@fields['site_id']}:"
        Rails.logger.debug "#{debug_prefix} post_comments_count (#{comments_count.to_i}) < 0: #{comments_count}"
      end
      @comments = Array.new([@fields['post_comments_count'].to_i, 0].max) { Redis::PostComment.new(nil) }
      @comments.define_singleton_method(:count) { length }
    end
    @comments
  end

  def site
    @site ||= if !@fields['site_site_logo'].nil? && !@fields['site_id'].nil?
                Site.new(site_logo: @fields['site_site_logo'], id: @fields['site_id'])
              end
  end

  def flagged?
    @flagged ||= rpost['flagged'] == 'true'
  end

  def reasons
    if @reasons.nil?
      reason_names = redis.zrange "posts/#{id}/reasons", 0, -1, with_scores: true
      @reasons = reason_names.map do |rn, weight|
        Reason.new(reason_name: rn, weight: weight)
      end
    end
    @reasons
  end

  def feedbacks
    @feedbacks ||= Redis::Feedback.post(id)
  end

  def deletion_logs
    @deletion_logs ||= DeletionLog.from_redis(id)
  end

  def deleted_at
    @deleted_at ||= deletion_logs.empty? ? nil : deletion_logs.first.created_at
  end
end


text:
# frozen_string_literal: true

class Redis::Feedback
  attr_reader :fields, :id

  def initialize(id, **overrides)
    @id = id
    @fields = redis.hgetall("feedbacks/#{id}").merge(overrides)
  end

  %w[feedback_type username is_invalidated].each do |m|
    define_method(m) { @fields[m] }
  end

  def api_key
    @api_key ||= APIKey.new(app_name: @fields['app_name'])
  end

  alias user_name username

  def user
    @user ||= User.new(username: username)
  end

  def self.post(post_id)
    feedback_ids = redis.smembers "post/#{post_id}/feedbacks"
    feedback_ids.to_a.map { |id| Redis::Feedback.new(id) }
  end

  # Stolen from feedbacks model

  def is_positive? # rubocop:disable Style/PredicateName
    feedback_type.include? 't'
  end

  def is_negative? # rubocop:disable Style/PredicateName
    feedback_type.include? 'f'
  end

  def is_naa? # rubocop:disable Style/PredicateName
    feedback_type.include? 'naa'
  end
end


text:
# frozen_string_literal: true

class Redis::Base
end


text:
# frozen_string_literal: true

class Redis::StackExchangeUser
  attr_reader :fields, :id

  def initialize(id, **overrides)
    @id = id
    @fields = overrides
  end

  %w[username].each do |m|
    define_method(m) { @fields[m] }
  end
end


text:
# frozen_string_literal: true

class Redis::QueryAverage
  def initialize(method, path, since: 0)
    @since = since
    @method = method
    @path = path
  end

  def counter(t)
    redis(logger: true).zcount(redis_key(t), @since, '+inf').to_i
  end

  def average(t)
    count = counter(t)
    return Float::INFINITY if count == 0
    redis(logger: true).zrangebyscore(redis_key(t), @since, '+inf').map(&:to_f).sum / count
  end

  def path
    "#{@method} #{@path}"
  end

  def raw_path
    "#{@method}/#{@path}"
  end

  def reset
    %i[db total view].each do |t|
      redis(logger: true).del(redis_key(t))
    end
    redis(logger: true).srem 'request_timings/path_strings', raw_path
  end

  def redis_key(t)
    "request_timings/#{t}/by_path/#{@method}/#{@path}"
  end
end


text:
# frozen_string_literal: true

class Redis::Reason < Redis::Base::Set
  source_type :reasons
  target_name :posts

  def update_reason_feedback_cache(feedback_type)
    feedback_type = "#{feedback_type}s" unless feedback_type.to_s.last == 's'
    redis.multi do
      intersect(feedback_type).store("#{prefix}/#{id}/#{feedback_type}")
      # Why is this reasons/prefix/<fb> rather than <fbs>
      for_feedback(feedback_type).difference('fps', 'naas').update! if feedback_type == 'tps'
      for_feedback(feedback_type).difference('tps', 'naas').update! if feedback_type == 'fps'
    end
  end

  def clear_reason_feedback_cache(feedback_type)
    feedback_type = "#{feedback_type}s" unless feedback_type.to_s.last == 's'
    redis.del "#{prefix}/#{id}/#{feedback_type}"
  end

  def for_feedback(feedback_type)
    Redis::Base::Collection.new("#{prefix}/#{id}/#{feedback_type}", type: :set)
  end
end


text:
# frozen_string_literal: true

class Redis::PostComment
  attr_reader :fields, :id

  def initialize(id, **overrides)
    @id = id
    @fields = overrides
  end
end


text:
# frozen_string_literal: true

class Redis::Base::ZSet
  def all(id)
    id.to_i
    redis.zrange "#{prefix}/#{id}", 0, -1
  end

  private

  attr_writer :prefix

  def prefix
    @prefix ||= self.class.to_s.downcase
  end
end


text:
# frozen_string_literal: true

class Redis::Base::Set
  def id
    @source_id
  end

  def initialize(id)
    @source_id = id
  end

  def intersect(*target_keys, **opts)
    target_keys.map! { |tk| tk.is_a?(Redis::Base::Set) ? tk.to_key : tk }
    Redis::Base::Collection.intersect(key, target_keys, **opts)
  end

  def difference(*target_keys, **opts)
    target_keys.map! { |tk| tk.is_a?(Redis::Base::Set) ? tk.to_key : tk }
    Redis::Base::Collection.difference(key, target_keys, **opts)
  end

  def method_missing(m, *args, &block)
    return target_all(*args, &block) if m == target
    super
  end

  def respond_to_missing?(m, _include_private = false)
    m == target
  end

  def to_key
    key
  end

  def count
    redis.scard key
  end

  private

  def target_all
    redis.smembers "#{prefix}/#{id}"
  end

  def key
    @key ||= "#{prefix}/#{id}"
  end

  def target
    self.class.target
  end

  # def id
  #   @source_id
  # end

  def prefix
    self.class.prefix
  end

  class << self
    def find(id)
      new(id.to_i)
    end

    def source_type(nprefix)
      @prefix = nprefix.to_sym
    end

    def target_name(ntarget)
      @target = ntarget.to_sym
    end

    def prefix
      @prefix ||= to_s.downcase.to_sym
    end

    def target
      throw "No target defined for #{self}" unless @target
      @target
    end
  end
end


text:
# frozen_string_literal: true

class Redis::Base::Collection
  attr_reader :key, :intersects

  def initialize(key, type: :set, intersects: [], differences: [])
    @type = type
    @key = key.to_s
    @intersects = intersects
    @differences = differences
  end

  def update!
    store(@key)
  end

  def intersect(*target_keys, type: @type)
    target_keys.map! { |tk| tk.respond_to?(:to_key) ? tk.to_key : tk }
    type = type.to_sym
    throw "Invalid type #{type}" unless %i[set zset].include? type
    Redis::Base::Collection.new(@key, type: type, intersects: @intersects.dup.push(target_keys).flatten, differences: @differences)
  end

  def difference(*target_keys, type: @type)
    target_keys.map! { |tk| tk.respond_to?(:to_key) ? tk.to_key : tk }
    type = type.to_sym
    throw "Invalid type #{type}" unless %i[set zset].include? type
    Redis::Base::Collection.new(@key, type: type, intersects: @intersects, differences: @differences.dup.push(target_keys).flatten)
  end

  def paginate(pagenum, pagesize, &mod)
    pagenum = [pagenum.to_i - 1, 0].max
    res = evaluate([pagenum * pagesize, (pagenum + 1) * pagesize]).map(&mod)
    res.define_singleton_method(:total_pages) { res.length / pagesize }
    res.define_singleton_method(:current_page) { pagenum + 1 }
    res
  end

  def cardinality
    # Eval to tmpkey and then zcard
    if @differences.empty? && @intersects.empty?
      return case @type
             when :set
               redis.scard @key
             when :zset
               redis.zcard @key
             end
    end
    redis.multi do |multi|
      key = "tmp/collection/#{Time.now.to_i}"
      case @type
      when :zset
        multi.zinterstore key, [@key, @intersects].flatten unless @intersects.empty?
        throw 'WTF' unless @differences.empty?
        multi.zdiffstore key, key, @differences unless @differences.empty?
        multi.zcard key
      when :set
        multi.sinterstore key, [@key, @intersects].flatten unless @intersects.empty?
        multi.sdiffstore key, key, @differences unless @differences.empty?
        multi.scard key
      end
      multi.del key
    end[1] # This is black magic to me. I'm really unsure what it is.
  end

  def evaluate(bounds = nil, order: nil)
    key = "tmp/collection/#{Time.now.to_i}"
    case @type
    when :set
      puts 'Cannot accept bounds for SET' unless bounds.nil?
      puts 'Cannot ordering for SET' unless order.nil?
      redis.sinterstore key, *@intersects
      redis.sdiff key, @differences
    when :zset
      bounds ||= [0, -1]
      redis.multi do
        redis.zinterstore key, [@key, @intersects].flatten
        throw 'Cannot do diff with zsets yet!' unless @differences.empty?
        redis.zdiffstore key, key, @differences unless @differences.empty?
        if order.to_s.casecmp('ASC') == 0
          redis.zrange key, *bounds
        else
          redis.zrevrange key, *bounds
        end
        redis.del key
      end[1]
    end
  end

  def store(key)
    # TODO: For all the commands in this section, doing this will an empty @intersects or @differences will explode in unknown ways
    throw 'WHUT R U STORIN' if @intersects.empty? && @differences.empty?
    redis.multi do |_multi|
      case @type
      when :set
        redis.sinterstore key, @key, @intersects unless @intersects.empty?
        redis.sdiffstore key, @key, @differences unless @differences.empty?
      when :zset
        redis.zinterstore key, [@key, @intersects].flatten unless @intersects.empty?
        redis.zdiffstore key, key, @differences unless @differences.empty?
      end
    end
  end

  class << self
    def intersect(key1, key2, **opts)
      Redis::Base::Collection.new(key1).intersect(key2, **opts)
    end
  end
end


text:
# frozen_string_literal: true

module Websocket
  extend ActiveSupport::Concern

  included do
    after_create :broadcast_create
    after_update :broadcast_update
    before_destroy :broadcast_destroy, prepend: true

    def broadcast_event(type)
      event_class = self.class.to_s
      table = event_class.pluralize.underscore
      channel = "#{table}_#{type}"
      object_data = attributes.delete_if { |k, _| AppConfig['sensitive_fields'].include? "#{table}.#{k}" }
      extended = respond_to?(:extended_websocket) ? extended_websocket : {}
      object_data.deep_merge! extended
      ApiChannel.broadcast_to channel, event_type: type, event_class: event_class, object: object_data
    end

    def broadcast_create
      broadcast_event 'create'
    end

    def broadcast_update
      broadcast_event 'update'
    end

    def broadcast_destroy
      broadcast_event 'destroy'
    end
  end
end


text:
# frozen_string_literal: true

module PostConcerns::Review
  extend ActiveSupport::Concern

  included do
    scope(:unreviewed, -> { where('feedbacks_count < 2 or feedbacks_count is null') })

    after_commit do
      if review_item.present? && should_dq?(ReviewQueue['posts'])
        review_item.update(completed: true)
      end
    end

    def custom_review_action(_queue, _item, user, response)
      feedbacks.create(user: user, feedback_type: response)
    end

    def should_dq?(queue)
      case queue.name
      when 'posts'
        age_threshold = SiteSetting['review_old_posts_days_threshold']
        weight_threshold = SiteSetting['review_old_posts_weight_threshold']
        post_age = DateTime.now - created_at.to_datetime
        feedback_count = feedbacks.count
        feedback_types = feedbacks.map(&:feedback_type).uniq

        # Post has two feedbacks, OR has one or non-conflicting feedback and is old and low-weight
        feedback_count >= 2 ||
          ((feedback_count == 1 || feedback_types.size == 1) &&
            post_age >= age_threshold.days && total_weight <= weight_threshold)
      else
        false
      end
    end

    def review_item_name
      title
    end
  end
end