/*
 *  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 Util = require('util');
var fs = require('fs');

var logger = require('winston').loggers.get('spi');
var async = require('async');

var File = require('../../spi/file');
var ntstatus = require('../../ntstatus');
var SMBError = require('../../smberror');
var JCR = require('./constants');
var utils = require('../../utils');
var webutils = require('../../webutils');

/**
 * Creates an instance of File.
 *
 * @constructor
 * @private
 * @this {JCRFile}
 * @param {String} filePath normalized file path
 * @param {Object} content JCR file content representation
 * @param {Number} fileLength file length
 * @param {JCRTree} tree tree object
 */
var JCRFile = function (filePath, content, fileLength, tree) {
  logger.debug('[%s] file.open %s', tree.share.config.backend, filePath);
  if (!(this instanceof JCRFile)) {
    return new JCRFile(filePath, content, fileLength, tree);
  }
  this.content = content;
  this.tree = tree;
  this.fileLength = fileLength;

  // object holding path/fd of local copy of remote resource
  this.localFile = null;

  // needs flushing?
  this.dirty = false;

  File.call(this, filePath, tree);
};

// the JCRFile prototype inherits from File
Util.inherits(JCRFile, File);

/**
 * Async factory method
 *
 * @param {String} filePath normalized file path
 * @param {JCRTree} tree tree object
 * @param {Object} [content=null] file meta data (null if unknown)
 * @param {Number} [fileLength=-1] file length (-1 if unknown)
 * @param {Function} cb callback called with the result
 * @param {SMBError} cb.error error (non-null if an error occurred)
 * @param {JCRFile} cb.file JCRFile instance
 */
JCRFile.createInstance = function (filePath, tree, content, fileLength, cb) {
  content = typeof content === 'object' ? content : null;
  fileLength = typeof fileLength === 'number' ? fileLength : -1;
  cb = arguments[arguments.length - 1];
  if (typeof cb !== 'function') {
    logger.error(new Error('JCRFile.createInstance: called without callback'));
    cb = function () {};
  }

  function getContent(callback) {
    if (content) {
      callback(null, content);
    } else {
      tree.share.getContent(filePath, false, function (err, content) {
        if (content) {
          callback(err, content);
        } else {
          callback(err || 'not found: ' + filePath);
        }
      });
    }
  }

  function getFileLength(content, callback) {
    if (fileLength > -1) {
      callback(null, content, fileLength);
    } else if (!tree.share.isFilePrimaryType(content[JCR.JCR_PRIMARYTYPE])) {
      // folder has length 0
      callback(null, content, 0);
    } else if (typeof content[JCR.JCR_CONTENT][JCR.JCR_DATA_LENGTH] === 'number') {
      callback(null, content, content[JCR.JCR_CONTENT][JCR.JCR_DATA_LENGTH]);
    } else {
      // last resort: send a separate request for file length
      tree.fetchFileLength(filePath, function (err, length) {
        if (err) {
          callback(err);
        } else {
          callback(null, content, length);
        }
      });
    }
  }

  async.seq(getContent, getFileLength)(function (err, metaData, length) {
    if (err) {
      cb(SMBError.fromSystemError(err, 'unable to create file instance due to unexpected error ' + filePath));
    } else {
      cb(null, new JCRFile(filePath, metaData, length, tree));
    }
  });
};

/**
 * Returns path and fd of local file holding a copy of the remote resource's content.
 *
 * @param {Function} cb callback called with the path of the local file holding a copy of the remote resource's content.
 * @param {String|Error} cb.error error (non-null if an error occurred)
 * @param {Object} cb.localFile object holding path/fd of local copy of remote resource
 * @param {String} cb.localFile.path path of local file holding a copy of the remote resource's content.
 * @param {Number} cb.localFile.fd file handle to opened local file
 */
JCRFile.prototype.ensureGotLocalCopy = function (cb) {
  if (this.localFile) {
    cb(null, this.localFile);
    return;
  }

  var self = this;
  this.tree.share.getLocalFile(this.filePath, this.lastModified(), function (err, localFilePath) {
    if (err) {
      cb(err);
    } else {
      fs.open(localFilePath, 'r+', function (err, fd) {
        if (err) {
          logger.error('failed to open local file %s (%s)', localFilePath, self.filePath, err);
          cb(err);
        } else {
          self.localFile = {
            path: localFilePath,
            fd: fd
          };
          cb(null, self.localFile);
        }
      });
    }
  });
};

/**
 * Uploads the local tmp file to the server if there are pending changes.
 *
 * @param {Function} cb callback called on completion.
 * @param {String|Error} cb.error error (non-null if an error occurred)
 */
JCRFile.prototype.syncLocalChanges = function (cb) {
  if (!this.localFile || !this.dirty) {
    // no local changes, we're done
    cb();
    return;
  }

  logger.debug('[%s] file.syncLocalChanges %s', this.tree.share.config.backend, this.filePath);

  // deferred write (spool local tmp file to server)
  var self = this;
  var url = this.tree.share.buildResourceUrl(this.filePath);
  var options = this.tree.share.applyRequestDefaults({
    url: url,
    method: 'PUT',
    headers: {
      'Content-Type': utils.lookupMimeType(this.filePath)
    }
  });
  this.ensureGotLocalCopy(function (err, localFile) {
    if (err) {
      cb(SMBError.fromSystemError(err, 'unable to get local copy due to unexpected error ' + self.filePath));
      return;
    }
    fs.createReadStream(null, { fd: localFile.fd, autoClose: false }).pipe(
      webutils.submitRequest(options, function (err, resp, body) {
        if (err) {
          logger.error('failed to spool %s to %s', self.localFile.path, self.filePath, err);
          cb(SMBError.fromSystemError(err, 'unable to submit request due to unexpected error ' + self.filePath));
        } else if (resp.statusCode !== 200 && resp.statusCode !== 204) {
          logger.error('failed to spool %s to %s - %s %s [%d]', localFile.path, self.filePath, this.method, this.href, resp.statusCode, body);
          cb(new SMBError(ntstatus.STATUS_UNSUCCESSFUL, 'unable to sync local changes due to unexpecte status code ' + resp.statusCode + ' ' + self.filePath));
        } else {
          // succeeded
          fs.fstat(localFile.fd, function (err, stats) {
            if (err) {
              cb(err);
            } else {
              // invalidate content cache
              self.tree.share.invalidateContentCache(self.filePath, false);
              // update length and lastModified
              self.fileLength = stats.size;
              self.setLastModified(stats.mtime.getTime());
              self.dirty = false;
              // touch cache entry
              self.tree.share.touchLocalFile(self.filePath, self.lastModified(), function (err) {
                cb(err);
              });
            }
          });
        }
      })
    );
  });
};

/**
 * Updates the content representation of the fileLength and lastModified attributes.
 *
 * @param {Object} content content object to patch
 * @param {Number} fileLength
 * @param {Number} lastModified (ms)
 */
JCRFile.prototype.patchContent = function (content, fileLength, lastModified) {
  // update fileLength content representation
  this.content[JCR.JCR_CONTENT][JCR.JCR_DATA_LENGTH] = fileLength;
  // update lastModified content representation
  this.content[JCR.JCR_CONTENT][JCR.JCR_LASTMODIFIED] = new Date(lastModified).toISOString();
};

//---------------------------------------------------------------------< File >

/**
 * Return a flag indicating whether this is a file.
 *
 * @return {Boolean} <code>true</code> if this is a file;
 *         <code>false</code> otherwise
 */
JCRFile.prototype.isFile = function () {
  return this.tree.share.isFilePrimaryType(this.content[JCR.JCR_PRIMARYTYPE]);
};

/**
 * Return a flag indicating whether this is a directory.
 *
 * @return {Boolean} <code>true</code> if this is a directory;
 *         <code>false</code> otherwise
 */
JCRFile.prototype.isDirectory = function () {
  return this.tree.share.isDirectoryPrimaryType(this.content[JCR.JCR_PRIMARYTYPE]);
};

/**
 * Return a flag indicating whether this file is read-only.
 *
 * @return {Boolean} <code>true</code> if this file is read-only;
 *         <code>false</code> otherwise
 */
JCRFile.prototype.isReadOnly = function () {
  return this.content[JCR.JCR_ISCHECKEDOUT] === false;
};

/**
 * Return the file size.
 *
 * @return {Number} file size, in bytes
 */
JCRFile.prototype.size = function () {
  return this.fileLength;
};

/**
 * Return the number of bytes that are allocated to the file.
 *
 * @return {Number} allocation size, in bytes
 */
JCRFile.prototype.allocationSize = function () {
  return this.isFile() ? this.size() : 0;
};

/**
 * Return the time of last modification, in milliseconds since
 * Jan 1, 1970, 00:00:00.0.
 *
 * @return {Number} time of last modification
 */
JCRFile.prototype.lastModified = function () {
  if (this.isFile() && this.content[JCR.JCR_CONTENT][JCR.JCR_LASTMODIFIED]) {
    return new Date(this.content[JCR.JCR_CONTENT][JCR.JCR_LASTMODIFIED]).getTime();
  } else {
    return this.created();
  }
};

/**
 * Sets the time of last modification, in milliseconds since
 * Jan 1, 1970, 00:00:00.0.
 *
 * @param {Number} ms
 * @return {Number} time of last modification
 */
JCRFile.prototype.setLastModified = function (ms) {
  if (this.isFile()) {
    // update lastModified (transient)
    this.content[JCR.JCR_CONTENT][JCR.JCR_LASTMODIFIED] = new Date(ms).toISOString();
  }
};

/**
 * Return the time when file status was last changed, in milliseconds since
 * Jan 1, 1970, 00:00:00.0.
 *
 * @return {Number} when file status was last changed
 */
JCRFile.prototype.lastChanged = function () {
  // todo correct?
  return this.created();
};

/**
 * Return the create time, in milliseconds since Jan 1, 1970, 00:00:00.0.
 * Jan 1, 1970, 00:00:00.0.
 *
 * @return {Number} time created
 */
JCRFile.prototype.created = function () {
  return new Date(this.content[JCR.JCR_CREATED]).getTime();
};

/**
 * Return the time of last access, in milliseconds since Jan 1, 1970, 00:00:00.0.
 * Jan 1, 1970, 00:00:00.0.
 *
 * @return {Number} time of last access
 */
JCRFile.prototype.lastAccessed = function () {
  // todo correct?
  return this.lastModified();
};

/**
 * Read bytes at a certain position inside the file.
 *
 * @param {Buffer} buffer the buffer that the data will be written to
 * @param {Number} offset the offset in the buffer to start writing at
 * @param {Number} length the number of bytes to read
 * @param {Number} position offset where to begin reading from in the file
 * @param {Function} cb callback called with the bytes actually read
 * @param {SMBError} cb.error error (non-null if an error occurred)
 * @param {Number} cb.bytesRead number of bytes actually read
 * @param {Buffer} cb.buffer buffer holding the bytes actually read
 */
JCRFile.prototype.read = function (buffer, offset, length, position, cb) {
  logger.debug('[%s] file.read %s offset=%d, length=%d, position=%d', this.tree.share.config.backend, this.filePath, offset, length, position);

  var self = this;
  function getLocalFile(callback) {
    self.ensureGotLocalCopy(callback);
  }

  function read(localFile, callback) {
    fs.read(localFile.fd, buffer, offset, length, position, callback);
  }

  async.waterfall([ getLocalFile, read ], function (err, bytesRead, buffer) {
    if (err) {
      err = SMBError.fromSystemError(err, 'unable to read file due to unexpected error ' + self.filePath);
    }
    cb(err, bytesRead, buffer);
  });
};

/**
 * Write bytes at a certain position inside the file.
 *
 * @param {Buffer} data buffer to write
 * @param {Number} position position inside file
 * @param {Function} cb callback called on completion
 * @param {SMBError} cb.error error (non-null if an error occurred)
 */
JCRFile.prototype.write = function (data, position, cb) {
  logger.debug('[%s] file.write %s data.length=%d, position=%d', this.tree.share.config.backend, this.filePath, data.length, position);

  var self = this;
  function getLocalFile(callback) {
    self.ensureGotLocalCopy(callback);
  }

  function write(localFile, callback) {
    fs.write(localFile.fd, data, 0, data.length, position, function (err) {
      callback(err, localFile);
    });
  }

  function updateStats(localFile, callback) {
    fs.fstat(localFile.fd, function (err, stats) {
      if (err) {
        callback(err);
      } else {
        // update length and lastModified
        self.fileLength = stats.size;
        self.setLastModified(stats.mtime.getTime());
        self.dirty = true;
        // touch cache entry
        self.tree.share.touchLocalFile(self.filePath, self.lastModified(), function (err) {
          callback(err);
        });
      }
    });
  }

  async.waterfall([ getLocalFile, write, updateStats ], function (err) {
    if (err) {
      err = SMBError.fromSystemError(err, 'unable to write file due to unexpected error ' + self.filePath);
    }
    cb(err);
  });
};

/**
 * Sets the file length.
 *
 * @param {Number} length file length
 * @param {Function} cb callback called on completion
 * @param {SMBError} cb.error error (non-null if an error occurred)
 */
JCRFile.prototype.setLength = function (length, cb) {
  logger.debug('[%s] file.setLength %s length=%d', this.tree.share.config.backend, this.filePath, length);
  // todo avoid spooling the entire file if length is 0, just create an empty local tmp file
  var self = this;
  this.ensureGotLocalCopy(function (err, localFile) {
    if (err) {
      cb(SMBError.fromSystemError(err, 'unable to set file length due to unexpected error ' + self.filePath));
      return;
    }
    fs.ftruncate(localFile.fd, length, function (err) {
      if (err) {
        cb(SMBError.fromSystemError(err, 'unable to truncate file due to unexpected error ' + self.filePath));
      } else {
        // update length and lastModified
        self.fileLength = length;
        var lastModified = Date.now();
        self.setLastModified(lastModified);
        self.dirty = true;
        // InDesign repeatedly sets (via TRANS2_SET_FILE_INFORMATION) & checks (via TRANS2_FIND_FIRST2) file length during saving.
        // since the file is just truncated locally (i.e. not yet flushed/closed) and the length is returned through a backend request
        // conflicting (i.e non-expected) length values lead to InDesign crashing.
        // a conservative solution would be syncing (i.e. uploading) the file on setLength but is very inefficient, especially for large files.
        // @FIXME workaround to avoid InDesign file corruption: refresh and patch content cache entry with new length
        self.tree.share.getContent(self.filePath, false, function (err, content) {
          if (content) {
            self.patchContent(content, length, lastModified);
            content.fetched = Date.now(); // touch cache entry
          }
          cb(err);
        });
      }
    });
  });
};

/**
 * Delete this file or directory. If this file denotes a directory, it must
 * be empty in order to be deleted.
 *
 * @param {Function} cb callback called on completion
 * @param {SMBError} cb.error error (non-null if an error occurred)
 */
JCRFile.prototype.delete = function (cb) {
  logger.debug('[%s] file.delete %s', this.tree.share.config.backend, this.filePath);
  var url = this.tree.share.buildResourceUrl(this.filePath);
  var options = this.tree.share.applyRequestDefaults({
    url: url,
    method: 'DELETE'
  });
  var self = this;
  webutils.submitRequest(options, function (err, resp, body) {
    if (err) {
      logger.error('failed to delete %s', self.filePath, err);
      cb(SMBError.fromSystemError(err, 'unable to delete file due to unexpected error ' + self.filePath));
    } else if (resp.statusCode !== 204) {
      logger.error('failed to delete %s - %s %s [%d]', self.filePath, this.method, this.href, resp.statusCode, body);
      cb(new SMBError(ntstatus.STATUS_UNSUCCESSFUL, 'unable to delete file due to unexpected ' + resp.statusCode + ' status code ' + self.filePath));
    } else {
      // succeeded
      // invalidate cache
      self.tree.share.invalidateContentCache(self.filePath, self.isDirectory());
      self.dirty = false;
      if (self.localFile) {
        fs.close(self.localFile.fd, function (ignored) {
          self.localFile = null;
          self.tree.share.discardLocalFile(self.filePath, function (ignored) {
            cb();
          });
        });
      } else {
        self.tree.share.discardLocalFile(self.filePath, function (ignored) {
          cb();
        });
      }
    }
  });
};

/**
 * Flush the contents of the file to disk.
 *
 * @param {Function} cb callback called on completion
 * @param {SMBError} cb.error error (non-null if an error occurred)
 */
JCRFile.prototype.flush = function (cb) {
  logger.debug('[%s] file.flush %s', this.tree.share.config.backend, this.filePath);

  if (!this.localFile) {
    // we're done
    cb();
    return;
  }

  fs.fsync(this.localFile.fd, function (err) {
    if (err) {
      logger.warn('failed to flush local file %s (%s)', self.localFile.path, self.filePath, err);
    }
    cb();
  });
};

/**
 * Close this file, releasing any resources.
 *
 * @param {Function} cb callback called on completion
 * @param {SMBError} cb.error error (non-null if an error occurred)
 */
JCRFile.prototype.close = function (cb) {
  logger.debug('[%s] file.close %s', this.tree.share.config.backend, this.filePath);

  if (!this.localFile) {
    // we're done
    cb();
    return;
  }

  var self = this;
  function sync(callback) {
    if (self.dirty) {
      self.syncLocalChanges(callback);
    } else {
      callback();
    }
  }

  function cleanup(callback) {
    fs.close(self.localFile.fd, function (err) {
      if (err) {
        logger.warn('failed to close local file %s (%s)', self.localFile.path, self.filePath, err);
      }
      self.localFile = null;
      callback();
    });
  }

  async.series([ sync, cleanup ], cb);
};

module.exports = JCRFile;
