import { NetaClawDataSourceEntity } from '../src/modules/netaclaw/entity/data_source.js';
import { NetaClawDataSourceService } from '../src/modules/netaclaw/service/data_source.js';

function makeDataSource(overrides: Partial<NetaClawDataSourceEntity> = {}) {
  return Object.assign(new NetaClawDataSourceEntity(), {
    id: 1,
    name: 'orders',
    label: 'Orders DB',
    type: 'mysql',
    host: 'db.internal',
    port: 3306,
    database: 'orders_prod',
    username: 'readonly',
    passwordEncrypted: 'encrypted-secret',
    readonly: true,
    status: 1,
    allowedAgentIds: [9],
    extra: {
      allowedTables: ['orders'],
      maskedColumns: { 'orders.phone': 'partial' },
    },
    ...overrides,
  });
}

describe('NetaClawDataSourceService projections', () => {
  it('returns admin-safe metadata without password ciphertext', () => {
    const service = new NetaClawDataSourceService();

    const result = service.toAdminSafe(makeDataSource());

    expect(result).toMatchObject({
      id: 1,
      name: 'orders',
      label: 'Orders DB',
      type: 'mysql',
      host: 'db.internal',
      port: 3306,
      database: 'orders_prod',
      username: 'readonly',
      readonly: true,
      status: 1,
      allowedAgentIds: [9],
      hasPassword: true,
    });
    expect(result).not.toHaveProperty('passwordEncrypted');
  });

  it('redacts sensitive ssl material from admin-safe metadata', () => {
    const service = new NetaClawDataSourceService();

    const result = service.toAdminSafe(makeDataSource({
      extra: {
        ssl: {
          key: 'private-key',
          cert: 'client-cert',
          passphrase: 'ssl-secret',
          rejectUnauthorized: true,
        },
      },
    }));

    expect(result.extra?.ssl).toEqual({ enabled: true });
    expect(JSON.stringify(result.extra)).not.toContain('private-key');
    expect(JSON.stringify(result.extra)).not.toContain('client-cert');
    expect(JSON.stringify(result.extra)).not.toContain('ssl-secret');
  });

  it('returns agent summaries without connection credentials or permissions', () => {
    const service = new NetaClawDataSourceService();

    const result = service.toAgentSummary(makeDataSource());

    expect(result).toEqual({
      name: 'orders',
      label: 'Orders DB',
      database: 'orders_prod',
      status: 1,
    });
    expect(result).not.toHaveProperty('host');
    expect(result).not.toHaveProperty('username');
    expect(result).not.toHaveProperty('passwordEncrypted');
    expect(result).not.toHaveProperty('allowedAgentIds');
    expect(result).not.toHaveProperty('extra');
  });

  it('lists only active mysql data sources allowed for the agent', async () => {
    const service = new NetaClawDataSourceService();
    service.dataSourceRepo = {
      find: jest.fn().mockResolvedValue([
        makeDataSource({ name: 'orders', allowedAgentIds: [9] }),
        makeDataSource({ name: 'finance', allowedAgentIds: [10] }),
        makeDataSource({ name: 'disabled', status: 0, allowedAgentIds: [9] }),
      ]),
    } as any;

    const result = await service.listForAgent(9);

    expect(service.dataSourceRepo.find).toHaveBeenCalledWith({
      where: { type: 'mysql', status: 1 },
      order: { id: 'DESC' },
    });
    expect(result).toEqual([
      {
        name: 'orders',
        label: 'Orders DB',
        database: 'orders_prod',
        status: 1,
      },
    ]);
  });

  it('rejects create payloads missing required connection fields', async () => {
    const service = new NetaClawDataSourceService();

    await expect(service.saveConfig({
      name: 'saved',
      username: 'readonly',
    } as any)).rejects.toThrow('data_source_host_required');
    await expect(service.testConnection({
      name: 'saved',
      host: 'db.internal',
      username: 'readonly',
    } as any)).rejects.toThrow('data_source_database_required');
  });

  it('whitelists complete saveConfig input and returns admin-safe projection', async () => {
    const service = new NetaClawDataSourceService();
    const saved = makeDataSource({ id: 2, name: 'saved' });
    service.dataSourceRepo = {
      create: jest.fn(payload => Object.assign(new NetaClawDataSourceEntity(), payload)),
      save: jest.fn().mockImplementation(entity => Promise.resolve(Object.assign(saved, entity))),
    } as any;
    service.secretCrypto = {
      encryptText: jest.fn().mockReturnValue('new-ciphertext'),
    } as any;
    service.mysqlPoolManager = {
      closePool: jest.fn().mockResolvedValue(undefined),
    } as any;

    const result = await service.saveConfig({
      name: 'saved',
      label: null,
      host: 'db.internal',
      database: 'orders_prod',
      username: 'readonly',
      allowedAgentIds: null,
      password: 'plain-secret',
      tenantId: 7,
    } as any);

    const createPayload = (service.dataSourceRepo.create as jest.Mock).mock.calls[0][0];
    expect(createPayload).toMatchObject({
      name: 'saved',
      label: null,
      type: 'mysql',
      readonly: true,
      status: 1,
      port: 3306,
      host: 'db.internal',
      database: 'orders_prod',
      username: 'readonly',
      allowedAgentIds: [],
      passwordEncrypted: 'new-ciphertext',
    });
    expect(createPayload).not.toHaveProperty('tenantId');
    expect(createPayload).not.toHaveProperty('createTime');
    expect(createPayload).not.toHaveProperty('updateTime');
    expect(service.secretCrypto.encryptText).toHaveBeenCalledWith('plain-secret');
    expect(service.mysqlPoolManager.closePool).toHaveBeenCalledWith(2);
    expect(result).toMatchObject({
      id: 2,
      name: 'saved',
      hasPassword: true,
    });
    expect(result).not.toHaveProperty('passwordEncrypted');
  });

  it('gets only authorized active mysql data sources by name', async () => {
    const service = new NetaClawDataSourceService();
    const source = makeDataSource({ allowedAgentIds: [9, 10] });
    service.dataSourceRepo = {
      findOne: jest.fn().mockResolvedValue(source),
    } as any;

    const result = await service.getAuthorizedSource('orders', 9);

    expect(service.dataSourceRepo.findOne).toHaveBeenCalledWith({
      where: { name: 'orders', type: 'mysql', status: 1 },
    });
    expect(result).toBe(source);
  });

  it('rejects missing and unauthorized data sources', async () => {
    const service = new NetaClawDataSourceService();
    service.dataSourceRepo = {
      findOne: jest.fn()
        .mockResolvedValueOnce(null)
        .mockResolvedValueOnce(makeDataSource({ allowedAgentIds: [10] })),
    } as any;

    await expect(service.getAuthorizedSource('missing', 9)).rejects.toThrow('data_source_not_found');
    await expect(service.getAuthorizedSource('orders', 9)).rejects.toThrow('data_source_not_authorized');
  });

  it('normalizes extra limits and preserves connection options', () => {
    const service = new NetaClawDataSourceService();

    expect(service.normalizeExtra({
      queryTimeoutMs: 90000,
      maxRows: 0,
      maxJoinTables: 20,
      poolConnectionLimit: 20,
      ssl: true,
      connectTimeout: 500,
    })).toEqual({
      allowedTables: [],
      blockedTables: [],
      maskedColumns: {},
      schemaVisibility: 'allowed-only',
      queryTimeoutMs: 30000,
      maxRows: 1,
      maxJoinTables: 6,
      poolConnectionLimit: 10,
      ssl: true,
      connectTimeout: 1000,
    });
  });

  it('deep-normalizes extra table and masked-column policy', () => {
    const service = new NetaClawDataSourceService();

    expect(service.normalizeExtra({
      allowedTables: [' Orders ', 'ORDERS', '', 9 as any, 'Customers'],
      blockedTables: [' Audit ', null as any, 'audit', ''],
      maskedColumns: {
        ' Orders.Phone ': 'partial',
        'badkey': 'redact',
        'orders.secret': 'hide' as any,
        'customers.email': 'hash',
      },
      schemaVisibility: 'invalid' as any,
    })).toMatchObject({
      allowedTables: ['orders', 'customers'],
      blockedTables: ['audit'],
      maskedColumns: {
        'orders.phone': 'partial',
        'customers.email': 'hash',
      },
      schemaVisibility: 'allowed-only',
    });
  });

  it('merges partial extra edits with existing policy before saving', async () => {
    const service = new NetaClawDataSourceService();
    const existing = makeDataSource({
      id: 2,
      extra: {
        allowedTables: ['Orders'],
        blockedTables: ['Audit'],
        maskedColumns: { 'Orders.Phone': 'partial' },
        schemaVisibility: 'all-names-only',
        maxRows: 200,
      },
    });
    service.dataSourceRepo = {
      findOne: jest.fn().mockResolvedValue(existing),
      create: jest.fn(payload => Object.assign(new NetaClawDataSourceEntity(), payload)),
      save: jest.fn().mockImplementation(entity => Promise.resolve(entity)),
    } as any;
    service.mysqlPoolManager = {
      closePool: jest.fn().mockResolvedValue(undefined),
    } as any;

    await service.saveConfig({ id: 2, extra: { maxRows: 100 } });

    const payload = (service.dataSourceRepo.create as jest.Mock).mock.calls[0][0];
    expect(payload.extra).toMatchObject({
      allowedTables: ['orders'],
      blockedTables: ['audit'],
      maskedColumns: { 'orders.phone': 'partial' },
      schemaVisibility: 'all-names-only',
      maxRows: 100,
    });
  });

  it('normalizes allowed agent ids when saving', async () => {
    const service = new NetaClawDataSourceService();
    const saved = makeDataSource({ id: 2 });
    service.dataSourceRepo = {
      create: jest.fn(payload => Object.assign(new NetaClawDataSourceEntity(), payload)),
      save: jest.fn().mockImplementation(entity => Promise.resolve(Object.assign(saved, entity))),
    } as any;
    service.mysqlPoolManager = {
      closePool: jest.fn().mockResolvedValue(undefined),
    } as any;

    await service.saveConfig({
      name: 'saved',
      host: 'db.internal',
      database: 'orders_prod',
      username: 'readonly',
      allowedAgentIds: [9, '9', '10', { id: 11 }, Number.POSITIVE_INFINITY, 'bad'] as any,
    });

    const payload = (service.dataSourceRepo.create as jest.Mock).mock.calls[0][0];
    expect(payload.allowedAgentIds).toEqual([9, 10]);
  });

  it('retains existing password and extra when editing without replacements', async () => {
    const service = new NetaClawDataSourceService();
    const existing = makeDataSource({
      id: 2,
      passwordEncrypted: 'existing-ciphertext',
      extra: {
        allowedTables: ['orders'],
        queryTimeoutMs: 1500,
      },
    });
    service.dataSourceRepo = {
      findOne: jest.fn().mockResolvedValue(existing),
      create: jest.fn(payload => Object.assign(new NetaClawDataSourceEntity(), payload)),
      save: jest.fn().mockImplementation(entity => Promise.resolve(entity)),
    } as any;
    service.secretCrypto = {
      encryptText: jest.fn(),
    } as any;
    service.mysqlPoolManager = {
      closePool: jest.fn().mockResolvedValue(undefined),
    } as any;

    await service.saveConfig({ id: 2, label: 'Updated label' });

    const payload = (service.dataSourceRepo.create as jest.Mock).mock.calls[0][0];
    expect(payload.passwordEncrypted).toBe('existing-ciphertext');
    expect(payload.extra).toMatchObject({
      allowedTables: ['orders'],
      queryTimeoutMs: 1500,
      maxRows: 200,
    });
    expect(service.secretCrypto.encryptText).not.toHaveBeenCalled();
  });

  it('tests connections with a transient pool and sanitized errors', async () => {
    const service = new NetaClawDataSourceService();
    const accessDeniedError = Object.assign(
      new Error('Access denied for user readonly@db.internal using password secret'),
      { code: 'ER_ACCESS_DENIED_ERROR' }
    );
    const transientPool = {
      query: jest.fn()
        .mockResolvedValueOnce([[{ ok: 1 }]])
        .mockRejectedValueOnce(Object.assign(new Error('connect ECONNREFUSED db.internal readonly secret'), { code: 'ECONNREFUSED' })),
      end: jest.fn().mockResolvedValue(undefined),
    };
    service.dataSourceRepo = {
      findOne: jest.fn().mockResolvedValue(makeDataSource({ id: 2, passwordEncrypted: 'existing-ciphertext' })),
    } as any;
    service.mysqlPoolManager = {
      createTransientPool: jest.fn()
        .mockResolvedValueOnce(transientPool)
        .mockResolvedValueOnce(transientPool)
        .mockRejectedValueOnce(accessDeniedError),
    } as any;

    await expect(service.testConnection({ id: 2, name: 'orders' })).resolves.toEqual({ ok: true });
    await expect(service.testConnection({ id: 2, name: 'orders' })).resolves.toEqual({
      ok: false,
      error: 'mysql_connection_failed:ECONNREFUSED',
    });
    await expect(service.testConnection({ id: 2, name: 'orders' })).resolves.toEqual({
      ok: false,
      error: 'mysql_connection_failed:ER_ACCESS_DENIED_ERROR',
    });
    expect(transientPool.query).toHaveBeenCalledWith('SELECT 1 AS ok');
    expect(transientPool.end).toHaveBeenCalledTimes(2);
    const testedSource = (service.mysqlPoolManager.createTransientPool as jest.Mock).mock.calls[0][0];
    expect(testedSource.passwordEncrypted).toBe('existing-ciphertext');
  });
});
