import when from "../ThirdParty/when.js";
import Credit from "./Credit.js";
import defaultValue from "./defaultValue.js";
import defined from "./defined.js";
import DeveloperError from "./DeveloperError.js";
import Event from "./Event.js";
import GeographicTilingScheme from "./GeographicTilingScheme.js";
import GoogleEarthEnterpriseMetadata from "./GoogleEarthEnterpriseMetadata.js";
import GoogleEarthEnterpriseTerrainData from "./GoogleEarthEnterpriseTerrainData.js";
import HeightmapTerrainData from "./HeightmapTerrainData.js";
import JulianDate from "./JulianDate.js";
import CesiumMath from "./Math.js";
import Rectangle from "./Rectangle.js";
import Request from "./Request.js";
import RequestState from "./RequestState.js";
import RequestType from "./RequestType.js";
import Resource from "./Resource.js";
import RuntimeError from "./RuntimeError.js";
import TaskProcessor from "./TaskProcessor.js";
import TileProviderError from "./TileProviderError.js";

var TerrainState = {
  UNKNOWN: 0,
  NONE: 1,
  SELF: 2,
  PARENT: 3,
};

var julianDateScratch = new JulianDate();

function TerrainCache() {
  this._terrainCache = {};
  this._lastTidy = JulianDate.now();
}

TerrainCache.prototype.add = function (quadKey, buffer) {
  this._terrainCache[quadKey] = {
    buffer: buffer,
    timestamp: JulianDate.now(),
  };
};

TerrainCache.prototype.get = function (quadKey) {
  var terrainCache = this._terrainCache;
  var result = terrainCache[quadKey];
  if (defined(result)) {
    delete this._terrainCache[quadKey];
    return result.buffer;
  }
};

TerrainCache.prototype.tidy = function () {
  JulianDate.now(julianDateScratch);
  if (JulianDate.secondsDifference(julianDateScratch, this._lastTidy) > 10) {
    var terrainCache = this._terrainCache;
    var keys = Object.keys(terrainCache);
    var count = keys.length;
    for (var i = 0; i < count; ++i) {
      var k = keys[i];
      var e = terrainCache[k];
      if (JulianDate.secondsDifference(julianDateScratch, e.timestamp) > 10) {
        delete terrainCache[k];
      }
    }

    JulianDate.clone(julianDateScratch, this._lastTidy);
  }
};

/**
 * Provides tiled terrain using the Google Earth Enterprise REST API.
 *
 * @alias GoogleEarthEnterpriseTerrainProvider
 * @constructor
 *
 * @param {Object} options Object with the following properties:
 * @param {Resource|String} options.url The url of the Google Earth Enterprise server hosting the imagery.
 * @param {GoogleEarthEnterpriseMetadata} options.metadata A metadata object that can be used to share metadata requests with a GoogleEarthEnterpriseImageryProvider.
 * @param {Ellipsoid} [options.ellipsoid] The ellipsoid.  If not specified, the WGS84 ellipsoid is used.
 * @param {Credit|String} [options.credit] A credit for the data source, which is displayed on the canvas.
 *
 * @see GoogleEarthEnterpriseImageryProvider
 * @see CesiumTerrainProvider
 *
 * @example
 * var geeMetadata = new GoogleEarthEnterpriseMetadata('http://www.earthenterprise.org/3d');
 * var gee = new Cesium.GoogleEarthEnterpriseTerrainProvider({
 *     metadata : geeMetadata
 * });
 *
 * @see {@link http://www.w3.org/TR/cors/|Cross-Origin Resource Sharing}
 */
function GoogleEarthEnterpriseTerrainProvider(options) {
  options = defaultValue(options, defaultValue.EMPTY_OBJECT);

  //>>includeStart('debug', pragmas.debug);
  if (!(defined(options.url) || defined(options.metadata))) {
    throw new DeveloperError("options.url or options.metadata is required.");
  }
  //>>includeEnd('debug');

  var metadata;
  if (defined(options.metadata)) {
    metadata = options.metadata;
  } else {
    var resource = Resource.createIfNeeded(options.url);
    metadata = new GoogleEarthEnterpriseMetadata(resource);
  }

  this._metadata = metadata;
  this._tilingScheme = new GeographicTilingScheme({
    numberOfLevelZeroTilesX: 2,
    numberOfLevelZeroTilesY: 2,
    rectangle: new Rectangle(
      -CesiumMath.PI,
      -CesiumMath.PI,
      CesiumMath.PI,
      CesiumMath.PI
    ),
    ellipsoid: options.ellipsoid,
  });

  var credit = options.credit;
  if (typeof credit === "string") {
    credit = new Credit(credit);
  }
  this._credit = credit;

  // Pulled from Google's documentation
  this._levelZeroMaximumGeometricError = 40075.16;

  this._terrainCache = new TerrainCache();
  this._terrainPromises = {};
  this._terrainRequests = {};

  this._errorEvent = new Event();

  this._ready = false;
  var that = this;
  var metadataError;
  this._readyPromise = metadata.readyPromise
    .then(function (result) {
      if (!metadata.terrainPresent) {
        var e = new RuntimeError(
          "The server " + metadata.url + " doesn't have terrain"
        );
        metadataError = TileProviderError.handleError(
          metadataError,
          that,
          that._errorEvent,
          e.message,
          undefined,
          undefined,
          undefined,
          e
        );
        return when.reject(e);
      }

      TileProviderError.handleSuccess(metadataError);
      that._ready = result;
      return result;
    })
    .otherwise(function (e) {
      metadataError = TileProviderError.handleError(
        metadataError,
        that,
        that._errorEvent,
        e.message,
        undefined,
        undefined,
        undefined,
        e
      );
      return when.reject(e);
    });
}

Object.defineProperties(GoogleEarthEnterpriseTerrainProvider.prototype, {
  /**
   * Gets the name of the Google Earth Enterprise server url hosting the imagery.
   * @memberof GoogleEarthEnterpriseTerrainProvider.prototype
   * @type {String}
   * @readonly
   */
  url: {
    get: function () {
      return this._metadata.url;
    },
  },

  /**
   * Gets the proxy used by this provider.
   * @memberof GoogleEarthEnterpriseTerrainProvider.prototype
   * @type {Proxy}
   * @readonly
   */
  proxy: {
    get: function () {
      return this._metadata.proxy;
    },
  },

  /**
   * Gets the tiling scheme used by this provider.  This function should
   * not be called before {@link GoogleEarthEnterpriseTerrainProvider#ready} returns true.
   * @memberof GoogleEarthEnterpriseTerrainProvider.prototype
   * @type {TilingScheme}
   * @readonly
   */
  tilingScheme: {
    get: function () {
      //>>includeStart('debug', pragmas.debug);
      if (!this._ready) {
        throw new DeveloperError(
          "tilingScheme must not be called before the imagery provider is ready."
        );
      }
      //>>includeEnd('debug');

      return this._tilingScheme;
    },
  },

  /**
   * Gets an event that is raised when the imagery provider encounters an asynchronous error.  By subscribing
   * to the event, you will be notified of the error and can potentially recover from it.  Event listeners
   * are passed an instance of {@link TileProviderError}.
   * @memberof GoogleEarthEnterpriseTerrainProvider.prototype
   * @type {Event}
   * @readonly
   */
  errorEvent: {
    get: function () {
      return this._errorEvent;
    },
  },

  /**
   * Gets a value indicating whether or not the provider is ready for use.
   * @memberof GoogleEarthEnterpriseTerrainProvider.prototype
   * @type {Boolean}
   * @readonly
   */
  ready: {
    get: function () {
      return this._ready;
    },
  },

  /**
   * Gets a promise that resolves to true when the provider is ready for use.
   * @memberof GoogleEarthEnterpriseTerrainProvider.prototype
   * @type {Promise.<Boolean>}
   * @readonly
   */
  readyPromise: {
    get: function () {
      return this._readyPromise;
    },
  },

  /**
   * Gets the credit to display when this terrain provider is active.  Typically this is used to credit
   * the source of the terrain.  This function should not be called before {@link GoogleEarthEnterpriseTerrainProvider#ready} returns true.
   * @memberof GoogleEarthEnterpriseTerrainProvider.prototype
   * @type {Credit}
   * @readonly
   */
  credit: {
    get: function () {
      return this._credit;
    },
  },

  /**
   * Gets a value indicating whether or not the provider includes a water mask.  The water mask
   * indicates which areas of the globe are water rather than land, so they can be rendered
   * as a reflective surface with animated waves.  This function should not be
   * called before {@link GoogleEarthEnterpriseTerrainProvider#ready} returns true.
   * @memberof GoogleEarthEnterpriseTerrainProvider.prototype
   * @type {Boolean}
   * @readonly
   */
  hasWaterMask: {
    get: function () {
      return false;
    },
  },

  /**
   * Gets a value indicating whether or not the requested tiles include vertex normals.
   * This function should not be called before {@link GoogleEarthEnterpriseTerrainProvider#ready} returns true.
   * @memberof GoogleEarthEnterpriseTerrainProvider.prototype
   * @type {Boolean}
   * @readonly
   */
  hasVertexNormals: {
    get: function () {
      return false;
    },
  },

  /**
   * Gets an object that can be used to determine availability of terrain from this provider, such as
   * at points and in rectangles.  This function should not be called before
   * {@link GoogleEarthEnterpriseTerrainProvider#ready} returns true.  This property may be undefined if availability
   * information is not available.
   * @memberof GoogleEarthEnterpriseTerrainProvider.prototype
   * @type {TileAvailability}
   * @readonly
   */
  availability: {
    get: function () {
      return undefined;
    },
  },
});

var taskProcessor = new TaskProcessor("decodeGoogleEarthEnterprisePacket");

// If the tile has its own terrain, then you can just use its child bitmask. If it was requested using it's parent
//  then you need to check all of its children to see if they have terrain.
function computeChildMask(quadKey, info, metadata) {
  var childMask = info.getChildBitmask();
  if (info.terrainState === TerrainState.PARENT) {
    childMask = 0;
    for (var i = 0; i < 4; ++i) {
      var child = metadata.getTileInformationFromQuadKey(
        quadKey + i.toString()
      );
      if (defined(child) && child.hasTerrain()) {
        childMask |= 1 << i;
      }
    }
  }

  return childMask;
}

/**
 * Requests the geometry for a given tile.  This function should not be called before
 * {@link GoogleEarthEnterpriseTerrainProvider#ready} returns true.  The result must include terrain data and
 * may optionally include a water mask and an indication of which child tiles are available.
 *
 * @param {Number} x The X coordinate of the tile for which to request geometry.
 * @param {Number} y The Y coordinate of the tile for which to request geometry.
 * @param {Number} level The level of the tile for which to request geometry.
 * @param {Request} [request] The request object. Intended for internal use only.
 * @returns {Promise.<TerrainData>|undefined} A promise for the requested geometry.  If this method
 *          returns undefined instead of a promise, it is an indication that too many requests are already
 *          pending and the request will be retried later.
 *
 * @exception {DeveloperError} This function must not be called before {@link GoogleEarthEnterpriseTerrainProvider#ready}
 *            returns true.
 */
GoogleEarthEnterpriseTerrainProvider.prototype.requestTileGeometry = function (
  x,
  y,
  level,
  request
) {
  //>>includeStart('debug', pragmas.debug)
  if (!this._ready) {
    throw new DeveloperError(
      "requestTileGeometry must not be called before the terrain provider is ready."
    );
  }
  //>>includeEnd('debug');

  var quadKey = GoogleEarthEnterpriseMetadata.tileXYToQuadKey(x, y, level);
  var terrainCache = this._terrainCache;
  var metadata = this._metadata;
  var info = metadata.getTileInformationFromQuadKey(quadKey);

  // Check if this tile is even possibly available
  if (!defined(info)) {
    return when.reject(new RuntimeError("Terrain tile doesn't exist"));
  }

  var terrainState = info.terrainState;
  if (!defined(terrainState)) {
    // First time we have tried to load this tile, so set terrain state to UNKNOWN
    terrainState = info.terrainState = TerrainState.UNKNOWN;
  }

  // If its in the cache, return it
  var buffer = terrainCache.get(quadKey);
  if (defined(buffer)) {
    var credit = metadata.providers[info.terrainProvider];
    return when.resolve(
      new GoogleEarthEnterpriseTerrainData({
        buffer: buffer,
        childTileMask: computeChildMask(quadKey, info, metadata),
        credits: defined(credit) ? [credit] : undefined,
        negativeAltitudeExponentBias: metadata.negativeAltitudeExponentBias,
        negativeElevationThreshold: metadata.negativeAltitudeThreshold,
      })
    );
  }

  // Clean up the cache
  terrainCache.tidy();

  // We have a tile, check to see if no ancestors have terrain or that we know for sure it doesn't
  if (!info.ancestorHasTerrain) {
    // We haven't reached a level with terrain, so return the ellipsoid
    return when.resolve(
      new HeightmapTerrainData({
        buffer: new Uint8Array(16 * 16),
        width: 16,
        height: 16,
      })
    );
  } else if (terrainState === TerrainState.NONE) {
    // Already have info and there isn't any terrain here
    return when.reject(new RuntimeError("Terrain tile doesn't exist"));
  }

  // Figure out where we are getting the terrain and what version
  var parentInfo;
  var q = quadKey;
  var terrainVersion = -1;
  switch (terrainState) {
    case TerrainState.SELF: // We have terrain and have retrieved it before
      terrainVersion = info.terrainVersion;
      break;
    case TerrainState.PARENT: // We have terrain in our parent
      q = q.substring(0, q.length - 1);
      parentInfo = metadata.getTileInformationFromQuadKey(q);
      terrainVersion = parentInfo.terrainVersion;
      break;
    case TerrainState.UNKNOWN: // We haven't tried to retrieve terrain yet
      if (info.hasTerrain()) {
        terrainVersion = info.terrainVersion; // We should have terrain
      } else {
        q = q.substring(0, q.length - 1);
        parentInfo = metadata.getTileInformationFromQuadKey(q);
        if (defined(parentInfo) && parentInfo.hasTerrain()) {
          terrainVersion = parentInfo.terrainVersion; // Try checking in the parent
        }
      }
      break;
  }

  // We can't figure out where to get the terrain
  if (terrainVersion < 0) {
    return when.reject(new RuntimeError("Terrain tile doesn't exist"));
  }

  // Load that terrain
  var terrainPromises = this._terrainPromises;
  var terrainRequests = this._terrainRequests;
  var sharedPromise;
  var sharedRequest;
  if (defined(terrainPromises[q])) {
    // Already being loaded possibly from another child, so return existing promise
    sharedPromise = terrainPromises[q];
    sharedRequest = terrainRequests[q];
  } else {
    // Create new request for terrain
    sharedRequest = request;
    var requestPromise = buildTerrainResource(
      this,
      q,
      terrainVersion,
      sharedRequest
    ).fetchArrayBuffer();

    if (!defined(requestPromise)) {
      return undefined; // Throttled
    }

    sharedPromise = requestPromise.then(function (terrain) {
      if (defined(terrain)) {
        return taskProcessor
          .scheduleTask(
            {
              buffer: terrain,
              type: "Terrain",
              key: metadata.key,
            },
            [terrain]
          )
          .then(function (terrainTiles) {
            // Add requested tile and mark it as SELF
            var requestedInfo = metadata.getTileInformationFromQuadKey(q);
            requestedInfo.terrainState = TerrainState.SELF;
            terrainCache.add(q, terrainTiles[0]);
            var provider = requestedInfo.terrainProvider;

            // Add children to cache
            var count = terrainTiles.length - 1;
            for (var j = 0; j < count; ++j) {
              var childKey = q + j.toString();
              var child = metadata.getTileInformationFromQuadKey(childKey);
              if (defined(child)) {
                terrainCache.add(childKey, terrainTiles[j + 1]);
                child.terrainState = TerrainState.PARENT;
                if (child.terrainProvider === 0) {
                  child.terrainProvider = provider;
                }
              }
            }
          });
      }

      return when.reject(new RuntimeError("Failed to load terrain."));
    });

    terrainPromises[q] = sharedPromise; // Store promise without delete from terrainPromises
    terrainRequests[q] = sharedRequest;

    // Set promise so we remove from terrainPromises just one time
    sharedPromise = sharedPromise.always(function () {
      delete terrainPromises[q];
      delete terrainRequests[q];
    });
  }

  return sharedPromise
    .then(function () {
      var buffer = terrainCache.get(quadKey);
      if (defined(buffer)) {
        var credit = metadata.providers[info.terrainProvider];
        return new GoogleEarthEnterpriseTerrainData({
          buffer: buffer,
          childTileMask: computeChildMask(quadKey, info, metadata),
          credits: defined(credit) ? [credit] : undefined,
          negativeAltitudeExponentBias: metadata.negativeAltitudeExponentBias,
          negativeElevationThreshold: metadata.negativeAltitudeThreshold,
        });
      }

      return when.reject(new RuntimeError("Failed to load terrain."));
    })
    .otherwise(function (error) {
      if (sharedRequest.state === RequestState.CANCELLED) {
        request.state = sharedRequest.state;
        return when.reject(error);
      }
      info.terrainState = TerrainState.NONE;
      return when.reject(error);
    });
};

/**
 * Gets the maximum geometric error allowed in a tile at a given level.
 *
 * @param {Number} level The tile level for which to get the maximum geometric error.
 * @returns {Number} The maximum geometric error.
 */
GoogleEarthEnterpriseTerrainProvider.prototype.getLevelMaximumGeometricError = function (
  level
) {
  return this._levelZeroMaximumGeometricError / (1 << level);
};

/**
 * Determines whether data for a tile is available to be loaded.
 *
 * @param {Number} x The X coordinate of the tile for which to request geometry.
 * @param {Number} y The Y coordinate of the tile for which to request geometry.
 * @param {Number} level The level of the tile for which to request geometry.
 * @returns {Boolean|undefined} Undefined if not supported, otherwise true or false.
 */
GoogleEarthEnterpriseTerrainProvider.prototype.getTileDataAvailable = function (
  x,
  y,
  level
) {
  var metadata = this._metadata;
  var quadKey = GoogleEarthEnterpriseMetadata.tileXYToQuadKey(x, y, level);

  var info = metadata.getTileInformation(x, y, level);
  if (info === null) {
    return false;
  }

  if (defined(info)) {
    if (!info.ancestorHasTerrain) {
      return true; // We'll just return the ellipsoid
    }

    var terrainState = info.terrainState;
    if (terrainState === TerrainState.NONE) {
      return false; // Terrain is not available
    }

    if (!defined(terrainState) || terrainState === TerrainState.UNKNOWN) {
      info.terrainState = TerrainState.UNKNOWN;
      if (!info.hasTerrain()) {
        quadKey = quadKey.substring(0, quadKey.length - 1);
        var parentInfo = metadata.getTileInformationFromQuadKey(quadKey);
        if (!defined(parentInfo) || !parentInfo.hasTerrain()) {
          return false;
        }
      }
    }

    return true;
  }

  if (metadata.isValid(quadKey)) {
    // We will need this tile, so request metadata and return false for now
    var request = new Request({
      throttle: false,
      throttleByServer: true,
      type: RequestType.TERRAIN,
    });
    metadata.populateSubtree(x, y, level, request);
  }
  return false;
};

/**
 * Makes sure we load availability data for a tile
 *
 * @param {Number} x The X coordinate of the tile for which to request geometry.
 * @param {Number} y The Y coordinate of the tile for which to request geometry.
 * @param {Number} level The level of the tile for which to request geometry.
 * @returns {undefined|Promise<void>} Undefined if nothing need to be loaded or a Promise that resolves when all required tiles are loaded
 */
GoogleEarthEnterpriseTerrainProvider.prototype.loadTileDataAvailability = function (
  x,
  y,
  level
) {
  return undefined;
};

//
// Functions to handle imagery packets
//
function buildTerrainResource(terrainProvider, quadKey, version, request) {
  version = defined(version) && version > 0 ? version : 1;
  return terrainProvider._metadata.resource.getDerivedResource({
    url: "flatfile?f1c-0" + quadKey + "-t." + version.toString(),
    request: request,
  });
}
export default GoogleEarthEnterpriseTerrainProvider;
