import assert from "assert";
import { fetchWithTimeout, getCoingeckoTokenIdByAddress, retry } from "../utils";
import { CoingeckoPriceNotFoundError } from "./CoingeckoErrors";
export function msToS(ms) {
    return Math.floor(ms / 1000);
}
// Singleton Coingecko class.
export class Coingecko {
    host;
    proHost;
    logger;
    apiKey;
    customPlatformIdMap;
    static instance;
    prices;
    pricesBySymbol;
    _maxPriceAge = 300; // seconds
    // Retry configuration.
    retryDelay = 1;
    numRetries = 0; // Most failures are due to 429 rate-limiting, so there is no point in retrying.
    basicApiTimeout = 500; // ms
    platformIdMap = new Map(); // chainId => platform_id (137 => "polygon-pos")
    tokenIdMap = {}; // coinGeckoId => { platform_id : "tokenAddress":}
    static get(logger, apiKey, customPlatformIdMap) {
        if (!this.instance)
            this.instance = new Coingecko("https://api.coingecko.com/api/v3", "https://pro-api.coingecko.com/api/v3", logger, apiKey, customPlatformIdMap);
        return this.instance;
    }
    get maxPriceAge() {
        return this._maxPriceAge;
    }
    set maxPriceAge(age) {
        assert(age >= 0);
        this.logger.debug({
            at: "Coingecko#maxPriceAge",
            message: `Setting maxPriceAge (S) ${this._maxPriceAge} => ${age}.`,
        });
        this._maxPriceAge = age;
    }
    constructor(host, proHost, logger, apiKey, customPlatformIdMap) {
        this.host = host;
        this.proHost = proHost;
        this.logger = logger;
        this.apiKey = apiKey;
        this.customPlatformIdMap = customPlatformIdMap;
        this.prices = {};
        this.pricesBySymbol = {};
    }
    async getPlatformId(chainId) {
        let id = this.platformIdMap.get(chainId);
        if (id) {
            return id;
        }
        const platforms = await this.getPlatforms();
        this.platformIdMap = new Map(platforms.filter((chain) => Boolean(chain.chain_identifier)).map((chain) => [chain.chain_identifier, chain.id]));
        // Extend the platformIdMap with any custom platform ids
        if (this.customPlatformIdMap) {
            Object.entries(this.customPlatformIdMap).forEach(([chainId, platformId]) => {
                this.platformIdMap.set(Number(chainId), platformId);
            });
        }
        id = this.platformIdMap.get(chainId);
        if (!id) {
            this.logger.error({
                message: `Coingecko does not support chain with id ${chainId}`,
                at: "Coingecko#getPlatformId",
            });
            throw new Error(`Coingecko does not support chain with id ${chainId}`);
        }
        return id;
    }
    // for tokens not found in our constants, we can attempt to fetch the id from coingecko itself
    async getCoingeckoTokenId(address, chainId) {
        let id;
        try {
            id = getCoingeckoTokenIdByAddress(address, chainId);
            return id;
        }
        catch {
            this.logger.warn({
                at: "Coingecko#getCoingeckoTokenIdByAddress",
                message: `Token with address ${address} not found in constants. Attempting to fetch ID from coingecko API...`,
            });
        }
        const platformId = await this.getPlatformId(chainId);
        id = this.getTokenIdFromAddress(address, platformId);
        if (id) {
            return id;
        }
        await this.updateTokenMap();
        id = this.getTokenIdFromAddress(address, platformId);
        if (!id) {
            const message = `Coin with address ${address} does not exist on chain with id ${chainId}`;
            this.logger.error({
                at: "Coingecko#getCoingeckoTokenIdByAddress",
                message,
            });
            throw new Error(message);
        }
        return id;
    }
    getTokenIdFromAddress(address, platformId) {
        return Object.entries(this.tokenIdMap).find(([_, value]) => Boolean(value?.[platformId]?.toLowerCase() === address.toLowerCase()))?.[0];
    }
    async updateTokenMap() {
        const rawTokenList = await this.call("coins/list?include_platform=true");
        this.tokenIdMap = Object.fromEntries(rawTokenList
            .filter((token) => Boolean(Object.values(token?.platforms)?.length > 0))
            .map((token) => [token.id, token.platforms]));
        return this.tokenIdMap;
    }
    // Fetch historic prices for a `contract` denominated in `currency` between timestamp `from` and `to`. Note timestamps
    // are assumed to be js timestamps and are converted to unixtimestamps by dividing by 1000.
    async getHistoricContractPrices(contract, from, to, currency = "usd") {
        assert(contract, "requires contract address");
        assert(currency, "requires currency symbol");
        assert(from, "requires from timestamp");
        assert(to, "requires to timestamp");
        const _from = msToS(from);
        const _to = msToS(to);
        const result = await this.call(`coins/ethereum/contract/${encodeURIComponent(contract.toLowerCase())}/market_chart/range/?vs_currency=${encodeURIComponent(currency)}&from=${_from}&to=${_to}`);
        // fyi timestamps are returned in ms in contrast to the current price endpoint
        if (result.prices)
            return result.prices;
        throw new Error("Something went wrong fetching coingecko prices!");
    }
    /**
     * Get the current price of a token denominated in `currency`.
     * @param contractAddress The L1 token address to fetch the price for.
     * @param date A datestring in the format "dd-mm-yyyy" to fetch the price for.
     * @param currency The currency to fetch the price in. Defaults to "usd".
     * @returns The price of the token at the given date.
     * @throws If today is selected and it is before 3am UTC or if the price is not found.
     */
    async getContractHistoricDayPrice(contractAddress, date, currency = "usd", chainId = 1) {
        const coingeckoTokenIdentifier = await this.getCoingeckoTokenId(contractAddress, chainId);
        assert(date, "Requires date string");
        // Build the path for the Coingecko API request
        const url = `coins/${encodeURIComponent(coingeckoTokenIdentifier)}/history`;
        // Build the query parameters for the Coingecko API request
        const queryParams = {
            date,
            localization: "false",
        };
        // Grab the result - parse out price, market cap, total volume, and timestamp
        const result = await this.call(`${url}?${new URLSearchParams(queryParams).toString()}`);
        const price = result?.market_data?.current_price?.[currency];
        assert(price, `No price found for ${contractAddress} on ${date}`);
        return price;
    }
    getContractDetails(contract_address, platform_id = "ethereum") {
        return this.call(`coins/${encodeURIComponent(platform_id)}/contract/${encodeURIComponent(contract_address.toLowerCase())}`);
    }
    async getCurrentPriceByContract(contractAddress, currency = "usd", chainId = 1) {
        const platform_id = await this.getPlatformId(chainId);
        const priceCache = this.getPriceCache(currency, platform_id);
        let tokenPrice = this.getCachedAddressPrice(contractAddress, currency, platform_id);
        if (tokenPrice === undefined) {
            await this.getContractPrices([contractAddress], currency, platform_id);
            tokenPrice = priceCache[contractAddress];
        }
        if (tokenPrice === undefined) {
            throw new CoingeckoPriceNotFoundError({ identifier: contractAddress, currency, lookupType: "address" });
        }
        return [tokenPrice.timestamp.toString(), tokenPrice.price];
    }
    async getCurrentPriceById(contractAddress, currency = "usd", chainId = 1) {
        const platform_id = await this.getPlatformId(chainId);
        const priceCache = this.getPriceCache(currency, platform_id);
        let tokenPrice = this.getCachedAddressPrice(contractAddress, currency, platform_id);
        if (tokenPrice === undefined) {
            const coingeckoId = await this.getCoingeckoTokenId(contractAddress, chainId);
            // Build the path for the Coingecko API request
            const result = await this.call(`simple/price?ids=${encodeURIComponent(coingeckoId)}&vs_currencies=${encodeURIComponent(currency)}&include_last_updated_at=true`);
            const cgPrice = result?.[coingeckoId];
            if (cgPrice === undefined || !cgPrice?.[currency]) {
                this.logger.debug({
                    at: "Coingecko#getCurrentPriceById",
                    message: `No Coingecko price found for id '${coingeckoId}' in ${currency}`,
                });
                throw new CoingeckoPriceNotFoundError({ identifier: coingeckoId, currency, lookupType: "id" });
            }
            else {
                this.updatePriceCache(cgPrice, contractAddress, currency, platform_id);
            }
        }
        tokenPrice = priceCache[contractAddress];
        if (tokenPrice === undefined) {
            throw new CoingeckoPriceNotFoundError({ identifier: contractAddress, currency, lookupType: "address" });
        }
        return [tokenPrice.timestamp.toString(), tokenPrice.price];
    }
    async getCurrentPriceBySymbol(symbol, currency = "usd") {
        let tokenPrice = this.getCachedSymbolPrice(symbol, currency);
        if (tokenPrice === undefined) {
            const result = await this.call(`simple/price?symbols=${encodeURIComponent(symbol)}&vs_currencies=${encodeURIComponent(currency)}&include_last_updated_at=true`);
            const cgPrice = result?.[symbol.toLowerCase()] || result?.[symbol.toUpperCase()];
            if (cgPrice === undefined || !cgPrice?.[currency]) {
                this.logger.debug({
                    at: "Coingecko#getCurrentPriceBySymbol",
                    message: `No Coingecko price found for symbol '${symbol}' in ${currency}`,
                });
                throw new CoingeckoPriceNotFoundError({ identifier: symbol, currency, lookupType: "symbol" });
            }
            else {
                this.updatePriceCacheBySymbol(cgPrice, symbol, currency);
            }
        }
        tokenPrice = this.getCachedSymbolPrice(symbol, currency);
        if (tokenPrice === undefined) {
            throw new CoingeckoPriceNotFoundError({ identifier: symbol, currency, lookupType: "symbol" });
        }
        return [tokenPrice.timestamp.toString(), tokenPrice.price];
    }
    // Return an array of spot prices for an array of collateral addresses in one async call. Note we might in future
    // This was adapted from packages/merkle-distributor/kpi-options-helpers/calculate-uma-tvl.ts
    async getContractPrices(addresses, currency = "usd", platform_id = "ethereum") {
        const priceCache = this.getPriceCache(currency, platform_id);
        // Pre-populate price cache with requested token addresses
        addresses.forEach((addr) => {
            if (priceCache[addr] === undefined) {
                priceCache[addr] = { address: addr, price: 0, timestamp: 0 };
            }
        });
        // Collect all known token addresses (requested + other cached).
        const contract_addresses = Object.keys(priceCache);
        assert(contract_addresses.length > 0, "Must supply at least 1 contract address");
        this.logger.debug({
            at: "Coingecko#getContractPrices",
            message: `Updating ${platform_id}/${currency} token prices.`,
            tokens: contract_addresses,
        });
        let result = {};
        try {
            // Coingecko expects a comma-delimited (%2c) list.
            result = await this.call(`simple/token_price/${encodeURIComponent(platform_id)}?contract_addresses=${contract_addresses
                .map((addr) => encodeURIComponent(addr))
                .join("%2C")}&vs_currencies=${encodeURIComponent(currency)}&include_last_updated_at=true`);
        }
        catch (err) {
            const errMsg = `Failed to retrieve ${platform_id}/${currency} prices (${err})`;
            this.logger.debug({
                at: "Coingecko#getContractPrices",
                message: errMsg,
                tokens: contract_addresses,
            });
            throw new Error(errMsg);
        }
        // Note: contract_addresses is a reliable reference for the price lookup.
        // priceCache might have been updated subsequently by concurrent price requests.
        contract_addresses.forEach((addr) => {
            const cgPrice = result[addr.toLowerCase()];
            if (cgPrice === undefined) {
                this.logger.debug({
                    at: "Coingecko#getContractPrices",
                    message: `Token ${addr} not included in CoinGecko response.`,
                });
            }
            else {
                this.updatePriceCache(cgPrice, addr, currency, platform_id);
            }
        });
        return addresses.map((addr) => priceCache[addr]);
    }
    getPlatforms() {
        return this.call("asset_platforms");
    }
    call(path) {
        const sendRequest = async () => {
            const { proHost } = this;
            // If no pro api key, only send basic request:
            if (this.apiKey === undefined) {
                return await this._callBasic(path);
            }
            // If pro api key, try basic and use pro as fallback.
            try {
                return await this._callBasic(path, this.basicApiTimeout);
            }
            catch (err) {
                this.logger.debug({
                    at: "sdk/coingecko",
                    message: `Basic CG url request failed, falling back to CG PRO host ${proHost}`,
                    errMessage: err.message,
                });
                return await this._callPro(path);
            }
        };
        // Note: If a pro API key is configured, there is no need to retry as the Pro API will act as the basic's fall back.
        return retry(sendRequest, this.apiKey === undefined ? this.numRetries : 0, this.retryDelay);
    }
    getPriceCache(currency, platform_id) {
        if (this.prices[platform_id] === undefined)
            this.prices[platform_id] = {};
        if (this.prices[platform_id][currency] === undefined)
            this.prices[platform_id][currency] = {};
        return this.prices[platform_id][currency];
    }
    getPriceCacheBySymbol(symbol) {
        if (this.pricesBySymbol[symbol] === undefined)
            this.pricesBySymbol[symbol] = {};
        return this.pricesBySymbol[symbol];
    }
    getCachedAddressPrice(contractAddress, currency, platform_id) {
        const priceCache = this.getPriceCache(currency, platform_id);
        const now = msToS(Date.now());
        const tokenPrice = priceCache[contractAddress];
        if (tokenPrice === undefined || tokenPrice.timestamp + this.maxPriceAge <= now) {
            if (this.maxPriceAge > 0) {
                this.logger.debug({
                    at: "Coingecko#getCachedAddressPrice",
                    message: `Cache miss on ${platform_id}/${currency} for ${contractAddress}`,
                    maxPriceAge: this.maxPriceAge,
                    tokenPrice: tokenPrice,
                });
            }
            return undefined;
        }
        else {
            this.logger.debug({
                at: "Coingecko#getCachedAddressPrice",
                message: `Cache hit on token ${contractAddress} (age ${now - tokenPrice.timestamp} S).`,
                price: tokenPrice,
            });
            return tokenPrice;
        }
    }
    getCachedSymbolPrice(symbol, currency) {
        const priceCache = this.getPriceCacheBySymbol(symbol);
        const now = msToS(Date.now());
        const tokenPrice = priceCache[currency];
        if (tokenPrice === undefined || tokenPrice.timestamp + this.maxPriceAge <= now) {
            return undefined;
        }
        return tokenPrice;
    }
    updatePriceCache(cgPrice, contractAddress, currency, platform_id) {
        const priceCache = this.getPriceCache(currency, platform_id);
        if (priceCache[contractAddress] === undefined) {
            priceCache[contractAddress] = { address: contractAddress, price: 0, timestamp: 0 };
        }
        if (cgPrice.last_updated_at > priceCache[contractAddress].timestamp) {
            priceCache[contractAddress] = {
                address: contractAddress,
                price: cgPrice[currency],
                timestamp: cgPrice.last_updated_at,
            };
            this.logger.debug({
                at: "Coingecko#updatePriceCache",
                message: `Updated ${platform_id}/${currency}/${contractAddress} token price cache.`,
            });
        }
        else {
            this.logger.debug({
                at: "Coingecko#updatePriceCache",
                message: `No new price available for token ${contractAddress}.`,
                token: cgPrice,
            });
        }
    }
    updatePriceCacheBySymbol(cgPrice, symbol, currency) {
        const priceCache = this.getPriceCacheBySymbol(symbol);
        if (priceCache[currency] === undefined) {
            priceCache[currency] = { price: 0, timestamp: 0 };
        }
        if (cgPrice.last_updated_at > priceCache[currency].timestamp) {
            priceCache[currency] = {
                price: cgPrice[currency],
                timestamp: cgPrice.last_updated_at,
            };
            this.logger.debug({
                at: "Coingecko#updatePriceCacheBySymbol",
                message: `Updated ${symbol}/${currency} token price cache.`,
            });
        }
        else {
            this.logger.debug({
                at: "Coingecko#updatePriceCacheBySymbol",
                message: `No new price available for symbol ${symbol}.`,
                token: cgPrice,
            });
        }
    }
    async _callBasic(path, timeout) {
        const url = `${this.host}/${path}`;
        // Don't use timeout if there is no pro API to fallback to.
        return await fetchWithTimeout(url, {}, {}, timeout);
    }
    async _callPro(path) {
        const url = `${this.proHost}/${path}`;
        return await fetchWithTimeout(url, { x_cg_pro_api_key: this.apiKey });
    }
}
//# sourceMappingURL=Coingecko.js.map