/* eslint-disable no-undef */
const { existsSync, promises: { writeFile, mkdir, access, readdir, rm } } = require('fs');
const { exec: _exec, spawn } = require('child_process');
const { join, posix, sep } = require('path');
const { prompt } = require('inquirer');
const { promisify } = require('util');
const chalk = require('chalk');

const exec = promisify(_exec);

let release;

/**
 * Colors used for log styling.
 */
const COLORS = {
  Brand: '#ff006a',
  Success: '#a5d479',
  Warn: '#ebd334',
  Info: '#7a85b8',
  Error: '#f55353'
};

/**
 * Dynamically create an assortment of functions that send colorful messages to console.
 */
Object.keys(COLORS).forEach(color => {
  global[color.toLowerCase()] = (...message) => {
    return console.log(chalk.hex(COLORS[color])(message) + chalk.reset());
  };
});

/**
 * Start the injection process automatically.
 */
(async () => {
  try {
    await startInjectionProcess();
  } catch (err) {
    if (err.code === 'EACCES') {
      return error('Vizality was unable to be injected due to missing permissions. Try again with elevated permissions.');
    }
    error(`The setup process encountered an issue that prevented it from running correctly:\n${err}`);
    info(`If the problem persists, please join our Discord server and reach out for support: ${chalk.hex(COLORS.Brand)('https://invite.vizality.com') + chalk.reset()}.`);
    return promptForRestartOrExit();
  }
})();

/**
 * Prompts the user, giving them an option to restart the injection process or exit.
 * @returns {Promise<void>}
 */
async function promptForRestartOrExit () {
  const responses = await prompt([
    {
      type: 'list',
      name: 'action',
      message: `Would you like to start over or exit?`,
      default: 'Inject',
      choices: wrapChoicesInColor([
        'Start Over',
        'Exit'
      ])
    }
  ]);

  if (responses.action.includes('Start Over')) {
    return startInjectionProcess();
  }

  warn('Process has been cancelled. Now exiting...');
  return process.exit(0);
}

/**
 * Checks whether a file or directory exists, and if so, whether the user has permissions for it.
 * @param {path} path File or directory path
 * @returns {boolean} Whether or not the file or directory exists, and if so, whether the user has permissions for it
 */
async function confirmExistAndHasPermissions (path) {
  return access(path)
    .then(() => true)
    .catch(() => false);
}

/**
 * Prompts the user to manually enter a Discord installation path if one can't be found automatically.
 * @param {('inject'|'uninject'|'reinject')} action Action to be performed
 * @returns {Promise<void>}
 */
async function promptManualPathCompletion (action) {
  const responses = await prompt([
    {
      type: 'list',
      name: 'action',
      message: 'Would you like to enter the Discord directory path manually?',
      default: 'Yes',
      choices: wrapChoicesInColor([
        'Yes',
        'No'
      ])
    },
    {
      type: 'input',
      name: 'discordPath',
      message: `Please enter the absolute path to your Discord directory:`,
      when: responses => responses.action.includes('Yes')
    }
  ]);

  if (responses.action.includes('No')) {
    return promptForRestartOrExit();
  }

  const discordPath = responses.discordPath?.trim();

  if (discordPath) {
    if (await confirmExistAndHasPermissions(discordPath)) {
      let discordAppPath;
      if (process.platform === 'win32') {
        const discordDirectory = await readdir(discordPath);
        /**
         * Get the latest version's folder. Then find and return the app directory path.
         */
        const latestVersionDirectory = discordDirectory.filter(path => path.startsWith('app-')).sort().reverse()[0];
        discordAppPath = join(discordPath, latestVersionDirectory, 'resources', 'app');
      } else if (process.platform === 'darwin') {
        discordAppPath = join(discordPath, 'Contents', 'Resources', 'app');
      } else {
        discordAppPath = join(discordPath, 'resources', 'app');
      }

      if (!discordAppPath || !(await confirmExistAndHasPermissions(discordAppPath))) {
        error(`${release}'s app directory couldn't be located and ${action}ion has been cancelled.`);
        return promptForRestartOrExit();
      }

      info(`Discord app directory was successfully found at ${chalk.hex(COLORS.Brand)(discordAppPath) + chalk.reset()}`);
      if (action === 'inject') {
        await inject(discordAppPath);
      } else if (action === 'uninject') {
        await uninject(discordAppPath);
      } else if (action === 'reinject') {
        await reinject(discordAppPath);
      } else {
        return error(`Unrecognized action used. Process was cancelled.`);
      }
    } else {
      error(`${release} installation couldn't be located at ${chalk.hex(COLORS.Brand)(discordAppPath) + chalk.reset()}.\nPlease make sure the path is correct and try again.`);
      return promptForRestartOrExit();
    }
  } else {
    error(`You must enter a valid path in order to ${action} Vizality.`);
    return promptForRestartOrExit();
  }
}

/**
 * Determines the user's Discord app directory based on operating system and specified Discord release.
 * @returns {Promise<string>} Discord app directory path
 */
async function getDiscordAppPath () {
  /**
   * Windows
   */
  if (process.platform === 'win32') {
    const discordPath = join(process.env.LOCALAPPDATA, release?.replace(' ', ''));
    if (!discordPath || !(await confirmExistAndHasPermissions(discordPath))) {
      info(`Unfortunately, the ${release} directory couldn't be located.`);
      return promptManualPathCompletion('inject');
    }

    /**
     * Read the Discord directory so that we can find the latest version's folder below.
     */
    const discordDirectory = await readdir(discordPath);

    /**
     * Get the latest version's folder. Then find and return the app directory path.
     */
    const latestVersionDirectory = discordDirectory.filter(path => path.startsWith('app-')).sort().reverse()[0];
    return join(discordPath, latestVersionDirectory, 'resources', 'app');
  /**
   * Mac
   */
  } else if (process.platform === 'darwin') {
    const discordPath = join('/Applications', `${release}.app`);

    if (!discordPath || !(await confirmExistAndHasPermissions(discordPath))) {
      error(`Unfortunately, the ${release} directory couldn't be located.`);
      return promptManualPathCompletion('inject');
    }

    return join(discordPath, 'Contents', 'Resources', 'app');
  }

  /**
   * If all else fails, assume the user is on Linux and try to find the Discord process.
   */
  const discordProcess = (await exec('ps x')).toString().split('\n').map(s => s.split(' ').filter(Boolean)).find(process => process[4] && (/discord$/i).test(process[4]) && process.includes('--type=renderer'));

  /**
   * If the process can't be found, try to determine the directory path by checking if any
   * predefined path directories exist.
   */
  if (!discordProcess) {
    warn('Cannot find Discord process, falling back to legacy path detection...');

    /**
     * Instead of using ~, use this to determine the user's home directory instead of the root's home directory.
     */
    const homeDirectory = (await exec('grep $(logname) /etc/passwd | cut -d ":" -f6').toString()).trim();

    const paths = [
      `/usr/share/${release.toLowerCase().replace(' ', '-')}`,
      `/usr/lib64/${release.toLowerCase().replace(' ', '-')}`,
      `/opt/${release.toLowerCase().replace(' ', '-')}`,
      `/opt/${release.replace(' ', '')}`,
      `${homeDirectory}/.local/bin/${release.replace(' ', '')}/`
    ];

    const discordPath = paths.find(async path => !(await confirmExistAndHasPermissions(path)));

    if (!discordPath || !(await confirmExistAndHasPermissions(discordPath))) {
      error(`Unfortunately, the ${release} directory couldn't be located.`);
      return promptManualPathCompletion('inject');
    }

    return join(discordPath, 'resources', 'app');
  }

  const discordPath = discordProcess[4].split('/');
  discordPath.splice(discordPath.length - 1, 1);
  return join('/', ...discordPath, 'resources', 'app');
}

/**
 * Run an npm command.
 * Used in this script for installing Node package dependencies.
 * @returns {Promise<void>}
 */
function installDependencies () {
  return new Promise((resolve, reject) => {
    const command = spawn('npm', [ 'install', '--only=production', '--silent' ], {
      cwd: join(__dirname, '..', '..', '..'),
      stdio: 'inherit',
      shell: true
    });
    command.on('close', () => resolve());
    command.on('error', err => reject(err));
  });
}

/**
 * Checks for and ensures all node_modules package depedencies are installed.
 * @returns {Promise<void>}
 */
async function ensureDependencies () {
  try {
    const nodeModulesPath = join(__dirname, '..', '..', '..', 'node_modules');

    info('Checking dependencies. Please wait...');

    /**
     * Make sure all dependencies in our package file are installed.
     */
    const { dependencies } = require('../../../package.json');
    const missingDependencies = [];
    const outdatedDependencies = [];
    for (const dependency in dependencies) {
      const dependencyPath = join(nodeModulesPath, dependency);
      /**
       * Let's make sure all the depdencies are installed.
       */
      if (!existsSync(dependencyPath)) {
        missingDependencies.push(dependency);
      } else {
        /**
         * Let's check the package versions and make sure they're the metting the qualifications of
         * the expected version specified in the package file.
         */
        const dependencyPackage = require(`../../../node_modules/${dependency}/package.json`);
        const expectedVersion = parseInt(dependencies[dependency].replace(/[^\d]/g, ''));
        const installedVersion = parseInt(dependencyPackage.version.replace(/[^\d]/g, ''));
        if (installedVersion < expectedVersion) {
          outdatedDependencies.push(dependency);
        }
      }
    }

    const missingDependenciesMessage =  `${missingDependencies.length} missing`;
    const outdatedDependenciesMessage =  `${outdatedDependencies.length} outdated`;

    if (missingDependencies.length || outdatedDependencies.length) {
      warn(`Found ${[ missingDependenciesMessage, outdatedDependenciesMessage ].filter(message => message)} ${missingDependencies.length + outdatedDependencies.length > 1 ? 'packages' : 'package'}. Resolving...`);
    }

    /**
     * Install the missing and updated dependencies.
     */
    await installDependencies();

    return info('Dependencies are up-to-date!');
  } catch (err) {
    error(`An error occured while installing the dependencies:\n${err.message}`);
    return process.exit(0);
  }
}

/**
 * Uninjects Vizality from Discord.
 * @param {path} discordAppPath Discord app path
 * @returns {Promise<void>}
 */
async function uninject (discordAppPath) {
  /**
   * Check for a valid Discord app directory.
   */
  if (!discordAppPath || !existsSync(discordAppPath)) {
    error(`It doesn't look like there's anything injected.`);
    return promptForRestartOrExit();
  }

  /**
   * Check for directory permissions.
   */
  if (!(await confirmExistAndHasPermissions(discordAppPath))) {
    error(`Vizality was unable to be uninjected due to missing permissions. Try again with elevated permissions.`);
    return promptForRestartOrExit();
  }

  /**
   * Delete the Discord app directory.
   */
  await rm(discordAppPath, { recursive: true, force: true });
  brand('-----------------------------------------------');
  brand('Vizality has been uninjected.');
  brand('-----------------------------------------------');
}

/**
 * Injects Vizality into Discord.
 * @param {path} discordAppPath Discord app path
 * @returns {Promise<void>}
 */
async function inject (discordAppPath) {
  const existsAndHasPermissions = await confirmExistAndHasPermissions(discordAppPath);
  if (existsAndHasPermissions) {
    const responses = await prompt([
      {
        type: 'list',
        name: 'action',
        message: `${`${chalk.hex(COLORS.Info)('It seems you already have a client modification in place.\n') + chalk.reset()}Would you like us to remove it and then try injecting again?`}`,
        default: 'Yes',
        choices: wrapChoicesInColor([
          'Yes',
          'No'
        ])
      }
    ]);

    if (responses.action.includes('Yes')) {
      return reinject(discordAppPath);
    }

    warn('Vizality injection canceled. Now exiting...');
    return process.exit(0);
  }

  await ensureDependencies();
  await mkdir(discordAppPath);
  Promise.all([
    /**
     * Relative to dist/setup.bundle.js
     */
    writeFile(join(discordAppPath, 'index.js'), `require('${join(__dirname, '..', 'injector').replace(RegExp(sep.repeat(2), 'g'), '/')}');`),
    writeFile(join(discordAppPath, 'package.json'), JSON.stringify({
      main: 'index.js',
      name: 'vizality'
    }, null, 2))
  ]);

  success('-----------------------------------------------');
  success(`Vizality has been successfully injected!\nPlease restart ${release} to complete the process.`);
  success('-----------------------------------------------');
}

/**
 * Reinjects Vizality into Discord.
 * @param {path} discordAppPath Discord app path
 * @returns {Promise<void>}
 */
async function reinject (discordAppPath) {
  await uninject(discordAppPath);
  await inject(discordAppPath);
}

/**
 * Starts the setup process, allowing the user to inject, uninject, or reinject Vizality.
 * @returns {Promise<void>}
 */
async function startInjectionProcess () {
  try {
    /**
     * Start out with a nifty console message of Vizality. ( ͡° ͜ʖ ͡°)
     */
    console.clear();
    console.log(
      '888     888 8888888 8888888888P        d8888 888      8888888 88888888888 Y88b   d88P\n' +
      '888     888   888         d88P        d88888 888        888       888      Y88b d88P  \n' +
      '888     888   888        d88P        d88P888 888        888       888       Y88o88P   \n' +
      'Y88b   d88P   888       d88P        d88P 888 888        888       888        Y888P    \n' +
      ' Y88b d88P    888      d88P        d88P  888 888        888       888         888     \n' +
      '  Y88o88P     888     d88P        d88P   888 888        888       888         888     \n' +
      '   Y888P      888    d88P        d8888888888 888        888       888         888     \n' +
      '    Y8P     8888888 d8888888888 d88P     888 88888888 8888888     888         888\n'
    );
    brand(
      '[===  m a k e  ----  y o u r  ----  v i s i o n  ----  a  ----  r e a l i t y  ===]\n'
    );
    info(
      '=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=\n' +
      '*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*=*\n'
    );

    /**
     * Not sure why someone would clone into System32... but let's not let them proceed to avoid any issues.
     */
    if (process.platform === 'win32') {
      if (__dirname.toLowerCase().split(sep).join(posix.sep).includes('/windows/system32')) {
        error('It seems Vizality is installed in your System 32 directory. This can create issues and bloat your Windows installation. Please move your Vizality installation to a different directory.');
        return process.exit(0);
      }
    }

    /**
     * Make sure the user is on Node v14+.
     */
    const NODE_MAJOR_VERSION = process.versions.node.split('.')[0];
    if (NODE_MAJOR_VERSION < 14) {
      error(`It looks like you're on an outdated version of Node.js. Vizality requires you to run at least Node v14 or later. You can download a newer version here: https://nodejs.org`);
      return process.exit(0);
    }

    /**
     * Start a prompt to process whether the user wants to inject or uninject and which release
     * of Discord they want to use.
     */
    const responses = await prompt([
      {
        type: 'list',
        name: 'action',
        message: `Would you like to inject, uninject, or reinject Vizality?`,
        default: 'Inject',
        choices: wrapChoicesInColor([
          'Inject',
          'Uninject',
          'Reinject'
        ])
      },
      {
        type: 'list',
        name: 'release',
        message: `Which release of Discord would you like to inject Vizality into?`,
        default: 'Discord Stable',
        when: responses => responses.action.includes('Inject'),
        choices: wrapChoicesInColor([
          'Discord Stable',
          'Discord PTB',
          'Discord Canary'
        ])
      },
      {
        type: 'list',
        name: 'release',
        message: `Which release of Discord would you like to uninject Vizality from?`,
        default: 'Discord Stable',
        when: responses => responses.action.includes('Uninject'),
        choices: wrapChoicesInColor([
          'Discord Stable',
          'Discord PTB',
          'Discord Canary'
        ])
      },
      {
        type: 'list',
        name: 'release',
        message: `Which release of Discord would you like to reinject Vizality into?`,
        default: 'Discord Stable',
        when: responses => responses.action.includes('Reinject'),
        choices: wrapChoicesInColor([
          'Discord Stable',
          'Discord PTB',
          'Discord Canary'
        ])
      }
    ]);

    ({ release, action } = responses);

    /**
     * Because we are using color insertions in our prompt, the output is not pretty.
     * Let's check for includes and assign some corresponding plaintext values.
     */
    switch (true) {
      case release.includes('Stable'):
        release = 'Discord';
        break;
      case release.includes('PTB'):
        release = 'Discord PTB';
        break;
      case release.includes('Canary'):
        release = 'Discord Canary';
        break;
    }
    switch (true) {
      case action.includes('Inject'):
        action = 'inject';
        break;
      case action.includes('Uninject'):
        action = 'uninject';
        break;
      case action.includes('Reinject'):
        action = 'reinject';
        break;
    }

    /**
     * Try to automatically find the user's Discord directory based on the specified release.
     */
    const discordAppPath = await getDiscordAppPath(release);
    if (!discordAppPath) {
      error(`${release}'s app directory couldn't be located. Please make sure the folder exists and you have the appropriate permissions.`);
      return promptManualPathCompletion(action);
    }

    /**
     * Perform the action based on the prompt.
     */
    switch (action) {
      case 'inject':
        return inject(discordAppPath);
      case 'uninject':
        return uninject(discordAppPath);
      case 'reinject':
        return reinject(discordAppPath);
    }
  } catch (err) {
    return error('Something went wrong with the initial setup process:', err);
  }
}

/**
 * Wraps each item in a choice array in a color.
 * @param {Array<string>} [choices] Choices
 * @returns {Array}
 */
function wrapChoicesInColor (choices = []) {
  const transformerChoices = [];
  choices.forEach(choice => transformerChoices.push(`${chalk.hex(COLORS.Brand)(choice) + chalk.reset()}`));
  return transformerChoices;
}
