// @vitest-environment jsdom
/**
 * The two consent gates in assets/bootstrap.js, driven end to end.
 *
 * The distinction they encode is the whole point of this file. ATTRIBUTION
 * events feed the merchant's own funnel on the merchant's own store, so the
 * merchant's opt-in is a legitimate basis for them. PRODUCT ANALYTICS goes to a
 * third party for OUR benefit, so only the shopper's CMP can open that gate —
 * a merchant toggle cannot stand in for a shopper's consent to that.
 *
 * The gates differ in their DEFAULT, which is where a refactor would quietly
 * merge them: with no CMP present, attribution follows the merchant setting and
 * product analytics is denied. Every case below pins one of those.
 */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const BOOTSTRAP_SRC = readFileSync( resolve( __dirname, '../../assets/bootstrap.js' ), 'utf8' );

function loadBootstrap( config: Record< string, unknown > ) {
	( window as any ).AISTHETIX_CONFIG = config;
	// eslint-disable-next-line no-new-func
	new Function( BOOTSTRAP_SRC )();
}

const BASE = {
	eventsUrl: 'https://woo.aisthetix.fyi/api/events',
	publishableKey: 'pk_test',
	visitorKey: 'vid-1',
	productId: '42',
};

/** Every POST the bootstrap made, parsed. */
function sentPayloads( fetchMock: ReturnType< typeof vi.fn > ): Array< Record< string, unknown > > {
	return fetchMock.mock.calls.map( ( call ) => JSON.parse( ( call[ 1 ] as { body: string } ).body ) );
}

let fetchMock: ReturnType< typeof vi.fn >;

beforeEach( () => {
	fetchMock = vi.fn( () => Promise.resolve( { ok: true } ) );
	( window as any ).fetch = fetchMock;
	delete ( window as any ).Cookiebot;
	delete ( window as any ).gtag;
	delete ( window as any ).google_tag_data;
	delete ( window as any ).dataLayer;
	document.cookie = 'cmplz_statistics=; expires=Thu, 01 Jan 1970 00:00:00 GMT';
	vi.useFakeTimers();
} );

afterEach( () => {
	vi.useRealTimers();
	delete ( window as any ).aisthetixTrackEvent;
	delete ( window as any ).aisthetixTrackProductEvent;
	delete ( window as any ).aisthetixProductAnalyticsAllowed;
	delete ( window as any ).AISTHETIX_CONFIG;
	vi.restoreAllMocks();
} );

describe( 'the product-analytics gate', () => {
	it( 'sends nothing without a CMP, even when the merchant enabled analytics', () => {
		// THE case that separates the two gates. Attribution would send here.
		loadBootstrap( { ...BASE, analyticsEnabled: true } );
		fetchMock.mockClear();

		const sent = ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', {
			productId: '42',
			hasSavedPhoto: false,
		} );

		expect( sent ).toBe( false );
		expect( fetchMock ).not.toHaveBeenCalled();
	} );

	it( 'sends when a CMP grants analytics, even with the merchant setting off', () => {
		( window as any ).Cookiebot = { consent: { statistics: true } };
		loadBootstrap( { ...BASE, analyticsEnabled: false } );
		fetchMock.mockClear();

		const sent = ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', {
			productId: '42',
			hasSavedPhoto: false,
		} );

		expect( sent ).toBe( true );
		expect( fetchMock ).toHaveBeenCalledTimes( 1 );
	} );

	it( 'sends nothing when a CMP denies, whatever the merchant set', () => {
		( window as any ).Cookiebot = { consent: { statistics: false } };
		loadBootstrap( { ...BASE, analyticsEnabled: true } );
		fetchMock.mockClear();

		expect(
			( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', {
				productId: '42',
				hasSavedPhoto: false,
			} )
		).toBe( false );
		expect( fetchMock ).not.toHaveBeenCalled();
	} );

	it( 'reports the gate to the widget, so the recorder is never even fetched', () => {
		loadBootstrap( { ...BASE, analyticsEnabled: true } );
		expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( false );

		( window as any ).Cookiebot = { consent: { statistics: true } };
		expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( true );
	} );

	it( 're-reads the CMP on every call, so a late grant takes effect', () => {
		loadBootstrap( { ...BASE, analyticsEnabled: false } );
		fetchMock.mockClear();

		expect(
			( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', hasSavedPhoto: false } )
		).toBe( false );

		// The shopper accepts the banner while the page is open.
		( window as any ).Cookiebot = { consent: { statistics: true } };

		expect(
			( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', hasSavedPhoto: false } )
		).toBe( true );
	} );

	it( 'stops immediately when consent is withdrawn mid-session', () => {
		( window as any ).Cookiebot = { consent: { statistics: true } };
		loadBootstrap( { ...BASE, analyticsEnabled: false } );
		fetchMock.mockClear();

		( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', hasSavedPhoto: false } );
		expect( fetchMock ).toHaveBeenCalledTimes( 1 );

		( window as any ).Cookiebot = { consent: { statistics: false } };
		( window as any ).aisthetixTrackProductEvent( 'tryon_requested', { productId: '42', requestId: 'r1' } );
		expect( fetchMock, 'no event after withdrawal' ).toHaveBeenCalledTimes( 1 );
	} );
} );

describe( 'the attribution gate, unchanged', () => {
	it( 'still follows the merchant setting when no CMP is present', () => {
		loadBootstrap( { ...BASE, analyticsEnabled: true } );
		fetchMock.mockClear();
		( window as any ).aisthetixTrackEvent( 'product_viewed', { productId: '42' } );
		expect( fetchMock ).toHaveBeenCalledTimes( 1 );
	} );

	it( 'still sends nothing when the setting is off and no CMP is present', () => {
		loadBootstrap( { ...BASE, analyticsEnabled: false } );
		fetchMock.mockClear();
		( window as any ).aisthetixTrackEvent( 'product_viewed', { productId: '42' } );
		expect( fetchMock ).not.toHaveBeenCalled();
	} );
} );

describe( 'the product-analytics payload', () => {
	beforeEach( () => {
		( window as any ).Cookiebot = { consent: { statistics: true } };
		loadBootstrap( { ...BASE, analyticsEnabled: false, pluginVersion: '0.6.0', locale: 'en_GB' } );
		fetchMock.mockClear();
	} );

	it( 'travels under the generic envelope, naming its own contract event', () => {
		( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', {
			productId: '42',
			hasSavedPhoto: true,
		} );
		const [ payload ] = sentPayloads( fetchMock );
		expect( payload.eventName ).toBe( 'aisthetix_product_analytics' );
		expect( payload.contractEvent ).toBe( 'tryon_modal_opened' );
	} );

	it( 'states the consent it saw, so the brain can enforce the same rule', () => {
		( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', {
			productId: '42',
			hasSavedPhoto: true,
		} );
		expect( sentPayloads( fetchMock )[ 0 ].analyticsConsent ).toBe( 'granted' );
	} );

	it( 'stamps the schema and plugin version on every event', () => {
		( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', {
			productId: '42',
			hasSavedPhoto: true,
		} );
		const props = sentPayloads( fetchMock )[ 0 ].props as Record< string, unknown >;
		expect( props.schemaVersion ).toBe( '1.0.0' );
		expect( props.appVersion ).toBe( '0.6.0' );
		expect( props.locale ).toBe( 'en_GB' );
	} );

	it( 'drops anything the contract does not declare for the event', () => {
		( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', {
			productId: '42',
			hasSavedPhoto: true,
			shopperNotes: 'anything at all',
		} );
		expect( sentPayloads( fetchMock )[ 0 ].props ).not.toHaveProperty( 'shopperNotes' );
	} );

	it( 'never carries a photo, a measurement or an email, whatever the caller passes', () => {
		// A generated identifier can legitimately contain the same digits as a measurement.
		vi.spyOn( window.crypto, 'randomUUID' ).mockReturnValue( '1a463714-0b57-4ccc-844a-e0cc3b108178' );
		( window as any ).aisthetixTrackProductEvent( 'tryon_completed', {
			productId: '42',
			requestId: 'r1',
			durationMs: 100,
			provider: 'gemini',
			cached: false,
			resultImage: 'data:image/png;base64,SECRET',
			heightCm: 178,
			weightKg: 72,
			email: 'shopper@example.com',
		} );
		const payload = sentPayloads( fetchMock )[ 0 ];
		const serialised = JSON.stringify( payload );
		expect( serialised ).not.toContain( 'SECRET' );
		expect( payload.eventId ).toBe( '1a463714-0b57-4ccc-844a-e0cc3b108178' );
		expect( JSON.stringify( payload.props ) ).not.toContain( '178' );
		for ( const field of [ 'resultImage', 'heightCm', 'weightKg', 'email' ] ) {
			expect( payload ).not.toHaveProperty( field );
			expect( payload.props ).not.toHaveProperty( field );
		}
		expect( serialised ).not.toContain( 'shopper@example.com' );
	} );

	it( 'drops a non-scalar value, which is what a blob or a photo would be', () => {
		( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', {
			productId: { toString: () => 'sneaky' },
			hasSavedPhoto: true,
		} );
		expect( sentPayloads( fetchMock )[ 0 ].props ).not.toHaveProperty( 'productId' );
	} );

	it( 'refuses an event the contract does not declare', () => {
		expect( ( window as any ).aisthetixTrackProductEvent( 'invented_event', { productId: '42' } ) ).toBe( false );
		expect( fetchMock ).not.toHaveBeenCalled();
	} );

	it( 'never sends a raw URL or referrer', () => {
		( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', {
			productId: '42',
			hasSavedPhoto: true,
			url: 'https://shop.example/p?discount=SECRET',
			referrer: 'https://google.com/search?q=private',
		} );
		const serialised = JSON.stringify( sentPayloads( fetchMock )[ 0 ] );
		expect( serialised ).not.toContain( 'SECRET' );
		expect( serialised ).not.toContain( 'q=private' );
	} );

	it( 'never breaks the storefront when fetch throws', () => {
		fetchMock.mockImplementation( () => {
			throw new Error( 'network stack exploded' );
		} );
		expect( () =>
			( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', hasSavedPhoto: true } )
		).not.toThrow();
	} );
} );

describe( 'consent changes announced to the widget', () => {
	it( 'announces a decision change so a running replay can be stopped', () => {
		loadBootstrap( { ...BASE, analyticsEnabled: false } );

		const seen: boolean[] = [];
		document.addEventListener( 'aisthetix:consent-changed', ( e ) => {
			seen.push( ( e as CustomEvent ).detail.productAnalytics );
		} );

		// The bootstrap binds on DOMContentLoaded; jsdom is already loaded, so
		// init() ran synchronously and the poll is armed.
		( window as any ).Cookiebot = { consent: { statistics: true } };
		vi.advanceTimersByTime( 1000 );
		expect( seen ).toEqual( [ true ] );

		( window as any ).Cookiebot = { consent: { statistics: false } };
		vi.advanceTimersByTime( 1000 );
		expect( seen ).toEqual( [ true, false ] );
	} );

	it( 'stops polling rather than leaving a timer running all session', () => {
		loadBootstrap( { ...BASE, analyticsEnabled: false } );
		const seen: boolean[] = [];
		document.addEventListener( 'aisthetix:consent-changed', ( e ) => {
			seen.push( ( e as CustomEvent ).detail.productAnalytics );
		} );

		vi.advanceTimersByTime( 31_000 );
		( window as any ).Cookiebot = { consent: { statistics: true } };
		vi.advanceTimersByTime( 10_000 );

		// A banner decision lands within seconds of the click; a timer that ran
		// for the whole session on every product page would be the wrong trade.
		expect( seen ).toEqual( [] );
	} );
} );

describe( 'the CMP signal, across every platform the loader claims to read', () => {
	// Only the Cookiebot branch was exercised before. The other three are the
	// ones that fail quietly: if Complianz parsing is wrong, product analytics
	// simply never works on a Complianz store and nobody is told — the gate
	// reads "no signal", and no signal is already a refusal.

	it( 'reads a Complianz grant from its cookie', () => {
		document.cookie = 'cmplz_statistics=allow';
		loadBootstrap( { ...BASE, analyticsEnabled: false } );
		expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( true );
	} );

	it( 'reads a Complianz refusal from its cookie', () => {
		document.cookie = 'cmplz_statistics=deny';
		loadBootstrap( { ...BASE, analyticsEnabled: true } );
		expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( false );
	} );

	it( 'reads a Complianz cookie sitting among others', () => {
		document.cookie = 'some_other=1';
		document.cookie = 'cmplz_statistics=allow';
		document.cookie = 'trailing=2';
		loadBootstrap( { ...BASE, analyticsEnabled: false } );
		expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( true );
	} );

	it( 'reads a Google Consent Mode update', () => {
		( window as any ).gtag = () => {};
		( window as any ).google_tag_data = {
			ics: { entries: { analytics_storage: { default: 'denied', update: 'granted' } } },
		};
		loadBootstrap( { ...BASE, analyticsEnabled: false } );
		// The update supersedes the default — that is the whole point of the
		// two fields, and reading the default would freeze a shopper on whatever
		// the site declared before they answered.
		expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( true );
	} );

	it( 'falls back to the Consent Mode default when there is no update yet', () => {
		( window as any ).gtag = () => {};
		( window as any ).google_tag_data = {
			ics: { entries: { analytics_storage: { default: 'denied' } } },
		};
		loadBootstrap( { ...BASE, analyticsEnabled: true } );
		expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( false );
	} );

	it( 'scans the dataLayer when Consent Mode has not populated its store', () => {
		( window as any ).dataLayer = [
			[ 'js', new Date() ],
			[ 'consent', 'default', { analytics_storage: 'denied' } ],
			[ 'consent', 'update', { analytics_storage: 'granted' } ],
		];
		loadBootstrap( { ...BASE, analyticsEnabled: false } );
		// Scanned backwards, so the most recent decision wins.
		expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( true );
	} );

	it( 'takes the most recent dataLayer decision, not the first', () => {
		( window as any ).dataLayer = [
			[ 'consent', 'default', { analytics_storage: 'granted' } ],
			[ 'consent', 'update', { analytics_storage: 'denied' } ],
		];
		loadBootstrap( { ...BASE, analyticsEnabled: true } );
		expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( false );
	} );

	it( 'ignores unrelated dataLayer rows', () => {
		( window as any ).dataLayer = [
			[ 'event', 'page_view' ],
			[ 'config', 'G-XXXX' ],
		];
		loadBootstrap( { ...BASE, analyticsEnabled: true } );
		// No recognised signal at all — which is a refusal for product analytics
		// however the merchant's own setting is configured.
		expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( false );
	} );

	it( 'lets Cookiebot win over a conflicting Complianz cookie', () => {
		( window as any ).Cookiebot = { consent: { statistics: false } };
		document.cookie = 'cmplz_statistics=allow';
		loadBootstrap( { ...BASE, analyticsEnabled: true } );
		// A site running two CMPs is misconfigured, but the refusal must win.
		expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( false );
	} );

	it( 'never throws when a CMP global is a shape it did not expect', () => {
		( window as any ).Cookiebot = 'not an object';
		( window as any ).google_tag_data = { ics: null };
		( window as any ).dataLayer = 'not an array';
		loadBootstrap( { ...BASE, analyticsEnabled: true } );
		expect( () => ( window as any ).aisthetixProductAnalyticsAllowed() ).not.toThrow();
		expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( false );
	} );
} );

describe( 'the ephemeral analytics session id', () => {
	it( 'is sent so a logged-out shopper on an attribution-off store still correlates', () => {
		// The ordinary WooCommerce shopper: attribution off (the default) means no
		// visitorKey cookie, logged out means no customerId. Without an id of some
		// kind the brain has nothing to key the funnel on and discards every
		// consented event.
		( window as any ).Cookiebot = { consent: { statistics: true } };
		loadBootstrap( { ...BASE, visitorKey: null, customerId: null } );

		( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', {
			productId: '42',
			runId: 'r1',
			openIndex: 1,
			placement: 'button_block',
		} );

		const [ payload ] = sentPayloads( fetchMock ).filter(
			( p ) => p.eventName === 'aisthetix_product_analytics'
		);
		expect( payload.analyticsSessionId ).toMatch( /^as1_[a-z0-9]{8,64}$/ );
	} );

	it( 'is the same for every event on the page, so one shopper is one funnel', () => {
		( window as any ).Cookiebot = { consent: { statistics: true } };
		loadBootstrap( { ...BASE, visitorKey: null, customerId: null } );

		( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', {
			productId: '42', runId: 'r1', openIndex: 1, placement: 'button_block',
		} );
		( window as any ).aisthetixTrackProductEvent( 'tryon_requested', {
			productId: '42', runId: 'r1', requestId: 'q1', attempt: 1,
		} );

		const payloads = sentPayloads( fetchMock ).filter(
			( p ) => p.eventName === 'aisthetix_product_analytics'
		);
		expect( payloads ).toHaveLength( 2 );
		expect( payloads[ 1 ].analyticsSessionId ).toBe( payloads[ 0 ].analyticsSessionId );
	} );

	it( 'is not written to the device', () => {
		// Memory only. A shopper who consented to product analytics agreed to one
		// funnel being joined up, not to being recognisable on their next visit.
		( window as any ).Cookiebot = { consent: { statistics: true } };
		const cookiesBefore = document.cookie;
		loadBootstrap( { ...BASE, visitorKey: null, customerId: null } );

		( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', {
			productId: '42', runId: 'r1', openIndex: 1, placement: 'button_block',
		} );

		const [ payload ] = sentPayloads( fetchMock ).filter(
			( p ) => p.eventName === 'aisthetix_product_analytics'
		);
		const id = String( payload.analyticsSessionId );
		expect( document.cookie ).toBe( cookiesBefore );
		expect( localStorage.getItem( 'aisthetix_analytics_session' ) ).toBeNull();
		expect( JSON.stringify( { ...localStorage } ) ).not.toContain( id );
		expect( JSON.stringify( { ...sessionStorage } ) ).not.toContain( id );
	} );

	it( 'does not exist at all until a consented event is sent', () => {
		// No grant, no event, no identifier — not even in memory.
		loadBootstrap( { ...BASE, visitorKey: null, customerId: null } );

		const sent = ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', {
			productId: '42', runId: 'r1', openIndex: 1, placement: 'button_block',
		} );

		expect( sent ).toBe( false );
		expect(
			sentPayloads( fetchMock ).filter( ( p ) => p.eventName === 'aisthetix_product_analytics' )
		).toHaveLength( 0 );
	} );

	it( 'does not appear on an attribution payload', () => {
		// Attribution is a different pipeline with a different consent basis. It
		// has its own durable key and must not gain a second identifier.
		( window as any ).Cookiebot = { consent: { statistics: true } };
		loadBootstrap( { ...BASE, analyticsEnabled: true } );

		( window as any ).aisthetixTrackEvent( 'product_viewed', { productId: '42' } );

		const attribution = sentPayloads( fetchMock ).filter(
			( p ) => p.eventName !== 'aisthetix_product_analytics'
		);
		expect( attribution.length ).toBeGreaterThan( 0 );
		for ( const payload of attribution ) {
			expect( payload ).not.toHaveProperty( 'analyticsSessionId' );
		}
	} );
} );
