import { HttpStatus } from '@nestjs/common';
import { DomainException } from '@modules/shared';
import { StringeeSmsService } from '../services/stringee-sms.service';

const allowedDecision = {
  allowed: true,
  current: 1,
  limit: 5,
  retryAfterSeconds: 0,
  bucket: 'otp' as const,
};

const blockedDecision = {
  allowed: false,
  current: 5,
  limit: 5,
  retryAfterSeconds: 42,
  bucket: 'otp' as const,
};

describe('StringeeSmsService', () => {
  let service: StringeeSmsService;
  let mockLogger: {
    log: ReturnType<typeof vi.fn>;
    warn: ReturnType<typeof vi.fn>;
    error: ReturnType<typeof vi.fn>;
  };
  let mockRateLimiter: { check: ReturnType<typeof vi.fn> };

  beforeEach(() => {
    mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
    mockRateLimiter = { check: vi.fn().mockResolvedValue(allowedDecision) };
    service = new StringeeSmsService(mockLogger as any, mockRateLimiter as any);
    vi.restoreAllMocks();
  });

  afterEach(() => {
    delete process.env['STRINGEE_API_KEY'];
    delete process.env['STRINGEE_BRANDNAME'];
  });

  describe('onModuleInit', () => {
    it('initializes when STRINGEE_API_KEY is set', () => {
      process.env['STRINGEE_API_KEY'] = 'test-api-key';
      process.env['STRINGEE_BRANDNAME'] = 'TestBrand';

      service.onModuleInit();

      expect(service.isAvailable).toBe(true);
      expect(mockLogger.log).toHaveBeenCalledWith(
        expect.stringContaining('TestBrand'),
        'StringeeSmsService',
      );
    });

    it('disables when STRINGEE_API_KEY is not set', () => {
      service.onModuleInit();

      expect(service.isAvailable).toBe(false);
      expect(mockLogger.warn).toHaveBeenCalledWith(
        expect.stringContaining('STRINGEE_API_KEY not set'),
        'StringeeSmsService',
      );
    });

    it('defaults brandname to GoodGo when not specified', () => {
      process.env['STRINGEE_API_KEY'] = 'test-api-key';

      service.onModuleInit();

      expect(service.isAvailable).toBe(true);
      expect(mockLogger.log).toHaveBeenCalledWith(
        expect.stringContaining('GoodGo'),
        'StringeeSmsService',
      );
    });
  });

  describe('NotificationChannelPort contract', () => {
    it('exposes the SMS channel identifier', () => {
      expect(service.channel).toBe('SMS');
    });
  });

  describe('sendNotification', () => {
    beforeEach(() => {
      process.env['STRINGEE_API_KEY'] = 'test-api-key';
      service.onModuleInit();
    });

    it('sends SMS successfully', async () => {
      const mockResponse = {
        ok: true,
        json: vi.fn().mockResolvedValue({ r: 0, message_id: 'msg-123' }),
        text: vi.fn(),
      };
      vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);

      const result = await service.sendNotification({
        to: '0901234567',
        message: 'Hello from GoodGo',
      });

      expect(result).toEqual({ messageId: 'msg-123' });
      expect(globalThis.fetch).toHaveBeenCalledWith(
        'https://api.stringee.com/v1/sms',
        expect.objectContaining({
          method: 'POST',
          headers: expect.objectContaining({
            'X-STRINGEE-AUTH': 'test-api-key',
          }),
        }),
      );
    });

    it('normalizes 0-prefixed phone numbers to +84', async () => {
      const mockResponse = {
        ok: true,
        json: vi.fn().mockResolvedValue({ r: 0, message_id: 'msg-456' }),
        text: vi.fn(),
      };
      vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);

      await service.sendNotification({ to: '0901234567', message: 'Test' });

      const callBody = JSON.parse(
        (globalThis.fetch as any).mock.calls[0][1].body,
      );
      expect(callBody.to[0].number).toBe('+84901234567');
    });

    it('normalizes +84 prefixed numbers correctly', async () => {
      const mockResponse = {
        ok: true,
        json: vi.fn().mockResolvedValue({ r: 0, message_id: 'msg-789' }),
        text: vi.fn(),
      };
      vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);

      await service.sendNotification({ to: '+84901234567', message: 'Test' });

      const callBody = JSON.parse(
        (globalThis.fetch as any).mock.calls[0][1].body,
      );
      expect(callBody.to[0].number).toBe('+84901234567');
    });

    it('retries on failure with exponential backoff', async () => {
      const mockFailResponse = {
        ok: false,
        status: 500,
        text: vi.fn().mockResolvedValue('Server error'),
      };
      const mockSuccessResponse = {
        ok: true,
        json: vi.fn().mockResolvedValue({ r: 0, message_id: 'msg-retry' }),
        text: vi.fn(),
      };

      vi.spyOn(globalThis, 'fetch')
        .mockResolvedValueOnce(mockFailResponse as any)
        .mockResolvedValueOnce(mockSuccessResponse as any);

      const result = await service.sendNotification({
        to: '0901234567',
        message: 'Retry test',
      });

      expect(result).toEqual({ messageId: 'msg-retry' });
      expect(globalThis.fetch).toHaveBeenCalledTimes(2);
      expect(mockLogger.warn).toHaveBeenCalledWith(
        expect.stringContaining('attempt 1/3 failed'),
        'StringeeSmsService',
      );
    });

    it('throws after 3 failed attempts', async () => {
      const mockFailResponse = {
        ok: false,
        status: 500,
        text: vi.fn().mockResolvedValue('Server error'),
      };

      vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockFailResponse as any);

      await expect(
        service.sendNotification({ to: '0901234567', message: 'Fail test' }),
      ).rejects.toThrow('Stringee API error (500)');

      expect(globalThis.fetch).toHaveBeenCalledTimes(3);
      expect(mockLogger.error).toHaveBeenCalledWith(
        expect.stringContaining('failed after 3 attempts'),
        'StringeeSmsService',
      );
    });

    it('throws when Stringee returns non-zero result code', async () => {
      const mockResponse = {
        ok: true,
        json: vi.fn().mockResolvedValue({ r: -1, message: 'Invalid number' }),
        text: vi.fn(),
      };

      vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);

      await expect(
        service.sendNotification({ to: '0901234567', message: 'Error test' }),
      ).rejects.toThrow('Stringee SMS rejected');
    });

    it('throws when not initialized', async () => {
      const uninitService = new StringeeSmsService(mockLogger as any, mockRateLimiter as any);

      await expect(
        uninitService.sendNotification({ to: '0901234567', message: 'Test' }),
      ).rejects.toThrow('Stringee SMS not initialized');
    });
  });

  describe('sendOTP', () => {
    beforeEach(() => {
      process.env['STRINGEE_API_KEY'] = 'test-api-key';
      process.env['STRINGEE_BRANDNAME'] = 'GoodGo';
      service.onModuleInit();
    });

    it('sends OTP with formatted message', async () => {
      const mockResponse = {
        ok: true,
        json: vi.fn().mockResolvedValue({ r: 0, message_id: 'otp-123' }),
        text: vi.fn(),
      };
      vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);

      const result = await service.sendOTP({ to: '0901234567', code: '123456' });

      expect(result).toEqual({ messageId: 'otp-123' });

      const callBody = JSON.parse(
        (globalThis.fetch as any).mock.calls[0][1].body,
      );
      expect(callBody.text).toContain('123456');
      expect(callBody.text).toContain('GoodGo');
      expect(callBody.text).toContain('5 phut');
    });

    it('applies the OTP rate-limit bucket before sending', async () => {
      const mockResponse = {
        ok: true,
        json: vi.fn().mockResolvedValue({ r: 0, message_id: 'otp-456' }),
        text: vi.fn(),
      };
      vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);

      await service.sendOTP({ to: '0901234567', code: '987654' });

      expect(mockRateLimiter.check).toHaveBeenNthCalledWith(1, '+84901234567', 'otp');
      expect(mockRateLimiter.check).toHaveBeenNthCalledWith(2, '+84901234567', 'otpHourly');
    });
  });

  describe('rate limiting', () => {
    beforeEach(() => {
      process.env['STRINGEE_API_KEY'] = 'test-api-key';
      service.onModuleInit();
    });

    it('rejects with TOO_MANY_REQUESTS when per-minute bucket is blocked', async () => {
      mockRateLimiter.check.mockResolvedValueOnce(blockedDecision);
      const fetchSpy = vi.spyOn(globalThis, 'fetch');

      await expect(
        service.sendOTP({ to: '0901234567', code: '123456' }),
      ).rejects.toMatchObject({
        errorCode: 'TOO_MANY_REQUESTS',
        status: HttpStatus.TOO_MANY_REQUESTS,
      });
      expect(fetchSpy).not.toHaveBeenCalled();
    });

    it('checks hourly bucket when per-minute passes', async () => {
      mockRateLimiter.check
        .mockResolvedValueOnce(allowedDecision)
        .mockResolvedValueOnce({ ...blockedDecision, bucket: 'otpHourly' as const });
      const fetchSpy = vi.spyOn(globalThis, 'fetch');

      await expect(
        service.sendOTP({ to: '0901234567', code: '123456' }),
      ).rejects.toBeInstanceOf(DomainException);
      expect(fetchSpy).not.toHaveBeenCalled();
      expect(mockRateLimiter.check).toHaveBeenCalledTimes(2);
    });

    it('uses transactional bucket for generic notifications', async () => {
      const mockResponse = {
        ok: true,
        json: vi.fn().mockResolvedValue({ r: 0, message_id: 'tx-1' }),
        text: vi.fn(),
      };
      vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);

      await service.sendNotification({ to: '0901234567', message: 'Payment confirmed' });

      expect(mockRateLimiter.check).toHaveBeenNthCalledWith(1, '+84901234567', 'transactional');
      expect(mockRateLimiter.check).toHaveBeenNthCalledWith(
        2,
        '+84901234567',
        'transactionalHourly',
      );
    });
  });

  describe('NotificationChannelPort.send', () => {
    beforeEach(() => {
      process.env['STRINGEE_API_KEY'] = 'test-api-key';
      service.onModuleInit();
    });

    it('routes OTP template keys through the otp bucket', async () => {
      const mockResponse = {
        ok: true,
        json: vi.fn().mockResolvedValue({ r: 0, message_id: 'port-otp' }),
        text: vi.fn(),
      };
      vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);

      await service.send({
        recipient: '0901234567',
        subject: 'OTP',
        body: '<p>Code 123456</p>',
        templateKey: 'user.phone_change_otp',
      });

      expect(mockRateLimiter.check).toHaveBeenNthCalledWith(1, '+84901234567', 'otp');
      const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
      expect(body.text).toBe('Code 123456');
    });

    it('strips HTML and uses transactional bucket for non-OTP templates', async () => {
      const mockResponse = {
        ok: true,
        json: vi.fn().mockResolvedValue({ r: 0, message_id: 'port-tx' }),
        text: vi.fn(),
      };
      vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);

      await service.send({
        recipient: '0901234567',
        subject: 'Subscription renewed',
        body: '<p>Your <b>GoodGo</b> plan is active.</p>',
        templateKey: 'subscription.renewed',
      });

      expect(mockRateLimiter.check).toHaveBeenNthCalledWith(1, '+84901234567', 'transactional');
      const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
      expect(body.text).toBe('Your GoodGo plan is active.');
    });
  });
});
