// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
  AISTHETIX_INSTANCE,
  applyConsentDecision,
  MODAL_OVERLAY_CLASS,
  NO_CAPTURE_CLASS,
  SUPPORTED_PERSON_PROFILES,
  isRecording,
  replayConfigured,
  resetReplayForTests,
  revokeReplay,
  startReplay,
  stopReplay,
} from './replay';

const CONFIG = {
  key: 'phc_browser',
  host: 'https://ingest.aisthetix.fyi',
  enabled: true,
  environment: 'staging',
  store: 'store_1',
};

interface FakeSdk {
  init: ReturnType<typeof vi.fn>;
  startSessionRecording: ReturnType<typeof vi.fn>;
  stopSessionRecording: ReturnType<typeof vi.fn>;
  register: ReturnType<typeof vi.fn>;
  opt_out_capturing: ReturnType<typeof vi.fn>;
  opt_in_capturing: ReturnType<typeof vi.fn>;
}

let sdk: FakeSdk;
let appended: HTMLScriptElement[];

/**
 * Stand in for the CDN: capture the injected script tag and, when the test
 * chooses, resolve it by installing a fake SDK and firing `onload`.
 */
function interceptScriptInjection(installSdk: boolean) {
  appended = [];
  const original = document.head.appendChild.bind(document.head);
  vi.spyOn(document.head, 'appendChild').mockImplementation(((node: Node) => {
    if (node instanceof HTMLScriptElement) {
      appended.push(node);
      queueMicrotask(() => {
        if (installSdk) {
          (window as unknown as { posthog?: FakeSdk }).posthog = sdk;
          node.onload?.(new Event('load'));
        } else {
          node.onerror?.(new Event('error'));
        }
      });
      return node;
    }
    return original(node);
  }) as typeof document.head.appendChild);
}

beforeEach(() => {
  resetReplayForTests();
  delete (window as unknown as { posthog?: unknown }).posthog;
  sdk = {
    // posthog-js's `init(key, options, name)` returns the NAMED instance.
    init: vi.fn(() => sdk),
    startSessionRecording: vi.fn(),
    stopSessionRecording: vi.fn(),
    register: vi.fn(),
    opt_out_capturing: vi.fn(),
    opt_in_capturing: vi.fn(),
  };
});

afterEach(() => {
  vi.restoreAllMocks();
});

describe('whether replay runs at all', () => {
  it('is off without a key', () => {
    expect(replayConfigured({ ...CONFIG, key: '' })).toBe(false);
  });

  it('is off when the server switch is off', () => {
    expect(replayConfigured({ ...CONFIG, enabled: false })).toBe(false);
  });

  it('is off without an ingest host', () => {
    expect(replayConfigured({ ...CONFIG, host: '' })).toBe(false);
  });

  it('is off when there is no configuration at all', () => {
    expect(replayConfigured(null)).toBe(false);
    expect(replayConfigured(undefined)).toBe(false);
  });

  it('is on when key, host and switch are all present', () => {
    expect(replayConfigured(CONFIG)).toBe(true);
  });
});

describe('loading the recorder', () => {
  it('requests nothing before the modal opens and consent is granted', async () => {
    interceptScriptInjection(true);
    await startReplay(CONFIG, () => false);
    expect(appended, 'no script request without both gates').toHaveLength(0);
    expect(isRecording()).toBe(false);
  });

  it('requests nothing when replay is switched off, whatever the modal does', async () => {
    interceptScriptInjection(true);
    await startReplay({ ...CONFIG, enabled: false }, () => true);
    expect(appended).toHaveLength(0);
  });

  it('loads from our own proxy, never from posthog.com', async () => {
    interceptScriptInjection(true);
    await startReplay(CONFIG, () => true);
    expect(appended).toHaveLength(1);
    expect(appended[0].src).toBe('https://ingest.aisthetix.fyi/static/array.js');
    expect(appended[0].src).not.toContain('posthog.com');
  });

  it('tells theme optimizers to leave the injected script alone', async () => {
    interceptScriptInjection(true);
    await startReplay(CONFIG, () => true);
    expect(appended[0].getAttribute('data-no-optimize')).toBe('1');
    expect(appended[0].getAttribute('data-cfasync')).toBe('false');
  });

  it('loads once even if the modal is opened repeatedly', async () => {
    interceptScriptInjection(true);
    await startReplay(CONFIG, () => true);
    stopReplay();
    await startReplay(CONFIG, () => true);
    expect(appended).toHaveLength(1);
  });

  it('gives up quietly when the script cannot be loaded', async () => {
    interceptScriptInjection(false);
    await startReplay(CONFIG, () => true);
    expect(isRecording()).toBe(false);
  });
});

describe('how the recorder is configured', () => {
  beforeEach(async () => {
    interceptScriptInjection(true);
    await startReplay(CONFIG, () => true);
  });

  it('keeps nothing on the shopper device', () => {
    const options = sdk.init.mock.calls[0][1];
    expect(options.persistence).toBe('memory');
  });

  it('asks for no person profile using a value the SDK actually accepts', () => {
    // posthog-js accepts `always` and `identified_only`, and nothing else. An
    // unrecognised literal is not rejected — it silently falls back, so the
    // config would read as a privacy guarantee while meaning whatever the
    // default happens to be that release.
    const options = sdk.init.mock.calls[0][1];
    expect(SUPPORTED_PERSON_PROFILES).toContain(options.person_profiles);
    // The storefront never calls identify(), so this is "no profile, ever" —
    // stated in the SDK's own vocabulary.
    expect(options.person_profiles).toBe('identified_only');
  });

  it('captures nothing by itself', () => {
    const options = sdk.init.mock.calls[0][1];
    expect(options.autocapture).toBe(false);
    expect(options.capture_pageview).toBe(false);
    expect(options.capture_pageleave).toBe(false);
  });

  it('never starts recording at init — only when the modal explicitly asks', () => {
    const options = sdk.init.mock.calls[0][1];
    expect(options.disable_session_recording).toBe(true);
  });

  it('masks every input', () => {
    const recording = sdk.init.mock.calls[0][1].session_recording;
    expect(recording.maskAllInputs).toBe(true);
  });

  it('blocks every image, canvas and video — the shopper is in all of them', () => {
    const recording = sdk.init.mock.calls[0][1].session_recording;
    expect(recording.blockSelector).toContain('img');
    expect(recording.blockSelector).toContain('canvas');
    expect(recording.blockSelector).toContain('video');
    expect(recording.blockSelector).toContain(NO_CAPTURE_CLASS);
  });

  it('blocks everything the merchant put on the page, and keeps the modal itself', () => {
    // The selector is evaluated against a real DOM rather than read as a
    // string: what matters is which nodes rrweb will refuse to record, and only
    // `matches()` answers that.
    document.body.innerHTML = `
      <div id="theme-header">Storefront navigation</div>
      <p id="marketing">Free shipping over 50 EUR</p>
      <form id="newsletter"><input id="email" type="email" /></form>
      <div id="live-chat">Chat transcript</div>
      <div class="${MODAL_OVERLAY_CLASS}">
        <div class="aisthetix-modal-content">
          <h2 id="modal-title">Virtual try-on</h2>
          <img id="result" alt="try-on result" />
        </div>
      </div>
    `;
    const { blockSelector } = sdk.init.mock.calls[0][1].session_recording as { blockSelector: string };

    for (const id of ['theme-header', 'marketing', 'newsletter', 'live-chat']) {
      expect(document.getElementById(id)!.matches(blockSelector), `${id} is the merchant's, not ours`).toBe(true);
    }
    // rrweb blocks a node's whole subtree, so a descendant is covered when one
    // of its ancestors matches — which is what `closest` asks.
    expect(
      document.getElementById('email')!.closest(blockSelector),
      'and so is whatever a shopper typed into their newsletter field'
    ).not.toBeNull();
    const overlay = document.querySelector(`.${MODAL_OVERLAY_CLASS}`)!;
    expect(overlay.matches(blockSelector), 'the modal is the one thing we may record').toBe(false);
    expect(document.getElementById('modal-title')!.matches(blockSelector), 'and its own chrome with it').toBe(false);
    expect(document.getElementById('result')!.matches(blockSelector), 'but never an image inside it').toBe(true);
  });

  it('records no cross-origin iframe, which is where a merchant chat widget lives', () => {
    const recording = sdk.init.mock.calls[0][1].session_recording;
    expect(recording.recordCrossOriginIframes).toBe(false);
  });

  it('sends through our proxy', () => {
    expect(sdk.init.mock.calls[0][1].api_host).toBe('https://ingest.aisthetix.fyi');
  });

  it('labels the recording as WooCommerce, which is the platform it is on', () => {
    // A replay filed under `shopify` is a replay nobody looking at WooCommerce
    // will ever find, and one that quietly inflates every Shopify count it
    // lands in.
    expect(sdk.register).toHaveBeenCalledWith(expect.objectContaining({ platform: 'woocommerce' }));
  });

  it('registers the store and environment, so a replay can be found', () => {
    expect(sdk.register).toHaveBeenCalledWith(
      expect.objectContaining({ store: 'store_1', environment: 'staging', surface: 'storefront' })
    );
  });
});

describe( 'the sanitizer posthog-js is initialised with', () => {
	beforeEach( async () => {
		interceptScriptInjection( true );
		await startReplay( CONFIG, () => true );
	} );

	it( 'is installed as before_send, which is the only hook that sees $snapshot', () => {
		// capture_pageview: false stops the automatic pageview EVENT. It does not
		// stop posthog-js attaching $current_url and friends to everything else,
		// replay traffic included — and none of that passes through the brain's
		// sanitizer.
		const options = sdk.init.mock.calls[ 0 ][ 1 ];
		expect( typeof options.before_send ).toBe( 'function' );
	} );

	it( 'strips the query and fragment from a replay snapshot going out', () => {
		const beforeSend = sdk.init.mock.calls[ 0 ][ 1 ].before_send as ( e: unknown ) => unknown;

		const sent = beforeSend( {
			event: '$snapshot',
			properties: {
				$session_id: 's1',
				$current_url: 'https://shop.example.com/product/tee?add-to-cart=12&s=blue#f',
				$referrer: 'https://mail.example.com/i?to=shopper%40example.com',
				$snapshot_data: [
					{ type: 4, data: { href: 'https://shop.example.com/product/tee?add-to-cart=12' } },
				],
			},
		} ) as { properties: Record< string, unknown > };

		expect( sent.properties.$current_url ).toBe( 'https://shop.example.com/product/tee' );
		expect( sent.properties.$referrer ).toBe( 'https://mail.example.com/i' );
		const snapshot = sent.properties.$snapshot_data as Array< { data: Record< string, unknown > } >;
		expect( snapshot[ 0 ].data.href ).toBe( 'https://shop.example.com/product/tee' );
		expect( JSON.stringify( sent ) ).not.toContain( 'shopper%40example.com' );
	} );

	it( 'strips them from an ordinary capture too', () => {
		const beforeSend = sdk.init.mock.calls[ 0 ][ 1 ].before_send as ( e: unknown ) => unknown;

		const sent = beforeSend( {
			event: 'tryon_modal_opened',
			properties: { $current_url: 'https://shop.example.com/p?q=1' },
			$set_once: { $initial_referrer: 'https://ads.example.com/c?click_id=abc' },
		} ) as { properties: Record< string, unknown >; $set_once: Record< string, unknown > };

		expect( sent.properties.$current_url ).toBe( 'https://shop.example.com/p' );
		expect( sent.$set_once.$initial_referrer ).toBe( 'https://ads.example.com/c' );
	} );
} );

describe('the recording lifecycle', () => {
  it('starts when the modal is open and consent holds', async () => {
    interceptScriptInjection(true);
    await startReplay(CONFIG, () => true);
    expect(sdk.startSessionRecording).toHaveBeenCalledTimes(1);
    expect(isRecording()).toBe(true);
  });

  it('does not start if the modal closed while the SDK was loading', async () => {
    interceptScriptInjection(true);
    let open = true;
    const promise = startReplay(CONFIG, () => open);
    open = false; // shopper closed the modal mid-load
    await promise;
    expect(sdk.startSessionRecording).not.toHaveBeenCalled();
    expect(isRecording()).toBe(false);
  });

  it('does not start if consent was withdrawn while the SDK was loading', async () => {
    interceptScriptInjection(true);
    let allowed = true;
    const promise = startReplay(CONFIG, () => allowed);
    allowed = false;
    await promise;
    expect(sdk.startSessionRecording).not.toHaveBeenCalled();
  });

  it('stops when the modal closes', async () => {
    interceptScriptInjection(true);
    await startReplay(CONFIG, () => true);
    stopReplay();
    expect(sdk.stopSessionRecording).toHaveBeenCalledTimes(1);
    expect(isRecording()).toBe(false);
  });

  it('is safe to stop when nothing is recording', () => {
    expect(() => stopReplay()).not.toThrow();
    expect(sdk.stopSessionRecording).not.toHaveBeenCalled();
  });

  it('does not start twice for one modal run', async () => {
    interceptScriptInjection(true);
    await startReplay(CONFIG, () => true);
    await startReplay(CONFIG, () => true);
    expect(sdk.startSessionRecording).toHaveBeenCalledTimes(1);
  });

  it('opts out entirely when consent is withdrawn', async () => {
    interceptScriptInjection(true);
    await startReplay(CONFIG, () => true);
    revokeReplay();
    expect(sdk.stopSessionRecording).toHaveBeenCalled();
    expect(sdk.opt_out_capturing).toHaveBeenCalled();
    expect(isRecording()).toBe(false);
  });

  it('survives an SDK that throws on stop', async () => {
    interceptScriptInjection(true);
    await startReplay(CONFIG, () => true);
    sdk.stopSessionRecording.mockImplementation(() => {
      throw new Error('recorder already torn down');
    });
    expect(() => stopReplay()).not.toThrow();
    expect(isRecording()).toBe(false);
  });
});

describe('consent that changes while the modal is open', () => {
  it('starts recording on a grant that arrives after the modal opened', async () => {
    // The shopper opened the modal before answering the banner, then accepted.
    // Nothing had started, and nothing was listening for the moment it could.
    interceptScriptInjection(true);
    let granted = false;
    await startReplay(CONFIG, () => granted);
    expect(isRecording(), 'nothing may run before the grant').toBe(false);

    granted = true;
    await applyConsentDecision(true, CONFIG, () => granted);

    expect(sdk.startSessionRecording).toHaveBeenCalledTimes(1);
    expect(isRecording()).toBe(true);
  });

  it('stops and opts out when consent is withdrawn', async () => {
    interceptScriptInjection(true);
    await startReplay(CONFIG, () => true);

    await applyConsentDecision(false, CONFIG, () => false);

    expect(sdk.stopSessionRecording).toHaveBeenCalled();
    expect(sdk.opt_out_capturing).toHaveBeenCalled();
    expect(isRecording()).toBe(false);
  });

  it('opts back in before recording again when the same shopper re-grants', async () => {
    // opt_out_capturing is sticky: without an explicit opt-in the SDK keeps
    // dropping everything, so a re-grant would look like it worked and record
    // nothing at all.
    interceptScriptInjection(true);
    let granted = true;
    await startReplay(CONFIG, () => granted);

    granted = false;
    await applyConsentDecision(false, CONFIG, () => granted);
    expect(sdk.opt_out_capturing).toHaveBeenCalledTimes(1);

    granted = true;
    await applyConsentDecision(true, CONFIG, () => granted);

    expect(sdk.opt_in_capturing, 'the SDK must be told to send again').toHaveBeenCalledTimes(1);
    expect(sdk.opt_in_capturing.mock.invocationCallOrder[0]).toBeLessThan(
      sdk.startSessionRecording.mock.invocationCallOrder[1]
    );
    expect(isRecording()).toBe(true);
  });

  it('does not start on a grant that arrives after the modal closed', async () => {
    interceptScriptInjection(true);
    await startReplay(CONFIG, () => false);

    await applyConsentDecision(true, CONFIG, () => false);

    expect(sdk.startSessionRecording).not.toHaveBeenCalled();
    expect(isRecording()).toBe(false);
  });
});

describe( 'a merchant who already runs PostHog on their storefront', () => {
	/**
	 * The merchant's own instance, configured for the merchant's own project.
	 *
	 * No masking, no block selector, their key, their proxy. Recording through
	 * it would start a full-page recorder on someone else's configuration — our
	 * `before_send`, our memory persistence, our masks and the modal
	 * `blockSelector` would never be installed at all.
	 */
	function preloadHostileDefault() {
		const hostile = {
			__loaded: true,
			init: vi.fn(),
			startSessionRecording: vi.fn(),
			stopSessionRecording: vi.fn(),
			register: vi.fn(),
			capture: vi.fn(),
			opt_out_capturing: vi.fn(),
			opt_in_capturing: vi.fn(),
		};
		( window as unknown as { posthog?: unknown } ).posthog = hostile;
		return hostile;
	}

	it( 'never starts a recording on the merchant\'s instance', async () => {
		const hostile = preloadHostileDefault();
		hostile.init.mockImplementation( () => sdk );

		await startReplay( CONFIG, () => true );

		expect( hostile.startSessionRecording ).not.toHaveBeenCalled();
		expect( hostile.register ).not.toHaveBeenCalled();
		expect( sdk.startSessionRecording ).toHaveBeenCalledTimes( 1 );
	} );

	it( 'asks for a NAMED instance rather than reusing the default one', async () => {
		const hostile = preloadHostileDefault();
		hostile.init.mockImplementation( () => sdk );

		await startReplay( CONFIG, () => true );

		const [ key, options, name ] = hostile.init.mock.calls[ 0 ];
		expect( key ).toBe( 'phc_browser' );
		expect( name ).toBe( AISTHETIX_INSTANCE );
		expect( options.api_host ).toBe( 'https://ingest.aisthetix.fyi' );
		expect( typeof options.before_send ).toBe( 'function' );
		expect( options.persistence ).toBe( 'memory' );
		expect( options.person_profiles ).toBe( 'identified_only' );
		expect( options.session_recording.maskAllInputs ).toBe( true );
		expect( options.session_recording.blockSelector ).toContain( MODAL_OVERLAY_CLASS );
	} );

	it( 'stops and opts out on OUR instance, not the merchant\'s', async () => {
		const hostile = preloadHostileDefault();
		hostile.init.mockImplementation( () => sdk );
		await startReplay( CONFIG, () => true );

		revokeReplay();

		expect( hostile.stopSessionRecording ).not.toHaveBeenCalled();
		expect( hostile.opt_out_capturing ).not.toHaveBeenCalled();
		expect( sdk.stopSessionRecording ).toHaveBeenCalled();
		expect( sdk.opt_out_capturing ).toHaveBeenCalled();
	} );

	it( 'does not re-fetch the library when the merchant already loaded it', async () => {
		const hostile = preloadHostileDefault();
		hostile.init.mockImplementation( () => sdk );
		interceptScriptInjection( true );

		await startReplay( CONFIG, () => true );

		expect( appended ).toHaveLength( 0 );
	} );

	it( 'still creates the named instance when we loaded the library ourselves', async () => {
		interceptScriptInjection( true );
		await startReplay( CONFIG, () => true );

		expect( sdk.init ).toHaveBeenCalledTimes( 1 );
		expect( sdk.init.mock.calls[ 0 ][ 2 ] ).toBe( AISTHETIX_INSTANCE );
	} );
} );
