/**
 * @license
 * Copyright The Closure Library Authors.
 * SPDX-License-Identifier: Apache-2.0
 */

goog.module('goog.net.ImageLoaderTest');
goog.setTestOnly();

const EventHandler = goog.require('goog.events.EventHandler');
const EventType = goog.require('goog.events.EventType');
const GoogEvent = goog.require('goog.events.Event');
const GoogEventTarget = goog.require('goog.events.EventTarget');
const GoogPromise = goog.require('goog.Promise');
const ImageLoader = goog.require('goog.net.ImageLoader');
const NetEventType = goog.require('goog.net.EventType');
const TestCase = goog.require('goog.testing.TestCase');
const Timer = goog.require('goog.Timer');
const dispose = goog.require('goog.dispose');
const events = goog.require('goog.events');
const googArray = goog.require('goog.array');
const googObject = goog.require('goog.object');
const googString = goog.require('goog.string');
const recordFunction = goog.require('goog.testing.recordFunction');
const testSuite = goog.require('goog.testing.testSuite');

const TEST_EVENT_TYPES = [
  EventType.LOAD,
  NetEventType.COMPLETE,
  NetEventType.ERROR,
];

/**
 * Mapping from test image file name to:
 * [expected width, expected height, expected event to be fired].
 */
const TEST_IMAGES = {
  'imageloader_testimg1.gif': [20, 20, EventType.LOAD],
  'imageloader_testimg2.gif': [20, 20, EventType.LOAD],
  'imageloader_testimg3.gif': [32, 32, EventType.LOAD],

  'this-is-not-image-1.gif': [0, 0, NetEventType.ERROR],
  'this-is-not-image-2.gif': [0, 0, NetEventType.ERROR],
};

let startTime;
let loader;

function assertImagesAreCorrect(results) {
  assertEquals(googObject.getCount(TEST_IMAGES), googObject.getCount(results));
  googObject.forEach(TEST_IMAGES, (value, key) => {
    // Check if fires the COMPLETE event.
    assertTrue('Image is not loaded completely.', key in results);

    const image = results[key];

    // Check image size.
    assertEquals('Image width is not correct', value[0], image[0]);
    assertEquals('Image length is not correct', value[1], image[1]);

    // Check if fired the correct event.
    assertEquals('Event *' + value[2] + '* must be fired', value[2], image[2]);
  });
}

/**
 * Overrides the loader's loadImage_ method so that it dispatches an image
 * loaded event immediately, causing any event listeners to receive them
 * synchronously.  This allows tests to assume synchronous execution.
 */
function makeLoaderSynchronous(loader) {
  const originalLoadImage = loader.loadImage_;
  loader.loadImage_ = function(request, id) {
    originalLoadImage.call(this, request, id);

    const event = new GoogEvent(EventType.LOAD);
    /** @suppress {globalThis} suppression added to enable type checking */
    event.currentTarget = this.imageIdToImageMap_[id];
    loader.onNetworkEvent_(event);
  };

  // Make listen() a no-op.
  loader.handler_.listen = () => {};
}

testSuite({
  setUpPage() {
    // Increase the timeout to 5 seconds to allow more time for images to load.
    TestCase.getActiveTestCase().promiseTimeout = 5 * 1000;
  },

  setUp() {
    startTime = Date.now();

    loader = new ImageLoader();

    // Adds test images to the loader.
    let i = 0;
    for (const key in TEST_IMAGES) {
      const imageId = 'img_' + i++;
      loader.addImage(imageId, key);
    }
  },

  tearDown() {
    dispose(loader);
  },

  /** Tests loading image and disposing before loading completes. */
  testDisposeInTheMiddleOfLoadingWorks() {
    const resolver = GoogPromise.withResolver();

    events.listen(loader, TEST_EVENT_TYPES, (e) => {
      assertFalse(
          'Handler is still invoked after loader is disposed.',
          loader.isDisposed());

      switch (e.type) {
        case NetEventType.COMPLETE:
          resolver.reject('This test should never get COMPLETE event.');
          return;

        case EventType.LOAD:
        case NetEventType.ERROR:
          loader.dispose();
          break;
      }

      // Make sure that handler is never called again after disposal before
      // marking test as successful.
      Timer.callOnce(() => {
        resolver.resolve();
      }, 500);
    });

    loader.start();
    return resolver.promise;
  },

  /** Tests loading of images until completion. */
  testLoadingUntilCompletion() {
    const resolver = GoogPromise.withResolver();
    const results = {};
    events.listen(loader, TEST_EVENT_TYPES, (e) => {
      let image;
      switch (e.type) {
        case EventType.LOAD:
          image = e.target;
          results[image.src.substring(image.src.lastIndexOf('/') + 1)] =
              [image.naturalWidth, image.naturalHeight, e.type];
          return;

        case NetEventType.ERROR:
          image = e.target;
          results[image.src.substring(image.src.lastIndexOf('/') + 1)] =
              [image.naturalWidth, image.naturalHeight, e.type];
          return;

        case NetEventType.COMPLETE:
          try {
            assertImagesAreCorrect(results);
          } catch (e) {
            resolver.reject(e);
            return;
          }
          resolver.resolve();
          return;
      }
    });

    loader.start();
    return resolver.promise;
  },

  /**
   * Verifies that if an additional image is added after start() was called, but
   * before COMPLETE was dispatched, no COMPLETE event is sent.  Verifies
   * COMPLETE is finally sent when .start() is called again and all images have
   * now completed loading.
   */
  testImagesAddedAfterStart() {
    // Use synchronous image loading.
    makeLoaderSynchronous(loader);

    // Add another image once the first images finishes loading.
    events.listenOnce(loader, EventType.LOAD, () => {
      loader.addImage('extra_image', 'extra_image.gif');
    });

    // Keep track of the total # of image loads.
    const loadRecordFn = recordFunction();
    events.listen(loader, EventType.LOAD, loadRecordFn);

    // Keep track of how many times COMPLETE was dispatched.
    const completeRecordFn = recordFunction();
    events.listen(loader, NetEventType.COMPLETE, completeRecordFn);

    // Start testing.
    loader.start();
    assertEquals(
        'COMPLETE event should not have been dispatched yet: An image was ' +
            'added after the initial batch was started.',
        0, completeRecordFn.getCallCount());
    assertEquals(
        'Just the test images should have loaded',
        googObject.getCount(TEST_IMAGES), loadRecordFn.getCallCount());

    loader.start();
    assertEquals(
        'COMPLETE should have been dispatched once.', 1,
        completeRecordFn.getCallCount());
    assertEquals(
        'All images should have been loaded',
        googObject.getCount(TEST_IMAGES) + 1, loadRecordFn.getCallCount());
  },

  /**
   * Verifies that more images can be added after an upload starts, and start()
   * can be called for them, resulting in just one COMPLETE event once all the
   * images have completed.
   */
  testImagesAddedAndStartedAfterStart() {
    // Use synchronous image loading.
    makeLoaderSynchronous(loader);

    // Keep track of the total # of image loads.
    const loadRecordFn = recordFunction();
    events.listen(loader, EventType.LOAD, loadRecordFn);

    // Add more images once the first images finishes loading, and call start()
    // to get them going.
    events.listenOnce(loader, EventType.LOAD, (e) => {
      loader.addImage('extra_image', 'extra_image.gif');
      loader.addImage('extra_image2', 'extra_image2.gif');
      loader.start();
    });

    // Keep track of how many times COMPLETE was dispatched.
    const completeRecordFn = recordFunction();
    events.listen(loader, NetEventType.COMPLETE, completeRecordFn);

    // Start testing.  Make sure all 7 images loaded.
    loader.start();
    assertEquals(
        'COMPLETE should have been dispatched once.', 1,
        completeRecordFn.getCallCount());
    assertEquals(
        'All images should have been loaded',
        googObject.getCount(TEST_IMAGES) + 2, loadRecordFn.getCallCount());
  },

  /**
   * Verifies that if images are removed after loading has started, COMPLETE
   * is dispatched once the remaining images have finished.
   */
  testImagesRemovedAfterStart() {
    // Use synchronous image loading.
    makeLoaderSynchronous(loader);

    // Remove 2 images once the first image finishes loading.
    events.listenOnce(loader, EventType.LOAD, function(e) {
      loader.removeImage(
          googArray.peek(googObject.getKeys(this.imageIdToRequestMap_)));
      loader.removeImage(
          googArray.peek(googObject.getKeys(this.imageIdToRequestMap_)));
    });

    // Keep track of the total # of image loads.
    const loadRecordFn = recordFunction();
    events.listen(loader, EventType.LOAD, loadRecordFn);

    // Keep track of how many times COMPLETE was dispatched.
    const completeRecordFn = recordFunction();
    events.listen(loader, NetEventType.COMPLETE, completeRecordFn);

    // Start testing.  Make sure only the 3 images remaining loaded.
    loader.start();
    assertEquals(
        'COMPLETE should have been dispatched once.', 1,
        completeRecordFn.getCallCount());
    assertEquals(
        'All images should have been loaded',
        googObject.getCount(TEST_IMAGES) - 2, loadRecordFn.getCallCount());
  },

  /**
   * Verifies order of event dispatch when events are handled by a client of
   * {@link goog.net.ImageLoader}.
   */
  testImageLoaderClientEventDispatchOrder() {
    const clientLoader = new ImageLoader();
    makeLoaderSynchronous(clientLoader);

    // Creates a testing client that will dispose of the image loader on the
    // final propagated LOAD event.
    const testingClientImageLoader = new TestingClientImageLoader(clientLoader);

    let i = 0;
    for (const key in TEST_IMAGES) {
      const imageId = 'img_' + i++;
      testingClientImageLoader.addImage(imageId, key);
    }

    // Add more images once the first images finishes loading, and call start()
    // to get them going.
    events.listenOnce(testingClientImageLoader, EventType.LOAD, (e) => {
      testingClientImageLoader.addImage('extra_image', 'extra_image.gif');
      testingClientImageLoader.addImage('extra_image2', 'extra_image2.gif');
      testingClientImageLoader.start();
    });

    // Start testing.
    testingClientImageLoader.start();

    assertEquals(
        'All images should have dispatched a LOAD call before disposing.',
        googObject.getCount(TEST_IMAGES), testingClientImageLoader.loadCount);
    assertEquals(
        'COMPLETE should never be dispatched if we dispose the instance on image removal.',
        0, testingClientImageLoader.completeCount);
    assertEquals(
        'There should be no references to images in the image loader at time of dispose.',
        testingClientImageLoader.imageLoaderRemainingSize, 0);
  },

  /**
     Verifies that the correct image attribute is set when using CORS requests.
   */
  testSetsCorsAttribute() {
    // Use synchronous image loading.
    makeLoaderSynchronous(loader);

    // Verify the crossOrigin attribute of the requested images.
    events.listen(loader, EventType.LOAD, (e) => {
      const image = e.target;
      if (image.id == 'cors_request') {
        assertEquals(
            'CORS requested image should have a crossOrigin attribute set',
            'anonymous', image.crossOrigin);
      } else {
        assertTrue(
            'Non-CORS requested images should not have a crossOrigin attribute',
            googString.isEmptyOrWhitespace(
                googString.makeSafe(image.crossOrigin)));
      }
    });

    // Make a new request for one of the images, this time using CORS.
    const srcs = googObject.getKeys(TEST_IMAGES);
    loader.addImage(
        'cors_request', srcs[0], ImageLoader.CorsRequestType.ANONYMOUS);
    loader.start();
  },
});

/**
 * Represents an example client that uses {@link goog.net.ImageLoader} and
 * consumes the events it emits.
 */
class TestingClientImageLoader extends GoogEventTarget {
  constructor(imageLoader) {
    super();

    /** @private @const {!ImageLoader} */
    this.imageLoader_ = imageLoader;
    this.eventHandler_ = new EventHandler(this);

    this.imagesRemaining = 0;
    this.imageLoaderRemainingSize = 0;
    this.loadCount = 0;
    this.completeCount = 0;

    // Handles events dispatched from ImageLoad
    this.eventHandler_.listen(
        this.imageLoader_, [EventType.LOAD, NetEventType.COMPLETE],
        this.handleImageLoaderEvent_);
  }

  addImage(id, url) {
    this.imagesRemaining += 1;
    this.imageLoader_.addImage(id, url);
  }

  start() {
    this.imageLoader_.start();
  }

  /**
   * Disposes the image loader when handling the final image, prior to
   * dispatching the COMPLETE event. This allows verification that the state of
   * the ImageLoader is clean before the original event is dispatched to any
   * clients.
   * @private
   */
  handleImageLoaderEvent_(e) {
    switch (e.type) {
      case NetEventType.COMPLETE:
        this.completeCount += 1;

      case EventType.LOAD:
        this.loadCount += 1;
        this.imagesRemaining -= 1;
    }

    /** @suppress {visibility} suppression added to enable type checking */
    this.imageLoaderRemainingSize =
        googObject.getKeys(this.imageLoader_.imageIdToRequestMap_).length;

    if (this.imagesRemaining == 0) {
      this.imageLoader_.disposeInternal();
    }
  }
}
