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

// Keyed by defaults.json config key, derived from the shared public-apps list.
const MONITORED_APPS = Object.fromEntries(
    require('./public-apps.json').map(app => [app.configKey, {packageName: app.packageName, path: app.path}])
);

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 getMajorMinorVersion(version) {
    const parsedVersion = parseSemver(version);
    return `${parsedVersion.major}.${parsedVersion.minor}`;
}

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 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 readVersionFromDefaults(defaultsContent, app, sourceLabel) {
    let parsedDefaults;

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

    const appDefaults = parsedDefaults[app.key];

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

    return appDefaults.version;
}

function getPrDefaultsVersion(app) {
    const defaultsPath = path.resolve(__dirname, '../ghost/core/core/shared/config/defaults.json');

    if (!fs.existsSync(defaultsPath)) {
        throw new Error('ghost/core/core/shared/config/defaults.json does not exist in this PR');
    }

    return readVersionFromDefaults(
        fs.readFileSync(defaultsPath, 'utf8'),
        app,
        'ghost/core/core/shared/config/defaults.json from PR'
    );
}

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 app version consistency check.`);
        return;
    }

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

    const failedApps = [];

    for (const app of changedApps) {
        const error = checkAppConsistency(app, getPrVersion(app), getPrDefaultsVersion(app));

        if (error) {
            failedApps.push(error);
            continue;
        }

        console.log(`${app.key} version consistency check passed (package.json ${getMajorMinorVersion(getPrVersion(app))} = defaults.json ${getPrDefaultsVersion(app)})`);
    }

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

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

/**
 * Patch releases are published automatically on merge to main, with npm as the
 * source of truth for the patch number — so PRs no longer need to bump the
 * version at all. The only invariant we still enforce is that package.json's
 * major.minor matches defaults.json, because Ghost core serves each app from
 * `<pkg>@~<major.minor>` on jsDelivr. A deliberate minor/major release (via
 * "pnpm ship") must move both in lockstep, or live sites would point at a
 * version line that never gets published.
 *
 * @returns {string|null} an error message when inconsistent, otherwise null
 */
function checkAppConsistency(app, prVersion, prDefaultsVersion) {
    const prMajorMinorVersion = getMajorMinorVersion(prVersion);

    if (prDefaultsVersion !== prMajorMinorVersion) {
        return `${app.key} (${app.packageName}): package.json is on ${prMajorMinorVersion} but defaults.json has ${app.key}.version set to ${prDefaultsVersion}. These must match — for a minor/major release run "pnpm ship" in ${app.path} (it updates both), otherwise align ghost/core/core/shared/config/defaults.json to ${prMajorMinorVersion}.`;
    }

    return null;
}

if (require.main === module) {
    try {
        main();
    } catch (error) {
        console.error(error.message);
        process.exit(1);
    }
}

module.exports = {checkAppConsistency, getMajorMinorVersion};
