/**
 * Unit tests for src/services/WebhookService.ts
 *
 * Covers: createSubscription, listSubscriptions, getSubscription,
 *         updateSubscription, deleteSubscription, listDeliveries,
 *         getSubscriptionSecret — all paths including error cases.
 */

import crypto from 'crypto';
import { Pool } from 'pg';
import { RedisClientType } from 'redis';
import { WebhookService, WebhookNotFoundError, WebhookValidationError } from '../../../src/services/WebhookService';
import { VaultClient } from '../../../src/vault/VaultClient';
import { IWebhookSubscription } from '../../../src/types/webhook';

// ─── Mocks ────────────────────────────────────────────────────────────────────

jest.mock('pg', () => ({
  Pool: jest.fn().mockImplementation(() => ({
    query: jest.fn(),
  })),
}));

jest.mock('../../../src/vault/VaultClient');

// ─── Helpers ──────────────────────────────────────────────────────────────────

const ORG_ID = crypto.randomUUID();
const SUB_ID = crypto.randomUUID();
const NOW = new Date('2026-03-30T10:00:00Z');

function makeSubRow(overrides: Partial<Record<string, unknown>> = {}) {
  return {
    id: SUB_ID,
    organization_id: ORG_ID,
    name: 'Test Hook',
    url: 'https://example.com/hook',
    events: ['agent.created'],
    secret_hash: 'vault',
    vault_secret_path: 'secret/data/agentidp/webhooks/org/sub/secret',
    active: true,
    failure_count: 0,
    created_at: NOW,
    updated_at: NOW,
    ...overrides,
  };
}

function makeDeliveryRow(overrides: Partial<Record<string, unknown>> = {}) {
  return {
    id: crypto.randomUUID(),
    subscription_id: SUB_ID,
    event_type: 'agent.created',
    payload: { id: 'evt-1', event: 'agent.created', timestamp: NOW.toISOString(), organization_id: ORG_ID, data: {} },
    status: 'pending',
    http_status_code: null,
    attempt_count: 0,
    next_retry_at: null,
    delivered_at: null,
    created_at: NOW,
    updated_at: NOW,
    ...overrides,
  };
}

// ─── Suite ────────────────────────────────────────────────────────────────────

describe('WebhookService', () => {
  let pool: jest.Mocked<Pool>;
  let vaultClient: jest.Mocked<VaultClient>;
  let service: WebhookService;
  const mockRedis = {} as RedisClientType;

  beforeEach(() => {
    jest.clearAllMocks();
    pool = new Pool() as jest.Mocked<Pool>;
    vaultClient = new (VaultClient as jest.MockedClass<typeof VaultClient>)('http://vault', 'token') as jest.Mocked<VaultClient>;
    service = new WebhookService(pool, vaultClient, mockRedis);
  });

  // ────────────────────────────────────────────────────────────────────────────
  // createSubscription
  // ────────────────────────────────────────────────────────────────────────────
  describe('createSubscription()', () => {
    it('stores secret in Vault when vaultClient present and returns subscription with secret', async () => {
      vaultClient.writeArbitrarySecret = jest.fn().mockResolvedValue(undefined);
      (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [makeSubRow()], rowCount: 1 });

      const result = await service.createSubscription(ORG_ID, {
        name: 'Test Hook',
        url: 'https://example.com/hook',
        events: ['agent.created'],
      });

      expect(vaultClient.writeArbitrarySecret).toHaveBeenCalledTimes(1);

      const [vaultPath, secretData] = (vaultClient.writeArbitrarySecret as jest.Mock).mock.calls[0] as [string, Record<string, string>];
      expect(vaultPath).toMatch(/^secret\/data\/agentidp\/webhooks\//);
      expect(typeof secretData['webhookSecret']).toBe('string');
      expect(secretData['webhookSecret'].length).toBeGreaterThan(0);

      expect(typeof result.secret).toBe('string');
      expect(result.secret.length).toBeGreaterThan(0);
      expect(result.id).toBe(SUB_ID);
      expect(result.organization_id).toBe(ORG_ID);
    });

    it('uses bcrypt hash in local mode (no vaultClient) and returns subscription with secret', async () => {
      const localService = new WebhookService(pool, null, mockRedis);
      (pool.query as jest.Mock).mockResolvedValueOnce({
        rows: [makeSubRow({ vault_secret_path: 'local', secret_hash: '$2b$10$hash' })],
        rowCount: 1,
      });

      const result = await localService.createSubscription(ORG_ID, {
        name: 'Local Hook',
        url: 'https://example.com/hook',
        events: ['agent.created'],
      });

      const insertArgs = (pool.query as jest.Mock).mock.calls[0][1] as unknown[];
      const secretHashArg = insertArgs[5] as string;
      expect(secretHashArg).toMatch(/^\$2[ab]\$/);
      expect(insertArgs[6]).toBe('local');
      expect(typeof result.secret).toBe('string');
      expect(result.secret.length).toBeGreaterThan(0);
    });

    it('throws WebhookValidationError for http:// URL', async () => {
      await expect(
        service.createSubscription(ORG_ID, {
          name: 'Bad Hook',
          url: 'http://example.com/hook',
          events: ['agent.created'],
        }),
      ).rejects.toThrow(WebhookValidationError);
    });

    it('throws WebhookValidationError for an invalid URL string', async () => {
      await expect(
        service.createSubscription(ORG_ID, {
          name: 'Bad Hook',
          url: 'not-a-url',
          events: ['agent.created'],
        }),
      ).rejects.toThrow(WebhookValidationError);
    });

    it('throws WebhookValidationError when events array is empty', async () => {
      await expect(
        service.createSubscription(ORG_ID, {
          name: 'Empty Events Hook',
          url: 'https://example.com/hook',
          events: [],
        }),
      ).rejects.toThrow(WebhookValidationError);
    });
  });

  // ────────────────────────────────────────────────────────────────────────────
  // listSubscriptions
  // ────────────────────────────────────────────────────────────────────────────
  describe('listSubscriptions()', () => {
    it('returns all subscriptions for the org with secret fields excluded', async () => {
      const rows = [makeSubRow(), makeSubRow({ id: crypto.randomUUID(), name: 'Hook 2' })];
      (pool.query as jest.Mock).mockResolvedValueOnce({ rows, rowCount: 2 });

      const result = await service.listSubscriptions(ORG_ID);

      expect(result).toHaveLength(2);
      result.forEach((sub: IWebhookSubscription) => {
        expect((sub as unknown as Record<string, unknown>)['secret_hash']).toBeUndefined();
        expect((sub as unknown as Record<string, unknown>)['vault_secret_path']).toBeUndefined();
        expect(sub.organization_id).toBe(ORG_ID);
      });
    });

    it('returns empty array when org has no subscriptions', async () => {
      (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });
      const result = await service.listSubscriptions(ORG_ID);
      expect(result).toEqual([]);
    });
  });

  // ────────────────────────────────────────────────────────────────────────────
  // getSubscription
  // ────────────────────────────────────────────────────────────────────────────
  describe('getSubscription()', () => {
    it('returns the matching subscription for the correct org', async () => {
      (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [makeSubRow()], rowCount: 1 });

      const result = await service.getSubscription(SUB_ID, ORG_ID);

      expect(result.id).toBe(SUB_ID);
      expect(result.organization_id).toBe(ORG_ID);
    });

    it('throws WebhookNotFoundError when subscription belongs to another org', async () => {
      (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });

      await expect(service.getSubscription(SUB_ID, crypto.randomUUID())).rejects.toThrow(
        WebhookNotFoundError,
      );
    });

    it('throws WebhookNotFoundError when subscription does not exist', async () => {
      (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });

      await expect(service.getSubscription(crypto.randomUUID(), ORG_ID)).rejects.toThrow(
        WebhookNotFoundError,
      );
    });
  });

  // ────────────────────────────────────────────────────────────────────────────
  // updateSubscription
  // ────────────────────────────────────────────────────────────────────────────
  describe('updateSubscription()', () => {
    it('updates the name and returns the updated subscription', async () => {
      const updatedRow = makeSubRow({ name: 'Updated Hook' });
      (pool.query as jest.Mock)
        .mockResolvedValueOnce({ rows: [makeSubRow()], rowCount: 1 })   // fetchRow (ownership check)
        .mockResolvedValueOnce({ rows: [updatedRow], rowCount: 1 });     // UPDATE RETURNING

      const result = await service.updateSubscription(SUB_ID, ORG_ID, { name: 'Updated Hook' });

      expect(result.name).toBe('Updated Hook');
    });

    it('validates the new URL when provided and rejects http://', async () => {
      (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [makeSubRow()], rowCount: 1 });

      await expect(
        service.updateSubscription(SUB_ID, ORG_ID, { url: 'http://bad.example.com' }),
      ).rejects.toThrow(WebhookValidationError);
    });

    it('throws WebhookNotFoundError when subscription is missing', async () => {
      (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });

      await expect(
        service.updateSubscription(crypto.randomUUID(), ORG_ID, { name: 'x' }),
      ).rejects.toThrow(WebhookNotFoundError);
    });

    it('returns current subscription unchanged when no fields are provided', async () => {
      const row = makeSubRow();
      (pool.query as jest.Mock)
        .mockResolvedValueOnce({ rows: [row], rowCount: 1 })  // fetchRow (ownership check)
        .mockResolvedValueOnce({ rows: [row], rowCount: 1 }); // getSubscription → fetchRow

      const result = await service.updateSubscription(SUB_ID, ORG_ID, {});

      expect(result.id).toBe(SUB_ID);
    });
  });

  // ────────────────────────────────────────────────────────────────────────────
  // deleteSubscription
  // ────────────────────────────────────────────────────────────────────────────
  describe('deleteSubscription()', () => {
    it('deletes the subscription successfully', async () => {
      (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 1 });

      await expect(service.deleteSubscription(SUB_ID, ORG_ID)).resolves.toBeUndefined();
      expect(pool.query).toHaveBeenCalledTimes(1);
    });

    it('throws WebhookNotFoundError when not found or wrong org', async () => {
      (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });

      await expect(service.deleteSubscription(crypto.randomUUID(), ORG_ID)).rejects.toThrow(
        WebhookNotFoundError,
      );
    });
  });

  // ────────────────────────────────────────────────────────────────────────────
  // listDeliveries
  // ────────────────────────────────────────────────────────────────────────────
  describe('listDeliveries()', () => {
    it('verifies org ownership and returns paginated deliveries', async () => {
      const deliveries = [makeDeliveryRow(), makeDeliveryRow()];
      (pool.query as jest.Mock)
        .mockResolvedValueOnce({ rows: [makeSubRow()], rowCount: 1 })         // fetchRow
        .mockResolvedValueOnce({ rows: [{ count: '5' }], rowCount: 1 })       // COUNT
        .mockResolvedValueOnce({ rows: deliveries, rowCount: 2 });             // SELECT

      const result = await service.listDeliveries(SUB_ID, ORG_ID, 20, 0);

      expect(result.total).toBe(5);
      expect(result.limit).toBe(20);
      expect(result.offset).toBe(0);
      expect(result.deliveries).toHaveLength(2);
    });

    it('throws WebhookNotFoundError when subscription belongs to another org', async () => {
      (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });

      await expect(
        service.listDeliveries(SUB_ID, crypto.randomUUID(), 20, 0),
      ).rejects.toThrow(WebhookNotFoundError);
    });

    it('passes correct limit and offset to the query', async () => {
      (pool.query as jest.Mock)
        .mockResolvedValueOnce({ rows: [makeSubRow()], rowCount: 1 })
        .mockResolvedValueOnce({ rows: [{ count: '50' }], rowCount: 1 })
        .mockResolvedValueOnce({ rows: [], rowCount: 0 });

      const result = await service.listDeliveries(SUB_ID, ORG_ID, 10, 30);

      expect(result.limit).toBe(10);
      expect(result.offset).toBe(30);

      const selectArgs = (pool.query as jest.Mock).mock.calls[2][1] as unknown[];
      expect(selectArgs[1]).toBe(10);
      expect(selectArgs[2]).toBe(30);
    });
  });

  // ────────────────────────────────────────────────────────────────────────────
  // getSubscriptionSecret
  // ────────────────────────────────────────────────────────────────────────────
  describe('getSubscriptionSecret()', () => {
    it('returns the secret from Vault in Vault mode', async () => {
      const vaultPath = 'secret/data/agentidp/webhooks/org/sub/secret';
      (pool.query as jest.Mock).mockResolvedValueOnce({
        rows: [makeSubRow({ vault_secret_path: vaultPath })],
        rowCount: 1,
      });
      vaultClient.readArbitrarySecret = jest.fn().mockResolvedValue({ webhookSecret: 'mysecret' });

      const result = await service.getSubscriptionSecret(SUB_ID, ORG_ID);

      expect(result).toBe('mysecret');
      expect(vaultClient.readArbitrarySecret).toHaveBeenCalledWith(vaultPath);
    });

    it('throws WebhookValidationError in local mode (secret not recoverable)', async () => {
      (pool.query as jest.Mock).mockResolvedValueOnce({
        rows: [makeSubRow({ vault_secret_path: 'local' })],
        rowCount: 1,
      });

      await expect(service.getSubscriptionSecret(SUB_ID, ORG_ID)).rejects.toThrow(
        WebhookValidationError,
      );
    });

    it('throws WebhookNotFoundError when subscription does not exist', async () => {
      (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });

      await expect(service.getSubscriptionSecret(crypto.randomUUID(), ORG_ID)).rejects.toThrow(
        WebhookNotFoundError,
      );
    });
  });

  // ────────────────────────────────────────────────────────────────────────────
  // Error class properties
  // ────────────────────────────────────────────────────────────────────────────
  describe('Error classes', () => {
    it('WebhookNotFoundError has httpStatus 404 and correct code', () => {
      const err = new WebhookNotFoundError('abc');
      expect(err.httpStatus).toBe(404);
      expect(err.code).toBe('WEBHOOK_NOT_FOUND');
      expect(err.details).toEqual({ subscriptionId: 'abc' });
    });

    it('WebhookValidationError has httpStatus 400 and correct code', () => {
      const err = new WebhookValidationError('bad', { field: 'url' });
      expect(err.httpStatus).toBe(400);
      expect(err.code).toBe('WEBHOOK_VALIDATION_ERROR');
    });
  });
});
