/**
 * Game Loop Manager
 *
 * Handles the core game simulation loop and iteration utilities.
 * - Main game loop (initLifeSim): Processes each game tick
 * - Game iteration (iterateGames): Loads and processes all active games
 */

import { Player, Person, Season, parseOneTimeEvents } from '../../models/index.js';
import { GameEngine, getGameEngine, BatchedUpdate, GameEvent } from './GameEngine.js';
import { config } from '../../config.js';
import { connectionRegistry } from '../../server/ConnectionRegistry.js';
import {
  checkTutorialEvents,
  checkDilemmas,
  applyDailyFamiliarityDecay,
  handleRelationships,
} from '../../stats/stats_manager.js';
import { getApplicableEvents } from './EventRegistry.js';
import { getIntradayActivity, getDailyPlan } from './intradayActivity.js';
import { updateDeathChance, checkDeath, handleDeath } from '../../services/health/health_manager.js';
import { handleHabitChanges } from '../../services/health/habit_manager.js';
import {
  handleNPCSchedules,
  updateNPCLocations,
  processHourlyNPCInteractions,
} from '../../services/npc/index.js';
import { getNextPendingEvent } from '../../events/conversations/index.js';
import { handleAllActivityProgress } from '../../events/activities/progress.js';
import {
  checkAchievementsAsync,
  getPlayerStatistics,
} from '../../services/retention/index.js';
import {
  endLiveActivityForPlayer,
  queueLiveActivityUpdate,
} from '../../services/notifications/liveActivityManager.js';
import { applyProratedFinances } from '../finance.js';

/**
 * Get the season based on month (utility function)
 */
function getSeason(month: number): Season {
  if (month >= 3 && month <= 5) return 'spring';
  if (month >= 6 && month <= 8) return 'summer';
  if (month >= 9 && month <= 11) return 'autumn';
  return 'winter';
}

export interface LoopManagerOptions {
  tickInterval?: number;
  maxOfflineMinutes?: number;
}

export interface OfflineStats {
  minutesOffline: number;
  lastOnline?: Date;
}

export interface ConnectionRecord {
  playerId: string;
  connection: 'connected' | 'disconnected';
  lastActivity: Date;
}

// In-memory player records (replace with database in production)
const playerRecords: Map<string, Player> = new Map();

/**
 * Register a player in the records
 */
export function registerPlayer(player: Player): void {
  if (!player.userId) return;
  playerRecords.set(player.userId, player);
}

/**
 * Unregister a player from records
 */
export function unregisterPlayer(playerId: string): void {
  playerRecords.delete(playerId);
}

/**
 * Get a player from records
 */
export function getPlayer(playerId: string): Player | undefined {
  return playerRecords.get(playerId);
}

/**
 * Get all registered players
 */
export function getAllPlayers(): Player[] {
  return Array.from(playerRecords.values());
}

/**
 * Iterate through all saved games and process one tick for disconnected players
 */
export async function iterateGames(
  loadGames: () => Promise<Array<{ playerId: string }>>,
  loadGameAsync: (playerId: string) => Promise<Player | null>,
  saveGameAsync: (player: Player) => Promise<void>
): Promise<void> {
  try {
    const games = await loadGames();

    if (!games || games.length === 0) {
      return;
    }

    for (const game of games) {
      // Skip players with active WebSocket connections
      if (connectionRegistry.has(game.playerId)) {
        continue;
      }

      const cachedPlayer = playerRecords.get(game.playerId);

      // Only process disconnected players
      if (!cachedPlayer || cachedPlayer.connection === 'disconnected') {
        const foundGame = await loadGameAsync(game.playerId);

        if (foundGame && foundGame.c?.status === 'alive') {
          console.log(
            `Iterating game ${foundGame.c.firstname} ${foundGame.c.lastname} ` +
            `${foundGame.minuteOfHour}min`
          );

          await initLifeSim(null, foundGame, saveGameAsync);

          // Grant achievement + life-goal credit for any milestones that fired
          // during this offline tick. Milestone events (graduation, marriage,
          // etc.) push onto player.lifecycleQueue and self-mark player.events,
          // but the offline path has no PlayerSession to drain that queue and
          // the queue is transient (never persisted) — so without this the
          // credit is permanently lost and won't re-fire on reconnect. Uses the
          // SAME idempotent, session-free grant calls as the online drain, with
          // NO client push. Bounded: only runs when the queue is non-empty.
          if (foundGame.lifecycleQueue && foundGame.lifecycleQueue.length > 0) {
            const { drainLifecycleQueueOffline } = await import(
              '../../services/retention/integration.js'
            );
            await drainLifecycleQueueOffline(foundGame);
          }

          if ((foundGame.c?.status as string) === 'dead' || foundGame.status !== 'playing') {
            await endLiveActivityForPlayer(foundGame);
          } else {
            await queueLiveActivityUpdate(foundGame);
          }
        }
      }
    }
  } catch (error) {
    console.error('Error iterating games:', error);
  }
}

/**
 * Send function type for WebSocket messaging
 */
export type SendFunction = (message: Record<string, unknown>) => Promise<void>;

export interface WebSocketConnection {
  userID: string;
  send: SendFunction;
}

/**
 * Create batched update object
 */
export function createBatchedUpdate(player: Player): BatchedUpdate {
  if (!player.c) return {};

  return {
    date: player.date,
    hourOfDay: player.hourOfDay,
    minuteOfHour: player.minuteOfHour,
    weekDayText: player.weekDayText,
    energy: player.c.energy,
    calcEnergy: player.c.calcEnergy,
    money: player.c.money,
    diamonds: player.c.diamonds,
    prestige: player.c.prestige,
    stress: player.c.stress,
    happiness: player.c.happiness,
    occupation: player.c.occupation,
    location: player.c.location,
    schedules: player.c.schedules,
    intraDayMessage: player.c.intraDayMessage,
    dailyPlan: player.c.dailyPlan,
    gameSpeed: player.gameSpeed,
  };
}

/**
 * Main game simulation loop - processes one game tick
 */
export async function initLifeSim(
  websocket: WebSocketConnection | null,
  oneTimePlayer: Player | null = null,
  saveGameAsync?: (player: Player) => Promise<void>,
  sendEventMessage?: (ws: WebSocketConnection | null, event: GameEvent) => Promise<void>,
  sendUserInfo?: (player: Player, ws: WebSocketConnection | null) => Promise<void>,
  sendDict?: (ws: WebSocketConnection | null, data: Record<string, unknown>) => Promise<void>
): Promise<boolean> {
  // Skip dummy users
  if (websocket?.userID === 'DUMMY_USER_ID') {
    return false;
  }

  // Get player
  let player: Player | null = null;
  if (oneTimePlayer) {
    player = oneTimePlayer;
  } else if (websocket) {
    player = playerRecords.get(websocket.userID) ?? null;
  }

  if (!player || !player.c) {
    return false;
  }

  const engine = getGameEngine();

  player.ticks = (player.ticks ?? 0) + 1;
  const gameSpeed = player.gameSpeed ?? 1000;

  if (player.ticks % gameSpeed === 0 || oneTimePlayer) {
    // Check if game is active
    if ((player.controller === 'active' && player.status !== 'creating') || oneTimePlayer) {
      // Handle death
      if (player.c.status === 'dead') {
        engine.handleDeath(player);
        if (saveGameAsync) {
          await saveGameAsync(player);
        }
        return false;
      }

      // Increment time
      player.minuteOfHour = (player.minuteOfHour ?? 0) + 1;
      player.time = `${player.hourOfDay ?? 0}:${player.minuteOfHour}`;

      if (player.minuteOfHour === 60) {
        player.minuteOfHour = 0;
      }

      // Hourly ticks
      if (player.minuteOfHour === 0) {
        const updateObject = createBatchedUpdate(player);
        player.hourOfDay = (player.hourOfDay ?? 0) + 1;

        // Update age
        const ageEvent = engine.updateAge(player);
        if (ageEvent?.type === 'messageEvent' && sendEventMessage) {
          // Mirror sibling branches (:460, :481): record the emitted id in
          // player.events. updateAge already self-guards on player.events, so
          // this is defensive — it keeps the offline path's dedup bookkeeping
          // explicit and uniform with the other event emitters.
          player.events.add(ageEvent.id);
          await sendEventMessage(websocket, ageEvent);
        }

        // Process intraday activity for main character and all relationships
        player.c = getIntradayActivity(player, player.c);
        for (let i = 0; i < (player.r?.length ?? 0); i++) {
          player.r[i] = getIntradayActivity(player, player.r[i]);
        }

        // Update NPC locations based on their daily plans
        updateNPCLocations(player);

        // Check for NPC interactions at current location
        // Only check when game is running at normal speeds (not fast-forwarding)
        if (gameSpeed < (config.SPEED_QUESTION_PAUSE ?? 500) && sendEventMessage) {
          const npcInteractionResults = processHourlyNPCInteractions(player);
          for (const result of npcInteractionResults) {
            if (result.triggered && result.event && result.interaction) {
              await sendEventMessage(websocket, {
                type: result.event.type as 'messageEvent',
                id: result.interaction.id,
                message: result.event.message,
              });
            }
          }
        }

        // Parse one-time scheduled events
        parseOneTimeEvents(
          player,
          sendEventMessage ? async (event) => {
            await sendEventMessage(websocket, {
              type: event.type as 'messageEvent',
              id: `onetime_${Date.now()}`,
              message: event.message,
            });
          } : undefined
        );

        // Daily ticks (at hour 24)
        if (player.hourOfDay === 24) {
          player.hourOfDay = 0;

          // Update day counters
          player.dayOfYear = player.dayOfYear === 365 ? 1 : (player.dayOfYear ?? 0) + 1;
          player.dayOfWeek = player.dayOfWeek === 7 ? 1 : (player.dayOfWeek ?? 0) + 1;

          // Update date
          const baseDate = new Date(2022, 0, 1);
          baseDate.setDate(baseDate.getDate() + (player.dayOfYear ?? 1) - 1);
          const month = baseDate.getMonth() + 1;
          const day = baseDate.getDate();

          player.date = `${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
          player.monthOfYear = month;
          player.season = getSeason(month);
          player.time = `${player.hourOfDay}:00`;

          const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
          player.weekDayText = WEEKDAYS[(player.dayOfWeek ?? 1) - 1];

          // School days calculation
          player.daysUntilSchoolEnds = 152 - (player.dayOfYear ?? 0);
          if ((player.dayOfYear ?? 0) > 244) {
            player.daysUntilSchoolEnds = (365 - 244) + player.daysUntilSchoolEnds;
          }

          player.daysSinceSchoolStarted = (player.dayOfYear ?? 0) - 244;
          if ((player.dayOfYear ?? 0) < 244) {
            player.daysSinceSchoolStarted = 121 + (player.dayOfYear ?? 0);
          }

          player.summerVacation = (player.dayOfYear ?? 0) > 152 && (player.dayOfYear ?? 0) < 244;

          // Habit quit progress: once per in-game day for the main character
          if ((player.c.habits ?? []).some((h) => h.status === 'quitting')) {
            handleHabitChanges(player, player.c);
          }
        }

        // Weekly ticks (Monday at hour 0)
        if (player.dayOfWeek === 1 && player.hourOfDay === 0) {
          console.log('Weekly tick');

          // Process weekly updates for main character
          engine.handleWeeklyUpdates(player, player.c);

          // Process weekly updates for ALL relationship characters (like Python)
          for (const person of player.r ?? []) {
            if (person.status === 'alive') {
              engine.handleWeeklyUpdates(player, person);
            }
          }

          // Process activity progress (learning, physical activities, etc.)
          // This updates performance based on focus level and triggers achievements
          handleAllActivityProgress(player);

          if (saveGameAsync) {
            await saveGameAsync(player);
          }
          if (sendUserInfo) {
            await sendUserInfo(player, websocket);
          }
        }

        // Daily events check (at hour 0)
        if (player.hourOfDay === 0) {
          player.dayEvent = false;

          // Generate daily plans for main character
          player.c = getDailyPlan(player, player.c);

          // Generate daily plans for NPC relationship characters
          // Uses specialized NPC scheduler for more varied NPC behaviors
          handleNPCSchedules(player);

          // Recover energy
          const peakEnergy = engine.getPeakEnergy(player.c);
          if ((player.c.energy ?? 0) < 100) {
            player.c.energy = (player.c.energy ?? 0) + 1;
          }

          // First day message
          if (player.c.ageDays === 1 && player.c.firstname) {
            if (sendUserInfo) {
              await sendUserInfo(player, websocket);
            }
          }

          // Birthday check
          if ((player.c.ageDays ?? 0) > 0 && (player.c.ageDays ?? 0) % 365 === 0) {
            player.c.ageYears = (player.c.ageYears ?? 0) + 1;
            player.c.deathChance = engine.updateDeathChance(player.c);

            // Check birthday achievements (live_to_50, live_to_100, etc.)
            const stats = getPlayerStatistics(player.userId);
            checkAchievementsAsync(
              player.userId,
              'birthday',
              { age: player.c.ageYears },
              { age: player.c.ageYears },
              stats,
              player
            ).catch((err) => console.error('Error checking birthday achievements:', err));

            if (saveGameAsync) {
              await saveGameAsync(player);
            }
            if (sendUserInfo) {
              await sendUserInfo(player, websocket);
            }
          }

          // Death check
          const deathChance = player.c.deathChance ?? 0;
          if (deathChance * 100 > Math.random() * 100 || (player.c.ageYears ?? 0) > 120) {
            const finalAge = player.c.ageYears ?? 0;
            const finalMoney = player.c.money ?? 0;

            // Route the consequence through the SINGLE shared death path
            // (mirrors PlayerSession.processDayTick / GameEngine.runGameTick):
            // sets status 'dead' + controller 'inactive', queues the
            // "You have died!" message, and builds + persists player.lifeSummary.
            // Previously this set player.c.status = 'dead' INLINE, skipping the
            // summary/controller/message — leaving an offline-dead player in a
            // half-applied state with no death flow on reconnect.
            handleDeath(player);

            // Check death achievements (die_at_69, die_poor, die_rich, never_marry, etc.)
            const deathStats = getPlayerStatistics(player.userId);
            checkAchievementsAsync(
              player.userId,
              'death',
              { age: finalAge },
              { age: finalAge, money: finalMoney },
              deathStats,
              player
            ).catch((err) => console.error('Error checking death achievements:', err));

            if (sendUserInfo) {
              await sendUserInfo(player, websocket);
            }
          }

          // Update daily plans
          if (gameSpeed > 10) {
            updateObject.dailyPlan = player.c.dailyPlan;
          }

          // Apply daily familiarity decay for all relationships
          // Ported from Python loop_manager.py: familiarity -= 3 for alive relationships
          applyDailyFamiliarityDecay(player);
        }

        // Process message queue
        if (player.messageQueue && player.messageQueue.length > 0) {
          const message = player.messageQueue.shift();
          if (message) {
            player.messageLog = player.messageLog ?? [];
            player.messageLog.push(message);

            if (sendEventMessage) {
              await sendEventMessage(websocket, {
                type: 'messageEvent',
                id: `queue_${Date.now()}`,
                message,
              });
            }
          }
        }

        // Update client if needed
        if (player.updateClient) {
          if (sendUserInfo) {
            await sendUserInfo(player, websocket);
          }
          player.updateClient = false;
        }

        // Check events at slower game speeds
        if (gameSpeed < (config.SPEED_QUESTION_PAUSE ?? 500)) {
          // Check tutorial events first (take priority for new players)
          const tutorialResult = checkTutorialEvents(player, 'check');
          if (tutorialResult && sendEventMessage) {
            const eventId = 'id' in tutorialResult ? tutorialResult.id : `tutorial_${Date.now()}`;
            if (tutorialResult.type === 'messageEvent') {
              player.events.add(eventId);
              await sendEventMessage(websocket, {
                type: 'messageEvent',
                id: eventId,
                message: tutorialResult.message,
              });
            } else if (tutorialResult.type === 'questionEvent') {
              await sendEventMessage(websocket, {
                type: 'questionEvent',
                id: eventId,
                message: tutorialResult.message,
              });
            }
          } else {
            // Check regular events using event registry for O(1) filtering
            const applicableEvents = getApplicableEvents(player);
            for (const eventInfo of applicableEvents) {
              try {
                const result = eventInfo.func(player, 'check');
                if (result) {
                  if (result.type === 'messageEvent' && sendEventMessage) {
                    player.events.add(eventInfo.id);
                    await sendEventMessage(websocket, {
                      type: 'messageEvent',
                      id: eventInfo.id,
                      message: result.message,
                    });
                  } else if (result.type === 'questionEvent' && sendEventMessage) {
                    await sendEventMessage(websocket, {
                      type: 'questionEvent',
                      id: eventInfo.id,
                      message: result.message,
                    });
                  }
                  break; // Only trigger one event per tick
                }
              } catch (error) {
                console.error(`Error in event ${eventInfo.id}:`, error);
              }
            }
          }

          // Check dilemmas
          const dilemmaResult = checkDilemmas(player);
          if (dilemmaResult && sendEventMessage) {
            await sendEventMessage(websocket, {
              type: dilemmaResult.type,
              id: 'id' in dilemmaResult ? dilemmaResult.id : `dilemma_${Date.now()}`,
              message: dilemmaResult.message,
            });
          }

          // Check pending conversation events (from AI tool calls)
          const pendingEvent = getNextPendingEvent(player);
          if (pendingEvent && sendEventMessage) {
            console.log(`Processing pending conversation event: ${pendingEvent.type}`);

            // Pause game while waiting for response (like questionEvents)
            if (pendingEvent.type === 'questionEvent') {
              player.previousGameSpeed = player.gameSpeed;
              player.gameSpeed = config.SPEED_QUESTION_PAUSE;
            }

            await sendEventMessage(websocket, {
              type: pendingEvent.type,
              id: pendingEvent.id,
              message: pendingEvent.message,
              ...(pendingEvent.type === 'questionEvent' ? {
                answers: (pendingEvent as any).answers,
                characters: (pendingEvent as any).characters,
                _callbackData: (pendingEvent as any)._callbackData,
              } : {
                title: (pendingEvent as any).title,
                characters: (pendingEvent as any).characters,
              }),
            });
          }

          // Send pending announcements as official messageEvents (popups)
          while (player.pendingAnnouncements.length > 0) {
            const announcement = player.pendingAnnouncements.shift()!;
            console.log(`Sending announcement: ${announcement.title}`);

            if (sendEventMessage) {
              await sendEventMessage(websocket, {
                type: 'messageEvent',
                id: announcement.id,
                title: announcement.title,
                message: announcement.message,
                image: announcement.image ?? '',
                date: player.date,
                hour: player.hourOfDay,
                characters: [announcement.characterId],
                energyCost: 0,
                moneyCost: 0,
                diamondCost: 0,
                affinityChange: 0,
                // Custom metadata for iOS to style this as a special announcement
                isAnnouncement: true,
                announcementCategory: announcement.category,
              });
            }
          }
        }

        // Send update object
        if (Object.keys(updateObject).length > 0 && sendDict) {
          await sendDict(websocket, updateObject);
        }
      } else if (gameSpeed > 10 && sendDict) {
        await sendDict(websocket, { type: 'u', minuteOfHour: player.minuteOfHour });
      }

      // Handle offline player
      if (oneTimePlayer) {
        player.connection = 'disconnected';
        player.controller = 'inactive';

        player.offlineStats = player.offlineStats ?? { minutesOffline: 0 };
        player.offlineStats.minutesOffline += 1;

        console.log(`Offline for ${player.offlineStats.minutesOffline} minutes, saving game`);

        if (saveGameAsync) {
          await saveGameAsync(player);
        }
      }
    }
  }

  return false;
}

/**
 * Start the game loop for a player
 */
export function startGameLoop(
  player: Player,
  tickCallback: (player: Player) => Promise<void>,
  intervalMs = 1000
): NodeJS.Timeout {
  return setInterval(async () => {
    if (player.controller === 'active' && player.c?.status === 'alive') {
      await tickCallback(player);
    }
  }, intervalMs);
}

/**
 * Stop the game loop
 */
export function stopGameLoop(intervalId: NodeJS.Timeout): void {
  clearInterval(intervalId);
}

/**
 * Process offline time for a player.
 *
 * Advances the simulation for each minute the player was away (capped at ~7
 * days) AND collects a "welcome back" digest while doing so: the elapsed time,
 * money delta, age delta, and the 2-3 most NOTABLE events that fired. The digest
 * is stashed on `player.offlineStats.digest` (persisted via Player.toJSON) so
 * the init/reconnect path can surface it to the client.
 *
 * Notability reuses the same predicate as the online push path
 * (isNotableEventMessage) so the offline loop and realtime loop agree on what
 * counts as a headline event.
 */
export async function processOfflineTime(
  player: Player,
  saveGameAsync: (player: Player) => Promise<void>
): Promise<number> {
  if (!player.offlineStats?.lastOnline) {
    return 0;
  }

  const now = new Date();
  const lastOnline = new Date(player.offlineStats.lastOnline);
  const minutesOffline = Math.floor((now.getTime() - lastOnline.getTime()) / 60000);

  // Cap at reasonable amount (e.g., 1 week)
  const maxMinutes = 7 * 24 * 60;
  const processMinutes = Math.min(minutesOffline, maxMinutes);

  if (processMinutes <= 0) {
    return 0;
  }

  console.log(`Processing ${processMinutes} minutes of offline time for player`);

  // Snapshot before-state for delta computation.
  const moneyBefore = player.c?.money ?? 0;
  const ageYearsBefore = player.c?.ageYears ?? 0;

  // ── FU3: prorate the leftover (sub-week) days of finance ──────────────────
  // The offline loop below charges a FULL week of finances only when it crosses
  // a Monday at hour 0 (initLifeSim's weekly tick). A multi-day absence that
  // does not span a Monday (or that ends mid-week) therefore under-reports the
  // money delta — historically a flat +$0. We compute, up front, how many
  // whole Monday weekly-ticks the loop WILL fire over the offline window (using
  // the exact same calendar condition the loop uses), then after the loop apply
  // a single prorated slice for the remaining days the weekly ticks did not
  // cover. This mirrors online+offline weekly-finance semantics and does NOT
  // double-count, because the proration is strictly the leftover-day remainder.
  const startMinuteOfHour = player.minuteOfHour ?? 0;
  const startHourOfDay = player.hourOfDay ?? 0;
  const startDayOfWeek = player.dayOfWeek ?? 1;
  // Minutes from now until the FIRST Monday@00:00 boundary the loop will cross.
  // initLifeSim crosses a weekly tick when, after incrementing, dayOfWeek===1
  // and hourOfDay===0 and minuteOfHour===0.
  const minutesIntoWeek =
    (((startDayOfWeek - 1 + 7) % 7) * 24 * 60) + startHourOfDay * 60 + startMinuteOfHour;
  const minutesToFirstMonday = (7 * 24 * 60 - minutesIntoWeek) % (7 * 24 * 60);
  let weeklyTicksCharged = 0;
  if (processMinutes >= minutesToFirstMonday && minutesToFirstMonday >= 0) {
    weeklyTicksCharged = 1 + Math.floor((processMinutes - minutesToFirstMonday) / (7 * 24 * 60));
  }
  const totalOfflineDays = processMinutes / (24 * 60);
  const leftoverDays = Math.max(0, totalOfflineDays - weeklyTicksCharged * 7);

  // Collect notable event messages as they fire during the offline ticks.
  // We dedup by message text and keep insertion order so the digest reflects
  // the first few headline moments of the offline window.
  const seenNotable = new Set<string>();
  const notableMessages: string[] = [];

  // Lazily import isNotableEventMessage to avoid a circular import
  // (PlayerSession imports from LoopManager's module graph).
  const { isNotableEventMessage } = await import('../PlayerSession.js');

  const captureEvent = async (
    _ws: WebSocketConnection | null,
    event: GameEvent
  ): Promise<void> => {
    const message = (event as { message?: unknown }).message;
    const msgStr = typeof message === 'string' ? message : '';
    if (!msgStr || !isNotableEventMessage(msgStr)) return;
    if (seenNotable.has(msgStr)) return;
    seenNotable.add(msgStr);
    notableMessages.push(msgStr);
  };

  // Run game ticks for offline period, passing the capture hook as
  // sendEventMessage so notable events are recorded (they are otherwise
  // dropped on the floor in the offline path).
  for (let i = 0; i < processMinutes; i++) {
    await initLifeSim(null, player, saveGameAsync, captureEvent);
    // Stop advancing once the character dies offline. initLifeSim's daily death
    // check now routes through the shared handleDeath (status 'dead', controller
    // 'inactive', lifeSummary built, message queued); continuing to tick past
    // death would advance age/finance past the death point and otherwise diverge
    // from the online path, which stops simulating a dead character. Age and
    // money are therefore frozen at the moment of death. applyProratedFinances
    // below is already guarded on status === 'alive', so it correctly no-ops.
    if (player.c?.status === 'dead') {
      break;
    }
  }

  // FU3: charge the leftover sub-week of finances the loop's whole-week Monday
  // ticks did not cover, so a multi-day absence yields a meaningful money delta.
  // (If the character is still alive — a dead character earns nothing.)
  if (leftoverDays > 0 && player.c && player.c.status === 'alive') {
    applyProratedFinances(player.c, leftoverDays);
  }

  const moneyDelta = (player.c?.money ?? 0) - moneyBefore;
  const ageYearsDelta = (player.c?.ageYears ?? 0) - ageYearsBefore;

  player.offlineStats = player.offlineStats ?? { minutesOffline: 0 };
  player.offlineStats.digest = {
    minutesAway: processMinutes,
    moneyDelta,
    ageYearsDelta,
    notableEvents: notableMessages.slice(0, 3),
    generatedAt: now.toISOString(),
  };

  return processMinutes;
}
