import { config } from './config.js';
import { createRedis } from './redisClient.js';
import { createPool } from './db.js';
import { initMySqlSupport, createMatchRow, updateMatchPerSecond, endMatch, insertMatchTick, processMatchResult } from './mysqlWriter.js';
import { verifyTicket } from './ticket.js';
import { enqueue, leaveQueue, dequeuePair } from './matchmaking.js';
import { createInitialState, step, isSetOver, resetBall } from './physics.js';
import { randomId } from './crypto.js';
import { saveMatchSnapshot, loadMatchSnapshot } from './matchStore.js';
import { sendRewardsRequest } from './rewardsClient.js';
import { createHttpAndWs } from './server.js';
import {
  WORKER_ID,
  setupIpcSubscriber,
  ipcSend,
  registerUserWorker,
  unregisterUserWorker,
  getUserWorker,
  registerMatchWorker,
  unregisterMatchWorker,
  getMatchWorker,
} from './ipc.js';

let startupError = null;
let redis = null;
let pool = null;
let mysqlSupport = null;
let mysqlStartupError = null;

// Dedicated Redis client for Pub/Sub subscriptions (subscribe mode blocks regular commands)
let redisSub = null;

const connections = new Map(); // ws -> session
const userSockets = new Map(); // userId -> ws
const activeMatches = new Map(); // matchId -> match
const userProfiles = new Map(); // userId -> { username }
const WS_CONNECTING = 0;
const WS_OPEN = 1;

function nowUtc() {
  // MySQL expects UTC string
  const d = new Date();
  return d.toISOString().slice(0, 19).replace('T', ' ');
}

function safeSend(ws, msg) {
  if (ws.readyState !== ws.OPEN) return;
  ws.send(JSON.stringify(msg));
}

function isSocketActive(ws) {
  return !!ws && (ws.readyState === WS_CONNECTING || ws.readyState === WS_OPEN);
}

/**
 * Send a message to a user by userId.
 * Fast path: local userSockets (same worker).
 * Slow path: publish to the owner worker's IPC channel via Redis.
 */
async function safeSendToUser(userId, msg) {
  const ws = userSockets.get(Number(userId));
  if (ws) {
    safeSend(ws, msg);
    return;
  }
  if (!redis || redis.mode === 'memory') return;
  const targetWorker = await getUserWorker(redis, userId);
  if (targetWorker && targetWorker !== WORKER_ID) {
    await ipcSend(redis, targetWorker, { type: 'ws.send', userId: Number(userId), msg });
  }
}

function getStatus() {
  return {
    ok: startupError === null,
    startupError,
    redisReady: !!redis,
    redisMode: redis?.mode ?? null,
    mysqlReady: !!pool && !!mysqlSupport,
    mysqlStartupError,
    connections: connections.size,
    activeMatches: activeMatches.size,
  };
}

function isRuntimeReady() {
  return startupError === null && !!redis;
}

function getUsernameByUserId(userId) {
  const ws = userSockets.get(userId);
  const session = ws ? connections.get(ws) : null;
  if (session?.username) {
    return session.username;
  }

  return userProfiles.get(userId)?.username || null;
}

function isValidUsername(username) {
  return typeof username === 'string' && /^[A-Za-z0-9_&!]{1,20}$/.test(username.trim());
}

async function resolveUsernameByUserId(userId) {
  // Live WS session is most authoritative (comes from signed ticket of current connection)
  const ws = userSockets.get(userId);
  const liveUsername = ws ? connections.get(ws)?.username : null;
  if (isValidUsername(liveUsername)) {
    const normalized = liveUsername.trim();
    userProfiles.set(Number(userId), { username: normalized });
    return normalized;
  }

  // DB is authoritative over stale in-memory cache
  if (pool) {
    try {
      const [rows] = await pool.query('SELECT username FROM users WHERE id = ? LIMIT 1', [Number(userId)]);
      const username = rows?.[0]?.username ? String(rows[0].username).trim() : '';
      if (isValidUsername(username)) {
        userProfiles.set(Number(userId), { username });
        return username;
      }
    } catch (error) {
      console.error('[matchmaking] username lookup failed', error);
    }
  }

  // Last resort: stale in-memory cache
  const fromCache = userProfiles.get(Number(userId))?.username;
  if (isValidUsername(fromCache)) return fromCache.trim();

  return null;
}

function attachWsHandlers(ws) {
  ws.on('message', async (buf) => {
    let msg;
    try {
      msg = JSON.parse(buf.toString('utf8'));
    } catch {
      safeSend(ws, { type: 'error', error: 'invalid_json' });
      return;
    }

    const session = connections.get(ws) ?? {};

    if (msg?.type === 'hello') {
      if (!isRuntimeReady()) {
        safeSend(ws, { type: 'hello.error', error: startupError || 'server_not_ready' });
        ws.close(1013, 'Server unavailable');
        return;
      }

      const result = verifyTicket(config.sharedSecret, msg.ticket);
      if (!result.ok) {
        safeSend(ws, { type: 'hello.error', error: result.error });
        ws.close(1008, 'Auth failed');
        return;
      }

      const { userId, username } = result.payload;
      session.userId = Number(userId);
      session.username = username;
      userProfiles.set(session.userId, { username });

      const existingWs = userSockets.get(session.userId);
      if (existingWs && existingWs !== ws && isSocketActive(existingWs)) {
        safeSend(ws, { type: 'hello.error', error: 'duplicate_session' });
        ws.close(1008, 'Duplicate session');
        return;
      }

      connections.set(ws, session);
      userSockets.set(session.userId, ws);
      // Register this worker as the owner of userId's WS for cross-worker routing.
      void registerUserWorker(redis, session.userId);

      // Try resume if client provides matchId hint
      if (msg.matchId) {
        const match = activeMatches.get(msg.matchId);
        if (match) {
          match.reconnect(session.userId, ws);
        } else if (redis) {
          // Match lives on a different worker — notify it about the new WS location.
          const ownerWorker = await getMatchWorker(redis, msg.matchId);
          if (ownerWorker && ownerWorker !== WORKER_ID) {
            // Another worker owns this match — forward reconnect request and send any snapshot
            await ipcSend(redis, ownerWorker, {
              type: 'match.reconnect',
              matchId: msg.matchId,
              userId: session.userId,
            });
            const snap = await loadMatchSnapshot(redis, msg.matchId);
            if (snap) safeSend(ws, { type: 'match.snapshot', snapshot: snap });
          }
          // If ownerWorker is null or this worker, don't send stale snapshots.
          // This prevents "ghost matches" after server restart where old snapshots still exist in Redis.
        }
      }

      safeSend(ws, { type: 'hello.ok', userId: session.userId });
      return;
    }

    if (!session.userId) {
      safeSend(ws, { type: 'error', error: 'not_authenticated' });
      return;
    }

    if (msg?.type === 'queue.join') {
      if (!isRuntimeReady()) {
        safeSend(ws, { type: 'error', error: startupError || 'server_not_ready' });
        return;
      }

      const currentWs = userSockets.get(session.userId);
      if (currentWs && currentWs !== ws && isSocketActive(currentWs)) {
        safeSend(ws, { type: 'error', error: 'duplicate_session' });
        return;
      }

      if (session.matchId) {
        safeSend(ws, { type: 'error', error: 'already_in_match' });
        return;
      }

      const queueSize = await enqueue(redis, session.userId);
      session.inQueue = true;
      connections.set(ws, session);
      safeSend(ws, { type: 'queue.status', status: 'searching', queueSize });
      return;
    }

    if (msg?.type === 'queue.leave') {
      if (!redis) {
        safeSend(ws, { type: 'queue.status', status: 'idle' });
        return;
      }

      await leaveQueue(redis, session.userId);
      session.inQueue = false;
      connections.set(ws, session);
      safeSend(ws, { type: 'queue.status', status: 'idle' });
      return;
    }

    if (msg?.type === 'match.input') {
      const matchId = session.matchId;
      if (!matchId) return;
      const match = activeMatches.get(matchId);
      if (match) {
        match.onInput(session.userId, msg);
      } else if (redis) {
        // Match lives on a different worker — forward input via IPC.
        const ownerWorker = await getMatchWorker(redis, matchId);
        if (ownerWorker && ownerWorker !== WORKER_ID) {
          await ipcSend(redis, ownerWorker, {
            type: 'match.input',
            matchId,
            userId: session.userId,
            msg,
          });
        }
      }
      return;
    }

    if (msg?.type === 'ping') {
      safeSend(ws, { type: 'pong', t: msg.t });
      // Forward sender's rtt to their opponent so the opponent can see ping quality
      if (session.matchId && typeof msg.rtt === 'number' && Number.isFinite(msg.rtt) && msg.rtt >= 0) {
        const match = activeMatches.get(session.matchId);
        if (match) {
          match.onPing(session.userId, Math.round(msg.rtt));
        } else if (redis) {
          // Match lives on a different worker — forward ping via IPC.
          const ownerWorker = await getMatchWorker(redis, session.matchId);
          if (ownerWorker && ownerWorker !== WORKER_ID) {
            await ipcSend(redis, ownerWorker, {
              type: 'match.ping',
              matchId: session.matchId,
              userId: session.userId,
              rttMs: Math.round(msg.rtt),
            });
          }
        }
      }
      return;
    }

    if (msg?.type === 'match.leave') {
      const matchId = session.matchId;
      if (!matchId) return;
      session.intentionalMatchLeave = true;
      connections.set(ws, session);
      const match = activeMatches.get(matchId);
      if (match) {
        match.onForfeit(session.userId, 'user_left');
      } else if (redis) {
        // Match lives on a different worker — forward intentional leave via IPC.
        const ownerWorker = await getMatchWorker(redis, matchId);
        if (ownerWorker && ownerWorker !== WORKER_ID) {
          await ipcSend(redis, ownerWorker, {
            type: 'match.leave',
            matchId,
            userId: session.userId,
            reason: 'user_left',
          });
        }
      }
      return;
    }

    safeSend(ws, { type: 'error', error: 'unknown_type' });
  });

  ws.on('close', () => {
    const session = connections.get(ws);
    connections.delete(ws);
    if (session?.userId) {
      const cur = userSockets.get(session.userId);
      if (cur === ws) {
        userSockets.delete(session.userId);
        void unregisterUserWorker(redis, session.userId);
      }
      if (!session.matchId && redis) {
        void leaveQueue(redis, session.userId).catch(() => {});
      }
      if (session.matchId) {
        if (!session.intentionalMatchLeave) {
          const match = activeMatches.get(session.matchId);
          if (match) {
            match.onDisconnect(session.userId);
          } else if (redis) {
            // Match may be owned by another worker; forward disconnect via IPC.
            void (async () => {
              try {
                const ownerWorker = await getMatchWorker(redis, session.matchId);
                if (ownerWorker && ownerWorker !== WORKER_ID) {
                  await ipcSend(redis, ownerWorker, {
                    type: 'match.disconnect',
                    matchId: session.matchId,
                    userId: session.userId,
                  });
                }
              } catch (e) {
                console.error('[ipc] match.disconnect forward error', e);
              }
            })();
          }
        }
      }
    }
  });
}

class Match {
  constructor({ matchId, leftUserId, rightUserId, leftUsername, rightUsername, mysqlMatchId }) {
    if (!leftUsername || !rightUsername) {
      throw new Error('match_requires_valid_usernames');
    }

    this.matchId = matchId;
    this.mysqlMatchId = mysqlMatchId ?? null;
    this.players = {
      left: {
        userId: leftUserId,
        username: leftUsername,
        ws: userSockets.get(leftUserId) ?? null,
        input: { move: 0, targetY: null },
        disconnectedAt: null,
        lastSeenAt: Date.now(),
      },
      right: {
        userId: rightUserId,
        username: rightUsername,
        ws: userSockets.get(rightUserId) ?? null,
        input: { move: 0, targetY: null },
        disconnectedAt: null,
        lastSeenAt: Date.now(),
      },
    };
    this.seed = Math.floor(Math.random() * 1e9);
    this.state = createInitialState(this.seed);
    this.pointsToWin = 11;
    this.setsToWin = 3;
    this.sets = { left: 0, right: 0 };
    this.currentSet = 1;
    this.warmupMs = 10_000;
    this.warmupEndsAt = Date.now() + this.warmupMs;
    this.pausePhase = 'set_break'; // starts in set_break; real game begins 3s after warmup
    this.resumeAt = this.warmupEndsAt + 3000;
    this.disconnectChecksEnabledAt = this.resumeAt;
    this.disconnectChecksArmed = false;
    this._preStartBroadcastDone = false;

    this._lastTickMs = Date.now();
    this._lastRedisSnapshotMs = 0;
    this._lastMysqlUpdateMs = 0;
    this._scoreFlushNeeded = false;
    this._ended = false;

    this._broadcast(this._matchFoundPayload('left'), 'left');
    this._broadcast(this._matchFoundPayload('right'), 'right');

    // Register this worker as the owner of the match for cross-worker IPC routing.
    if (redis) void registerMatchWorker(redis, matchId);

    // attach matchId to sessions
    for (const side of ['left', 'right']) {
      const userId = this.players[side].userId;
      const ws = userSockets.get(userId);
      if (ws) {
        const s = connections.get(ws) ?? {};
        s.matchId = this.matchId;
        s.inQueue = false;
        connections.set(ws, s);
      }
    }
  }

  reconnect(userId, ws) {
    const side = this._sideOf(userId);
    if (!side) return;
    this.players[side].ws = ws;
    const wasDisconnected = this.players[side].disconnectedAt != null;
    this.players[side].disconnectedAt = null;
    this.players[side].lastSeenAt = Date.now();

    const session = connections.get(ws) ?? {};
    if (session.username) {
      this.players[side].username = session.username;
    }

    const s = session;
    s.matchId = this.matchId;
    connections.set(ws, s);

    safeSend(ws, {
      type: 'match.reconnected',
      matchId: this.matchId,
      side,
      opponentUserId: this.players[side === 'left' ? 'right' : 'left'].userId,
      opponentUsername: this.players[side === 'left' ? 'right' : 'left'].username,
      warmupEndsAt: this.warmupEndsAt,
      pointsToWin: this.pointsToWin,
      setsToWin: this.setsToWin,
    });
    safeSend(ws, { type: 'match.state', matchId: this.matchId, state: this._publicState() });

    // Tell the opponent that this player is back online.
    if (wasDisconnected) {
      const opponentSide = side === 'left' ? 'right' : 'left';
      this._broadcast({ type: 'opponent.status', status: 'connected' }, opponentSide);
    }
  }

  onInput(userId, msg) {
    const side = this._sideOf(userId);
    if (!side) return;
    const move = Number(msg.move ?? 0);
    if (![ -1, 0, 1 ].includes(move)) return;

    let targetY = null;
    if (msg.targetY !== null && msg.targetY !== undefined) {
      const parsedTargetY = Number(msg.targetY);
      if (!Number.isFinite(parsedTargetY)) return;
      targetY = Math.max(0, Math.min(1, parsedTargetY));
    }

    const wasDisconnected = this.players[side].disconnectedAt != null;
    this.players[side].input = { move, targetY };
    this.players[side].lastSeenAt = Date.now();
    this.players[side].disconnectedAt = null;
    if (wasDisconnected) {
      const opponentSide = side === 'left' ? 'right' : 'left';
      this._broadcast({ type: 'opponent.status', status: 'connected' }, opponentSide);
    }
  }

  onDisconnect(userId) {
    const side = this._sideOf(userId);
    if (!side) return;
    if (this.players[side].disconnectedAt != null) return;
    this.players[side].disconnectedAt = Date.now();
    // Notify the opponent that this player disconnected.
    const opponentSide = side === 'left' ? 'right' : 'left';
    this._broadcast({ type: 'opponent.status', status: 'disconnected' }, opponentSide);
  }

  onForfeit(userId) {
    const side = this._sideOf(userId);
    if (!side || this._ended) return;
    const winnerSide = side === 'left' ? 'right' : 'left';
    this._end(`forfeit_${side}`, winnerSide);
  }

  onPing(userId, rttMs) {
    const side = this._sideOf(userId);
    if (!side) return;
    const wasDisconnected = this.players[side].disconnectedAt != null;
    this.players[side].lastSeenAt = Date.now();
    this.players[side].disconnectedAt = null;
    const opponentSide = side === 'left' ? 'right' : 'left';
    if (wasDisconnected) {
      this._broadcast({ type: 'opponent.status', status: 'connected' }, opponentSide);
    }
    // Forward sender's measured rtt to the opponent so they can display ping quality
    this._broadcast({ type: 'opponent.ping', pingMs: rttMs }, opponentSide);
  }

  tick() {
    if (this._ended) return;

    const now = Date.now();
    const dt = Math.min(0.05, Math.max(0.001, (now - this._lastTickMs) / 1000));
    this._lastTickMs = now;

    // Arm disconnect timeout checks only after the first serve window starts.
    // This prevents instant draw/forfeit at match entry when clients are still syncing.
    if (!this.disconnectChecksArmed && now >= this.disconnectChecksEnabledAt) {
      this.disconnectChecksArmed = true;
      for (const side of ['left', 'right']) {
        const p = this.players[side];
        p.lastSeenAt = now;
        if (p.disconnectedAt) {
          p.disconnectedAt = now;
        }
      }
    }

    if (this.disconnectChecksArmed) {
      // Safety net for abrupt disconnects where close event may not arrive:
      // mark disconnected quickly for UI, then end as draw after hard timeout.
      const disconnectStatusMs = Math.max(0, Math.min(config.disconnectForfeitMs, config.disconnectStatusMs ?? 1000));
      const forfeitMs = config.disconnectForfeitMs;
      for (const side of ['left', 'right']) {
        const p = this.players[side];
        if (!p.disconnectedAt && now - p.lastSeenAt >= disconnectStatusMs) {
          p.disconnectedAt = p.lastSeenAt;
          const opponentSide = side === 'left' ? 'right' : 'left';
          this._broadcast({ type: 'opponent.status', status: 'disconnected' }, opponentSide);
        }
      }

      const timedOutSides = [];
      for (const side of ['left', 'right']) {
        const p = this.players[side];
        if (p.disconnectedAt && now - p.disconnectedAt >= forfeitMs) {
          timedOutSides.push(side);
        }
      }

      if (timedOutSides.length === 2) {
        this._end('both_disconnect', null);
        return;
      }
      if (timedOutSides.length === 1) {
        const disconnectedSide = timedOutSides[0];
        this._end(`disconnect_timeout_${disconnectedSide}`, null);
        return;
      }
    }

    if (now >= this.warmupEndsAt) {
      // Broadcast pre-start 3-2-1 countdown once, right as warmup ends
      if (!this._preStartBroadcastDone) {
        this._preStartBroadcastDone = true;
        this._broadcast({
          type: 'match.set_break',
          matchId: this.matchId,
          sets: { left: this.sets.left, right: this.sets.right },
          currentSet: this.currentSet,
          breakDurationMs: 3000,
          isPreStart: true,
        });
      }

      if (this.pausePhase === 'point_pause' && now >= this.resumeAt) {
        if (isSetOver(this.state, this.pointsToWin, 2)) {
          const setWinner = this.state.scoreL > this.state.scoreR ? 'left' : 'right';
          this.sets[setWinner] += 1;

          if (this.sets[setWinner] >= this.setsToWin) {
            this.state.pendingReset = null;
            this._end('sets', setWinner);
            return;
          }

          this.currentSet += 1;
          this.state.scoreL = 0;
          this.state.scoreR = 0;
          // set winner gets serve after the break
          this.state.pendingReset = setWinner === 'left' ? -1 : 1;
          this.pausePhase = 'set_break';
          this.resumeAt = now + 3000;
          this._broadcast({
            type: 'match.set_break',
            matchId: this.matchId,
            sets: { left: this.sets.left, right: this.sets.right },
            currentSet: this.currentSet,
            breakDurationMs: 3000,
          });
        } else {
          resetBall(this.state, this.state.pendingReset);
          this.state.pendingReset = null;
          this.pausePhase = 'playing';
        }
      } else if (this.pausePhase === 'set_break' && now >= this.resumeAt) {
        resetBall(this.state, this.state.pendingReset);
        this.state.pendingReset = null;
        this.pausePhase = 'playing';
      } else if (this.pausePhase === 'playing') {
        step(this.state, dt, this.players.left.input, this.players.right.input);
        if (this.state.pendingReset != null) {
          this.pausePhase = 'point_pause';
          this.resumeAt = now + 1000;
          this._scoreFlushNeeded = true;
        }
      } else {
        // point_pause or set_break — timer not yet expired; still process paddle inputs.
        // Ball stays frozen because state.pendingReset != null in physics.js.
        step(this.state, dt, this.players.left.input, this.players.right.input);
      }
    }

    // Broadcast state at tick rate
    this._broadcast({ type: 'match.state', matchId: this.matchId, state: this._publicState() });

    // Redis snapshot (for reconnect)
    if (redis && now - this._lastRedisSnapshotMs >= config.redisSnapshotMs) {
      this._lastRedisSnapshotMs = now;
      void saveMatchSnapshot(redis, this.matchId, this._snapshot(), 60 * 30);
    }

    // MySQL update (score/time) — immediately on every point, or periodic fallback
    if (this.mysqlMatchId && pool && mysqlSupport &&
        (this._scoreFlushNeeded || now - this._lastMysqlUpdateMs >= config.mysqlUpdateIntervalMs)) {
      this._lastMysqlUpdateMs = now;
      this._scoreFlushNeeded = false;
      const score = this._formatScore();
      const participantsJson = JSON.stringify([this.players.left.userId, this.players.right.userId]);
      void updateMatchPerSecond(pool, mysqlSupport, this.mysqlMatchId, { status: 'live', score, participantsJson })
        .catch((e) => console.error('[mysql] per-second update failed', e));

      if (mysqlSupport.hasMatchTicks) {
        const tickTs = nowUtc();
        const stateJson = JSON.stringify(this._snapshot());
        void insertMatchTick(pool, this.mysqlMatchId, tickTs, stateJson)
          .catch((e) => console.error('[mysql] tick insert failed', e));
      }
    }
  }

  _publicState() {
    const now = Date.now();
    return {
      t: this.state.t,
      scoreL: this.state.scoreL,
      scoreR: this.state.scoreR,
      setsL: this.sets.left,
      setsR: this.sets.right,
      currentSet: this.currentSet,
      pointsToWin: this.pointsToWin,
      setsToWin: this.setsToWin,
      isWarmup: now < this.warmupEndsAt,
      warmupEndsAt: this.warmupEndsAt,
      warmupRemainingMs: Math.max(0, this.warmupEndsAt - now),
      ball: this.state.ball,
      paddleL: { y: this.state.paddleL.y },
      paddleR: { y: this.state.paddleR.y },
    };
  }

  _matchFoundPayload(side) {
    const opponentSide = side === 'left' ? 'right' : 'left';
    return {
      type: 'match.found',
      matchId: this.matchId,
      side,
      opponentUserId: this.players[opponentSide].userId,
      opponentUsername: this.players[opponentSide].username,
      warmupEndsAt: this.warmupEndsAt,
      pointsToWin: this.pointsToWin,
      setsToWin: this.setsToWin,
    };
  }

  _snapshot() {
    return {
      matchId: this.matchId,
      mysqlMatchId: this.mysqlMatchId,
      players: { left: this.players.left.userId, right: this.players.right.userId },
      seed: this.seed,
      state: this._publicState(),
      updatedAtMs: Date.now(),
    };
  }

  _broadcast(msg, onlySide = null) {
    if (!onlySide) {
      for (const side of ['left', 'right']) this._broadcast(msg, side);
      return;
    }
    const userId = this.players[onlySide].userId;
    // Use safeSendToUser for cross-worker routing; also update local ws reference.
    const localWs = userSockets.get(userId);
    if (localWs) {
      this.players[onlySide].ws = localWs; // keep reference fresh
      safeSend(localWs, msg);
    } else {
      // Fire-and-forget to the owner worker of this player's WS.
      void safeSendToUser(userId, msg);
    }
  }

  _sideOf(userId) {
    if (this.players.left.userId === userId) return 'left';
    if (this.players.right.userId === userId) return 'right';
    return null;
  }

  async _end(reason, winnerSide = null) {
    if (this._ended) return;
    this._ended = true;

    const score = this._formatScore();
    const endTime = nowUtc();
    const isDraw = !winnerSide;
    const winnerUserId = isDraw ? null : this.players[winnerSide].userId;
    const loserUserId = isDraw ? null : this.players[winnerSide === 'left' ? 'right' : 'left'].userId;
    const leftUserId = this.players.left.userId;
    const rightUserId = this.players.right.userId;
    const leftUsername = this.players.left.username;
    const rightUsername = this.players.right.username;

    // Legacy payload/storage compatibility: keep winner/loser fields populated even on draw.
    // Canonical draw signal is `isDraw === true`.
    const payloadWinnerUserId = isDraw ? leftUserId : winnerUserId;
    const payloadLoserUserId = isDraw ? rightUserId : loserUserId;
    const payloadWinnerUsername = isDraw ? leftUsername : this.players[winnerSide].username;
    const payloadLoserUsername = isDraw ? rightUsername : this.players[winnerSide === 'left' ? 'right' : 'left'].username;

    try {
      if (!pool || !this.mysqlMatchId) {
        throw new Error('mysql_not_ready');
      }
      await endMatch(pool, mysqlSupport, this.mysqlMatchId, {
        score,
        endTimeUtc: endTime,
        winnerUserId,
        loserUserId,
      });
    } catch (e) {
      console.error('[mysql] end match failed', e);
    }

    const payload = {
      discipline: 'ping-pong',
      mode: '1v1',
      matchId: this.mysqlMatchId,
      matchKey: this.matchId,
      reason,
      score,
      sets: { left: this.sets.left, right: this.sets.right },
      points: { left: this.state.scoreL, right: this.state.scoreR },
      pointsToWin: this.pointsToWin,
      setsToWin: this.setsToWin,
      isDraw,
      winnerUserId: payloadWinnerUserId,
      winnerUsername: payloadWinnerUsername,
      loserUserId: payloadLoserUserId,
      loserUsername: payloadLoserUsername,
      players: {
        left: { userId: leftUserId, username: leftUsername },
        right: { userId: rightUserId, username: rightUsername },
      },
      refundPolicy: isDraw ? 'stake_refund_each' : 'standard',
      endedAt: endTime,
    };

    this._broadcast({ type: 'match.end', matchId: this.matchId, payload });

    // Save final snapshot and let it expire
    try {
      if (!redis) {
        throw new Error('redis_not_ready');
      }
      await saveMatchSnapshot(redis, this.matchId, { ...this._snapshot(), ended: true, payload }, 60 * 5);
    } catch (e) {
      console.error('[redis] final snapshot failed', e);
    }

    // ── Write match result directly to MySQL ───────────────────────────────
    // Primary path: Node → MySQL directly (no HTTP, no middleman).
    // Fallback: if pool is unavailable, try the PHP HTTP endpoint.
    const matchKey = this.matchId;
    let savedDirectly = false;

    if (pool) {
      try {
        await processMatchResult(pool, {
          matchKey,
          matchId: this.mysqlMatchId,
          winnerUserId: payloadWinnerUserId,
          loserUserId: payloadLoserUserId,
          winnerUsername: payloadWinnerUsername,
          loserUsername: payloadLoserUsername,
          leftUserId,
          rightUserId,
          leftUsername,
          rightUsername,
          isDraw,
          score,
          setsWinner: Math.max(this.sets.left, this.sets.right),
          setsLoser: Math.min(this.sets.left, this.sets.right),
          reason,
          endedAt: endTime,
          payloadJson: JSON.stringify(payload),
        });
        savedDirectly = true;
        console.log(`[mysql] processMatchResult ok — matchKey=${matchKey}`);
        this._broadcast({ type: 'rewards.done', matchId: this.matchId });
      } catch (e) {
        console.error('[mysql] processMatchResult failed, trying HTTP fallback', e);
      }
    }

    if (!savedDirectly) {
      // HTTP fallback: PHP endpoint writes to MySQL
      sendRewardsRequest(payload)
        .then((resp) => {
          this._broadcast({ type: 'rewards.queued', matchId: this.matchId, response: resp });
        })
        .catch((e) => {
          console.error('[rewards] HTTP fallback also failed', e);
          this._broadcast({ type: 'rewards.error', matchId: this.matchId });
        })
        .finally(() => {
          activeMatches.delete(this.matchId);
          if (redis) void unregisterMatchWorker(redis, this.matchId);
        });
    } else {
      activeMatches.delete(this.matchId);
      if (redis) void unregisterMatchWorker(redis, this.matchId);
    }
  }

  _formatScore() {
    return `sety ${this.sets.left}:${this.sets.right} | punkty ${this.state.scoreL}:${this.state.scoreR}`;
  }
}

// Matchmaking loop
setInterval(async () => {
  if (!isRuntimeReady()) return;

  try {
    const pair = await dequeuePair(redis);
    if (!pair) return;

    const [u1, u2] = pair;

    // Validate that both players still have a routable live websocket endpoint.
    // This prevents creating matches with stale queue users after worker restarts.
    if (redis && redis.mode !== 'memory') {
      const [owner1, owner2] = await Promise.all([
        getUserWorker(redis, u1),
        getUserWorker(redis, u2),
      ]);

      const alive1 = !!owner1 && (owner1 !== WORKER_ID || isSocketActive(userSockets.get(u1)));
      const alive2 = !!owner2 && (owner2 !== WORKER_ID || isSocketActive(userSockets.get(u2)));

      if (!alive1 || !alive2) {
        if (alive1) await enqueue(redis, u1);
        if (alive2) await enqueue(redis, u2);
        console.warn('[matchmaking] dropped stale queue pair and requeued alive users', {
          u1,
          u2,
          owner1,
          owner2,
          alive1,
          alive2,
        });
        return;
      }
    } else {
      const alive1 = isSocketActive(userSockets.get(u1));
      const alive2 = isSocketActive(userSockets.get(u2));
      if (!alive1 || !alive2) {
        if (alive1) await enqueue(redis, u1);
        if (alive2) await enqueue(redis, u2);
        console.warn('[matchmaking] dropped stale in-memory queue pair and requeued alive users', { u1, u2, alive1, alive2 });
        return;
      }
    }

    const [leftUsername, rightUsername] = await Promise.all([
      resolveUsernameByUserId(u1),
      resolveUsernameByUserId(u2),
    ]);

    if (!leftUsername || !rightUsername) {
      console.warn('[matchmaking] skipped pair without valid usernames', { u1, u2, leftUsername, rightUsername });
      return;
    }

    const matchId = randomId('m_');

    let mysqlMatchId = null;
    if (pool && mysqlSupport) {
      try {
        const participantsJson = JSON.stringify([u1, u2]);
        mysqlMatchId = await createMatchRow(pool, mysqlSupport, {
          team1Id: u1,
          team2Id: u2,
          startTimeUtc: nowUtc(),
          platform: 'PC',
          matchType: 'przyjacielski',
          participantsJson,
          discipline: 'ping-pong',
        });
      } catch (e) {
        console.error('[matchmaking] mysql createMatchRow failed, starting match without DB row', e);
      }
    }

    const match = new Match({
      matchId,
      leftUserId: u1,
      rightUserId: u2,
      leftUsername,
      rightUsername,
      mysqlMatchId,
    });
    activeMatches.set(matchId, match);
  } catch (e) {
    console.error('[matchmaking] error', e);
  }
}, 150);

// Physics tick loop (single scheduler for all matches)
const tickMs = Math.max(5, Math.floor(1000 / config.matchTickHz));
setInterval(() => {
  for (const match of activeMatches.values()) {
    try { match.tick(); } catch (e) { console.error('[match] tick error', e); }
  }
}, tickMs);

const { server } = createHttpAndWs({
  onConnection: (ws) => {
    if (!isRuntimeReady()) {
      safeSend(ws, { type: 'hello.error', error: startupError || 'server_not_ready' });
      ws.close(1013, 'Server unavailable');
      return;
    }
    connections.set(ws, {});
    attachWsHandlers(ws);
    safeSend(ws, { type: 'hello', message: 'send hello ticket' });
  },
  getStatus,
});

server.listen(config.port, async () => {
  console.log(`[ping-pong-1v1] listening on :${config.port}`);
  try {
    redis = await createRedis();
    try {
      pool = createPool();
      mysqlSupport = await initMySqlSupport(pool);
      mysqlStartupError = null;
    } catch (error) {
      mysqlStartupError = error instanceof Error ? error.message : String(error);
      pool = null;
      mysqlSupport = null;
      console.warn('[mysql] unavailable, continuing without DB persistence:', error);
    }
    startupError = null;
    console.log(`[ping-pong-1v1] startup dependencies ready (redis mode: ${redis?.mode ?? 'unknown'})`);

    // Start cross-worker IPC listener (cluster mode).
    redisSub = await setupIpcSubscriber(redis, (payload) => {
      if (payload.type === 'ws.send') {
        // Another worker wants us to deliver a WS message to one of our local users.
        const ws = userSockets.get(Number(payload.userId));
        if (ws) safeSend(ws, payload.msg);

      } else if (payload.type === 'match.input') {
        // Another worker forwarded a player's match.input to us (we own the match).
        const match = activeMatches.get(payload.matchId);
        if (match) match.onInput(payload.userId, payload.msg);

      } else if (payload.type === 'match.reconnect') {
        // A player reconnected on another worker; update our match to route via IPC.
        const match = activeMatches.get(payload.matchId);
        if (match) {
          const side = match._sideOf(payload.userId);
          if (side) {
            const wasDisconnected = match.players[side].disconnectedAt != null;
            // Clear stale local WS reference — _broadcast will fall back to safeSendToUser.
            match.players[side].ws = null;
            match.players[side].disconnectedAt = null;
            match.players[side].lastSeenAt = Date.now();
            if (wasDisconnected) {
              const opponentSide = side === 'left' ? 'right' : 'left';
              match._broadcast({ type: 'opponent.status', status: 'connected' }, opponentSide);
            }
          }
        }

      } else if (payload.type === 'match.ping') {
        // Forward ping from another worker to the owned match.
        const match = activeMatches.get(payload.matchId);
        if (match) {
          match.onPing(payload.userId, payload.rttMs);
        }

      } else if (payload.type === 'match.disconnect') {
        // A player disconnected on another worker; reflect disconnect in owned match.
        const match = activeMatches.get(payload.matchId);
        if (match) {
          match.onDisconnect(payload.userId);
        }

      } else if (payload.type === 'match.leave') {
        // A player explicitly left the match on another worker.
        const match = activeMatches.get(payload.matchId);
        if (match) {
          match.onForfeit(payload.userId, payload.reason || 'user_left');
        }
      }
    });
  } catch (error) {
    startupError = error instanceof Error ? error.message : String(error);
    console.error('[ping-pong-1v1] startup failed:', error);
  }
});

process.on('unhandledRejection', (error) => {
  console.error('[ping-pong-1v1] unhandled rejection:', error);
});

process.on('uncaughtException', (error) => {
  console.error('[ping-pong-1v1] uncaught exception:', error);
});
