// @vitest-environment jsdom
import type { ReactNode } from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act, render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { App } from './App';
import { setConsent } from './storage';
import type { WidgetConfig } from './types';

// Shared spies must exist before the hoisted vi.mock factories run.
const { tryOnSync, fetchImageAsBase64, trackTryOnCompleted, compressGarmentImage } = vi.hoisted( () => ( {
	tryOnSync: vi.fn(),
	fetchImageAsBase64: vi.fn(),
	trackTryOnCompleted: vi.fn(),
	compressGarmentImage: vi.fn(),
} ) );

// new VirtualTryOnAPI(...) returns the object below, so the App's `api.tryOnSync`
// is our spy. storage is intentionally NOT mocked — hasConsent() reads the real
// jsdom localStorage, which is what the gate depends on.
vi.mock( './api', () => ( {
	VirtualTryOnAPI: vi.fn( () => ( { tryOnSync } ) ),
	QuotaExhaustedError: class QuotaExhaustedError extends Error {},
	VisitorRateLimitedError: class VisitorRateLimitedError extends Error {},
} ) );
vi.mock( './imageUtils', () => ( {
	fetchImageAsBase64,
	compressGarmentImage,
	// Real class so `instanceof` works across the mock boundary.
	UndecodableImageError: class UndecodableImageError extends Error {},
} ) );
vi.mock( './events', () => ( { trackTryOnCompleted } ) );

// Render sub-views as markers so the test isolates App's state machine + gate.
vi.mock( './components/Modal', () => ( {
	Modal: ( { isOpen, children }: { isOpen: boolean; children: ReactNode } ) =>
		isOpen ? <div data-testid="modal">{ children }</div> : null,
} ) );
vi.mock( './components/AvatarUpload', () => ( { AvatarUpload: () => <div data-testid="avatar-upload" /> } ) );
vi.mock( './components/ProcessingView', () => ( { ProcessingView: () => <div data-testid="processing" /> } ) );
vi.mock( './components/ResultView', () => ( { ResultView: () => <div data-testid="result" /> } ) );
vi.mock( './components/ErrorView', () => ( {
	ErrorView: ( { message }: { message?: string } ) => <div data-testid="error">{ message }</div>,
} ) );
vi.mock( './components/QuotaBlockedView', () => ( { QuotaBlockedView: () => <div data-testid="quota" /> } ) );

const CONFIG: WidgetConfig = {
	apiBaseUrl: 'https://brain.test/api/storefront/tryon',
	publishableKey: 'pk_test',
	productImage: 'https://shop.test/garment.png',
	productTitle: 'Test Garment',
	productId: 'prod_1',
	productCategories: [],
	visitorKey: 'test-visitor',
	locale: 'en',
};
const AVATAR = { imageData: 'data:image/png;base64,AVATAR', createdAt: '2026-01-01T00:00:00Z' };

// A stored avatar from a prior visit — the returning-visitor path that skips
// AvatarUpload and lands straight in startTryOnWithAvatar (the funnel under test).
function seedAvatar(): void {
	localStorage.setItem( 'aisthetix-avatar', JSON.stringify( AVATAR ) );
}

function mountWithTrigger(): HTMLButtonElement {
	const trigger = document.createElement( 'button' );
	trigger.id = 'aisthetix-tryon-trigger';
	document.body.appendChild( trigger );
	render( <App config={ CONFIG } /> );
	return trigger;
}

beforeEach( () => {
	localStorage.clear();
	tryOnSync.mockReset().mockResolvedValue( { image: 'RESULT_BASE64', cached: false } );
	fetchImageAsBase64.mockReset().mockResolvedValue( 'data:image/png;base64,GARMENT' );
	trackTryOnCompleted.mockReset();
	compressGarmentImage.mockReset().mockImplementation( async ( x: string ) => x );
} );

afterEach( () => {
	cleanup();
	document.body.innerHTML = '';
} );

describe( 'consent gate before AI try-on generation (startTryOnWithAvatar funnel)', () => {
	it( 'returning visitor WITHOUT consent: trigger routes to consent screen and never generates', async () => {
		seedAvatar(); // avatar present, but consent NOT set -> hasConsent() === false
		const trigger = mountWithTrigger();

		await act( async () => {
			fireEvent.click( trigger );
		} );

		await waitFor( () => expect( screen.getByTestId( 'avatar-upload' ) ).toBeTruthy() );
		expect( tryOnSync ).not.toHaveBeenCalled();
		expect( fetchImageAsBase64 ).not.toHaveBeenCalled();
	} );

	it( 'WITH consent: the same trigger path proceeds to generation and calls tryOnSync', async () => {
		seedAvatar();
		setConsent(); // hasConsent() === true

		const trigger = mountWithTrigger();

		await act( async () => {
			fireEvent.click( trigger );
		} );

		await waitFor( () => expect( tryOnSync ).toHaveBeenCalledTimes( 1 ) );
		expect( tryOnSync ).toHaveBeenCalledWith(
		AVATAR.imageData,
		'data:image/png;base64,GARMENT',
		'',
		'prod_1'
		);
	} );

	it( 'compresses the garment before sending it to try-on', async () => {
		seedAvatar();
		setConsent();
		compressGarmentImage.mockResolvedValue( 'data:image/jpeg;base64,COMPRESSED_GARMENT' );

		const trigger = mountWithTrigger();
		await act( async () => {
			fireEvent.click( trigger );
		} );

		await waitFor( () => expect( tryOnSync ).toHaveBeenCalledTimes( 1 ) );
		expect( compressGarmentImage ).toHaveBeenCalledWith( 'data:image/png;base64,GARMENT' );
		expect( tryOnSync ).toHaveBeenCalledWith(
		AVATAR.imageData,
		'data:image/jpeg;base64,COMPRESSED_GARMENT',
		'',
		'prod_1'
		);
	} );

	it( 'shows the clothing-image error copy when the garment fails to decode', async () => {
		seedAvatar();
		setConsent();
		const { UndecodableImageError } = await import( './imageUtils' );
		compressGarmentImage.mockRejectedValue( new ( UndecodableImageError as any )() );

		const trigger = mountWithTrigger();
		await act( async () => {
			fireEvent.click( trigger );
		} );

		await waitFor( () =>
			expect( screen.getByTestId( 'error' ).textContent ).toBe(
				'Could not load the clothing image. Please try again.'
			)
		);
		expect( tryOnSync ).not.toHaveBeenCalled();
	} );
} );
