/**
 * Regression tests for the XSS hardening in link_analytics.html (PR #3095)
 * and the follow-up consistency fix at commit 12a1b11b0
 * (`researchDiversity` wrapped in `Number()` at the "Recent Researches
 * (N total)" header — link_analytics_render.js).
 *
 * Unlike pages/news.js, link_analytics writes the rendered HTML directly
 * to #domain-list via innerHTML — no DOMPurify barrier. Every untrusted
 * string interpolation must therefore be wrapped in Number() (numeric
 * coercion), escapeHtml() (entity encoding), encodeURIComponent()
 * (path segment), or encodeURI() (host). These tests pin each escape
 * site by injecting a `<script>` payload and asserting:
 *   (a) no `<script>` element is created in the DOM, and
 *   (b) the specific escape ran (e.g. `&lt;script&gt;` or `NaN` present).
 *
 * The function under test was extracted to
 *   src/local_deep_research/web/static/js/pages/link_analytics_render.js
 * (surgical extraction mirroring PR #4584). The inline wrapper at
 * link_analytics.html:797-802 still reassigns it on window; the last
 * test verifies that wrapper pattern still works against the extracted
 * module.
 */

const PAYLOAD = '<script>alert(1)</script>';

beforeAll(async () => {
    // xss-protection.js provides window.escapeHtml, which the render
    // function relies on as a global. In production it's loaded by
    // base.html before the link_analytics inline script runs.
    await import('@js/security/xss-protection.js');

    // link_analytics_render.js is an IIFE that exposes
    // window.updateEnhancedDomainList on load.
    await import('@js/pages/link_analytics_render.js');
});

beforeEach(() => {
    // The render function only references #domain-list. Reset it between
    // tests so payload leakage from one test cannot satisfy another.
    document.body.innerHTML = '<div id="domain-list"></div>';
});

describe('pages/link_analytics_render.js — XSS regression for PR #3095', () => {
    it('Number()-coerces researchDiversity header + usage badges (commit 12a1b11b0)', () => {
        // Regression for the consistency fix: ${researchDiversity} in the
        // "Recent Researches (N total)" header was the only sibling field
        // not wrapped in Number(). Injecting a payload must produce 0
        // (NaN is falsy, so `Number(x) || 0` falls through) — never the
        // raw payload. Matches the `|| 0` UX pattern from news.js.
        const domains = [{
            domain: 'safe.example.com',
            count: 0,
            percentage: 0,
            research_count: 0,
            recent_researches: [{ id: 'r1', query: 'safe query' }],
        }];
        const metrics = {
            'safe.example.com': {
                usage_count: PAYLOAD,
                usage_percentage: PAYLOAD,
                research_diversity: PAYLOAD,
                frequency_rank: PAYLOAD,
            },
        };

        window.updateEnhancedDomainList(domains, metrics);

        const html = document.getElementById('domain-list').innerHTML;

        // No script element created.
        expect(document.querySelectorAll('#domain-list script').length).toBe(0);
        // No raw payload substring anywhere.
        expect(html).not.toContain(PAYLOAD);
        // Header ran through Number() || 0 — payload can't leak; NaN
        // falls through to 0 per the established news.js pattern.
        expect(html).toContain('Recent Researches (0 total)');
        // Frequency badge: both usage_count and usage_percentage hit Number() || 0.
        expect(html).toContain('📊 0 uses (0%)');
        // Diversity badge: research_diversity hit Number() || 0.
        expect(html).toContain('🔍 0 researches');
        // Frequency rank ran through Number() — payload can't leak via the
        // `#${rank}` interpolation. The broader guards above catch this
        // if Number() coercion is ever dropped (the payload would render
        // raw as `#<script>...`).
    });

    it('escapeHtml-encodes research link query in title attr and text', () => {
        // Payload includes a quote so the title-attribute assertion can
        // distinguish "escapeHtml ran" from "browser just didn't parse <
        // as a tag inside a quoted attr". If escapeHtml did NOT run, the
        // quote would break out of the title attribute and spawn a
        // rogue onmouseover attribute on the <a>.
        const ATTR_BREAKOUT = `" onmouseover="alert(1)`;
        const domains = [{
            domain: 'safe.example.com',
            count: 1,
            recent_researches: [
                { id: 'r1', query: PAYLOAD },
                { id: 'r2', query: 'short ' + PAYLOAD },
                { id: 'r3', query: ATTR_BREAKOUT },
            ],
        }];

        window.updateEnhancedDomainList(domains, {});

        const links = document.querySelectorAll('#domain-list .ldr-research-link');
        expect(links.length).toBe(3);
        expect(document.querySelectorAll('#domain-list script').length).toBe(0);

        // First two links: text node is entity-encoded. innerHTML carries
        // the escaped form for text content (the text is inside an <a>
        // element, not an attribute, so the entities are preserved).
        const html = document.getElementById('domain-list').innerHTML;
        expect(html).toContain('&lt;script&gt;');
        expect(html).not.toContain('>' + PAYLOAD); // no raw payload in any text node

        // Third link: breakout payload must be contained inside the title
        // attribute, not parsed as a new attribute.
        const breakoutLink = links[2];
        expect(breakoutLink.getAttribute('onmouseover')).toBeNull();
        expect(breakoutLink.getAttribute('title')).toBe(ATTR_BREAKOUT);
    });

    it('escapes domain name in link text via escapeHtml and in href via encodeURI', () => {
        const domains = [{
            domain: PAYLOAD,
            count: 1,
            recent_researches: [],
        }];

        window.updateEnhancedDomainList(domains, {});

        const html = document.getElementById('domain-list').innerHTML;
        const link = document.querySelector('#domain-list .ldr-domain-name a');

        expect(link).not.toBeNull();
        expect(document.querySelectorAll('#domain-list script').length).toBe(0);
        // Link text is entity-encoded.
        expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
        // The href is percent-encoded via encodeURI (< > " all escape to
        // %3C %3E %22), so the payload cannot break out of the href
        // attribute or be replayed as an executable URL.
        const href = link.getAttribute('href');
        expect(href).toContain('%3Cscript%3E');
        expect(href.toLowerCase()).not.toContain('javascript:');
    });

    it('escapeHtml-encodes classification category/subcategory and Number()-coerces confidence', () => {
        // Quote in subcategory tests attribute containment on the badge title.
        const ATTR_BREAKOUT = `" tiles="evil`;
        const domains = [{
            domain: 'classified.example.com',
            count: 1,
            recent_researches: [],
            classification: {
                category: PAYLOAD,
                subcategory: PAYLOAD + ATTR_BREAKOUT,
                confidence: PAYLOAD,
            },
        }];

        window.updateEnhancedDomainList(domains, {});

        const badge = document.querySelector('#domain-list .ldr-classified-badge');
        expect(badge).not.toBeNull();
        expect(document.querySelectorAll('#domain-list script').length).toBe(0);

        // Category text node entity-encoded (whitespace from template
        // literal indentation surrounds the value).
        expect(badge.textContent.trim()).toBe(PAYLOAD);

        // Subcategory ran through escapeHtml: the breakout quote must be
        // contained inside the title attribute, not parsed as a new
        // attribute. The badge must not gain a `tiles` attribute.
        expect(badge.getAttribute('tiles')).toBeNull();
        expect(badge.getAttribute('title')).toContain(PAYLOAD);
        expect(badge.getAttribute('title')).toContain(ATTR_BREAKOUT);

        // Confidence ran through Math.round(Number(...)) — produces NaN,
        // never the raw payload.
        expect(badge.getAttribute('title')).toContain('(NaN% confidence)');
    });

    it('renders the full set of expected selectors on a valid payload', () => {
        const domains = [{
            domain: 'example.com',
            count: 5,
            percentage: 12.5,
            research_count: 3,
            frequency_rank: 1,
            recent_researches: [{ id: 'r1', query: 'a safe query' }],
            classification: { category: 'Tech', subcategory: 'AI', confidence: 0.9 },
        }];
        const metrics = {
            'example.com': {
                usage_count: 5,
                usage_percentage: 12.5,
                research_diversity: 3,
                frequency_rank: 1,
            },
        };

        expect(() => window.updateEnhancedDomainList(domains, metrics)).not.toThrow();

        const list = document.getElementById('domain-list');
        expect(list.querySelector('.ldr-domain-item-expanded')).not.toBeNull();
        expect(list.querySelector('.ldr-domain-header')).not.toBeNull();
        expect(list.querySelector('.ldr-domain-name')).not.toBeNull();
        expect(list.querySelector('.ldr-domain-stats')).not.toBeNull();
        expect(list.querySelector('.ldr-frequency')).not.toBeNull();
        expect(list.querySelector('.ldr-diversity')).not.toBeNull();
        expect(list.querySelector('.ldr-research-links')).not.toBeNull();
        expect(list.querySelector('.ldr-research-links-title')).not.toBeNull();
        expect(list.querySelector('.ldr-research-link')).not.toBeNull();
        expect(list.querySelector('.ldr-classified-badge')).not.toBeNull();

        // Numeric rendering is intact on the happy path.
        const html = list.innerHTML;
        expect(html).toContain('Recent Researches (3 total)');
        expect(html).toContain('📊 5 uses (12.5%)');
        expect(html).toContain('🔍 3 researches');
        expect(html).toContain('(90% confidence)');
    });

    it('renders the empty-list fallback when domains is []', () => {
        window.updateEnhancedDomainList([], {});

        const html = document.getElementById('domain-list').innerHTML;
        expect(html).toContain('No domain data available');
        expect(document.querySelectorAll('#domain-list script').length).toBe(0);
    });

    it('supports the inline-script wrapper pattern (read + reassign + call)', () => {
        // link_analytics.html:797-802 captures the original
        // window.updateEnhancedDomainList and reassigns it to a wrapper
        // that also calls loadClassificationsAndUpdateDisplay(). The
        // surgical extraction must preserve this pattern: window must
        // hold a reassignable function reference, not a frozen export.
        const original = window.updateEnhancedDomainList;
        expect(typeof original).toBe('function');

        let sideEffectRan = false;
        window.updateEnhancedDomainList = function(domains, metrics) {
            original.call(this, domains, metrics);
            sideEffectRan = true;
        };

        try {
            window.updateEnhancedDomainList(
                [{ domain: 'wrapper.example.com', count: 1, recent_researches: [] }],
                {}
            );
            expect(sideEffectRan).toBe(true);
            // Original behavior still ran — domain was rendered.
            expect(document.querySelector('#domain-list .ldr-domain-item-expanded')).not.toBeNull();
        } finally {
            // Restore so subsequent tests get the un-wrapped function.
            window.updateEnhancedDomainList = original;
        }
    });
});
