// Script to generate changelog
// Usage: node generate-changelog.mjs <previous-version-tag>
// GitHub CLI (gh command) is required

import * as childProcess from "child_process";

const ignoreList = [
    "louislam",
    "CommanderStorm",
    "UptimeKumaBot",
    "weblate",
    "Copilot",
    "autofix-ci[bot]",
    "app/copilot-swe-agent",
    "app/github-actions",
    "github-actions[bot]",
];

const mergeList = ["chore: Translations Update from Weblate", "chore: Update dependencies"];

const outputFormat = JSON.stringify({
    improvements: [123, 456],
    newFeatures: [789],
    bugFixes: [101, 112],
    securityFixes: [131, 415],
    translationContributions: [161, 718],
    others: [192, 21],
});

const prompt = `Input Data:
\`\`\`json
{{ input }}
\`\`\`

LLM Task:
- Output a one-line JSON object in the following format:
{{ outputFormat }}
- Empty arrays included if there are no items for that category.
- Exclude reverted pull requests.
- "fix: " type pull requests should be categorized as "bugFixes".
- "chore: " type pull requests should be categorized as "others"
- "feat: " type pull requests should be categorized as "newFeatures" or "improvements" based on the content of the title, you should determine it.
- "refactor: " type pull requests should be categorized as "improvements".
`.replace("{{ outputFormat }}", outputFormat);

const categoryList = {
    // In case the LLM cannot categorize some items
    uncategorized: {
        title: "Uncategorized",
        items: [],
    },
    newFeatures: {
        title: "🆕 New Features",
        items: [],
    },
    improvements: {
        title: "💇‍♀️ Improvements",
        items: [],
    },
    bugFixes: {
        title: "🐞 Bug Fixes",
        items: [],
    },
    securityFixes: {
        title: "⬆️ Security Fixes",
        items: [],
    },
    translationContributions: {
        title: "🦎 Translation Contributions",
        items: [],
    },
    others: {
        title: "Others",
        items: [],
    },
};

if (import.meta.main) {
    await main();
}

/**
 * Main Function
 * @returns {Promise<void>}
 */
async function main() {
    const previousVersion = process.argv[2];
    const action = process.argv[3];
    const categorizedMap = process.argv[4] ? JSON.parse(process.argv[4]) : null;

    if (action === "generate") {
        console.log(`Generating changelog since version ${previousVersion}...`);
        console.log(await generateChangelog(previousVersion, categorizedMap));
    } else {
        if (!previousVersion) {
            console.error("Please provide the previous version as the first argument.");
            process.exit(1);
        }
        console.log(await getPrompt(previousVersion));
    }
}

/**
 * Get Prompt for LLM
 * @param {string} previousVersion Previous Version Tag
 * @returns {Promise<string>} Prompt for LLM
 */
export async function getPrompt(previousVersion) {
    const input = JSON.stringify(await getPullRequestList(previousVersion, true));
    return prompt.replace("{{ input }}", input);
}

/**
 * Generate Changelog
 * @param {string} previousVersion Previous Version Tag
 * @param {object} categorizedMap It should be generated by the LLM based on the prompt
 * @returns {Promise<string>} Changelog Content
 */
export async function generateChangelog(previousVersion, categorizedMap) {
    const prList = await getPullRequestList(previousVersion);
    const list = [];

    let i = 1;
    for (const pr of prList) {
        console.log(`Progress: ${i++}/${prList.length}`);
        let authorSet = await getAuthorList(pr.number);
        authorSet = await mainAuthorToFront(pr.author.login, authorSet);

        if (mergeList.includes(pr.title)) {
            // Check if it is already in the list
            const existingItem = list.find((item) => item.title === pr.title);
            if (existingItem) {
                existingItem.numbers.push(pr.number);
                for (const author of authorSet) {
                    existingItem.authors.add(author);
                    // Sort the authors
                    existingItem.authors = new Set([...existingItem.authors].sort((a, b) => a.localeCompare(b)));
                }
                continue;
            }
        }

        const item = {
            numbers: [pr.number],
            title: pr.title,
            authors: authorSet,
        };

        list.push(item);
    }

    for (const item of list) {
        // Concat pr numbers into a string like #123 #456
        const prPart = item.numbers.map((num) => `#${num}`).join(" ");

        // Concat authors into a string like @user1 @user2
        let authorPart = [...item.authors].map((author) => `@${author}`).join(" ");

        if (authorPart) {
            authorPart = `(Thanks ${authorPart})`;
        }

        const line = `- ${prPart} ${item.title} ${authorPart}`;

        // Determine the category of the item, based on the title and the categorizedMap
        let category = "uncategorized";
        let prNumber = item.numbers[0];

        for (const cat in categorizedMap) {
            if (categorizedMap[cat].includes(prNumber)) {
                category = cat;
                break;
            }
        }

        categoryList[category].items.push(line);
    }

    // Generate markdown
    let content = "";

    for (const cat in categoryList) {
        content += `### ${categoryList[cat].title}\n`;
        for (const item of categoryList[cat].items) {
            content += `${item}\n`;
        }
        content += `\n`;
    }

    return content;
}

/**
 * @param {string} previousVersion Previous Version Tag
 * @param {boolean} removeAuthor Whether to strip the author field from the returned PR list
 * @returns {Promise<object>} List of Pull Requests merged since previousVersion
 */
async function getPullRequestList(previousVersion, removeAuthor = false) {
    // Get the date of previousVersion in iso8601-strict format (2026-02-19T13:34:03+08:00) from git
    const previousVersionDate = childProcess
        .execSync(`git log -1 --format=%cd --date=iso8601-strict ${previousVersion}`)
        .toString()
        .trim();

    if (!previousVersionDate) {
        throw new Error(
            `Unable to find the date of version ${previousVersion}. Please make sure the version tag exists.`
        );
    }

    const ghProcess = childProcess.spawnSync(
        "gh",
        [
            "pr",
            "list",
            "--state",
            "merged",
            "--base",
            "master",
            "--search",
            `merged:>=${previousVersionDate}`,
            "--json",
            "number,title,author",
            "--limit",
            "1000",
        ],
        {
            encoding: "utf-8",
        }
    );

    if (ghProcess.error) {
        throw ghProcess.error;
    }

    if (ghProcess.status !== 0) {
        throw new Error(`gh command failed with status ${ghProcess.status}: ${ghProcess.stderr}`);
    }

    const obj = JSON.parse(ghProcess.stdout);

    if (removeAuthor) {
        for (const pr of obj) {
            delete pr.author;
        }
    }

    return obj;
}

/**
 * @param {number} prID Pull Request ID
 * @returns {Promise<Set<string>>} Set of Authors' GitHub Usernames
 */
async function getAuthorList(prID) {
    const ghProcess = childProcess.spawnSync("gh", ["pr", "view", prID, "--json", "commits"], {
        encoding: "utf-8",
    });

    if (ghProcess.error) {
        throw ghProcess.error;
    }

    if (ghProcess.status !== 0) {
        throw new Error(`gh command failed with status ${ghProcess.status}: ${ghProcess.stderr}`);
    }

    const prInfo = JSON.parse(ghProcess.stdout);
    const commits = prInfo.commits;

    const set = new Set();

    for (const commit of commits) {
        for (const author of commit.authors) {
            if (author.login && !ignoreList.includes(author.login)) {
                set.add(author.login);
            }
        }
    }

    // Sort the set
    return new Set([...set].sort((a, b) => a.localeCompare(b)));
}

/**
 * @param {string} mainAuthor Main Author
 * @param {Set<string>} authorSet Set of Authors
 * @returns {Set<string>} New Set with mainAuthor at the front
 */
async function mainAuthorToFront(mainAuthor, authorSet) {
    if (ignoreList.includes(mainAuthor)) {
        return authorSet;
    }
    return new Set([mainAuthor, ...authorSet]);
}
