# frozen_string_literal: true

require 'image_optim/benchmark_result'
require 'image_optim/bin_resolver'
require 'image_optim/cache'
require 'image_optim/config'
require 'image_optim/errors'
require 'image_optim/handler'
require 'image_optim/image_meta'
require 'image_optim/optimized_path'
require 'image_optim/path'
require 'image_optim/table'
require 'image_optim/timer'
require 'image_optim/worker'
require 'in_threads'
require 'shellwords'

%w[
  pngcrush pngout advpng optipng pngquant oxipng
  jhead jpegoptim jpegrecompress jpegtran
  gifsicle
  svgo
].each do |worker|
  require "image_optim/worker/#{worker}"
end

# Main interface
class ImageOptim
  # Nice level
  attr_reader :nice

  # Number of threads to run with
  attr_reader :threads

  # Verbose output?
  attr_reader :verbose

  # Use image_optim_pack
  attr_reader :pack

  # Skip workers with missing or problematic binaries
  attr_reader :skip_missing_workers

  # Allow lossy workers and optimizations
  attr_reader :allow_lossy

  # Cache directory
  attr_reader :cache_dir

  # Cache worker digests
  attr_reader :cache_worker_digests

  # Timeout in seconds for each image
  attr_reader :timeout

  # Initialize workers, specify options using worker underscored name:
  #
  # pass false to disable worker
  #
  #     ImageOptim.new(:pngcrush => false)
  #
  # or hash with options to worker
  #
  #     ImageOptim.new(:advpng => {:level => 3}, :optipng => {:level => 2})
  #
  # use :threads to set number of parallel optimizers to run (passing true or
  # nil determines number of processors, false disables parallel processing)
  #
  #     ImageOptim.new(:threads => 8)
  #
  # use :nice to specify optimizers nice level (true or nil makes it 10, false
  # makes it 0)
  #
  #     ImageOptim.new(:nice => 20)
  def initialize(options = {})
    config = Config.new(options)
    @verbose = config.verbose
    $stderr << "config:\n#{config.to_s.gsub(/^/, '  ')}" if verbose

    %w[
      nice
      threads
      pack
      skip_missing_workers
      allow_lossy
      cache_dir
      cache_worker_digests
      timeout
    ].each do |name|
      instance_variable_set(:"@#{name}", config.send(name))
      $stderr << "#{name}: #{send(name)}\n" if verbose
    end

    @bin_resolver = BinResolver.new(self)

    $stderr << "PATH: #{@bin_resolver.env_path}\n" if verbose

    @workers_by_format = Worker.create_all_by_format(self) do |klass|
      config.for_worker(klass)
    end

    @cache = Cache.new(self, @workers_by_format)

    log_workers_by_format if verbose

    config.assert_no_unused_options!
  end

  # Get workers for image
  def workers_for_image(path)
    @workers_by_format[Path.convert(path).image_format]
  end

  # Optimize one file, return new path as OptimizedPath or nil if
  # optimization failed
  def optimize_image(original)
    original = Path.convert(original)
    return unless (workers = workers_for_image(original))

    optimized = @cache.fetch(original) do
      timer = timeout && Timer.new(timeout)

      Handler.for(original) do |handler|
        begin
          workers.each do |worker|
            handler.process do |src, dst|
              worker.optimize(src, dst, timeout: timer)
            end
          end
        rescue Errors::TimeoutExceeded
          handler.result
        end
      end
    end

    return unless optimized

    OptimizedPath.new(optimized, original)
  end

  # Optimize one file in place, return original as OptimizedPath or nil if
  # optimization failed
  def optimize_image!(original)
    original = Path.convert(original)
    return unless (result = optimize_image(original))

    result.replace(original)
    OptimizedPath.new(original, result.original_size)
  end

  # Optimize image data, return new data or nil if optimization failed
  def optimize_image_data(original_data)
    format = ImageMeta.format_for_data(original_data)
    return unless format

    Path.temp_file %W[image_optim .#{format}] do |temp|
      temp.binmode
      temp.write(original_data)
      temp.close

      if (result = optimize_image(temp.path))
        result.binread
      end
    end
  end

  def benchmark_image(original)
    src = Path.convert(original)
    return unless (workers = workers_for_image(src))

    dst = src.temp_path
    begin
      workers.map do |worker|
        start = ElapsedTime.now
        worker.optimize(src, dst)
        BenchmarkResult.new(src, dst, ElapsedTime.now - start, worker)
      end
    ensure
      dst.unlink
    end
  end

  # Optimize multiple images
  # if block given yields path and result for each image and returns array of
  # yield results
  # else return array of path and result pairs
  def optimize_images(paths, &block)
    run_method_for(paths, :optimize_image, &block)
  end

  # Optimize multiple images in place
  # if block given yields path and result for each image and returns array of
  # yield results
  # else return array of path and result pairs
  def optimize_images!(paths, &block)
    run_method_for(paths, :optimize_image!, &block)
  end

  # Optimize multiple image datas
  # if block given yields original and result for each image data and returns
  # array of yield results
  # else return array of path and result pairs
  def optimize_images_data(datas, &block)
    run_method_for(datas, :optimize_image_data, &block)
  end

  def benchmark_images(paths, &block)
    run_method_for(paths, :benchmark_image, &block)
  end

  class << self
    # Optimization methods with default options
    def method_missing(method, *args, &block)
      if optimize_image_method?(method)
        new.send(method, *args, &block)
      else
        super
      end
    end

    def respond_to_missing?(method, include_private = false)
      optimize_image_method?(method) || super
    end

    # Version of image_optim gem spec loaded
    def version
      Gem.loaded_specs['image_optim'].version.to_s
    rescue
      'DEV'
    end

    # Full version of image_optim
    def full_version
      "image_optim v#{version}"
    end

  private

    def optimize_image_method?(method)
      method_defined?(method) && method.to_s =~ /^optimize_image/
    end
  end

  # Are there workers for file at path?
  def optimizable?(path)
    !!workers_for_image(path)
  end

  # Check existance of binary, create symlink if ENV contains path for key
  # XXX_BIN where XXX is upper case bin name
  def resolve_bin!(bin)
    @bin_resolver.resolve!(bin)
  end

  # Join resolve_dir, default path and vendor path for PATH environment variable
  def env_path
    @bin_resolver.env_path
  end

private

  def log_workers_by_format
    $stderr << "Workers by format:\n"
    @workers_by_format.each do |format, workers|
      $stderr << "#{format}:\n"
      workers.each do |worker|
        $stderr << "  #{worker.class.bin_sym}:\n"
        worker.options.each do |name, value|
          $stderr << "    #{name}: #{value.inspect}\n"
        end
      end
    end
  end

  # Run method for each item in list
  # if block given yields item and result for item and returns array of yield
  # results
  # else return array of item and result pairs
  def run_method_for(list, method_name, &block)
    apply_threading(list).map do |item|
      result = send(method_name, item)
      if block
        yield item, result
      else
        [item, result]
      end
    end
  end

  # Apply threading if threading is allowed
  def apply_threading(enum)
    if threads > 1
      enum.in_threads(threads)
    else
      enum
    end
  end
end
