const fs = require('fs');
const path = require('path');
const execFileSync = require('child_process').execFileSync;

const MONITORED_APPS = {
    portal: {
        packageName: '@tryghost/portal',
        path: 'apps/portal'
    },
    sodoSearch: {
        packageName: '@tryghost/sodo-search',
        path: 'apps/sodo-search'
    },
    comments: {
        packageName: '@tryghost/comments-ui',
        path: 'apps/comments-ui'
    },
    announcementBar: {
        packageName: '@tryghost/announcement-bar',
        path: 'apps/announcement-bar'
    },
    signupForm: {
        packageName: '@tryghost/signup-form',
        path: 'apps/signup-form'
    }
};

const MONITORED_APP_ENTRIES = Object.entries(MONITORED_APPS);
const MONITORED_APP_PATHS = MONITORED_APP_ENTRIES.map(([, app]) => app.path);

function runGit(args) {
    try {
        return execFileSync('git', args, {encoding: 'utf8'}).trim();
    } catch (error) {
        const stderr = error.stderr ? error.stderr.toString().trim() : '';
        const stdout = error.stdout ? error.stdout.toString().trim() : '';
        const message = stderr || stdout || error.message;
        throw new Error(`Failed to run "git ${args.join(' ')}": ${message}`);
    }
}

function readVersionFromPackageJson(packageJsonContent, sourceLabel) {
    let parsedPackageJson;

    try {
        parsedPackageJson = JSON.parse(packageJsonContent);
    } catch (error) {
        throw new Error(`Unable to parse ${sourceLabel}: ${error.message}`);
    }

    if (!parsedPackageJson.version || typeof parsedPackageJson.version !== 'string') {
        throw new Error(`${sourceLabel} does not contain a valid "version" field`);
    }

    return parsedPackageJson.version;
}

function parseSemver(version) {
    const match = version.match(/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/);

    if (!match) {
        throw new Error(`Invalid semver version "${version}"`);
    }

    const prerelease = match[4] ? match[4].split('.').map((identifier) => {
        if (/^\d+$/.test(identifier)) {
            return Number(identifier);
        }

        return identifier;
    }) : [];

    return {
        major: Number(match[1]),
        minor: Number(match[2]),
        patch: Number(match[3]),
        prerelease
    };
}

function comparePrereleaseIdentifier(a, b) {
    const isANumber = typeof a === 'number';
    const isBNumber = typeof b === 'number';

    if (isANumber && isBNumber) {
        if (a === b) {
            return 0;
        }

        return a > b ? 1 : -1;
    }

    if (isANumber) {
        return -1;
    }

    if (isBNumber) {
        return 1;
    }

    if (a === b) {
        return 0;
    }

    return a > b ? 1 : -1;
}

function compareSemver(a, b) {
    const aVersion = parseSemver(a);
    const bVersion = parseSemver(b);

    if (aVersion.major !== bVersion.major) {
        return aVersion.major > bVersion.major ? 1 : -1;
    }

    if (aVersion.minor !== bVersion.minor) {
        return aVersion.minor > bVersion.minor ? 1 : -1;
    }

    if (aVersion.patch !== bVersion.patch) {
        return aVersion.patch > bVersion.patch ? 1 : -1;
    }

    const aPrerelease = aVersion.prerelease;
    const bPrerelease = bVersion.prerelease;

    if (!aPrerelease.length && !bPrerelease.length) {
        return 0;
    }

    if (!aPrerelease.length) {
        return 1;
    }

    if (!bPrerelease.length) {
        return -1;
    }

    const maxLength = Math.max(aPrerelease.length, bPrerelease.length);
    for (let i = 0; i < maxLength; i += 1) {
        const aIdentifier = aPrerelease[i];
        const bIdentifier = bPrerelease[i];

        if (aIdentifier === undefined) {
            return -1;
        }

        if (bIdentifier === undefined) {
            return 1;
        }

        const identifierComparison = comparePrereleaseIdentifier(aIdentifier, bIdentifier);
        if (identifierComparison !== 0) {
            return identifierComparison;
        }
    }

    return 0;
}

function getChangedFiles(baseSha, compareSha) {
    let mergeBaseSha;

    try {
        mergeBaseSha = runGit(['merge-base', baseSha, compareSha]);
    } catch (error) {
        throw new Error(`Unable to determine merge-base for ${baseSha} and ${compareSha}. Ensure the base branch history is available in the checkout.\n${error.message}`);
    }

    return runGit(['diff', '--name-only', mergeBaseSha, compareSha, '--', ...MONITORED_APP_PATHS])
        .split('\n')
        .map(file => file.trim())
        .filter(Boolean);
}

function getChangedAppFiles(app, changedFiles) {
    return changedFiles.filter((file) => {
        return file === app.path || file.startsWith(`${app.path}/`);
    });
}

function getChangedApps(changedFiles) {
    return MONITORED_APP_ENTRIES
        .filter(([, app]) => getChangedAppFiles(app, changedFiles).length > 0)
        .map(([key, app]) => ({key, ...app}));
}

function isDependencyOnlyChange(app, changedFiles) {
    const filesInApp = getChangedAppFiles(app, changedFiles);
    return filesInApp.length > 0 && filesInApp.every(file => file === `${app.path}/package.json`);
}

function getPrVersion(app) {
    const packageJsonPath = path.resolve(__dirname, `../../${app.path}/package.json`);

    if (!fs.existsSync(packageJsonPath)) {
        throw new Error(`${app.path}/package.json does not exist in this PR`);
    }

    return readVersionFromPackageJson(
        fs.readFileSync(packageJsonPath, 'utf8'),
        `${app.path}/package.json from PR`
    );
}

function getMainVersion(app) {
    return readVersionFromPackageJson(
        runGit(['show', `origin/main:${app.path}/package.json`]),
        `${app.path}/package.json from main`
    );
}

function main() {
    const baseSha = process.env.PR_BASE_SHA;
    const compareSha = process.env.PR_COMPARE_SHA || process.env.GITHUB_SHA;

    if (!baseSha) {
        throw new Error('Missing PR_BASE_SHA environment variable');
    }

    if (!compareSha) {
        throw new Error('Missing PR_COMPARE_SHA/GITHUB_SHA environment variable');
    }

    const changedFiles = getChangedFiles(baseSha, compareSha);
    const changedApps = getChangedApps(changedFiles);

    if (changedApps.length === 0) {
        console.log(`No app changes detected. Skipping version bump check.`);
        return;
    }

    console.log(`Checking version bump for apps: ${changedApps.map(app => app.key).join(', ')}`);

    const failedApps = [];

    for (const app of changedApps) {
        if (isDependencyOnlyChange(app, changedFiles)) {
            console.log(`${app.key} only has dependency changes in package.json; skipping version bump check.`);
            continue;
        }

        const prVersion = getPrVersion(app);
        const mainVersion = getMainVersion(app);

        if (compareSemver(prVersion, mainVersion) <= 0) {
            failedApps.push(
                `${app.key} (${app.packageName}) was changed but version was not bumped above main (${prVersion} <= ${mainVersion}). Please run "pnpm ship" in ${app.path} to bump the package version.`
            );
            continue;
        }

        console.log(`${app.key} version bump check passed (${prVersion} > ${mainVersion})`);
    }

    if (failedApps.length) {
        throw new Error(`Version bump checks failed:\n- ${failedApps.join('\n- ')}`);
    }

    console.log('All monitored app version bump checks passed.');
}

try {
    main();
} catch (error) {
    console.error(error.message);
    process.exit(1);
}
