'use strict';

const { DEFAULT_TOKENS, toBaseUnits } = require('./tokens');

/**
 * Pair coverage and opportunity detection, plus the mandatory pre-execution
 * "last look" (TECH_SPEC_V5 §3.1).
 */

/**
 * Jupiter reports priceImpactPct as a FRACTION, not percentage points.
 *
 * Verified empirically 2026-07-20 against the live API:
 *   0.1 SOL     -> 0.0000207...
 *   1,000 SOL   -> 0.0001418...
 *   50,000 SOL  -> 0.0035604...   (~$7.6M notional)
 *
 * 0.00356 as percentage points would mean a $7.6M trade moved the market by
 * 0.0036% — implausible. As a fraction it is 0.36%, which is plausible. So the
 * value is a fraction and MUST be multiplied by 100 before comparing against
 * MAX_PRICE_IMPACT_PCT, which is expressed in percentage points.
 *
 * Getting this wrong makes the liquidity check 100x too permissive — it would
 * pass every trade regardless of impact. Do not "simplify" this away.
 */
function impactToPercentagePoints(rawPriceImpactPct) {
  const n = Number(rawPriceImpactPct);
  if (!Number.isFinite(n)) return null;
  return n * 100;
}

/**
 * Parses TRADING_PAIRS into the pairs actually watched.
 *
 * NFR-19: unset or absent MUST yield exactly SOL/USDC — nothing more. This is
 * purely additive over the original single-pair behavior.
 *
 * A symbol not present in DEFAULT_TOKENS is skipped with a logged warning at
 * startup — not a silent failure, not a crash.
 *
 * Returns { pairs, warnings } so callers can surface skipped symbols in the UI
 * (FE-S7) rather than leaving them buried in the log.
 */
function getPairsToWatch(env = process.env) {
  const raw = env.TRADING_PAIRS;
  const warnings = [];

  if (raw == null || !String(raw).trim()) {
    // NFR-19 — the exact original behavior.
    return {
      pairs: [{ base: 'SOL', quote: 'USDC', symbol: 'SOL/USDC' }],
      warnings,
    };
  }

  const pairs = [];
  const seen = new Set();

  for (const entry of String(raw).split(',')) {
    const trimmed = entry.trim();
    if (!trimmed) continue;

    const parts = trimmed.split('/');
    if (parts.length !== 2) {
      warnings.push({
        entry: trimmed,
        reason: `Malformed pair "${trimmed}" — expected SYMBOL/SYMBOL. Skipped.`,
      });
      continue;
    }

    const base = parts[0].trim().toUpperCase();
    const quote = parts[1].trim().toUpperCase();

    const unknown = [base, quote].filter((s) => !DEFAULT_TOKENS[s]);
    if (unknown.length) {
      warnings.push({
        entry: trimmed,
        reason:
          `Unknown token symbol${unknown.length > 1 ? 's' : ''} ${unknown.join(', ')} ` +
          `in pair "${trimmed}". Not in DEFAULT_TOKENS. Skipped. ` +
          `Adding a token requires verifying its mint against an official source (NFR-20).`,
      });
      continue;
    }

    if (base === quote) {
      warnings.push({ entry: trimmed, reason: `Pair "${trimmed}" has identical legs. Skipped.` });
      continue;
    }

    // v1 sizes trades from MAX_TRADE_SIZE_SOL, so the base leg must be SOL.
    // A non-SOL base has no configured size; skipping is the honest behavior.
    if (base !== 'SOL') {
      warnings.push({
        entry: trimmed,
        reason:
          `Pair "${trimmed}" has a non-SOL base. Trade size is configured only as ` +
          `MAX_TRADE_SIZE_SOL, so this pair has no size to trade. Skipped.`,
      });
      continue;
    }

    const symbol = `${base}/${quote}`;
    if (seen.has(symbol)) continue;
    seen.add(symbol);
    pairs.push({ base, quote, symbol });
  }

  if (!pairs.length) {
    warnings.push({
      entry: String(raw),
      reason:
        'TRADING_PAIRS was set but produced no usable pairs. Falling back to SOL/USDC ' +
        'so the bot does not run watching nothing.',
    });
    pairs.push({ base: 'SOL', quote: 'USDC', symbol: 'SOL/USDC' });
  }

  return { pairs, warnings };
}

/**
 * Quotes one round trip: base -> quote -> base.
 *
 * profitPct is computed from real quote OUTPUT AMOUNTS, which already net out
 * AMM swap fees.
 *
 * DELIBERATE DESIGN CHOICE (TECH_SPEC_V5 §3.1): there is no separate fee-parsing
 * step. Re-checking profit against a fresh quote catches fee erosion and price
 * movement together, in one measurement, rather than as two checks that could
 * disagree with each other. Do not add a separate fee-estimation path without a
 * specific reason — it would duplicate what the degradation check covers.
 *
 * NOTE: profitPct does NOT account for Solana network fees, priority fees, or
 * Jito tips. MIN_PROFIT_THRESHOLD_PCT must be set high enough to cover those.
 * This is stated in .env.example too.
 */
async function quoteRoundTrip({ jupiter, pair, tradeSizeSol, slippageBps }) {
  const baseToken = DEFAULT_TOKENS[pair.base];
  const quoteToken = DEFAULT_TOKENS[pair.quote];
  const inAmount = toBaseUnits(tradeSizeSol, baseToken.decimals);

  const legA = await jupiter.getQuote({
    inputMint: baseToken.mint,
    outputMint: quoteToken.mint,
    amount: inAmount,
    slippageBps,
  });

  const legB = await jupiter.getQuote({
    inputMint: quoteToken.mint,
    outputMint: baseToken.mint,
    amount: legA.outAmount,
    slippageBps,
  });

  const started = BigInt(inAmount);
  const ended = BigInt(legB.outAmount);
  // Percentage points, computed in integer space then scaled, to avoid drift.
  const profitPct = (Number(ended - started) / Number(started)) * 100;

  const impactA = impactToPercentagePoints(legA.priceImpactPct);
  const impactB = impactToPercentagePoints(legB.priceImpactPct);
  // "the worse of the two legs" — TECH_SPEC_V5 §3.1 check 2.
  const worstImpactPct = Math.max(impactA ?? Infinity, impactB ?? Infinity);

  return {
    pair,
    legA,
    legB,
    inAmount,
    outAmount: legB.outAmount,
    profitPct,
    worstImpactPct,
    quotedAt: Date.now(),
  };
}

/** Detection pass. Returns an opportunity only if it clears the threshold. */
async function detectOpportunity({ jupiter, pair, tradeSizeSol, slippageBps, minProfitPct }) {
  const rt = await quoteRoundTrip({ jupiter, pair, tradeSizeSol, slippageBps });
  return { ...rt, isOpportunity: rt.profitPct >= minProfitPct };
}

/**
 * PRE-EXECUTION VALIDATION — the "last look".
 *
 * NFR-18: this runs unconditionally before EVERY trade. It is not configurable
 * to skip; only its numeric thresholds are configurable. It must never become
 * skippable, including from the UI.
 *
 * It re-fetches fresh quotes for the same pair and checks, in this order:
 *   1. Spread still available   — fresh profitPct still clears minProfitPct,
 *                                 not the profit at detection time, which may
 *                                 now be a full poll interval old.
 *   2. Liquidity adequate       — the worse of the two legs' priceImpactPct
 *                                 stays under maxPriceImpactPct.
 *   3. Degradation tolerance    — a drop of more than maxProfitDegradationPct
 *                                 percentage points from the originally
 *                                 detected profit blocks execution, even if
 *                                 still technically above threshold. Treated as
 *                                 a signal of an unstable, actively-closing
 *                                 opportunity rather than a safe entry.
 *
 * NEVER throws and never returns a trade on stale data. On any failure the
 * caller skips that attempt with the specific reason and continues to the next
 * pair/cycle.
 *
 * When it passes, `quotes` holds the FRESH round trip — execution must use
 * these, not the original ones.
 */
async function validateOpportunityStillGood({
  jupiter,
  opportunity,
  tradeSizeSol,
  slippageBps,
  minProfitPct,
  maxPriceImpactPct,
  maxProfitDegradationPct,
}) {
  let fresh;
  try {
    fresh = await quoteRoundTrip({
      jupiter,
      pair: opportunity.pair,
      tradeSizeSol,
      slippageBps,
    });
  } catch (err) {
    // A failed re-quote is a skip, never a thrown error and never a trade.
    return {
      ok: false,
      reason: 'quote_failed',
      message: `Could not re-quote ${opportunity.pair.symbol} for validation: ${err.message}`,
    };
  }

  // Check 1 — spread still available.
  if (fresh.profitPct < minProfitPct) {
    return {
      ok: false,
      reason: 'spread_closed',
      message:
        `Spread no longer clears threshold: ${fresh.profitPct.toFixed(4)}% < ` +
        `${minProfitPct}% (was ${opportunity.profitPct.toFixed(4)}% at detection)`,
      freshProfitPct: fresh.profitPct,
    };
  }

  // Check 2 — liquidity adequate.
  if (!Number.isFinite(fresh.worstImpactPct)) {
    return {
      ok: false,
      reason: 'price_impact_unknown',
      message:
        `Jupiter did not report a usable priceImpactPct for ${opportunity.pair.symbol}. ` +
        `Skipping rather than trading blind on liquidity.`,
    };
  }
  if (fresh.worstImpactPct > maxPriceImpactPct) {
    return {
      ok: false,
      reason: 'price_impact_too_high',
      message:
        `Price impact too high: ${fresh.worstImpactPct.toFixed(4)}% > ` +
        `${maxPriceImpactPct}% on the worse leg`,
      worstImpactPct: fresh.worstImpactPct,
    };
  }

  // Check 3 — degradation tolerance.
  const degradation = opportunity.profitPct - fresh.profitPct;
  if (degradation > maxProfitDegradationPct) {
    return {
      ok: false,
      reason: 'profit_degraded',
      message:
        `Profit degraded ${degradation.toFixed(4)} points since detection ` +
        `(${opportunity.profitPct.toFixed(4)}% -> ${fresh.profitPct.toFixed(4)}%), ` +
        `over the ${maxProfitDegradationPct} point tolerance — opportunity is closing`,
      degradation,
    };
  }

  return { ok: true, quotes: fresh, degradation };
}

module.exports = {
  getPairsToWatch,
  quoteRoundTrip,
  detectOpportunity,
  validateOpportunityStillGood,
  impactToPercentagePoints,
};
