/**
 * Life Goals Service Tests
 *
 * Covers the forward-looking aspiration system:
 * - goals progress as their underlying stat advances (hook-driven and periodic)
 * - a goal completes, granting its diamond reward + life-score contribution
 * - the active slate refreshes / unlocks new goals by life stage (age)
 * - goal state persists through serialize -> load (save/load round-trip)
 */
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
  LIFE_GOAL_DEFINITIONS,
  ACTIVE_GOAL_SLATE_SIZE,
  getLifeGoalById,
  ageToLifeStage,
  buildGoalContext,
  evaluateGoals,
  refreshGoalSlate,
  getPlayerLifeGoals,
  formatActiveGoals,
  getLifeScore,
  serializePlayerLifeGoals,
  loadPlayerLifeGoals,
  clearPlayerLifeGoals,
  clearAllLifeGoals,
  type GoalEvalContext,
} from '../../../src/services/retention/lifeGoals.js';
import {
  initializePlayerStatistics,
  incrementStat,
  updateStat,
  setBooleanStat,
  clearAllStatistics,
} from '../../../src/services/retention/statistics.js';

const PLAYER_ID = 'test-life-goals-player';

/** Build a fake player object that satisfies the diamond + context shapes. */
function makePlayer(opts: { age: number; money?: number; prestige?: number; diamonds?: number }) {
  return {
    userId: PLAYER_ID,
    money: opts.money ?? 0,
    c: {
      ageYears: opts.age,
      prestige: opts.prestige ?? 0,
      diamonds: opts.diamonds ?? 0,
    },
  };
}

function baseCtx(overrides: Partial<GoalEvalContext> = {}): GoalEvalContext {
  return {
    age: 25,
    money: 0,
    prestige: 0,
    jobLevel: 0,
    childrenCount: 0,
    friendsCount: 0,
    everMarried: false,
    lifetimeEarnings: 0,
    ...overrides,
  };
}

describe('Life Goals Service', () => {
  beforeEach(() => {
    clearAllLifeGoals();
    clearAllStatistics();
    initializePlayerStatistics(PLAYER_ID);
  });

  afterEach(() => {
    clearPlayerLifeGoals(PLAYER_ID);
    clearAllStatistics();
  });

  describe('catalog', () => {
    it('defines a catalog of forward-looking goals with required fields', () => {
      expect(LIFE_GOAL_DEFINITIONS.length).toBeGreaterThanOrEqual(8);
      for (const def of LIFE_GOAL_DEFINITIONS) {
        expect(def.id).toBeTruthy();
        expect(def.title).toBeTruthy();
        expect(def.target).toBeGreaterThan(0);
        expect(def.reward).toBeGreaterThan(0);
        expect(def.lifeScore).toBeGreaterThan(0);
        expect(def.minAge).toBeLessThanOrEqual(def.maxAge);
        expect(typeof def.progress).toBe('function');
      }
    });

    it('maps ages to coarse life stages', () => {
      expect(ageToLifeStage(8)).toBe('child');
      expect(ageToLifeStage(15)).toBe('teen');
      expect(ageToLifeStage(24)).toBe('youngAdult');
      expect(ageToLifeStage(40)).toBe('adult');
      expect(ageToLifeStage(70)).toBe('senior');
    });
  });

  describe('slate refresh by life stage', () => {
    it('seeds a bounded active slate of age-eligible goals', () => {
      const ctx = baseCtx({ age: 25 });
      const changed = refreshGoalSlate(PLAYER_ID, ctx);
      expect(changed).toBe(true);

      const state = getPlayerLifeGoals(PLAYER_ID);
      expect(state.active.length).toBeGreaterThan(0);
      expect(state.active.length).toBeLessThanOrEqual(ACTIVE_GOAL_SLATE_SIZE);

      // Every active goal must be eligible for the current age.
      for (const g of state.active) {
        const def = getLifeGoalById(g.id)!;
        expect(ctx.age).toBeGreaterThanOrEqual(def.minAge);
        expect(ctx.age).toBeLessThanOrEqual(def.maxAge);
      }
    });

    it('unlocks new goals only when the character reaches the eligible age', () => {
      // A young child should NOT yet have the senior longevity goal.
      const childCtx = baseCtx({ age: 8 });
      refreshGoalSlate(PLAYER_ID, childCtx);
      let ids = getPlayerLifeGoals(PLAYER_ID).active.map((g) => g.id);
      expect(ids).not.toContain('live_to_eighty');

      // A senior re-evaluating becomes eligible for the longevity goal (once a
      // slot is free). Clear and reseed at senior age to prove eligibility.
      clearPlayerLifeGoals(PLAYER_ID);
      const seniorCtx = baseCtx({ age: 70 });
      refreshGoalSlate(PLAYER_ID, seniorCtx);
      ids = getPlayerLifeGoals(PLAYER_ID).active.map((g) => g.id);
      // live_to_eighty is eligible at 70 (minAge 55) — it should be a candidate.
      const eligibleSenior = LIFE_GOAL_DEFINITIONS.filter(
        (d) => seniorCtx.age >= d.minAge && seniorCtx.age <= d.maxAge
      ).map((d) => d.id);
      expect(eligibleSenior).toContain('live_to_eighty');
      // The slate is drawn from eligible goals.
      for (const id of ids) {
        expect(eligibleSenior).toContain(id);
      }
    });
  });

  describe('progress advancement', () => {
    it('advances a goal as its underlying stat increases (e.g. children)', () => {
      // Force the "raise_two_children" goal into the slate by clearing others.
      clearPlayerLifeGoals(PLAYER_ID);
      const player = makePlayer({ age: 30 });

      // 0 children -> 0%
      evaluateGoals(PLAYER_ID, buildGoalContext(PLAYER_ID, player), player);
      let goal = formatActiveGoals(PLAYER_ID).find((g) => g.id === 'raise_two_children');
      expect(goal).toBeDefined();
      expect(goal!.progressPercent).toBe(0);

      // 1 child -> 50%
      incrementStat(PLAYER_ID, 'childrenCount', 1);
      evaluateGoals(PLAYER_ID, buildGoalContext(PLAYER_ID, player), player);
      goal = formatActiveGoals(PLAYER_ID).find((g) => g.id === 'raise_two_children');
      expect(goal!.current).toBe(1);
      expect(goal!.progressPercent).toBe(50);
    });
  });

  describe('completion grants rewards', () => {
    it('completes a goal, grants its diamond reward and life-score, and moves it to completed', () => {
      clearPlayerLifeGoals(PLAYER_ID);
      const def = getLifeGoalById('raise_two_children')!;
      const player = makePlayer({ age: 30, diamonds: 0 });

      // Seed slate then reach the target (2 children).
      incrementStat(PLAYER_ID, 'childrenCount', 2);
      const { completed } = evaluateGoals(PLAYER_ID, buildGoalContext(PLAYER_ID, player), player);

      const done = completed.find((c) => c.id === 'raise_two_children');
      expect(done).toBeDefined();
      expect(done!.reward).toBe(def.reward);
      expect(done!.lifeScore).toBe(def.lifeScore);

      // Diamonds granted to the character.
      expect(player.c.diamonds).toBe(def.reward);

      // Life score accumulated.
      expect(getLifeScore(PLAYER_ID)).toBe(def.lifeScore);

      // Goal moved from active -> completed.
      const state = getPlayerLifeGoals(PLAYER_ID);
      expect(state.completed.map((c) => c.id)).toContain('raise_two_children');
      expect(state.active.map((g) => g.id)).not.toContain('raise_two_children');
    });

    it('pulls a fresh goal into the freed slot after a completion', () => {
      clearPlayerLifeGoals(PLAYER_ID);
      const player = makePlayer({ age: 30 });

      // Seed a full slate first.
      evaluateGoals(PLAYER_ID, buildGoalContext(PLAYER_ID, player), player);
      const beforeCount = getPlayerLifeGoals(PLAYER_ID).active.length;
      expect(beforeCount).toBe(ACTIVE_GOAL_SLATE_SIZE);

      // Complete an active goal (friends -> "make_three_friends" if present, else
      // children). Drive both stats to guarantee at least one completes.
      updateStat(PLAYER_ID, 'highestJobLevel', 5); // climb_the_ladder / land_first_job
      incrementStat(PLAYER_ID, 'friendsCount', 3);
      incrementStat(PLAYER_ID, 'childrenCount', 2);
      const { completed } = evaluateGoals(PLAYER_ID, buildGoalContext(PLAYER_ID, player), player);
      expect(completed.length).toBeGreaterThan(0);

      // Slate refilled toward the target size (as long as eligible goals remain).
      const after = getPlayerLifeGoals(PLAYER_ID);
      expect(after.active.length).toBeLessThanOrEqual(ACTIVE_GOAL_SLATE_SIZE);
      // No completed goal should still be active.
      const completedIds = new Set(after.completed.map((c) => c.id));
      for (const g of after.active) {
        expect(completedIds.has(g.id)).toBe(false);
      }
    });

    it('does not re-grant a completed goal on subsequent evaluations', () => {
      clearPlayerLifeGoals(PLAYER_ID);
      const def = getLifeGoalById('raise_two_children')!;
      const player = makePlayer({ age: 30, diamonds: 0 });

      incrementStat(PLAYER_ID, 'childrenCount', 2);
      evaluateGoals(PLAYER_ID, buildGoalContext(PLAYER_ID, player), player);
      const diamondsAfterFirst = player.c.diamonds;
      expect(diamondsAfterFirst).toBe(def.reward);

      // Evaluate again — completed goal must not pay out twice.
      evaluateGoals(PLAYER_ID, buildGoalContext(PLAYER_ID, player), player);
      expect(player.c.diamonds).toBe(diamondsAfterFirst);
    });
  });

  describe('money / prestige periodic goals', () => {
    it('advances the millionaire goal off live money state', () => {
      clearPlayerLifeGoals(PLAYER_ID);
      const player = makePlayer({ age: 40, money: 500_000 });
      evaluateGoals(PLAYER_ID, buildGoalContext(PLAYER_ID, player), player);
      const goal = formatActiveGoals(PLAYER_ID).find((g) => g.id === 'reach_millionaire');
      expect(goal).toBeDefined();
      expect(goal!.progressPercent).toBe(50);
    });

    it('completes the rising-star goal when prestige hits 100', () => {
      clearPlayerLifeGoals(PLAYER_ID);
      const def = getLifeGoalById('rising_star')!;
      const player = makePlayer({ age: 30, prestige: 100, diamonds: 0 });

      // Force rising_star into the active slate (it may not seed by default at
      // age 30 since the bounded slate fills with earlier catalog goals first).
      loadPlayerLifeGoals(PLAYER_ID, {
        active: [{ id: 'rising_star', current: 0, progressPercent: 0 }],
        completed: [],
        lifeScore: 0,
      });

      const { completed } = evaluateGoals(PLAYER_ID, buildGoalContext(PLAYER_ID, player), player);
      expect(completed.map((c) => c.id)).toContain('rising_star');
      expect(player.c.diamonds).toBeGreaterThanOrEqual(def.reward);
    });
  });

  describe('persistence (save/load round-trip)', () => {
    it('serializes and re-hydrates active + completed goals and life score', () => {
      clearPlayerLifeGoals(PLAYER_ID);
      const player = makePlayer({ age: 30, money: 0 });

      // Make some progress + a completion.
      incrementStat(PLAYER_ID, 'childrenCount', 2);
      incrementStat(PLAYER_ID, 'friendsCount', 1);
      evaluateGoals(PLAYER_ID, buildGoalContext(PLAYER_ID, player), player);

      const snapshot = serializePlayerLifeGoals(PLAYER_ID);
      expect(snapshot.completed.length).toBeGreaterThan(0);
      expect(snapshot.lifeScore).toBeGreaterThan(0);

      // Simulate save -> wipe in-memory -> load from snapshot.
      const beforeActive = getPlayerLifeGoals(PLAYER_ID).active.map((g) => g.id).sort();
      clearPlayerLifeGoals(PLAYER_ID);
      expect(getPlayerLifeGoals(PLAYER_ID).active.length).toBe(0);
      expect(getLifeScore(PLAYER_ID)).toBe(0);

      loadPlayerLifeGoals(PLAYER_ID, snapshot);

      const restored = getPlayerLifeGoals(PLAYER_ID);
      expect(restored.active.map((g) => g.id).sort()).toEqual(beforeActive);
      expect(restored.completed.length).toBe(snapshot.completed.length);
      expect(getLifeScore(PLAYER_ID)).toBe(snapshot.lifeScore);
    });

    it('tolerates missing / null persisted data', () => {
      expect(() => loadPlayerLifeGoals(PLAYER_ID, null)).not.toThrow();
      const state = getPlayerLifeGoals(PLAYER_ID);
      expect(state.active).toEqual([]);
      expect(state.completed).toEqual([]);
      expect(getLifeScore(PLAYER_ID)).toBe(0);
    });

    it('drops unknown goal ids on load (catalog drift safety)', () => {
      loadPlayerLifeGoals(PLAYER_ID, {
        active: [{ id: 'no_such_goal', current: 1, progressPercent: 50 }],
        completed: [{ id: 'gone_goal', completedAt: '2026-01-01T00:00:00.000Z' }],
        lifeScore: 42,
      });
      const state = getPlayerLifeGoals(PLAYER_ID);
      expect(state.active.map((g) => g.id)).not.toContain('no_such_goal');
      // Completed history is preserved even for unknown ids (history is immutable).
      expect(state.completed.map((c) => c.id)).toContain('gone_goal');
      expect(getLifeScore(PLAYER_ID)).toBe(42);
    });
  });
});

// Silence unused import lint for setBooleanStat (kept for parity / future use).
void setBooleanStat;
