/**
 * Welcome-back digest (T010c daily-return hook #1).
 *
 * processOfflineTime advances the offline simulation AND collects a digest of
 * what happened while the player was away: elapsed minutes, money delta, age
 * delta, and the 2-3 most NOTABLE events. The digest is stashed on
 * player.offlineStats.digest (persisted by Player.toJSON) and surfaced to the
 * client on init/reconnect via PlayerSession.sendOfflineDigest.
 *
 * Notable events are sourced here through player.messageQueue, which the offline
 * loop (LoopManager.initLifeSim) shifts and emits via sendEventMessage on each
 * hour boundary — exactly the hook processOfflineTime listens on. The loop
 * drains one queued message per in-game hour, so the helper starts at
 * minuteOfHour=59 and runs enough offline minutes (>= 60 per message) for the
 * queued messages to surface. We prime the queue with a mix of notable +
 * mundane messages and assert only the notable ones (matched by the shared
 * isNotableEventMessage predicate) land in the digest, capped at 3.
 */
import { describe, it, expect } from 'vitest';
import { Person } from '../../src/models/Person.js';
import { Player } from '../../src/models/Player.js';
import { processOfflineTime } from '../../src/game/engine/LoopManager.js';
import { PlayerSession, isNotableEventMessage } from '../../src/game/PlayerSession.js';

/**
 * Build an offline player. The offline loop drains one queued message per
 * in-game hour boundary, so we start at minuteOfHour=59 (first tick crosses an
 * hour) and require at least 60 offline minutes per queued message we expect to
 * surface. Callers pass `minutesAgo` accordingly.
 */
interface OfflinePlayerOpts {
  /** Make the character an earner (occupation 'work' + salary) so weekly /
   *  prorated finances produce a positive money delta. Default false. */
  employed?: boolean;
  /** Monthly salary for an employed character. Default 4000. */
  salary?: number;
}

function makeOfflinePlayer(
  minutesAgo: number,
  queued: string[],
  opts: OfflinePlayerOpts = {}
): Player {
  const player = new Player({
    userId: 'digest-1',
    character: new Person({
      id: 'me',
      firstname: 'Ada',
      lastname: 'Vance',
      sex: 'Female',
      status: 'alive',
      ageYears: 25,
      ageDays: 9131,
      money: 1000,
      // An employed earner runs real wages/rent/savings through finance.ts;
      // otherwise the character earns nothing (NON_EARNING / no occupation).
      occupation: opts.employed ? 'work' : '',
      salary: opts.employed ? (opts.salary ?? 4000) : 0,
      spendingHabits: 'normal',
    } as never),
    status: 'playing',
    controller: 'active',
    connection: 'disconnected',
    // High gameSpeed keeps the offline ticks focused on the messageQueue
    // emitter (the event-registry/NPC branches are gated below this speed).
    gameSpeed: 1000,
  });
  // Start one minute before the hour so each subsequent hour crossing drains a
  // queued message through sendEventMessage (the hook processOfflineTime taps).
  player.minuteOfHour = 59;
  player.hourOfDay = 12;
  player.dayOfWeek = 3; // mid-week start (Tuesday) — see FU3 tests below
  player.messageQueue = [...queued];
  player.offlineStats = {
    minutesOffline: minutesAgo,
    lastOnline: new Date(Date.now() - minutesAgo * 60_000),
  };
  return player;
}

describe('Welcome-back digest — processOfflineTime', () => {
  it('captures elapsed time, money delta, age delta and notable events', async () => {
    // 4 queued messages drain at one per hour boundary; allow >= 4 hours.
    // Employed earner so the offline money delta comes from REAL finance
    // (prorated weekly wages/rent/savings via FU3) rather than a synthetic bump.
    const player = makeOfflinePlayer(
      4 * 60 + 5,
      [
        'You were hired as a barista!', // notable
        'You ate a sandwich.', // mundane
        'You and Sam started dating.', // notable
        'You watched some TV.', // mundane
      ],
      { employed: true }
    );

    const minutes = await processOfflineTime(player, async () => {});

    expect(minutes).toBe(4 * 60 + 5);

    const digest = player.offlineStats.digest;
    expect(digest).toBeDefined();
    expect(digest!.minutesAway).toBe(4 * 60 + 5);
    // FU3: a few-hours absence with no Monday boundary still yields a non-zero,
    // sensible positive money delta from the prorated finance slice (savings on
    // a $4000/mo salary, net of the prorated rent), NOT the old flat +$0.
    expect(digest!.moneyDelta).toBeGreaterThan(0);
    expect(digest!.moneyDelta).toBeLessThan(100); // ~4h of a weekly savings slice
    expect(typeof digest!.ageYearsDelta).toBe('number');
    expect(digest!.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);

    // Only the two notable messages, in order, none of the mundane ones.
    expect(digest!.notableEvents).toEqual([
      'You were hired as a barista!',
      'You and Sam started dating.',
    ]);
    for (const msg of digest!.notableEvents) {
      expect(isNotableEventMessage(msg)).toBe(true);
    }
  });

  it('caps the digest at the 3 most notable events', async () => {
    // 4 notable messages drain over 4 hours; only the first 3 are kept.
    const player = makeOfflinePlayer(4 * 60 + 10, [
      'You got promoted!',
      'You graduated college.',
      'You got married.',
      'You won the lottery.', // 4th notable — should be dropped
    ]);

    await processOfflineTime(player, async () => {});

    const digest = player.offlineStats.digest!;
    expect(digest.notableEvents).toHaveLength(3);
    expect(digest.notableEvents).toEqual([
      'You got promoted!',
      'You graduated college.',
      'You got married.',
    ]);
  });

  it('FU3: a 3-day mid-week absence (no Monday) yields a non-zero money delta', async () => {
    // Start Tuesday (dayOfWeek=3); 3 days later is Friday — the offline window
    // never crosses a Monday@00:00, so the loop's whole-week finance tick never
    // fires. Before FU3 this meant a flat +$0 delta. With proration the leftover
    // ~3 days of weekly finance are charged, producing a meaningful delta.
    const threeDaysMin = 3 * 24 * 60;
    const player = makeOfflinePlayer(threeDaysMin, [], { employed: true });
    const moneyBefore = player.c.money ?? 0;

    const minutes = await processOfflineTime(player, async () => {});
    expect(minutes).toBe(threeDaysMin);

    const digest = player.offlineStats.digest!;
    expect(digest.minutesAway).toBe(threeDaysMin);
    // Earner: positive savings delta from ~3/7 of a $245/wk net (35% savings rate).
    expect(digest.moneyDelta).toBeGreaterThan(0);
    expect(player.c.money).toBeGreaterThan(moneyBefore);
    // Sensible magnitude: well under a full week's net ($245).
    expect(digest.moneyDelta).toBeLessThan(245);
  });

  it('FU3: a 5-day absence crossing one Monday charges a full week + prorated leftover', async () => {
    // Tuesday start; 5 days reaches the next Sunday→Monday boundary so the loop
    // fires ONE whole-week finance tick, and FU3 prorates only the leftover days
    // (no double-count). The combined delta is a sensible multi-day amount.
    const fiveDaysMin = 5 * 24 * 60;
    const player = makeOfflinePlayer(fiveDaysMin, [], { employed: true });
    const moneyBefore = player.c.money ?? 0;

    await processOfflineTime(player, async () => {});

    const digest = player.offlineStats.digest!;
    expect(digest.moneyDelta).toBeGreaterThan(0);
    expect(player.c.money).toBeGreaterThan(moneyBefore);
    // Between one and two weeks of net savings ($245..$490) — proves the whole
    // week tick fired AND the leftover was prorated, without double-counting.
    expect(digest.moneyDelta).toBeGreaterThanOrEqual(245);
    expect(digest.moneyDelta).toBeLessThan(490);
  });

  it('FU3: a non-earner offline a few days still sees a (negative-then-floored) finance effect', async () => {
    // A non-earner owes the rent floor; over a multi-day absence the prorated
    // slice reduces money (floored at 0), so the delta is non-zero and negative
    // rather than the old flat +$0. Confirms finance runs for non-earners too.
    const threeDaysMin = 3 * 24 * 60;
    const player = makeOfflinePlayer(threeDaysMin, [], { employed: false });
    const moneyBefore = player.c.money ?? 0; // 1000

    await processOfflineTime(player, async () => {});

    const digest = player.offlineStats.digest!;
    expect(digest.moneyDelta).toBeLessThan(0); // rent floor drawdown
    expect(player.c.money).toBeLessThan(moneyBefore);
    expect(player.c.money).toBeGreaterThanOrEqual(0); // floored, recoverable
  });

  it('returns 0 and writes no digest when there is no lastOnline', async () => {
    const player = makeOfflinePlayer(5, []);
    player.offlineStats = { minutesOffline: 0 };
    const minutes = await processOfflineTime(player, async () => {});
    expect(minutes).toBe(0);
    expect(player.offlineStats.digest).toBeUndefined();
  });

  it('persists the digest across a Player save/load round-trip', async () => {
    // One message needs >= 1 hour to drain.
    const player = makeOfflinePlayer(65, ['You were hired as an intern.']);
    await processOfflineTime(player, async () => {});
    expect(player.offlineStats.digest).toBeDefined();

    const reloaded = new Player(JSON.parse(JSON.stringify(player.toJSON())));
    expect(reloaded.offlineStats.digest).toBeDefined();
    expect(reloaded.offlineStats.digest!.notableEvents).toEqual([
      'You were hired as an intern.',
    ]);
  });
});

describe('Welcome-back digest — surfaced on init/reconnect', () => {
  /** Minimal fake WebSocket that records sent messages and reports OPEN. */
  class FakeWS {
    static OPEN = 1;
    readyState = 1;
    sent: unknown[] = [];
    send(data: string): void {
      this.sent.push(JSON.parse(data));
    }
  }

  it('emits a dedicated offlineDigest message and clears it (one-time)', async () => {
    const player = makeOfflinePlayer(70, ['You were hired as a manager.']);
    await processOfflineTime(player, async () => {});
    expect(player.offlineStats.digest).toBeDefined();

    const ws = new FakeWS();
    const session = new PlayerSession(ws as never, player);
    process.env.NODE_ENV = 'test';

    session.sendPlayerObject();

    const sentTypes = ws.sent.map((m) => (m as { type?: string }).type);
    expect(sentTypes).toContain('playerObject');
    expect(sentTypes).toContain('offlineDigest');

    const digestMsg = ws.sent.find(
      (m) => (m as { type?: string }).type === 'offlineDigest'
    ) as Record<string, unknown>;
    expect(digestMsg.minutesAway).toBe(70);
    expect(digestMsg.notableEvents).toEqual(['You were hired as a manager.']);

    // The playerObject still carries the digest inside offlineStats too.
    const playerObj = ws.sent.find(
      (m) => (m as { type?: string }).type === 'playerObject'
    ) as Record<string, unknown>;
    const offlineStats = playerObj.offlineStats as { digest?: unknown };
    expect(offlineStats.digest).toBeDefined();

    // Consumed: a later reconnect does not re-fire the digest.
    expect(player.offlineStats.digest).toBeUndefined();
    expect(session.sendOfflineDigest()).toBe(false);
  });

  it('sendOfflineDigest is a no-op when no digest exists', () => {
    const player = makeOfflinePlayer(0, []);
    player.offlineStats = { minutesOffline: 0 };
    const ws = new FakeWS();
    const session = new PlayerSession(ws as never, player);
    expect(session.sendOfflineDigest()).toBe(false);
    expect(ws.sent).toHaveLength(0);
  });

  it('a player backdated 2 days gets a digest produced on the connect path', async () => {
    // Simulate the live connect path: a returning player whose last-active is
    // backdated two days. WebSocketServer.initializeSession runs
    // processOfflineTime BEFORE sendPlayerObject; here we exercise the same
    // sequence directly so the test does not need a live DB.
    const twoDaysMin = 2 * 24 * 60;
    const player = makeOfflinePlayer(twoDaysMin + 30, [
      'You were promoted to Senior Engineer!', // notable
      'You made breakfast.', // mundane
    ]);

    const minutes = await processOfflineTime(player, async () => {});
    expect(minutes).toBe(twoDaysMin + 30);

    const ws = new FakeWS();
    const session = new PlayerSession(ws as never, player);
    process.env.NODE_ENV = 'test';
    session.sendPlayerObject();

    const digestMsg = ws.sent.find(
      (m) => (m as { type?: string }).type === 'offlineDigest'
    ) as Record<string, unknown> | undefined;
    expect(digestMsg).toBeDefined();
    expect(digestMsg!.minutesAway).toBe(twoDaysMin + 30);
    // The character aged across the offline window (>= 1 day worth of ticks).
    expect(typeof digestMsg!.ageYearsDelta).toBe('number');
    // The notable promotion lands; the mundane breakfast does not.
    expect(digestMsg!.notableEvents).toEqual([
      'You were promoted to Senior Engineer!',
    ]);
  });
});
