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

import { v4 as uuidv4 } from 'uuid';
import { CredentialService } from '../../../src/services/CredentialService';
import { CredentialRepository } from '../../../src/repositories/CredentialRepository';
import { AgentRepository } from '../../../src/repositories/AgentRepository';
import { AuditService } from '../../../src/services/AuditService';
import { VaultClient } from '../../../src/vault/VaultClient';
import {
  AgentNotFoundError,
  CredentialNotFoundError,
  CredentialAlreadyRevokedError,
  CredentialError,
} from '../../../src/utils/errors';
import { IAgent, ICredential, ICredentialRow } from '../../../src/types/index';

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

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 MockVaultClient = VaultClient as jest.MockedClass<typeof VaultClient>;

const AGENT_ID = uuidv4();
const CREDENTIAL_ID = uuidv4();

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

const MOCK_CREDENTIAL: ICredential = {
  credentialId: CREDENTIAL_ID,
  clientId: AGENT_ID,
  status: 'active',
  createdAt: new Date(),
  expiresAt: null,
  revokedAt: null,
};

const MOCK_CREDENTIAL_ROW: ICredentialRow = {
  ...MOCK_CREDENTIAL,
  secretHash: '$2b$10$somehashvalue',
  vaultPath: null,
};

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

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

  beforeEach(() => {
    jest.clearAllMocks();
    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 CredentialService(credentialRepo, agentRepo, auditService);
    auditService.logEvent.mockResolvedValue({} as never);
  });

  // ────────────────────────────────────────────────────────────────
  // generateCredential
  // ────────────────────────────────────────────────────────────────
  describe('generateCredential()', () => {
    it('should generate and return a credential with a one-time secret', async () => {
      agentRepo.findById.mockResolvedValue(MOCK_AGENT);
      credentialRepo.create.mockResolvedValue(MOCK_CREDENTIAL);

      const result = await service.generateCredential(AGENT_ID, {}, IP, UA);
      expect(result.credentialId).toBe(CREDENTIAL_ID);
      expect(result.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/);
    });

    it('should throw AgentNotFoundError for unknown agent', async () => {
      agentRepo.findById.mockResolvedValue(null);
      await expect(service.generateCredential('unknown', {}, IP, UA)).rejects.toThrow(
        AgentNotFoundError,
      );
    });

    it('should throw CredentialError for suspended agent', async () => {
      agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'suspended' });
      await expect(service.generateCredential(AGENT_ID, {}, IP, UA)).rejects.toThrow(
        CredentialError,
      );
    });

    it('should throw CredentialError for decommissioned agent', async () => {
      agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'decommissioned' });
      await expect(service.generateCredential(AGENT_ID, {}, IP, UA)).rejects.toThrow(
        CredentialError,
      );
    });
  });

  // ────────────────────────────────────────────────────────────────
  // listCredentials
  // ────────────────────────────────────────────────────────────────
  describe('listCredentials()', () => {
    it('should return a paginated list', async () => {
      agentRepo.findById.mockResolvedValue(MOCK_AGENT);
      credentialRepo.findByAgentId.mockResolvedValue({
        credentials: [MOCK_CREDENTIAL],
        total: 1,
      });

      const result = await service.listCredentials(AGENT_ID, { page: 1, limit: 20 });
      expect(result.data).toHaveLength(1);
      expect(result.total).toBe(1);
    });

    it('should throw AgentNotFoundError for unknown agent', async () => {
      agentRepo.findById.mockResolvedValue(null);
      await expect(
        service.listCredentials('unknown', { page: 1, limit: 20 }),
      ).rejects.toThrow(AgentNotFoundError);
    });
  });

  // ────────────────────────────────────────────────────────────────
  // rotateCredential
  // ────────────────────────────────────────────────────────────────
  describe('rotateCredential()', () => {
    it('should rotate and return a new secret', async () => {
      agentRepo.findById.mockResolvedValue(MOCK_AGENT);
      credentialRepo.findById.mockResolvedValue(MOCK_CREDENTIAL_ROW);
      credentialRepo.updateHash.mockResolvedValue(MOCK_CREDENTIAL);

      const result = await service.rotateCredential(AGENT_ID, CREDENTIAL_ID, {}, IP, UA);
      expect(result.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/);
    });

    it('should throw AgentNotFoundError for unknown agent', async () => {
      agentRepo.findById.mockResolvedValue(null);
      await expect(
        service.rotateCredential('unknown', CREDENTIAL_ID, {}, IP, UA),
      ).rejects.toThrow(AgentNotFoundError);
    });

    it('should throw CredentialNotFoundError for unknown credential', async () => {
      agentRepo.findById.mockResolvedValue(MOCK_AGENT);
      credentialRepo.findById.mockResolvedValue(null);
      await expect(
        service.rotateCredential(AGENT_ID, 'unknown', {}, IP, UA),
      ).rejects.toThrow(CredentialNotFoundError);
    });

    it('should throw CredentialAlreadyRevokedError for revoked credential', async () => {
      agentRepo.findById.mockResolvedValue(MOCK_AGENT);
      credentialRepo.findById.mockResolvedValue({
        ...MOCK_CREDENTIAL_ROW,
        status: 'revoked',
        revokedAt: new Date(),
      });
      await expect(
        service.rotateCredential(AGENT_ID, CREDENTIAL_ID, {}, IP, UA),
      ).rejects.toThrow(CredentialAlreadyRevokedError);
    });
  });

  // ────────────────────────────────────────────────────────────────
  // revokeCredential
  // ────────────────────────────────────────────────────────────────
  describe('revokeCredential()', () => {
    it('should revoke the credential', async () => {
      agentRepo.findById.mockResolvedValue(MOCK_AGENT);
      credentialRepo.findById.mockResolvedValue(MOCK_CREDENTIAL_ROW);
      credentialRepo.revoke.mockResolvedValue({ ...MOCK_CREDENTIAL, status: 'revoked', revokedAt: new Date() });

      await expect(
        service.revokeCredential(AGENT_ID, CREDENTIAL_ID, IP, UA),
      ).resolves.toBeUndefined();
    });

    it('should throw AgentNotFoundError for unknown agent', async () => {
      agentRepo.findById.mockResolvedValue(null);
      await expect(
        service.revokeCredential('unknown', CREDENTIAL_ID, IP, UA),
      ).rejects.toThrow(AgentNotFoundError);
    });

    it('should throw CredentialAlreadyRevokedError for already-revoked credential', async () => {
      agentRepo.findById.mockResolvedValue(MOCK_AGENT);
      credentialRepo.findById.mockResolvedValue({
        ...MOCK_CREDENTIAL_ROW,
        status: 'revoked',
        revokedAt: new Date(),
      });
      await expect(
        service.revokeCredential(AGENT_ID, CREDENTIAL_ID, IP, UA),
      ).rejects.toThrow(CredentialAlreadyRevokedError);
    });
  });
});

// ─── Vault-path tests ──────────────────────────────────────────────────────

describe('CredentialService — Vault path (Phase 2)', () => {
  let service: CredentialService;
  let credentialRepo: jest.Mocked<CredentialRepository>;
  let agentRepo: jest.Mocked<AgentRepository>;
  let auditService: jest.Mocked<AuditService>;
  let vaultClient: jest.Mocked<VaultClient>;

  const VAULT_PATH = `secret/data/agentidp/agents/${AGENT_ID}/credentials/${CREDENTIAL_ID}`;

  const MOCK_VAULT_CREDENTIAL_ROW: ICredentialRow = {
    ...MOCK_CREDENTIAL,
    secretHash: '',
    vaultPath: VAULT_PATH,
  };

  beforeEach(() => {
    jest.clearAllMocks();
    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>;
    vaultClient = new MockVaultClient('http://localhost:8200', 'token') as jest.Mocked<VaultClient>;
    service = new CredentialService(credentialRepo, agentRepo, auditService, vaultClient);
    auditService.logEvent.mockResolvedValue({} as never);
  });

  describe('generateCredential() with Vault', () => {
    it('writes secret to Vault and stores the vault_path in the DB', async () => {
      agentRepo.findById.mockResolvedValue(MOCK_AGENT);
      vaultClient.writeSecret.mockResolvedValue(VAULT_PATH);
      credentialRepo.createWithVaultPath.mockResolvedValue(MOCK_CREDENTIAL);

      const result = await service.generateCredential(AGENT_ID, {}, IP, UA);

      expect(vaultClient.writeSecret).toHaveBeenCalledWith(
        AGENT_ID,
        expect.any(String),
        expect.any(String),
      );
      expect(credentialRepo.createWithVaultPath).toHaveBeenCalled();
      expect(credentialRepo.create).not.toHaveBeenCalled();
      expect(result.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/);
    });
  });

  describe('rotateCredential() with Vault', () => {
    it('writes new Vault version and updates vault_path in the DB', async () => {
      agentRepo.findById.mockResolvedValue(MOCK_AGENT);
      credentialRepo.findById.mockResolvedValue(MOCK_VAULT_CREDENTIAL_ROW);
      vaultClient.writeSecret.mockResolvedValue(VAULT_PATH);
      credentialRepo.updateVaultPath.mockResolvedValue(MOCK_CREDENTIAL);

      const result = await service.rotateCredential(AGENT_ID, CREDENTIAL_ID, {}, IP, UA);

      expect(vaultClient.writeSecret).toHaveBeenCalledWith(
        AGENT_ID,
        CREDENTIAL_ID,
        expect.any(String),
      );
      expect(credentialRepo.updateVaultPath).toHaveBeenCalled();
      expect(credentialRepo.updateHash).not.toHaveBeenCalled();
      expect(result.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/);
    });
  });

  describe('revokeCredential() with Vault', () => {
    it('revokes DB record and deletes Vault secret', async () => {
      agentRepo.findById.mockResolvedValue(MOCK_AGENT);
      credentialRepo.findById.mockResolvedValue(MOCK_VAULT_CREDENTIAL_ROW);
      credentialRepo.revoke.mockResolvedValue({ ...MOCK_CREDENTIAL, status: 'revoked', revokedAt: new Date() });
      vaultClient.deleteSecret.mockResolvedValue();

      await service.revokeCredential(AGENT_ID, CREDENTIAL_ID, IP, UA);

      expect(credentialRepo.revoke).toHaveBeenCalledWith(CREDENTIAL_ID);
      expect(vaultClient.deleteSecret).toHaveBeenCalledWith(AGENT_ID, CREDENTIAL_ID);
    });

    it('does not call Vault delete when credential has no vault_path (bcrypt credential)', async () => {
      agentRepo.findById.mockResolvedValue(MOCK_AGENT);
      credentialRepo.findById.mockResolvedValue(MOCK_CREDENTIAL_ROW); // vaultPath: null
      credentialRepo.revoke.mockResolvedValue({ ...MOCK_CREDENTIAL, status: 'revoked', revokedAt: new Date() });

      await service.revokeCredential(AGENT_ID, CREDENTIAL_ID, IP, UA);

      expect(vaultClient.deleteSecret).not.toHaveBeenCalled();
    });
  });
});
