/**
 * performActivity skill-progression + varied-outcome tests (T006)
 *
 * Verifies the deepened proactive agency loop:
 *  - per-activity skill increments on each performance and PERSISTS across a
 *    Person.toJSON() -> reconstruct round-trip
 *  - repeated study yields a NON-FLAT (growing/tiered) intelligence trajectory,
 *    not the old constant +3
 *  - the varied-outcome path is reachable and DETERMINISTIC with a seeded RNG
 *    (poor / normal / great tiers)
 *  - the energy / age / free-slot gates still hold (and do NOT mutate skills)
 *
 * Follows the existing performActivity.test.ts MockPlayerSession pattern.
 */
import { describe, it, expect } from 'vitest';
import { Person } from '../../src/models/Person';
import { Player } from '../../src/models/Player';
import { handlePerformActivity } from '../../src/handlers/performActivity';
import {
  resolveActivityOutcome,
  rollOutcomeTier,
  skillMasteryMultiplier,
  PLAYER_ACTIVITIES,
} from '../../src/game/engine/intradayActivity';

class MockPlayerSession {
  player: Player;
  sentMessages: any[] = [];
  saveCount = 0;

  constructor(player: Player) {
    this.player = player;
  }

  send(message: any) {
    this.sentMessages.push(message);
  }

  sendPlayerObject() {
    this.sentMessages.push({ type: 'playerObject', player: this.player });
  }

  async savePlayer() {
    this.saveCount += 1;
    return undefined;
  }

  getLastMessage() {
    return this.sentMessages[this.sentMessages.length - 1];
  }

  byType(type: string) {
    return this.sentMessages.filter((m) => m?.type === type);
  }
}

function makeSession(overrides: Partial<ConstructorParameters<typeof Person>[0]> = {}) {
  const character = new Person({
    id: 'char-1',
    firstname: 'Test',
    lastname: 'Player',
    sex: 'Male',
    ageYears: 25,
    money: 1000,
    energy: 100,
    health: 90,
    happiness: 70,
    social: 50,
    intelligence: 50,
    creativity: 50,
    stress: 10,
    affinity: 50,
    // Each performance consumes the current free slot, so for multi-call tests we
    // re-seed a fresh free slot before each call (see refreshFreeSlot below).
    dailyPlan: [
      { time: 8, location: 'work', title: 'You arrive at work', name: 'work' },
      { time: 20, location: 'home', title: 'You relax at home', name: 'home' },
    ] as any,
    ...overrides,
  });

  const player = new Player({
    userId: 'user-1',
    character,
    r: [],
    status: 'playing',
    date: '2024-06-15',
    hourOfDay: 10,
    minuteOfHour: 0,
  });

  return new MockPlayerSession(player);
}

/** Reset the evening free slot so a subsequent performActivity has a slot to spend. */
function refreshFreeSlot(session: MockPlayerSession) {
  session.player.c.dailyPlan = [
    { time: 8, location: 'work', title: 'You arrive at work', name: 'work' },
    { time: 20, location: 'home', title: 'You relax at home', name: 'home' },
  ] as any;
  // Keep energy topped up so the energy gate never interferes with progression.
  session.player.c.energy = 100;
}

describe('performActivity skill progression (T006)', () => {
  it('increments the per-activity skill on each performance', async () => {
    const session = makeSession();
    expect(session.player.c.skills?.study ?? 0).toBe(0);

    await handlePerformActivity({ activityId: 'study' }, session as any, () => 0.5);
    expect(session.player.c.skills.study).toBe(1);

    refreshFreeSlot(session);
    await handlePerformActivity({ activityId: 'study' }, session as any, () => 0.5);
    expect(session.player.c.skills.study).toBe(2);

    // Skills are tracked PER activity id.
    refreshFreeSlot(session);
    await handlePerformActivity({ activityId: 'exercise' }, session as any, () => 0.5);
    expect(session.player.c.skills.exercise).toBe(1);
    expect(session.player.c.skills.study).toBe(2);
  });

  it('persists skills across a toJSON() -> reconstruct round-trip', async () => {
    const session = makeSession();
    await handlePerformActivity({ activityId: 'study' }, session as any, () => 0.5);
    refreshFreeSlot(session);
    await handlePerformActivity({ activityId: 'study' }, session as any, () => 0.5);

    expect(session.player.c.skills.study).toBe(2);

    // Round-trip the character through serialization and back.
    const serialized = session.player.c.toJSON();
    expect(serialized.skills).toEqual({ study: 2 });

    const restored = new Person(serialized);
    expect(restored.skills.study).toBe(2);

    // A fresh character (no skills in data) defaults to an empty map.
    const fresh = new Person({ id: 'x', firstname: 'A', lastname: 'B', sex: 'Female' });
    expect(fresh.skills).toEqual({});
    expect(fresh.toJSON().skills).toEqual({});
  });

  it('yields a NON-FLAT intelligence trajectory over repeated study (not +3 each time)', async () => {
    // Drive enough sessions to cross skill tiers (novice -> apprentice -> skilled),
    // all on the deterministic "normal" outcome so variance does not muddy the
    // monotone-but-tapering progression we are asserting.
    const session = makeSession();
    const gains: number[] = [];

    for (let i = 0; i < 18; i++) {
      const before = session.player.c.intelligence;
      refreshFreeSlot(session);
      await handlePerformActivity({ activityId: 'study' }, session as any, () => 0.5);
      gains.push(session.player.c.intelligence - before);
    }

    // The per-session gain is NOT a constant +3 forever.
    const allFlat3 = gains.every((g) => g === 3);
    expect(allFlat3).toBe(false);

    // Early sessions (novice 1.6x -> round(3*1.6)=5) gain MORE than later ones
    // (skilled 1.15x -> round(3*1.15)=3): a real curve that tapers.
    expect(gains[0]).toBe(5); // skill 0 (novice)
    expect(gains[5]).toBe(round3(1.3)); // skill 5 (apprentice) -> round(3*1.3)=4
    expect(gains[16]).toBe(round3(1.15)); // skill 16 (skilled) -> round(3*1.15)=3
    // The early gain strictly exceeds the late gain (front-loaded progression).
    expect(gains[0]).toBeGreaterThan(gains[16]);
  });

  it('rolls deterministic, distinct outcome tiers from a seeded RNG (poor/normal/great)', async () => {
    // rollOutcomeTier thresholds: <0.15 poor, <0.85 normal, else great.
    expect(rollOutcomeTier(() => 0.05)).toBe('poor');
    expect(rollOutcomeTier(() => 0.5)).toBe('normal');
    expect(rollOutcomeTier(() => 0.95)).toBe('great');

    // End-to-end through the handler: a "poor" session gives a smaller gain and a
    // "great" session gives a larger gain than "normal", and the payload reports
    // the tier + a distinct message.
    const base = PLAYER_ACTIVITIES.study.effects.intelligence ?? 0; // 3
    const novice = skillMasteryMultiplier(0); // 1.6

    const poorSession = makeSession();
    await handlePerformActivity({ activityId: 'study' }, poorSession as any, () => 0.05);
    const poor = poorSession.byType('activityPerformed')[0];
    expect(poor.outcomeTier).toBe('poor');
    expect(poor.deltas.intelligence).toBe(Math.round(base * novice * 0.5)); // round(3*1.6*0.5)=2

    const greatSession = makeSession();
    await handlePerformActivity({ activityId: 'study' }, greatSession as any, () => 0.95);
    const great = greatSession.byType('activityPerformed')[0];
    expect(great.outcomeTier).toBe('great');
    expect(great.deltas.intelligence).toBe(Math.round(base * novice * 1.75)); // round(3*1.6*1.75)=8
    // A "great" breakthrough grants a bonus skill level (0 -> 1 + 1 = 2).
    expect(greatSession.player.c.skills.study).toBe(2);
    // Distinct feedback message per tier.
    expect(great.message).toMatch(/breakthrough/i);
    expect(poor.message).toMatch(/didn't go very well/i);

    // Great gain > poor gain (varied outcomes are meaningfully distinct).
    expect(great.deltas.intelligence).toBeGreaterThan(poor.deltas.intelligence);
  });

  it('still enforces the energy gate without mutating skills', async () => {
    const session = makeSession({ energy: 2 }); // below every activity cost
    await handlePerformActivity({ activityId: 'exercise' }, session as any, () => 0.5);

    const last = session.getLastMessage();
    expect(last.type).toBe('error');
    expect(last.message).toMatch(/energy/i);
    expect(session.byType('activityPerformed').length).toBe(0);
    expect(session.player.c.energy).toBe(2); // unchanged
    expect(session.player.c.skills.exercise ?? 0).toBe(0); // no skill credited
  });

  it('still enforces the age gate without mutating skills', async () => {
    const young = makeSession({ ageYears: 8 });
    await handlePerformActivity({ activityId: 'sideHustle' }, young as any, () => 0.5);

    const last = young.getLastMessage();
    expect(last.type).toBe('error');
    expect(young.byType('activityPerformed').length).toBe(0);
    expect(young.player.c.skills.sideHustle ?? 0).toBe(0);
  });

  it('still requires a free slot without mutating skills', async () => {
    const noSlot = makeSession({
      dailyPlan: [
        { time: 7, location: 'home', title: 'You relax at home', name: 'home' },
        { time: 22, location: 'home', title: 'You go to bed', name: 'bed' },
      ] as any,
    });
    await handlePerformActivity({ activityId: 'hobby' }, noSlot as any, () => 0.5);

    const last = noSlot.getLastMessage();
    expect(last.type).toBe('error');
    expect(last.message).toMatch(/no free time slot/i);
    expect(noSlot.player.c.skills.hobby ?? 0).toBe(0);
  });

  it('resolveActivityOutcome is a pure function of (activity, skill, tier)', () => {
    const study = PLAYER_ACTIVITIES.study;

    // Novice normal: round(3 * 1.6 * 1.0) = 5.
    const r0 = resolveActivityOutcome(study, 0, 'normal');
    expect(r0.deltas.intelligence).toBe(5);
    expect(r0.nextSkillLevel).toBe(1);

    // Expert normal (skill 30, 1.0x): round(3 * 1.0 * 1.0) = 3.
    const r30 = resolveActivityOutcome(study, 30, 'normal');
    expect(r30.deltas.intelligence).toBe(3);
    expect(r30.nextSkillLevel).toBe(31);

    // Direction preserved: a poor session never rounds a non-zero base to 0.
    const happinessBase = study.effects.happiness ?? 0; // -1
    const poor = resolveActivityOutcome(study, 30, 'poor');
    expect(happinessBase).toBeLessThan(0);
    expect(poor.deltas.happiness).toBeLessThanOrEqual(-1);
  });
});

/** Local mirror of the rounding the production code applies for assertions. */
function round3(multiplier: number): number {
  return Math.round(3 * multiplier);
}
