require "fastimage"
require_dependency "image_sizer"

class UploadCreator

  TYPES_CONVERTED_TO_JPEG ||= %i{bmp png}

  TYPES_TO_CROP ||= %w{avatar card_background custom_emoji profile_background}.each(&:freeze)

  WHITELISTED_SVG_ELEMENTS ||= %w{
    circle clippath defs ellipse g line linearGradient path polygon polyline
    radialGradient rect stop svg text textpath tref tspan use
  }.each(&:freeze)

  # Available options
  #  - type (string)
  #  - content_type (string)
  #  - origin (string)
  #  - for_group_message (boolean)
  #  - for_theme (boolean)
  #  - for_private_message (boolean)
  #  - pasted (boolean)
  def initialize(file, filename, opts = {})
    @file = file
    @filename = filename || ''
    @upload = Upload.new(original_filename: filename, filesize: 0)
    @opts = opts
  end

  def create_for(user_id)
    if filesize <= 0
      @upload.errors.add(:base, I18n.t("upload.empty"))
      return @upload
    end

    DistributedMutex.synchronize("upload_#{user_id}_#{@filename}") do
      if FileHelper.is_image?(@filename)
        extract_image_info!
        return @upload if @upload.errors.present?

        if @filename[/\.svg$/i]
          whitelist_svg!
        elsif !Rails.env.test?
          convert_to_jpeg! if should_convert_to_jpeg?
          downsize!        if should_downsize?

          return @upload   if is_still_too_big?

          fix_orientation! if should_fix_orientation?
          crop!            if should_crop?
          optimize!        if should_optimize?
        end
      end

      # compute the sha of the file
      sha1 = Upload.generate_digest(@file)

      # do we already have that upload?
      @upload = Upload.find_by(sha1: sha1)

      # make sure the previous upload has not failed
      if @upload && @upload.url.blank?
        @upload.destroy
        @upload = nil
      end

      # return the previous upload if any
      return @upload unless @upload.nil?

      # create the upload otherwise
      @upload = Upload.new
      @upload.user_id           = user_id
      @upload.original_filename = @filename
      @upload.filesize          = filesize
      @upload.sha1              = sha1
      @upload.url               = ""
      @upload.origin            = @opts[:origin][0...1000] if @opts[:origin]
      @upload.extension         = File.extname(@filename)[1..10]

      if FileHelper.is_image?(@filename)
        @upload.width, @upload.height = ImageSizer.resize(*@image_info.size)
      end

      @upload.for_private_message = true if @opts[:for_private_message]
      @upload.for_group_message   = true if @opts[:for_group_message]
      @upload.for_theme           = true if @opts[:for_theme]

      return @upload unless @upload.save

      # store the file and update its url
      File.open(@file.path) do |f|
        url = Discourse.store.store_upload(f, @upload, @opts[:content_type])
        if url.present?
          @upload.url = url
          @upload.save
        else
          @upload.errors.add(:url, I18n.t("upload.store_failure", upload_id: @upload.id, user_id: user_id))
        end
      end

      if @upload.errors.empty? && FileHelper.is_image?(@filename) && @opts[:type] == "avatar"
        Jobs.enqueue(:create_avatar_thumbnails, upload_id: @upload.id, user_id: user_id)
      end

      @upload
    end
  ensure
    @file.close! rescue nil
  end

  def extract_image_info!
    @image_info = FastImage.new(@file) rescue nil
    @file.rewind

    if @image_info.nil?
      @upload.errors.add(:base, I18n.t("upload.images.not_supported_or_corrupted"))
    elsif filesize <= 0
      @upload.errors.add(:base, I18n.t("upload.empty"))
    elsif pixels == 0
      @upload.errors.add(:base, I18n.t("upload.images.size_not_found"))
    end
  end

  MIN_PIXELS_TO_CONVERT_TO_JPEG ||= 1280 * 720

  def should_convert_to_jpeg?
    return false if !TYPES_CONVERTED_TO_JPEG.include?(@image_info.type)
    return true  if @opts[:pasted]
    return false if SiteSetting.png_to_jpg_quality == 100
    pixels > MIN_PIXELS_TO_CONVERT_TO_JPEG
  end

  def convert_to_jpeg!
    jpeg_tempfile = Tempfile.new(["image", ".jpg"])

    OptimizedImage.ensure_safe_paths!(@file.path, jpeg_tempfile.path)

    begin
      execute_convert(@file, jpeg_tempfile)
    rescue
      # retry with debugging enabled
      execute_convert(@file, jpeg_tempfile, true)
    end

    # keep the JPEG if it's at least 15% smaller
    if File.size(jpeg_tempfile.path) < filesize * 0.85
      @file = jpeg_tempfile
      @filename = (File.basename(@filename, ".*").presence || I18n.t("image").presence || "image") + ".jpg"
      @opts[:content_type] = "image/jpeg"
      extract_image_info!
    else
      jpeg_tempfile.close! rescue nil
    end
  end

  def execute_convert(input_file, output_file, debug = false)
    command = ['convert', input_file.path,
               '-auto-orient',
               '-background', 'white',
               '-interlace', 'none',
               '-flatten',
               '-quality', SiteSetting.png_to_jpg_quality.to_s]
    command << '-debug' << 'all' if debug
    command << output_file.path

    Discourse::Utils.execute_command(*command, failure_message: I18n.t("upload.png_to_jpg_conversion_failure_message"))
  end

  def should_downsize?
    max_image_size > 0 && filesize >= max_image_size
  end

  def downsize!
    3.times do
      original_size = filesize
      downsized_pixels = [pixels, max_image_pixels].min / 2
      OptimizedImage.downsize(@file.path, @file.path, "#{downsized_pixels}@", filename: @filename, allow_animation: allow_animation)
      extract_image_info!
      return if filesize >= original_size || pixels == 0 || !should_downsize?
    end
  end

  def is_still_too_big?
    if max_image_pixels > 0 && pixels >= max_image_pixels
      @upload.errors.add(:base, I18n.t("upload.images.larger_than_x_megapixels", max_image_megapixels: SiteSetting.max_image_megapixels))
      true
    elsif max_image_size > 0 && filesize >= max_image_size
      @upload.errors.add(:base, I18n.t("upload.images.too_large", max_size_kb: SiteSetting.max_image_size_kb))
      true
    else
      false
    end
  end

  def whitelist_svg!
    doc = Nokogiri::XML(@file)
    doc.xpath(svg_whitelist_xpath).remove
    File.write(@file.path, doc.to_s)
    @file.rewind
  end

  def should_fix_orientation?
    # orientation is between 1 and 8, 1 being the default
    # cf. http://www.daveperrett.com/articles/2012/07/28/exif-orientation-handling-is-a-ghetto/
    @image_info.orientation.to_i > 1
  end

  def fix_orientation!
    OptimizedImage.ensure_safe_paths!(@file.path)
    Discourse::Utils.execute_command('convert', @file.path, '-auto-orient', @file.path)
    extract_image_info!
  end

  def should_crop?
    TYPES_TO_CROP.include?(@opts[:type])
  end

  def crop!
    max_pixel_ratio = Discourse::PIXEL_RATIOS.max

    case @opts[:type]
    when "avatar"
      width = height = Discourse.avatar_sizes.max
      OptimizedImage.resize(@file.path, @file.path, width, height, filename: @filename, allow_animation: allow_animation)
    when "profile_background"
      max_width = 850 * max_pixel_ratio
      width, height = ImageSizer.resize(@image_info.size[0], @image_info.size[1], max_width: max_width, max_height: max_width)
      OptimizedImage.downsize(@file.path, @file.path, "#{width}x#{height}\\>", filename: @filename, allow_animation: allow_animation)
    when "card_background"
      max_width = 590 * max_pixel_ratio
      width, height = ImageSizer.resize(@image_info.size[0], @image_info.size[1], max_width: max_width, max_height: max_width)
      OptimizedImage.downsize(@file.path, @file.path, "#{width}x#{height}\\>", filename: @filename, allow_animation: allow_animation)
    when "custom_emoji"
      OptimizedImage.downsize(@file.path, @file.path, "100x100\\>", filename: @filename, allow_animation: allow_animation)
    end

    extract_image_info!
  end

  def should_optimize?
    # GIF is too slow (plus, we'll soon be converting them to MP4)
    # Optimizing SVG is useless
    return false if @file.path =~ /\.(gif|svg)$/i
    # Safeguard for large PNGs
    return pixels < 2_000_000 if @file.path =~ /\.png/i
    # Everything else is fine!
    true
  end

  def optimize!
    OptimizedImage.ensure_safe_paths!(@file.path)
    FileHelper.optimize_image!(@file.path)
    extract_image_info!
  rescue ImageOptim::Worker::TimeoutExceeded
    Rails.logger.warn("ImageOptim timed out while optimizing #{@filename}")
  end

  def filesize
    File.size?(@file.path).to_i
  end

  def max_image_size
    @max_image_size ||= SiteSetting.max_image_size_kb.kilobytes
  end

  def max_image_pixels
    @max_image_pixels ||= SiteSetting.max_image_megapixels * 1_000_000
  end

  def pixels
    @image_info.size&.reduce(:*).to_i
  end

  def allow_animation
    @allow_animation ||= @opts[:type] == "avatar" ? SiteSetting.allow_animated_avatars : SiteSetting.allow_animated_thumbnails
  end

  def svg_whitelist_xpath
    @@svg_whitelist_xpath ||= "//*[#{WHITELISTED_SVG_ELEMENTS.map { |e| "name()!='#{e}'" }.join(" and ") }]"
  end

end