// CCXT data fetcher for historical OHLC and realtime ticks
import ccxt from 'ccxt';

/**
 * Thrown when an exchange returns a 429 rate-limit response.
 * retryAfterMs is derived from the exchange's Retry-After header when available.
 */
export class ExchangeRateLimitError extends Error {
  constructor(exchange, retryAfterMs, originalMessage) {
    super(`Rate limit on ${exchange}: retry after ${retryAfterMs}ms (${originalMessage})`);
    this.name = 'ExchangeRateLimitError';
    this.exchange = exchange.toUpperCase();
    this.retryAfterMs = retryAfterMs;
  }
}

/**
 * Extract retry-after duration in milliseconds from a CCXT RateLimitExceeded error.
 * Priority: Retry-After header → error message numeric → 30s fallback.
 */
function extractRetryAfterMs(exchange, error) {
  const header = exchange.last_response_headers?.['retry-after'];
  if (header) {
    const secs = parseFloat(header);
    if (!isNaN(secs)) return Math.ceil(secs * 1000);
  }
  // Some exchanges embed the delay in the message (e.g. "retry after 5000 ms")
  const msMatch = error.message?.match(/(\d+)\s*ms/i);
  if (msMatch) return parseInt(msMatch[1], 10);
  const secMatch = error.message?.match(/(\d+(?:\.\d+)?)\s*s(?:ec|econds?)?/i);
  if (secMatch) return Math.ceil(parseFloat(secMatch[1]) * 1000);
  return 30_000;
}

export class CCXTFetcher {
  constructor(config, logger, metadataGenerator = null) {
    this.config = config;
    this.logger = logger;
    this.exchanges = new Map();
    this.metadataGenerator = metadataGenerator;
    this.metadataCache = new Map(); // Cache metadata by ticker
  }

  /**
   * Parse ticker string to exchange and symbol
   * Expected format: "SYMBOL.EXCHANGE" (e.g., "BTC/USDT.BINANCE")
   */
  parseTicker(ticker) {
    const lastDot = ticker.lastIndexOf('.');
    if (lastDot === -1) {
      throw new Error(`Invalid ticker format: ${ticker}. Expected "SYMBOL.EXCHANGE"`);
    }

    return {
      exchange: ticker.slice(lastDot + 1).toLowerCase(),
      symbol: ticker.slice(0, lastDot)
    };
  }

  /**
   * Get metadata for a ticker (cached or generate on-the-fly)
   */
  async getMetadata(ticker) {
    // Check cache first
    if (this.metadataCache.has(ticker)) {
      return this.metadataCache.get(ticker);
    }

    // Generate metadata on-the-fly
    if (!this.metadataGenerator) {
      throw new Error('Metadata generator not available');
    }

    const { exchange: exchangeName, symbol } = this.parseTicker(ticker);
    const exchangeUpper = exchangeName.toUpperCase();
    const exchange = this.getExchange(exchangeName);

    // Load market info from CCXT
    this.logger.info({ exchangeName, symbol }, 'Loading markets for metadata');
    await exchange.loadMarkets();
    const market = exchange.market(symbol);

    if (!market) {
      throw new Error(`Market not found: ${symbol} on ${exchangeUpper}`);
    }

    // Convert to our metadata format
    const metadata = this.metadataGenerator.convertMarketToMetadata(exchangeUpper, symbol, market);

    // Cache it
    this.metadataCache.set(ticker, metadata);

    return metadata;
  }

  /**
   * Get or create CCXT exchange instance
   */
  getExchange(exchangeName) {
    if (this.exchanges.has(exchangeName)) {
      return this.exchanges.get(exchangeName);
    }

    // Create exchange instance
    const ExchangeClass = ccxt[exchangeName];
    if (!ExchangeClass) {
      throw new Error(`Unsupported exchange: ${exchangeName}`);
    }

    const exchange = new ExchangeClass({
      enableRateLimit: true,
      options: {
        defaultType: 'spot'
      }
    });

    this.exchanges.set(exchangeName, exchange);
    this.logger.info({ exchange: exchangeName }, 'Created CCXT exchange instance');

    return exchange;
  }

  /**
   * Fetch historical OHLC data
   * @param {string} ticker - Ticker in format "EXCHANGE:SYMBOL"
   * @param {string} startTime - Start time in microseconds
   * @param {string} endTime - End time in microseconds
   * @param {number} periodSeconds - OHLC period in seconds
   * @param {number} limit - Optional limit on number of candles
   * @returns {Promise<Array>} Array of OHLC candles
   */
  async fetchHistoricalOHLC(ticker, startTime, endTime, periodSeconds, limit) {
    const { exchange: exchangeName, symbol } = this.parseTicker(ticker);
    const exchange = this.getExchange(exchangeName);

    // Convert nanoseconds to milliseconds
    const startMs = Math.floor(parseInt(startTime) / 1_000_000);
    const endMs = Math.floor(parseInt(endTime) / 1_000_000);

    // Map period seconds to CCXT timeframe
    const timeframe = this.secondsToTimeframe(periodSeconds);

    const marketsLoaded = exchange.markets != null && Object.keys(exchange.markets).length > 0;
    this.logger.info(
      { ticker, timeframe, startMs, endMs, limit, marketsLoaded },
      'Fetching historical OHLC'
    );

    const fetchedCandles = [];
    let since = startMs;

    // Always page in fixed batches of 1000 regardless of any limit hint.
    // The caller's limit/countback is irrelevant to how much we need to fetch from the exchange.
    const PAGE_SIZE = 1000;

    const FETCH_RETRIES = 3;
    const FETCH_RETRY_DELAY_MS = 5000;

    while (since < endMs) {
      let candles;
      let lastError;
      for (let attempt = 1; attempt <= FETCH_RETRIES; attempt++) {
        try {
          candles = await exchange.fetchOHLCV(symbol, timeframe, since, PAGE_SIZE);
          lastError = null;
          break;
        } catch (error) {
          lastError = error;
          const isRateLimit = error.constructor?.name === 'RateLimitExceeded';
          const isRetryable = !isRateLimit && (
            error.constructor?.name === 'NetworkError' ||
            error.constructor?.name === 'RequestTimeout' ||
            error.constructor?.name === 'ExchangeNotAvailable'
          );
          this.logger.warn(
            {
              errorType: error.constructor?.name,
              error: error.message,
              errorUrl: error.url,
              ticker,
              since,
              attempt,
              retryable: isRetryable,
              rateLimit: isRateLimit
            },
            'OHLC fetch attempt failed'
          );
          if (isRateLimit || !isRetryable || attempt === FETCH_RETRIES) break;
          await exchange.sleep(FETCH_RETRY_DELAY_MS * attempt);
        }
      }
      if (lastError) {
        if (lastError.constructor?.name === 'RateLimitExceeded') {
          const retryAfterMs = extractRetryAfterMs(exchange, lastError);
          this.logger.warn({ ticker, retryAfterMs }, 'OHLC fetch rate-limited by exchange');
          throw new ExchangeRateLimitError(exchangeName, retryAfterMs, lastError.message);
        }
        this.logger.error(
          {
            errorType: lastError.constructor?.name,
            error: lastError.message,
            errorUrl: lastError.url,
            ticker,
            since,
            marketsLoaded: exchange.markets != null && Object.keys(exchange.markets).length > 0,
            stack: lastError.stack
          },
          'Error fetching OHLC'
        );
        throw lastError;
      }

      if (candles.length === 0) {
        break;
      }

      // Filter candles within the requested time range
      const filteredCandles = candles.filter(c => {
        const timestamp = c[0];
        return timestamp >= startMs && timestamp < endMs;  // endMs is exclusive
      });

      fetchedCandles.push(...filteredCandles);

      // Advance to next batch start
      const lastTimestamp = candles[candles.length - 1][0];
      since = lastTimestamp + (periodSeconds * 1000);

      if (since >= endMs) {
        break;
      }

      // Apply rate limiting
      await exchange.sleep(exchange.rateLimit);
    }

    // Get metadata for proper denomination
    const metadata = await this.getMetadata(ticker);

    // Build a map of fetched candles by timestamp (ms)
    const fetchedByTs = new Map(fetchedCandles.map(c => [c[0], c]));

    if (fetchedCandles.length === 0) {
      // No data from exchange — return empty so caller writes a NOT_FOUND marker.
      return [];
    }

    const periodMs = periodSeconds * 1000;

    // Forward-fill interior gaps — periods between the first and last real bar
    // where the exchange returned no candle. Edge gaps (before firstRealTs or
    // after lastRealTs) are left absent; they'll be caught by gap detection and
    // trigger a targeted backfill request.
    const realTimestamps = [...fetchedByTs.keys()].sort((a, b) => a - b);
    const firstRealTs = realTimestamps[0];
    const lastRealTs  = realTimestamps[realTimestamps.length - 1];

    const allCandles = [];
    let gapCount = 0;
    let prevClose = null;

    for (let ts = firstRealTs; ts <= lastRealTs; ts += periodMs) {
      if (fetchedByTs.has(ts)) {
        const bar = this.convertToOHLC(fetchedByTs.get(ts), ticker, periodSeconds, metadata);
        prevClose = bar.close;
        allCandles.push(bar);
      } else if (prevClose !== null) {
        // Interior gap — forward-fill with previous close, zero volume
        gapCount++;
        allCandles.push({
          ticker,
          timestamp:  (ts * 1_000_000).toString(),
          open:  prevClose,
          high:  prevClose,
          low:   prevClose,
          close: prevClose,
          volume: '0',
          open_time:  (ts * 1_000_000).toString(),
          close_time: ((ts + periodSeconds * 1000) * 1_000_000).toString()
        });
      }
    }

    if (gapCount > 0) {
      this.logger.info(
        { ticker, gapCount, total: allCandles.length },
        'Forward-filled interior gap bars with previous close price'
      );
    }

    return allCandles;
  }

  /**
   * Fetch recent trades for realtime tick data
   * @param {string} ticker - Ticker in format "EXCHANGE:SYMBOL"
   * @param {string} since - Optional timestamp in nanoseconds to fetch from
   * @returns {Promise<Array>} Array of trade ticks
   */
  async fetchRecentTrades(ticker, since = null) {
    const { exchange: exchangeName, symbol } = this.parseTicker(ticker);
    const exchange = this.getExchange(exchangeName);

    try {
      // Convert nanoseconds to milliseconds if provided
      const sinceMs = since ? Math.floor(parseInt(since) / 1_000_000) : undefined;

      const trades = await exchange.fetchTrades(symbol, sinceMs, 1000);

      this.logger.debug(
        { ticker, count: trades.length },
        'Fetched recent trades'
      );

      // Get metadata for proper denomination
      const metadata = await this.getMetadata(ticker);

      // Convert to our Tick format
      return trades.map(trade => this.convertToTick(trade, ticker, metadata));
    } catch (error) {
      if (error.constructor?.name === 'RateLimitExceeded') {
        const retryAfterMs = extractRetryAfterMs(exchange, error);
        this.logger.warn({ ticker, retryAfterMs }, 'Trades fetch rate-limited by exchange');
        throw new ExchangeRateLimitError(exchangeName, retryAfterMs, error.message);
      }
      this.logger.error(
        { error: error.message, ticker },
        'Error fetching trades'
      );
      throw error;
    }
  }

  /**
   * Convert CCXT OHLCV array to our OHLC format
   * CCXT format: [timestamp, open, high, low, close, volume]
   * Uses precision fields from market metadata for proper integer representation
   */
  convertToOHLC(candle, ticker, periodSeconds, metadata) {
    const [timestamp, open, high, low, close, volume] = candle;

    const priceMult = Math.pow(10, metadata.pricePrecision ?? 2);
    const sizeMult = Math.pow(10, metadata.sizePrecision ?? 8);

    return {
      ticker,
      timestamp: (timestamp * 1_000_000).toString(), // Convert ms to nanoseconds
      open: Math.round(open * priceMult).toString(),
      high: Math.round(high * priceMult).toString(),
      low: Math.round(low * priceMult).toString(),
      close: Math.round(close * priceMult).toString(),
      volume: Math.round(volume * sizeMult).toString(),
      open_time: (timestamp * 1_000_000).toString(),
      close_time: ((timestamp + periodSeconds * 1000) * 1_000_000).toString()
    };
  }

  /**
   * Convert CCXT trade to our Tick format
   * Uses precision fields from market metadata for proper integer representation
   */
  convertToTick(trade, ticker, metadata) {
    const priceMult = Math.pow(10, metadata.pricePrecision ?? 2);
    const sizeMult = Math.pow(10, metadata.sizePrecision ?? 8);

    const price = Math.round(trade.price * priceMult);
    const amount = Math.round(trade.amount * sizeMult);
    const quoteAmount = Math.round((trade.price * trade.amount) * priceMult);

    return {
      trade_id: trade.id || `${trade.timestamp}`,
      ticker,
      timestamp: (trade.timestamp * 1_000_000).toString(), // Convert ms to nanoseconds
      price: price.toString(),
      amount: amount.toString(),
      quote_amount: quoteAmount.toString(),
      taker_buy: trade.side === 'buy',
      sequence: trade.order ? trade.order.toString() : undefined
    };
  }

  /**
   * Convert period seconds to CCXT timeframe string
   */
  secondsToTimeframe(seconds) {
    const timeframes = {
      60: '1m',
      300: '5m',
      900: '15m',
      1800: '30m',
      3600: '1h',
      7200: '2h',
      14400: '4h',
      21600: '6h',
      28800: '8h',
      43200: '12h',
      86400: '1d',
      259200: '3d',
      604800: '1w',
      2592000: '1M'
    };

    const timeframe = timeframes[seconds];
    if (!timeframe) {
      throw new Error(`Unsupported period: ${seconds} seconds`);
    }

    return timeframe;
  }

  /**
   * Close all exchange connections
   */
  async close() {
    for (const [name, exchange] of this.exchanges) {
      if (exchange.close) {
        await exchange.close();
      }
    }
    this.exchanges.clear();
  }
}
