/**
 * Credential Management Service for SentryAgent.ai AgentIdP.
 * Business logic for generating, listing, rotating, and revoking credentials.
 *
 * All writes to `secret_hash` and `vault_path` are encrypted via EncryptionService
 * (AES-256-CBC, key stored in Vault) before being persisted to PostgreSQL.
 * All reads of those fields are decrypted before use.
 * The `isEncrypted()` guard supports backward-compat with pre-encryption rows.
 */

import { CredentialRepository } from '../repositories/CredentialRepository.js';
import { AgentRepository } from '../repositories/AgentRepository.js';
import { AuditService } from './AuditService.js';
import { VaultClient } from '../vault/VaultClient.js';
import { EventPublisher } from './EventPublisher.js';
import { EncryptionService } from './EncryptionService.js';
import {
  ICredential,
  ICredentialWithSecret,
  ICredentialListFilters,
  IPaginatedCredentialsResponse,
  IGenerateCredentialRequest,
} from '../types/index.js';
import {
  AgentNotFoundError,
  CredentialNotFoundError,
  CredentialAlreadyRevokedError,
  CredentialError,
} from '../utils/errors.js';
import { generateClientSecret, hashSecret } from '../utils/crypto.js';
import { v4 as uuidv4 } from 'uuid';

/**
 * Service for credential lifecycle management.
 * The plain-text clientSecret is only returned on generation and rotation.
 */
export class CredentialService {
  /**
   * @param credentialRepository - The credential data repository.
   * @param agentRepository - The agent repository (for status checks).
   * @param auditService - The audit log service.
   * @param vaultClient - Optional VaultClient. When provided, new credentials are stored in Vault.
   *                      When null, bcrypt is used (Phase 1 behaviour).
   * @param eventPublisher - Optional EventPublisher. When provided, credential events are
   *                         published as webhooks and Kafka messages (fire-and-forget).
   * @param encryptionService - Optional EncryptionService. When provided, sensitive column values
   *                            are encrypted before write and decrypted after read (SOC 2 CC6.1).
   *                            When null, values are stored as-is (backward-compat mode).
   */
  constructor(
    private readonly credentialRepository: CredentialRepository,
    private readonly agentRepository: AgentRepository,
    private readonly auditService: AuditService,
    private readonly vaultClient: VaultClient | null = null,
    private readonly eventPublisher: EventPublisher | null = null,
    private readonly encryptionService: EncryptionService | null = null,
  ) {}

  // ──────────────────────────────────────────────────────────────────────────
  // Encryption helpers
  // ──────────────────────────────────────────────────────────────────────────

  /**
   * Encrypts a column value if EncryptionService is available; otherwise returns the value as-is.
   *
   * @param value - The plaintext column value.
   * @returns The encrypted value, or the original value if encryption is not configured.
   */
  private async maybeEncrypt(value: string): Promise<string> {
    if (this.encryptionService === null) return value;
    return this.encryptionService.encryptColumn(value);
  }


  /**
   * Generates a new client credential for an agent.
   * The agent must be in 'active' status.
   * Returns the plain-text clientSecret once — it is never retrievable again.
   *
   * @param agentId - The agent UUID.
   * @param data - Optional expiry date for the credential.
   * @param ipAddress - Client IP for audit logging.
   * @param userAgent - Client User-Agent for audit logging.
   * @returns The credential with the one-time plain-text clientSecret.
   * @throws AgentNotFoundError if the agent does not exist.
   * @throws CredentialError if the agent is not in 'active' status.
   */
  async generateCredential(
    agentId: string,
    data: IGenerateCredentialRequest,
    ipAddress: string,
    userAgent: string,
  ): Promise<ICredentialWithSecret> {
    const agent = await this.agentRepository.findById(agentId);
    if (!agent) {
      throw new AgentNotFoundError(agentId);
    }

    if (agent.status !== 'active') {
      throw new CredentialError(
        'Credentials can only be generated for active agents.',
        'AGENT_NOT_ACTIVE',
        { agentId, status: agent.status },
      );
    }

    const expiresAt = data.expiresAt !== undefined ? new Date(data.expiresAt) : null;
    const plainSecret = generateClientSecret();

    let credential: ICredential;

    if (this.vaultClient !== null) {
      // Phase 2: generate the UUID first so the Vault path includes the real credentialId
      const credentialId = uuidv4();
      const vaultPath = await this.vaultClient.writeSecret(agentId, credentialId, plainSecret);
      const encryptedVaultPath = await this.maybeEncrypt(vaultPath);
      credential = await this.credentialRepository.createWithVaultPath(
        credentialId,
        agentId,
        encryptedVaultPath,
        expiresAt,
      );
    } else {
      // Phase 1: bcrypt hash stored in PostgreSQL
      const secretHash = await hashSecret(plainSecret);
      const encryptedHash = await this.maybeEncrypt(secretHash);
      credential = await this.credentialRepository.create(agentId, encryptedHash, expiresAt);
    }

    await this.auditService.logEvent(
      agentId,
      'credential.generated',
      'success',
      ipAddress,
      userAgent,
      { credentialId: credential.credentialId },
    );

    // Publish event (fire-and-forget)
    void this.eventPublisher?.publishEvent(
      agent.organizationId,
      'credential.generated',
      { credentialId: credential.credentialId, agentId },
    );

    return { ...credential, clientSecret: plainSecret };
  }

  /**
   * Returns a paginated list of credentials for an agent.
   * The clientSecret is never included in list responses.
   *
   * @param agentId - The agent UUID.
   * @param filters - Pagination and optional status filter.
   * @returns Paginated credentials response.
   * @throws AgentNotFoundError if the agent does not exist.
   */
  async listCredentials(
    agentId: string,
    filters: ICredentialListFilters,
  ): Promise<IPaginatedCredentialsResponse> {
    const agent = await this.agentRepository.findById(agentId);
    if (!agent) {
      throw new AgentNotFoundError(agentId);
    }

    const { credentials, total } = await this.credentialRepository.findByAgentId(
      agentId,
      filters,
    );

    return {
      data: credentials,
      total,
      page: filters.page,
      limit: filters.limit,
    };
  }

  /**
   * Rotates a credential by generating a new secret for the same credentialId.
   * Only 'active' credentials can be rotated.
   * Returns the new plain-text clientSecret once.
   *
   * @param agentId - The agent UUID.
   * @param credentialId - The credential UUID to rotate.
   * @param data - Optional new expiry date.
   * @param ipAddress - Client IP for audit logging.
   * @param userAgent - Client User-Agent for audit logging.
   * @returns The updated credential with the new one-time clientSecret.
   * @throws AgentNotFoundError if the agent does not exist.
   * @throws CredentialNotFoundError if the credential does not exist.
   * @throws CredentialAlreadyRevokedError if the credential is already revoked.
   */
  async rotateCredential(
    agentId: string,
    credentialId: string,
    data: IGenerateCredentialRequest,
    ipAddress: string,
    userAgent: string,
  ): Promise<ICredentialWithSecret> {
    const agent = await this.agentRepository.findById(agentId);
    if (!agent) {
      throw new AgentNotFoundError(agentId);
    }

    const existing = await this.credentialRepository.findById(credentialId);
    if (!existing || existing.clientId !== agentId) {
      throw new CredentialNotFoundError(credentialId);
    }

    if (existing.status === 'revoked') {
      throw new CredentialAlreadyRevokedError(
        credentialId,
        existing.revokedAt?.toISOString() ?? new Date().toISOString(),
      );
    }

    const expiresAt = data.expiresAt !== undefined ? new Date(data.expiresAt) : null;
    const plainSecret = generateClientSecret();

    let updated: ICredential | null;

    if (this.vaultClient !== null) {
      // Phase 2: overwrite the existing Vault secret (KV v2 creates a new version)
      const vaultPath = await this.vaultClient.writeSecret(agentId, credentialId, plainSecret);
      const encryptedVaultPath = await this.maybeEncrypt(vaultPath);
      updated = await this.credentialRepository.updateVaultPath(credentialId, encryptedVaultPath, expiresAt);
    } else {
      // Phase 1 / migrating credential: use bcrypt
      const newHash = await hashSecret(plainSecret);
      const encryptedHash = await this.maybeEncrypt(newHash);
      updated = await this.credentialRepository.updateHash(credentialId, encryptedHash, expiresAt);
    }

    if (!updated) {
      throw new CredentialNotFoundError(credentialId);
    }

    await this.auditService.logEvent(
      agentId,
      'credential.rotated',
      'success',
      ipAddress,
      userAgent,
      { credentialId },
    );

    // Publish event (fire-and-forget)
    void this.eventPublisher?.publishEvent(
      agent.organizationId,
      'credential.rotated',
      { credentialId, agentId },
    );

    return { ...updated, clientSecret: plainSecret };
  }

  /**
   * Permanently revokes a credential.
   * Revoking an already-revoked credential returns 409 Conflict.
   *
   * @param agentId - The agent UUID.
   * @param credentialId - The credential UUID to revoke.
   * @param ipAddress - Client IP for audit logging.
   * @param userAgent - Client User-Agent for audit logging.
   * @throws AgentNotFoundError if the agent does not exist.
   * @throws CredentialNotFoundError if the credential does not exist or belongs to another agent.
   * @throws CredentialAlreadyRevokedError if the credential is already revoked.
   */
  async revokeCredential(
    agentId: string,
    credentialId: string,
    ipAddress: string,
    userAgent: string,
  ): Promise<void> {
    const agent = await this.agentRepository.findById(agentId);
    if (!agent) {
      throw new AgentNotFoundError(agentId);
    }

    const existing = await this.credentialRepository.findById(credentialId);
    if (!existing || existing.clientId !== agentId) {
      throw new CredentialNotFoundError(credentialId);
    }

    if (existing.status === 'revoked') {
      throw new CredentialAlreadyRevokedError(
        credentialId,
        existing.revokedAt?.toISOString() ?? new Date().toISOString(),
      );
    }

    await this.credentialRepository.revoke(credentialId);

    // Phase 2: permanently delete the secret from Vault
    // vault_path may be encrypted — decrypt before use if needed
    if (this.vaultClient !== null && existing.vaultPath !== null) {
      await this.vaultClient.deleteSecret(agentId, credentialId);
    }

    await this.auditService.logEvent(
      agentId,
      'credential.revoked',
      'success',
      ipAddress,
      userAgent,
      { credentialId },
    );

    // Publish event (fire-and-forget)
    void this.eventPublisher?.publishEvent(
      agent.organizationId,
      'credential.revoked',
      { credentialId, agentId },
    );
  }
}
