import {describe, beforeEach, afterEach, test, expect, vi} from 'vitest';
import {fakeServer, type FakeServer} from 'nise';
import {FontFaceManager} from './font_face_manager.ts';
import {RequestManager} from '../util/request_manager.ts';
import {sleep} from '../util/test/util.ts';

describe('FontFaceManager', () => {
    const requestManager = new RequestManager();

    let added: FontFace[];
    let deleted: FontFace[];
    let server: FakeServer;

    let urlsWithoutAFontFile: Set<string>;

    function requestedUrls(): string[] {
        return server.requests.map(function (request) { return request.url; });
    }

    function stubFontFace({
        load = function () { return Promise.resolve(); },
        supportsFeatureSettings = true
    }: {
        load?: () => Promise<unknown>;
        supportsFeatureSettings?: boolean;
    } = {}) {
        (globalThis as any).FontFace = class {
            family: string;
            featureSettings: string;
            constructor(family: string, _source: ArrayBuffer, descriptors: FontFaceDescriptors = {}) {
                this.family = family;
                this.featureSettings = supportsFeatureSettings ? descriptors.featureSettings || 'normal' : 'normal';
            }
            load = load;
        };
    }

    function silenceWarnings() {
        vi.spyOn(console, 'warn').mockImplementation(function () {});
    }

    beforeEach(() => {
        added = [];
        deleted = [];
        urlsWithoutAFontFile = new Set();
        global.fetch = null;
        server = fakeServer.create({autoRespond: true, autoRespondAfter: 0});
        server.respondWith(function (request) {
            if (urlsWithoutAFontFile.has(request.url)) request.respond(404, undefined, 'Not Found');
            else request.respond(200, undefined, 'font file');
        });
        stubFontFace();
        Object.defineProperty(document, 'fonts', {
            configurable: true,
            value: {
                add(face: FontFace) { added.push(face); },
                delete(face: FontFace) { deleted.push(face); }
            }
        });
    });

    afterEach(() => {
        vi.restoreAllMocks();
        server.restore();
        delete (globalThis as any).FontFace;
        delete (document as any).fonts;
    });

    test('reports whether the style declared anything', () => {
        const manager = new FontFaceManager(requestManager);
        expect(manager.hasFontFaces()).toBe(false);

        manager.setFontFaces({'Noto Sans Regular': 'https://example.com/noto.ttf'});
        expect(manager.hasFontFaces()).toBe(true);

        manager.setFontFaces(null);
        expect(manager.hasFontFaces()).toBe(false);
    });

    test('registers a font file under a family of its own, so the style cannot restyle the page', async () => {
        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({'Noto Sans Regular': 'https://example.com/noto.ttf'});

        const family = await manager.getFontFamily('Noto Sans Regular', 0x41, 'normal');

        expect(family).toMatch(/^maplibre-gl-font-face-\d+$/);
        expect(family).not.toBe('Noto Sans Regular');
        expect(added).toHaveLength(1);
        expect(added[0].family).toBe(family);
        expect(requestedUrls()).toEqual(['https://example.com/noto.ttf']);
    });

    test('downloads a file only once, and only once a codepoint needs it', async () => {
        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({
            'Noto Sans Regular': [{url: 'https://example.com/khmer.ttf', 'unicode-range': ['U+1780-17FF']}]
        });

        expect(requestedUrls()).toEqual([]);

        await expect(manager.getFontFamily('Noto Sans Regular', 0x41, 'normal')).resolves.toBeNull();
        expect(requestedUrls()).toEqual([]);

        const [normal, vertical, secondVertical] = await Promise.all([
            manager.getFontFamily('Noto Sans Regular', 0x1780, 'normal'),
            manager.getFontFamily('Noto Sans Regular', 0x1780, 'vert'),
            manager.getFontFamily('Noto Sans Regular', 0x1781, 'vert')
        ]);

        expect(vertical).toBe(secondVertical);
        expect(vertical).not.toBe(normal);
        expect(added).toMatchObject([
            {family: normal, featureSettings: 'normal'},
            {family: vertical, featureSettings: '"vert" 1'}
        ]);
        expect(requestedUrls()).toEqual(['https://example.com/khmer.ttf']);
    });

    test('picks the file whose unicode range covers the codepoint', async () => {
        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({
            'Noto Sans Regular': [
                {url: 'https://example.com/khmer.ttf', 'unicode-range': ['U+1780-17FF']},
                {url: 'https://example.com/devanagari.ttf', 'unicode-range': ['U+0900-097F']}
            ]
        });

        await manager.getFontFamily('Noto Sans Regular', 0x1780, 'normal');
        await manager.getFontFamily('Noto Sans Regular', 0x0915, 'normal');

        expect(requestedUrls()).toEqual(['https://example.com/khmer.ttf', 'https://example.com/devanagari.ttf']);
    });

    test('keeps the horizontal face usable when vertical settings fail to load', async () => {
        silenceWarnings();
        stubFontFace({
            load: vi.fn()
                .mockResolvedValueOnce(undefined)
                .mockRejectedValue(new Error('unsupported settings'))
        });
        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({Noto: [
            {url: 'https://example.com/noto.ttf'},
            {url: 'https://example.com/fallback.ttf'}
        ]});

        await expect(manager.getFontFamily('Noto', 0x30FC, 'vert')).resolves.toBeNull();
        const horizontalFamily = await manager.getFontFamily('Noto', 0x30FC, 'normal');
        const [normalFace, verticalFace] = added;

        expect(horizontalFamily).toBe(normalFace.family);
        expect(deleted).toEqual([verticalFace]);
        expect(requestedUrls()).toEqual(['https://example.com/noto.ttf']);
        expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('with "vert" enabled'));
    });

    test('skips vertical faces when the browser ignores feature settings', async () => {
        stubFontFace({supportsFeatureSettings: false});
        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({Noto: 'https://example.com/noto.ttf'});

        await expect(manager.getFontFamily('Noto', 0x30FC, 'vert')).resolves.toBeNull();

        expect(added).toMatchObject([{featureSettings: 'normal'}]);
        expect(requestedUrls()).toEqual(['https://example.com/noto.ttf']);
    });

    test('reads the unicode range grammar that CSS uses', async () => {
        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({
            Single: [{url: 'https://example.com/single.ttf', 'unicode-range': ['U+A5']}],
            Explicit: [{url: 'https://example.com/explicit.ttf', 'unicode-range': ['u+1780-17ff']}],
            Wildcard: [{url: 'https://example.com/wildcard.ttf', 'unicode-range': ['U+4??']}],
            PastTheEnd: [{url: 'https://example.com/past-the-end.ttf', 'unicode-range': ['U+??????']}]
        });

        await expect(manager.getFontFamily('Single', 0xa5, 'normal')).resolves.not.toBeNull();
        await expect(manager.getFontFamily('Single', 0xa6, 'normal')).resolves.toBeNull();

        await expect(manager.getFontFamily('Explicit', 0x1780, 'normal')).resolves.not.toBeNull();
        await expect(manager.getFontFamily('Explicit', 0x1800, 'normal')).resolves.toBeNull();

        await expect(manager.getFontFamily('Wildcard', 0x400, 'normal')).resolves.not.toBeNull();
        await expect(manager.getFontFamily('Wildcard', 0x4ff, 'normal')).resolves.not.toBeNull();
        await expect(manager.getFontFamily('Wildcard', 0x500, 'normal')).resolves.toBeNull();

        await expect(manager.getFontFamily('PastTheEnd', 0x10ffff, 'normal')).resolves.not.toBeNull();
    });

    test('honours every range of a multi-range font face', async () => {
        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({
            Unifont: [{url: 'https://example.com/unifont.ttf', 'unicode-range': ['U+0900-097F', 'U+1780-17FF']}]
        });

        await expect(manager.getFontFamily('Unifont', 0x0915, 'normal')).resolves.not.toBeNull();
        await expect(manager.getFontFamily('Unifont', 0x1790, 'normal')).resolves.not.toBeNull();
        await expect(manager.getFontFamily('Unifont', 0x41, 'normal')).resolves.toBeNull();
    });

    test('covers every codepoint when no unicode range is given', async () => {
        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({Unifont: 'https://example.com/unifont.ttf'});

        await expect(manager.getFontFamily('Unifont', 0, 'normal')).resolves.not.toBeNull();
        await expect(manager.getFontFamily('Unifont', 0x10ffff, 'normal')).resolves.not.toBeNull();
    });

    test('walks the font stack in order, giving the next name a turn only where the one before does not cover the codepoint', async () => {
        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({
            'Noto Sans Regular': [{url: 'https://example.com/khmer.ttf', 'unicode-range': ['U+1780-17FF']}],
            'Noto Sans Italic': 'https://example.com/italic.ttf'
        });

        await manager.getFontFamily('Noto Sans Regular,Noto Sans Italic', 0x41, 'normal');
        await manager.getFontFamily('Noto Sans Regular,Noto Sans Italic', 0x1780, 'normal');

        expect(requestedUrls()).toEqual(['https://example.com/italic.ttf', 'https://example.com/khmer.ttf']);
    });

    test('leaves a codepoint no font face covers to the caller', async () => {
        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({
            'Noto Sans Regular': [{url: 'https://example.com/khmer.ttf', 'unicode-range': ['U+1780-17FF']}]
        });

        await expect(manager.getFontFamily('Noto Sans Regular', 0x41, 'normal')).resolves.toBeNull();
        await expect(manager.getFontFamily('Some Other Font', 0x1780, 'normal')).resolves.toBeNull();
    });

    test('ignores a file that fails to download, falling through to the next one', async () => {
        silenceWarnings();
        urlsWithoutAFontFile.add('https://example.com/urlsWithoutAFontFile.ttf');

        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({
            'Noto Sans Regular': [{url: 'https://example.com/urlsWithoutAFontFile.ttf'}, {url: 'https://example.com/noto.ttf'}]
        });

        const vertical = await manager.getFontFamily('Noto Sans Regular', 0x41, 'vert');

        expect(added).toHaveLength(2);
        expect(vertical).toBe(`${added[0].family}-vert`);
        expect(added[1].family).toBe(vertical);
        expect(requestedUrls()).toEqual(['https://example.com/urlsWithoutAFontFile.ttf', 'https://example.com/noto.ttf']);
        expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('404'));
    });

    test('ignores a font the browser refuses to decode, taking it back out of the document', async () => {
        silenceWarnings();
        stubFontFace({load: vi.fn().mockRejectedValue(new Error('could not be loaded'))});

        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({'Noto Sans Regular': 'https://example.com/not-a-font.txt'});

        await expect(manager.getFontFamily('Noto Sans Regular', 0x41, 'normal')).resolves.toBeNull();
        expect(deleted).toEqual(added);
    });

    test('ignores a declaration without a URL', async () => {
        silenceWarnings();
        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({'Noto Sans Regular': [{'unicode-range': ['U+0-10FFFF']} as any]});

        await expect(manager.getFontFamily('Noto Sans Regular', 0x41, 'normal')).resolves.toBeNull();
        expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('it has no URL'));
    });

    test('drops an unparseable unicode range but keeps the rest', async () => {
        silenceWarnings();
        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({
            'Noto Sans Regular': [{
                url: 'https://example.com/khmer.ttf',
                'unicode-range': ['0-255', 'U+', 'U+1234567', 'U+4??-U+5??', 'U+17FF-1780', 'U+FFFFFF', 'U+1780-17FF']
            }]
        });

        await expect(manager.getFontFamily('Noto Sans Regular', 0x41, 'normal')).resolves.toBeNull();
        await expect(manager.getFontFamily('Noto Sans Regular', 0x1780, 'normal')).resolves.not.toBeNull();
        expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('U+1234567'));
    });

    test('ignores a face whose every unicode range is unparseable', async () => {
        silenceWarnings();
        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({
            'Noto Sans Regular': [{url: 'https://example.com/khmer.ttf', 'unicode-range': ['nonsense']}]
        });

        await expect(manager.getFontFamily('Noto Sans Regular', 0x1780, 'normal')).resolves.toBeNull();
        expect(requestedUrls()).toEqual([]);
    });

    test('gives up when the environment has no CSS Font Loading API', async () => {
        silenceWarnings();
        delete (globalThis as any).FontFace;

        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({'Noto Sans Regular': 'https://example.com/noto.ttf'});

        await expect(manager.getFontFamily('Noto Sans Regular', 0x41, 'normal')).resolves.toBeNull();
        expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('CSS Font Loading API'));
    });

    test('does not register a font face that was replaced while its file was downloading', async () => {
        server.autoRespond = false;
        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({'Noto Sans Regular': 'https://example.com/noto.ttf'});
        const staleFamily = manager.getFontFamily('Noto Sans Regular', 0x41, 'normal');
        await sleep(0);

        manager.setFontFaces({'Noto Sans Regular': 'https://example.com/other.ttf'});
        server.respond();

        await expect(staleFamily).resolves.toBeNull();
        expect(added).toHaveLength(0);

        const familyPromise = manager.getFontFamily('Noto Sans Regular', 0x41, 'normal');
        await sleep(0);
        server.respond();
        const family = await familyPromise;
        expect(added).toHaveLength(1);
        expect(added[0].family).toBe(family);
        expect(requestedUrls()).toEqual(['https://example.com/noto.ttf', 'https://example.com/other.ttf']);
    });

    test('hands the font faces back when they are replaced, and again on destroy', async () => {
        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({'Noto Sans Regular': 'https://example.com/noto.ttf'});
        await manager.getFontFamily('Noto Sans Regular', 0x41, 'vert');

        manager.setFontFaces({'Noto Sans Regular': 'https://example.com/other.ttf'});

        expect(added).toHaveLength(2);
        expect(deleted).toEqual(added);

        const family = await manager.getFontFamily('Noto Sans Regular', 0x41, 'vert');
        expect(added).toHaveLength(4);
        expect(added[3].family).toBe(family);
        expect(requestedUrls()).toEqual(['https://example.com/noto.ttf', 'https://example.com/other.ttf']);

        manager.destroy();
        expect(deleted).toEqual(added);
        expect(manager.hasFontFaces()).toBe(false);
    });

    test('discards a vertical face that finishes loading after destroy', async () => {
        let finishVerticalLoad: () => void;
        const pendingVerticalLoad = new Promise<void>(resolve => { finishVerticalLoad = resolve; });
        stubFontFace({
            load: vi.fn().mockResolvedValueOnce(undefined).mockReturnValue(pendingVerticalLoad)
        });
        const manager = new FontFaceManager(requestManager);
        manager.setFontFaces({Noto: 'https://example.com/noto.ttf'});

        const familyPromise = manager.getFontFamily('Noto', 0x41, 'vert');
        await vi.waitFor(() => expect(added).toHaveLength(2));

        manager.destroy();
        expect(deleted).toEqual(added);
        expect(manager.hasFontFaces()).toBe(false);
        finishVerticalLoad();
        await expect(familyPromise).resolves.toBeNull();
    });
});
