import type { AuthenticationResult, ConfidentialClientApplication } from '@azure/msal-node';
import type { Logger } from 'nestjs-pino';
import type { CachedToken, DownstreamTokenCache } from '../downstream-token-cache.service';
import { OboAcquireError, OboStrategy } from './obo.strategy';

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;
  };
}

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

function makeCache(getResult: CachedToken | null = null): CacheStub {
  return {
    get: jest.fn().mockResolvedValue(getResult),
    set: jest.fn().mockResolvedValue(undefined),
  };
}

function makeMsal(opts?: { acquireTokenOnBehalfOf?: jest.Mock }): {
  msal: ConfidentialClientApplication;
  acquire: jest.Mock;
} {
  const acquire =
    opts?.acquireTokenOnBehalfOf ??
    jest.fn().mockResolvedValue({
      accessToken: 'fresh-downstream-token',
      expiresOn: new Date(Date.now() + 3_600_000),
    } as AuthenticationResult);
  const msal = { acquireTokenOnBehalfOf: acquire } as unknown as ConfidentialClientApplication;
  return { msal, acquire };
}

function makeStrategy(opts: { cache?: CacheStub; msal?: ConfidentialClientApplication }): {
  strategy: OboStrategy;
  cache: CacheStub;
  logger: ReturnType<typeof makeLogger>;
} {
  const cache = opts.cache ?? makeCache();
  const logger = makeLogger();
  const msal = opts.msal ?? makeMsal().msal;
  const strategy = new OboStrategy(msal, cache as unknown as DownstreamTokenCache, logger);
  return { strategy, cache, logger };
}

const INPUT = {
  userAccessToken: 'user-access-token',
  actorIdHash: 'hash(jane)',
  resource: 'api://downstream-svc',
  scopes: ['api://downstream-svc/.default'],
};

describe('OboStrategy.acquire', () => {
  it('returns the cached token verbatim on a fresh cache hit (no MSAL round-trip)', async () => {
    const cached: CachedToken = {
      accessToken: 'cached-downstream-token',
      expiresAt: Date.now() + 600_000,
    };
    const cache = makeCache(cached);
    const { acquire } = makeMsal();
    const { strategy } = makeStrategy({
      cache,
      msal: { acquireTokenOnBehalfOf: acquire } as never,
    });

    await expect(strategy.acquire(INPUT)).resolves.toEqual(cached);
    expect(cache.get).toHaveBeenCalledWith({
      actorIdHash: 'hash(jane)',
      resource: 'api://downstream-svc',
    });
    expect(acquire).not.toHaveBeenCalled();
    expect(cache.set).not.toHaveBeenCalled();
  });

  it('re-acquires from MSAL when the cached token is inside the 60 s safety buffer', async () => {
    const cached: CachedToken = {
      accessToken: 'stale-cached-token',
      expiresAt: Date.now() + 30_000, // 30s < 60s buffer
    };
    const cache = makeCache(cached);
    const { msal, acquire } = makeMsal();
    const { strategy } = makeStrategy({ cache, msal });

    const result = await strategy.acquire(INPUT);

    expect(acquire).toHaveBeenCalledTimes(1);
    expect(result.accessToken).toBe('fresh-downstream-token');
    expect(cache.set).toHaveBeenCalledWith(
      expect.objectContaining({ actorIdHash: 'hash(jane)', resource: 'api://downstream-svc' }),
    );
  });

  it('calls MSAL with the OBO assertion + scopes on a cold cache, then writes the result', async () => {
    const expiry = new Date(Date.now() + 3_600_000);
    const { msal, acquire } = makeMsal({
      acquireTokenOnBehalfOf: jest.fn().mockResolvedValue({
        accessToken: 'fresh-downstream-token',
        expiresOn: expiry,
      } as AuthenticationResult),
    });
    const cache = makeCache(null);
    const { strategy } = makeStrategy({ cache, msal });

    const result = await strategy.acquire(INPUT);

    expect(acquire).toHaveBeenCalledWith({
      oboAssertion: 'user-access-token',
      scopes: ['api://downstream-svc/.default'],
    });
    expect(result).toEqual({
      accessToken: 'fresh-downstream-token',
      expiresAt: expiry.getTime(),
    });
    expect(cache.set).toHaveBeenCalledWith({
      actorIdHash: 'hash(jane)',
      resource: 'api://downstream-svc',
      token: { accessToken: 'fresh-downstream-token', expiresAt: expiry.getTime() },
    });
  });

  it('throws OboAcquireError(msal-refused) when MSAL throws', async () => {
    const error = new Error('AADSTS50013: oboAssertion is no longer valid');
    const { msal } = makeMsal({
      acquireTokenOnBehalfOf: jest.fn().mockRejectedValue(error),
    });
    const cache = makeCache(null);
    const { strategy, logger } = makeStrategy({ cache, msal });

    await expect(strategy.acquire(INPUT)).rejects.toMatchObject({
      name: 'OboAcquireError',
      reason: 'msal-refused',
      cause: error,
    });
    expect(cache.set).not.toHaveBeenCalled();
    expect(logger.warn).toHaveBeenCalledWith(
      expect.objectContaining({ event: 'downstream.obo.msal_refused' }),
      'OboStrategy',
    );
  });

  it('throws OboAcquireError(msal-no-result) when MSAL returns null', async () => {
    const { msal } = makeMsal({
      acquireTokenOnBehalfOf: jest.fn().mockResolvedValue(null),
    });
    const cache = makeCache(null);
    const { strategy } = makeStrategy({ cache, msal });

    await expect(strategy.acquire(INPUT)).rejects.toMatchObject({
      name: 'OboAcquireError',
      reason: 'msal-no-result',
    });
    expect(cache.set).not.toHaveBeenCalled();
  });

  it('throws OboAcquireError(msal-no-result) when MSAL returns an empty accessToken', async () => {
    const { msal } = makeMsal({
      acquireTokenOnBehalfOf: jest.fn().mockResolvedValue({
        accessToken: '',
        expiresOn: new Date(Date.now() + 3_600_000),
      } as AuthenticationResult),
    });
    const { strategy } = makeStrategy({ msal });
    await expect(strategy.acquire(INPUT)).rejects.toMatchObject({ reason: 'msal-no-result' });
  });

  it('throws OboAcquireError(msal-no-result) when MSAL returns a null expiresOn', async () => {
    const { msal } = makeMsal({
      acquireTokenOnBehalfOf: jest.fn().mockResolvedValue({
        accessToken: 'fresh',
        expiresOn: null,
      } as unknown as AuthenticationResult),
    });
    const { strategy } = makeStrategy({ msal });
    await expect(strategy.acquire(INPUT)).rejects.toMatchObject({ reason: 'msal-no-result' });
  });

  it('treats the cache lookup as the *only* hit signal — empty cached resource string still bypasses MSAL when fresh', async () => {
    // Edge case: an empty `resource` parameter is technically legal
    // for the strategy's contract (the cache key is just the
    // concatenated string). The cache returns a fresh entry, and
    // MSAL must NOT be called. Pins the contract: the strategy
    // never second-guesses what the framework asks for.
    const fresh: CachedToken = {
      accessToken: 'cached',
      expiresAt: Date.now() + 600_000,
    };
    const cache = makeCache(fresh);
    const { msal, acquire } = makeMsal();
    const { strategy } = makeStrategy({ cache, msal });
    await strategy.acquire({ ...INPUT, resource: '' });
    expect(acquire).not.toHaveBeenCalled();
  });
});

describe('OboAcquireError', () => {
  it('carries the reason discriminator + the original cause for the future audit emission', () => {
    const cause = new Error('underlying');
    const err = new OboAcquireError('msal-refused', cause);
    expect(err.reason).toBe('msal-refused');
    expect(err.cause).toBe(cause);
    expect(err.name).toBe('OboAcquireError');
  });
});
