/**
 * @license
 * Copyright 2020 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import '../test/setup';
import { expect } from 'chai';
import { SinonStub, spy, stub, useFakeTimers } from 'sinon';
import { FirebaseApp } from '@firebase/app-types';
import {
  FAKE_SITE_KEY,
  getFakeApp,
  getFakeCustomTokenProvider,
  getFakePlatformLoggingProvider,
  removegreCAPTCHAScriptsOnPage
} from '../test/util';
import { activate } from './api';
import {
  getToken,
  addTokenListener,
  removeTokenListener,
  formatDummyToken,
  defaultTokenErrorData
} from './internal-api';
import * as reCAPTCHA from './recaptcha';
import * as client from './client';
import * as storage from './storage';
import { getState, clearState, setState, getDebugState } from './state';
import { AppCheckTokenListener } from '@firebase/app-check-interop-types';
import { Deferred } from '@firebase/util';

const fakePlatformLoggingProvider = getFakePlatformLoggingProvider();

describe('internal api', () => {
  let app: FirebaseApp;

  beforeEach(() => {
    app = getFakeApp();
  });

  afterEach(() => {
    clearState();
    removegreCAPTCHAScriptsOnPage();
  });
  // TODO: test error conditions
  describe('getToken()', () => {
    const fakeRecaptchaToken = 'fake-recaptcha-token';
    const fakeRecaptchaAppCheckToken = {
      token: 'fake-recaptcha-app-check-token',
      expireTimeMillis: 123,
      issuedAtTimeMillis: 0
    };

    const fakeCachedAppCheckToken = {
      token: 'fake-cached-app-check-token',
      expireTimeMillis: 123,
      issuedAtTimeMillis: 0
    };

    it('uses customTokenProvider to get an AppCheck token', async () => {
      const clock = useFakeTimers();
      const customTokenProvider = getFakeCustomTokenProvider();
      const customProviderSpy = spy(customTokenProvider, 'getToken');

      activate(app, customTokenProvider);
      const token = await getToken(app, fakePlatformLoggingProvider);

      expect(customProviderSpy).to.be.called;
      expect(token).to.deep.equal({
        token: 'fake-custom-app-check-token'
      });

      clock.restore();
    });

    it('uses reCAPTCHA token to exchange for AppCheck token if no customTokenProvider is provided', async () => {
      activate(app, FAKE_SITE_KEY);

      const reCAPTCHASpy = stub(reCAPTCHA, 'getToken').returns(
        Promise.resolve(fakeRecaptchaToken)
      );
      const exchangeTokenStub: SinonStub = stub(
        client,
        'exchangeToken'
      ).returns(Promise.resolve(fakeRecaptchaAppCheckToken));

      const token = await getToken(app, fakePlatformLoggingProvider);

      expect(reCAPTCHASpy).to.be.called;

      expect(exchangeTokenStub.args[0][0].body['recaptcha_token']).to.equal(
        fakeRecaptchaToken
      );
      expect(token).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token });
    });

    it('resolves with a dummy token and an error if failed to get a token', async () => {
      const errorStub = stub(console, 'error');
      activate(app, FAKE_SITE_KEY);

      const reCAPTCHASpy = stub(reCAPTCHA, 'getToken').returns(
        Promise.resolve(fakeRecaptchaToken)
      );

      const error = new Error('oops, something went wrong');
      stub(client, 'exchangeToken').returns(Promise.reject(error));

      const token = await getToken(app, fakePlatformLoggingProvider);

      expect(reCAPTCHASpy).to.be.called;
      expect(token).to.deep.equal({
        token: formatDummyToken(defaultTokenErrorData),
        error
      });
      expect(errorStub.args[0][1].message).to.include(
        'oops, something went wrong'
      );
      errorStub.restore();
    });

    it('notifies listeners using cached token', async () => {
      activate(app, FAKE_SITE_KEY);

      const clock = useFakeTimers();
      stub(storage, 'readTokenFromStorage').returns(
        Promise.resolve(fakeCachedAppCheckToken)
      );

      const listener1 = spy();
      const listener2 = spy();
      addTokenListener(app, fakePlatformLoggingProvider, listener1);
      addTokenListener(app, fakePlatformLoggingProvider, listener2);

      await getToken(app, fakePlatformLoggingProvider);

      expect(listener1).to.be.calledWith({
        token: fakeCachedAppCheckToken.token
      });
      expect(listener2).to.be.calledWith({
        token: fakeCachedAppCheckToken.token
      });

      clock.restore();
    });

    it('notifies listeners using new token', async () => {
      activate(app, FAKE_SITE_KEY);

      stub(storage, 'readTokenFromStorage').returns(Promise.resolve(undefined));
      stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
      stub(client, 'exchangeToken').returns(
        Promise.resolve(fakeRecaptchaAppCheckToken)
      );

      const listener1 = spy();
      const listener2 = spy();
      addTokenListener(app, fakePlatformLoggingProvider, listener1);
      addTokenListener(app, fakePlatformLoggingProvider, listener2);

      await getToken(app, fakePlatformLoggingProvider);

      expect(listener1).to.be.calledWith({
        token: fakeRecaptchaAppCheckToken.token
      });
      expect(listener2).to.be.calledWith({
        token: fakeRecaptchaAppCheckToken.token
      });
    });

    it('ignores listeners that throw', async () => {
      activate(app, FAKE_SITE_KEY);
      stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
      stub(client, 'exchangeToken').returns(
        Promise.resolve(fakeRecaptchaAppCheckToken)
      );
      const listener1 = (): void => {
        throw new Error();
      };
      const listener2 = spy();

      addTokenListener(app, fakePlatformLoggingProvider, listener1);
      addTokenListener(app, fakePlatformLoggingProvider, listener2);

      await getToken(app, fakePlatformLoggingProvider);

      expect(listener2).to.be.calledWith({
        token: fakeRecaptchaAppCheckToken.token
      });
    });

    it('loads persisted token to memory and returns it', async () => {
      const clock = useFakeTimers();
      activate(app, FAKE_SITE_KEY);

      stub(storage, 'readTokenFromStorage').returns(
        Promise.resolve(fakeCachedAppCheckToken)
      );

      const clientStub = stub(client, 'exchangeToken');

      expect(getState(app).token).to.equal(undefined);
      expect(await getToken(app, fakePlatformLoggingProvider)).to.deep.equal({
        token: fakeCachedAppCheckToken.token
      });
      expect(getState(app).token).to.equal(fakeCachedAppCheckToken);
      expect(clientStub).has.not.been.called;

      clock.restore();
    });

    it('persists token to storage', async () => {
      activate(app, FAKE_SITE_KEY);

      stub(storage, 'readTokenFromStorage').returns(Promise.resolve(undefined));
      stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
      stub(client, 'exchangeToken').returns(
        Promise.resolve(fakeRecaptchaAppCheckToken)
      );
      const storageWriteStub = stub(storage, 'writeTokenToStorage');
      const result = await getToken(app, fakePlatformLoggingProvider);
      expect(result).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token });
      expect(storageWriteStub).has.been.calledWith(
        app,
        fakeRecaptchaAppCheckToken
      );
    });

    it('returns the valid token in memory without making network request', async () => {
      const clock = useFakeTimers();
      activate(app, FAKE_SITE_KEY);
      setState(app, { ...getState(app), token: fakeRecaptchaAppCheckToken });

      const clientStub = stub(client, 'exchangeToken');
      expect(await getToken(app, fakePlatformLoggingProvider)).to.deep.equal({
        token: fakeRecaptchaAppCheckToken.token
      });
      expect(clientStub).to.not.have.been.called;

      clock.restore();
    });

    it('force to get new token when forceRefresh is true', async () => {
      activate(app, FAKE_SITE_KEY);
      setState(app, { ...getState(app), token: fakeRecaptchaAppCheckToken });

      stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
      stub(client, 'exchangeToken').returns(
        Promise.resolve(fakeRecaptchaAppCheckToken)
      );

      expect(
        await getToken(app, fakePlatformLoggingProvider, true)
      ).to.deep.equal({
        token: fakeRecaptchaAppCheckToken.token
      });
    });

    it('exchanges debug token if in debug mode', async () => {
      const exchangeTokenStub: SinonStub = stub(
        client,
        'exchangeToken'
      ).returns(Promise.resolve(fakeRecaptchaAppCheckToken));
      const debugState = getDebugState();
      debugState.enabled = true;
      debugState.token = new Deferred();
      debugState.token.resolve('my-debug-token');
      activate(app, FAKE_SITE_KEY);

      const token = await getToken(app, fakePlatformLoggingProvider);
      expect(exchangeTokenStub.args[0][0].body['debug_token']).to.equal(
        'my-debug-token'
      );
      expect(token).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token });
    });
  });

  describe('addTokenListener', () => {
    it('adds token listeners', () => {
      const listener = (): void => {};

      addTokenListener(app, fakePlatformLoggingProvider, listener);

      expect(getState(app).tokenListeners[0]).to.equal(listener);
    });

    it('starts proactively refreshing token after adding the first listener', () => {
      const listener = (): void => {};
      setState(app, { ...getState(app), isTokenAutoRefreshEnabled: true });
      expect(getState(app).tokenListeners.length).to.equal(0);
      expect(getState(app).tokenRefresher).to.equal(undefined);

      addTokenListener(app, fakePlatformLoggingProvider, listener);

      expect(getState(app).tokenRefresher?.isRunning()).to.be.true;
    });

    it('notifies the listener with the valid token in memory immediately', done => {
      const clock = useFakeTimers();
      const fakeListener: AppCheckTokenListener = token => {
        expect(token).to.deep.equal({
          token: `fake-memory-app-check-token`
        });
        clock.restore();
        done();
      };

      setState(app, {
        ...getState(app),
        token: {
          token: `fake-memory-app-check-token`,
          expireTimeMillis: 123,
          issuedAtTimeMillis: 0
        }
      });

      addTokenListener(app, fakePlatformLoggingProvider, fakeListener);
    });

    it('notifies the listener with the valid token in storage', done => {
      const clock = useFakeTimers();
      activate(app, FAKE_SITE_KEY);
      stub(storage, 'readTokenFromStorage').returns(
        Promise.resolve({
          token: `fake-cached-app-check-token`,
          expireTimeMillis: 123,
          issuedAtTimeMillis: 0
        })
      );

      const fakeListener: AppCheckTokenListener = token => {
        expect(token).to.deep.equal({
          token: `fake-cached-app-check-token`
        });
        clock.restore();
        done();
      };

      addTokenListener(app, fakePlatformLoggingProvider, fakeListener);
      clock.tick(1);
    });

    it('notifies the listener with the debug token immediately', done => {
      const fakeListener: AppCheckTokenListener = token => {
        expect(token).to.deep.equal({
          token: `my-debug-token`
        });
        done();
      };

      const debugState = getDebugState();
      debugState.enabled = true;
      debugState.token = new Deferred();
      debugState.token.resolve('my-debug-token');

      activate(app, FAKE_SITE_KEY);
      addTokenListener(app, fakePlatformLoggingProvider, fakeListener);
    });

    it('does NOT start token refresher in debug mode', () => {
      const debugState = getDebugState();
      debugState.enabled = true;
      debugState.token = new Deferred();
      debugState.token.resolve('my-debug-token');

      activate(app, FAKE_SITE_KEY);
      addTokenListener(app, fakePlatformLoggingProvider, () => {});

      const state = getState(app);
      expect(state.tokenRefresher).is.undefined;
    });
  });

  describe('removeTokenListener', () => {
    it('should remove token listeners', () => {
      const listener = (): void => {};
      addTokenListener(app, fakePlatformLoggingProvider, listener);
      expect(getState(app).tokenListeners.length).to.equal(1);

      removeTokenListener(app, listener);
      expect(getState(app).tokenListeners.length).to.equal(0);
    });

    it('should stop proactively refreshing token after deleting the last listener', () => {
      const listener = (): void => {};
      setState(app, { ...getState(app), isTokenAutoRefreshEnabled: true });

      addTokenListener(app, fakePlatformLoggingProvider, listener);
      expect(getState(app).tokenListeners.length).to.equal(1);
      expect(getState(app).tokenRefresher?.isRunning()).to.be.true;

      removeTokenListener(app, listener);
      expect(getState(app).tokenListeners.length).to.equal(0);
      expect(getState(app).tokenRefresher?.isRunning()).to.be.false;
    });
  });
});
