// silverCourse — daily lesson dispatch, reminders, catch-up, inbound routing.
// Owns the user-facing flow once a mentee is on tier="silver".

import { existsSync, readdirSync } from 'fs';
import { join as pathJoin } from 'path';
import { sendText, createGroup, getGroupInviteLink } from './whatsappAdapter.js';
import { now, nowIso, zurichParts } from './clock.js';
import { journeyEvent } from './journey.js';
import {
  lessonById, loadTaskRegistry, taskById, tasksDueOnDay,
  loadSilver, saveSilver,
  nextLessonToSend, nextCatchupLesson,
  markLessonSent, markLessonNudged, markLessonComplete,
  recordTaskProof, markTaskAsked, isTaskRecorded,
  initSilver, pauseSilver, setPaymentStatus,
  detectLocaleFromPhone, resolveMessage,
  maybeMarkModule1Complete, maybeMarkModuleComplete,
  setDossierStatus, appendDossierTurn,
  nextDossierQuestion, dossierAnswers,
  setAccessLevel, mintWhatsappToken, ACCESS_LEVELS,
  markCoachJoined, findSilverByGroupJid,
  shouldSendStrategyNudge, recordStrategyNudge, detectStrategyOnDisk,
} from './silverStore.js';

const CATCHUP_CAP_PER_SESSION = 2;

// ── Tick: called by scheduler at slot 8/14/18 (or test-mode trigger) ─────────

export async function tickSilverUser(scriptDir, slug, slot) {
  const state = loadSilver(scriptDir, slug);
  if (!state) return { skipped: 'no-state' };

  if (slot === 8)  return await morningSend(scriptDir, state);
  if (slot === 14) return await nudge(scriptDir, state, '14:00');
  if (slot === 18) return await nudge(scriptDir, state, '18:00');
  return { skipped: 'unknown-slot' };
}

async function morningSend(scriptDir, state) {
  // ECO-39: gate the lesson loop on coach having joined the WhatsApp group.
  // Bot creates the group + sends welcome at Hotmart approval, then waits
  // silently until the coach is detected. The 14:00/18:00 nudges below also
  // short-circuit on this same flag.
  if (!state.coachJoinedAt) {
    return { skipped: 'awaiting-coach-join' };
  }
  // Honor scheduled start date.
  if (now().getTime() < new Date(state.startedAt).getTime()) {
    return { skipped: 'before-start-date', startedAt: state.startedAt };
  }
  // Don't re-send a lesson we already sent today and haven't been completed yet.
  const today = zurichParts(now()).date;
  const targetLesson = nextLessonToSend(scriptDir, state);
  if (!targetLesson) {
    return await coursesComplete(scriptDir, state);
  }
  const status = state.lessons[targetLesson.id] || {};
  const lastSent = status.lastSentAt ? zurichParts(new Date(status.lastSentAt)).date : null;
  if (lastSent === today) {
    return { skipped: 'already-sent-today', lessonId: targetLesson.id };
  }
  await sendLesson(scriptDir, state, targetLesson);
  saveSilver(scriptDir, state.slug, state);
  return { sent: targetLesson.id };
}

async function nudge(scriptDir, state, slotLabel) {
  // ECO-39: same coach-join gate as morningSend.
  if (!state.coachJoinedAt) return { skipped: 'awaiting-coach-join' };
  const lessonId = state.awaitingYesFor;
  if (!lessonId) return { skipped: 'nothing-pending' };
  const status = state.lessons[lessonId] || {};
  if (status.completedAt) return { skipped: 'already-complete' };
  const lesson = lessonById(scriptDir, lessonId);
  const key = slotLabel === '14:00' ? 'lesson_nudge_14' : 'lesson_nudge_18';
  const text = resolveMessage(scriptDir, state, key, { lessonDay: lesson.day, lessonTitle: lesson.title });
  await sendText(state.groupJid, text);
  markLessonNudged(state, lessonId, slotLabel);
  saveSilver(scriptDir, state.slug, state);
  journeyEvent(scriptDir, state.slug, 'silver_nudge', { lessonId, slot: slotLabel });
  return { nudged: lessonId, slot: slotLabel };
}

async function sendLesson(scriptDir, state, lesson) {
  const key = lesson.day === 1 ? 'lesson_morning_day1' : 'lesson_morning';
  const body = resolveMessage(scriptDir, state, key, {
    lessonDay: lesson.day,
    lessonTitle: lesson.title,
    lessonUrl: lesson.videoUrl,
  });
  await sendText(state.groupJid, body);
  markLessonSent(state, lesson.id);
  journeyEvent(scriptDir, state.slug, 'silver_lesson_sent', { lessonId: lesson.id, day: lesson.day });
  await maybeWeeklyCheckin(scriptDir, state, lesson);
}

async function maybeWeeklyCheckin(scriptDir, state, lesson) {
  if (!lesson.weeklyGoalText) return;
  const already = state.weeklyCheckins?.find(w => w.week === lesson.week);
  if (already) return;
  const weeklyTasks = loadTaskRegistry(scriptDir).tasks
    .filter(t => t.week === lesson.week && t.askAt === 'weekly');
  const taskLines = weeklyTasks
    .filter(t => !isTaskRecorded(state, t.id))
    .map(t => `• ${t.askPrompt}`)
    .join('\n');
  // Custom-task lines override the default body when present.
  const baseText = resolveMessage(scriptDir, state, 'weekly_checkin', {
    week: lesson.week,
    weeklyGoalText: lesson.weeklyGoalText,
  });
  const text = taskLines
    ? baseText.replace(/How did this week go\? \(1–3 lines\.\)$/, taskLines)
    : baseText;
  await sendText(state.groupJid, text);
  state.weeklyCheckins = state.weeklyCheckins || [];
  state.weeklyCheckins.push({ week: lesson.week, sentAt: nowIso(), userReply: null });
  for (const t of weeklyTasks) markTaskAsked(state, t.id);
  // The most recently asked task becomes pendingTaskAsk so the next reply maps to it.
  if (weeklyTasks.length > 0) state.pendingTaskAsk = weeklyTasks[weeklyTasks.length - 1].id;
  journeyEvent(scriptDir, state.slug, 'silver_weekly_checkin_sent', { week: lesson.week, taskIds: weeklyTasks.map(t => t.id) });
}

// Day-end task probe — fired after the user confirms Yes for a lesson, if a
// task is registered with askOnDay === lesson.day && askAt === 'day-end'.
async function maybeDayEndTaskProbe(scriptDir, state, lesson) {
  const due = tasksDueOnDay(scriptDir, lesson.day).filter(t => t.askAt === 'day-end' && !isTaskRecorded(state, t.id));
  if (due.length === 0) return;
  for (const t of due) {
    await sendText(state.groupJid, `🎯 ${t.askPrompt}`);
    markTaskAsked(state, t.id);
  }
  state.pendingTaskAsk = due[due.length - 1].id;
  journeyEvent(scriptDir, state.slug, 'silver_task_asked', { taskIds: due.map(t => t.id) });
}

async function coursesComplete(scriptDir, state) {
  if (state.completedAt) return { skipped: 'already-finished' };
  state.completedAt = nowIso();
  await sendText(state.groupJid, resolveMessage(scriptDir, state, 'course_complete', {}));
  saveSilver(scriptDir, state.slug, state);
  journeyEvent(scriptDir, state.slug, 'silver_completed');
  return { completed: true };
}

// ── Inbound message handling ────────────────────────────────────────────────

const YES_RX = /^(\s*)(yes|yep|yeah|done|watched|finito|ok|👍|✅|✔️|si|sí|oui|ja)\b/i;
const NO_RX  = /^(\s*)(no|nope|not yet|later|nicht|nein|non|nao|não)\b/i;
const HUMAN_RX = /\b(human|coach|talk to (a|someone)|escalate|real person)\b/i;

export async function handleSilverMessage(scriptDir, slug, { text, fromNum }) {
  const state = loadSilver(scriptDir, slug);
  if (!state) return { error: 'no-silver-state' };
  state.lastInteractionAt = nowIso();

  // Dossier collection short-circuit (ECO-15/34): when collecting, user
  // replies are answers to the current question, not Q&A or lesson confirms.
  // ECO-41: strategy collection now happens on the website by default.
  // The WhatsApp dossier path is only taken when the SILVER_WHATSAPP_DOSSIER=1
  // env flag is on AND the dossier was explicitly started via coach trigger.
  if (state.dossier?.status === 'collecting' && process.env.SILVER_WHATSAPP_DOSSIER === '1') {
    if (HUMAN_RX.test(text)) {
      return await escalate(scriptDir, state, text, 'user-requested-during-dossier');
    }
    return await captureDossierAnswer(scriptDir, state, text);
  }

  // Weekly check-in reply capture (most-recent unanswered)
  const openCheckin = (state.weeklyCheckins || []).slice().reverse().find(w => !w.userReply);
  if (openCheckin && text.length > 5 && !YES_RX.test(text) && !NO_RX.test(text)) {
    openCheckin.userReply = text;
    openCheckin.repliedAt = nowIso();
    saveSilver(scriptDir, slug, state);
    journeyEvent(scriptDir, slug, 'silver_weekly_checkin_reply', { week: openCheckin.week });
    // Don't return — let the message also flow into Q&A if it contains a question.
  }

  // Role-reply context (ECO-17): if the user is currently awaiting reply for
  // a role offer (last weekly batch), classify APPLIED/SKIP/INFO and route.
  // Only fires for unlocked users with an active role-reply pointer.
  if (state.awaitingRoleReplyFor) {
    const { handleRoleReply } = await import('./silverRoles.js');
    const result = await handleRoleReply(scriptDir, state, text);
    if (result.handled) return { ok: true, ...result };
  }

  // Status reply context (ECO-18): if any applied role had a status prompt
  // sent recently, classify and route.
  if ((state.appliedRoles || []).some(r => r.lastStatusPromptAt)) {
    const { handleStatusReply } = await import('./silverRoles.js');
    const result = await handleStatusReply(scriptDir, state, text);
    if (result.handled) return { ok: true, ...result };
  }

  // YES fast path — saves an API call on the most common reply. Anything
  // else (NO, "send the next video", "talk to a coach", "what's CV?", etc.)
  // goes to Claude with the full tool surface so it can decide what to do.
  if (YES_RX.test(text) && state.awaitingYesFor) {
    return await onYes(scriptDir, state);
  }

  // Everything else flows through Claude. The agent has tools for:
  //   sendLesson(day) / sendNextLesson() / replayLastLesson()
  //   markLessonComplete() / sendTemplate(id) / escalateToHuman(reason)
  //   recordTaskProof(...) / getUserProgress()
  return await runQA(scriptDir, state, text);
}

async function onYes(scriptDir, state) {
  const lessonId = state.awaitingYesFor;
  markLessonComplete(state, lessonId, 'user-yes');
  saveSilver(scriptDir, state.slug, state);
  journeyEvent(scriptDir, state.slug, 'silver_lesson_completed', { lessonId, source: 'user-yes' });
  const lesson = lessonById(scriptDir, lessonId);
  await sendText(state.groupJid, resolveMessage(scriptDir, state, 'lesson_complete', { lessonDay: lesson.day, lessonTitle: lesson.title }));
  await maybeDayEndTaskProbe(scriptDir, state, lesson);
  // Module gate (refined: lessons + gating tasks).
  // After every "Yes" we walk modules 1..4 and apply the generalized
  // maybeMarkModuleComplete check:
  //   - If all lessons done AND all tasks claimed → flip module N done
  //     (and for N=1, fire the strategy-URL message — ECO-41)
  //   - If all lessons done but tasks remain → send "still need: …"
  //     so the user knows what's blocking module completion
  for (let n = 1; n <= 4; n += 1) {
    const wasComplete = !!state.modulesCompleted?.[n];
    const result = maybeMarkModuleComplete(scriptDir, state, n);
    if (result.ready && !wasComplete) {
      journeyEvent(scriptDir, state.slug, `silver_module${n}_complete`, { lessonId });
      if (n === 1) {
        const strategyUrl = process.env.WEBAPP_BASE
          ? `${process.env.WEBAPP_BASE.replace(/\/$/, '')}/admin/silver/${state.slug}/strategy`
          : 'https://vianexu.com/clients/ecobe/admin';
        await sendText(state.groupJid, resolveMessage(scriptDir, state, 'module1_complete_strategy_url', {
          strategyUrl,
          coachName: state.coachName || 'Coach',
          leadName: state.leadName || 'friend',
        }));
      } else {
        await sendText(state.groupJid, resolveMessage(scriptDir, state, `module${n}_complete`, {
          leadName: state.leadName || 'friend',
        }));
      }
      break; // only one module finishes per "Yes"
    }
    if (result.lessonsDone && result.tasksRemaining.length > 0 && !wasComplete) {
      // Lessons in module N are all done but gating tasks remain. Tell the
      // user what's still open. Only fires the FIRST time we hit this state
      // for this module to avoid spamming on every later "Yes".
      const pendingKey = `module${n}TasksReminderSentAt`;
      if (!state[pendingKey]) {
        const taskList = result.tasksRemaining.map(t => `• ${t.title || t.askPrompt}`).join('\n');
        await sendText(state.groupJid, resolveMessage(scriptDir, state, 'module_lessons_done_tasks_remaining', {
          moduleN: n,
          taskList,
          taskCount: result.tasksRemaining.length,
        }));
        state[pendingKey] = nowIso();
      }
      break;
    }
  }
  saveSilver(scriptDir, state.slug, state);
  await maybeCatchupSends(scriptDir, state);
  return { ok: true, action: 'yes', lessonId };
}

async function maybeCatchupSends(scriptDir, state) {
  let sent = 0;
  while (sent < CATCHUP_CAP_PER_SESSION) {
    const next = nextCatchupLesson(scriptDir, state);
    if (!next) break;
    // Avoid re-sending today's morning send if it's the same lesson awaiting yes.
    if (state.awaitingYesFor === next.id) break;
    await sendLesson(scriptDir, state, next);
    saveSilver(scriptDir, state.slug, state);
    sent++;
    // After a catch-up send we wait for the next Yes before continuing.
    break;
  }
}

async function escalate(scriptDir, state, userText, reason) {
  state.escalations = state.escalations || [];
  state.escalations.push({ at: nowIso(), reason, userQuestion: userText });
  saveSilver(scriptDir, state.slug, state);
  await sendText(state.groupJid, resolveMessage(scriptDir, state, 'qa_escalation', {}));
  journeyEvent(scriptDir, state.slug, 'silver_escalated', { reason });
  // Coach DM happens via groupHandler / numbers.json — wired in production index.js.
  return { ok: true, action: 'escalated', reason };
}

async function runQA(scriptDir, state, userText) {
  // Lazy-load agent so dev mode without OPENAI_KEY still works for non-QA flows.
  const { answerQuestion } = await import('./silverAgent.js');
  const lessonId = state.pendingQuestionFor || state.awaitingYesFor || null;
  const lesson = lessonId ? lessonById(scriptDir, lessonId) : null;
  const pendingTask = state.pendingTaskAsk ? taskById(scriptDir, state.pendingTaskAsk) : null;
  const result = await answerQuestion(scriptDir, {
    state,
    lesson,
    pendingTask,
    userText,
  });

  // Apply tool effects that mutate state (recordTaskProof).
  if (result.recordedTask) {
    recordTaskProof(state, result.recordedTask);
    journeyEvent(scriptDir, state.slug, 'silver_task_recorded', result.recordedTask);
  }

  state.qaHistory = state.qaHistory || [];
  state.qaHistory.push({ at: nowIso(), q: userText, a: result.text, escalate: result.escalate, toolCalls: result.toolCalls });
  if (state.qaHistory.length > 50) state.qaHistory = state.qaHistory.slice(-50);
  saveSilver(scriptDir, state.slug, state);
  journeyEvent(scriptDir, state.slug, 'silver_qa', { q: userText, escalate: !!result.escalate });

  // Send any conversational text from the model first, then act on the
  // lesson tool (so the user sees the reply + the lesson video together).
  if (result.text) await sendText(state.groupJid, result.text);

  // Lesson actions from sendLesson / sendNextLesson / replayLastLesson /
  // markLessonComplete tools. Honors the same dedup as morningSend (no
  // double-send if same lesson was already sent today).
  if (result.lessonAction) {
    const action = result.lessonAction;
    let target = null;
    if (action.kind === 'send' && action.day) target = lessonByDay(scriptDir, action.day);
    if (action.kind === 'sendNext')           target = nextLessonToSend(scriptDir, state);
    if (action.kind === 'replay') {
      const lastId = state.awaitingYesFor || state.pendingQuestionFor;
      target = lastId ? lessonById(scriptDir, lastId) : null;
    }
    if (target) {
      await sendLesson(scriptDir, state, target);
      saveSilver(scriptDir, state.slug, state);
      journeyEvent(scriptDir, state.slug, 'silver_lesson_sent_via_agent', { lessonId: target.id, kind: action.kind });
    }
    if (action.kind === 'markComplete' && state.awaitingYesFor) {
      // Run the same flow as a YES reply
      return await onYes(scriptDir, state);
    }
  }

  if (result.escalate) await escalate(scriptDir, state, userText, 'agent-low-confidence');

  return { ok: true, action: 'qa', escalate: !!result.escalate, lessonAction: result.lessonAction || null };
}

// ── Onboarding shortcut for dev/test ────────────────────────────────────────

export async function startSilver(scriptDir, slug, { groupJid, clientPhone, leadName, startDate, email }) {
  const state = initSilver(scriptDir, slug, { groupJid, clientPhone, leadName, startDate, email });
  const startedHuman = new Date(state.startedAt).toLocaleDateString('en-GB', { timeZone: 'Europe/Zurich', weekday: 'long', day: '2-digit', month: 'short' });
  await sendText(groupJid, `Welcome to Silver, ${leadName || 'friend'} 👋\n\nThis is your 30-day execution system. Each morning I'll send the day's video and a short prompt. Reply *Yes* when watched. Ask me anything along the way.\n\nFirst lesson lands *${startedHuman}* at 08:00 (Central Europe time). Reply *send Day 1 now* if you want to start immediately.`);
  return state;
}

// ── Hotmart-driven onboarding (ECO-5 + ECO-10) ──────────────────────────────
//
// Called by lib/hotmartWebhook.js on `purchase.approved`. Differs from
// `startSilver` in three ways:
//   1. Creates a per-client WhatsApp group via the adapter (bot creates it
//      empty; coach is NOT pre-assigned per "option C" — the unassigned
//      mentee surfaces in the admin dashboard for a coach to claim).
//   2. Sets `accessLevel='course_only'`, `paymentStatus='approved'`, plus
//      Hotmart transaction/product/offer fields on the silver.json state.
//   3. Sends the Hotmart welcome copy via `resolveMessage('welcome', ...)`
//      with NO Job-bot link (per ECO-10 revision). If the messages file is
//      not yet authored (ECO-28 pending), falls back to the existing
//      inline welcome to keep dev usable.
//
// Idempotent: if a silver.json already exists for this slug we just update
// payment metadata and skip the group create + welcome send.

export async function startSilverFromHotmart(scriptDir, { slug, event }) {
  const existing = loadSilver(scriptDir, slug);
  if (existing) {
    setPaymentStatus(existing, event.status, {
      hotmartTransactionId: event.transaction,
      hotmartProductId: event.productId,
      hotmartOfferId: event.offerId,
    });
    saveSilver(scriptDir, slug, existing);
    journeyEvent(scriptDir, slug, 'silver_hotmart_repeat_event', { transaction: event.transaction, status: event.status });
    return { ok: true, action: 'updated-existing', slug };
  }

  // 1. Create the WhatsApp group. Coach is null — surfaces as unassigned.
  const groupName = `Ecobe Silver | ${event.buyerName || 'New Member'}`;
  const buyerJid = event.buyerPhone ? `${event.buyerPhone}@s.whatsapp.net` : null;
  const initialParticipants = buyerJid ? [buyerJid] : [];
  let group;
  try {
    group = await createGroup(groupName, initialParticipants);
  } catch (err) {
    // If group create fails (Baileys not ready, network, etc.), still
    // create the silver.json record so the coach can see the lead in the
    // dashboard and create the group manually. The webhook returns 500
    // and Hotmart retries, which will succeed once the bot reconnects.
    journeyEvent(scriptDir, slug, 'silver_hotmart_group_create_failed', { error: err.message, transaction: event.transaction });
    throw err;
  }

  const groupJid = group.id || group.gid;
  const inviteLink = await getGroupInviteLink(groupJid).catch(() => null);

  // 2. Init silver state with full Hotmart context.
  const state = initSilver(scriptDir, slug, {
    groupJid,
    clientPhone: event.buyerPhone,
    leadName: event.buyerName,
    locale: detectLocaleFromPhone(event.buyerPhone),
    coachJid: null,         // option C: unassigned, coach claims from dashboard
    coachName: null,
    email: event.buyerEmail,
    hotmartTransactionId: event.transaction,
    hotmartProductId: event.productId,
    hotmartOfferId: event.offerId,
    paymentStatus: 'approved',
  });

  journeyEvent(scriptDir, slug, 'silver_hotmart_started', {
    transaction: event.transaction,
    productId: event.productId,
    offerId: event.offerId,
    groupJid,
    inviteLink,
  });

  // 3. Send welcome via copy resolver, fallback to inline if messages.* not present.
  const welcome = renderWelcome(scriptDir, state);
  await sendText(groupJid, welcome);

  return { ok: true, action: 'created', slug, groupJid, inviteLink };
}

export async function pauseSilverFromHotmart(scriptDir, { slug, event }) {
  const state = loadSilver(scriptDir, slug);
  if (!state) {
    // No record to pause. This happens when a refund fires for a customer
    // we never onboarded (e.g. they bought before the webhook was live).
    // Audit-log via journey + return ok-noop so Hotmart doesn't retry.
    journeyEvent(scriptDir, slug, 'silver_hotmart_pause_noop', { transaction: event.transaction, status: event.status });
    return { ok: true, action: 'noop-no-state' };
  }
  setPaymentStatus(state, event.status, {
    hotmartTransactionId: event.transaction,
    hotmartProductId: event.productId,
    hotmartOfferId: event.offerId,
  });
  pauseSilver(state, `hotmart:${event.status}`);
  saveSilver(scriptDir, slug, state);
  journeyEvent(scriptDir, slug, 'silver_hotmart_paused', { transaction: event.transaction, status: event.status });
  if (state.groupJid) {
    const text = resolveMessage(scriptDir, state, 'refund_pause', { name: state.leadName || 'there' });
    if (!text.startsWith('[missing:')) await sendText(state.groupJid, text);
  }
  return { ok: true, action: 'paused', slug, status: event.status };
}

// ── Dossier collection (ECO-15 / ECO-34) ────────────────────────────────────
//
// Coach clicks "Trigger Bot" in admin dashboard → `startDossierCollection()`
// is invoked from the coach admin endpoint. It flips `dossier.status` to
// 'collecting', sends the intro copy + first question. Subsequent user
// messages in the group are routed (via the short-circuit at the top of
// handleSilverMessage) into `captureDossierAnswer()`, which appends a turn
// and either asks the next question or finalises the dossier.
//
// On finalisation: writes mentees/{slug}/career-strategy.json with the
// collected inputs, sets dossier.status='ready_for_review', and surfaces
// the user back in the admin dashboard for the coach to review and click
// "Unlock Job-bot". No Job-bot link ships from this function — that's
// the coach's manual click in the dashboard.

export async function startDossierCollection(scriptDir, slug, { coachEmail }) {
  const state = loadSilver(scriptDir, slug);
  if (!state) return { ok: false, reason: 'no-state' };
  if (state.accessLevel !== ACCESS_LEVELS.DOSSIER_PENDING) {
    return { ok: false, reason: 'wrong-access-level', accessLevel: state.accessLevel };
  }
  if (state.dossier?.status === 'collecting') {
    return { ok: false, reason: 'already-collecting' };
  }
  state.coachTriggeredAt = nowIso();
  state.coachTriggeredBy = coachEmail || 'unknown';
  setDossierStatus(state, 'collecting', { collectionTurns: [] });
  saveSilver(scriptDir, slug, state);
  journeyEvent(scriptDir, slug, 'silver_dossier_started', { coachEmail });

  // Intro + first question
  await sendText(state.groupJid, resolveMessage(scriptDir, state, 'dossier_collection_intro', {
    leadName: state.leadName || 'friend',
  }));
  const q = nextDossierQuestion(state);
  if (q) {
    const text = resolveMessage(scriptDir, state, 'dossier_collection_question', {
      step: q.idx + 1, total: q.total, question: q.prompt,
    });
    await sendText(state.groupJid, text);
  }
  return { ok: true, status: 'collecting', firstQuestionId: q?.id };
}

async function captureDossierAnswer(scriptDir, state, text) {
  const trimmed = (text || '').trim();
  // Treat the user's "yes" reply to the intro as a confirmation, not an answer.
  // We detect that by: collectionTurns is empty AND text matches YES_RX.
  if ((state.dossier?.collectionTurns || []).length === 0 && YES_RX.test(trimmed) && trimmed.length < 12) {
    // Re-prompt with the first question. (We already sent it in
    // startDossierCollection, but a "yes" confirmation makes the UX clearer.)
    const q = nextDossierQuestion(state);
    if (q) {
      const msg = resolveMessage(scriptDir, state, 'dossier_collection_question', {
        step: q.idx + 1, total: q.total, question: q.prompt,
      });
      await sendText(state.groupJid, msg);
    }
    return { ok: true, action: 'dossier-intro-ack' };
  }

  const current = nextDossierQuestion(state);
  if (!current) {
    // Edge: dossier marked collecting but already complete. Defensive: finalise.
    return await finaliseDossier(scriptDir, state);
  }

  appendDossierTurn(state, current.prompt, trimmed);
  saveSilver(scriptDir, state.slug, state);
  journeyEvent(scriptDir, state.slug, 'silver_dossier_turn', { questionId: current.id, answer: trimmed.slice(0, 200) });

  const nextQ = nextDossierQuestion(state);
  if (nextQ) {
    const msg = resolveMessage(scriptDir, state, 'dossier_collection_question', {
      step: nextQ.idx + 1, total: nextQ.total, question: nextQ.prompt,
    });
    await sendText(state.groupJid, msg);
    return { ok: true, action: 'dossier-next', stepAsked: nextQ.idx + 1 };
  }

  return await finaliseDossier(scriptDir, state);
}

async function finaliseDossier(scriptDir, state) {
  const slug = state.slug;
  const answers = dossierAnswers(state);

  // Write career-strategy.json (existing pattern from regenerate-strategy.js).
  // We persist the raw inputs here; the actual strategy generation step
  // (Claude API call to produce a polished plan) is the coach's review
  // workflow — kept out of the WhatsApp critical path so the user gets a
  // fast confirmation.
  const fs = await import('fs');
  const path = await import('path');
  const strategyDir = path.join(scriptDir, 'mentees', slug);
  fs.mkdirSync(strategyDir, { recursive: true });
  const strategyPath = path.join(strategyDir, 'career-strategy.json');
  const existingStrategy = fs.existsSync(strategyPath)
    ? (() => { try { return JSON.parse(fs.readFileSync(strategyPath, 'utf8')); } catch { return {}; } })()
    : {};
  const merged = {
    ...existingStrategy,
    silverDossier: {
      collectedAt: nowIso(),
      inputs: answers,
      raw: state.dossier.collectionTurns,
    },
  };
  fs.writeFileSync(strategyPath, JSON.stringify(merged, null, 2));

  setDossierStatus(state, 'ready_for_review', { strategyPath: `mentees/${slug}/career-strategy.json` });
  saveSilver(scriptDir, slug, state);
  journeyEvent(scriptDir, slug, 'silver_dossier_ready_for_review', { strategyPath });

  await sendText(state.groupJid, resolveMessage(scriptDir, state, 'dossier_collection_complete', {
    leadName: state.leadName || 'friend',
  }));

  return { ok: true, action: 'dossier-finalised', strategyPath };
}

// ── Coach unlock (ECO-15 / ECO-38) ──────────────────────────────────────────
//
// Called from the coach admin endpoint when a coach clicks "Unlock Job-bot".
// Mints a one-time token, sends the jobbot link to the group, and flips the
// access level. Returns the token + expiry so the dashboard can show
// confirmation. The actual Job-bot side-effect (server-side token registration)
// happens via lib/jobbotClient.js (POST /auth/whatsapp-token) — that integration
// is owned by the caller, not this function.

export async function unlockJobbotForUser(scriptDir, slug, { coachEmail, jobbotUrl }) {
  const state = loadSilver(scriptDir, slug);
  if (!state) return { ok: false, reason: 'no-state' };
  if (state.accessLevel === ACCESS_LEVELS.FULL) {
    return { ok: false, reason: 'already-unlocked' };
  }
  if (state.dossier?.status !== 'ready_for_review' && state.dossier?.status !== 'approved') {
    return { ok: false, reason: 'dossier-not-ready', dossierStatus: state.dossier?.status };
  }

  const token = mintWhatsappToken(state, 30);
  state.coachUnlockedAt = nowIso();
  state.coachUnlockedBy = coachEmail || 'unknown';
  setAccessLevel(state, ACCESS_LEVELS.FULL, `coach:${coachEmail || 'unknown'}`);
  state.dossier.status = 'approved';
  state.dossier.reviewedBy = coachEmail || 'unknown';
  state.dossier.reviewedAt = nowIso();
  saveSilver(scriptDir, slug, state);
  journeyEvent(scriptDir, slug, 'silver_coach_unlocked', { coachEmail, tokenExpiresAt: token.expiresAt });

  const linkBase = jobbotUrl || process.env.JOBBOT_BASE || 'https://jobbot.ecobe.com';
  const jobbotLink = `${linkBase.replace(/\/$/, '')}/plan?t=${token.value}`;
  await sendText(state.groupJid, resolveMessage(scriptDir, state, 'coach_unlocked_jobbot_ready', {
    jobbotLink,
    leadName: state.leadName || 'friend',
  }));

  return {
    ok: true,
    accessLevel: ACCESS_LEVELS.FULL,
    token: token.value,
    tokenExpiresAt: token.expiresAt,
    jobbotLink,
  };
}

// ── Coach join (ECO-39) ─────────────────────────────────────────────────────
//
// Called from two places:
//   - lib/index.js Baileys group-participants.update listener (auto-detect)
//   - lib/coachAdmin.js force-coach-join admin endpoint (manual override)
//
// Idempotent: if coachJoinedAt is already set, returns { skipped: 'already' }
// without sending another message. Lesson loop in morningSend()/nudge() will
// pick up the change at the next 08:00 Zurich tick.
//
// `target` can be either a slug string OR a state object (when the caller
// already loaded it). `params`: { coachJid, coachName?, source? }.

export async function applyCoachJoin(scriptDir, target, { coachJid, coachName = null, source = 'manual-admin' }) {
  let state, slug;
  if (typeof target === 'string') {
    slug = target;
    state = loadSilver(scriptDir, slug);
  } else {
    state = target;
    slug = state?.slug;
  }
  if (!state) return { ok: false, reason: 'no-state' };

  const flipped = markCoachJoined(state, { coachJid, coachName, source });
  if (!flipped) return { ok: true, skipped: 'already-joined', coachJid: state.coachJid };

  saveSilver(scriptDir, slug, state);
  journeyEvent(scriptDir, slug, 'silver_coach_joined', { coachJid, coachName, source });

  // Send the "first lesson tomorrow" copy to the group.
  if (state.groupJid) {
    const startedHuman = new Date(state.startedAt).toLocaleDateString('en-GB', { timeZone: 'Europe/Zurich', weekday: 'long', day: '2-digit', month: 'short' });
    const text = resolveMessage(scriptDir, state, 'coach_joined_lessons_starting', {
      coachName: coachName || 'your coach',
      leadName: state.leadName || 'friend',
      startedHuman,
    });
    if (!text.startsWith('[missing:')) {
      await sendText(state.groupJid, text);
    }
  }
  return { ok: true, coachJid: state.coachJid, coachJoinedAt: state.coachJoinedAt };
}

// ── Strategy nudge + completion detection (ECO-41) ──────────────────────────
//
// runStrategyNudges(scriptDir): scan every Silver mentee with
// accessLevel='dossier_pending', send the daily strategy nudge if due
// (>22h since last, capped at 7 total per user). Returns a summary.
//
// markStrategyCompleted(scriptDir, slug, { coachEmail }): called by the
// admin-server `/strategy-completed` webhook when the website detects the
// form has been submitted. Flips dossier.status='ready_for_review', sends
// the acknowledgement message, surfaces in coach pending-unlock queue.
//
// pollStrategyOnDisk(scriptDir): fallback for when the admin server hasn't
// pinged us — checks `mentees/{slug}/career-strategy.json` and flips state
// the first time it sees the file. Idempotent (uses strategyDetectedAt).

export async function runStrategyNudges(scriptDir) {
  const root = pathJoin(scriptDir, 'mentees');
  if (!existsSync(root)) return { processed: 0, nudged: 0 };
  const summary = { processed: 0, nudged: 0, errors: [] };
  for (const slug of readdirSync(root)) {
    const sp = pathJoin(root, slug, 'silver.json');
    if (!existsSync(sp)) continue;
    const state = loadSilver(scriptDir, slug);
    if (!state) continue;
    summary.processed += 1;
    if (!shouldSendStrategyNudge(state)) continue;
    try {
      const strategyUrl = process.env.WEBAPP_BASE
        ? `${process.env.WEBAPP_BASE.replace(/\/$/, '')}/admin/silver/${state.slug}/strategy`
        : 'https://vianexu.com/clients/ecobe/admin';
      const text = resolveMessage(scriptDir, state, 'strategy_nudge_daily', {
        strategyUrl,
        nudgeNumber: (state.strategyNudgesSent || 0) + 1,
        nudgeCap: 7,
      });
      await sendText(state.groupJid, text);
      recordStrategyNudge(state);
      saveSilver(scriptDir, slug, state);
      journeyEvent(scriptDir, slug, 'silver_strategy_nudge', { sent: state.strategyNudgesSent });
      summary.nudged += 1;
    } catch (err) {
      summary.errors.push({ slug, error: err.message });
    }
  }
  return summary;
}

export async function markStrategyCompleted(scriptDir, slug, { coachEmail = null } = {}) {
  const state = loadSilver(scriptDir, slug);
  if (!state) return { ok: false, reason: 'no-state' };
  if (state.dossier?.status === 'approved') {
    return { ok: true, skipped: 'already-approved', dossierStatus: state.dossier.status };
  }
  const wasNotReady = state.dossier?.status !== 'ready_for_review';
  setDossierStatus(state, 'ready_for_review', {
    strategyPath: `mentees/${slug}/career-strategy.json`,
    generatedAt: state.dossier?.generatedAt || nowIso(),
  });
  if (!state.strategyDetectedAt) state.strategyDetectedAt = nowIso();
  saveSilver(scriptDir, slug, state);
  journeyEvent(scriptDir, slug, 'silver_strategy_completed', { coachEmail, wasNotReady });
  if (state.groupJid && wasNotReady) {
    await sendText(state.groupJid, resolveMessage(scriptDir, state, 'strategy_completed_acknowledgement', {
      leadName: state.leadName || 'friend',
    }));
  }
  return { ok: true, dossierStatus: 'ready_for_review' };
}

// Polling fallback: if no `/strategy-completed` ping comes in but the
// website wrote the strategy file directly, detect on next sweep.
export async function pollStrategyOnDisk(scriptDir) {
  const root = pathJoin(scriptDir, 'mentees');
  if (!existsSync(root)) return { processed: 0, detected: 0 };
  const summary = { processed: 0, detected: 0 };
  for (const slug of readdirSync(root)) {
    const state = loadSilver(scriptDir, slug);
    if (!state) continue;
    summary.processed += 1;
    if (!detectStrategyOnDisk(scriptDir, state)) { saveSilver(scriptDir, slug, state); continue; }
    saveSilver(scriptDir, slug, state);
    await markStrategyCompleted(scriptDir, slug, { coachEmail: 'auto:disk-poll' });
    summary.detected += 1;
  }
  return summary;
}

function renderWelcome(scriptDir, state) {
  const startedHuman = new Date(state.startedAt).toLocaleDateString('en-GB', { timeZone: 'Europe/Zurich', weekday: 'long', day: '2-digit', month: 'short' });
  const vars = {
    leadName: state.leadName || 'friend',
    startedHuman,
    membersAreaUrl: process.env.HOTMART_MEMBERS_AREA_URL || 'https://members.hotmart.com',
    communityUrl: process.env.ECOBE_COMMUNITY_URL || 'https://ecobeacademy.com/community',
  };
  const fromCopy = resolveMessage(scriptDir, state, 'welcome', vars);
  if (!fromCopy.startsWith('[missing:')) return fromCopy;
  // Fallback: inline EN copy matching the revised ECO-10 contract (no Job-bot link).
  return `Welcome to Silver, ${vars.leadName} 👋\n\nYou now have 30-day access to the full course in Hotmart.\n\n📚 Course (Hotmart Members Area): ${vars.membersAreaUrl}\n💬 Community: ${vars.communityUrl}\n\nA coach will join this group shortly to support you. Each morning I'll send the day's lesson here and ask if you watched it — just reply *Yes* when done.\n\nFirst lesson lands *${startedHuman}* at 08:00 (Central Europe time). Reply *send Day 1 now* if you want to start immediately.`;
}
