/*
 *  Copyright 2015 Adobe Systems Incorporated. All rights reserved.
 *  This file is licensed to you under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License. You may obtain a copy
 *  of the License at http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software distributed under
 *  the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
 *  OF ANY KIND, either express or implied. See the License for the specific language
 *  governing permissions and limitations under the License.
 */

'use strict';

var logger = require('winston').loggers.get('default');
var async = require('async');
var _ = require('lodash');
var path = require('path');

var SMBFile = require('./smbfile');
var common = require('./common');
var utils = require('./utils');
var ntstatus = require('./ntstatus');

// timeout in ms after which a NT_TRANSACT_NOTIFY_CHANGE request will be replied with a dummy change notification.
// after receiving such a change notification the client (i.e. Finder on os-x) will send a TRANS2_FIND_FIRST2 cmd to refresh.
var CHANGE_LISTENER_TIMEOUT = 5000; // todo FIXME use configured refresh interval

/**
 * Represents a tree connection established by <code>TREE_CONNECT_ANDX</code> or <code>SMB2 TREE_CONNECT</code>
 *
 * @param {SMBServer} smbServer
 * @param {SMBShare} smbShare
 * @param {Tree} spiTree
 * @constructor
 */
function SMBTree(smbServer, smbShare, spiTree) {
  this.smbServer = smbServer;
  this.smbShare = smbShare;
  this.spiTree = spiTree;
  this.tid = ++SMBTree.tidCounter;

  this.files = {};
  this.listeners = {};
}

SMBTree.tidCounter = 0;
SMBTree.fidCounter = 0;

SMBTree.prototype.getShare = function () {
  return this.smbShare;
};

SMBTree.prototype.getFile = function (fid) {
  return this.files[fid];
};

SMBTree.prototype.closeFile = function (fid, cb) {
  var file = this.files[fid];
  if (!file) {
    process.nextTick(function () { cb(new Error('no such file')); });
  } else {
    delete this.files[fid];
    file.close(cb);
  }
};

/**
 * Test whether or not the specified file exists.
 *
 * @param {String} name file name
 * @param {Function} cb callback called with the result
 * @param {SMBError} cb.error error (non-null if an error occurred)
 * @param {Boolean} cb.exists true if the file exists; false otherwise
 */
SMBTree.prototype.exists = function (name, cb) {
  this.spiTree.exists(utils.normalizeSMBFileName(name), cb);
};

/**
 * Open or create an existing file/directory.
 *
 * @param {String} name file name
 * @param {Number} createDisposition flag specifying action if file does/does not exist
 * @param {Boolean} openTargetDirectory true if target for open is a directory
 * @param {Function} cb callback called with the opened file
 * @param {SMBError} cb.error error (non-null if an error occurred)
 * @param {SMBFile} cb.file opened file
 */
SMBTree.prototype.openOrCreate = function (name, createDisposition, openTargetDirectory, cb) {
  var self = this;

  name = utils.normalizeSMBFileName(name);

  function create(callback) {
    var createFn = openTargetDirectory ? self.createDirectory : self.createFile;
    createFn.call(self, name, callback);
  }

  function open(callback) {
    self.spiTree.open(name, function (err, file) {
      if (err) {
        callback(err);
        return;
      }
      var fid = ++SMBTree.fidCounter;
      // todo what's the exact difference between consts.FILE_SUPERSEDE and consts.FILE_OVERWRITE_IF ?
      var openAction;
      if (createDisposition === common.FILE_OVERWRITE
        || createDisposition === common.FILE_OVERWRITE_IF
        || createDisposition === common.FILE_SUPERSEDE) {
        openAction = common.FILE_OVERWRITTEN;
      } else {
        openAction = common.FILE_OPENED;
      }
      var result = new SMBFile(file, self, openAction, fid);
      self.files[fid] = result;
      if (openAction === common.FILE_OVERWRITTEN) {
        result.setLength(0, function (err) {
          callback(err, result);
        });
      } else {
        callback(null, result);
      }
    });
  }

  if (createDisposition === common.FILE_OPEN || createDisposition === common.FILE_OVERWRITE) {
    // open existing
    open(cb);
  } else if (createDisposition === common.FILE_CREATE) {
    // create new
    create(cb);
  } else {
    // conditional create/open (consts.FILE_SUPERSEDE, consts.FILE_OPEN_IF, consts.FILE_OVERWRITE_IF)
    self.exists(name, function (err, exists) {
      if (err) {
        cb(err);
        return;
      }
      if (exists) {
        open(cb);
      } else {
        create(cb);
      }
    });
  }
};

/**
 * Open an existing file/directory.
 *
 * @param {String} name file name
 * @param {Function} cb callback called with the opened file
 * @param {SMBError} cb.error error (non-null if an error occurred)
 * @param {SMBFile} cb.file opened file
 */
SMBTree.prototype.open = function (name, cb) {
  var self = this;
  this.spiTree.open(utils.normalizeSMBFileName(name), function (err, file) {
    if (err) {
      cb(err);
    } else {
      var fid = ++SMBTree.fidCounter;
      var result = new SMBFile(file, self, common.FILE_OPENED, fid);
      self.files[fid] = result;
      cb(null, result);
    }
  });
};

/**
 * Reopen an existing file/directory using an already assigned fid.
 * Special purpose method called when an already open SMBFile instance
 * is renamed in order to make sure that the internal state of the
 * wrapped File instance is consistent with the new path/name.
 *
 * @param {String} name file name
 * @param {Number} fid file ID
 * @param {Function} cb callback called with the opened file
 * @param {SMBError} cb.error error (non-null if an error occurred)
 * @param {SMBFile} cb.file reopened file
 */
SMBTree.prototype.reopen = function (name, fid, cb) {
  var self = this;
  this.spiTree.open(utils.normalizeSMBFileName(name), function (err, file) {
    if (err) {
      cb(err);
    } else {
      var result = new SMBFile(file, self, common.FILE_OPENED, fid);
      self.files[fid] = result;
      cb(null, result);
    }
  });
};

/**
 * List entries, matching a specified pattern.
 *
 * @param {String} pattern pattern
 * @param {Function} cb callback called with an array of matching files
 * @param {SMBError} cb.error error (non-null if an error occurred)
 * @param {SMBFile[]} cb.files array of matching files
 */
SMBTree.prototype.list = function (pattern, cb) {
  var npattern = utils.normalizeSMBFileName(pattern);
  var self = this;
  this.spiTree.list(npattern, function (err, files) {
    if (err) {
      cb(err);
    } else {
      var results = files.map(function (file) {
        return new SMBFile(file, self);
      });
      cb(null, results);
      if (utils.getPathName(npattern) === '*') {
        // emit event
        self.smbServer.emit('folderListed', self.smbShare.getName(), utils.getParentPath(npattern));
      }
    }
  });
};

/**
 * Create a new file.
 *
 * @param {String} name file name
 * @param {Function} cb callback called on completion
 * @param {SMBError} cb.error error (non-null if an error occurred)
 * @param {SMBFile} cb.file created file
 */
SMBTree.prototype.createFile = function (name, cb) {
  var self = this;
  var nname = utils.normalizeSMBFileName(name);

  this.spiTree.createFile(nname, function (err, file) {
    if (err) {
      cb(err);
      return;
    }
    var fid = ++SMBTree.fidCounter;
    var result = new SMBFile(file, self, common.FILE_CREATED, fid);
    self.files[fid] = result;
    cb(null, result);

    self.notifyChangeListeners(common.FILE_ACTION_ADDED, nname);

    // emit event
    self.smbServer.emit('fileCreated', self.smbShare.getName(), nname);
  });
};

/**
 * Create a new directory.
 *
 * @param {String} name directory name
 * @param {Function} cb callback called on completion
 * @param {SMBError} cb.error error (non-null if an error occurred)
 * @param {SMBFile} cb.file created directory
 */
SMBTree.prototype.createDirectory = function (name, cb) {
  var self = this;
  var nname = utils.normalizeSMBFileName(name);

  this.spiTree.createDirectory(nname, function (err, file) {
    if (err) {
      cb(err);
      return;
    }
    var fid = ++SMBTree.fidCounter;
    var result = new SMBFile(file, self, common.FILE_CREATED, fid);
    self.files[fid] = result;
    cb(null, result);

    self.notifyChangeListeners(common.FILE_ACTION_ADDED, nname);

    // emit event
    self.smbServer.emit('folderCreated', self.smbShare.getName(), nname);
  });
};

/**
 * Delete a file.
 *
 * @param {String} name file name
 * @param {Function} cb callback called with the result
 * @param {SMBError} cb.error error (non-null if an error occurred)
 * @param {Boolean} cb.deleted true if the file could be deleted; false otherwise
 */
SMBTree.prototype.delete = function (name, cb) {
  var self = this;
  var nname = utils.normalizeSMBFileName(name);

  this.spiTree.delete(nname, function (err) {
    cb(err);
    if (!err) {
      self.notifyChangeListeners(common.FILE_ACTION_REMOVED, nname);

      // emit event
      self.smbServer.emit('fileDeleted', self.smbShare.getName(), nname);
    }
  });
};

/**
 * Delete a directory. It must be empty in order to be deleted.
 *
 * @param {String} name directory name
 * @param {Function} cb callback called with the result
 * @param {SMBError} cb.error error (non-null if an error occurred)
 * @param {Boolean} cb.deleted true if the directory could be deleted; false otherwise
 */
SMBTree.prototype.deleteDirectory = function (name, cb) {
  var self = this;
  var nname = utils.normalizeSMBFileName(name);

  this.spiTree.deleteDirectory(nname, function (err) {
    cb(err);
    if (!err) {
      self.notifyChangeListeners(common.FILE_ACTION_REMOVED, nname);

      // emit event
      self.smbServer.emit('folderDeleted', self.smbShare.getName(), nname);
    }
  });
};

/**
 * Rename a file or directory.
 *
 * @param {String|SMBFile} nameOrFile name of target file or target file
 * @param {String} newName new name
 * @param {Function} cb callback called on completion
 * @param {SMBError} cb.error error (non-null if an error occurred)
 */
SMBTree.prototype.rename = function (nameOrFile, newName, cb) {
  var self = this;

  var targetFID;
  var oldName;
  if (typeof nameOrFile === 'string') {
    oldName = nameOrFile;
  } else {
    targetFID = nameOrFile.getId();
    oldName = nameOrFile.getPath();
  }
  var nOldName = utils.normalizeSMBFileName(oldName);
  var nNewName = utils.normalizeSMBFileName(newName);

  // todo check if source has uncommitted changes (i.e. needs flush)
  // todo check if source has deleteOnClose set
  this.spiTree.rename(nOldName, nNewName, function (err) {
    if (err) {
      cb(err);
      return;
    }
    if (targetFID) {
      self.reopen(nNewName, targetFID, cb);
    } else {
      cb();
    }

    self.notifyChangeListeners(common.FILE_ACTION_RENAMED, nOldName, nNewName);

    // emit event
    self.smbServer.emit('itemMoved', self.smbShare.getName(), nOldName, nOldName);
  });
};

/**
 * Flush the contents of all open files.
 *
 * @param {Function} cb callback called on completion
 * @param {SMBError} cb.error error (non-null if an error occurred)
 */
SMBTree.prototype.flush = function (cb) {
  async.forEachOf(this.files,
    function (file, fid, callback) {
      file.flush(callback);
    },
    cb);
};

/**
 * Disconnect this tree.
 */
SMBTree.prototype.disconnect = function () {
  var self = this;
  // cancel any pending change listeners
  _.forOwn(this.listeners, function (listener, mid) {
    self.cancelChangeListener(mid);
  });
  // delegate to spi
  this.spiTree.disconnect(function (err) {
    if (err) {
      logger.error('tree disconnect failed:', err);
    }
  });
};

/**
 * Refresh a specific folder.
 *
 * @param {String} folderPath
 * @param {Boolean} deep
 * @param {Function} cb callback called on completion
 * @param {String|Error} cb.error error (non-null if an error occurred)
 */
SMBTree.prototype.refresh = function (folderPath, deep, cb) {
  var self = this;
  // give SPI impl a chance to invalidate cache
  this.spiTree.refresh(folderPath, deep, function (err) {
    if (err) {
      cb(err);
    } else {
      // dummy change notification to force client to refresh
      var p = path.join(folderPath, '/'); // append trailing / to folder path (to make sure the proper listener is selected)
      self.notifyChangeListeners(common.FILE_ACTION_MODIFIED, p);
      cb();
    }
  });
};

/**
 * Register a one-shot notification listener that will send a NT_TRANSACT_NOTIFY_CHANGE response.
 *
 * see https://msdn.microsoft.com/en-us/library/ee442155.aspx
 *
 * @param {Number} mid - multiplex id (msg.header.mid, identifies an SMB request within an SMB session)
 * @param {SMBFile} file - directory to watch for changes
 * @param {Boolean} deep - watch all subdirectories too
 * @param {Number} completionFilter - completion filter bit flags
 * @param {Function} cb - callback to be called on changes
 * @param {Number} cb.action - file action
 * @param {String} cb.name - name of file that changed
 * @param {String} [cb.newName] - optional, new name if this was a rename
 */
SMBTree.prototype.registerChangeListener = function (mid, file, deep, completionFilter, cb) {
  var self = this;
  var listener = {
    mid: mid,
    path: file.getPath(),
    deep: deep,
    completionFilter: completionFilter,
    cb: cb
  };
  // auto refresh after timeout if no change (via SMB server) occurred within specified period
  listener.autoRefreshTimer = setTimeout(
    function () {
      // dummy change notification to force client to refresh
      var p = path.join(listener.path, '/'); // append trailing / to folder path (to make sure the proper listener is selected)
      self.notifyChangeListeners(common.FILE_ACTION_MODIFIED, p);
    },
    CHANGE_LISTENER_TIMEOUT
  );

  this.listeners[mid] = listener;
};

/**
 * Notify the appropriate listener (if there is one) for some change
 * and remove it from the collection of registered listeners (one shot notification).
 *
 * @param {Number} action file action
 * @param {String} name name of file that changed
 * @param {String} [newName] optional, new name of file in case of a rename
 */
SMBTree.prototype.notifyChangeListeners = function (action, name, newName) {

  function trimListenerPath(name, listener) {
    return name.substr(listener.path.length + ((listener.path === '/') ? 0 : 1));
  }

  function getSearchPredicate(path) {
    return function (listener, mid) {
      // todo evaluate listener.completionFilter WRT action
      return (!listener.deep && utils.getParentPath(path) === listener.path)
        || (listener.deep && path.indexOf(listener.path) === 0);
    };
  }

  var listener = _.find(this.listeners, getSearchPredicate(name));
  var listenerNew;
  if (action === common.FILE_ACTION_RENAMED) {
    // rename
    listenerNew = _.find(this.listeners, getSearchPredicate(newName));
    if (listener || listenerNew) {
      if (listener === listenerNew) {
        // in-place rename: same listener for both old and new name
        listener.cb(action, name.substr(listener.path.length + 1), trimListenerPath(newName, listener));
      } else if (listener && listenerNew) {
        // there's separate listeners for old and new name
        listener.cb(common.FILE_ACTION_RENAMED_OLD_NAME, trimListenerPath(name, listener));
        listenerNew.cb(common.FILE_ACTION_RENAMED_NEW_NAME, trimListenerPath(newName, listenerNew));
      } else if (listener) {
        // there's only a listener for old name
        listener.cb(common.FILE_ACTION_RENAMED_OLD_NAME, trimListenerPath(name, listener));
      } else {
        // there's only a listener for new name
        listenerNew.cb(common.FILE_ACTION_RENAMED_NEW_NAME, trimListenerPath(newName, listenerNew));
      }
    }
  } else {
    // not a rename
    if (listener) {
      listener.cb(action, trimListenerPath(name, listener));
    }
  }

  // one shot notification, cancel listeners
  if (listener) {
    this.cancelChangeListener(listener.mid);
  }
  if (listenerNew) {
    this.cancelChangeListener(listenerNew.mid);
  }
};

/**
 * Cancel the specified listener.
 *
 * @param {Number} mid - multiplex id (msg.header.mid, identifies an SMB request within an SMB session)
 * @return {Function} cancelled listener callback or null
 */
SMBTree.prototype.cancelChangeListener = function (mid) {
  var result = this.listeners[mid];
  if (result) {
    if (result.autoRefreshTimer) {
      // cancel auto refresh timer
      clearTimeout(result.autoRefreshTimer);
    }
    delete this.listeners[mid];
  }
  return result;
};

/**
 * Clears the tree's cache.
 * @param {function} cb Will be invoked when the operation is complete.
 * @param {string|Error} cb.err Will be truthy if there were errors during the operation.
 */
SMBTree.prototype.clearCache = function (cb) {
  var self = this;
  if (self.spiTree) {
    self.spiTree.clearCache(cb);
  } else {
    logger.debug('cannot clear cache because spiTree is not an object');
    cb();
  }
};

module.exports = SMBTree;
