// In-browser game host. The portal builds (CrazyGames et al.) ship no server:
// this module runs the same RoomManager / Match / Store the node transport runs
// and speaks the identical JSON protocol over an in-memory socket, so game.js
// cannot tell the difference. Solo vs bots only — no matchmaking, no accounts.
//
// Deliberately DOM-free so test/local-host.test.js can drive it under node.

import { CFG, PALETTE } from '../../server/sim.js';
import { RoomManager, GAME_MODES, GAME_MAPS } from '../../server/room.js';
import { Store, memoryIO } from '../../server/store-core.js';
import { isFlagPattern } from '../flags.js';

const PLAYER_COLORS = new Set(PALETTE.slice(0, 6));
const SHAPES = new Set(['blade', 'heavy', 'wing', 'overclock']);
const BASE_PATTERNS = new Set(['solid', 'dashed', 'pulse', 'chevron',
  'gold', 'rainbow', 'lightning', 'nyan']);
const FREE_SET = new Set(['solid', 'dashed']);
const validPattern = (p) => BASE_PATTERNS.has(p) || isFlagPattern(p);

// Nobody else is coming, so the public lobby only has to cover the join
// animation instead of waiting for humans that will never show up.
export const LOCAL_ROOM_CFG = { publicWait: 0.6, restartWait: 2 };

function sanitizeName(name) {
  const s = String(name || '').replace(/[^\w\-. Ѐ-ӿ]/g, '').trim().slice(0, 14);
  return s || 'RIDER-' + Math.floor(Math.random() * 900 + 100);
}

// localStorage-backed persistence; falls back to memory in tests / private mode
export function localStorageIO(key = 'tron.profiles') {
  try {
    const ls = globalThis.localStorage;
    ls.getItem(key);
    return {
      read: () => ls.getItem(key),
      write: (data) => { try { ls.setItem(key, data); } catch { /* quota */ } },
    };
  } catch {
    return memoryIO();
  }
}

// Quacks like a WebSocket, including the part where nothing is delivered until
// the consumer has had a turn to attach handlers — the host greets a client the
// moment it connects, and that hello has to survive the wait.
class LocalSocket {
  constructor(host) {
    this.host = host;
    this.readyState = 0;
    this.queue = [];
    this.onopen = this.onmessage = this.onclose = this.onerror = null;
    setTimeout(() => {
      if (this.readyState !== 0) return;
      this.readyState = 1;
      this.onopen?.();
      const pending = this.queue;
      this.queue = [];
      for (const data of pending) this.onmessage?.({ data });
    }, 0);
  }

  send(raw) {
    if (this.readyState !== 1) return;
    let msg;
    try { msg = JSON.parse(raw); } catch { return; }
    if (msg && typeof msg.t === 'string') this.host.handle(this, msg);
  }

  deliver(obj) {
    if (this.readyState > 1) return;
    const data = JSON.stringify(obj);
    if (this.readyState === 0) this.queue.push(data);
    else this.onmessage?.({ data });
  }

  close() {
    if (this.readyState === 3) return;
    const wasOpen = this.readyState === 1;
    this.readyState = 3;
    this.queue = [];
    this.host.drop(this);
    if (wasOpen) this.onclose?.();
  }
}

export class LocalHost {
  constructor({ io = localStorageIO(), simCfg = {}, roomCfg = LOCAL_ROOM_CFG } = {}) {
    this.manager = new RoomManager(simCfg, roomCfg);
    this.store = new Store(io);
    this.clients = new Map();     // socket -> {memberId, room, profile}
    this.roomSockets = new Map(); // room -> Set<socket>
    this.tickCount = 0;
    this.timer = null;
    this.acc = 0;
    this.last = 0;
  }

  connect() {
    const sock = new LocalSocket(this);
    this.clients.set(sock, { memberId: null, room: null, profile: null });
    sock.deliver({ t: 'hello', cfg: { arena: CFG.arena }, tg: null, local: true });
    return sock;
  }

  drop(sock) {
    const c = this.clients.get(sock);
    if (c) this.leaveRoom(sock, c);
    this.clients.delete(sock);
  }

  joinSocketToRoom(sock, c, room, member) {
    c.room = room;
    c.memberId = member.id;
    if (!this.roomSockets.has(room)) this.roomSockets.set(room, new Set());
    this.roomSockets.get(room).add(sock);
    sock.deliver(room.fullState(member.id));
  }

  leaveRoom(sock, c) {
    if (!c.room) return;
    c.room.removeHuman(c.memberId);
    const set = this.roomSockets.get(c.room);
    if (set) { set.delete(sock); if (set.size === 0) this.roomSockets.delete(c.room); }
    c.room = null;
    c.memberId = null;
  }

  handle(sock, msg) {
    const c = this.clients.get(sock);
    if (!c) return;
    const store = this.store;
    switch (msg.t) {
      case 'auth': {
        const token = String(msg.token || '').slice(0, 64);
        if (token.length < 8) break;
        c.profile = store.auth(token, sanitizeName(msg.name));
        sock.deliver({ t: 'profile', p: store.publicProfile(c.profile) });
        break;
      }
      case 'buy': {
        if (!c.profile) break;
        const res = store.buy(c.profile, String(msg.item || ''));
        sock.deliver(res.error
          ? { t: 'buyfail', item: msg.item, reason: res.error }
          : { t: 'profile', p: store.publicProfile(c.profile), bought: msg.item });
        break;
      }
      case 'ad_done': {
        if (!c.profile) break;
        const res = store.adReward(c.profile);
        sock.deliver(res.error
          ? { t: 'buyfail', item: 'ad', reason: res.error }
          : { t: 'profile', p: store.publicProfile(c.profile), adReward: res.shards });
        break;
      }
      case 'play': {
        if (c.room) this.leaveRoom(sock, c);
        const name = sanitizeName(msg.name);
        const mode = GAME_MODES.includes(msg.mode) ? msg.mode : 'classic';
        const map = GAME_MAPS.includes(msg.map) ? msg.map : 'empty';
        // every local room is a fresh solo room; codes and invites mean nothing
        const room = this.manager.joinPublic(mode, map);
        if (!room) { sock.deliver({ t: 'joinfail', reason: 'room unavailable' }); break; }
        const color = PLAYER_COLORS.has(msg.color) ? msg.color : undefined;
        let shape = SHAPES.has(msg.shape) ? msg.shape : undefined;
        let pattern = validPattern(msg.pattern) ? msg.pattern : undefined;
        if (c.profile) {
          const owned = store.owned(c.profile);
          if (pattern && !owned.patterns.has(pattern)) pattern = undefined;
          if (shape && !owned.shapes.has(shape)) shape = undefined;
          c.profile.name = name;
          store.save();
        } else {
          if (pattern && !FREE_SET.has(pattern)) pattern = undefined;
          if (shape === 'overclock') shape = undefined;
        }
        const member = room.addHuman(name, color, shape, pattern);
        if (!member) { sock.deliver({ t: 'joinfail', reason: 'room is full' }); break; }
        this.joinSocketToRoom(sock, c, room, member);
        break;
      }
      case 'leave': this.leaveRoom(sock, c); break;
      case 'steer':
        if (c.room?.match && Number.isFinite(msg.v)) c.room.match.setSteer(c.memberId, msg.v);
        break;
      case 'boost':
        if (c.room?.match) c.room.match.setBoost(c.memberId, !!msg.on);
        break;
      case 'use':
        if (c.room?.match) c.room.match.useItem(c.memberId);
        break;
      case 'ping': sock.deliver({ t: 'pong', now: msg.now }); break;
    }
  }

  // one simulation step + the broadcasts the node transport would have sent
  tick(dt) {
    this.tickCount++;
    this.manager.tick(dt);

    for (const [room, sockets] of this.roomSockets) {
      const events = room.drainEvents();
      if (events.length) for (const s of sockets) s.deliver({ t: 'ev', list: events });
      if (room.match && !room.match.over) {
        const snap = room.match.snapshot();
        for (const s of sockets) s.deliver(snap);
      }
      if (room.match && this.tickCount % 6 === 0) {
        for (const s of sockets) {
          const c = this.clients.get(s);
          if (!c || c.room !== room) continue;
          const b = room.match.bikes.get(c.memberId);
          if (!b) continue;
          s.deliver({
            t: 'me', energy: Math.round(b.energy), kills: b.kills,
            item: b.item, ch: b.itemCharges,
            lap: room.match.race ? Math.min(b.lap + 1, room.match.cfg.raceLaps) : undefined,
            cp: room.match.race ? b.nextCp : undefined,
          });
        }
      }
      if (room.lastResult && !room.lastResult.consumed) {
        const result = room.lastResult;
        result.consumed = true;
        for (const s of sockets) {
          const c = this.clients.get(s);
          if (!c || c.room !== room || !c.profile) continue;
          const place = result.standings.findIndex(x => x.id === c.memberId);
          if (place === -1) continue; // spectated this round
          const st = result.standings[place];
          const summary = this.store.applyRound(c.profile, {
            placement: place + 1,
            riders: result.riders,
            kills: st.kills,
            win: c.memberId === result.winnerId,
            pickups: st.pickups || 0,
            uses: st.uses || 0,
            survived: st.survived || 0,
            boosted: st.boosted || 0,
            padHits: st.padHits || 0,
            mode: room.mode,
            map: room.map,
          });
          s.deliver({ t: 'reward', ...summary, place: place + 1, p: this.store.publicProfile(c.profile) });
        }
      }
    }
    if (this.tickCount % 150 === 0) this.manager.gc();
  }

  // browser driver: fixed 30 Hz steps off a coarser wall clock
  start(now = () => performance.now()) {
    if (this.timer) return;
    const dt = 1 / CFG.tickRate;
    this.last = now();
    this.timer = setInterval(() => {
      const t = now();
      this.acc = Math.min(0.25, this.acc + (t - this.last) / 1000);
      this.last = t;
      while (this.acc >= dt) { this.acc -= dt; this.tick(dt); }
    }, 1000 / CFG.tickRate / 2);
  }

  stop() {
    if (this.timer) clearInterval(this.timer);
    this.timer = null;
  }
}
