'use strict';

require('dotenv').config();

const { loadConfig } = require('./lib/config');
const { loadWallet, createConnection, publicAddress, getSolBalance } = require('./lib/wallet');
const { createJupiterClient } = require('./lib/jupiterClient');
const { createJitoClient } = require('./lib/jitoClient');
const {
  getPairsToWatch,
  detectOpportunity,
  validateOpportunityStillGood,
} = require('./lib/priceWatcher');
const { executeLeg } = require('./lib/executor');
const { createLicenseClient, LicenseRejected } = require('./lib/license');
const { createState } = require('./lib/dashboard/state');
const { createDashboardServer } = require('./lib/dashboard/server');
const { createRelayClient } = require('./lib/dashboard/relayClient');
const { createUsageTracker } = require('./lib/usageTracker');

// Dashboard state mirrors what the console prints, so the UI and the log can
// never disagree about what the bot did.
const state = createState();

const log = {
  info: (m) => { console.log(`[${new Date().toISOString()}] ${m}`); state.log('info', m); },
  warn: (m) => { console.warn(`[${new Date().toISOString()}] WARN  ${m}`); state.log('error', m); },
  error: (m) => { console.error(`[${new Date().toISOString()}] ERROR ${m}`); state.log('error', m); },
  trade: (m) => { console.log(`[${new Date().toISOString()}] ${m}`); state.log('trade', m); },
  // Skips are recorded through state.recordSkip so they are counted and
  // grouped, not just printed (FE-S10).
  skip: (m) => console.log(`[${new Date().toISOString()}] SKIP  ${m}`),
};

let paused = false;
let running = true;

let license = null;
let dashboard = null;
let relay = null;
let usage = null;

async function main() {
  const cfg = loadConfig();

  // Licence check runs before anything touches a wallet or the network.
  license = createLicenseClient(process.env, { logger: log });
  const check = await license.verify();
  if (!check.enforced) {
    if (check.reason === 'not_configured') {
      // REQUIRE_LICENSE makes a missing/unconfigured licence fatal — this is
      // what makes the purchase actually necessary in the distributed build.
      if (cfg.requireLicense) {
        throw new LicenseRejected(
          'A licence is required to run this bot. Set LICENSE_KEY (and ' +
            'LICENSE_SERVER_BASE_URL) in your .env. If you have not purchased a ' +
            'licence, visit https://botsol.ai.',
          'not_configured'
        );
      }
      log.warn(
        'LICENSE_KEY or LICENSE_SERVER_BASE_URL is not set — running unlicensed. ' +
          'Set both in .env to enable licence verification.'
      );
    } else {
      // unreachable / server_error — fail OPEN in both modes. A customer who
      // paid must not lose a trading day to an outage on the operator's side
      // (storefront FAQ promise). Only a definite "no" (throws below) stops it.
      log.warn(
        `Licence server not reachable this start (${check.reason}) — continuing. ` +
          'Verification is not a per-trade gate.'
      );
    }
  } else {
    log.info(`Licence verified. Instance ${license.instanceId.slice(0, 8)}…`);
  }
  license.startHeartbeat();

  state.setLicense({
    configured: license.isConfigured,
    enforced: check.enforced,
    reason: check.reason,
    key: process.env.LICENSE_KEY,   // only the last 4 are retained
  });
  state.setDryRun(cfg.dryRun);
  state.setConfig(cfg);
  state.setRouting({
    jitoEnabled: cfg.enableJitoBundles,
    tipLamports: cfg.enableJitoBundles ? cfg.jitoTipLamports : null,
  });

  const keypair = loadWallet();
  const connection = createConnection();
  const jupiter = createJupiterClient();

  // Constructed only when enabled — the default path never touches Jito (NFR-15).
  const jito = cfg.enableJitoBundles ? createJitoClient() : null;

  const { pairs, warnings } = getPairsToWatch();
  state.setPairs(pairs, warnings);

  log.info('BOTSOL solana-arb-bot starting');
  log.info(`Wallet ${publicAddress(keypair)}`);
  state.setWallet(publicAddress(keypair));

  log.info(`Watching ${pairs.length} pair(s): ${pairs.map((p) => p.symbol).join(', ')}`);
  for (const w of warnings) log.warn(w.reason);

  // Local dashboard — 127.0.0.1 only, pause/resume the only controls.
  //
  // Started BEFORE any network call. A slow or hanging RPC must not delay the
  // dashboard: the Status tab is required to work with no internet connection
  // at all, and "the bot is up but the UI never loads" is the worst possible
  // presentation of a network problem.
  // One controls object, shared by the local dashboard and the relay, so
  // pausing from either surface goes through the exact same path. Pause/resume
  // is the entire control surface — NFR-18's validation is never skippable
  // from here.
  const controls = {
    pause() { paused = true; state.setStatus('paused'); },
    resume() { paused = false; state.setStatus('running'); },
  };
  dashboard = createDashboardServer({ state, controls, env: process.env });
  try {
    const port = await dashboard.listen(Number(process.env.DASHBOARD_PORT || 4747));
    log.info(`Dashboard at http://127.0.0.1:${port} (local only)`);
  } catch (err) {
    log.warn(`Dashboard could not start: ${err.message}. Trading continues.`);
    dashboard = null;
  }

  // Remote viewing via relay — opt-in, outbound-only. Off unless RELAY_URL,
  // RELAY_TOKEN and RELAY_SESSION are all set.
  relay = createRelayClient({ state, controls, env: process.env, logger: log });
  if (relay.enabled) {
    relay.start();
    log.info('Relay enabled — the bot will dial out for remote viewing.');
  } else {
    relay = null;
  }

  // Usage reporting — opt-in, off by default, aggregate RPC counts only.
  usage = createUsageTracker({ env: process.env, licenseKey: process.env.LICENSE_KEY, logger: log });
  if (usage.start()) {
    log.info('Usage reporting enabled — aggregate RPC call counts only.');
  }

  // Balance is fetched after the UI is already serving, so a slow RPC shows as
  // an unknown balance rather than a dashboard that never appears.
  try {
    const balance = await getSolBalance(connection, keypair);
    state.setWallet(publicAddress(keypair), balance);
    log.info(`Balance ${balance.toFixed(6)} SOL`);
  } catch (err) {
    log.warn(`Could not fetch balance: ${err.message}`);
  }

  log.info(`Jupiter ${jupiter.base}${jupiter.hasApiKey ? ' (with API key)' : ' (keyless)'}`);
  log.info(
    `Thresholds — min profit ${cfg.minProfitPct}%, size ${cfg.maxTradeSizeSol} SOL, ` +
      `max impact ${cfg.maxPriceImpactPct}%, max degradation ${cfg.maxProfitDegradationPct} pts`
  );

  if (cfg.enableJitoBundles) {
    log.info(
      `Jito bundles ENABLED — tip ${cfg.jitoTipLamports} lamports ` +
        `(${cfg.jitoTipLamports / 1e9} SOL) per attempt. The tip is paid win or lose, ` +
        `and a round-trip trade pays it twice.`
    );
  } else {
    log.info('Jito bundles disabled — execution goes through your own RPC.');
  }

  if (cfg.dryRun) {
    log.info('DRY_RUN is ON — trades will be built and signed but never submitted.');
  } else {
    log.warn('DRY_RUN is OFF — this bot will submit real transactions with real funds.');
  }

  state.setStatus('running');

  while (running) {
    if (!paused) {
      for (const pair of pairs) {
        if (!running || paused) break;
        await runCycle({ cfg, pair, jupiter, jito, connection, keypair });
      }
    }
    await sleep(cfg.pollIntervalMs);
  }
}

async function runCycle({ cfg, pair, jupiter, jito, connection, keypair }) {
  let opportunity;
  try {
    opportunity = await detectOpportunity({
      jupiter,
      pair,
      tradeSizeSol: cfg.maxTradeSizeSol,
      slippageBps: cfg.slippageBps,
      minProfitPct: cfg.minProfitPct,
    });
  } catch (err) {
    log.warn(`${pair.symbol}: quote failed — ${err.message}`);
    return;
  }

  // Detection quoted a round trip — two Jupiter calls. Counted only for the
  // opt-in aggregate; nothing is sent unless reporting is explicitly enabled.
  if (usage) usage.countRpc(2);

  if (!opportunity.isOpportunity) return;

  log.info(
    `${pair.symbol}: opportunity detected, ${opportunity.profitPct.toFixed(4)}% — validating`
  );

  // NFR-18 — unconditional. There is no configuration path that skips this.
  const validation = await validateOpportunityStillGood({
    jupiter,
    opportunity,
    tradeSizeSol: cfg.maxTradeSizeSol,
    slippageBps: cfg.slippageBps,
    minProfitPct: cfg.minProfitPct,
    maxPriceImpactPct: cfg.maxPriceImpactPct,
    maxProfitDegradationPct: cfg.maxProfitDegradationPct,
  });
  // The last-look re-quote is another round trip — two more Jupiter calls.
  if (usage) usage.countRpc(2);

  if (!validation.ok) {
    // A skip is the product working correctly, not an error. Counted and
    // grouped by reason so a long run does not drown the log (FE-S10).
    log.skip(`${pair.symbol}: ${validation.reason} — ${validation.message}`);
    state.recordSkip(validation.reason, `${pair.symbol}: ${validation.message}`, pair.symbol);
    return;
  }

  // Execution uses the FRESH quotes from validation, never the original ones.
  const { quotes } = validation;
  log.info(
    `${pair.symbol}: validated at ${quotes.profitPct.toFixed(4)}% — executing round trip`
  );

  try {
    const legA = await executeLeg({
      connection,
      keypair,
      jupiter,
      jito,
      quote: quotes.legA,
      dryRun: cfg.dryRun,
      enableJitoBundles: cfg.enableJitoBundles,
      jitoTipLamports: cfg.jitoTipLamports,
      priorityLamports: cfg.priorityLamports,
      logger: log,
    });
    log.trade(`${pair.symbol}: leg A ${legA.dryRun ? "simulated" : "confirmed"} ${legA.txid}`);

    const legB = await executeLeg({
      connection,
      keypair,
      jupiter,
      jito,
      quote: quotes.legB,
      dryRun: cfg.dryRun,
      enableJitoBundles: cfg.enableJitoBundles,
      jitoTipLamports: cfg.jitoTipLamports,
      priorityLamports: cfg.priorityLamports,
      logger: log,
    });
    log.trade(`${pair.symbol}: leg B ${legB.dryRun ? 'simulated' : 'confirmed'} ${legB.txid}`);

    // Only a genuinely submitted round trip moves realised P&L. A dry run
    // records nothing — presenting a simulated figure as realised would be
    // exactly the fabricated number FE-P6 forbids.
    const profitSol = (quotes.profitPct / 100) * cfg.maxTradeSizeSol;
    state.recordRoundTrip({ profitSol, submitted: legA.submitted && legB.submitted });
  } catch (err) {
    log.error(`${pair.symbol}: execution failed — ${err.message}`);
  }
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function shutdown(signal) {
  log.info(`${signal} received — shutting down`);
  running = false;
  state.setStatus('stopped');
  // Release the claim so restarting is not blocked by our own stale claim.
  if (usage) usage.stop();
  if (relay) relay.stop();
  if (license) await license.release();
  if (dashboard) await dashboard.close();
  process.exit(0);
}
process.on('SIGINT', () => { shutdown('SIGINT'); });
process.on('SIGTERM', () => { shutdown('SIGTERM'); });

if (require.main === module) {
  main().catch(async (err) => {
    if (err instanceof LicenseRejected) {
      log.error(`Licence check failed — ${err.message}`);
      process.exit(2);
    }
    log.error(err.message);
    if (license) await license.release();
    process.exit(1);
  });
}

module.exports = { runCycle };
