/**
 * Tests for components/semantic_search.js
 *
 * Focus on the pure data-shaping helpers exposed via window.SemanticSearch:
 * - buildTieredResults: 3-tier merge with dedup + sort
 * - isSafeExternalUrl: URL scheme validation (security-critical)
 *
 * createSemanticResultCard and renderSnippet do DOM/markdown work and depend
 * on optional libraries (marked, DOMPurify) — exercised via integration in
 * library-search tests already.
 */

import '@js/components/semantic_search.js';

const SS = window.SemanticSearch;

describe('SemanticSearch.buildTieredResults', () => {
    it('returns empty tiers for empty inputs', () => {
        const r = SS.buildTieredResults([], []);
        expect(r.tier1).toEqual([]);
        expect(r.tier2).toEqual([]);
        expect(r.tier3).toEqual([]);
    });

    it('puts text-only matches in tier2 (preserving original order)', () => {
        const text = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
        const r = SS.buildTieredResults(text, []);
        expect(r.tier1).toEqual([]);
        expect(r.tier3).toEqual([]);
        expect(r.tier2.map(x => x.historyItem.id)).toEqual(['a', 'b', 'c']);
    });

    it('puts semantic-only matches in tier3', () => {
        const sem = [
            { research_id: 'x', similarity: 0.5 },
            { research_id: 'y', similarity: 0.8 },
        ];
        const r = SS.buildTieredResults([], sem);
        expect(r.tier1).toEqual([]);
        expect(r.tier2).toEqual([]);
        expect(r.tier3).toHaveLength(2);
    });

    it('places items appearing in both tiers in tier1 with semanticMatch populated', () => {
        const text = [{ id: 'a' }, { id: 'b' }];
        const sem = [
            { research_id: 'a', similarity: 0.7, snippet: 'hi' },
            { research_id: 'c', similarity: 0.9 },
        ];
        const r = SS.buildTieredResults(text, sem);
        expect(r.tier1).toHaveLength(1);
        expect(r.tier1[0].historyItem.id).toBe('a');
        expect(r.tier1[0].semanticMatch.similarity).toBe(0.7);
        expect(r.tier1[0].semanticMatch.snippet).toBe('hi');
        // 'b' was text-only → tier2
        expect(r.tier2.map(x => x.historyItem.id)).toEqual(['b']);
        // 'c' was semantic-only → tier3
        expect(r.tier3.map(x => x.semanticResult.research_id)).toEqual(['c']);
    });

    it('sorts tier1 by similarity DESC', () => {
        const text = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
        const sem = [
            { research_id: 'a', similarity: 0.3 },
            { research_id: 'b', similarity: 0.9 },
            { research_id: 'c', similarity: 0.6 },
        ];
        const r = SS.buildTieredResults(text, sem);
        expect(r.tier1.map(x => x.historyItem.id)).toEqual(['b', 'c', 'a']);
    });

    it('sorts tier3 by similarity DESC', () => {
        const sem = [
            { research_id: 'a', similarity: 0.2 },
            { research_id: 'b', similarity: 0.95 },
            { research_id: 'c', similarity: 0.5 },
        ];
        const r = SS.buildTieredResults([], sem);
        expect(r.tier3.map(x => x.semanticResult.research_id)).toEqual(['b', 'c', 'a']);
    });

    it('dedups semantic results by ID, keeping the highest similarity', () => {
        const sem = [
            { research_id: 'dup', similarity: 0.3 },
            { research_id: 'dup', similarity: 0.8 },
            { research_id: 'dup', similarity: 0.5 },
        ];
        const r = SS.buildTieredResults([], sem);
        expect(r.tier3).toHaveLength(1);
        expect(r.tier3[0].semanticResult.similarity).toBe(0.8);
    });

    it('skips semantic results missing the configured ID key', () => {
        const sem = [
            { research_id: 'a', similarity: 0.5 },
            { similarity: 0.9 }, // missing research_id
            { research_id: '', similarity: 0.7 }, // empty falsy
        ];
        const r = SS.buildTieredResults([], sem);
        expect(r.tier3).toHaveLength(1);
        expect(r.tier3[0].semanticResult.research_id).toBe('a');
    });

    it('honors custom textIdKey and semanticIdKey', () => {
        const text = [{ doc_id: 'x' }];
        const sem = [{ id: 'x', similarity: 0.5 }];
        const r = SS.buildTieredResults(text, sem, {
            textIdKey: 'doc_id',
            semanticIdKey: 'id',
        });
        expect(r.tier1).toHaveLength(1);
        expect(r.tier1[0].historyItem.doc_id).toBe('x');
    });

    it('uses default keys when options is undefined', () => {
        const text = [{ id: 'k' }];
        const sem = [{ research_id: 'k', similarity: 0.5 }];
        const r = SS.buildTieredResults(text, sem);
        expect(r.tier1).toHaveLength(1);
    });

    it('coerces IDs to strings for matching (number vs string)', () => {
        const text = [{ id: 42 }];
        const sem = [{ research_id: '42', similarity: 0.5 }];
        const r = SS.buildTieredResults(text, sem);
        expect(r.tier1).toHaveLength(1);
    });

    it('defaults snippet to empty string when missing', () => {
        const text = [{ id: 'a' }];
        const sem = [{ research_id: 'a', similarity: 0.5 }];
        const r = SS.buildTieredResults(text, sem);
        expect(r.tier1[0].semanticMatch.snippet).toBe('');
    });
});

describe('SemanticSearch.isSafeExternalUrl', () => {
    let savedValidator;

    beforeEach(() => {
        // Force the fallback path so we test the inline scheme list,
        // not URLValidator's behavior (covered by url-validator tests).
        savedValidator = window.URLValidator;
        delete window.URLValidator;
    });

    afterEach(() => {
        if (savedValidator !== undefined) window.URLValidator = savedValidator;
    });

    it('accepts http and https URLs', () => {
        expect(SS.isSafeExternalUrl('http://example.com')).toBe(true);
        expect(SS.isSafeExternalUrl('https://example.com/path?q=1')).toBe(true);
    });

    it('rejects javascript: URLs', () => {
        expect(SS.isSafeExternalUrl('javascript:alert(1)')).toBe(false);
    });

    it('rejects data: URLs', () => {
        expect(SS.isSafeExternalUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
    });

    it('rejects vbscript: URLs', () => {
        expect(SS.isSafeExternalUrl('vbscript:msgbox(1)')).toBe(false);
    });

    it('rejects about:, blob:, file: schemes', () => {
        expect(SS.isSafeExternalUrl('about:blank')).toBe(false);
        expect(SS.isSafeExternalUrl('blob:https://example.com/abc')).toBe(false);
        expect(SS.isSafeExternalUrl('file:///etc/passwd')).toBe(false);
    });

    it('rejects non-string input', () => {
        expect(SS.isSafeExternalUrl(null)).toBe(false);
        expect(SS.isSafeExternalUrl(undefined)).toBe(false);
        expect(SS.isSafeExternalUrl(42)).toBe(false);
        expect(SS.isSafeExternalUrl({})).toBe(false);
    });

    it('rejects empty string', () => {
        expect(SS.isSafeExternalUrl('')).toBe(false);
    });

    it('is case-insensitive against scheme obfuscation', () => {
        expect(SS.isSafeExternalUrl('JaVaScRiPt:alert(1)')).toBe(false);
    });

    it('rejects relative URLs (no scheme)', () => {
        expect(SS.isSafeExternalUrl('/relative/path')).toBe(false);
        expect(SS.isSafeExternalUrl('example.com')).toBe(false);
    });

    it('delegates to URLValidator when present', () => {
        window.URLValidator = {
            isSafeUrl: vi.fn().mockReturnValue(true),
        };
        expect(SS.isSafeExternalUrl('https://example.com')).toBe(true);
        expect(window.URLValidator.isSafeUrl).toHaveBeenCalledWith(
            'https://example.com',
            { allowMailto: false }
        );
    });
});
