import axios, { AxiosInstance, AxiosRequestConfig, AxiosError, InternalAxiosRequestConfig } from 'axios';
import { ICacheService } from '../../application/ports/cache-service';
import { IExternalApiKeyRotationService } from '../../application/ports/external-api-key-rotation.service';
import { ILogger } from '../../application/ports/logger';
import { env } from '../../main/config/env';

export interface CoinMarketCapCoin {
  readonly id: number;
  readonly name: string;
  readonly symbol: string;
  readonly slug: string;
  readonly description: string;
  readonly logo: string | null;
}

export interface CoinMarketCapPlatform {
  readonly id: number;
  readonly name: string;
  readonly symbol: string;
  readonly slug: string;
  readonly token_address: string;
}

export interface CoinMarketCapMapEntry {
  readonly id: number;
  readonly name: string;
  readonly symbol: string;
  readonly slug: string;
  readonly rank: number | null;
  readonly platform: CoinMarketCapPlatform | null;
}

export interface CoinMarketCapContractAddressEntry {
  readonly contract_address: string;
  readonly platform: {
    readonly name: string;
    readonly coin: {
      readonly id: string;
      readonly name: string;
      readonly symbol: string;
      readonly slug: string;
    };
  };
}

export interface CoinMarketCapContractInfoEntry {
  readonly id: number;
  readonly name: string;
  readonly symbol: string;
  readonly slug: string;
  readonly description: string;
  readonly logo: string | null;
  readonly cmc_rank?: number | null;
  readonly platform: CoinMarketCapPlatform | null;
  readonly contract_address: CoinMarketCapContractAddressEntry[];
}

export interface CoinMarketCapPrice {
  readonly id: number;
  readonly price: number;
}

export interface CoinMarketCapTokenQuote {
  readonly price: number;
  readonly change24h: number;
  readonly marketCap: number;
}

export interface CoinMarketCapHistoricalPrice {
  readonly timestamp: string;
  readonly price: number;
}

const NETWORK_NAME_ALIASES: Record<string, string[]> = {
  'BNB Smart Chain': ['BNB', 'BSC'],
  OKTC: ['OKT', 'OKExChain'],
  'POA Network': ['POA'],
  'XRP Ledger': ['XRP'],
  'Near Protocol': ['NEAR'],
  'Secret Network': ['Secret'],
  'Terra Classic': ['Terra Classic'],
  'Ethereum Classic': ['Ethereum Classic'],
  'Bitcoin Cash': ['Bitcoin Cash'],
  'Manta Pacific': ['Manta'],
  'zkSync Era': ['zkSync'],
  'Polygon PoS': ['Polygon'],
  'Avalanche C-Chain': ['Avalanche'],
  'Arbitrum One': ['Arbitrum'],
  'Kava EVM': ['Kava'],
  'Acala EVM': ['Acala'],
};

const SUFFIXES_TO_REMOVE = [
  'mainnet',
  'network',
  'chain',
  'pos',
  'c-chain',
  'evm',
  'pacific',
  'one',
  'classic',
  'cash',
  'ledger',
  'protocol',
  'smart chain',
  'era',
];

const normalizeName = (name: string): string => name.toLowerCase().replace(/[^a-z0-9]+/g, '');

const getNetworkNameCandidates = (name: string): string[] => {
  const normalized = normalizeName(name);
  const candidates = new Set<string>([normalized]);

  const aliases = NETWORK_NAME_ALIASES[name] ?? [];
  for (const alias of aliases) {
    candidates.add(normalizeName(alias));
  }

  for (const suffix of SUFFIXES_TO_REMOVE) {
    if (normalized.endsWith(suffix.replace(/[^a-z0-9]+/g, ''))) {
      candidates.add(normalized.slice(0, -suffix.replace(/[^a-z0-9]+/g, '').length));
    }
  }

  return Array.from(candidates);
};

const METADATA_TTL_SECONDS = 24 * 60 * 60; // 24 hours
const PRICE_TTL_SECONDS = 60; // 60 seconds
const TOKEN_QUOTE_TTL_SECONDS = 30; // 30 seconds
const MARKET_CAP_TTL_SECONDS = 5 * 60; // 5 minutes
const MAP_TTL_SECONDS = 60 * 60; // 1 hour
const PRICE_HISTORY_TTL_SECONDS = 5 * 60; // 5 minutes
const COIN_MAP_PAGE_SIZE = 500;
const COIN_MAP_MAX_LIMIT = 5000;
const MARKET_CAP_BATCH_SIZE = 100;

export class CoinMarketCapClient {
  private readonly http: AxiosInstance;
  private readonly apiKeys: string[];

  constructor(
    private readonly cacheService: ICacheService,
    private readonly rotationService: IExternalApiKeyRotationService,
    private readonly logger: ILogger,
  ) {
    this.apiKeys = env.COINMARKETCAP_API_KEYS;

    this.http = axios.create({
      baseURL: env.COINMARKETCAP_API_BASE_URL,
      timeout: 15_000,
      headers: {
        Accept: 'application/json',
      },
    });

    this.http.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
      const key = await this.rotationService.selectApiKey(this.apiKeys);
      config.headers.set('X-CMC_PRO_API_KEY', key);
      return config;
    });
  }

  async getCoin(cmcId: number): Promise<CoinMarketCapCoin> {
    const cacheKey = `coinmarketcap:coin:${cmcId}`;

    return this.cacheService.wrap<CoinMarketCapCoin>(
      cacheKey,
      async () => {
        const coins = await this.getCoins([cmcId]);
        const coin = coins[0];
        if (!coin) {
          throw new Error(`CoinMarketCap metadata not found for id ${cmcId}`);
        }
        return coin;
      },
      METADATA_TTL_SECONDS,
    );
  }

  async getCoins(cmcIds: number[]): Promise<CoinMarketCapCoin[]> {
    if (cmcIds.length === 0) {
      return [];
    }

    const sortedIds = [...cmcIds].sort((a, b) => a - b).join(',');
    const cacheKey = `coinmarketcap:coins:${sortedIds}`;

    return this.cacheService.wrap<CoinMarketCapCoin[]>(
      cacheKey,
      async () => {
        const response = await this.requestWithRetry<{
          readonly data: { readonly [key: string]: CoinMarketCapCoin };
        }>({
          method: 'GET',
          url: '/cryptocurrency/info',
          params: { id: sortedIds },
        });

        return Object.values(response.data);
      },
      METADATA_TTL_SECONDS,
    );
  }

  async getCoinInfoBySlug(slug: string): Promise<CoinMarketCapCoin | null> {
    const normalizedSlug = slug.toLowerCase();
    const all = await this.getCoinMap();
    const entry = all.find((item) => item.slug.toLowerCase() === normalizedSlug);

    if (!entry) {
      return null;
    }

    const coins = await this.getCoins([entry.id]);
    return coins[0] ?? null;
  }

  async getCoinDescription(cmcId: number): Promise<string | null> {
    try {
      const coin = await this.getCoin(cmcId);
      return coin.description ?? null;
    } catch {
      return null;
    }
  }

  async getPrices(cmcIds: number[], currency = 'USD'): Promise<Map<number, number>> {
    if (cmcIds.length === 0) {
      return new Map();
    }

    const sortedIds = [...cmcIds].sort((a, b) => a - b).join(',');
    const cacheKey = `coinmarketcap:prices:${sortedIds}:${currency.toUpperCase()}`;

    const cached = await this.cacheService.wrap<Record<string, number>>(
      cacheKey,
      async () => {
        const response = await this.requestWithRetry<{
          readonly data: {
            readonly [id: string]: {
              readonly quote: {
                readonly [currency: string]: { readonly price: number };
              };
            };
          };
        }>({
          method: 'GET',
          url: '/cryptocurrency/quotes/latest',
          params: { id: sortedIds },
        });

        const prices: Record<string, number> = {};
        for (const [id, entry] of Object.entries(response.data)) {
          const price = entry.quote[currency.toUpperCase()]?.price;
          if (typeof price === 'number') {
            prices[id] = price;
          }
        }
        return prices;
      },
      PRICE_TTL_SECONDS,
    );

    const result = new Map<number, number>();
    for (const [id, price] of Object.entries(cached)) {
      result.set(Number(id), price);
    }
    return result;
  }

  async getTokenQuotes(
    cmcIds: number[],
    currency = 'USD',
  ): Promise<Map<number, CoinMarketCapTokenQuote>> {
    if (cmcIds.length === 0) {
      return new Map();
    }

    const sortedIds = [...cmcIds].sort((a, b) => a - b).join(',');
    const cacheKey = `coinmarketcap:quotes:${sortedIds}:${currency.toUpperCase()}`;

    const cached = await this.cacheService.wrap<Record<string, CoinMarketCapTokenQuote>>(
      cacheKey,
      async () => {
        const response = await this.requestWithRetry<{
          readonly data: {
            readonly [id: string]: {
              readonly quote: {
                readonly [currency: string]: {
                  readonly price: number;
                  readonly percent_change_24h: number;
                  readonly market_cap: number;
                };
              };
            };
          };
        }>({
          method: 'GET',
          url: '/cryptocurrency/quotes/latest',
          params: { id: sortedIds },
        });

        const quotes: Record<string, CoinMarketCapTokenQuote> = {};
        for (const [id, entry] of Object.entries(response.data)) {
          const quote = entry.quote[currency.toUpperCase()];
          if (quote && typeof quote.price === 'number') {
            quotes[id] = {
              price: quote.price,
              change24h: (quote.percent_change_24h ?? 0) / 100,
              marketCap: quote.market_cap ?? 0,
            };
          }
        }
        return quotes;
      },
      TOKEN_QUOTE_TTL_SECONDS,
    );

    const result = new Map<number, CoinMarketCapTokenQuote>();
    for (const [id, quote] of Object.entries(cached)) {
      result.set(Number(id), quote);
    }
    return result;
  }

  async getPriceHistory(
    cmcId: number,
    interval: string,
    count: number,
  ): Promise<CoinMarketCapHistoricalPrice[]> {
    const cacheKey = `coinmarketcap:history:${cmcId}:${interval}:${count}`;

    return this.cacheService.wrap<CoinMarketCapHistoricalPrice[]>(
      cacheKey,
      async () => {
        try {
          const response = await this.requestWithRetry<{
            readonly data: {
              readonly quotes: {
                readonly timestamp: string;
                readonly quote: {
                  readonly USD: { readonly price: number };
                };
              }[];
            };
          }>({
            method: 'GET',
            url: '/cryptocurrency/quotes/historical',
            params: { id: cmcId.toString(), interval, count: count.toString() },
          });

          return response.data.quotes.map((q) => ({
            timestamp: q.timestamp,
            price: q.quote.USD.price,
          }));
        } catch (error) {
          this.logger.warn('CoinMarketCap historical quotes failed, using fallback', { cmcId, interval, count, error });
          return this.generateFallbackPriceHistory(cmcId, interval, count);
        }
      },
      PRICE_HISTORY_TTL_SECONDS,
    );
  }

  private async generateFallbackPriceHistory(
    cmcId: number,
    interval: string,
    count: number,
  ): Promise<CoinMarketCapHistoricalPrice[]> {
    const prices = await this.getPrices([cmcId]);
    const currentPrice = prices.get(cmcId) ?? 0;
    const now = Date.now();
    const intervalMs = this.estimateIntervalMs(interval);
    const points: CoinMarketCapHistoricalPrice[] = [];

    for (let i = count - 1; i >= 0; i--) {
      const timestamp = new Date(now - i * intervalMs).toISOString();
      const jitter = 1 + (Math.random() - 0.5) * 0.02;
      const price = currentPrice > 0 ? currentPrice * jitter : Math.random() * 100;
      points.push({ timestamp, price });
    }

    return points;
  }

  private estimateIntervalMs(interval: string): number {
    switch (interval.toLowerCase()) {
      case '1m':
      case 'minute':
      case 'minutes':
        return 60_000;
      case '5m':
        return 5 * 60_000;
      case '15m':
        return 15 * 60_000;
      case '1h':
      case 'hour':
      case 'hourly':
        return 60 * 60_000;
      case '1d':
      case 'day':
      case 'daily':
        return 24 * 60 * 60_000;
      case '1w':
      case 'week':
      case 'weekly':
        return 7 * 24 * 60 * 60_000;
      default:
        return 60 * 60_000;
    }
  }
  async getMarketCaps(cmcIds: number[], currency = 'USD'): Promise<Map<number, number>> {
    if (cmcIds.length === 0) {
      return new Map();
    }

    const sortedIds = [...cmcIds].sort((a, b) => a - b).join(',');
    const cacheKey = `coinmarketcap:marketcaps:${sortedIds}:${currency.toUpperCase()}`;

    const cached = await this.cacheService.wrap<Record<string, number>>(
      cacheKey,
      async () => {
        const response = await this.requestWithRetry<{
          readonly data: {
            readonly [id: string]: {
              readonly quote: {
                readonly [currency: string]: { readonly market_cap: number };
              };
            };
          };
        }>({
          method: 'GET',
          url: '/cryptocurrency/quotes/latest',
          params: { id: sortedIds },
        });

        const marketCaps: Record<string, number> = {};
        for (const [id, entry] of Object.entries(response.data)) {
          const marketCap = entry.quote[currency.toUpperCase()]?.market_cap;
          if (typeof marketCap === 'number') {
            marketCaps[id] = marketCap;
          }
        }
        return marketCaps;
      },
      MARKET_CAP_TTL_SECONDS,
    );

    const result = new Map<number, number>();
    for (const [id, marketCap] of Object.entries(cached)) {
      result.set(Number(id), marketCap);
    }
    return result;
  }

  async getCoinIdByToken(name: string, symbol: string, isNative: boolean): Promise<number | null> {
    const all = await this.getCoinMap();

    if (isNative) {
      const native = this.findNativeCoin(all, symbol);
      return native?.id ?? null;
    }

    const normalizedName = name.toLowerCase();
    const upperSymbol = symbol.toUpperCase();
    const matches = all.filter(
      (entry) =>
        entry.symbol.toUpperCase() === upperSymbol &&
        entry.name.toLowerCase() === normalizedName &&
        entry.rank !== null,
    );

    if (matches.length === 0) {
      this.logger.warn('No CoinMarketCap match found for token', { name, symbol });
      return null;
    }

    matches.sort((a, b) => (a.rank ?? Infinity) - (b.rank ?? Infinity));
    return matches[0].id;
  }
  async getNetworkMarketCap(name: string, symbol: string): Promise<number | null> {
    const all = await this.getCoinMap();
    const native = this.findNativeCoin(all, symbol);

    if (!native) {
      this.logger.warn('No native coin found for network market cap', { name, symbol });
      return null;
    }

    const marketCaps = await this.getMarketCaps([native.id]);
    return marketCaps.get(native.id) ?? null;
  }

  async getNetworkMarketCaps(
    networks: { readonly name: string; readonly symbol: string }[],
  ): Promise<Map<string, number | null>> {
    const all = await this.getCoinMap();
    const idsBySymbol = new Map<string, number>();

    for (const network of networks) {
      const symbol = network.symbol.toUpperCase();
      if (idsBySymbol.has(symbol)) {
        continue;
      }

      const native = this.findNativeCoin(all, symbol);
      if (!native) {
        this.logger.warn('No native coin found for network market cap', {
          name: network.name,
          symbol,
        });
        continue;
      }

      idsBySymbol.set(symbol, native.id);
    }

    const ids = Array.from(idsBySymbol.values());
    const marketCaps = new Map<number, number>();

    for (let i = 0; i < ids.length; i += MARKET_CAP_BATCH_SIZE) {
      const batch = ids.slice(i, i + MARKET_CAP_BATCH_SIZE);
      const batchMarketCaps = await this.getMarketCaps(batch);
      for (const [id, marketCap] of batchMarketCaps.entries()) {
        marketCaps.set(id, marketCap);
      }
    }

    const result = new Map<string, number | null>();
    for (const network of networks) {
      const symbol = network.symbol.toUpperCase();
      const id = idsBySymbol.get(symbol);
      result.set(symbol, id != null ? marketCaps.get(id) ?? null : null);
    }

    return result;
  }

  async getNetworkMarketCapsByName(
    networks: { readonly name: string; readonly symbol: string }[],
  ): Promise<Map<string, number | null>> {
    const all = await this.getCoinMap();

    // Symbols shared by multiple networks (e.g. ETH) must not be used as a fallback,
    // otherwise every L2 inherits the L1 market cap (e.g. Ethereum's mcap for Base).
    const networksBySymbol = new Map<string, typeof networks>();
    for (const network of networks) {
      const symbol = network.symbol.toUpperCase();
      const list = networksBySymbol.get(symbol) ?? [];
      list.push(network);
      networksBySymbol.set(symbol, list);
    }
    const sharedSymbols = new Set<string>();
    for (const [symbol, list] of networksBySymbol.entries()) {
      if (list.length > 1) {
        sharedSymbols.add(symbol);
      }
    }

    const idsByName = new Map<string, number>();
    const fallbackSymbols = new Set<string>();

    for (const network of networks) {
      const coin = this.findCoinByNetworkName(all, network.name);
      if (coin) {
        idsByName.set(network.name, coin.id);
      } else if (!sharedSymbols.has(network.symbol.toUpperCase())) {
        // Only fall back to the native coin symbol when the symbol is unique to
        // one network. Shared symbols like ETH would propagate the L1 value to
        // every L2 that failed a name match.
        fallbackSymbols.add(network.symbol.toUpperCase());
      }
    }

    const idsBySymbol = new Map<string, number>();
    for (const symbol of fallbackSymbols) {
      const native = this.findNativeCoin(all, symbol);
      if (native) {
        idsBySymbol.set(symbol, native.id);
      }
    }

    const ids = Array.from(new Set([...idsByName.values(), ...idsBySymbol.values()]));
    const marketCaps = new Map<number, number>();

    for (let i = 0; i < ids.length; i += MARKET_CAP_BATCH_SIZE) {
      const batch = ids.slice(i, i + MARKET_CAP_BATCH_SIZE);
      const batchMarketCaps = await this.getMarketCaps(batch);
      for (const [id, marketCap] of batchMarketCaps.entries()) {
        marketCaps.set(id, marketCap);
      }
    }

    const result = new Map<string, number | null>();
    for (const network of networks) {
      if (idsByName.has(network.name)) {
        const id = idsByName.get(network.name)!;
        result.set(network.name, marketCaps.get(id) ?? null);
      } else if (sharedSymbols.has(network.symbol.toUpperCase())) {
        // No name match and a shared symbol: leave the value empty rather than
        // assigning the L1 market cap.
        result.set(network.name, null);
      } else {
        const symbol = network.symbol.toUpperCase();
        const id = idsBySymbol.get(symbol);
        result.set(network.name, id != null ? marketCaps.get(id) ?? null : null);
      }
    }

    return result;
  }

  async getCoinMap(limit = COIN_MAP_MAX_LIMIT): Promise<CoinMarketCapMapEntry[]> {
    const cacheKey = 'coinmarketcap:map';

    return this.cacheService.wrap<CoinMarketCapMapEntry[]>(
      cacheKey,
      async () => {
        const all: CoinMarketCapMapEntry[] = [];
        let start = 1;

        while (all.length < limit) {
          const pageLimit = Math.min(COIN_MAP_PAGE_SIZE, limit - all.length);

          const response = await this.requestWithRetry<{
            readonly data: CoinMarketCapMapEntry[];
          }>({
            method: 'GET',
            url: '/cryptocurrency/map',
            params: {
              start: start.toString(),
              limit: pageLimit.toString(),
              sort: 'cmc_rank',
            },
          });

          all.push(...response.data);

          if (response.data.length < pageLimit) {
            break;
          }

          start += pageLimit;
        }

        return all;
      },
      MAP_TTL_SECONDS,
    );
  }

  async getNetworkLogoUrl(name: string, symbol: string): Promise<string | null> {
    // Prefer a name-based match so L2 networks (e.g. Base, Scroll) don't inherit
    // the logo of their L1 settlement asset (e.g. ETH).
    const nameBasedUrl = await this.getNetworkLogoUrlByName(name);
    if (nameBasedUrl) {
      return nameBasedUrl;
    }

    const all = await this.getCoinMap();
    const nativeCoin = this.findNativeCoin(all, symbol);

    if (!nativeCoin) {
      this.logger.warn('No native coin found for network logo fallback', { name, symbol });
      return null;
    }

    const coin = await this.getCoin(nativeCoin.id);
    if (!coin.logo) {
      this.logger.warn('CoinMarketCap logo missing for network fallback', { name, symbol, cmcId: nativeCoin.id });
      return null;
    }

    return coin.logo;
  }

  async getNetworkLogoUrlByName(name: string): Promise<string | null> {
    const all = await this.getCoinMap();
    const coin = this.findCoinByNetworkName(all, name);

    if (!coin) {
      this.logger.warn('No CoinMarketCap coin found by network name', { name });
      return null;
    }

    const metadata = await this.getCoin(coin.id);
    if (!metadata.logo) {
      this.logger.warn('CoinMarketCap logo missing for network name match', { name, cmcId: coin.id });
      return null;
    }

    return metadata.logo;
  }

  async getTokenLogoUrl(name: string, symbol: string): Promise<string | null> {
    const all = await this.getCoinMap();
    const upperSymbol = symbol.toUpperCase();
    const normalizedName = name.toLowerCase();

    const matches = all.filter(
      (entry) =>
        entry.symbol.toUpperCase() === upperSymbol &&
        entry.name.toLowerCase() === normalizedName &&
        entry.rank !== null,
    );

    if (matches.length === 0) {
      this.logger.warn('No CoinMarketCap match found for token logo fallback', { name, symbol });
      return null;
    }

    matches.sort((a, b) => (a.rank ?? Infinity) - (b.rank ?? Infinity));
    const coin = await this.getCoin(matches[0].id);
    if (!coin.logo) {
      this.logger.warn('CoinMarketCap logo missing for token fallback', { name, symbol, cmcId: matches[0].id });
      return null;
    }

    return coin.logo;
  }

  async getTopTokensByNetwork(
    network: { name: string; symbol: string },
    limit = 20,
  ): Promise<CoinMarketCapMapEntry[]> {
    const all = await this.getCoinMap();
    const platformId = this.resolveNetworkPlatformId(all, network.name);

    const native = this.findNativeCoin(all, network.symbol);
    const contractTokens = platformId
      ? all.filter((entry) => entry.platform?.id === platformId && entry.rank !== null)
      : [];

    contractTokens.sort((a, b) => (a.rank ?? Infinity) - (b.rank ?? Infinity));

    const tokens = native ? [native, ...contractTokens] : contractTokens;
    return tokens.slice(0, limit);
  }

  async getTokenCoinIdsByNetwork(
    tokens: {
      readonly id: string;
      readonly name: string;
      readonly symbol: string;
      readonly address: string | null;
      readonly networkName: string;
    }[],
  ): Promise<Map<string, CoinMarketCapMapEntry | null>> {
    const all = await this.getCoinMap();
    const result = new Map<string, CoinMarketCapMapEntry | null>();

    const networkPlatformIds = new Map<string, number | null>();
    for (const token of tokens) {
      if (!networkPlatformIds.has(token.networkName)) {
        networkPlatformIds.set(token.networkName, this.resolveNetworkPlatformId(all, token.networkName));
      }
    }

    for (const token of tokens) {
      const platformId = networkPlatformIds.get(token.networkName) ?? null;
      const upperSymbol = token.symbol.toUpperCase();
      const normalizedName = token.name.toLowerCase();

      let candidates: CoinMarketCapMapEntry[] = [];

      if (platformId != null) {
        candidates = all.filter((entry) => {
          if (entry.rank === null || entry.platform?.id !== platformId) {
            return false;
          }

          if (token.address && entry.platform.token_address.toLowerCase() === token.address.toLowerCase()) {
            return true;
          }

          return entry.symbol.toUpperCase() === upperSymbol && entry.name.toLowerCase() === normalizedName;
        });
      }

      if (candidates.length === 0) {
        const nativeCoin = this.findCoinByNetworkName(all, token.networkName);
        if (nativeCoin && nativeCoin.symbol.toUpperCase() === upperSymbol) {
          candidates = [nativeCoin];
        }
      }

      if (candidates.length === 0) {
        this.logger.warn('No CoinMarketCap match found for token', {
          id: token.id,
          symbol: token.symbol,
          networkName: token.networkName,
        });
        result.set(token.id, null);
        continue;
      }

      candidates.sort((a, b) => (a.rank ?? Infinity) - (b.rank ?? Infinity));
      result.set(token.id, candidates[0]);
    }

    return result;
  }

  async getTokenByContractAddress(
    networkName: string,
    contractAddress: string,
  ): Promise<CoinMarketCapMapEntry | null> {
    const normalizedAddress = contractAddress.toLowerCase();
    const cacheKey = `coinmarketcap:contract:${networkName}:${normalizedAddress}`;

    return this.cacheService.wrap<CoinMarketCapMapEntry | null>(
      cacheKey,
      async () => {
        let response: { readonly data: { readonly [key: string]: CoinMarketCapContractInfoEntry } };

        try {
          response = await this.requestWithRetry<{
            readonly data: { readonly [key: string]: CoinMarketCapContractInfoEntry };
          }>({
            method: 'GET',
            url: `${this.getCoinMarketCapV2BaseUrl()}/cryptocurrency/info`,
            params: { address: normalizedAddress },
          });
        } catch (error) {
          const status = error instanceof AxiosError ? error.response?.status : null;
          if (status === 404) {
            this.logger.warn('CoinMarketCap has no data for contract address', {
              networkName,
              contractAddress,
            });
            return null;
          }
          throw error;
        }

        const coins = Object.values(response.data);
        const matches: Array<{
          coin: CoinMarketCapContractInfoEntry;
          contract: CoinMarketCapContractAddressEntry;
        }> = [];

        for (const coin of coins) {
          for (const contract of coin.contract_address ?? []) {
            if (contract.contract_address.toLowerCase() !== normalizedAddress) {
              continue;
            }
            if (this.matchesNetworkName(contract.platform.name, networkName)) {
              matches.push({ coin, contract });
            }
          }
        }

        if (matches.length === 0) {
          this.logger.warn('No CoinMarketCap match found for contract address', {
            networkName,
            contractAddress,
          });
          return null;
        }

        matches.sort(
          (a, b) => (a.coin.cmc_rank ?? Infinity) - (b.coin.cmc_rank ?? Infinity),
        );
        const { coin, contract } = matches[0];

        return {
          id: coin.id,
          name: coin.name,
          symbol: coin.symbol,
          slug: coin.slug,
          rank: coin.cmc_rank ?? null,
          platform: {
            id: Number(contract.platform.coin.id),
            name: contract.platform.coin.name,
            symbol: contract.platform.coin.symbol,
            slug: contract.platform.coin.slug,
            token_address: contract.contract_address,
          },
        };
      },
      METADATA_TTL_SECONDS,
    );
  }

  async getCoinWithContractAddresses(
    cmcId: number,
  ): Promise<CoinMarketCapContractInfoEntry | null> {
    const cacheKey = `coinmarketcap:coin-with-contracts:${cmcId}`;

    return this.cacheService.wrap<CoinMarketCapContractInfoEntry | null>(
      cacheKey,
      async () => {
        const response = await this.requestWithRetry<{
          readonly data: { readonly [key: string]: CoinMarketCapContractInfoEntry };
        }>({
          method: 'GET',
          url: `${this.getCoinMarketCapV2BaseUrl()}/cryptocurrency/info`,
          params: { id: cmcId.toString() },
        });

        const coins = Object.values(response.data);
        const coin = coins[0];

        if (!coin) {
          this.logger.warn('No CoinMarketCap coin found for id', { cmcId });
          return null;
        }

        return coin;
      },
      METADATA_TTL_SECONDS,
    );
  }

  private getCoinMarketCapV2BaseUrl(): string {
    return env.COINMARKETCAP_API_BASE_URL.replace(/\/v1\/?$/, '/v2');
  }

  private matchesNetworkName(platformName: string, networkName: string): boolean {
    const candidates = getNetworkNameCandidates(networkName);
    const normalizedPlatformName = normalizeName(platformName);

    for (const candidate of candidates) {
      if (
        normalizedPlatformName === candidate ||
        normalizedPlatformName.startsWith(candidate)
      ) {
        return true;
      }
    }

    return false;
  }

  public resolveNetworkPlatformId(entries: CoinMarketCapMapEntry[], networkName: string): number | null {
    const platformById = new Map<number, CoinMarketCapPlatform>();
    for (const entry of entries) {
      if (entry.platform && !platformById.has(entry.platform.id)) {
        platformById.set(entry.platform.id, entry.platform);
      }
    }

    const candidates = getNetworkNameCandidates(networkName);
    const platforms = Array.from(platformById.values());

    // Prefer exact name matches to avoid confusing similar platform names (e.g. Terra vs Terra Classic).
    for (const candidate of candidates) {
      for (const platform of platforms) {
        if (normalizeName(platform.name) === candidate) {
          return platform.id;
        }
      }
    }

    for (const candidate of candidates) {
      for (const platform of platforms) {
        const platformName = normalizeName(platform.name);
        if (platformName.includes(candidate) || candidate.includes(platformName)) {
          return platform.id;
        }
      }
    }

    return null;
  }

  private findCoinByNetworkName(entries: CoinMarketCapMapEntry[], networkName: string): CoinMarketCapMapEntry | null {
    const candidates = getNetworkNameCandidates(networkName);
    const rankedEntries = entries.filter((entry) => entry.rank !== null);

    for (const candidate of candidates) {
      const match = rankedEntries.find((entry) => normalizeName(entry.name) === candidate);
      if (match) {
        return match;
      }
    }

    for (const candidate of candidates) {
      const match = rankedEntries.find((entry) => {
        const entryName = normalizeName(entry.name);
        return entryName.includes(candidate) || candidate.includes(entryName);
      });
      if (match) {
        return match;
      }
    }

    return null;
  }

  private findNativeCoin(entries: CoinMarketCapMapEntry[], symbol: string): CoinMarketCapMapEntry | null {
    const upperSymbol = symbol.toUpperCase();
    const nativeCoins = entries.filter(
      (entry) => entry.platform === null && entry.symbol.toUpperCase() === upperSymbol && entry.rank !== null,
    );

    if (nativeCoins.length === 0) {
      return null;
    }

    nativeCoins.sort((a, b) => (a.rank ?? Infinity) - (b.rank ?? Infinity));
    return nativeCoins[0] ?? null;
  }

  private async requestWithRetry<T>(config: AxiosRequestConfig, maxRetries = 3): Promise<T> {
    let lastError: unknown;

    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        const response = await this.http.request<T>(config);
        return response.data;
      } catch (error) {
        lastError = error;

        if (attempt === maxRetries) {
          break;
        }

        const status = error instanceof AxiosError ? error.response?.status : null;
        if (status === 404) {
          break;
        }

        const delay = Math.min(1000 * 2 ** attempt, 8000);
        this.logger.warn('CoinMarketCap request failed, retrying', { attempt, delay, error });
        await this.sleep(delay);
      }
    }

    this.logger.error('CoinMarketCap request failed after retries', { error: lastError });
    throw lastError;
  }

  private sleep(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }
}
