import {
  ConnectionStatus,
  DEFAULT_RECONNECT_CONFIG,
  buildHeartbeatPayload,
  computeBackoffDelay,
  decodeJwtPayload,
  ensureFreshAccessToken,
  shouldRefreshAccessToken,
  subscribeLifecycleEvents,
} from './websocketReconnect';

describe('websocketReconnect helpers', () => {
  describe('computeBackoffDelay', () => {
    it('stays within exponential cap for attempt 0', () => {
      const delay = computeBackoffDelay(0, { baseDelayMs: 1000, maxDelayMs: 30000 }, () => 1);
      expect(delay).toBe(1000);
    });

    it('doubles upper bound each attempt until maxDelay', () => {
      const delay = computeBackoffDelay(3, { baseDelayMs: 1000, maxDelayMs: 30000 }, () => 1);
      expect(delay).toBe(8000);
    });

    it('never exceeds maxDelayMs', () => {
      const delay = computeBackoffDelay(20, { baseDelayMs: 1000, maxDelayMs: 5000 }, () => 1);
      expect(delay).toBe(5000);
    });

    it('applies jitter via random()', () => {
      const delay = computeBackoffDelay(2, { baseDelayMs: 1000, maxDelayMs: 30000 }, () => 0.5);
      expect(delay).toBe(2000);
    });
  });

  describe('shouldRefreshAccessToken', () => {
    it('returns true when token already expired', () => {
      expect(shouldRefreshAccessToken(100, 200, 60)).toBe(true);
    });

    it('returns true within skew window', () => {
      expect(shouldRefreshAccessToken(250, 200, 60)).toBe(true);
    });

    it('returns false when plenty of lifetime remains', () => {
      expect(shouldRefreshAccessToken(500, 200, 60)).toBe(false);
    });

    it('returns false for missing exp', () => {
      expect(shouldRefreshAccessToken(undefined, 200, 60)).toBe(false);
    });
  });

  describe('decodeJwtPayload', () => {
    it('decodes a minimal JWT payload', () => {
      // {"exp":1999999999}
      const token = `x.${btoa(JSON.stringify({ exp: 1999999999 }))}.y`;
      expect(decodeJwtPayload(token)).toEqual({ exp: 1999999999 });
    });

    it('returns null for garbage', () => {
      expect(decodeJwtPayload('not-a-jwt')).toBeNull();
      expect(decodeJwtPayload(null)).toBeNull();
    });
  });

  describe('ensureFreshAccessToken', () => {
    it('returns existing access token when still fresh', async () => {
      const refreshRequest = jest.fn();
      const token = await ensureFreshAccessToken({
        getAccessToken: () =>
          `h.${btoa(JSON.stringify({ exp: 9999999999 }))}.s`,
        getRefreshToken: () =>
          `h.${btoa(JSON.stringify({ exp: 9999999999 }))}.s`,
        refreshRequest,
        nowSeconds: () => 1000,
        skewSeconds: 60,
      });
      expect(token).toContain('h.');
      expect(refreshRequest).not.toHaveBeenCalled();
    });

    it('refreshes when access token is near expiry', async () => {
      const setTokens = jest.fn();
      const refreshRequest = jest.fn().mockResolvedValue({
        access: 'new-access',
        refresh: 'new-refresh',
      });
      const token = await ensureFreshAccessToken({
        getAccessToken: () =>
          `h.${btoa(JSON.stringify({ exp: 1050 }))}.s`,
        getRefreshToken: () =>
          `h.${btoa(JSON.stringify({ exp: 9999999999 }))}.s`,
        setTokens,
        refreshRequest,
        nowSeconds: () => 1000,
        skewSeconds: 60,
      });
      expect(refreshRequest).toHaveBeenCalled();
      expect(setTokens).toHaveBeenCalledWith('new-access', 'new-refresh');
      expect(token).toBe('new-access');
    });

    it('returns null when refresh token is expired', async () => {
      const refreshRequest = jest.fn();
      const token = await ensureFreshAccessToken({
        getAccessToken: () =>
          `h.${btoa(JSON.stringify({ exp: 1000 }))}.s`,
        getRefreshToken: () =>
          `h.${btoa(JSON.stringify({ exp: 900 }))}.s`,
        refreshRequest,
        nowSeconds: () => 1000,
        skewSeconds: 60,
      });
      expect(token).toBeNull();
      expect(refreshRequest).not.toHaveBeenCalled();
    });
  });

  describe('buildHeartbeatPayload', () => {
    it('includes ping type and email', () => {
      expect(JSON.parse(buildHeartbeatPayload('a@b.com'))).toEqual({
        type: 'ping',
        email: 'a@b.com',
      });
    });
  });

  describe('ConnectionStatus / defaults', () => {
    it('exposes expected status strings', () => {
      expect(ConnectionStatus.CONNECTED).toBe('connected');
      expect(ConnectionStatus.RECONNECTING).toBe('reconnecting');
      expect(DEFAULT_RECONNECT_CONFIG.maxAttempts).toBeGreaterThan(0);
    });
  });

  describe('subscribeLifecycleEvents', () => {
    it('fires onResume when document becomes visible', () => {
      const onResume = jest.fn();
      const onOnline = jest.fn();
      const cleanup = subscribeLifecycleEvents({ onResume, onOnline });

      Object.defineProperty(document, 'visibilityState', {
        configurable: true,
        get: () => 'visible',
      });
      document.dispatchEvent(new Event('visibilitychange'));
      expect(onResume).toHaveBeenCalled();

      cleanup();
    });

    it('fires onOnline for window online event', () => {
      const onResume = jest.fn();
      const onOnline = jest.fn();
      const cleanup = subscribeLifecycleEvents({ onResume, onOnline });

      window.dispatchEvent(new Event('online'));
      expect(onOnline).toHaveBeenCalled();

      cleanup();
    });

    it('hooks Capacitor App/Network plugins when present', () => {
      const appListener = jest.fn(() => ({ remove: jest.fn() }));
      const networkListener = jest.fn(() => ({ remove: jest.fn() }));
      window.Capacitor = {
        Plugins: {
          App: { addListener: appListener },
          Network: { addListener: networkListener },
        },
      };

      const cleanup = subscribeLifecycleEvents({
        onResume: jest.fn(),
        onOnline: jest.fn(),
      });

      expect(appListener).toHaveBeenCalledWith(
        'appStateChange',
        expect.any(Function),
      );
      expect(networkListener).toHaveBeenCalledWith(
        'networkStatusChange',
        expect.any(Function),
      );

      cleanup();
      delete window.Capacitor;
    });
  });
});
