/**
 * Decoupled Game Engine for BaoLife
 *
 * Provides a testable game loop that handles core game logic.
 * Extracted from initLifeSim() for CLI use and testing.
 */

import { Player, Person, Season } from '../../models/index.js';
import { config } from '../../config.js';
import {
  updateDeathChance as healthUpdateDeathChance,
  checkDeath as healthCheckDeath,
  handleDeath as healthHandleDeath,
  ESTATE_TAX_RETENTION,
  HEIR_AFFINITY_BONUS_MAX,
} from '../../services/health/health_manager.js';
import {
  processWeeklyRelationshipEvents,
  processWeeklyFriendEvents,
} from '../../events/relationships/index.js';
import { handleEducation as educationManagerHandleEducation, advanceEducation } from '../../services/education/education_manager.js';
import { checkPregnancyTerm } from '../../services/character/character_manager.js';
import { FOCUS_ENERGY_MODIFIERS } from '../../constants/focus.js';
import { applyWeeklyFinances } from '../finance.js';
// Aliased to avoid colliding with GameEngine's own private handleJob (which only
// nudges happiness). This is the real tier-promotion function shared with the
// online PlayerSession path, so offline and online careers progress identically.
import { handleJob as processJobProgression } from '../../services/jobs/index.js';
import {
  applyHourlySurvival,
  ENERGY_RESTORE_PER_NIGHT,
  ENERGY_MAX,
} from '../economyConstants.js';

// ============================================================================
// T010d: NPC-death grief / inheritance + parenting-arc tuning constants
// ============================================================================

/** Max happiness lost when a maximally-loved (affinity 100) NPC dies. */
export const GRIEF_HAPPINESS_MAX = 40;
/** Affinity at/above which a death is profound enough to flip the player's mood. */
export const GRIEF_MOOD_THRESHOLD = 60;
/** Minimum affinity for a deceased family member to leave an inheritance. */
export const INHERITANCE_AFFINITY_THRESHOLD = 50;

/** Age (years) at which a child resolves into an adult outcome. */
export const CHILD_ADULT_AGE = 18;
/** Affinity at/above which a matured child becomes a supportive adult. */
export const PARENTING_SUPPORTIVE_AFFINITY = 70;
/** Affinity at/below which a matured child becomes a distant adult. */
export const PARENTING_DISTANT_AFFINITY = 30;
/** Affinity bonus granted to a supportive matured child. */
export const PARENTING_SUPPORTIVE_BONUS = 15;
/** Additional affinity penalty applied to a distant matured child. */
export const PARENTING_DISTANT_PENALTY = 20;

/**
 * Inheritance from a deceased relationship NPC, scaled by the player's affinity
 * toward them. Reuses the player-death legacy shape (estate-tax retention + an
 * affinity bonus up to HEIR_AFFINITY_BONUS_MAX) but keyed on the *player's*
 * feeling for the NPC rather than an heir's feeling for the player.
 */
export function computeNpcInheritance(npcMoney: number, affinity: number): number {
  const estate = Math.max(0, npcMoney) * ESTATE_TAX_RETENTION;
  const a = Math.max(0, Math.min(100, affinity));
  const bonus = 1 + (a / 100) * HEIR_AFFINITY_BONUS_MAX;
  return Math.round(estate * bonus);
}

// Storage interface for game persistence
export interface IGameStorage {
  saveGame(player: Player): Promise<void>;
  loadGame(playerId: string): Promise<Player | null>;
}

// Output interface for client communication
export interface IGameOutput {
  sendEventMessage(event: GameEvent): Promise<void>;
  sendUserInfo(player: Player): Promise<void>;
  sendDict(data: Record<string, unknown>): Promise<void>;
}

export interface GameEvent {
  type: 'messageEvent' | 'questionEvent';
  id: string;
  message: string;
  [key: string]: unknown;
}

export interface BatchedUpdate {
  type?: string;
  date?: string;
  hourOfDay?: number;
  minuteOfHour?: number;
  weekDayText?: string;
  energy?: number;
  calcEnergy?: number;
  money?: number;
  diamonds?: number;
  prestige?: number;
  stress?: number;
  happiness?: number;
  occupation?: string;
  location?: string;
  schedules?: unknown[];
  intraDayMessage?: string;
  dailyPlan?: unknown[];
  gameSpeed?: number;
  [key: string]: unknown;  // Index signature for compatibility
}

const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];

/**
 * Decoupled game engine that can run with or without WebSocket
 */
export class GameEngine {
  private storage: IGameStorage | null;
  private output: IGameOutput | null;

  constructor(storage?: IGameStorage, output?: IGameOutput) {
    this.storage = storage ?? null;
    this.output = output ?? null;
  }

  /**
   * Get the season based on month
   */
  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';
  }

  /**
   * Calculate death chance based on age and health.
   * Delegates to health_manager.updateDeathChance — the SINGLE source of the
   * age/health mortality curve, so the offline path can never diverge from the
   * online one. See that function for the calibrated per-day probabilities
   * (re-tuned in T003 once the meal sink removed force-starvation, so healthy
   * lives now die of old age in a ~70-95 band instead of pinning at the cap).
   */
  updateDeathChance(person: Person): number {
    return healthUpdateDeathChance(person);
  }

  /**
   * Check if a person should die based on their death chance.
   * Delegates to health_manager.
   *
   * @param person - Person to check
   * @returns true if the person should die, false otherwise
   */
  checkDeath(person: Person): boolean {
    return healthCheckDeath(person);
  }

  /**
   * Get peak energy for a person
   * Calculates based on habits, activities, education, and job level (matches Python)
   * Also updates person.peakEnergy and person.calcEnergy
   *
   * Energy costs include:
   * - Quitting habits: +5 each
   * - Extracurricular activities: energyModifier from catalog (10-40)
   * - Education (schools): energyModifier from school (15-20)
   * - Jobs: level.energy_modifier
   * - Focus modifiers: Work Hard +10, Slack Off -10, Socialize +10, Balanced 0
   */
  getPeakEnergy(person: Person): number {
    let peakEnergy = 0;

    // Focus energy modifiers from centralized constants
    const focusModifiers = FOCUS_ENERGY_MODIFIERS;

    // Quitting habits cost energy (+5 each)
    for (const habit of person.habits ?? []) {
      if (habit.status === 'quitting') {
        peakEnergy += 5;
      }
    }

    // Process all activities (extracurriculars, schools, jobs, etc.)
    for (const activity of person.activities ?? []) {
      if (typeof activity === 'object' && activity !== null) {
        const activityObj = activity as {
          id?: string;
          type?: string;
          energyModifier?: number;
        };

        // Direct energy modifier on activity object
        if (activityObj.energyModifier) {
          peakEnergy += activityObj.energyModifier;
        }

        // Find matching activity record for focus/level modifiers
        const record = (person.activityRecords ?? []).find(
          (r) => typeof r === 'object' && r !== null && r.id === activityObj.id
        ) as {
          id?: string;
          type?: string;
          level?: { energy_modifier?: number };
          focus?: string;
        } | undefined;

        if (record) {
          // Job level energy modifier
          if (record.type === 'job' && record.level?.energy_modifier) {
            peakEnergy += record.level.energy_modifier;
          }

          // Focus energy modifier
          if (record.focus && focusModifiers[record.focus] !== undefined) {
            peakEnergy += focusModifiers[record.focus];
          }
        }
      }
    }

    // Add education energy modifier if person has current_education
    if (person.current_education) {
      const eduRecord = person.current_education as {
        focus?: string;
        energyModifier?: number;
      };
      // Education focus modifier
      if (eduRecord.focus && focusModifiers[eduRecord.focus] !== undefined) {
        peakEnergy += focusModifiers[eduRecord.focus];
      }
    }

    // Ensure minimum peak energy of 0
    peakEnergy = Math.max(0, peakEnergy);

    // Update person's peak and calculated energy
    person.peakEnergy = peakEnergy;
    person.calcEnergy = Math.max(0, (person.energy ?? 100) - peakEnergy);

    return peakEnergy;
  }

  /**
   * Update age tracking for a day and handle relationship decay
   * Ported from Python stats_manager.py updateAge()
   *
   * Decay mechanics:
   * - Every 30 days (for player > 18 years): affinity -= 1 for all relationships
   * - On relationship birthday (yearly): affinity -= 1 + death chance update
   * - Affinity floor at -100
   */
  updateAge(player: Player): GameEvent | null {
    if (!player.c) return null;

    // Increment player's age in hours first
    player.c.ageHours = (player.c.ageHours ?? 0) + 1;

    // Daily tick check (every 24 hours)
    if ((player.c.ageHours ?? 0) % 24 === 0) {
      player.c.ageDays = (player.c.ageDays ?? 0) + 1;

      // NPC death this tick surfaces a memorial event as the return value; any
      // additional NPC deaths in the same tick are queued via messageQueue.
      let pendingReturn: GameEvent | null = null;

      // Process age and decay for all relationships
      for (let i = 0; i < (player.r?.length ?? 0); i++) {
        const person = player.r[i];
        if (person.status !== 'alive') continue;

        // Increment relationship's age
        person.ageDays = (person.ageDays ?? 0) + 1;

        // Monthly affinity decay (every 30 days) for adult players
        // Ported from Python: if player.c.ageYears > 18 and item.ageDays % 30 == 0
        if ((player.c.ageYears ?? 0) > 18 && (person.ageDays ?? 0) % 30 === 0) {
          person.affinity = (person.affinity ?? 0) - 1;
        }

        // Yearly birthday check for relationships
        if ((person.ageDays ?? 0) % 365 === 0) {
          person.ageYears = (person.ageYears ?? 0) + 1;
          person.affinity = (person.affinity ?? 0) - 1; // Annual affinity decay
          person.deathChance = this.updateDeathChance(person);

          // Send birthday message for titled relationships (not classmates)
          const relationships = person.relationships ?? [];
          const isClassmate = relationships.includes('classmate');
          const title = (person as { title?: string }).title;

          if (!isClassmate && title) {
            // Idempotent per person/year. Keyed on the stable person.id (not the
            // mutable, non-unique firstname) and sharing player.events with the
            // online path (stats_manager.updateAge), so the every-60s offline
            // iteration loop can't re-deliver a birthday that already fired.
            const npcKey = `birthday_npc_${person.id || person.firstname}_${person.ageYears}`;
            if (!player.events.has(npcKey)) {
              player.events.add(npcKey);
              return {
                type: 'messageEvent',
                id: npcKey,
                message: `Your ${title} ${person.firstname} ${person.lastname} is now ${person.ageYears} years old.`,
                positive: true,
              };
            }
          }
        }

        // Check for relationship death
        const deathChance = person.deathChance ?? 0;
        const health = (person as { health?: number }).health ?? 1;
        if (deathChance * health * 100 > Math.random() * 100 || (person.ageYears ?? 0) > 120) {
          const npcDeathEvent = this.handleNpcDeath(player, person);
          if (npcDeathEvent && !pendingReturn) {
            // Surface the first NPC death this tick (memorial) as the return
            // event; any others are queued via messageQueue inside handleNpcDeath.
            pendingReturn = npcDeathEvent;
          }
        }

        // Parenting arc: while a child is a minor, accumulate parenting
        // investment (their affinity is the running proxy for warmth + how often
        // the player interacted). When the child crosses into adulthood, resolve
        // their lifelong outcome from that accumulated investment.
        const childEvent = this.applyParentingArc(player, person);
        if (childEvent && !pendingReturn) {
          pendingReturn = childEvent;
        }

        // Enforce affinity bounds (-100..100) per centralized STAT_BOUNDS.affinity
        if ((person.affinity ?? 0) < -100) {
          person.affinity = -100;
        }
        if ((person.affinity ?? 0) > 100) {
          person.affinity = 100;
        }
      }

      if (pendingReturn) {
        return pendingReturn;
      }

      // Check for player's birthday
      if ((player.c.ageDays ?? 0) > 0 && (player.c.ageDays ?? 0) % 365 === 0) {
        player.c.ageYears = (player.c.ageYears ?? 0) + 1;

        // Idempotent per year, sharing the dedup key/set with the online path.
        const playerKey = `birthday_${player.c.ageYears}`;
        if (!player.events.has(playerKey)) {
          player.events.add(playerKey);
          return {
            type: 'messageEvent',
            id: playerKey,
            message: `${player.c.firstname} is now ${player.c.ageYears} years old!`,
            positive: true,
          };
        }
      }
    }

    return null;
  }

  /**
   * Handle the death of a relationship NPC (T010d). Grief, inheritance, memorial.
   *
   * This is the NPC-death analogue of the player-death legacy (T010b) and does
   * NOT touch handleDeath (player) at all.
   *
   *  - Grief: the player takes a happiness/mood hit scaled to how much they loved
   *    the NPC (their affinity). High-affinity loss hurts a lot; a barely-known
   *    acquaintance barely registers.
   *  - Inheritance: if the deceased was high-affinity immediate family
   *    (familyLevel 1, affinity high), an affinity-scaled inheritance transfers to
   *    the player's money (reusing computeInheritance's estate-tax + affinity-bonus
   *    shape).
   *  - Memorial: a life event / message is logged so the loss is recorded.
   *
   * Returns a memorial messageEvent (so updateAge can surface it) or null.
   */
  handleNpcDeath(player: Player, person: Person): GameEvent | null {
    person.status = 'dead';

    const title = (person as { title?: string }).title ?? 'friend';
    const affinity = person.affinity ?? 0;
    const fullName = `${person.firstname} ${person.lastname}`;

    player.messageQueue = player.messageQueue ?? [];

    // --- Grief scaled to love (affinity). Only positive affinity grieves. ---
    const loved = Math.max(0, affinity);
    // Up to GRIEF_HAPPINESS_MAX happiness lost at affinity 100.
    const happinessHit = Math.round((loved / 100) * GRIEF_HAPPINESS_MAX);
    if (happinessHit > 0 && player.c) {
      player.c.happiness = Math.max(0, (player.c.happiness ?? 50) - happinessHit);
      player.c.stress = Math.min(100, (player.c.stress ?? 0) + Math.round(happinessHit / 2));
      // A profound loss colors the player's mood.
      if (loved >= GRIEF_MOOD_THRESHOLD) {
        player.c.mood = 'Depressed';
      }
    }

    // --- Inheritance from high-affinity immediate family. ---
    let inheritance = 0;
    const isFamily = (person.familyLevel ?? 0) === 1 || (person.relationships ?? []).includes('family');
    if (isFamily && affinity >= INHERITANCE_AFFINITY_THRESHOLD) {
      inheritance = computeNpcInheritance(person.money ?? 0, affinity);
      if (inheritance > 0 && player.c) {
        player.c.money = (player.c.money ?? 0) + inheritance;
      }
    }

    // --- Memorial message / life event ---
    let memorial = `Your ${title} ${fullName} has died at the age of ${person.ageYears} years old.`;
    if (happinessHit >= GRIEF_MOOD_THRESHOLD / 2) {
      memorial += ` The loss weighs heavily on you.`;
    }
    if (inheritance > 0) {
      memorial += ` They left you an inheritance of $${inheritance.toLocaleString()}.`;
    }

    // First death this tick is returned as the event; record subsequent ones in
    // the message queue so nothing is lost.
    const memorialEvent: GameEvent = {
      type: 'messageEvent',
      id: `npc_death_${person.id || person.firstname}`,
      message: memorial,
      positive: false,
      inheritance,
      affinity,
    };

    // Always queue so the offline digest / message log captures it too.
    player.messageQueue.push(memorial);

    return memorialEvent;
  }

  /**
   * Parenting arc (T010d). While a child is a minor, their affinity is the
   * running proxy for accumulated parenting investment (warmth + interaction
   * frequency — dates, gifts, conversations all move affinity, and neglect
   * decays it). When the child turns CHILD_ADULT_AGE, we resolve their lifelong
   * adult outcome from that accumulated affinity:
   *
   *  - high affinity  → 'supportive': adult child stays warm and in regular contact.
   *  - low/negative   → 'distant': adult child drifts, rare contact.
   *  - middling       → 'neutral'.
   *
   * The outcome is recorded once (childMaturedOutcome) so it is idempotent across
   * the every-60s offline iteration. Returns a messageEvent describing the
   * outcome, or null if no maturation happened this tick.
   */
  applyParentingArc(player: Player, person: Person): GameEvent | null {
    const relationships = person.relationships ?? [];
    if (!relationships.includes('child')) return null;

    // Accumulate parenting investment while still a minor (cheap running tally,
    // mainly for transparency / future tuning; affinity is the source of truth).
    if ((person.ageYears ?? 0) < CHILD_ADULT_AGE) {
      person.parentingInvestment = (person.parentingInvestment ?? 0) + Math.max(0, (person.affinity ?? 0)) / 365;
      return null;
    }

    // Already resolved — idempotent.
    if (person.childMaturedOutcome) return null;

    const affinity = person.affinity ?? 0;
    let outcome: 'supportive' | 'neutral' | 'distant';
    let message: string;
    const title = (person as { title?: string }).title ?? 'child';

    if (affinity >= PARENTING_SUPPORTIVE_AFFINITY) {
      outcome = 'supportive';
      // A well-tended child stays close and warm.
      person.affinity = Math.min(100, affinity + PARENTING_SUPPORTIVE_BONUS);
      person.familiarity = Math.min(100, (person.familiarity ?? 0) + 20);
      (person as { contactFrequency?: string }).contactFrequency = 'frequent';
      message = `Your ${title} ${person.firstname} has grown into a supportive adult who stays close to you.`;
    } else if (affinity <= PARENTING_DISTANT_AFFINITY) {
      outcome = 'distant';
      // A neglected child grows distant: affinity sinks further, contact is rare.
      person.affinity = Math.max(-100, affinity - PARENTING_DISTANT_PENALTY);
      person.familiarity = Math.max(0, (person.familiarity ?? 0) - 20);
      (person as { contactFrequency?: string }).contactFrequency = 'rare';
      message = `Your ${title} ${person.firstname} has grown into a distant adult. Years of neglect left you estranged.`;
    } else {
      outcome = 'neutral';
      (person as { contactFrequency?: string }).contactFrequency = 'occasional';
      message = `Your ${title} ${person.firstname} has grown into an adult, keeping you at a comfortable distance.`;
    }

    person.childMaturedOutcome = outcome;

    player.messageQueue = player.messageQueue ?? [];
    player.messageQueue.push(message);

    return {
      type: 'messageEvent',
      id: `child_matured_${person.id || person.firstname}`,
      message,
      positive: outcome === 'supportive',
      outcome,
    };
  }

  /**
   * Handle player character death.
   * Sets status to dead, controller to inactive, and queues death message.
   * Delegates to health_manager for the core logic.
   */
  handleDeath(player: Player): void {
    if (!player.c) return;

    // Use health_manager implementation which:
    // - Queues "You have died!" message
    // - Sets controller to 'inactive'
    // - Sets character status to 'dead'
    healthHandleDeath(player);

    console.log(`Player ${player.c.firstname} has died at age ${player.c.ageYears}`);
  }

  /**
   * Run one game tick for a player
   */
  async runGameTick(player: Player, forceUpdate = false): Promise<Player> {
    if (!player.c) return player;

    player.ticks = (player.ticks ?? 0) + 1;

    // Check if we should run this tick
    const gameSpeed = player.gameSpeed ?? 1000;
    if (player.ticks % gameSpeed !== 0 && !forceUpdate) {
      return player;
    }

    // Check if game is active
    if (player.controller !== 'active' && !forceUpdate) {
      return player;
    }

    // Check if player is creating character
    if (player.status === 'creating' && !forceUpdate) {
      return player;
    }

    // Handle death
    if (player.c.status === 'dead') {
      this.handleDeath(player);
      if (this.storage) {
        await this.storage.saveGame(player);
      }
      return player;
    }

    // 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) {
      // Recalculate calcEnergy then apply the SHARED hourly survival tick so the
      // offline path produces identical hunger/thirst/health/starvation/energy-
      // scarcity results to the online PlayerSession path (both call
      // economyConstants.applyHourlySurvival).
      this.getPeakEnergy(player.c);
      applyHourlySurvival(player.c);

      const updateObject: BatchedUpdate = {
        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,
        hunger: player.c.hunger,
        thirst: player.c.thirst,
        health: player.c.health,
        intelligence: player.c.intelligence,
        ageYears: player.c.ageYears,
        occupation: player.c.occupation,
        location: player.c.location,
        schedules: player.c.schedules,
        intraDayMessage: player.c.intraDayMessage,
        dailyPlan: player.c.dailyPlan,
        gameSpeed: player.gameSpeed,
      };

      player.hourOfDay = (player.hourOfDay ?? 0) + 1;

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

        // Update day counter
        if (player.dayOfYear === 365) {
          player.dayOfYear = 1;
        } else {
          player.dayOfYear = (player.dayOfYear ?? 0) + 1;
        }

        if (player.dayOfWeek === 7) {
          player.dayOfWeek = 1;
        } else {
          player.dayOfWeek = (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 = this.getSeason(month);
        player.time = `${player.hourOfDay}:00`;
        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;

        // Deliver any pregnancy that reached term (creates a real child +
        // queues child_born; the offline credit is granted by
        // drainLifecycleQueueOffline in LoopManager). Mirrors the online
        // PlayerSession.processDayTick call.
        checkPregnancyTerm(player);

        // Advance education by age (grade progression + HS/college graduation).
        // Mirrors the online PlayerSession.processDayTick call.
        advanceEducation(player);
      }

      // Weekly ticks (Monday at hour 0)
      if (player.dayOfWeek === 1 && player.hourOfDay === 0) {
        // Handle weekly updates for all relationships
        for (const person of player.r ?? []) {
          this.handleWeeklyUpdates(player, person);
        }

        // Handle weekly updates for main character
        this.handleWeeklyUpdates(player, player.c);

        if (this.storage) {
          await this.storage.saveGame(player);
        }
        if (this.output) {
          await this.output.sendUserInfo(player);
        }
      }

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

        // Update age (once per day)
        player.c.ageDays = (player.c.ageDays ?? 0) + 1;

        // Overnight sleep recovery — UNIFIED with the online PlayerSession path:
        // restore ENERGY_RESTORE_PER_NIGHT toward the 100 ceiling (was a divergent
        // +1/day toward peakEnergy, which let online/offline drift apart).
        if ((player.c.energy ?? ENERGY_MAX) < ENERGY_MAX) {
          player.c.energy = Math.min(ENERGY_MAX, (player.c.energy ?? ENERGY_MAX) + ENERGY_RESTORE_PER_NIGHT);
        }
        this.getPeakEnergy(player.c);

        // First day message
        if (player.c.ageDays === 1 && player.c.firstname) {
          if (this.output) {
            await this.output.sendEventMessage({
              type: 'messageEvent',
              id: 'first_day',
              message: `${player.c.firstname} is starting their life, full of opportunities.`,
            });
          }
        }

        // 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 = this.updateDeathChance(player.c);

          const playerKey = `birthday_${player.c.ageYears}`;
          if (this.output && !player.events.has(playerKey)) {
            player.events.add(playerKey);
            await this.output.sendEventMessage({
              type: 'messageEvent',
              id: playerKey,
              message: `${player.c.firstname} is ${player.c.ageYears} years old.`,
            });
          }

          if (this.storage) {
            await this.storage.saveGame(player);
          }
        }

        // Death check
        const deathChance = player.c.deathChance ?? 0;
        if (deathChance * 100 > Math.random() * 100 || (player.c.ageYears ?? 0) > 120) {
          // Single source of truth: sets status='dead', controller='inactive',
          // queues the death message, and builds player.lifeSummary so the
          // offline path behaves identically to the online PlayerSession path.
          // The persisted lifeSummary is surfaced to the client on reconnect.
          healthHandleDeath(player);

          if (this.output) {
            await this.output.sendEventMessage({
              type: 'messageEvent',
              id: 'death',
              message: `${player.c.firstname} has died at the age of ${player.c.ageYears} years.`,
            });
          }
        }

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

        // Decrease familiarity for relationships
        for (const person of player.r ?? []) {
          if (person.status === 'alive' && (person.familiarity ?? 0) > 0) {
            person.familiarity = (person.familiarity ?? 0) - 3;
          }
        }
      }

      // 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 (this.output) {
            await this.output.sendEventMessage({
              type: 'messageEvent',
              id: `queue_${Date.now()}`,
              message,
            });
          }
        }
      }

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

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

    return player;
  }

  /**
   * Handle weekly updates for a person
   * Public so LoopManager can call it for all relationships
   *
   * Matches Python's weekly tick processing in loop_manager.py:
   * - handleFinances
   * - handleMoods
   * - handleEducation
   * - handleJob
   * - handleRelationships
   * - handleHabitChanges (for player character only)
   */
  handleWeeklyUpdates(player: Player, person: Person): void {
    // Advance career progression (tier promotion / firing / salary update) for the
    // PLAYER character BEFORE finances, so a same-week promotion raises salary
    // before this week's pay computes. This is the real promotion-bearing
    // handleJob (aliased as processJobProgression), shared with the online
    // PlayerSession.processWeekTick path for exact parity. It pushes any
    // promotion/firing message onto player.messageQueue, which the offline loop
    // drains as a messageEvent (delivered on reconnect) — the existing channel.
    if (person.id === player.c.id) {
      try {
        processJobProgression(player, person);
      } catch (err) {
        console.error('Error processing job progression:', err);
      }
    }

    // Handle finances
    this.handleFinances(person);

    // Handle moods
    this.handleMoods(player, person);

    // Handle education
    this.handleEducation(person);

    // Handle job (legacy happiness nudge — harmless; tier promotion handled above)
    this.handleJob(player, person);

    // Handle relationships
    this.handleRelationships(player, person);
  }

  /**
   * Process habit quitting progress for a person
   * Called weekly to increment quit progress for habits with status 'quitting'
   * After 30 weeks, the habit is successfully quit and removed
   *
   * Matches Python handleHabitChanges() from health/health_manager.py
   */
  handleHabitChanges(player: Player, person: Person): void {
    const habits = person.habits ?? [];

    for (let i = habits.length - 1; i >= 0; i--) {
      const habit = habits[i];
      if (habit.status === 'quitting') {
        habit.quitProgress = (habit.quitProgress ?? 0) + 1;
        console.log(`Habit: ${habit.name} progress: ${habit.quitProgress}/30`);

        if ((habit.quitProgress ?? 0) >= 30) {
          // Get display name for the success message
          const displayName = habit.name.replace(/_/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase());

          // Remove habit from list
          habits.splice(i, 1);
          player.messageQueue = player.messageQueue ?? [];
          player.messageQueue.push(`You have successfully quit "${displayName}"! You feel proud and healthier.`);

          // Quit payoff — mirror of habit_manager.handleHabitChanges (online).
          const pa = person as unknown as { happiness?: number; health?: number };
          pa.happiness = Math.min(100, (pa.happiness ?? 50) + 10);
          pa.health = Math.min(100, (pa.health ?? 100) + 5);

          // Recalculate peak energy (habit is gone, so no more +5 energy cost)
          this.getPeakEnergy(person);
        }
      }
    }
  }

  /**
   * Handle weekly finances for a person
   */
  private handleFinances(person: Person): void {
    // Delegates to the shared weekly-finance logic used by the online path too,
    // so online and offline players get identical weekly money deltas.
    applyWeeklyFinances(person);
  }

  /**
   * Handle weekly mood updates for a person
   */
  private handleMoods(player: Player, person: Person): void {
    // Adjust stress based on work/school
    if (person.occupation === 'student') {
      person.stress = Math.min(100, (person.stress ?? 0) + Math.random() * 5);
    } else if (person.occupation && person.occupation !== 'retired') {
      person.stress = Math.min(100, (person.stress ?? 0) + Math.random() * 3);
    }

    // Natural mood recovery
    person.stress = Math.max(0, (person.stress ?? 0) - Math.random() * 10);

    // Happiness fluctuation
    const happinessChange = (Math.random() - 0.5) * 10;
    person.happiness = Math.max(0, Math.min(100, (person.happiness ?? 50) + happinessChange));
  }

  /**
   * Handle weekly education updates
   * Uses education_manager for proper GPA updates based on focus level
   *
   * Focus effects on GPA:
   * - Work Hard: GPA increases more often (+1 modifier)
   * - Slack Off: GPA decreases more often (-1 modifier)
   * - Balanced/Socialize: Neutral changes
   */
  private handleEducation(person: Person): void {
    if (person.occupation === 'student') {
      // Increase intelligence slightly
      person.intelligence = Math.min(100, (person.intelligence ?? 50) + Math.random() * 2);

      // Use education_manager for GPA updates based on focus
      // This updates the activity record GPA based on current_education focus
      educationManagerHandleEducation(person);
    }
  }

  /**
   * Handle weekly job updates
   */
  private handleJob(player: Player, person: Person): void {
    if (person.occupation && person.occupation !== 'student' && person.occupation !== 'retired') {
      // Job satisfaction affects mood
      const jobSatisfaction = Math.random() > 0.5 ? 5 : -5;
      person.happiness = Math.max(0, Math.min(100, (person.happiness ?? 50) + jobSatisfaction));
    }
  }

  /**
   * Handle weekly relationship updates
   * Ported from Python: handleRelationships in functions.py and relationship_manager.py
   *
   * Weekly decay mechanics:
   * - Familiarity decays by 1 per week (toward 0)
   * - Affinity decays by 1 per week (toward 0, not below 0 for weekly)
   * - 5% chance of random relationship events for romantic relationships
   * - Messaging modifiers decay toward neutral (handled separately)
   *
   * Note: The main affinity decay (monthly and yearly) is handled in updateAge()
   */
  private handleRelationships(player: Player, person: Person): void {
    // Weekly decay for relationships
    // This is lighter than the daily/monthly decay in updateAge()
    for (const relPerson of player.r ?? []) {
      // Weekly familiarity decay (separate from daily -3 decay)
      if (relPerson.familiarity !== undefined && relPerson.familiarity > 0) {
        relPerson.familiarity = Math.max(0, relPerson.familiarity - 1);
      }

      // Weekly affinity decay (lighter than monthly decay in updateAge)
      // Only decay positive affinity toward 0 weekly; negative affinity stays
      if (relPerson.affinity !== undefined && relPerson.affinity > 0) {
        relPerson.affinity = Math.max(0, relPerson.affinity - 1);
      }
    }

    // Weekly social beats: romantic (5% partner event) + friend/family beats so
    // the wider social circle evolves too (mirrors the online PlayerSession).
    const relationshipEvents = [
      ...processWeeklyRelationshipEvents(player),
      ...processWeeklyFriendEvents(player),
    ];

    // Queue any triggered events to message queue
    for (const event of relationshipEvents) {
      if (event && 'message' in event) {
        player.messageQueue = player.messageQueue ?? [];
        player.messageQueue.push(event.message);
      }
    }
  }

  /**
   * Synchronous wrapper for runGameTick (for testing)
   */
  runGameTickSync(player: Player, forceUpdate = false): Player {
    // This is a simplified sync version that doesn't do async operations
    return player;
  }
}

// Singleton instance
let engineInstance: GameEngine | null = null;

export function getGameEngine(): GameEngine {
  if (!engineInstance) {
    engineInstance = new GameEngine();
  }
  return engineInstance;
}

export function setGameEngine(
  storage?: IGameStorage,
  output?: IGameOutput
): GameEngine {
  engineInstance = new GameEngine(storage, output);
  return engineInstance;
}
