Jump to content

User:Barkeep49/rfxCloser.js

From Wikipedia, the free encyclopedia
This is an old revision of this page, as edited by Barkeep49 (talk | contribs) at 03:21, 17 April 2025 (hmm). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/**
 * RfX Closer User Script for Wikipedia 
 * Helps bureaucrats close RfAs and RfBs by providing a guided workflow and API interactions.
 * Version 1.0.0
 */
(function() {
    'use strict';

    // --- Configuration ---
    const config = {
        pageName: mw.config.get('wgPageName'),
        userName: mw.config.get('wgUserName') || 'YourUsername',
        tagLine: ' (using [[User:Barkeep49/rfxCloser.js|rfxCloser]])',
        selectors: {
            container: '#rfx-closer-container',
            header: '.rfx-closer-header',
            title: '.rfx-closer-title',
            collapseButton: '.rfx-closer-collapse',
            closeButton: '.rfx-closer-close',
            contentAndInputContainer: '.rfx-closer-main-content',
            inputSection: '.rfx-closer-input-section',
            inputFields: '.rfx-closer-input-fields',
            supportInput: '#support-count',
            opposeInput: '#oppose-count',
            neutralInput: '#neutral-count',
            percentageDisplay: '.rfx-closer-percentage',
            contentContainer: '.rfx-closer-content-container',
            stepsContainer: '#rfx-closer-steps',
            outcomeSelector: '#rfx-outcome-selector',
            launchButton: '#rfx-closer-launch',
            launchListItem: '#rfx-closer-launch-li',
            toolsMenu: '#p-tb ul',
            cratListTextarea: '#rfx-crat-list-textarea',
            cratNotifyMessage: '#rfx-crat-notify-message',
            cratNotifyButton: '#rfx-crat-notify-button',
            cratNotifyStatus: '#rfx-crat-notify-status',
            candidateOnholdNotifyMessage: '#rfx-candidate-onhold-notify-message',
            candidateOnholdNotifyButton: '#rfx-candidate-onhold-notify-button',
            candidateOnholdNotifyStatus: '#rfx-candidate-onhold-notify-status',
            stepElement: '.rfx-closer-step',
            stepCheckbox: 'input[type="checkbox"]'
        },
        hardcodedCrats: [
            '28bytes', 'Acalamari', 'AmandaNP', 'Avraham', 'Barkeep49',
            'Bibliomaniac15', 'Cecropia', 'Dweller', 'Lee Vilenski', 'Maxim',
            'Primefac', 'UninvitedCompany', 'Useight', 'WereSpielChequers',
            'Xaosflux', 'Xeno'
        ],
        groupDisplayNames: {
            'ipblock-exempt': 'IP block exempt', 'rollbacker': 'Rollbacker',
            'eventcoordinator': 'Event coordinator', 'filemover': 'File mover',
            'templateeditor': 'Template editor', 'massmessage-sender': 'Mass message sender',
            'extendedconfirmed': 'Extended confirmed user', 'extendedmover': 'Page mover',
            'patroller': 'New page reviewer', 'abusefilter-helper': 'Edit filter helper',
            'abusefilter': 'Edit filter manager', 'reviewer': 'Pending changes reviewer',
            'accountcreator': 'Account creator', 'autoreviewer': 'Autopatrolled'
        },
        apiDefaults: {
            format: 'json',
            formatversion: 2
        }
    };

    // Determine RfX type and base page name
    config.rfxType = config.pageName.includes('Requests_for_adminship') ? 'adminship' : 'bureaucratship';
    config.baseRfxPage = `Wikipedia:Requests_for_${config.rfxType}`;
    config.displayBaseRfxPage = config.baseRfxPage.replace(/_/g, ' ');
    config.candidateSubpage = config.pageName.split('/').pop(); // Get the candidate part

    // --- State Variables ---
    let rfaData = null;
    let basePageWikitextCache = {};
    let actualCandidateUsername = config.candidateSubpage; // Default, updated on fetch
    let fetchErrorOccurred = false;
    let wikitextErrorOccurred = false;
    let isCollapsed = false;
    let isDragging = false;
    let dragStartX, dragStartY, containerStartX, containerStartY;

    // --- Initial Check ---
    if (!new RegExp(`^${config.baseRfxPage.replace(/ /g, '_')}/[^/]+$`).test(config.pageName)) {
        return;
    }

    // --- Utility Functions ---

    /** Escapes characters for use in regex, handling spaces/underscores. */
    function escapeRegex(string) {
        const spacedString = string.replace(/_/g, ' ');
        const underscoredString = string.replace(/ /g, '_');
        if (spacedString === underscoredString) {
            return spacedString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        }
        const escapedSpaced = spacedString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        const escapedUnderscored = underscoredString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        return `(?:${escapedUnderscored}|${escapedSpaced})`;
    }

    /** Adds a delay. */
    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    /** Generic API request helper. */
    async function makeApiRequest(params, method = 'get', tokenType = null) {
        const api = new mw.Api();
        const fullParams = { ...config.apiDefaults, ...params };
        try {
            let response;
            if (method === 'get') {
                response = await api.get(fullParams);
            } else if (method === 'post' && tokenType) {
                response = await api.postWithToken(tokenType, fullParams);
            } else if (method === 'post') {
                response = await api.post(fullParams);
            } else {
                throw new Error(`Unsupported API method: ${method}`);
            }
            return response;
        } catch (error) {
            const errorCode = error?.error?.code || error?.textStatus || 'unknown';
            const errorInfo = error?.error?.info || error?.xhr?.responseText || 'Unknown API error';
            console.error(`RfX Closer API [${method.toUpperCase()} ${params.action}] Error:`, { params, errorCode, errorInfo, errorObj: error });
            throw { code: errorCode, info: errorInfo, params: params };
        }
    }

    /** Creates a DOM element with attributes and content. */
    function createElement(tag, options = {}, children = []) {
        const el = document.createElement(tag);
        Object.entries(options).forEach(([key, value]) => {
            if (key === 'style' && typeof value === 'object') {
                Object.assign(el.style, value);
            } else if (key === 'dataset' && typeof value === 'object') {
                Object.assign(el.dataset, value);
            } else if (key === 'className') {
                el.className = value;
            } else if (key === 'textContent') {
                el.textContent = value;
            } else if (key === 'innerHTML') {
                el.innerHTML = value;
            } else {
                el.setAttribute(key, value);
            }
        });
        children.forEach(child => {
            if (child instanceof Node) {
                el.appendChild(child);
            } else if (typeof child === 'string') {
                el.appendChild(document.createTextNode(child));
            }
        });
        return el;
    }

    /** Creates a standard API action button. */
    function createActionButton(id, text, onClick) {
        const button = createElement('button', { id, textContent: text, className: 'rfx-closer-action-button' });
        if (onClick) {
            button.addEventListener('click', onClick);
        }
        return button;
    }

    /** Creates a standard status display area. */
    function createStatusArea(id, className = 'rfx-closer-api-status') {
        return createElement('div', { id, className });
    }

    /** Creates a container with "Copy Code" and "Edit Page" links. */
    function createActionLinks(targetPage, wikitextOrGetter, linkTextPrefix = '', isCreateLink = false) {
        const linksContainer = createElement('div', { className: 'rfx-action-links-container' });
        const copyLink = createElement('a', {
            href: '#',
            textContent: `Copy Code${linkTextPrefix ? ' for ' + linkTextPrefix : ''}`,
            className: 'rfx-action-link',
            title: 'Copy generated wikitext to clipboard'
        });

        copyLink.addEventListener('click', (e) => {
            e.preventDefault();
            const textToCopy = (typeof wikitextOrGetter === 'function') ? wikitextOrGetter() : wikitextOrGetter;
            if (textToCopy === null || textToCopy === undefined) {
                console.warn("RfX Closer: Attempted to copy null/undefined text via link.");
                copyLink.textContent = 'Error (No Text)'; copyLink.classList.add('error');
                setTimeout(() => { copyLink.textContent = `Copy Code${linkTextPrefix ? ' for ' + linkTextPrefix : ''}`; copyLink.classList.remove('error'); }, 3000);
                return;
            }
            navigator.clipboard.writeText(textToCopy).then(() => {
                copyLink.textContent = 'Copied!'; copyLink.classList.add('copied'); copyLink.style.fontWeight = 'bold';
                setTimeout(() => { copyLink.textContent = `Copy Code${linkTextPrefix ? ' for ' + linkTextPrefix : ''}`; copyLink.classList.remove('copied'); copyLink.style.fontWeight = 'normal'; }, 1500);
            }).catch(err => {
                console.error('RfX Closer: Failed to copy text via link: ', err);
                copyLink.textContent = 'Error Copying!'; copyLink.classList.add('error');
                setTimeout(() => { copyLink.textContent = `Copy Code${linkTextPrefix ? ' for ' + linkTextPrefix : ''}`; copyLink.classList.remove('error'); }, 3000);
            });
        });
        linksContainer.appendChild(copyLink);

        if (targetPage) {
            const editLink = createElement('a', {
                href: mw.util.getUrl(targetPage, { action: 'edit' }),
                target: '_blank',
                textContent: `${isCreateLink ? 'Create' : 'Edit'} ${linkTextPrefix || targetPage.replace(/_/g, ' ')}`,
                className: 'rfx-action-link',
                title: `${isCreateLink ? 'Create' : 'Edit'} ${targetPage.replace(/_/g, ' ')} in edit mode`
            });
            linksContainer.appendChild(editLink);
        }
        return linksContainer;
    }

    /** Creates a box with content and a copy button (kept for fallback cases). */
    function createCopyableBox(content, helpText = 'Click button to copy', isLarge = false, inputElement = null) {
        const boxContainer = createElement('div', { className: 'rfx-copy-box-container' });
        const displayElement = inputElement ? inputElement : createElement('pre', {
            textContent: content,
            className: 'rfx-copy-box-display',
            style: isLarge ? { maxHeight: '300px' } : {}
        });
        if (!inputElement) { boxContainer.appendChild(displayElement); }

        const copyButton = createElement('button', {
            textContent: 'Copy',
            title: helpText,
            className: 'rfx-copy-box-button',
            style: (inputElement && inputElement.tagName === 'TEXTAREA') ? { top: 'auto', bottom: '5px' } : {}
        });

        copyButton.addEventListener('click', () => {
            const textToCopy = inputElement ? inputElement.value : content;
            if (textToCopy === null || textToCopy === undefined) { console.warn("RfX Closer: Attempted to copy null/undefined."); return; }
            navigator.clipboard.writeText(textToCopy).then(() => {
                copyButton.textContent = 'Copied!'; copyButton.classList.add('copied'); copyButton.disabled = true;
                setTimeout(() => { copyButton.textContent = 'Copy'; copyButton.classList.remove('copied'); copyButton.disabled = false; }, 1500);
            }).catch(err => {
                console.error('RfX Closer: Failed to copy text: ', err); copyButton.textContent = 'Error!'; copyButton.classList.add('error');
                setTimeout(() => { copyButton.textContent = 'Copy'; copyButton.classList.remove('error'); copyButton.disabled = false; }, 3000);
            });
        });
        boxContainer.appendChild(copyButton);
        boxContainer.appendChild(createElement('div', { textContent: helpText, className: 'rfx-copy-box-helptext' }));
        return boxContainer;
    }

    // --- Data Fetching Functions ---

    /** Fetches summary data from the main RfX list page. */
    async function fetchRfaData() {
        if (rfaData && !fetchErrorOccurred) return rfaData;
        fetchErrorOccurred = false;
        try {
            const data = await makeApiRequest({
                action: 'parse',
                page: config.baseRfxPage,
                prop: 'text'
            });
            const htmlContent = data.parse.text;
            const tempDiv = createElement('div', { innerHTML: htmlContent });
            const reportTable = tempDiv.querySelector('.rfx-report');
            let foundData = null;

            if (reportTable) {
                const rows = reportTable.querySelectorAll('tbody tr');
                rows.forEach(row => {
                    const rfaLink = row.querySelector('td:first-child a');
                    const linkHref = rfaLink ? rfaLink.getAttribute('href').replace(/ /g, '_') : '';
                    const targetHref = `/wiki/${config.baseRfxPage.replace(/ /g, '_')}/${config.candidateSubpage}`;

                    if (rfaLink && linkHref === targetHref) {
                        const cells = row.querySelectorAll('td');
                        if (cells.length >= 8) {
                            foundData = {
                                candidate: rfaLink.textContent.trim(),
                                support: cells[1]?.textContent.trim() || '0',
                                oppose: cells[2]?.textContent.trim() || '0',
                                neutral: cells[3]?.textContent.trim() || '0',
                                percent: cells[4]?.textContent.trim().replace('%', '') || 'N/A',
                                status: cells[5]?.textContent.trim() || 'N/A',
                                endTime: cells[6]?.textContent.trim() || 'N/A',
                                timeLeft: cells[7]?.textContent.trim() || 'N/A',
                            };
                        }
                    }
                });
            }

            if (foundData) {
                rfaData = foundData;
                actualCandidateUsername = rfaData.candidate;
            } else {
                throw new Error(`Could not find summary data for ${config.candidateSubpage}`);
            }
            return rfaData;
        } catch (error) {
            console.warn(`RfX Closer: Error fetching or parsing summary data: ${error.info || error.message}. Using defaults.`);
            fetchErrorOccurred = true;
            rfaData = { candidate: actualCandidateUsername, support: '0', oppose: '0', neutral: '0', percent: 'N/A', status: 'N/A', endTime: 'N/A', timeLeft: 'N/A' };
            return rfaData;
        }
    }

    /** Fetches the full wikitext of a given page, using cache. */
    async function fetchPageWikitext(targetPageName) {
        const normalizedPageName = targetPageName.replace(/ /g, '_');
        if (basePageWikitextCache[normalizedPageName]) {
            return basePageWikitextCache[normalizedPageName];
        }

        try {
            const data = await makeApiRequest({
                action: 'query',
                prop: 'revisions',
                titles: normalizedPageName,
                rvslots: 'main',
                rvprop: 'content'
            });
            const page = data.query.pages[0];
            if (page && page.missing) {
                basePageWikitextCache[normalizedPageName] = '';
                return '';
            }
            if (page && page.revisions?.[0]?.slots?.main?.content) {
                const wikitext = page.revisions[0].slots.main.content;
                basePageWikitextCache[normalizedPageName] = wikitext;
                if (normalizedPageName === config.pageName.replace(/ /g, '_')) wikitextErrorOccurred = false;
                return wikitext;
            } else {
                throw new Error(`Could not find wikitext content for page ${normalizedPageName}. Response: ${JSON.stringify(data)}`);
            }
        } catch (error) {
            console.error(`RfX Closer: Error fetching wikitext for ${normalizedPageName}:`, error);
            if (normalizedPageName === config.pageName.replace(/ /g, '_')) wikitextErrorOccurred = true;
            return null;
        }
    }

    /** Specific fetcher for the current RfX page wikitext. */
    const fetchRfXWikitext = () => {
        wikitextErrorOccurred = false; // Reset flag
        return fetchPageWikitext(config.pageName);
    };

    /** API function to check user groups. */
    async function getUserGroups(username) {
        try {
            const data = await makeApiRequest({
                action: 'query', list: 'users', ususers: username, usprop: 'groups'
            });
            const user = data.query?.users?.[0];
            if (user && !user.missing && !user.invalid) {
                const groups = user.groups || [];
                console.log(`RfX Closer getUserGroups: Found groups for ${username}:`, groups);
                return groups;
            } else {
                console.warn(`RfX Closer getUserGroups: User '${username}' not found or invalid.`);
                return null; // Indicate user not found/invalid
            }
        } catch (error) {
            console.error(`RfX Closer getUserGroups: API error checking groups for '${username}'.`, error);
            return null; // Indicate API error
        }
    }

    /** API function to grant/remove rights. */
    function grantPermissionAPI(username, groupToAdd, reason, groupsToRemove = null, expiry = 'infinity') {
        const params = {
            action: 'userrights',
            user: username,
            reason: reason,
            expiry: expiry
        };
        if (groupToAdd) params.add = groupToAdd;
        if (groupsToRemove) params.remove = groupsToRemove; // Expects pipe-separated string
        return makeApiRequest(params, 'post', 'userrights');
    }

    /** Helper function to post a message to a talk page. */
    async function postToTalkPage(targetPage, sectionTitle, messageContent, summary) {
        try {
            await makeApiRequest({
                action: 'edit',
                title: targetPage,
                section: 'new',
                sectiontitle: sectionTitle,
                text: messageContent,
                summary: summary
            }, 'post', 'edit');
            return { success: true, page: targetPage };
        } catch (error) {
            return { success: false, page: targetPage, error: `${error.info} (${error.code})` };
        }
    }

    // --- UI Update Functions ---

    /** Updates the percentage display based on input fields. */
    const updatePercentageDisplay = () => {
        const supportInputEl = document.querySelector(config.selectors.supportInput);
        const opposeInputEl = document.querySelector(config.selectors.opposeInput);
        const percentageDivEl = document.querySelector(config.selectors.percentageDisplay);
        if (!supportInputEl || !opposeInputEl || !percentageDivEl) return;

        const support = parseInt(supportInputEl.value, 10) || 0;
        const oppose = parseInt(opposeInputEl.value, 10) || 0;
        const total = support + oppose;
        const percentage = total > 0 ? (support / total * 100).toFixed(2) : 0;
        percentageDivEl.textContent = `Support percentage: ${percentage}% (${support}/${total})`;
    };

    /** Updates the input fields with fetched or default data. */
    const updateInputFields = async () => {
        try {
            const data = await fetchRfaData(); // Ensures data is fetched/available
            const supportInputEl = document.querySelector(config.selectors.supportInput);
            const opposeInputEl = document.querySelector(config.selectors.opposeInput);
            const neutralInputEl = document.querySelector(config.selectors.neutralInput);

            if (supportInputEl && opposeInputEl && neutralInputEl) {
                supportInputEl.value = data?.support || '';
                opposeInputEl.value = data?.oppose || '';
                neutralInputEl.value = data?.neutral || '';
                updatePercentageDisplay();
            } else {
                console.error("RfX Closer: Could not find input elements to update.");
            }
        } catch (error) {
            // Error already logged by fetchRfaData, just ensure UI shows defaults
            console.error("RfX Closer: Error updating input fields, likely due to fetch failure.");
            updatePercentageDisplay(); // Update percentage based on potentially empty fields
        }
    };

    /** Helper to get current vote counts from input fields. */
    function getCurrentVoteCounts() {
        const supportInputEl = document.querySelector(config.selectors.supportInput);
        const opposeInputEl = document.querySelector(config.selectors.opposeInput);
        const neutralInputEl = document.querySelector(config.selectors.neutralInput);
        return {
            support: supportInputEl?.value || '0',
            oppose: opposeInputEl?.value || '0',
            neutral: neutralInputEl?.value || '0'
        };
    }

    /** Updates count line (e.g., '''X successful candidacies so far''') in wikitext. */
    function updateCountInWikitext(wikitext, countRegex, delta = 1) {
        let updated = false;
        const newWikitext = wikitext.replace(countRegex, (match, openingFormatting, currentCountStr, closingFormatting) => {
            const currentCount = parseInt(currentCountStr, 10);
            if (!isNaN(currentCount)) {
                const newCount = currentCount + delta;
                updated = true;
                const textPart = match.replace(openingFormatting || '', '').replace(currentCountStr, '').replace(closingFormatting || '', '');
                return `${openingFormatting || ''}${newCount}${textPart}${closingFormatting || ''}`;
            }
            return match; // Return original match if count couldn't be parsed
        });
        return updated ? newWikitext : wikitext; // Return original if no update occurred
    }


    // --- Step Description Render Functions (Extracted) ---

    function renderStep0Description() { /* Check Timing */
        const stepContainer = createElement('div');
        stepContainer.appendChild(createElement('p', {
            innerHTML: `Verify that at least 7 days have passed since the listing on <a href="/wiki/${config.baseRfxPage}" target="_blank">${config.displayBaseRfxPage}</a>.`
        }));
        const timingInfoContainer = createElement('div', {
            className: 'rfx-closer-info-box',
            textContent: 'Loading timing info...'
        });
        stepContainer.appendChild(timingInfoContainer);

        fetchRfaData().then(data => {
            if (fetchErrorOccurred) {
                timingInfoContainer.textContent = 'Could not load timing info. Check console for errors.';
                timingInfoContainer.classList.add('error');
            } else {
                const isTooEarly = data.status ? data.status.toLowerCase() === 'open' : true;
                timingInfoContainer.innerHTML = `End Time (UTC): ${data.endTime || 'N/A'}<br>Time Left: ${data.timeLeft || 'N/A'}<br>Status: ${data.status || 'N/A'}`;
                timingInfoContainer.style.backgroundColor = isTooEarly ? '#ffe6e6' : '#e6f3e6';
                timingInfoContainer.style.borderColor = isTooEarly ? '#f5c6cb' : '#c3e6cb';
                timingInfoContainer.classList.remove('error');
            }
        }).catch(() => {
            timingInfoContainer.textContent = 'Error processing timing info.';
            timingInfoContainer.classList.add('error');
        });
        return stepContainer;
    }

    function renderStep1Description() { /* Verify History */
        const historyUrl = `/w/index.php?title=${encodeURIComponent(config.pageName)}&action=history`;
        return createElement('div', {}, [
            createElement('p', { textContent: 'Check the history of the transcluded page to ensure comments are genuine and haven\'t been tampered with.' }),
            createElement('a', {
                href: historyUrl,
                target: '_blank',
                textContent: 'View page history',
                style: { display: 'inline-block', marginTop: '5px', padding: '5px 10px', backgroundColor: '#f8f9fa', border: '1px solid #a2a9b1', borderRadius: '3px', textDecoration: 'none' }
            })
        ]);
    }

    function renderStep2Description() { /* Determine Consensus */
        const stepContainer = createElement('div');
        stepContainer.appendChild(createElement('p', {
            innerHTML: 'Use traditional rules of thumb and your best judgement to determine consensus. Consider:<br>- Vote counts<br>- Quality of arguments<br>- Contributor weight<br>- Concerns raised and resolution'
        }));
        const voteTallyContainer = createElement('div', {
            className: 'rfx-closer-info-box',
            textContent: 'Loading vote tally...'
        });
        stepContainer.appendChild(voteTallyContainer);

        fetchRfaData().then(data => {
            if (fetchErrorOccurred) {
                voteTallyContainer.textContent = 'Could not load vote tally. Check console for errors.';
                voteTallyContainer.classList.add('error');
            } else {
                voteTallyContainer.innerHTML = `<strong>Current Vote Tally:</strong><br>Support: ${data.support || 'N/A'}<br>Oppose: ${data.oppose || 'N/A'}<br>Neutral: ${data.neutral || 'N/A'}<br>Support %: ${data.percent !== 'N/A' ? data.percent + '%' : 'N/A'}`;
                const numericPercent = parseFloat(data.percent);
                if (!isNaN(numericPercent)) {
                    if (numericPercent >= 75) voteTallyContainer.style.backgroundColor = '#e6f3e6';
                    else if (numericPercent >= 65) voteTallyContainer.style.backgroundColor = '#fff3cd';
                    else voteTallyContainer.style.backgroundColor = '#ffe6e6';
                } else {
                    voteTallyContainer.style.backgroundColor = '#f8f9fa';
                }
                voteTallyContainer.style.borderColor = window.getComputedStyle(voteTallyContainer).backgroundColor.replace('rgb', 'rgba').replace(')', ', 0.5)');
                voteTallyContainer.classList.remove('error');
            }
        }).catch(() => {
            voteTallyContainer.textContent = 'Error processing vote tally.';
            voteTallyContainer.classList.add('error');
        });
        return stepContainer;
    }

    function renderStep4Description(selectedOutcome, votes) { /* Prepare RfX Page Wikitext */
        const container = createElement('div');
        const loadingMsg = createElement('p', { textContent: 'Loading and processing wikitext...' });
        container.appendChild(loadingMsg);

        let reason = '', topTemplateName = '', isHoldOutcome = false;
        switch (selectedOutcome) {
            case 'successful': reason = 'successful'; topTemplateName = 'rfap'; break;
            case 'unsuccessful': reason = 'Unsuccessful'; topTemplateName = 'rfaf'; break;
            case 'withdrawn': reason = 'WD'; topTemplateName = 'rfaf'; break;
            case 'notnow': reason = 'NOTNOW'; topTemplateName = 'rfaf'; break;
            case 'snow': reason = 'SNOW'; topTemplateName = 'rfaf'; break;
            case 'onhold': reason = 'On hold'; topTemplateName = 'rfah'; isHoldOutcome = true; break;
            default: return 'This step is not applicable for the selected outcome.';
        }
        const topTemplateCode = `{{subst:${topTemplateName}}}`;

        fetchRfXWikitext().then(wikitext => {
            loadingMsg.remove();
            if (wikitext === null || wikitextErrorOccurred) {
                container.appendChild(createElement('p', { innerHTML: '<strong>Error:</strong> Could not fetch or process the page wikitext. Please perform the template replacements manually. Check console for details.', style: { color: 'red' } }));
                container.appendChild(createElement('p', { innerHTML: `You will need to manually add <code>${topTemplateCode}</code> at the top and potentially perform other closing steps.` }));
                return;
            }

            let modifiedWikitext = wikitext;
            modifiedWikitext = modifiedWikitext.replace(/\{\{(subst:)?(rfap|rfaf|rfah|Rfa withdrawn|Rfa snow)\}\}\s*\n?/i, ''); // Remove existing top templates
            modifiedWikitext = topTemplateCode + "\n" + modifiedWikitext; // Add new top template

            const finaltallyTemplate = `'''Final <span id="rfatally">(?/?/?)</span>; ended by [[User:Barkeep49|Barkeep49]] ([[User_talk:Barkeep49|talk]]) 03:21, 17 April 2025 (UTC)`;
            const footerTemplate = `
:''The above adminship discussion is preserved as an archive of the discussion. <span style="color:red">'''Please do not modify it.'''</span> Subsequent comments should be made on the appropriate discussion page (such as the talk page of either [[{{NAMESPACE}} talk:{{PAGENAME}}|this nomination]] or the nominated user). No further edits should be made to this page.''</div>__ARCHIVEDTALK__ __NOEDITSECTION__
`;
            const chatLink = `*See [[/Bureaucrat chat]].`;
            const escapedUsernamePattern = escapeRegex(actualCandidateUsername);
            const headerLineRegex = new RegExp(`^(\\s*===\\s*.*?(?:\\[\\[(?:User:${escapedUsernamePattern}|${escapedUsernamePattern})\\|${escapedUsernamePattern}\\]\\]|${escapedUsernamePattern}).*?\\s*===\\s*$)`, 'mi');
            let headerMatch = modifiedWikitext.match(headerLineRegex);
            let contentReplaced = false;

            if (headerMatch) {
                const headerEndIndex = headerMatch.index + headerMatch[0].length;
                const nextMarkerRegex = /\n(\s*(?:(?:={2,}.*={2,}$)|(?:'''\[\[Wikipedia:Requests for adminship#Monitors\|Monitors\]\]''':)))/m;
                const searchStringAfterHeader = modifiedWikitext.substring(headerEndIndex);
                const nextMarkerMatch = searchStringAfterHeader.match(nextMarkerRegex);
                if (nextMarkerMatch) {
                    const nextMarkerStartIndex = headerEndIndex + nextMarkerMatch.index;
                    const textBeforeHeaderEnd = modifiedWikitext.substring(0, headerEndIndex);
                    const textAfterMarkerStart = modifiedWikitext.substring(nextMarkerStartIndex);
                    const replacementContent = isHoldOutcome ? `\n${chatLink}\n${finaltallyTemplate}\n` : `\n${finaltallyTemplate}\n`;
                    modifiedWikitext = textBeforeHeaderEnd.trimEnd() + replacementContent + textAfterMarkerStart;
                    contentReplaced = true;
                } else { console.warn("RfX Closer: Could not find next section marker after header."); }
            } else { console.warn("RfX Closer: Could not find candidate header line."); }

            if (contentReplaced) {
                if (!isHoldOutcome) {
                    modifiedWikitext = modifiedWikitext.replace(/\{\{(subst:)?rfab\}\}\s*$/i, ''); // Remove existing footer
                    modifiedWikitext = modifiedWikitext.trim() + "\n" + footerTemplate; // Add new footer
                }
                const introText = isHoldOutcome
                    ? `Ensure <code>${topTemplateCode}</code> is at the top, and the 'Bureaucrat chat' link and final tally are placed correctly below the header.`
                    : `Use the links below to copy the full generated wikitext for this RfX page or open it directly in the editor. <strong>Review carefully before saving.</strong>`;
                container.appendChild(createElement('p', { innerHTML: introText }));
                container.appendChild(createActionLinks(config.pageName, modifiedWikitext, 'Full RfX Page'));
            } else {
                container.appendChild(createElement('p', { innerHTML: '<strong>Warning:</strong> Could not automatically find the section between the candidate header and the next section/Monitors line to replace. Manual replacement needed. Check console for details.', style: { color: 'orange' } }));
                const manualContent = isHoldOutcome ? `${chatLink}\n${finaltallyTemplate}` : finaltallyTemplate;
                container.appendChild(createElement('p', { innerHTML: `Please manually replace the content between the candidate header and the next section with:` }));
                container.appendChild(createCopyableBox(manualContent, 'Copy content to insert manually'));
                if (!isHoldOutcome) {
                    container.appendChild(createElement('p', { innerHTML: `Also ensure <code>${footerTemplate}</code> is at the very bottom.` }));
                }
                container.appendChild(createActionLinks(config.pageName, modifiedWikitext, 'RfX Page (Manual Edit Needed)'));
            }
        }).catch(err => {
            loadingMsg.remove();
            container.appendChild(createElement('p', { innerHTML: '<strong>Error:</strong> Error processing wikitext. Check console.', style: { color: 'red' } }));
            console.error("RfX Closer: Error in wikitext processing chain:", err);
        });
        return container;
    }

    function renderStep5Description() { /* Start Bureaucrat Chat */
        const chatPageName = `${config.pageName}/Bureaucrat chat`;
        const chatPageDisplay = `${config.candidateSubpage}/Bureaucrat chat`;
        const container = createElement('div');

        container.appendChild(createElement('p', {
            innerHTML: `Create the bureaucrat chat page at <a href="${mw.util.getUrl(chatPageName)}" target="_blank">[[${chatPageDisplay}]]</a> with the following content. You can add an optional initial comment below.`
        }));
        container.appendChild(createElement('label', {
            textContent: 'Optional initial comment (will be added under == Discussion ==):',
            htmlFor: 'rfx-crat-chat-textarea', // Link label to textarea
            style: { display: 'block', marginBottom: '4px' }
        }));
        const commentTextarea = createElement('textarea', {
            id: 'rfx-crat-chat-textarea', // Added ID
            className: 'rfx-crat-chat-textarea',
            placeholder: `e.g., Initiating discussion per RfX hold... [[User:Barkeep49|Barkeep49]] ([[User talk:Barkeep49|talk]]) 03:31, 16 April 2025 (UTC)` // Use config.userName
        });
        container.appendChild(commentTextarea);

        const getChatPageWikitext = () => {
            const initialComment = commentTextarea.value.trim();
            return `{{Bureaucrat discussion header}}\n\n== Discussion ==\n${initialComment}\n\n== Recusals ==\n\n== Summary ==`;
        };
        container.appendChild(createActionLinks(chatPageName, getChatPageWikitext, 'Bureaucrat Chat Page', true));
        return container;
    }

    function renderStep6Description() { /* Notify Bureaucrats */
        const container = createElement('div');
        const chatPageName = `${config.pageName}/Bureaucrat chat`;
        const chatPageLink = `[[${chatPageName}|bureaucrat chat]]`;

        container.appendChild(createElement('p', { innerHTML: `Notify bureaucrats about the new chat page. Edit the message and the list of bureaucrats below if needed, then use the API button.` }));

        // Bureaucrat List Textarea
        container.appendChild(createElement('label', {
            htmlFor: config.selectors.cratListTextarea.substring(1), // Remove # for htmlFor
            textContent: 'Bureaucrats to Notify (edit list as needed, one username per line):',
            style: { display: 'block', marginTop: '10px', marginBottom: '4px' }
        }));
        const cratListTextarea = createElement('textarea', {
            id: config.selectors.cratListTextarea.substring(1), // Remove # for ID
            className: 'rfx-onhold-notify-textarea',
            rows: 6,
            value: config.hardcodedCrats.join('\n') // Use config
        });
        container.appendChild(cratListTextarea);

        // Notification Message Textarea
        container.appendChild(createElement('label', {
            htmlFor: config.selectors.cratNotifyMessage.substring(1),
            textContent: 'Notification Message:',
            style: { display: 'block', marginTop: '10px', marginBottom: '4px' }
        }));
        const messageTextarea = createElement('textarea', {
            id: config.selectors.cratNotifyMessage.substring(1),
            className: 'rfx-onhold-notify-textarea',
            value: `== Bureaucrat Chat ==\nYour input is requested at the freshly-created ${chatPageLink} for [[${config.pageName}]]. [[User:Barkeep49|Barkeep49]] ([[User talk:Barkeep49|talk]]) 03:31, 16 April 2025 (UTC)`
        });
        container.appendChild(messageTextarea);

        // Button and Status Area
        const buttonContainer = createElement('div');
        const postButton = createActionButton(config.selectors.cratNotifyButton.substring(1), 'Notify Bureaucrats via API', handleNotifyCratsClick); // Pass handler
        const statusArea = createStatusArea(config.selectors.cratNotifyStatus.substring(1), 'rfx-crat-notify-status');
        buttonContainer.appendChild(postButton);
        buttonContainer.appendChild(statusArea);
        container.appendChild(buttonContainer);

        // Initial Status Update Logic
        const updateInitialStatus = () => {
            const bureaucratsToNotify = cratListTextarea.value.trim().split('\n').map(name => name.trim()).filter(name => name);
            if (bureaucratsToNotify.length > 0) {
                statusArea.textContent = `Ready to notify ${bureaucratsToNotify.length} bureaucrats from the list.`;
                postButton.disabled = false;
            } else {
                statusArea.textContent = 'Bureaucrat list is currently empty.';
                postButton.disabled = true;
            }
            statusArea.style.color = 'inherit';
        };
        cratListTextarea.addEventListener('input', updateInitialStatus);
        updateInitialStatus(); // Set initial state

        return container;
    }

    function renderStep7Description() { /* Notify Candidate (On Hold) */
        const container = createElement('div');
        const candidateTalkPage = `User talk:${actualCandidateUsername}`;
        const chatPageName = `${config.pageName}/Bureaucrat chat`;
        const chatPageLink = `[[${chatPageName}|bureaucrat chat]]`;

        container.appendChild(createElement('p', { innerHTML: `Notify the candidate (${actualCandidateUsername}) about the 'on hold' status.` }));

        const messageTextarea = createElement('textarea', {
            id: config.selectors.candidateOnholdNotifyMessage.substring(1),
            className: 'rfx-onhold-notify-textarea',
            value: `== Your ${config.rfxType === 'adminship' ? 'RfA' : 'RfB'} ==\nHi ${actualCandidateUsername}, just letting you know that your ${config.rfxType} request has been placed on hold pending discussion amongst the bureaucrats. You can follow the discussion at the ${chatPageLink}. [[User:Barkeep49|Barkeep49]] ([[User talk:Barkeep49|talk]]) 03:31, 16 April 2025 (UTC)`
        });
        container.appendChild(messageTextarea);

        const buttonContainer = createElement('div');
        const postButton = createActionButton(config.selectors.candidateOnholdNotifyButton.substring(1), 'Post Notification via API', handleNotifyCandidateOnholdClick); // Pass handler
        const manualEditLink = createElement('a', {
            href: mw.util.getUrl(candidateTalkPage, { action: 'edit', section: 'new', sectiontitle: `Your ${config.rfxType === 'adminship' ? 'RfA' : 'RfB'}` }),
            target: '_blank',
            className: 'rfx-notify-editlink',
            textContent: `Post Manually...`
        });
        const statusArea = createStatusArea(config.selectors.candidateOnholdNotifyStatus.substring(1), 'rfx-candidate-onhold-notify-status');

        buttonContainer.appendChild(postButton);
        buttonContainer.appendChild(manualEditLink);
        buttonContainer.appendChild(statusArea);
        container.appendChild(buttonContainer);

        return container;
    }

    async function renderStep8Description() { /* Process Promotion */
        const container = createElement('div');
        container.appendChild(createElement('p', { textContent: 'For successful RfXs, configure rights changes and grant via API:' }));
        const loadingStatus = createElement('p', { textContent: 'Loading current user groups...', style: { fontStyle: 'italic' } });
        container.appendChild(loadingStatus);
        const rightsContainer = createElement('div');
        container.appendChild(rightsContainer);
        const grantButton = createActionButton('rfx-grant-rights-button', 'Grant Rights via API'); // ID added
        grantButton.disabled = true;
        const statusArea = createStatusArea('rfx-grant-rights-status'); // ID added
        let removeCheckboxes = {}; // To store OOUI checkbox widgets

        try {
            const currentGroups = await getUserGroups(actualCandidateUsername);
            loadingStatus.remove();
            if (currentGroups === null) {
                statusArea.textContent = 'Error: Could not load user groups. Cannot proceed.';
                statusArea.style.color = 'red';
            } else {
                const groupToAdd = config.rfxType === 'adminship' ? 'sysop' : 'bureaucrat';
                const groupToAddLabel = config.rfxType === 'adminship' ? 'Administrator' : 'Bureaucrat';

                // Use OOUI for checkboxes
                const addFieldset = new OO.ui.FieldsetLayout({ label: 'Group to Add' });
                const addCheckbox = new OO.ui.CheckboxInputWidget({ selected: true, disabled: true, classes: ['rfx-closer-checkbox'] });
                addFieldset.addItems([new OO.ui.FieldLayout(addCheckbox, { label: groupToAddLabel, align: 'inline' })]);
                rightsContainer.appendChild(addFieldset.$element[0]);

                if (config.rfxType === 'adminship') {
                    const removeFieldset = new OO.ui.FieldsetLayout({ label: 'Existing rights (Uncheck to keep)' });
                    const groupsToExclude = ['*', 'user', 'autoconfirmed', groupToAdd, 'importer', 'transwiki', 'researcher', 'checkuser', 'suppress'];
                    removeCheckboxes = {}; // Reset
                    let hasRemovable = false;
                    currentGroups.forEach(groupName => {
                        if (!groupsToExclude.includes(groupName)) {
                            hasRemovable = true;
                            const checkbox = new OO.ui.CheckboxInputWidget({ selected: true });
                            removeCheckboxes[groupName] = checkbox; // Store the widget
                            const displayLabel = config.groupDisplayNames[groupName] || groupName;
                            const field = new OO.ui.FieldLayout(checkbox, { label: displayLabel, align: 'inline' });
                            removeFieldset.addItems([field]);
                        }
                    });
                    if (hasRemovable) {
                        rightsContainer.appendChild(removeFieldset.$element[0]);
                    } else {
                        rightsContainer.appendChild(createElement('p', { textContent: 'User holds no other explicit groups to potentially remove.', style: { fontSize: '0.9em', fontStyle: 'italic' } }));
                    }
                } else {
                     rightsContainer.appendChild(createElement('p', { textContent: 'No groups removed when granting bureaucrat rights.', style: { fontSize: '0.9em', fontStyle: 'italic' } }));
                }

                const userGroups = mw.config.get('wgUserGroups');
                const canGrant = userGroups && userGroups.includes('bureaucrat');
                if (canGrant) {
                    grantButton.disabled = false;
                    grantButton.addEventListener('click', () => handleGrantRightsClick(removeCheckboxes)); // Pass checkboxes map
                } else {
                    statusArea.textContent = 'You lack bureaucrat rights to use the API.'; statusArea.style.color = 'orange';
                }
            }
        } catch (error) {
            loadingStatus.remove();
            statusArea.textContent = `Error loading user groups: ${error.message || 'Unknown API error'}.`;
            statusArea.style.color = 'red';
        }

        container.appendChild(grantButton);
        container.appendChild(statusArea);
        const list = createElement('ul', { style: { paddingLeft: '20px', marginTop: '10px' } }, [
            createElement('li', { innerHTML: `Manual link: <a href="/wiki/Special:Userrights/${encodeURIComponent(actualCandidateUsername)}" target="_blank">Special:Userrights/${actualCandidateUsername}</a>` }),
            createElement('li', { innerHTML: `Reference: <a href="/wiki/Special:ListGroupRights" target="_blank">Special:ListGroupRights</a>` })
        ]);
        container.appendChild(list);
        return container;
    }

    async function renderStep9Description(selectedOutcome, votes) { /* Update Lists */
        const container = createElement('div');

        // --- 1. Remove from main RfX list page ---
        const removeDiv = createElement('div', { style: { marginBottom: '15px' } });
        const removeLinkText = config.displayBaseRfxPage;
        removeDiv.appendChild(createElement('p', { innerHTML: `<strong>1. First, remove entry from ${removeLinkText}:</strong>` }));
        const removeLoadingPara = createElement('p', { textContent: ` Loading wikitext for ${removeLinkText}...`, style: { fontStyle: 'italic' } });
        removeDiv.appendChild(removeLoadingPara);
        container.appendChild(removeDiv);

        try {
            const basePageWikitext = await fetchPageWikitext(config.baseRfxPage);
            removeLoadingPara.remove();
            if (basePageWikitext !== null) {
                const escapedPageNameForRegex = config.pageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
                const lineToRemoveRegex = new RegExp(`^.*?\\{\\{(?:${escapedPageNameForRegex.replace(/_/g, '[_ ]')})\\}\\}.*?$\\n(?:^----\\s*\\n)?`, 'gmi');
                const modifiedBasePageWikitext = basePageWikitext.replace(lineToRemoveRegex, '');
                if (modifiedBasePageWikitext.length < basePageWikitext.length) {
                    removeDiv.appendChild(createActionLinks(config.baseRfxPage, modifiedBasePageWikitext, removeLinkText));
                } else {
                    removeDiv.appendChild(createElement('p', { innerHTML: ` Warning: Could not find <code>{{${config.pageName}}}</code> to remove from ${removeLinkText}. Remove manually.`, style: { color: 'orange' } }));
                    removeDiv.appendChild(createActionLinks(config.baseRfxPage, basePageWikitext, removeLinkText)); // Still provide link to edit
                }
            } else { throw new Error(`Failed to fetch wikitext for ${config.baseRfxPage}`); }
        } catch (error) {
            console.error("RfX Closer: Error processing base page wikitext:", error);
            removeLoadingPara.remove();
            removeDiv.appendChild(createElement('p', { textContent: ` Error loading wikitext for ${removeLinkText}. Edit manually.`, style: { color: 'red' } }));
        }

        // --- 2. Add to outcome lists ---
        const addDiv = createElement('div'); // Container for list updates
        addDiv.appendChild(createElement('p', { innerHTML: `<strong>2. Then, add entry to appropriate list(s):</strong>` }));

        let generatedListEntry = null, generatedRfarowTemplate = null, yearlyListPageName = '', alphabeticalListPageName = '', isSuccessfulList = false;
        const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
        const now = new Date(); let day = now.getDate(); let month = months[now.getMonth()]; let year = now.getFullYear(); let formattedDate = `${day} ${month} ${year}`;
        // Attempt to parse date from fetched data
        if (rfaData && rfaData.endTime && rfaData.endTime !== 'N/A') {
            try { const dateParts = rfaData.endTime.match(/(\d{1,2}) (\w+) (\d{4})/); if (dateParts) { formattedDate = `${dateParts[1]} ${dateParts[2]} ${dateParts[3]}`; day = parseInt(dateParts[1], 10); month = dateParts[2]; year = parseInt(dateParts[3], 10); } else { console.warn("Could not parse DD Month YYYY from endTime:", rfaData.endTime); } } catch (e) { console.warn("Error parsing endTime:", rfaData.endTime, e); }
        }

        // Determine list pages and entry formats based on outcome
        if (selectedOutcome === 'successful') {
            isSuccessfulList = true;
            yearlyListPageName = `Wikipedia:Successful ${config.rfxType} candidacies/${year}`;
            generatedRfarowTemplate = `|{{rfarow|${actualCandidateUsername}||${formattedDate}|successful|${votes.support}|${votes.oppose}|${votes.neutral}|${config.userName}}}`;
        } else if (['unsuccessful', 'withdrawn', 'notnow', 'snow'].includes(selectedOutcome)) {
            isSuccessfulList = false;
            let reasonText = 'Unsuccessful', rfarowResult = 'unsuccessful';
            switch(selectedOutcome) {
                case 'withdrawn': reasonText = 'Withdrawn'; rfarowResult = 'Withdrawn'; break;
                case 'notnow': reasonText = '[[WP:NOTNOW|NOTNOW]]'; rfarowResult = 'NOTNOW'; break;
                case 'snow': reasonText = '[[WP:SNOW|SNOW]]'; rfarowResult = 'SNOW'; break;
            }
            yearlyListPageName = `Wikipedia:Unsuccessful ${config.rfxType} candidacies (Chronological)/${year}`;
            const firstLetter = actualCandidateUsername.charAt(0).toUpperCase();
            let alphaBase = `Wikipedia:Unsuccessful ${config.rfxType} candidacies`;
            if (config.rfxType === 'adminship') { alphabeticalListPageName = (firstLetter >= 'A' && firstLetter <= 'Z') ? `${alphaBase}/${firstLetter}` : `${alphaBase} (Alphabetical)`; }
            else { alphabeticalListPageName = `${alphaBase} (Alphabetical)`; }
            const isSubsequentNomination = /[_ ]\d+$/.test(config.pageName);
            generatedListEntry = `${isSubsequentNomination ? '*:' : '*'} [[${config.pageName}|${actualCandidateUsername}]] ${formattedDate} - ${reasonText} ([[User:${config.userName}|${config.userName}]]) (${votes.support}/${votes.oppose}/${votes.neutral})`;
            generatedRfarowTemplate = `|{{rfarow|${actualCandidateUsername}||${formattedDate}|${rfarowResult}|${votes.support}|${votes.oppose}|${votes.neutral}|${config.userName}}}`;
        }

        // --- Handle Yearly Page Update ---
        if (yearlyListPageName && generatedRfarowTemplate) {
            const yearlyDiv = createElement('div', { style: { marginTop: '10px' } });
            yearlyDiv.appendChild(createElement('p', { innerHTML: `2a. Update <a href="/wiki/${yearlyListPageName}" target="_blank">${yearlyListPageName}</a>:` }));
            yearlyDiv.appendChild(createElement('div', { className: 'rfx-closer-known-issue', innerHTML: `<strong>Known Issue:</strong> The script may fail to automatically uncomment the correct month header if it's currently commented out. Please double-check the yearly list page and manually uncomment the header (e.g., remove <code>&lt;!--</code> and <code>--&gt;</code>) if needed before saving.` }));
            const yearlyLoading = createElement('p', { textContent: ` Loading...`, style: { fontStyle: 'italic' } });
            yearlyDiv.appendChild(yearlyLoading);
            addDiv.appendChild(yearlyDiv);

            try {
                const yearlyWikitext = await fetchPageWikitext(yearlyListPageName);
                yearlyLoading.remove();
                if (yearlyWikitext !== null) {
                    let modifiedYearlyWikitext = yearlyWikitext; let modificationPerformed = false; let noteText = ''; let entryAdded = false; let countUpdated = false; let uncommentAttempted = false;
                    const countRegex = isSuccessfulList ? /(\'\'\'?)(\d+)\s+successful candidacies so far(\'\'\'?)/i : /(\'\'\'?)(\d+)\s+unsuccessful candidacies so far(\'\'\'?)/i;
                    const originalWikitextForCountCheck = modifiedYearlyWikitext;
                    modifiedYearlyWikitext = updateCountInWikitext(modifiedYearlyWikitext, countRegex, 1);
                    if (modifiedYearlyWikitext !== originalWikitextForCountCheck) { modificationPerformed = true; countUpdated = true; } else { console.warn(`RfX Closer: Could not update count on ${yearlyListPageName}`); }

                    const escapedMonth = month.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
                    // Updated regex to match both commented and uncommented month headers
                    const monthHeaderRegex = new RegExp(`^(?:<!--)?\\|-\\s*\\n\\|\\s*colspan="[\\d]+"\\s*style="background:#ffdead;"\\s*\\|\\s*'''\\{\\{Anchord\\|${escapedMonth}\\s+${year}\\}\\}'''.*?$(?:\\n-->)?`, 'mi');
                    let insertionIndex = -1;
                    let headerFound = false;
                    let originalWikitextForInsertionCheck = modifiedYearlyWikitext;
                    let headerMatch = modifiedYearlyWikitext.match(monthHeaderRegex);

                    if (headerMatch) {
                        headerFound = true;
                        const fullMatchText = headerMatch[0];
                        const headerStartIndex = headerMatch.index;
                        const isCommented = fullMatchText.startsWith('<!--');

                        if (isCommented) {
                            uncommentAttempted = true;
                            console.warn(`RfX Closer: Attempting to uncomment month header for ${month} ${year}.`);
                            // Find the end of the commented section
                            const commentEndIndex = modifiedYearlyWikitext.indexOf('-->', headerStartIndex);
                            if (commentEndIndex !== -1) {
                                // Extract the content between <!-- and -->
                                const commentedContent = modifiedYearlyWikitext.substring(headerStartIndex + 4, commentEndIndex);
                                // Replace the entire commented section with the uncommented content
                                modifiedYearlyWikitext = modifiedYearlyWikitext.substring(0, headerStartIndex) + 
                                                       commentedContent + 
                                                       modifiedYearlyWikitext.substring(commentEndIndex + 3);
                                modificationPerformed = true;
                                // Find the next |- after the header
                                const nextRowIndex = modifiedYearlyWikitext.indexOf('|-', headerStartIndex);
                                insertionIndex = nextRowIndex !== -1 ? nextRowIndex : headerStartIndex + commentedContent.length;
                            } else {
                                headerFound = false;
                                console.error(`RfX Closer: Failed to find end of comment for ${month} ${year} header.`);
                            }
                        } else {
                            // For uncommented headers, find the next |- or end of the section
                            const nextRowIndex = modifiedYearlyWikitext.indexOf('|-', headerStartIndex + fullMatchText.length);
                            insertionIndex = nextRowIndex !== -1 ? nextRowIndex : headerStartIndex + fullMatchText.length;
                        }
                    } else {
                        headerFound = false;
                        console.warn(`RfX Closer: Could not find header for ${month} ${year} on ${yearlyListPageName}.`);
                    }

                    if (headerFound && insertionIndex !== -1) {
                        // Ensure we have a newline before inserting
                        if (insertionIndex > 0 && modifiedYearlyWikitext[insertionIndex - 1] !== '\n') {
                            modifiedYearlyWikitext = modifiedYearlyWikitext.substring(0, insertionIndex) + '\n' + modifiedYearlyWikitext.substring(insertionIndex);
                            insertionIndex++;
                        }
                        const insertionContent = '|-\n' + generatedRfarowTemplate + '\n';
                        modifiedYearlyWikitext = modifiedYearlyWikitext.substring(0, insertionIndex) + insertionContent + modifiedYearlyWikitext.substring(insertionIndex);
                        if (modifiedYearlyWikitext !== originalWikitextForInsertionCheck) {
                            modificationPerformed = true;
                        }
                        entryAdded = true;
                        console.log(`RfX Closer: Entry added to ${yearlyListPageName} at position ${insertionIndex}.`);
                    } else if (!headerFound) {
                        console.warn(`RfX Closer: Row not inserted into yearly list because header was not found.`);
                    }

                    noteText = ` `;
                    if (countUpdated && entryAdded) { noteText += `Count updated and entry added.`; }
                    else if (countUpdated && !entryAdded) { noteText += `Count updated (could not add entry).`; }
                    else if (!countUpdated && entryAdded) { noteText += `Entry added (could not update count).`; }
                    else if (!countUpdated && !entryAdded) { noteText = ` Could not automatically update ${yearlyListPageName}.`; }
                    if (uncommentAttempted && !entryAdded) { noteText += ` (Failed to uncomment month header or insert entry).`; }
                    else if (uncommentAttempted) { noteText += ` (Attempted to uncomment month header - please verify).`; }

                    yearlyDiv.appendChild(createElement('p', { innerHTML: noteText }));
                    if (modificationPerformed) {
                        yearlyDiv.appendChild(createActionLinks(yearlyListPageName, modifiedYearlyWikitext, yearlyListPageName));
                    }
                    if (!entryAdded) { yearlyDiv.appendChild(createCopyableBox(generatedRfarowTemplate, `Copy row template to manually insert`)); }
                } else { throw new Error(`Failed to fetch wikitext for ${yearlyListPageName}`); }
            } catch (error) {
                console.error("RfX Closer: Error processing yearly list:", error);
                yearlyLoading.remove();
                yearlyDiv.appendChild(createElement('p', { innerHTML: ` Error processing ${yearlyListPageName}. Update manually.`, style: { color: 'red' } }));
                if (generatedRfarowTemplate) { yearlyDiv.appendChild(createCopyableBox(generatedRfarowTemplate, `Copy row template to manually insert`)); }
            }
        }

        // --- Handle Alphabetical List Page Update ---
        if (alphabeticalListPageName && !isSuccessfulList && generatedListEntry) {
            const alphaDiv = createElement('div', { style: { marginTop: '15px' } });
            alphaDiv.appendChild(createElement('p', { innerHTML: `2b. Update <a href="/wiki/${alphabeticalListPageName}" target="_blank">${alphabeticalListPageName}</a>:` }));
            const alphaLoading = createElement('p', { textContent: ` Loading...`, style: { fontStyle: 'italic' } });
            alphaDiv.appendChild(alphaLoading);
            addDiv.appendChild(alphaDiv);

            try {
                const alphaWikitext = await fetchPageWikitext(alphabeticalListPageName);
                alphaLoading.remove();
                if (alphaWikitext !== null) {
                    const lines = alphaWikitext.split('\n'); let insertionLineIndex = -1; const newCandidateName = actualCandidateUsername; const lineRegex = /^\s*([*:]*)\s*\[\[(?:[^|]+\|)?([^\]]+)\]\]/i;
                    for (let i = 0; i < lines.length; i++) { const line = lines[i]; const match = line.match(lineRegex); if (match) { const existingUsername = match[2].trim(); if (newCandidateName.localeCompare(existingUsername, undefined, { sensitivity: 'base' }) < 0) { insertionLineIndex = i; break; } } }

                    let modifiedAlphaWikitext; let alphaNoteText = ` `;
                    if (insertionLineIndex !== -1) { lines.splice(insertionLineIndex, 0, generatedListEntry); modifiedAlphaWikitext = lines.join('\n'); alphaNoteText += `Entry inserted alphabetically.`; }
                    else { let lastRelevantIndex = -1; for (let i = lines.length - 1; i >= 0; i--) { if (lines[i].match(lineRegex)) { lastRelevantIndex = i; break; } } if (lastRelevantIndex !== -1) { lines.splice(lastRelevantIndex + 1, 0, generatedListEntry); } else { if (alphaWikitext.length > 0 && !alphaWikitext.endsWith('\n')) { lines.push(''); } lines.push(generatedListEntry); } modifiedAlphaWikitext = lines.join('\n'); alphaNoteText += `Entry appended.`; }

                    alphaDiv.appendChild(createElement('p', { innerHTML: alphaNoteText }));
                    alphaDiv.appendChild(createActionLinks(alphabeticalListPageName, modifiedAlphaWikitext, alphabeticalListPageName));
                } else { throw new Error(`Failed to fetch wikitext for ${alphabeticalListPageName}`); }
            } catch (error) {
                console.error("RfX Closer: Error processing alphabetical list:", error);
                alphaLoading.remove();
                alphaDiv.appendChild(createElement('p', { innerHTML: ` Error processing ${alphabeticalListPageName}. Update manually.`, style: { color: 'red' } }));
                if (generatedListEntry) { alphaDiv.appendChild(createCopyableBox(generatedListEntry, `Copy entry to manually insert`)); }
            }
        }

        container.appendChild(addDiv);
        return container;
    }

    function renderStep10Description(selectedOutcome) { /* Notify Candidate (Closing) */
        if (selectedOutcome === 'onhold') {
            return '(Notification for "on hold" is handled in Step 7.)';
        }

        const container = createElement('div');
        const candidateTalkPage = `User talk:${actualCandidateUsername}`;
        const sectionTitle = `Outcome of your ${config.rfxType === 'adminship' ? 'RfA' : 'RfB'}`;
        const editUrl = mw.util.getUrl(candidateTalkPage, { action: 'edit', section: 'new', sectiontitle: sectionTitle });

        container.appendChild(createElement('p', { innerHTML: `Prepare message for <a href="/wiki/${encodeURIComponent(candidateTalkPage)}" target="_blank">${candidateTalkPage}</a>.` }));

        const optionsDiv = createElement('div', { className: 'rfx-notify-options' });
        container.appendChild(optionsDiv);
        const messageTextarea = createElement('textarea', { className: 'rfx-notify-textarea', rows: 5 });
        const copyBoxPlaceholder = createElement('div'); // Placeholder for copy box/links
        const buttonContainer = createElement('div', { style: { marginTop: '10px' } });
        const statusArea = createStatusArea('rfx-notify-status-closing', 'rfx-notify-status'); // New ID
        let messageContentGetter = () => ''; // Function to get current message content

        if (selectedOutcome === 'successful') {
            const templateName = config.rfxType === 'adminship' ? 'New sysop' : 'New bureaucrat';
            const defaultMessage = `{{subst:${templateName}}} [[User:${config.userName}|${config.userName}]] ([[User talk:${config.userName}|talk]]) 03:31, 16 April 2025 (UTC)`;

            const radioTemplateId = 'rfx-notify-template-radio';
            const radioCustomId = 'rfx-notify-custom-radio';
            const radioTemplate = createElement('input', { type: 'radio', name: 'rfx-notify-choice', id: radioTemplateId, value: 'template', checked: true });
            const radioCustom = createElement('input', { type: 'radio', name: 'rfx-notify-choice', id: radioCustomId, value: 'custom' });
            optionsDiv.appendChild(createElement('label', { htmlFor: radioTemplateId }, [radioTemplate, ` Use {{${templateName}}}`]));
            optionsDiv.appendChild(createElement('label', { htmlFor: radioCustomId }, [radioCustom, ' Use custom message']));

            messageTextarea.value = `Congratulations! Your ${config.rfxType === 'adminship' ? 'RfA' : 'RfB'} was successful. [[User:${config.userName}|${config.userName}]] ([[User talk:${config.userName}|talk]]) 03:31, 16 April 2025 (UTC)~`;
            messageTextarea.style.display = 'none';
            container.appendChild(messageTextarea);
            container.appendChild(copyBoxPlaceholder);

            const updateSuccessActions = () => {
                copyBoxPlaceholder.innerHTML = ''; // Clear previous
                if (radioTemplate.checked) {
                    messageContentGetter = () => defaultMessage;
                    copyBoxPlaceholder.appendChild(createActionLinks(null, defaultMessage, `{{${templateName}}}`)); // No edit link needed
                } else {
                    messageContentGetter = () => messageTextarea.value;
                    copyBoxPlaceholder.appendChild(createCopyableBox(null, 'Copy the message above', false, messageTextarea));
                }
            };
            radioTemplate.addEventListener('change', () => { messageTextarea.style.display = 'none'; updateSuccessActions(); });
            radioCustom.addEventListener('change', () => { messageTextarea.style.display = 'block'; updateSuccessActions(); });
            updateSuccessActions(); // Initial call

        } else { // Unsuccessful outcomes
            let reasonPhrase = 'unsuccessful';
            switch(selectedOutcome) {
                case 'withdrawn': reasonPhrase = 'withdrawn by candidate'; break;
                case 'notnow': reasonPhrase = 'closed as [[WP:NOTNOW|NOTNOW]]'; break;
                case 'snow': reasonPhrase = 'closed per [[WP:SNOW|SNOW]]'; break;
            }
            const defaultMessage = `Hi ${actualCandidateUsername}. I'm ${config.userName} and I have closed your ${config.rfxType === 'adminship' ? 'RfA' : 'RfB'} as ${reasonPhrase}. Thanks for submitting your candidacy. I hope the feedback left by editors is useful and know that many people have successfully gained ${config.rfxType === 'adminship' ? 'administrator' : 'bureaucrat'} rights after initially being unsuccessful. [[User:Barkeep49|Barkeep49]] ([[User talk:Barkeep49|talk]]) 03:31, 16 April 2025 (UTC)`;
            messageTextarea.value = defaultMessage;
            container.appendChild(messageTextarea);
            container.appendChild(copyBoxPlaceholder);
            messageContentGetter = () => messageTextarea.value;
            copyBoxPlaceholder.appendChild(createCopyableBox(null, 'Copy the message above', false, messageTextarea));
        }

        // --- API Post Button ---
        const postButton = createActionButton('rfx-notify-candidate-closing-button', 'Post Notification via API', () => handleNotifyCandidateClosingClick(messageContentGetter)); // New ID, pass getter
        buttonContainer.appendChild(postButton);

        // Add Manual Edit Link
        const manualEditLink = createElement('a', { href: editUrl, target: '_blank', className: 'rfx-notify-editlink', textContent: `Post Manually...` });
        buttonContainer.appendChild(manualEditLink);

        container.appendChild(buttonContainer);
        container.appendChild(statusArea);

        return container;
    }

    // --- Step Definitions Array ---
    const steps = [
        { title: 'Check Timing', description: renderStep0Description, completed: false }, // 0
        { title: 'Verify History', description: renderStep1Description, completed: false }, // 1
        { title: 'Determine Consensus', description: renderStep2Description, completed: false }, // 2
        { title: 'Select Outcome', description: 'Based on the consensus determination, select the appropriate outcome:', completed: false, isSelectionStep: true }, // 3
        { title: 'Prepare RfX Page Wikitext', description: renderStep4Description, completed: false, showFor: ['successful', 'unsuccessful', 'withdrawn', 'notnow', 'snow', 'onhold'] }, // 4
        { title: 'Start Bureaucrat Chat', description: renderStep5Description, completed: false, showFor: ['onhold'] }, // 5
        { title: 'Notify Bureaucrats', description: renderStep6Description, completed: false, showFor: ['onhold'] }, // 6
        { title: 'Notify Candidate (On Hold)', description: renderStep7Description, completed: false, showFor: ['onhold'] }, // 7
        { title: 'Process Promotion', description: renderStep8Description, completed: false, showFor: ['successful'] }, // 8
        { title: 'Update Lists', description: renderStep9Description, completed: false, showFor: ['successful', 'unsuccessful', 'withdrawn', 'notnow', 'snow'] }, // 9
        { title: 'Notify Candidate (Closing)', description: renderStep10Description, completed: false, showFor: ['successful', 'unsuccessful', 'withdrawn', 'notnow', 'snow'] } // 10
    ];


    // --- Step Rendering and Logic ---

    /** Renders a single step based on current state. */
    function renderStep(step, index, selectedOutcome = '', currentVotes = {}) {
        const stepElement = createElement('div', {
            className: 'rfx-closer-step',
            dataset: { step: index, completed: step.completed }
        });
        const titleElement = createElement('h3');
        const descriptionContainer = createElement('div', { className: 'rfx-closer-step-description' });
        const displayIndex = index + 1; // 1-based index for display

        if (step.isSelectionStep) {
            titleElement.textContent = `${displayIndex}. ${step.title}`;
            descriptionContainer.textContent = step.description;
            const templateSelector = createElement('select', { id: config.selectors.outcomeSelector.substring(1) }); // Remove # for ID
            templateSelector.innerHTML = `
                <option value="" disabled selected>-- Select Outcome --</option>
                <option value="successful">Successful</option>
                <option value="unsuccessful">Unsuccessful</option>
                <option value="withdrawn">Withdrawn</option>
                <option value="notnow">NOTNOW</option>
                <option value="snow">SNOW</option>
                <option value="onhold">On hold</option>`;
            templateSelector.addEventListener('change', handleOutcomeChange); // Attach listener
            stepElement.appendChild(titleElement);
            stepElement.appendChild(descriptionContainer);
            stepElement.appendChild(templateSelector);
        } else {
            const checkbox = createElement('input', { type: 'checkbox', checked: step.completed, dataset: { stepIndex: index } });
            checkbox.addEventListener('change', (e) => {
                const stepIndex = parseInt(e.target.dataset.stepIndex, 10);
                steps[stepIndex].completed = e.target.checked;
                stepElement.dataset.completed = steps[stepIndex].completed;
            });
            titleElement.appendChild(checkbox);
            titleElement.appendChild(document.createTextNode(` ${displayIndex}. ${step.title}`)); // Add space

            // Handle step description rendering (sync or async)
            Promise.resolve().then(() => {
                if (typeof step.description === 'function') {
                    if (!step.showFor || step.showFor.includes(selectedOutcome)) {
                        return step.description(selectedOutcome, currentVotes); // Call the specific render function
                    } else {
                        // Provide specific messages for hidden steps
                        if (selectedOutcome === 'onhold' && (index === 9 || index === 10)) return '(This step is not applicable for the "on hold" outcome.)';
                        if (selectedOutcome !== 'successful' && index === 8) return '(This step is only applicable for successful outcomes.)';
                        if (selectedOutcome !== 'onhold' && (index === 5 || index === 6 || index === 7)) return '(This step is only applicable for the "on hold" outcome.)';
                        return 'This step is not applicable for the selected outcome.';
                    }
                } else {
                    return step.description; // Should not happen with current structure
                }
            }).then(content => {
                descriptionContainer.innerHTML = ''; // Clear previous content
                if (content instanceof Node) {
                    descriptionContainer.appendChild(content);
                } else {
                    descriptionContainer.textContent = content || '';
                }
            }).catch(error => {
                console.error(`Error rendering description for step ${displayIndex}:`, error);
                descriptionContainer.textContent = 'Error loading step content. Check console.';
                descriptionContainer.style.color = 'red';
            });

            stepElement.appendChild(titleElement);
            stepElement.appendChild(descriptionContainer);
        }

        // Determine visibility
        if (index <= 3) { // Steps 0-3 always show initially
            stepElement.style.display = 'block';
        } else {
            const shouldShow = (index === 4 && selectedOutcome) || (step.showFor && step.showFor.includes(selectedOutcome));
            stepElement.style.display = shouldShow ? 'block' : 'none';
        }

        return stepElement;
    }

    /** Renders all steps based on the selected outcome. */
    function renderAllSteps(selectedOutcome = '', currentVotes = {}) {
        const stepsContainerEl = document.querySelector(config.selectors.stepsContainer);
        if (!stepsContainerEl) return;
        stepsContainerEl.innerHTML = ''; // Clear existing steps
        steps.forEach((step, index) => {
            const stepElement = renderStep(step, index, selectedOutcome, currentVotes);
            stepsContainerEl.appendChild(stepElement);
        });
        // Re-attach selector value
        const selector = document.querySelector(config.selectors.outcomeSelector);
        if (selector) selector.value = selectedOutcome;
    }

    // --- Event Handlers ---

    /** Handles clicks on the "Notify Bureaucrats" button (Step 6). */
    async function handleNotifyCratsClick() {
        const postButton = document.querySelector(config.selectors.cratNotifyButton);
        const statusArea = document.querySelector(config.selectors.cratNotifyStatus);
        const cratListTextarea = document.querySelector(config.selectors.cratListTextarea);
        const messageTextarea = document.querySelector(config.selectors.cratNotifyMessage);
        if (!postButton || !statusArea || !cratListTextarea || !messageTextarea) return;

        postButton.disabled = true;
        statusArea.innerHTML = 'Starting notifications... (This may take a while)<ul id="crat-notify-progress"></ul>';
        const progressList = statusArea.querySelector('#crat-notify-progress'); // Get list element
        const messageContent = messageTextarea.value.trim();
        const editSummary = `Notification: Bureaucrat chat created for [[${config.pageName}]]${config.tagLine}`;
        const sectionTitle = `Bureaucrat chat for ${actualCandidateUsername}`;
        let successCount = 0, failCount = 0;

        const bureaucratsToNotify = cratListTextarea.value.trim().split('\n').map(name => name.trim()).filter(name => name);

        if (bureaucratsToNotify.length === 0) {
            statusArea.textContent = 'Error: Bureaucrat list is empty.'; statusArea.style.color = 'red';
            postButton.disabled = false; if (progressList) progressList.remove(); return;
        }
        if (!messageContent) {
            statusArea.textContent = 'Error: Message cannot be empty.'; statusArea.style.color = 'red';
            postButton.disabled = false; if (progressList) progressList.remove(); return;
        }

        for (const cratUsername of bureaucratsToNotify) {
            if (!cratUsername || cratUsername.includes(':') || cratUsername.includes('/') || cratUsername.includes('#')) {
                const progressItem = createElement('li', { textContent: `✗ Skipping invalid username: ${cratUsername}`, className: 'warning' });
                if (progressList) progressList.appendChild(progressItem);
                failCount++; continue;
            }

            const talkPage = `User talk:${cratUsername}`;
            const progressItem = createElement('li', { textContent: `Notifying ${cratUsername}...` });
            if (progressList) progressList.appendChild(progressItem);
            statusArea.scrollTop = statusArea.scrollHeight;

            const result = await postToTalkPage(talkPage, sectionTitle, messageContent, editSummary);
            if (result.success) {
                progressItem.textContent = `✓ Notified ${cratUsername}`; progressItem.classList.add('success'); successCount++;
            } else {
                progressItem.textContent = `✗ Failed ${cratUsername}: ${result.error}`; progressItem.classList.add('error'); failCount++;
            }
            await sleep(300); // Delay
        }

        const finalStatus = createElement('p', {
            textContent: `Finished: ${successCount} successful, ${failCount} failed.`,
            style: { fontWeight: 'bold', color: failCount > 0 ? 'orange' : 'green' }
        });
        statusArea.appendChild(finalStatus);
        statusArea.scrollTop = statusArea.scrollHeight;

        if (failCount === 0) { // Only mark complete if all succeed
            const stepElement = postButton.closest(config.selectors.stepElement);
            const stepIndex = parseInt(stepElement?.dataset.step, 10);
            if (stepElement && !isNaN(stepIndex) && steps[stepIndex]) {
                steps[stepIndex].completed = true; stepElement.dataset.completed = true;
                const checkbox = stepElement.querySelector(config.selectors.stepCheckbox); if (checkbox) checkbox.checked = true;
            }
            postButton.textContent = 'Notifications Sent'; // Keep disabled on full success
        } else {
            postButton.disabled = false; // Re-enable if some failed
        }
    }

    /** Handles clicks on the "Notify Candidate (On Hold)" button (Step 7). */
    async function handleNotifyCandidateOnholdClick() {
        const postButton = document.querySelector(config.selectors.candidateOnholdNotifyButton);
        const statusArea = document.querySelector(config.selectors.candidateOnholdNotifyStatus);
        const messageTextarea = document.querySelector(config.selectors.candidateOnholdNotifyMessage);
        if (!postButton || !statusArea || !messageTextarea) return;

        postButton.disabled = true;
        statusArea.textContent = 'Posting message...'; statusArea.style.color = 'inherit';
        const messageContent = messageTextarea.value.trim();
        const candidateTalkPage = `User talk:${actualCandidateUsername}`;
        const editSummary = `Notifying candidate about RfX hold${config.tagLine}`;
        const sectionTitle = `Your ${config.rfxType === 'adminship' ? 'RfA' : 'RfB'}`;

        if (!messageContent) {
            statusArea.textContent = 'Error: Message cannot be empty.'; statusArea.style.color = 'red';
            postButton.disabled = false; return;
        }

        const result = await postToTalkPage(candidateTalkPage, sectionTitle, messageContent, editSummary);
        if (result.success) {
            statusArea.textContent = 'Success! Message posted to candidate talk page.'; statusArea.style.color = 'green';
            const stepElement = postButton.closest(config.selectors.stepElement);
            const stepIndex = parseInt(stepElement?.dataset.step, 10);
            if (stepElement && !isNaN(stepIndex) && steps[stepIndex]) {
                steps[stepIndex].completed = true; stepElement.dataset.completed = true;
                const checkbox = stepElement.querySelector(config.selectors.stepCheckbox); if (checkbox) checkbox.checked = true;
            }
            postButton.textContent = 'Posted'; // Keep disabled on success
        } else {
            statusArea.textContent = `Error posting message: ${result.error}. Please post manually.`; statusArea.style.color = 'red';
            postButton.disabled = false; // Re-enable on error
        }
    }

    /** Handles clicks on the "Grant Rights" button (Step 8). */
    async function handleGrantRightsClick(removeCheckboxesMap) {
        const grantButton = document.getElementById('rfx-grant-rights-button'); // Use specific ID
        const statusArea = document.getElementById('rfx-grant-rights-status'); // Use specific ID
        if (!grantButton || !statusArea) return;

        grantButton.disabled = true; statusArea.textContent = 'Processing...'; statusArea.style.color = 'inherit';
        const groupToAdd = config.rfxType === 'adminship' ? 'sysop' : 'bureaucrat';
        const promotionReason = `Per [[${config.pageName}|successful RfX]]${config.tagLine}`;
        const groupsToRemoveList = Object.entries(removeCheckboxesMap)
            .filter(([groupName, checkboxWidget]) => checkboxWidget.isSelected())
            .map(([groupName]) => groupName);
        const groupsToRemoveString = groupsToRemoveList.length > 0 ? groupsToRemoveList.join('|') : null;

        console.log("RfX Closer: Attempting grant.", { add: groupToAdd, remove: groupsToRemoveString });
        try {
            const currentGroups = await getUserGroups(actualCandidateUsername); // Re-check just before granting
            const alreadyHasGroup = currentGroups && currentGroups.includes(groupToAdd);

            if (alreadyHasGroup && !groupsToRemoveString) {
                statusArea.textContent = `User already has '${groupToAdd}'. No action taken.`; statusArea.style.color = 'blue'; grantButton.disabled = false; return;
            }

            const data = await grantPermissionAPI(actualCandidateUsername, alreadyHasGroup ? null : groupToAdd, promotionReason, groupsToRemoveString);
            console.log('Userrights API Success Response:', data); statusArea.textContent = 'API call successful. Verifying...';

            const finalGroups = await getUserGroups(actualCandidateUsername); // Verify after grant
            let finalMessage = "", finalColor = "orange", stepCompleted = false;

            if (finalGroups && finalGroups.includes(groupToAdd)) {
                finalMessage = `Success! User now has '${groupToAdd}'. `; finalColor = 'green'; stepCompleted = true;
            } else if (finalGroups) {
                if (alreadyHasGroup && groupsToRemoveString) { finalMessage = `User already had '${groupToAdd}'. `; finalColor = 'green'; stepCompleted = true; }
                else { finalMessage = `API OK, but user does NOT have '${groupToAdd}'! Manual check required. `; finalColor = 'red'; }
            } else { finalMessage = `API OK, but could not verify final groups. Manual check required. `; finalColor = 'orange'; }

            const actuallyRemoved = data.userrights?.removed?.map(g => g.group) || [];
            if (groupsToRemoveString) {
                finalMessage += (actuallyRemoved.length > 0) ? `Removed: ${actuallyRemoved.join(', ')}.` : ` (No selected groups were removed).`;
            }
            statusArea.textContent = finalMessage; statusArea.style.color = finalColor;

            if (stepCompleted) {
                const stepElement = grantButton.closest(config.selectors.stepElement);
                const stepIndex = parseInt(stepElement?.dataset.step, 10);
                if (stepElement && !isNaN(stepIndex) && steps[stepIndex]) {
                    steps[stepIndex].completed = true; stepElement.dataset.completed = true;
                    const checkbox = stepElement.querySelector(config.selectors.stepCheckbox); if (checkbox) checkbox.checked = true;
                }
            }
        } catch (error) {
            console.error('Userrights API Error:', error);
            statusArea.textContent = `Error: ${error.info || 'Unknown API error'}. Please grant manually.`; statusArea.style.color = 'red';
        } finally {
            grantButton.disabled = false; // Always re-enable button unless specific success case handled above
        }
    }

    /** Handles clicks on the "Notify Candidate (Closing)" button (Step 10). */
    async function handleNotifyCandidateClosingClick(messageGetter) {
        const postButton = document.getElementById('rfx-notify-candidate-closing-button'); // Use specific ID
        const statusArea = document.getElementById('rfx-notify-status-closing'); // Use specific ID
        if (!postButton || !statusArea || !messageGetter) return;

        postButton.disabled = true;
        statusArea.textContent = 'Posting message...'; statusArea.style.color = 'inherit';
        const messageContent = messageGetter(); // Get current message from the appropriate source
        const candidateTalkPage = `User talk:${actualCandidateUsername}`;
        const editSummary = `Notifying candidate of ${config.rfxType === 'adminship' ? 'RfA' : 'RfB'} outcome${config.tagLine}`;
        const sectionTitle = `Outcome of your ${config.rfxType === 'adminship' ? 'RfA' : 'RfB'}`;

        if (!messageContent) {
            statusArea.textContent = 'Error: Message cannot be empty.'; statusArea.style.color = 'red';
            postButton.disabled = false; return;
        }

        const result = await postToTalkPage(candidateTalkPage, sectionTitle, messageContent, editSummary);
        if (result.success) {
            statusArea.textContent = 'Success! Message posted to talk page.'; statusArea.style.color = 'green';
            const stepElement = postButton.closest(config.selectors.stepElement);
            const stepIndex = parseInt(stepElement?.dataset.step, 10);
            if (stepElement && !isNaN(stepIndex) && steps[stepIndex]) {
                steps[stepIndex].completed = true; stepElement.dataset.completed = true;
                const checkbox = stepElement.querySelector(config.selectors.stepCheckbox); if (checkbox) checkbox.checked = true;
            }
            postButton.textContent = 'Posted'; // Keep disabled on success
        } else {
            statusArea.textContent = `Error posting message: ${result.error}. Please post manually.`; statusArea.style.color = 'red';
            postButton.disabled = false; // Re-enable on error
        }
    }

    /** Handles change in the outcome selector dropdown (Step 3). */
    function handleOutcomeChange(event) {
        const selectedOutcome = event.target.value;
        const currentVotes = getCurrentVoteCounts();

        // Mark step 3 as completed if an outcome is selected
        if (steps[3]) steps[3].completed = selectedOutcome !== '';

        const isClosingOutcome = selectedOutcome && !['', 'onhold'].includes(selectedOutcome);
        const promises = [fetchRfaData()]; // Always ensure summary data

        // Always fetch RfX page itself if an outcome is selected (for Step 4)
        if (selectedOutcome) promises.push(fetchRfXWikitext());

        // Pre-fetch pages needed for closing lists (Step 9)
        if (isClosingOutcome) {
            promises.push(fetchPageWikitext(config.baseRfxPage));
            const year = new Date().getFullYear(); // Use current year
            let yearlyListPageName = '', alphabeticalListPageName = '';
            if (selectedOutcome === 'successful') {
                yearlyListPageName = `Wikipedia:Successful ${config.rfxType} candidacies/${year}`;
            } else { // Unsuccessful outcomes
                yearlyListPageName = `Wikipedia:Unsuccessful ${config.rfxType} candidacies (Chronological)/${year}`;
                const firstLetter = actualCandidateUsername.charAt(0).toUpperCase();
                let alphaBase = `Wikipedia:Unsuccessful ${config.rfxType} candidacies`;
                if (config.rfxType === 'adminship') { alphabeticalListPageName = (firstLetter >= 'A' && firstLetter <= 'Z') ? `${alphaBase}/${firstLetter}` : `${alphaBase} (Alphabetical)`; }
                else { alphabeticalListPageName = `${alphaBase} (Alphabetical)`; }
            }
            if (yearlyListPageName) promises.push(fetchPageWikitext(yearlyListPageName));
            if (alphabeticalListPageName) promises.push(fetchPageWikitext(alphabeticalListPageName));
        }

        // Wait for fetches before re-rendering
        Promise.all(promises).then(() => {
            renderAllSteps(selectedOutcome, currentVotes);
        }).catch(error => {
            console.error("RfX Closer: Error during pre-fetch in handleOutcomeChange:", error);
            renderAllSteps(selectedOutcome, currentVotes); // Still try to render
        });
    }

    /** Handles dragging the header. */
    function handleHeaderMouseDown(e) {
        if (e.target.tagName === 'BUTTON') return; // Ignore clicks on buttons
        const containerEl = document.querySelector(config.selectors.container);
        if (!containerEl) return;
        isDragging = true;
        dragStartX = e.clientX;
        dragStartY = e.clientY;
        const rect = containerEl.getBoundingClientRect();
        containerStartX = rect.left;
        containerStartY = rect.top;
        containerEl.style.position = 'fixed';
        containerEl.style.left = containerStartX + 'px';
        containerEl.style.top = containerStartY + 'px';
        containerEl.style.transform = ''; // Remove centering transform
        containerEl.style.cursor = 'grabbing';
        containerEl.style.userSelect = 'none';
        document.addEventListener('mousemove', handleDocumentMouseMove);
        document.addEventListener('mouseup', handleDocumentMouseUp);
    }

    function handleDocumentMouseMove(e) {
        if (!isDragging) return;
        const containerEl = document.querySelector(config.selectors.container);
        if (!containerEl) return;
        e.preventDefault();
        const dx = e.clientX - dragStartX;
        const dy = e.clientY - dragStartY;
        containerEl.style.left = (containerStartX + dx) + 'px';
        containerEl.style.top = (containerStartY + dy) + 'px';
    }

    function handleDocumentMouseUp() {
        if (isDragging) {
            const containerEl = document.querySelector(config.selectors.container);
            if (containerEl) {
                containerEl.style.cursor = 'grab';
                containerEl.style.userSelect = '';
            }
            isDragging = false;
            document.removeEventListener('mousemove', handleDocumentMouseMove);
            document.removeEventListener('mouseup', handleDocumentMouseUp);
        }
    }

    /** Handles the collapse/expand button click. */
    function handleCollapseClick() {
        const containerEl = document.querySelector(config.selectors.container);
        const contentAndInputContainerEl = document.querySelector(config.selectors.contentAndInputContainer);
        const collapseButtonEl = document.querySelector(config.selectors.collapseButton);
        if (!containerEl || !contentAndInputContainerEl || !collapseButtonEl) return;

        isCollapsed = !isCollapsed;
        if (isCollapsed) {
            contentAndInputContainerEl.style.display = 'none';
            collapseButtonEl.innerHTML = '+';
            containerEl.style.maxHeight = 'unset';
            containerEl.style.height = 'auto';
        } else {
            contentAndInputContainerEl.style.display = 'flex';
            collapseButtonEl.innerHTML = '−';
            containerEl.style.maxHeight = '90vh';
            containerEl.style.height = '';
            // Force a reflow to ensure proper display
            containerEl.style.display = 'flex';
            containerEl.style.flexDirection = 'column';
            // Re-render content
            updateInputFields();
            renderAllSteps(document.querySelector(config.selectors.outcomeSelector)?.value || '', getCurrentVoteCounts());
        }
    }

    /** Handles the close button click. */
    function handleCloseClick() {
        const containerEl = document.querySelector(config.selectors.container);
        if (containerEl) containerEl.style.display = 'none';
    }

    /** Handles the main launch button click. */
    function handleLaunchClick(e) {
        e.preventDefault();
        const containerEl = document.querySelector(config.selectors.container);
        if (!containerEl) return;

        if (containerEl.style.display === 'none' || containerEl.style.display === '') {
            fetchRfaData().then(() => {
                updateInputFields();
                renderAllSteps('', getCurrentVoteCounts());
                containerEl.style.display = 'flex';

                const supportInputEl = document.querySelector(config.selectors.supportInput);
                const opposeInputEl = document.querySelector(config.selectors.opposeInput);
                if (supportInputEl && !supportInputEl.dataset.listenerAttached) {
                    supportInputEl.addEventListener('input', updatePercentageDisplay);
                    supportInputEl.dataset.listenerAttached = 'true';
                }
                if (opposeInputEl && !opposeInputEl.dataset.listenerAttached) {
                    opposeInputEl.addEventListener('input', updatePercentageDisplay);
                    opposeInputEl.dataset.listenerAttached = 'true';
                }
            }).catch(err => {
                console.error("RfX Closer: Failed to fetch initial data on launch.", err);
                containerEl.style.display = 'flex';
                const stepsContainerEl = document.querySelector(config.selectors.stepsContainer);
                if (stepsContainerEl) stepsContainerEl.innerHTML = '<p style="color: red;">Error fetching initial data. Check console.</p>';
            });
        } else {
            containerEl.style.display = 'none';
        }
    }

    // --- UI Element Creation ---
    function buildInitialUI() {
        const container = createElement('div', { id: config.selectors.container.substring(1), style: { display: 'none' } });

        // Header
        const title = createElement('h2', { textContent: 'RfX Closer', className: config.selectors.title.substring(1) });
        const collapseButton = createElement('button', { innerHTML: '−', title: 'Collapse/Expand', className: 'rfx-closer-button rfx-closer-collapse' });
        const closeButton = createElement('button', { innerHTML: '×', title: 'Close', className: 'rfx-closer-button rfx-closer-close' });
        const headerButtonContainer = createElement('div', {}, [collapseButton, closeButton]);
        const header = createElement('div', { className: config.selectors.header.substring(1) }, [title, headerButtonContainer]);

        // Input Section Helper
        const createInputField = (label, id) => createElement('div', {}, [
            createElement('label', { textContent: label, htmlFor: id }),
            createElement('input', { type: 'number', id: id, min: '0' })
        ]);

        // Input Section
        const supportInputContainer = createInputField('Support', config.selectors.supportInput.substring(1));
        const opposeInputContainer = createInputField('Oppose', config.selectors.opposeInput.substring(1));
        const neutralInputContainer = createInputField('Neutral', config.selectors.neutralInput.substring(1));
        const inputFields = createElement('div', { className: config.selectors.inputFields.substring(1) }, [supportInputContainer, opposeInputContainer, neutralInputContainer]);
        const percentageDiv = createElement('div', { className: config.selectors.percentageDisplay.substring(1), textContent: 'Support percentage: N/A' });
        const inputSection = createElement('div', { className: config.selectors.inputSection.substring(1) }, [inputFields, percentageDiv]);

        // Content Section
        const stepsContainer = createElement('div', { id: config.selectors.stepsContainer.substring(1) });
        const contentContainer = createElement('div', { className: config.selectors.contentContainer.substring(1) }, [stepsContainer]);

        // Assembly
        const contentAndInputContainer = createElement('div', { className: config.selectors.contentAndInputContainer.substring(1) }, [inputSection, contentContainer]);
        container.appendChild(header);
        container.appendChild(contentAndInputContainer);
        document.body.appendChild(container);

        // Add Event Listeners to dynamically created elements
        header.addEventListener('mousedown', handleHeaderMouseDown);
        collapseButton.addEventListener('click', handleCollapseClick);
        closeButton.addEventListener('click', handleCloseClick);
    }

    // --- CSS Styling ---
    function addStyles() {
        const styles = `
            :root {
                --border-color: #a2a9b1;
                --border-color-light: #ccc;
                --background-light: #f8f9fa;
                --background-hover: #eaecf0;
                --text-color: #202122;
                --text-muted: #54595d;
                --link-color: #0645ad;
                --button-primary: #36c;
                --button-hover: #447ff5;
                --success-bg: #e6f3e6;
                --success-border: #c3e6cb;
                --error-bg: #f8d7da;
                --error-border: #f5c6cb;
                --warning-bg: #fcf8e3;
                --warning-border: #faebcc;
                --warning-text: #8a6d3b;
            }

            /* Base Container */
            #rfx-closer-container {
                position: fixed;
                right: 10px;
                top: 10px;
                width: 380px;
                max-height: 90vh;
                background: var(--background-light);
                border: 1px solid var(--border-color);
                border-radius: 5px;
                z-index: 1001;
                box-shadow: 0 4px 8px rgba(0,0,0,0.15);
                font-family: sans-serif;
                font-size: 14px;
                color: var(--text-color);
                display: flex;
                flex-direction: column;
                padding: 10px;
                overflow: hidden;
            }

            /* Content Container */
            .rfx-closer-main-content {
                display: flex;
                flex-direction: column;
                flex: 1;
                overflow: auto;
                min-height: 0;
            }

            /* Content and Input Container */
            .rfx-closer-content-container {
                flex: 1;
                overflow-y: auto;
                padding: 20px;
                min-height: 100px;
            }

            /* Header Styles */
            .rfx-closer-header {
                display: flex;
                justify-content: space-between;
                align-items: center;
                padding: 15px 20px;
                background: var(--background-light);
                border-bottom: 1px solid var(--border-color);
                cursor: grab;
                flex-shrink: 0;
            }

            .rfx-closer-title {
                margin: 0;
                font-size: 1.15em;
                font-weight: bold;
            }

            /* Button Styles */
            .rfx-closer-button,
            .rfx-closer-action-button,
            .rfx-notify-editlink {
                border-radius: 3px;
                cursor: pointer;
            }

            .rfx-closer-button {
                background: none;
                border: 1px solid transparent;
                font-size: 1.5em;
                color: var(--text-muted);
                padding: 0 5px;
                line-height: 1;
            }

            .rfx-closer-button:hover {
                background-color: var(--background-hover);
            }

            .rfx-closer-action-button {
                padding: 5px 10px;
                background-color: var(--button-primary);
                color: white;
                border: 1px solid var(--button-primary);
                margin: 10px 0;
                font-size: 0.95em;
            }

            .rfx-closer-action-button:hover {
                background-color: var(--button-hover);
            }

            .rfx-closer-action-button:disabled {
                background-color: var(--border-color);
                border-color: var(--border-color);
                cursor: not-allowed;
            }

            /* Input Styles */
            .rfx-closer-input-section {
                padding: 15px;
                border-bottom: 1px solid var(--border-color);
                background: white;
                flex-shrink: 0;
                margin-bottom: 15px;
            }

            .rfx-closer-input-fields {
                display: grid;
                grid-template-columns: repeat(3, 1fr);
                gap: 10px;
            }

            .rfx-closer-input-fields div {
                display: flex;
                flex-direction: column;
                gap: 4px;
            }

            .rfx-closer-input-fields label {
                font-size: 0.85em;
                color: var(--text-muted);
            }

            /* Common Input Elements */
            .rfx-closer-input-fields input,
            .rfx-notify-textarea,
            .rfx-crat-chat-textarea,
            .rfx-onhold-notify-textarea {
                padding: 5px;
                border: 1px solid var(--border-color);
                border-radius: 3px;
                width: 100%;
                box-sizing: border-box;
                margin: 5px 0;
            }

            /* Status Elements */
            .rfx-closer-api-status,
            .rfx-notify-status,
            .rfx-crat-notify-status,
            .rfx-candidate-onhold-notify-status {
                font-size: 0.9em;
                margin-top: 8px;
            }

            /* Info Boxes */
            .rfx-closer-info-box,
            .rfx-closer-known-issue,
            .rfx-closer-percentage {
                margin-top: 10px;
                padding: 8px 10px;
                border-radius: 3px;
                font-size: 0.9em;
            }

            .rfx-closer-info-box {
                border: 1px solid var(--border-color-light);
                background-color: var(--background-light);
            }

            .rfx-closer-info-box.error {
                background-color: var(--error-bg);
                border-color: var(--error-border);
                color: #721c24;
            }

            .rfx-closer-known-issue {
                border: 1px solid var(--warning-border);
                background-color: var(--warning-bg);
                color: var(--warning-text);
            }

            /* Launch Button */
            #rfx-closer-launch {
                color: var(--link-color) !important;
                text-decoration: none;
                cursor: pointer;
            }

            #rfx-closer-launch:hover {
                text-decoration: underline !important;
            }

            /* Step Styles */
            .rfx-closer-step {
                margin-bottom: 20px;
                padding: 15px;
                border: 1px solid var(--border-color);
                border-radius: 4px;
                background: white;
                transition: background-color 0.3s ease;
                overflow-x: auto; /* Allow horizontal scrolling if needed */
            }

            .rfx-closer-step[data-completed="true"] {
                background-color: var(--success-bg);
                border-color: var(--success-border);
            }

            /* Status Colors */
            .rfx-crat-notify-status .success { color: green; }
            .rfx-crat-notify-status .error { color: red; }
            .rfx-crat-notify-status .warning { color: orange; }

            /* OOUI Overrides */
            .rfx-closer-checkbox.oo-ui-widget-disabled,
            .rfx-closer-checkbox.oo-ui-widget-disabled .oo-ui-checkboxInputWidget-checkIcon {
                opacity: 1 !important;
            }

            /* Link Styles */
            .rfx-action-link {
                display: inline-block;
                margin: 5px 0;
                padding: 5px 10px;
                background-color: var(--background-light);
                border: 1px solid var(--border-color);
                border-radius: 3px;
                text-decoration: none;
                color: var(--link-color);
            }

            .rfx-action-link:hover {
                background-color: var(--background-hover);
            }

            /* Dropdown Styles */
            #rfx-outcome-selector {
                width: 100%;
                padding: 5px;
                margin-top: 10px;
                border: 1px solid var(--border-color);
                border-radius: 3px;
                background-color: white;
            }
        `;
        document.head.appendChild(createElement('style', { textContent: styles }));
    }

    // --- Initialization ---
    mw.loader.using(['oojs-ui-core', 'oojs-ui-widgets', 'mediawiki.api', 'mediawiki.widgets.DateInputWidget', 'mediawiki.util'], function() {
        buildInitialUI(); // Create the UI elements
        addStyles(); // Add the CSS

        // Create and add launch button
        const launchButton = createElement('a', {
            id: config.selectors.launchButton.substring(1),
            textContent: 'RfX Closer',
            href: '#'
        });
        launchButton.addEventListener('click', handleLaunchClick); // Attach listener

        const pageTools = document.querySelector(config.selectors.toolsMenu);
        if (pageTools) {
            const li = createElement('li', { id: config.selectors.launchListItem.substring(1) }, [launchButton]);
            pageTools.appendChild(li);
        }
    });

    console.log("RfX Closer: Script loaded (Refactored).");

})();