/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
/** @packageDocumentation
 * @module MapLayers
 */
import { assert, BeEvent } from "@itwin/core-bentley";
import { ImageSource, ImageSourceFormat } from "@itwin/core-common";
import { Angle } from "@itwin/core-geometry";
import { IModelApp } from "../../IModelApp";
import { NotifyMessageDetails, OutputMessagePriority } from "../../NotificationManager";
import { appendQueryParams, GeographicTilingScheme, WebMercatorTilingScheme } from "../internal";
import { headersIncludeAuthMethod, setBasicAuthorization, setRequestTimeout } from "../../request/utils";
/** @internal */
const tileImageSize = 256, untiledImageSize = 256;
const earthRadius = 6378137;
const doDebugToolTips = false;
/** The status of the map layer imagery provider that lets you know if authentication is needed to request tiles.
 * @public
 */
export var MapLayerImageryProviderStatus;
(function (MapLayerImageryProviderStatus) {
    MapLayerImageryProviderStatus[MapLayerImageryProviderStatus["Valid"] = 0] = "Valid";
    MapLayerImageryProviderStatus[MapLayerImageryProviderStatus["RequireAuth"] = 1] = "RequireAuth";
})(MapLayerImageryProviderStatus || (MapLayerImageryProviderStatus = {}));
/** Abstract class for map layer imagery providers.
 * Map layer imagery providers request and provide tile images and other data. Each map layer from a separate source needs its own imagery provider object.
 * @beta
 */
export class MapLayerImageryProvider {
    /** @internal */
    get status() { return this._status; }
    /** Determine if this provider supports map feature info.
     * For example, this can be used to show the map feature info tool only when a provider is registered to support it.
     * @returns true if provider supports map feature info else return false.
     * @public
     */
    get supportsMapFeatureInfo() { return false; }
    resetStatus() { this.setStatus(MapLayerImageryProviderStatus.Valid); }
    /** @internal */
    get tileSize() { return this._usesCachedTiles ? tileImageSize : untiledImageSize; }
    /** @internal */
    get maximumScreenSize() { return 2 * this.tileSize; }
    get minimumZoomLevel() { return this.defaultMinimumZoomLevel; }
    get maximumZoomLevel() { return this.defaultMaximumZoomLevel; }
    /** @internal */
    get usesCachedTiles() { return this._usesCachedTiles; }
    get mutualExclusiveSubLayer() { return false; }
    /** @internal */
    get useGeographicTilingScheme() { return false; }
    /** @internal */
    get _filterByCartoRange() { return true; }
    constructor(_settings, _usesCachedTiles) {
        this._settings = _settings;
        this._usesCachedTiles = _usesCachedTiles;
        this._hasSuccessfullyFetchedTile = false;
        this.onStatusChanged = new BeEvent();
        /** @internal */
        this._mercatorTilingScheme = new WebMercatorTilingScheme();
        /** @internal */
        this._geographicTilingScheme = new GeographicTilingScheme();
        /** @internal */
        this._status = MapLayerImageryProviderStatus.Valid;
        /** @internal */
        this._includeUserCredentials = false;
        /** @internal */
        this.onFirstRequestCompleted = new BeEvent();
        /**
         * This value is used internally for various computations, this should not get overriden.
         * @internal
         */
        this.defaultMinimumZoomLevel = 0;
        /**
         * This value is used internally for various computations, this should not get overriden.
         * @internal
         */
        this.defaultMaximumZoomLevel = 22;
        this._mercatorTilingScheme = new WebMercatorTilingScheme();
        this._geographicTilingScheme = new GeographicTilingScheme(2, 1, true);
    }
    /**
     * Initialize the provider by loading the first tile at its default maximum zoom level.
     * @beta
     */
    async initialize() {
        this.loadTile(0, 0, this.defaultMaximumZoomLevel).then((tileData) => {
            if (tileData !== undefined)
                this._missingTileData = tileData.data;
        });
    }
    get tilingScheme() { return this.useGeographicTilingScheme ? this._geographicTilingScheme : this._mercatorTilingScheme; }
    /**
     * Add attribution logo cards for the data supplied by this provider to the [[Viewport]]'s logo div.
     * @param _cards Logo cards HTML element that may contain custom data attributes.
     * @param _viewport Viewport to add logo cards to.
     * @beta
     */
    addLogoCards(_cards, _viewport) { }
    /** @internal */
    get transparentBackgroundString() { return this._settings.transparentBackground ? "true" : "false"; }
    /** @internal */
    async _areChildrenAvailable(_tile) { return true; }
    /** @internal */
    getPotentialChildIds(quadId) {
        const childLevel = quadId.level + 1;
        return quadId.getChildIds(this.tilingScheme.getNumberOfXChildrenAtLevel(childLevel), this.tilingScheme.getNumberOfYChildrenAtLevel(childLevel));
    }
    /**
     * Get child IDs of a quad and generate tiles based on these child IDs.
     * See [[ImageryTileTree._loadChildren]] for the definition of `resolveChildren` where this function is commonly called.
     * @param quadId quad to generate child IDs for.
     * @param resolveChildren Function that creates tiles from child IDs.
     * @beta
     */
    _generateChildIds(quadId, resolveChildren) {
        resolveChildren(this.getPotentialChildIds(quadId));
    }
    /** @internal */
    generateChildIds(tile, resolveChildren) {
        if (tile.depth >= this.maximumZoomLevel || (undefined !== this.cartoRange && this._filterByCartoRange && !this.cartoRange.intersectsRange(tile.rectangle))) {
            tile.setLeaf();
            return;
        }
        this._generateChildIds(tile.quadId, resolveChildren);
    }
    /**
     * Get tooltip text for a specific quad and cartographic position.
     * @param strings List of strings to contain tooltip text.
     * @param quadId Quad ID to get tooltip for.
     * @param _carto Cartographic that may be used to retrieve and/or format tooltip text.
     * @param tree Tree associated with the quad to get the tooltip for.
     * @internal
     */
    async getToolTip(strings, quadId, _carto, tree) {
        if (doDebugToolTips) {
            const range = quadId.getLatLongRangeDegrees(tree.tilingScheme);
            strings.push(`QuadId: ${quadId.debugString}, Lat: ${range.low.x} - ${range.high.x} Long: ${range.low.y} - ${range.high.y}`);
        }
    }
    /** @internal */
    async getFeatureInfo(featureInfos, _quadId, _carto, _tree, _hit, _options) {
        // default implementation; simply return an empty feature info
        featureInfos.push({ layerName: this._settings.name });
    }
    /** @internal */
    async getImageFromTileResponse(tileResponse, zoomLevel) {
        const arrayBuffer = await tileResponse.arrayBuffer();
        const byteArray = new Uint8Array(arrayBuffer);
        if (!byteArray || (byteArray.length === 0))
            return undefined;
        if (this.matchesMissingTile(byteArray) && zoomLevel > 8)
            return undefined;
        const contentType = tileResponse.headers.get("content-type")?.toLowerCase();
        let imageFormat;
        if (contentType) {
            // Note: 'includes' is used here instead of exact comparison because we encountered
            // some servers that would give content type such as 'image/png;charset=UTF-8'.
            if (contentType.includes("image/jpeg"))
                imageFormat = ImageSourceFormat.Jpeg;
            else if (contentType.includes("image/png"))
                imageFormat = ImageSourceFormat.Png;
        }
        if (imageFormat !== undefined)
            return new ImageSource(byteArray, imageFormat);
        assert(false, "Invalid tile content type");
        return undefined;
    }
    /**
     * Change the status of this provider.
     * Sub-classes should override 'onStatusUpdated' instead of this method.
     * @internal
     */
    setStatus(status) {
        if (this._status !== status) {
            this.onStatusUpdated(status);
            this._status = status;
            this.onStatusChanged.raiseEvent(this);
        }
    }
    /** Method called whenever the status changes, giving the opportunity to sub-classes to have a custom behavior.
     *  @internal
     */
    onStatusUpdated(_newStatus) { }
    /** @internal */
    setRequestAuthorization(headers) {
        if (this._settings.userName && this._settings.password) {
            setBasicAuthorization(headers, this._settings.userName, this._settings.password);
        }
    }
    /** @internal */
    async makeTileRequest(url, timeoutMs) {
        // We want to complete the first request before letting other requests go;
        // this done to avoid flooding server with requests missing credentials
        if (!this._firstRequestPromise)
            this._firstRequestPromise = new Promise((resolve) => this.onFirstRequestCompleted.addOnce(() => resolve()));
        else
            await this._firstRequestPromise;
        let response;
        try {
            response = await this.makeRequest(url, timeoutMs);
        }
        finally {
            this.onFirstRequestCompleted.raiseEvent();
        }
        if (response === undefined)
            throw new Error("fetch call failed");
        return response;
    }
    /** @internal */
    async makeRequest(url, timeoutMs) {
        let response;
        let headers;
        let hasCreds = false;
        if (this._settings.userName && this._settings.password) {
            hasCreds = true;
            headers = new Headers();
            this.setRequestAuthorization(headers);
        }
        const opts = {
            method: "GET",
            headers,
            credentials: this._includeUserCredentials ? "include" : undefined,
        };
        if (timeoutMs !== undefined)
            setRequestTimeout(opts, timeoutMs);
        response = await fetch(url, opts);
        if (response.status === 401
            && headersIncludeAuthMethod(response.headers, ["ntlm", "negotiate"])
            && !this._includeUserCredentials
            && !hasCreds) {
            // Removed the previous headers and make sure "include" credentials is set
            opts.headers = undefined;
            opts.credentials = "include";
            // We got a http 401 challenge, lets try again with SSO enabled (i.e. Windows Authentication)
            response = await fetch(url, opts);
            if (response.status === 200) {
                this._includeUserCredentials = true; // avoid going through 401 challenges over and over
            }
        }
        return response;
    }
    /** Returns a map layer tile at the specified settings. */
    async loadTile(row, column, zoomLevel) {
        try {
            const tileUrl = await this.constructUrl(row, column, zoomLevel);
            if (tileUrl.length === 0)
                return undefined;
            const tileResponse = await this.makeTileRequest(tileUrl);
            if (!this._hasSuccessfullyFetchedTile) {
                this._hasSuccessfullyFetchedTile = true;
            }
            return await this.getImageFromTileResponse(tileResponse, zoomLevel);
        }
        catch (error) {
            if (error?.status === 401) {
                this.setStatus(MapLayerImageryProviderStatus.RequireAuth);
                // Only report error to end-user if we were previously able to fetch tiles
                // and then encountered an error, otherwise I assume an error was already reported
                // through the source validation process.
                if (this._hasSuccessfullyFetchedTile) {
                    const msg = IModelApp.localization.getLocalizedString("iModelJs:MapLayers.Messages.LoadTileTokenError", { layerName: this._settings.name });
                    IModelApp.notifications.outputMessage(new NotifyMessageDetails(OutputMessagePriority.Warning, msg));
                }
            }
            return undefined;
        }
    }
    /** @internal */
    async toolTipFromUrl(strings, url) {
        const headers = new Headers();
        this.setRequestAuthorization(headers);
        try {
            const response = await fetch(url, {
                method: "GET",
                headers,
                credentials: this._includeUserCredentials ? "include" : undefined,
            });
            const text = await response.text();
            if (undefined !== text) {
                strings.push(text);
            }
        }
        catch {
        }
    }
    /** @internal */
    matchesMissingTile(tileData) {
        if (!this._missingTileData)
            return false;
        if (tileData.length !== this._missingTileData.length)
            return false;
        for (let i = 0; i < tileData.length; i += 10) {
            if (this._missingTileData[i] !== tileData[i]) {
                return false;
            }
        }
        return true;
    }
    /**
     * Calculates the projected x cartesian coordinate in EPSG:3857 from the longitude in EPSG:4326 (WGS84)
     * @param longitude Longitude in EPSG:4326 (WGS84)
     * @internal
     */
    getEPSG3857X(longitude) {
        return longitude * 20037508.34 / 180.0;
    }
    /**
     * Calculates the projected y cartesian coordinate in EPSG:3857 from the latitude in EPSG:4326 (WGS84)
     * @param latitude Latitude in EPSG:4326 (WGS84)
     * @internal
     */
    getEPSG3857Y(latitude) {
        const y = Math.log(Math.tan((90.0 + latitude) * Math.PI / 360.0)) / (Math.PI / 180.0);
        return y * 20037508.34 / 180.0;
    }
    /**
     * Calculates the longitude in EPSG:4326 (WGS84) from the projected x cartesian coordinate in EPSG:3857
     * @param x3857 Projected x cartesian coordinate in EPSG:3857
     * @internal
     */
    getEPSG4326Lon(x3857) {
        return Angle.radiansToDegrees(x3857 / earthRadius);
    }
    /**
     * Calculates the latitude in EPSG:4326 (WGS84) from the projected y cartesian coordinate in EPSG:3857
     * @param y3857 Projected y cartesian coordinate in EPSG:3857
     * @internal
     */
    getEPSG4326Lat(y3857) {
        const y = 2 * Math.atan(Math.exp(y3857 / earthRadius)) - (Math.PI / 2);
        return Angle.radiansToDegrees(y);
    }
    /**
     * Get the bounding box/extents of a tile in EPSG:4326 (WGS84) format.
     * Map tile providers like Bing and Mapbox allow the URL to be constructed directly from the zoom level and tile coordinates.
     * However, WMS-based servers take a bounding box instead. This method can help get that bounding box from a tile.
     * @param row Row of the tile
     * @param column Column of the tile
     * @param zoomLevel Desired zoom level of the tile
     * @internal
     */
    getEPSG4326Extent(row, column, zoomLevel) {
        // Shift left (this.tileSize << zoomLevel) overflow when using 512 pixels tile at higher resolution,
        // so use Math.pow instead (I assume the performance lost to be minimal)
        const mapSize = this.tileSize * Math.pow(2, zoomLevel);
        const leftGrid = this.tileSize * column;
        const topGrid = this.tileSize * row;
        const longitudeLeft = 360 * ((leftGrid / mapSize) - 0.5);
        const y0 = 0.5 - ((topGrid + this.tileSize) / mapSize);
        const latitudeBottom = 90.0 - 360.0 * Math.atan(Math.exp(-y0 * 2 * Math.PI)) / Math.PI;
        const longitudeRight = 360 * (((leftGrid + this.tileSize) / mapSize) - 0.5);
        const y1 = 0.5 - (topGrid / mapSize);
        const latitudeTop = 90.0 - 360.0 * Math.atan(Math.exp(-y1 * 2 * Math.PI)) / Math.PI;
        return { longitudeLeft, longitudeRight, latitudeTop, latitudeBottom };
    }
    /**
     * Get the bounding box/extents of a tile in EPSG:3857 format.
     * @param row Row of the tile
     * @param column Column of the tile
     * @param zoomLevel Desired zoom level of the tile
     * @internal
     */
    getEPSG3857Extent(row, column, zoomLevel) {
        const epsg4326Extent = this.getEPSG4326Extent(row, column, zoomLevel);
        const left = this.getEPSG3857X(epsg4326Extent.longitudeLeft);
        const right = this.getEPSG3857X(epsg4326Extent.longitudeRight);
        const bottom = this.getEPSG3857Y(epsg4326Extent.latitudeBottom);
        const top = this.getEPSG3857Y(epsg4326Extent.latitudeTop);
        return { left, right, bottom, top };
    }
    /** @internal */
    getEPSG3857ExtentString(row, column, zoomLevel) {
        const tileExtent = this.getEPSG3857Extent(row, column, zoomLevel);
        return `${tileExtent.left.toFixed(2)},${tileExtent.bottom.toFixed(2)},${tileExtent.right.toFixed(2)},${tileExtent.top.toFixed(2)}`;
    }
    /** @internal */
    getEPSG4326TileExtentString(row, column, zoomLevel, latLongAxisOrdering) {
        const tileExtent = this.getEPSG4326Extent(row, column, zoomLevel);
        return this.getEPSG4326ExtentString(tileExtent, latLongAxisOrdering);
    }
    /** @internal */
    getEPSG4326ExtentString(tileExtent, latLongAxisOrdering) {
        if (latLongAxisOrdering) {
            return `${tileExtent.latitudeBottom.toFixed(8)},${tileExtent.longitudeLeft.toFixed(8)},${tileExtent.latitudeTop.toFixed(8)},${tileExtent.longitudeRight.toFixed(8)}`;
        }
        else {
            return `${tileExtent.longitudeLeft.toFixed(8)},${tileExtent.latitudeBottom.toFixed(8)},${tileExtent.longitudeRight.toFixed(8)},${tileExtent.latitudeTop.toFixed(8)}`;
        }
    }
    /** Append custom parameters for settings to provided URL object.
     * @internal
     */
    appendCustomParams(url) {
        if (!this._settings.savedQueryParams && !this._settings.unsavedQueryParams)
            return url;
        let tmpUrl = appendQueryParams(url, this._settings.savedQueryParams);
        tmpUrl = appendQueryParams(tmpUrl, this._settings.unsavedQueryParams);
        return tmpUrl;
    }
}
//# sourceMappingURL=MapLayerImageryProvider.js.map