/**
 * WebhookService — manages webhook subscriptions and delivery history.
 *
 * HMAC signing secrets are stored in Vault when available (vault_secret_path holds the KV path).
 * In local mode (no Vault) the secret is bcrypt-hashed and stored in secret_hash, and
 * vault_secret_path is set to the sentinel value 'local'. The raw secret is never persisted
 * in PostgreSQL and is only returned once at subscription creation time.
 *
 * SOC 2 CC6.1: vault_secret_path is encrypted at rest via EncryptionService (AES-256-CBC)
 * before being written to PostgreSQL, and decrypted on read when Vault path retrieval is needed.
 */

import { Pool } from 'pg';
import { RedisClientType } from 'redis';
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
import { VaultClient } from '../vault/VaultClient.js';
import { EncryptionService } from './EncryptionService.js';
import { SentryAgentError } from '../utils/errors.js';
import {
  IWebhookSubscription,
  ICreateWebhookRequest,
  IUpdateWebhookRequest,
  IWebhookDelivery,
  IPaginatedDeliveriesResponse,
  WebhookEventType,
} from '../types/webhook.js';

// ============================================================================
// Custom errors
// ============================================================================

/** 404 — Referenced webhook subscription was not found (or belongs to another org). */
export class WebhookNotFoundError extends SentryAgentError {
  constructor(subscriptionId?: string) {
    super(
      'Webhook subscription with the specified ID was not found.',
      'WEBHOOK_NOT_FOUND',
      404,
      subscriptionId ? { subscriptionId } : undefined,
    );
  }
}

/** 400 — Webhook subscription request failed validation. */
export class WebhookValidationError extends SentryAgentError {
  constructor(message: string, details?: Record<string, unknown>) {
    super(message, 'WEBHOOK_VALIDATION_ERROR', 400, details);
  }
}

// ============================================================================
// Internal DB row shape
// ============================================================================

/** Internal representation of a webhook_subscriptions row. */
interface WebhookSubscriptionRow {
  id: string;
  organization_id: string;
  name: string;
  url: string;
  events: WebhookEventType[];
  secret_hash: string;
  vault_secret_path: string;
  active: boolean;
  failure_count: number;
  created_at: Date;
  updated_at: Date;
}

/** Internal representation of a webhook_deliveries row. */
interface WebhookDeliveryRow {
  id: string;
  subscription_id: string;
  event_type: string;
  payload: Record<string, unknown>;
  status: 'pending' | 'delivered' | 'failed' | 'dead_letter';
  http_status_code: number | null;
  attempt_count: number;
  next_retry_at: Date | null;
  delivered_at: Date | null;
  created_at: Date;
  updated_at: Date;
}

// ============================================================================
// Mappers
// ============================================================================

/**
 * Maps a raw DB row to a public IWebhookSubscription (never includes secret fields).
 */
function mapRowToSubscription(row: WebhookSubscriptionRow): IWebhookSubscription {
  return {
    id: row.id,
    organization_id: row.organization_id,
    name: row.name,
    url: row.url,
    events: row.events,
    active: row.active,
    failure_count: row.failure_count,
    created_at: row.created_at,
    updated_at: row.updated_at,
  };
}

/**
 * Maps a raw DB row to a public IWebhookDelivery.
 */
function mapRowToDelivery(row: WebhookDeliveryRow): IWebhookDelivery {
  return {
    id: row.id,
    subscription_id: row.subscription_id,
    event_type: row.event_type as WebhookEventType,
    payload: row.payload,
    status: row.status,
    http_status_code: row.http_status_code,
    attempt_count: row.attempt_count,
    next_retry_at: row.next_retry_at,
    delivered_at: row.delivered_at,
    created_at: row.created_at,
    updated_at: row.updated_at,
  };
}

// ============================================================================
// Service
// ============================================================================

/**
 * Service for managing webhook subscriptions and delivery audit records.
 * Coordinates PostgreSQL persistence and optional Vault secret storage.
 */
export class WebhookService {
  /**
   * @param pool - PostgreSQL connection pool.
   * @param vaultClient - Optional VaultClient. When provided, HMAC secrets are stored in Vault.
   * @param redis - Redis client (reserved for future caching needs).
   * @param encryptionService - Optional EncryptionService. When provided, vault_secret_path
   *                            is encrypted before write and decrypted before use (SOC 2 CC6.1).
   */
  constructor(
    private readonly pool: Pool,
    private readonly vaultClient: VaultClient | null,
    _redis: RedisClientType, // reserved for future subscription caching
    private readonly encryptionService: EncryptionService | null = null,
  ) {}

  // ──────────────────────────────────────────────────────────────────────────
  // Subscriptions
  // ──────────────────────────────────────────────────────────────────────────

  /**
   * Creates a new webhook subscription for the given organization.
   *
   * A 32-byte random HMAC signing secret is generated. When Vault is configured the
   * secret is stored in Vault KV v2 at `secret/data/agentidp/webhooks/{orgId}/{id}/secret`
   * and `vault_secret_path` is set to that path. In local mode the secret is bcrypt-hashed
   * and stored in `secret_hash`; the raw secret is returned from this method and must be
   * delivered to the caller immediately — it cannot be retrieved again.
   *
   * @param orgId - The organization UUID that owns this subscription.
   * @param req - The creation request (name, url, events).
   * @returns The created IWebhookSubscription plus the one-time `secret` field on the raw object.
   * @throws WebhookValidationError if the URL is not https:// or events array is empty.
   */
  async createSubscription(
    orgId: string,
    req: ICreateWebhookRequest,
  ): Promise<IWebhookSubscription & { secret: string }> {
    this.validateUrl(req.url);
    this.validateEvents(req.events);

    const secret = crypto.randomBytes(32).toString('hex');
    const subscriptionId = crypto.randomUUID();

    let secretHash: string;
    let vaultSecretPath: string;

    if (this.vaultClient !== null) {
      // Store raw secret in Vault under a webhook-specific path
      const vaultPath = `secret/data/agentidp/webhooks/${orgId}/${subscriptionId}/secret`;
      await this.storeWebhookSecretInVault(vaultPath, secret);
      secretHash = 'vault';
      // Encrypt the vault path before persisting (SOC 2 CC6.1)
      vaultSecretPath =
        this.encryptionService !== null
          ? await this.encryptionService.encryptColumn(vaultPath)
          : vaultPath;
    } else {
      // Local mode: bcrypt-hash the secret; raw secret cannot be recovered later
      secretHash = await bcrypt.hash(secret, 10);
      vaultSecretPath = 'local';
    }

    const result = await this.pool.query<WebhookSubscriptionRow>(
      `INSERT INTO webhook_subscriptions
         (id, organization_id, name, url, events, secret_hash, vault_secret_path, active, failure_count)
       VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, true, 0)
       RETURNING *`,
      [
        subscriptionId,
        orgId,
        req.name,
        req.url,
        JSON.stringify(req.events),
        secretHash,
        vaultSecretPath,
      ],
    );

    const row = result.rows[0];
    return { ...mapRowToSubscription(row), secret };
  }

  /**
   * Retrieves the raw HMAC signing secret for a subscription (Vault mode only).
   *
   * In local mode the secret was only available at creation time and cannot be recovered.
   *
   * @param subscriptionId - The subscription UUID.
   * @param orgId - The organization UUID (ownership verification).
   * @returns The raw HMAC signing secret.
   * @throws WebhookNotFoundError if the subscription does not exist or belongs to another org.
   * @throws WebhookValidationError if the subscription is in local mode (secret not recoverable).
   */
  async getSubscriptionSecret(subscriptionId: string, orgId: string): Promise<string> {
    const row = await this.fetchRow(subscriptionId, orgId);

    if (row.vault_secret_path === 'local') {
      throw new WebhookValidationError(
        'Secret not available — use webhook secret returned at creation time.',
        { subscriptionId },
      );
    }

    // Decrypt vault_secret_path if it was stored encrypted (SOC 2 CC6.1 backward-compat)
    let vaultPath = row.vault_secret_path;
    if (this.encryptionService !== null && this.encryptionService.isEncrypted(vaultPath)) {
      vaultPath = await this.encryptionService.decryptColumn(vaultPath);
    }

    return this.retrieveWebhookSecretFromVault(vaultPath);
  }

  /**
   * Returns all webhook subscriptions for the given organization.
   *
   * @param orgId - The organization UUID.
   * @returns Array of IWebhookSubscription (no secret fields).
   */
  async listSubscriptions(orgId: string): Promise<IWebhookSubscription[]> {
    const result = await this.pool.query<WebhookSubscriptionRow>(
      `SELECT * FROM webhook_subscriptions
       WHERE organization_id = $1
       ORDER BY created_at DESC`,
      [orgId],
    );
    return result.rows.map(mapRowToSubscription);
  }

  /**
   * Returns a single webhook subscription by ID.
   *
   * @param id - The subscription UUID.
   * @param orgId - The organization UUID (ownership verification).
   * @returns The IWebhookSubscription.
   * @throws WebhookNotFoundError if not found or belongs to another org.
   */
  async getSubscription(id: string, orgId: string): Promise<IWebhookSubscription> {
    const row = await this.fetchRow(id, orgId);
    return mapRowToSubscription(row);
  }

  /**
   * Partially updates a webhook subscription.
   *
   * Only the fields provided in `req` are changed. If a new URL is provided it must
   * use the `https://` scheme.
   *
   * @param id - The subscription UUID.
   * @param orgId - The organization UUID (ownership verification).
   * @param req - Fields to update.
   * @returns The updated IWebhookSubscription.
   * @throws WebhookNotFoundError if not found or belongs to another org.
   * @throws WebhookValidationError if the new URL is not https://.
   */
  async updateSubscription(
    id: string,
    orgId: string,
    req: IUpdateWebhookRequest,
  ): Promise<IWebhookSubscription> {
    // Verify ownership before mutating
    await this.fetchRow(id, orgId);

    if (req.url !== undefined) {
      this.validateUrl(req.url);
    }

    const setClauses: string[] = [];
    const values: unknown[] = [];
    let paramIndex = 1;

    if (req.name !== undefined) {
      setClauses.push(`name = $${paramIndex++}`);
      values.push(req.name);
    }
    if (req.url !== undefined) {
      setClauses.push(`url = $${paramIndex++}`);
      values.push(req.url);
    }
    if (req.events !== undefined) {
      this.validateEvents(req.events);
      setClauses.push(`events = $${paramIndex++}::jsonb`);
      values.push(JSON.stringify(req.events));
    }
    if (req.active !== undefined) {
      setClauses.push(`active = $${paramIndex++}`);
      values.push(req.active);
    }

    if (setClauses.length === 0) {
      // Nothing to update — re-fetch and return current state
      return this.getSubscription(id, orgId);
    }

    setClauses.push(`updated_at = NOW()`);
    values.push(id);
    values.push(orgId);

    const result = await this.pool.query<WebhookSubscriptionRow>(
      `UPDATE webhook_subscriptions
       SET ${setClauses.join(', ')}
       WHERE id = $${paramIndex++} AND organization_id = $${paramIndex}
       RETURNING *`,
      values,
    );

    if (result.rowCount === 0) {
      throw new WebhookNotFoundError(id);
    }

    return mapRowToSubscription(result.rows[0]);
  }

  /**
   * Permanently deletes a webhook subscription (and all its deliveries via CASCADE).
   *
   * @param id - The subscription UUID.
   * @param orgId - The organization UUID (ownership verification).
   * @throws WebhookNotFoundError if not found or belongs to another org.
   */
  async deleteSubscription(id: string, orgId: string): Promise<void> {
    const result = await this.pool.query(
      `DELETE FROM webhook_subscriptions
       WHERE id = $1 AND organization_id = $2`,
      [id, orgId],
    );

    if (result.rowCount === 0) {
      throw new WebhookNotFoundError(id);
    }
  }

  // ──────────────────────────────────────────────────────────────────────────
  // Deliveries
  // ──────────────────────────────────────────────────────────────────────────

  /**
   * Returns a paginated list of delivery records for a subscription.
   *
   * Verifies that the subscription belongs to the given organization before querying.
   *
   * @param subscriptionId - The subscription UUID.
   * @param orgId - The organization UUID (ownership verification).
   * @param limit - Page size (default 20).
   * @param offset - Row offset (default 0).
   * @returns Paginated delivery records.
   * @throws WebhookNotFoundError if the subscription does not exist or belongs to another org.
   */
  async listDeliveries(
    subscriptionId: string,
    orgId: string,
    limit: number,
    offset: number,
  ): Promise<IPaginatedDeliveriesResponse> {
    // Verify ownership
    await this.fetchRow(subscriptionId, orgId);

    const countResult = await this.pool.query<{ count: string }>(
      `SELECT COUNT(*) AS count FROM webhook_deliveries WHERE subscription_id = $1`,
      [subscriptionId],
    );
    const total = parseInt(countResult.rows[0].count, 10);

    const result = await this.pool.query<WebhookDeliveryRow>(
      `SELECT * FROM webhook_deliveries
       WHERE subscription_id = $1
       ORDER BY created_at DESC
       LIMIT $2 OFFSET $3`,
      [subscriptionId, limit, offset],
    );

    return {
      deliveries: result.rows.map(mapRowToDelivery),
      total,
      limit,
      offset,
    };
  }

  // ──────────────────────────────────────────────────────────────────────────
  // Private helpers
  // ──────────────────────────────────────────────────────────────────────────

  /**
   * Fetches the full subscription row including secret fields (for internal use only).
   *
   * @param id - The subscription UUID.
   * @param orgId - The organization UUID.
   * @throws WebhookNotFoundError if not found or org mismatch.
   */
  private async fetchRow(id: string, orgId: string): Promise<WebhookSubscriptionRow> {
    const result = await this.pool.query<WebhookSubscriptionRow>(
      `SELECT * FROM webhook_subscriptions WHERE id = $1 AND organization_id = $2`,
      [id, orgId],
    );
    if (result.rowCount === 0) {
      throw new WebhookNotFoundError(id);
    }
    return result.rows[0];
  }

  /**
   * Validates that a URL uses the https:// scheme.
   *
   * @param url - The URL to validate.
   * @throws WebhookValidationError if the scheme is not https.
   */
  private validateUrl(url: string): void {
    try {
      const parsed = new URL(url);
      if (parsed.protocol !== 'https:') {
        throw new WebhookValidationError(
          'Webhook URL must use the https:// scheme.',
          { url },
        );
      }
    } catch (err) {
      if (err instanceof WebhookValidationError) throw err;
      throw new WebhookValidationError('Webhook URL is not a valid URL.', { url });
    }
  }

  /**
   * Validates that the events array is non-empty.
   *
   * @param events - The events array to validate.
   * @throws WebhookValidationError if the array is empty.
   */
  private validateEvents(events: WebhookEventType[]): void {
    if (events.length === 0) {
      throw new WebhookValidationError('Webhook subscription must include at least one event type.');
    }
  }

  /**
   * Stores a webhook HMAC secret in Vault at the given KV v2 data path.
   *
   * @param vaultPath - Full KV v2 data path (e.g. `secret/data/agentidp/webhooks/...`).
   * @param secret - The raw HMAC secret to store.
   */
  private async storeWebhookSecretInVault(vaultPath: string, secret: string): Promise<void> {
    await this.vaultClient!.writeArbitrarySecret(vaultPath, { webhookSecret: secret });
  }

  /**
   * Retrieves a webhook HMAC secret from Vault at the given KV v2 data path.
   *
   * @param vaultPath - Full KV v2 data path.
   * @returns The raw HMAC secret string.
   * @throws WebhookValidationError if the secret is missing or empty.
   */
  private async retrieveWebhookSecretFromVault(vaultPath: string): Promise<string> {
    const data = await this.vaultClient!.readArbitrarySecret(vaultPath);
    const secret = data['webhookSecret'];
    if (typeof secret !== 'string' || secret.length === 0) {
      throw new WebhookValidationError('Vault returned an empty or missing webhook secret.', { vaultPath });
    }
    return secret;
  }
}
