/**
 * ForexEngine.js — Pure JS FX simulation engine (no React dependencies)
 */
const RNG = require("../RNG.js");

// ── Sessions ────────────────────────────────────────────────────────────────
const FX_SESSIONS = [
  [0.00,  2.00, "sydney-open",  0.55, 1.8, 4],
  [2.00,  7.00, "tokyo",        0.70, 1.4, 5],
  [7.00, 12.00, "london",       1.30, 0.9, 8],
  [12.00,16.00, "london-ny",    1.60, 0.7, 10],
  [16.00,17.00, "ny-afternoon", 1.10, 1.0, 7],
  [17.00,21.00, "ny-late",      0.75, 1.2, 5],
  [21.00,22.00, "rollover",     0.30, 3.5, 2],
  [22.00,24.00, "pacific",      0.45, 2.0, 3],
];

const WEEKEND_SESSION  = { label: "weekend-closed", volMult: 0.05, spreadMult: 8.0, liquidityScore: 1 };
const ROLLOVER_SESSION = { label: "rollover",       volMult: 0.30, spreadMult: 3.5, liquidityScore: 2 };

function getFxSession(nowMs) {
  const d        = new Date(nowMs);
  const utcDay   = d.getUTCDay();
  const utcHour  = d.getUTCHours() + d.getUTCMinutes() / 60 + d.getUTCSeconds() / 3600;
  const isSat      = utcDay === 6;
  const isFriNight = utcDay === 5 && utcHour >= 21;
  const isSunEarly = utcDay === 0 && utcHour < 22;
  if (isSat || isFriNight || isSunEarly) return WEEKEND_SESSION;
  for (const [start, end, label, volMult, spreadMult, liquidityScore] of FX_SESSIONS) {
    if (utcHour >= start && utcHour < end)
      return { label, volMult, spreadMult, liquidityScore };
  }
  return ROLLOVER_SESSION;
}

// ── Pair profiles ────────────────────────────────────────────────────────────
const PAIR_PROFILES = {
  EURUSD: { dailyVol: 0.0055, pipSize: 0.0001, basePipSpread: 0.8,  carryDrift:  0.0002, reversionSpeed: 0.025, jumpProb: 0.0004, jumpStdPip: 8  },
  GBPUSD: { dailyVol: 0.0070, pipSize: 0.0001, basePipSpread: 1.0,  carryDrift: -0.0005, reversionSpeed: 0.022, jumpProb: 0.0005, jumpStdPip: 12 },
  USDJPY: { dailyVol: 0.0060, pipSize: 0.01,   basePipSpread: 0.9,  carryDrift:  0.0010, reversionSpeed: 0.020, jumpProb: 0.0004, jumpStdPip: 10 },
  AUDUSD: { dailyVol: 0.0065, pipSize: 0.0001, basePipSpread: 1.0,  carryDrift:  0.0003, reversionSpeed: 0.028, jumpProb: 0.0004, jumpStdPip: 9  },
  USDCAD: { dailyVol: 0.0055, pipSize: 0.0001, basePipSpread: 1.0,  carryDrift:  0.0001, reversionSpeed: 0.027, jumpProb: 0.0003, jumpStdPip: 8  },
  USDCHF: { dailyVol: 0.0055, pipSize: 0.0001, basePipSpread: 1.0,  carryDrift: -0.0003, reversionSpeed: 0.030, jumpProb: 0.0004, jumpStdPip: 9  },
  NZDUSD: { dailyVol: 0.0070, pipSize: 0.0001, basePipSpread: 1.5,  carryDrift:  0.0004, reversionSpeed: 0.030, jumpProb: 0.0005, jumpStdPip: 10 },
  EURGBP: { dailyVol: 0.0045, pipSize: 0.0001, basePipSpread: 1.2,  carryDrift:  0.0007, reversionSpeed: 0.035, jumpProb: 0.0003, jumpStdPip: 7  },
  EURJPY: { dailyVol: 0.0080, pipSize: 0.01,   basePipSpread: 1.4,  carryDrift:  0.0012, reversionSpeed: 0.020, jumpProb: 0.0005, jumpStdPip: 14 },
  GBPJPY: { dailyVol: 0.0095, pipSize: 0.01,   basePipSpread: 2.0,  carryDrift:  0.0015, reversionSpeed: 0.018, jumpProb: 0.0006, jumpStdPip: 18 },
  AUDJPY: { dailyVol: 0.0080, pipSize: 0.01,   basePipSpread: 1.8,  carryDrift:  0.0013, reversionSpeed: 0.022, jumpProb: 0.0005, jumpStdPip: 14 },
  CADJPY: { dailyVol: 0.0075, pipSize: 0.01,   basePipSpread: 2.0,  carryDrift:  0.0011, reversionSpeed: 0.023, jumpProb: 0.0005, jumpStdPip: 12 },
  CHFJPY: { dailyVol: 0.0075, pipSize: 0.01,   basePipSpread: 2.2,  carryDrift: -0.0001, reversionSpeed: 0.025, jumpProb: 0.0005, jumpStdPip: 13 },
  EURCHF: { dailyVol: 0.0030, pipSize: 0.0001, basePipSpread: 1.5,  carryDrift: -0.0002, reversionSpeed: 0.040, jumpProb: 0.0003, jumpStdPip: 6  },
  EURCAD: { dailyVol: 0.0065, pipSize: 0.0001, basePipSpread: 2.0,  carryDrift:  0.0003, reversionSpeed: 0.028, jumpProb: 0.0004, jumpStdPip: 11 },
  EURAUD: { dailyVol: 0.0075, pipSize: 0.0001, basePipSpread: 2.0,  carryDrift: -0.0001, reversionSpeed: 0.027, jumpProb: 0.0005, jumpStdPip: 12 },
  GBPAUD: { dailyVol: 0.0090, pipSize: 0.0001, basePipSpread: 2.5,  carryDrift: -0.0008, reversionSpeed: 0.022, jumpProb: 0.0006, jumpStdPip: 16 },
  GBPCAD: { dailyVol: 0.0085, pipSize: 0.0001, basePipSpread: 2.5,  carryDrift: -0.0006, reversionSpeed: 0.022, jumpProb: 0.0006, jumpStdPip: 14 },
  GBPCHF: { dailyVol: 0.0080, pipSize: 0.0001, basePipSpread: 2.5,  carryDrift: -0.0008, reversionSpeed: 0.024, jumpProb: 0.0005, jumpStdPip: 14 },
  AUDCAD: { dailyVol: 0.0065, pipSize: 0.0001, basePipSpread: 2.2,  carryDrift:  0.0002, reversionSpeed: 0.030, jumpProb: 0.0004, jumpStdPip: 10 },
  AUDCHF: { dailyVol: 0.0060, pipSize: 0.0001, basePipSpread: 2.2,  carryDrift:  0.0006, reversionSpeed: 0.032, jumpProb: 0.0004, jumpStdPip: 10 },
  NZDJPY: { dailyVol: 0.0085, pipSize: 0.01,   basePipSpread: 2.5,  carryDrift:  0.0014, reversionSpeed: 0.025, jumpProb: 0.0005, jumpStdPip: 14 },
  NZDCAD: { dailyVol: 0.0070, pipSize: 0.0001, basePipSpread: 2.8,  carryDrift:  0.0003, reversionSpeed: 0.028, jumpProb: 0.0004, jumpStdPip: 11 },
  USDZAR: { dailyVol: 0.0160, pipSize: 0.0001, basePipSpread: 30,   carryDrift:  0.0040, reversionSpeed: 0.015, jumpProb: 0.0010, jumpStdPip: 40 },
  USDMXN: { dailyVol: 0.0130, pipSize: 0.0001, basePipSpread: 20,   carryDrift:  0.0030, reversionSpeed: 0.018, jumpProb: 0.0008, jumpStdPip: 30 },
  USDBRL: { dailyVol: 0.0140, pipSize: 0.0001, basePipSpread: 25,   carryDrift:  0.0035, reversionSpeed: 0.016, jumpProb: 0.0010, jumpStdPip: 35 },
  USDTRY: { dailyVol: 0.0200, pipSize: 0.0001, basePipSpread: 40,   carryDrift:  0.0060, reversionSpeed: 0.010, jumpProb: 0.0015, jumpStdPip: 60 },
  USDSEK: { dailyVol: 0.0075, pipSize: 0.0001, basePipSpread: 3.0,  carryDrift:  0.0010, reversionSpeed: 0.025, jumpProb: 0.0004, jumpStdPip: 12 },
  USDNOK: { dailyVol: 0.0080, pipSize: 0.0001, basePipSpread: 3.5,  carryDrift:  0.0008, reversionSpeed: 0.024, jumpProb: 0.0005, jumpStdPip: 14 },
  USDSGD: { dailyVol: 0.0035, pipSize: 0.0001, basePipSpread: 2.5,  carryDrift:  0.0005, reversionSpeed: 0.035, jumpProb: 0.0003, jumpStdPip: 6  },
  USDHKD: { dailyVol: 0.0003, pipSize: 0.0001, basePipSpread: 1.5,  carryDrift:  0.0001, reversionSpeed: 0.090, jumpProb: 0.0001, jumpStdPip: 2  },
  USDCNH: { dailyVol: 0.0020, pipSize: 0.0001, basePipSpread: 5.0,  carryDrift:  0.0002, reversionSpeed: 0.060, jumpProb: 0.0002, jumpStdPip: 5  },
};

function getPairProfile(symbol) {
  if (PAIR_PROFILES[symbol]) return PAIR_PROFILES[symbol];
  const isExotic = symbol.length > 6 || symbol.includes("_");
  return {
    dailyVol:       isExotic ? 0.0120 : 0.0070,
    pipSize:        symbol.includes("JPY") ? 0.01 : 0.0001,
    basePipSpread:  isExotic ? 15 : 2.5,
    carryDrift:     0,
    reversionSpeed: isExotic ? 0.015 : 0.025,
    jumpProb:       isExotic ? 0.0008 : 0.0004,
    jumpStdPip:     isExotic ? 20 : 10,
  };
}

// ── Pair state factory ───────────────────────────────────────────────────────
function createPairState(symbol, price, opts = {}) {
  const profile    = getPairProfile(symbol);
  const p          = Math.max(Number(price) || 1, 1e-6);
  const pipSize    = opts.pipSize ?? profile.pipSize;
  const ticksPerDay = opts.ticksPerDay ?? 100;
  const dailyVol   = opts.dailyVol ?? profile.dailyVol;
  const baseTickVol = dailyVol / Math.sqrt(ticksPerDay);

  return {
    price:      p,
    logPrice:   Math.log(p),
    fairValue:  p,
    prevLogRet: 0,
    pipSize,
    pipetteSize: pipSize / 10,
    decimals:    pipSize >= 0.01 ? 3 : 5,
    garchVar:    baseTickVol * baseTickVol,
    garchOmega:  0.10,
    garchAlpha:  0.08,
    garchBeta:   0.87,
    baseTickVol,
    momentumEMA:    0,
    momentumDecay:  0.85,
    momentumWeight: 0.08,
    momentumCap:    baseTickVol * 1.0,
    reversionSpeed: opts.reversionSpeed ?? profile.reversionSpeed,
    carryDrift:     opts.carryDrift     ?? profile.carryDrift,
    jumpProb:       opts.jumpProb       ?? 0.003,
    jumpStdPip:     opts.jumpStdPip     ?? profile.jumpStdPip,
    basePipSpread:  opts.basePipSpread  ?? profile.basePipSpread,
    open: p, high: p, low: p, close: p,
    volumeLots: 0,
    bid: 0, ask: 0,
  };
}

function roundPipette(price, pipetteSize) {
  return Math.round(price / pipetteSize) * pipetteSize;
}
function fmtFx(price, decimals) {
  return parseFloat(price.toFixed(decimals));
}
function pipsToPrice(pips, pipSize)        { return pips * pipSize; }
function priceToPips(priceOffset, pipSize) { return Math.abs(priceOffset) / pipSize; }

const LP_NAMES = ["UBS", "DB", "Citi", "HSBC", "JPM", "BNP", "Barclays", "GS"];

// ── Engine ───────────────────────────────────────────────────────────────────
class ForexEngine {
  constructor(pairs = [], seed = 42, options = {}) {
    this._rng  = new RNG(seed);
    this._opts = {
      ticksPerDay:   options.ticksPerDay   ?? 100,
      maxBookLevels: options.maxBookLevels ?? 8,
      maxTicks:      options.maxTicks      ?? 60,
      ...options,
    };
    this.states       = {};
    this.assets       = {};
    this.quotes       = {};
    this.orderBooks   = {};
    this.recentTrades = {};
    this.sessions     = {};

    const syms = Array.isArray(pairs) ? pairs : [pairs];
    for (const s of syms) if (s) this._registerPair(s);
  }

  // ── Public ─────────────────────────────────────────────────────────────────

  updatePrice(pair, price) {
    const p = Math.max(Number(price) || 1e-6, 1e-8);
    if (!this.states[pair] || !this.states[pair]._initialised) {
      this._initPair(pair, p);
      return;
    }
    const st = this.states[pair];
    st.fairValue = p;
    const pipDrift = priceToPips(Math.abs(p - st.price), st.pipSize);
    if (pipDrift > 10) {
      st.garchVar = Math.min(st.garchVar * 1.5, st.baseTickVol * st.baseTickVol * 9);
    }
  }

  tick(pair, opts = { drift: true, book: true, trades: true }) {
    if (!this.states[pair] || !this.states[pair]._initialised) {
      this._initPair(pair, 1.0);
    }
    const st      = this.states[pair];
    const nowMs   = Date.now();
    const session = getFxSession(nowMs);
    this.sessions[pair] = session;

    if (opts.drift  !== false) this._evolvePrice(pair, st, session);
    this._computeQuotes(pair, st, session);
    if (opts.book   !== false) this._buildOrderBook(pair, st, session);
    if (opts.trades !== false) this._generateTicks(pair, st, session);

    this.assets[pair] = {
      price:      fmtFx(st.price, st.decimals),
      open:       fmtFx(st.open,  st.decimals),
      high:       fmtFx(st.high,  st.decimals),
      low:        fmtFx(st.low,   st.decimals),
      close:      fmtFx(st.close, st.decimals),
      volumeLots: parseFloat(st.volumeLots.toFixed(2)),
    };

    return this._snapshot(pair);
  }

  resetCandle(pair) {
    const st = this.states[pair];
    if (!st) return;
    st.open = st.price; st.high = st.price;
    st.low  = st.price; st.close = st.price;
    st.volumeLots = 0;
  }

  // ── Price evolution ────────────────────────────────────────────────────────

  _evolvePrice(pair, st, session) {
    const rng        = this._rng;
    const longRunVar = st.baseTickVol * st.baseTickVol;
    const rawVar     =
      st.garchOmega * longRunVar +
      st.garchAlpha * (st.prevLogRet * st.prevLogRet) +
      st.garchBeta  * st.garchVar;

    st.garchVar = Math.min(Math.max(rawVar, 0.0625 * longRunVar), 25 * longRunVar);

    const tickVol    = Math.sqrt(st.garchVar) * session.volMult;
    const annualTicks = this._opts.ticksPerDay * 260;
    const tickCarry  = st.carryDrift / annualTicks;
    const tickDrift  = tickCarry - 0.5 * st.garchVar;

    const logGap = Math.log(Math.max(st.fairValue, 1e-8)) - st.logPrice;
    const ouPull = Math.max(-0.015, Math.min(0.015, st.reversionSpeed * logGap));

    st.momentumEMA =
      st.momentumDecay * st.momentumEMA +
      (1 - st.momentumDecay) * st.prevLogRet;
    const rawMomentum  = st.momentumWeight * st.momentumEMA;
    const momentumPull = Math.max(-st.momentumCap, Math.min(st.momentumCap, rawMomentum));

    const shock = rng.fattailNormal(0, tickVol, 5, 5);

    let jumpContrib = 0;
    const jumpP = st.jumpProb * Math.max(0.5, session.volMult);
    if (rng.next() < jumpP) {
      const jumpLogStd = pipsToPrice(st.jumpStdPip, st.pipSize) / Math.max(st.price, 1e-8);
      jumpContrib = rng.normal(0, jumpLogStd);
    }

    const logRet = tickDrift + ouPull + momentumPull + shock + jumpContrib;

    st.logPrice   += logRet;
    st.price       = roundPipette(Math.exp(st.logPrice), st.pipetteSize);
    st.price       = Math.max(st.price, st.pipetteSize);
    st.prevLogRet  = logRet;

    if (st.price > st.high) st.high = st.price;
    if (st.price < st.low)  st.low  = st.price;
    st.close = st.price;
  }

  // ── Quotes ─────────────────────────────────────────────────────────────────

  _computeQuotes(pair, st, session) {
    const rng       = this._rng;
    const volRatio  = Math.sqrt(st.garchVar) / st.baseTickVol;
    const pipSpread = st.basePipSpread *
                      Math.max(0.6, Math.min(4.0, volRatio)) *
                      session.spreadMult;

    const midNoise        = rng.normal(0, st.pipSize * 0.08);
    const mid             = st.price + midNoise;
    const halfSpreadPrice = pipsToPrice(pipSpread / 2, st.pipSize);

    const bid = roundPipette(mid - halfSpreadPrice, st.pipetteSize);
    const ask = roundPipette(mid + halfSpreadPrice, st.pipetteSize);

    st.bid = Math.max(bid, st.pipetteSize);
    st.ask = Math.max(ask, st.bid + st.pipetteSize);

    const spreadPrice = st.ask - st.bid;
    const spreadPips  = priceToPips(spreadPrice, st.pipSize);

    this.quotes[pair] = {
      bid:        fmtFx(st.bid, st.decimals),
      ask:        fmtFx(st.ask, st.decimals),
      mid:        fmtFx((st.bid + st.ask) / 2, st.decimals),
      spread:     parseFloat(spreadPrice.toFixed(st.decimals)),
      spreadPips: parseFloat(spreadPips.toFixed(1)),
      pipSize:    st.pipSize,
      decimals:   st.decimals,
    };
  }

  // ── Order book ─────────────────────────────────────────────────────────────

  _buildOrderBook(pair, st, session) {
    const rng      = this._rng;
    const levels   = this._opts.maxBookLevels;
    const baseLots = 2_000 * (session.liquidityScore / 10);
    const levelSpacingPip = st.basePipSpread < 2
      ? rng.uniform(0.1, 0.5)
      : rng.uniform(0.5, 2.0);

    const bids = [], asks = [];

    for (let i = 0; i < levels; i++) {
      const decay   = Math.exp(-i * 0.30);
      const noise   = rng.logNormal(0, 0.35);
      const lots    = Math.max(0.1, parseFloat((baseLots * decay * noise).toFixed(1)));
      const lpCount = rng.int(1, 5);

      const bidPrice = roundPipette(
        st.bid - i * pipsToPrice(levelSpacingPip, st.pipSize), st.pipetteSize);
      const askPrice = roundPipette(
        st.ask + i * pipsToPrice(levelSpacingPip, st.pipSize), st.pipetteSize);

      if (bidPrice > 0) {
        bids.push({ price: fmtFx(bidPrice, st.decimals), lots, units: Math.round(lots * 100_000), lps: lpCount, topLP: rng.pick(LP_NAMES) });
      }
      asks.push({ price: fmtFx(askPrice, st.decimals), lots: Math.max(0.1, parseFloat((baseLots * Math.exp(-i * 0.30) * rng.logNormal(0, 0.35)).toFixed(1))), units: Math.round(lots * 100_000), lps: lpCount, topLP: rng.pick(LP_NAMES) });
    }

    this.orderBooks[pair] = { bids, asks };
  }

  // ── Tick tape ──────────────────────────────────────────────────────────────

  _generateTicks(pair, st, session) {
    const rng      = this._rng;
    const maxTicks = this._opts.maxTicks;
    const lambda   = session.liquidityScore > 7 ? 6 : session.liquidityScore > 4 ? 3 : 1;
    const nTicks   = rng.poisson(lambda);

    if (!this.recentTrades[pair]) this.recentTrades[pair] = [];
    const tape = this.recentTrades[pair];

    for (let i = 0; i < nTicks; i++) {
      const bullBias  = 0.5 + Math.max(-0.25, Math.min(0.25, st.momentumEMA * 15));
      const isBuy     = rng.next() < bullBias;
      const execPrice = isBuy ? st.ask : st.bid;

      const isInstitutional = rng.next() < 0.15;
      const lots = isInstitutional
        ? parseFloat(rng.uniform(5, 50).toFixed(2))
        : parseFloat(rng.uniform(0.01, 2.0).toFixed(2));

      let slipPips = 0;
      if (lots > 10) slipPips = (lots / 100) * st.basePipSpread * rng.uniform(0, 1);

      const slippedPrice = roundPipette(
        execPrice + (isBuy ? 1 : -1) * pipsToPrice(slipPips, st.pipSize),
        st.pipetteSize,
      );

      tape.unshift({
        price:         fmtFx(Math.max(slippedPrice, st.pipetteSize), st.decimals),
        lots,
        units:         Math.round(lots * 100_000),
        side:          isBuy ? "buy" : "sell",
        isInstitutional,
        slippagePips:  parseFloat(slipPips.toFixed(1)),
        lp:            rng.pick(LP_NAMES),
        ts:            Date.now() - rng.int(0, 4_900),
      });

      st.volumeLots += lots;
    }

    if (tape.length > maxTicks) tape.length = maxTicks;
  }

  // ── Init / snapshot ────────────────────────────────────────────────────────

  _registerPair(pair) {
    this.states[pair]       = { _initialised: false };
    this.assets[pair]       = { price: 0, open: 0, high: 0, low: 0, close: 0, volumeLots: 0 };
    this.quotes[pair]       = { bid: 0, ask: 0, mid: 0, spread: 0, spreadPips: 0, pipSize: 0.0001, decimals: 5 };
    this.orderBooks[pair]   = { bids: [], asks: [] };
    this.recentTrades[pair] = [];
    this.sessions[pair]     = getFxSession(Date.now());
  }

  _initPair(pair, price) {
    const p  = Math.max(Number(price) || 1.0, 1e-8);
    const st = createPairState(pair, p, { ticksPerDay: this._opts.ticksPerDay });
    st._initialised         = true;
    this.states[pair]       = st;
    this.assets[pair]       = { price: p, open: p, high: p, low: p, close: p, volumeLots: 0 };
    this.quotes[pair]       = { bid: 0, ask: 0, mid: p, spread: 0, spreadPips: 0, pipSize: st.pipSize, decimals: st.decimals };
    this.orderBooks[pair]   = { bids: [], asks: [] };
    this.recentTrades[pair] = [];
    this.sessions[pair]     = getFxSession(Date.now());
  }

  _snapshot(pair) {
    return {
      pair,
      session:   this.sessions[pair],
      asset:     this.assets[pair],
      quote:     this.quotes[pair],
      orderBook: this.orderBooks[pair],
      ticks:     this.recentTrades[pair],
    };
  }
}

module.exports = ForexEngine;