// @ts-ignore - clack is ESM and TS complains about that. It works though
import * as clack from '@clack/prompts';
import axios from 'axios';
import chalk from 'chalk';
import * as childProcess from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { setInterval } from 'timers';
import { URL } from 'url';
import { promisify } from 'util';
import * as Sentry from '@sentry/node';
import { windowedSelect } from './vendor/clack-custom-select';

const SAAS_URL = 'https://sentry.io/';

interface WizardProjectData {
  apiKeys: {
    token: string;
  };
  projects: SentryProjectData[];
}

export type PackageDotJson = {
  scripts?: Record<string, string>;
  dependencies?: Record<string, string>;
  devDependencies?: Record<string, string>;
};

export interface SentryProjectData {
  id: string;
  slug: string;
  name: string;
  organization: {
    slug: string;
  };
  keys: [{ dsn: { public: string } }];
}

export async function abort(message?: string, status?: number): Promise<never> {
  clack.outro(message ?? 'Wizard setup cancelled.');
  const sentryHub = Sentry.getCurrentHub();
  const sentryTransaction = sentryHub.getScope().getTransaction();
  sentryTransaction?.setStatus('aborted');
  sentryTransaction?.finish();
  const sentrySession = sentryHub.getScope().getSession();
  if (sentrySession) {
    sentrySession.status = status === 0 ? 'abnormal' : 'crashed';
    sentryHub.captureSession(true);
  }
  await Sentry.flush(3000);
  return process.exit(status ?? 1);
}

export async function abortIfCancelled<T>(
  input: T | Promise<T>,
): Promise<Exclude<T, symbol>> {
  if (clack.isCancel(await input)) {
    clack.cancel('Wizard setup cancelled.');
    const sentryHub = Sentry.getCurrentHub();
    const sentryTransaction = sentryHub.getScope().getTransaction();
    sentryTransaction?.setStatus('cancelled');
    sentryTransaction?.finish();
    sentryHub.captureSession(true);
    await Sentry.flush(3000);
    process.exit(0);
  } else {
    return input as Exclude<T, symbol>;
  }
}

export function printWelcome(options: {
  wizardName: string;
  promoCode?: string;
  message?: string;
}): void {
  let wizardPackage: { version?: string } = {};

  try {
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
    wizardPackage = require(path.join(
      path.dirname(require.resolve('@sentry/wizard')),
      '..',
      'package.json',
    ));
  } catch {
    // We don't need to have this
  }

  // eslint-disable-next-line no-console
  console.log('');
  clack.intro(chalk.inverse(` ${options.wizardName} `));

  let welcomeText =
    options.message ||
    'This Wizard will help you to set up Sentry for your application.\nThank you for using Sentry :)';

  if (options.promoCode) {
    welcomeText += `\n\nUsing promo-code: ${options.promoCode}`;
  }

  if (wizardPackage.version) {
    welcomeText += `\n\nVersion: ${wizardPackage.version}`;
  }

  clack.note(welcomeText);
}

export async function confirmContinueEvenThoughNoGitRepo(): Promise<void> {
  try {
    childProcess.execSync('git rev-parse --is-inside-work-tree', {
      stdio: 'ignore',
    });
  } catch {
    const continueWithoutGit = await abortIfCancelled(
      clack.confirm({
        message:
          'You are not inside a git repository. The wizard will create and update files. Do you still want to continue?',
      }),
    );

    Sentry.setTag('continue-without-git', continueWithoutGit);

    if (!continueWithoutGit) {
      await abort(undefined, 0);
    }
  }
}

export async function askForWizardLogin(options: {
  url: string;
  promoCode?: string;
  platform?: 'javascript-nextjs' | 'javascript-sveltekit';
}): Promise<WizardProjectData> {
  Sentry.setTag('has-promo-code', !!options.promoCode);

  let hasSentryAccount = await clack.confirm({
    message: 'Do you already have a Sentry account?',
  });

  hasSentryAccount = await abortIfCancelled(hasSentryAccount);

  Sentry.setTag('already-has-sentry-account', hasSentryAccount);

  let wizardHash: string;
  try {
    wizardHash = (
      await axios.get<{ hash: string }>(`${options.url}api/0/wizard/`)
    ).data.hash;
  } catch {
    if (options.url !== SAAS_URL) {
      clack.log.error('Loading Wizard failed. Did you provide the right URL?');
      await abort(
        chalk.red(
          'Please check your configuration and try again.\n\n   Let us know if you think this is an issue with the wizard or Sentry: https://github.com/getsentry/sentry-wizard/issues',
        ),
      );
    } else {
      clack.log.error('Loading Wizard failed.');
      await abort(
        chalk.red(
          'Please try again in a few minutes and let us know if this issue persists: https://github.com/getsentry/sentry-wizard/issues',
        ),
      );
    }
  }

  const loginUrl = new URL(
    `${options.url}account/settings/wizard/${wizardHash!}/`,
  );

  if (!hasSentryAccount) {
    loginUrl.searchParams.set('signup', '1');
    if (options.platform) {
      loginUrl.searchParams.set('project_platform', options.platform);
    }
  }

  if (options.promoCode) {
    loginUrl.searchParams.set('code', options.promoCode);
  }

  clack.log.info(
    `${chalk.bold(
      `Please open the following link in your browser to ${
        hasSentryAccount ? 'log' : 'sign'
      } into Sentry:`,
    )}\n\n${chalk.cyan(loginUrl.toString())}`,
  );

  const loginSpinner = clack.spinner();

  loginSpinner.start(
    'Waiting for you to click the link above 👆. Take your time.',
  );

  const data = await new Promise<WizardProjectData>((resolve) => {
    const pollingInterval = setInterval(() => {
      axios
        .get<WizardProjectData>(`${options.url}api/0/wizard/${wizardHash}/`)
        .then((result) => {
          resolve(result.data);
          clearTimeout(timeout);
          clearInterval(pollingInterval);
          void axios.delete(`${options.url}api/0/wizard/${wizardHash}/`);
        })
        .catch(() => {
          // noop - just try again
        });
    }, 500);

    const timeout = setTimeout(() => {
      clearInterval(pollingInterval);
      loginSpinner.stop(
        'Login timed out. No worries - it happens to the best of us.',
      );

      Sentry.setTag('opened-wizard-link', false);
      void abort('Please restart the Wizard and log in to complete the setup.');
    }, 180_000);
  });

  loginSpinner.stop('Login complete.');
  Sentry.setTag('opened-wizard-link', true);

  return data;
}

export async function askForProjectSelection(
  projects: SentryProjectData[],
): Promise<SentryProjectData> {
  const selection: SentryProjectData | symbol = await abortIfCancelled(
    windowedSelect({
      maxItems: 12,
      message: 'Select your Sentry project.',
      options: projects.map((project) => {
        return {
          value: project,
          label: `${project.organization.slug}/${project.slug}`,
        };
      }),
    }),
  );

  Sentry.setTag('project', selection.slug);
  Sentry.setUser({ id: selection.organization.slug });

  return selection;
}

export async function installPackage({
  packageName,
  alreadyInstalled,
}: {
  packageName: string;
  alreadyInstalled: boolean;
}): Promise<void> {
  if (alreadyInstalled) {
    const shouldUpdatePackage = await abortIfCancelled(
      clack.confirm({
        message: `The ${chalk.bold.cyan(
          packageName,
        )} package is already installed. Do you want to update it to the latest version?`,
      }),
    );

    if (!shouldUpdatePackage) {
      return;
    }
  }

  const sdkInstallSpinner = clack.spinner();

  const packageManager = await getPackageManager();

  sdkInstallSpinner.start(
    `${alreadyInstalled ? 'Updating' : 'Installing'} ${chalk.bold.cyan(
      packageName,
    )} with ${chalk.bold(packageManager)}.`,
  );

  try {
    if (packageManager === 'yarn') {
      await promisify(childProcess.exec)(`yarn add ${packageName}@latest`);
    } else if (packageManager === 'pnpm') {
      await promisify(childProcess.exec)(`pnpm add ${packageName}@latest`);
    } else if (packageManager === 'npm') {
      await promisify(childProcess.exec)(`npm install ${packageName}@latest`);
    }
  } catch (e) {
    sdkInstallSpinner.stop('Installation failed.');
    clack.log.error(
      `${chalk.red(
        'Encountered the following error during installation:',
        // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
      )}\n\n${e}\n\n${chalk.dim(
        'If you think this issue is caused by the Sentry wizard, let us know here:\nhttps://github.com/getsentry/sentry-wizard/issues',
      )}`,
    );
    await abort();
  }

  sdkInstallSpinner.stop(
    `${alreadyInstalled ? 'Updated' : 'Installed'} ${chalk.bold.cyan(
      packageName,
    )} with ${chalk.bold(packageManager)}.`,
  );
}

export async function askForSelfHosted(): Promise<{
  url: string;
  selfHosted: boolean;
}> {
  const choice: 'saas' | 'self-hosted' | symbol = await abortIfCancelled(
    clack.select({
      message: 'Are you using Sentry SaaS or self-hosted Sentry?',
      options: [
        { value: 'saas', label: 'Sentry SaaS (sentry.io)' },
        { value: 'self-hosted', label: 'Self-hosted/on-premise/single-tenant' },
      ],
    }),
  );

  if (choice === 'saas') {
    Sentry.setTag('url', SAAS_URL);
    Sentry.setTag('self-hosted', false);
    return { url: SAAS_URL, selfHosted: false };
  }

  let validUrl: string | undefined;
  while (validUrl === undefined) {
    const url = await abortIfCancelled(
      clack.text({
        message: 'Please enter the URL of your self-hosted Sentry instance.',
        placeholder: 'https://sentry.io/',
      }),
    );

    try {
      validUrl = new URL(url).toString();

      // We assume everywhere else that the URL ends in a slash
      if (!validUrl.endsWith('/')) {
        validUrl += '/';
      }
    } catch {
      clack.log.error(
        'Please enter a valid URL. (It should look something like "http://sentry.mydomain.com/")',
      );
    }
  }

  Sentry.setTag('url', validUrl);
  Sentry.setTag('self-hosted', true);
  return { url: validUrl, selfHosted: true };
}

export async function addSentryCliRc(authToken: string): Promise<void> {
  const clircExists = fs.existsSync(path.join(process.cwd(), '.sentryclirc'));
  if (clircExists) {
    const clircContents = fs.readFileSync(
      path.join(process.cwd(), '.sentryclirc'),
      'utf8',
    );

    const likelyAlreadyHasAuthToken = !!(
      clircContents.includes('[auth]') && clircContents.match(/token=./g)
    );

    if (likelyAlreadyHasAuthToken) {
      clack.log.warn(
        `${chalk.bold(
          '.sentryclirc',
        )} already has auth token. Will not add one.`,
      );
    } else {
      try {
        await fs.promises.writeFile(
          path.join(process.cwd(), '.sentryclirc'),
          `${clircContents}\n[auth]\ntoken=${authToken}\n`,
          { encoding: 'utf8', flag: 'w' },
        );
        clack.log.success(
          `Added auth token to ${chalk.bold(
            '.sentryclirc',
          )} for you to test uploading source maps locally.`,
        );
      } catch {
        clack.log.warning(
          `Failed to add auth token to ${chalk.bold(
            '.sentryclirc',
          )}. Uploading source maps during build will likely not work locally.`,
        );
      }
    }
  } else {
    try {
      await fs.promises.writeFile(
        path.join(process.cwd(), '.sentryclirc'),
        `[auth]\ntoken=${authToken}\n`,
        { encoding: 'utf8', flag: 'w' },
      );
      clack.log.success(
        `Created ${chalk.bold(
          '.sentryclirc',
        )} with auth token for you to test uploading source maps locally.`,
      );
    } catch {
      clack.log.warning(
        `Failed to create ${chalk.bold(
          '.sentryclirc',
        )} with auth token. Uploading source maps during build will likely not work locally.`,
      );
    }
  }

  await addAuthTokenFileToGitIgnore('.sentryclirc');
}

export async function addDotEnvSentryBuildPluginFile(
  authToken: string,
): Promise<void> {
  const DOT_ENV_FILE = '.env.sentry-build-plugin';

  const envVarContent = `# DO NOT commit this file to your repository!
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
# It's used for authentication when uploading source maps.
# You can also set this env variable in your own \`.env\` files and remove this file.
SENTRY_AUTH_TOKEN="${authToken}"
`;

  const dotEnvFilePath = path.join(process.cwd(), DOT_ENV_FILE);
  const dotEnvFileExists = fs.existsSync(dotEnvFilePath);

  if (dotEnvFileExists) {
    const dotEnvFileContent = fs.readFileSync(dotEnvFilePath, 'utf8');

    const hasAuthToken = !!dotEnvFileContent.match(
      /^\s*SENTRY_AUTH_TOKEN\s*=/g,
    );

    if (hasAuthToken) {
      clack.log.warn(
        `${chalk.bold(DOT_ENV_FILE)} already has auth token. Will not add one.`,
      );
    } else {
      try {
        await fs.promises.writeFile(
          dotEnvFilePath,
          `${dotEnvFileContent}\n${envVarContent}`,
          {
            encoding: 'utf8',
            flag: 'w',
          },
        );
        clack.log.success(`Added auth token to ${chalk.bold(DOT_ENV_FILE)}`);
      } catch {
        clack.log.warning(
          `Failed to add auth token to ${chalk.bold(
            DOT_ENV_FILE,
          )}. Uploading source maps during build will likely not work locally.`,
        );
      }
    }
  } else {
    try {
      await fs.promises.writeFile(dotEnvFilePath, envVarContent, {
        encoding: 'utf8',
        flag: 'w',
      });
      clack.log.success(
        `Created ${chalk.bold(
          DOT_ENV_FILE,
        )} with auth token for you to test source map uploading locally.`,
      );
    } catch {
      clack.log.warning(
        `Failed to create ${chalk.bold(
          DOT_ENV_FILE,
        )} with auth token. Uploading source maps during build will likely not work locally.`,
      );
    }
  }

  await addAuthTokenFileToGitIgnore(DOT_ENV_FILE);
}

async function addAuthTokenFileToGitIgnore(filename: string): Promise<void> {
  //TODO: Add a check to see if the file is already ignored in .gitignore
  try {
    await fs.promises.appendFile(
      path.join(process.cwd(), '.gitignore'),
      `\n# Sentry Auth Token\n${filename}\n`,
      { encoding: 'utf8' },
    );
    clack.log.success(
      `Added ${chalk.bold(filename)} to ${chalk.bold('.gitignore')}.`,
    );
  } catch {
    clack.log.error(
      `Failed adding ${chalk.bold(filename)} to ${chalk.bold(
        '.gitignore',
      )}. Please add it manually!`,
    );
  }
}

export async function ensurePackageIsInstalled(
  packageJson: PackageDotJson,
  packageId: string,
  packageName: string,
) {
  if (!hasPackageInstalled(packageId, packageJson)) {
    const continueWithoutPackage = await abortIfCancelled(
      clack.confirm({
        message: `${packageName} does not seem to be installed. Do you still want to continue?`,
        initialValue: false,
      }),
    );

    if (!continueWithoutPackage) {
      await abort(undefined, 0);
    }
  }
}

export async function getPackageDotJson(): Promise<PackageDotJson> {
  const packageJsonFileContents = await fs.promises
    .readFile(path.join(process.cwd(), 'package.json'), 'utf8')
    .catch(() => {
      clack.log.error(
        'Could not find package.json. Make sure to run the wizard in the root of your app!',
      );
      return abort();
    });

  let packageJson: PackageDotJson | undefined = undefined;

  try {
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
    packageJson = JSON.parse(packageJsonFileContents);
  } catch {
    clack.log.error(
      'Unable to parse your package.json. Make sure it has a valid format!',
    );

    await abort();
  }

  return packageJson || {};
}

export function hasPackageInstalled(
  packageName: string,
  packageJson: PackageDotJson,
): boolean {
  return (
    !!packageJson?.dependencies?.[packageName] ||
    !!packageJson?.devDependencies?.[packageName]
  );
}

async function getPackageManager(): Promise<string> {
  let detectedPackageManager;
  if (fs.existsSync(path.join(process.cwd(), 'yarn.lock'))) {
    detectedPackageManager = 'yarn';
  } else if (fs.existsSync(path.join(process.cwd(), 'package-lock.json'))) {
    detectedPackageManager = 'npm';
  } else if (fs.existsSync(path.join(process.cwd(), 'pnpm-lock.yaml'))) {
    detectedPackageManager = 'pnpm';
  }

  if (detectedPackageManager) {
    return detectedPackageManager;
  }

  const selectedPackageManager: string | symbol = await abortIfCancelled(
    clack.select({
      message: 'Please select your package manager.',
      options: [
        { value: 'npm', label: 'Npm' },
        { value: 'yarn', label: 'Yarn' },
        { value: 'pnpm', label: 'Pnpm' },
      ],
    }),
  );

  Sentry.setTag('package-manager', selectedPackageManager);

  return selectedPackageManager;
}
