const fs = require('fs');

// This is also the order expected for each version.
const ChangeTypes = ["Headline feature", "Feature", "Improved", "Change", "Removed", "Fix"];
// A list of change types that were previously used but aren't accepted anymore, will be replaced with the value in the map.
const ChangeTypeSubstitutions = {
    "Improve": "Improved",
};
const HeaderSeparator = "------------------------------------------------------------------------";

function reportError(ctx, message) {
    console.error(`Error on line ${ctx.lineIndex}: ${message}`);
    process.exit(-1);
}

function reportLineError(line, message) {
    console.error(`Error on line ${line.index + 1}: ${message}`);
    process.exit(-1);
}

function consumeLine(ctx) {
    if (ctx.lineIndex >= ctx.lines.length) {
        reportError(ctx, "Unexpected end of file");
    }
    const line = ctx.lines[ctx.lineIndex];
    ctx.lineIndex++;
    return line;
}

function peekLine(ctx) {
    if (ctx.lineIndex >= ctx.lines.length) {
        return null;
    }
    return ctx.lines[ctx.lineIndex];
}

function readVersionHeader(ctx) {
    // Extract the version number.
    const versionLine = consumeLine(ctx);
    const versionMatch = versionLine.text.match(/^((\d+\.\d+\.\d+(\.\d+)?(-\w+)?) \((\d{4}-\d{2}-\d{2}|in development)\))$/);
    if (versionMatch == null) {
        reportLineError(versionLine, "Expected a version number");
    }

    // Check the version separator
    const versionSeparator = consumeLine(ctx);
    if (versionSeparator.text != HeaderSeparator) {
        reportLineError(versionSeparator, "Expected version separator");
    }

    // Check for optional headline, this means the there is text not starting with - and an empty line before
    // the first entry.
    var headline = peekLine(ctx);
    if (headline != null && headline.text != "" && !headline.text.startsWith("-")) {
        consumeLine(ctx);

        // Check for empty line
        const emptyLine = peekLine(ctx);
        if (emptyLine == null || emptyLine.text != "") {
            reportError(ctx, "Expected empty line after headline");
        }

        consumeLine(ctx);
        headline = headline.text;
    } else {
        headline = "";
    }

    return {
        version: versionMatch[2],
        date: versionMatch[5],
        headline: headline,
    };
}

function isValidReference(ref) {
    const regex = /^\w*#\d+( \(partial\))?$/;
    return regex.test(ref);
}

function readVersionEntry(ctx, rawEntry, versionInfo) {
    // Check for the '- ' prefix
    var text = rawEntry.text;
    if (!text.startsWith("- ")) {
        reportLineError(rawEntry, "Expected entry prefix '- '");
    }
    text = text.substr(2);

    // Extract the change type
    const changeTypeIdx = text.indexOf(":");
    if (changeTypeIdx == -1) {
        reportLineError(rawEntry, "Expected change type");
    }
    var changeType = text.substring(0, changeTypeIdx).trim();
    text = text.substring(changeTypeIdx + 1).trim();

    // Check if changeType is valid
    if (!ChangeTypes.includes(changeType)) {
        // Check for substitution
        if (changeType in ChangeTypeSubstitutions) {
            changeType = ChangeTypeSubstitutions[changeType];
        } else {
            reportLineError(rawEntry, `Invalid change type '${changeType}', types can be ${ChangeTypes.join(", ")}`);
        }
    }

    // Extract the references.
    var refs = [];
    if (text.startsWith("[")) {
        const refsEndIdx = text.indexOf("]");
        if (refsEndIdx == -1) {
            reportLineError(rawEntry, "Open reference bracket '[' without closing bracket ']'");
        }
        refs = text.substring(1, refsEndIdx).split(",").map(ref => ref.trim());
        text = text.substring(refsEndIdx + 1).trim();
    } else {
        // Text must start with '[', allow having this missing in older versions.
        if (versionInfo.date == "in development") {
            reportLineError(rawEntry, "Expected reference [#REF]");
        }
    }

    // Check if reference is correct, can be '#123' or 'project#123'
    for (const ref of refs) {
        if(!isValidReference(ref)) {
            reportLineError(rawEntry, `Invalid reference '${ref}', must be '#123' or 'project#123'`);
        }
    }

    const result = {
        changeType: changeType,
        refs: refs,
        description: text.trim(),
    };
    return result;
}

function readVersionEntries(ctx, versionInfo) {
    var entries = [];
    while (ctx.lineIndex < ctx.lines.length) {
        const rawEntry = consumeLine(ctx);
        // Empty line means end of version block.
        if (rawEntry.text == "") {
            break;
        }
        const entry = readVersionEntry(ctx, rawEntry, versionInfo);
        entries.push(entry);
    }
    return entries;
}

function readVersionBlock(ctx) {
    const versionInfo = readVersionHeader(ctx);
    const entries = readVersionEntries(ctx, versionInfo);
    return {
        version: versionInfo.version,
        date: versionInfo.date,
        headline: versionInfo.headline,
        entries: entries
    };
}

function parseChangelog(changelog) {
    // Split the changelog into lines but also keep the line numbers.
    const lines = changelog.split('\n').map((text, index) => ({ text, index }));

    // Remove duplicate empty lines.
    for (var i = 0; i < lines.length; i++) {
        if (lines[i].text == "" && (i == 0 || lines[i - 1].text == "")) {
            lines.splice(i, 1);
            i--;
        }
    }

    var ctx = {
        lines,
        lineIndex: 0,
        versions: []
    };

    while (ctx.lineIndex < ctx.lines.length) {
        const versionBlock = readVersionBlock(ctx);
        ctx.versions.push(versionBlock);
    }

    return ctx.versions;
}

function referenceSort(a, b) {
    // Extract the number from the reference
    const numA = parseInt(a.replace(/\D/g, ''), 10);
    const numB = parseInt(b.replace(/\D/g, ''), 10);

    // Check if both are direct references or subproject references
    const isDirectA = a.startsWith('#');
    const isDirectB = b.startsWith('#');

    if (isDirectA && isDirectB) {
        // If both are direct references, sort by the number
        return numA - numB;
    } else if (!isDirectA && !isDirectB) {
        // If both are subproject references, sort by the name and then the number
        const nameA = a.split('#')[0];
        const nameB = b.split('#')[0];
        if (nameA.toLowerCase() < nameB.toLowerCase()) return -1;
        if (nameA.toLowerCase() > nameB.toLowerCase()) return 1;
        return numA - numB;
    } else {
        // If one is a direct reference and the other is a subproject reference,
        // the direct reference should come first
        return isDirectA ? -1 : 1;
    }
}

function reorderGroups(a, b) {
    const changeTypeDiff = ChangeTypes.indexOf(a.changeType) - ChangeTypes.indexOf(b.changeType);
    if (changeTypeDiff != 0) {
        return changeTypeDiff;
    }
    // If changeType is identical, sort based on reference
    const hasMainRefA = a.refs.length > 0 && a.refs[0].startsWith("#");
    const hasMainRefB = b.refs.length > 0 && b.refs[0].startsWith("#");
    // Sort by main reference number.
    if (hasMainRefA && hasMainRefB) {
        const numA = parseInt(a.refs[0].replace(/\D/g, ''), 10);
        const numB = parseInt(b.refs[0].replace(/\D/g, ''), 10);
        return numA - numB;
    } else if (hasMainRefA && !hasMainRefB) {
        return -1;
    } else if (!hasMainRefA && hasMainRefB) {
        return 1;
    } else {
        // Sort by reference name.
        const nameA = a.refs.length > 0 ? a.refs[0].split('#')[0] : "";
        const nameB = b.refs.length > 0 ? b.refs[0].split('#')[0] : "";
        if (nameA.toLowerCase() < nameB.toLowerCase()) return -1;
        if (nameA.toLowerCase() > nameB.toLowerCase()) return 1;
        return 0;
    }
}

function cleanupVersionBlock(version) {
    // Sort the references first, numbers come before sub project refs.
    version.entries.forEach(entry => entry.refs.sort((a, b) => referenceSort(a, b)));

    // Sort the entries by change type.
    version.entries.sort((a, b) => reorderGroups(a, b));

    // Ensure entries end with a dot.
    version.entries.forEach(entry => {
        if (!entry.description.endsWith(".")) {
            entry.description += ".";
        }
    });
}

function cleanupVersions(versions) {
    for (const version of versions) {
        cleanupVersionBlock(version);
    }
}

function printChangelog(versions) {
    for (const version of versions) {
        console.log(`${version.version} (${version.date})`);
        console.log(HeaderSeparator);
        if (version.headline != "") {
            console.log(version.headline);
            console.log();
        }
        for (const entry of version.entries) {
            var line = `- ${entry.changeType}: `;
            if (entry.refs.length > 0) {
                line += `[${entry.refs.join(", ")}] `;
            }
            line += `${entry.description}`;
            console.log(line);
        }
        console.log();
    }
}

function checkLineEndings(changelog) {
    if (changelog.indexOf("\r\n") != -1) {
        console.error("Changelog contains Windows line endings, please convert to Unix line endings.");
        process.exit(-1);
    }
}

const filePath = process.argv[2];
fs.readFile(filePath, 'utf8', (err, data) => {
    if (err) {
        console.error('Error reading the file:', err);
        process.exit(-1);
    }

    checkLineEndings(data);

    var versions = parseChangelog(data);

    cleanupVersions(versions);

    printChangelog(versions);
});