import {
  stylefunction as applyStylefunction,
  styleFunctionArgs,
} from './stylefunction.js';
import {expandUrl} from 'ol/tileurlfunction.js';
import {finalizeLayer, setupLayer} from './apply.js';
import {getUid} from 'ol/util.js';
import {normalizeSourceUrl, normalizeStyleUrl} from './mapbox.js';

/** @typedef {import("ol").Map} Map */
/** @typedef {import("ol/layer").Layer} Layer */
/** @typedef {import("ol/layer").Group} LayerGroup */
/** @typedef {import("ol/layer").Vector} VectorLayer */
/** @typedef {import("ol/layer").VectorTile} VectorTileLayer */
/** @typedef {import("ol/source").Source} Source */

/**
 * @typedef {Object} FeatureIdentifier
 * @property {string|number} id The feature id.
 * @property {string} source The source id.
 */

const functionCacheByStyleId = {};
const filterCacheByStyleId = {};

let styleId = 0;
export function getStyleId(glStyle) {
  if (!glStyle.id) {
    glStyle.id = styleId++;
  }
  return glStyle.id;
}

export function getStyleFunctionKey(glStyle, olLayer) {
  return getStyleId(glStyle) + '.' + getUid(olLayer);
}

/**
 * @param {Object} glStyle Mapboox style object.
 * @return {Object} Function cache.
 */
export function getFunctionCache(glStyle) {
  let functionCache = functionCacheByStyleId[glStyle.id];
  if (!functionCache) {
    functionCache = {};
    functionCacheByStyleId[getStyleId(glStyle)] = functionCache;
  }
  return functionCache;
}

export function clearFunctionCache() {
  for (const key in functionCacheByStyleId) {
    delete functionCacheByStyleId[key];
  }
}

/**
 * @param {Object} glStyle Mapboox style object.
 * @return {Object} Filter cache.
 */
export function getFilterCache(glStyle) {
  let filterCache = filterCacheByStyleId[glStyle.id];
  if (!filterCache) {
    filterCache = {};
    filterCacheByStyleId[getStyleId(glStyle)] = filterCache;
  }
  return filterCache;
}

export function deg2rad(degrees) {
  return (degrees * Math.PI) / 180;
}

export const defaultResolutions = (function () {
  const resolutions = [];
  for (let res = 78271.51696402048; resolutions.length <= 24; res /= 2) {
    resolutions.push(res);
  }
  return resolutions;
})();

/**
 * @param {number} width Width of the canvas.
 * @param {number} height Height of the canvas.
 * @return {HTMLCanvasElement} Canvas.
 */
export function createCanvas(width, height) {
  if (typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope && typeof OffscreenCanvas !== 'undefined') { // eslint-disable-line
    return /** @type {?} */ (new OffscreenCanvas(width, height));
  }
  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  return canvas;
}

export function getZoomForResolution(resolution, resolutions) {
  let i = 0;
  const ii = resolutions.length;
  for (; i < ii; ++i) {
    const candidate = resolutions[i];
    if (candidate < resolution && i + 1 < ii) {
      const zoomFactor = resolutions[i] / resolutions[i + 1];
      return i + Math.log(resolutions[i] / resolution) / Math.log(zoomFactor);
    }
  }
  return ii - 1;
}

const pendingRequests = {};
/**
 * @param {ResourceType} resourceType Type of resource to load.
 * @param {string} url Url of the resource.
 * @param {Options} [options={}] Options.
 * @param {{request?: Request}} [metadata] Object to be filled with the request.
 * @return {Promise<Object|Response>} Promise that resolves with the loaded resource
 * or rejects with the Response object.
 * @private
 */
export function fetchResource(resourceType, url, options = {}, metadata) {
  if (url in pendingRequests) {
    if (metadata) {
      metadata.request = pendingRequests[url][0];
    }
    return pendingRequests[url][1];
  }
  const request = options.transformRequest
    ? options.transformRequest(url, resourceType) || new Request(url)
    : new Request(url);
  if (!request.headers.get('Accept')) {
    request.headers.set('Accept', 'application/json');
  }
  if (metadata) {
    metadata.request = request;
  }
  const pendingRequest = fetch(request)
    .then(function (response) {
      delete pendingRequests[url];
      return response.ok
        ? response.json()
        : Promise.reject(new Error('Error fetching source ' + url));
    })
    .catch(function (error) {
      delete pendingRequests[url];
      return Promise.reject(new Error('Error fetching source ' + url));
    });
  pendingRequests[url] = [request, pendingRequest];
  return pendingRequest;
}

export function getGlStyle(glStyleOrUrl, options) {
  if (typeof glStyleOrUrl === 'string') {
    if (glStyleOrUrl.trim().startsWith('{')) {
      try {
        const glStyle = JSON.parse(glStyleOrUrl);
        return Promise.resolve(glStyle);
      } catch (error) {
        return Promise.reject(error);
      }
    } else {
      glStyleOrUrl = normalizeStyleUrl(glStyleOrUrl, options.accessToken);
      return fetchResource('Style', glStyleOrUrl, options);
    }
  } else {
    return Promise.resolve(glStyleOrUrl);
  }
}

function getTransformedTilesUrl(tilesUrl, options) {
  if (options.transformRequest) {
    const transformedRequest = options.transformRequest(tilesUrl, 'Tiles');
    if (transformedRequest instanceof Request) {
      return decodeURI(transformedRequest.url);
    }
  }
  return tilesUrl;
}

const tilejsonCache = {};
/**
 * @param {Object} glSource glStyle source object.
 * @param {string} styleUrl Style URL.
 * @param {Options} options Options.
 * @return {Object} TileJson
 */
export function getTileJson(glSource, styleUrl, options = {}) {
  const cacheKey = [styleUrl, JSON.stringify(glSource)].toString();
  let promise = tilejsonCache[cacheKey];
  if (!promise || options.transformRequest) {
    const url = glSource.url;
    if (url && !glSource.tiles) {
      const normalizedSourceUrl = normalizeSourceUrl(
        url,
        options.accessToken,
        options.accessTokenParam || 'access_token',
        styleUrl || location.href
      );
      if (url.startsWith('mapbox://')) {
        promise = Promise.resolve(
          Object.assign({}, glSource, {
            url: undefined,
            tiles: expandUrl(normalizedSourceUrl),
          })
        );
      } else {
        const metadata = {};
        promise = fetchResource(
          'Source',
          normalizedSourceUrl,
          options,
          metadata
        ).then(function (tileJson) {
          tileJson.tiles = tileJson.tiles.map(function (tileUrl) {
            if (tileJson.scheme === 'tms') {
              tileUrl = tileUrl.replace('{y}', '{-y}');
            }
            return getTransformedTilesUrl(
              normalizeSourceUrl(
                tileUrl,
                options.accessToken,
                options.accessTokenParam || 'access_token',
                metadata.request.url
              ),
              options
            );
          });
          return Promise.resolve(tileJson);
        });
      }
    } else {
      glSource = Object.assign({}, glSource, {
        tiles: glSource.tiles.map(function (tileUrl) {
          if (glSource.scheme === 'tms') {
            tileUrl = tileUrl.replace('{y}', '{-y}');
          }
          return getTransformedTilesUrl(
            normalizeSourceUrl(
              tileUrl,
              options.accessToken,
              options.accessTokenParam || 'access_token',
              styleUrl || location.href
            ),
            options
          );
        }),
      });
      promise = Promise.resolve(Object.assign({}, glSource));
    }
    tilejsonCache[cacheKey] = promise;
  }
  return promise;
}

/**
 * @param {HTMLImageElement|HTMLCanvasElement} spriteImage Sprite image id.
 * @param {{x: number, y: number, width: number, height: number, pixelRatio: number}} spriteImageData Sprite image data.
 * @param {number} haloWidth Halo width.
 * @param {{r: number, g: number, b: number, a: number}} haloColor Halo color.
 * @return {HTMLCanvasElement} Canvas element with the halo.
 */
export function drawIconHalo(
  spriteImage,
  spriteImageData,
  haloWidth,
  haloColor
) {
  const imageCanvas = document.createElement('canvas');
  const imgSize = [
    2 * haloWidth * spriteImageData.pixelRatio + spriteImageData.width,
    2 * haloWidth * spriteImageData.pixelRatio + spriteImageData.height,
  ];
  imageCanvas.width = imgSize[0];
  imageCanvas.height = imgSize[1];
  const imageContext = imageCanvas.getContext('2d');
  imageContext.drawImage(
    spriteImage,
    spriteImageData.x,
    spriteImageData.y,
    spriteImageData.width,
    spriteImageData.height,
    haloWidth * spriteImageData.pixelRatio,
    haloWidth * spriteImageData.pixelRatio,
    spriteImageData.width,
    spriteImageData.height
  );
  const imageData = imageContext.getImageData(0, 0, imgSize[0], imgSize[1]);
  imageContext.globalCompositeOperation = 'destination-over';
  imageContext.fillStyle = `rgba(${haloColor.r * 255},${haloColor.g * 255},${
    haloColor.b * 255
  },${haloColor.a})`;
  const data = imageData.data;
  for (let i = 0, ii = imageData.width; i < ii; ++i) {
    for (let j = 0, jj = imageData.height; j < jj; ++j) {
      const index = (j * ii + i) * 4;
      const alpha = data[index + 3];
      if (alpha > 0) {
        imageContext.arc(
          i,
          j,
          haloWidth * spriteImageData.pixelRatio,
          0,
          2 * Math.PI
        );
      }
    }
  }
  imageContext.fill();
  return imageCanvas;
}

function smoothstep(min, max, value) {
  const x = Math.max(0, Math.min(1, (value - min) / (max - min)));
  return x * x * (3 - 2 * x);
}

/**
 * @param {HTMLImageElement} image SDF image
 * @param {{x: number, y: number, width: number, height: number}} area Area to unSDF
 * @param {{r: number, g: number, b: number, a: number}} color Color to use
 * @return {HTMLCanvasElement} Regular image
 */
export function drawSDF(image, area, color) {
  const imageCanvas = document.createElement('canvas');
  imageCanvas.width = area.width;
  imageCanvas.height = area.height;
  const imageContext = imageCanvas.getContext('2d');
  imageContext.drawImage(
    image,
    area.x,
    area.y,
    area.width,
    area.height,
    0,
    0,
    area.width,
    area.height
  );
  const imageData = imageContext.getImageData(0, 0, area.width, area.height);
  const data = imageData.data;
  for (let i = 0, ii = imageData.width; i < ii; ++i) {
    for (let j = 0, jj = imageData.height; j < jj; ++j) {
      const index = (j * ii + i) * 4;
      const dist = data[index + 3] / 255;

      const buffer = 0.75;
      const gamma = 0.1;

      const alpha = smoothstep(buffer - gamma, buffer + gamma, dist);
      if (alpha > 0) {
        data[index + 0] = Math.round(255 * color.r * alpha);
        data[index + 1] = Math.round(255 * color.g * alpha);
        data[index + 2] = Math.round(255 * color.b * alpha);
        data[index + 3] = Math.round(255 * alpha);
      } else {
        data[index + 3] = 0;
      }
    }
  }
  imageContext.putImageData(imageData, 0, 0);
  return imageCanvas;
}

/**
 * Get the OpenLayers layer instance that contains the provided Mapbox Style
 * `layer`. Note that multiple Mapbox Style layers are combined in a single
 * OpenLayers layer instance when they use the same Mapbox Style `source`.
 * @param {Map|LayerGroup} map OpenLayers Map or LayerGroup.
 * @param {string} layerId Mapbox Style layer id.
 * @return {Layer} OpenLayers layer instance.
 */
export function getLayer(map, layerId) {
  const layers = map.getLayers().getArray();
  for (let i = 0, ii = layers.length; i < ii; ++i) {
    const mapboxLayers = layers[i].get('mapbox-layers');
    if (mapboxLayers && mapboxLayers.indexOf(layerId) !== -1) {
      return /** @type {Layer} */ (layers[i]);
    }
  }
  return undefined;
}

/**
 * Get the OpenLayers layer instances for the provided Mapbox Style `source`.
 * @param {Map|LayerGroup} map OpenLayers Map or LayerGroup.
 * @param {string} sourceId Mapbox Style source id.
 * @return {Array<Layer>} OpenLayers layer instances.
 */
export function getLayers(map, sourceId) {
  const result = [];
  const layers = map.getLayers().getArray();
  for (let i = 0, ii = layers.length; i < ii; ++i) {
    if (layers[i].get('mapbox-source') === sourceId) {
      result.push(/** @type {Layer} */ (layers[i]));
    }
  }
  return result;
}

/**
 * Get the OpenLayers source instance for the provided Mapbox Style `source`.
 * @param {Map|LayerGroup} map OpenLayers Map or LayerGroup.
 * @param {string} sourceId Mapbox Style source id.
 * @return {Source} OpenLayers source instance.
 */
export function getSource(map, sourceId) {
  const layers = map.getLayers().getArray();
  for (let i = 0, ii = layers.length; i < ii; ++i) {
    const source = /** @type {Layer} */ (layers[i]).getSource();
    if (layers[i].get('mapbox-source') === sourceId) {
      return source;
    }
  }
  return undefined;
}

/**
 * Sets or removes a feature state. The feature state is taken into account for styling,
 * just like the feature's properties, and can be used e.g. to conditionally render selected
 * features differently.
 *
 * The feature state will be stored on the OpenLayers layer matching the feature identifier, in the
 * `mapbox-featurestate` property.
 * @param {Map|VectorLayer|VectorTileLayer} mapOrLayer OpenLayers Map or layer to set the feature
 * state on.
 * @param {FeatureIdentifier} feature Feature identifier.
 * @param {Object|null} state Feature state. Set to `null` to remove the feature state.
 */
export function setFeatureState(mapOrLayer, feature, state) {
  const layers =
    'getLayers' in mapOrLayer
      ? getLayers(mapOrLayer, feature.source)
      : [mapOrLayer];
  for (let i = 0, ii = layers.length; i < ii; ++i) {
    const featureState = layers[i].get('mapbox-featurestate');
    if (featureState) {
      if (state) {
        featureState[feature.id] = state;
      } else {
        delete featureState[feature.id];
      }
      layers[i].changed();
    } else {
      throw new Error(`Map or layer for source "${feature.source}" not found.`);
    }
  }
}

/**
 * Sets or removes a feature state. The feature state is taken into account for styling,
 * just like the feature's properties, and can be used e.g. to conditionally render selected
 * features differently.
 * @param {Map|VectorLayer|VectorTileLayer} mapOrLayer Map or layer to set the feature state on.
 * @param {FeatureIdentifier} feature Feature identifier.
 * @return {Object|null} Feature state or `null` when no feature state is set for the given
 * feature identifier.
 */
export function getFeatureState(mapOrLayer, feature) {
  const layers =
    'getLayers' in mapOrLayer
      ? getLayers(mapOrLayer, feature.source)
      : [mapOrLayer];
  for (let i = 0, ii = layers.length; i < ii; ++i) {
    const featureState = layers[i].get('mapbox-featurestate');
    if (featureState && featureState[feature.id]) {
      return featureState[feature.id];
    }
  }
  return undefined;
}

/**
 * Get the Mapbox Layer object for the provided `layerId`.
 * @param {Map|LayerGroup} mapOrGroup Map or LayerGroup.
 * @param {string} layerId Mapbox Layer id.
 * @return {Object} Mapbox Layer object.
 */
export function getMapboxLayer(mapOrGroup, layerId) {
  const style = mapOrGroup.get('mapbox-style');
  const layerStyle = style.layers.find(function (layer) {
    return layer.id === layerId;
  });
  return layerStyle;
}

/**
 * Add a new Mapbox Layer object to the style. The map will be re-rendered.
 * @param {Map|LayerGroup} mapOrGroup The Map or LayerGroup `apply` was called on.
 * @param {Object} mapboxLayer Mapbox Layer object.
 * @param {string} [beforeLayerId] Optional id of the Mapbox Layer before the new layer that will be added.
 * @return {Promise<void>} Resolves when the added layer is available.
 */
export function addMapboxLayer(mapOrGroup, mapboxLayer, beforeLayerId) {
  const glStyle = mapOrGroup.get('mapbox-style');
  const mapboxLayers = glStyle.layers;
  let spliceIndex;
  let sourceIndex = -1;
  if (beforeLayerId !== undefined) {
    const beforeMapboxLayer = getMapboxLayer(mapOrGroup, beforeLayerId);
    if (beforeMapboxLayer === undefined) {
      throw new Error(`Layer with id "${beforeLayerId}" not found.`);
    }
    spliceIndex = mapboxLayers.indexOf(beforeMapboxLayer);
  } else {
    spliceIndex = mapboxLayers.length;
  }
  let sourceOffset;
  if (
    spliceIndex > 0 &&
    mapboxLayers[spliceIndex - 1].source === mapboxLayer.source
  ) {
    sourceIndex = spliceIndex - 1;
    sourceOffset = -1;
  } else if (
    spliceIndex < mapboxLayers.length &&
    mapboxLayers[spliceIndex].source === mapboxLayer.source
  ) {
    sourceIndex = spliceIndex;
    sourceOffset = 0;
  }
  if (sourceIndex === -1) {
    const {options, styleUrl} = mapOrGroup.get('mapbox-metadata');
    const layer = setupLayer(glStyle, styleUrl, mapboxLayer, options);
    if (beforeLayerId) {
      const beforeLayer = getLayer(mapOrGroup, beforeLayerId);
      const beforeLayerIndex = mapOrGroup
        .getLayers()
        .getArray()
        .indexOf(beforeLayer);
      mapOrGroup.getLayers().insertAt(beforeLayerIndex, layer);
    }
    mapboxLayers.splice(spliceIndex, 0, mapboxLayer);
    return finalizeLayer(
      layer,
      [mapboxLayer.id],
      glStyle,
      styleUrl,
      mapOrGroup,
      options
    );
  }

  if (mapboxLayers.some((layer) => layer.id === mapboxLayer.id)) {
    throw new Error(`Layer with id "${mapboxLayer.id}" already exists.`);
  }
  const sourceLayerId = mapboxLayers[sourceIndex].id;
  const args =
    styleFunctionArgs[
      getStyleFunctionKey(
        mapOrGroup.get('mapbox-style'),
        getLayer(mapOrGroup, sourceLayerId)
      )
    ];
  mapboxLayers.splice(spliceIndex, 0, mapboxLayer);
  if (args) {
    const [
      olLayer,
      glStyle,
      sourceOrLayers,
      resolutions,
      spriteData,
      spriteImageUrl,
      getFonts,
      getImage,
    ] = args;
    if (Array.isArray(sourceOrLayers)) {
      const layerIndex = sourceOrLayers.indexOf(sourceLayerId) + sourceOffset;
      sourceOrLayers.splice(layerIndex, 0, mapboxLayer.id);
    }
    applyStylefunction(
      olLayer,
      glStyle,
      sourceOrLayers,
      resolutions,
      spriteData,
      spriteImageUrl,
      getFonts,
      getImage
    );
  } else {
    getLayer(mapOrGroup, mapboxLayers[sourceIndex].id).changed();
  }
  return Promise.resolve();
}

/**
 * Update a Mapbox Layer object in the style. The map will be re-rendered with the new style.
 * @param {Map|LayerGroup} mapOrGroup The Map or LayerGroup `apply` was called on.
 * @param {Object} mapboxLayer Updated Mapbox Layer object.
 */
export function updateMapboxLayer(mapOrGroup, mapboxLayer) {
  const glStyle = mapOrGroup.get('mapbox-style');
  const mapboxLayers = glStyle.layers;
  const index = mapboxLayers.findIndex(function (layer) {
    return layer.id === mapboxLayer.id;
  });
  if (index === -1) {
    throw new Error(`Layer with id "${mapboxLayer.id}" not found.`);
  }
  const oldLayer = mapboxLayers[index];
  if (oldLayer.source !== mapboxLayer.source) {
    throw new Error(
      'Updated layer and previous version must use the same source.'
    );
  }
  delete getFunctionCache(glStyle)[mapboxLayer.id];
  delete getFilterCache(glStyle)[mapboxLayer.id];
  mapboxLayers[index] = mapboxLayer;
  const args =
    styleFunctionArgs[
      getStyleFunctionKey(
        mapOrGroup.get('mapbox-style'),
        getLayer(mapOrGroup, mapboxLayer.id)
      )
    ];
  if (args) {
    applyStylefunction.apply(undefined, args);
  } else {
    getLayer(mapOrGroup, mapboxLayer.id).changed();
  }
}

/**
 * Remove a Mapbox Layer object from the style. The map will be re-rendered.
 * @param {Map|LayerGroup} mapOrGroup The Map or LayerGroup `apply` was called on.
 * @param {string|Object} mapboxLayerIdOrLayer Mapbox Layer id or Mapbox Layer object.
 */
export function removeMapboxLayer(mapOrGroup, mapboxLayerIdOrLayer) {
  const mapboxLayerId =
    typeof mapboxLayerIdOrLayer === 'string'
      ? mapboxLayerIdOrLayer
      : mapboxLayerIdOrLayer.id;
  const layer = getLayer(mapOrGroup, mapboxLayerId);
  /** @type {Array<Object>} */
  const layerMapboxLayers = layer.get('mapbox-layers');
  if (layerMapboxLayers.length === 1) {
    throw new Error(
      'Cannot remove last Mapbox layer from an OpenLayers layer.'
    );
  }
  layerMapboxLayers.splice(layerMapboxLayers.indexOf(mapboxLayerId), 1);
  const glStyle = mapOrGroup.get('mapbox-style');
  const layers = glStyle.layers;
  layers.splice(
    layers.findIndex((layer) => layer.id === mapboxLayerId),
    1
  );
  const args = styleFunctionArgs[getStyleFunctionKey(glStyle, layer)];
  if (args) {
    const [
      olLayer,
      glStyle,
      sourceOrLayers,
      resolutions,
      spriteData,
      spriteImageUrl,
      getFonts,
      getImage,
    ] = args;
    if (Array.isArray(sourceOrLayers)) {
      sourceOrLayers.splice(
        sourceOrLayers.findIndex((layer) => layer === mapboxLayerId),
        1
      );
    }
    applyStylefunction(
      olLayer,
      glStyle,
      sourceOrLayers,
      resolutions,
      spriteData,
      spriteImageUrl,
      getFonts,
      getImage
    );
  } else {
    getLayer(mapOrGroup, mapboxLayerId).changed();
  }
}

/**
 * @typedef {import("./apply.js").Options} Options
 * @typedef {import('./apply.js').ResourceType} ResourceType
 * @private
 */
