#!/usr/bin/env node
/**
 * Link Analytics Full Page UI Test (shard: link-analytics)
 *
 * Verifies the rendered /metrics/links page:
 *   - page loads without console errors or uncaught exceptions
 *   - the domain list renders with the expected CSS selectors
 *   - the inline-script wrapper (link_analytics.html:797-802) preserved
 *     after the surgical extraction of updateEnhancedDomainList to
 *     /static/js/pages/link_analytics_render.js — i.e. the function
 *     is callable and the rendered number in the "Recent Researches
 *     (N total)" header matches the API response (catches the exact
 *     bug class of commit 12a1b11b0 on valid data).
 *
 * Existing LinkAnalyticsTests in test_metrics_dashboard_ci.js:474-552
 * is smoke-only (never expands a domain, never sees the header). This
 * shard is the deeper coverage complement.
 *
 * Run: node test_link_analytics_full.js
 */

const { setupTest, teardownTest, TestResults, log, navigateTo, withTimeout } = require('./test_lib');

const LinkAnalyticsFullTests = {
    /**
     * Render check: #domain-list must populate with at least one
     * .ldr-domain-item-expanded, plus the frequency and diversity badges.
     * The page must render clean — the inline-script wrapper depends on
     * updateEnhancedDomainList being on window, and any throw would
     * break the render path silently. Console/pageerror capture is
     * handled centrally by the `run` wrapper in main(), not here.
     */
    async pageLoadsCleanAndRendersList(page, baseUrl) {
        await navigateTo(page, `${baseUrl}/metrics/links`);

        // #domain-list is populated by updateEnhancedDomainList (now in
        // link_analytics_render.js). Wait for at least one rendered item.
        await page.waitForSelector('#domain-list .ldr-domain-item-expanded', {
            timeout: 15000,
        });

        const result = await page.evaluate(() => {
            const list = document.getElementById('domain-list');
            const items = list?.querySelectorAll('.ldr-domain-item-expanded') ?? [];
            const firstHeader = list?.querySelector('.ldr-domain-header') ?? null;
            return {
                itemCount: items.length,
                hasFrequencyBadge: !!list?.querySelector('.ldr-frequency'),
                hasDiversityBadge: !!list?.querySelector('.ldr-diversity'),
                firstDomainText: firstHeader?.querySelector('.ldr-domain-name')?.textContent?.trim() ?? null,
            };
        });

        return {
            passed: result.itemCount > 0 && result.hasFrequencyBadge && result.hasDiversityBadge,
            message: `${result.itemCount} domain(s) rendered, first="${result.firstDomainText}"`,
        };
    },

    /**
     * Expand the first domain and verify the "Recent Researches (N total)"
     * header is present. The (N total) value must be a number — if the
     * Number() coercion from commit 12a1b11b0 regressed on valid data,
     * N would be `NaN` and this assertion catches it.
     */
    async recentResearchesHeaderShowsNumericTotal(page, baseUrl) {
        await navigateTo(page, `${baseUrl}/metrics/links`);

        await page.waitForSelector('#domain-list .ldr-domain-item-expanded', {
            timeout: 15000,
        });

        // Domains always render expanded in this layout (.ldr-domain-item-expanded
        // is the row class), so the research-links block is already in the DOM
        // if the domain has any recent researches. Find one with links present.
        const result = await page.evaluate(() => {
            const titles = Array.from(document.querySelectorAll('.ldr-research-links-title'));
            const match = titles.find(t => /Recent Researches\s*\(/.test(t.textContent || ''));
            if (!match) return { found: false };
            const text = match.textContent.trim();
            // Header looks like "Recent Researches (N total)". Capture N.
            // Restrict N to digits to avoid super-linear backtracking.
            const m = text.match(/Recent Researches\s*\((\d+)\s+total\)/);
            return {
                found: true,
                rawText: text,
                totalToken: m ? m[1] : null,
            };
        });

        if (!result.found) {
            return { passed: null, skipped: true, message: 'No "Recent Researches" header present (no domains had recent researches in test data)' };
        }

        // totalToken must be a valid base-10 number — proves Number() coercion
        // succeeded on a valid input (the inverse of the XSS test which proves
        // it produces NaN on a bad input).
        const isNumeric = /^\d+$/.test(result.totalToken || '');
        return {
            passed: isNumeric,
            message: `header raw="${result.rawText}", totalToken="${result.totalToken}", numeric=${isNumeric}`,
        };
    },

    /**
     * Verify the frequency badge text is well-formed: "📊 N uses (N%)" with
     * numeric N. Catches the same Number()-coercion bug class as the header
     * test, on a separate field path.
     */
    async frequencyBadgeWellFormed(page, baseUrl) {
        await navigateTo(page, `${baseUrl}/metrics/links`);
        await page.waitForSelector('.ldr-frequency', { timeout: 15000 });

        const result = await page.evaluate(() => {
            const badges = Array.from(document.querySelectorAll('.ldr-frequency'));
            const sample = badges.slice(0, 3).map(b => b.textContent.trim());
            const allMatch = badges.every(b => /📊\s+\d+\s+uses\s+\(\d+(?:\.\d+)?%\)/.test(b.textContent || ''));
            return { sample, allMatch, count: badges.length };
        });

        return {
            passed: result.allMatch && result.count > 0,
            message: `${result.count} frequency badges, allMatch=${result.allMatch}, sample=${JSON.stringify(result.sample)}`,
        };
    },

    /**
     * Verify no <script> element was injected into the rendered list —
     * the runtime mirror of the Vitest XSS regression. If a payload ever
     * survives the Number()/escapeHtml() barrier, this catches it in a
     * real browser against live API data.
     */
    async noScriptElementLeakedIntoList(page, baseUrl) {
        await navigateTo(page, `${baseUrl}/metrics/links`);
        await page.waitForSelector('#domain-list .ldr-domain-item-expanded', { timeout: 15000 });

        const result = await page.evaluate(() => {
            const list = document.getElementById('domain-list');
            return {
                scriptCount: list?.querySelectorAll('script').length ?? 0,
                eventHandlers: Array.from(list?.querySelectorAll('*') ?? [])
                    .filter(el => Array.from(el.attributes).some(a => a.name.startsWith('on') && a.name !== 'onmouseover' && a.name !== 'onmouseout'))
                    .map(el => ({ tag: el.tagName, attrs: Array.from(el.attributes).filter(a => a.name.startsWith('on')).map(a => a.name) })),
            };
        });

        return {
            passed: result.scriptCount === 0 && result.eventHandlers.length === 0,
            message: `script elements in #domain-list: ${result.scriptCount}; unexpected event handlers: ${JSON.stringify(result.eventHandlers)}`,
        };
    },
};

// ============================================================================
// Main Test Runner
// ============================================================================
async function main() {
    log.section('Link Analytics Full Page Tests');

    const ctx = await setupTest({ authenticate: true });
    const results = new TestResults('Link Analytics Full Page Tests');
    const { page } = ctx;
    const { baseUrl } = ctx.config;

    // Capture browser-side errors across the entire test run. Each test
    // receives this array; the verdict for any test that observed the
    // navigation lifecycle is "fail" if errors is non-empty.
    const browserErrors = [];
    page.on('console', (msg) => {
        if (msg.type() === 'error') {
            browserErrors.push(`console.error: ${msg.text()}`);
        }
    });
    page.on('pageerror', (err) => {
        browserErrors.push(`pageerror: ${err.message}`);
    });

    const subTestTimeout = ctx.config.isCI ? 60000 : 30000;
    async function run(category, name, testFn) {
        // Snapshot browser errors before the test starts. After testFn
        // resolves, the delta is attributed to THIS test — not shared
        // across tests, not stale. If the delta is non-empty, the test
        // fails regardless of its own verdict, because a console error
        // or pageerror during render signals the inline-script wrapper
        // or extracted module is broken in the real browser.
        const before = browserErrors.length;
        let result;
        try {
            result = await withTimeout(
                testFn(page, baseUrl),
                subTestTimeout,
                `${category}/${name}`
            );
        } catch (error) {
            results.add(category, name, false, `Error: ${error.message}`);
            return;
        }

        const newErrors = browserErrors.slice(before);
        if (result.skipped) {
            results.skip(category, name, result.message);
            return;
        }
        if (newErrors.length > 0) {
            results.add(
                category,
                name,
                false,
                `${result.message} | ${newErrors.length} browser error(s): ${newErrors.join('; ')}`
            );
            return;
        }
        results.add(category, name, result.passed, result.message);
    }

    try {
        await run('Render', 'Page Loads Clean And Renders List',
            (p, u) => LinkAnalyticsFullTests.pageLoadsCleanAndRendersList(p, u));
        await run('Render', 'Recent Researches Header Shows Numeric Total',
            (p, u) => LinkAnalyticsFullTests.recentResearchesHeaderShowsNumericTotal(p, u));
        await run('Render', 'Frequency Badge Well Formed',
            (p, u) => LinkAnalyticsFullTests.frequencyBadgeWellFormed(p, u));
        await run('Security', 'No Script Element Leaked Into List',
            (p, u) => LinkAnalyticsFullTests.noScriptElementLeakedIntoList(p, u));
    } catch (error) {
        log.error(`Fatal error: ${error.message}`);
        console.error(error.stack);
    } finally {
        results.print();
        results.save();
        await teardownTest(ctx);
        process.exit(results.exitCode());
    }
}

if (require.main === module) {
    main().catch(error => {
        console.error('Test runner failed:', error);
        process.exit(1);
    });
}

module.exports = { LinkAnalyticsFullTests };
