/* svc/gamedb.js — game database on IndexedDB.
   Multi-database (named collections), PGN import with progress, duplicate
   detection, filtered queries, favorites/tags, and a position index that
   powers the opening explorer. */
'use strict';

import { idb } from './idb.js';
import { settings } from './settings.js';
import { Chess, START_FEN } from '../core/rules.js';
import { splitPgn, scanPgnGame, parsePgnGame } from '../core/doc.js';

function hash(str) {
  let h1 = 5381;
  for (let i = 0; i < str.length; i++) h1 = ((h1 << 5) + h1 + str.charCodeAt(i)) | 0;
  return (h1 >>> 0).toString(36);
}

/** compact position key: piece placement + side to move + castling + ep */
export function positionKey(fen) {
  return fen.split(' ').slice(0, 4).join(' ');
}

export const gamedb = {
  /* ---------- databases ---------- */
  async listDatabases() {
    const metas = await idb.getAll('meta');
    const dbs = metas.filter(m => m.key.startsWith('db:')).map(m => ({ name: m.key.slice(3), ...m }));
    return dbs.sort((a, b) => a.name.localeCompare(b.name));
  },

  async _bumpDb(name, delta) {
    const key = 'db:' + name;
    const meta = (await idb.get('meta', key)) || { key, count: 0, created: Date.now() };
    meta.count = Math.max(0, (meta.count || 0) + delta);
    meta.updated = Date.now();
    await idb.put('meta', meta);
  },

  async deleteDatabase(name) {
    const ids = await idb.indexKeys('games', 'db', name);
    // remove explorer entries per game
    for (let i = 0; i < ids.length; i += 300) {
      const chunk = ids.slice(i, i + 300);
      const posIds = [];
      for (const gid of chunk) posIds.push(...await idb.indexKeys('positions', 'gameId', gid));
      await idb.bulkDel('positions', posIds);
      await idb.bulkDel('games', chunk);
    }
    await idb.del('meta', 'db:' + name);
  },

  /* ---------- import ---------- */
  /** Import PGN text into `dbName`. onProgress(done,total). Returns {added, dups, failed}. */
  async importPgn(dbName, text, { onProgress, indexPositions = true, skipDups = true } = {}) {
    const chunks = splitPgn(text);
    let added = 0, dups = 0, failed = 0;
    const batch = [];
    const existingKeys = skipDups ? new Set((await idb.getAll('games')).map(g => g.dupKey)) : new Set();
    for (let i = 0; i < chunks.length; i++) {
      try {
        const { headers, sans, result } = scanPgnGame(chunks[i]);
        if (!sans.length && !headers.FEN) { failed++; continue; }
        const dupKey = hash(sans.join(' ') + '|' + (headers.White || '') + '|' + (headers.Black || ''));
        if (skipDups && existingKeys.has(dupKey)) { dups++; continue; }
        existingKeys.add(dupKey);
        batch.push({
          db: dbName,
          white: headers.White || '?', black: headers.Black || '?',
          whiteElo: parseInt(headers.WhiteElo, 10) || null,
          blackElo: parseInt(headers.BlackElo, 10) || null,
          event: headers.Event || '?', site: headers.Site || '',
          round: headers.Round || '', date: headers.Date || '????.??.??',
          result: result || headers.Result || '*',
          eco: headers.ECO || '', opening: headers.Opening || '',
          fen: headers.FEN || null,
          plyCount: sans.length, sans, pgn: chunks[i],
          dupKey, tags: [], fav: false, imported: Date.now(),
        });
      } catch { failed++; }
      if (batch.length >= 200 || i === chunks.length - 1) {
        const ids = await addBatch(batch);
        added += ids.length;
        if (indexPositions) await indexBatch(batch, ids);
        batch.length = 0;
        onProgress && onProgress(i + 1, chunks.length);
        await new Promise(r => setTimeout(r));   // yield to UI
      }
    }
    await this._bumpDb(dbName, added);
    return { added, dups, failed, total: chunks.length };
  },

  /** Save a finished GameDoc (play mode, matches, tournaments). */
  async saveDoc(dbName, doc, extraTags = []) {
    const h = doc.headers;
    const sans = doc.mainline().map(n => n.san);
    const rec = {
      db: dbName,
      white: h.White || '?', black: h.Black || '?',
      whiteElo: parseInt(h.WhiteElo, 10) || null, blackElo: parseInt(h.BlackElo, 10) || null,
      event: h.Event || 'Casual game', site: h.Site || 'RushChess', round: h.Round || '',
      date: h.Date || '????.??.??', result: h.Result || '*',
      eco: h.ECO || '', opening: h.Opening || '',
      fen: doc.startFen !== START_FEN ? doc.startFen : null,
      plyCount: sans.length, sans, pgn: doc.pgn(),
      dupKey: hash(sans.join(' ') + '|' + (h.White || '') + '|' + (h.Black || '') + '|' + Date.now()),
      tags: extraTags, fav: false, imported: Date.now(),
    };
    const id = await idb.add('games', rec);
    await indexBatch([rec], [id]);
    await this._bumpDb(dbName, 1);
    return id;
  },

  async updateGame(id, patch) {
    const g = await idb.get('games', id);
    if (!g) return;
    Object.assign(g, patch);
    await idb.put('games', g);
  },

  async deleteGames(ids) {
    for (const gid of ids) {
      const posIds = await idb.indexKeys('positions', 'gameId', gid);
      await idb.bulkDel('positions', posIds);
      const g = await idb.get('games', gid);
      if (g) await this._bumpDb(g.db, -1);
    }
    await idb.bulkDel('games', ids);
  },

  async getGame(id) { return idb.get('games', id); },

  /** Load a full GameDoc (with variations) from a stored game. */
  async loadDoc(id) {
    const g = await idb.get('games', id);
    if (!g) return null;
    const doc = parsePgnGame(g.pgn);
    doc.headers.Result = g.result;
    doc._dbId = id;
    return doc;
  },

  /* ---------- query ---------- */
  /** Filtered listing. Returns array of records (no pgn body for speed). */
  async query(f = {}) {
    const out = [];
    const test = g => {
      if (f.db && g.db !== f.db) return false;
      if (f.player && !(g.white.toLowerCase().includes(f.player) || g.black.toLowerCase().includes(f.player))) return false;
      if (f.white && !g.white.toLowerCase().includes(f.white)) return false;
      if (f.black && !g.black.toLowerCase().includes(f.black)) return false;
      if (f.event && !g.event.toLowerCase().includes(f.event)) return false;
      if (f.eco && !(g.eco || '').toUpperCase().startsWith(f.eco)) return false;
      if (f.result && g.result !== f.result) return false;
      if (f.minElo && !((g.whiteElo || 0) >= f.minElo || (g.blackElo || 0) >= f.minElo)) return false;
      if (f.dateFrom && g.date < f.dateFrom) return false;
      if (f.dateTo && g.date.slice(0, 4) > f.dateTo.slice(0, 4) && g.date !== '????.??.??') return false;
      if (f.fav && !g.fav) return false;
      if (f.tag && !(g.tags || []).includes(f.tag)) return false;
      if (f.opening) {
        const line = g.sans.slice(0, 16).join(' ').toLowerCase();
        if (!line.startsWith(f.opening.toLowerCase())) return false;
      }
      return true;
    };
    const limit = f.limit || 100000;
    await idb.each('games', f.db ? 'db' : null, f.db || null, g => {
      if (test(g)) {
        const { pgn, sans, ...meta } = g;
        out.push({ ...meta, sans: sans.slice(0, 12) });
        if (out.length >= limit) return false;
      }
    });
    return out;
  },

  async findDuplicates(dbName) {
    const seen = new Map(), dups = [];
    await idb.each('games', dbName ? 'db' : null, dbName || null, g => {
      const key = g.sans.join(' ') + '|' + g.result;
      if (seen.has(key)) dups.push({ keep: seen.get(key), dup: g.id });
      else seen.set(key, g.id);
    });
    return dups;
  },

  async stats(dbName) {
    let games = 0, white = 0, black = 0, draw = 0, elosum = 0, elocnt = 0;
    const players = new Set(), events = new Set();
    await idb.each('games', dbName ? 'db' : null, dbName || null, g => {
      games++;
      if (g.result === '1-0') white++;
      else if (g.result === '0-1') black++;
      else if (g.result === '1/2-1/2') draw++;
      players.add(g.white); players.add(g.black);
      events.add(g.event);
      if (g.whiteElo) { elosum += g.whiteElo; elocnt++; }
      if (g.blackElo) { elosum += g.blackElo; elocnt++; }
    });
    return { games, white, black, draw, players: players.size, events: events.size, avgElo: elocnt ? Math.round(elosum / elocnt) : null };
  },

  /* ---------- opening explorer ---------- */
  /** Move stats at a position: [{san, count, w, d, l, avgElo}] */
  async explorer(fen, dbName) {
    const key = positionKey(fen);
    const rows = await idb.index('positions', 'key', key);
    const map = new Map();
    for (const r of rows) {
      if (dbName && r.db !== dbName) continue;
      let e = map.get(r.san);
      if (!e) { e = { san: r.san, count: 0, w: 0, d: 0, l: 0, elo: 0, eloN: 0, recent: 0 }; map.set(r.san, e); }
      e.count++;
      if (r.result === '1-0') e.w++;
      else if (r.result === '0-1') e.l++;
      else if (r.result === '1/2-1/2') e.d++;
      if (r.elo) { e.elo += r.elo; e.eloN++; }
      if (r.year >= 2020) e.recent++;
    }
    return [...map.values()]
      .map(e => ({ ...e, avgElo: e.eloN ? Math.round(e.elo / e.eloN) : null }))
      .sort((a, b) => b.count - a.count);
  },

  async gamesAtPosition(fen, dbName, limit = 50) {
    const key = positionKey(fen);
    const rows = await idb.index('positions', 'key', key);
    const ids = [...new Set(rows.filter(r => !dbName || r.db === dbName).map(r => r.gameId))].slice(0, limit);
    const out = [];
    for (const id of ids) {
      const g = await idb.get('games', id);
      if (g) { const { pgn, sans, ...meta } = g; out.push(meta); }
    }
    return out;
  },
};

async function addBatch(batch) {
  const ids = [];
  for (const rec of batch) ids.push(await idb.add('games', rec));
  return ids;
}

async function indexBatch(batch, ids) {
  const maxPly = settings.get('perf.explorerPlies');
  const rows = [];
  for (let i = 0; i < batch.length; i++) {
    const g = batch[i];
    if (g.fen) continue;                     // only index standard-start games
    const year = parseInt(g.date, 10) || 0;
    const elo = Math.round(((g.whiteElo || 0) + (g.blackElo || 0)) / ((g.whiteElo ? 1 : 0) + (g.blackElo ? 1 : 0) || 1)) || null;
    try {
      const pos = new Chess();
      const legalCache = () => pos.legalMoves();
      for (let p = 0; p < Math.min(g.sans.length, maxPly); p++) {
        const key = positionKey(pos.fen());
        const legal = legalCache();
        const norm = s => s.replace(/[+#]/g, '');
        const mv = legal.find(x => norm(pos.san(x, legal)) === norm(g.sans[p]));
        if (!mv) break;
        rows.push({ key, gameId: ids[i], db: g.db, san: pos.san(mv, legal), result: g.result, ply: p, elo, year });
        pos.makeMove(mv);
      }
    } catch { /* index what we could */ }
  }
  if (rows.length) await idb.bulkAdd('positions', rows);
}
