/* @flow */
import nodeFs from 'fs';
import path from 'path';
import {promisify} from 'util';

import {default as defaultFxRunner} from 'fx-runner';
import FirefoxProfile from 'firefox-profile';
import {fs} from 'mz';
import eventToPromise from 'event-to-promise';

import isDirectory from '../util/is-directory';
import {isErrorWithCode, UsageError, WebExtError} from '../errors';
import {getPrefs as defaultPrefGetter} from './preferences';
import {getManifestId} from '../util/manifest';
import {findFreeTcpPort as defaultRemotePortFinder} from './remote';
import {createLogger} from '../util/logger';
// Import flow types
import type {
  PreferencesAppName,
  PreferencesGetterFn,
  FirefoxPreferences,
} from './preferences';
import type {ExtensionManifest} from '../util/manifest';


const log = createLogger(__filename);

const defaultAsyncFsStat: typeof fs.stat = fs.stat.bind(fs);

const defaultUserProfileCopier = FirefoxProfile.copyFromUserProfile;

export const defaultFirefoxEnv = {
  XPCOM_DEBUG_BREAK: 'stack',
  NS_TRACE_MALLOC_DISABLE_STACKS: '1',
};

// defaultRemotePortFinder types and implementation.


export type RemotePortFinderFn =
  () => Promise<number>;

// Declare the needed 'fx-runner' module flow types.

export type FirefoxRunnerParams = {|
  binary: ?string,
  profile?: string,
  'new-instance'?: boolean,
  'no-remote'?: boolean,
  'foreground'?: boolean,
  'listen': number,
  'binary-args'?: Array<string> | string,
  'env'?: {
    // This match the flowtype signature for process.env (and prevent flow
    // from complaining about differences between their type signature)
    [key: string]: string | void
  },
  'verbose'?: boolean,
|};

export interface FirefoxProcess extends events$EventEmitter {
  stderr: events$EventEmitter;
  stdout: events$EventEmitter;
  kill: Function;
}

export type FirefoxRunnerResults = {|
  process: FirefoxProcess,
  binary: string,
  args: Array<string>,
|}

export type FirefoxRunnerFn =
  (params: FirefoxRunnerParams) => Promise<FirefoxRunnerResults>;


export type FirefoxInfo = {|
  firefox: FirefoxProcess,
  debuggerPort: number,
|}

// Run command types and implementaion.

export type FirefoxRunOptions = {|
  fxRunner?: FirefoxRunnerFn,
  findRemotePort?: RemotePortFinderFn,
  firefoxBinary?: string,
  binaryArgs?: Array<string>,
  args?: Array<any>,
|};

/*
 * Runs Firefox with the given profile object and resolves a promise on exit.
 */
export async function run(
  profile: FirefoxProfile,
  {
    fxRunner = defaultFxRunner,
    findRemotePort = defaultRemotePortFinder,
    firefoxBinary, binaryArgs,
  }: FirefoxRunOptions = {}
): Promise<FirefoxInfo> {

  log.debug(`Running Firefox with profile at ${profile.path()}`);

  const remotePort = await findRemotePort();

  const results = await fxRunner({
    // if this is falsey, fxRunner tries to find the default one.
    'binary': firefoxBinary,
    'binary-args': binaryArgs,
    // This ensures a new instance of Firefox is created. It has nothing
    // to do with the devtools remote debugger.
    'no-remote': true,
    'listen': remotePort,
    'foreground': true,
    'profile': profile.path(),
    'env': {
      ...process.env,
      ...defaultFirefoxEnv,
    },
    'verbose': true,
  });

  const firefox = results.process;

  log.debug(`Executing Firefox binary: ${results.binary}`);
  log.debug(`Firefox args: ${results.args.join(' ')}`);

  firefox.on('error', (error) => {
    // TODO: show a nice error when it can't find Firefox.
    // if (/No such file/.test(err) || err.code === 'ENOENT') {
    log.error(`Firefox error: ${error}`);
    throw error;
  });

  log.info(
    'Use --verbose or open Tools > Web Developer > Browser Console ' +
    'to see logging');

  firefox.stderr.on('data', (data) => {
    log.debug(`Firefox stderr: ${data.toString().trim()}`);
  });

  firefox.stdout.on('data', (data) => {
    log.debug(`Firefox stdout: ${data.toString().trim()}`);
  });

  firefox.on('close', () => {
    log.debug('Firefox closed');
  });

  return { firefox, debuggerPort: remotePort };
}


// isDefaultProfile types and implementation.

const DEFAULT_PROFILES_NAMES = [
  'default',
  'dev-edition-default',
];

export type IsDefaultProfileFn = (
  profilePathOrName: string,
  ProfileFinder?: typeof FirefoxProfile.Finder,
  fsStat?: typeof fs.stat,
) => Promise<boolean>;

/*
 * Tests if a profile is a default Firefox profile (both as a profile name or
 * profile path).
 *
 * Returns a promise that resolves to true if the profile is one of default Firefox profile.
 */
export async function isDefaultProfile(
  profilePathOrName: string,
  ProfileFinder?: typeof FirefoxProfile.Finder = FirefoxProfile.Finder,
  fsStat?: typeof fs.stat = fs.stat,
): Promise<boolean> {
  if (DEFAULT_PROFILES_NAMES.includes(profilePathOrName)) {
    return true;
  }

  const baseProfileDir = ProfileFinder.locateUserDirectory();
  const profilesIniPath = path.join(baseProfileDir, 'profiles.ini');
  try {
    await fsStat(profilesIniPath);
  } catch (error) {
    if (isErrorWithCode('ENOENT', error)) {
      log.debug(`profiles.ini not found: ${error}`);

      // No profiles exist yet, default to false (the default profile name contains a
      // random generated component).
      return false;
    }

    // Re-throw any unexpected exception.
    throw error;
  }

  // Check for profile dir path.
  const finder = new ProfileFinder(baseProfileDir);
  const readProfiles = promisify((...args) => finder.readProfiles(...args));

  await readProfiles();

  const normalizedProfileDirPath = path.normalize(
    path.join(path.resolve(profilePathOrName), path.sep)
  );

  for (const profile of finder.profiles) {
    // Check if the profile dir path or name is one of the default profiles
    // defined in the profiles.ini file.
    if (DEFAULT_PROFILES_NAMES.includes(profile.Name) ||
        profile.Default === '1') {
      let profileFullPath;

      // Check for profile name.
      if (profile.Name === profilePathOrName) {
        return true;
      }

      // Check for profile path.
      if (profile.IsRelative === '1') {
        profileFullPath = path.join(baseProfileDir, profile.Path, path.sep);
      } else {
        profileFullPath = path.join(profile.Path, path.sep);
      }

      if (path.normalize(profileFullPath) === normalizedProfileDirPath) {
        return true;
      }
    }
  }

  // Profile directory not found.
  return false;
}

// configureProfile types and implementation.

export type ConfigureProfileOptions = {|
  app?: PreferencesAppName,
  getPrefs?: PreferencesGetterFn,
  customPrefs?: FirefoxPreferences,
|};

export type ConfigureProfileFn = (
  profile: FirefoxProfile,
  options?: ConfigureProfileOptions
) => Promise<FirefoxProfile>;

/*
 * Configures a profile with common preferences that are required to
 * activate extension development.
 *
 * Returns a promise that resolves with the original profile object.
 */
export function configureProfile(
  profile: FirefoxProfile,
  {
    app = 'firefox',
    getPrefs = defaultPrefGetter,
    customPrefs = {},
  }: ConfigureProfileOptions = {},
): Promise<FirefoxProfile> {
  // Set default preferences. Some of these are required for the add-on to
  // operate, such as disabling signatures.
  const prefs = getPrefs(app);
  Object.keys(prefs).forEach((pref) => {
    profile.setPreference(pref, prefs[pref]);
  });
  if (Object.keys(customPrefs).length > 0) {
    const customPrefsStr = JSON.stringify(customPrefs, null, 2);
    log.info(`Setting custom Firefox preferences: ${customPrefsStr}`);
    Object.keys(customPrefs).forEach((custom) => {
      profile.setPreference(custom, customPrefs[custom]);
    });
  }
  profile.updatePreferences();
  return Promise.resolve(profile);
}

export type getProfileFn = (profileName: string) => Promise<string | void>;

export type CreateProfileFinderParams = {|
  userDirectoryPath?: string,
  FxProfile?: typeof FirefoxProfile
|}

export function defaultCreateProfileFinder(
  {
    userDirectoryPath,
    FxProfile = FirefoxProfile,
  }: CreateProfileFinderParams = {}
): getProfileFn {
  const finder = new FxProfile.Finder(userDirectoryPath);
  const readProfiles = promisify((...args) => finder.readProfiles(...args));
  const getPath = promisify((...args) => finder.getPath(...args));
  return async (profileName: string): Promise<string | void> => {
    try {
      await readProfiles();
      const hasProfileName = finder.profiles.filter(
        (profileDef) => profileDef.Name === profileName).length !== 0;
      if (hasProfileName) {
        return await getPath(profileName);
      }
    } catch (error) {
      if (!isErrorWithCode('ENOENT', error)) {
        throw error;
      }
      log.warn('Unable to find Firefox profiles.ini');
    }
  };
}

// useProfile types and implementation.

export type UseProfileParams = {
  app?: PreferencesAppName,
  configureThisProfile?: ConfigureProfileFn,
  isFirefoxDefaultProfile?: IsDefaultProfileFn,
  customPrefs?: FirefoxPreferences,
  createProfileFinder?: typeof defaultCreateProfileFinder,
};

// Use the target path as a Firefox profile without cloning it

export async function useProfile(
  profilePath: string,
  {
    app,
    configureThisProfile = configureProfile,
    isFirefoxDefaultProfile = isDefaultProfile,
    customPrefs = {},
    createProfileFinder = defaultCreateProfileFinder,
  }: UseProfileParams = {},
): Promise<FirefoxProfile> {
  const isForbiddenProfile = await isFirefoxDefaultProfile(profilePath);
  if (isForbiddenProfile) {
    throw new UsageError(
      'Cannot use --keep-profile-changes on a default profile' +
      ` ("${profilePath}")` +
      ' because web-ext will make it insecure and unsuitable for daily use.' +
      '\nSee https://github.com/mozilla/web-ext/issues/1005'
    );
  }

  let destinationDirectory;
  const getProfilePath = createProfileFinder();

  const profileIsDirPath = await isDirectory(profilePath);
  if (profileIsDirPath) {
    log.debug(`Using profile directory "${profilePath}"`);
    destinationDirectory = profilePath;
  } else {
    log.debug(`Assuming ${profilePath} is a named profile`);
    destinationDirectory = await getProfilePath(profilePath);
    if (!destinationDirectory) {
      throw new UsageError(
        `The request "${profilePath}" profile name ` +
        'cannot be resolved to a profile path'
      );
    }
  }

  const profile = new FirefoxProfile({destinationDirectory});
  return await configureThisProfile(profile, {app, customPrefs});
}


// createProfile types and implementation.

export type CreateProfileParams = {
  app?: PreferencesAppName,
  configureThisProfile?: ConfigureProfileFn,
  customPrefs?: FirefoxPreferences,
};

/*
 * Creates a new temporary profile and resolves with the profile object.
 *
 * The profile will be deleted when the system process exits.
 */
export async function createProfile(
  {
    app,
    configureThisProfile = configureProfile,
    customPrefs = {},
  }: CreateProfileParams = {},
): Promise<FirefoxProfile> {
  const profile = new FirefoxProfile();
  return await configureThisProfile(profile, {app, customPrefs});
}


// copyProfile types and implementation.

export type CopyProfileOptions = {|
  app?: PreferencesAppName,
  configureThisProfile?: ConfigureProfileFn,
  copyFromUserProfile?: Function,
  customPrefs?: FirefoxPreferences,
|};

/*
 * Copies an existing Firefox profile and creates a new temporary profile.
 * The new profile will be configured with some preferences required to
 * activate extension development.
 *
 * It resolves with the new profile object.
 *
 * The temporary profile will be deleted when the system process exits.
 *
 * The existing profile can be specified as a directory path or a name of
 * one that exists in the current user's Firefox directory.
 */
export async function copyProfile(
  profileDirectory: string,
  {
    app,
    configureThisProfile = configureProfile,
    copyFromUserProfile = defaultUserProfileCopier,
    customPrefs = {},
  }: CopyProfileOptions = {},
): Promise<FirefoxProfile> {

  const copy = promisify(FirefoxProfile.copy);
  const copyByName = promisify(copyFromUserProfile);

  try {
    const dirExists = await isDirectory(profileDirectory);

    let profile;

    if (dirExists) {
      log.debug(`Copying profile directory from "${profileDirectory}"`);
      profile = await copy({profileDirectory});
    } else {
      log.debug(`Assuming ${profileDirectory} is a named profile`);
      profile = await copyByName({name: profileDirectory});
    }

    return configureThisProfile(profile, {app, customPrefs});
  } catch (error) {
    throw new WebExtError(
      `Could not copy Firefox profile from ${profileDirectory}: ${error}`);
  }
}


// installExtension types and implementation.

export type InstallExtensionParams = {|
  asProxy?: boolean,
  manifestData: ExtensionManifest,
  profile: FirefoxProfile,
  extensionPath: string,
  asyncFsStat?: typeof defaultAsyncFsStat,
|};

/*
 * Installs an extension into the given Firefox profile object.
 * Resolves when complete.
 *
 * The extension is copied into a special location and you need to turn
 * on some preferences to allow this. See extensions.autoDisableScopes in
 * ./preferences.js.
 *
 * When asProxy is true, a special proxy file will be installed. This is a
 * text file that contains the path to the extension source.
 */
export async function installExtension(
  {
    asProxy = false,
    manifestData,
    profile,
    extensionPath,
    asyncFsStat = defaultAsyncFsStat,
  }: InstallExtensionParams): Promise<any> {
  // This more or less follows
  // https://github.com/saadtazi/firefox-profile-js/blob/master/lib/firefox_profile.js#L531
  // (which is broken for web extensions).
  // TODO: maybe uplift a patch that supports web extensions instead?

  if (!profile.extensionsDir) {
    throw new WebExtError('profile.extensionsDir was unexpectedly empty');
  }

  try {
    await asyncFsStat(profile.extensionsDir);
  } catch (error) {
    if (isErrorWithCode('ENOENT', error)) {
      log.debug(`Creating extensions directory: ${profile.extensionsDir}`);
      await fs.mkdir(profile.extensionsDir);
    } else {
      throw error;
    }
  }

  const id = getManifestId(manifestData);
  if (!id) {
    throw new UsageError(
      'An explicit extension ID is required when installing to ' +
      'a profile (applications.gecko.id not found in manifest.json)');
  }

  if (asProxy) {
    log.debug(`Installing as an extension proxy; source: ${extensionPath}`);

    const isDir = await isDirectory(extensionPath);
    if (!isDir) {
      throw new WebExtError(
        'proxy install: extensionPath must be the extension source ' +
        `directory; got: ${extensionPath}`);
    }

    // Write a special extension proxy file containing the source
    // directory. See:
    // https://developer.mozilla.org/en-US/Add-ons/Setting_up_extension_development_environment#Firefox_extension_proxy_file
    const destPath = path.join(profile.extensionsDir, `${id}`);
    const writeStream = nodeFs.createWriteStream(destPath);
    writeStream.write(extensionPath);
    writeStream.end();
    return await eventToPromise(writeStream, 'close');
  } else {
    // Write the XPI file to the profile.
    const readStream = nodeFs.createReadStream(extensionPath);
    const destPath = path.join(profile.extensionsDir, `${id}.xpi`);
    const writeStream = nodeFs.createWriteStream(destPath);

    log.debug(`Installing extension from ${extensionPath} to ${destPath}`);
    readStream.pipe(writeStream);

    return await Promise.all([
      eventToPromise(readStream, 'close'),
      eventToPromise(writeStream, 'close'),
    ]);
  }
}
