/**
 * @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 './setup';
import { expect } from 'chai';
import { stub } from 'sinon';
import { FirebaseNamespace, FirebaseOptions } from '../src/public-types';
import { _FirebaseApp, _FirebaseNamespace } from '../src/types';
import { _components, _clearComponents } from '@firebase/app';
import {
  Component,
  ComponentType,
  InstantiationMode
} from '@firebase/component';

import { createFirebaseNamespace } from '../src/firebaseNamespace';
import { createFirebaseNamespaceLite } from '../src/lite/firebaseNamespaceLite';

import { createTestComponent, TestService } from './util';

executeFirebaseTests();
executeFirebaseLiteTests();

function executeFirebaseTests(): void {
  firebaseAppTests('Firebase App Tests', createFirebaseNamespace);

  describe('Firebase Service Registration', () => {
    const firebase: FirebaseNamespace = createFirebaseNamespace();

    afterEach(() => {
      const deleteTasks = [];
      for (const app of firebase.apps) {
        deleteTasks.push(app.delete());
      }
      return Promise.all(deleteTasks);
    });

    it('will do nothing if registerComponent is called again with the same name', () => {
      const registerStub = stub(
        (firebase as _FirebaseNamespace).INTERNAL,
        'registerComponent'
      ).callThrough();

      const testComponent = createTestComponent('test');

      (firebase as _FirebaseNamespace).INTERNAL.registerComponent(
        testComponent
      );
      firebase.initializeApp({});
      const serviceNamespace = (firebase as any).test;

      (firebase as _FirebaseNamespace).INTERNAL.registerComponent(
        testComponent
      );

      const serviceNamespace2 = (firebase as any).test;

      expect(serviceNamespace).to.eq(serviceNamespace2);
      expect(registerStub).to.have.not.thrown();
      registerStub.restore();
    });

    it('returns cached service instances', () => {
      firebase.initializeApp({});
      (firebase as _FirebaseNamespace).INTERNAL.registerComponent(
        createTestComponent('test')
      );

      const service = (firebase as any).test();

      expect(service).to.eq((firebase as any).test());
    });

    it('does not instantiate explicit components unless called explicitly', () => {
      firebase.initializeApp({});
      (firebase as _FirebaseNamespace).INTERNAL.registerComponent(
        createTestComponent('explicit1').setInstantiationMode(
          InstantiationMode.EXPLICIT
        )
      );

      let explicitService;

      // Expect getImmediate in a consuming component to return null.
      const consumerComponent = new Component(
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        'consumer' as any,
        container => {
          explicitService = container
            .getProvider('explicit1' as any)
            .getImmediate({ optional: true });
          return new TestService(
            container.getProvider('app-compat').getImmediate()
          );
        },
        ComponentType.PUBLIC
      );
      (firebase as _FirebaseNamespace).INTERNAL.registerComponent(
        consumerComponent
      );

      (firebase as any).consumer();
      expect(explicitService).to.be.null;
    });

    it('does instantiate explicit components when called explicitly', () => {
      firebase.initializeApp({});
      (firebase as _FirebaseNamespace).INTERNAL.registerComponent(
        createTestComponent('explicit2').setInstantiationMode(
          InstantiationMode.EXPLICIT
        )
      );

      let explicitService;

      // Expect getImmediate in a consuming component to return the service.
      const consumerComponent = new Component(
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        'consumer' as any,
        container => {
          explicitService = container
            .getProvider('explicit2' as any)
            .getImmediate({ optional: true });
          return new TestService(
            container.getProvider('app-compat').getImmediate()
          );
        },
        ComponentType.PUBLIC
      );
      (firebase as _FirebaseNamespace).INTERNAL.registerComponent(
        consumerComponent
      );

      (firebase as any).explicit2();
      (firebase as any).consumer();
      expect(explicitService).to.not.be.null;
    });

    it(`creates a new instance of a service after removing the existing instance`, () => {
      const app = firebase.initializeApp({});
      (firebase as _FirebaseNamespace).INTERNAL.registerComponent(
        createTestComponent('test')
      );

      const service = (firebase as any).test();

      expect(service).to.eq((firebase as any).test());

      (app as _FirebaseApp)._removeServiceInstance('test');

      expect(service, (firebase as any).test());
    });

    it(`creates a new instance of a service after removing the existing instance - for service that supports multiple instances`, () => {
      const app = firebase.initializeApp({});
      (firebase as _FirebaseNamespace).INTERNAL.registerComponent(
        createTestComponent('multiInstance', true)
      );

      // default instance
      const instance1 = (firebase.app() as any).multiInstance();
      const serviceIdentifier = 'custom instance identifier';
      const instance2 = (firebase.app() as any).multiInstance(
        serviceIdentifier
      );

      (app as _FirebaseApp)._removeServiceInstance(
        'multiInstance',
        serviceIdentifier
      );

      // default instance should not be changed
      expect(instance1).to.eq((firebase.app() as any).multiInstance());

      expect(instance2).to.not.eq(
        (firebase.app() as any).multiInstance(serviceIdentifier)
      );
    });
  });

  describe('Firebase Version Registration', () => {
    const firebase: FirebaseNamespace = createFirebaseNamespace();

    afterEach(() => {
      _clearComponents();
    });

    it('will register an official version component without warnings', () => {
      const warnStub = stub(console, 'warn');
      const initialSize = _components.size;

      firebase.registerVersion('@firebase/app-compat', '1.2.3');
      expect(_components.get('fire-core-compat-version')).to.exist;
      expect(_components.size).to.equal(initialSize + 1);

      expect(warnStub.called).to.be.false;
    });

    it('will register an arbitrary version component without warnings', () => {
      const warnStub = stub(console, 'warn');
      const initialSize = _components.size;

      firebase.registerVersion('angularfire', '1.2.3');
      expect(_components.get('angularfire-version')).to.exist;
      expect(_components.size).to.equal(initialSize + 1);

      expect(warnStub.called).to.be.false;
    });

    it('will do nothing if registerVersion() is given illegal characters', () => {
      const warnStub = stub(console, 'warn');
      const initialSize = _components.size;

      firebase.registerVersion('remote config', '1.2.3');
      expect(warnStub.args[0][1]).to.include('library name "remote config"');
      expect(_components.size).to.equal(initialSize);

      firebase.registerVersion('remote-config', '1.2/3');
      expect(warnStub.args[1][1]).to.include('version name "1.2/3"');
      expect(_components.size).to.equal(initialSize);
    });
  });
}

function executeFirebaseLiteTests(): void {
  firebaseAppTests('Firebase App Lite Tests', createFirebaseNamespaceLite);

  describe('Firebase Lite Service Registration', () => {
    const firebase: FirebaseNamespace = createFirebaseNamespaceLite();

    afterEach(() => {
      const deleteTasks = [];
      for (const app of firebase.apps) {
        deleteTasks.push(app.delete());
      }
      return Promise.all(deleteTasks);
    });

    it('allows Performance service to register', () => {
      (firebase as _FirebaseNamespace).INTERNAL.registerComponent(
        createTestComponent('performance')
      );
      const app = firebase.initializeApp({});
      const perf = (app as any).performance();
      expect(perf).to.be.instanceof(TestService);
    });

    it('allows Installations service to register', () => {
      (firebase as _FirebaseNamespace).INTERNAL.registerComponent(
        createTestComponent('installations')
      );
      const app = firebase.initializeApp({});
      const perf = (app as any).installations();
      expect(perf).to.be.instanceof(TestService);
    });

    it('does NOT allow services other than Performance and installations to register', () => {
      expect(() =>
        (firebase as _FirebaseNamespace).INTERNAL.registerComponent(
          createTestComponent('auth')
        )
      ).to.throw();
    });

    it('allows any private component to register', () => {
      expect(() =>
        (firebase as _FirebaseNamespace).INTERNAL.registerComponent(
          createTestComponent('auth-internal', false, ComponentType.PRIVATE)
        )
      ).to.not.throw();
    });
  });
}

function firebaseAppTests(
  testName: string,
  firebaseNamespaceFactory: () => FirebaseNamespace
): void {
  describe(testName, () => {
    const firebase: FirebaseNamespace = firebaseNamespaceFactory();

    afterEach(() => {
      const deleteTasks = [];
      for (const app of firebase.apps) {
        deleteTasks.push(app.delete());
      }
      return Promise.all(deleteTasks);
    });

    it('has no initial apps.', () => {
      expect(firebase.apps.length).to.eq(0);
    });

    it('Can get app via firebase namespace.', () => {
      const app = firebase.initializeApp({});
      expect(app).to.be.not.null;
    });

    it('can initialize DEFAULT App.', () => {
      const app = firebase.initializeApp({});
      expect(firebase.apps.length).to.eq(1);
      expect(app).to.eq(firebase.apps[0]);
      expect(app.name).to.eq('[DEFAULT]');
      expect(firebase.app()).to.eq(app);
      expect(firebase.app('[DEFAULT]')).to.eq(app);
    });

    it('can get options of App.', () => {
      const options: FirebaseOptions = { projectId: 'projectId' };
      const app = firebase.initializeApp(options);
      expect(app.options).to.deep.eq(options);
    });

    it('can delete App.', async () => {
      const app = firebase.initializeApp({});
      expect(firebase.apps.length).to.eq(1);
      await app.delete();
      expect(firebase.apps.length).to.eq(0);
    });

    it('can create named App.', () => {
      const app = firebase.initializeApp({}, 'my-app');
      expect(firebase.apps.length).to.eq(1);
      expect(app.name).to.eq('my-app');
      expect(firebase.app('my-app')).to.eq(app);
    });

    it('can create named App and DEFAULT app.', () => {
      firebase.initializeApp({}, 'my-app');
      expect(firebase.apps.length).to.eq(1);
      firebase.initializeApp({});
      expect(firebase.apps.length).to.eq(2);
    });

    it('initializeApp can be called more than once and returns the same instance if the options and config are the same', () => {
      const app = firebase.initializeApp(
        {
          apiKey: 'test1'
        },
        { automaticDataCollectionEnabled: true }
      );
      expect(
        firebase.initializeApp(
          {
            apiKey: 'test1'
          },
          { automaticDataCollectionEnabled: true }
        )
      ).to.equal(app);
    });

    it('duplicate DEFAULT initialize with different options is an error.', () => {
      firebase.initializeApp({ apiKey: 'key1' });
      expect(() => firebase.initializeApp({ apiKey: 'key2' })).throws(
        /\[DEFAULT\].*exists/i
      );
    });

    it('duplicate named App initialize with different options is an error.', () => {
      firebase.initializeApp({ apiKey: 'key1', appId: 'id' }, 'abc');
      expect(() => firebase.initializeApp({ apiKey: 'key1' }, 'abc')).throws(
        /'abc'.*exists/i
      );
    });

    it('duplicate DEFAULT initialize with different config is an error.', () => {
      firebase.initializeApp(
        { apiKey: 'key1' },
        { automaticDataCollectionEnabled: true }
      );
      expect(() =>
        firebase.initializeApp(
          { apiKey: 'key1' },
          { automaticDataCollectionEnabled: false }
        )
      ).throws(/\[DEFAULT\].*exists/i);
    });

    it('duplicate named App initialize with different config is an error.', () => {
      firebase.initializeApp(
        { apiKey: 'key1' },
        { name: 'abc', automaticDataCollectionEnabled: true }
      );
      expect(() =>
        firebase.initializeApp(
          { apiKey: 'key1' },
          { name: 'abc', automaticDataCollectionEnabled: false }
        )
      ).throws(/'abc'.*exists/i);
    });

    it('automaticDataCollectionEnabled is `false` by default', () => {
      const app = firebase.initializeApp({}, 'my-app');
      expect(app.automaticDataCollectionEnabled).to.eq(false);
    });

    it('automaticDataCollectionEnabled can be set via the config object', () => {
      const app = firebase.initializeApp(
        {},
        { automaticDataCollectionEnabled: true }
      );
      expect(app.automaticDataCollectionEnabled).to.eq(true);
    });

    it('Modifying options object does not change options.', () => {
      const options: FirebaseOptions = {
        appId: 'original',
        measurementId: 'someId'
      };
      firebase.initializeApp(options);
      options.appId = 'changed';
      delete options.measurementId;
      expect(firebase.app().options).to.deep.eq({
        appId: 'original',
        measurementId: 'someId'
      });
    });

    it('Error to use app after it is deleted.', async () => {
      const app = firebase.initializeApp({});
      await app.delete();
      expect(() => console.log(app.name)).throws(/already.*deleted/);
    });

    it('OK to create same-name app after it is deleted.', async () => {
      const app = firebase.initializeApp({}, 'app-name');
      await app.delete();

      const app2 = firebase.initializeApp({}, 'app-name');
      expect(app).to.not.eq(app2, 'Expect new instance.');
      // But original app id still orphaned.
      expect(() => console.log(app.name)).throws(/already.*deleted/);
    });

    it('OK to use Object.prototype member names as app name.', () => {
      const app = firebase.initializeApp({}, 'toString');
      expect(firebase.apps.length).to.eq(1);
      expect(app.name).to.eq('toString');
      expect(firebase.app('toString')).to.eq(app);
    });

    it('Error to get uninitialized app using Object.prototype member name.', () => {
      expect(() => firebase.app('toString')).throws(/'toString'.*created/i);
    });

    describe('Check for bad app names', () => {
      const tests = ['', 123, false];
      for (const data of tests) {
        it("where name == '" + data + "'", () => {
          expect(() => firebase.initializeApp({}, data as string)).throws(
            /Illegal app name/i
          );
        });
      }
    });

    describe('Check for bad app names, passed as an object', () => {
      const tests = ['', 123, false, null];
      for (const name of tests) {
        it("where name == '" + name + "'", () => {
          expect(() =>
            firebase.initializeApp({}, { name: name as string })
          ).throws(/Illegal app name/i);
        });
      }
    });
  });
}
