import { Logger } from "@nestjs/common";

import { EXCHANGE_DEFAULTS, ExchangeOptions } from "./exchange.options";
import {
  CurrencyPair,
  ExchangeRateProvider,
} from "./exchange.types";

/** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */
const USD_RATE_REGEX =
  /currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/;

/**
 * Central Bank of Ethiopia (CBE) rate provider.
 *
 * Sources a single canonical direction — **USD→ETB** (selling rate) — by
 * scraping ethio.forex, caching the result, and falling back to a configured
 * rate when the scrape fails. The inverse (ETB→USD) is derived by
 * {@link ExchangeService}, so this provider only ever reports USD→ETB.
 */
export class CbeExchangeProvider implements ExchangeRateProvider {
  readonly name = "CBE";

  private readonly logger = new Logger(CbeExchangeProvider.name);
  private readonly options: Required<ExchangeOptions>;
  private cachedRate: number | null = null;
  private cacheExpiresAt = 0;

  constructor(options: ExchangeOptions) {
    this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) };
  }

  async getBaseRate(pair: CurrencyPair): Promise<number | null> {
    // CBE only sources USD→ETB; everything else is derived upstream.
    if (pair.from !== "USD" || pair.to !== "ETB") {
      return null;
    }
    return this.getUsdToEtbRate();
  }

  /**
   * Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex.
   * Cached for `cacheTtlMs`; on failure reuses the last cached rate, else
   * returns `fallbackRate`.
   */
  private async getUsdToEtbRate(): Promise<number> {
    const now = Date.now();

    if (this.cachedRate !== null && now < this.cacheExpiresAt) {
      return this.cachedRate;
    }

    const { scrapeUrl, fallbackRate, cacheTtlMs, requestTimeoutMs } =
      this.options;

    try {
      const response = await fetch(scrapeUrl, {
        signal: AbortSignal.timeout(requestTimeoutMs),
        headers: { "User-Agent": "Mozilla/5.0" },
      });

      if (!response.ok) {
        throw new Error(`CBE scrape responded with status ${response.status}`);
      }

      const html = await response.text();
      const rates = this.parseScrapedRates(html);

      if (!rates) {
        throw new Error("USD rate not found in ethio.forex page HTML");
      }

      const rate = rates.selling;
      if (!Number.isFinite(rate) || rate <= 0) {
        throw new Error(`Invalid selling rate parsed: ${rate}`);
      }

      this.cachedRate = rate;
      this.cacheExpiresAt = now + cacheTtlMs;
      this.logger.log(
        `CBE USD→ETB rate refreshed from ethio.forex — buying=${rates.buying} selling=${rate}`,
      );
      return rate;
    } catch (err) {
      this.logger.error(
        `Failed to scrape CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`,
      );

      if (this.cachedRate !== null) {
        this.logger.warn(
          `Using previously cached CBE rate: ${this.cachedRate}`,
        );
        return this.cachedRate;
      }

      return fallbackRate;
    }
  }

  private parseScrapedRates(
    html: string,
  ): { buying: number; selling: number } | null {
    const decoded = this.unescapeHtml(html);
    const match = USD_RATE_REGEX.exec(decoded);
    if (!match) return null;

    const buying = Number(match[1]);
    const selling = Number(match[2]);
    if (!Number.isFinite(buying) || !Number.isFinite(selling)) return null;

    return { buying, selling };
  }

  private unescapeHtml(html: string): string {
    return html
      .replace(/&quot;/g, '"')
      .replace(/&#34;/g, '"')
      .replace(/&amp;/g, "&")
      .replace(/&lt;/g, "<")
      .replace(/&gt;/g, ">");
  }
}

/** Drops keys whose value is `undefined` so they don't override defaults via spread. */
function stripUndefined(options: ExchangeOptions): ExchangeOptions {
  return Object.fromEntries(
    Object.entries(options).filter(([, value]) => value !== undefined),
  );
}
