/* core/doc.js — GameDoc: a game as a variation tree.
   Nodes carry moves, comments, NAG annotations, per-node evaluations and
   clock times. Full PGN import/export including nested variations,
   comments and NAGs. This replaces the old linear moveLog model. */
'use strict';

import { Chess, START_FEN } from './rules.js';
import { Emitter } from '../ui/dom.js';

export const NAG_TEXT = {
  1: '!', 2: '?', 3: '!!', 4: '??', 5: '!?', 6: '?!',
  10: '=', 13: '∞', 14: '⩲', 15: '⩱', 16: '±', 17: '∓', 18: '+−', 19: '−+',
  22: '⨀', 32: '↑↑', 36: '↑', 40: '→', 132: '⇆', 146: 'N',
};
export const NAG_CLASS = { 1: 'good', 3: 'brilliant', 5: 'interesting', 6: 'dubious', 2: 'mistake', 4: 'blunder' };

let NEXT_ID = 1;

function newNode(parent, data) {
  return {
    id: NEXT_ID++,
    parent,
    children: [],
    uci: data?.uci || null,
    san: data?.san || null,
    move: data?.move || null,      // raw move object (from rules.js) — rebuilt on demand
    comment: data?.comment || '',
    nags: data?.nags || [],
    evalCp: data?.evalCp ?? null,  // white-centric centipawns
    evalMate: data?.evalMate ?? null,
    evalDepth: data?.evalDepth ?? null,
    clockMs: data?.clockMs ?? null,
    quality: data?.quality || null, // 'best'|'good'|'inaccuracy'|'mistake'|'blunder' (from review)
  };
}

export class GameDoc extends Emitter {
  constructor({ fen, headers } = {}) {
    super();
    this.startFen = fen || START_FEN;
    this.headers = Object.assign({
      Event: '-', Site: 'RushChess', Date: fmtDate(new Date()),
      Round: '-', White: 'White', Black: 'Black', Result: '*',
    }, headers || {});
    this.root = newNode(null, null);
    this.current = this.root;
    this._pos = null;         // cached Chess at current
    this._posNode = null;
    this.dirty = false;
  }

  /* ================= position access ================= */
  path(node) {
    const out = [];
    for (let n = node || this.current; n && n.parent !== undefined && n !== this.root; n = n.parent)
      out.unshift(n);
    return out;
  }

  positionAt(node) {
    const g = new Chess(this.startFen);
    for (const n of this.path(node)) {
      const m = g.findUci(n.uci);
      if (!m) break;                 // corrupt path safeguard
      g.play(m);                     // play() also feeds the repetition map
    }
    return g;
  }

  get position() {
    if (this._posNode !== this.current || !this._pos) {
      this._pos = this.positionAt(this.current);
      this._posNode = this.current;
    }
    return this._pos;
  }

  _invalidate() { this._pos = null; this._posNode = null; }

  ply(node) { return this.path(node).length; }
  moveNumberAt(node) {
    const startNo = parseInt(this.startFen.split(' ')[5] || '1', 10);
    const blackFirst = this.startFen.split(' ')[1] === 'b';
    const ply = this.ply(node);
    return startNo + Math.floor((ply - (blackFirst ? 0 : 1)) / 2);
  }
  isWhiteMove(node) {
    const blackFirst = this.startFen.split(' ')[1] === 'b';
    const ply = this.ply(node);
    return blackFirst ? ply % 2 === 0 : ply % 2 === 1;
  }

  mainline() {
    const out = [];
    for (let n = this.root; n.children.length; ) { n = n.children[0]; out.push(n); }
    return out;
  }
  tip() { let n = this.current; while (n.children.length) n = n.children[0]; return n; }
  isAtTipOfMainline() {
    return this.current.children.length === 0 && this.path().every(n => n.parent.children[0] === n);
  }

  result() {
    // terminal result at the current node (uses replay history for repetition)
    return this.position.result();
  }

  /* ================= mutation ================= */
  /** Play a move at the current node. Returns the (possibly existing) child node. */
  playMove(move) {
    const pos = this.position;
    const m = typeof move === 'string' ? pos.findUci(move) : move;
    if (!m) return null;
    const uci = pos.uci(m);
    const existing = this.current.children.find(c => c.uci === uci);
    if (existing) {
      this.current = existing;
      this._invalidate();
      this.emit('navigate', existing);
      return existing;
    }
    const san = pos.san(m);
    const node = newNode(this.current, { uci, san, move: m });
    this.current.children.push(node);
    this.current = node;
    this._invalidate();
    this.dirty = true;
    this.emit('change');
    this.emit('navigate', node);
    return node;
  }

  playSan(sanText) {
    const pos = this.position;
    const norm = s => s.replace(/[+#?!]+$/g, '').replace(/0-0-0/g, 'O-O-O').replace(/^0-0$/, 'O-O');
    const legal = pos.legalMoves();
    const m = legal.find(x => norm(pos.san(x, legal)) === norm(sanText));
    return m ? this.playMove(m) : null;
  }

  deleteNode(node) {
    if (!node || node === this.root) return;
    const sib = node.parent.children;
    const i = sib.indexOf(node);
    if (i >= 0) sib.splice(i, 1);
    if (this.path(this.current).includes(node) || this.current === node)
      this.current = node.parent;
    this._invalidate();
    this.dirty = true;
    this.emit('change');
    this.emit('navigate', this.current);
  }

  /** Move a variation up among its siblings (swap with elder). */
  promote(node) {
    const sib = node.parent.children;
    const i = sib.indexOf(node);
    if (i > 0) { [sib[i - 1], sib[i]] = [sib[i], sib[i - 1]]; this.dirty = true; this.emit('change'); }
  }

  /** Make the line through `node` the mainline all the way up. */
  promoteToMain(node) {
    for (let n = node; n && n !== this.root; n = n.parent) {
      const sib = n.parent.children;
      const i = sib.indexOf(n);
      if (i > 0) { sib.splice(i, 1); sib.unshift(n); }
    }
    this.dirty = true;
    this.emit('change');
  }

  setComment(node, text) { node.comment = text || ''; this.dirty = true; this.emit('change'); }
  toggleNag(node, nag) {
    const i = node.nags.indexOf(nag);
    if (i >= 0) node.nags.splice(i, 1);
    else {
      // move-quality NAGs 1..6 are mutually exclusive
      if (nag >= 1 && nag <= 6) node.nags = node.nags.filter(n => n > 6);
      node.nags.push(nag);
    }
    this.dirty = true;
    this.emit('change');
  }

  /** Truncate: delete everything after node on its branch. */
  truncateAfter(node) {
    node.children = [];
    this._invalidate();
    this.dirty = true;
    this.emit('change');
  }

  /* ================= navigation ================= */
  goTo(node) {
    if (!node || node === this.current) return;
    this.current = node;
    this._invalidate();
    this.emit('navigate', node);
  }
  goStart() { this.goTo(this.root); }
  goEnd() { this.goTo(this.tip()); }
  goPrev() { if (this.current.parent) this.goTo(this.current.parent); }
  goNext() { if (this.current.children[0]) this.goTo(this.current.children[0]); }
  goNextBranch() {
    // jump to next node (in mainline pre-order) that has siblings/comment
    let n = this.current;
    const flat = this.flatten();
    const i = flat.indexOf(n);
    for (let j = i + 1; j < flat.length; j++)
      if (flat[j].children.length > 1 || flat[j].comment || flat[j].nags.length) return this.goTo(flat[j]);
  }

  flatten() {
    const out = [];
    const walk = n => { if (n !== this.root) out.push(n); n.children.forEach(walk); };
    walk(this.root);
    return out;
  }

  findByUciPath(ucis) {
    let n = this.root;
    for (const u of ucis) {
      const c = n.children.find(x => x.uci === u);
      if (!c) return null;
      n = c;
    }
    return n;
  }

  /* ================= PGN ================= */
  pgn({ includeVariations = true, includeComments = true } = {}) {
    const h = { ...this.headers };
    let out = '';
    const order = ['Event', 'Site', 'Date', 'Round', 'White', 'Black', 'Result'];
    for (const k of order) out += `[${k} "${h[k] ?? '-'}"]\n`;
    for (const [k, v] of Object.entries(h))
      if (!order.includes(k) && v != null && v !== '') out += `[${k} "${v}"]\n`;
    if (this.startFen !== START_FEN) out += `[SetUp "1"]\n[FEN "${this.startFen}"]\n`;
    out += '\n';
    const text = this._lineText(this.root.children[0], true, includeVariations, includeComments).trim();
    out += text + (text ? ' ' : '') + (h.Result || '*') + '\n';
    return out;
  }

  /** Emit a line starting at `node` (following first children), with
      variations of each move emitted as (…) after it. */
  _lineText(node, forceNumber, vars, comments) {
    let out = '';
    let interrupted = forceNumber;
    while (node) {
      if (this.isWhiteMove(node)) out += this.moveNumberAt(node) + '. ';
      else if (interrupted) out += this.moveNumberAt(node) + '... ';
      out += node.san;
      for (const nag of node.nags) out += (nag <= 6 && NAG_TEXT[nag]) ? NAG_TEXT[nag] : ' $' + nag;
      out += ' ';
      interrupted = false;
      if (comments && node.comment) { out += `{${node.comment.replace(/[{}]/g, '')}} `; interrupted = true; }
      if (vars && node === node.parent.children[0] && node.parent.children.length > 1) {
        for (const alt of node.parent.children.slice(1)) {
          out += '(' + this._lineText2(alt, vars, comments).trim() + ') ';
        }
        interrupted = true;
      }
      node = node.children[0];
    }
    return out;
  }
  /** A variation line rooted at `alt` (alt itself + its continuation). */
  _lineText2(alt, vars, comments) {
    let out = '';
    if (this.isWhiteMove(alt)) out += this.moveNumberAt(alt) + '. ';
    else out += this.moveNumberAt(alt) + '... ';
    out += alt.san;
    for (const nag of alt.nags) out += (nag <= 6 && NAG_TEXT[nag]) ? NAG_TEXT[nag] : ' $' + nag;
    out += ' ';
    let interrupted = false;
    if (comments && alt.comment) { out += `{${alt.comment.replace(/[{}]/g, '')}} `; interrupted = true; }
    if (vars && alt.children.length > 1) {
      // continuation with its own variations handled by _lineText
    }
    out += this._lineText(alt.children[0], interrupted, vars, comments);
    return out;
  }

  /* ---- serialization for storage (compact JSON) ---- */
  serialize() {
    const enc = n => {
      const o = { u: n.uci, s: n.san };
      if (n.comment) o.c = n.comment;
      if (n.nags.length) o.n = n.nags;
      if (n.evalCp != null) o.e = n.evalCp;
      if (n.evalMate != null) o.m = n.evalMate;
      if (n.clockMs != null) o.t = n.clockMs;
      if (n.quality) o.q = n.quality;
      if (n.children.length) o.k = n.children.map(enc);
      return o;
    };
    return {
      headers: this.headers,
      startFen: this.startFen,
      tree: this.root.children.map(enc),
    };
  }

  static deserialize(data) {
    const doc = new GameDoc({ fen: data.startFen, headers: data.headers });
    const dec = (parent, o) => {
      const n = newNode(parent, {
        uci: o.u, san: o.s, comment: o.c, nags: o.n || [],
        evalCp: o.e, evalMate: o.m, clockMs: o.t, quality: o.q,
      });
      parent.children.push(n);
      (o.k || []).forEach(k => dec(n, k));
    };
    (data.tree || []).forEach(o => dec(doc.root, o));
    return doc;
  }
}

/* ================= PGN parsing (multi-game, variations, comments, NAGs) ===== */

/** Split a PGN file into raw game chunks. */
export function splitPgn(text) {
  const games = [];
  const lines = text.split(/\r?\n/);
  let cur = [], inMoves = false;
  for (const line of lines) {
    const isHeader = /^\s*\[\w+\s+"/.test(line);
    if (isHeader && inMoves) { games.push(cur.join('\n')); cur = []; inMoves = false; }
    if (!isHeader && line.trim() !== '') inMoves = true;
    cur.push(line);
  }
  if (cur.some(l => l.trim())) games.push(cur.join('\n'));
  return games.filter(g => g.trim());
}

export function parseHeaders(chunk) {
  const headers = {};
  const re = /\[(\w+)\s+"([^"]*)"\]/g;
  let m;
  while ((m = re.exec(chunk))) headers[m[1]] = m[2];
  return headers;
}

const SAN_NAG_SUFFIX = { '!!': 3, '??': 4, '!?': 5, '?!': 6, '!': 1, '?': 2 };

/** Parse one PGN game into a GameDoc. Throws with a useful message. */
export function parsePgnGame(text) {
  const headers = parseHeaders(text);
  const doc = new GameDoc({ fen: headers.FEN || undefined, headers });
  delete doc.headers.SetUp;
  delete doc.headers.FEN;

  // movetext = after last header line
  const body = text.replace(/\[(\w+)\s+"([^"]*)"\]\s*/g, '').replace(/;[^\n]*/g, ' ');

  // tokenize: comments, parens, NAGs, results, move numbers, SAN
  const tokens = body.match(/\{[^}]*\}|\(|\)|\$\d+|1-0|0-1|1\/2-1\/2|\*|[^\s()]+/g) || [];

  let cursor = doc.root;              // node after which the next move attaches
  const stack = [];
  const pos = () => doc.positionAt(cursor);

  for (const tokRaw of tokens) {
    let tok = tokRaw;
    if (tok.startsWith('{')) {
      const target = cursor === doc.root ? doc.root : cursor;
      target.comment = (target.comment ? target.comment + ' ' : '') + tok.slice(1, -1).trim();
      continue;
    }
    if (tok === '(') {
      // variation of the LAST move: attach alternatives at its parent
      stack.push(cursor);
      cursor = cursor.parent || doc.root;
      continue;
    }
    if (tok === ')') {
      cursor = stack.pop() || doc.root;
      continue;
    }
    if (tok.startsWith('$')) {
      if (cursor !== doc.root) cursor.nags.push(parseInt(tok.slice(1), 10));
      continue;
    }
    if (/^(1-0|0-1|1\/2-1\/2|\*)$/.test(tok)) { doc.headers.Result = tok; continue; }
    tok = tok.replace(/^\d+\.+/, '');            // strip "12." / "12..."
    if (!tok || /^\.+$/.test(tok)) continue;

    // suffix NAGs written on the SAN itself
    let sufNag = null;
    for (const [suf, nag] of Object.entries(SAN_NAG_SUFFIX)) {
      if (tok.endsWith(suf)) { sufNag = nag; tok = tok.slice(0, -suf.length); break; }
    }
    const clean = tok.replace(/[+#]$/, '').replace(/0-0-0/, 'O-O-O').replace(/^0-0$/, 'O-O');
    const g = pos();
    const legal = g.legalMoves();
    const norm = s => s.replace(/[+#]$/, '');
    const mv = legal.find(x => norm(g.san(x, legal)) === clean);
    if (!mv) throw new Error(`Illegal or unknown move "${tokRaw}" (after ${cursor.san || 'start'})`);
    const uci = g.uci(mv);
    let child = cursor.children.find(c => c.uci === uci);
    if (!child) {
      child = newNode(cursor, { uci, san: g.san(mv) });
      cursor.children.push(child);
    }
    cursor = child;
    if (sufNag) cursor.nags.push(sufNag);
  }
  doc.current = doc.root;
  doc.dirty = false;
  return doc;
}

/** Fast header+moves extraction for DB import (mainline only, no doc). */
export function scanPgnGame(text) {
  const headers = parseHeaders(text);
  let body = text.replace(/\[(\w+)\s+"([^"]*)"\]\s*/g, ' ')
    .replace(/\{[^}]*\}/g, ' ')
    .replace(/;[^\n]*/g, ' ')
    .replace(/\$\d+/g, ' ');
  let depth = 0, out = '';
  for (const ch of body) {           // strip nested variations without regex loops
    if (ch === '(') depth++;
    else if (ch === ')') depth = Math.max(0, depth - 1);
    else if (depth === 0) out += ch;
  }
  const tokens = out.split(/\s+/).filter(Boolean);
  const sans = [];
  let result = headers.Result || '*';
  for (let tok of tokens) {
    if (/^(1-0|0-1|1\/2-1\/2|\*)$/.test(tok)) { result = tok; break; }
    tok = tok.replace(/^\d+\.+/, '');
    if (!tok || /^\.+$/.test(tok)) continue;
    tok = tok.replace(/[?!]+$/, '');
    if (tok) sans.push(tok);
  }
  return { headers, sans, result };
}

export function figurine(san, on) {
  if (!on) return san;
  return san.replace(/[KQRBN]/g, c => ({ K: '♔', Q: '♕', R: '♖', B: '♗', N: '♘' }[c]));
}

function fmtDate(d) {
  return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
}
