import * as HomeHubCore from '@bthlabs/homehub-core';
import noop from 'lodash/noop';
import {DateTime} from 'luxon';

import {Clock} from 'src/lib/clock';
import * as WeatherService from 'src/services/weather';

import {WeatherFactory} from 'tests/__fixtures__/weather';

describe('src/services/weather', () => {
  describe('WeatherService', () => {
    let fakeCharacteristics = null;
    let fakeResult = null;

    beforeEach(() => {
      fakeCharacteristics = {
        city: 'Wroclaw,PL',
        units: 'metric',
      };
      fakeResult = {
        data: WeatherFactory(),
      };

      spyOn(HomeHubCore.API.Services, 'start').and.returnValue(fakeResult);
      spyOn(HomeHubCore.API.Services, 'stop').and.returnValue('ok');
    });

    describe('emptyCharacteristics', () => {
      it('returns empty characteristics', () => {
        // Given
        const result = WeatherService.WeatherService.emptyCharacteristics();

        // Then
        expect(result).toEqual({
          city: '',
          units: 'metric',
        });
      });
    });

    describe('start', () => {
      it('notifies subscribers with the result of start service API call', async () => {
        // Given
        const service = new WeatherService.WeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        spyOn(service, 'notify');

        // When
        await service.start();

        // Then
        expect(HomeHubCore.API.Services.start).toHaveBeenCalledWith(
          service.kind, 'testing', fakeCharacteristics
        );
        expect(service.notify).toHaveBeenCalledWith(fakeResult);
      });
    });

    describe('stop', () => {
      it('calls the stop service API method', async () => {
        // Given
        const service = new WeatherService.WeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });

        // When
        await service.stop();

        // Then
        expect(HomeHubCore.API.Services.stop).toHaveBeenCalledWith(
          service.kind, 'testing'
        );
      });
    });

    describe('setCharacteristics', () => {
      it('updates the service characteristics', () => {
        // Given
        const newCharacteristics = {
          city: 'Poznan,PL',
          units: 'imperial',
        };
        const service = new WeatherService.WeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        spyOn(service, 'restart');

        // When
        service.setCharacteristics(newCharacteristics);

        // Then
        expect(service.characteristics).toEqual(newCharacteristics);
      });

      it('restarts the service if city characteristic changed', () => {
        // Given
        const newCharacteristics = {
          ...fakeCharacteristics,
          city: 'Poznan,PL',
        };
        const service = new WeatherService.WeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        spyOn(service, 'restart');

        // When
        service.setCharacteristics(newCharacteristics);

        // Then
        expect(service.restart).toHaveBeenCalled();
      });

      it('restarts the service if units characteristic changed', () => {
        // Given
        const newCharacteristics = {
          ...fakeCharacteristics,
          units: 'imperial',
        };
        const service = new WeatherService.WeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        spyOn(service, 'restart');

        // When
        service.setCharacteristics(newCharacteristics);

        // Then
        expect(service.restart).toHaveBeenCalled();
      });
    });
  });

  describe('OfflineWeatherService', () => {
    let fakeCharacteristics = null;
    let fakeClock = null;
    let fakeDateTime = null;
    let fakeResult = null;

    beforeEach(() => {
      fakeCharacteristics = {
        city: 'Wroclaw,PL',
        units: 'metric',
        apiKey: 'API_KEY',
      };

      fakeClock = jasmine.createSpyObj(
        'FakeClock', ['addEventListener', 'removeEventListener']
      );
      fakeClock.now = DateTime.local(1987, 10, 3, 8, 0, 0);
      spyOn(Clock, 'instance').and.returnValue(fakeClock);

      fakeDateTime = DateTime.local(1987, 10, 3, 8, 0, 0);
      spyOn(DateTime, 'fromSeconds').and.returnValue(fakeDateTime);

      fakeResult = {
        data: WeatherFactory(),
      };

      spyOn(document, 'createElement');
      spyOn(document.body, 'appendChild');
      spyOn(document.body, 'removeChild');
      spyOn(HomeHubCore.LocalStorage, 'getItem').and.returnValue(null);
      spyOn(HomeHubCore.LocalStorage, 'setItem');
    });

    afterEach(() => {
      const kind = WeatherService.OfflineWeatherService.kind;
      const callbackKey = `${kind}_testing_callback`;
      window[callbackKey] = undefined;
    });

    it('defines availability properties', () => {
      // Then
      expect(WeatherService.OfflineWeatherService.availableOnline).toBe(false);
      expect(WeatherService.OfflineWeatherService.availableOffline).toBe(true);
    });

    describe('emptyCharacteristics', () => {
      it('returns empty characteristics', () => {
        // Given
        const result = WeatherService.OfflineWeatherService.emptyCharacteristics();

        // Then
        expect(result).toEqual({
          city: '',
          units: 'metric',
          apiKey: '',
        });
      });
    });

    describe('constructor', () => {
      it('initializes an instance', () => {
        // Given
        const service = new WeatherService.OfflineWeatherService({
          instance: 'this-should-be-an-uuid4',
          characteristics: fakeCharacteristics,
        });

        // Then
        expect(service.instanceKey).toEqual(
          `${service.kind}_this_should_be_an_uuid4`
        );
        expect(service.callbackKey).toEqual(
          `${service.kind}_this_should_be_an_uuid4_callback`
        );
        expect(service.lastScriptElement).toBe(null);
        expect(service.lastData).toBe(null);
        expect(service.lastRefreshDt).toBe(null);
      });
    });

    describe('start', () => {
      it('installs the JSONP callback', async () => {
        // Given
        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        spyOn(service, 'refresh');

        // When
        await service.start();

        // Then
        expect(window[service.callbackKey]).toEqual(service.onWeatherCallback);
      });

      it('does not initialize the last data if it is missing from local storage', async () => {
        // Given
        HomeHubCore.LocalStorage.getItem.and.returnValue(null);
        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        spyOn(service, 'refresh');

        // When
        await service.start();

        // Then
        expect(service.lastData).toBe(null);
        expect(service.lastRefreshDt).toBe(null);
        expect(HomeHubCore.LocalStorage.getItem).toHaveBeenCalledWith(
          `${service.instanceKey}_lastData`
        );
      });

      it('initializes last data if it is present in local storage', async () => {
        // Given
        HomeHubCore.LocalStorage.getItem.and.returnValue(fakeResult);
        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        spyOn(service, 'refresh');

        // When
        await service.start();

        // Then
        expect(service.lastData).toEqual(fakeResult);
        expect(service.lastRefreshDt).toEqual(fakeDateTime);
        expect(DateTime.fromSeconds).toHaveBeenCalledWith(fakeResult.dt);
      });

      it('triggers a refresh', async () => {
        // Given
        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        spyOn(service, 'refresh');

        // When
        await service.start();

        // Then
        expect(service.refresh).toHaveBeenCalled();
      });

      it('adds event listener for the clock tick event', async () => {
        // Given
        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        spyOn(service, 'refresh');

        // When
        await service.start();

        // Then
        expect(Clock.instance().addEventListener).toHaveBeenCalledWith(
          'tick', service.onClockTick
        );
      });
    });

    describe('stop', () => {
      it('replaces the JSONP callback with a noop', async () => {
        // Given
        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });

        // When
        await service.stop();

        // Then
        expect(window[service.callbackKey]).toEqual(noop);
      });

      it('removes event listener for the clock tick event', async () => {
        // Given
        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });

        // When
        await service.stop();

        // Then
        expect(Clock.instance().removeEventListener).toHaveBeenCalledWith(
          'tick', service.onClockTick
        );
      });
    });

    describe('initialState', () => {
      it('returns the initial state', () => {
        // Given
        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });

        // When
        const result = service.initialState();

        // Then
        expect(result).toEqual({
          error: null,
          data: null,
        });
      });

      it('returns state with last data if it is present', () => {
        // Given
        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        service.lastData = fakeResult;

        // When
        const result = service.initialState();

        // Then
        expect(result).toEqual({
          error: null,
          data: fakeResult,
        });
      });
    });

    describe('onClockTick', () => {
      it('does not request a refresh when it should not refresh', () => {
        // Given
        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        spyOn(service, 'shouldRefresh').and.returnValue(false);
        spyOn(service, 'refresh');

        // When
        service.onClockTick();

        // Then
        expect(service.refresh).not.toHaveBeenCalled();
        expect(service.shouldRefresh).toHaveBeenCalled();
      });

      it('requests a refresh when it should refresh', () => {
        // Given
        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        spyOn(service, 'shouldRefresh').and.returnValue(true);
        spyOn(service, 'refresh');

        // When
        service.onClockTick();

        // Then
        expect(service.refresh).toHaveBeenCalled();
      });
    });

    describe('shouldRefresh', () => {
      it('returns true if last data is null', () => {
        // Given
        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });

        // When
        const result = service.shouldRefresh();

        // Then
        expect(result).toBe(true);
      });

      it('returns true if last refresh was more than 600 secs ago', () => {
        // Given
        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        service.lastData = fakeResult;
        service.lastRefreshDt = DateTime.local(1987, 10, 3, 7, 50, 0);

        // When
        const result = service.shouldRefresh();

        // Then
        expect(result).toBe(true);
      });

      it('returns false if last refresh was less than 600 secs ago', () => {
        // Given
        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        service.lastData = fakeResult;
        service.lastRefreshDt = DateTime.local(1987, 10, 3, 7, 50, 1);

        // When
        const result = service.shouldRefresh();

        // Then
        expect(result).toBe(false);
      });
    });

    describe('onWeatherCallback', () => {
      it('removes the last script element', () => {
        // Given
        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        service.lastScriptElement = jasmine.createSpy();
        spyOn(service, 'notify');

        // When
        service.onWeatherCallback(fakeResult);

        // Then
        expect(document.body.removeChild).toHaveBeenCalledWith(
          service.lastScriptElement
        );
      });

      it('stores the data in local storage and instance', () => {
        // Given
        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        service.lastScriptElement = jasmine.createSpy();
        spyOn(service, 'notify');

        // When
        service.onWeatherCallback(fakeResult);

        // Then
        expect(service.lastData).toEqual(fakeResult);
        expect(service.lastRefreshDt).toEqual(fakeClock.now);
        expect(HomeHubCore.LocalStorage.setItem).toHaveBeenCalledWith(
          `${service.instanceKey}_lastData`, fakeResult
        );
      });

      it('notifies subscribers with the loaded data', () => {
        // Given
        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        service.lastScriptElement = jasmine.createSpy();
        spyOn(service, 'notify');

        // When
        service.onWeatherCallback(fakeResult);

        // Then
        expect(service.notify).toHaveBeenCalledWith({
          error: null,
          data: fakeResult,
        });
      });
    });

    describe('refresh', () => {
      it('is a noop when it should not refresh', () => {
        // Given
        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        spyOn(service, 'shouldRefresh').and.returnValue(false);

        // When
        service.refresh();

        // Then
        expect(document.createElement).not.toHaveBeenCalled();
        expect(document.body.appendChild).not.toHaveBeenCalled();
      });

      it('calls the API via JSONP when it should refresh', () => {
        // Given
        const fakeScriptElement = jasmine.createSpy();
        document.createElement.and.returnValue(fakeScriptElement);

        const service = new WeatherService.OfflineWeatherService({
          instance: 'testing',
          characteristics: fakeCharacteristics,
        });
        spyOn(service, 'shouldRefresh').and.returnValue(true);

        // When
        service.refresh();

        // Then
        expect(document.createElement).toHaveBeenCalledWith('script');
        expect(fakeScriptElement.async).toBe(true);
        expect(fakeScriptElement.src).toBeDefined();
        expect(fakeScriptElement.type).toEqual('text/javascript');
        expect(service.lastScriptElement).toEqual(fakeScriptElement);
        expect(document.body.appendChild).toHaveBeenCalledWith(
          fakeScriptElement
        );

        expect(fakeScriptElement.src.startsWith(
          WeatherService.OfflineWeatherService.apiURL
        )).toBe(
          true
        );

        const scriptSrcURL = new URL(fakeScriptElement.src);
        const scriptSrcURLParams = {};
        for (let pair of scriptSrcURL.searchParams.entries()) {
          scriptSrcURLParams[pair[0]] = pair[1];
        }
        expect(scriptSrcURLParams).toEqual({
          APPID: fakeCharacteristics.apiKey,
          callback: service.callbackKey,
          q: fakeCharacteristics.city,
          units: fakeCharacteristics.units,
        });
      });
    });
  });
});
