import { randomBytes } from 'node:crypto';
import type { Logger } from 'nestjs-pino';
import { DownstreamTokenCache, type CachedToken } from './downstream-token-cache.service';

const KEY = randomBytes(32);
const OTHER_KEY = randomBytes(32);

interface RedisStub {
  get: jest.Mock;
  set: jest.Mock;
}

function makeRedis(opts?: { get?: jest.Mock; set?: jest.Mock }): RedisStub {
  return {
    get: opts?.get ?? jest.fn().mockResolvedValue(null),
    set: opts?.set ?? jest.fn().mockResolvedValue('OK'),
  };
}

function makeLogger() {
  return { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as Logger & {
    log: jest.Mock;
    warn: jest.Mock;
    error: jest.Mock;
  };
}

function makeCache(
  redis: RedisStub,
  key: Buffer = KEY,
): { cache: DownstreamTokenCache; logger: ReturnType<typeof makeLogger> } {
  const logger = makeLogger();
  // The service treats the injected redis client as the bare
  // ioredis surface; we only need `.get` and `.set` for these tests.
  const cache = new DownstreamTokenCache(redis as never, key, logger);
  return { cache, logger };
}

const INPUT = { actorIdHash: 'hash(jane)', resource: 'api://downstream-svc' };

describe('DownstreamTokenCache.get', () => {
  it('returns null on a true cache miss (Redis returned null)', async () => {
    const redis = makeRedis({ get: jest.fn().mockResolvedValue(null) });
    const { cache } = makeCache(redis);
    expect(await cache.get(INPUT)).toBeNull();
    expect(redis.get).toHaveBeenCalledWith('obo:hash(jane):api://downstream-svc');
  });

  it('decrypts a well-formed entry round-tripped through set()', async () => {
    // Use the cache itself to write, then read back — exercises the
    // encrypt/decrypt round-trip with the same key.
    let stored: string | null = null;
    const redis = makeRedis({
      get: jest.fn().mockImplementation(() => Promise.resolve(stored)),
      set: jest.fn().mockImplementation((_k: string, v: string) => {
        stored = v;
        return Promise.resolve('OK');
      }),
    });
    const { cache } = makeCache(redis);
    const token: CachedToken = { accessToken: 'down-token', expiresAt: Date.now() + 600_000 };
    await cache.set({ ...INPUT, token });
    const read = await cache.get(INPUT);
    expect(read).toEqual(token);
  });

  it('returns null and logs when decryption fails (tampered ciphertext)', async () => {
    let stored: string | null = null;
    const redis = makeRedis({
      get: jest.fn().mockImplementation(() => Promise.resolve(stored)),
      set: jest.fn().mockImplementation((_k: string, v: string) => {
        stored = v;
        return Promise.resolve('OK');
      }),
    });
    const { cache, logger } = makeCache(redis);
    await cache.set({
      ...INPUT,
      token: { accessToken: 'down-token', expiresAt: Date.now() + 600_000 },
    });
    // Flip the very last char of the ciphertext to break the GCM
    // auth tag — decrypt() rejects the tag verification.
    stored = stored ? `${stored.slice(0, -1)}${stored.endsWith('A') ? 'B' : 'A'}` : null;
    expect(await cache.get(INPUT)).toBeNull();
    expect(logger.warn).toHaveBeenCalledWith(
      expect.objectContaining({ event: 'downstream.obo_cache.decrypt_failed' }),
      'DownstreamTokenCache',
    );
  });

  it('returns null when the entry is decryptable but malformed JSON shape', async () => {
    // Round-trip a non-CachedToken value through encryption, then
    // ask the cache to read it — the shape guard collapses it to a
    // miss so the strategy re-acquires.
    let stored: string | null = null;
    const writerRedis = makeRedis({
      set: jest.fn().mockImplementation((_k: string, v: string) => {
        stored = v;
        return Promise.resolve('OK');
      }),
    });
    const { cache: writer } = makeCache(writerRedis);
    // Manually write a bogus shape with the same key.
    // We piggyback on the cache's encrypt path by calling set with
    // a wide token then handcraft the stored payload after.
    await writer.set({
      ...INPUT,
      token: { accessToken: 'x', expiresAt: Date.now() + 600_000 },
    });
    // Now read with a fresh cache instance pointing at the same
    // stored payload but interpreted with a STRICT shape check —
    // mock the get to return an encrypted payload of a non-CachedToken.
    // Use a separately-encrypted bogus payload by routing through
    // session-crypto directly.
    // eslint-disable-next-line @typescript-eslint/no-require-imports
    const { encrypt } = require('../session/session-crypto') as {
      encrypt: (plaintext: string, key: Buffer) => string;
    };
    stored = encrypt(JSON.stringify({ accessToken: 42, expiresAt: 'soon' }), KEY);
    const reader = new DownstreamTokenCache(
      { get: jest.fn().mockResolvedValue(stored), set: jest.fn() } as never,
      KEY,
      makeLogger(),
    );
    expect(await reader.get(INPUT)).toBeNull();
  });

  it('returns null when the value was encrypted under a different key', async () => {
    // Write with one key, read with another — `decipher.final()`
    // throws on the GCM auth-tag mismatch and the cache treats it
    // as a miss.
    let stored: string | null = null;
    const writerRedis = makeRedis({
      set: jest.fn().mockImplementation((_k: string, v: string) => {
        stored = v;
        return Promise.resolve('OK');
      }),
    });
    const { cache: writer } = makeCache(writerRedis, KEY);
    await writer.set({
      ...INPUT,
      token: { accessToken: 'down-token', expiresAt: Date.now() + 600_000 },
    });
    const reader = new DownstreamTokenCache(
      { get: jest.fn().mockResolvedValue(stored), set: jest.fn() } as never,
      OTHER_KEY,
      makeLogger(),
    );
    expect(await reader.get(INPUT)).toBeNull();
  });

  it('returns null and logs on a Redis read failure', async () => {
    const redis = makeRedis({
      get: jest.fn().mockRejectedValue(new Error('connection refused')),
    });
    const { cache, logger } = makeCache(redis);
    expect(await cache.get(INPUT)).toBeNull();
    expect(logger.warn).toHaveBeenCalledWith(
      expect.objectContaining({ event: 'downstream.obo_cache.read_failed' }),
      'DownstreamTokenCache',
    );
  });
});

describe('DownstreamTokenCache.set', () => {
  it('writes the encrypted token to Redis with a PX TTL equal to expiry minus 60 s', async () => {
    const now = 10_000_000_000;
    jest.useFakeTimers();
    jest.setSystemTime(now);
    try {
      const redis = makeRedis();
      const { cache } = makeCache(redis);
      const distinctiveToken = 'PLAINTEXT_DOWNSTREAM_TOKEN_SENTINEL';
      const token: CachedToken = {
        accessToken: distinctiveToken,
        expiresAt: now + 600_000,
      };
      await cache.set({ ...INPUT, token });
      expect(redis.set).toHaveBeenCalledWith(
        'obo:hash(jane):api://downstream-svc',
        expect.any(String),
        'PX',
        540_000, // 600s expiry - 60s buffer
      );
      // The ciphertext stored is NOT the raw access token.
      const ciphertext = redis.set.mock.calls[0]?.[1] as string;
      expect(ciphertext).not.toContain(distinctiveToken);
      expect(ciphertext.startsWith('v1.')).toBe(true);
    } finally {
      jest.useRealTimers();
    }
  });

  it('skips the write when the token expires within the safety buffer', async () => {
    const now = 10_000_000_000;
    jest.useFakeTimers();
    jest.setSystemTime(now);
    try {
      const redis = makeRedis();
      const { cache } = makeCache(redis);
      const token: CachedToken = { accessToken: 'x', expiresAt: now + 30_000 }; // 30s < 60s buffer
      await cache.set({ ...INPUT, token });
      expect(redis.set).not.toHaveBeenCalled();
    } finally {
      jest.useRealTimers();
    }
  });

  it('logs but does not throw when the Redis SET fails', async () => {
    const redis = makeRedis({
      set: jest.fn().mockRejectedValue(new Error('OOM')),
    });
    const { cache, logger } = makeCache(redis);
    await expect(
      cache.set({
        ...INPUT,
        token: { accessToken: 'x', expiresAt: Date.now() + 600_000 },
      }),
    ).resolves.toBeUndefined();
    expect(logger.warn).toHaveBeenCalledWith(
      expect.objectContaining({ event: 'downstream.obo_cache.write_failed' }),
      'DownstreamTokenCache',
    );
  });
});
