// Adapted from rstudio/shiny repository
//
// Generic driver class for doing chunk-wise asynchronous processing of a
// FileList object. Subclass/clone it and override the `on*` functions to
// make it do something useful.
var SemanticFileProcessor = function(files) {
  this.files = files;
  this.fileIndex = -1;
  // Currently need to use small chunk size because R-Websockets can't
  // handle continuation frames
  this.aborted = false;
  this.completed = false;

  // TODO: Register error/abort callbacks

  this.$run();
};
(function() {
  // Begin callbacks. Subclassers/cloners may override any or all of these.
  this.onBegin = function(files, cont) {
    setTimeout(cont, 0);
  };
  this.onFile = function(file, cont) {
    setTimeout(cont, 0);
  };
  this.onComplete = function() {
  };
  this.onAbort = function() {
  };
  // End callbacks

  // Aborts processing, unless it's already completed
  this.abort = function() {
    if (this.completed || this.aborted)
      return;

    this.aborted = true;
    this.onAbort();
  };

  // Returns a bound function that will call this.$run one time.
  this.$getRun = function() {
    var self = this;
    var called = false;
    return function() {
      if (called)
        return;
      called = true;
      self.$run();
    };
  };

  // This function will be called multiple times to advance the process.
  // It relies on the state of the object's fields to know what to do next.
  this.$run = function() {

    if (this.aborted || this.completed)
      return;

    if (this.fileIndex < 0) {
      // Haven't started yet--begin
      this.fileIndex = 0;
      this.onBegin(this.files, this.$getRun());
      return;
    }

    if (this.fileIndex === this.files.length) {
      // Just ended
      this.completed = true;
      this.onComplete();
      return;
    }

    // If we got here, then we have a file to process, or we are
    // in the middle of processing a file, or have just finished
    // processing a file.

    var file = this.files[this.fileIndex++];
    this.onFile(file, this.$getRun());
  };
}).call(SemanticFileProcessor.prototype);

var SemanticFileUploader = function(shinyapp, id, files, el) {
  this.shinyapp = shinyapp;
  this.id = id;
  this.el = el;
  SemanticFileProcessor.call(this, files);
};
$.extend(SemanticFileUploader.prototype, SemanticFileProcessor.prototype);
(function() {
  this.makeRequest = function(method, args, onSuccess, onFailure, blobs) {
    this.shinyapp.makeRequest(method, args, onSuccess, onFailure, blobs);
  };
  this.onBegin = function(files, cont) {
    var self = this;

    // Reset progress bar
    this.$setError(null);
    this.$setActive(true);
    this.$setVisible(true);
    this.onProgress(null, 0);

    this.totalBytes = 0;
    this.progressBytes = 0;
    $.each(files, function(i, file) {
      self.totalBytes += file.size;
    });

    var fileInfo = $.map(files, function(file, i) {
      return {
        name: file.name,
        size: file.size,
        type: file.type
      };
    });

    this.makeRequest(
      'uploadInit', [fileInfo],
      function(response) {
        self.jobId = response.jobId;
        self.uploadUrl = response.uploadUrl;
        cont();
      },
      function(error) {
        self.onError(error);
      });
  };
  this.onFile = function(file, cont) {
    var self = this;
    this.onProgress(file, 0);

    $.ajax(this.uploadUrl, {
      type: 'POST',
      cache: false,
      xhr: function() {
        var xhrVal = $.ajaxSettings.xhr();
        if (xhrVal.upload) {
          xhrVal.upload.onprogress = function(e) {
            if (e.lengthComputable) {
              self.onProgress(
                file,
                (self.progressBytes + e.loaded) / self.totalBytes);
            }
          };
        }
        return xhrVal;
      },
      data: file,
      contentType: 'application/octet-stream',
      processData: false,
      success: function() {
        self.progressBytes += file.size;
        cont();
      },
      error: function(jqXHR, textStatus, errorThrown) {
        self.onError(jqXHR.responseText || textStatus);
      }
    });
  };
  this.onComplete = function() {
    var self = this;

    var fileInfo = $.map(this.files, function(file, i) {
      return {
        name: file.name,
        size: file.size,
        type: file.type
      };
    });

    // Trigger shiny:inputchanged. Unlike a normal shiny:inputchanged event,
    // it's not possible to modify the information before the values get
    // sent to the server.
    var evt = jQuery.Event("shiny:inputchanged");
    evt.name = this.id;
    evt.value = fileInfo;
    evt.binding = semanticFileInput;
    evt.el = this.el;
    evt.inputType = 'shiny.fileupload';
    $(document).trigger(evt);

    this.makeRequest(
      'uploadEnd', [this.jobId, this.id],
      function(response) {
        self.$setActive(false);
        self.onProgress(null, 1);
        self.$bar().progress('set label', 'Upload complete');
        // Reset the file input's value to "". This allows the same file to be
        // uploaded again. https://stackoverflow.com/a/22521275
        $(evt.el).val("");
      },
      function(error) {
        self.onError(error);
      });
    this.$bar().progress('set label', 'Finishing upload');
  };
  this.onError = function(message) {
    this.$setError(message || '');
    this.$setActive(false);
  };
  this.onAbort = function() {
    this.$setVisible(false);
  };
  this.onProgress = function(file, completed) {
    this.$bar().progress('set percent', Math.round(completed * 100));
    this.$bar().progress('set label', file ? file.name : '');
  };
  this.$container = function() {
    return $('#' + Shiny.$escape(this.id) + '_progress.ui-ss-progress-file');
  };
  this.$bar = function() {
    return $('#' + Shiny.$escape(this.id) + '_progress.ui-ss-progress-file');
  };
  this.$setVisible = function(visible) {
    this.$container().css('visibility', visible ? 'visible' : 'hidden');
  };
  this.$setError = function(error) {
    var errorState = (this.$bar().progress('is error') ) + ' error';
    this.$bar().toggleClass('error', (error !== null));
    if (error !== null) {
      this.onProgress(null, 1);
      this.$bar().progress('set error', error);
    }
  };
  this.$setActive = function(active) {
    this.$container().toggleClass('active', !!active);
  };
}).call(SemanticFileUploader.prototype);


// NOTE On Safari, at least version 10.1.2, *if the developer console is open*,
// setting the input's value will behave strangely because of a Safari bug. The
// uploaded file's name will appear over the placeholder value, instead of
// replacing it. The workaround is to restart Safari. When I (Alan Dipert) ran
// into this bug Winston Chang helped me diagnose the exact problem, and Winston
// then submitted a bug report to Apple.
function setFileText($el, files) {
  var $fileText = $el.closest('div.ui-ss-input').find('input[type=text]');
  if (files.length === 1) {
    $fileText.val(files[0].name);
  } else {
    $fileText.val(files.length + " files");
  }
}

// If previously selected files are uploading, abort that.
function abortCurrentUpload($el) {
  var uploader = $el.data('currentUploader');
  if (uploader) uploader.abort();
  // Clear data-restore attribute if present.
  $el.removeAttr('data-restore');
}

function uploadDroppedFilesIE10Plus(el, files) {
  var $el = $(el);
  abortCurrentUpload($el);

  // Set the label in the text box
  setFileText($el, files);

  // Start the new upload and put the uploader in 'currentUploader'.
  $el.data(
    'currentUploader',
    new SemanticFileUploader(Shiny.shinyapp, semanticFileInput.getId(el), files, el)
  );
}

function uploadFiles(evt) {
  var $el = $(evt.target);
  abortCurrentUpload($el);

  var files = evt.target.files;
  var id = semanticFileInput.getId(evt.target);

  if (files.length === 0)
    return;

  // Set the label in the text box
  setFileText($el, files);

  // Start the new upload and put the uploader in 'currentUploader'.
  $el.data('currentUploader',
    new SemanticFileUploader(Shiny.shinyapp, id, files, evt.target));
}

// Here we maintain a list of all the current file inputs. This is necessary
// because we need to trigger events on them in order to respond to file drag
// events. For example, they should all light up when a file is dragged on to
// the page.
var $fileInputs = $();

var semanticFileInput = new Shiny.InputBinding();
$.extend(semanticFileInput, {
  find: function(scope) {
    return $(scope).find('input[type="file"]');
  },
  getId: function(el) {
    return Shiny.InputBinding.prototype.getId.call(this, el) || el.name;
  },
  initialize: function(el) {
    // Automatically makes the progress visible, so need to hide it again
    var $progress = $(el).closest('div.field').find('.ui-ss-progress-file');
    $progress.css('visibility', 'hidden');
  },
  getValue: function(el) {
    // This returns a non-undefined value only when there's a 'data-restore'
    // attribute, which is set only when restoring Shiny state. If a file is
    // uploaded through the browser, 'data-restore' gets cleared.
    var data = $(el).attr('data-restore');
    if (data) {
      data = JSON.parse(data);

      // Set the label in the text box
      var $fileText = $(el).closest('div.ui-ss-input').find('input[type=text]');
      if (data.name.length === 1) {
        $fileText.val(data.name[0]);
      } else {
        $fileText.val(data.name.length + " files");
      }

      // Manually set up progress bar. A bit inelegant because it duplicates
      // code from SemanticFileUploader, but duplication is less bad than alternatives.
      var $progress = $(el).closest('div.field').find('.ui-ss-progress-file');
      $progress.removeClass('active');
      $progress.progress('set percent', 100);
      $progress.css('visibility', 'visible');

      return data;

    } else {
      return null;
    }
  },
  setValue: function(el, value) {
    // Not implemented
  },
  getType: function(el) {
    // This will be used only when restoring a file from a saved state.
    return 'shiny.file';
  },
  _zoneOf: function(el) {
    return $(el).closest("div.ui-ss-input");
  },
  // This function makes it possible to attach listeners to the dragenter,
  // dragleave, and drop events of a single element with children. It's not
  // intuitive to do directly because outer elements fire "dragleave" events
  // both when the drag leaves the element and when the drag enters a child. To
  // make it easier, we maintain a count of the elements being dragged across
  // and trigger 3 new types of event:
  //
  // 1. draghover:enter - When a drag enters el and any of its children.
  // 2. draghover:leave - When the drag leaves el and all of its children.
  // 3. draghover:drop - When an item is dropped on el or any of its children.
  _enableDraghover: function(el) {
    let $el = $(el),
        childCounter = 0;
    $el.on({
      "dragenter.draghover": e => {
        if (childCounter++ === 0) {
          $el.trigger("draghover:enter", e);
        }
      },
      "dragleave.draghover": e => {
        if (--childCounter === 0) {
          $el.trigger("draghover:leave", e);
        }
        if (childCounter < 0) {
          console.error("draghover childCounter is negative somehow");
        }
      },
      "dragover.draghover": e => {
        e.preventDefault();
      },
      "drop.draghover": e => {
        childCounter = 0;
        $el.trigger("draghover:drop", e);
        e.preventDefault();
      }
    });
    return $el;
  },
  _disableDraghover: function(el) {
    return $(el).off(".draghover");
  },
  _ZoneClass: {
    ACTIVE: "shiny-file-input-active",
    OVER: "shiny-file-input-over"
  },
  _enableDocumentEvents: function() {
    let $doc = $("html"),
        {ACTIVE, OVER} = this._ZoneClass;
    this._enableDraghover($doc)
      .on({
        "draghover:enter.draghover": e => {
          this._zoneOf($fileInputs).addClass(ACTIVE);
        },
        "draghover:leave.draghover": e => {
          this._zoneOf($fileInputs).removeClass(ACTIVE);
        },
        "draghover:drop.draghover": e => {
          this._zoneOf($fileInputs)
            .removeClass(OVER)
            .removeClass(ACTIVE);
        }
      });
  },
  _disableDocumentEvents: function() {
    let $doc = $("html");
    $doc.off(".draghover");
    this._disableDraghover($doc);
  },
  _canSetFiles: function(fileList) {
    var testEl = document.createElement("input");
    testEl.type = "file";
    try {
      testEl.files = fileList;
    } catch (e) {
      return false;
    }
    return true;
  },
  _handleDrop: function(e, el) {
    const files = e.originalEvent.dataTransfer.files,
          $el   = $(el);
    if (files === undefined || files === null) {
      // 1. The FileList object isn't supported by this browser, and
      // there's nothing else we can try. (< IE 10)
      console.log("Dropping files is not supported on this browser. (no FileList)");
    } else if (!this._canSetFiles(files)) {
      // 2. The browser doesn't support assigning a type=file input's .files
      // property, but we do have a FileList to work with. (IE10+/Edge)
      $el.val("");
      uploadDroppedFilesIE10Plus(el, files);
    } else {
      // 3. The browser supports FileList and input.files assignment.
      // (Chrome, Safari)
      $el.val("");
      el.files = e.originalEvent.dataTransfer.files;
      // Recent versions of Firefox (57+, or "Quantum" and beyond) don't seem to
      // automatically trigger a change event, so we trigger one manually here.
      // On browsers that do trigger change, this operation appears to be
      // idempotent, as el.files doesn't change between events.
      $el.trigger("change");
    }
  },
  subscribe: function(el, callback) {
    $(el).on("change.semanticFileInput", uploadFiles);
    // Here we try to set up the necessary events for Drag and Drop ("DnD").
    if ($fileInputs.length === 0) this._enableDocumentEvents();
    $fileInputs = $fileInputs.add(el);
    let $zone = this._zoneOf(el),
        {OVER} = this._ZoneClass;
    this._enableDraghover($zone)
      .on({
        "draghover:enter.draghover": e => {
          $zone.addClass(OVER);
        },
        "draghover:leave.draghover": e => {
          $zone.removeClass(OVER);
          // Prevent this event from bubbling to the document handler,
          // which would deactivate all zones.
          e.stopPropagation();
        },
        "draghover:drop.draghover": (e, dropEvent) => {
          this._handleDrop(dropEvent, el);
        }
      });
  },

  unsubscribe: function(el) {
    let $el   = $(el),
        $zone = this._zoneOf(el);

    $zone
      .removeClass(this._ZoneClass.OVER)
      .removeClass(this._ZoneClass.ACTIVE);

    this._disableDraghover($zone);
    $el.off(".semanticFileInput");
    $zone.off(".draghover");

    // Remove el from list of inputs and (maybe) clean up global event handlers.
    $fileInputs = $fileInputs.not(el);
    if ($fileInputs.length === 0) this._disableDocumentEvents();
  }
});

Shiny.inputBindings.register(semanticFileInput, 'shiny.semanticFileInput');
