import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
import { Command } from 'commander';
import downloadTarball from 'download-tarball';
import ejs from 'ejs';
import findUp from 'find-up';
import fs from 'fs';
import { boolish } from 'getenv';
import path from 'path';
import prompts from 'prompts';

import { createExampleApp } from './createExampleApp';
import { installDependencies } from './packageManager';
import {
  getLocalFolderNamePrompt,
  getLocalSubstitutionDataPrompts,
  getSlugPrompt,
  getSubstitutionDataPrompts,
} from './prompts';
import {
  formatRunCommand,
  PackageManagerName,
  resolvePackageManager,
} from './resolvePackageManager';
import { eventCreateExpoModule, getTelemetryClient, logEventAsync } from './telemetry';
import { CommandOptions, LocalSubstitutionData, SubstitutionData } from './types';
import { newStep } from './utils/ora';

const debug = require('debug')('create-expo-module:main') as typeof console.log;
const packageJson = require('../package.json');

// Opt in to using beta versions
const EXPO_BETA = boolish('EXPO_BETA', false);

// `yarn run` may change the current working dir, then we should use `INIT_CWD` env.
const CWD = process.env.INIT_CWD || process.cwd();

// Ignore some paths. Especially `package.json` as it is rendered
// from `$package.json` file instead of the original one.
const IGNORES_PATHS = [
  '.DS_Store',
  'build',
  'node_modules',
  'package.json',
  '.npmignore',
  '.gitignore',
];

// Url to the documentation on Expo Modules
const DOCS_URL = 'https://docs.expo.dev/modules';

const FYI_LOCAL_DIR = 'https://expo.fyi/expo-module-local-autolinking.md';

async function getCorrectLocalDirectory(targetOrSlug: string) {
  const packageJsonPath = await findUp('package.json', { cwd: CWD });
  if (!packageJsonPath) {
    console.log(
      chalk.red.bold(
        '⚠️ This command should be run inside your Expo project when run with the --local flag.'
      )
    );
    console.log(
      chalk.red(
        'For native modules to autolink correctly, you need to place them in the `modules` directory in the root of the project.'
      )
    );
    return null;
  }
  return path.join(packageJsonPath, '..', 'modules', targetOrSlug);
}

/**
 * The main function of the command.
 *
 * @param target Path to the directory where to create the module. Defaults to current working dir.
 * @param options An options object for `commander`.
 */
async function main(target: string | undefined, options: CommandOptions) {
  if (options.local) {
    console.log();
    console.log(
      `${chalk.gray('The local module will be created in the ')}${chalk.gray.bold.italic(
        'modules'
      )} ${chalk.gray('directory in the root of your project. Learn more: ')}${chalk.gray.bold(
        FYI_LOCAL_DIR
      )}`
    );
    console.log();
  }
  const slug = await askForPackageSlugAsync(target, options.local);
  const targetDir = options.local
    ? await getCorrectLocalDirectory(target || slug)
    : path.join(CWD, target || slug);

  if (!targetDir) {
    return;
  }
  await fs.promises.mkdir(targetDir, { recursive: true });
  await confirmTargetDirAsync(targetDir);

  options.target = targetDir;

  const data = await askForSubstitutionDataAsync(slug, options.local);

  // Make one line break between prompts and progress logs
  console.log();

  const packageManager = resolvePackageManager();
  const packagePath = options.source
    ? path.join(CWD, options.source)
    : await downloadPackageAsync(targetDir, options.local);

  await logEventAsync(eventCreateExpoModule(packageManager, options));

  await newStep('Creating the module from template files', async (step) => {
    await createModuleFromTemplate(packagePath, targetDir, data);
    step.succeed('Created the module from template files');
  });
  if (!options.local) {
    await newStep('Installing module dependencies', async (step) => {
      await installDependencies(packageManager, targetDir);
      step.succeed('Installed module dependencies');
    });
    await newStep('Compiling TypeScript files', async (step) => {
      await spawnAsync(packageManager, ['run', 'build'], {
        cwd: targetDir,
        stdio: 'ignore',
      });
      step.succeed('Compiled TypeScript files');
    });
  }

  if (!options.source) {
    // Files in the downloaded tarball are wrapped in `package` dir.
    // We should remove it after all.
    await fs.promises.rm(packagePath, { recursive: true, force: true });
  }
  if (!options.local && data.type !== 'local') {
    if (!options.withReadme) {
      await fs.promises.rm(path.join(targetDir, 'README.md'), { force: true });
    }
    if (!options.withChangelog) {
      await fs.promises.rm(path.join(targetDir, 'CHANGELOG.md'), { force: true });
    }
    if (options.example) {
      // Create "example" folder
      await createExampleApp(data, targetDir, packageManager);
    }

    await newStep('Creating an empty Git repository', async (step) => {
      try {
        const result = await createGitRepositoryAsync(targetDir);
        if (result) {
          step.succeed('Created an empty Git repository');
        } else if (result === null) {
          step.succeed('Skipped creating an empty Git repository, already within a Git repository');
        } else if (result === false) {
          step.warn(
            'Could not create an empty Git repository, see debug logs with EXPO_DEBUG=true'
          );
        }
      } catch (error: any) {
        step.fail(error.toString());
      }
    });
  }

  console.log();
  if (options.local) {
    console.log(`✅ Successfully created Expo module in ${chalk.bold.italic(`modules/${slug}`)}`);
    printFurtherLocalInstructions(slug, data.project.moduleName);
  } else {
    console.log('✅ Successfully created Expo module');
    printFurtherInstructions(targetDir, packageManager, options.example);
  }
}

/**
 * Recursively scans for the files within the directory. Returned paths are relative to the `root` path.
 */
async function getFilesAsync(root: string, dir: string | null = null): Promise<string[]> {
  const files: string[] = [];
  const baseDir = dir ? path.join(root, dir) : root;

  for (const file of await fs.promises.readdir(baseDir)) {
    const relativePath = dir ? path.join(dir, file) : file;

    if (IGNORES_PATHS.includes(relativePath) || IGNORES_PATHS.includes(file)) {
      continue;
    }

    const fullPath = path.join(baseDir, file);
    const stat = await fs.promises.lstat(fullPath);
    if (stat.isDirectory()) {
      files.push(...(await getFilesAsync(root, relativePath)));
    } else {
      files.push(relativePath);
    }
  }
  return files;
}

/**
 * Asks NPM registry for the url to the tarball.
 */
async function getNpmTarballUrl(packageName: string, version: string = 'latest'): Promise<string> {
  debug(`Using module template ${chalk.bold(packageName)}@${chalk.bold(version)}`);
  const { stdout } = await spawnAsync('npm', ['view', `${packageName}@${version}`, 'dist.tarball']);
  return stdout.trim();
}

/**
 * Gets expo SDK version major from the local package.json.
 */
async function getLocalSdkMajorVersion(): Promise<string | null> {
  const path = require.resolve('expo/package.json', { paths: [process.cwd()] });
  if (!path) {
    return null;
  }
  const { version } = require(path) ?? {};
  return version?.split('.')[0] ?? null;
}

/**
 * Selects correct version of the template based on the SDK version for local modules and EXPO_BETA flag.
 */
async function getTemplateVersion(isLocal: boolean) {
  if (EXPO_BETA) {
    return 'next';
  }
  if (!isLocal) {
    return 'latest';
  }
  try {
    const sdkVersionMajor = await getLocalSdkMajorVersion();
    return sdkVersionMajor ? `sdk-${sdkVersionMajor}` : 'latest';
  } catch {
    console.log();
    console.warn(
      chalk.yellow(
        "Couldn't determine the SDK version from the local project, using `latest` as the template version."
      )
    );
    return 'latest';
  }
}

/**
 * Downloads the template from NPM registry.
 */
async function downloadPackageAsync(targetDir: string, isLocal = false): Promise<string> {
  return await newStep('Downloading module template from npm', async (step) => {
    const templateVersion = await getTemplateVersion(isLocal);
    const packageName = isLocal ? 'expo-module-template-local' : 'expo-module-template';

    try {
      await downloadTarball({
        url: await getNpmTarballUrl(packageName, templateVersion),
        dir: targetDir,
      });
    } catch {
      console.log();
      console.warn(
        chalk.yellow(
          "Couldn't download the versioned template from npm, falling back to the latest version."
        )
      );
      await downloadTarball({
        url: await getNpmTarballUrl(packageName, 'latest'),
        dir: targetDir,
      });
    }

    step.succeed('Downloaded module template from npm registry.');

    return path.join(targetDir, 'package');
  });
}

function handleSuffix(name: string, suffix: string): string {
  if (name.endsWith(suffix)) {
    return name;
  }
  return `${name}${suffix}`;
}

/**
 * Creates the module based on the `ejs` template (e.g. `expo-module-template` package).
 */
async function createModuleFromTemplate(
  templatePath: string,
  targetPath: string,
  data: SubstitutionData | LocalSubstitutionData
) {
  const files = await getFilesAsync(templatePath);

  // Iterate through all template files.
  for (const file of files) {
    const renderedRelativePath = ejs.render(file.replace(/^\$/, ''), data, {
      openDelimiter: '{',
      closeDelimiter: '}',
      escape: (value: string) => value.replace(/\./g, path.sep),
    });
    const fromPath = path.join(templatePath, file);
    const toPath = path.join(targetPath, renderedRelativePath);
    const template = await fs.promises.readFile(fromPath, 'utf8');
    const renderedContent = ejs.render(template, data);

    if (!fs.existsSync(path.dirname(toPath))) {
      await fs.promises.mkdir(path.dirname(toPath), { recursive: true });
    }
    await fs.promises.writeFile(toPath, renderedContent, 'utf8');
  }
}

async function createGitRepositoryAsync(targetDir: string) {
  // Check if we are inside a git repository already
  try {
    await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], {
      stdio: 'ignore',
      cwd: targetDir,
    });
    debug(chalk.dim('New project is already inside of a Git repository, skipping `git init`.'));
    return null;
  } catch (e: any) {
    if (e.errno === 'ENOENT') {
      debug(chalk.dim('Unable to initialize Git repo. `git` not in $PATH.'));
      return false;
    }
  }

  // Create a new git repository
  await spawnAsync('git', ['init'], { stdio: 'ignore', cwd: targetDir });
  await spawnAsync('git', ['add', '-A'], { stdio: 'ignore', cwd: targetDir });

  const commitMsg = `Initial commit\n\nGenerated by ${packageJson.name} ${packageJson.version}.`;
  await spawnAsync('git', ['commit', '-m', commitMsg], {
    stdio: 'ignore',
    cwd: targetDir,
  });

  debug(chalk.dim('Initialized a Git repository.'));
  return true;
}

/**
 * Asks the user for the package slug (npm package name).
 */
async function askForPackageSlugAsync(customTargetPath?: string, isLocal = false): Promise<string> {
  const { slug } = await prompts(
    (isLocal ? getLocalFolderNamePrompt : getSlugPrompt)(customTargetPath),
    {
      onCancel: () => process.exit(0),
    }
  );
  return slug;
}

/**
 * Asks the user for some data necessary to render the template.
 * Some values may already be provided by command options, the prompt is skipped in that case.
 */
async function askForSubstitutionDataAsync(
  slug: string,
  isLocal = false
): Promise<SubstitutionData | LocalSubstitutionData> {
  const promptQueries = await (
    isLocal ? getLocalSubstitutionDataPrompts : getSubstitutionDataPrompts
  )(slug);

  // Stop the process when the user cancels/exits the prompt.
  const onCancel = () => {
    process.exit(0);
  };

  const {
    name,
    description,
    package: projectPackage,
    authorName,
    authorEmail,
    authorUrl,
    repo,
  } = await prompts(promptQueries, { onCancel });

  if (isLocal) {
    return {
      project: {
        slug,
        name,
        package: projectPackage,
        moduleName: handleSuffix(name, 'Module'),
        viewName: handleSuffix(name, 'View'),
      },
      type: 'local',
    };
  }

  return {
    project: {
      slug,
      name,
      version: '0.1.0',
      description,
      package: projectPackage,
      moduleName: handleSuffix(name, 'Module'),
      viewName: handleSuffix(name, 'View'),
    },
    author: `${authorName} <${authorEmail}> (${authorUrl})`,
    license: 'MIT',
    repo,
    type: 'remote',
  };
}

/**
 * Checks whether the target directory is empty and if not, asks the user to confirm if he wants to continue.
 */
async function confirmTargetDirAsync(targetDir: string): Promise<void> {
  const files = await fs.promises.readdir(targetDir);
  if (files.length === 0) {
    return;
  }
  const { shouldContinue } = await prompts(
    {
      type: 'confirm',
      name: 'shouldContinue',
      message: `The target directory ${chalk.magenta(
        targetDir
      )} is not empty, do you want to continue anyway?`,
      initial: true,
    },
    {
      onCancel: () => false,
    }
  );
  if (!shouldContinue) {
    process.exit(0);
  }
}

/**
 * Prints how the user can follow up once the script finishes creating the module.
 */
function printFurtherInstructions(
  targetDir: string,
  packageManager: PackageManagerName,
  includesExample: boolean
) {
  if (includesExample) {
    const commands = [
      `cd ${path.relative(CWD, targetDir)}`,
      formatRunCommand(packageManager, 'open:ios'),
      formatRunCommand(packageManager, 'open:android'),
    ];

    console.log();
    console.log(
      'To start developing your module, navigate to the directory and open Android and iOS projects of the example app'
    );
    commands.forEach((command) => console.log(chalk.gray('>'), chalk.bold(command)));
    console.log();
  }
  console.log(`Learn more on Expo Modules APIs: ${chalk.blue.bold(DOCS_URL)}`);
}

function printFurtherLocalInstructions(slug: string, name: string) {
  console.log();
  console.log(`You can now import this module inside your application.`);
  console.log(`For example, you can add this line to your App.tsx or App.js file:`);
  console.log(`${chalk.gray.italic(`import ${name} from './modules/${slug}';`)}`);
  console.log();
  console.log(`Learn more on Expo Modules APIs: ${chalk.blue.bold(DOCS_URL)}`);
  console.log(
    chalk.yellow(
      `Remember to re-build your native app (for example, with ${chalk.bold('npx expo run')}) when you make changes to the module. Native code changes are not reloaded with Fast Refresh.`
    )
  );
}

const program = new Command();

program
  .name(packageJson.name)
  .version(packageJson.version)
  .description(packageJson.description)
  .arguments('[path]')
  .option(
    '-s, --source <source_dir>',
    'Local path to the template. By default it downloads `expo-module-template` from NPM.'
  )
  .option('--with-readme', 'Whether to include README.md file.', false)
  .option('--with-changelog', 'Whether to include CHANGELOG.md file.', false)
  .option('--no-example', 'Whether to skip creating the example app.', false)
  .option(
    '--local',
    'Whether to create a local module in the current project, skipping installing node_modules and creating the example directory.',
    false
  )
  .action(main);

program
  .hook('postAction', async () => {
    await getTelemetryClient().flush?.();
  })
  .parse(process.argv);
