import { Command, flags } from '@heroku-cli/command';
import { color, hux } from '@heroku/heroku-cli-util';
import { ux } from '@oclif/core/ux';
import assert from 'assert';
import fetch from 'node-fetch';
import * as Stream from 'stream';
import * as util from 'util';
import { listPipelineApps } from '../../lib/api.js';
import keyBy from '../../lib/pipelines/key-by.js';
function assertNotPromotingToSelf(source, target) {
    assert.notStrictEqual(source, target, `Cannot promote from an app to itself: ${target}. Specify a different target app.`);
}
function findAppInPipeline(apps, target) {
    const found = apps.find(app => (app.name === target) || (app.id === target));
    assert(found, `Cannot find app ${color.app(target)}`);
    return found;
}
const PROMOTION_ORDER = ['development', 'staging', 'production'];
const wait = (ms = 100) => new Promise(resolve => setTimeout(resolve, ms));
export default class Promote extends Command {
    static description = 'promote the latest release of this app to its downstream app(s)';
    static examples = [
        color.command('heroku pipelines:promote -a my-app-staging'),
    ];
    static flags = {
        app: flags.app({
            required: true,
        }),
        remote: flags.remote(),
        to: flags.string({
            char: 't',
            description: 'comma separated list of apps to promote to',
        }),
    };
    static sleep(time) {
        return new Promise(resolve => setTimeout(resolve, time));
    }
    async run() {
        const { flags } = await this.parse(Promote);
        const appNameOrId = flags.app;
        const coupling = await getCoupling(this.heroku, appNameOrId);
        ux.stdout(`Fetching apps from ${color.pipeline(coupling.pipeline.name)}...`);
        const allApps = await listPipelineApps(this.heroku, coupling.pipeline.id);
        const sourceStage = coupling.stage;
        let promotionActionName = '';
        let targetApps = [];
        if (flags.to) {
            // The user specified a specific set of apps they want to target
            // We don't have to infer the apps or the stage they want to promote to
            // Strip out any empty app names due to something like a trailing comma
            const targetAppNames = flags.to.split(',').filter((appName) => appName.length > 0);
            // Now let's make sure that we can find every target app they specified
            // The only requirement is that the app be in this pipeline. They can be at any stage.
            targetApps = targetAppNames.reduce((acc, targetAppNameOrId) => {
                assertNotPromotingToSelf(appNameOrId, targetAppNameOrId);
                const app = findAppInPipeline(allApps, targetAppNameOrId);
                if (app) {
                    acc.push(app);
                }
                return acc;
            }, []);
            promotionActionName = `Starting promotion to apps: ${targetAppNames.toString()}`;
        }
        else {
            const targetStage = PROMOTION_ORDER[PROMOTION_ORDER.indexOf(sourceStage) + 1];
            assertValidPromotion(appNameOrId, sourceStage, targetStage);
            targetApps = allApps.filter(app => app.pipelineCoupling.stage === targetStage);
            assertApps(appNameOrId, targetApps, targetStage);
            promotionActionName = `Starting promotion to ${targetStage}`;
        }
        const promotion = await promote(this.heroku, promotionActionName, coupling.pipeline.id, coupling.app.id, targetApps);
        const pollLoop = pollPromotionStatus(this.heroku, promotion.id, true);
        ux.stdout('Waiting for promotion to complete...');
        let promotionTargets = await pollLoop;
        try {
            promotionTargets = await streamReleaseCommand(this.heroku, promotionTargets, promotion);
        }
        catch (error) {
            ux.error(error);
        }
        const appsByID = keyBy(allApps, 'id');
        const styledTargets = promotionTargets.reduce((memo, target) => {
            const app = appsByID[target.app.id];
            const details = [target.status];
            if (isFailed(target)) {
                details.push(target.error_message);
            }
            memo[app.name] = details;
            return memo;
        }, {});
        if (promotionTargets.every(isSucceeded)) {
            ux.stdout('\nPromotion successful');
        }
        else {
            ux.warn('\nPromotion to some apps failed');
        }
        hux.styledObject(styledTargets);
    }
}
function assertApps(app, targetApps, targetStage) {
    if (targetApps.length === 0) {
        throw new Error(`Cannot promote from ${color.app(app)} as there are no downstream apps in ${targetStage} stage`);
    }
}
function assertValidPromotion(app, source, target) {
    if (!target || PROMOTION_ORDER.indexOf(source) < 0) { // eslint-disable-line unicorn/prefer-includes
        throw new Error(`Cannot promote ${app} from '${source}' stage`);
    }
}
async function getCoupling(heroku, app) {
    ux.stdout('Fetching app info...');
    const { body: coupling } = await heroku.get(`/apps/${app}/pipeline-couplings`);
    return coupling;
}
async function getRelease(heroku, app, releaseId) {
    ux.stdout('Fetching release info...');
    const { body: release } = await heroku.get(`/apps/${app}/releases/${releaseId}`);
    return release;
}
function isComplete(promotionTarget) {
    return promotionTarget.status !== 'pending';
}
function isFailed(promotionTarget) {
    return promotionTarget.status === 'failed';
}
function isSucceeded(promotionTarget) {
    return promotionTarget.status === 'succeeded';
}
function pollPromotionStatus(heroku, id, needsReleaseCommand) {
    return heroku.get(`/pipeline-promotions/${id}/promotion-targets`).then(({ body: targets }) => {
        if (targets.every(isComplete)) {
            return targets;
        }
        //
        // With only one target, we can return as soon as the release is created.
        // The command will then read the release phase output
        //
        // `needsReleaseCommand` allows us to keep polling, as it can take a few
        // seconds to get the release to succeeded after the release command
        // finished.
        //
        if (needsReleaseCommand && targets.length === 1 && targets[0].release !== null) {
            return targets;
        }
        return wait(1000).then(pollPromotionStatus.bind(null, heroku, id, needsReleaseCommand));
    });
}
async function promote(heroku, label, id, sourceAppId, targetApps, secondFactor) {
    const options = {
        body: {
            pipeline: { id },
            source: { app: { id: sourceAppId } },
            targets: targetApps.map(app => ({ app: { id: app.id } })),
        },
        headers: {},
    };
    if (secondFactor) {
        options.headers = { 'Heroku-Two-Factor-Code': secondFactor };
    }
    try {
        ux.stdout(`${label}...`);
        const { body: promotions } = await heroku.post('/pipeline-promotions', options);
        return promotions;
    }
    catch (error) {
        if (!error.body || error.body.id !== 'two_factor') {
            throw error;
        }
        const secondFactor = await heroku.twoFactorPrompt();
        return promote(heroku, label, id, sourceAppId, targetApps, secondFactor);
    }
}
async function streamReleaseCommand(heroku, targets, promotion) {
    if (targets.length !== 1 || targets.every(isComplete)) {
        return pollPromotionStatus(heroku, promotion.id, false);
    }
    const target = targets[0];
    const release = await getRelease(heroku, target.app.id, target.release.id);
    if (!release.output_stream_url) {
        return pollPromotionStatus(heroku, promotion.id, false);
    }
    ux.stdout('Running release command...');
    async function streamReleaseOutput(releaseStreamUrl) {
        const finished = util.promisify(Stream.finished);
        const fetchResponse = await fetch(releaseStreamUrl);
        if (fetchResponse.status >= 400) {
            throw new Error('stream release output not available');
        }
        fetchResponse.body.pipe(process.stdout);
        await finished(fetchResponse.body);
    }
    async function retry(maxAttempts, fn) {
        let currentAttempt = 0;
        while (true) {
            try {
                await fn();
                return;
            }
            catch (error) {
                if (++currentAttempt === maxAttempts) {
                    throw error;
                }
                await Promote.sleep(1000);
            }
        }
    }
    await retry(100, () => streamReleaseOutput(release.output_stream_url));
    return pollPromotionStatus(heroku, promotion.id, false);
}
