# frozen_string_literal: true

module WPScan
  module DB
    # Class used to perform DB updates
    # simplecov:disable
    class Updater
      # /!\ Might want to also update the Enumeration#cli_options when some filenames are changed here
      FILES = %w[
        metadata.json wp_fingerprints.json
        timthumbs-v3.txt config_backups.txt db_exports.txt backup_folders.txt
        dynamic_finders.yml LICENSE sponsor.txt
      ].freeze

      OLD_FILES = %w[
        wordpress.db user-agents.txt dynamic_finders_01.yml
        wordpresses.json plugins.json themes.json
      ].freeze

      # Enterprise vulnerability DB dumps, downloaded (in addition to FILES) only when an
      # --enterprise-db-token is supplied, from ENTERPRISE_HOST using the X-DB-JSON-AUTH header.
      ENTERPRISE_FILES = %w[wordpresses.json.gz plugins.json.gz themes.json.gz].freeze

      ENTERPRISE_HOST = 'enterprise-data.wpscan.org'
      DEFAULT_HOST    = 'data.wpscan.org'

      attr_reader :repo_directory

      def initialize(repo_directory)
        @repo_directory = Pathname.new(repo_directory).expand_path

        FileUtils.mkdir_p(repo_directory.to_s)

        # When --no-update is passed, return to avoid raising an error if the directory is not writable
        # Mainly there for Homebrew: https://github.com/wpscanteam/wpscan/pull/1455
        return if ParsedCli.update == false

        unless repo_directory.writable?
          raise "#{repo_directory} is not writable (uid: #{Process.uid}, gid: #{Process.gid})"
        end

        delete_old_files
      end

      # Removes DB files which are no longer used
      # this doesn't raise errors if they don't exist
      def delete_old_files
        OLD_FILES.each do |old_file|
          FileUtils.remove_file(local_file_path(old_file), true)
        end
      end

      # @return [ String, nil ] The enterprise DB token. Resolved exactly like Controller::VulnApi
      #                         does; the two must stay in sync or the dumps won't be downloaded.
      def enterprise_db_token
        ParsedCli.enterprise_db_token || ENV.fetch('WPSCAN_ENTERPRISE_DB_TOKEN', nil)
      end

      # @return [ Array<String> ] The files to keep in sync (plus the enterprise dumps when a token is set)
      def files
        return FILES + ENTERPRISE_FILES if enterprise_db_token

        FILES
      end

      # @return [ Boolean ] Whether the file is an enterprise dump (fetched from ENTERPRISE_HOST with auth)
      def enterprise_file?(filename)
        !enterprise_db_token.nil? && ENTERPRISE_FILES.include?(filename)
      end

      # @return [ Time, nil ]
      def last_update
        Time.parse(File.read(last_update_file))
      rescue ArgumentError, Errno::ENOENT
        nil # returns nil if the file does not exist or contains invalid time data
      end

      # @return [ String ]
      def last_update_file
        @last_update_file ||= repo_directory.join('.last_update').to_s
      end

      # @return [ Boolean ]
      def outdated?
        date = last_update

        date.nil? || date < 5.days.ago
      end

      # @return [ Boolean ]
      def missing_files?
        files.each do |file|
          return true unless File.exist?(repo_directory.join(file))
        end
        false
      end

      # @return [ Hash ] The params for Typhoeus::Request
      # @note Those params can't be overriden by CLI options
      def request_params(filename = nil)
        params = Browser.instance.default_request_params.merge(
          timeout: 600,
          connecttimeout: 300,
          accept_encoding: 'gzip, deflate',
          cache_ttl: 0,
          headers: { 'User-Agent' => Browser.instance.default_user_agent }
        )

        if enterprise_file?(filename)
          params[:headers]['X-DB-JSON-AUTH'] = enterprise_db_token
          # The dumps are pre-gzipped static objects. If we advertise Accept-Encoding: gzip and the
          # host responds with Content-Encoding: gzip, libcurl transparently decompresses at the
          # transfer layer, so res.body would be plain JSON written into a *.json.gz file, breaking
          # both Zlib::GzipReader and the SHA512 checksum (computed over the raw .gz).
          params.delete(:accept_encoding)
        end

        if ParsedCli.proxy_target_only
          params.delete(:proxy)
          params.delete(:proxyuserpwd)
        end

        params
      end

      # @return [ String ] The raw file URL associated with the given filename
      def remote_file_url(filename)
        host = enterprise_file?(filename) ? ENTERPRISE_HOST : DEFAULT_HOST

        "https://#{host}/#{filename}"
      end

      # @return [ String ] The checksum of the associated remote filename
      def remote_file_checksum(filename)
        url = "#{remote_file_url(filename)}.sha512"

        res = Typhoeus.get(url, request_params(filename))
        raise Error::Download, res if res.timed_out? || res.code != 200

        res.body.chomp
      end

      # @return [ String ]
      def local_file_path(filename)
        repo_directory.join(filename.to_s).to_s
      end

      def local_file_checksum(filename)
        Digest::SHA512.file(local_file_path(filename)).hexdigest
      end

      # @return [ String ]
      def backup_file_path(filename)
        repo_directory.join("#{filename}.back").to_s
      end

      def create_backup(filename)
        return unless File.exist?(local_file_path(filename))

        FileUtils.cp(local_file_path(filename), backup_file_path(filename))
      end

      def restore_backup(filename)
        return unless File.exist?(backup_file_path(filename))

        FileUtils.cp(backup_file_path(filename), local_file_path(filename))
      end

      def delete_backup(filename)
        FileUtils.rm(backup_file_path(filename))
      end

      # @return [ Array(String, String) ] The checksum of the downloaded file and the
      #                                    Cloudflare Ray ID of the response (or nil)
      def download(filename)
        file_path = local_file_path(filename)
        file_url  = remote_file_url(filename)

        res = Typhoeus.get(file_url, request_params(filename))
        raise Error::Download, res if res.timed_out? || res.code != 200

        File.binwrite(file_path, res.body)

        [local_file_checksum(filename), res.headers && res.headers['CF-Ray']]
      end

      # @return [ Array<String> ] The filenames updated
      def update
        updated = []

        files.each do |filename|
          db_checksum = remote_file_checksum(filename)

          # Checking if the file needs to be updated
          next if File.exist?(local_file_path(filename)) && db_checksum == local_file_checksum(filename)

          create_backup(filename)
          dl_checksum, cf_ray = download(filename)

          raise Error::ChecksumsMismatch.new(filename, cf_ray: cf_ray) unless dl_checksum == db_checksum

          updated << filename
        rescue StandardError => e
          restore_backup(filename)
          raise e
        ensure
          delete_backup(filename) if File.exist?(backup_file_path(filename))
        end

        File.write(last_update_file, Time.now)

        updated
      end
    end
  end
  # simplecov:enable
end
