/**
 * Re-engagement reminders (T010c daily-return hook #2).
 *
 * The background job finds lapsed players (last active 24-72h ago) and fires a
 * throttle-respecting 'reminder'-category push. The targeting + dispatch logic
 * is extracted into pure functions (selectLapsedReEngagementTargets,
 * buildReEngagementMessage, runReEngagementSweep) so it can be tested with an
 * injected clock and an injected sender — no real wall clock, no real APNs.
 *
 * The throttle test drives the REAL notificationManager.notify path against the
 * stub APNs sender (no credentials configured in tests => stub success), so we
 * exercise the actual 4/hr throttle rather than a mock of it.
 */
import { describe, it, expect, beforeEach } from 'vitest';
import {
  REENGAGE_MIN_HOURS,
  REENGAGE_MAX_HOURS,
  selectLapsedReEngagementTargets,
  buildReEngagementMessage,
  runReEngagementSweep,
  type ReEngagementCandidate,
} from '../../../src/services/background/jobs.js';
import { notify, clearAllThrottles } from '../../../src/services/notifications/notificationManager.js';

const HOUR = 60 * 60 * 1000;
const NOW = 1_700_000_000_000; // fixed clock for determinism

function candidate(
  userId: string,
  hoursAgo: number,
  overrides: Partial<ReEngagementCandidate> = {}
): ReEngagementCandidate {
  return {
    userId,
    lastActiveMs: NOW - hoursAgo * HOUR,
    deviceToken: `token-${userId}`,
    characterName: 'Ada',
    ageYears: 30,
    ...overrides,
  };
}

describe('selectLapsedReEngagementTargets — absence window', () => {
  it('selects ONLY players in the 24-72h window', () => {
    const candidates = [
      candidate('active', 1), // <24h — still engaged
      candidate('edge-23', 23), // <24h
      candidate('low-edge', REENGAGE_MIN_HOURS), // exactly 24h — included
      candidate('mid', 48), // in window
      candidate('high-edge', REENGAGE_MAX_HOURS), // exactly 72h — included
      candidate('churned', 96), // >72h — excluded
    ];

    const selected = selectLapsedReEngagementTargets(candidates, NOW).map((c) => c.userId);

    expect(selected).toEqual(['low-edge', 'mid', 'high-edge']);
    expect(selected).not.toContain('active');
    expect(selected).not.toContain('edge-23');
    expect(selected).not.toContain('churned');
  });

  it('excludes candidates without a device token', () => {
    const candidates = [
      candidate('has-token', 48),
      candidate('no-token', 48, { deviceToken: undefined }),
    ];
    const selected = selectLapsedReEngagementTargets(candidates, NOW).map((c) => c.userId);
    expect(selected).toEqual(['has-token']);
  });

  it('honors custom window overrides', () => {
    const candidates = [candidate('p', 10)];
    expect(selectLapsedReEngagementTargets(candidates, NOW, { minHours: 5, maxHours: 12 })).toHaveLength(1);
    expect(selectLapsedReEngagementTargets(candidates, NOW, { minHours: 12, maxHours: 24 })).toHaveLength(0);
  });
});

describe('buildReEngagementMessage — copy variants', () => {
  it('uses the birthday variant when a recent birthday + age are present', () => {
    const msg = buildReEngagementMessage(
      candidate('p', 48, { hadRecentBirthday: true, ageYears: 41, characterName: 'Mei' })
    );
    expect(msg.body).toContain('Mei just turned 41');
  });

  it('falls back to the generic decision hook', () => {
    const msg = buildReEngagementMessage(candidate('p', 48, { characterName: 'Mei' }));
    expect(msg.body).toContain("Mei's life");
    expect(msg.body.toLowerCase()).toContain('decision awaits');
  });

  it('handles a missing character name gracefully', () => {
    const msg = buildReEngagementMessage(candidate('p', 48, { characterName: undefined }));
    expect(msg.body).toContain('your character');
  });
});

describe('runReEngagementSweep — dispatch + throttle', () => {
  beforeEach(() => {
    clearAllThrottles();
  });

  it('produces a reminder notification only for in-window players (stub send)', async () => {
    const sent: Array<{ userId: string; category: string }> = [];
    const candidates = [
      candidate('engaged', 2),
      candidate('lapsed-a', 30),
      candidate('lapsed-b', 60),
      candidate('churned', 200),
    ];

    const result = await runReEngagementSweep(candidates, NOW, async (args) => {
      sent.push({ userId: args.userId, category: args.category });
      return { sent: true };
    });

    expect(result.selected).toBe(2);
    expect(result.sent).toBe(2);
    expect(sent.map((s) => s.userId).sort()).toEqual(['lapsed-a', 'lapsed-b']);
    // All reminders use the 'reminder' notification category.
    expect(sent.every((s) => s.category === 'reminder')).toBe(true);
  });

  it('respects the shared 4/hr throttle via the real notify path', async () => {
    // Five lapsed players sharing ONE userId would normally all send, but the
    // throttle is per-user. Use one user with repeated sweeps to exhaust the
    // budget. 'reminder' is priority 1 => throttled at 3 (1 slot reserved for
    // milestones), so the 4th send for the same user is throttled.
    const target = candidate('lapsed-user', 48);

    const send = (args: {
      userId: string;
      deviceToken: string;
      title: string;
      body: string;
      category: 'reminder';
    }) =>
      notify({
        userId: args.userId,
        deviceToken: args.deviceToken,
        isBackgrounded: true,
        payload: { title: args.title, body: args.body, category: args.category },
      });

    const outcomes: boolean[] = [];
    for (let i = 0; i < 4; i++) {
      const r = await runReEngagementSweep([target], NOW, (a) => send({ ...a, category: 'reminder' }));
      outcomes.push(r.sent === 1);
    }

    // First 3 send; 4th is throttled (reminder reserves the milestone slot).
    expect(outcomes).toEqual([true, true, true, false]);
  });

  it('tallies throttled sends in the result', async () => {
    const target = candidate('throttled-user', 48);
    const send: typeof notify extends never ? never : Parameters<typeof runReEngagementSweep>[2] =
      async () => ({ sent: false, reason: 'throttled' });

    const result = await runReEngagementSweep([target], NOW, send);
    expect(result.selected).toBe(1);
    expect(result.sent).toBe(0);
    expect(result.throttled).toBe(1);
  });
});
