const fs = require("fs");
const path = require("path");

const DEFAULT_FILE = path.join(
    __dirname,
    "..",
    "_site",
    "kst4ContestVersionInfo.xml"
);

function fail(message) {
    throw new Error(`[versionInfo validation] ${message}`);
}

function normaliseVersion(value) {
    return String(value || "")
        .trim()
        .replace(/^v/, "")
        .split("-")[0]
        .split("+")[0];
}

function extractTag(xml, tagName) {
    const match = xml.match(
        new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`)
    );

    return match ? match[1].trim() : null;
}

function assertWellFormedStructure(xml) {
    if (/&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[0-9a-f]+;)/i.test(xml)) {
        fail("the XML contains an unescaped ampersand");
    }

    const withoutCommentsAndDeclaration = xml
        .replace(/<!--[\s\S]*?-->/g, "")
        .replace(/<\?[\s\S]*?\?>/g, "");

    const tagPattern =
        /<\/?([A-Za-z_][\w:.-]*)(?:\s[^<>]*?)?\s*\/?>/g;

    const stack = [];
    let match;

    while (
        (match = tagPattern.exec(withoutCommentsAndDeclaration)) !== null
        ) {
        const fullTag = match[0];
        const tagName = match[1];

        if (fullTag.startsWith("</")) {
            const openTag = stack.pop();

            if (openTag !== tagName) {
                fail(
                    `closing tag </${tagName}> does not match `
                    + `<${openTag || "none"}>`
                );
            }
        } else if (!fullTag.endsWith("/>")) {
            stack.push(tagName);
        }
    }

    if (stack.length > 0) {
        fail(`unclosed tag <${stack[stack.length - 1]}>`);
    }

    const remainingMarkup =
        withoutCommentsAndDeclaration.replace(tagPattern, "");

    // A plain ">" is legal character data and occurs in historical
    // changelog notation such as "->". A remaining "<" cannot be legal
    // after all valid tags have been removed.
    if (/</.test(remainingMarkup)) {
        fail("the XML contains malformed markup");
    }
}

function assertContainsTag(block, tagName) {
    const pattern =
        new RegExp(`<${tagName}>[\\s\\S]*?<\\/${tagName}>`);

    if (!pattern.test(block)) {
        fail(`required element <${tagName}> is missing`);
    }
}

function validateVersionInfo(xml, expectedStableVersion = "") {
    if (!xml || Buffer.byteLength(xml, "utf8") < 500) {
        fail("the generated file is empty or implausibly small");
    }

    assertWellFormedStructure(xml);

    const completeDocumentPattern =
        /^<\?xml[^>]*>\s*<praktiKST>[\s\S]*<\/praktiKST>\s*$/;

    if (!completeDocumentPattern.test(xml)) {
        fail(
            "the document does not contain one complete "
            + "<praktiKST> root element"
        );
    }

    const latestVersionBlock = extractTag(xml, "latestVersion");

    if (latestVersionBlock === null) {
        fail("<latestVersion> is missing");
    }

    for (const tagName of [
        "versionNumber",
        "semanticVersion",
        "adminMessage",
        "majorChanges",
        "latestVersionPathOnWebserver"
    ]) {
        assertContainsTag(latestVersionBlock, tagName);
    }

    const legacyVersion =
        extractTag(latestVersionBlock, "versionNumber");

    const semanticVersion =
        extractTag(latestVersionBlock, "semanticVersion");

    const releaseUrl =
        extractTag(
            latestVersionBlock,
            "latestVersionPathOnWebserver"
        );

    if (
        !legacyVersion
        || !/^\d+(?:\.\d+)?$/.test(legacyVersion)
    ) {
        fail(
            "<versionNumber> is not a valid legacy numeric version"
        );
    }

    if (
        !semanticVersion
        || !/^\d+\.\d+(?:\.\d+)?$/.test(semanticVersion)
    ) {
        fail("<semanticVersion> is not a valid Stable version");
    }

    if (
        !releaseUrl
        || !releaseUrl.startsWith(
            "https://github.com/praktimarc/"
            + "kst4contest/releases/tag/"
        )
    ) {
        fail(
            "<latestVersionPathOnWebserver> is not "
            + "a KST4Contest release URL"
        );
    }

    const changeLogs = [
        ...xml.matchAll(
            /<changeLog>([\s\S]*?)<\/changeLog>/g
        )
    ].map((match) => match[1]);

    if (changeLogs.length === 0) {
        fail(
            "the document does not contain any "
            + "<changeLog> entries"
        );
    }

    for (const entry of changeLogs) {
        for (const tagName of [
            "changedVersionNumber",
            "date",
            "description",
            "added",
            "changed",
            "fixed",
            "removed"
        ]) {
            assertContainsTag(entry, tagName);
        }
    }

    const expected = normaliseVersion(expectedStableVersion);

    if (expected) {
        if (semanticVersion !== expected) {
            fail(
                `latest Stable version ${semanticVersion} `
                + `does not match expected release ${expected}`
            );
        }

        const releaseEntryExists = changeLogs.some(
            (entry) =>
                extractTag(
                    entry,
                    "changedVersionNumber"
                ) === expected
        );

        if (!releaseEntryExists) {
            fail(
                "the changelog does not contain the expected "
                + `Stable release ${expected}`
            );
        }
    }

    return {
        semanticVersion,
        changeLogEntries: changeLogs.length
    };
}

function parseArguments(argv) {
    const result = {
        file: DEFAULT_FILE,
        expectedStableVersion:
            process.env.EXPECTED_STABLE_VERSION || ""
    };

    for (let index = 0; index < argv.length; index++) {
        if (
            argv[index] === "--file"
            && argv[index + 1]
        ) {
            result.file = path.resolve(argv[++index]);
        } else if (
            argv[index] === "--expected-stable"
            && argv[index + 1]
        ) {
            result.expectedStableVersion = argv[++index];
        } else {
            fail(
                `unknown or incomplete argument: ${argv[index]}`
            );
        }
    }

    return result;
}

if (require.main === module) {
    try {
        const options =
            parseArguments(process.argv.slice(2));

        const xml =
            fs.readFileSync(options.file, "utf8");

        const result =
            validateVersionInfo(
                xml,
                options.expectedStableVersion
            );

        console.log(
            `[versionInfo validation] OK: `
            + `Stable ${result.semanticVersion}, `
            + `${result.changeLogEntries} changelog entries, `
            + options.file
        );
    } catch (err) {
        console.error(err.message);
        process.exitCode = 1;
    }
}

module.exports = {
    normaliseVersion,
    validateVersionInfo
};