/**
 * Online Reward Delivery — End-to-End Integration (T009 verification)
 *
 * The headless lifetime-simulator reports reward cadence = 0 because it never
 * drives the ONLINE handler/hook path that actually grants and delivers rewards.
 * This suite proves that, in a real online session, completing a reward-worthy
 * action GRANTS the reward (diamonds via the shared diamond economy) AND SENDS a
 * corresponding reward message to the connected client (session.send), across
 * the three live reward systems:
 *
 *   1. Daily quests   — complete via updateQuestProgress(), then claim through the
 *                       REAL command handler handleClaimQuestReward(). Asserts the
 *                       diamond grant + the `questRewardClaimed` client message.
 *   2. Achievements   — fire the REAL retention hook onChildBorn(). Asserts the
 *                       diamond grant + the `achievementUnlocked` client message.
 *   3. Life goals     — drive the REAL hook updateLifeGoals() to completion.
 *                       Asserts the diamond grant + the `lifeGoalsUpdate` client
 *                       message carrying justCompleted + reward.
 *
 * It also confirms the FU-A diamond clamp coexists with the reward path: balances
 * never go negative, and an affordable spend still deducts after rewards land.
 *
 * Storage is forced in-memory (disableDatabaseStorage) so the test exercises the
 * production code paths without a live MySQL connection — the same fallback the
 * handlers use when the DB is unavailable.
 */
import { describe, it, expect, beforeEach, afterEach } from 'vitest';

import { handleClaimQuestReward } from '../../../src/handlers/retention.js';
import {
  onChildBorn,
  updateLifeGoals,
} from '../../../src/services/retention/integration.js';
import {
  generateDailyQuests,
  updateQuestProgress,
  getActiveQuests,
  disableDatabaseStorage,
  enableDatabaseStorage,
  clearAllQuests,
  type ActiveQuest,
} from '../../../src/services/retention/dailyQuests.js';
import {
  initializePlayerStatistics,
  incrementStat,
  clearAllStatistics,
} from '../../../src/services/retention/statistics.js';
import {
  clearAllLifeGoals,
  clearPlayerLifeGoals,
} from '../../../src/services/retention/lifeGoals.js';
import { clearAllAchievements } from '../../../src/services/retention/achievements.js';
import {
  getDiamondBalance,
  spendDiamonds,
  clearAllDiamonds,
} from '../../../src/monetization/diamondEconomy.js';
import type { PlayerSession } from '../../../src/game/PlayerSession.js';

const PLAYER_ID = 'test-online-reward-delivery';

interface SentMessage {
  type: string;
  [key: string]: unknown;
}

/**
 * Minimal fake session matching what the real online code touches:
 * - .player (with userId + c.diamonds currency container)
 * - .send(message) — captures server->client messages
 * - .sendPlayerObject() — the handlers call this after a successful reward to
 *   push the refreshed balance; we record it so we can assert the client was
 *   told to refresh.
 */
function makeFakeSession(opts: { age?: number; money?: number; prestige?: number } = {}) {
  const sent: SentMessage[] = [];
  let playerObjectSends = 0;
  const player = {
    userId: PLAYER_ID,
    money: opts.money ?? 0,
    c: {
      ageYears: opts.age ?? 30,
      prestige: opts.prestige ?? 0,
      diamonds: 0,
    },
  };
  const session = {
    player,
    send(message: unknown) {
      sent.push(message as SentMessage);
    },
    sendPlayerObject() {
      playerObjectSends += 1;
    },
  };
  return {
    session: session as unknown as PlayerSession,
    sent,
    player,
    getPlayerObjectSends: () => playerObjectSends,
  };
}

function lastOfType(sent: SentMessage[], type: string): SentMessage | undefined {
  return [...sent].reverse().find((m) => m.type === type);
}

describe('Online reward delivery (handler + hook path) — T009', () => {
  beforeEach(() => {
    // Force the in-memory storage path so the production handler/hook code runs
    // without a live DB. This is the same fallback the server uses if MySQL is
    // unavailable, so we still exercise the real code paths.
    disableDatabaseStorage();
    clearAllQuests();
    clearAllStatistics();
    clearAllLifeGoals();
    clearAllAchievements();
    clearAllDiamonds();
    initializePlayerStatistics(PLAYER_ID);
  });

  afterEach(() => {
    clearAllQuests();
    clearAllStatistics();
    clearPlayerLifeGoals(PLAYER_ID);
    clearAllAchievements();
    clearAllDiamonds();
    enableDatabaseStorage();
  });

  it('daily quest: completing + claiming through handleClaimQuestReward grants diamonds AND sends questRewardClaimed to the client', async () => {
    const { session, player, sent, getPlayerObjectSends } = makeFakeSession();

    // Online session would have generated the day's quests already.
    const quests = await generateDailyQuests(PLAYER_ID);
    expect(quests.length).toBeGreaterThan(0);

    // Pick the easiest quest and drive it to completion via the SAME function the
    // integration hooks call (updateQuestProgress).
    const active = await getActiveQuests(PLAYER_ID);
    const target: ActiveQuest = active.reduce((a, b) =>
      a.progressRequired <= b.progressRequired ? a : b
    );

    const progressResult = await updateQuestProgress(
      PLAYER_ID,
      target.questType,
      target.progressRequired,
      player
    );
    expect(progressResult).toBeTruthy();
    expect(progressResult!.justCompleted).toBe(true);

    // Completion alone must NOT have paid out yet — the reward is granted on claim.
    expect(getDiamondBalance(player)).toBe(0);

    // Now claim through the REAL online command handler (what the client triggers).
    await handleClaimQuestReward({ questId: target.id }, session);

    // (a) Diamonds were actually awarded (balance increased by the quest reward).
    expect(getDiamondBalance(player)).toBe(target.diamondReward);
    expect(player.c.diamonds).toBe(target.diamondReward);

    // (b) A reward message was SENT to the client session.
    const claimed = lastOfType(sent, 'questRewardClaimed');
    expect(claimed).toBeDefined();
    expect(claimed!.success).toBe(true);
    expect((claimed!.reward as { diamonds: number }).diamonds).toBe(target.diamondReward);

    // The handler also pushes a refreshed player object so the client sees the
    // new balance, and re-emits the quest slate.
    expect(getPlayerObjectSends()).toBeGreaterThan(0);
    expect(lastOfType(sent, 'dailyQuestsStatus')).toBeDefined();
  });

  it('daily quest: a second claim of the same quest is rejected (no double-pay, balance unchanged)', async () => {
    const { session, player, sent } = makeFakeSession();

    await generateDailyQuests(PLAYER_ID);
    const active = await getActiveQuests(PLAYER_ID);
    const target = active.reduce((a, b) =>
      a.progressRequired <= b.progressRequired ? a : b
    );
    await updateQuestProgress(PLAYER_ID, target.questType, target.progressRequired, player);

    await handleClaimQuestReward({ questId: target.id }, session);
    const afterFirst = getDiamondBalance(player);
    expect(afterFirst).toBe(target.diamondReward);

    sent.length = 0;
    await handleClaimQuestReward({ questId: target.id }, session);

    // No extra diamonds on the duplicate claim.
    expect(getDiamondBalance(player)).toBe(afterFirst);
    const claimed = lastOfType(sent, 'questRewardClaimed');
    expect(claimed).toBeDefined();
    expect(claimed!.success).toBe(false);
  });

  it('achievement: onChildBorn unlocks "have_child", grants diamonds AND sends achievementUnlocked to the client', async () => {
    const { session, player, sent } = makeFakeSession({ age: 30 });

    // Fire the REAL retention hook the online game loop calls when a child is born.
    await onChildBorn(session);

    // (a) The achievement reward (diamonds) actually landed on the character.
    expect(getDiamondBalance(player)).toBeGreaterThan(0);

    // (b) The client received an achievementUnlocked message for the unlock.
    const unlock = lastOfType(sent, 'achievementUnlocked');
    expect(unlock).toBeDefined();
    const ach = unlock!.achievement as { id: string; key: string; reward: number };
    expect(ach.key).toBe('have_child');
    expect(ach.reward).toBeGreaterThan(0);

    // The diamond grant equals the achievement's advertised reward.
    expect(getDiamondBalance(player)).toBe(ach.reward);
  });

  it('life goal: completing "Raise a Family" via updateLifeGoals grants diamonds AND sends lifeGoalsUpdate w/ justCompleted to the client', async () => {
    const { session, player, sent } = makeFakeSession({ age: 30 });

    // Seed the active slate (unlocks age-eligible goals incl. raise_two_children).
    updateLifeGoals(session);
    sent.length = 0;

    // Bring children to the goal target of 2, then re-evaluate via the real hook.
    incrementStat(PLAYER_ID, 'childrenCount', 2);
    const completed = updateLifeGoals(session);

    // (a) The completion granted diamonds to the character.
    expect(player.c.diamonds).toBeGreaterThan(0);

    // (b) The client received a lifeGoalsUpdate carrying the just-completed goal.
    const update = lastOfType(sent, 'lifeGoalsUpdate');
    expect(update).toBeDefined();
    const justCompleted = update!.justCompleted as Array<Record<string, unknown>>;
    const family = justCompleted.find((g) => g.id === 'raise_two_children');
    expect(family).toBeDefined();
    expect(family!.reward).toBeGreaterThan(0);

    // The hook's return value agrees with what was sent.
    expect(completed.some((g) => g.id === 'raise_two_children')).toBe(true);

    // Diamond grant equals the goal's advertised reward.
    expect(player.c.diamonds).toBe(family!.reward as number);
  });

  it('FU-A clamp coexists with rewards: balance is non-negative, an affordable spend deducts, an overspend is refused', async () => {
    const { session, player } = makeFakeSession({ age: 30 });

    // Earn a real reward through the online achievement path.
    await onChildBorn(session);
    const earned = getDiamondBalance(player);
    expect(earned).toBeGreaterThan(0);

    // An affordable spend deducts cleanly.
    const spendSome = spendDiamonds(player, 'test_spend', 1);
    expect(spendSome.success).toBe(true);
    expect(getDiamondBalance(player)).toBe(earned - 1);

    // An overspend is refused and leaves the balance untouched (never negative).
    const balanceBefore = getDiamondBalance(player);
    const overspend = spendDiamonds(player, 'test_overspend', balanceBefore + 1000);
    expect(overspend.success).toBe(false);
    expect(getDiamondBalance(player)).toBe(balanceBefore);
    expect(getDiamondBalance(player)).toBeGreaterThanOrEqual(0);

    // Draining the exact balance is allowed and clamps to zero, never below.
    const drain = spendDiamonds(player, 'test_drain', balanceBefore);
    expect(drain.success).toBe(true);
    expect(getDiamondBalance(player)).toBe(0);
    expect(player.c.diamonds).toBe(0);
  });
});
