# Copyright (c) 2012 National ICT Australia Limited (NICTA).
# This software may be used and distributed solely under the terms of the MIT license (License).
# You should find a copy of the License in LICENSE.TXT or at http://opensource.org/licenses/MIT.
# By downloading or using this software you accept the terms and the liability disclaimer in the License.

require 'active_support'
require 'active_support/deprecation'
require 'active_support/core_ext'
require 'eventmachine'
require 'uri'
require 'open-uri'
require 'tempfile'

module OmfEc
  # DSL methods to be used for OEDL scripts
  module DSL

    # Define OEDL-specific exceptions. These are the Exceptions that might be
    # raised when the OMF EC is processing an OEDL experiment scripts
    #
    # The base exception is OEDLException
    class OEDLException < StandardError; end

    class OEDLArgumentException < OEDLException
      attr_reader :cmd, :arg
      def initialize(cmd, arg, msg = nil)
        @cmd = cmd
        @arg = arg
        msg ||= "Illegal value for argument '#{arg}' in command '#{cmd}'"
        super(msg)
      end
    end

    class OEDLCommandException < OEDLException
      attr_reader :cmd
      def initialize(cmd, msg = nil)
        @cmd = cmd
        msg ||= "Illegal command '#{cmd}' unsupported by OEDL"
        super(msg)
      end
    end

    class OEDLUnknownProperty < OEDLException
      attr_reader :cmd
      def initialize(name, msg = nil)
        @name = name
        msg ||= "Unknown property '#{name}', not previously defined in your OEDL experiment"
        super(msg)
      end
    end

    # Use EM timer to execute after certain time
    #
    # @example do something after 2 seconds
    #
    #   after 2.seconds { 'do something' }
    def after(time, &block)
      OmfCommon.eventloop.after(time, &block)
    end

    # Use EM periodic timer to execute after certain time
    #
    # @example do something every 2 seconds
    #
    #   every 2.seconds { 'do something' }
    def every(time, &block)
      OmfCommon.eventloop.every(time, &block)
    end

    def def_application(name, &block)
      app_def = OmfEc::AppDefinition.new(name)
      OmfEc.experiment.app_definitions[name] = app_def
      block.call(app_def) if block
    end

    # Define a group, create a pubsub topic for the group
    #
    # @param [String] name name of the group
    #
    # @example add resource 'a', 'b' to group 'My_Pinger'
    #
    #   defGroup('My_Pinger', 'a', 'b') do |g|
    #     g.addApplication("ping") do |app|
    #       app.setProperty('target', 'mytestbed.net')
    #       app.setProperty('count', 3)
    #     end
    #   end
    #
    #   # Or pass resources as an array
    #
    #   res_array = ['a', 'b']
    #
    #   defGroup('My_Pinger', res_array) do |g|
    #     g.addApplication("ping") do |app|
    #       app.setProperty('target', 'mytestbed.net')
    #       app.setProperty('count', 3)
    #     end
    #   end
    #
    def def_group(name, *members, &block)
      group = OmfEc::Group.new(name)
      OmfEc.experiment.add_group(group)
      group.add_resource(*members)

      block.call(group) if block
    end

    # Get a group instance
    #
    # @param [String] name name of the group
    def group(name, &block)
      group = OmfEc.experiment.group(name)
      raise RuntimeError, "Group #{name} not found" if group.nil?

      block.call(group) if block
      group
    end

    # Iterator for all defined groups
    def all_groups(&block)
      OmfEc.experiment.each_group(&block)
    end

    def all_groups?(&block)
      OmfEc.experiment.all_groups?(&block)
    end

    alias_method :all_nodes!, :all_groups

    # Exit the experiment
    #
    # @see OmfEc::Experiment.done
    def done!
      OmfEc::Experiment.done
    end

    alias_method :done, :done!

    # Define an experiment property which can be used to bind
    # to application and other properties. Changing an experiment
    # property should also change the bound properties, or trigger
    # commands to change them.
    #
    # @param name of property
    # @param default_value for this property
    # @param description short text description of this property
    # @param type of property
    #
    def def_property(name, default_value, description = nil, type = nil)
      OmfEc.experiment.add_property(name, default_value, description)
    end

    # Return the context for setting experiment wide properties
    def property
      return OmfEc.experiment.property
    end

    # Check if a property exist, if not then define it
    # Take the same parameter as def_property
    #
    def ensure_property(name, default_value, description = nil, type = nil)
      begin
        property[name]
      rescue
        def_property(name, default_value, description, type)
      end
    end

    alias_method :prop, :property

    # Check if all elements in array equal the value provided
    #
    def all_equal(array, value = nil, &block)
      if array.empty?
        false
      else
        if value
          array.all? { |v| v.to_s == value.to_s }
        else
          array.all?(&block)
        end
      end
    end

    # Check if any elements in array equals the value provided
    #
    def one_equal(array, value)
      !array.any? ? false : array.any? { |v| v.to_s == value.to_s }
    end

    # Define an event
    #
    # @param [Symbol] name of the event
    #
    # @param [Hash] opts additional options
    # @option opts [Fixnum] :every indicates non-reactive style event checking, i.e. trigger will be evaluated periodically with :every as interval
    def def_event(name, opts = {}, &trigger)
      raise ArgumentError, 'Need a trigger callback' if trigger.nil?
      OmfEc.experiment.add_event(name, opts, trigger)
    end

    # Create an alias name of an event
    def alias_event(new_name, name)
      unless (event = OmfEc.experiment.event(name))
        raise RuntimeError, "Can not create alias for Event '#{name}' which is not defined"
      else
        event[:aliases] << new_name
      end
    end

    # Define an event callback
    def on_event(name, consume_event = true, &callback)
      unless (event = OmfEc.experiment.event(name))
        raise RuntimeError, "Event '#{name}' not defined"
      else
        event[:callbacks] ||= []
        event[:callbacks] << callback
        event[:consume_event] = consume_event
      end
    end

    # Define a new graph widget showing experiment related measurements to be
    # be used in a LabWiki column.
    #
    # The block is called with an instance of the 'LabWiki::OMFBridge::GraphDescription'
    # class. See that classes' documentation on the methods supported.
    #
    # @param name short/easy to remember name for this graph
    def def_graph(name = nil, &block)
      if OmfEc.experiment.show_graph
        gd = OmfEc::Graph::GraphDescription.create(name)
        block.call(gd)
        gd._report
      end
    end

    # Load an additional OEDL script referenced by a URI
    #
    # The supported URI schemes are:
    # - file:///foo/bar.rb , which loads the file located at '/foo/bar.rb' on the local filesystem
    # - system:///foo/bar.rb , which loads the file located at 'foo/bar.rb' in the default Ruby path of this EC
    # - http://foo.com/bar.rb , which loads the file located at the URL 'http://foo.com/bar.rb'
    #
    # If an optional has of key/value is provided, then define an OMF
    # Experiment Property for each keys and assigne them the values.
    #
    # @param uri URI for the OEDL script to load
    # @param opts optional hash of key/values for extra Experiment Property to define
    #
    def load_oedl(location, opts = {})
      begin
        u = URI(location.downcase)
      rescue Exception => e
        warn "Unsupported OEDL library location '#{location}'"
        return
      end

      # Define the additional properties from opts
      opts.each { |k,v| def_property(k, v,) }

      # Keep the old syntax around for a while, warn users to use the new URI syntax
      # TODO: remove this in a couple of EC versions
      if u.scheme.nil? || u.scheme.empty?
        deprecated_load_oedl(location)
        return
      end

      # Find out which type of location this is and deal with it accordingly
      case u.scheme.downcase.to_sym
      when :system
        begin
          u.path[0]='' # get rid of first '/'
          require u.path
          info "Loaded built-in OEDL library '#{location}'"
        rescue Exception => e
          error "Fail loading built-in OEDL library '#{location}': #{e}"
        end
      when :file, :http, :https
        begin
          file = Tempfile.new("oedl-#{Time.now.to_i}")
          # see: http://stackoverflow.com/questions/7578898
          open(u.to_s.sub(%r{^file:}, '')) { |io| file.write(io.read) }
          file.close
          OmfEc.experiment.archive_oedl(file.path)
          load(file.path)
          file.unlink
          info "Loaded external OEDL library '#{location}'"
        rescue Exception => e
          error "Fail loading external OEDL library '#{location}': #{e}"
        end
      else
        warn "Unsupported scheme for OEDL library location '#{location}'"
        return
      end
    end

    def deprecated_load_oedl(location)
      warn "Loading OEDL Library using DEPRECATED syntax. Please use proper URI syntax"
      begin
        require location
        info "Loaded built-in OEDL library '#{location}'"
      rescue LoadError
        begin
          file = Tempfile.new("oedl-#{Time.now.to_i}")
          open(location) { |io| file.write(io.read) }
          file.close
          OmfEc.experiment.archive_oedl(file.path)
          load(file.path)
          file.unlink
          info "Loaded external OEDL library '#{location}'"
        rescue Exception => e
          error "Fail loading external OEDL library '#{location}': #{e}"
        end
      rescue Exception => e
        error "Fail loading built-in OEDL library '#{location}': #{e}"
      end
    end

    # Define a new prototype. The supplied block is executed with the new Prototype instance as a single argument.
    #
    # @param refName reference name for this property
    # @param name optional, short/easy to remember name for this property
    def defPrototype(refName, name = nil, &block)
      p = Prototype.create(refName)
      p.name = name
      block.call(p)
    end

    # Define a query for measurements
    # This requires that the EC was started with its JobService related
    # parameters set (e.g. js_url or job_url)
    # The EC contacts the JobService and:
    # 1 - request the creation of a Measurement Point corresponding the query 
    #    parameter of this function.
    # 2 - read the data generated by that query, and return it. 
    #
    # @param query a SQL query 
    #
    def def_query(query)
      raise "No valid URL to connect to the Job Service!" if OmfEc.experiment.job_url.nil?
      require 'json'
      require 'net/http'
      begin
        query = query.sql if query.kind_of? OmfEc::Graph::MSBuilder
        # Create a Measurement Point for that Job item
        unless OmfEc.experiment.job_mps.include?(query)
          mp = { name: "#{Time.now.to_i}", sql: query }
          u = URI.parse(OmfEc.experiment.job_url+'/measurement_points')
          req = Net::HTTP::Post.new(u.path, {'Content-Type' =>'application/json'})
          req.body = JSON.pretty_generate(mp)
          res = Net::HTTP.new(u.host, u.port).start {|http| http.request(req) }
          raise "Could not connect to the service providing measurements\n"+
                "Response #{res.code} #{res.message}:\n#{res.body}" unless res.kind_of? Net::HTTPSuccess
          mp = JSON.parse(res.body)
          raise "No valid URL to connect to the measurement point" if mp['href'].nil?
          OmfEc.experiment.job_mps[query] = mp['href']
        end
        # Read and format data from that Measurement Point
        u = URI.parse(OmfEc.experiment.job_mps[query]+'/data')
        res = Net::HTTP.get(u)
        raise "No valid data from the service providing measurements" if res.nil? || res.empty? || !(res.kind_of? String)
        resjon = JSON.parse(res)
        metrics = resjon['schema'].map { |e| e[0] }
        data = []
        resjon['data'].each do |a|
          row = {}
          a.each_index { |i| row[metrics[i].downcase.to_sym] = a[i] }
          data << row
        end
        return data
      rescue Exception => ex
        return nil if ex.kind_of? EOFError
        error "def_query - #{ex} (#{ex.class})"
        error "def_query - #{ex.backtrace.join("\n\t")}"
        return nil
      end
    end

    # Define a query for measurements, using the Sequel Syntax
    # Refer to the def_query method above.
    # In this variant, the query is defined using the Sequel Syntax against a
    # Measurement Stream which must have been previously defined in the OEDl
    # experiment (e.g. app.measure('foo') in a addApplication block)
    #
    # @param ms_name the name of the existing measurement stream on which to run
    # this query 
    #
    def ms(ms_name)
      db = Sequel.postgres
      db.instance_variable_set('@server_version', 90105)
      if (table_name = OmfEc.experiment.mp_table_names[ms_name])
        msb = OmfEc::Graph::MSBuilder.new(db[table_name.to_sym])
      else
        warn "Measurement point '#{ms_name}' NOT defined"
      end
      msb
    end

  end
end