require 'open-uri'
require 'json/ld/expand'
require 'json/ld/compact'
require 'json/ld/frame'
require 'json/ld/to_rdf'
require 'json/ld/from_rdf'

module JSON::LD
  ##
  # A JSON-LD processor implementing the JsonLdProcessor interface.
  #
  # This API provides a clean mechanism that enables developers to convert JSON-LD data into a a variety of output formats that
  # are easier to work with in various programming languages. If a JSON-LD API is provided in a programming environment, the
  # entirety of the following API must be implemented.
  #
  # @see http://json-ld.org/spec/latest/json-ld-api/#the-application-programming-interface
  # @author [Gregg Kellogg](http://greggkellogg.net/)
  class API
    include Expand
    include Compact
    include Triples
    include FromTriples
    include Frame

    attr_accessor :value
    attr_accessor :context

    ##
    # Initialize the API, reading in any document and setting global options
    #
    # @param [String, #read, Hash, Array] input
    # @param [String, #read,, Hash, Array] context
    #   An external context to use additionally to the context embedded in input when expanding the input.
    # @param [Hash] options
    # @yield [api]
    # @yieldparam [API]
    def initialize(input, context, options = {}, &block)
      @options = options
      @value = case input
      when Array, Hash then input.dup
      when IO, StringIO then JSON.parse(input.read)
      when String
        content = nil
        RDF::Util::File.open_file(input) {|f| content = JSON.parse(f.read)}
        content
      end
      @context = EvaluationContext.new(options)
      @context = @context.parse(context) if context
      
      if block_given?
        case block.arity
          when 0, -1 then instance_eval(&block)
          else block.call(self)
        end
      end
    end
    
    ##
    # Expands the given input according to the steps in the Expansion Algorithm. The input must be copied, expanded and returned
    # if there are no errors. If the expansion fails, an appropriate exception must be thrown.
    #
    # The resulting `Array` is returned via the provided callback.
    #
    # Note that for Ruby, if the callback is not provided and a block is given, it will be yielded
    #
    # @param [String, #read, Hash, Array] input
    #   The JSON-LD object to copy and perform the expansion upon.
    # @param [String, #read, Hash, Array] context
    #   An external context to use additionally to the context embedded in input when expanding the input.
    # @param [Proc] callback (&block)
    #   Alternative to using block, with same parameters.
    # @param  [Hash{Symbol => Object}] options
    # @option options [Boolean] :base
    #   Base IRI to use when processing relative IRIs.
    # @raise [InvalidContext]
    # @yield jsonld
    # @yieldparam [Array<Hash>] jsonld
    #   The expanded JSON-LD document
    # @return [Array<Hash>]
    #   The expanded JSON-LD document
    # @see http://json-ld.org/spec/latest/json-ld-api/#expansion-algorithm
    def self.expand(input, context = nil, callback = nil, options = {})
      result = nil
      API.new(input, context, options) do |api|
        result = api.expand(api.value, nil, api.context)
      end

      # If, after the algorithm outlined above is run, the resulting element is an
      # JSON object with just a @graph property, element is set to the value of @graph's value.
      result = result['@graph'] if result.is_a?(Hash) && result.keys == %w(@graph)

      # Finally, if element is a JSON object, it is wrapped into an array.
      result = [result] unless result.is_a?(Array)
      callback.call(result) if callback
      yield result if block_given?
      result
    end

    ##
    # Compacts the given input according to the steps in the Compaction Algorithm. The input must be copied, compacted and
    # returned if there are no errors. If the compaction fails, an appropirate exception must be thrown.
    #
    # If no context is provided, the input document is compacted using the top-level context of the document
    #
    # The resulting `Hash` is returned via the provided callback.
    #
    # Note that for Ruby, if the callback is not provided and a block is given, it will be yielded
    #
    # @param [String, #read, Hash, Array] input
    #   The JSON-LD object to copy and perform the compaction upon.
    # @param [String, #read, Hash, Array] context
    #   The base context to use when compacting the input.
    # @param [Proc] callback (&block)
    #   Alternative to using block, with same parameters.
    # @param  [Hash{Symbol => Object}] options
    #   Other options passed to {#expand}
    # @option options [Boolean] :optimize (false)
    #   Perform further optimmization of the compacted output.
    #   (Presently, this is a noop).
    # @yield jsonld
    # @yieldparam [Hash] jsonld
    #   The compacted JSON-LD document
    # @return [Hash]
    #   The compacted JSON-LD document
    # @raise [InvalidContext, ProcessingError]
    # @see http://json-ld.org/spec/latest/json-ld-api/#compaction-algorithm
    def self.compact(input, context, callback = nil, options = {})
      expanded = result = nil

      # 1) Perform the Expansion Algorithm on the JSON-LD input.
      #    This removes any existing context to allow the given context to be cleanly applied.
      expanded = API.expand(input, nil, nil, options)

      API.new(expanded, context, options) do
        debug(".compact") {"expanded input: #{expanded.to_json(JSON_STATE)}"}
        result = compact(value, nil)

        # xxx) Add the given context to the output
        result = case result
        when Hash then self.context.serialize.merge(result)
        when Array
          kwgraph = self.context.compact_iri('@graph', :quiet => true)
          self.context.serialize.merge(kwgraph => result)
        when String
          kwid = self.context.compact_iri('@id', :quiet => true)
          self.context.serialize.merge(kwid => result)
        end
      end
      callback.call(result) if callback
      yield result if block_given?
      result
    end

    ##
    # Frames the given input using the frame according to the steps in the Framing Algorithm. The input is used to build the
    # framed output and is returned if there are no errors. If there are no matches for the frame, null must be returned.
    # Exceptions must be thrown if there are errors.
    #
    # The resulting `Array` is returned via the provided callback.
    #
    # Note that for Ruby, if the callback is not provided and a block is given, it will be yielded
    #
    # @param [String, #read, Hash, Array] input
    #   The JSON-LD object to copy and perform the framing on.
    # @param [String, #read, Hash, Array] frame
    #   The frame to use when re-arranging the data.
    # @param [Proc] callback (&block)
    #   Alternative to using block, with same parameters.
    # @param  [Hash{Symbol => Object}] options
    #   Other options passed to {#expand}
    # @option options [Boolean] :embed (true)
    #   a flag specifying that objects should be directly embedded in the output,
    #   instead of being referred to by their IRI.
    # @option options [Boolean] :explicit (false)
    #   a flag specifying that for properties to be included in the output,
    #   they must be explicitly declared in the framing context.
    # @option options [Boolean] :omitDefault (false)
    #   a flag specifying that properties that are missing from the JSON-LD
    #   input should be omitted from the output.
    # @yield jsonld
    # @yieldparam [Hash] jsonld
    #   The framed JSON-LD document
    # @return [Array<Hash>]
    #   The framed JSON-LD document
    # @raise [InvalidFrame]
    # @see http://json-ld.org/spec/latest/json-ld-api/#framing-algorithm
    def self.frame(input, frame, callback = nil, options = {})
      result = nil
      match_limit = 0
      framing_state = {
        :embed       => true,
        :explicit    => false,
        :omitDefault => false,
        :embeds      => nil,
      }
      framing_state[:embed] = options[:embed] if options.has_key?(:embed)
      framing_state[:explicit] = options[:explicit] if options.has_key?(:explicit)
      framing_state[:omitDefault] = options[:omitDefault] if options.has_key?(:omitDefault)

      # de-reference frame to create the framing object
      frame = case frame
      when Hash then frame.dup
      when IO, StringIO then JSON.parse(frame.read)
      when String
        content = nil
        RDF::Util::File.open_file(frame) {|f| content = JSON.parse(f.read)}
        content
      end

      # Expand frame to simplify processing
      expanded_frame = API.expand(frame)
      
      # Expand input to simplify processing
      expanded_input = API.expand(input)

      # Initialize input using frame as context
      API.new(expanded_input, nil, options) do
        debug(".frame") {"context from frame: #{context.inspect}"}
        debug(".frame") {"expanded frame: #{expanded_frame.to_json(JSON_STATE)}"}
        debug(".frame") {"expanded input: #{value.to_json(JSON_STATE)}"}

        # Get framing subjects from expanded input, replacing Blank Node identifiers as necessary
        @subjects = Hash.ordered
        depth {get_framing_subjects(@subjects, value, BlankNodeNamer.new("t"))}
        debug(".frame") {"subjects: #{@subjects.to_json(JSON_STATE)}"}

        result = []
        frame(framing_state, @subjects, expanded_frame[0], result, nil)
        debug(".frame") {"after frame: #{result.to_json(JSON_STATE)}"}
        
        # Initalize context from frame
        @context = depth {@context.parse(frame['@context'])}
        # Compact result
        compacted = depth {compact(result, nil)}
        compacted = [compacted] unless compacted.is_a?(Array)

        # Add the given context to the output
        kwgraph = context.compact_iri('@graph', :quiet => true)
        result = context.serialize.merge({kwgraph => compacted})
        debug(".frame") {"after compact: #{result.to_json(JSON_STATE)}"}
        result = cleanup_preserve(result)
      end

      callback.call(result) if callback
      yield result if block_given?
      result
    end

    ##
    # Processes the input according to the RDF Conversion Algorithm, calling the provided callback for each triple generated.
    #
    # Note that for Ruby, if the callback is not provided and a block is given, it will be yielded
    #
    # @param [String, #read, Hash, Array] input
    #   The JSON-LD object to process when outputting statements.
    # @param [String, #read, Hash, Array] context
    #   An external context to use additionally to the context embedded in input when expanding the input.
    # @param [Proc] callback (&block)
    #   Alternative to using block, with same parameteres.
    # @param [{Symbol,String => Object}] options
    #   Options passed to {#expand}
    # @raise [InvalidContext]
    # @yield statement
    # @yieldparam [RDF::Statement] statement
    def self.toRDF(input, context = nil, callback = nil, options = {})
      # 1) Perform the Expansion Algorithm on the JSON-LD input.
      #    This removes any existing context to allow the given context to be cleanly applied.
      expanded = expand(input, context, nil, options)

      API.new(expanded, nil, options) do
        debug(".expand") {"expanded input: #{value.to_json(JSON_STATE)}"}
        # Start generating statements
        statements("", value, nil, nil, nil) do |statement|
          callback.call(statement) if callback
          yield statement if block_given?
        end
      end
    end
    
    ##
    # Take an ordered list of RDF::Statements and turn them into a JSON-LD document.
    #
    # The resulting `Array` is returned via the provided callback.
    #
    # Note that for Ruby, if the callback is not provided and a block is given, it will be yielded
    #
    # @param [Array<RDF::Statement>] input
    # @param [Proc] callback (&block)
    #   Alternative to using block, with same parameteres.
    # @param  [Hash{Symbol => Object}] options
    # @option options [Boolean] :notType don't use @type for rdf:type
    # @yield jsonld
    # @yieldparam [Hash] jsonld
    #   The JSON-LD document in expanded form
    # @return [Array<Hash>]
    #   The JSON-LD document in expanded form
    def self.fromRDF(input, callback = nil, options = {})
      result = nil

      API.new(nil, nil, options) do |api|
        result = api.from_statements(input)
      end

      callback.call(result) if callback
      yield result if block_given?
      result
    end
  end
end