import { Provide, Scope, ScopeEnum, Inject, Logger } from '@midwayjs/core';
import { ILogger } from '@midwayjs/logger';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Equal, Like, Repository } from 'typeorm';
import { NetaClawAgentChannelEntity } from '../entity/agent_channel.js';
import { NetaClawAgentService } from './agent.js';
import { NetaClawWeixinService, WeixinCredential } from './weixin.js';
import { NetaClawAgentExecutorService } from './agent_executor.js';
import { NetaClawSessionService } from '../gateway/session.js';
import { NetaClawAgentChannelGroupService } from './agent_channel_group.js';
import {
  buildDmDispatchKey,
  buildGroupDispatchKey,
  buildDmSessionId,
  buildGroupSessionId,
  buildDmClarifyKey,
  buildGroupClarifyKey,
  decideChatScope,
  decideGroupAcceptance,
  type ChatScope,
} from '../runtime/chat_scope.js';
import {
  GroupInteractionDeclineError,
  withClarifyTimeout,
} from '../runtime/errors.js';
import { WeixinDbService } from './weixin_db.js';
import { WeixinArchiveSyncService } from './weixin_archive_sync.js';
import { DesktopOpService } from '../../desktop_op/service/desktop_op.js';

type RunnerState = {
  stopped: boolean;
  syncBuf: string;
  contextTokens: Record<string, string>;
  seenMessageIds: Record<string, number>;
};

@Provide()
@Scope(ScopeEnum.Singleton)
export class NetaClawAgentChannelService {
  @InjectEntityModel(NetaClawAgentChannelEntity)
  channelRepo: Repository<NetaClawAgentChannelEntity>;

  @Inject()
  agentService: NetaClawAgentService;

  @Inject()
  weixinService: NetaClawWeixinService;

  @Inject()
  agentExecutor: NetaClawAgentExecutorService;

  @Inject()
  sessionService: NetaClawSessionService;

  @Inject()
  groupService: NetaClawAgentChannelGroupService;

  @Inject()
  weixinDbService: WeixinDbService;

  @Inject()
  archiveSyncService: WeixinArchiveSyncService;

  @Inject()
  desktopOpService!: DesktopOpService;

  @Logger()
  logger: ILogger;

  private readonly runners = new Map<number, RunnerState>();

  /** 微信 clarify / risk 阻塞 Map — key: `${channelId}:${senderId}` */
  private readonly pendingClarify = new Map<string, {
    resolve: (answer: string) => void;
    choices?: string[];
  }>();

  /** 每个 sender 的串行队列尾，保证同一用户消息按顺序进入 agent，但不阻塞 runLoop 拉新消息 */
  private readonly senderQueues = new Map<string, Promise<void>>();

  async page(params: { page?: number; size?: number; keyWord?: string; type?: string; loginStatus?: string }) {
    const { page = 1, size = 20, keyWord, type, loginStatus } = params;
    const where: any = {};
    if (keyWord) where.name = Like(`%${keyWord}%`);
    if (type) where.type = Equal(type);
    if (loginStatus) where.loginStatus = Equal(loginStatus);

    const [list, total] = await this.channelRepo.findAndCount({
      where,
      order: { createTime: 'DESC' },
      skip: (page - 1) * size,
      take: size,
    });

    const channelIds = list.map(item => item.id);
    const groupStats = await this.groupService.statsByChannels(channelIds);
    const enriched = list.map(item => {
      const base: any = {
        ...item,
        groupTotal: groupStats.get(item.id)?.total ?? 0,
        groupEnabled: groupStats.get(item.id)?.enabled ?? 0,
      };
      if (item.type === 'weixin-db') {
        const cred = (item.credential as any) ?? {};
        base.wechatVersion = cred.wechatVersion ?? null;
        base.wxid = cred.wxid ?? null;
      }
      return base;
    });

    return { list: enriched, pagination: { page, size, total } };
  }

  async add(data: Partial<NetaClawAgentChannelEntity>) {
    const payload = await this.normalizePayload(data);
    const entity = this.channelRepo.create({
      ...payload,
      loginStatus: 'disconnected',
      status: payload.status ?? 1,
    });
    const saved = await this.channelRepo.save(entity);
    await this.syncRunner(saved.id);
    return saved;
  }

  async update(data: Partial<NetaClawAgentChannelEntity>) {
    const existing = await this.info(Number(data.id));
    if (!existing) throw new Error('频道不存在');
    const payload = await this.normalizePayload(data, existing);

    // v4: 双 agent toolset 校验(weixinReply.enabled=true 时)
    if (payload.type === 'weixin-db' || existing?.type === 'weixin-db') {
      const cfg = (payload.config ?? existing?.config) as any;
      if (cfg?.weixinReply?.enabled === true) {
        const replyAgentId = payload.agentId ?? existing?.agentId;
        const desktopAgentId = cfg.weixinReply.desktopAgentId;

        if (!desktopAgentId) {
          throw new Error('desktop-agent-not-configured: weixinReply.enabled=true 但未配置 desktopAgentId');
        }

        const replyAgent = replyAgentId ? await this.agentService.info(replyAgentId) : null;
        const desktopAgent = await this.agentService.info(desktopAgentId);

        if (!desktopAgent) {
          throw new Error('desktop-agent-not-found: ' + desktopAgentId);
        }

        // reply 和 desktop 不能是同一个 agent(语义上才区分双 agent)
        if (replyAgentId && replyAgentId === desktopAgentId) {
          throw new Error('reply-and-desktop-cannot-be-same');
        }

        // 推荐性提示(只 warn,不 block):reply agent 若未开 subagent,delegate_task 在 ReAct 中不可用
        if (replyAgent && !(replyAgent as any).subagentConfig?.enabled) {
          this.logger.warn(
            '[agent-channel] reply agent %s subagentConfig.enabled=false — delegate_task 将不可用,reply agent 无法委托 desktop agent',
            replyAgent.id,
          );
        }

        // 自动 patch desktop agent:加 weixin_desktop toolset + 配 weixin_send_text 的 subagent / routing
        // 用户只需选 multimodal model 的 agent 即可,toolset/路由由系统按需补齐
        const desktopToolsets: string[] = Array.isArray((desktopAgent as any).toolsets)
          ? [...(desktopAgent as any).toolsets]
          : [];
        let toolsetsDirty = false;
        if (!desktopToolsets.includes('weixin_desktop')) {
          desktopToolsets.push('weixin_desktop');
          toolsetsDirty = true;
        }

        const tools = (desktopAgent as any).tools ?? {};
        const perTool: any = tools.perTool ?? {};
        const wxTool: any = perTool['weixin_send_text'] ?? {};
        let perToolDirty = false;
        if (wxTool.allowInSubagent !== true) { wxTool.allowInSubagent = true; perToolDirty = true; }
        if (wxTool.workerRoutingStrategy !== 'force-main-process-proxy') {
          wxTool.workerRoutingStrategy = 'force-main-process-proxy';
          perToolDirty = true;
        }

        if (toolsetsDirty || perToolDirty) {
          if (perToolDirty) perTool['weixin_send_text'] = wxTool;
          await this.agentService.update({
            id: desktopAgent.id,
            ...(toolsetsDirty ? { toolsets: desktopToolsets } : {}),
            ...(perToolDirty ? { tools: { ...tools, perTool } } : {}),
          } as any);
          this.logger.info(
            '[agent-channel] auto-patched desktop agent %s (toolsets+%s, perTool+%s)',
            desktopAgent.id, toolsetsDirty, perToolDirty,
          );
        }
      }
    }

    // v4: weixinReply.enabled 由 true 变 false 时 cascade abort
    if (existing?.type === 'weixin-db') {
      const oldCfg = (existing.config as any)?.weixinReply;
      const newCfg = (payload.config as any)?.weixinReply;
      if (oldCfg?.enabled === true && newCfg?.enabled === false) {
        this.desktopOpService.abortByFilter(
          t => t.appId === 'weixin' && t.target?.channelId === existing.id,
          'weixin-reply-disabled',
        );
      }
    }

    await this.channelRepo.save({ ...existing, ...payload });
    await this.syncRunner(existing.id, true);
  }

  async delete(ids: number[]) {
    for (const id of ids) {
      this.stopRunner(id);
      await this.groupService.cascadeDeleteByChannel(id);
      this.weixinDbService.unbindChannel(id);
      await this.archiveSyncService.deleteChannelArchive(id);
      // v4: cascade 取消该 channel 的所有桌面任务(pending + running)
      this.desktopOpService.abortByFilter(
        t => t.appId === 'weixin' && t.target?.channelId === id,
        'channel-deleted',
      );
    }
    await this.channelRepo.delete(ids);
  }

  /**
   * 切换群启停状态,weixin-db 渠道同步刷新白名单缓存(让 IncrementalReader 下次 tick 生效)。
   */
  async toggleGroupAndNotify(groupId: number, status: 0 | 1): Promise<void> {
    const group = await this.groupService.findById(groupId);
    if (!group) throw new Error(`group ${groupId} not found`);
    await this.groupService.toggle(groupId, status);

    const channel = await this.info(group.channelId);
    if (channel?.type === 'weixin-db') {
      await this.weixinDbService.refreshWhitelist(channel.id);
    }
  }

  async info(id: number) {
    return this.channelRepo.findOneBy({ id });
  }

  async options() {
    const agents = await this.agentService.publishedList();
    return {
      types: [
        { value: 'weixin',    label: '微信 ClawBot (个人助手 · 仅私聊)' },
        { value: 'weixin-db', label: '微信本地代理 (群聊 · 需 Windows + PC 微信)' },
      ],
      loginStatuses: [
        { value: 'disconnected', label: '未连接' },
        { value: 'pending', label: '待扫码' },
        { value: 'connected', label: '已连接' },
        { value: 'error', label: '异常' },
      ],
      agents: agents.map(item => ({
        id: item.id,
        name: item.name,
        label: item.label || item.name,
        // v4: 暴露 toolsets 供前端过滤桌面操作 Agent 下拉
        toolsets: (item as any).toolsets ?? [],
      })),
    };
  }

  async createWeixinQr(channelId: number) {
    const channel = await this.ensureWeixinChannel(channelId);
    await this.channelRepo.save({
      ...channel,
      loginStatus: 'pending',
      lastError: null,
      runtime: { ...(channel.runtime || {}), qrRequestedAt: new Date().toISOString() },
    });
    return this.weixinService.createQrLogin(channelId);
  }

  async pollWeixinQr(channelId: number) {
    await this.ensureWeixinChannel(channelId);
    const result = await this.weixinService.pollQrLogin(channelId);
    if (result.status === 'confirmed') {
      const credential = result.credential as WeixinCredential;
      const channel = await this.ensureWeixinChannel(channelId);
      await this.channelRepo.save({
        ...channel,
        loginStatus: 'connected',
        credential,
        runtime: { syncBuf: '', contextTokens: {}, seenMessageIds: {} },
        lastError: null,
        lastConnectedAt: new Date().toISOString(),
      });
      await this.syncRunner(channelId, true);
    } else if (result.status === 'expired') {
      await this.channelRepo.update({ id: channelId }, { loginStatus: 'disconnected' });
    }
    return result;
  }

  async disconnect(channelId: number) {
    const channel = await this.info(channelId);
    if (!channel) throw new Error('频道不存在');
    this.stopRunner(channelId);
    await this.channelRepo.save({
      ...channel,
      loginStatus: 'disconnected',
      credential: null,
      runtime: { syncBuf: '', contextTokens: {}, seenMessageIds: {} },
      lastError: null,
    });
  }

  async restart(channelId: number) {
    await this.syncRunner(channelId, true);
  }

  async clearSessions(channelId: number) {
    const channel = await this.info(channelId);
    if (!channel) throw new Error('频道不存在');

    const sessions = await this.sessionService.sessionRepo.find({
      where: {
        sessionId: Like(`channel:${channelId}:weixin:%`),
      },
      select: ['sessionId'],
    });

    for (const session of sessions) {
      await this.sessionService.deleteSession(session.sessionId);
    }

    return {
      cleared: sessions.length,
    };
  }

  async restoreConnectedRunners() {
    // weixin: 只有 loginStatus=connected 才能恢复(凭据有效)
    // weixin-db: 连接态启动时才知道, 初始必然 disconnected, 只要 status=1 就尝试 bind
    const channels = await this.channelRepo.find({
      where: [
        { status: Equal(1), type: Equal('weixin'), loginStatus: Equal('connected') },
        { status: Equal(1), type: Equal('weixin-db') },
      ],
      order: { id: 'ASC' },
    });
    for (const channel of channels) {
      await this.syncRunner(channel.id);
    }
  }

  private async normalizePayload(
    data: Partial<NetaClawAgentChannelEntity>,
    existing?: NetaClawAgentChannelEntity
  ) {
    const payload: Partial<NetaClawAgentChannelEntity> = { ...data };
    if (payload.agentId) {
      const agent = await this.agentService.info(Number(payload.agentId));
      if (!agent) throw new Error('绑定的 Agent 不存在');
      payload.agentName = agent.name;
    } else if (payload.agentId === null) {
      payload.agentName = null;
    } else {
      payload.agentId = existing?.agentId ?? null;
      payload.agentName = existing?.agentName ?? null;
    }

    payload.type = payload.type || existing?.type || 'weixin';
    payload.config = payload.config ?? existing?.config ?? {};
    payload.credential = payload.credential ?? existing?.credential ?? null;
    payload.runtime = payload.runtime ?? existing?.runtime ?? {};
    return payload;
  }

  private async ensureWeixinChannel(channelId: number) {
    const channel = await this.info(channelId);
    if (!channel) throw new Error('频道不存在');
    if (channel.type !== 'weixin') throw new Error('当前仅支持微信频道');
    return channel;
  }

  private stopRunner(channelId: number) {
    const state = this.runners.get(channelId);
    if (state) {
      state.stopped = true;
      this.runners.delete(channelId);
    }
  }

  async syncRunner(channelId: number, forceRestart = false) {
    const channel = await this.info(channelId);
    if (!channel) {
      this.stopRunner(channelId);
      return;
    }

    // weixin-db: 不跑 runLoop, 改为 WeixinDbService.bindChannel 启动 WalWatcher
    // loginStatus 由 bindChannel 内部维护 (disconnected → connected/error),
    // 这里只看 status (频道启用与否) — 否则新建频道初始 disconnected 会死锁
    if (channel.type === 'weixin-db') {
      if (channel.status !== 1) {
        this.weixinDbService.unbindChannel(channelId);
        return;
      }
      if (forceRestart) this.weixinDbService.unbindChannel(channelId);
      if (!this.runners.has(channelId)) {
        this.runners.set(channelId, { stopped: false, syncBuf: '', contextTokens: {}, seenMessageIds: {} });
      }
      const state = this.runners.get(channelId)!;
      await this.weixinDbService.bindChannel(channel, async (cid, pseudo) => {
        const ch = await this.info(cid);
        if (!ch) return;
        this.routeInboundMessage(ch, state, pseudo as any);
      });
      return;
    }

    // weixin ClawBot 原 runLoop 路径
    const credential = channel.credential as WeixinCredential | null;
    const shouldRun =
      channel.status === 1 &&
      channel.type === 'weixin' &&
      channel.loginStatus === 'connected' &&
      credential?.token &&
      credential?.accountId &&
      channel.agentId;

    if (!shouldRun) {
      this.stopRunner(channelId);
      return;
    }

    if (forceRestart) {
      this.stopRunner(channelId);
    }
    if (this.runners.has(channelId)) {
      return;
    }

    const runtime = channel.runtime || {};
    const state: RunnerState = {
      stopped: false,
      syncBuf: String(runtime.syncBuf || ''),
      contextTokens: { ...(runtime.contextTokens || {}) },
      seenMessageIds: { ...(runtime.seenMessageIds || {}) },
    };
    this.runners.set(channelId, state);
    void this.runLoop(channelId, state);
  }

  private async runLoop(channelId: number, state: RunnerState) {
    while (!state.stopped) {
      try {
        const channel = await this.info(channelId);
        if (!channel || channel.status !== 1 || channel.loginStatus !== 'connected') {
          this.stopRunner(channelId);
          return;
        }

        const credential = channel.credential as WeixinCredential;
        if (!credential?.token || !credential?.accountId || !channel.agentId) {
          this.stopRunner(channelId);
          return;
        }

        const update = await this.weixinService.getUpdates(credential, state.syncBuf);
        if (update.syncBuf !== state.syncBuf) {
          state.syncBuf = update.syncBuf;
          await this.persistRuntime(channelId, state);
        }

        for (const message of update.messages) {
          this.routeInboundMessage(channel, state, message);
        }
      } catch (error: any) {
        this.logger.error('[AgentChannel] runner error channelId=%s message=%s', channelId, error?.message || error);
        await this.channelRepo.update(
          { id: channelId },
          {
            loginStatus: 'error',
            lastError: error?.message || String(error),
          }
        );
        await this.sleep(5000);
      }

      await this.sleep(800);
    }
  }

  private routeInboundMessage(
    channel: NetaClawAgentChannelEntity,
    state: RunnerState,
    message: Record<string, any>,
  ): void {
    // v4 临时诊断:打印完整 pseudo,便于排查 extractText 等过滤为何静默 return
    this.logger.info(
      '[AgentChannel] routeInbound DEBUG channelId=%s msg=%j',
      channel.id, message,
    );
    // Task 9: 跨渠道丢弃 — ClawBot (weixin) 拒收 group / UIA (weixin-uia) 拒收 dm
    const roomIdRaw = String((message as any).room_id || (message as any).chat_room_id || '').trim();
    if (channel.type === 'weixin' && roomIdRaw) {
      this.logger.debug(
        '[AgentChannel] drop_group_on_clawbot channelId=%s room=%s',
        channel.id, roomIdRaw);
      return;
    }
    if (channel.type === 'weixin-db' && !roomIdRaw) {
      this.logger.debug(
        '[AgentChannel] drop_dm_on_weixin_db channelId=%s sender=%s',
        channel.id, String((message as any).from_user_id || ''));
      return;
    }

    const credential = channel.credential as WeixinCredential | null;
    const scope = decideChatScope(message, credential?.accountId || '');
    if (!scope) return;

    // 去重
    const messageId = String(message.message_id || '').trim();
    const now = Date.now();
    for (const [key, ts] of Object.entries(state.seenMessageIds)) {
      if (now - ts > 5 * 60 * 1000) delete state.seenMessageIds[key];
    }
    if (messageId && state.seenMessageIds[messageId]) return;
    if (messageId) state.seenMessageIds[messageId] = now;

    // context_token 按 chat_id 索引（P0-2）：
    // - DM: scope.chatId === senderId
    // - 群: scope.chatId === roomId
    const contextToken = String(message.context_token || '').trim();
    if (contextToken) state.contextTokens[scope.chatId] = contextToken;

    // pendingClarify 短路
    const clarifyKey = scope.kind === 'dm'
      ? buildDmClarifyKey(channel.id, scope.senderId)
      : buildGroupClarifyKey(channel.id, scope.chatId, scope.senderId);
    const text = this.weixinService.extractText(message.item_list || []);
    if (!text) {
      this.logger.info(
        '[AgentChannel] drop_no_text channelId=%s scope=%s chatId=%s item_list=%j',
        channel.id, scope.kind, scope.chatId, message.item_list,
      );
      return;
    }
    if (this.consumePendingClarifyReply(clarifyKey, text)) return;

    if (channel.type === 'weixin-db' && this.isWeixinDbToolWatermark(text)) {
      this.logger.debug(
        '[AgentChannel] drop_weixin_db_tool_watermark channelId=%s scope=%s chatId=%s sender=%s',
        channel.id, scope.kind, scope.chatId, scope.senderId,
      );
      return;
    }

    // 入 senderQueue
    const dispatchKey = scope.kind === 'dm'
      ? buildDmDispatchKey(channel.id, scope.senderId)
      : buildGroupDispatchKey(channel.id, scope.chatId);
    const previous = this.senderQueues.get(dispatchKey) ?? Promise.resolve();
    const next = previous
      .catch(() => undefined)
      .then(() => this.handleInboundMessage(channel, state, scope, message, text))
      .catch(error => {
        this.logger.error(
          '[AgentChannel] handleInboundMessage error channelId=%s scope=%s chatId=%s sender=%s message=%s',
          channel.id,
          scope.kind,
          scope.chatId,
          scope.senderId,
          (error as any)?.message || error,
        );
      })
      .finally(() => {
        if (this.senderQueues.get(dispatchKey) === next) {
          this.senderQueues.delete(dispatchKey);
        }
      });
    this.senderQueues.set(dispatchKey, next);
  }

  private consumePendingClarifyReply(clarifyKey: string, text: string): boolean {
    const pending = this.pendingClarify.get(clarifyKey);
    if (!pending) return false;
    this.pendingClarify.delete(clarifyKey);
    let answer = text;
    if (pending.choices?.length) {
      const num = parseInt(text, 10);
      if (num >= 1 && num <= pending.choices.length) {
        answer = pending.choices[num - 1];
      }
    }
    pending.resolve(answer);
    return true;
  }

  private isWeixinDbToolWatermark(text: string): boolean {
    const trimmed = String(text || '').trim();
    return trimmed.endsWith('—AI') || trimmed.endsWith('鈥擜I');
  }

  private async handleInboundMessage(
    channel: NetaClawAgentChannelEntity,
    state: RunnerState,
    scope: ChatScope,
    rawMessage: Record<string, any>,
    text: string,
  ): Promise<void> {
    const credential = channel.credential as WeixinCredential;
    const tokenForChat = state.contextTokens[scope.chatId];

    if (scope.kind === 'group') {
      // weixin-db: 白名单在 DB 解密阶段过滤, 此处只查已存在的群记录
      // weixin ClawBot: runLoop 送来的群消息理论上已在 routeInboundMessage 丢弃
      const group = await this.groupService.findByKey(channel.id, scope.chatId);
      if (!group) {
        this.logger.debug(
          '[AgentChannel] group no_whitelist_record channelId=%s roomId=%s',
          channel.id, scope.chatId);
        return;
      }

      // weixin-db 群聊必须显式绑定群级 Agent; ClawBot 保留频道默认 Agent 回退。
      const groupBoundAgentId = (group as any).boundAgentId ?? null;
      const effectiveAgentId = channel.type === 'weixin-db'
        ? groupBoundAgentId
        : groupBoundAgentId ?? channel.agentId;
      if (!effectiveAgentId) {
        this.logger.warn(
          '[AgentChannel] group_no_agent_bound channelId=%s roomId=%s',
          channel.id, scope.chatId);
        return;
      }
      const effectiveAgentName =
        effectiveAgentId === channel.agentId
          ? (channel.agentName || undefined)
          : undefined;

      const aliases = this.collectBotAliases(channel);
      const accountIds = [credential?.accountId, credential?.userId].filter(Boolean) as string[];
      const acceptance = decideGroupAcceptance(group as any, rawMessage, text, aliases, accountIds);
      if (acceptance.accept) {
        this.logger.info(
          '[AgentChannel] group dispatch channelId=%s roomId=%s sender=%s trigger=%s accept=true',
          channel.id, scope.chatId, scope.senderId, group.triggerMode,
        );
      } else {
        this.logger.debug(
          '[AgentChannel] group dispatch channelId=%s roomId=%s sender=%s trigger=%s accept=false reason=%s',
          channel.id, scope.chatId, scope.senderId, group.triggerMode, acceptance.reason,
        );
        return;
      }

      await this.persistRuntime(channel.id, state);
      const sessionId = buildGroupSessionId(channel.id, scope.chatId);
      const clarifyKey = buildGroupClarifyKey(channel.id, scope.chatId, scope.senderId);
      const roomName = group.roomName || this.extractGroupName(rawMessage) || scope.chatId;
      const result = await this.agentExecutor.execute({
        sessionId,
        message: acceptance.cleanedText ?? text,
        agentId: effectiveAgentId,
        agentName: effectiveAgentName,
        userId: scope.senderId,
        chatScope: 'group',
        bizContext: {
          channelId: channel.id,
          roomId: scope.chatId,
          roomName,
          senderId: scope.senderId,
          senderWxid: typeof rawMessage.sender_wxid === 'string' ? rawMessage.sender_wxid : undefined,
          senderDisplayName: typeof rawMessage.sender_display_name === 'string' ? rawMessage.sender_display_name : undefined,
          senderRemark: typeof rawMessage.sender_remark === 'string' ? rawMessage.sender_remark : undefined,
          senderNickName: typeof rawMessage.sender_nick_name === 'string' ? rawMessage.sender_nick_name : undefined,
        },
        onClarifyRequest: async (question, choices) => {
          const aliasForNote = aliases[0] || '机器人';
          let msg = `❓ ${question}`;
          if (choices?.length) {
            msg += '\n' + choices.map((c, i) => `${i + 1}. ${c}`).join('\n');
          }
          msg += `\n\n（仅 @${aliasForNote} 的发起者本人可回复，5 分钟超时）`;
          await this.weixinService.sendText(credential, scope.chatId, msg, tokenForChat);
          const promise = new Promise<string>(resolve => {
            this.pendingClarify.set(clarifyKey, { resolve, choices });
          });
          return withClarifyTimeout(promise, 300_000)
            .catch(err => {
              if (err?.isInteractionDecline) {
                this.logger.info(
                  '[AgentChannel] group clarify timeout channelId=%s roomId=%s senderId=%s',
                  channel.id, scope.chatId, scope.senderId,
                );
              }
              throw err;
            })
            .finally(() => {
              this.pendingClarify.delete(clarifyKey);
            });
        },
      });

      if (typeof result.content === 'string' && result.content.startsWith('⚠️ 群内禁止高风险操作')) {
        this.logger.warn(
          '[AgentChannel] group risk declined channelId=%s roomId=%s content=%s',
          channel.id, scope.chatId, result.content.slice(0, 120),
        );
      }

      // v4: reply agent 需要显式触发发送动作,避免只生成文本但没有发到微信。
      const isWeixinReply_v4 = channel.type === 'weixin-db' && (channel.config as any)?.weixinReply?.enabled === true;
      if (isWeixinReply_v4) {
        const calledSendAction = (result as any).toolExecutions?.some((t: any) =>
          t.name === 'delegate_task' || t.name === 'weixin_send_text'
        );
        if (!calledSendAction) {
          this.logger.warn(
            '[AgentChannel] WARN reply agent did not call delegate_task/weixin_send_text (message dropped silently). channelId=%s roomId=%s finalContent="%s..."',
            channel.id, scope.chatId, String(result.content || '').slice(0, 80),
          );
        }
      }

      // v4: weixin-db 不再自动发送,reply agent 必须主动 delegate_task 给 desktop agent
      if (channel.type === 'weixin-db') {
        await this.groupService.touchActive(channel.id, scope.chatId);
        return;
      }

      // iLink (weixin ClawBot) 群路径 — 理论上 Task 9 已在 routeInboundMessage 丢弃, 此处保留作为防御
      await this.weixinService.sendText(
        credential, scope.chatId, result.content, tokenForChat,
      );
      await this.groupService.touchActive(channel.id, scope.chatId);
      await this.channelRepo.update(
        { id: channel.id },
        { loginStatus: 'connected', lastError: null, lastConnectedAt: new Date().toISOString() },
      );
      return;
    }

    // DM 路径
    await this.persistRuntime(channel.id, state);
    const sessionId = buildDmSessionId(channel.id, scope.senderId);
    const clarifyKey = buildDmClarifyKey(channel.id, scope.senderId);
    const result = await this.agentExecutor.execute({
      sessionId,
      message: text,
      agentId: channel.agentId,
      agentName: channel.agentName || undefined,
      userId: scope.senderId,
      chatScope: 'dm',
      onClarifyRequest: async (question, choices) => {
        let msg = `❓ ${question}`;
        if (choices?.length) {
          msg += '\n' + choices.map((c, i) => `${i + 1}. ${c}`).join('\n');
          msg += '\n\n请回复数字或直接输入你的回答';
        }
        await this.weixinService.sendText(credential, scope.senderId, msg, tokenForChat);
        return new Promise<string>(resolve => {
          this.pendingClarify.set(clarifyKey, { resolve, choices });
        });
      },
      onRiskConfirmRequest: async ({ toolName, toolLabel, riskReason }) => {
        const msg = NetaClawAgentChannelService.buildRiskConfirmPrompt({ toolName, toolLabel, riskReason });
        await this.weixinService.sendText(credential, scope.senderId, msg, tokenForChat);
        return new Promise<boolean>(resolve => {
          this.pendingClarify.set(clarifyKey, {
            resolve: (answer: string) => resolve(NetaClawAgentChannelService.parseRiskConfirmReply(answer)),
          });
        });
      },
    });

    await this.weixinService.sendText(
      credential, scope.senderId, result.content, tokenForChat,
    );
    await this.channelRepo.update(
      { id: channel.id },
      { loginStatus: 'connected', lastError: null, lastConnectedAt: new Date().toISOString() },
    );
  }

  private extractGroupName(rawMessage: Record<string, any>): string | null {
    const candidates = [rawMessage.room_name, rawMessage.chat_room_name, rawMessage.group_name];
    for (const c of candidates) {
      if (typeof c === 'string' && c.trim()) return c.trim();
    }
    return null;
  }

  private collectBotAliases(channel: NetaClawAgentChannelEntity): string[] {
    const config = (channel.config || {}) as { group?: { botAlias?: string } };
    const nickname = (channel.credential as any)?.nickname as string | undefined;
    const aliases: string[] = [];
    if (config.group?.botAlias && config.group.botAlias.trim()) aliases.push(config.group.botAlias.trim());
    if (nickname && nickname.trim()) aliases.push(nickname.trim());
    return aliases;
  }

  private async persistRuntime(channelId: number, state: RunnerState) {
    const channel = await this.info(channelId);
    if (!channel) return;
    await this.channelRepo.save({
      ...channel,
      runtime: {
        syncBuf: state.syncBuf,
        contextTokens: state.contextTokens,
        seenMessageIds: state.seenMessageIds,
      },
    });
  }

  private sleep(ms: number) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  static buildRiskConfirmPrompt(params: {
    toolName: string;
    toolLabel?: string;
    riskReason: string;
  }): string {
    const label = params.toolLabel || params.toolName;
    return [
      `⚠️ 检测到高风险操作：${label}`,
      `原因：${params.riskReason}`,
      '',
      '1. 确认执行',
      '2. 不执行',
      '',
      '请回复数字，或输入“确认”/“取消”',
    ].join('\n');
  }

  static parseRiskConfirmReply(answer: string): boolean {
    const trimmed = (answer || '').trim().toLowerCase();
    if (!trimmed) return false;
    if (trimmed === '1') return true;
    if (trimmed === '2') return false;
    if (trimmed === '确认' || trimmed === '确认执行') return true;
    if (trimmed === '取消' || trimmed === '不执行') return false;
    if (trimmed === 'yes' || trimmed === 'y') return true;
    return false;
  }
}
