import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { IncomingMessage, ServerResponse } from 'http';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

const listConfiguredChannelsMock = vi.fn();
const listConfiguredChannelAccountsMock = vi.fn();
const readOpenClawConfigMock = vi.fn();
const listAgentsSnapshotMock = vi.fn();
const sendJsonMock = vi.fn();
const proxyAwareFetchMock = vi.fn();
const saveChannelConfigMock = vi.fn();
const getChannelFormValuesMock = vi.fn();
const setChannelDefaultAccountMock = vi.fn();
const assignChannelAccountToAgentMock = vi.fn();
const clearChannelBindingMock = vi.fn();
const parseJsonBodyMock = vi.fn();
const testOpenClawConfigDir = join(tmpdir(), 'clawx-tests', 'channel-routes-openclaw');

vi.mock('@electron/utils/channel-config', () => ({
  cleanupDanglingWeChatPluginState: vi.fn(),
  deleteChannelAccountConfig: vi.fn(),
  deleteChannelConfig: vi.fn(),
  getChannelFormValues: (...args: unknown[]) => getChannelFormValuesMock(...args),
  listConfiguredChannelAccounts: (...args: unknown[]) => listConfiguredChannelAccountsMock(...args),
  listConfiguredChannelAccountsFromConfig: (...args: unknown[]) => listConfiguredChannelAccountsMock(...args),
  listConfiguredChannels: (...args: unknown[]) => listConfiguredChannelsMock(...args),
  listConfiguredChannelsFromConfig: (...args: unknown[]) => listConfiguredChannelsMock(...args),
  readOpenClawConfig: (...args: unknown[]) => readOpenClawConfigMock(...args),
  saveChannelConfig: (...args: unknown[]) => saveChannelConfigMock(...args),
  setChannelDefaultAccount: (...args: unknown[]) => setChannelDefaultAccountMock(...args),
  setChannelEnabled: vi.fn(),
  validateChannelConfig: vi.fn(),
  validateChannelCredentials: vi.fn(),
}));

vi.mock('@electron/utils/agent-config', () => ({
  assignChannelAccountToAgent: (...args: unknown[]) => assignChannelAccountToAgentMock(...args),
  clearAllBindingsForChannel: vi.fn(),
  clearChannelBinding: (...args: unknown[]) => clearChannelBindingMock(...args),
  listAgentsSnapshot: (...args: unknown[]) => listAgentsSnapshotMock(...args),
  listAgentsSnapshotFromConfig: (...args: unknown[]) => listAgentsSnapshotMock(...args),
}));

vi.mock('@electron/utils/plugin-install', () => ({
  ensureDiscordPluginInstalled: vi.fn(),
  ensureDingTalkPluginInstalled: vi.fn(),
  ensureFeishuPluginInstalled: vi.fn(),
  ensureQQBotPluginInstalled: vi.fn(),
  ensureWeChatPluginInstalled: vi.fn(),
  ensureWeComPluginInstalled: vi.fn(),
  ensureWhatsAppPluginInstalled: vi.fn(),
}));

vi.mock('@electron/utils/wechat-login', () => ({
  cancelWeChatLoginSession: vi.fn(),
  saveWeChatAccountState: vi.fn(),
  startWeChatLoginSession: vi.fn(),
  waitForWeChatLoginSession: vi.fn(),
}));

vi.mock('@electron/utils/whatsapp-login', () => ({
  whatsAppLoginManager: {
    start: vi.fn(),
    stop: vi.fn(),
  },
}));

vi.mock('@electron/api/route-utils', () => ({
  parseJsonBody: (...args: unknown[]) => parseJsonBodyMock(...args),
  sendJson: (...args: unknown[]) => sendJsonMock(...args),
}));

vi.mock('@electron/utils/paths', () => ({
  getOpenClawConfigDir: () => testOpenClawConfigDir,
  getOpenClawDir: () => testOpenClawConfigDir,
  getOpenClawResolvedDir: () => testOpenClawConfigDir,
}));

vi.mock('@electron/utils/proxy-fetch', () => ({
  proxyAwareFetch: (...args: unknown[]) => proxyAwareFetchMock(...args),
}));

// Stub openclaw SDK functions that are dynamically loaded via createRequire
// in the real code — the extracted utility module is easy to mock.
vi.mock('@electron/utils/openclaw-sdk', () => ({
  listDiscordDirectoryGroupsFromConfig: vi.fn().mockResolvedValue([]),
  listDiscordDirectoryPeersFromConfig: vi.fn().mockResolvedValue([]),
  normalizeDiscordMessagingTarget: vi.fn().mockReturnValue(undefined),
  listTelegramDirectoryGroupsFromConfig: vi.fn().mockResolvedValue([]),
  listTelegramDirectoryPeersFromConfig: vi.fn().mockResolvedValue([]),
  normalizeTelegramMessagingTarget: vi.fn().mockReturnValue(undefined),
  listSlackDirectoryGroupsFromConfig: vi.fn().mockResolvedValue([]),
  listSlackDirectoryPeersFromConfig: vi.fn().mockResolvedValue([]),
  normalizeSlackMessagingTarget: vi.fn().mockReturnValue(undefined),
  normalizeWhatsAppMessagingTarget: vi.fn().mockReturnValue(undefined),
}));

describe('handleChannelRoutes', () => {
  beforeEach(() => {
    vi.resetAllMocks();
    rmSync(testOpenClawConfigDir, { recursive: true, force: true });
    proxyAwareFetchMock.mockReset();
    parseJsonBodyMock.mockResolvedValue({});
    getChannelFormValuesMock.mockResolvedValue(undefined);
    listConfiguredChannelAccountsMock.mockReturnValue({});
    listAgentsSnapshotMock.mockResolvedValue({
      agents: [],
      channelOwners: {},
      channelAccountOwners: {},
    });
    readOpenClawConfigMock.mockResolvedValue({
      channels: {},
    });
  });

  afterAll(() => {
    rmSync(testOpenClawConfigDir, { recursive: true, force: true });
  });

  it('reports healthy running multi-account channels as connected', async () => {
    listConfiguredChannelsMock.mockResolvedValue(['feishu']);
    listConfiguredChannelAccountsMock.mockResolvedValue({
      feishu: {
        defaultAccountId: 'default',
        accountIds: ['default', 'feishu-2412524e'],
      },
    });
    readOpenClawConfigMock.mockResolvedValue({
      channels: {
        feishu: {
          defaultAccount: 'default',
        },
      },
    });
    listAgentsSnapshotMock.mockResolvedValue({
      agents: [],
      channelAccountOwners: {
        'feishu:default': 'main',
        'feishu:feishu-2412524e': 'code',
      },
    });

    const rpc = vi.fn().mockResolvedValue({
      channels: {
        feishu: {
          configured: true,
        },
      },
      channelAccounts: {
        feishu: [
          {
            accountId: 'default',
            configured: true,
            connected: false,
            running: true,
            linked: false,
          },
          {
            accountId: 'feishu-2412524e',
            configured: true,
            connected: false,
            running: true,
            linked: false,
          },
        ],
      },
      channelDefaultAccountId: {
        feishu: 'default',
      },
    });

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    const handled = await handleChannelRoutes(
      { method: 'GET' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/accounts'),
      {
        gatewayManager: {
          rpc,
          getStatus: () => ({ state: 'running' }),
          getDiagnostics: () => ({ consecutiveHeartbeatMisses: 0, consecutiveRpcFailures: 0 }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(handled).toBe(true);
    expect(rpc).toHaveBeenCalledWith('channels.status', { probe: false }, 8000);
    expect(sendJsonMock).toHaveBeenCalledWith(
      expect.anything(),
      200,
      expect.objectContaining({
        success: true,
        channels: [
          expect.objectContaining({
            channelType: 'feishu',
            status: 'connected',
            accounts: expect.arrayContaining([
              expect.objectContaining({ accountId: 'default', status: 'connected' }),
              expect.objectContaining({ accountId: 'feishu-2412524e', status: 'connected' }),
            ]),
          }),
        ],
      }),
    );
  });

  it('rejects non-canonical account ID on channel config save', async () => {
    parseJsonBodyMock.mockResolvedValue({
      channelType: 'feishu',
      accountId: '测试账号',
      config: { appId: 'cli_xxx', appSecret: 'secret' },
    });

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    const handled = await handleChannelRoutes(
      { method: 'POST' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/config'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(handled).toBe(true);
    expect(sendJsonMock).toHaveBeenCalledWith(
      expect.anything(),
      400,
      expect.objectContaining({
        success: false,
        error: expect.stringContaining('Invalid accountId format'),
      }),
    );
    expect(saveChannelConfigMock).not.toHaveBeenCalled();
  });

  it('allows legacy non-canonical account ID on channel config save when account already exists', async () => {
    parseJsonBodyMock.mockResolvedValue({
      channelType: 'telegram',
      accountId: 'Legacy_Account',
      config: { botToken: 'token', allowedUsers: '123456' },
    });
    listConfiguredChannelAccountsMock.mockReturnValue({
      telegram: {
        defaultAccountId: 'default',
        accountIds: ['default', 'Legacy_Account'],
      },
    });

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    const handled = await handleChannelRoutes(
      { method: 'POST' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/config'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(handled).toBe(true);
    expect(saveChannelConfigMock).toHaveBeenCalledWith(
      'telegram',
      { botToken: 'token', allowedUsers: '123456' },
      'Legacy_Account',
    );
    expect(sendJsonMock).toHaveBeenCalledWith(
      expect.anything(),
      200,
      expect.objectContaining({ success: true }),
    );
  });

  it('rejects non-canonical account ID on default-account route', async () => {
    parseJsonBodyMock.mockResolvedValue({
      channelType: 'feishu',
      accountId: 'ABC',
    });

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    await handleChannelRoutes(
      { method: 'PUT' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/default-account'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(sendJsonMock).toHaveBeenCalledWith(
      expect.anything(),
      400,
      expect.objectContaining({
        success: false,
        error: expect.stringContaining('Invalid accountId format'),
      }),
    );
    expect(setChannelDefaultAccountMock).not.toHaveBeenCalled();
  });

  it('rejects non-canonical account ID on binding routes', async () => {
    parseJsonBodyMock.mockResolvedValue({
      channelType: 'feishu',
      accountId: 'Account-Upper',
      agentId: 'main',
    });

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    await handleChannelRoutes(
      { method: 'PUT' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/binding'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(sendJsonMock).toHaveBeenCalledWith(
      expect.anything(),
      400,
      expect.objectContaining({
        success: false,
        error: expect.stringContaining('Invalid accountId format'),
      }),
    );
    expect(assignChannelAccountToAgentMock).not.toHaveBeenCalled();

    parseJsonBodyMock.mockResolvedValue({
      channelType: 'feishu',
      accountId: 'INVALID VALUE',
    });
    await handleChannelRoutes(
      { method: 'DELETE' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/binding'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );
    expect(clearChannelBindingMock).not.toHaveBeenCalled();
  });

  it('allows legacy non-canonical account ID on default-account and binding routes', async () => {
    listConfiguredChannelAccountsMock.mockReturnValue({
      feishu: {
        defaultAccountId: 'default',
        accountIds: ['default', 'Legacy_Account'],
      },
    });
    listAgentsSnapshotMock.mockResolvedValue({
      agents: [{ id: 'main', name: 'Main Agent' }],
      channelOwners: {},
      channelAccountOwners: {},
    });

    parseJsonBodyMock.mockResolvedValue({
      channelType: 'feishu',
      accountId: 'Legacy_Account',
    });
    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    await handleChannelRoutes(
      { method: 'PUT' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/default-account'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );
    expect(setChannelDefaultAccountMock).toHaveBeenCalledWith('feishu', 'Legacy_Account');

    parseJsonBodyMock.mockResolvedValue({
      channelType: 'feishu',
      accountId: 'Legacy_Account',
      agentId: 'main',
    });
    await handleChannelRoutes(
      { method: 'PUT' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/binding'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );
    expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('main', 'feishu', 'Legacy_Account');
  });

  it('migrates legacy channel-wide fallback before manually binding a non-default account', async () => {
    listConfiguredChannelAccountsMock.mockReturnValue({
      telegram: {
        defaultAccountId: 'default',
        accountIds: ['default', 'telegram-a1b2c3d4'],
      },
    });
    listAgentsSnapshotMock.mockResolvedValue({
      agents: [{ id: 'main', name: 'Main' }, { id: 'code', name: 'Code Agent' }],
      channelOwners: { telegram: 'main' },
      channelAccountOwners: {},
    });
    readOpenClawConfigMock.mockResolvedValue({
      bindings: [
        { agentId: 'main', match: { channel: 'telegram' } },
      ],
    });
    parseJsonBodyMock.mockResolvedValue({
      channelType: 'telegram',
      accountId: 'telegram-a1b2c3d4',
      agentId: 'code',
    });

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    await handleChannelRoutes(
      { method: 'PUT' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/binding'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(assignChannelAccountToAgentMock).toHaveBeenNthCalledWith(1, 'main', 'telegram', 'default');
    expect(clearChannelBindingMock).toHaveBeenCalledWith('telegram');
    expect(assignChannelAccountToAgentMock).toHaveBeenNthCalledWith(2, 'code', 'telegram', 'telegram-a1b2c3d4');
  });

  it('does not synthesize a default binding when no legacy channel-wide binding exists', async () => {
    listConfiguredChannelAccountsMock.mockReturnValue({
      telegram: {
        defaultAccountId: 'default',
        accountIds: ['default', 'telegram-a1b2c3d4'],
      },
    });
    listAgentsSnapshotMock.mockResolvedValue({
      agents: [{ id: 'main', name: 'Main' }, { id: 'code', name: 'Code Agent' }],
      channelOwners: { telegram: 'code' },
      channelAccountOwners: {
        'telegram:telegram-a1b2c3d4': 'code',
      },
    });
    readOpenClawConfigMock.mockResolvedValue({
      bindings: [
        { agentId: 'code', match: { channel: 'telegram', accountId: 'telegram-a1b2c3d4' } },
      ],
    });
    parseJsonBodyMock.mockResolvedValue({
      channelType: 'telegram',
      accountId: 'telegram-b2c3d4e5',
      agentId: 'code',
    });

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    await handleChannelRoutes(
      { method: 'PUT' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/binding'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(clearChannelBindingMock).not.toHaveBeenCalled();
    expect(assignChannelAccountToAgentMock).toHaveBeenCalledTimes(1);
    expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('code', 'telegram', 'telegram-b2c3d4e5');
  });

  it('preserves mixed-case agent ids when migrating a legacy channel-wide binding', async () => {
    listConfiguredChannelAccountsMock.mockReturnValue({
      telegram: {
        defaultAccountId: 'default',
        accountIds: ['default', 'telegram-a1b2c3d4'],
      },
    });
    listAgentsSnapshotMock.mockResolvedValue({
      agents: [{ id: 'MainAgent', name: 'Main Agent' }, { id: 'code', name: 'Code Agent' }],
      channelOwners: { telegram: 'mainagent' },
      channelAccountOwners: {},
    });
    readOpenClawConfigMock.mockResolvedValue({
      bindings: [
        { agentId: 'MainAgent', match: { channel: 'telegram' } },
      ],
    });
    parseJsonBodyMock.mockResolvedValue({
      channelType: 'telegram',
      accountId: 'telegram-a1b2c3d4',
      agentId: 'code',
    });

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    await handleChannelRoutes(
      { method: 'PUT' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/binding'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(assignChannelAccountToAgentMock).toHaveBeenNthCalledWith(1, 'MainAgent', 'telegram', 'default');
    expect(assignChannelAccountToAgentMock).toHaveBeenNthCalledWith(2, 'code', 'telegram', 'telegram-a1b2c3d4');
  });

  it('does not mutate legacy bindings when the requested agent does not exist', async () => {
    listConfiguredChannelAccountsMock.mockReturnValue({
      telegram: {
        defaultAccountId: 'default',
        accountIds: ['default', 'telegram-a1b2c3d4'],
      },
    });
    listAgentsSnapshotMock.mockResolvedValue({
      agents: [{ id: 'main', name: 'Main Agent' }],
      channelOwners: { telegram: 'main' },
      channelAccountOwners: {},
    });
    readOpenClawConfigMock.mockResolvedValue({
      bindings: [
        { agentId: 'main', match: { channel: 'telegram' } },
      ],
    });
    parseJsonBodyMock.mockResolvedValue({
      channelType: 'telegram',
      accountId: 'telegram-a1b2c3d4',
      agentId: 'missing-agent',
    });

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    await handleChannelRoutes(
      { method: 'PUT' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/binding'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(clearChannelBindingMock).not.toHaveBeenCalled();
    expect(assignChannelAccountToAgentMock).not.toHaveBeenCalled();
    expect(sendJsonMock).toHaveBeenCalledWith(
      expect.anything(),
      500,
      expect.objectContaining({
        success: false,
        error: expect.stringContaining('Agent "missing-agent" not found'),
      }),
    );
  });

  it('rejects binding requests without accountId before legacy migration runs', async () => {
    listAgentsSnapshotMock.mockResolvedValue({
      agents: [{ id: 'main', name: 'Main Agent' }],
      channelOwners: {},
      channelAccountOwners: {},
    });
    parseJsonBodyMock.mockResolvedValue({
      channelType: 'telegram',
      agentId: 'main',
    });

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    await handleChannelRoutes(
      { method: 'PUT' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/binding'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(clearChannelBindingMock).not.toHaveBeenCalled();
    expect(assignChannelAccountToAgentMock).not.toHaveBeenCalled();
    expect(sendJsonMock).toHaveBeenCalledWith(
      expect.anything(),
      400,
      expect.objectContaining({
        success: false,
        error: 'accountId is required',
      }),
    );
  });

  it('falls back to the legacy owner when explicit default owner is stale', async () => {
    listConfiguredChannelAccountsMock.mockReturnValue({
      telegram: {
        defaultAccountId: 'default',
        accountIds: ['default', 'telegram-a1b2c3d4'],
      },
    });
    listAgentsSnapshotMock.mockResolvedValue({
      agents: [{ id: 'MainAgent', name: 'Main Agent' }, { id: 'code', name: 'Code Agent' }],
      channelOwners: {},
      channelAccountOwners: {},
    });
    readOpenClawConfigMock.mockResolvedValue({
      bindings: [
        { agentId: 'MissingAgent', match: { channel: 'telegram', accountId: 'default' } },
        { agentId: 'MainAgent', match: { channel: 'telegram' } },
      ],
    });
    parseJsonBodyMock.mockResolvedValue({
      channelType: 'telegram',
      accountId: 'telegram-a1b2c3d4',
      agentId: 'code',
    });

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    await handleChannelRoutes(
      { method: 'PUT' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/binding'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(assignChannelAccountToAgentMock).toHaveBeenNthCalledWith(1, 'MainAgent', 'telegram', 'default');
    expect(assignChannelAccountToAgentMock).toHaveBeenNthCalledWith(2, 'code', 'telegram', 'telegram-a1b2c3d4');
  });

  it('skips default binding migration when both explicit and legacy owners are stale', async () => {
    listConfiguredChannelAccountsMock.mockReturnValue({
      telegram: {
        defaultAccountId: 'default',
        accountIds: ['default', 'telegram-a1b2c3d4'],
      },
    });
    listAgentsSnapshotMock.mockResolvedValue({
      agents: [{ id: 'code', name: 'Code Agent' }],
      channelOwners: {},
      channelAccountOwners: {},
    });
    readOpenClawConfigMock.mockResolvedValue({
      bindings: [
        { agentId: 'MissingDefault', match: { channel: 'telegram', accountId: 'default' } },
        { agentId: 'MissingLegacy', match: { channel: 'telegram' } },
      ],
    });
    parseJsonBodyMock.mockResolvedValue({
      channelType: 'telegram',
      accountId: 'telegram-a1b2c3d4',
      agentId: 'code',
    });

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    await handleChannelRoutes(
      { method: 'PUT' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/binding'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(clearChannelBindingMock).toHaveBeenCalledWith('telegram');
    expect(assignChannelAccountToAgentMock).toHaveBeenCalledTimes(1);
    expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('code', 'telegram', 'telegram-a1b2c3d4');
  });

  it('converts legacy channel-wide fallback into an explicit default binding when saving a non-default account', async () => {
    parseJsonBodyMock.mockResolvedValue({
      channelType: 'telegram',
      accountId: 'telegram-a1b2c3d4',
      config: { botToken: 'token', allowedUsers: '123456' },
    });
    listAgentsSnapshotMock.mockResolvedValue({
      agents: [{ id: 'main', name: 'Main' }],
      channelOwners: { telegram: 'main' },
      channelAccountOwners: {},
    });
    readOpenClawConfigMock.mockResolvedValue({
      bindings: [
        { agentId: 'main', match: { channel: 'telegram' } },
      ],
    });

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    await handleChannelRoutes(
      { method: 'POST' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/config'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(saveChannelConfigMock).toHaveBeenCalledWith(
      'telegram',
      { botToken: 'token', allowedUsers: '123456' },
      'telegram-a1b2c3d4',
    );
    expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('main', 'telegram', 'default');
    expect(clearChannelBindingMock).toHaveBeenCalledWith('telegram');
    expect(assignChannelAccountToAgentMock).not.toHaveBeenCalledWith('main', 'telegram', 'telegram-a1b2c3d4');
  });

  it('keeps channel connected when one account is healthy and another errors', async () => {
    listConfiguredChannelsMock.mockResolvedValue(['telegram']);
    listConfiguredChannelAccountsMock.mockResolvedValue({
      telegram: {
        defaultAccountId: 'default',
        accountIds: ['default', 'telegram-b'],
      },
    });
    readOpenClawConfigMock.mockResolvedValue({
      channels: {
        telegram: {
          defaultAccount: 'default',
        },
      },
    });

    const rpc = vi.fn().mockResolvedValue({
      channels: {
        telegram: {
          configured: true,
        },
      },
      channelAccounts: {
        telegram: [
          {
            accountId: 'default',
            configured: true,
            connected: true,
            running: true,
            linked: false,
          },
          {
            accountId: 'telegram-b',
            configured: true,
            connected: false,
            running: false,
            linked: false,
            lastError: 'secondary bot failed',
          },
        ],
      },
      channelDefaultAccountId: {
        telegram: 'default',
      },
    });

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    await handleChannelRoutes(
      { method: 'GET' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/accounts'),
      {
        gatewayManager: {
          rpc,
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(sendJsonMock).toHaveBeenCalledWith(
      expect.anything(),
      200,
      expect.objectContaining({
        success: true,
        channels: [
          expect.objectContaining({
            channelType: 'telegram',
            status: 'connected',
            accounts: expect.arrayContaining([
              expect.objectContaining({ accountId: 'default', status: 'connected' }),
              expect.objectContaining({ accountId: 'telegram-b', status: 'error' }),
            ]),
          }),
        ],
      }),
    );
  });

  it('filters runtime-only stale accounts when not configured locally', async () => {
    listConfiguredChannelsMock.mockResolvedValue(['feishu']);
    listConfiguredChannelAccountsMock.mockResolvedValue({
      feishu: {
        defaultAccountId: 'default',
        accountIds: ['default'],
      },
    });
    readOpenClawConfigMock.mockResolvedValue({
      channels: {
        feishu: {
          defaultAccount: 'default',
        },
      },
    });

    const rpc = vi.fn().mockResolvedValue({
      channels: {
        feishu: {
          configured: true,
        },
      },
      channelAccounts: {
        feishu: [
          {
            accountId: 'default',
            configured: true,
            connected: true,
            running: true,
          },
          {
            accountId: '2',
            configured: false,
            connected: false,
            running: false,
            lastError: 'stale runtime session',
          },
        ],
      },
      channelDefaultAccountId: {
        feishu: 'default',
      },
    });

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    await handleChannelRoutes(
      { method: 'GET' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/accounts'),
      {
        gatewayManager: {
          rpc,
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(sendJsonMock).toHaveBeenCalledWith(
      expect.anything(),
      200,
      expect.objectContaining({
        success: true,
        channels: [
          expect.objectContaining({
            channelType: 'feishu',
            accounts: [expect.objectContaining({ accountId: 'default' })],
          }),
        ],
      }),
    );
    const payload = sendJsonMock.mock.calls.at(-1)?.[2] as {
      channels?: Array<{ channelType: string; accounts: Array<{ accountId: string }> }>;
    };
    const feishu = payload.channels?.find((entry) => entry.channelType === 'feishu');
    expect(feishu?.accounts.map((entry) => entry.accountId)).toEqual(['default']);
  });

  it('returns degraded channel health when channels.status times out while gateway is still running', async () => {
    listConfiguredChannelsMock.mockResolvedValue(['feishu']);
    listConfiguredChannelAccountsMock.mockResolvedValue({
      feishu: {
        defaultAccountId: 'default',
        accountIds: ['default'],
      },
    });
    readOpenClawConfigMock.mockResolvedValue({
      channels: {
        feishu: {
          defaultAccount: 'default',
        },
      },
    });

    const rpc = vi.fn().mockRejectedValue(new Error('RPC timeout: channels.status'));

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    await handleChannelRoutes(
      { method: 'GET' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/accounts'),
      {
        gatewayManager: {
          rpc,
          getStatus: () => ({ state: 'running' }),
          getDiagnostics: () => ({ consecutiveHeartbeatMisses: 0, consecutiveRpcFailures: 0 }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(sendJsonMock).toHaveBeenCalledWith(
      expect.anything(),
      200,
      expect.objectContaining({
        success: true,
        gatewayHealth: expect.objectContaining({
          state: 'degraded',
          reasons: expect.arrayContaining(['channels_status_timeout']),
        }),
        channels: [
          expect.objectContaining({
            channelType: 'feishu',
            status: 'degraded',
            statusReason: 'channels_status_timeout',
            accounts: [
              expect.objectContaining({
                accountId: 'default',
                status: 'degraded',
              }),
            ],
          }),
        ],
      }),
    );
  });

  it('keeps channel degraded when only filtered stale runtime accounts carry lastError', async () => {
    listConfiguredChannelsMock.mockResolvedValue(['feishu']);
    listConfiguredChannelAccountsMock.mockResolvedValue({
      feishu: {
        defaultAccountId: 'default',
        accountIds: ['default'],
      },
    });
    readOpenClawConfigMock.mockResolvedValue({
      channels: {
        feishu: {
          defaultAccount: 'default',
        },
      },
    });

    const rpc = vi.fn().mockResolvedValue({
      channels: {
        feishu: {
          configured: true,
        },
      },
      channelAccounts: {
        feishu: [
          {
            accountId: 'default',
            configured: true,
            connected: true,
            running: true,
            linked: false,
          },
          {
            accountId: '2',
            configured: false,
            connected: false,
            running: false,
            lastError: 'stale runtime session',
          },
        ],
      },
      channelDefaultAccountId: {
        feishu: 'default',
      },
    });

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    await handleChannelRoutes(
      { method: 'GET' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/accounts'),
      {
        gatewayManager: {
          rpc,
          getStatus: () => ({ state: 'running' }),
          getDiagnostics: () => ({ consecutiveHeartbeatMisses: 1, consecutiveRpcFailures: 0 }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(sendJsonMock).toHaveBeenCalledWith(
      expect.anything(),
      200,
      expect.objectContaining({
        success: true,
        channels: [
          expect.objectContaining({
            channelType: 'feishu',
            status: 'degraded',
            accounts: [
              expect.objectContaining({ accountId: 'default', status: 'degraded' }),
            ],
          }),
        ],
      }),
    );
  });

  it('lists known QQ Bot targets for a configured account', async () => {
    const knownUsersPath = join(testOpenClawConfigDir, 'qqbot', 'data');
    mkdirSync(knownUsersPath, { recursive: true });
    writeFileSync(join(knownUsersPath, 'known-users.json'), JSON.stringify([
      {
        openid: '207A5B8339D01F6582911C014668B77B',
        type: 'c2c',
        nickname: 'Alice',
        accountId: 'default',
        lastSeenAt: 200,
      },
      {
        openid: 'member-openid',
        type: 'group',
        nickname: 'Weather Group',
        groupOpenid: 'GROUP_OPENID_123',
        accountId: 'default',
        lastSeenAt: 100,
      },
    ]), 'utf8');

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    const handled = await handleChannelRoutes(
      { method: 'GET' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/targets?channelType=qqbot&accountId=default'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(handled).toBe(true);
    expect(sendJsonMock).toHaveBeenCalledWith(
      expect.anything(),
      200,
      expect.objectContaining({
        success: true,
        channelType: 'qqbot',
        accountId: 'default',
        targets: [
          expect.objectContaining({
            value: 'qqbot:c2c:207A5B8339D01F6582911C014668B77B',
            kind: 'user',
          }),
          expect.objectContaining({
            value: 'qqbot:group:GROUP_OPENID_123',
            kind: 'group',
          }),
        ],
      }),
    );
  });

  it('lists Feishu targets for a configured account', async () => {
    readOpenClawConfigMock.mockResolvedValue({
      channels: {
        feishu: {
          appId: 'cli_app_id',
          appSecret: 'cli_app_secret',
          allowFrom: ['ou_config_user'],
          groups: {
            oc_config_group: {},
          },
        },
      },
    });

    proxyAwareFetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
      if (url.includes('/tenant_access_token/internal')) {
        const body = JSON.parse(String(init?.body || '{}')) as { app_id?: string };
        if (body.app_id === 'cli_app_id') {
          return {
            ok: true,
            json: async () => ({
              code: 0,
              tenant_access_token: 'tenant-token',
            }),
          };
        }
      }

      if (url.includes('/applications/cli_app_id')) {
        return {
          ok: true,
          json: async () => ({
            code: 0,
            data: {
              app: {
                creator_id: 'ou_owner',
                owner: {
                  owner_type: 2,
                  owner_id: 'ou_owner',
                },
              },
            },
          }),
        };
      }

      if (url.includes('/contact/v3/users')) {
        return {
          ok: true,
          json: async () => ({
            code: 0,
            data: {
              items: [
                { open_id: 'ou_live_user', name: 'Alice Feishu' },
              ],
            },
          }),
        };
      }

      if (url.includes('/im/v1/chats')) {
        return {
          ok: true,
          json: async () => ({
            code: 0,
            data: {
              items: [
                { chat_id: 'oc_live_chat', name: 'Project Chat' },
              ],
            },
          }),
        };
      }

      throw new Error(`Unexpected fetch: ${url}`);
    });

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    const handled = await handleChannelRoutes(
      { method: 'GET' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/targets?channelType=feishu&accountId=default'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(handled).toBe(true);
    expect(sendJsonMock).toHaveBeenCalledWith(
      expect.anything(),
      200,
      expect.objectContaining({
        success: true,
        channelType: 'feishu',
        accountId: 'default',
        targets: expect.arrayContaining([
          expect.objectContaining({ value: 'user:ou_owner', kind: 'user' }),
          expect.objectContaining({ value: 'user:ou_live_user', kind: 'user' }),
          expect.objectContaining({ value: 'chat:oc_live_chat', kind: 'group' }),
        ]),
      }),
    );
  });

  it('lists WeCom targets from reqid cache and session history', async () => {
    mkdirSync(join(testOpenClawConfigDir, 'wecom'), { recursive: true });
    writeFileSync(
      join(testOpenClawConfigDir, 'wecom', 'reqid-map-default.json'),
      JSON.stringify({
        'chat-alpha': { reqId: 'req-1', ts: 100 },
      }),
      'utf8',
    );
    mkdirSync(join(testOpenClawConfigDir, 'agents', 'main', 'sessions'), { recursive: true });
    writeFileSync(
      join(testOpenClawConfigDir, 'agents', 'main', 'sessions', 'sessions.json'),
      JSON.stringify({
        'agent:main:wecom:chat-bravo': {
          updatedAt: 200,
          chatType: 'group',
          displayName: 'Ops Group',
          deliveryContext: {
            channel: 'wecom',
            accountId: 'default',
            to: 'wecom:chat-bravo',
          },
        },
      }),
      'utf8',
    );

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    const handled = await handleChannelRoutes(
      { method: 'GET' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/targets?channelType=wecom&accountId=default'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(handled).toBe(true);
    expect(sendJsonMock).toHaveBeenCalledWith(
      expect.anything(),
      200,
      expect.objectContaining({
        success: true,
        channelType: 'wecom',
        accountId: 'default',
        targets: expect.arrayContaining([
          expect.objectContaining({ value: 'wecom:chat-bravo', kind: 'group' }),
          expect.objectContaining({ value: 'wecom:chat-alpha', kind: 'channel' }),
        ]),
      }),
    );
  });

  it('lists DingTalk targets from session history', async () => {
    mkdirSync(join(testOpenClawConfigDir, 'agents', 'main', 'sessions'), { recursive: true });
    writeFileSync(
      join(testOpenClawConfigDir, 'agents', 'main', 'sessions', 'sessions.json'),
      JSON.stringify({
        'agent:main:dingtalk:cid-group': {
          updatedAt: 300,
          chatType: 'group',
          displayName: 'DingTalk Dev Group',
          deliveryContext: {
            channel: 'dingtalk',
            accountId: 'default',
            to: 'cidDeVGroup=',
          },
        },
      }),
      'utf8',
    );

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    const handled = await handleChannelRoutes(
      { method: 'GET' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/targets?channelType=dingtalk&accountId=default'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(handled).toBe(true);
    expect(sendJsonMock).toHaveBeenCalledWith(
      expect.anything(),
      200,
      expect.objectContaining({
        success: true,
        channelType: 'dingtalk',
        accountId: 'default',
        targets: [
          expect.objectContaining({
            value: 'cidDeVGroup=',
            kind: 'group',
          }),
        ],
      }),
    );
  });

  it('lists WeChat targets from session history via the UI alias', async () => {
    mkdirSync(join(testOpenClawConfigDir, 'agents', 'main', 'sessions'), { recursive: true });
    writeFileSync(
      join(testOpenClawConfigDir, 'agents', 'main', 'sessions', 'sessions.json'),
      JSON.stringify({
        'agent:main:wechat:wxid_target': {
          updatedAt: 400,
          chatType: 'direct',
          displayName: 'Alice WeChat',
          deliveryContext: {
            channel: 'openclaw-weixin',
            accountId: 'wechat-bot',
            to: 'wechat:wxid_target',
          },
        },
      }),
      'utf8',
    );

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    const handled = await handleChannelRoutes(
      { method: 'GET' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/targets?channelType=wechat&accountId=wechat-bot'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart: vi.fn(),
        },
      } as never,
    );

    expect(handled).toBe(true);
    expect(sendJsonMock).toHaveBeenCalledWith(
      expect.anything(),
      200,
      expect.objectContaining({
        success: true,
        channelType: 'wechat',
        accountId: 'wechat-bot',
        targets: [
          expect.objectContaining({
            value: 'wechat:wxid_target',
            kind: 'user',
          }),
        ],
      }),
    );
  });

  it('restarts gateway after a no-change channel config save', async () => {
    parseJsonBodyMock.mockResolvedValue({
      channelType: 'telegram',
      accountId: 'default',
      config: { botToken: 'telegram-token', allowedUsers: '123456' },
    });
    getChannelFormValuesMock.mockResolvedValue({ botToken: 'telegram-token', allowedUsers: '123456' });
    listConfiguredChannelAccountsMock.mockReturnValue({
      telegram: {
        defaultAccountId: 'default',
        accountIds: ['default'],
      },
    });
    const debouncedRestart = vi.fn();

    const { handleChannelRoutes } = await import('@electron/api/routes/channels');
    const handled = await handleChannelRoutes(
      { method: 'POST' } as IncomingMessage,
      {} as ServerResponse,
      new URL('http://127.0.0.1:13210/api/channels/config'),
      {
        gatewayManager: {
          rpc: vi.fn(),
          getStatus: () => ({ state: 'running' }),
          debouncedReload: vi.fn(),
          debouncedRestart,
        },
      } as never,
    );

    expect(handled).toBe(true);
    expect(saveChannelConfigMock).not.toHaveBeenCalled();
    expect(debouncedRestart).toHaveBeenCalledWith(150);
    expect(sendJsonMock).toHaveBeenCalledWith(
      expect.anything(),
      200,
      expect.objectContaining({ success: true, noChange: true }),
    );
  });
});
