/**
 * Regression test for NPC birthday dedup through the REAL offline loop:
 *   iterate_offline_games (every 60s)
 *     -> LoopManager.iterateGames -> LoopManager.initLifeSim
 *       -> GameEngine.updateAge
 *
 * Unlike tests/game/gameEngine.birthdayDedup.test.ts (which exercises
 * GameEngine.updateAge in isolation), this drives the full initLifeSim hourly
 * tick and performs a `Player.toJSON() -> new Player(...)` save/load between
 * passes. That round-trip is exactly what the offline job does: it reloads each
 * disconnected player from the DB on every 60s iteration, so a slightly stale
 * snapshot can re-process the same birthday day.
 *
 * The bug (fixed in GameEngine.updateAge): the offline emitter had no
 * player.events guard and keyed the NPC birthday on the mutable, non-unique
 * firstname, so the re-processed day re-delivered the birthday. The dedup now
 * lives inside updateAge and shares the player.events key namespace with the
 * online path, so it survives the reload.
 *
 * IMPORTANT: person.title is NOT serialized by Person.toJSON(), so we re-apply
 * it after the reload. Without that, the post-reload birthday block is skipped
 * for lack of a title and the test would go green WITHOUT exercising the dedup
 * guard at all (the masking weakness in the isolated suite's save/load case).
 */
import { describe, it, expect } from 'vitest';
import { Person } from '../../src/models/Person.js';
import { Player } from '../../src/models/Player.js';
import { initLifeSim } from '../../src/game/engine/LoopManager.js';
import type { GameEvent } from '../../src/game/engine/GameEngine.js';

const NPC_ID = 'npc-mom';
const NPC_TITLE = 'Mother';
const NPC_BIRTHDAY_ID = `birthday_npc_${NPC_ID}_31`; // ageYears 30 -> 31

/** A titled, alive relative one daily tick away from a yearly birthday. */
function makeRelative(): Person {
  const person = new Person({
    id: NPC_ID,
    firstname: 'Mona',
    lastname: 'Doe',
    sex: 'Female',
    status: 'alive',
    relationships: ['mother'],
    ageYears: 30,
    ageDays: 364, // +1 daily increment -> 365 -> birthday
    affinity: 80,
    deathChance: 0,
    health: 0, // deathChance * health === 0 -> cannot randomly die mid-test
  } as never);
  (person as unknown as { title?: string }).title = NPC_TITLE;
  return person;
}

/**
 * Disconnected player positioned so a SINGLE initLifeSim() call crosses the
 * hour boundary (minuteOfHour 59 -> 0) and runs the daily block inside
 * updateAge (ageHours 23 -> 24). Player ageDays is kept off any 365 multiple so
 * the player's own birthday never fires and pollutes the count.
 * gameSpeed >= SPEED_QUESTION_PAUSE skips the NPC-interaction/event-registry
 * branches so the test stays focused on the age emitter.
 */
function makeOfflinePlayer(relatives: Person[]): Player {
  const player = new Player({
    userId: 'offline-1',
    character: new Person({
      id: 'me',
      firstname: 'You',
      lastname: 'Me',
      sex: 'Male',
      status: 'alive',
      ageYears: 20,
      ageDays: 7000,
    } as never),
    status: 'playing',
    controller: 'active',
    connection: 'disconnected',
    r: relatives,
  });
  armBirthdayHour(player);
  return player;
}

/** Re-arm the player one tick before the hour and one hour before the daily
 * block, and reset the NPC back to its pre-birthday boundary — simulating the
 * 60s offline job re-processing the SAME birthday day from a stale reload. */
function armBirthdayHour(player: Player): void {
  player.minuteOfHour = 59;
  player.c.ageHours = 23;
  player.c.ageDays = 7000;
  player.gameSpeed = 1000;
  const npc = player.r[0];
  if (npc) {
    npc.ageDays = 364;
    npc.ageYears = 30;
  }
}

/** Run one offline hourly tick, collecting any emitted GameEvents. */
async function tickOfflineHour(player: Player): Promise<GameEvent[]> {
  const collected: GameEvent[] = [];
  const sendEventMessage = async (_ws: unknown, event: GameEvent): Promise<void> => {
    collected.push(event);
  };
  await initLifeSim(
    null,
    player,
    undefined,
    sendEventMessage as never,
  );
  return collected;
}

const npcBirthdays = (events: GameEvent[]): GameEvent[] =>
  events.filter((e) => e.id === NPC_BIRTHDAY_ID);

describe('Offline loop (initLifeSim) — NPC birthday dedup across save/load', () => {
  it('delivers an NPC birthday exactly once even when a stale reload re-processes the same day', async () => {
    const player = makeOfflinePlayer([makeRelative()]);

    // Pass 1: the offline tick crosses the birthday. It fires exactly once and
    // updateAge records the dedup key in player.events.
    const firstPass = await tickOfflineHour(player);
    expect(npcBirthdays(firstPass)).toHaveLength(1);
    expect(player.events.has(NPC_BIRTHDAY_ID)).toBe(true);

    // Save/load exactly as the offline job does between iterations.
    const reloaded = new Player(JSON.parse(JSON.stringify(player.toJSON())));
    // person.title is not persisted by Person.toJSON(); re-apply so the
    // post-reload birthday block actually runs and the dedup guard — not a
    // missing title — is what suppresses the re-fire.
    (reloaded.r[0] as unknown as { title?: string }).title = NPC_TITLE;
    // The dedup key must survive the round-trip (Set -> array -> Set).
    expect(reloaded.events.has(NPC_BIRTHDAY_ID)).toBe(true);

    // Pass 2: stale reload re-processes the SAME birthday day.
    armBirthdayHour(reloaded);
    const secondPass = await tickOfflineHour(reloaded);

    // No second delivery — the round-tripped player.events suppresses it.
    expect(npcBirthdays(secondPass)).toHaveLength(0);
    // And the NPC's title is still present, proving pass 2 reached the birthday
    // block and was stopped by the guard (not by an accidental title loss).
    expect((reloaded.r[0] as unknown as { title?: string }).title).toBe(NPC_TITLE);
  });

  it('keys on stable id so two same-named relatives each fire once across the offline loop', async () => {
    const mom = makeRelative();
    (mom as unknown as { title?: string }).title = 'Mother';
    mom.firstname = 'Sam';
    const dad = makeRelative();
    dad.id = 'npc-dad';
    dad.firstname = 'Sam'; // same first name, different stable id
    (dad as unknown as { title?: string }).title = 'Father';
    dad.relationships = ['father'];

    const player = makeOfflinePlayer([mom, dad]);

    // updateAge returns at the first birthday it finds (Mom) per tick.
    const firstPass = await tickOfflineHour(player);
    expect(firstPass.some((e) => e.id === 'birthday_npc_npc-mom_31')).toBe(true);
    expect(player.events.has('birthday_npc_npc-dad_31')).toBe(false);

    // Next offline hour processes Dad, whose id-based key is distinct from Mom's
    // (the old firstname-based key would have collided on "Sam").
    player.minuteOfHour = 59;
    player.c.ageHours = 23;
    const secondPass = await tickOfflineHour(player);
    expect(secondPass.some((e) => e.id === 'birthday_npc_npc-dad_31')).toBe(true);
  });
});
