import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BadRequestException, InternalServerErrorException } from '@nestjs/common';
import {
  createCipheriv,
  createDecipheriv,
  randomBytes,
} from 'node:crypto';

jest.mock('node:dns', () => ({
  lookup: jest.fn(
    (
      _hostname: string,
      _options: unknown,
      callback: (error: null, addresses: Array<{ address: string; family: number }>) => void,
    ) => callback(null, [{ address: '203.0.113.10', family: 4 }]),
  ),
}));

import { AiConfigService } from './ai-config.service';
import { AiConfig, AiProvider, SINGLETON_KEY } from './ai-config.entity';

// ---------------------------------------------------------------------------
// Helpers for testing encryption directly
// ---------------------------------------------------------------------------

const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;

function encryptWithKey(key: Buffer, plaintext: string) {
  const iv = randomBytes(IV_LENGTH);
  const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
  const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
  const tag = cipher.getAuthTag();
  return {
    ciphertext: encrypted.toString('base64'),
    iv: iv.toString('base64'),
    authTag: tag.toString('base64'),
  };
}

function decryptWithKey(
  key: Buffer,
  ciphertextB64: string,
  ivB64: string,
  authTagB64: string,
): string {
  const iv = Buffer.from(ivB64, 'base64');
  const authTag = Buffer.from(authTagB64, 'base64');
  const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
  decipher.setAuthTag(authTag);
  const decrypted = Buffer.concat([
    decipher.update(Buffer.from(ciphertextB64, 'base64')),
    decipher.final(),
  ]);
  return decrypted.toString('utf-8');
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('AiConfigService', () => {
  let service: AiConfigService;
  let repo: jest.Mocked<Pick<Repository<AiConfig>, 'findOne' | 'save' | 'create'>>;

  // Use a known encryption key so tests are deterministic
  const TEST_KEY_BYTES_32 = Buffer.alloc(32, 'a'); // 32 bytes of 'a'
  const TEST_KEY_HEX = TEST_KEY_BYTES_32.toString('hex'); // 64 hex chars

  function makeConfig(overrides: Partial<AiConfig> = {}): AiConfig {
    return {
      id: 1,
      singletonKey: SINGLETON_KEY,
      provider: AiProvider.OPENAI,
      baseUrl: 'https://api.openai.com/v1',
      encryptedApiKey: null,
      apiKeyIv: null,
      apiKeyAuthTag: null,
      keyLast4: null,
      defaultModel: null,
      enabled: false,
      timeoutMs: 30000,
      verified: false,
      lastTestedAt: null,
      lastTestLatencyMs: null,
      createdAt: new Date(),
      updatedAt: new Date(),
      ...overrides,
    };
  }

  beforeEach(async () => {
    process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
    delete process.env.AI_API_KEY;

    repo = {
      findOne: jest.fn(),
      save: jest.fn(),
      create: jest.fn(),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        AiConfigService,
        { provide: getRepositoryToken(AiConfig), useValue: repo },
      ],
    }).compile();

    service = module.get<AiConfigService>(AiConfigService);
  });

  afterEach(() => {
    delete process.env.AI_CONFIG_ENCRYPTION_KEY;
    delete process.env.AI_API_KEY;
    delete process.env.AI_ALLOW_PRIVATE_BASE_URL;
    delete process.env.NODE_ENV;
  });

  // ── Encryption ────────────────────────────────────────────────────────

  describe('encryption', () => {
    it('roundtrip: encrypt then decrypt returns original text', () => {
      const plaintext = 'sk-test-key-1234567890abcdef';
      const { ciphertext, iv, authTag } = encryptWithKey(TEST_KEY_BYTES_32, plaintext);
      const decrypted = decryptWithKey(TEST_KEY_BYTES_32, ciphertext, iv, authTag);
      expect(decrypted).toBe(plaintext);
    });

    it('random IV: same key + same plaintext produces different ciphertexts', () => {
      const plaintext = 'sk-test-key-1234567890abcdef';
      const key = TEST_KEY_BYTES_32;
      const r1 = encryptWithKey(key, plaintext);
      const r2 = encryptWithKey(key, plaintext);
      expect(r1.iv).not.toBe(r2.iv);
      expect(r1.ciphertext).not.toBe(r2.ciphertext);
      expect(decryptWithKey(key, r1.ciphertext, r1.iv, r1.authTag)).toBe(plaintext);
      expect(decryptWithKey(key, r2.ciphertext, r2.iv, r2.authTag)).toBe(plaintext);
    });

    it('wrong key fails decryption', () => {
      const plaintext = 'sk-test-key-1234567890abcdef';
      const correctKey = TEST_KEY_BYTES_32;
      const wrongKey = Buffer.alloc(32, 'b');
      const { ciphertext, iv, authTag } = encryptWithKey(correctKey, plaintext);
      expect(() =>
        decryptWithKey(wrongKey, ciphertext, iv, authTag),
      ).toThrow();
    });

    it('tampered auth tag fails decryption', () => {
      const plaintext = 'sk-test-key-1234567890abcdef';
      const key = TEST_KEY_BYTES_32;
      const { ciphertext, iv, authTag } = encryptWithKey(key, plaintext);
      const tamperedTag = Buffer.from(authTag, 'base64');
      tamperedTag[0] ^= 1;
      expect(() =>
        decryptWithKey(key, ciphertext, iv, tamperedTag.toString('base64')),
      ).toThrow();
    });
  });

  // ── Encryption key validation ─────────────────────────────────────────

  describe('encryption key validation', () => {
    it('rejects plain 32-char string (not hex or base64)', async () => {
      process.env.AI_CONFIG_ENCRYPTION_KEY = 'a'.repeat(32);
      repo.findOne.mockResolvedValue(makeConfig());

      // Re-create service with new key
      const module: TestingModule = await Test.createTestingModule({
        providers: [
          AiConfigService,
          { provide: getRepositoryToken(AiConfig), useValue: repo },
        ],
      }).compile();
      const svc = module.get<AiConfigService>(AiConfigService);

      await expect(
        svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
      ).rejects.toThrow(InternalServerErrorException);

      process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
    });

    it('rejects invalid base64 input', async () => {
      process.env.AI_CONFIG_ENCRYPTION_KEY = '!!!invalid!!!';
      repo.findOne.mockResolvedValue(makeConfig());

      const module: TestingModule = await Test.createTestingModule({
        providers: [
          AiConfigService,
          { provide: getRepositoryToken(AiConfig), useValue: repo },
        ],
      }).compile();
      const svc = module.get<AiConfigService>(AiConfigService);

      await expect(
        svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
      ).rejects.toThrow(InternalServerErrorException);

      process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
    });

    it('accepts valid base64 32-byte key', async () => {
      const b64key = TEST_KEY_BYTES_32.toString('base64');
      process.env.AI_CONFIG_ENCRYPTION_KEY = b64key;
      const existing = makeConfig();
      repo.findOne.mockResolvedValue(existing);
      repo.save.mockImplementation((c) => Promise.resolve(c));

      const module: TestingModule = await Test.createTestingModule({
        providers: [
          AiConfigService,
          { provide: getRepositoryToken(AiConfig), useValue: repo },
        ],
      }).compile();
      const svc = module.get<AiConfigService>(AiConfigService);

      const result = await svc.saveConfig({ provider: AiProvider.OPENAI });
      expect(result).toBeDefined();

      process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
    });

    it('rejects non-canonical base64 (extra padding)', async () => {
      const canonicalB64 = TEST_KEY_BYTES_32.toString('base64');
      // Non-canonical: add extra padding
      const badB64 = canonicalB64 + '==';
      process.env.AI_CONFIG_ENCRYPTION_KEY = badB64;
      repo.findOne.mockResolvedValue(makeConfig());

      const module: TestingModule = await Test.createTestingModule({
        providers: [
          AiConfigService,
          { provide: getRepositoryToken(AiConfig), useValue: repo },
        ],
      }).compile();
      const svc = module.get<AiConfigService>(AiConfigService);

      await expect(
        svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
      ).rejects.toThrow(InternalServerErrorException);

      process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
    });

    it('rejects base64 with invalid characters', async () => {
      process.env.AI_CONFIG_ENCRYPTION_KEY = '!!!!aaaa';
      repo.findOne.mockResolvedValue(makeConfig());

      const module: TestingModule = await Test.createTestingModule({
        providers: [
          AiConfigService,
          { provide: getRepositoryToken(AiConfig), useValue: repo },
        ],
      }).compile();
      const svc = module.get<AiConfigService>(AiConfigService);

      await expect(
        svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
      ).rejects.toThrow(InternalServerErrorException);

      process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
    });

    it('throws in production when no key set', async () => {
      process.env.NODE_ENV = 'production';
      delete process.env.AI_CONFIG_ENCRYPTION_KEY;

      repo.findOne.mockResolvedValue(makeConfig());

      await expect(
        service.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
      ).rejects.toThrow(InternalServerErrorException);

      delete process.env.NODE_ENV;
      process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
    });
  });

  // ── Config management ─────────────────────────────────────────────────

  describe('getOrCreateConfig', () => {
    it('returns existing config when found', async () => {
      const existing = makeConfig();
      repo.findOne.mockResolvedValue(existing);
      const result = await service.getOrCreateConfig();
      expect(result).toBe(existing);
      expect(repo.findOne).toHaveBeenCalledWith({ where: { singletonKey: SINGLETON_KEY } });
      expect(repo.create).not.toHaveBeenCalled();
    });

    it('creates default config when none exists', async () => {
      repo.findOne.mockResolvedValue(null);
      const created = makeConfig();
      repo.create.mockReturnValue(created);
      repo.save.mockResolvedValue(created);
      const result = await service.getOrCreateConfig();
      expect(repo.create).toHaveBeenCalled();
      expect(result.provider).toBe(AiProvider.OPENAI);
      expect(result.enabled).toBe(false);
    });
  });

  describe('getConfig', () => {
    it('returns masked key info with source=none when no key', async () => {
      repo.findOne.mockResolvedValue(makeConfig());
      const result = await service.getConfig();
      expect(result.hasApiKey).toBe(false);
      expect(result.hasDatabaseKey).toBe(false);
      expect(result.maskedApiKey).toBeNull();
      expect(result.keySource).toBe('none');
    });

    it('returns hasApiKey=true and hasDatabaseKey=true when DB key exists', async () => {
      const key = 'sk-abcdefghij1234';
      const { ciphertext, iv, authTag } = encryptWithKey(
        TEST_KEY_BYTES_32,
        key,
      );
      repo.findOne.mockResolvedValue(
        makeConfig({
          encryptedApiKey: ciphertext,
          apiKeyIv: iv,
          apiKeyAuthTag: authTag,
          keyLast4: '1234',
        }),
      );
      const result = await service.getConfig();
      expect(result.hasApiKey).toBe(true);
      expect(result.hasDatabaseKey).toBe(true);
      expect(result.maskedApiKey).toBe('••••1234');
      expect(result.keySource).toBe('database');
    });

    it('never returns plaintext key in GET response', async () => {
      const key = 'sk-topsecret1234';
      const { ciphertext, iv, authTag } = encryptWithKey(
        TEST_KEY_BYTES_32,
        key,
      );
      repo.findOne.mockResolvedValue(
        makeConfig({
          encryptedApiKey: ciphertext,
          apiKeyIv: iv,
          apiKeyAuthTag: authTag,
          keyLast4: '1234',
        }),
      );
      const result = await service.getConfig();
      const json = JSON.stringify(result);
      expect(json).not.toContain('topsecret');
      expect(json).not.toContain('sk-');
    });

    it('env key fallback: source=environment, hasDatabaseKey=false', async () => {
      process.env.AI_API_KEY = 'sk-env-key-1234';
      repo.findOne.mockResolvedValue(makeConfig());
      const result = await service.getConfig();
      expect(result.hasApiKey).toBe(true);
      expect(result.hasDatabaseKey).toBe(false);
      expect(result.keySource).toBe('environment');
      delete process.env.AI_API_KEY;
    });
  });

  // ── Save config ───────────────────────────────────────────────────────

  describe('saveConfig', () => {
    it('saves provider and baseUrl', async () => {
      const existing = makeConfig();
      repo.findOne.mockResolvedValue(existing);
      repo.save.mockImplementation((c) => Promise.resolve(c));

      const result = await service.saveConfig({
        provider: AiProvider.DEEPSEEK,
        baseUrl: 'https://api.deepseek.com',
      });

      expect(result.provider).toBe(AiProvider.DEEPSEEK);
      expect(result.baseUrl).toBe('https://api.deepseek.com');
    });

    it('encrypts and saves apiKey', async () => {
      const existing = makeConfig();
      repo.findOne.mockResolvedValue(existing);
      repo.save.mockImplementation((c) => Promise.resolve(c));

      const apiKey = 'sk-saved-key-5678';
      const result = await service.saveConfig({
        provider: AiProvider.OPENAI,
        apiKey,
      });

      expect(result.encryptedApiKey).toBeTruthy();
      expect(result.apiKeyIv).toBeTruthy();
      expect(result.apiKeyAuthTag).toBeTruthy();
      expect(result.keyLast4).toBe('5678');

      const encKey = result.encryptedApiKey;
      const encIv = result.apiKeyIv;
      const encTag = result.apiKeyAuthTag;
      expect(encKey).toBeTruthy();
      expect(encIv).toBeTruthy();
      expect(encTag).toBeTruthy();
      if (!encKey || !encIv || !encTag) throw new Error('encrypted fields missing');
      const decrypted = decryptWithKey(TEST_KEY_BYTES_32, encKey, encIv, encTag);
      expect(decrypted).toBe(apiKey);
    });

    it('empty apiKey preserves existing key', async () => {
      const existingKey = 'sk-existing-9999';
      const { ciphertext, iv, authTag } = encryptWithKey(
        TEST_KEY_BYTES_32,
        existingKey,
      );
      const existing = makeConfig({
        encryptedApiKey: ciphertext,
        apiKeyIv: iv,
        apiKeyAuthTag: authTag,
        keyLast4: '9999',
      });
      repo.findOne.mockResolvedValue(existing);
      repo.save.mockImplementation((c) => Promise.resolve(c));

      const result = await service.saveConfig({
        provider: AiProvider.OPENAI,
        apiKey: '',
      });

      expect(result.encryptedApiKey).toBe(ciphertext);
      expect(result.keyLast4).toBe('9999');
    });

    it('undefined apiKey preserves existing key', async () => {
      const existingKey = 'sk-existing-9999';
      const { ciphertext, iv, authTag } = encryptWithKey(
        TEST_KEY_BYTES_32,
        existingKey,
      );
      const existing = makeConfig({
        encryptedApiKey: ciphertext,
        apiKeyIv: iv,
        apiKeyAuthTag: authTag,
        keyLast4: '9999',
      });
      repo.findOne.mockResolvedValue(existing);
      repo.save.mockImplementation((c) => Promise.resolve(c));

      const result = await service.saveConfig({
        provider: AiProvider.OPENAI,
      });

      expect(result.encryptedApiKey).toBe(ciphertext);
    });

    it('rejects enabled=true without any key', async () => {
      repo.findOne.mockResolvedValue(makeConfig());
      repo.save.mockImplementation((c) => Promise.resolve(c));

      await expect(
        service.saveConfig({
          provider: AiProvider.OPENAI,
          enabled: true,
        }),
      ).rejects.toThrow(BadRequestException);
    });

    it('allows enabled=true when DB key exists and defaultModel is set', async () => {
      const { ciphertext, iv, authTag } = encryptWithKey(
        TEST_KEY_BYTES_32,
        'sk-existing-key',
      );
      repo.findOne.mockResolvedValue(
        makeConfig({ encryptedApiKey: ciphertext, apiKeyIv: iv, apiKeyAuthTag: authTag, defaultModel: 'gpt-4' }),
      );
      repo.save.mockImplementation((c) => Promise.resolve(c));

      const result = await service.saveConfig({
        provider: AiProvider.OPENAI,
        enabled: true,
      });
      expect(result.enabled).toBe(true);
    });

    it('allows enabled=true with env key fallback and defaultModel', async () => {
      process.env.AI_API_KEY = 'sk-env-key';
      repo.findOne.mockResolvedValue(makeConfig({ defaultModel: 'gpt-4' }));
      repo.save.mockImplementation((c) => Promise.resolve(c));

      const result = await service.saveConfig({
        provider: AiProvider.OPENAI,
        enabled: true,
      });
      expect(result.enabled).toBe(true);
      delete process.env.AI_API_KEY;
    });

    it('provider switch replaces default baseUrl', async () => {
      repo.findOne.mockResolvedValue(makeConfig({ baseUrl: 'https://api.openai.com/v1' }));
      repo.save.mockImplementation((c) => Promise.resolve(c));

      const result = await service.saveConfig({
        provider: AiProvider.DEEPSEEK,
      });
      expect(result.baseUrl).toBe('https://api.deepseek.com');
    });

    it('OPENAI_COMPATIBLE requires baseUrl', async () => {
      repo.findOne.mockResolvedValue(makeConfig());
      repo.save.mockImplementation((c) => Promise.resolve(c));

      await expect(
        service.saveConfig({
          provider: AiProvider.OPENAI_COMPATIBLE,
        }),
      ).rejects.toThrow(BadRequestException);
    });

    it('enabled=true with defaultModel in DTO works even if config has none', async () => {
      const { ciphertext, iv, authTag } = encryptWithKey(
        TEST_KEY_BYTES_32,
        'sk-existing-key',
      );
      repo.findOne.mockResolvedValue(
        makeConfig({ encryptedApiKey: ciphertext, apiKeyIv: iv, apiKeyAuthTag: authTag }),
      );
      repo.save.mockImplementation((c) => Promise.resolve(c));

      const result = await service.saveConfig({
        provider: AiProvider.OPENAI,
        enabled: true,
        defaultModel: 'gpt-4',
      });
      expect(result.enabled).toBe(true);
    });

    it('enabled=true rejects when defaultModel is empty everywhere', async () => {
      const { ciphertext, iv, authTag } = encryptWithKey(
        TEST_KEY_BYTES_32,
        'sk-existing-key',
      );
      repo.findOne.mockResolvedValue(
        makeConfig({ encryptedApiKey: ciphertext, apiKeyIv: iv, apiKeyAuthTag: authTag }),
      );
      repo.save.mockImplementation((c) => Promise.resolve(c));

      await expect(
        service.saveConfig({
          provider: AiProvider.OPENAI,
          enabled: true,
        }),
      ).rejects.toThrow(BadRequestException);
    });
  });

  // ── Clear key ─────────────────────────────────────────────────────────

  describe('clearKey', () => {
    it('clears DB key and disables when no env key', async () => {
      const { ciphertext, iv, authTag } = encryptWithKey(
        TEST_KEY_BYTES_32,
        'sk-to-clear',
      );
      repo.findOne.mockResolvedValue(
        makeConfig({
          encryptedApiKey: ciphertext,
          apiKeyIv: iv,
          apiKeyAuthTag: authTag,
          keyLast4: 'lear',
          enabled: true,
        }),
      );
      repo.save.mockImplementation((c) => Promise.resolve(c));

      const result = await service.clearKey();
      expect(result.hasApiKey).toBe(false);
      expect(result.hasDatabaseKey).toBe(false);
      expect(result.keySource).toBe('none');
      expect(result.maskedApiKey).toBeNull();
    });

    it('clear DB key falls back to env source', async () => {
      process.env.AI_API_KEY = 'sk-env-after-clear';
      const { ciphertext, iv, authTag } = encryptWithKey(
        TEST_KEY_BYTES_32,
        'sk-to-clear',
      );
      repo.findOne.mockResolvedValue(
        makeConfig({
          encryptedApiKey: ciphertext,
          apiKeyIv: iv,
          apiKeyAuthTag: authTag,
          keyLast4: 'lear',
        }),
      );
      repo.save.mockImplementation((c) => Promise.resolve(c));

      const result = await service.clearKey();
      expect(result.keySource).toBe('environment');
      expect(result.hasDatabaseKey).toBe(false);
      delete process.env.AI_API_KEY;
    });
  });

  // ── URL / SSRF validation ──────────────────────────────────────────────

  describe('baseUrl validation', () => {
    it('rejects non-http protocol', async () => {
      repo.findOne.mockResolvedValue(makeConfig());
      await expect(
        service.saveConfig({
          provider: AiProvider.OPENAI_COMPATIBLE,
          baseUrl: 'ftp://evil.com',
        }),
      ).rejects.toThrow(BadRequestException);
    });

    it('rejects URL with username/password', async () => {
      repo.findOne.mockResolvedValue(makeConfig());
      await expect(
        service.saveConfig({
          provider: AiProvider.OPENAI_COMPATIBLE,
          baseUrl: 'https://user:pass@evil.com',
        }),
      ).rejects.toThrow(BadRequestException);
    });

    it('rejects URL with search/query string', async () => {
      repo.findOne.mockResolvedValue(makeConfig());
      await expect(
        service.saveConfig({
          provider: AiProvider.OPENAI_COMPATIBLE,
          baseUrl: 'https://evil.com/v1?proxy=internal',
        }),
      ).rejects.toThrow(BadRequestException);
    });

    it('rejects URL with hash/fragment', async () => {
      repo.findOne.mockResolvedValue(makeConfig());
      await expect(
        service.saveConfig({
          provider: AiProvider.OPENAI_COMPATIBLE,
          baseUrl: 'https://evil.com/v1#section',
        }),
      ).rejects.toThrow(BadRequestException);
    });

    it('rejects localhost for OPENAI_COMPATIBLE', async () => {
      repo.findOne.mockResolvedValue(makeConfig());
      await expect(
        service.saveConfig({
          provider: AiProvider.OPENAI_COMPATIBLE,
          baseUrl: 'http://localhost:8080/v1',
        }),
      ).rejects.toThrow(BadRequestException);
    });

    it('rejects 127.0.0.1 for OPENAI_COMPATIBLE', async () => {
      repo.findOne.mockResolvedValue(makeConfig());
      await expect(
        service.saveConfig({
          provider: AiProvider.OPENAI_COMPATIBLE,
          baseUrl: 'https://127.0.0.1:8080',
        }),
      ).rejects.toThrow(BadRequestException);
    });

    it('rejects .local hostname', async () => {
      repo.findOne.mockResolvedValue(makeConfig());
      await expect(
        service.saveConfig({
          provider: AiProvider.OPENAI_COMPATIBLE,
          baseUrl: 'https://myservice.local/v1',
        }),
      ).rejects.toThrow(BadRequestException);
    });

    it('rejects IPv6 loopback ::1', async () => {
      repo.findOne.mockResolvedValue(makeConfig());
      await expect(
        service.saveConfig({
          provider: AiProvider.OPENAI_COMPATIBLE,
          baseUrl: 'http://[::1]:8080/v1',
        }),
      ).rejects.toThrow(BadRequestException);
    });

    it('allows private IP when AI_ALLOW_PRIVATE_BASE_URL=true', async () => {
      process.env.AI_ALLOW_PRIVATE_BASE_URL = 'true';
      repo.findOne.mockResolvedValue(makeConfig());
      repo.save.mockImplementation((c) => Promise.resolve(c));

      const result = await service.saveConfig({
        provider: AiProvider.OPENAI_COMPATIBLE,
        baseUrl: 'http://localhost:8080/v1',
      });
      expect(result.baseUrl).toBe('http://localhost:8080/v1');
      delete process.env.AI_ALLOW_PRIVATE_BASE_URL;
    });

    it('OPENAI rejects non-openai hostname', async () => {
      repo.findOne.mockResolvedValue(makeConfig());
      await expect(
        service.saveConfig({
          provider: AiProvider.OPENAI,
          baseUrl: 'https://evil.com/v1',
        }),
      ).rejects.toThrow(BadRequestException);
    });

    it('OPENAI rejects wrong pathname', async () => {
      repo.findOne.mockResolvedValue(makeConfig());
      await expect(
        service.saveConfig({
          provider: AiProvider.OPENAI,
          baseUrl: 'https://api.openai.com/evil-proxy',
        }),
      ).rejects.toThrow(BadRequestException);
    });

    it('normalizes trailing slash', async () => {
      repo.findOne.mockResolvedValue(makeConfig());
      repo.save.mockImplementation((c) => Promise.resolve(c));

      const result = await service.saveConfig({
        provider: AiProvider.OPENAI,
        baseUrl: 'https://api.openai.com/v1/',
      });
      expect(result.baseUrl).toBe('https://api.openai.com/v1');
    });
  });

  // ── getRuntimeConfig ──────────────────────────────────────────────────

  describe('getRuntimeConfig', () => {
    it('returns config with plaintext key', async () => {
      const apiKey = 'sk-runtime-key';
      const { ciphertext, iv, authTag } = encryptWithKey(
        TEST_KEY_BYTES_32,
        apiKey,
      );
      repo.findOne.mockResolvedValue(
        makeConfig({
          encryptedApiKey: ciphertext,
          apiKeyIv: iv,
          apiKeyAuthTag: authTag,
          enabled: true,
          defaultModel: 'gpt-4',
        }),
      );

      const runtime = await service.getRuntimeConfig();
      expect(runtime.apiKey).toBe(apiKey);
      expect(runtime.enabled).toBe(true);
    });

    it('throws when not enabled', async () => {
      repo.findOne.mockResolvedValue(makeConfig({ enabled: false }));
      await expect(service.getRuntimeConfig()).rejects.toThrow(BadRequestException);
    });

    it('throws when no key available', async () => {
      repo.findOne.mockResolvedValue(makeConfig({ enabled: true }));
      await expect(service.getRuntimeConfig()).rejects.toThrow(BadRequestException);
    });

    it('throws when defaultModel is empty', async () => {
      const { ciphertext, iv, authTag } = encryptWithKey(
        TEST_KEY_BYTES_32,
        'sk-runtime-key',
      );
      repo.findOne.mockResolvedValue(
        makeConfig({
          encryptedApiKey: ciphertext,
          apiKeyIv: iv,
          apiKeyAuthTag: authTag,
          enabled: true,
          defaultModel: null,
        }),
      );
      await expect(service.getRuntimeConfig()).rejects.toThrow(BadRequestException);
    });

    it('throws when no config row exists', async () => {
      repo.findOne.mockResolvedValue(null);
      await expect(service.getRuntimeConfig()).rejects.toThrow(InternalServerErrorException);
    });
  });
});
