/**
 * Unit tests for src/services/OAuth2Service.ts
 */

import crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import { OAuth2Service } from '../../../src/services/OAuth2Service';
import { TokenRepository } from '../../../src/repositories/TokenRepository';
import { CredentialRepository } from '../../../src/repositories/CredentialRepository';
import { AgentRepository } from '../../../src/repositories/AgentRepository';
import { AuditService } from '../../../src/services/AuditService';
import {
  AuthenticationError,
  AuthorizationError,
  FreeTierLimitError,
} from '../../../src/utils/errors';
import { IAgent, ICredential, ICredentialRow, ITokenPayload } from '../../../src/types/index';
import { hashSecret, generateClientSecret } from '../../../src/utils/crypto';

jest.mock('../../../src/repositories/TokenRepository');
jest.mock('../../../src/repositories/CredentialRepository');
jest.mock('../../../src/repositories/AgentRepository');
jest.mock('../../../src/services/AuditService');

const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: { type: 'spki', format: 'pem' },
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});

const MockTokenRepo = TokenRepository as jest.MockedClass<typeof TokenRepository>;
const MockCredentialRepo = CredentialRepository as jest.MockedClass<typeof CredentialRepository>;
const MockAgentRepo = AgentRepository as jest.MockedClass<typeof AgentRepository>;
const MockAuditService = AuditService as jest.MockedClass<typeof AuditService>;

const MOCK_AGENT_ID = uuidv4();
const MOCK_AGENT: IAgent = {
  agentId: MOCK_AGENT_ID,
  organizationId: 'org_system',
  email: 'agent@sentryagent.ai',
  agentType: 'screener',
  version: '1.0.0',
  capabilities: ['agents:read'],
  owner: 'team-a',
  deploymentEnv: 'production',
  status: 'active',
  isPublic: false,
  createdAt: new Date(),
  updatedAt: new Date(),
};

const IP = '127.0.0.1';
const UA = 'test/1.0';

describe('OAuth2Service', () => {
  let service: OAuth2Service;
  let tokenRepo: jest.Mocked<TokenRepository>;
  let credentialRepo: jest.Mocked<CredentialRepository>;
  let agentRepo: jest.Mocked<AgentRepository>;
  let auditService: jest.Mocked<AuditService>;

  let plainSecret: string;
  let credentialRow: ICredentialRow;

  beforeEach(async () => {
    jest.clearAllMocks();

    tokenRepo = new MockTokenRepo({} as never, {} as never) as jest.Mocked<TokenRepository>;
    credentialRepo = new MockCredentialRepo({} as never) as jest.Mocked<CredentialRepository>;
    agentRepo = new MockAgentRepo({} as never) as jest.Mocked<AgentRepository>;
    auditService = new MockAuditService({} as never) as jest.Mocked<AuditService>;

    service = new OAuth2Service(
      tokenRepo,
      credentialRepo,
      agentRepo,
      auditService,
      privateKey,
      publicKey,
    );

    plainSecret = generateClientSecret();
    const secretHash = await hashSecret(plainSecret);
    const credId = uuidv4();

    const mockCredential: ICredential = {
      credentialId: credId,
      clientId: MOCK_AGENT_ID,
      status: 'active',
      createdAt: new Date(),
      expiresAt: null,
      revokedAt: null,
    };

    credentialRow = { ...mockCredential, secretHash, vaultPath: null };

    credentialRepo.findByAgentId.mockResolvedValue({ credentials: [mockCredential], total: 1 });
    credentialRepo.findById.mockResolvedValue(credentialRow);
    auditService.logEvent.mockResolvedValue({} as never);
  });

  // ────────────────────────────────────────────────────────────────
  // issueToken
  // ────────────────────────────────────────────────────────────────
  describe('issueToken()', () => {
    beforeEach(() => {
      agentRepo.findById.mockResolvedValue(MOCK_AGENT);
      tokenRepo.getMonthlyCount.mockResolvedValue(0);
      tokenRepo.incrementMonthlyCount.mockResolvedValue(1);
    });

    it('should issue a token for valid credentials', async () => {
      const result = await service.issueToken(
        MOCK_AGENT_ID,
        plainSecret,
        'agents:read',
        IP,
        UA,
      );
      expect(result.token_type).toBe('Bearer');
      expect(result.expires_in).toBe(3600);
      expect(result.access_token).toBeTruthy();
    });

    it('should throw AuthenticationError for unknown agent', async () => {
      agentRepo.findById.mockResolvedValue(null);
      await expect(
        service.issueToken('unknown', plainSecret, 'agents:read', IP, UA),
      ).rejects.toThrow(AuthenticationError);
    });

    it('should throw AuthenticationError for wrong secret', async () => {
      await expect(
        service.issueToken(MOCK_AGENT_ID, 'wrong_secret', 'agents:read', IP, UA),
      ).rejects.toThrow(AuthenticationError);
    });

    it('should throw AuthorizationError for suspended agent', async () => {
      agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'suspended' });
      await expect(
        service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read', IP, UA),
      ).rejects.toThrow(AuthorizationError);
    });

    it('should throw AuthorizationError for decommissioned agent', async () => {
      agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'decommissioned' });
      await expect(
        service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read', IP, UA),
      ).rejects.toThrow(AuthorizationError);
    });

    it('should throw FreeTierLimitError when monthly limit reached', async () => {
      tokenRepo.getMonthlyCount.mockResolvedValue(10000);
      await expect(
        service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read', IP, UA),
      ).rejects.toThrow(FreeTierLimitError);
    });
  });

  // ────────────────────────────────────────────────────────────────
  // introspectToken
  // ────────────────────────────────────────────────────────────────
  describe('introspectToken()', () => {
    let validToken: string;
    let callerPayload: ITokenPayload;

    beforeEach(async () => {
      agentRepo.findById.mockResolvedValue(MOCK_AGENT);
      tokenRepo.getMonthlyCount.mockResolvedValue(0);
      tokenRepo.incrementMonthlyCount.mockResolvedValue(1);

      const issued = await service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read tokens:read', IP, UA);
      validToken = issued.access_token;

      const { verifyToken } = await import('../../../src/utils/jwt');
      callerPayload = verifyToken(validToken, publicKey);
    });

    it('should return active: true for a valid token', async () => {
      tokenRepo.isRevoked.mockResolvedValue(false);
      const result = await service.introspectToken(validToken, callerPayload, IP, UA);
      expect(result.active).toBe(true);
      expect(result.sub).toBe(MOCK_AGENT_ID);
    });

    it('should return active: false for a revoked token', async () => {
      tokenRepo.isRevoked.mockResolvedValue(true);
      const result = await service.introspectToken(validToken, callerPayload, IP, UA);
      expect(result.active).toBe(false);
    });

    it('should introspect successfully regardless of caller scope (tokens:read enforced by OPA middleware)', async () => {
      // Scope enforcement for tokens:read has been moved to OpaMiddleware.
      // The service introspects any token presented to it once the request has
      // passed the middleware layer.
      tokenRepo.isRevoked.mockResolvedValue(false);
      const noScopePayload = { ...callerPayload, scope: 'agents:read' };
      const result = await service.introspectToken(validToken, noScopePayload, IP, UA);
      expect(result.active).toBe(true);
    });

    it('should return active: false for an expired token', async () => {
      const result = await service.introspectToken('invalid.jwt.token', callerPayload, IP, UA);
      expect(result.active).toBe(false);
    });
  });

  // ────────────────────────────────────────────────────────────────
  // revokeToken
  // ────────────────────────────────────────────────────────────────
  describe('revokeToken()', () => {
    let validToken: string;
    let callerPayload: ITokenPayload;

    beforeEach(async () => {
      agentRepo.findById.mockResolvedValue(MOCK_AGENT);
      tokenRepo.getMonthlyCount.mockResolvedValue(0);
      tokenRepo.incrementMonthlyCount.mockResolvedValue(1);

      const issued = await service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read', IP, UA);
      validToken = issued.access_token;

      const { verifyToken } = await import('../../../src/utils/jwt');
      callerPayload = verifyToken(validToken, publicKey);

      tokenRepo.addToRevocationList.mockResolvedValue();
    });

    it('should revoke a token successfully', async () => {
      await expect(
        service.revokeToken(validToken, callerPayload, IP, UA),
      ).resolves.toBeUndefined();
      expect(tokenRepo.addToRevocationList).toHaveBeenCalled();
    });

    it('should throw AuthorizationError if revoking another agent token', async () => {
      const otherPayload = { ...callerPayload, sub: uuidv4() };
      await expect(
        service.revokeToken(validToken, otherPayload, IP, UA),
      ).rejects.toThrow(AuthorizationError);
    });

    it('should succeed silently for a malformed token (RFC 7009)', async () => {
      await expect(
        service.revokeToken('not.a.valid.token', callerPayload, IP, UA),
      ).resolves.toBeUndefined();
    });
  });
});
