jest.mock('uuid', () => ({
  v4: jest.fn(() => 'mock-uuid-v4'),
  v7: jest.fn(() => 'mock-uuid-v7'),
}));

import { NetaClawAgentChannelGroupService } from '../../../../src/modules/netaclaw/service/agent_channel_group.js';

function buildService(opts: {
  upsert?: jest.Mock;
  findOne?: jest.Mock;
  update?: jest.Mock;
  find?: jest.Mock;
  delete?: jest.Mock;
} = {}): NetaClawAgentChannelGroupService {
  const service = new NetaClawAgentChannelGroupService();
  service.groupRepo = {
    upsert: opts.upsert ?? jest.fn().mockResolvedValue({ identifiers: [{ id: 1 }] }),
    findOne: opts.findOne ?? jest.fn().mockResolvedValue(null),
    update: opts.update ?? jest.fn().mockResolvedValue(undefined),
    find: opts.find ?? jest.fn().mockResolvedValue([]),
    delete: opts.delete ?? jest.fn().mockResolvedValue(undefined),
  } as any;
  return service;
}

describe('NetaClawAgentChannelGroupService.addByName', () => {
  it('creates new group with status=1, triggerMode=at_mention by default', async () => {
    const findOne = jest.fn().mockResolvedValue(null);
    const create = jest.fn().mockImplementation((e) => e);
    const save = jest.fn().mockResolvedValue({ id: 77, channelId: 7, roomName: '产品群' });
    const service = new NetaClawAgentChannelGroupService();
    service.groupRepo = { findOne, create, save } as any;

    const result = await service.addByName(7, '产品群', 'at_mention');
    expect(result).toEqual({ id: 77, created: true });
    expect(findOne).toHaveBeenCalledWith({ where: { channelId: 7, roomName: '产品群' } });
    const entity = create.mock.calls[0][0];
    expect(entity).toMatchObject({
      channelId: 7,
      roomId: '产品群',
      roomName: '产品群',
      status: 1,
      triggerMode: 'at_mention',
    });
  });

  it('returns existing row without overwrite when group already added', async () => {
    const existing = { id: 5, channelId: 7, roomName: '产品群' };
    const findOne = jest.fn().mockResolvedValue(existing);
    const save = jest.fn();
    const service = new NetaClawAgentChannelGroupService();
    service.groupRepo = { findOne, save, create: jest.fn() } as any;

    const result = await service.addByName(7, '产品群', 'at_mention');
    expect(result).toEqual({ id: 5, created: false });
    expect(save).not.toHaveBeenCalled();
  });

  it('trims whitespace from roomName', async () => {
    const findOne = jest.fn().mockResolvedValue(null);
    const create = jest.fn().mockImplementation((e) => e);
    const save = jest.fn().mockResolvedValue({ id: 1 });
    const service = new NetaClawAgentChannelGroupService();
    service.groupRepo = { findOne, create, save } as any;

    await service.addByName(7, '  产品群  ', 'at_mention');
    const entity = create.mock.calls[0][0];
    expect(entity.roomName).toBe('产品群');
    expect(entity.roomId).toBe('产品群');
  });

  it('persists explicit group agent and reply identity when provided', async () => {
    const findOne = jest.fn().mockResolvedValue(null);
    const create = jest.fn().mockImplementation((e) => e);
    const save = jest.fn().mockResolvedValue({ id: 1 });
    const service = new NetaClawAgentChannelGroupService();
    service.groupRepo = { findOne, create, save } as any;

    await service.addByName(7, '产品群', 'at_mention', {
      boundAgentId: 12,
      replyIdentityOverride: 'silent',
    });
    const entity = create.mock.calls[0][0];
    expect(entity.boundAgentId).toBe(12);
    expect(entity.replyIdentityOverride).toBe('silent');
  });

  it('rejects empty roomName', async () => {
    const service = new NetaClawAgentChannelGroupService();
    service.groupRepo = {} as any;
    await expect(service.addByName(7, '   ', 'at_mention')).rejects.toThrow(/roomName required/);
  });

  it('rejects invalid triggerMode', async () => {
    const service = new NetaClawAgentChannelGroupService();
    service.groupRepo = { findOne: jest.fn().mockResolvedValue(null) } as any;
    await expect(service.addByName(7, '群', 'weird' as any)).rejects.toThrow(/invalid triggerMode/);
  });

  it('rejects too-long roomName', async () => {
    const service = new NetaClawAgentChannelGroupService();
    service.groupRepo = {} as any;
    await expect(service.addByName(7, 'x'.repeat(129), 'at_mention')).rejects.toThrow(/too long/);
  });
});

describe('NetaClawAgentChannelGroupService other methods', () => {
  it('toggle updates status', async () => {
    const update = jest.fn().mockResolvedValue(undefined);
    const service = buildService({ update });
    await service.toggle(42, 1);
    expect(update).toHaveBeenCalledWith({ id: 42 }, { status: 1 });
  });

  it('toggle rejects invalid status', async () => {
    const service = buildService();
    await expect(service.toggle(42, 99 as any)).rejects.toThrow(/status/);
  });

  it('toggle rejects -1 (status=-1 已废弃)', async () => {
    const service = buildService();
    await expect(service.toggle(42, -1 as any)).rejects.toThrow(/status/);
  });

  it('updatePolicy accepts at_mention without prefix', async () => {
    const update = jest.fn().mockResolvedValue(undefined);
    const service = buildService({ update });
    await service.updatePolicy(42, 'at_mention');
    expect(update).toHaveBeenCalledWith({ id: 42 }, { triggerMode: 'at_mention', triggerPrefix: null });
  });

  it('updatePolicy accepts all', async () => {
    const update = jest.fn().mockResolvedValue(undefined);
    const service = buildService({ update });
    await service.updatePolicy(42, 'all');
    expect(update).toHaveBeenCalledWith({ id: 42 }, { triggerMode: 'all', triggerPrefix: null });
  });

  it('updatePolicy rejects prefix mode entirely', async () => {
    const service = buildService();
    await expect(service.updatePolicy(42, 'prefix')).rejects.toThrow(/prefix mode 已弃用/);
    await expect(service.updatePolicy(42, 'prefix', '  ')).rejects.toThrow(/prefix mode 已弃用/);
  });

  it('updatePolicy rejects prefix even with non-empty prefix arg', async () => {
    const service = buildService();
    await expect(service.updatePolicy(42, 'prefix', '  /bot  ')).rejects.toThrow(/prefix mode 已弃用/);
  });

  it('updatePolicy rejects unknown triggerMode', async () => {
    const service = buildService();
    await expect(service.updatePolicy(42, 'weird' as any)).rejects.toThrow(/triggerMode/);
  });

  it('rename updates roomName', async () => {
    const update = jest.fn().mockResolvedValue(undefined);
    const service = buildService({ update });
    await service.rename(42, '新群名');
    expect(update).toHaveBeenCalledWith({ id: 42 }, { roomName: '新群名' });
  });

  it('touchActive updates lastActiveAt', async () => {
    const update = jest.fn().mockResolvedValue(undefined);
    const service = buildService({ update });
    await service.touchActive(7, 'room-1@chatroom');
    expect(update).toHaveBeenCalledWith(
      { channelId: 7, roomId: 'room-1@chatroom' },
      { lastActiveAt: expect.any(String) },
    );
  });

  it('list returns rows for given channelId', async () => {
    const rows = [{ id: 1, channelId: 7, roomId: 'r1', status: 1 }];
    const find = jest.fn().mockResolvedValue(rows);
    const service = buildService({ find });
    const result = await service.list(7);
    expect(find).toHaveBeenCalledWith({
      where: { channelId: 7 },
      order: { updateTime: 'DESC' },
    });
    expect(result).toBe(rows);
  });

  it('cascadeDeleteByChannel removes all groups of a channel', async () => {
    const del = jest.fn().mockResolvedValue(undefined);
    const service = buildService({ delete: del });
    await service.cascadeDeleteByChannel(7);
    expect(del).toHaveBeenCalledWith({ channelId: 7 });
  });
});

describe('NetaClawAgentChannelGroupService.setBoundAgent', () => {
  it('persists new agent id', async () => {
    const update = jest.fn().mockResolvedValue(undefined);
    const service = buildService({ update });
    await service.setBoundAgent(42, 7);
    expect(update).toHaveBeenCalledWith({ id: 42 }, { boundAgentId: 7 });
  });

  it('rejects null because weixin-db groups require an explicit agent', async () => {
    const update = jest.fn().mockResolvedValue(undefined);
    const service = buildService({ update });
    await expect(service.setBoundAgent(42, null)).rejects.toThrow(/boundAgentId required/);
    expect(update).not.toHaveBeenCalled();
  });

  it('stores a different agent id', async () => {
    const update = jest.fn().mockResolvedValue(undefined);
    const service = buildService({ update });
    await service.setBoundAgent(99, 42);
    expect(update).toHaveBeenCalledWith({ id: 99 }, { boundAgentId: 42 });
  });
});

describe('NetaClawAgentChannelGroupService.setReplyIdentityOverride', () => {
  it.each(['silent', 'ai_prefix'] as const)('accepts valid value %s', async (value) => {
    const update = jest.fn().mockResolvedValue(undefined);
    const service = buildService({ update });
    await service.setReplyIdentityOverride(42, value);
    expect(update).toHaveBeenCalledWith({ id: 42 }, { replyIdentityOverride: value });
  });

  it('rejects null because weixin-db groups require an explicit reply identity', async () => {
    const update = jest.fn().mockResolvedValue(undefined);
    const service = buildService({ update });
    await expect(service.setReplyIdentityOverride(42, null)).rejects.toThrow(/replyIdentityOverride required/);
    expect(update).not.toHaveBeenCalled();
  });

  it('rejects invalid string', async () => {
    const service = buildService();
    await expect(
      service.setReplyIdentityOverride(42, 'bogus' as any),
    ).rejects.toThrow(/invalid replyIdentityOverride/);
  });
});

describe('NetaClawAgentChannelGroupService.statsByChannels', () => {
  it('returns empty map when channelIds is empty', async () => {
    const service = buildService();
    const result = await service.statsByChannels([]);
    expect(result.size).toBe(0);
  });

  it('aggregates total and enabled per channelId', async () => {
    const getRawMany = jest.fn().mockResolvedValue([
      { channelId: 7, total: 5, enabled: 2 },
      { channelId: 9, total: '3', enabled: '0' },
    ]);
    const qb: any = {
      select: jest.fn().mockReturnThis(),
      addSelect: jest.fn().mockReturnThis(),
      where: jest.fn().mockReturnThis(),
      groupBy: jest.fn().mockReturnThis(),
      getRawMany,
    };
    const service = buildService();
    service.groupRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) } as any;

    const result = await service.statsByChannels([7, 9]);
    expect(result.get(7)).toEqual({ total: 5, enabled: 2 });
    expect(result.get(9)).toEqual({ total: 3, enabled: 0 });
  });
});
