import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AdsConfig } from './model';

// Mock ads.ts's three impure imports so the client-mirrored gate is observable without a VK
// bridge, a wallet context or the Svelte toast store. ./model is type-only (erased).
const mocks = vi.hoisted(() => ({
  vkShowInterstitial: vi.fn(),
  vkRewardedReady: vi.fn(),
  vkShowRewarded: vi.fn(),
  executionContext: vi.fn(),
  showToast: vi.fn(),
}));
vi.mock('./vk', () => ({
  vkShowInterstitial: mocks.vkShowInterstitial,
  vkRewardedReady: mocks.vkRewardedReady,
  vkShowRewarded: mocks.vkShowRewarded,
}));
vi.mock('./wallet', () => ({ executionContext: mocks.executionContext }));
vi.mock('./app.svelte', () => ({ showToast: mocks.showToast }));

import { maybeShowInterstitial } from './ads';

const ADS: AdsConfig = { cooldownGlobalS: 300, cooldownVsAiS: 1800, cooldownHintS: 60, suppressed: false };
const ONLINE = { vsAi: false, online: true };
// A realistic epoch base: the never-shown last time is 0, so the first show needs now >> cooldown
// (as with a real Date.now); anchoring at 0 would gate the very first call (now - 0 < cooldown).
const BASE = 1_700_000_000_000;

beforeEach(() => {
  // A minimal in-memory localStorage (node has none) so the per-kind last-shown gate persists
  // across calls within a test.
  const store = new Map<string, string>();
  (globalThis as unknown as { localStorage: Storage }).localStorage = {
    getItem: (k: string) => store.get(k) ?? null,
    setItem: (k: string, v: string) => void store.set(k, v),
    removeItem: (k: string) => void store.delete(k),
    clear: () => store.clear(),
    key: () => null,
    length: 0,
  } as Storage;
  vi.clearAllMocks();
  vi.useFakeTimers();
  vi.setSystemTime(BASE);
  mocks.executionContext.mockReturnValue('vk');
  mocks.vkShowInterstitial.mockResolvedValue(true);
});

afterEach(() => {
  vi.useRealTimers();
  vi.unstubAllEnvs();
});

describe('maybeShowInterstitial (client-mirrored gate)', () => {
  it('does not show when the config is absent', async () => {
    expect(await maybeShowInterstitial(undefined, 'move', ONLINE)).toBe(false);
    expect(mocks.vkShowInterstitial).not.toHaveBeenCalled();
  });

  it('does not show when suppressed (no-ads / no_banner)', async () => {
    expect(await maybeShowInterstitial({ ...ADS, suppressed: true }, 'move', ONLINE)).toBe(false);
    expect(mocks.showToast).not.toHaveBeenCalled();
    expect(mocks.vkShowInterstitial).not.toHaveBeenCalled();
  });

  it('does not show when offline', async () => {
    expect(await maybeShowInterstitial(ADS, 'move', { vsAi: false, online: false })).toBe(false);
    expect(mocks.vkShowInterstitial).not.toHaveBeenCalled();
  });

  it('does not show outside VK (real ad path)', async () => {
    mocks.executionContext.mockReturnValue('web');
    expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(false);
    expect(mocks.vkShowInterstitial).not.toHaveBeenCalled();
  });

  it('shows a VK interstitial inside VK when the cooldown allows', async () => {
    expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true);
    expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(1);
    expect(mocks.showToast).not.toHaveBeenCalled();
  });

  it('respects the cooldown: a second move within the window is gated', async () => {
    expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true);
    vi.setSystemTime(BASE + 299_000); // +299s < the 300s global cooldown
    expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(false);
    vi.setSystemTime(BASE + 300_000); // the cooldown has now elapsed
    expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true);
    expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(2);
  });

  it('shares one timer across kinds: a hint uses the shorter gap from the last ad', async () => {
    // A move ad, then a hint: the hint is held until its own (shorter) 60s gap from THAT ad has
    // elapsed — not fired at once (a per-kind timer used to let it fire immediately).
    expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true);
    vi.setSystemTime(BASE + 59_000);
    expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(false);
    vi.setSystemTime(BASE + 60_000); // the hint's 60s gap from the last ad has elapsed
    expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(true);
  });

  it('does not stack a move ad onto a just-shown hint ad (the reported bug)', async () => {
    // A vs_ai hint-move fires the hint ad; a plain vs_ai move 30s later must NOT fire — the shared
    // timer holds it for the vs_ai gap. Separate per-kind timers let it fire at once (the bug).
    const vsAi = { vsAi: true, online: true };
    expect(await maybeShowInterstitial(ADS, 'hint', vsAi)).toBe(true);
    vi.setSystemTime(BASE + 30_000); // 30s later, far under the 1800s vs_ai gap
    expect(await maybeShowInterstitial(ADS, 'move', vsAi)).toBe(false);
    expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(1); // only the hint ad fired
  });

  it('a hint-pushed ad restarts the vs_ai gap, so the next plain move is not over-served', async () => {
    // The scenario the owner flagged: a vs_ai ad, then 20 min later a hinted move pushes an ad on
    // its short gap, then a plain move 10 min after that must NOT fire — the shared timer restarted
    // the 30-min vs_ai gap at the hint ad, so 10 min is not enough (it fires only 30 min after it).
    const vsAi = { vsAi: true, online: true };
    expect(await maybeShowInterstitial(ADS, 'move', vsAi)).toBe(true); // t0
    vi.setSystemTime(BASE + 20 * 60_000); // +20 min: a hint pushes an ad on its 1-min gap
    expect(await maybeShowInterstitial(ADS, 'hint', vsAi)).toBe(true);
    vi.setSystemTime(BASE + 30 * 60_000); // +10 min after the hint ad
    expect(await maybeShowInterstitial(ADS, 'move', vsAi)).toBe(false);
    vi.setSystemTime(BASE + 50 * 60_000); // +30 min after the hint ad
    expect(await maybeShowInterstitial(ADS, 'move', vsAi)).toBe(true);
  });

  it('uses the longer vs_ai cooldown for a vs_ai move', async () => {
    expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(true);
    vi.setSystemTime(BASE + 1_799_000); // +1799s < the 1800s vs_ai cooldown
    expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(false);
    vi.setSystemTime(BASE + 1_800_000);
    expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(true);
  });

  it('shows the test-stub toast instead of a real ad when the stub flag is set', async () => {
    vi.stubEnv('VITE_ADS_STUB', '1');
    mocks.executionContext.mockReturnValue('web'); // the stub bypasses the VK gate
    expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true);
    expect(mocks.showToast).toHaveBeenCalledWith('ad fired');
    expect(mocks.vkShowInterstitial).not.toHaveBeenCalled();
  });
});
