// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { QuotaExhaustedError, VirtualTryOnAPI, VisitorRateLimitedError } from './api';

function response( status: number, body: unknown, ok = status >= 200 && status < 300 ) {
	return { status, ok, json: vi.fn().mockResolvedValue( body ) } as unknown as Response;
}

describe( 'VirtualTryOnAPI', () => {
	afterEach( () => vi.unstubAllGlobals() );

	it( 'sends the multipart request with normalized endpoint and optional routing fields', async () => {
		const fetchMock = vi.fn().mockResolvedValue( response( 200, { image: 'done', cached: false } ) );
		vi.stubGlobal( 'fetch', fetchMock );
		const signal = new AbortController().signal;
		const api = new VirtualTryOnAPI( 'https://brain.test/tryon/', 'pk_test', 'visitor-1' );

		await expect( api.tryOnSync( 'data:image/png;base64,WA==', 'WA==', 'swimwear', '42', signal ) )
			.resolves.toEqual( { image: 'done', cached: false } );
		const [ endpoint, request ] = fetchMock.mock.calls[ 0 ];
		expect( endpoint ).toBe( 'https://brain.test/tryon' );
		expect( request.headers ).toEqual( {
			'X-Aisthetix-Key': 'pk_test',
			'X-Aisthetix-Visitor': 'visitor-1',
		} );
		expect( request.signal ).toBe( signal );
		expect( request.body.get( 'product_category' ) ).toBe( 'swimwear' );
		expect( request.body.get( 'product_id' ) ).toBe( '42' );
		expect( request.body.get( 'user_image' ) ).toBeInstanceOf( Blob );
	} );

	it( 'omits empty optional routing fields', async () => {
		const fetchMock = vi.fn().mockResolvedValue( response( 200, { image: 'done' } ) );
		vi.stubGlobal( 'fetch', fetchMock );
		await new VirtualTryOnAPI( '/tryon', 'key', 'visitor' ).tryOnSync( 'WA==', 'WA==' );
		const form = fetchMock.mock.calls[ 0 ][ 1 ].body as FormData;
		expect( form.has( 'product_category' ) ).toBe( false );
		expect( form.has( 'product_id' ) ).toBe( false );
	} );

	it( 'maps quota and visitor limits to stable typed errors', async () => {
		const fetchMock = vi.fn()
			.mockResolvedValueOnce( response( 402, {} ) )
			.mockResolvedValueOnce( response( 429, { cooldownMinutesRemaining: 17 } ) )
			.mockResolvedValueOnce( response( 429, { cooldownMinutesRemaining: 'bad' } ) );
		vi.stubGlobal( 'fetch', fetchMock );
		const api = new VirtualTryOnAPI( '/tryon', 'key', 'visitor' );

		await expect( api.tryOnSync( 'WA==', 'WA==' ) ).rejects.toBeInstanceOf( QuotaExhaustedError );
		await expect( api.tryOnSync( 'WA==', 'WA==' ) ).rejects.toMatchObject( {
			name: 'VisitorRateLimitedError', cooldownMinutesRemaining: 17,
		} satisfies Partial<VisitorRateLimitedError> );
		await expect( api.tryOnSync( 'WA==', 'WA==' ) ).rejects.toMatchObject( {
			cooldownMinutesRemaining: undefined,
		} );
	} );

	it( 'surfaces an API detail and falls back when an error body is unreadable', async () => {
		const unreadable = { status: 503, ok: false, json: vi.fn().mockRejectedValue( new Error( 'invalid json' ) ) };
		const fetchMock = vi.fn()
			.mockResolvedValueOnce( response( 400, { detail: 'Bad photo' }, false ) )
			.mockResolvedValueOnce( unreadable );
		vi.stubGlobal( 'fetch', fetchMock );
		const api = new VirtualTryOnAPI( '/tryon', 'key', 'visitor' );

		await expect( api.tryOnSync( 'WA==', 'WA==' ) ).rejects.toThrow( 'Bad photo' );
		await expect( api.tryOnSync( 'WA==', 'WA==' ) ).rejects.toThrow( 'Unknown error' );
	} );
} );
