import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import {
    listMangas,
    createManga,
    getManga,
    updateManga,
    updateMangaCover,
    deleteMangaCover,
    attachTag,
    detachTag,
    getSimilarMangas
} from './mangas';

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 emptyPage() {
    return { items: [], page: { limit: 50, offset: 0, total: null } };
}

function cardFixture(extra: Record<string, unknown> = {}) {
    return {
        id: 'b1',
        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',
        authors: [{ id: 'a1', name: 'Kentaro Miura' }],
        genres: [],
        ...extra
    };
}

function detailFixture(extra: Record<string, unknown> = {}) {
    return {
        ...cardFixture(),
        tags: [],
        ...extra
    };
}

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

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

    it('listMangas hits /v1/mangas with no params by default', async () => {
        fetchSpy.mockResolvedValueOnce(ok(emptyPage()));
        await listMangas();
        expect(fetchSpy).toHaveBeenCalledTimes(1);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/mangas$/);
    });

    it('listMangas returns the paged envelope with cards', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({
                items: [cardFixture()],
                page: { limit: 50, offset: 0, total: 1 }
            })
        );
        const result = await listMangas();
        expect(result.items).toHaveLength(1);
        expect(result.items[0].title).toBe('Berserk');
        expect(result.items[0].authors[0].name).toBe('Kentaro Miura');
        expect(result.page).toEqual({ limit: 50, offset: 0, total: 1 });
    });

    it('listMangas encodes search, status, ids (csv), limit, offset, sort, order', async () => {
        fetchSpy.mockResolvedValueOnce(ok(emptyPage()));
        await listMangas({
            search: 'one piece',
            status: 'completed',
            authorIds: ['a1', 'a2'],
            genreIds: ['g1'],
            tagIds: ['t1', 't2'],
            limit: 10,
            offset: 20,
            sort: 'title',
            order: 'asc'
        });
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/mangas\?/);
        expect(url).toContain('search=one+piece');
        expect(url).toContain('status=completed');
        // Multi-value facets land as a single comma-separated param so the
        // backend can apply pure AND semantics across the list.
        expect(url).toContain('author_id=a1%2Ca2');
        expect(url).toContain('genre_id=g1');
        expect(url).toContain('tag_id=t1%2Ct2');
        expect(url).toContain('limit=10');
        expect(url).toContain('offset=20');
        expect(url).toContain('sort=title');
        expect(url).toContain('order=asc');
    });

    it('getManga returns the enriched detail shape', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok(detailFixture({ tags: [{ id: 't1', name: 'Seinen', added_by: 'u1' }] }))
        );
        const m = await getManga('b1');
        expect(m.tags).toHaveLength(1);
        expect(m.tags[0].name).toBe('Seinen');
        expect(m.authors[0].name).toBe('Kentaro Miura');
    });

    it('createManga POSTs multipart with the new metadata shape', async () => {
        fetchSpy.mockResolvedValueOnce(ok(detailFixture()));
        await createManga({
            title: 'Berserk',
            authors: ['Kentaro Miura'],
            status: 'completed',
            alt_titles: ['ベルセルク'],
            genre_ids: ['g1']
        });
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/mangas$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('POST');
        expect(init.body).toBeInstanceOf(FormData);
        const form = init.body as FormData;
        const metadata = form.get('metadata') as Blob;
        expect(metadata).toBeInstanceOf(Blob);
        expect(metadata.type).toBe('application/json');
        const text = await new Promise<string>((resolve) => {
            const reader = new FileReader();
            reader.onload = () => resolve(reader.result as string);
            reader.readAsText(metadata);
        });
        const parsed = JSON.parse(text);
        expect(parsed.title).toBe('Berserk');
        expect(parsed.authors).toEqual(['Kentaro Miura']);
        expect(parsed.status).toBe('completed');
        expect(parsed.alt_titles).toEqual(['ベルセルク']);
        expect(parsed.genre_ids).toEqual(['g1']);
        expect(form.get('cover')).toBeNull();
        // The browser sets Content-Type with boundary automatically when body
        // is a FormData — we must NOT set it ourselves.
        expect(init.headers).toBeUndefined();
    });

    it('createManga attaches the cover Blob when supplied', async () => {
        fetchSpy.mockResolvedValueOnce(ok(detailFixture()));
        const cover = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' });
        await createManga({ title: 'Berserk' }, cover);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        const form = init.body as FormData;
        const got = form.get('cover');
        expect(got).toBeInstanceOf(Blob);
    });

    it('updateManga PATCHes with the provided patch body', async () => {
        fetchSpy.mockResolvedValueOnce(ok(detailFixture({ status: 'completed' })));
        const updated = await updateManga('b1', {
            status: 'completed',
            authors: ['Kentaro Miura', 'Studio Gaga']
        });
        expect(updated.status).toBe('completed');
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/mangas\/b1$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('PATCH');
        expect(JSON.parse(init.body as string)).toEqual({
            status: 'completed',
            authors: ['Kentaro Miura', 'Studio Gaga']
        });
    });

    it('updateMangaCover PUTs multipart with the cover blob', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok(detailFixture({ cover_image_path: 'mangas/b1/cover.png' }))
        );
        const cover = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' });
        const updated = await updateMangaCover('b1', cover);
        expect(updated.cover_image_path).toBe('mangas/b1/cover.png');
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/mangas\/b1\/cover$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('PUT');
        expect(init.body).toBeInstanceOf(FormData);
        const form = init.body as FormData;
        expect(form.get('cover')).toBeInstanceOf(Blob);
        // Boundary is filled in by the browser when body is FormData.
        expect(init.headers).toBeUndefined();
    });

    it('updateMangaCover throws ApiError on payload_too_large', async () => {
        fetchSpy.mockResolvedValue(
            envelope(413, 'payload_too_large', 'cover exceeds size cap')
        );
        const cover = new Blob([new Uint8Array(1)]);
        await expect(updateMangaCover('b1', cover)).rejects.toMatchObject({
            name: 'ApiError',
            status: 413,
            code: 'payload_too_large'
        });
    });

    it('deleteMangaCover DELETEs and returns the refreshed detail with null path', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok(detailFixture({ cover_image_path: null }))
        );
        const updated = await deleteMangaCover('b1');
        expect(updated.cover_image_path).toBeNull();
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/mangas\/b1\/cover$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('DELETE');
        expect(init.body).toBeUndefined();
    });

    it('attachTag POSTs the name and returns the TagRef', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ id: 't9', name: 'Dark Fantasy', added_by: 'u1' }, 201)
        );
        const tag = await attachTag('b1', 'Dark Fantasy');
        expect(tag.name).toBe('Dark Fantasy');
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/mangas\/b1\/tags$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('POST');
        expect(JSON.parse(init.body as string)).toEqual({ name: 'Dark Fantasy' });
    });

    it('detachTag DELETEs the (manga, tag) pair', async () => {
        fetchSpy.mockResolvedValueOnce(noContent());
        await detachTag('b1', 't9');
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/mangas\/b1\/tags\/t9$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('DELETE');
    });

    it('getSimilarMangas hits /v1/mangas/:id/similar and unwraps items', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({ items: [cardFixture({ id: 's1' }), cardFixture({ id: 's2' })] })
        );
        const items = await getSimilarMangas('b1');
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/mangas\/b1\/similar$/);
        expect(items.map((m) => m.id)).toEqual(['s1', 's2']);
    });

    it('getSimilarMangas returns an empty array when there are no matches', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ items: [] }));
        expect(await getSimilarMangas('b1')).toEqual([]);
    });

    it('listMangas serializes content-warning include/exclude filters', async () => {
        fetchSpy.mockResolvedValueOnce(ok(emptyPage()));
        await listMangas({ cwInclude: ['gore'], cwExclude: ['sexual', 'nudity'] });
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('cw_include=gore');
        expect(url).toContain('cw_exclude=sexual%2Cnudity');
    });

    it('getManga throws ApiError carrying the envelope code on non-2xx', async () => {
        fetchSpy.mockResolvedValue(envelope(404, 'not_found', 'manga not found'));
        await expect(getManga('missing')).rejects.toMatchObject({
            name: 'ApiError',
            status: 404,
            code: 'not_found',
            message: 'manga not found'
        });
    });
});
