import {spy as sinonSpy, stub as sinonStub} from 'sinon';
import Collection from '../../../../src/ol/Collection.js';
import Feature from '../../../../src/ol/Feature.js';
import ImageState from '../../../../src/ol/ImageState.js';
import Map from '../../../../src/ol/Map.js';
import MapBrowserEvent from '../../../../src/ol/MapBrowserEvent.js';
import MapEvent from '../../../../src/ol/MapEvent.js';
import Overlay from '../../../../src/ol/Overlay.js';
import View from '../../../../src/ol/View.js';
import Control from '../../../../src/ol/control/Control.js';
import GeoJSON from '../../../../src/ol/format/GeoJSON.js';
import {TRUE} from '../../../../src/ol/functions.js';
import LineString from '../../../../src/ol/geom/LineString.js';
import Point from '../../../../src/ol/geom/Point.js';
import Polygon from '../../../../src/ol/geom/Polygon.js';
import DoubleClickZoom from '../../../../src/ol/interaction/DoubleClickZoom.js';
import DragPan from '../../../../src/ol/interaction/DragPan.js';
import Interaction from '../../../../src/ol/interaction/Interaction.js';
import MouseWheelZoom from '../../../../src/ol/interaction/MouseWheelZoom.js';
import PinchZoom from '../../../../src/ol/interaction/PinchZoom.js';
import Select from '../../../../src/ol/interaction/Select.js';
import {defaults as defaultInteractions} from '../../../../src/ol/interaction/defaults.js';
import LayerGroup from '../../../../src/ol/layer/Group.js';
import ImageLayer from '../../../../src/ol/layer/Image.js';
import Layer from '../../../../src/ol/layer/Layer.js';
import Property from '../../../../src/ol/layer/Property.js';
import TileLayer from '../../../../src/ol/layer/Tile.js';
import VectorLayer from '../../../../src/ol/layer/Vector.js';
import VectorTileLayer from '../../../../src/ol/layer/VectorTile.js';
import WebGLVectorLayer from '../../../../src/ol/layer/WebGLVector.js';
import {tile as tileStrategy} from '../../../../src/ol/loadingstrategy.js';
import {
  clearUserProjection,
  fromLonLat,
  get as getProjection,
  transform,
  useGeographic,
} from '../../../../src/ol/proj.js';
import ImageStatic from '../../../../src/ol/source/ImageStatic.js';
import VectorSource from '../../../../src/ol/source/Vector.js';
import VectorTileSource from '../../../../src/ol/source/VectorTile.js';
import XYZ from '../../../../src/ol/source/XYZ.js';
import Icon from '../../../../src/ol/style/Icon.js';
import {shared as iconImageCache} from '../../../../src/ol/style/IconImageCache.js';
import Style from '../../../../src/ol/style/Style.js';
import {createXYZ} from '../../../../src/ol/tilegrid.js';

describe('ol/Map', function () {
  describe('constructor', function () {
    it('creates a new map', function () {
      const map = new Map({});
      expect(map).to.be.a(Map);
    });

    it('accepts a promise for view options', (done) => {
      let resolve;

      const map = new Map({
        view: new Promise((r) => {
          resolve = r;
        }),
      });

      expect(map.getView()).to.be.a(View);
      expect(map.getView().isDef()).to.be(false);

      map.once('change:view', () => {
        const view = map.getView();
        expect(view).to.be.a(View);
        expect(view.isDef()).to.be(true);
        expect(view.getCenter()).to.eql([1, 2]);
        expect(view.getZoom()).to.be(3);
        done();
      });

      resolve({
        center: [1, 2],
        zoom: 3,
      });
    });

    it('allows the view to be set with a promise later after construction', (done) => {
      const map = new Map({
        view: new View({zoom: 1, center: [0, 0]}),
      });

      expect(map.getView()).to.be.a(View);
      expect(map.getView().isDef()).to.be(true);

      let resolve;
      map.setView(
        new Promise((r) => {
          resolve = r;
        }),
      );

      expect(map.getView()).to.be.a(View);
      expect(map.getView().isDef()).to.be(false);

      map.once('change:view', () => {
        const view = map.getView();
        expect(view).to.be.a(View);
        expect(view.isDef()).to.be(true);
        expect(view.getCenter()).to.eql([1, 2]);
        expect(view.getZoom()).to.be(3);
        done();
      });

      resolve({
        center: [1, 2],
        zoom: 3,
      });
    });

    it('creates a set of default interactions', function () {
      const map = new Map({});
      const interactions = map.getInteractions();
      const length = interactions.getLength();
      expect(length).to.be.greaterThan(0);

      for (let i = 0; i < length; ++i) {
        expect(interactions.item(i).getMap()).to.be(map);
      }
    });

    it('creates the viewport', function () {
      const map = new Map({});
      const viewport = map.getViewport();
      const className =
        'ol-viewport' + ('ontouchstart' in window ? ' ol-touch' : '');
      expect(viewport.className).to.be(className);
    });

    it('creates the overlay containers', function () {
      const map = new Map({});
      const container = map.getOverlayContainer();
      expect(container.className).to.be('ol-overlaycontainer');

      const containerStop = map.getOverlayContainerStopEvent();
      expect(containerStop.className).to.be('ol-overlaycontainer-stopevent');
    });

    it('calls setMap for controls added by other controls', function () {
      let subSetMapCalled = false;
      class SubControl extends Control {
        setMap(map) {
          super.setMap(map);
          subSetMapCalled = true;
        }
      }
      class MainControl extends Control {
        setMap(map) {
          super.setMap(map);
          map.addControl(
            new SubControl({
              element: document.createElement('div'),
            }),
          );
        }
      }
      new Map({
        target: document.createElement('div'),
        controls: [
          new MainControl({
            element: document.createElement('div'),
          }),
        ],
      });
      expect(subSetMapCalled).to.be(true);
    });
  });

  describe('#addLayer()', function () {
    it('adds a layer to the map', function () {
      const map = new Map({});
      const layer = new TileLayer();
      map.addLayer(layer);

      expect(map.getLayers().item(0)).to.be(layer);
      expect(layer.get(Property.MAP)).to.be(map);
    });

    it('throws if a layer is added twice', function () {
      const map = new Map({});
      const layer = new TileLayer();
      map.addLayer(layer);

      const call = function () {
        map.addLayer(layer);
      };
      expect(call).to.throwException();
    });
  });

  describe('#removeLayer()', function () {
    it('removes a layer from the map', function () {
      const map = new Map({});
      const layer = new TileLayer();
      map.addLayer(layer);

      expect(layer.get(Property.MAP)).to.be(map);
      map.removeLayer(layer);
      expect(layer.get(Property.MAP)).to.be(null);
    });

    it('removes a layer group from the map', function () {
      const map = new Map({});
      const layer = new TileLayer();
      const group = new LayerGroup({layers: [layer]});
      map.addLayer(group);
      expect(layer.get(Property.MAP)).to.be(map);

      map.removeLayer(group);
      expect(layer.get(Property.MAP)).to.be(null);
    });
  });

  describe('#setLayerGroup()', function () {
    it('sets the layer group', function () {
      const map = new Map({});

      const layer = new Layer({});
      const group = new LayerGroup({layers: [layer]});
      map.setLayerGroup(group);

      expect(map.getLayerGroup()).to.be(group);
      expect(layer.get(Property.MAP)).to.be(map);
    });

    it('removes the map property from old layers', function () {
      const oldLayer = new Layer({});
      const map = new Map({layers: [oldLayer]});
      expect(oldLayer.get(Property.MAP)).to.be(map);

      const layer = new Layer({});
      const group = new LayerGroup({layers: [layer]});
      map.setLayerGroup(group);

      expect(layer.get(Property.MAP)).to.be(map);
      expect(oldLayer.get(Property.MAP)).to.be(null);
    });
  });

  describe('#getAllLayers()', function () {
    it('returns all layers, also from inside groups', function () {
      const map = new Map({});
      const layer = new TileLayer();
      const group = new LayerGroup({layers: [layer]});
      map.addLayer(group);

      const allLayers = map.getAllLayers();
      expect(allLayers.length).to.be(1);
      expect(allLayers[0]).to.be(layer);
    });
  });

  describe('#setLayers()', function () {
    it('adds an array of layers to the map', function () {
      const map = new Map({});

      const layer0 = new TileLayer();
      const layer1 = new TileLayer();
      map.setLayers([layer0, layer1]);

      const collection = map.getLayers();
      expect(collection.getLength()).to.be(2);
      expect(collection.item(0)).to.be(layer0);
      expect(collection.item(1)).to.be(layer1);
      expect(layer0.get(Property.MAP)).to.be(map);
      expect(layer1.get(Property.MAP)).to.be(map);
    });

    it('clears any existing layers', function () {
      const oldLayer = new TileLayer();
      const map = new Map({layers: [oldLayer]});
      expect(oldLayer.get(Property.MAP)).to.be(map);

      const newLayer1 = new TileLayer();
      const newLayer2 = new TileLayer();
      map.setLayers([newLayer1, newLayer2]);
      expect(newLayer1.get(Property.MAP)).to.be(map);
      expect(newLayer2.get(Property.MAP)).to.be(map);
      expect(oldLayer.get(Property.MAP)).to.be(null);

      expect(map.getLayers().getLength()).to.be(2);
    });

    it('also works with collections', function () {
      const map = new Map({});

      const layer0 = new TileLayer();
      const layer1 = new TileLayer();
      map.setLayers(new Collection([layer0, layer1]));

      const collection = map.getLayers();
      expect(collection.getLength()).to.be(2);
      expect(collection.item(0)).to.be(layer0);
      expect(collection.item(1)).to.be(layer1);
    });
  });

  describe('#addInteraction()', function () {
    it('adds an interaction to the map', function () {
      const map = new Map({});
      const interaction = new Interaction({});

      const before = map.getInteractions().getLength();
      map.addInteraction(interaction);
      const after = map.getInteractions().getLength();
      expect(after).to.be(before + 1);
      expect(interaction.getMap()).to.be(map);
    });
  });

  describe('#removeInteraction()', function () {
    it('removes an interaction from the map', function () {
      const map = new Map({});
      const interaction = new Interaction({});

      const before = map.getInteractions().getLength();
      map.addInteraction(interaction);

      map.removeInteraction(interaction);
      expect(map.getInteractions().getLength()).to.be(before);

      expect(interaction.getMap()).to.be(null);
    });
  });

  describe('movestart/moveend event', function () {
    let target, view, map;

    beforeEach(function () {
      target = document.createElement('div');

      const style = target.style;
      style.position = 'absolute';
      style.left = '-1000px';
      style.top = '-1000px';
      style.width = '360px';
      style.height = '180px';
      document.body.appendChild(target);

      view = new View({
        projection: 'EPSG:4326',
      });
      map = new Map({
        target: target,
        view: view,
        layers: [
          new TileLayer({
            source: new XYZ({
              url: '#{x}/{y}/{z}',
            }),
          }),
        ],
      });
    });

    afterEach(function () {
      disposeMap(map);
    });

    it('are fired only once after view changes', function (done) {
      const center = [10, 20];
      const zoom = 3;
      let startCalls = 0;
      let endCalls = 0;
      map.on('movestart', function () {
        ++startCalls;
        expect(startCalls).to.be(1);
      });
      map.on('moveend', function () {
        ++endCalls;
        expect(endCalls).to.be(1);
        expect(view.getCenter()).to.eql(center);
        expect(view.getZoom()).to.be(zoom);
        window.setTimeout(done, 1000);
      });

      view.setCenter(center);
      view.setZoom(zoom);
    });

    it('are fired in sequence', function (done) {
      view.setCenter([0, 0]);
      view.setResolution(0.703125);
      map.renderSync();
      const center = [10, 20];
      const zoom = 3;
      const calls = [];
      map.on('movestart', function (e) {
        calls.push('start');
        expect(calls).to.eql(['start']);
        expect(e.frameState.viewState.center).to.eql([0, 0]);
        expect(e.frameState.viewState.resolution).to.be(0.703125);
      });
      map.on('moveend', function () {
        calls.push('end');
        expect(calls).to.eql(['start', 'end']);
        expect(view.getCenter()).to.eql(center);
        expect(view.getZoom()).to.be(zoom);
        done();
      });

      view.setCenter(center);
      view.setZoom(zoom);
    });
  });

  describe('rendercomplete event', function () {
    let map, target;

    beforeEach(function () {
      target = document.createElement('div');
      target.style.width = '100px';
      target.style.height = '100px';
      document.body.appendChild(target);
    });

    afterEach(function () {
      disposeMap(map);
      map.getLayers().forEach((layer) => layer.dispose());
    });

    describe('renderer ready property', function () {
      beforeEach(function () {
        map = new Map({
          target: target,
          layers: [
            new TileLayer({
              opacity: 0.5,
              source: new XYZ({
                url: 'spec/ol/data/osm-{z}-{x}-{y}.png',
              }),
            }),
            new ImageLayer({
              source: new ImageStatic({
                url: 'spec/ol/data/osm-0-0-0.png',
                imageExtent: getProjection('EPSG:3857').getExtent(),
                projection: 'EPSG:3857',
              }),
            }),
            new VectorLayer({
              source: new VectorSource({
                url: 'spec/ol/data/point.json',
                format: new GeoJSON(),
              }),
            }),
            new VectorLayer({
              source: new VectorSource({
                url: 'spec/ol/data/point.json',
                format: new GeoJSON(),
                strategy: tileStrategy(createXYZ()),
              }),
            }),
            new VectorLayer({
              source: new VectorSource({
                features: [new Feature(new Point([0, 0]))],
              }),
            }),
            new VectorLayer({
              source: new VectorSource({
                loader: function (extent, resolution, projection) {
                  this.addFeature(new Feature(new Point([0, 0])));
                },
              }),
            }),
            new WebGLVectorLayer({
              source: new VectorSource({
                features: [new Feature(new Point([0, 0]))],
              }),
              style: {
                'circle-radius': 4,
              },
            }),
          ],
        });
      });

      it('triggers when all tiles and sources are loaded and faded in', function (done) {
        const layers = map.getLayers().getArray();
        map.once('rendercomplete', function () {
          expect(map.tileQueue_.getTilesLoading()).to.be(0);
          expect(
            layers[1]
              .getSource()
              .getImage(
                map.getView().calculateExtent(),
                map.getView().getResolution(),
                1,
                map.getView().getProjection(),
              )
              .getState(),
          ).to.be(ImageState.LOADED);
          expect(layers[2].getSource().getFeatures().length).to.be(1);
          expect(layers[6].getRenderer().ready).to.be(true);
          done();
        });
        map.setView(
          new View({
            center: [0, 0],
            zoom: 0,
          }),
        );
      });

      it('ignores invisible layers', function (done) {
        map.getLayers().forEach((layer, i) => layer.setVisible(i === 4));
        map.setView(
          new View({
            center: [0, 0],
            zoom: 0,
          }),
        );
        map.once('rendercomplete', () => done());
      });
    });

    describe('with icons', function () {
      /** @type {Icon} */
      let icon;
      beforeEach(function () {
        iconImageCache.clear();
        icon = new Icon({
          src: 'spec/ol/data/dot.png?delayed',
        });

        const delay = 100;
        // Delay icon change events
        let states = [{state: icon.getImageState()}];
        icon.listenImageChange = function (listener) {
          if (!listener._delay) {
            listener._delay = (e) => {
              const key = setTimeout(() => {
                states.shift();
                listener.call(this, e);
              }, delay);
              Object.assign(states[states.length - 1], {key, listener});
              states.push({
                state: Icon.prototype.getImageState.call(this),
              });
            };
          }
          return Icon.prototype.listenImageChange.call(this, listener._delay);
        };
        icon.unlistenImageChange = function (listener) {
          states = states.filter((state) => {
            if (state.listener !== listener) {
              return true;
            }
            clearTimeout(listener.key);
            return false;
          });
          const addedListener = listener._delay;
          delete listener._delay;
          return Icon.prototype.unlistenImageChange.call(this, addedListener);
        };
        icon.getImageState = function () {
          return states[0].state;
        };
      });

      it('waits for icons to be loaded with ol/renderer/canvas/VectorTileLayer', function (done) {
        const delayIconAtTile = 1;
        let tilesRequested = 0;
        const tileSize = 64;
        const tileGrid = createXYZ({tileSize: tileSize});
        map = new Map({
          target: target,
          view: new View({
            center: [0, 0],
            resolution: 1,
          }),
          layers: [
            new VectorTileLayer({
              source: new VectorTileSource({
                tileSize: tileSize,
                tileUrlFunction: (tileCoord) => tileCoord.join('/'),
                tileLoadFunction: function (tile, url) {
                  const coordinate = tileGrid.getTileCoordCenter(
                    tile.getTileCoord(),
                  );
                  const feature = new Feature(new Point(coordinate));
                  tile.setFeatures([feature]);
                  if (tilesRequested++ === delayIconAtTile) {
                    feature.setStyle(new Style({image: icon}));
                  }
                },
              }),
              style: new Style({
                image: new Icon({
                  src: 'spec/ol/data/dot.png',
                }),
              }),
            }),
          ],
        });
        let iconLoaded = false;
        icon.listenImageChange(function (e) {
          if (e.target.getImageState() === ImageState.LOADED) {
            iconLoaded = true;
          }
        });
        map.once('rendercomplete', function () {
          try {
            expect(tilesRequested).to.be.greaterThan(delayIconAtTile);
            expect(iconLoaded).to.be(true);
            done();
          } catch (e) {
            done(e);
          }
        });
      });

      it('waits for icons to be loaded with ol/renderer/canvas/VectorLayer', function (done) {
        map = new Map({
          target: target,
          view: new View({
            center: [0, 0],
            resolution: 1,
          }),
          layers: [
            new VectorLayer({
              source: new VectorSource({
                features: [new Feature(new Point([0, 0]))],
              }),
              style: new Style({
                image: icon,
              }),
            }),
          ],
        });
        let iconLoaded = false;
        icon.listenImageChange(function (e) {
          if (e.target.getImageState() === ImageState.LOADED) {
            iconLoaded = true;
          }
        });
        map.once('rendercomplete', function () {
          try {
            expect(iconLoaded).to.be(true);
            done();
          } catch (e) {
            done(e);
          }
        });
      });
    });
  });

  describe('loadstart/loadend event sequence', function () {
    let map;
    beforeEach(function () {
      const target = document.createElement('div');
      target.style.width = '100px';
      target.style.height = '100px';
      document.body.appendChild(target);
      map = new Map({
        target: target,
        layers: [
          new TileLayer({
            opacity: 0.5,
            source: new XYZ({
              url: 'spec/ol/data/osm-{z}-{x}-{y}.png',
            }),
          }),
          new ImageLayer({
            source: new ImageStatic({
              url: 'spec/ol/data/osm-0-0-0.png',
              imageExtent: getProjection('EPSG:3857').getExtent(),
              projection: 'EPSG:3857',
            }),
          }),
          new VectorLayer({
            source: new VectorSource({
              url: 'spec/ol/data/point.json',
              format: new GeoJSON(),
            }),
          }),
          new VectorLayer({
            source: new VectorSource({
              url: 'spec/ol/data/point.json',
              format: new GeoJSON(),
              strategy: tileStrategy(createXYZ()),
            }),
          }),
          new VectorLayer({
            source: new VectorSource({
              features: [new Feature(new Point([0, 0]))],
            }),
          }),
          new VectorLayer({
            source: new VectorSource({
              loader: function (extent, resolution, projection) {
                this.addFeature(new Feature(new Point([0, 0])));
              },
            }),
          }),
          new WebGLVectorLayer({
            source: new VectorSource({
              features: [new Feature(new Point([0, 0]))],
            }),
            style: {
              'circle-radius': 4,
              'circle-fill-color': 'red',
            },
          }),
        ],
      });
    });

    afterEach(function () {
      disposeMap(map);
      map.getLayers().forEach((layer) => layer.dispose());
    });

    it('is a reliable start-end sequence', function (done) {
      let loading = 0;
      map.on('loadstart', () => {
        map.getView().setZoom(0.1);
        loading++;
      });
      map.on('loadend', () => {
        expect(loading).to.be(1);
        done();
      });
      map.setView(
        new View({
          center: [0, 0],
          zoom: 0,
        }),
      );
    });
  });

  describe('#getFeaturesAtPixel', function () {
    let target, map, layer;
    beforeEach(function () {
      target = document.createElement('div');
      target.style.width = '100px';
      target.style.height = '100px';
      document.body.appendChild(target);
      layer = new VectorLayer({
        source: new VectorSource({
          features: [
            new Feature(
              new LineString([
                [-50, 0],
                [50, 0],
              ]),
            ),
          ],
        }),
      });
      map = new Map({
        target: target,
        layers: [layer],
        view: new View({
          center: [0, 0],
          zoom: 17,
        }),
      });
      map.renderSync();
    });
    afterEach(function () {
      disposeMap(map);
    });

    it('returns an empty array if no feature was found', function () {
      const features = map.getFeaturesAtPixel([0, 0]);
      expect(features).to.be.an(Array);
      expect(features).to.be.empty();
    });

    it('returns an array of found features', function () {
      const features = map.getFeaturesAtPixel([50, 50]);
      expect(features).to.be.an(Array);
      expect(features[0]).to.be.an(Feature);
    });

    it('returns an array of found features with declutter: true', function () {
      const layer = map.getLayers().item(0);
      map.removeLayer(layer);
      const otherLayer = new VectorLayer({
        declutter: true,
        source: layer.getSource(),
      });
      map.addLayer(otherLayer);
      map.renderSync();
      const features = map.getFeaturesAtPixel([50, 50]);
      expect(features).to.be.an(Array);
      expect(features[0]).to.be.a(Feature);
    });

    it('respects options', function () {
      const otherLayer = new VectorLayer({
        source: new VectorSource(),
      });
      map.addLayer(otherLayer);
      map.renderSync();
      const features = map.getFeaturesAtPixel([50, 50], {
        layerFilter: function (layer) {
          return layer === otherLayer;
        },
      });
      expect(features).to.be.an(Array);
      expect(features).to.be.empty();
    });

    it('finds off-world geometries', function () {
      const line1 = new LineString([
        [130, 0],
        [230, 0],
      ]);
      line1.transform('EPSG:4326', 'EPSG:3857');
      const line2 = new LineString([
        [-230, 0],
        [-130, 0],
      ]);
      line2.transform('EPSG:4326', 'EPSG:3857');
      layer.getSource().addFeature(new Feature(line1));
      layer.getSource().addFeature(new Feature(line2));
      map.getView().setCenter(fromLonLat([180, 0]));
      map.renderSync();

      let features = map.getFeaturesAtPixel([60, 50]);
      expect(features).to.be.an(Array);
      expect(features.length).to.be(2);

      features = map.getFeaturesAtPixel([60, 50], {checkWrapped: false});
      expect(features).to.be.an(Array);
      expect(features.length).to.be(1);

      map.getView().setCenter(fromLonLat([-180, 0]));
      map.renderSync();

      features = map.getFeaturesAtPixel([40, 50]);
      expect(features).to.be.an(Array);
      expect(features.length).to.be(2);

      features = map.getFeaturesAtPixel([40, 50], {checkWrapped: false});
      expect(features).to.be.an(Array);
      expect(features.length).to.be(1);
    });
  });

  describe('#getFeaturesAtPixel - useGeographic', function () {
    let target, map;
    const size = 256;
    beforeEach(function () {
      useGeographic();

      target = document.createElement('div');
      target.style.width = size + 'px';
      target.style.height = size + 'px';
      document.body.appendChild(target);

      map = new Map({
        target: target,
        layers: [
          new VectorLayer({
            source: new VectorSource({
              features: [
                new Feature(
                  new Polygon([
                    [
                      [-100, 40],
                      [-90, 40],
                      [-90, 50],
                      [-100, 50],
                      [-100, 40],
                    ],
                  ]),
                ),
              ],
            }),
          }),
        ],
        view: new View({
          center: [0, 0],
          zoom: 0,
        }),
      });
      map.renderSync();
    });

    afterEach(function () {
      disposeMap(map);
      clearUserProjection();
    });

    it('returns an empty array if no feature was found', function () {
      const features = map.getFeaturesAtPixel([size / 2, size / 2]);
      expect(features).to.be.an(Array);
      expect(features).to.be.empty();
    });

    it('returns an array of found features', function () {
      const coordinate = [-95, 45];
      const pixel = map.getPixelFromCoordinate(coordinate);
      const features = map.getFeaturesAtPixel(pixel);
      expect(features).to.be.an(Array);
      expect(features[0]).to.be.a(Feature);
    });
  });

  describe('#hasFeatureAtPixel - useGeographic', function () {
    let target, map;
    const size = 256;
    beforeEach(function () {
      useGeographic();

      target = document.createElement('div');
      target.style.width = size + 'px';
      target.style.height = size + 'px';
      document.body.appendChild(target);

      map = new Map({
        target: target,
        layers: [
          new VectorLayer({
            source: new VectorSource({
              features: [
                new Feature(
                  new Polygon([
                    [
                      [-100, 40],
                      [-90, 40],
                      [-90, 50],
                      [-100, 50],
                      [-100, 40],
                    ],
                  ]),
                ),
              ],
            }),
          }),
        ],
        view: new View({
          center: [0, 0],
          zoom: 0,
        }),
      });
      map.renderSync();
    });

    afterEach(function () {
      disposeMap(map);
      clearUserProjection();
    });

    it('returns false if no feature was found', function () {
      const has = map.hasFeatureAtPixel([size / 2, size / 2]);
      expect(has).to.be(false);
    });

    it('returns true if there are features found', function () {
      const coordinate = [-95, 45];
      const pixel = map.getPixelFromCoordinate(coordinate);
      const has = map.hasFeatureAtPixel(pixel);
      expect(has).to.be(true);
    });
  });

  describe('#forEachFeatureAtPixel', function () {
    let map, target;

    beforeEach(function () {
      target = document.createElement('div');
      target.style.width = '360px';
      target.style.height = '180px';
      document.body.appendChild(target);
    });

    afterEach(function () {
      disposeMap(map);
      map = undefined;
    });
    it('does hitdetection with image offset', function (done) {
      const svg = `<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
        <rect x="32" y="32" width="32" height="32" />
      </svg>`;

      const feature = new Feature(new Point([0, 0]));
      feature.setStyle(
        new Style({
          image: new Icon({
            src: 'data:image/svg+xml;base64,' + window.btoa(svg),
            color: [255, 0, 0, 1],
            offset: [32, 32],
            size: [32, 32],
          }),
        }),
      );

      map = new Map({
        pixelRatio: 2,
        controls: [],
        interactions: [],
        target: target,
        layers: [
          new VectorLayer({
            source: new VectorSource({
              features: [feature],
            }),
          }),
        ],
        view: new View({
          projection: 'EPSG:4326',
          center: [0, 0],
          resolution: 1,
        }),
      });
      map.once('rendercomplete', function () {
        const hit = map.forEachFeatureAtPixel(
          map.getPixelFromCoordinate([0, 0]),
          () => true,
        );
        try {
          expect(hit).to.be(true);
          done();
        } catch (e) {
          done(e);
        }
      });
    });
  });

  describe('#render()', function () {
    let target, map;

    beforeEach(function () {
      target = document.createElement('div');
      const style = target.style;
      style.position = 'absolute';
      style.left = '-1000px';
      style.top = '-1000px';
      style.width = '360px';
      style.height = '180px';
      document.body.appendChild(target);
      map = new Map({
        target: target,
        view: new View({
          projection: 'EPSG:4326',
          center: [0, 0],
          resolution: 1,
        }),
      });
    });

    afterEach(function () {
      disposeMap(map, target);
    });

    it('is called when the view.changed() is called', function () {
      const view = map.getView();

      const spy = sinonSpy(map, 'render');
      view.changed();
      expect(spy.callCount).to.be(1);
    });

    it('is not called on view changes after the view has been removed', function () {
      const view = map.getView();
      map.setView(null);

      const spy = sinonSpy(map, 'render');
      view.changed();
      expect(spy.callCount).to.be(0);
    });

    it('calls renderFrame_ and results in a postrender event', function (done) {
      const spy = sinonSpy(map, 'renderFrame_');
      map.render();
      map.once('postrender', function (event) {
        expect(event).to.be.a(MapEvent);
        expect(typeof spy.firstCall.args[0]).to.be('number');
        spy.restore();
        expect(event.frameState).not.to.be(null);
        done();
      });
    });

    it('layers dispatch prerender and postrender when not decluttering', function (done) {
      const layer = new VectorLayer({source: new VectorSource()});
      let prerender = false;
      let postrender = false;
      const renderDeferredSpy = sinonSpy(layer.getRenderer(), 'renderDeferred');
      layer.on('prerender', () => (prerender = true));
      layer.on('postrender', () => {
        expect(renderDeferredSpy.callCount).to.be(0);
        renderDeferredSpy.restore();
        postrender = true;
      });
      map.addLayer(layer);
      map.once('postrender', () => {
        try {
          expect(prerender).to.be(true);
          expect(postrender).to.be(true);
          done();
        } catch (e) {
          done(e);
        }
      });
      map.render();
    });

    it('layers dispatch prerender and postrender when decluttering', function (done) {
      const layer = new VectorLayer({
        source: new VectorSource(),
        declutter: true,
      });
      let prerender = false;
      let postrender = false;
      const renderDeferredSpy = sinonSpy(layer.getRenderer(), 'renderDeferred');
      layer.on('prerender', () => (prerender = true));
      layer.on('postrender', () => {
        expect(renderDeferredSpy.callCount).to.be(1);
        renderDeferredSpy.restore();
        postrender = true;
      });
      map.addLayer(layer);
      map.once('postrender', () => {
        try {
          expect(prerender).to.be(true);
          expect(postrender).to.be(true);
          done();
        } catch (e) {
          done(e);
        }
      });
      map.render();
    });

    it('uses the same render frame for subsequent calls', function () {
      map.render();
      const id1 = map.animationDelayKey_;
      map.render();
      const id2 = map.animationDelayKey_;
      expect(id1).to.be(id2);
    });

    it('creates a new render frame after renderSync()', function () {
      map.render();
      expect(map.animationDelayKey_).to.not.be(undefined);

      map.renderSync();
      expect(map.animationDelayKey_).to.be(undefined);
    });

    it('results in an postrender event (for zero height map)', function (done) {
      target.style.height = '0px';
      map.updateSize();

      map.render();
      map.once('postrender', function (event) {
        expect(event).to.be.a(MapEvent);
        const frameState = event.frameState;
        expect(frameState).to.be(null);
        done();
      });
    });

    it('results in an postrender event (for zero width map)', function (done) {
      target.style.width = '0px';
      map.updateSize();

      map.render();
      map.once('postrender', function (event) {
        expect(event).to.be.a(MapEvent);
        const frameState = event.frameState;
        expect(frameState).to.be(null);
        done();
      });
    });
  });

  describe('#handlePostRender()', function () {
    let map, target;

    beforeEach(function () {
      target = document.createElement('div');
      target.style.width = '100px';
      target.style.height = '100px';
      document.body.appendChild(target);
      map = new Map({
        target: target,
        view: new View({center: [0, 0], zoom: 1}),
      });
      map.renderSync();
    });

    afterEach(function () {
      disposeMap(map, target);
    });

    it('loads tiles when animating with calling reprioritize', function () {
      const reprioritizeSpy = sinonSpy(map.tileQueue_, 'reprioritize');
      const loadSpy = sinonSpy(map.tileQueue_, 'loadMoreTiles');
      sinonStub(map.tileQueue_, 'isEmpty').returns(false);
      sinonStub(map.tileQueue_, 'getTilesLoading').returns(0);

      map.frameState_.viewHints = [1, 0];
      map.frameState_.time = Infinity; // guarantee lowOnFrameBudget is false
      map.handlePostRender();

      expect(loadSpy.callCount).to.be(1);
      expect(reprioritizeSpy.callCount).to.be(1);
    });

    it('loads tiles after animation ends without calling reprioritize', function () {
      const reprioritizeSpy = sinonSpy(map.tileQueue_, 'reprioritize');
      const loadSpy = sinonSpy(map.tileQueue_, 'loadMoreTiles');
      sinonStub(map.tileQueue_, 'isEmpty').returns(false);
      sinonStub(map.tileQueue_, 'getTilesLoading').returns(0);

      map.frameState_.viewHints = [0, 0];
      map.frameState_.time = Infinity; // guarantee lowOnFrameBudget is false
      map.handlePostRender();

      expect(loadSpy.callCount).to.be(1);
      expect(reprioritizeSpy.callCount).to.be(0);
    });
  });

  describe('dispose', function () {
    let map;

    beforeEach(function () {
      map = new Map({
        target: document.createElement('div'),
      });
    });

    it('removes the viewport from its parent', function () {
      map.dispose();
      expect(map.getViewport().parentNode).to.be(null);
    });

    it('removes window listeners', function () {
      map.dispose();
      expect(map.targetChangeHandlerKeys_).to.be(null);
    });
  });

  describe('#setTarget', function () {
    /** @type {Map|undefined} */
    let map;

    beforeEach(function () {
      map = new Map({
        target: document.createElement('div'),
      });
      expect(map.targetChangeHandlerKeys_).to.be.ok();
    });

    afterEach(() => {
      disposeMap(map);
    });

    describe('map with target not attached to dom', function () {
      it('has undefined as size with target not in document', function () {
        expect(map.getSize()).to.be(undefined);
      });
    });

    describe('map container with negative width and heigth due to borders', () => {
      it('does not try to set a negative map size', () => {
        const target = map.getTargetElement();
        document.body.appendChild(target);
        target.style.border = '1px solid black';
        target.style.display = 'none';
        map.updateSize();
        document.body.removeChild(target);
        expect(map.getSize()).to.eql([0, 0]);
      });
    });

    describe('call setTarget with null', function () {
      it('unregisters the viewport resize listener', function () {
        map.setTarget(null);
        expect(map.targetChangeHandlerKeys_).to.be(null);
      });
    });

    describe('call setTarget with an element', function () {
      it('registers a viewport resize listener', function () {
        map.setTarget(null);
        map.setTarget(document.createElement('div'));
        expect(map.targetChangeHandlerKeys_).to.be.ok();
      });
    });

    it('detach and re-attach', function (done) {
      const target = map.getTargetElement();
      map.setTarget(null);
      target.style.width = '100px';
      target.style.height = '100px';
      document.body.appendChild(target);
      map.setTarget(target);
      map.addLayer(
        new VectorLayer({
          source: new VectorSource({
            features: [new Feature(new Point([0, 0]))],
          }),
        }),
      );
      map.getView().setCenter([0, 0]);
      map.getView().setZoom(0);
      map.renderSync();
      try {
        expect(target.querySelector('canvas')).to.be.a(HTMLCanvasElement);
        map.setTarget(null);
        expect(target.querySelector('canvas')).to.be(null);
        map.setTarget(target);
        map.once('rendercomplete', () => {
          try {
            expect(target.querySelector('canvas')).to.be.a(HTMLCanvasElement);
            done();
          } catch (e) {
            done(e);
          }
        });
      } finally {
        target.remove();
      }
    });
  });

  describe('#getPixelRatio() and #setPixelRatio()', function () {
    let map;

    beforeEach(function () {
      map = new Map({
        target: document.createElement('div'),
      });
    });

    afterEach(function () {
      disposeMap(map);
    });

    it('gets the pixel ratio', function () {
      expect(map.getPixelRatio()).to.be(window.devicePixelRatio || 1);
    });

    it('sets the pixel ratio and re-renders the map', function () {
      const spy = sinonSpy(map, 'render');
      map.setPixelRatio(2);
      expect(map.getPixelRatio()).to.be(2);
      expect(spy.called).to.be(true);
      spy.restore();
    });
  });

  describe('create interactions', function () {
    let options;

    function createEvent(
      type,
      {altKey, button, hasTabIndex, hasFocus, isPrimary} = {},
    ) {
      if (altKey === undefined) {
        altKey = false;
      }
      if (button === undefined) {
        button = 0;
      }
      if (hasTabIndex === undefined) {
        hasTabIndex = true;
      }
      if (hasFocus === undefined) {
        hasFocus = true;
      }
      if (isPrimary === undefined) {
        isPrimary = true;
      }
      const originalEvent = new PointerEvent(type, {
        altKey,
        button,
        isPrimary,
      });
      Object.defineProperty(originalEvent, 'target', {
        writable: false,
        value: {
          getTargetElement: function () {
            return {
              contains: function () {
                return hasFocus;
              },
            };
          },
        },
      });
      return new MapBrowserEvent(
        type,
        {
          getTargetElement: function () {
            return {
              hasAttribute: function (attribute) {
                return hasTabIndex;
              },
              contains: function () {
                return hasFocus;
              },
              getRootNode: function () {
                return {};
              },
            };
          },
          getOwnerDocument: function () {
            return {};
          },
        },
        originalEvent,
      );
    }

    beforeEach(function () {
      options = {
        altShiftDragRotate: false,
        doubleClickZoom: false,
        keyboard: false,
        mouseWheelZoom: false,
        shiftDragZoom: false,
        dragPan: false,
        pinchRotate: false,
        pinchZoom: false,
      };
    });

    describe('create mousewheel interaction', function () {
      it('creates mousewheel interaction', function () {
        options.mouseWheelZoom = true;
        const interactions = defaultInteractions(options);
        expect(interactions.getLength()).to.eql(1);
        expect(interactions.item(0)).to.be.a(MouseWheelZoom);
        expect(interactions.item(0).useAnchor_).to.eql(true);
        interactions.item(0).setMouseAnchor(false);
        expect(interactions.item(0).useAnchor_).to.eql(false);
        expect(interactions.item(0).condition_).to.be(TRUE);
      });
      it('does not use the default condition when onFocusOnly option is set', function () {
        options.onFocusOnly = true;
        options.mouseWheelZoom = true;
        const interactions = defaultInteractions(options);
        expect(interactions.item(0).condition_).to.not.be(TRUE);
        let event = createEvent('pointerdown');
        expect(interactions.item(0).condition_(event)).to.be(true);
        event = createEvent('pointerdown', {hasFocus: false});
        expect(interactions.item(0).condition_(event)).to.be(false);
        event = createEvent('pointerdown', {
          hasTabIndex: false,
          hasFocus: false,
        });
        expect(interactions.item(0).condition_(event)).to.be(true);
      });
    });

    describe('create dragpan interaction', function () {
      it('creates dragpan interaction', function () {
        options.dragPan = true;
        const interactions = defaultInteractions(options);
        expect(interactions.getLength()).to.eql(1);
        expect(interactions.item(0)).to.be.a(DragPan);
        let event = createEvent('pointerdown');
        expect(interactions.item(0).condition_(event)).to.be(true);
        event = createEvent('pointerdown', {hasFocus: false});
        expect(interactions.item(0).condition_(event)).to.be(true);
        event = createEvent('pointerdown', {altKey: true, hasFocus: false});
        expect(interactions.item(0).condition_(event)).to.be(false);
        event = createEvent('pointerdown', {button: 1, hasFocus: false});
        expect(interactions.item(0).condition_(event)).to.be(false);
      });
      it('does not use the default condition when onFocusOnly option is set', function () {
        options.onFocusOnly = true;
        options.dragPan = true;
        const interactions = defaultInteractions(options);
        let event = createEvent('pointerdown');
        expect(interactions.item(0).condition_(event)).to.be(true);
        event = createEvent('pointerdown', {hasFocus: false});
        expect(interactions.item(0).condition_(event)).to.be(false);
        event = createEvent('pointerdown', {
          hasTabIndex: false,
          hasFocus: false,
        });
        expect(interactions.item(0).condition_(event)).to.be(true);
      });
    });

    describe('create pinchZoom interaction', function () {
      it('creates pinchZoom interaction', function () {
        options.pinchZoom = true;
        const interactions = defaultInteractions(options);
        expect(interactions.getLength()).to.eql(1);
        expect(interactions.item(0)).to.be.a(PinchZoom);
      });
    });

    describe('create double click interaction', function () {
      beforeEach(function () {
        options.doubleClickZoom = true;
      });

      describe('default zoomDelta', function () {
        it('create double click interaction with default delta', function () {
          const interactions = defaultInteractions(options);
          expect(interactions.getLength()).to.eql(1);
          expect(interactions.item(0)).to.be.a(DoubleClickZoom);
          expect(interactions.item(0).delta_).to.eql(1);
        });
      });

      describe('set zoomDelta', function () {
        it('create double click interaction with set delta', function () {
          options.zoomDelta = 7;
          const interactions = defaultInteractions(options);
          expect(interactions.getLength()).to.eql(1);
          expect(interactions.item(0)).to.be.a(DoubleClickZoom);
          expect(interactions.item(0).delta_).to.eql(7);
        });
      });
    });

    describe('#getEventPixel', function () {
      let target;

      beforeEach(function () {
        target = document.createElement('div');
        target.style.position = 'absolute';
        target.style.top = '10px';
        target.style.left = '20px';
        target.style.width = '800px';
        target.style.height = '400px';

        document.body.appendChild(target);
      });
      afterEach(function () {
        target.remove();
      });

      it('works with touchend events', function () {
        const map = new Map({
          target: target,
        });

        const browserEvent = {
          type: 'touchend',
          target: target,
          changedTouches: [
            {
              clientX: 100,
              clientY: 200,
            },
          ],
        };
        const position = map.getEventPixel(browserEvent);
        // 80 = clientX - target.style.left
        expect(position[0]).to.eql(80);
        // 190 = clientY - target.style.top
        expect(position[1]).to.eql(190);

        disposeMap(map);
      });
    });

    describe('#getOverlayById()', function () {
      let target, map, overlay, overlay_target;

      beforeEach(function () {
        target = document.createElement('div');
        const style = target.style;
        style.position = 'absolute';
        style.left = '-1000px';
        style.top = '-1000px';
        style.width = '360px';
        style.height = '180px';
        document.body.appendChild(target);
        map = new Map({
          target: target,
          view: new View({
            projection: 'EPSG:4326',
            center: [0, 0],
            resolution: 1,
          }),
        });
        overlay_target = document.createElement('div');
      });

      afterEach(function () {
        disposeMap(map);
      });

      it('returns an overlay by id', function () {
        overlay = new Overlay({
          id: 'foo',
          element: overlay_target,
          position: [0, 0],
        });
        map.addOverlay(overlay);
        expect(map.getOverlayById('foo')).to.be(overlay);
      });

      it('returns null when no overlay is found', function () {
        overlay = new Overlay({
          id: 'foo',
          element: overlay_target,
          position: [0, 0],
        });
        map.addOverlay(overlay);
        expect(map.getOverlayById('bar')).to.be(null);
      });

      it('returns null after removing overlay', function () {
        overlay = new Overlay({
          id: 'foo',
          element: overlay_target,
          position: [0, 0],
        });
        map.addOverlay(overlay);
        expect(map.getOverlayById('foo')).to.be(overlay);
        map.removeOverlay(overlay);
        expect(map.getOverlayById('foo')).to.be(null);
      });
    });

    describe('getCoordinateFromPixel() and getPixelFromCoordinate()', function () {
      let target, view, map;
      const centerGeographic = [2.460938, 48.850258];
      const centerMercator = transform(
        centerGeographic,
        getProjection('EPSG:4326'),
        getProjection('EPSG:3857'),
      );
      const screenCenter = [500, 500];

      beforeEach(function () {
        target = document.createElement('div');

        const style = target.style;
        style.position = 'absolute';
        style.left = '-1000px';
        style.top = '-1000px';
        style.width = `${screenCenter[0] * 2}px`;
        style.height = `${screenCenter[1] * 2}px`;
        document.body.appendChild(target);

        useGeographic();

        view = new View({
          center: centerGeographic,
          zoom: 3,
        });
        map = new Map({
          target: target,
          view: view,
          layers: [
            new TileLayer({
              source: new XYZ({
                url: '#{x}/{y}/{z}',
              }),
            }),
          ],
        });
      });

      afterEach(function () {
        disposeMap(map);
        clearUserProjection();
      });

      it('gets coordinates in user projection', function (done) {
        map.renderSync();
        const coordinateGeographic = map.getCoordinateFromPixel(screenCenter);
        expect(coordinateGeographic[0]).to.roughlyEqual(
          centerGeographic[0],
          1e-5,
        );
        expect(coordinateGeographic[1]).to.roughlyEqual(
          centerGeographic[1],
          1e-5,
        );
        done();
      });

      it('gets coordinates in view projection', function (done) {
        map.renderSync();
        const coordinateMercator =
          map.getCoordinateFromPixelInternal(screenCenter);
        expect(coordinateMercator[0]).to.roughlyEqual(centerMercator[0], 1e-5);
        expect(coordinateMercator[1]).to.roughlyEqual(centerMercator[1], 1e-5);
        done();
      });

      it('gets pixel from coordinates in user projection', function (done) {
        map.renderSync();
        const pixel = map.getPixelFromCoordinate(centerGeographic);
        expect(pixel).to.eql(screenCenter);
        done();
      });

      it('gets pixel from coordinates in view projection', function (done) {
        map.renderSync();
        const pixel = map.getPixelFromCoordinateInternal(centerMercator);
        expect(pixel).to.eql(screenCenter);
        done();
      });
    });
  });

  describe('#handleMapBrowserEvent()', function () {
    let map, target, dragpan;
    beforeEach(function () {
      target = document.createElement('div');
      target.style.width = '100px';
      target.style.height = '100px';
      document.body.appendChild(target);
      dragpan = new DragPan();
      map = new Map({
        target: target,
        interactions: [dragpan],
        layers: [
          new TileLayer({
            source: new XYZ({
              url: 'spec/ol/data/osm-{z}-{x}-{y}.png',
            }),
          }),
        ],
        view: new View({
          zoom: 0,
          center: [0, 0],
        }),
      });
      map.renderSync();
    });

    afterEach(function () {
      disposeMap(map, target);
    });

    it('calls handleEvent on interaction', function () {
      const spy = sinonSpy(dragpan, 'handleEvent');
      map.handleMapBrowserEvent(
        new MapBrowserEvent(
          'pointermove',
          map,
          new PointerEvent('pointermove'),
        ),
      );
      expect(spy.callCount).to.be(1);
      spy.restore();
    });

    it('does not call handleEvent on interaction when map has no target', function () {
      map.setTarget(null);
      const spy = sinonSpy(dragpan, 'handleEvent');
      map.handleMapBrowserEvent(
        new MapBrowserEvent(
          'pointermove',
          map,
          new PointerEvent('pointermove'),
        ),
      );
      expect(spy.callCount).to.be(0);
      spy.restore();
    });

    it('does not call handleEvent on interaction that has been removed', function () {
      const spy = sinonSpy(dragpan, 'handleEvent');
      let callCount = 0;
      const interaction = new Interaction({
        handleEvent: function () {
          ++callCount;
          map.removeInteraction(dragpan);
          return true;
        },
      });
      map.addInteraction(interaction);
      map.handleMapBrowserEvent(
        new MapBrowserEvent(
          'pointermove',
          map,
          new PointerEvent('pointermove'),
        ),
      );
      expect(callCount).to.be(1);
      expect(spy.callCount).to.be(0);
      spy.restore();
    });

    it('does not call handleEvent on interaction when MapBrowserEvent propagation stopped', function () {
      const select = new Select();
      const selectStub = sinonStub(select, 'handleEvent');
      selectStub.callsFake(function (e) {
        e.stopPropagation();
        return true;
      });
      map.addInteraction(select);
      const spy = sinonSpy(dragpan, 'handleEvent');
      map.handleMapBrowserEvent(
        new MapBrowserEvent(
          'pointermove',
          map,
          new PointerEvent('pointermove'),
        ),
      );
      expect(spy.callCount).to.be(0);
      expect(selectStub.callCount).to.be(1);
      spy.restore();
      selectStub.restore();
    });

    describe('external map', () => {
      let iframe, spy;

      beforeEach(() => {
        iframe = document.createElement('iframe');
        iframe.width = '100';
        iframe.height = '100';
        iframe.src = 'spec/ol/data/external-map.html';
        document.body.appendChild(iframe);
        spy = sinonSpy(dragpan, 'handleDownEvent');
      });
      afterEach(() => {
        map.setTarget(null);
        document.body.removeChild(iframe);
        spy.restore();
      });
      it('handles events from a map in a separate window', (done) => {
        document.body.removeChild(map.getTargetElement());
        map.setTarget(null);
        const win = iframe.contentWindow;
        win.addEventListener('DOMContentLoaded', () => {
          map.setTarget(iframe.contentDocument.getElementById('map'));
          win.postMessage('test');
          setTimeout(() => {
            expect(spy.callCount).to.be(1);
            expect(spy.firstCall.returnValue).to.be(true);
            done();
          }, 100);
        });
      });
    });
  });

  describe('resize', function () {
    const width = 256;
    const height = 256;
    /** @type {Map} */
    let map;
    /** @type {HTMLElement} */
    let target;

    beforeEach(function () {
      target = document.createElement('div');
      target.style.height = `${width}px`;
      target.style.width = `${height}px`;
    });
    afterEach(function () {
      disposeMap(map, target);
    });

    it('has updated the viewport when the change:size event is being dispatched', function (done) {
      map = new Map({
        target: target,
        view: new View(),
        layers: [],
        controls: [],
        interactions: [],
      });
      map.on('change:size', () => {
        expect(map.getView().getViewportSize_()).to.eql([width, height]);
        done();
      });
      document.body.appendChild(target);
    });
  });
});