import {
    describe,
    it,
    expect,
    vi,
    beforeEach,
    afterEach,
    type MockInstance
} from 'vitest';
import {
    listChapters,
    getChapter,
    getChapterPages,
    createChapter,
    chapterLabel
} from './chapters';

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

function envelope(status: number, code: string, message: string): Response {
    return new Response(JSON.stringify({ error: { code, message } }), {
        status,
        headers: { 'content-type': 'application/json' }
    });
}

const emptyPage = { items: [], page: { limit: 50, offset: 0, total: null } };

const chapterFixture = {
    id: 'c1',
    manga_id: 'm1',
    number: 1,
    title: 'The Brand',
    page_count: 0,
    created_at: '2026-01-01T00:00:00Z'
};

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

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

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

    it('listChapters encodes limit and offset', async () => {
        fetchSpy.mockResolvedValueOnce(ok(emptyPage));
        await listChapters('m1', { limit: 10, offset: 20 });
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toContain('limit=10');
        expect(url).toContain('offset=20');
    });

    it('listChapters returns the paged envelope', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({
                items: [chapterFixture],
                page: { limit: 50, offset: 0, total: null }
            })
        );
        const result = await listChapters('m1');
        expect(result.items[0]).toEqual(chapterFixture);
        expect(result.page.total).toBeNull();
    });

    it('getChapter hits /v1/mangas/{id}/chapters/{chapter_id}', async () => {
        fetchSpy.mockResolvedValueOnce(ok(chapterFixture));
        const c = await getChapter('m1', 'ch-uuid-1');
        expect(c).toEqual(chapterFixture);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/mangas\/m1\/chapters\/ch-uuid-1$/);
    });

    it('getChapter surfaces 404 via ApiError.code', async () => {
        fetchSpy.mockResolvedValueOnce(envelope(404, 'not_found', 'not found'));
        await expect(getChapter('m1', 'unknown-uuid')).rejects.toMatchObject({
            status: 404,
            code: 'not_found'
        });
    });

    it('createChapter POSTs multipart and renames page files to page-NNN.<ext>', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ ...chapterFixture, page_count: 3 }));
        const pages = [
            new File([new Uint8Array([1, 2])], 'IMG_2837.HEIC', { type: 'image/jpeg' }),
            new File([new Uint8Array([3, 4])], 'random.png', { type: 'image/png' }),
            // No extension; MIME-derived fallback should kick in.
            new File([new Uint8Array([5])], 'scan_42', { type: 'image/webp' })
        ];
        const result = await createChapter(
            'm1',
            { number: 1, title: null },
            pages
        );
        expect(result.page_count).toBe(3);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/mangas\/m1\/chapters$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('POST');
        const form = init.body as FormData;
        // Metadata part is JSON.
        const metadata = form.get('metadata') as Blob;
        expect(metadata.type).toBe('application/json');
        // Three pages, all renamed; original filenames discarded.
        const submitted = form.getAll('page') as File[];
        expect(submitted).toHaveLength(3);
        // Original-extension preferred over MIME-derived; capitalised
        // .HEIC dropped because it's not in the allowed list, so the
        // MIME-derived `.jpg` wins.
        expect(submitted[0].name).toBe('page-001.jpg');
        expect(submitted[1].name).toBe('page-002.png');
        expect(submitted[2].name).toBe('page-003.webp');
        // No original filenames leak through.
        for (const f of submitted) {
            expect(f.name).not.toMatch(/IMG_2837|random|scan_42/);
        }
    });

    describe('chapterLabel', () => {
        it('returns the site title verbatim when present', () => {
            expect(chapterLabel({ number: 7, title: 'Ch.7 : Official' })).toBe(
                'Ch.7 : Official'
            );
        });

        it('falls back to "Chapter {number}" when title is null', () => {
            expect(chapterLabel({ number: 3, title: null })).toBe('Chapter 3');
        });
    });

    it('getChapterPages unwraps the {pages} envelope into the array', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok({
                pages: [
                    {
                        id: 'p1',
                        chapter_id: 'c1',
                        page_number: 1,
                        storage_key: 'mangas/m1/chapters/c1/pages/0001.png',
                        content_type: 'image/png'
                    }
                ]
            })
        );
        const pages = await getChapterPages('m1', 'ch-uuid-1');
        expect(pages).toHaveLength(1);
        expect(pages[0].storage_key).toContain('0001.png');
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/mangas\/m1\/chapters\/ch-uuid-1\/pages$/);
    });
});
