// Engaged-worker economy curve probe + REGRESSION (T003 measurement, T005 fix).
//
// The passive metrics.test.ts worker NEVER sets a job focus, frozen at the entry
// salary tier for a whole career (~$97k net-worth peak). This probe runs the
// OPT-IN engaged worker (workFocus: 'Work Hard', persisted via the real
// setJobFocus right after applyForJob) and PRINTS the per-age salary / money /
// netWorth curve so we can SEE that late-game money is now meaningful versus the
// $100-$5M shop.
//
// T005 FIX (see docs/.../T005-wired-curve.md): the promotion mechanic
// (job_manager.handleJob) is now WIRED into BOTH weekly ticks — online
// PlayerSession.processWeekTick and offline GameEngine.handleWeeklyUpdates — so
// job focus drives a real climbing salary ladder. Post-fix the engaged curve
// CLIMBS through multiple tiers while the passive curve only creeps. These
// assertions now LOCK IN the climbing behavior (they would re-fail if the wiring
// regressed to dead code, restoring the flat curve).
//
// Run just this probe to capture output:
//   npx vitest run tests/e2e/economy-curve.test.ts

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

// -----------------------------------------------------------------------------
// Mocks (identical surface to metrics.test.ts — the simulator requires them).
// -----------------------------------------------------------------------------

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: vi.fn(async () => undefined),
  updateLifeGoals: vi.fn(() => []),
}));

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

// Match metrics.test.ts: start age 18, 160 in-game-year horizon. We use a
// SINGLE seed per trajectory: each full-lifetime run is ~50-60s, and under the
// parallel full-suite load (vitest runs files concurrently) a multi-seed `it`
// exceeds the 180s timeout. The salary-tier curves are seed-stable (seed only
// shifts the death age), so one seed is sufficient to surface the numbers;
// seed-7 / seed-42 (earlier-death) figures are recorded in
// docs/goals/baolife-economy/notes/T003-engaged-curve.md.
const CURVE_SEEDS = [1];
const ONE_YEAR_MIN = 365 * 24 * 60;
const CURVE_MAX_MINUTES = 160 * ONE_YEAR_MIN;

interface TrajectoryStats {
  label: string;
  seed: number;
  finalAge: number;
  /** Entry salary (first non-zero salary seen while working). */
  entrySalary: number;
  /** Highest salary tier reached over the working life. */
  peakSalary: number;
  /** Age at which the peak salary was first reached. */
  peakSalaryAge: number;
  /** Distinct salary tiers seen while working, in first-seen order. */
  salaryTiers: number[];
  /** Working-life (age 22-65) net-worth trough / peak / final. */
  troughNetWorth: number;
  peakNetWorth: number;
  peakNetWorthAge: number;
  finalWorkingNetWorth: number;
}

function analyze(label: string, r: SimulatorResult): TrajectoryStats {
  const ym = r.trajectory.yearlyMoney;
  const working = ym.filter((y) => y.ageYears >= 22 && y.ageYears <= 65);
  const salaried = working.filter((y) => y.salary > 0);

  const entrySalary = salaried.length > 0 ? salaried[0].salary : 0;
  let peakSalary = 0;
  let peakSalaryAge = 0;
  for (const y of salaried) {
    if (y.salary > peakSalary) {
      peakSalary = y.salary;
      peakSalaryAge = y.ageYears;
    }
  }
  const salaryTiers: number[] = [];
  for (const y of salaried) {
    if (!salaryTiers.includes(y.salary)) salaryTiers.push(y.salary);
  }

  const netWorths = working.map((y) => y.netWorth);
  const troughNetWorth = netWorths.length ? Math.min(...netWorths) : 0;
  let peakNetWorth = 0;
  let peakNetWorthAge = 0;
  for (const y of working) {
    if (y.netWorth > peakNetWorth) {
      peakNetWorth = y.netWorth;
      peakNetWorthAge = y.ageYears;
    }
  }
  const finalWorkingNetWorth = working.length ? working[working.length - 1].netWorth : 0;

  return {
    label,
    seed: r.seed,
    finalAge: r.finalAge,
    entrySalary,
    peakSalary,
    peakSalaryAge,
    salaryTiers,
    troughNetWorth,
    peakNetWorth,
    peakNetWorthAge,
    finalWorkingNetWorth,
  };
}

function printCurve(label: string, r: SimulatorResult): TrajectoryStats {
  const stats = analyze(label, r);
  const lines: string[] = [];
  lines.push(`\n========== ECONOMY CURVE: ${label} (seed=${r.seed}) ==========`);
  lines.push(`finalAge=${r.finalAge} deathCause=${r.deathCause ?? '(cap)'} errors=${r.errors.length} invariants=${r.invariantViolations.length}`);
  lines.push(`-- per-age salary / money / netWorth (every ~5 years + last) --`);
  const ym = r.trajectory.yearlyMoney;
  const sampled = ym.filter((_, i) => i % 5 === 0 || i === ym.length - 1);
  for (const y of sampled) {
    lines.push(
      `  age ${String(y.ageYears).padStart(3)}: salary=${String(y.salary).padStart(5)} ` +
        `money=${y.money.toFixed(0).padStart(8)} netWorth=${y.netWorth.toFixed(0).padStart(8)} ` +
        `occ=${y.occupation || '-'}`
    );
  }
  lines.push(`-- summary --`);
  lines.push(
    `  entrySalary=${stats.entrySalary} peakSalary=${stats.peakSalary} (first reached age ${stats.peakSalaryAge})`
  );
  lines.push(`  salaryTierProgression: [${stats.salaryTiers.join(' -> ')}]`);
  lines.push(
    `  working-life netWorth: trough=${stats.troughNetWorth.toFixed(0)} ` +
      `peak=${stats.peakNetWorth.toFixed(0)} (age ${stats.peakNetWorthAge}) ` +
      `final(~65)=${stats.finalWorkingNetWorth.toFixed(0)}`
  );
  lines.push(`==============================================================`);
  // eslint-disable-next-line no-console
  console.log(lines.join('\n'));
  return stats;
}

async function runTrajectory(
  label: string,
  base: Omit<SimulatorOptions, 'seed'>,
  seeds: number[]
): Promise<TrajectoryStats[]> {
  const out: TrajectoryStats[] = [];
  for (const seed of seeds) {
    const r = await simulateLifetime({
      seed,
      characterAge: 18,
      maxMinutes: CURVE_MAX_MINUTES,
      ...base,
    });
    out.push(printCurve(label, r));
  }
  return out;
}

// HARD economy ceiling — no seed/career may peak past this (runaway guard).
const NET_WORTH_CEILING = 5_000_000;

describe('engaged-worker economy curve (T005 regression — climbing ladder)', () => {
  // POST-FIX BEHAVIOR (T005, see docs/.../T005-wired-curve.md):
  // The promotion/raise mechanic (job_manager.handleJob: >90 performance ->
  // promote -> raise salary) is now WIRED into BOTH live weekly ticks:
  //   - ONLINE  PlayerSession.processWeekTick()  -> handleJob() BEFORE finances
  //   - OFFLINE GameEngine.handleWeeklyUpdates() -> processJobProgression()
  //             (aliased job_manager.handleJob) BEFORE finances
  // job_manager.handleJob's post-promotion performance reset was also corrected
  // from 0 -> 50 (the fresh-hire baseline); resetting to 0 instantly satisfied
  // the <10 termination check on the next weekly tick, firing the worker right
  // after every promotion (a promote-then-fire sawtooth that prevented any
  // ladder). With the reset fixed, an engaged worker CLIMBS multiple tiers while
  // the passive worker (no focus, neutral random-walk) only creeps. These
  // assertions LOCK IN the climbing behavior: they re-fail if the wiring
  // regresses to dead code (flat curve) or the curve explodes past the ceiling.
  //
  // Engaged trajectories must show salaryTierProgression length > 1, engaged net
  // worth materially above the passive control, engaged Accountant reaching a
  // salary tier above $1200, and engaged Software Engineer climbing above $2000.

  it('engaged Accountant + SWE climb tiers above the passive control (bounded)', async () => {
    // Run all three trajectories in one test so the cross-trajectory comparison
    // (engaged >> passive) is coherent without cross-test shared state.
    const [acct] = await runTrajectory(
      'ENGAGED Accountant (Work Hard)',
      { employmentJobTitle: 'Accountant', workFocus: 'Work Hard' },
      CURVE_SEEDS
    );
    const [swe] = await runTrajectory(
      'ENGAGED Software Engineer (Work Hard)',
      { employmentJobTitle: 'Software Engineer', workFocus: 'Work Hard' },
      CURVE_SEEDS
    );
    const [passive] = await runTrajectory(
      'PASSIVE Accountant (no focus)',
      { employmentJobTitle: 'Accountant' },
      CURVE_SEEDS
    );

    // --- Engaged trajectories show a CLIMBING tier ladder (length > 1). ---
    expect(acct.salaryTiers.length).toBeGreaterThan(1);
    expect(swe.salaryTiers.length).toBeGreaterThan(1);

    // --- Engaged Accountant reaches a salary tier ABOVE the $1200 entry. ---
    expect(acct.peakSalary).toBeGreaterThan(1200);

    // --- Engaged Software Engineer climbs ABOVE its $2000 entry. ---
    expect(swe.peakSalary).toBeGreaterThan(2000);

    // --- Engaged net worth MATERIALLY above the passive control. ---
    // Passive peak is ~$98k; engaged Accountant ~$163k, engaged SWE ~$274k.
    // Require each engaged peak to clear the passive peak by a clear margin
    // (>= 1.3x) so a regression to the flat curve fails this assertion.
    expect(acct.peakNetWorth).toBeGreaterThan(passive.peakNetWorth * 1.3);
    expect(swe.peakNetWorth).toBeGreaterThan(passive.peakNetWorth * 1.3);

    // --- Passive control only CREEPS (entry tier dominates; not exploding). ---
    // It may occasionally creep one rung via the neutral random-walk, but its
    // peak salary stays at the bottom of the ladder and net worth stays modest.
    // (Bound raised from 200k after the savings rate was bumped 0.10 -> 0.35 for
    // meaningful wealth; the engaged-dominates-passive relative check below is
    // what actually proves the passive worker stays the laggard.)
    expect(passive.peakSalary).toBeLessThanOrEqual(2000);
    expect(passive.peakNetWorth).toBeLessThan(600_000);

    // --- HARD CEILING: no trajectory may explode past $5M (runaway guard). ---
    expect(acct.peakNetWorth).toBeLessThan(NET_WORTH_CEILING);
    expect(swe.peakNetWorth).toBeLessThan(NET_WORTH_CEILING);
    expect(passive.peakNetWorth).toBeLessThan(NET_WORTH_CEILING);

    // --- Basic validity of every curve. ---
    for (const s of [acct, swe, passive]) {
      expect(Number.isFinite(s.peakSalary)).toBe(true);
      expect(s.entrySalary).toBeGreaterThan(0);
      expect(s.peakSalary).toBeGreaterThanOrEqual(s.entrySalary);
    }
  }, 300_000);
});
