/* svc/engines.js — engine access layer.
   - EngineService: server status + one-shot searches (/api/go)
   - Analyser: continuous analysis via repeated timed passes (works with
     engines that do not support `stop`; Stockfish keeps its hash between
     passes so lines deepen steadily)
   - eval formatting helpers (pawns / win% / WDL estimate) */
'use strict';

import { Emitter } from '../ui/dom.js';
import { settings } from './settings.js';

// Resolve API + static paths relative to where the app is served, so it works
// at the site root, under a path prefix (e.g. /tools/chess/), or from file://.
// document.baseURI is the directory of index.html.
const BASE = new URL('.', document.baseURI).href;
export function appUrl(rel) { return new URL(rel.replace(/^\//, ''), BASE).href; }

async function api(path, body) {
  try {
    const r = await fetch(appUrl(path), body ? {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    } : undefined);
    if (!r.ok) return null;
    return await r.json();
  } catch { return null; }
}

class EngineService extends Emitter {
  constructor() {
    super();
    this.engines = {};        // key -> {available, name}
    this.serverUp = false;
    this._pollTimer = null;
  }

  async refresh() {
    const s = await api('/api/status');
    const was = this.serverUp;
    this.serverUp = !!s;
    this.engines = (s && s.engines) || {};
    if (was !== this.serverUp) this.emit('status');
    return this.serverUp;
  }

  startPolling() {
    if (this._pollTimer) return;
    this._pollTimer = setInterval(() => { if (!this.serverUp) this.refresh(); }, 8000);
  }

  available() {
    return Object.entries(this.engines)
      .filter(([, e]) => e.available)
      .map(([k, e]) => ({ key: k, name: e.name }));
  }
  nameOf(key) { return this.engines[key]?.name || key; }
  has(key) { return !!this.engines[key]?.available; }

  /** One-shot search. opts: {engine, instance, fen, moves, movetime, depth,
      elo, multipv}. Returns server result or null. */
  async go(opts) {
    if (!this.serverUp) return null;
    const body = { ...opts };
    if (opts.engine === 'stockfish') {
      body.threads = settings.get('engine.sfThreads');
      body.hash = settings.get('engine.sfHash');
    }
    const res = await api('/api/go', body);
    if (!res || res.error) {
      if (res?.error) console.warn('engine error:', res.error);
      await this.refresh();
      return null;
    }
    return res;
  }

  async newgame() { if (this.serverUp) await api('/api/newgame', {}); }
}

export const engines = new EngineService();

/* ================= continuous analyser ================= */

export class Analyser extends Emitter {
  constructor({ engine, instance = 'a', multipv = 1, stepMs = null } = {}) {
    super();
    this.engine = engine;
    this.instance = instance;
    this.multipv = multipv;
    this.stepMs = stepMs;
    this.running = false;
    this._gen = 0;
    this._pass = 0;           // passes on the current target (drives the time ramp)
    this._target = null;      // {fen, moves}
    this._loop = null;
    this.last = null;         // last info for current target
  }

  setEngine(key) { this.engine = key; this._bump(); }
  setMultiPv(n) { this.multipv = n; this._bump(); }

  setTarget(target) {
    this._target = target;
    this._bump();
  }

  _bump() {
    this._gen++;
    this._pass = 0;           // restart the time ramp for the new target
    this.last = null;
    this.emit('reset');
  }

  start() {
    if (this.running) return;
    this.running = true;
    this._run();
    this.emit('state', true);
  }

  stop() {
    this.running = false;
    this._gen++;
    this.emit('state', false);
  }

  toggle() { this.running ? this.stop() : this.start(); }

  async _run() {
    if (this._loop) return;
    this._loop = (async () => {
      while (this.running) {
        const target = this._target;
        const gen = this._gen;
        if (!target || !engines.has(this.engine)) {
          await new Promise(r => setTimeout(r, 300));
          continue;
        }
        // Time ramp: quick first pass for instant feedback, then each pass
        // roughly doubles up to a cap, so the search keeps reaching deeper the
        // longer a position is held (RushChess can't do a true `go infinite`).
        const base = this.stepMs || settings.get('analysis.stepMs');
        const cap = settings.get('analysis.maxStepMs') || 8000;
        const movetime = Math.round(Math.min(base * Math.pow(2, this._pass), cap));
        const res = await engines.go({
          engine: this.engine, instance: this.instance,
          fen: target.fen, moves: target.moves,
          movetime, multipv: this.multipv,
        });
        if (gen !== this._gen) continue;      // stale — target changed mid-pass
        if (res) {
          this.last = res;
          if (movetime < cap) this._pass = Math.min(this._pass + 1, 8);
          this.emit('info', res, target);
        } else {
          await new Promise(r => setTimeout(r, 600));
        }
      }
      this._loop = null;
    })();
  }
}

/* ================= evaluation display ================= */

/** white-centric score object from a UCI info line + side to move */
export function whiteScore(info, sideToMove) {
  const sign = sideToMove === 'w' ? 1 : -1;
  if (info.scoreMate != null) return { mate: sign * info.scoreMate };
  if (info.scoreCp != null) return { cp: sign * info.scoreCp };
  return null;
}

export function winPercent(cp) {
  // lichess model: 50 + 50 * (2/(1+exp(-0.00368208*cp)) - 1)
  return 50 + 50 * (2 / (1 + Math.exp(-0.00368208 * cp)) - 1);
}

export function fmtEval(score, mode) {
  if (!score) return '—';
  mode = mode || settings.get('analysis.evalDisplay');
  if (score.mate != null) return (score.mate > 0 ? '+M' : '−M') + Math.abs(score.mate);
  const cp = score.cp;
  if (mode === 'winpercent') return winPercent(cp).toFixed(0) + '%';
  if (mode === 'wdl') {
    const w = winPercent(cp) / 100;
    const l = winPercent(-cp) / 100;
    const d = Math.max(0, 1 - Math.abs(2 * w - 1)) * 0.6;
    const scale = (1 - d) / (w + l);
    return `${Math.round(w * scale * 100)}/${Math.round(d * 100)}/${Math.round(l * scale * 100)}`;
  }
  const p = cp / 100;
  return (p > 0 ? '+' : '') + p.toFixed(2);
}

export function evalClass(score) {
  if (!score) return '';
  const v = score.mate != null ? Math.sign(score.mate) * 100 : score.cp / 100;
  return v > 0.3 ? 'ev-pos' : v < -0.3 ? 'ev-neg' : '';
}

/** height % of the white part of an eval bar */
export function evalBarPercent(score) {
  if (!score) return 50;
  // a forced mate is decisive: fill the bar entirely with the winning side's
  // colour (no sliver of the losing side). A large but non-mate cp still keeps
  // a thin strip, since it isn't a forced win.
  if (score.mate != null) return score.mate > 0 ? 100 : 0;
  return Math.max(3, Math.min(97, winPercent(score.cp)));
}
