import { OAuthSequelizeAccessTokenRepository } from '@app/o-auth/access-token';
import { OAuthSequelizeCredentialRepository } from '@app/o-auth/credential';
import { OAuthSequelizeRefreshTokenRepository } from '@app/o-auth/refresh-token';
import { ConfigService } from '@nestjs/config';
import { getConnectionToken } from '@nestjs/sequelize';
import { Test, TestingModule } from '@nestjs/testing';
import { Op } from 'sequelize';
import { TokenRetentionService } from './token-retention.service';

describe('TokenRetentionService', () => {
  let service: TokenRetentionService;
  let accessTokenRepo: { repository: { destroy: jest.Mock } };
  let refreshTokenRepo: { repository: { destroy: jest.Mock } };
  let credentialRepo: { repository: { destroy: jest.Mock } };
  let sequelize: { transaction: jest.Mock; query: jest.Mock };

  const buildModule = async (
    env: Record<string, string | number> = {},
    options: { lockAcquired?: boolean } = {},
  ): Promise<void> => {
    const lockAcquired = options.lockAcquired !== false;

    accessTokenRepo = { repository: { destroy: jest.fn().mockResolvedValue(3) } };
    refreshTokenRepo = { repository: { destroy: jest.fn().mockResolvedValue(5) } };
    credentialRepo = { repository: { destroy: jest.fn().mockResolvedValue(12) } };

    sequelize = {
      transaction: jest.fn().mockImplementation(async (cb) => cb('SENTINEL_TX')),
      query: jest.fn().mockResolvedValue([{ acquired: lockAcquired }]),
    };

    const config = {
      get: jest.fn((key: string, fallback?: unknown) =>
        env[key] !== undefined ? env[key] : fallback,
      ),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        TokenRetentionService,
        { provide: ConfigService, useValue: config },
        { provide: getConnectionToken(), useValue: sequelize },
        { provide: OAuthSequelizeAccessTokenRepository, useValue: accessTokenRepo },
        { provide: OAuthSequelizeRefreshTokenRepository, useValue: refreshTokenRepo },
        { provide: OAuthSequelizeCredentialRepository, useValue: credentialRepo },
      ],
    }).compile();

    service = module.get(TokenRetentionService);
  };

  it('purges all three tables with default cutoffs (7d / 7d / 90d) when lock is acquired', async () => {
    await buildModule();
    const before = Date.now();

    const result = await service.purge();

    expect(result).toEqual({ access: 3, refresh: 5, credential: 12 });
    expect(sequelize.transaction).toHaveBeenCalledTimes(1);
    expect(sequelize.query).toHaveBeenCalledWith(
      expect.stringContaining('pg_try_advisory_xact_lock'),
      expect.objectContaining({
        replacements: { lockId: expect.any(Number) },
        transaction: 'SENTINEL_TX',
      }),
    );
    expect(accessTokenRepo.repository.destroy).toHaveBeenCalledTimes(1);
    expect(refreshTokenRepo.repository.destroy).toHaveBeenCalledTimes(1);
    expect(credentialRepo.repository.destroy).toHaveBeenCalledTimes(1);

    const accessCall = accessTokenRepo.repository.destroy.mock.calls[0][0] as {
      where: { expiresAt: Record<symbol, Date> };
      force: boolean;
      transaction: string;
    };
    const accessCutoff = accessCall.where.expiresAt[Op.lt];
    expect(accessCutoff).toBeInstanceOf(Date);
    expect(accessCutoff.getTime()).toBeGreaterThanOrEqual(before - 7 * 24 * 60 * 60 * 1000 - 5);
    expect(accessCutoff.getTime()).toBeLessThanOrEqual(Date.now() - 7 * 24 * 60 * 60 * 1000 + 5);
    expect(accessCall.force).toBe(true);
    expect(accessCall.transaction).toBe('SENTINEL_TX');
  });

  it('skips purge when another instance holds the advisory lock', async () => {
    await buildModule({}, { lockAcquired: false });

    const result = await service.purge();

    expect(result).toBeNull();
    expect(sequelize.transaction).toHaveBeenCalledTimes(1);
    expect(sequelize.query).toHaveBeenCalledTimes(1);
    expect(accessTokenRepo.repository.destroy).not.toHaveBeenCalled();
    expect(refreshTokenRepo.repository.destroy).not.toHaveBeenCalled();
    expect(credentialRepo.repository.destroy).not.toHaveBeenCalled();
  });

  it('respects custom retention days from env vars', async () => {
    await buildModule({
      OAUTH_ACCESS_TOKEN_RETENTION_DAYS: 30,
      OAUTH_REFRESH_TOKEN_RETENTION_DAYS: 60,
      OAUTH_CREDENTIAL_RETENTION_DAYS: 365,
    });
    const before = Date.now();

    await service.purge();

    const accessCutoff = (
      accessTokenRepo.repository.destroy.mock.calls[0][0] as {
        where: { expiresAt: Record<symbol, Date> };
      }
    ).where.expiresAt[Op.lt];
    const refreshCutoff = (
      refreshTokenRepo.repository.destroy.mock.calls[0][0] as {
        where: { expiresAt: Record<symbol, Date> };
      }
    ).where.expiresAt[Op.lt];
    const credentialCutoff = (
      credentialRepo.repository.destroy.mock.calls[0][0] as {
        where: { createdAt: Record<symbol, Date> };
      }
    ).where.createdAt[Op.lt];

    expect(accessCutoff.getTime()).toBeCloseTo(before - 30 * 24 * 60 * 60 * 1000, -3);
    expect(refreshCutoff.getTime()).toBeCloseTo(before - 60 * 24 * 60 * 60 * 1000, -3);
    expect(credentialCutoff.getTime()).toBeCloseTo(before - 365 * 24 * 60 * 60 * 1000, -3);
  });

  it('uses `expiresAt` for tokens and `createdAt` for credentials', async () => {
    await buildModule();

    await service.purge();

    expect(
      (accessTokenRepo.repository.destroy.mock.calls[0][0] as { where: object }).where,
    ).toHaveProperty('expiresAt');
    expect(
      (refreshTokenRepo.repository.destroy.mock.calls[0][0] as { where: object }).where,
    ).toHaveProperty('expiresAt');
    expect(
      (credentialRepo.repository.destroy.mock.calls[0][0] as { where: object }).where,
    ).toHaveProperty('createdAt');
  });
});
