// Repeatable pacing/balance metrics probe.
//
// Runs the lifetime simulator for a fixed set of seeds to FULL lifetime (death
// or 100-year cap) and prints the per-life trajectory summary to stdout:
//   - hunger / thirst / energy / calcEnergy / health min-max-mean bands
//   - in-game days spent at calcEnergy 0
//   - money / net worth sampled per in-game year
//   - event-prompt density (per day / month / year + peak single-day burst)
//   - reward cadence (lifeGoal / quest / achievement / diamond signals)
//   - final age + death cause
//
// This is the reusable command for later pacing waves. Invoke via:
//   npm run metrics            (from server/)
//
// It is intentionally a thin vitest harness (the simulator needs the vi.mock()
// set below) rather than a standalone script, so it shares the exact mock
// surface the rest of the e2e suite uses.

import { describe, it, vi, expect } from 'vitest';

// -----------------------------------------------------------------------------
// Mocks (same set as smoke.test.ts / lifetime-simulator.test.ts)
// -----------------------------------------------------------------------------

vi.mock('../../src/database/players.js', () => ({
  savePlayer: vi.fn(async () => undefined),
  saveConversation: vi.fn(async () => undefined),
  saveConversationMessage: vi.fn(async () => undefined),
  loadPlayer: vi.fn(async () => null),
  loadConversations: vi.fn(async () => []),
  loadPlayerConversations: vi.fn(async () => []),
  markConversationAsRead: vi.fn(async () => undefined),
  updateConnectionStatus: vi.fn(async () => undefined),
  ensureTables: vi.fn(async () => undefined),
  saveGameAsync: vi.fn(async () => undefined),
  loadGameAsync: vi.fn(async () => null),
  loadAllPlayerIds: vi.fn(async () => []),
  loadGames: vi.fn(async () => []),
}));

type MemInstance = {
  instanceId: string;
  eventId: string;
  playerId: string;
  status: 'pending' | 'answered' | 'resolved' | 'cancelled';
  createdAt: string;
  selectedChoiceId?: string;
};
const memInstances: MemInstance[] = [];

vi.mock('../../src/database/eventInstances.js', () => ({
  createEventInstance: vi.fn(async (input: Omit<MemInstance, 'createdAt' | 'status'>) => {
    const inst: MemInstance = { ...input, createdAt: new Date().toISOString(), status: 'pending' };
    memInstances.push(inst);
    return inst;
  }),
  getPendingEventInstances: vi.fn(async (playerId: string) =>
    memInstances.filter((i) => i.playerId === playerId && i.status === 'pending')
  ),
  answerEventInstance: vi.fn(async (instanceId: string, choiceId: string) => {
    const inst = memInstances.find((i) => i.instanceId === instanceId);
    if (!inst) return null;
    inst.status = 'answered';
    inst.selectedChoiceId = choiceId;
    return inst;
  }),
  resolveEventInstance: vi.fn(async (instanceId: string) => {
    const inst = memInstances.find((i) => i.instanceId === instanceId);
    if (!inst) return null;
    inst.status = 'resolved';
    return inst;
  }),
  ensureEventInstancesTable: vi.fn(async () => undefined),
}));

vi.mock('../../src/services/notifications/notificationManager.js', () => ({
  notifyRealtimeEvent: vi.fn(async () => undefined),
  queueRealtimeNotification: vi.fn(async () => undefined),
  clearThrottle: vi.fn(() => undefined),
  notificationManager: {},
}));

vi.mock('../../src/events/conversations/npc_initiative.js', () => ({
  checkNPCInitiatedMessages: vi.fn(async () => undefined),
  checkRelationshipAtRiskNudges: vi.fn(async () => []),
  clearNPCInitiativeState: vi.fn(() => undefined),
  isAppropriateHour: vi.fn(() => true),
  detectNPCTriggers: vi.fn(() => []),
}));

vi.mock('../../src/services/retention/integration.js', () => ({
  onJobObtained: vi.fn(async () => undefined),
  onPromotion: vi.fn(async () => undefined),
  onFired: vi.fn(async () => undefined),
  onMarriage: vi.fn(async () => undefined),
  onDating: vi.fn(async () => undefined),
  onChildBorn: vi.fn(async () => undefined),
  onFriendMade: vi.fn(async () => undefined),
  onBirthday: vi.fn(async () => undefined),
  onGraduation: vi.fn(async () => undefined),
  // onDeath fires from processDayTick when the character dies of old age. It is
  // NOT mocked in the older e2e suites because those caps never reached natural
  // death — but the metrics run intentionally runs long enough to reach it, so
  // the mock must include it or the death tick throws and breaks the loop
  // BEFORE the simulator's clean death-return, hiding the real death age.
  onDeath: vi.fn(async () => undefined),
  // Sync, returns CompletedGoalResult[]. processDayTick calls it directly, so it
  // must be present or every game-day logs a (caught) mock-export error.
  updateLifeGoals: vi.fn(() => []),
}));

import { simulateLifetime, type SimulatorResult, type StatBand } from './lifetime-simulator.js';

const METRIC_SEEDS = [1, 7, 42];

// In-game minutes per simulated year.
const ONE_YEAR_MIN = 365 * 24 * 60;

// Sim horizon for the metrics run. Age does NOT advance 1:1 with simulated time
// (the loop ages a character ~0.66 years per simulated year), so a character
// starting at 18 needs a long horizon to live out a full natural lifespan and
// actually reach the death check. 160 simulated years comfortably covers the
// ~70-95 natural-death band without leaving healthy characters truncated at the
// horizon. Each seed is still a single multi-second run.
const METRIC_MAX_MINUTES = 160 * ONE_YEAR_MIN;

function fmtBand(b: StatBand): string {
  return `min=${b.min.toFixed(1)} max=${b.max.toFixed(1)} mean=${b.mean.toFixed(1)} (n=${b.samples})`;
}

function printSummary(r: SimulatorResult): void {
  const t = r.trajectory;
  const lines: string[] = [];
  lines.push(`\n========== LIFE METRICS (seed=${r.seed}) ==========`);
  lines.push(`finalAge:        ${r.finalAge}`);
  lines.push(`deathCause:      ${r.deathCause ?? '(survived cap)'}`);
  lines.push(`completed:       ${r.completed}`);
  lines.push(`minutesSimulated:${r.minutesSimulated}`);
  lines.push(`errors:          ${r.errors.length}`);
  lines.push(`invariants:      ${r.invariantViolations.length}`);
  lines.push(`-- survival bands (per-day samples) --`);
  lines.push(`  hunger:     ${fmtBand(t.hunger)}`);
  lines.push(`  thirst:     ${fmtBand(t.thirst)}`);
  lines.push(`  energy:     ${fmtBand(t.energy)}`);
  lines.push(`  calcEnergy: ${fmtBand(t.calcEnergy)}`);
  lines.push(`  health:     ${fmtBand(t.health)}`);
  lines.push(`  days at calcEnergy<=0: ${t.daysAtZeroCalcEnergy}`);
  lines.push(`-- event density (felt cadence = event_prompt + event_resolved) --`);
  lines.push(
    `  total=${t.promptDensity.totalPrompts} (interactive=${t.promptDensity.interactivePrompts}) ` +
      `perWeek=${t.promptDensity.perWeekMean.toFixed(2)} perMonth=${t.promptDensity.perMonthMean.toFixed(2)} ` +
      `perYear=${t.promptDensity.perYearMean.toFixed(1)} peakSingleDay=${t.promptDensity.peakSingleDay}`
  );
  lines.push(
    `  midlife dead-air: ${t.promptDensity.midlifeZeroMonths}/${t.promptDensity.midlifeMonths} ` +
      `mid-life months (age 25-65) with ZERO events`
  );
  lines.push(`-- reward cadence --`);
  lines.push(
    `  total=${t.rewardCadence.total} lifeGoals=${t.rewardCadence.lifeGoalUpdates} ` +
      `quests=${t.rewardCadence.questEvents} achievements=${t.rewardCadence.achievementUnlocks} ` +
      `diamonds=${t.rewardCadence.diamondAwards}`
  );
  lines.push(`-- money / net worth (per in-game year) --`);
  const sampled = t.yearlyMoney.filter((_, i) => i % 5 === 0 || i === t.yearlyMoney.length - 1);
  for (const y of sampled) {
    lines.push(
      `  age ${String(y.ageYears).padStart(3)}: money=${y.money.toFixed(0)} ` +
        `netWorth=${y.netWorth.toFixed(0)} occ=${y.occupation || '-'} salary=${y.salary} ` +
        `diamonds=${y.diamonds} prestige=${y.prestige}`
    );
  }
  // Working-life net-worth band (ages employmentAge..retirementAge) — the
  // economy health signal Part 2 asserts on.
  const working = t.yearlyMoney.filter((y) => y.ageYears >= 22 && y.ageYears <= 65);
  if (working.length > 0) {
    const peak = Math.max(...working.map((y) => y.netWorth));
    const trough = Math.min(...working.map((y) => y.netWorth));
    const last = working[working.length - 1].netWorth;
    lines.push(
      `  working-life net worth: trough=${trough.toFixed(0)} peak=${peak.toFixed(0)} ` +
        `final(age~65)=${last.toFixed(0)}`
    );
  }
  lines.push(`====================================================`);
  // eslint-disable-next-line no-console
  console.log(lines.join('\n'));
}

describe('pacing metrics (npm run metrics)', () => {
  for (const seed of METRIC_SEEDS) {
    it(`seed ${seed}: full lifetime trajectory`, async () => {
      const r = await simulateLifetime({ seed, characterAge: 18, maxMinutes: METRIC_MAX_MINUTES });
      printSummary(r);

      // ── Cadence bands (T004 event-cadence wave) ──
      // These guard against regressing into either pathology:
      //  - BURST: no in-game day should fire more than ~3 felt events. The
      //    per-day emit gate (EventEngine) caps this at 1 today; assert <=3 so
      //    a future small relaxation stays safe but a regression to the old
      //    age-gate burst (peak 17) fails loudly.
      //  - DEAD-AIR: zero mid-life (age 25-65) months with no felt events.
      //  - STEADY STATE: felt cadence stays in a digestible band (not silent,
      //    not spammy). Measured ~4/in-game-month; assert a generous 1-12 so
      //    seed variance and later content waves don't flake.
      const d = r.trajectory.promptDensity;
      expect(d.peakSingleDay).toBeLessThanOrEqual(3);
      expect(d.midlifeZeroMonths).toBe(0);
      expect(d.perMonthMean).toBeGreaterThan(1);
      expect(d.perMonthMean).toBeLessThan(12);

      // ── Economy band (T005 economy wave) ──
      // The simulator now drives the character into EMPLOYMENT via the real
      // applyForJob path (see lifetime-simulator's maybeDriveEmployment), so
      // weekly wages / scaling rent / lifestyle / savings actually run through
      // finance.ts and the net-worth curve is measurable. Once employment is
      // driven the prior goal's economy constants already produce a HEALTHY
      // curve, so this wave only MEASURES + GUARDS it (no constant re-tuning).
      //
      // Healthy target band, asserted on the working-life window (ages 22-65):
      //   - STARTS NEAR 0: the character has no money before its first job.
      //   - GROWS: peak working-life net worth clears a meaningful floor
      //     (savings accumulate over a career) — not flat-broke.
      //   - RECOVERABLE / NOT PERMA-BROKE: net worth is floored at 0 (never
      //     negative) and the peak comes well after the start (accumulation).
      //   - BOUNDED: it does NOT run away to infinity — a full career tops out
      //     in the tens of thousands, comfortably under a generous $5M ceiling
      //     (scaling rent + lifestyle sinks keep it bounded).
      // Bands are generous so seed variance / death-age shifts don't flake.
      const ym = r.trajectory.yearlyMoney;
      const startSample = ym.find((y) => y.ageYears <= 18);
      if (startSample) {
        expect(startSample.netWorth).toBe(0); // near 0 before employment
      }
      const working = ym.filter((y) => y.ageYears >= 22 && y.ageYears <= 65);
      // Every seed lives past the employment age, so there is always a window.
      expect(working.length).toBeGreaterThan(0);
      const peakNetWorth = Math.max(...working.map((y) => y.netWorth));
      const troughNetWorth = Math.min(...working.map((y) => y.netWorth));
      // Never goes negative (money floored at 0): recoverable, not bankrupting.
      expect(troughNetWorth).toBeGreaterThanOrEqual(0);
      // Savings accumulate over a working life — meaningful growth, not flat-0.
      expect(peakNetWorth).toBeGreaterThan(5_000);
      // Bounded: sinks keep the economy from running away to infinity.
      expect(peakNetWorth).toBeLessThan(5_000_000);
    }, 180_000);
  }
});
