import type { Database } from 'bun:sqlite';
import { db, ensureUserExists } from '../db';
import { isGroupId } from '../utils/whatsapp';
import { TaskService } from '../tasks/service';
import { GroupSyncService } from './group-sync';
import { ContactsService } from './contacts';
import { ICONS } from '../utils/icons';
import { padTaskId, codeId, formatDDMM, bold, italic, code, section } from '../utils/formatting';
import { getQuickHelp, getFullHelp } from './messages/help';
import { IdentityService } from './identity';
import { AllowedGroups } from './allowed-groups';
import { Metrics } from './metrics';
import { ResponseQueue } from './response-queue';
import { randomTokenBase64Url, sha256Hex } from '../utils/crypto';
import { route as routeCommand } from './commands';
import { ACTION_ALIASES, SCOPE_ALIASES, todayYMD as todayYMD_TZ, resolveTaskIdFromInput } from './commands/shared';
import { parseNueva } from './commands/parsers/nueva';

type CommandContext = {
	sender: string;     // normalized user id (digits only), but accept raw too
	groupId: string;    // full JID (e.g., xxx@g.us)
	message: string;    // raw message text
	mentions: string[]; // array of raw JIDs mentioned
	messageId?: string; // id del mensaje origen (para task_origins y reacciones)
	participant?: string; // JID del autor del mensaje origen (en grupos)
	fromMe?: boolean;     // si el mensaje origen fue enviado por la instancia
};

export type CommandResponse = {
	recipient: string;
	message: string;
	mentions?: string[]; // full JIDs to mention in the outgoing message
};

export type CommandOutcome = {
	responses: CommandResponse[];
	ok: boolean;
	createdTaskIds?: number[];
};

export class CommandService {
	static dbInstance: Database = db;
	private static readonly CTA_HELP: string = 'ℹ️ Tus tareas: `/t mias` · Todas: `/t todas` · Info: `/t info` · Web: `/t web`';



	private static async processTareaCommand(
		context: CommandContext
	): Promise<CommandResponse[]> {
		const trimmed = (context.message || '').trim();
		const tokens = trimmed.split(/\s+/);
		const rawAction = (tokens[1] || '').toLowerCase();
		const action = ACTION_ALIASES[rawAction] || rawAction;
		// Métrica: uso de alias (info/mias/todas)
		try {
			if (rawAction === 'info') {
				Metrics.inc('commands_alias_used_total', 1, { action: 'info' });
			} else if (rawAction === 'mias' || rawAction === 'mías') {
				Metrics.inc('commands_alias_used_total', 1, { action: 'mias' });
			} else if (rawAction === 'todas' || rawAction === 'todos') {
				Metrics.inc('commands_alias_used_total', 1, { action: 'todas' });
			}
		} catch {}
		// Refrescar métricas agregadas de onboarding tras cualquier comando (para conversión)
		try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}

		// Usar formatDDMM desde utils/formatting

		// TZ y "hoy" en TZ para marcar vencidas en listados
		const todayYMD = todayYMD_TZ();

		if (!action || action === 'ayuda') {
			const feature = String(process.env.FEATURE_HELP_V2 ?? 'true').toLowerCase();
			const helpV2Enabled = !['false', '0', 'no'].includes(feature);

			const isAdvanced = (tokens[2] || '').toLowerCase() === 'avanzada';

			// Fallback legacy (Help v1)
			if (!helpV2Enabled) {
				if (isAdvanced) {
					const adv = [
						'*Ayuda avanzada:*',
						'Comandos y alias:',
						'  · Crear: `n`, `nueva`, `crear`, `+`',
						'  · Ver: `ver`, `mostrar`, `listar`, `ls` (opciones: `grupo` | `mis` | `todos` | `sin`)',
						'  · Completar: `x`, `hecho`, `completar`, `done` (acepta varios IDs: "`/t x 14 19 24`" o "`/t x 14,19,24`"; máximo 10)',
						'  · Tomar: `tomar`, `claim` (acepta varios IDs: "`/t tomar 12 19 50`" o "`/t tomar 12,19,50`"; máximo 10)',
						'  · Soltar: `soltar`, `unassign`',
						'Preferencias:',
						'  · `/t configurar diario|l-v|semanal|off [HH:MM]` (por defecto _08:30_; semanal: _lunes_; l-v: lunes a viernes)',
						'Notas:',
						'  · En grupos, el bot responde por DM (no publica en el grupo).',
						'  · Si creas una tarea en un grupo y no mencionas a nadie → “sin responsable”; en DM → se asigna a quien la crea.',
						'  · Fechas dd/MM con ⚠️ si está vencida.',
						'  · Mostramos los IDs de las tareas con 4 dígitos, pero puedes escribirlos sin ceros (p. ej., 26).',
					].join('\n');
					return [{
						recipient: context.sender,
						message: adv
					}];
				}
				const help = [
					'Guía rápida:',
					'- Crear: `/t n Descripción 2028-11-26 @Ana`',
					'- Ver grupo: `/t ver` (en el grupo)',
					'- Ver mis tareas: `/t ver mis` (por DM)',
					'- Ver todas: `/t ver todas` (por DM)',
					'- Completar: `/t x 123` (máx. 10)',
					'- Tomar: `/t tomar 12` (máx. 10)',
					'- Configurar recordatorios: `/t configurar diario|l-v|semanal|off [HH:MM]`',
					'- Ayuda avanzada: `/t ayuda avanzada`'
				].join('\n');
				return [{
					recipient: context.sender,
					message: help
				}];
			}

			// Help v2
			if (isAdvanced) {
				return [{
					recipient: context.sender,
					message: getFullHelp()
				}];
			}
			const quick = getQuickHelp();
			const msg = [quick, '', `Ayuda avanzada: ${code('/t ayuda avanzada')}`].join('\n');
			return [{
				recipient: context.sender,
				message: msg
			}];
		}

		// Listar pendientes
		if (action === 'ver') {
			const scopeRaw = (tokens[2] || '').toLowerCase();
			const scope = scopeRaw
				? (SCOPE_ALIASES[scopeRaw] || scopeRaw)
				: ((rawAction === 'mias' || rawAction === 'mías') ? 'mis' : ((rawAction === 'todas' || rawAction === 'todos') ? 'todos' : 'todos'));
			const LIMIT = 10;

			// En grupos: no listamos; responder por DM con transición
			if (isGroupId(context.groupId)) {
				try { Metrics.inc('ver_dm_transition_total'); } catch {}
				return [{
					recipient: context.sender,
					message: 'No respondo en grupos. Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web'
				}];
			}


			// Ver todos: "tus tareas" + "sin responsable" de grupos donde eres miembro activo (snapshot fresca)
			if (scope === 'todos') {
				const sections: string[] = [];

				// Encabezado fijo para la sección de tareas del usuario
				sections.push(bold('Tus tareas'));

				// Tus tareas (mis)
				const myItems = TaskService.listUserPending(context.sender, LIMIT);
				if (myItems.length > 0) {
					// Agrupar por grupo como en "ver mis"
					const byGroup = new Map<string, typeof myItems>();
					for (const t of myItems) {
						const key = t.group_id || '(sin grupo)';
						const arr = byGroup.get(key) || [];
						arr.push(t);
						byGroup.set(key, arr);
					}

					for (const [groupId, arr] of byGroup.entries()) {
						const groupName =
							(groupId && GroupSyncService.activeGroupsCache.get(groupId)) ||
							(groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo');

						sections.push(groupName);
						const rendered = await Promise.all(arr.map(async (t) => {
							const names = await Promise.all(
								(t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid)
							);
							const owner =
								(t.assignees?.length || 0) === 0
									? `${ICONS.unassigned} sin responsable`
									: `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`;
							const isOverdue = t.due_date ? t.due_date < todayYMD : false;
							const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : '';
							return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`;
						}));
						sections.push(...rendered);
						sections.push('');
					}

					// Quitar línea en blanco final si procede
					if (sections.length > 0 && sections[sections.length - 1] === '') {
						sections.pop();
					}

					const totalMy = TaskService.countUserPending(context.sender);
					if (totalMy > myItems.length) {
						sections.push(`… y ${totalMy - myItems.length} más`);
					}
				} else {
					sections.push(italic('_No tienes tareas pendientes._'));
				}

				// En contexto de grupo: mantener compatibilidad mostrando solo "sin responsable" del grupo actual
				if (isGroupId(context.groupId)) {
					if (!GroupSyncService.isGroupActive(context.groupId)) {
						sections.push('⚠️ _Este grupo no está activo._');
					} else {
						const groupName = GroupSyncService.activeGroupsCache.get(context.groupId) || context.groupId;
						const unassigned = TaskService.listGroupUnassigned(context.groupId, LIMIT);
						if (unassigned.length > 0) {
							if (sections.length && sections[sections.length - 1] !== '') sections.push('');
							sections.push(`${groupName} — Sin responsable`);
							const renderedUnassigned = unassigned.map((t) => {
								const isOverdue = t.due_date ? t.due_date < todayYMD : false;
								const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : '';
								return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${ICONS.unassigned}`;
							});
							sections.push(...renderedUnassigned);

							const totalUnassigned = TaskService.countGroupUnassigned(context.groupId);
							if (totalUnassigned > unassigned.length) {
								sections.push(`… y ${totalUnassigned - unassigned.length} más`);
							}
						} else {
							sections.push(`${groupName} — Sin responsable\n_(no hay tareas sin responsable)_`);
						}
					}
				} else {
					// En DM: usar membresía real (snapshot fresca) para incluir "sin responsable" por grupo
					const memberGroups = GroupSyncService.getFreshMemberGroupsForUser(context.sender);
					if (memberGroups.length > 0) {
						const perGroup = TaskService.listUnassignedByGroups(memberGroups, LIMIT);
						for (const gid of perGroup.keys()) {
							const unassigned = perGroup.get(gid)!;
							const groupName =
								(gid && GroupSyncService.activeGroupsCache.get(gid)) ||
								gid;

							if (unassigned.length > 0) {
								if (sections.length && sections[sections.length - 1] !== '') sections.push('');
								sections.push(`${groupName} — Sin responsable`);
								const renderedUnassigned = unassigned.map((t) => {
									const isOverdue = t.due_date ? t.due_date < todayYMD : false;
									const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : '';
									return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${ICONS.unassigned}`;
								});
								sections.push(...renderedUnassigned);

								const totalUnassigned = TaskService.countGroupUnassigned(gid);
								if (totalUnassigned > unassigned.length) {
									sections.push(`… y ${totalUnassigned - unassigned.length} más`);
								}
							}
						}
					} else {
						// Si no hay snapshot fresca de membresía, mantenemos una nota instructiva mínima
						sections.push('ℹ️ Para ver tareas sin responsable, escribe por privado `/t todas` o usa `/t web`.');
					}
				}

				return [{
					recipient: context.sender,
					message: sections.join('\n')
				}];
			}


			// Ver mis
			const items = TaskService.listUserPending(context.sender, LIMIT);
			if (items.length === 0) {
				return [{
					recipient: context.sender,
					message: italic('No tienes tareas pendientes.')
				}];
			}

			const total = TaskService.countUserPending(context.sender);

			// Agrupar por grupo
			const byGroup = new Map<string, typeof items>();
			for (const t of items) {
				const key = t.group_id || '(sin grupo)';
				const arr = byGroup.get(key) || [];
				arr.push(t);
				byGroup.set(key, arr);
			}

			const sections: string[] = [bold('Tus tareas')];
			for (const [groupId, arr] of byGroup.entries()) {
				const groupName =
					(groupId && GroupSyncService.activeGroupsCache.get(groupId)) ||
					(groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo');

				sections.push(groupName);
				const rendered = await Promise.all(arr.map(async (t) => {
					const names = await Promise.all(
						(t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid)
					);
					const owner =
						(t.assignees?.length || 0) === 0
							? `${ICONS.unassigned}`
							: `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`;
					const isOverdue = t.due_date ? t.due_date < todayYMD : false;
					const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : '';
					return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`;
				}));
				sections.push(...rendered);
				sections.push('');
			}

			// Quitar línea en blanco final si procede
			if (sections.length > 0 && sections[sections.length - 1] === '') {
				sections.pop();
			}

			if (total > items.length) {
				sections.push(`… y ${total - items.length} más`);
			}

			return [{
				recipient: context.sender,
				message: sections.join('\n')
			}];
		}

		// Completar tarea (con validación opcional de membresía)
		if (action === 'completar') {
			// Soportar múltiples IDs separados por espacios y/o comas
			const rawIds = (tokens.slice(2).join(' ') || '').trim();
			const parsedList = Array.from(new Set(
				rawIds
					.split(/[,\s]+/)
					.map(t => t.trim())
					.filter(Boolean)
					.map(t => parseInt(t, 10))
					.filter(n => Number.isFinite(n) && n > 0)
			));
			const MAX_IDS = 10;
			const truncated = parsedList.length > MAX_IDS;
			const ids = parsedList.slice(0, MAX_IDS);

			// Sin IDs: ayuda de uso
			if (ids.length === 0) {
				return [{
					recipient: context.sender,
					message: 'ℹ️ Uso: `/t x 26` o múltiples: `/t x 14 19 24` o `/t x 14,19,24` (máx. 10)'
				}];
			}

			// Caso de 1 ID: mantener comportamiento actual
			if (ids.length === 1) {
				const idInput = ids[0];
				const resolvedId = resolveTaskIdFromInput(idInput);
				if (!resolvedId) {
					return [{
						recipient: context.sender,
						message: `⚠️ Tarea ${codeId(idInput)} no encontrada.`
					}];
				}

				const task = TaskService.getTaskById(resolvedId);
				if (!task) {
					return [{
						recipient: context.sender,
						message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
					}];
				}
				const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true';
				if (task && task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
					return [{
						recipient: context.sender,
						message: 'No puedes completar esta tarea porque no eres de este grupo.'
					}];
				}

				const res = TaskService.completeTask(resolvedId, context.sender);
				const who = (await ContactsService.getDisplayName(context.sender)) || context.sender;
				if (res.status === 'not_found') {
					return [{
						recipient: context.sender,
						message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
					}];
				}
				if (res.status === 'already') {
					const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
					return [{
						recipient: context.sender,
						message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} _Ya estaba completada_ — ${res.task?.description || '(sin descripción)'}${due}`
					}];
				}

				const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
				return [{
					recipient: context.sender,
					message: `${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} _completada_ — ${res.task?.description || '(sin descripción)'}${due}`
				}];
			}

			// Modo múltiple
			const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true';
			let cntUpdated = 0, cntAlready = 0, cntNotFound = 0, cntBlocked = 0;
			const lines: string[] = [];

			if (truncated) {
				lines.push('⚠️ Se procesarán solo los primeros 10 IDs.');
			}

			for (const idInput of ids) {
				const resolvedId = resolveTaskIdFromInput(idInput);
				if (!resolvedId) {
					lines.push(`⚠️ ${codeId(idInput)} no encontrada.`);
					cntNotFound++;
					continue;
				}

				const task = TaskService.getTaskById(resolvedId);
				if (task && task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
					lines.push(`🚫 ${codeId(resolvedId)} — no permitido (no eres miembro activo).`);
					cntBlocked++;
					continue;
				}

				const res = TaskService.completeTask(resolvedId, context.sender);
				const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
				if (res.status === 'already') {
					lines.push(`ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`);
					cntAlready++;
				} else if (res.status === 'updated') {
					lines.push(`${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} completada — ${res.task?.description || '(sin descripción)'}${due}`);
					cntUpdated++;
				} else if (res.status === 'not_found') {
					lines.push(`⚠️ ${codeId(resolvedId)} no encontrada.`);
					cntNotFound++;
				}
			}

			// Resumen final
			const summary: string[] = [];
			if (cntUpdated) summary.push(`completadas ${cntUpdated}`);
			if (cntAlready) summary.push(`ya estaban ${cntAlready}`);
			if (cntNotFound) summary.push(`no encontradas ${cntNotFound}`);
			if (cntBlocked) summary.push(`bloqueadas ${cntBlocked}`);
			if (summary.length) {
				lines.push('');
				lines.push(`Resumen: ${summary.join(', ')}.`);
			}

			return [{
				recipient: context.sender,
				message: lines.join('\n')
			}];
		}

		// Tomar tarea (con validación opcional de membresía)
		if (action === 'tomar') {
			// Soportar múltiples IDs separados por espacios y/o comas
			const rawIds = (tokens.slice(2).join(' ') || '').trim();
			const parsedList = Array.from(new Set(
				rawIds
					.split(/[,\s]+/)
					.map(t => t.trim())
					.filter(Boolean)
					.map(t => parseInt(t, 10))
					.filter(n => Number.isFinite(n) && n > 0)
			));
			const MAX_IDS = 10;
			const truncated = parsedList.length > MAX_IDS;
			const ids = parsedList.slice(0, MAX_IDS);

			// Sin IDs: ayuda de uso
			if (ids.length === 0) {
				return [{
					recipient: context.sender,
					message: 'ℹ️ Uso: `/t tomar 26` o múltiples: `/t tomar 12 19 50` o `/t tomar 12,19,50` (máx. 10)'
				}];
			}

			// Caso de 1 ID: mantener comportamiento actual
			if (ids.length === 1) {
				const idInput = ids[0];

				const resolvedId = resolveTaskIdFromInput(idInput);
				if (!resolvedId) {
					return [{
						recipient: context.sender,
						message: `⚠️ Tarea ${codeId(idInput)} no encontrada.`
					}];
				}

				const task = TaskService.getTaskById(resolvedId);
				if (!task) {
					return [{
						recipient: context.sender,
						message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
					}];
				}
				const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true';
				if (task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
					return [{
						recipient: context.sender,
						message: 'No puedes tomar esta tarea porque no eres de este grupo.'
					}];
				}

				const res = TaskService.claimTask(resolvedId, context.sender);
				const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';

				if (res.status === 'not_found') {
					return [{
						recipient: context.sender,
						message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
					}];
				}
				if (res.status === 'completed') {
					return [{
						recipient: context.sender,
						message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`
					}];
				}
				if (res.status === 'already') {
					return [{
						recipient: context.sender,
						message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}`
					}];
				}

				const lines = [
					italic(`${ICONS.take} Has tomado ${codeId(resolvedId, res.task?.display_code)}`),
					`${res.task?.description || '(sin descripción)'}`,
					res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''
				].filter(Boolean);
				return [{
					recipient: context.sender,
					message: lines.join('\n')
				}];
			}

			// Modo múltiple
			const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true';
			let cntClaimed = 0, cntAlready = 0, cntCompleted = 0, cntNotFound = 0, cntBlocked = 0;
			const lines: string[] = [];

			if (truncated) {
				lines.push('⚠️ Se procesarán solo los primeros 10 IDs.');
			}

			for (const idInput of ids) {
				const resolvedId = resolveTaskIdFromInput(idInput);
				if (!resolvedId) {
					lines.push(`⚠️ ${codeId(idInput)} no encontrada.`);
					cntNotFound++;
					continue;
				}

				const task = TaskService.getTaskById(resolvedId);
				if (task && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
					lines.push(`🚫 ${codeId(resolvedId)} — no permitido (no eres miembro activo).`);
					cntBlocked++;
					continue;
				}

				const res = TaskService.claimTask(resolvedId, context.sender);
				const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
				if (res.status === 'already') {
					lines.push(`ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}`);
					cntAlready++;
				} else if (res.status === 'claimed') {
					lines.push(`${ICONS.take} ${codeId(resolvedId, res.task?.display_code)} tomada — ${res.task?.description || '(sin descripción)'}${due}`);
					cntClaimed++;
				} else if (res.status === 'completed') {
					lines.push(`ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`);
					cntCompleted++;
				} else if (res.status === 'not_found') {
					lines.push(`⚠️ ${codeId(resolvedId)} no encontrada.`);
					cntNotFound++;
				}
			}

			// Resumen final
			const summary: string[] = [];
			if (cntClaimed) summary.push(`tomadas ${cntClaimed}`);
			if (cntAlready) summary.push(`ya las tenías ${cntAlready}`);
			if (cntCompleted) summary.push(`ya completadas ${cntCompleted}`);
			if (cntNotFound) summary.push(`no encontradas ${cntNotFound}`);
			if (cntBlocked) summary.push(`bloqueadas ${cntBlocked}`);
			if (summary.length) {
				lines.push('');
				lines.push(`Resumen: ${summary.join(', ')}.`);
			}

			return [{
				recipient: context.sender,
				message: lines.join('\n')
			}];
		}

		// Soltar tarea (con validación opcional de membresía)
		if (action === 'soltar') {
			const idToken = tokens[2];
			const idInput = idToken ? parseInt(idToken, 10) : NaN;
			if (!idInput || Number.isNaN(idInput)) {
				return [{
					recipient: context.sender,
					message: 'ℹ️ Uso: `/t soltar 26`'
				}];
			}

			const resolvedId = resolveTaskIdFromInput(idInput);
			if (!resolvedId) {
				return [{
					recipient: context.sender,
					message: `⚠️ Tarea ${codeId(idInput)} no encontrada.`
				}];
			}

			const task = TaskService.getTaskById(resolvedId);
			if (!task) {
				return [{
					recipient: context.sender,
					message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
				}];
			}
			const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true';
			if (task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
				return [{
					recipient: context.sender,
					message: '⚠️ No puedes soltar esta tarea porque no eres de este grupo.'
				}];
			}

			const res = TaskService.unassignTask(resolvedId, context.sender);
			const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';

			if (res.status === 'forbidden_personal') {
				return [{
					recipient: context.sender,
					message: '⚠️ No puedes soltar una tarea personal. Márcala como completada para eliminarla'
				}];
			}

			if (res.status === 'not_found') {
				return [{
					recipient: context.sender,
					message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
				}];
			}
			if (res.status === 'completed') {
				return [{
					recipient: context.sender,
					message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`
				}];
			}
			if (res.status === 'not_assigned') {
				return [{
					recipient: context.sender,
					message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} no la tenías asignada — ${res.task?.description || '(sin descripción)'}${due}`
				}];
			}

			if (res.now_unassigned) {
				const lines = [
					`${ICONS.unassigned} ${codeId(resolvedId, res.task?.display_code)}`,
					`${res.task?.description || '(sin descripción)'}`,
					res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '',
					italic('queda sin responsable.')
				].filter(Boolean);
				return [{
					recipient: context.sender,
					message: lines.join('\n')
				}];
			}

			const lines = [
				`${ICONS.unassign} ${codeId(resolvedId, res.task?.display_code)}`,
				`${res.task?.description || '(sin descripción)'}`,
				res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''
			].filter(Boolean);
			return [{
				recipient: context.sender,
				message: lines.join('\n')
			}];
		}

		if (action === 'configurar') {
			const optRaw = (tokens[2] || '').toLowerCase();
			const map: Record<string, 'daily' | 'weekly' | 'off' | 'weekdays'> = {
				'daily': 'daily',
				'diario': 'daily',
				'diaria': 'daily',
				'l-v': 'weekdays',
				'lv': 'weekdays',
				'laborables': 'weekdays',
				'weekdays': 'weekdays',
				'semanal': 'weekly',
				'weekly': 'weekly',
				'off': 'off',
				'apagar': 'off',
				'ninguno': 'off'
			};
			const freq = map[optRaw];

			// Hora opcional HH:MM
			const timeRaw = tokens[3] || '';
			let timeNorm: string | null = null;
			if (timeRaw) {
				const m = /^(\d{1,2}):([0-5]\d)$/.exec(timeRaw);
				if (!m) {
					return [{
						recipient: context.sender,
						message: 'ℹ️ Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`'
					}];
				}
				const hh = Math.max(0, Math.min(23, parseInt(m[1], 10)));
				timeNorm = `${String(hh).padStart(2, '0')}:${m[2]}`;
			}

			if (!freq) {
				return [{
					recipient: context.sender,
					message: 'ℹ️ Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`'
				}];
			}
			const ensured = ensureUserExists(context.sender, this.dbInstance);
			if (!ensured) {
				throw new Error('No se pudo asegurar el usuario');
			}
			this.dbInstance.prepare(`
        INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
        VALUES (?, ?, COALESCE(?, COALESCE((SELECT reminder_time FROM user_preferences WHERE user_id = ?), '08:30')), NULL, strftime('%Y-%m-%d %H:%M:%f', 'now'))
        ON CONFLICT(user_id) DO UPDATE SET
          reminder_freq = excluded.reminder_freq,
          reminder_time = CASE WHEN ? IS NOT NULL THEN excluded.reminder_time ELSE reminder_time END,
          updated_at = excluded.updated_at
      `).run(ensured, freq, timeNorm, ensured, timeNorm);

			let label: string;
			if (freq === 'daily') {
				label = timeNorm ? `diario (${timeNorm})` : 'diario';
			} else if (freq === 'weekdays') {
				label = timeNorm ? `laborables (lunes a viernes ${timeNorm})` : 'laborables (lunes a viernes)';
			} else if (freq === 'weekly') {
				label = timeNorm ? `semanal (lunes ${timeNorm})` : 'semanal (lunes 08:30)';
			} else {
				label = 'apagado';
			}

			return [{
				recipient: context.sender,
				message: `✅ Recordatorios: ${label}`
			}];
		}

		// Enlace de acceso a la web (/t web)
		if (action === 'web') {
			// Solo por DM
			if (isGroupId(context.groupId)) {
				return [{
					recipient: context.sender,
					message: 'ℹ️ Este comando se usa por privado. Envíame `/t web` por DM.'
				}];
			}

			const base = (process.env.WEB_BASE_URL || '').trim();
			if (!base) {
				return [{
					recipient: context.sender,
					message: '⚠️ La web no está configurada todavía. Contacta con el administrador (falta WEB_BASE_URL).'
				}];
			}

			const ensured = ensureUserExists(context.sender, this.dbInstance);
			if (!ensured) {
				throw new Error('No se pudo asegurar el usuario');
			}

			const toIso = (d: Date) => d.toISOString().replace('T', ' ').replace('Z', '');
			const now = new Date();
			const nowIso = toIso(now);
			const expiresIso = toIso(new Date(now.getTime() + 10 * 60 * 1000)); // 10 minutos

			// Invalidar tokens vigentes (uso único)
			this.dbInstance.prepare(`
        UPDATE web_tokens
        SET used_at = ?
        WHERE user_id = ?
          AND used_at IS NULL
          AND expires_at > ?
      `).run(nowIso, ensured, nowIso);

			// Generar nuevo token y guardar solo el hash
			const token = randomTokenBase64Url(32);
			const tokenHash = await sha256Hex(token);

			this.dbInstance.prepare(`
        INSERT INTO web_tokens (user_id, token_hash, expires_at, metadata)
        VALUES (?, ?, ?, NULL)
      `).run(ensured, tokenHash, expiresIso);

			try { Metrics.inc('web_tokens_issued_total'); } catch { }

			const url = new URL(`/login?token=${encodeURIComponent(token)}`, base).toString();
			return [{
				recipient: context.sender,
				message: `Acceso web: ${url}\nVálido durante 10 minutos. Si caduca, vuelve a enviar "/t web".`
			}];
		}

		if (action !== 'nueva') {
			const feature = String(process.env.FEATURE_HELP_V2 ?? 'true').toLowerCase();
			const helpV2Enabled = !['false', '0', 'no'].includes(feature);

			try { Metrics.inc('commands_unknown_total'); } catch { }
			if (!helpV2Enabled) {
				return [{
					recipient: context.sender,
					message: `Acción ${rawAction || '(vacía)'} no implementada aún`
				}];
			}
			const header = `❓ ${section('Comando no reconocido')}`;
			const cta = `Prueba ${code('/t ayuda')}`;
			const help = getQuickHelp();
			return [{
				recipient: context.sender,
				message: [header, cta, '', help].join('\n')
			}];
		}

		// Parseo específico de "nueva"
		// Normalizar menciones del contexto para parseo y asignaciones (A2: fallback a números plausibles)
		const MIN_FALLBACK_DIGITS = (() => {
			const raw = (process.env.ONBOARDING_FALLBACK_MIN_DIGITS || '').trim();
			const n = parseInt(raw || '8', 10);
			return Number.isFinite(n) && n > 0 ? n : 8;
		})();
		const MAX_FALLBACK_DIGITS = (() => {
			const raw = (process.env.ONBOARDING_FALLBACK_MAX_DIGITS || '').trim();
			const n = parseInt(raw || '15', 10);
			return Number.isFinite(n) && n > 0 ? n : 15;
		})();

		type FailReason = 'non_numeric' | 'too_short' | 'too_long' | 'from_lid' | 'invalid';
		const isDigits = (s: string) => /^\d+$/.test(s);
		const plausibility = (s: string, opts?: { fromLid?: boolean }): { ok: boolean; reason?: FailReason } => {
			if (!s) return { ok: false, reason: 'invalid' };
			if (opts?.fromLid) return { ok: false, reason: 'from_lid' };
			if (!isDigits(s)) return { ok: false, reason: 'non_numeric' };
			if (s.length < MIN_FALLBACK_DIGITS) return { ok: false, reason: 'too_short' };
			if (s.length >= MAX_FALLBACK_DIGITS) return { ok: false, reason: 'too_long' };
			return { ok: true };
		};
		const incOnboardingFailure = (source: 'mentions' | 'tokens', reason: FailReason) => {
			try {
				const gid = isGroupId(context.groupId) ? context.groupId : 'dm';
				Metrics.inc('onboarding_assign_failures_total', 1, { group_id: String(gid), source, reason });
			} catch { }
		};

		// 1) Menciones aportadas por el backend (JIDs crudos)
		const unresolvedAssigneeDisplays: string[] = [];
		const mentionsNormalizedFromContext = Array.from(new Set(
			(context.mentions || []).map((j) => {
				const norm = normalizeWhatsAppId(j);
				if (!norm) {
					// agregar a no resolubles para JIT (mostrar sin @ ni dominio)
					const raw = String(j || '');
					const disp = raw.split('@')[0].split(':')[0].replace(/^@+/, '').replace(/^\+/, '');
					if (disp) unresolvedAssigneeDisplays.push(disp);
					incOnboardingFailure('mentions', 'invalid');
					return null;
				}
				const resolved = IdentityService.resolveAliasOrNull(norm);
				if (resolved) return resolved;
				// detectar si la mención proviene de un JID @lid (no plausible aunque sea numérico)
				const dom = String(j || '').split('@')[1]?.toLowerCase() || '';
				const fromLid = dom.includes('lid');
				const p = plausibility(norm, { fromLid });
				if (p.ok) return norm;
				// conservar para copy JIT
				unresolvedAssigneeDisplays.push(norm);
				incOnboardingFailure('mentions', p.reason!);
				return null;
			}).filter((id): id is string => !!id)
		));

		// 2) Tokens de texto que empiezan por '@' como posibles asignados
		const atTokenCandidates = tokens.slice(2)
			.filter(t => t.startsWith('@'))
			.map(t => t.replace(/^@+/, '').replace(/^\+/, '').replace(/[.,;:!?)\]}¿¡"'’”]+$/, ''));
		const normalizedFromAtTokens = Array.from(new Set(
			atTokenCandidates.map((v) => {
				// Token especial: '@yo' (capturado como 'yo' tras limpiar '@') => autoasignación; no cuenta como fallo
				if (String(v).toLowerCase() === 'yo') {
					return null;
				}
				const norm = normalizeWhatsAppId(v);
				if (!norm) {
					// agregar a no resolubles para JIT (texto ya viene sin @/+)
					if (v) unresolvedAssigneeDisplays.push(v);
					incOnboardingFailure('tokens', 'invalid');
					return null;
				}
				const resolved = IdentityService.resolveAliasOrNull(norm);
				if (resolved) return resolved;
				const p = plausibility(norm, { fromLid: false });
				if (p.ok) return norm;
				// conservar para copy JIT (preferimos el token limpio v)
				unresolvedAssigneeDisplays.push(v);
				incOnboardingFailure('tokens', p.reason!);
				return null;
			}).filter((id): id is string => !!id)
		));

		// 3) Unir y deduplicar
		const combinedAssigneeCandidates = Array.from(new Set([
			...mentionsNormalizedFromContext,
			...normalizedFromAtTokens
		]));

		if (process.env.NODE_ENV !== 'test') {
			console.log('[A0] /t nueva menciones', {
				context_mentions: context.mentions || [],
				mentions_normalized: mentionsNormalizedFromContext,
				at_tokens: atTokenCandidates,
				at_normalized: normalizedFromAtTokens,
				combined: combinedAssigneeCandidates
			});
		}

		const { description, dueDate, selfAssign } = parseNueva(trimmed, mentionsNormalizedFromContext);

		// Asegurar creador
		const createdBy = ensureUserExists(context.sender, this.dbInstance);
		if (!createdBy) {
			throw new Error('No se pudo asegurar el usuario creador');
		}

		// Normalizar menciones y excluir duplicados y el número del bot
		const botNumber = process.env.CHATBOT_PHONE_NUMBER || '';
		const assigneesNormalized = Array.from(new Set(
			[
				...(selfAssign ? [context.sender] : []),
				...combinedAssigneeCandidates
			].filter(id => !botNumber || id !== botNumber)
		));

		// Asegurar usuarios asignados
		const ensuredAssignees = assigneesNormalized
			.map(id => ensureUserExists(id, this.dbInstance))
			.filter((id): id is string => !!id);

		// Asignación por defecto según contexto:
		// - En grupos: si no hay menciones → sin dueño (ningún asignado)
		// - En DM: si no hay menciones → asignada al creador
		let assignmentUserIds: string[] = [];
		if (ensuredAssignees.length > 0) {
			assignmentUserIds = ensuredAssignees;
		} else {
			assignmentUserIds = (context.groupId && isGroupId(context.groupId)) ? [] : [createdBy];
		}

		// Definir group_id solo si el grupo está activo
		const groupIdToUse = (context.groupId && GroupSyncService.isGroupActive(context.groupId))
			? context.groupId
			: null;

		// Crear tarea y asignaciones
		const taskId = TaskService.createTask(
			{
				description: description || '',
				due_date: dueDate ?? null,
				group_id: groupIdToUse,
				created_by: createdBy,
			},
			assignmentUserIds.map(uid => ({
				user_id: uid,
				assigned_by: createdBy,
			}))
		);

		// Registrar origen del comando para esta tarea (Fase 1)
		// Registrar interacción del usuario (last_command_at)
		try {
			const ensured = ensureUserExists(context.sender, this.dbInstance);
			if (ensured) {
				try {
					this.dbInstance.prepare(`UPDATE users SET last_command_at = strftime('%Y-%m-%d %H:%M:%f','now') WHERE id = ?`).run(ensured);
				} catch {}
			}
		} catch {}
		try {
			if (groupIdToUse && isGroupId(groupIdToUse) && context.messageId) {
				const participant = typeof context.participant === 'string' ? context.participant : null;
				const fromMe = typeof context.fromMe === 'boolean' ? (context.fromMe ? 1 : 0) : null;
				try {
					this.dbInstance.prepare(`
          INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id, participant, from_me)
          VALUES (?, ?, ?, ?, ?)
        `).run(taskId, groupIdToUse, context.messageId, participant, fromMe);
				} catch {
					this.dbInstance.prepare(`
          INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id)
          VALUES (?, ?, ?)
        `).run(taskId, groupIdToUse, context.messageId);
				}
			}
		} catch { }

		// Recuperar la tarea creada para obtener display_code asignado
		const createdTask = TaskService.getTaskById(taskId);

		const mentionsForSending = assignmentUserIds.map(uid => `${uid}@s.whatsapp.net`);

		// Resolver nombres útiles
		const creatorName = await ContactsService.getDisplayName(createdBy);
		const creatorJid = `${createdBy}@s.whatsapp.net`;
		const groupName = groupIdToUse ? GroupSyncService.activeGroupsCache.get(groupIdToUse) : null;

		const assignedDisplayNames = await Promise.all(
			assignmentUserIds.map(async uid => {
				const name = await ContactsService.getDisplayName(uid);
				return name || uid;
			})
		);

		const responses: CommandResponse[] = [];

		// 1) Ack al creador con formato compacto
		const dueFmt = formatDDMM(dueDate);
		const ownerPart = assignmentUserIds.length === 0
			? `${ICONS.unassigned} ${groupName ? ` (${groupName})` : ''}`
			: `${assignmentUserIds.length > 1 ? '👥' : '👤'} ${assignedDisplayNames.join(', ')}`;
		const ackLines = [
			`${ICONS.create} ${codeId(taskId, createdTask?.display_code)} ${description || '(sin descripción)'}`,
			dueFmt ? `${ICONS.date} ${dueFmt}` : null,
			ownerPart
		].filter(Boolean);
		responses.push({
			recipient: createdBy,
			message: [ackLines.join('\n'), '', CommandService.CTA_HELP].join('\n'),
			mentions: mentionsForSending.length > 0 ? mentionsForSending : undefined
		});

		// 2) DM a cada asignado (excluyendo al creador para evitar duplicados)
		for (const uid of assignmentUserIds) {
			if (uid === createdBy) continue;
			responses.push({
				recipient: uid,
				message: [
					`${ICONS.assignNotice} ${codeId(taskId, createdTask?.display_code)}`,
					`${description || '(sin descripción)'}`,
					formatDDMM(dueDate) ? `${ICONS.date} ${formatDDMM(dueDate)}` : null,
					groupName ? `Grupo: ${groupName}` : null,
					`- Completar: \`/t x ${createdTask?.display_code}\``,
					`- Soltar: \`/t soltar ${createdTask?.display_code}\``
				].filter(Boolean).join('\n') + '\n\n' + CommandService.CTA_HELP,
				mentions: [creatorJid]
			});
		}

		// A4: DM JIT al asignador si quedaron menciones/tokens irrecuperables
		{
			const unresolvedList = Array.from(new Set(unresolvedAssigneeDisplays.filter(Boolean)));
			if (unresolvedList.length > 0) {
				const isTest = String(process.env.NODE_ENV || '').toLowerCase() === 'test';
				const enabled = isTest
					? String(process.env.ONBOARDING_ENABLE_IN_TEST || '').toLowerCase() === 'true'
					: (() => {
						const v = process.env.ONBOARDING_PROMPTS_ENABLED;
						return v == null ? true : ['true', '1', 'yes'].includes(String(v).toLowerCase());
					})();
				const groupLabel = isGroupId(context.groupId) ? String(context.groupId) : 'dm';
				if (!enabled) {
					try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'disabled' }); } catch { }
				} else {
					const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
					if (!bot || !/^\d+$/.test(bot)) {
						try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'missing_bot_number' }); } catch { }
					} else {
						const list = unresolvedList.join(', ');
						let groupCtx = '';
						if (isGroupId(context.groupId)) {
							const name = GroupSyncService.activeGroupsCache.get(context.groupId) || context.groupId;
							groupCtx = ` (en el grupo ${name})`;
						}
						const msg = `No puedo asignar a ${list} aún${groupCtx}. Pídele que toque este enlace y diga 'activar': https://wa.me/${bot}`;
						responses.push({ recipient: createdBy, message: msg });
						try { Metrics.inc('onboarding_prompts_sent_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure' }); } catch { }
					}
				}
			}
		}

		// Fase 2: disparar paquete de onboarding (2 DMs) tras crear tarea en grupo
		try {
			const isTest = String(process.env.NODE_ENV || '').toLowerCase() === 'test';
			const enabledBase = ['true','1','yes','on'].includes(String(process.env.ONBOARDING_DM_ENABLED || '').toLowerCase());
			const enabled = enabledBase && (!isTest || String(process.env.ONBOARDING_ENABLE_IN_TEST || '').toLowerCase() === 'true');
			const gid = groupIdToUse || (isGroupId(context.groupId) ? context.groupId : null);

			if (!enabled) {
				try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'disabled', group_id: String(gid || '') }); } catch {}
			} else if (!gid) {
				try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_group', group_id: String(context.groupId || '') }); } catch {}
			} else {
				// Gating enforce
				let allowed = true;
				try {
					const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
					if (mode === 'enforce') {
						try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
						allowed = AllowedGroups.isAllowed(gid);
					}
				} catch {}
				if (!allowed) {
					try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'not_allowed', group_id: String(gid) }); } catch {}
				} else {
					const displayCode = createdTask?.display_code;
					if (!(typeof displayCode === 'number' && Number.isFinite(displayCode))) {
						try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_display_code', group_id: String(gid) }); } catch {}
					} else {
						// Candidatos
						let members = GroupSyncService.listActiveMemberIds(gid);
						const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
						const exclude = new Set<string>([createdBy, ...assignmentUserIds]);
						members = members
							.filter(id => /^\d+$/.test(id) && id.length < 14)
							.filter(id => !exclude.has(id))
							.filter(id => !bot || id !== bot);

						if (members.length === 0) {
							try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_members', group_id: String(gid) }); } catch {}
						} else {
							const capRaw = Number(process.env.ONBOARDING_EVENT_CAP);
							const cap = Number.isFinite(capRaw) && capRaw > 0 ? Math.floor(capRaw) : 30;
							let recipients = members;
							if (recipients.length > cap) {
								try { Metrics.inc('onboarding_recipients_capped_total', recipients.length - cap, { group_id: String(gid) }); } catch {}
								recipients = recipients.slice(0, cap);
							}

							const cooldownRaw = Number(process.env.ONBOARDING_DM_COOLDOWN_DAYS);
							const cooldownDays = Number.isFinite(cooldownRaw) && cooldownRaw >= 0 ? Math.floor(cooldownRaw) : 14;
							const delayEnv = Number(process.env.ONBOARDING_BUNDLE_DELAY_MS);
							const delay2 = Number.isFinite(delayEnv) && delayEnv >= 0 ? Math.floor(delayEnv) : 5000 + Math.floor(Math.random() * 5001); // 5–10s por defecto

							const groupLabel = GroupSyncService.activeGroupsCache.get(gid) || gid;
							const codeStr = String(displayCode);
							const desc = (description || '(sin descripción)').trim();
							const shortDesc = desc.length > 100 ? (desc.slice(0, 100) + '…') : desc;

							const codeInline = codeId(taskId, displayCode);
							const cmdTake = code(`/t tomar ${padTaskId(displayCode)}`);
							const cmdInfo = code(`/t info`);
							const groupBold = bold(`‘${groupLabel}’`);

							const msg1 = `¡Hola!, soy el bot de tareas. En ${groupBold} acaban de crear una tarea: ${codeInline} _${shortDesc}_
- Para hacerte cargo: ${cmdTake}
- Más info: ${cmdInfo}
${ICONS.info} Solo escribo por privado.
${ICONS.info} Cuando reciba tu primer mensaje ya no te enviaré más este recordatorio`;

							const msg2 = `GUÍA RÁPIDA
Puedes interactuar escribiéndome por privado:
- Ver tus tareas: ${code('/t mias')}
- Ver todas: ${code('/t todas')}
- Recibe recordatorios: ${code('/t configurar diario|l-v|semanal|off')}
- Web: ${code('/t web')}`;

							for (const rcpt of recipients) {
								const stats = ResponseQueue.getOnboardingStats(rcpt);
								let variant: 'initial' | 'reminder' | null = null;

								if (!stats || (stats.total || 0) === 0) {
									variant = 'initial';
								} else if (stats.firstInitialAt) {
									let firstMs = NaN;
									try {
										const s = String(stats.firstInitialAt);
										const iso = s.includes('T') ? s : (s.replace(' ', 'T') + 'Z');
										firstMs = Date.parse(iso);
									} catch {}
									const nowMs = Date.now();
									const okCooldown = Number.isFinite(firstMs) ? (nowMs - firstMs) >= cooldownDays * 24 * 60 * 60 * 1000 : false;

									// Interacción del usuario desde el primer paquete
									let hadInteraction = false;
									try {
										const row = this.dbInstance.prepare(`SELECT last_command_at FROM users WHERE id = ?`).get(rcpt) as any;
										const lcRaw = row?.last_command_at ? String(row.last_command_at) : null;
										if (lcRaw) {
											const lcIso = lcRaw.includes('T') ? lcRaw : (lcRaw.replace(' ', 'T') + 'Z');
											const lcMs = Date.parse(lcIso);
											hadInteraction = Number.isFinite(lcMs) && Number.isFinite(firstMs) && lcMs > firstMs;
										}
									} catch {}

									if (okCooldown && !hadInteraction) {
										variant = 'reminder';
									} else {
										try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: hadInteraction ? 'had_interaction' : 'cooldown_active', group_id: String(gid) }); } catch {}
									}
								}

								if (!variant) continue;

								const bundleId = randomTokenBase64Url(12);
								try {
									ResponseQueue.enqueueOnboarding(rcpt, msg1, { variant, part: 1, bundle_id: bundleId, group_id: gid, task_id: taskId, display_code: displayCode }, 0);
									ResponseQueue.enqueueOnboarding(rcpt, msg2, { variant, part: 2, bundle_id: bundleId, group_id: gid, task_id: taskId, display_code: displayCode }, delay2);
									try { Metrics.inc('onboarding_bundle_sent_total', 1, { variant, group_id: String(gid) }); } catch {}
								} catch {}
							}
						}
					}
				}
			}
		} catch {}

		return responses;
	}

	static async handle(context: CommandContext): Promise<CommandResponse[]> {
		const outcome = await this.handleWithOutcome(context);
		return outcome.responses;
	}

	static async handleWithOutcome(context: CommandContext): Promise<CommandOutcome> {
		const msg = (context.message || '').trim();
		if (!/^\/(tarea|t)\b/i.test(msg)) {
			return { responses: [], ok: true };
		}

		// Registrar interacción del usuario (last_command_at) para cualquier comando /t …
		try {
			const ensured = ensureUserExists(context.sender, this.dbInstance);
			if (ensured) {
				try {
					this.dbInstance.prepare(`UPDATE users SET last_command_at = strftime('%Y-%m-%d %H:%M:%f','now') WHERE id = ?`).run(ensured);
				} catch {}
			}
		} catch {}

		// Gating de grupos en modo 'enforce' (cuando CommandService se invoca directamente)
		if (isGroupId(context.groupId)) {
			try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch { }
			const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
			if (mode === 'enforce') {
				try {
					if (!AllowedGroups.isAllowed(context.groupId)) {
						try { Metrics.inc('commands_blocked_total'); } catch { }
						return { responses: [], ok: true };
					}
				} catch {
					// Si falla el check, ser permisivos
				}
			}
		}

		try {
			const routed = await routeCommand(context, { db: this.dbInstance });
			const responses = routed ?? (await this.processTareaCommand(context));

			// Clasificación explícita del outcome (evita lógica en server)
			const tokens = msg.split(/\s+/);
			const rawAction = (tokens[1] || '').toLowerCase();
			const action = ACTION_ALIASES[rawAction] || rawAction;

			// Casos explícitos considerados éxito
			if (!action || action === 'ayuda' || action === 'web') {
				return { responses, ok: true };
			}

			const lowerMsgs = (responses || []).map(r => String(r?.message || '').toLowerCase());

			const isOkException = (m: string) =>
				m.includes('ya estaba completada') ||
				m.includes('ya la tenías') ||
				m.includes('no la tenías');

			const isErrorMsg = (m: string) =>
				m.startsWith('ℹ️ uso:'.toLowerCase()) ||
				m.includes('uso:') ||
				m.includes('no puedes') ||
				m.includes('no permitido') ||
				m.includes('no encontrada') ||
				m.includes('comando no reconocido');

			let hasError = false;
			for (const m of lowerMsgs) {
				if (isErrorMsg(m) && !isOkException(m)) {
					hasError = true;
					break;
				}
			}

			return { responses, ok: !hasError };
		} catch (error) {
			return {
				responses: [{
					recipient: context.sender,
					message: 'Error processing command'
				}],
				ok: false
			};
		}
	}
}
