// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only

const crypto = require('crypto');
const path = require('path');
const { app, dialog, shell, remote } = require('electron');

const fastGlob = require('fast-glob');
const glob = require('glob');
const pify = require('pify');
const fse = require('fs-extra');
const toArrayBuffer = require('to-arraybuffer');
const { map, isArrayBuffer, isString } = require('lodash');
const normalizePath = require('normalize-path');
const sanitizeFilename = require('sanitize-filename');
const getGuid = require('uuid/v4');
const { isPathInside } = require('../ts/util/isPathInside');
const { isWindows } = require('../ts/OS');
const {
  writeWindowsZoneIdentifier,
} = require('../ts/util/windowsZoneIdentifier');

let xattr;
try {
  // eslint-disable-next-line max-len
  // eslint-disable-next-line global-require, import/no-extraneous-dependencies, import/no-unresolved
  xattr = require('fs-xattr');
} catch (e) {
  console.log('x-attr dependency did not load successfully');
}

const PATH = 'attachments.noindex';
const STICKER_PATH = 'stickers.noindex';
const TEMP_PATH = 'temp';
const DRAFT_PATH = 'drafts.noindex';

const getApp = () => app || remote.app;

exports.getAllAttachments = async userDataPath => {
  const dir = exports.getPath(userDataPath);
  const pattern = normalizePath(path.join(dir, '**', '*'));

  const files = await fastGlob(pattern, { onlyFiles: true });
  return map(files, file => path.relative(dir, file));
};

exports.getAllStickers = async userDataPath => {
  const dir = exports.getStickersPath(userDataPath);
  const pattern = normalizePath(path.join(dir, '**', '*'));

  const files = await fastGlob(pattern, { onlyFiles: true });
  return map(files, file => path.relative(dir, file));
};

exports.getAllDraftAttachments = async userDataPath => {
  const dir = exports.getDraftPath(userDataPath);
  const pattern = normalizePath(path.join(dir, '**', '*'));

  const files = await fastGlob(pattern, { onlyFiles: true });
  return map(files, file => path.relative(dir, file));
};

exports.getBuiltInImages = async () => {
  const dir = path.join(__dirname, '../images');
  const pattern = path.join(dir, '**', '*.svg');

  // Note: we cannot use fast-glob here because, inside of .asar files, readdir will not
  //   honor the withFileTypes flag: https://github.com/electron/electron/issues/19074
  const files = await pify(glob)(pattern, { nodir: true });
  return map(files, file => path.relative(dir, file));
};

//      getPath :: AbsolutePath -> AbsolutePath
exports.getPath = userDataPath => {
  if (!isString(userDataPath)) {
    throw new TypeError("'userDataPath' must be a string");
  }
  return path.join(userDataPath, PATH);
};

//      getStickersPath :: AbsolutePath -> AbsolutePath
exports.getStickersPath = userDataPath => {
  if (!isString(userDataPath)) {
    throw new TypeError("'userDataPath' must be a string");
  }
  return path.join(userDataPath, STICKER_PATH);
};

//      getTempPath :: AbsolutePath -> AbsolutePath
exports.getTempPath = userDataPath => {
  if (!isString(userDataPath)) {
    throw new TypeError("'userDataPath' must be a string");
  }
  return path.join(userDataPath, TEMP_PATH);
};

//      getDraftPath :: AbsolutePath -> AbsolutePath
exports.getDraftPath = userDataPath => {
  if (!isString(userDataPath)) {
    throw new TypeError("'userDataPath' must be a string");
  }
  return path.join(userDataPath, DRAFT_PATH);
};

//      clearTempPath :: AbsolutePath -> AbsolutePath
exports.clearTempPath = userDataPath => {
  const tempPath = exports.getTempPath(userDataPath);
  return fse.emptyDir(tempPath);
};

//      createReader :: AttachmentsPath ->
//                      RelativePath ->
//                      IO (Promise ArrayBuffer)
exports.createReader = root => {
  if (!isString(root)) {
    throw new TypeError("'root' must be a path");
  }

  return async relativePath => {
    if (!isString(relativePath)) {
      throw new TypeError("'relativePath' must be a string");
    }

    const absolutePath = path.join(root, relativePath);
    const normalized = path.normalize(absolutePath);
    if (!isPathInside(normalized, root)) {
      throw new Error('Invalid relative path');
    }
    const buffer = await fse.readFile(normalized);
    return toArrayBuffer(buffer);
  };
};

exports.createDoesExist = root => {
  if (!isString(root)) {
    throw new TypeError("'root' must be a path");
  }

  return async relativePath => {
    if (!isString(relativePath)) {
      throw new TypeError("'relativePath' must be a string");
    }

    const absolutePath = path.join(root, relativePath);
    const normalized = path.normalize(absolutePath);
    if (!isPathInside(normalized, root)) {
      throw new Error('Invalid relative path');
    }
    try {
      await fse.access(normalized, fse.constants.F_OK);
      return true;
    } catch (error) {
      return false;
    }
  };
};

exports.copyIntoAttachmentsDirectory = root => {
  if (!isString(root)) {
    throw new TypeError("'root' must be a path");
  }

  const userDataPath = getApp().getPath('userData');

  return async sourcePath => {
    if (!isString(sourcePath)) {
      throw new TypeError('sourcePath must be a string');
    }

    if (!isPathInside(sourcePath, userDataPath)) {
      throw new Error(
        "'sourcePath' must be relative to the user config directory"
      );
    }

    const name = exports.createName();
    const relativePath = exports.getRelativePath(name);
    const absolutePath = path.join(root, relativePath);
    const normalized = path.normalize(absolutePath);
    if (!isPathInside(normalized, root)) {
      throw new Error('Invalid relative path');
    }

    await fse.ensureFile(normalized);
    await fse.copy(sourcePath, normalized);
    return relativePath;
  };
};

exports.writeToDownloads = async ({ data, name }) => {
  const appToUse = getApp();
  const downloadsPath =
    appToUse.getPath('downloads') || appToUse.getPath('home');
  const sanitized = sanitizeFilename(name);

  const extension = path.extname(sanitized);
  const basename = path.basename(sanitized, extension);
  const getCandidateName = count => `${basename} (${count})${extension}`;

  const existingFiles = await fse.readdir(downloadsPath);
  let candidateName = sanitized;
  let count = 0;
  while (existingFiles.includes(candidateName)) {
    count += 1;
    candidateName = getCandidateName(count);
  }

  const target = path.join(downloadsPath, candidateName);
  const normalized = path.normalize(target);
  if (!isPathInside(normalized, downloadsPath)) {
    throw new Error('Invalid filename!');
  }

  await writeWithAttributes(normalized, Buffer.from(data));

  return {
    fullPath: normalized,
    name: candidateName,
  };
};

async function writeWithAttributes(target, data) {
  await fse.writeFile(target, Buffer.from(data));

  if (process.platform === 'darwin' && xattr) {
    // kLSQuarantineTypeInstantMessageAttachment
    const type = '0003';

    // Hexadecimal seconds since epoch
    const timestamp = Math.trunc(Date.now() / 1000).toString(16);

    const appName = 'Signal';
    const guid = getGuid();

    // https://ilostmynotes.blogspot.com/2012/06/gatekeeper-xprotect-and-quarantine.html
    const attrValue = `${type};${timestamp};${appName};${guid}`;

    await xattr.set(target, 'com.apple.quarantine', attrValue);
  } else if (isWindows()) {
    // This operation may fail (see the function's comments), which is not a show-stopper.
    try {
      await writeWindowsZoneIdentifier(target);
    } catch (err) {
      console.warn('Failed to write Windows Zone.Identifier file; continuing');
    }
  }
}

exports.openFileInDownloads = async name => {
  const shellToUse = shell || remote.shell;
  const appToUse = getApp();

  const downloadsPath =
    appToUse.getPath('downloads') || appToUse.getPath('home');
  const target = path.join(downloadsPath, name);

  const normalized = path.normalize(target);
  if (!isPathInside(normalized, downloadsPath)) {
    throw new Error('Invalid filename!');
  }

  shellToUse.showItemInFolder(normalized);
};

exports.saveAttachmentToDisk = async ({ data, name }) => {
  const dialogToUse = dialog || remote.dialog;
  const browserWindow = remote.getCurrentWindow();

  const { canceled, filePath } = await dialogToUse.showSaveDialog(
    browserWindow,
    {
      defaultPath: name,
    }
  );

  if (canceled) {
    return null;
  }

  await writeWithAttributes(filePath, Buffer.from(data));

  const basename = path.basename(filePath);

  return {
    fullPath: filePath,
    name: basename,
  };
};

exports.openFileInFolder = async target => {
  const shellToUse = shell || remote.shell;

  shellToUse.showItemInFolder(target);
};

//      createWriterForNew :: AttachmentsPath ->
//                            ArrayBuffer ->
//                            IO (Promise RelativePath)
exports.createWriterForNew = root => {
  if (!isString(root)) {
    throw new TypeError("'root' must be a path");
  }

  return async arrayBuffer => {
    if (!isArrayBuffer(arrayBuffer)) {
      throw new TypeError("'arrayBuffer' must be an array buffer");
    }

    const name = exports.createName();
    const relativePath = exports.getRelativePath(name);
    return exports.createWriterForExisting(root)({
      data: arrayBuffer,
      path: relativePath,
    });
  };
};

//      createWriter :: AttachmentsPath ->
//                      { data: ArrayBuffer, path: RelativePath } ->
//                      IO (Promise RelativePath)
exports.createWriterForExisting = root => {
  if (!isString(root)) {
    throw new TypeError("'root' must be a path");
  }

  return async ({ data: arrayBuffer, path: relativePath } = {}) => {
    if (!isString(relativePath)) {
      throw new TypeError("'relativePath' must be a path");
    }

    if (!isArrayBuffer(arrayBuffer)) {
      throw new TypeError("'arrayBuffer' must be an array buffer");
    }

    const buffer = Buffer.from(arrayBuffer);
    const absolutePath = path.join(root, relativePath);
    const normalized = path.normalize(absolutePath);
    if (!isPathInside(normalized, root)) {
      throw new Error('Invalid relative path');
    }

    await fse.ensureFile(normalized);
    await fse.writeFile(normalized, buffer);
    return relativePath;
  };
};

//      createDeleter :: AttachmentsPath ->
//                       RelativePath ->
//                       IO Unit
exports.createDeleter = root => {
  if (!isString(root)) {
    throw new TypeError("'root' must be a path");
  }

  return async relativePath => {
    if (!isString(relativePath)) {
      throw new TypeError("'relativePath' must be a string");
    }

    const absolutePath = path.join(root, relativePath);
    const normalized = path.normalize(absolutePath);
    if (!isPathInside(normalized, root)) {
      throw new Error('Invalid relative path');
    }
    await fse.remove(absolutePath);
  };
};

exports.deleteAll = async ({ userDataPath, attachments }) => {
  const deleteFromDisk = exports.createDeleter(exports.getPath(userDataPath));

  for (let index = 0, max = attachments.length; index < max; index += 1) {
    const file = attachments[index];
    // eslint-disable-next-line no-await-in-loop
    await deleteFromDisk(file);
  }

  console.log(`deleteAll: deleted ${attachments.length} files`);
};

exports.deleteAllStickers = async ({ userDataPath, stickers }) => {
  const deleteFromDisk = exports.createDeleter(
    exports.getStickersPath(userDataPath)
  );

  for (let index = 0, max = stickers.length; index < max; index += 1) {
    const file = stickers[index];
    // eslint-disable-next-line no-await-in-loop
    await deleteFromDisk(file);
  }

  console.log(`deleteAllStickers: deleted ${stickers.length} files`);
};

exports.deleteAllDraftAttachments = async ({ userDataPath, stickers }) => {
  const deleteFromDisk = exports.createDeleter(
    exports.getDraftPath(userDataPath)
  );

  for (let index = 0, max = stickers.length; index < max; index += 1) {
    const file = stickers[index];
    // eslint-disable-next-line no-await-in-loop
    await deleteFromDisk(file);
  }

  console.log(`deleteAllDraftAttachments: deleted ${stickers.length} files`);
};

//      createName :: Unit -> IO String
exports.createName = () => {
  const buffer = crypto.randomBytes(32);
  return buffer.toString('hex');
};

//      getRelativePath :: String -> Path
exports.getRelativePath = name => {
  if (!isString(name)) {
    throw new TypeError("'name' must be a string");
  }

  const prefix = name.slice(0, 2);
  return path.join(prefix, name);
};

//      createAbsolutePathGetter :: RootPath -> RelativePath -> AbsolutePath
exports.createAbsolutePathGetter = rootPath => relativePath => {
  const absolutePath = path.join(rootPath, relativePath);
  const normalized = path.normalize(absolutePath);
  if (!isPathInside(normalized, rootPath)) {
    throw new Error('Invalid relative path');
  }
  return normalized;
};
