/**
 * Proof-it-fires test for the OFFLINE lifecycle-credit drain
 * (T008, baolife-release-hardening).
 *
 * Bug: milestone events (graduation, friend made, etc.) that fire while a player
 * is DISCONNECTED push onto `player.lifecycleQueue` and self-mark
 * `player.events`. The offline simulation job
 *   iterate_offline_games -> LoopManager.iterateGames -> initLifeSim(null, ...)
 * never drained that queue (there is no PlayerSession offline) and the queue is
 * transient (Player.lifecycleQueue is reset to [] on every load, never
 * persisted). The online drain (PlayerSession.drainLifecycleQueue) was the ONLY
 * path that routed those entries to checkAchievementsAsync / evaluateGoals, so
 * the achievement + life-goal credit was permanently lost — and because
 * player.events was self-marked, the milestone would not re-fire on reconnect.
 *
 * The fix: iterateGames now calls drainLifecycleQueueOffline(player) after
 * initLifeSim when the queue is non-empty, reusing the SAME idempotent,
 * session-free grant calls as the online drain, with NO client push.
 *
 * These tests:
 *   POSITIVE  — a graduation (4.0) + friend-made milestone queued offline get
 *               full credit: graduate_hs + straight_a achievements AND the
 *               "Build Your Circle" life goal (friends>=3) completes.
 *   THROUGH THE REAL LOOP — driving the actual iterateGames(loadGames, ...)
 *               offline job grants the queued graduation credit. This FAILS if
 *               the drainLifecycleQueueOffline call is removed from iterateGames.
 *   IDEMPOTENCY — re-draining (offline again) and a subsequent ONLINE-style
 *               grant do NOT double-grant (no duplicate achievement, no second
 *               life-goal completion, no extra diamonds).
 */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

import { Player } from '../../src/models/Player.js';
import { Person } from '../../src/models/Person.js';
import {
  iterateGames,
  unregisterPlayer,
} from '../../src/game/engine/LoopManager.js';
import {
  drainLifecycleQueueOffline,
} from '../../src/services/retention/integration.js';
import {
  checkAchievementsAsync,
  hasUnlockedAchievement,
  clearAllAchievements,
} from '../../src/services/retention/achievements.js';
import {
  clearAllLifeGoals,
  getPlayerLifeGoals,
  buildGoalContext,
  evaluateGoals,
} from '../../src/services/retention/lifeGoals.js';
import { clearAllStatistics } from '../../src/services/retention/statistics.js';
import { trackFriendMade } from '../../src/services/retention/index.js';

function makeOfflinePlayer(userId: string): Player {
  const character = new Person({
    id: `char-${userId}`,
    firstname: 'Off',
    lastname: 'Line',
    sex: 'Female',
    status: 'alive',
    ageYears: 18,
    ageDays: 100, // off any 365 multiple so no spurious birthday fires
    occupation: 'student',
    deathChance: 0,
    health: 0,
  } as never);

  const player = new Player({
    userId,
    character,
    status: 'playing',
    connection: 'disconnected',
    controller: 'inactive',
    // Park mid-hour so a single initLifeSim tick does NOT cross an hour
    // boundary: we are testing the lifecycle-queue drain, not the age/event loop.
    minuteOfHour: 10,
    hourOfDay: 12,
  });
  return player;
}

describe('offline lifecycle credit drain (T008)', () => {
  beforeEach(() => {
    clearAllAchievements();
    clearAllLifeGoals();
    clearAllStatistics();
  });

  afterEach(() => {
    vi.restoreAllMocks();
    clearAllAchievements();
    clearAllLifeGoals();
    clearAllStatistics();
  });

  it('POSITIVE: queued graduation + friends grant achievement AND life-goal credit', async () => {
    const playerId = 'offline-positive';
    const player = makeOfflinePlayer(playerId);

    // Simulate milestones that "fired" while offline (event funcs push these).
    player.lifecycleQueue.push({
      type: 'graduation',
      data: { level: 'high school', gpa: 4.0 },
    });
    player.lifecycleQueue.push({ type: 'friend_made' });
    player.lifecycleQueue.push({ type: 'friend_made' });
    player.lifecycleQueue.push({ type: 'friend_made' });

    const processed = await drainLifecycleQueueOffline(player);

    expect(processed).toBe(4);
    // Queue is fully drained.
    expect(player.lifecycleQueue.length).toBe(0);

    // Achievement credit (graduate path).
    expect(hasUnlockedAchievement(playerId, 'graduate_hs')).toBe(true);
    expect(hasUnlockedAchievement(playerId, 'straight_a')).toBe(true);
    // make_first_friend (friendsCount === 1) along the way.
    expect(hasUnlockedAchievement(playerId, 'make_first_friend')).toBe(true);

    // Life-goal credit: "Build Your Circle" (make_three_friends, target 3)
    // completes because trackFriendMade bumped friendsCount to 3 and the
    // offline drain ran evaluateGoals.
    const goals = getPlayerLifeGoals(playerId);
    const completedIds = goals.completed.map((g) => g.id);
    expect(completedIds).toContain('make_three_friends');
  });

  it('THROUGH THE REAL LOOP: iterateGames grants queued offline graduation credit', async () => {
    const playerId = 'offline-loop';
    const player = makeOfflinePlayer(playerId);
    player.lifecycleQueue.push({
      type: 'graduation',
      data: { level: 'high school', gpa: 4.0 },
    });

    const loadGames = vi.fn(async () => [{ playerId }]);
    const loadGameAsync = vi.fn(async () => player);
    const saveGameAsync = vi.fn(async () => {});

    try {
      await iterateGames(loadGames, loadGameAsync, saveGameAsync);
    } finally {
      unregisterPlayer(playerId);
    }

    expect(loadGameAsync).toHaveBeenCalledWith(playerId);
    // If the drainLifecycleQueueOffline call is removed from iterateGames, the
    // queue is never drained and these assertions FAIL.
    expect(hasUnlockedAchievement(playerId, 'graduate_hs')).toBe(true);
    expect(hasUnlockedAchievement(playerId, 'straight_a')).toBe(true);
    expect(player.lifecycleQueue.length).toBe(0);
  });

  it('IDEMPOTENCY: re-draining offline does NOT double-grant', async () => {
    const playerId = 'offline-idempotent';
    const player = makeOfflinePlayer(playerId);

    player.lifecycleQueue.push({
      type: 'graduation',
      data: { level: 'high school', gpa: 4.0 },
    });
    player.lifecycleQueue.push({ type: 'friend_made' });
    player.lifecycleQueue.push({ type: 'friend_made' });
    player.lifecycleQueue.push({ type: 'friend_made' });

    await drainLifecycleQueueOffline(player);

    expect(hasUnlockedAchievement(playerId, 'graduate_hs')).toBe(true);
    const goalsAfterFirst = getPlayerLifeGoals(playerId);
    const completedAfterFirst = goalsAfterFirst.completed
      .filter((g) => g.id === 'make_three_friends').length;
    expect(completedAfterFirst).toBe(1);

    // Re-queue the SAME milestones (e.g. a stale snapshot reprocesses) and drain
    // again. unlockAchievement() returns null for already-unlocked keys and
    // evaluateGoals skips already-completed goals, so nothing should re-grant.
    player.lifecycleQueue.push({
      type: 'graduation',
      data: { level: 'high school', gpa: 4.0 },
    });
    player.lifecycleQueue.push({ type: 'friend_made' });

    const grad2 = await checkAchievementsAsync(
      playerId,
      'graduate',
      { level: 'high school', gpa: 4.0 },
    );
    // graduate_hs/straight_a already unlocked -> nothing newly returned.
    expect(grad2.map((u) => u.key)).not.toContain('graduate_hs');
    expect(grad2.map((u) => u.key)).not.toContain('straight_a');

    await drainLifecycleQueueOffline(player);

    const goalsAfterSecond = getPlayerLifeGoals(playerId);
    const completedAfterSecond = goalsAfterSecond.completed
      .filter((g) => g.id === 'make_three_friends').length;
    // Still exactly one completion — no double-grant.
    expect(completedAfterSecond).toBe(1);
  });

  it('IDEMPOTENCY: offline grant then later ONLINE-style grant does NOT double-grant', async () => {
    const playerId = 'offline-then-online';
    const player = makeOfflinePlayer(playerId);

    player.lifecycleQueue.push({
      type: 'graduation',
      data: { level: 'high school', gpa: 4.0 },
    });
    await drainLifecycleQueueOffline(player);
    expect(hasUnlockedAchievement(playerId, 'graduate_hs')).toBe(true);

    // Player reconnects; the online handler path would call checkAchievementsAsync
    // again for the same milestone. It must NOT re-unlock.
    const onlineUnlocked = await checkAchievementsAsync(
      playerId,
      'graduate',
      { level: 'high school', gpa: 4.0 },
    );
    expect(onlineUnlocked.length).toBe(0);

    // Life-goal idempotency across the same boundary: complete friends offline,
    // then re-evaluate online — no second completion.
    trackFriendMade(playerId);
    trackFriendMade(playerId);
    trackFriendMade(playerId);
    const ctx1 = buildGoalContext(playerId, player);
    evaluateGoals(playerId, ctx1, player);
    const onceCompleted = getPlayerLifeGoals(playerId).completed
      .filter((g) => g.id === 'make_three_friends').length;
    expect(onceCompleted).toBe(1);

    const ctx2 = buildGoalContext(playerId, player);
    evaluateGoals(playerId, ctx2, player);
    const stillOnce = getPlayerLifeGoals(playerId).completed
      .filter((g) => g.id === 'make_three_friends').length;
    expect(stillOnce).toBe(1);
  });
});
