require 'set'

module Sprockets
  # Functional utilities for dealing with Processor functions.
  #
  # A Processor is a general function that my modify or transform an asset as
  # part of the pipeline. CoffeeScript to JavaScript conversion, Minification
  # or Concatenation are all implemented as seperate Processor steps.
  #
  # Processors maybe any object that responds to call. So procs or a class that
  # defines a self.call method.
  #
  # For ergonomics, processors may return a number of shorthand values.
  # Unfortunately, this means that processors can not compose via ordinary
  # function composition. The composition helpers here can help.
  module ProcessorUtils
    extend self

    # Public: Compose processors in right to left order.
    #
    # processors - Array of processors callables
    #
    # Returns a composed Proc.
    def compose_processors(*processors)
      context = self

      if processors.length == 1
        obj = method(:call_processor).to_proc.curry[processors.first]
      else
        obj = method(:call_processors).to_proc.curry[processors]
      end

      metaclass = (class << obj; self; end)
      metaclass.send(:define_method, :cache_key) do
        context.processors_cache_keys(processors)
      end

      obj
    end

    # Public: Invoke list of processors in right to left order.
    #
    # The right to left order processing mirrors standard function composition.
    # Think about:
    #
    #   bundle.call(uglify.call(coffee.call(input)))
    #
    # processors - Array of processor callables
    # input - Hash of input data to pass to each processor
    #
    # Returns a Hash with :data and other processor metadata key/values.
    def call_processors(processors, input)
      data = input[:data] || ""
      metadata = (input[:metadata] || {}).dup

      processors.reverse_each do |processor|
        result = call_processor(processor, input.merge(data: data, metadata: metadata))
        data = result.delete(:data)
        metadata.merge!(result)
      end

      metadata.merge(data: data)
    end

    # Public: Invoke processor.
    #
    # processor - Processor callables
    # input - Hash of input data to pass to processor
    #
    # Returns a Hash with :data and other processor metadata key/values.
    def call_processor(processor, input)
      metadata = (input[:metadata] || {}).dup
      metadata[:data] = input[:data]

      case result = processor.call({data: "", metadata: {}}.merge(input))
      when NilClass
        metadata
      when Hash
        metadata.merge(result)
      when String
        metadata.merge(data: result)
      else
        raise TypeError, "invalid processor return type: #{result.class}"
      end
    end

    # Internal: Get processor defined cached key.
    #
    # processor - Processor function
    #
    # Returns JSON serializable key or nil.
    def processor_cache_key(processor)
      processor.cache_key if processor.respond_to?(:cache_key)
    end

    # Internal: Get combined cache keys for set of processors.
    #
    # processors - Array of processor functions
    #
    # Returns Array of JSON serializable keys.
    def processors_cache_keys(processors)
      processors.map { |processor| processor_cache_key(processor) }
    end

    # Internal: Set of all "simple" value types allowed to be returned in
    # processor metadata.
    VALID_METADATA_VALUE_TYPES = Set.new([
      String,
      Symbol,
      Fixnum,
      Bignum,
      TrueClass,
      FalseClass,
      NilClass
    ]).freeze

    # Internal: Set of all nested compound metadata types that can nest values.
    VALID_METADATA_COMPOUND_TYPES = Set.new([
      Array,
      Hash,
      Set
    ]).freeze

    # Internal: Hash of all "simple" value types allowed to be returned in
    # processor metadata.
    VALID_METADATA_VALUE_TYPES_HASH = VALID_METADATA_VALUE_TYPES.each_with_object({}) do |type, hash|
      hash[type] = true
    end.freeze

    # Internal: Hash of all nested compound metadata types that can nest values.
    VALID_METADATA_COMPOUND_TYPES_HASH = VALID_METADATA_COMPOUND_TYPES.each_with_object({}) do |type, hash|
      hash[type] = true
    end.freeze

    # Internal: Set of all allowed metadata types.
    VALID_METADATA_TYPES = (VALID_METADATA_VALUE_TYPES + VALID_METADATA_COMPOUND_TYPES).freeze

    # Internal: Validate returned result of calling a processor pipeline and
    # raise a friendly user error message.
    #
    # result - Metadata Hash returned from call_processors
    #
    # Returns result or raises a TypeError.
    def validate_processor_result!(result)
      if !result.instance_of?(Hash)
        raise TypeError, "processor metadata result was expected to be a Hash, but was #{result.class}"
      end

      if !result[:data].instance_of?(String)
        raise TypeError, "processor :data was expected to be a String, but as #{result[:data].class}"
      end

      result.each do |key, value|
        if !key.instance_of?(Symbol)
          raise TypeError, "processor metadata[#{key.inspect}] expected to be a Symbol"
        end

        if !valid_processor_metadata_value?(value)
          raise TypeError, "processor metadata[:#{key}] returned a complex type: #{value.inspect}\n" +
            "Only #{VALID_METADATA_TYPES.to_a.join(", ")} maybe used."
        end
      end

      result
    end

    # Internal: Validate object is in validate metadata whitelist.
    #
    # value - Any Object
    #
    # Returns true if class is in whitelist otherwise false.
    def valid_processor_metadata_value?(value)
      if VALID_METADATA_VALUE_TYPES_HASH[value.class]
        true
      elsif VALID_METADATA_COMPOUND_TYPES_HASH[value.class]
        value.all? { |v| valid_processor_metadata_value?(v) }
      else
        false
      end
    end
  end
end
