/**
 * T003 — Online<->offline weekly parity.
 *
 * The OFFLINE loop (GameEngine.handleWeeklyUpdates) ran five weekly subsystems,
 * but the ONLINE loop (PlayerSession.processWeekTick) only ran handleJob +
 * applyWeeklyFinances. So connected players never received:
 *   1. the 5% weekly random ROMANCE beat (processWeeklyRelationshipEvents), and
 *   2. student GPA weekly DRIFT (handleEducation).
 *
 * These tests drive a connected PlayerSession's weekly tick and assert the two
 * newly-wired behaviors. They FAIL if processWeekTick regresses to a
 * job+finances-only tick (verified by temporarily removing the two calls).
 *
 * They also confirm the parity-preserving EXCLUSIONS hold: we do NOT add
 * handleMoods (online happiness moves elsewhere) and we do NOT add the per-NPC
 * weekly affinity/familiarity decay loop (online updateAge already decays
 * affinity), so the only affinity movement here comes from the romance event.
 */
import { describe, it, expect, vi, afterEach } from 'vitest';

import { Person } from '../../src/models/Person.js';
import { Player } from '../../src/models/Player.js';
import { PlayerSession } from '../../src/game/PlayerSession.js';
import type { RomanticRelationship } from '../../src/services/relationships/relationship_manager.js';

class MockWebSocket {
  messages: string[] = [];
  readyState = 1; // OPEN
  send(data: string) { this.messages.push(data); }
  close() { this.readyState = 3; }
}

function makeSession(player: Player): { session: PlayerSession; ws: MockWebSocket } {
  const ws = new MockWebSocket();
  const session = new PlayerSession(ws as unknown as never, player);
  // Never hit the DB during the weekly save.
  vi.spyOn(session as unknown as { savePlayer: () => Promise<void> }, 'savePlayer')
    .mockResolvedValue(undefined);
  return { session, ws };
}

function callWeekTick(session: PlayerSession): void {
  (session as unknown as { processWeekTick: () => void }).processWeekTick();
}

afterEach(() => {
  vi.restoreAllMocks();
});

describe('PlayerSession.processWeekTick — online romance parity (T003)', () => {
  it('queues a relationship messageEvent and moves partner affinity on the 5% beat', () => {
    const character = new Person({
      id: 'char-1',
      firstname: 'Test',
      lastname: 'Player',
      sex: 'Male',
      ageYears: 30,
      occupation: 'engineer',
      salary: 2000,
      money: 100,
    } as never);

    const partner = new Person({
      id: 'partner-1',
      firstname: 'Sam',
      lastname: 'Partner',
      sex: 'Female',
      ageYears: 29,
      status: 'alive',
      affinity: 50,
    } as never);

    const player = new Player({
      userId: 'weekly-romance-player',
      character,
      status: 'playing',
      date: '2024-06-15',
    });
    player.r = [partner];
    const rel: RomanticRelationship = {
      id: 'rel-1',
      person1: 'char-1',
      person2: 'partner-1',
      startDate: '2024-01-01',
      relationshipStatus: 'Dating',
      description: 'Dating Sam',
      relationshipScore: 50,
      eventsLog: [],
    };
    player.relData = [rel];

    const { session } = makeSession(player);

    // Force the 5% branch:
    //   eventChance = floor(random*100)+1 must be > 95  -> 0.99 -> 100
    //   getRandomEvent index = floor(random*8)          -> 0.99 -> 7 (affinity +1, non-zero)
    const randSpy = vi.spyOn(Math, 'random').mockReturnValue(0.99);

    const affinityBefore = partner.affinity;
    const scoreBefore = rel.relationshipScore;
    const queueLenBefore = player.messageQueue.length;

    callWeekTick(session);

    // A romance event message was queued (rides the existing
    // messageQueue -> messageEvent drain in processHourTick).
    expect(player.messageQueue.length).toBe(queueLenBefore + 1);
    expect(player.relData[0].eventsLog.length).toBeGreaterThan(0);

    // Partner affinity and relationship score moved due to the event.
    expect(partner.affinity).not.toBe(affinityBefore);
    expect(rel.relationshipScore).not.toBe(scoreBefore);

    // The queued message is a plain string (the same surfacing shape GameEngine
    // uses): it rides the EXISTING player.messageQueue -> messageEvent drain in
    // processHourTick. We assert the queued payload is the formatted romance
    // message (and not, e.g., a job/finance side effect) so the wiring is the
    // romance beat specifically.
    const queued = player.messageQueue[player.messageQueue.length - 1];
    expect(typeof queued).toBe('string');
    expect(queued).toBe(player.relData[0].eventsLog[player.relData[0].eventsLog.length - 1]);

    randSpy.mockRestore();
  });

  it('does NOT fire the romance beat outside the 5% window (no random event queued)', () => {
    const character = new Person({
      id: 'char-1', firstname: 'Test', lastname: 'Player', sex: 'Male',
      ageYears: 30, occupation: 'engineer', salary: 2000, money: 100,
    } as never);
    const partner = new Person({
      id: 'partner-1', firstname: 'Sam', lastname: 'Partner', sex: 'Female',
      ageYears: 29, status: 'alive', affinity: 50,
    } as never);
    const player = new Player({
      userId: 'weekly-romance-player-2', character, status: 'playing', date: '2024-06-15',
    });
    player.r = [partner];
    player.relData = [{
      id: 'rel-1', person1: 'char-1', person2: 'partner-1', startDate: '2024-01-01',
      relationshipStatus: 'Dating', description: 'Dating Sam', relationshipScore: 50, eventsLog: [],
    } as RomanticRelationship];

    const { session } = makeSession(player);

    // eventChance = floor(0.10*100)+1 = 11  (NOT > 95) -> no random event,
    // but the weekly +1 score nudge still applies (proves the call ran).
    vi.spyOn(Math, 'random').mockReturnValue(0.10);
    const affinityBefore = partner.affinity;

    callWeekTick(session);

    // No random romance message queued, partner affinity untouched by a beat.
    expect(player.messageQueue.length).toBe(0);
    expect(partner.affinity).toBe(affinityBefore);
  });
});

describe('PlayerSession.processWeekTick — online education parity (T003)', () => {
  it('drifts a student GPA on the weekly tick (handleEducation ran)', () => {
    const character = new Person({
      id: 'student-1',
      firstname: 'Stu',
      lastname: 'Dent',
      sex: 'Female',
      ageYears: 16,
      occupation: 'student',
      intelligence: 50,
    } as never);
    // current_education links to an activity record by id; handleEducation drifts
    // that record's GPA by random(-1, 1+modifier).
    character.current_education = { id: 'edu-1', focus: 'Work Hard' };
    character.activityRecords = [
      { id: 'edu-1', educationLevel: 'high_school', gpa: 50, GPA: 50, focus: 'Work Hard' },
    ];

    const player = new Player({
      userId: 'weekly-education-player',
      character,
      status: 'playing',
      date: '2024-06-15',
    });

    const { session } = makeSession(player);

    // Force GPA upward: 'Work Hard' modifier => random(-1, 2); 0.99 -> +2.
    vi.spyOn(Math, 'random').mockReturnValue(0.99);

    const gpaBefore = character.activityRecords[0].gpa;
    callWeekTick(session);
    const gpaAfter = character.activityRecords[0].gpa;

    // GPA record was touched by handleEducation during the weekly tick.
    expect(gpaAfter).not.toBe(gpaBefore);
    expect(gpaAfter).toBeGreaterThan(gpaBefore);
    // Intelligence is NOT bumped online (that lives in GameEngine.handleEducation's
    // own wrapper, not the shared educationManagerHandleEducation we call) — parity
    // is on the GPA-drift function only.
  });

  it('is a no-op for a non-student (handleEducation guards on occupation)', () => {
    const character = new Person({
      id: 'worker-1', firstname: 'Work', lastname: 'Er', sex: 'Male',
      ageYears: 40, occupation: 'engineer', salary: 2000, money: 100,
    } as never);
    character.activityRecords = [
      { id: 'edu-1', educationLevel: 'high_school', gpa: 50, focus: 'Balanced' },
    ];
    // No current_education -> handleEducation returns early.
    const player = new Player({
      userId: 'weekly-noschool-player', character, status: 'playing', date: '2024-06-15',
    });

    const { session } = makeSession(player);
    vi.spyOn(Math, 'random').mockReturnValue(0.99);

    const gpaBefore = character.activityRecords[0].gpa;
    callWeekTick(session);
    expect(character.activityRecords[0].gpa).toBe(gpaBefore);
  });
});
