import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import { ApiError, request, setOn401Hook, fileUrl } from './client';
import { getManga } from './mangas';

describe('fileUrl', () => {
    it('keeps a normal /-separated key path literal', () => {
        expect(fileUrl('mangas/abc/chapters/def/pages/0001.jpg')).toBe(
            '/api/v1/files/mangas/abc/chapters/def/pages/0001.jpg'
        );
    });

    it('percent-encodes reserved characters within a segment', () => {
        // `?`, `#`, `%`, and spaces inside a segment must be encoded so they
        // can't be reinterpreted as query/fragment delimiters — while the
        // slashes stay as literal path separators.
        expect(fileUrl('weird key/a?b#c%d/x y.png')).toBe(
            '/api/v1/files/weird%20key/a%3Fb%23c%25d/x%20y.png'
        );
    });
});

describe('request error envelope parsing', () => {
    let fetchSpy: MockInstance<typeof globalThis.fetch>;

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

    it('parses {error:{code,message}} into ApiError.code and message', async () => {
        fetchSpy.mockResolvedValueOnce(
            new Response(
                JSON.stringify({ error: { code: 'invalid_input', message: 'title is required' } }),
                { status: 400, headers: { 'content-type': 'application/json' } }
            )
        );
        await expect(getManga('x')).rejects.toMatchObject({
            status: 400,
            code: 'invalid_input',
            message: 'title is required'
        });
    });

    it('falls back to http_error code when body is HTML (e.g. upstream proxy)', async () => {
        fetchSpy.mockResolvedValueOnce(
            new Response('<html>upstream proxy bad</html>', {
                status: 502,
                headers: { 'content-type': 'text/html' }
            })
        );
        const err = (await getManga('x').catch((e) => e)) as ApiError;
        expect(err).toBeInstanceOf(ApiError);
        expect(err.status).toBe(502);
        expect(err.code).toBe('http_error');
        expect(err.message).toContain('upstream proxy bad');
    });

    it('falls back to http_error code when body is empty', async () => {
        fetchSpy.mockResolvedValueOnce(new Response('', { status: 500 }));
        const err = (await getManga('x').catch((e) => e)) as ApiError;
        expect(err).toBeInstanceOf(ApiError);
        expect(err.status).toBe(500);
        expect(err.code).toBe('http_error');
    });

    it('treats empty 200/201 bodies as undefined (no JSON.parse crash)', async () => {
        // Regression: addMangaToCollection is typed `void` and the
        // backend returns 201 (created) / 200 (already there) with
        // no body. Without the empty-body short-circuit, `res.json()`
        // would throw `JSON.parse: unexpected end of data`.
        fetchSpy.mockResolvedValueOnce(new Response(null, { status: 201 }));
        const created = await request<void>('/v1/whatever', { method: 'POST' });
        expect(created).toBeUndefined();

        fetchSpy.mockResolvedValueOnce(new Response(null, { status: 200 }));
        const ok200 = await request<void>('/v1/whatever', { method: 'POST' });
        expect(ok200).toBeUndefined();
    });

    it('falls back to http_error code when JSON has no error envelope', async () => {
        fetchSpy.mockResolvedValueOnce(
            new Response(JSON.stringify({ message: 'oops' }), {
                status: 500,
                headers: { 'content-type': 'application/json' }
            })
        );
        const err = (await getManga('x').catch((e) => e)) as ApiError;
        expect(err.code).toBe('http_error');
    });
});

describe('on401 hook', () => {
    let fetchSpy: MockInstance<typeof globalThis.fetch>;

    beforeEach(() => {
        fetchSpy = vi.spyOn(globalThis, 'fetch');
    });
    afterEach(() => {
        vi.restoreAllMocks();
        // Critical: reset the module-level hook between tests so a
        // hook installed by one test doesn't leak into the next.
        setOn401Hook(null);
    });

    it('invokes the hook exactly once on a 401 response and re-throws', async () => {
        const hook = vi.fn();
        setOn401Hook(hook);
        fetchSpy.mockResolvedValueOnce(
            new Response(
                JSON.stringify({ error: { code: 'unauthenticated', message: 'no auth' } }),
                { status: 401, headers: { 'content-type': 'application/json' } }
            )
        );
        await expect(getManga('x')).rejects.toMatchObject({
            status: 401,
            code: 'unauthenticated'
        });
        expect(hook).toHaveBeenCalledTimes(1);
    });

    it('does not invoke the hook on a 401 when suppressOn401 is set', async () => {
        // The change-password endpoint returns 401 for a *wrong current
        // password*, not an expired session — clearing the cached user
        // there would spuriously log the (still-authenticated) user out.
        // Such calls opt out of the hook while still throwing the 401.
        const hook = vi.fn();
        setOn401Hook(hook);
        fetchSpy.mockResolvedValueOnce(
            new Response(
                JSON.stringify({ error: { code: 'unauthenticated', message: 'wrong password' } }),
                { status: 401, headers: { 'content-type': 'application/json' } }
            )
        );
        await expect(
            request('/v1/auth/me/password', { method: 'PATCH' }, { suppressOn401: true })
        ).rejects.toMatchObject({ status: 401, code: 'unauthenticated' });
        expect(hook).not.toHaveBeenCalled();
    });

    it('does not invoke the hook on non-401 errors', async () => {
        const hook = vi.fn();
        setOn401Hook(hook);
        fetchSpy.mockResolvedValueOnce(
            new Response(
                JSON.stringify({ error: { code: 'not_found', message: 'no' } }),
                { status: 404, headers: { 'content-type': 'application/json' } }
            )
        );
        await expect(getManga('x')).rejects.toMatchObject({ status: 404 });
        expect(hook).not.toHaveBeenCalled();
    });

    it('does not invoke the hook on successful responses', async () => {
        const hook = vi.fn();
        setOn401Hook(hook);
        fetchSpy.mockResolvedValueOnce(
            new Response(
                JSON.stringify({
                    id: 'm1',
                    title: 't',
                    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: [],
                    genres: [],
                    tags: []
                }),
                { status: 200, headers: { 'content-type': 'application/json' } }
            )
        );
        await getManga('m1');
        expect(hook).not.toHaveBeenCalled();
    });

    it('swallows hook exceptions so the original ApiError still propagates', async () => {
        const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
        setOn401Hook(() => {
            throw new Error('hook boom');
        });
        fetchSpy.mockResolvedValueOnce(
            new Response(
                JSON.stringify({ error: { code: 'unauthenticated', message: 'x' } }),
                { status: 401, headers: { 'content-type': 'application/json' } }
            )
        );
        await expect(getManga('x')).rejects.toMatchObject({ status: 401 });
        // The original ApiError won — the hook's panic was logged but
        // didn't replace the API error.
        expect(consoleSpy).toHaveBeenCalled();
    });
});
