/**
 * Tests for services/ui.js
 *
 * Tests UI utility functions: progress bars, spinners, error display,
 * inline errors, escapeHtmlFallback, and notification system.
 */

import '@js/security/xss-protection.js';
import '@js/services/ui.js';

const ui = window.ui;

describe('ui service', () => {
    describe('updateProgressBar', () => {
        let fill, pct;

        beforeEach(() => {
            fill = document.createElement('div');
            fill.id = 'test-fill';
            pct = document.createElement('span');
            pct.id = 'test-pct';
            document.body.appendChild(fill);
            document.body.appendChild(pct);
        });

        afterEach(() => {
            fill.remove();
            pct.remove();
        });

        it('sets width and text for percentage', () => {
            ui.updateProgressBar(fill, pct, 42);
            expect(fill.style.width).toBe('42%');
            expect(pct.textContent).toBe('42%');
        });

        it('clamps percentage to 0-100', () => {
            ui.updateProgressBar(fill, pct, -10);
            expect(fill.style.width).toBe('0%');
            expect(pct.textContent).toBe('0%');

            ui.updateProgressBar(fill, pct, 150);
            expect(fill.style.width).toBe('100%');
            expect(pct.textContent).toBe('100%');
        });

        it('handles null/undefined percentage as 0', () => {
            ui.updateProgressBar(fill, pct, null);
            expect(fill.style.width).toBe('0%');
        });

        it('adds ldr-complete class at 100%', () => {
            ui.updateProgressBar(fill, pct, 100);
            expect(fill.classList.contains('ldr-complete')).toBe(true);
        });

        it('removes ldr-complete class when below 100%', () => {
            fill.classList.add('ldr-complete');
            ui.updateProgressBar(fill, pct, 50);
            expect(fill.classList.contains('ldr-complete')).toBe(false);
        });

        it('accepts string IDs instead of elements', () => {
            ui.updateProgressBar('test-fill', 'test-pct', 75);
            expect(fill.style.width).toBe('75%');
            expect(pct.textContent).toBe('75%');
        });

        it('rounds percentage text', () => {
            ui.updateProgressBar(fill, pct, 33.7);
            expect(pct.textContent).toBe('34%');
        });
    });

    describe('showSpinner / hideSpinner', () => {
        let container;

        beforeEach(() => {
            container = document.createElement('div');
            container.id = 'spinner-container';
            document.body.appendChild(container);
        });

        afterEach(() => {
            container.remove();
        });

        it('creates a spinner element in the container', () => {
            ui.showSpinner(container, 'Loading data...');
            expect(container.querySelector('.ldr-loading-spinner')).not.toBeNull();
        });

        it('displays the message text', () => {
            ui.showSpinner(container, 'Please wait');
            expect(container.textContent).toContain('Please wait');
        });

        it('escapes HTML in the message', () => {
            ui.showSpinner(container, '<script>alert(1)</script>');
            expect(container.innerHTML).not.toContain('<script>');
        });

        it('hideSpinner removes the spinner', () => {
            ui.showSpinner(container);
            expect(container.querySelector('.ldr-loading-spinner')).not.toBeNull();
            ui.hideSpinner(container);
            expect(container.querySelector('.ldr-loading-spinner')).toBeNull();
        });

        it('hideSpinner does nothing when no spinner exists', () => {
            expect(() => ui.hideSpinner(container)).not.toThrow();
        });

        it('accepts string IDs', () => {
            ui.showSpinner('spinner-container', 'Test');
            expect(container.querySelector('.ldr-loading-spinner')).not.toBeNull();
            ui.hideSpinner('spinner-container');
            expect(container.querySelector('.ldr-loading-spinner')).toBeNull();
        });
    });

    describe('showError', () => {
        let container;

        beforeEach(() => {
            container = document.createElement('div');
            container.id = 'error-container';
            document.body.appendChild(container);
        });

        afterEach(() => {
            container.remove();
        });

        it('shows error message in container', () => {
            ui.showError(container, 'Something went wrong');
            expect(container.textContent).toContain('Something went wrong');
        });

        it('escapes HTML in error message', () => {
            ui.showError(container, '<img onerror="xss">');
            expect(container.innerHTML).not.toContain('<img');
        });

        it('creates error element with icon', () => {
            ui.showError(container, 'Error');
            expect(container.querySelector('.ldr-error-message')).not.toBeNull();
            expect(container.querySelector('.fa-exclamation-circle')).not.toBeNull();
        });
    });

    describe('showInlineError / clearInlineError', () => {
        let container;

        beforeEach(() => {
            container = document.createElement('div');
            container.id = 'inline-error-container';
            document.body.appendChild(container);
        });

        afterEach(() => {
            container.remove();
        });

        it('creates an inline error element', () => {
            const el = ui.showInlineError(container, 'Field is required');
            expect(el).not.toBeNull();
            expect(el.classList.contains('ldr-inline-error')).toBe(true);
            expect(el.textContent).toContain('Field is required');
        });

        it('sets role="alert" for accessibility', () => {
            const el = ui.showInlineError(container, 'Error');
            expect(el.getAttribute('role')).toBe('alert');
        });

        it('adds dismiss button by default', () => {
            const el = ui.showInlineError(container, 'Error');
            const closeBtn = el.querySelector('.ldr-inline-error-close');
            expect(closeBtn).not.toBeNull();
            expect(closeBtn.getAttribute('aria-label')).toBe('Dismiss error');
        });

        it('dismiss button removes error', () => {
            const el = ui.showInlineError(container, 'Error');
            const closeBtn = el.querySelector('.ldr-inline-error-close');
            closeBtn.click();
            expect(container.querySelector('.ldr-inline-error')).toBeNull();
        });

        it('replaces existing inline error', () => {
            ui.showInlineError(container, 'First error');
            ui.showInlineError(container, 'Second error');
            const errors = container.querySelectorAll('.ldr-inline-error');
            expect(errors.length).toBe(1);
            expect(errors[0].textContent).toContain('Second error');
        });

        it('clearInlineError removes all errors', () => {
            ui.showInlineError(container, 'Error 1');
            ui.clearInlineError(container);
            expect(container.querySelector('.ldr-inline-error')).toBeNull();
        });

        it('returns null for non-existent container', () => {
            expect(ui.showInlineError('#nonexistent', 'Error')).toBeNull();
        });

        it('accepts string ID for container', () => {
            const el = ui.showInlineError('inline-error-container', 'Test');
            expect(el).not.toBeNull();
        });

        it('uses textContent (not innerHTML) for message', () => {
            const el = ui.showInlineError(container, '<script>xss</script>');
            const span = el.querySelector('span');
            expect(span.textContent).toBe('<script>xss</script>');
            expect(span.innerHTML).not.toContain('<script>');
        });
    });

    describe('showMessage', () => {
        afterEach(() => {
            // Persistent banners stay in the DOM by design; clean up
            // between tests so each test starts fresh.
            document
                .getElementById('notification-banner-polite')
                ?.remove();
            document
                .getElementById('notification-banner-assertive')
                ?.remove();
        });

        it('shows the polite banner for success messages', () => {
            ui.showMessage('Saved!', 'success');
            const polite = document.getElementById(
                'notification-banner-polite',
            );
            expect(polite).not.toBeNull();
            expect(polite.textContent).toContain('Saved!');
            expect(polite.getAttribute('role')).toBe('status');
            expect(polite.getAttribute('aria-live')).toBe('polite');
        });

        it('shows the assertive banner for error messages', () => {
            ui.showMessage('Error occurred', 'error');
            const assertive = document.getElementById(
                'notification-banner-assertive',
            );
            expect(assertive).not.toBeNull();
            expect(assertive.textContent).toContain('Error occurred');
            expect(assertive.getAttribute('role')).toBe('alert');
            expect(assertive.getAttribute('aria-live')).toBe('assertive');
        });

        it('uses the assertive banner for warnings', () => {
            ui.showMessage('Heads up', 'warning');
            const assertive = document.getElementById(
                'notification-banner-assertive',
            );
            expect(assertive.textContent).toContain('Heads up');
        });

        it('uses the polite banner for info messages', () => {
            ui.showMessage('FYI', 'info');
            const polite = document.getElementById(
                'notification-banner-polite',
            );
            expect(polite.textContent).toContain('FYI');
        });

        it('escapes HTML by using textContent', () => {
            ui.showMessage('<img src=x onerror=alert(1)>', 'info');
            const polite = document.getElementById(
                'notification-banner-polite',
            );
            // textContent is set, not innerHTML, so the markup is text.
            expect(polite.textContent).toContain(
                '<img src=x onerror=alert(1)>',
            );
            expect(polite.querySelector('img')).toBeNull();
        });

        it('reuses the same banner element across calls', () => {
            ui.showMessage('First', 'success');
            const firstNode = document.getElementById(
                'notification-banner-polite',
            );
            ui.showMessage('Second', 'info');
            const secondNode = document.getElementById(
                'notification-banner-polite',
            );
            // Same DOM node — live regions must persist for the
            // screen reader to announce subsequent updates.
            expect(secondNode).toBe(firstNode);
            expect(secondNode.textContent).toContain('Second');
            expect(secondNode.textContent).not.toContain('First');
        });

        it('switches between banners when type changes', () => {
            ui.showMessage('Saved', 'success');
            ui.showMessage('Boom', 'error');
            const polite = document.getElementById(
                'notification-banner-polite',
            );
            const assertive = document.getElementById(
                'notification-banner-assertive',
            );
            expect(assertive.textContent).toContain('Boom');
            // Polite is hidden via transform but its prior text
            // is still in the DOM; the visible banner is assertive.
            expect(polite.style.transform).toContain('-100%');
        });
    });
});

describe('renderMarkdown', () => {
    it('returns warning HTML for null/empty input', () => {
        const result = ui.renderMarkdown(null);
        expect(result).toContain('No content available');
    });

    it('returns plaintext fallback when marked is unavailable', () => {
        // happy-dom doesn't have `marked` loaded
        const result = ui.renderMarkdown('# Hello World');
        // Should escape HTML and show as plaintext
        expect(result).toContain('Hello World');
    });
});

describe('renderMarkdown with KaTeX math', () => {
    // Configure a real marked + KaTeX + DOMPurify pipeline for this block only.
    // The setup mirrors app.js so renderMarkdown takes the marked-rendering
    // branch instead of falling through to the plaintext-escape fallback.
    beforeAll(async () => {
        const { Marked } = await import('marked');
        const markedKatex = (await import('marked-katex-extension')).default;
        const DOMPurify = (await import('dompurify')).default;
        const m = new Marked();
        // Mirror app.js so the test exercises the production config.
        m.use(markedKatex({ throwOnError: false, errorColor: 'currentColor' }));
        globalThis.marked = m;
        globalThis.DOMPurify = DOMPurify;
    });
    afterAll(() => {
        delete globalThis.marked;
        delete globalThis.DOMPurify;
    });

    // happy-dom does not parse <math> into the MathML namespace, so DOMPurify
    // strips the MathML accessibility subtree (annotation, semantics, mrow, ...)
    // even though those elements survive in real browsers. We assert on the
    // KaTeX HTML rendering branch instead — that is the visible math layer
    // and the proof that the extension actually ran.

    it('renders inline math as KaTeX HTML', () => {
        const result = ui.renderMarkdown('The equation $E=mc^2$ is famous');
        expect(result).toContain('class="katex"');
        expect(result).toContain('class="katex-html"');
        expect(result).toContain('mord mathnormal');
    });

    it('renders display math as a katex-display block', () => {
        const result = ui.renderMarkdown('$$\\sum_{i=1}^n i$$');
        expect(result).toContain('class="katex-display"');
        expect(result).toContain('op-symbol');
    });

    it('renders math alongside other markdown', () => {
        // The leading "Intro." paragraph is a deliberate sacrificial node.
        // DOMPurify >=3.4.8 reads element names through the cached
        // Node.prototype.nodeName getter (anti-clobbering hardening). happy-dom's
        // getter returns "" when invoked that way, so the first top-level node of
        // the sanitized fragment is treated as an unknown tag and unwrapped — its
        // tags dropped, text kept (a leading `<h1>Title</h1>` collapses to
        // `Title`). Real browsers return the correct tag name and are unaffected,
        // so this is a test-env-only artifact, tracked upstream at
        // https://github.com/capricorn86/happy-dom/issues/2182. Prefixing a
        // throwaway paragraph keeps the heading off the first slot so we can still
        // assert on its `<h1>` rendering here. See also the MathML note above.
        const result = ui.renderMarkdown('Intro.\n\n# Title\n\nSome text $x^2$ more text\n\n- list item');
        expect(result).toContain('<h1');
        expect(result).toContain('<li>');
        expect(result).toContain('class="katex"');
    });

    it('does not treat adjacent dollar amounts as math (standard mode)', () => {
        // With marked-katex-extension default (no nonStandard flag),
        // "$10 and $20" must not be parsed as math: the `$` delimiters
        // require whitespace boundaries.
        const result = ui.renderMarkdown('It costs $10 and $20 per unit.');
        expect(result).not.toContain('class="katex"');
    });

    it('renders multi-line display math ($$\\n…\\n$$)', () => {
        // Canonical block form — what LLMs and textbooks typically emit.
        // This pattern caused display math to be silently dropped from PDFs
        // before commit 2c47b529e; locking in the rendering behavior here
        // guards against a related regression in the markdown path.
        const result = ui.renderMarkdown('Para 1\n\n$$\n\\sum_{i=1}^n i\n$$\n\nPara 2');
        expect(result).toContain('class="katex-display"');
        expect(result).toContain('Para 1');
        expect(result).toContain('Para 2');
    });

    it('does not render math inside inline code spans', () => {
        // `$x^2$` inside backticks must stay as literal code, not be parsed
        // as math. This is both a correctness and a defense-in-depth concern
        // (an LLM documenting LaTeX syntax should not have its examples
        // silently rendered).
        const result = ui.renderMarkdown('Use `$x^2$` to write x squared.');
        expect(result).not.toContain('class="katex"');
        expect(result).toContain('<code>$x^2$</code>');
    });

    it('does not render math inside fenced code blocks', () => {
        const result = ui.renderMarkdown('```\n$E=mc^2$\n```');
        expect(result).not.toContain('class="katex"');
        expect(result).toContain('$E=mc^2$');
    });

    it('renders invalid LaTeX without throwing (throwOnError: false)', () => {
        // app.js configures the extension with throwOnError: false so a
        // malformed formula in LLM output degrades gracefully rather than
        // breaking the whole render. Verify the wrapper doesn't blow up.
        expect(() => ui.renderMarkdown('Bad math: $\\frac{a$')).not.toThrow();
        const result = ui.renderMarkdown('Bad math: $\\frac{a$');
        // Either the error span renders or KaTeX recovers — either way the
        // surrounding text must come through.
        expect(result).toContain('Bad math:');
    });
});
