require 'date'
require 'cgi'

module Agents
  class JavaScriptAgent < Agent
    include FormConfigurable

    can_dry_run!

    default_schedule "never"

    description <<-MD
      The JavaScript Agent allows you to write code in JavaScript that can create and receive events.  If other Agents aren't meeting your needs, try this one!

      You can put code in the `code` option, or put your code in a Credential and reference it from `code` with `credential:<name>` (recommended).

      You can implement `Agent.check` and `Agent.receive` as you see fit.  The following methods will be available on Agent in the JavaScript environment:

      * `this.createEvent(payload)`
      * `this.incomingEvents()` (the returned event objects will each have a `payload` property)
      * `this.memory()`
      * `this.memory(key)`
      * `this.memory(keyToSet, valueToSet)`
      * `this.setMemory(object)` (replaces the Agent's memory with the provided object)
      * `this.deleteKey(key)` (deletes a key from memory and returns the value)
      * `this.credential(name)`
      * `this.credential(name, valueToSet)`
      * `this.options()`
      * `this.options(key)`
      * `this.log(message)`
      * `this.error(message)`
      * `this.escapeHtml(htmlToEscape)`
      * `this.unescapeHtml(htmlToUnescape)`
    MD

    form_configurable :language, type: :array, values: %w[JavaScript CoffeeScript]
    form_configurable :code, type: :text, ace: true
    form_configurable :expected_receive_period_in_days
    form_configurable :expected_update_period_in_days

    def validate_options
      cred_name = credential_referenced_by_code
      if cred_name
        errors.add(:base, "The credential '#{cred_name}' referenced by code cannot be found") unless credential(cred_name).present?
      else
        errors.add(:base, "The 'code' option is required") unless options['code'].present?
      end

      if interpolated['language'].present? && !interpolated['language'].downcase.in?(%w[javascript coffeescript])
        errors.add(:base, "The 'language' must be JavaScript or CoffeeScript")
      end
    end

    def working?
      return false if recent_error_logs?

      if interpolated['expected_update_period_in_days'].present?
        return false unless event_created_within?(interpolated['expected_update_period_in_days'])
      end

      if interpolated['expected_receive_period_in_days'].present?
        return false unless last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago
      end

      true
    end

    def check
      log_errors do
        execute_js("check")
      end
    end

    def receive(incoming_events)
      log_errors do
        execute_js("receive", incoming_events)
      end
    end

    def default_options
      js_code = <<-JS
        Agent.check = function() {
          if (this.options('make_event')) {
            this.createEvent({ 'message': 'I made an event!' });
            var callCount = this.memory('callCount') || 0;
            this.memory('callCount', callCount + 1);
          }
        };

        Agent.receive = function() {
          var events = this.incomingEvents();
          for(var i = 0; i < events.length; i++) {
            this.createEvent({ 'message': 'I got an event!', 'event_was': events[i].payload });
          }
        }
      JS

      {
        'code' => Utils.unindent(js_code),
        'language' => 'JavaScript',
        'expected_receive_period_in_days' => '2',
        'expected_update_period_in_days' => '2'
      }
    end

    private

    def execute_js(js_function, incoming_events = [])
      js_function = js_function == "check" ? "check" : "receive"
      context = V8::Context.new
      context.eval(setup_javascript)

      context["doCreateEvent"] = lambda { |a, y| create_event(payload: clean_nans(JSON.parse(y))).payload.to_json }
      context["getIncomingEvents"] = lambda { |a| incoming_events.to_json }
      context["getOptions"] = lambda { |a, x| interpolated.to_json }
      context["doLog"] = lambda { |a, x| log x }
      context["doError"] = lambda { |a, x| error x }
      context["getMemory"] = lambda { |a| memory.to_json }
      context["setMemoryKey"] = lambda do |a, x, y|
        memory[x] = clean_nans(y)
      end
      context["setMemory"] = lambda do |a, x|
        memory.replace(clean_nans(x))
      end
      context["deleteKey"] = lambda { |a, x| memory.delete(x).to_json }
      context["escapeHtml"] = lambda { |a, x| CGI.escapeHTML(x) }
      context["unescapeHtml"] = lambda { |a, x| CGI.unescapeHTML(x) }
      context['getCredential'] = lambda { |a, k| credential(k); }
      context['setCredential'] = lambda { |a, k, v| set_credential(k, v) }

      if (options['language'] || '').downcase == 'coffeescript'
        context.eval(CoffeeScript.compile code)
      else
        context.eval(code)
      end
      context.eval("Agent.#{js_function}();")
    end

    def code
      cred = credential_referenced_by_code
      if cred
        credential(cred) || 'Agent.check = function() { this.error("Unable to find credential"); };'
      else
        interpolated['code']
      end
    end

    def credential_referenced_by_code
      (interpolated['code'] || '').strip =~ /\Acredential:(.*)\Z/ && $1
    end

    def set_credential(name, value)
      c = user.user_credentials.find_or_initialize_by(credential_name: name)
      c.credential_value = value
      c.save!
    end

    def setup_javascript
      <<-JS
        function Agent() {};

        Agent.createEvent = function(opts) {
          return JSON.parse(doCreateEvent(JSON.stringify(opts)));
        }

        Agent.incomingEvents = function() {
          return JSON.parse(getIncomingEvents());
        }

        Agent.memory = function(key, value) {
          if (typeof(key) !== "undefined" && typeof(value) !== "undefined") {
            setMemoryKey(key, value);
          } else if (typeof(key) !== "undefined") {
            return JSON.parse(getMemory())[key];
          } else {
            return JSON.parse(getMemory());
          }
        }

        Agent.setMemory = function(obj) {
          setMemory(obj);
        }

        Agent.credential = function(name, value) {
          if (typeof(value) !== "undefined") {
            setCredential(name, value);
          } else {
            return getCredential(name);
          }
        }

        Agent.options = function(key) {
          if (typeof(key) !== "undefined") {
            return JSON.parse(getOptions())[key];
          } else {
            return JSON.parse(getOptions());
          }
        }

        Agent.log = function(message) {
          doLog(message);
        }

        Agent.error = function(message) {
          doError(message);
        }

        Agent.deleteKey = function(key) {
          return JSON.parse(deleteKey(key));
        }

        Agent.escapeHtml = function(html) {
          return escapeHtml(html);
        }

        Agent.unescapeHtml = function(html) {
          return unescapeHtml(html);
        }

        Agent.check = function(){};
        Agent.receive = function(){};
      JS
    end

    def log_errors
      begin
        yield
      rescue V8::Error => e
        error "JavaScript error: #{e.message}"
      end
    end

    def clean_nans(input)
      if input.is_a?(V8::Array)
        input.map {|v| clean_nans(v) }
      elsif input.is_a?(V8::Object)
        input.inject({}) { |m, (k, v)| m[k] = clean_nans(v); m }
      elsif input.is_a?(Float) && input.nan?
        'NaN'
      else
        input
      end
    end
  end
end
