import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import {
    listMyCollections,
    listMyCollectionsOrEmpty,
    createCollection,
    getCollection,
    updateCollection,
    deleteCollection,
    listCollectionMangas,
    addMangaToCollection,
    removeMangaFromCollection,
    getMyCollectionsContaining
} from './collections';

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 envelope(status: number, code: string, message: string): Response {
    return new Response(JSON.stringify({ error: { code, message } }), {
        status,
        headers: { 'content-type': 'application/json' }
    });
}

function collectionFixture(extra: Record<string, unknown> = {}) {
    return {
        id: 'c1',
        user_id: 'u1',
        name: 'Favorites',
        description: null,
        created_at: '2026-01-01T00:00:00Z',
        updated_at: '2026-01-01T00:00:00Z',
        manga_count: 0,
        sample_covers: [],
        ...extra
    };
}

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

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

    it('listMyCollections returns the paged envelope', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({
                items: [collectionFixture()],
                page: { limit: 50, offset: 0, total: 1 }
            })
        );
        const result = await listMyCollections();
        expect(result.items[0].name).toBe('Favorites');
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/me\/collections$/);
    });

    it('listMyCollectionsOrEmpty returns empty page on 401', async () => {
        fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'login required'));
        const result = await listMyCollectionsOrEmpty();
        expect(result.items).toEqual([]);
        expect(result.page.total).toBeNull();
    });

    it('listMyCollectionsOrEmpty re-throws non-401 errors', async () => {
        fetchSpy.mockResolvedValueOnce(envelope(500, 'internal_error', 'oops'));
        await expect(listMyCollectionsOrEmpty()).rejects.toMatchObject({ status: 500 });
    });

    it('createCollection POSTs JSON to /v1/collections', async () => {
        fetchSpy.mockResolvedValueOnce(ok(collectionFixture(), 201));
        const c = await createCollection({ name: 'Favorites' });
        expect(c.name).toBe('Favorites');
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('POST');
        expect(JSON.parse(init.body as string)).toEqual({ name: 'Favorites' });
    });

    it('getCollection encodes the id', async () => {
        fetchSpy.mockResolvedValueOnce(ok(collectionFixture()));
        await getCollection('id with space');
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('/v1/collections/id%20with%20space');
    });

    it('updateCollection PATCHes with the patch body', async () => {
        fetchSpy.mockResolvedValueOnce(ok(collectionFixture({ name: 'Read later' })));
        const updated = await updateCollection('c1', { name: 'Read later' });
        expect(updated.name).toBe('Read later');
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('PATCH');
        expect(JSON.parse(init.body as string)).toEqual({ name: 'Read later' });
    });

    it('deleteCollection issues DELETE', async () => {
        fetchSpy.mockResolvedValueOnce(noContent());
        await deleteCollection('c1');
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('DELETE');
    });

    it('listCollectionMangas returns the paged envelope of mangas', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({
                items: [
                    {
                        id: 'm1',
                        title: 'Berserk',
                        status: 'ongoing',
                        alt_titles: [],
                        description: null,
                        cover_image_path: null,
                        created_at: '2026-01-01T00:00:00Z',
                        updated_at: '2026-01-01T00:00:00Z'
                    }
                ],
                page: { limit: 50, offset: 0, total: 1 }
            })
        );
        const r = await listCollectionMangas('c1');
        expect(r.items[0].title).toBe('Berserk');
    });

    it('addMangaToCollection POSTs the manga_id', async () => {
        fetchSpy.mockResolvedValueOnce(ok({}, 201));
        await addMangaToCollection('c1', 'm9');
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('POST');
        expect(JSON.parse(init.body as string)).toEqual({ manga_id: 'm9' });
    });

    it('removeMangaFromCollection DELETEs the nested resource', async () => {
        fetchSpy.mockResolvedValueOnce(noContent());
        await removeMangaFromCollection('c1', 'm9');
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/collections\/c1\/mangas\/m9$/);
    });

    it('getMyCollectionsContaining returns the id list', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ collection_ids: ['c1', 'c3'] }));
        const ids = await getMyCollectionsContaining('m1');
        expect(ids).toEqual(['c1', 'c3']);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/mangas\/m1\/my-collections$/);
    });
});
