# frozen_string_literal: true

require 'cache'
require 'open3'
require_dependency 'route_format'
require_dependency 'plugin/instance'
require_dependency 'auth/default_current_user_provider'
require_dependency 'version'
require 'digest/sha1'

# Prevents errors with reloading dev with conditional includes
if Rails.env.development?
  require_dependency 'file_store/s3_store'
  require_dependency 'file_store/local_store'
end

module Discourse
  DB_POST_MIGRATE_PATH ||= "db/post_migrate"

  require 'sidekiq/exception_handler'
  class SidekiqExceptionHandler
    extend Sidekiq::ExceptionHandler
  end

  class Utils
    def self.execute_command(*command, failure_message: "", success_status_codes: [0], chdir: ".")
      stdout, stderr, status = Open3.capture3(*command, chdir: chdir)

      if !status.exited? || !success_status_codes.include?(status.exitstatus)
        failure_message = "#{failure_message}\n" if !failure_message.blank?
        raise "#{caller[0]}: #{failure_message}#{stderr}"
      end

      stdout
    end

    def self.pretty_logs(logs)
      logs.join("\n".freeze)
    end
  end

  # Log an exception.
  #
  # If your code is in a scheduled job, it is recommended to use the
  # error_context() method in Jobs::Base to pass the job arguments and any
  # other desired context.
  # See app/jobs/base.rb for the error_context function.
  def self.handle_job_exception(ex, context = {}, parent_logger = nil)
    return if ex.class == Jobs::HandledExceptionWrapper

    context ||= {}
    parent_logger ||= SidekiqExceptionHandler

    cm = RailsMultisite::ConnectionManagement
    parent_logger.handle_exception(ex, {
      current_db: cm.current_db,
      current_hostname: cm.current_hostname
    }.merge(context))

    raise ex if Rails.env.test?
  end

  # Expected less matches than what we got in a find
  class TooManyMatches < StandardError; end

  # When they try to do something they should be logged in for
  class NotLoggedIn < StandardError; end

  # When the input is somehow bad
  class InvalidParameters < StandardError; end

  # When they don't have permission to do something
  class InvalidAccess < StandardError
    attr_reader :obj, :custom_message, :opts
    def initialize(msg = nil, obj = nil, opts = nil)
      super(msg)

      @opts = opts || {}
      @custom_message = opts[:custom_message] if @opts[:custom_message]
      @obj = obj
    end
  end

  # When something they want is not found
  class NotFound < StandardError
    attr_reader :status
    attr_reader :check_permalinks
    attr_reader :original_path

    def initialize(message = nil, status: 404, check_permalinks: false, original_path: nil)
      @status = status
      @check_permalinks = check_permalinks
      @original_path = original_path
      super(message)
    end
  end

  # When a setting is missing
  class SiteSettingMissing < StandardError; end

  # When ImageMagick is missing
  class ImageMagickMissing < StandardError; end

  # When read-only mode is enabled
  class ReadOnly < StandardError; end

  # Cross site request forgery
  class CSRF < StandardError; end

  class Deprecation < StandardError; end

  class ScssError < StandardError; end

  def self.filters
    @filters ||= [:latest, :unread, :new, :read, :posted, :bookmarks]
  end

  def self.anonymous_filters
    @anonymous_filters ||= [:latest, :top, :categories]
  end

  def self.top_menu_items
    @top_menu_items ||= Discourse.filters + [:category, :categories, :top]
  end

  def self.anonymous_top_menu_items
    @anonymous_top_menu_items ||= Discourse.anonymous_filters + [:category, :categories, :top]
  end

  PIXEL_RATIOS ||= [1, 1.5, 2, 3]

  def self.avatar_sizes
    # TODO: should cache these when we get a notification system for site settings
    set = Set.new

    SiteSetting.avatar_sizes.split("|").map(&:to_i).each do |size|
      PIXEL_RATIOS.each do |pixel_ratio|
        set << size * pixel_ratio
      end
    end

    set
  end

  def self.activate_plugins!
    all_plugins = Plugin::Instance.find_all("#{Rails.root}/plugins")

    if Rails.env.development?
      plugin_hash = Digest::SHA1.hexdigest(all_plugins.map { |p| p.path }.sort.join('|'))
      hash_file = "#{Rails.root}/tmp/plugin-hash"

      old_hash = begin
        File.read(hash_file)
      rescue Errno::ENOENT
      end

      if old_hash && old_hash != plugin_hash
        puts "WARNING: It looks like your discourse plugins have recently changed."
        puts "It is highly recommended to remove your `tmp` directory, otherwise"
        puts "plugins might not work."
        puts
      else
        File.write(hash_file, plugin_hash)
      end
    end

    @plugins = []
    all_plugins.each do |p|
      v = p.metadata.required_version || Discourse::VERSION::STRING
      if Discourse.has_needed_version?(Discourse::VERSION::STRING, v)
        p.activate!
        @plugins << p
      else
        STDERR.puts "Could not activate #{p.metadata.name}, discourse does not meet required version (#{v})"
      end
    end
  end

  def self.disabled_plugin_names
    plugins.select { |p| !p.enabled? }.map(&:name)
  end

  def self.plugins
    @plugins ||= []
  end

  def self.hidden_plugins
    @hidden_plugins ||= []
  end

  def self.visible_plugins
    self.plugins - self.hidden_plugins
  end

  def self.plugin_themes
    @plugin_themes ||= plugins.map(&:themes).flatten
  end

  def self.official_plugins
    plugins.find_all { |p| p.metadata.official? }
  end

  def self.unofficial_plugins
    plugins.find_all { |p| !p.metadata.official? }
  end

  def self.find_plugins(args)
    plugins.select do |plugin|
      next if args[:include_official] == false && plugin.metadata.official?
      next if args[:include_unofficial] == false && !plugin.metadata.official?
      next if args[:include_disabled] == false && !plugin.enabled?

      true
    end
  end

  def self.find_plugin_js_assets(args)
    self.find_plugins(args).find_all do |plugin|
      plugin.js_asset_exists?
    end.map { |plugin| "plugins/#{plugin.asset_name}" }
  end

  def self.assets_digest
    @assets_digest ||= begin
      digest = Digest::MD5.hexdigest(ActionView::Base.assets_manifest.assets.values.sort.join)

      channel = "/global/asset-version"
      message = MessageBus.last_message(channel)

      unless message && message.data == digest
        MessageBus.publish channel, digest
      end
      digest
    end
  end

  BUILTIN_AUTH ||= [
    Auth::AuthProvider.new(authenticator: Auth::FacebookAuthenticator.new, frame_width: 580, frame_height: 400, icon: "fab-facebook"),
    Auth::AuthProvider.new(authenticator: Auth::GoogleOAuth2Authenticator.new, frame_width: 850, frame_height: 500), # Custom icon implemented in client
    Auth::AuthProvider.new(authenticator: Auth::GithubAuthenticator.new, icon: "fab-github"),
    Auth::AuthProvider.new(authenticator: Auth::TwitterAuthenticator.new, icon: "fab-twitter"),
    Auth::AuthProvider.new(authenticator: Auth::InstagramAuthenticator.new, icon: "fab-instagram")
  ]

  def self.auth_providers
    BUILTIN_AUTH + DiscoursePluginRegistry.auth_providers.to_a
  end

  def self.enabled_auth_providers
    auth_providers.select { |provider|  provider.authenticator.enabled?  }
  end

  def self.authenticators
    # NOTE: this bypasses the site settings and gives a list of everything, we need to register every middleware
    #  for the cases of multisite
    auth_providers.map(&:authenticator)
  end

  def self.enabled_authenticators
    authenticators.select { |authenticator|  authenticator.enabled?  }
  end

  def self.cache
    @cache ||= begin
      if GlobalSetting.skip_redis?
        ActiveSupport::Cache::MemoryStore.new
      else
        Cache.new
      end
    end
  end

  # Get the current base URL for the current site
  def self.current_hostname
    SiteSetting.force_hostname.presence || RailsMultisite::ConnectionManagement.current_hostname
  end

  def self.base_uri(default_value = "")
    ActionController::Base.config.relative_url_root.presence || default_value
  end

  def self.base_protocol
    SiteSetting.force_https? ? "https" : "http"
  end

  def self.base_url_no_prefix
    default_port = SiteSetting.force_https? ? 443 : 80
    url = +"#{base_protocol}://#{current_hostname}"
    url << ":#{SiteSetting.port}" if SiteSetting.port.to_i > 0 && SiteSetting.port.to_i != default_port

    if Rails.env.development? && SiteSetting.port.blank?
      url << ":#{ENV["UNICORN_PORT"] || 3000}"
    end

    url
  end

  def self.base_url
    base_url_no_prefix + base_uri
  end

  def self.route_for(uri)
    unless uri.is_a?(URI)
      uri = begin
        URI(uri)
      rescue URI::Error
      end
    end

    return unless uri

    path = +(uri.path || "")
    if !uri.host || (uri.host == Discourse.current_hostname && path.start_with?(Discourse.base_uri))
      path.slice!(Discourse.base_uri)
      return Rails.application.routes.recognize_path(path)
    end

    nil
  rescue ActionController::RoutingError
    nil
  end

  class << self
    alias_method :base_path, :base_uri
    alias_method :base_url_no_path, :base_url_no_prefix
  end

  READONLY_MODE_KEY_TTL  ||= 60
  READONLY_MODE_KEY      ||= 'readonly_mode'
  PG_READONLY_MODE_KEY   ||= 'readonly_mode:postgres'
  USER_READONLY_MODE_KEY ||= 'readonly_mode:user'

  READONLY_KEYS ||= [
    READONLY_MODE_KEY,
    PG_READONLY_MODE_KEY,
    USER_READONLY_MODE_KEY
  ]

  def self.enable_readonly_mode(key = READONLY_MODE_KEY)
    if key == USER_READONLY_MODE_KEY
      $redis.set(key, 1)
    else
      $redis.setex(key, READONLY_MODE_KEY_TTL, 1)
      keep_readonly_mode(key) if !Rails.env.test?
    end

    MessageBus.publish(readonly_channel, true)
    Site.clear_anon_cache!
    true
  end

  def self.keep_readonly_mode(key)
    # extend the expiry by 1 minute every 30 seconds
    @mutex ||= Mutex.new

    @mutex.synchronize do
      @dbs ||= Set.new
      @dbs << RailsMultisite::ConnectionManagement.current_db
      @threads ||= {}

      unless @threads[key]&.alive?
        @threads[key] = Thread.new do
          while @dbs.size > 0 do
            sleep 30

            @mutex.synchronize do
              @dbs.each do |db|
                RailsMultisite::ConnectionManagement.with_connection(db) do
                  if !$redis.expire(key, READONLY_MODE_KEY_TTL)
                    @dbs.delete(db)
                  end
                end
              end
            end
          end
        end
      end
    end
  end

  def self.disable_readonly_mode(key = READONLY_MODE_KEY)
    $redis.del(key)
    MessageBus.publish(readonly_channel, false)
    Site.clear_anon_cache!
    true
  end

  def self.readonly_mode?(keys = READONLY_KEYS)
    recently_readonly? || $redis.mget(*keys).compact.present?
  end

  def self.pg_readonly_mode?
    $redis.get(PG_READONLY_MODE_KEY).present?
  end

  # Shared between processes
  def self.postgres_last_read_only
    @postgres_last_read_only ||= DistributedCache.new('postgres_last_read_only', namespace: false)
  end

  # Per-process
  def self.redis_last_read_only
    @redis_last_read_only ||= {}
  end

  def self.recently_readonly?
    postgres_read_only = postgres_last_read_only[$redis.namespace]
    redis_read_only = redis_last_read_only[$redis.namespace]

    (redis_read_only.present? && redis_read_only > 15.seconds.ago) ||
      (postgres_read_only.present? && postgres_read_only > 15.seconds.ago)
  end

  def self.received_postgres_readonly!
    postgres_last_read_only[$redis.namespace] = Time.zone.now
  end

  def self.received_redis_readonly!
    redis_last_read_only[$redis.namespace] = Time.zone.now
  end

  def self.clear_readonly!
    postgres_last_read_only[$redis.namespace] = redis_last_read_only[$redis.namespace] = nil
    Site.clear_anon_cache!
    true
  end

  def self.request_refresh!(user_ids: nil)
    # Causes refresh on next click for all clients
    #
    # This is better than `MessageBus.publish "/file-change", ["refresh"]` because
    # it spreads the refreshes out over a time period
    if user_ids
      MessageBus.publish("/refresh_client", 'clobber', user_ids: user_ids)
    else
      MessageBus.publish('/global/asset-version', 'clobber')
    end
  end

  def self.ensure_version_file_loaded
    unless @version_file_loaded
      version_file = "#{Rails.root}/config/version.rb"
      require version_file if File.exists?(version_file)
      @version_file_loaded = true
    end
  end

  def self.git_version
    ensure_version_file_loaded
    $git_version ||=
      begin
        git_cmd = 'git rev-parse HEAD'
        self.try_git(git_cmd, Discourse::VERSION::STRING)
      end
  end

  def self.git_branch
    ensure_version_file_loaded
    $git_branch ||=
      begin
        git_cmd = 'git rev-parse --abbrev-ref HEAD'
        self.try_git(git_cmd, 'unknown')
      end
  end

  def self.full_version
    ensure_version_file_loaded
    $full_version ||=
      begin
        git_cmd = 'git describe --dirty --match "v[0-9]*"'
        self.try_git(git_cmd, 'unknown')
      end
  end

  def self.last_commit_date
    ensure_version_file_loaded
    $last_commit_date ||=
      begin
        git_cmd = 'git log -1 --format="%ct"'
        seconds = self.try_git(git_cmd, nil)
        seconds.nil? ? nil : DateTime.strptime(seconds, '%s')
      end
  end

  def self.try_git(git_cmd, default_value)
    version_value = false

    begin
      version_value = `#{git_cmd}`.strip
    rescue
      version_value = default_value
    end

    if version_value.empty?
      version_value = default_value
    end

    version_value
  end

  # Either returns the site_contact_username user or the first admin.
  def self.site_contact_user
    user = User.find_by(username_lower: SiteSetting.site_contact_username.downcase) if SiteSetting.site_contact_username.present?
    user ||= (system_user || User.admins.real.order(:id).first)
  end

  SYSTEM_USER_ID ||= -1

  def self.system_user
    @system_user ||= User.find_by(id: SYSTEM_USER_ID)
  end

  def self.store
    if SiteSetting.Upload.enable_s3_uploads
      @s3_store_loaded ||= require 'file_store/s3_store'
      FileStore::S3Store.new
    else
      @local_store_loaded ||= require 'file_store/local_store'
      FileStore::LocalStore.new
    end
  end

  def self.stats
    PluginStore.new("stats")
  end

  def self.current_user_provider
    @current_user_provider || Auth::DefaultCurrentUserProvider
  end

  def self.current_user_provider=(val)
    @current_user_provider = val
  end

  def self.asset_host
    Rails.configuration.action_controller.asset_host
  end

  def self.readonly_channel
    "/site/read-only"
  end

  # all forking servers must call this
  # after fork, otherwise Discourse will be
  # in a bad state
  def self.after_fork
    # note: some of this reconnecting may no longer be needed per https://github.com/redis/redis-rb/pull/414
    MessageBus.after_fork
    SiteSetting.after_fork
    $redis._client.reconnect
    Rails.cache.reconnect
    Logster.store.redis.reconnect
    # shuts down all connections in the pool
    Sidekiq.redis_pool.shutdown { |c| nil }
    # re-establish
    Sidekiq.redis = sidekiq_redis_config
    start_connection_reaper

    # in case v8 was initialized we want to make sure it is nil
    PrettyText.reset_context

    Tilt::ES6ModuleTranspilerTemplate.reset_context if defined? Tilt::ES6ModuleTranspilerTemplate
    JsLocaleHelper.reset_context if defined? JsLocaleHelper
    nil
  end

  # you can use Discourse.warn when you want to report custom environment
  # with the error, this helps with grouping
  def self.warn(message, env = nil)
    append = env ? (+" ") << env.map { |k, v|"#{k}: #{v}" }.join(" ") : ""

    if !(Logster::Logger === Rails.logger)
      Rails.logger.warn("#{message}#{append}")
      return
    end

    loggers = [Rails.logger]
    if Rails.logger.chained
      loggers.concat(Rails.logger.chained)
    end

    logster_env = env

    if old_env = Thread.current[Logster::Logger::LOGSTER_ENV]
      logster_env = Logster::Message.populate_from_env(old_env)

      # a bit awkward by try to keep the new params
      env.each do |k, v|
        logster_env[k] = v
      end
    end

    loggers.each do |logger|
      if !(Logster::Logger === logger)
        logger.warn("#{message} #{append}")
        next
      end

      logger.store.report(
        ::Logger::Severity::WARN,
        "discourse",
        message,
        env: logster_env
      )
    end

    if old_env
      env.each do |k, v|
        # do not leak state
        logster_env.delete(k)
      end
    end

    nil
  end

  # report a warning maintaining backtrack for logster
  def self.warn_exception(e, message: "", env: nil)
    if Rails.logger.respond_to? :add_with_opts

      env ||= {}
      env[:current_db] ||= RailsMultisite::ConnectionManagement.current_db

      # logster
      Rails.logger.add_with_opts(
        ::Logger::Severity::WARN,
        "#{message} : #{e}",
        "discourse-exception",
        backtrace: e.backtrace.join("\n"),
        env: env
      )
    else
      # no logster ... fallback
      Rails.logger.warn("#{message} #{e}")
    end
  rescue
    STDERR.puts "Failed to report exception #{e} #{message}"
  end

  def self.start_connection_reaper
    return if GlobalSetting.connection_reaper_age < 1 ||
              GlobalSetting.connection_reaper_interval < 1

    # this helps keep connection counts in check
    Thread.new do
      while true
        begin
          sleep GlobalSetting.connection_reaper_interval
          reap_connections(GlobalSetting.connection_reaper_age)
        rescue => e
          Discourse.warn_exception(e, message: "Error reaping connections")
        end
      end
    end
  end

  def self.reap_connections(idle)
    pools = []
    ObjectSpace.each_object(ActiveRecord::ConnectionAdapters::ConnectionPool) { |pool| pools << pool }

    pools.each do |pool|
      # reap recovers connections that were aborted
      # eg a thread died or a dev forgot to check it in
      # flush removes idle connections
      # after fork we have "deadpools" so ignore them, they have been discarded
      # so @connections is set to nil
      if pool.connections
        pool.reap
        pool.flush(idle)
      end
    end
  end

  def self.deprecate(warning, drop_from: nil, since: nil, raise_error: false, output_in_test: false)
    location = caller_locations[1].yield_self { |l| "#{l.path}:#{l.lineno}:in \`#{l.label}\`" }
    warning = ["Deprecation notice:", warning]
    warning << "(deprecated since Discourse #{since})" if since
    warning << "(removal in Discourse #{drop_from})" if drop_from
    warning << "\nAt #{location}"
    warning = warning.join(" ")

    if raise_error
      raise Deprecation.new(warning)
    end

    if Rails.env == "development"
      STDERR.puts(warning)
    end

    if output_in_test && Rails.env == "test"
      STDERR.puts(warning)
    end

    digest = Digest::MD5.hexdigest(warning)
    redis_key = "deprecate-notice-#{digest}"

    if !$redis.without_namespace.get(redis_key)
      Rails.logger.warn(warning)
      begin
        $redis.without_namespace.setex(redis_key, 3600, "x")
      rescue Redis::CommandError => e
        raise unless e.message =~ /READONLY/
      end
    end
    warning
  end

  SIDEKIQ_NAMESPACE ||= 'sidekiq'.freeze

  def self.sidekiq_redis_config
    conf = GlobalSetting.redis_config.dup
    conf[:namespace] = SIDEKIQ_NAMESPACE
    conf
  end

  def self.static_doc_topic_ids
    [SiteSetting.tos_topic_id, SiteSetting.guidelines_topic_id, SiteSetting.privacy_topic_id]
  end

  cattr_accessor :last_ar_cache_reset

  def self.reset_active_record_cache_if_needed(e)
    last_cache_reset = Discourse.last_ar_cache_reset
    if e && e.message =~ /UndefinedColumn/ && (last_cache_reset.nil? || last_cache_reset < 30.seconds.ago)
      Rails.logger.warn "Clearing Active Record cache, this can happen if schema changed while site is running or in a multisite various databases are running different schemas. Consider running rake multisite:migrate."
      Discourse.last_ar_cache_reset = Time.zone.now
      Discourse.reset_active_record_cache
    end
  end

  def self.reset_active_record_cache
    ActiveRecord::Base.connection.query_cache.clear
    (ActiveRecord::Base.connection.tables - %w[schema_migrations versions]).each do |table|
      table.classify.constantize.reset_column_information rescue nil
    end
    nil
  end

  def self.running_in_rack?
    ENV["DISCOURSE_RUNNING_IN_RACK"] == "1"
  end

  def self.skip_post_deployment_migrations?
    ['1', 'true'].include?(ENV["SKIP_POST_DEPLOYMENT_MIGRATIONS"]&.to_s)
  end
end