import {
    describe,
    it,
    expect,
    vi,
    beforeEach,
    afterEach,
    type MockInstance
} from 'vitest';
import {
    createBookmark,
    deleteBookmark,
    listMyBookmarks,
    listMyBookmarksOrEmpty
} from './bookmarks';

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' }
    });
}

const bookmarkFixture = {
    id: 'b1',
    user_id: 'u1',
    manga_id: 'm1',
    chapter_id: null,
    page: null,
    created_at: '2026-01-01T00:00:00Z'
};

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

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

    it('createBookmark POSTs JSON to /v1/bookmarks', async () => {
        fetchSpy.mockResolvedValueOnce(ok(bookmarkFixture, 201));
        const b = await createBookmark({ manga_id: 'm1' });
        expect(b).toEqual(bookmarkFixture);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/bookmarks$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('POST');
        expect(JSON.parse(init.body as string)).toEqual({ manga_id: 'm1' });
    });

    it('createBookmark surfaces 409 conflict', async () => {
        fetchSpy.mockResolvedValueOnce(envelope(409, 'conflict', 'already bookmarked'));
        await expect(createBookmark({ manga_id: 'm1' })).rejects.toMatchObject({
            status: 409,
            code: 'conflict'
        });
    });

    it('deleteBookmark DELETEs /v1/bookmarks/{id} and handles 204', async () => {
        fetchSpy.mockResolvedValueOnce(noContent());
        await expect(deleteBookmark('b1')).resolves.toBeUndefined();
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/bookmarks\/b1$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('DELETE');
    });

    it('listMyBookmarks hits /v1/me/bookmarks and returns paged envelope', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [bookmarkFixture], page: { limit: 50, offset: 0, total: null } })
        );
        const result = await listMyBookmarks();
        expect(result.items).toHaveLength(1);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/me\/bookmarks$/);
    });

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

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