import { TeamsService } from './teams.service';
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';

function monthKey(monthsAgo: number): string {
  const now = new Date();
  const d = new Date(now.getFullYear(), now.getMonth() - monthsAgo, 1);
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}

function isoDate(monthsAgo: number, day: number): string {
  const now = new Date();
  return new Date(now.getFullYear(), now.getMonth() - monthsAgo, day).toISOString();
}

describe('TeamsService#getOverviewStats theoretical balance', () => {
  const repository = { findOneOrFail: jest.fn() };
  const access = { assertMember: jest.fn() };
  let service: TeamsService;

  beforeEach(() => {
    jest.resetAllMocks();
    access.assertMember.mockResolvedValue(undefined);
    service = new TeamsService(
      repository as any,
      {} as any,
      {} as any,
      {} as any,
      {} as any,
      {} as any,
      { info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
      access as any,
      {} as any,
      {} as any,
    );
  });

  it('adds still-open, unpaid debt to the theoretical balance while leaving the actual cash balance untouched', async () => {
    repository.findOneOrFail.mockResolvedValue({
      id: 9,
      balance: 100,
      // Bewegung liegt außerhalb des 12-Monats-Fensters, damit der Ist-Kassenstand
      // über das gesamte sichtbare Fenster flach bei 100 bleibt.
      transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
      players: [
        {
          id: 1,
          firstName: 'Alex',
          lastName: 'Muster',
          active: true,
          balance: -30,
          transactions: [
            {
              date: isoDate(2, 10),
              amount: 30,
              type: { id: 11, name: 'fine' },
              note: 'Zu spät zum Training',
            },
          ],
        },
      ],
    });

    const result = await service.getOverviewStats(9, 42);

    expect(access.assertMember).toHaveBeenCalledWith(42, 9);
    const beforeFine = result.balanceHistory.find((p) => p.month === monthKey(4));
    const now = result.balanceHistory.find((p) => p.month === monthKey(0));

    expect(beforeFine?.balance).toBe(100);
    expect(beforeFine?.theoreticalBalance).toBe(100);
    expect(now?.balance).toBe(100);
    // Sanity-Check: entspricht team.balance (100) + aktuelle offene Beiträge (30).
    expect(now?.theoreticalBalance).toBe(130);
  });

  it('excludes deactivation-adjustment transactions from the historical reconstruction', async () => {
    repository.findOneOrFail.mockResolvedValue({
      id: 9,
      balance: 50,
      transactions: [{ date: isoDate(13, 5), amount: 50, type: { name: 'credit' } }],
      players: [
        {
          id: 1,
          firstName: 'Alex',
          lastName: 'Muster',
          active: true,
          balance: -20,
          transactions: [
            {
              date: isoDate(6, 5),
              amount: 999,
              type: { id: 1, name: 'credit' },
              note: `${DEACTIVATION_ADJUSTMENT_NOTE_PREFIX} #1`,
            },
            {
              date: isoDate(1, 10),
              amount: 20,
              type: { id: 11, name: 'fine' },
              note: 'Zu spät',
            },
          ],
        },
      ],
    });

    const result = await service.getOverviewStats(9, 42);

    // Older than the adjustment's own month (6 months ago) — the only kind of checkpoint
    // where wrongly including the €999 adjustment would still show up as "already happened".
    const beforeAdjustment = result.balanceHistory.find((p) => p.month === monthKey(9));
    const now = result.balanceHistory.find((p) => p.month === monthKey(0));

    // Without exclusion this would read 1049 (50 + the wrongly-included €999 adjustment);
    // with correct exclusion only the (not-yet-existing-at-month-9) fine is irrelevant here too,
    // so it stays at the team's flat 50.
    expect(beforeAdjustment?.theoreticalBalance).toBe(50);
    expect(now?.theoreticalBalance).toBe(70);
  });

  it("excludes an inactive player's outstanding debt from the theoretical balance", async () => {
    repository.findOneOrFail.mockResolvedValue({
      id: 9,
      balance: 100,
      transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
      players: [
        {
          id: 1,
          firstName: 'Alex',
          lastName: 'Muster',
          active: true,
          balance: 0,
          transactions: [],
        },
        {
          id: 2,
          firstName: 'Bea',
          lastName: 'Beispiel',
          active: false,
          balance: -40,
          transactions: [
            {
              date: isoDate(2, 10),
              amount: 40,
              type: { id: 11, name: 'fine' },
              note: 'Zu spät',
            },
          ],
        },
      ],
    });

    const result = await service.getOverviewStats(9, 42);

    const now = result.balanceHistory.find((p) => p.month === monthKey(0));

    // The inactive player's unpaid fine is real debt, but no longer counted at all —
    // the theoretical line must match the actual cash balance exactly.
    expect(now?.balance).toBe(100);
    expect(now?.theoreticalBalance).toBe(100);
  });

  it('lowers the theoretical balance when a player has prepaid credit not yet reflected as spent', async () => {
    repository.findOneOrFail.mockResolvedValue({
      id: 9,
      balance: 100,
      transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
      players: [
        {
          id: 1,
          firstName: 'Alex',
          lastName: 'Muster',
          active: true,
          balance: 30,
          transactions: [
            {
              date: isoDate(2, 10),
              amount: 30,
              type: { id: 1, name: 'credit' },
              note: 'Vorauszahlung',
            },
          ],
        },
      ],
    });

    const result = await service.getOverviewStats(9, 42);

    const now = result.balanceHistory.find((p) => p.month === monthKey(0));

    // Prepaid credit is not yet "spent" in the reconstruction, so the theoretical
    // line dips below the actual cash balance at this checkpoint.
    expect(now?.balance).toBe(100);
    expect(now?.theoreticalBalance).toBe(70);
    expect(now!.theoreticalBalance).toBeLessThan(now!.balance);
  });

  it('sums fine/levy/fee bookings per month into monthlyFlow.penalties, excluded from income/expense', async () => {
    repository.findOneOrFail.mockResolvedValue({
      id: 9,
      balance: 100,
      transactions: [{ date: isoDate(0, 1), amount: 100, type: { name: 'credit' } }],
      players: [
        {
          id: 1,
          firstName: 'Alex',
          lastName: 'Muster',
          active: true,
          balance: -45,
          transactions: [
            { date: isoDate(0, 5), amount: 10, type: { id: 11, name: 'fine' }, note: 'Zu spät' },
            { date: isoDate(0, 6), amount: 15, type: { id: 12, name: 'levy' }, note: 'Umlage' },
            { date: isoDate(0, 7), amount: 5, type: { id: 13, name: 'fee' }, note: 'Gebühr' },
            { date: isoDate(1, 8), amount: 20, type: { id: 11, name: 'fine' }, note: 'Vormonat' },
            { date: isoDate(0, 9), amount: 12, type: { id: 0, name: 'payment' }, note: 'Beitrag' },
          ],
        },
      ],
    });

    const result = await service.getOverviewStats(9, 42);

    const now = result.monthlyFlow.find((p) => p.month === monthKey(0));
    const lastMonth = result.monthlyFlow.find((p) => p.month === monthKey(1));

    expect(now?.penalties).toBe(30);
    expect(lastMonth?.penalties).toBe(20);
    // Payment still counts as income, fine/levy/fee never do.
    expect(now?.income).toBe(12);
    expect(now?.expense).toBe(0);
  });

  it('keeps returning an empty balance history when the team has no cash movement at all', async () => {
    repository.findOneOrFail.mockResolvedValue({
      id: 9,
      balance: 0,
      transactions: [],
      players: [{ id: 1, firstName: 'Alex', lastName: 'Muster', active: true, balance: 0, transactions: [] }],
    });

    const result = await service.getOverviewStats(9, 42);

    expect(result.balanceHistory).toEqual([]);
  });
});

describe('TeamsService#getTeamTransactionsJournal', () => {
  const repository = { findOneOrFail: jest.fn() };
  const access = { assertMember: jest.fn() };
  let service: TeamsService;

  const team = {
    id: 9,
    transactions: [
      { id: 101, date: '2026-06-01', amount: 50, type: { name: 'credit' }, note: 'Sponsoring' },
      { id: 102, date: '2026-06-15', amount: 20, type: { name: 'expense' }, note: 'Bälle' },
    ],
    players: [
      {
        id: 1,
        firstName: 'Alex',
        lastName: 'Muster',
        transactions: [
          { id: 1, date: '2026-06-10', amount: 12, type: { name: 'payment' }, note: 'Beitrag' },
          { id: 2, date: '2026-06-20', amount: 5, type: { name: 'fine' }, note: 'Zu spät' },
        ],
      },
      {
        id: 2,
        firstName: 'Bea',
        lastName: 'Beispiel',
        transactions: [
          { id: 3, date: '2026-06-05', amount: 30, type: { name: 'levy' }, note: 'Turnier-Umlage' },
        ],
      },
    ],
  };

  beforeEach(() => {
    jest.resetAllMocks();
    access.assertMember.mockResolvedValue(undefined);
    repository.findOneOrFail.mockResolvedValue(team);
    service = new TeamsService(
      repository as any,
      {} as any,
      {} as any,
      {} as any,
      {} as any,
      {} as any,
      { info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
      access as any,
      {} as any,
      {} as any,
    );
  });

  function query(overrides: Partial<Record<string, unknown>> = {}) {
    return {
      page: 1,
      limit: 25,
      sortBy: 'date',
      sortDir: 'desc',
      ...overrides,
    } as any;
  }

  it('checks membership before returning any data', async () => {
    await service.getTeamTransactionsJournal(9, 42, query());
    expect(access.assertMember).toHaveBeenCalledWith(42, 9);
  });

  it('returns the total count of all 5 bookings across both sources, unpaginated', async () => {
    const result = await service.getTeamTransactionsJournal(9, 42, query());
    expect(result.total).toBe(5);
  });

  it('paginates using page/limit and slices from the already-sorted result', async () => {
    const page1 = await service.getTeamTransactionsJournal(9, 42, query({ page: 1, limit: 2 }));
    const page2 = await service.getTeamTransactionsJournal(9, 42, query({ page: 2, limit: 2 }));

    expect(page1.data).toHaveLength(2);
    expect(page2.data).toHaveLength(2);
    expect(page1.total).toBe(5);
    expect(page2.total).toBe(5);
    // Newest first by default (date desc) - no overlap between the two pages.
    expect(page1.data.map((row) => row.id)).toEqual([2, 102]);
    expect(page2.data.map((row) => row.id)).toEqual([1, 3]);
  });

  it('sorts by amount in both directions', async () => {
    const asc = await service.getTeamTransactionsJournal(
      9,
      42,
      query({ sortBy: 'amount', sortDir: 'asc', limit: 100 }),
    );
    const desc = await service.getTeamTransactionsJournal(
      9,
      42,
      query({ sortBy: 'amount', sortDir: 'desc', limit: 100 }),
    );

    expect(asc.data.map((row) => row.amount)).toEqual([5, 12, 20, 30, 50]);
    expect(desc.data.map((row) => row.amount)).toEqual([50, 30, 20, 12, 5]);
  });

  it('filters by exact booking type', async () => {
    const result = await service.getTeamTransactionsJournal(9, 42, query({ type: 'fine' }));

    expect(result.total).toBe(1);
    expect(result.data[0].note).toBe('Zu spät');
  });

  it('filters by a case-insensitive search across player name and note', async () => {
    const byName = await service.getTeamTransactionsJournal(9, 42, query({ search: 'bea' }));
    const byNote = await service.getTeamTransactionsJournal(9, 42, query({ search: 'BÄLLE' }));

    expect(byName.total).toBe(1);
    expect(byName.data[0].playerName).toBe('Bea Beispiel');
    expect(byNote.total).toBe(1);
    expect(byNote.data[0].note).toBe('Bälle');
  });

  it('rejects when the actor is not a team member', async () => {
    access.assertMember.mockRejectedValue(new Error('forbidden'));

    await expect(service.getTeamTransactionsJournal(9, 42, query())).rejects.toThrow('forbidden');
    expect(repository.findOneOrFail).not.toHaveBeenCalled();
  });
});

describe('TeamsService#createNewTeam', () => {
  const repository = { create: jest.fn(), save: jest.fn() };
  const playerRepository = { create: jest.fn(), save: jest.fn() };
  const rolesRepository = { findOneBy: jest.fn() };
  const settingsRepository = { create: jest.fn(), save: jest.fn() };
  const usersRepository = { findOneBy: jest.fn() };
  const logger = { info: jest.fn(), debug: jest.fn(), warn: jest.fn() };
  let manager: any;
  let dataSource: any;
  let service: TeamsService;

  beforeEach(() => {
    jest.resetAllMocks();
    manager = {
      getRepository: jest.fn((entity: { name: string }) => {
        switch (entity.name) {
          case 'Team':
            return repository;
          case 'Player':
            return playerRepository;
          case 'TeamRole':
            return rolesRepository;
          case 'TeamSetting':
            return settingsRepository;
          case 'User':
            return usersRepository;
          default:
            throw new Error(`unexpected entity ${entity.name}`);
        }
      }),
    };
    dataSource = { transaction: jest.fn((work: any) => work(manager)) };
    service = new TeamsService(
      {} as any,
      {} as any,
      {} as any,
      {} as any,
      {} as any,
      {} as any,
      logger as any,
      {} as any,
      {} as any,
      dataSource as any,
    );
  });

  it('creates the team, its default settings, and a captain membership for the creator, all inside one transaction', async () => {
    const savedTeam = { id: 7, name: '1. Herren' };
    repository.create.mockReturnValue({ name: '1. Herren' });
    repository.save.mockResolvedValue(savedTeam);
    settingsRepository.create.mockImplementation((s: unknown) => s);
    settingsRepository.save.mockResolvedValue([]);
    usersRepository.findOneBy.mockResolvedValue({
      id: 42,
      firstName: 'Alex',
      lastName: 'Muster',
    });
    rolesRepository.findOneBy.mockResolvedValue({ id: 3, name: 'captain' });
    playerRepository.create.mockImplementation((p: unknown) => p);
    playerRepository.save.mockResolvedValue({});

    const result = await service.createNewTeam({ name: '1. Herren' } as any, '42');

    expect(dataSource.transaction).toHaveBeenCalledTimes(1);
    expect(result).toBe(savedTeam);
    expect(rolesRepository.findOneBy).toHaveBeenCalledWith({ id: 3 });
    expect(usersRepository.findOneBy).toHaveBeenCalledWith({ id: 42 });
    expect(playerRepository.create).toHaveBeenCalledWith(
      expect.objectContaining({
        firstName: 'Alex',
        lastName: 'Muster',
        team: savedTeam,
        teamRole: { id: 3, name: 'captain' },
        user: { id: 42, firstName: 'Alex', lastName: 'Muster' },
      }),
    );
    expect(playerRepository.save).toHaveBeenCalled();
  });

  it('does not create the team at all if the captain-membership write fails', async () => {
    const savedTeam = { id: 7, name: '1. Herren' };
    repository.create.mockReturnValue({ name: '1. Herren' });
    repository.save.mockResolvedValue(savedTeam);
    settingsRepository.create.mockImplementation((s: unknown) => s);
    settingsRepository.save.mockResolvedValue([]);
    usersRepository.findOneBy.mockResolvedValue({ id: 42, firstName: 'Alex', lastName: 'Muster' });
    rolesRepository.findOneBy.mockResolvedValue({ id: 3, name: 'captain' });
    playerRepository.create.mockImplementation((p: unknown) => p);
    playerRepository.save.mockRejectedValue(new Error('db down'));

    await expect(
      service.createNewTeam({ name: '1. Herren' } as any, '42'),
    ).rejects.toThrow('db down');

    expect(logger.info).not.toHaveBeenCalled();
  });
});
