import {
    describe,
    it,
    expect,
    vi,
    beforeEach,
    afterEach,
    type MockInstance
} from 'vitest';
import {
    getMyTagsForPage,
    addTagToPage,
    removeTagFromPage,
    listMyPageTags,
    listMyDistinctPageTags,
    listTaggedChapters,
    listTaggedMangas,
    searchPages
} from './page_tags';

function ok(body: unknown, status = 200): Response {
    return new Response(JSON.stringify(body), {
        status,
        headers: { 'content-type': 'application/json' }
    });
}

function noContent(): Response {
    return new Response(null, { status: 204 });
}

function taggedItemFixture(extra: Record<string, unknown> = {}) {
    return {
        tag: 'funny',
        page_id: 'p1',
        chapter_id: 'ch1',
        manga_id: 'm1',
        page_number: 1,
        chapter_number: 1,
        chapter_title: null,
        manga_title: 'Berserk',
        storage_key: 'mangas/m1/chapters/ch1/pages/0001.png',
        tagged_at: '2026-01-01T00:00:00Z',
        ...extra
    };
}

describe('page_tags api client', () => {
    let fetchSpy: MockInstance<typeof globalThis.fetch>;

    beforeEach(() => {
        fetchSpy = vi.spyOn(globalThis, 'fetch');
    });
    afterEach(() => {
        vi.restoreAllMocks();
    });

    it('getMyTagsForPage unwraps `tags`', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ tags: ['funny', 'fight'] }));
        const tags = await getMyTagsForPage('p1');
        expect(tags).toEqual(['funny', 'fight']);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/pages\/p1\/my-tags$/);
    });

    it('addTagToPage POSTs { tag }', async () => {
        fetchSpy.mockResolvedValueOnce(ok({}, 201));
        await addTagToPage('p1', 'funny');
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/pages\/p1\/tags$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('POST');
        expect(JSON.parse(init.body as string)).toEqual({ tag: 'funny' });
    });

    it('removeTagFromPage URL-encodes the tag', async () => {
        fetchSpy.mockResolvedValueOnce(noContent());
        await removeTagFromPage('p1', 'character:askeladd');
        const url = fetchSpy.mock.calls[0][0] as string;
        // ':' becomes %3A under encodeURIComponent.
        expect(url).toContain('/v1/pages/p1/tags/character%3Aaskeladd');
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('DELETE');
    });

    it('listMyPageTags forwards tag / q / pagination params', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({
                items: [taggedItemFixture()],
                page: { limit: 50, offset: 0, total: 1 }
            })
        );
        await listMyPageTags({ tag: 'funny', q: 'fu', limit: 10, offset: 20 });
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('tag=funny');
        expect(url).toContain('q=fu');
        expect(url).toContain('limit=10');
        expect(url).toContain('offset=20');
    });

    it('listMyPageTags omits query string when no params', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [], page: { limit: 50, offset: 0, total: 0 } })
        );
        await listMyPageTags();
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/me\/page-tags$/);
    });

    it('listMyDistinctPageTags unwraps `items`', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({
                items: [
                    { tag: 'funny', count: 5 },
                    { tag: 'fight', count: 2 }
                ]
            })
        );
        const summaries = await listMyDistinctPageTags();
        expect(summaries.map((s) => s.tag)).toEqual(['funny', 'fight']);
    });

    it('listMyDistinctPageTags sends only `q` when prefix supplied', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ items: [] }));
        await listMyDistinctPageTags('fu');
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('q=fu');
    });

    it('listTaggedChapters always sends tag, order/limit/offset are optional', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({
                items: [],
                page: { limit: 50, offset: 0, total: 0 }
            })
        );
        await listTaggedChapters({ tag: 'funny' });
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/me\/page-tags\/chapters\?tag=funny$/);
    });

    it('listTaggedChapters forwards order + pagination', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [], page: { limit: 25, offset: 10, total: 0 } })
        );
        await listTaggedChapters({
            tag: 'funny',
            order: 'asc',
            limit: 25,
            offset: 10
        });
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('tag=funny');
        expect(url).toContain('order=asc');
        expect(url).toContain('limit=25');
        expect(url).toContain('offset=10');
    });

    it('listTaggedMangas hits the mangas endpoint', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [], page: { limit: 50, offset: 0, total: 0 } })
        );
        await listTaggedMangas({ tag: 'funny' });
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/me\/page-tags\/mangas\?tag=funny$/);
    });

    it('searchPages serializes tags (CSV), text and content-warning filters', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [], page: { limit: 50, offset: 0, total: 0 } })
        );
        await searchPages({
            tags: ['fight', 'city'],
            text: 'dragon',
            cwInclude: ['gore'],
            cwExclude: ['sexual'],
            limit: 20
        });
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('/v1/me/page-search?');
        expect(url).toContain('tags=fight%2Ccity');
        expect(url).toContain('text=dragon');
        expect(url).toContain('cw_include=gore');
        expect(url).toContain('cw_exclude=sexual');
        expect(url).toContain('limit=20');
    });

    it('searchPages omits empty filters', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [], page: { limit: 50, offset: 0, total: 0 } })
        );
        await searchPages({ text: 'x', tags: [], cwInclude: [] });
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('text=x');
        expect(url).not.toContain('tags=');
        expect(url).not.toContain('cw_include=');
    });
});
