// @vitest-environment jsdom
import 'fake-indexeddb/auto';
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 { clearTryOns } from './demo/tryOnStore';
import { en } from './i18n/en';
import type { WidgetConfig } from './types';

const { tryOnSync, fetchImageAsBase64, trackTryOnCompleted, compressGarmentImage } = vi.hoisted(
	() => ( {
		tryOnSync: vi.fn(),
		fetchImageAsBase64: vi.fn(),
		trackTryOnCompleted: vi.fn(),
		compressGarmentImage: vi.fn(),
	} )
);

vi.mock( './api', () => ( {
	VirtualTryOnAPI: vi.fn( () => ( { tryOnSync } ) ),
	QuotaExhaustedError: class QuotaExhaustedError extends Error {},
	VisitorRateLimitedError: class VisitorRateLimitedError extends Error {},
} ) );
vi.mock( './imageUtils', () => ( {
	fetchImageAsBase64,
	compressGarmentImage,
	downloadImage: vi.fn(),
	UndecodableImageError: class UndecodableImageError extends Error {},
} ) );
vi.mock( './events', () => ( { trackTryOnCompleted } ) );

// Only the shell and the screens that are not under test are stubbed. The demo
// components render for real, so these assertions are about what a visitor sees.
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="merchant-result" /> } ) );
vi.mock( './components/ErrorView', () => ( { ErrorView: () => <div data-testid="error" /> } ) );
vi.mock( './components/QuotaBlockedView', () => ( { QuotaBlockedView: () => <div data-testid="quota" /> } ) );

const MERCHANT: WidgetConfig = {
	apiBaseUrl: 'https://brain.test/api/storefront/tryon',
	publishableKey: 'pk_test',
	productImage: 'https://shop.test/garment.png',
	productTitle: 'Amalfi Linen Dress',
	productId: 'prod_1',
	productUrl: 'https://shop.test/product/amalfi',
	locale: 'en',
};

const DEMO: WidgetConfig = {
	...MERCHANT,
	demo: true,
	demoTryOnLimit: 5,
	demoLeadUrl: '/wp-json/vela-demo/v1/lead',
};

const AVATAR = { imageData: 'data:image/png;base64,AVATAR', createdAt: '2026-01-01T00:00:00Z' };

function seedReturningVisitor(): void {
	localStorage.setItem( 'aisthetix-avatar', JSON.stringify( AVATAR ) );
	setConsent();
}

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

async function click( element: HTMLElement ): Promise<void> {
	await act( async () => {
		fireEvent.click( element );
	} );
}

beforeEach( async () => {
	// tryOnStore memoizes its connection, so swapping in a fresh IDBFactory would
	// not actually give this file a fresh database. Emptying the store does.
	await clearTryOns();
	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 );
	globalThis.fetch = vi.fn().mockResolvedValue( { ok: true } ) as unknown as typeof fetch;
} );

afterEach( () => {
	cleanup();
	document.body.innerHTML = '';
	Reflect.deleteProperty( globalThis, 'fetch' );
} );

// The demo customizations ship in the same bundle every merchant gets. The
// only thing keeping them out of a real storefront is this flag, so it is
// worth a test of its own.
describe( 'a merchant store, with the demo flag absent', () => {
	it( 'shows no counter and no way into a gallery', async () => {
		seedReturningVisitor();
		const trigger = mount( MERCHANT );
		await click( trigger );

		await waitFor( () => expect( screen.getByTestId( 'merchant-result' ) ).toBeTruthy() );
		expect( document.querySelector( '.aisthetix-modal-toolbar' ) ).toBeNull();
		expect( screen.queryByText( en.gallery.open ) ).toBeNull();
	} );

	it( 'keeps the merchant result screen, with no garment thumbnail on it', async () => {
		seedReturningVisitor();
		const trigger = mount( MERCHANT );
		await click( trigger );

		await waitFor( () => expect( screen.getByTestId( 'merchant-result' ) ).toBeTruthy() );
		expect( document.querySelector( '.aisthetix-tryon-figure-pip' ) ).toBeNull();
	} );

	it( 'never asks for an email, however many try-ons were made', async () => {
		localStorage.setItem( 'aisthetix-demo-tryons', '4' );
		seedReturningVisitor();
		const trigger = mount( MERCHANT );
		await click( trigger );

		await waitFor( () => expect( tryOnSync ).toHaveBeenCalledTimes( 1 ) );
		expect( screen.queryByText( en.lead.purpose ) ).toBeNull();
	} );
} );

describe( 'the counter, on the demo', () => {
	// "Before you start", not "when you run out": a visitor uploading their
	// first photo already knows what the demo gives them.
	it( 'is on screen before the first try-on', async () => {
		const trigger = mount( DEMO );
		await click( trigger );

		await waitFor( () => expect( screen.getByTestId( 'avatar-upload' ) ).toBeTruthy() );
		expect( screen.getByText( '5 try-ons left' ) ).toBeTruthy();
	} );

	it( 'counts down after a try-on', async () => {
		seedReturningVisitor();
		const trigger = mount( DEMO );
		await click( trigger );

		await waitFor( () => expect( screen.getByText( '4 try-ons left' ) ).toBeTruthy() );
	} );

	it( 'says nothing when the demo advertised no limit', async () => {
		const trigger = mount( { ...DEMO, demoTryOnLimit: undefined } );
		await click( trigger );

		await waitFor( () => expect( screen.getByTestId( 'avatar-upload' ) ).toBeTruthy() );
		expect( screen.queryByText( /try-ons? left/ ) ).toBeNull();
	} );
} );

describe( 'the result, on the demo', () => {
	it( 'puts the garment on the render as a thumbnail', async () => {
		seedReturningVisitor();
		const trigger = mount( DEMO );
		await click( trigger );

		await waitFor( () => {
			const pip = document.querySelector( '.aisthetix-tryon-figure-pip' );
			expect( pip?.getAttribute( 'src' ) ).toBe( 'data:image/png;base64,GARMENT' );
		} );
	} );

	it( 'files the try-on in the gallery, so it survives the modal closing', async () => {
		seedReturningVisitor();
		const trigger = mount( DEMO );
		await click( trigger );

		await waitFor( () =>
			expect( screen.getByText( en.gallery.openWithCount( 1 ) ) ).toBeTruthy()
		);

		await click( screen.getByText( en.gallery.openWithCount( 1 ) ) );

		await waitFor( () => expect( screen.getByText( 'Amalfi Linen Dress' ) ).toBeTruthy() );
		expect( screen.getByRole( 'link', { name: en.gallery.viewProduct } ).getAttribute( 'href' ) ).toBe(
			'https://shop.test/product/amalfi'
		);
		// The render came back out of IndexedDB, garment thumbnail and all.
		expect( document.querySelector( '.aisthetix-gallery-item .aisthetix-tryon-figure-pip' ) ).toBeTruthy();
	} );
} );

describe( 'the email gate, on the demo', () => {
	it( 'leaves the first try-on alone', async () => {
		seedReturningVisitor();
		const trigger = mount( DEMO );
		await click( trigger );

		await waitFor( () => expect( tryOnSync ).toHaveBeenCalledTimes( 1 ) );
		expect( screen.queryByText( en.lead.purpose ) ).toBeNull();
	} );

	it( 'stops the second one and states why it is asking', async () => {
		localStorage.setItem( 'aisthetix-demo-tryons', '1' );
		seedReturningVisitor();
		const trigger = mount( DEMO );
		await click( trigger );

		await waitFor( () => expect( screen.getByText( en.lead.purpose ) ).toBeTruthy() );
		expect( tryOnSync ).not.toHaveBeenCalled();
	} );

	it( 'runs the interrupted try-on once the address is given', async () => {
		localStorage.setItem( 'aisthetix-demo-tryons', '1' );
		seedReturningVisitor();
		const trigger = mount( DEMO );
		await click( trigger );

		await waitFor( () => expect( screen.getByText( en.lead.purpose ) ).toBeTruthy() );

		fireEvent.change( screen.getByLabelText( en.lead.emailLabel ), {
			target: { value: 'buyer@store.co' },
		} );
		fireEvent.click( screen.getByRole( 'checkbox' ) );
		await click( screen.getByRole( 'button', { name: en.lead.submit } ) );

		await waitFor( () => expect( tryOnSync ).toHaveBeenCalledTimes( 1 ) );
		expect( localStorage.getItem( 'aisthetix-demo-lead' ) ).toBe( 'buyer@store.co' );
	} );

	it( 'does not generate anything when the lead could not be saved', async () => {
		globalThis.fetch = vi.fn().mockResolvedValue( { ok: false } ) as unknown as typeof fetch;
		localStorage.setItem( 'aisthetix-demo-tryons', '1' );
		seedReturningVisitor();
		const trigger = mount( DEMO );
		await click( trigger );

		await waitFor( () => expect( screen.getByText( en.lead.purpose ) ).toBeTruthy() );

		fireEvent.change( screen.getByLabelText( en.lead.emailLabel ), {
			target: { value: 'buyer@store.co' },
		} );
		fireEvent.click( screen.getByRole( 'checkbox' ) );
		await click( screen.getByRole( 'button', { name: en.lead.submit } ) );

		await waitFor( () => expect( screen.getByRole( 'alert' ).textContent ).toBe( en.lead.error ) );
		expect( tryOnSync ).not.toHaveBeenCalled();
		expect( localStorage.getItem( 'aisthetix-demo-lead' ) ).toBeNull();
	} );

	// The way out has to cost nothing and take nothing away.
	it( 'lets the visitor leave without an address, keeping what they made', async () => {
		localStorage.setItem( 'aisthetix-demo-tryons', '1' );
		seedReturningVisitor();
		const trigger = mount( DEMO );
		await click( trigger );

		await waitFor( () => expect( screen.getByText( en.lead.purpose ) ).toBeTruthy() );
		await click( screen.getByRole( 'button', { name: en.lead.skip } ) );

		expect( tryOnSync ).not.toHaveBeenCalled();
		expect( localStorage.getItem( 'aisthetix-demo-tryons' ) ).toBe( '1' );
	} );
} );
