#!/usr/bin/env node

import { PackageJSON } from '@0x/types';
import { logUtils } from '@0x/utils';
import { spawn } from 'child_process';
import * as promisify from 'es6-promisify';
import * as fs from 'fs';
import * as _ from 'lodash';
import * as moment from 'moment';
import * as path from 'path';
import { exec as execAsync, spawn as spawnAsync } from 'promisify-child-process';
import * as prompt from 'prompt';
import semver = require('semver');
import semverSort = require('semver-sort');

import { constants } from './constants';
import { Package, PackageToNextVersion, VersionChangelog } from './types';
import { changelogUtils } from './utils/changelog_utils';
import { configs } from './utils/configs';
import { alertDiscordAsync } from './utils/discord';
import { DocGenerateUtils } from './utils/doc_generate_utils';
import { publishReleaseNotesAsync } from './utils/github_release_utils';
import { utils } from './utils/utils';

const TODAYS_TIMESTAMP = moment().unix();

async function confirmAsync(message: string): Promise<void> {
    prompt.start();
    const result = await promisify(prompt.get)([message]);
    const didConfirm = result[message] === 'y';
    if (!didConfirm) {
        utils.log('Publish process aborted.');
        process.exit(0);
    }
}

(async () => {
    // Fetch public, updated Lerna packages
    const shouldIncludePrivate = true;
    const allPackagesToPublish = await utils.getPackagesToPublishAsync(shouldIncludePrivate);
    if (_.isEmpty(allPackagesToPublish)) {
        utils.log('No packages need publishing');
        process.exit(0);
    }
    const packagesWithDocs = getPackagesWithDocs(allPackagesToPublish);

    if (!configs.IS_LOCAL_PUBLISH) {
        await confirmAsync(
            'THIS IS NOT A TEST PUBLISH! You are about to publish one or more packages to npm. Are you sure you want to continue? (y/n)',
        );
    }

    // Update CHANGELOGs
    const updatedPublicPackages = _.filter(allPackagesToPublish, pkg => !pkg.packageJson.private);
    const updatedPublicPackageNames = _.map(updatedPublicPackages, pkg => pkg.packageJson.name);
    utils.log(`Will update CHANGELOGs and publish: \n${updatedPublicPackageNames.join('\n')}\n`);
    const packageToNextVersion = await updateChangeLogsAsync(updatedPublicPackages);

    const updatedPrivatePackages = _.filter(allPackagesToPublish, pkg => pkg.packageJson.private);
    _.each(updatedPrivatePackages, pkg => {
        const currentVersion = pkg.packageJson.version;
        const packageName = pkg.packageJson.name;
        const nextPatchVersionIfValid = semver.inc(currentVersion, 'patch');
        if (nextPatchVersionIfValid !== null) {
            packageToNextVersion[packageName] = nextPatchVersionIfValid;
        } else {
            throw new Error(`Encountered invalid semver version: ${currentVersion} for package: ${packageName}`);
        }
    });

    // Push changelogs changes and markdown docs to Github
    if (!configs.IS_LOCAL_PUBLISH) {
        // Generate markdown docs for packages
        await generateDocMDAsync(packagesWithDocs);
        await pushChangelogsAndMDDocsToGithubAsync();
    }

    // Call LernaPublish
    utils.log('Version updates to apply:');
    _.each(packageToNextVersion, (versionChange: string, packageName: string) => {
        utils.log(`${packageName} -> ${versionChange}`);
    });
    utils.log(`Calling 'lerna publish'...`);
    await lernaPublishAsync(packageToNextVersion);

    const isDryRun = configs.IS_LOCAL_PUBLISH;
    if (!isDryRun) {
        // Publish docker images to DockerHub
        await publishImagesToDockerHubAsync(allPackagesToPublish);

        // Upload markdown docs to S3 bucket
        await execAsync(`yarn upload_md_docs`, { cwd: constants.monorepoRootPath });
    }

    const releaseNotes = await publishReleaseNotesAsync(updatedPublicPackages, isDryRun);
    utils.log('Published release notes');

    if (!isDryRun && releaseNotes) {
        try {
            await alertDiscordAsync(releaseNotes);
        } catch (e) {
            utils.log("Publish successful, but couldn't auto-alert discord (", e.message, '), Please alert manually.');
        }
    }
    process.exit(0);
})().catch(err => {
    utils.log(err);
    process.exit(1);
});

async function publishImagesToDockerHubAsync(allUpdatedPackages: Package[]): Promise<void> {
    for (const pkg of allUpdatedPackages) {
        const packageJSON = pkg.packageJson;
        const shouldPublishDockerImage =
            packageJSON.config !== undefined &&
            packageJSON.config.postpublish !== undefined &&
            packageJSON.config.postpublish.dockerHubRepo !== undefined;
        if (!shouldPublishDockerImage) {
            continue;
        }
        const dockerHubRepo = _.get(packageJSON, 'config.postpublish.dockerHubRepo');
        const pkgName = pkg.packageJson.name;
        const packageDirName = _.startsWith(pkgName, '@0x/') ? pkgName.split('/')[1] : pkgName;

        // Build the Docker image
        logUtils.log(`Building '${dockerHubRepo}' docker image...`);
        await spawnAsync('docker', ['build', '-t', dockerHubRepo, '.'], {
            cwd: `${constants.monorepoRootPath}/packages/${packageDirName}`,
        });

        // Tag the docker image with the latest version
        const version = pkg.packageJson.version;
        logUtils.log(`Tagging '${dockerHubRepo}' docker image with version ${version}...`);
        await execAsync(`docker tag ${dockerHubRepo} ${configs.DOCKER_HUB_ORG}/${dockerHubRepo}:${version}`);
        await execAsync(`docker tag ${dockerHubRepo} ${configs.DOCKER_HUB_ORG}/${dockerHubRepo}:latest`);

        // Publish to DockerHub
        logUtils.log(`Pushing '${dockerHubRepo}' docker image to DockerHub...`);
        await execAsync(`docker push ${configs.DOCKER_HUB_ORG}/${dockerHubRepo}:${version}`);
        await execAsync(`docker push ${configs.DOCKER_HUB_ORG}/${dockerHubRepo}:latest`);
    }
}

function getPackagesWithDocs(allUpdatedPackages: Package[]): Package[] {
    const rootPackageJsonPath = `${constants.monorepoRootPath}/package.json`;
    const rootPackageJSON = utils.readJSONFile<PackageJSON>(rootPackageJsonPath);
    const packagesWithDocPagesStringIfExist = _.get(rootPackageJSON, 'config.packagesWithDocPages', undefined);
    if (packagesWithDocPagesStringIfExist === undefined) {
        return []; // None to generate & publish
    }
    const packagesWithDocPages = packagesWithDocPagesStringIfExist.split(' ');
    const updatedPackagesWithDocPages: Package[] = [];
    _.each(allUpdatedPackages, pkg => {
        const nameWithoutPrefix = pkg.packageJson.name.replace('@0x/', '');
        if (_.includes(packagesWithDocPages, nameWithoutPrefix)) {
            updatedPackagesWithDocPages.push(pkg);
        }
    });
    return updatedPackagesWithDocPages;
}

async function generateDocMDAsync(packagesWithDocs: Package[]): Promise<void> {
    for (const pkg of packagesWithDocs) {
        const nameWithoutPrefix = pkg.packageJson.name.replace('@0x/', '');
        const docGenerateAndUploadUtils = new DocGenerateUtils(nameWithoutPrefix);
        await docGenerateAndUploadUtils.generateAndUploadDocsAsync();
    }
}

async function pushChangelogsAndMDDocsToGithubAsync(): Promise<void> {
    await execAsync(`git add . --all`, { cwd: constants.monorepoRootPath });
    await execAsync(`git commit -m "Updated CHANGELOGS & MD docs"`, { cwd: constants.monorepoRootPath });
    await execAsync(`git push`, { cwd: constants.monorepoRootPath });
    utils.log(`Pushed CHANGELOG updates & updated MD docs to Github`);
}

async function updateChangeLogsAsync(updatedPublicPackages: Package[]): Promise<PackageToNextVersion> {
    const packageToNextVersion: PackageToNextVersion = {};
    for (const pkg of updatedPublicPackages) {
        const packageName = pkg.packageJson.name;
        let changelog = changelogUtils.getChangelogOrCreateIfMissing(packageName, pkg.location);

        const currentVersion = pkg.packageJson.version;
        const shouldAddNewEntry = changelogUtils.shouldAddNewChangelogEntry(
            pkg.packageJson.name,
            currentVersion,
            changelog,
        );
        if (shouldAddNewEntry) {
            // Create a new entry for a patch version with generic changelog entry.
            const nextPatchVersionIfValid = semver.inc(currentVersion, 'patch');
            if (nextPatchVersionIfValid === null) {
                throw new Error(`Encountered invalid semver version: ${currentVersion} for package: ${packageName}`);
            }
            const newChangelogEntry: VersionChangelog = {
                timestamp: TODAYS_TIMESTAMP,
                version: nextPatchVersionIfValid,
                changes: [
                    {
                        note: constants.dependenciesUpdatedMessage,
                    },
                ],
            };
            changelog = [newChangelogEntry, ...changelog];
            packageToNextVersion[packageName] = nextPatchVersionIfValid;
        } else {
            // Update existing entry with timestamp
            const lastEntry = changelog[0];
            if (lastEntry.timestamp === undefined) {
                lastEntry.timestamp = TODAYS_TIMESTAMP;
            }
            // Check version number is correct.
            const proposedNextVersion = lastEntry.version;
            lastEntry.version = updateVersionNumberIfNeeded(currentVersion, proposedNextVersion);
            changelog[0] = lastEntry;
            packageToNextVersion[packageName] = lastEntry.version;
        }

        // Save updated CHANGELOG.json
        await changelogUtils.writeChangelogJsonFileAsync(pkg.location, changelog);
        utils.log(`${packageName}: Updated CHANGELOG.json`);
        // Generate updated CHANGELOG.md
        const changelogMd = changelogUtils.generateChangelogMd(changelog);
        await changelogUtils.writeChangelogMdFileAsync(pkg.location, changelogMd);
        utils.log(`${packageName}: Updated CHANGELOG.md`);
    }

    return packageToNextVersion;
}

async function lernaPublishAsync(packageToNextVersion: { [name: string]: string }): Promise<void> {
    return new Promise<void>((resolve, reject) => {
        const packageVersionString = _.map(packageToNextVersion, (nextVersion: string, packageName: string) => {
            return `${packageName}|${nextVersion}`;
        }).join(',');
        // HACK(fabio): Previously we would pass the packageVersionString directly to `lerna publish` using the
        // `--cdVersions` flag. Since we now need to use `spawn` instead of `exec` when calling Lerna, passing
        // them as a string arg is causing `spawn` to error with `ENAMETOOLONG`. In order to shorten the args
        // passed to `spawn` we now write the new version to a file and pass the filepath to the `cdVersions` arg.
        const cdVersionsFilepath = path.join(__dirname, 'cd_versions.txt');
        fs.writeFileSync(cdVersionsFilepath, packageVersionString);
        const lernaPublishCmd = `node`;
        const lernaPublishArgs = [
            `${constants.lernaExecutable}`,
            'publish',
            `--cdVersions=${cdVersionsFilepath}`,
            `--registry=${configs.NPM_REGISTRY_URL}`,
            `--yes`,
        ];
        if (configs.IS_LOCAL_PUBLISH) {
            lernaPublishArgs.push('--no-git-tag-version');
            lernaPublishArgs.push('--no-push');
        }
        if (configs.DIST_TAG !== '') {
            lernaPublishArgs.push(`--dist-tag=${configs.DIST_TAG}`);
        }
        utils.log('Lerna is publishing...');
        try {
            const child = spawn(lernaPublishCmd, lernaPublishArgs, {
                cwd: constants.monorepoRootPath,
            });
            child.stdout.on('data', async (data: Buffer) => {
                const output = data.toString('utf8');
                utils.log('Lerna publish cmd: ', output);
                const isOTPPrompt = _.includes(output, 'Enter OTP:');
                if (isOTPPrompt) {
                    // Prompt for OTP
                    prompt.start();
                    const result = await promisify(prompt.get)(['OTP']);
                    child.stdin.write(`${result.OTP}\n`);
                }
                const didFinishPublishing = _.includes(output, 'Successfully published:');
                if (didFinishPublishing) {
                    // Remove temporary cdVersions file
                    fs.unlinkSync(cdVersionsFilepath);
                    resolve();
                }
            });
            child.stderr.on('data', (data: Buffer) => {
                const output = data.toString('utf8');
                utils.log('Lerna publish cmd: ', output);
            });
        } catch (err) {
            // Remove temporary cdVersions file
            fs.unlinkSync(cdVersionsFilepath);
            reject(err);
        }
    });
}

function updateVersionNumberIfNeeded(currentVersion: string, proposedNextVersion: string): string {
    const updatedVersionIfValid = semver.inc(currentVersion, 'patch');
    if (updatedVersionIfValid === null) {
        throw new Error(`Encountered invalid semver: ${currentVersion}`);
    }
    if (proposedNextVersion === currentVersion) {
        return updatedVersionIfValid;
    }
    const sortedVersions = semverSort.desc([proposedNextVersion, currentVersion]);
    if (sortedVersions[0] !== proposedNextVersion) {
        return updatedVersionIfValid;
    }
    return proposedNextVersion;
}
