import { WebSocket } from 'ws';
import { Player } from '../models/Player.js';
import { BatchedUpdate } from './BatchedUpdate.js';
import { config } from '../config.js';
import { savePlayer as dbSavePlayer, saveConversation } from '../database/players.js';
import { getEventRuntime } from '../events/v2/runtime.js';
import { updateAge, getPeakEnergy } from '../stats/stats_manager.js';
import { sanitizeOutgoingMessage } from '../utils/messageSanitizer.js';
import { clampPlayerStats, cleanStalePendingEvents } from '../utils/statUtils.js';
import { getIntradayActivity, getDailyPlan } from './engine/intradayActivity.js';
import { applyWeeklyFinances, computeWeeklyFinances } from './finance.js';
import { updateQuestProgress, sendQuestProgress } from '../services/retention/index.js';
import { handleJob } from '../services/jobs/index.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 {
  ENERGY_RESTORE_PER_NIGHT,
  ENERGY_MAX,
  applyHourlySurvival,
} from './economyConstants.js';
import { updateDeathChance, checkDeath, handleDeath } from '../services/health/health_manager.js';
import { handleHabitChanges } from '../services/health/habit_manager.js';
import { notifyRealtimeEvent, queueRealtimeNotification, clearThrottle } from '../services/notifications/notificationManager.js';
import {
  endLiveActivityForPlayer,
  queueLiveActivityUpdate,
} from '../services/notifications/liveActivityManager.js';
import { checkNPCInitiatedMessages, clearNPCInitiativeState, checkRelationshipAtRiskNudges } from '../events/conversations/npc_initiative.js';
import { updateNPCLocations, processHourlyNPCInteractions } from '../services/npc/index.js';
import { applyNeglectDecay } from '../services/relationships/relationship_manager.js';
import {
  onJobObtained,
  onPromotion,
  onFired,
  onMarriage,
  onDating,
  onChildBorn,
  onFriendMade,
  onBirthday,
  onGraduation,
  onDeath,
  updateLifeGoals,
} from '../services/retention/integration.js';
import {
  loadPlayerLifeGoals,
  serializePlayerLifeGoals,
} from '../services/retention/lifeGoals.js';
import {
  loadQuestEngagement,
  serializeQuestEngagement,
} from '../services/retention/dailyQuests.js';

/**
 * Whether a life-event message is "notable" enough to warrant a push
 * notification or inclusion in the welcome-back digest. Shared with the offline
 * loop (LoopManager.processOfflineTime) so both paths agree on what counts as a
 * headline event and we don't drift two copies of this regex.
 */
const NOTABLE_EVENT_PATTERN = /promot|fired|hired|graduat|married|born|died|accident|arrest|award|inherit|lottery|engag|breakup|broke up|hospital|dating|started seeing|divorce|expel|suspend|scholarship|pregnan|adopt|retire/i;

export function isNotableEventMessage(message: unknown): boolean {
  const msgStr = typeof message === 'string' ? message : '';
  return NOTABLE_EVENT_PATTERN.test(msgStr);
}

function getNotificationTitle(message: string): string {
  if (/promot|hired|fired|retire/i.test(message)) return 'Career Update';
  if (/married|engag|divorce|dating|broke up|breakup/i.test(message)) return 'Relationship';
  if (/born|pregnan|adopt/i.test(message)) return 'Family News';
  if (/died|accident|hospital/i.test(message)) return 'Life Event';
  if (/graduat|expel|suspend|scholarship/i.test(message)) return 'Education';
  if (/award|inherit|lottery/i.test(message)) return 'News';
  return 'BaoLife';
}

export class PlayerSession {
  readonly userId: string;
  readonly player: Player;
  private ws: WebSocket;
  private tickInterval: ReturnType<typeof setInterval> | null = null;
  private batchedUpdate: BatchedUpdate = new BatchedUpdate();
  private tickCounter: number = 0;
  private lifeGoalsHydrated: boolean = false;
  private questEngagementHydrated: boolean = false;

  constructor(ws: WebSocket, player: Player) {
    this.ws = ws;
    this.player = player;
    this.userId = player.userId;
  }

  /** Whether the underlying WebSocket connection is fully open */
  get isWSOpen(): boolean {
    return this.ws.readyState === WebSocket.OPEN;
  }

  ownsWebSocket(ws: WebSocket): boolean {
    return this.ws === ws;
  }

  start(): void {
    if (this.tickInterval) return;

    this.tickInterval = setInterval(() => {
      this.tick();
    }, config.TICK_INTERVAL);
  }

  stop(): void {
    if (this.tickInterval) {
      clearInterval(this.tickInterval);
      this.tickInterval = null;
    }
  }

  get isRunning(): boolean {
    return this.tickInterval !== null;
  }

  private tick(): void {
    if (!this.player.isActive) return;

    this.tickCounter++;
    const ticksNeeded = this.player.getTicksForSpeed();

    if (this.tickCounter >= ticksNeeded) {
      this.tickCounter = 0;
      this.advanceMinute();
    }
  }

  private advanceMinute(): void {
    this.player.minuteOfHour++;

    // Update time string like Python: player.time = str(player.hourOfDay) + ":" + str(player.minuteOfHour)
    this.player.time = `${this.player.hourOfDay}:${this.player.minuteOfHour.toString().padStart(2, '0')}`;

    if (this.player.minuteOfHour >= 60) {
      this.player.minuteOfHour = 0;
      this.player.hourOfDay++;
      void this.processHourTick();
    }

    if (this.player.hourOfDay >= 24) {
      this.player.hourOfDay = 0;
      this.player.dayOfYear++;
      if (this.player.dayOfYear > 365) {
        this.player.dayOfYear = 1;
      }
      this.player.dayOfWeek = ((this.player.dayOfWeek) % 7) + 1;
      this.processDayTick();
    }

    if (this.player.dayOfWeek === 1 && this.player.hourOfDay === 0 && this.player.minuteOfHour === 0) {
      this.processWeekTick();
    }

    // Send minute update EVERY minute like Python does
    this.processMinuteTick();
  }

  private processMinuteTick(): void {
    // Python sends minute updates every tick: {'type':'u', 'minuteOfHour': player.minuteOfHour}
    this.send({
      type: 'u',
      minuteOfHour: this.player.minuteOfHour,
    });
  }

  private async processHourTick(): Promise<void> {
    // Age progression — updateAge increments ageHours each call,
    // ageDays every 24 hours, handles relationship aging/decay/death,
    // and returns birthday events
    const ageResult = updateAge(this.player);
    if (ageResult) {
      if ('id' in ageResult) {
        this.player.events.add(ageResult.id as string);
      }
      this.send(ageResult);
    }

    // Recalculate peakEnergy from habits/activities and clamp calcEnergy >= 0
    // BEFORE the survival tick so the energy-scarcity penalty sees fresh
    // calcEnergy. Energy itself does not passively drain (spent by actions,
    // restored overnight in processDayTick).
    getPeakEnergy(this.player.c);
    this.player.c.calcEnergy = Math.max(0, this.player.c.calcEnergy ?? 0);

    // Shared hourly survival: hunger/thirst rise, starvation/health-decay rules,
    // and the energy-scarcity penalty — identical to the offline GameEngine path
    // (both consume economyConstants.applyHourlySurvival).
    applyHourlySurvival(this.player.c);

    // Update intraday location based on current hour
    getIntradayActivity(this.player, this.player.c);

    // Live NPC simulation while CONNECTED (previously offline-only): move NPCs
    // along their daily plans and surface "run into someone" encounters, so the
    // world feels alive in real time too, not just during offline catch-up.
    // Mirrors the offline LoopManager block; gated to non-paused speeds.
    for (let i = 0; i < (this.player.r?.length ?? 0); i++) {
      this.player.r[i] = getIntradayActivity(this.player, this.player.r[i]);
    }
    updateNPCLocations(this.player);
    if (this.player.gameSpeed < config.SPEED_QUESTION_PAUSE) {
      try {
        const interactions = processHourlyNPCInteractions(this.player);
        for (const result of interactions) {
          if (result.triggered && result.event && result.interaction) {
            this.send({
              type: 'messageEvent',
              id: result.interaction.id,
              message: result.event.message,
            });
          }
        }
      } catch (err) {
        if (process.env.NODE_ENV !== 'test') {
          console.log(`[NPC] online interaction error: ${err}`);
        }
      }
    }

    // On hour change, send full time update batch
    this.batchedUpdate.add('hourOfDay', this.player.hourOfDay);
    this.batchedUpdate.add('minuteOfHour', this.player.minuteOfHour);
    this.batchedUpdate.add('time', this.player.time);
    this.batchedUpdate.add('date', this.player.date);
    this.batchedUpdate.add('season', this.player.season);
    this.batchedUpdate.add('dayOfYear', this.player.dayOfYear);
    this.batchedUpdate.add('dayOfWeek', this.player.dayOfWeek);
    this.batchedUpdate.add('hunger', this.player.c.hunger);
    this.batchedUpdate.add('thirst', this.player.c.thirst);
    this.batchedUpdate.add('energy', this.player.c.energy);
    this.batchedUpdate.add('calcEnergy', this.player.c.calcEnergy);
    this.batchedUpdate.add('location', this.player.c.location);
    this.batchedUpdate.add('intraDayMessage', this.player.c.intraDayMessage);
    this.batchedUpdate.add('money', this.player.c.money);
    this.batchedUpdate.add('diamonds', this.player.c.diamonds);
    this.sendBatchedUpdate();
    queueLiveActivityUpdate(this.player)
      .catch(err => console.error('[LiveActivity] update error:', err));

    // Process message queue (like Python: player.messageQueue.pop(0))
    if (this.player.messageQueue && this.player.messageQueue.length > 0) {
      const message = this.player.messageQueue.shift();
      if (message) {
        this.player.messageLog.push(message);
        // Send as messageEvent like Python
        this.send({
          type: 'messageEvent',
          id: 'queue',
          message: message,
          date: this.player.date,
          hour: this.player.hourOfDay,
          title: '',
          image: '',
          energyCost: 0,
          diamondCost: 0,
          moneyCost: 0,
          affinityChange: 0,
          characters: [],
        });
        // Push notification if app is backgrounded — only for notable events
        // Skip mundane messages to preserve the 4/hr notification budget
        const msgStr = typeof message === 'string' ? message : '';
        const isNotable = isNotableEventMessage(msgStr);
        if (isNotable) {
          queueRealtimeNotification(this.player, {
            title: getNotificationTitle(msgStr),
            body: msgStr.length > 80 ? msgStr.substring(0, 80) + '...' : msgStr,
            type: 'life_event',
          }).catch(err => console.error('[Push] messageQueue error:', err));
        }
      }
    }

    // Check for NPC-initiated messages (max 1/day, appropriate hours only)
    try {
      await checkNPCInitiatedMessages(this.player, this);
    } catch (error) {
      if (process.env.NODE_ENV !== 'test') {
        console.log(`[NPC Initiative] Check error: ${error}`);
      }
    }

    // Only check events when game is not paused (speed < SPEED_QUESTION_PAUSE)
    if (this.player.gameSpeed < config.SPEED_QUESTION_PAUSE) {
      const v2Prompt = await getEventRuntime().promptNext(
        this.player as unknown as Parameters<ReturnType<typeof getEventRuntime>['promptNext']>[0]
      );
      if (v2Prompt) {
        // Only INTERACTIVE prompts pause the simulation. An 'event_prompt'
        // awaits a player choice (the client replies with eventResponse, which
        // resumes the loop via handlers/events.ts by restoring
        // previousGameSpeed), so we must pause — mirroring the offline path
        // (LoopManager) — or the loop keeps advancing time under an unanswered
        // prompt. A passive 'event_resolved' auto-resolved server-side: there is
        // no client round-trip to resume the loop, so pausing it would stall the
        // sim forever. We still send it (and notify) but do NOT pause.
        const isInteractive = v2Prompt.type === 'event_prompt';
        if (isInteractive) {
          this.player.previousGameSpeed = this.player.gameSpeed;
          this.player.gameSpeed = config.SPEED_QUESTION_PAUSE;
        }

        this.send(v2Prompt);

        // If the WebSocket isn't fully open (app may be backgrounded),
        // attempt a push notification so the player knows something happened
        if (this.ws.readyState !== WebSocket.OPEN && this.player.deviceToken) {
          const rawBody = v2Prompt.type === 'event_prompt'
            ? v2Prompt.prompt
            : v2Prompt.resolutionText;
          const body = rawBody && rawBody.length > 80 ? rawBody.substring(0, 80) + '...' : rawBody;
          const title = getNotificationTitle(body || '');
          notifyRealtimeEvent(
            this.userId,
            this.player.deviceToken,
            true, // backgrounded since WS not open
            title,
            body
          ).catch((err) => console.error('[Push] Notification error:', err));
        }
        queueLiveActivityUpdate(this.player, { major: true })
          .catch(err => console.error('[LiveActivity] event update error:', err));

        // Interactive prompts wait for the player's answer, so return early and
        // leave the loop paused. Passive resolutions don't pause; let the tick
        // fall through to drainLifecycleQueue() so it completes normally.
        if (isInteractive) {
          return;
        }
      }
    }

    // Drain lifecycle events queued by event functions (achievements, quests, etc.)
    this.drainLifecycleQueue();
  }

  private processDayTick(): void {
    // Clamp all stats to valid ranges (0-100) - prevents corrupted data
    clampPlayerStats(this.player);

    // Deliver any pregnancy that reached term today (creates a real child +
    // queues child_born credit, drained at the end of the next hour tick).
    // Mirrored on the offline GameEngine day tick.
    checkPregnancyTerm(this.player);

    // Advance the education credential by age (grade progression + HS/college
    // graduation), so education actually unlocks careers. Idempotent; mirrored
    // on the offline GameEngine day tick.
    advanceEducation(this.player);

    // Clean up stale pending events
    const removedEvents = cleanStalePendingEvents(this.player);
    if (removedEvents > 0 && process.env.NODE_ENV !== 'test') {
      console.log(`Cleaned up ${removedEvents} stale pending events`);
    }

    // Death checks — run daily for player character
    if (this.player.c.status === 'alive') {
      updateDeathChance(this.player.c);
      if (checkDeath(this.player.c)) {
        const finalAge = this.player.c.ageYears ?? 0;
        const finalMoney = this.player.c.money ?? 0;

        // Single source of truth: sets status/controller, queues message,
        // and builds player.lifeSummary (mirrored in the offline GameEngine path).
        handleDeath(this.player);

        endLiveActivityForPlayer(this.player)
          .catch(err => console.error('[LiveActivity] end on death error:', err));

        // Surface the life summary + score to the client to power the
        // end-of-life replay hook / New Life flow.
        if (this.player.lifeSummary) {
          this.send({
            type: 'lifeSummaryEvent',
            summary: this.player.lifeSummary,
          });
        }

        // Light up death-triggered achievements (mirrors the onChildBorn/
        // onBirthday lifecycle wiring). Fire-and-forget like the others.
        void onDeath(this, finalAge, finalMoney);
      }
    }

    // Relationship neglect (T010d): erode an untended romance, and surface a
    // "relationship at risk" nudge for once-high-affinity NPCs that have drifted.
    if (this.player.c.status === 'alive') {
      const neglect = applyNeglectDecay(this.player, this.player.date);
      if (neglect.brokeUp && this.player.messageQueue && this.player.messageQueue.length > 0) {
        // The breakup message is already in messageQueue (drained by processHourTick);
        // nothing further needed here.
      }
      // Fire neglect nudges (gated/stubbed notifications; safe in tests).
      void checkRelationshipAtRiskNudges(this.player, this);
    }

    // Overnight sleep recovery: restore energy per day (shared constant so
    // online and offline restore the same amount).
    if ((this.player.c.energy ?? ENERGY_MAX) < ENERGY_MAX) {
      this.player.c.energy = Math.min(ENERGY_MAX, (this.player.c.energy ?? ENERGY_MAX) + ENERGY_RESTORE_PER_NIGHT);
    }
    getPeakEnergy(this.player.c);
    this.player.c.calcEnergy = Math.max(0, this.player.c.calcEnergy ?? 0);

    // Generate fresh daily plan for intraday activity system
    getDailyPlan(this.player, this.player.c);

    // Update date string like Python: date.fromordinal(date(2022, 1, 1).toordinal() + player.dayOfYear - 1).strftime('%m-%d')
    // Using Jan 1, 2022 as base year (2022 starts on Saturday, dayOfWeek 7 = Sunday, 1 = Monday in Python)
    const baseDate = new Date(2022, 0, 1); // Jan 1, 2022
    const currentDate = new Date(baseDate);
    currentDate.setDate(currentDate.getDate() + this.player.dayOfYear - 1);

    const month = String(currentDate.getMonth() + 1).padStart(2, '0');
    const day = String(currentDate.getDate()).padStart(2, '0');
    this.player.date = `${month}-${day}`;
    this.player.monthOfYear = currentDate.getMonth() + 1;

    // Update season based on month
    const monthNum = this.player.monthOfYear;
    if (monthNum >= 3 && monthNum <= 5) {
      this.player.season = 'spring';
    } else if (monthNum >= 6 && monthNum <= 8) {
      this.player.season = 'summer';
    } else if (monthNum >= 9 && monthNum <= 11) {
      this.player.season = 'autumn';
    } else {
      this.player.season = 'winter';
    }

    // Update weekday text
    const weekDays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
    // Python uses 1-indexed dayOfWeek (1=Sunday? or 1=Monday?)
    // Looking at Python: player.weekDayText = ["Sunday", "Monday", ...][player.dayOfWeek-1]
    // So dayOfWeek 1 = Sunday
    this.player.weekDayText = weekDays[this.player.dayOfWeek - 1] || 'Monday';

    // Update weekend flag
    this.player.weekend = this.player.dayOfWeek === 1 || this.player.dayOfWeek === 7; // Sunday or Saturday

    // Update weather based on season with seasonal probabilities
    this.player.weather = PlayerSession.rollDailyWeather(this.player.season);

    // Update school-related calculations
    this.player.daysUntilSchoolEnds = 152 - this.player.dayOfYear;
    if (this.player.dayOfYear > 244) {
      this.player.daysUntilSchoolEnds = (365 - 244) + this.player.daysUntilSchoolEnds;
    }
    this.player.daysSinceSchoolStarted = this.player.dayOfYear - 244;
    if (this.player.dayOfYear < 244) {
      this.player.daysSinceSchoolStarted = 121 + this.player.dayOfYear;
    }
    this.player.summerVacation = this.player.dayOfYear > 152 && this.player.dayOfYear < 244;

    // Habit quit progress advances once per in-game day (matches UI "30 days")
    const hasQuittingHabits = (this.player.c.habits ?? []).some((h) => h.status === 'quitting');
    if (hasQuittingHabits) {
      handleHabitChanges(this.player, this.player.c);
      this.sendPlayerObject();
    }

    // Send day update to client
    this.batchedUpdate.add('date', this.player.date);
    this.batchedUpdate.add('monthOfYear', this.player.monthOfYear);
    this.batchedUpdate.add('season', this.player.season);
    this.batchedUpdate.add('weekDayText', this.player.weekDayText);
    this.batchedUpdate.add('weekend', this.player.weekend);
    this.batchedUpdate.add('weather', this.player.weather);
    this.batchedUpdate.add('dayOfYear', this.player.dayOfYear);
    this.batchedUpdate.add('dayOfWeek', this.player.dayOfWeek);
    this.sendBatchedUpdate();

    // Periodic life-goals check: money / prestige / age goals advance off the
    // game clock rather than discrete hooks, so re-evaluate daily. Emits a
    // lifeGoalsUpdate only when something changed (handled inside updateLifeGoals).
    if (this.player.c.status === 'alive') {
      try {
        updateLifeGoals(this);
      } catch (err) {
        console.error('[LifeGoals] daily check error:', err);
      }
    }

    // Save daily (in addition to weekly save)
    this.savePlayer().catch(err => console.error('Error in daily auto-save:', err));
  }

  /**
   * Roll daily weather based on seasonal probabilities.
   * Returns a weather type with seasonal weighting.
   */
  static rollDailyWeather(season: string): 'sunny' | 'cloudy' | 'rainy' | 'snowy' | 'stormy' | 'windy' | 'foggy' {
    const roll = Math.random() * 100;

    // Seasonal weather probability tables (cumulative %)
    // Each season has different likelihoods for weather types
    if (season === 'winter') {
      // Winter: more snow, cloudy, less sunny
      if (roll < 15) return 'sunny';
      if (roll < 35) return 'cloudy';
      if (roll < 50) return 'rainy';
      if (roll < 75) return 'snowy';
      if (roll < 85) return 'stormy';
      if (roll < 95) return 'windy';
      return 'foggy';
    } else if (season === 'spring') {
      // Spring: rainy, variable
      if (roll < 30) return 'sunny';
      if (roll < 45) return 'cloudy';
      if (roll < 70) return 'rainy';
      if (roll < 72) return 'snowy';
      if (roll < 82) return 'stormy';
      if (roll < 92) return 'windy';
      return 'foggy';
    } else if (season === 'summer') {
      // Summer: mostly sunny, occasional storms
      if (roll < 50) return 'sunny';
      if (roll < 65) return 'cloudy';
      if (roll < 75) return 'rainy';
      if (roll < 75) return 'snowy'; // no snow in summer
      if (roll < 85) return 'stormy';
      if (roll < 93) return 'windy';
      return 'foggy';
    } else {
      // Autumn: cloudy, rainy, foggy
      if (roll < 25) return 'sunny';
      if (roll < 45) return 'cloudy';
      if (roll < 65) return 'rainy';
      if (roll < 68) return 'snowy';
      if (roll < 78) return 'stormy';
      if (roll < 88) return 'windy';
      return 'foggy';
    }
  }

  private processWeekTick(): void {
    if (this.player.c) {
      // Advance career progression BEFORE finances so a same-week promotion
      // raises salary before this week's pay is computed. handleJob nudges
      // performance from focus, promotes at performance>90 (respecting
      // prestige/intelligence gates), updates person.salary, and handles firing.
      // It pushes any promotion/firing message onto player.messageQueue, which is
      // drained as a messageEvent each hour tick (see processHourTick) — the same
      // existing player-facing channel — so no new surfacing mechanism is needed.
      // Mirrors the offline GameEngine.handleWeeklyUpdates ordering exactly.
      try {
        handleJob(this.player, this.player.c);
      } catch (err) {
        console.error('Error processing job progression:', err);
      }

      // Apply recurring weekly finances for the player character so online players
      // earn salary and pay expenses identically to the offline GameEngine path.
      applyWeeklyFinances(this.player.c);

      // Daily-quest: credit the `earn_money` quest by GROSS weekly income.
      // computeWeeklyFinances returns grossIncome=0 for non-earning occupations
      // (students/preschoolers), so the >0 guard covers them. We use GROSS, not
      // net player.c.money (which is post rent/savings and floored at 0), so the
      // quest tracks income earned rather than money retained. ONLINE-ONLY by
      // design: daily quests reward active play, and firing only here (once per
      // weekly tick) avoids the offline finance-loop double-count and any
      // offline quest-diamond award. Fire-and-forget, mirroring savePlayer below.
      const weeklyGross = computeWeeklyFinances(this.player.c).grossIncome;
      if (weeklyGross > 0) {
        updateQuestProgress(this.userId, 'earn_money', weeklyGross, this.player)
          .then((quest) => {
            if (quest) sendQuestProgress(this, quest);
          })
          .catch((err) => console.error('Error updating earn_money quest:', err));
      }

      // Online<->offline parity (T003): the offline GameEngine.handleWeeklyUpdates
      // runs two more player-facing weekly subsystems that were missing online, so
      // connected players never saw them. Wire exactly these two here, once for the
      // PLAYER character. We intentionally EXCLUDE handleMoods (online happiness
      // already moves via v2 event effects + hourly survival) and the per-NPC
      // weekly affinity/familiarity decay loop (online updateAge already decays
      // affinity monthly/yearly — re-running it here would double-apply).

      // 5% weekly random romance beat. Mirrors GameEngine.handleRelationships:
      // processWeeklyRelationshipEvents(player) returns EventResult(s); push each
      // event.message onto player.messageQueue, which the existing
      // messageQueue -> messageEvent drain in processHourTick surfaces to the
      // client. No new channel. Wrapped in try/catch so a failure can't break the
      // weekly tick.
      try {
        const relationshipEvents = [
          ...processWeeklyRelationshipEvents(this.player),
          ...processWeeklyFriendEvents(this.player), // friend/family beats too
        ];
        for (const event of relationshipEvents) {
          if (event && 'message' in event) {
            this.player.messageQueue = this.player.messageQueue ?? [];
            this.player.messageQueue.push(event.message);
          }
        }
      } catch (err) {
        console.error('Error processing weekly relationship events:', err);
      }

      // Student GPA weekly drift for the player character (parity with the offline
      // GameEngine.handleEducation, which calls educationManagerHandleEducation(person)).
      // No-op for non-students. Wrapped so a failure can't break the weekly tick.
      try {
        educationManagerHandleEducation(this.player.c);
      } catch (err) {
        console.error('Error processing weekly education update:', err);
      }
    }
    this.savePlayer().catch(err => console.error('Error saving player:', err));
  }

  private sendBatchedUpdate(): void {
    if (this.batchedUpdate.isEmpty) return;
    const message = this.batchedUpdate.toMessage();
    this.send(message);
  }

  send(message: unknown): void {
    if (this.ws.readyState === WebSocket.OPEN) {
      // Sanitize message before sending (defense-in-depth for JSON artifacts)
      const sanitized = sanitizeOutgoingMessage(message);
      const data = JSON.stringify(sanitized);
      const msgType = (message as any)?.type ?? 'unknown';
      if (process.env.NODE_ENV !== 'test') {
        console.log(`Sending message type: ${msgType}, size: ${data.length} bytes`);
      }
      this.ws.send(data);
    } else {
      const msgType = (message as any)?.type ?? 'unknown';
      if (process.env.NODE_ENV !== 'test') {
        console.warn(`Cannot send message type ${msgType}: WebSocket not open (state: ${this.ws.readyState})`);
      }
    }
  }

  sendPlayerObject(): void {
    if (process.env.NODE_ENV !== 'test') {
      console.log(`Sending playerObject to user: ${this.userId}`);
    }
    // Python sends the player object directly with type as a property
    // toJSON() now includes type: 'playerObject' like Python's playerClass
    const playerJson = this.player.toJSON();
    const conversations = playerJson.conversations as unknown[];
    if (process.env.NODE_ENV !== 'test') {
      console.log(`playerObject includes ${conversations?.length ?? 0} conversations`);
      if (conversations && conversations.length > 0) {
        console.log(`First conversation preview:`, JSON.stringify(conversations[0]).slice(0, 200));
      }
    }
    this.send(playerJson);

    // Hydrate forward-looking life goals from the persisted snapshot (once per
    // session), seed/refresh the active slate for the current life stage, and
    // emit the initial lifeGoalsUpdate so the client has a destination to show.
    this.ensureLifeGoalsHydrated();
    try {
      updateLifeGoals(this);
    } catch (err) {
      console.error('[LifeGoals] initial update error:', err);
    }

    // Surface the welcome-back digest (if any) on connect/reconnect. The digest
    // also rides inside playerObject.offlineStats.digest above, but we emit a
    // dedicated message so the client can present a "while you were away" card
    // without diffing the whole player object. The digest is consumed once and
    // cleared so it never re-fires on a later reconnect within the same life.
    this.sendOfflineDigest();
  }

  /**
   * Hydrate the life-goals service from the player's persisted snapshot exactly
   * once per session. Idempotent: subsequent calls are no-ops so we don't clobber
   * live progress made during the session.
   */
  private ensureLifeGoalsHydrated(): void {
    if (this.lifeGoalsHydrated) return;
    loadPlayerLifeGoals(this.userId, this.player.lifeGoals ?? null);
    this.lifeGoalsHydrated = true;

    // Hydrate quest-engagement state (full-clear streak + weekly challenge) from
    // the same persisted blob, once per session.
    if (!this.questEngagementHydrated) {
      loadQuestEngagement(this.userId, this.player.questEngagement ?? null);
      this.questEngagementHydrated = true;
    }
  }

  /**
   * Emit the welcome-back digest as a standalone `offlineDigest` message if the
   * offline loop produced one. Clears the digest after sending so it is shown
   * exactly once per offline period. Safe to call when no digest exists (no-op).
   *
   * Returns true if a digest was sent.
   */
  sendOfflineDigest(): boolean {
    const digest = this.player.offlineStats?.digest;
    if (!digest) return false;

    this.send({
      type: 'offlineDigest',
      minutesAway: digest.minutesAway,
      moneyDelta: digest.moneyDelta,
      ageYearsDelta: digest.ageYearsDelta,
      notableEvents: digest.notableEvents,
      generatedAt: digest.generatedAt,
    });

    // Consume it: the welcome-back card is a one-time surface.
    if (this.player.offlineStats) {
      this.player.offlineStats.digest = undefined;
    }
    return true;
  }

  async savePlayer(): Promise<void> {
    try {
      // Snapshot the authoritative (in-memory) life-goals state into the player
      // blob so the destination survives save/load.
      this.player.lifeGoals = serializePlayerLifeGoals(this.userId);

      // Snapshot quest-engagement state (full-clear streak + weekly challenge)
      // into the player blob so the deeper quest loop survives save/load.
      if (this.questEngagementHydrated) {
        this.player.questEngagement = serializeQuestEngagement(this.userId);
      }

      // First, sync all in-memory conversations to the dedicated conversations table
      // This ensures no conversation data is lost (conversations are NOT stored in player JSON)
      await this.syncConversationsToTable();

      // Save player (without conversations - they're in the dedicated table)
      await dbSavePlayer(this.player);
    } catch (error) {
      console.error(`Error saving player ${this.userId}:`, error);
    }
  }

  /**
   * Sync all in-memory conversations to the dedicated conversations table.
   * Called during savePlayer to ensure no conversation data is lost.
   */
  private async syncConversationsToTable(): Promise<void> {
    if (!this.player.conversations || this.player.conversations.length === 0) {
      return;
    }

    const savePromises = this.player.conversations.map(async (conv) => {
      try {
        // Get character ID - handle both string ID and Person object
        const characterId = typeof conv.character === 'string'
          ? conv.character
          : (conv.character as any)?.id;

        if (!characterId) {
          console.warn(`Skipping conversation with missing character: ${conv.id}`);
          return;
        }

        await saveConversation(
          this.player.userId,
          characterId,
          conv.id,
          Array.isArray(conv.conversation) ? conv.conversation : [],
          conv.unread ?? false
        );
      } catch (err) {
        console.error(`Error saving conversation ${conv.id}:`, err);
      }
    });

    await Promise.all(savePromises);
    if (process.env.NODE_ENV !== 'test') {
      console.log(`Synced ${this.player.conversations.length} conversations to table for player ${this.userId}`);
    }
  }

  /**
   * Drain the player's lifecycle queue and fire retention handlers.
   * Events are queued by event functions (which lack session access)
   * and processed here where the session is available.
   */
  private drainLifecycleQueue(): void {
    const queue = this.player.lifecycleQueue;
    if (!queue || queue.length === 0) return;

    // Drain all queued events
    while (queue.length > 0) {
      const event = queue.shift()!;
      try {
        switch (event.type) {
          case 'job_obtained':
            void onJobObtained(this);
            break;
          case 'promotion':
            void onPromotion(this, (event.data?.title as string) ?? 'Senior Position');
            break;
          case 'fired':
            void onFired(this);
            break;
          case 'marriage':
            void onMarriage(this);
            break;
          case 'dating':
            void onDating(this);
            break;
          case 'child_born':
            void onChildBorn(this);
            break;
          case 'friend_made':
            void onFriendMade(this);
            break;
          case 'birthday':
            void onBirthday(this, (event.data?.age as number) ?? this.player.c.ageYears);
            break;
          case 'graduation':
            void onGraduation(this, (event.data?.level as string) ?? 'high school', event.data?.gpa as number | undefined);
            break;
          default:
            if (process.env.NODE_ENV !== 'test') {
              console.warn(`Unknown lifecycle event type: ${event.type}`);
            }
        }
      } catch (err) {
        console.error(`[Retention] Error processing lifecycle event ${event.type}:`, err);
      }
    }
  }

  disconnect(): void {
    this.stop();
    this.player.connection = 'disconnected';
    clearThrottle(this.userId);
    clearNPCInitiativeState(this.userId);
  }
}
