import 'dart:math'; import 'pipeline/question_pipeline.dart'; import 'question_service.dart'; import 'question_source_mix.dart'; import 'question_source_preferences.dart'; import 'question_source_preferences_db.dart'; import 'questions_db.dart'; /// Ratio-aware entry point for topping up a user's unanswered question queue. class QuestionDispatcher { QuestionDispatcher({ required QuestionsDb questionsDb, required QuestionService questionService, required QuestionPipeline questionPipeline, required QuestionSourcePreferencesDb preferencesDb, this.debugLoggingEnabled = false, this.maxQueuedQuestions = 3, Random? random, }) : _questionsDb = questionsDb, _questionService = questionService, _questionPipeline = questionPipeline, _preferencesDb = preferencesDb, _random = random ?? Random(); final QuestionsDb _questionsDb; final QuestionService _questionService; final QuestionPipeline _questionPipeline; final QuestionSourcePreferencesDb _preferencesDb; final Random _random; final bool debugLoggingEnabled; final int maxQueuedQuestions; void _log(String message) { if (debugLoggingEnabled) { // ignore: avoid_print print('[QuestionDispatcher] $message'); } } /// Maximum maintenance passes before giving up on an empty queue. static const int _maxQueueAttempts = 6; /// Ensures at least one enabled-source question exists; returns mix-ordered head. Future?> dispatchNextQuestion(String firebaseUid) async { _log('dispatchNextQuestion($firebaseUid)'); await _questionsDb.ensureUserExists(firebaseUid); var rows = await _listEnabledUnansweredOrdered(firebaseUid); _log('Initial unanswered for mix: ${rows.length} rows'); for (var attempt = 0; rows.isEmpty && attempt < _maxQueueAttempts; attempt++) { _log('Queue empty, running maintenance (attempt $attempt)'); await _runQueueMaintenance(firebaseUid); rows = await _listEnabledUnansweredOrdered(firebaseUid); } if (rows.isEmpty) { _log('Still empty after maintenance, returning null'); return null; } _log('Returning first row out of ${rows.length}'); return _toClientPayload(rows.first, rows.length); } /// Unanswered questions for enabled sources only, ordered by configured mix. /// /// Runs [_runQueueMaintenance] in a loop until at least one question is ready /// or [_maxQueueAttempts] are exhausted. This ensures spelling enqueue, /// catalog seeding, and pipeline-state setup all complete before returning. Future>> listPanelQuestions( String firebaseUid, ) async { await _questionsDb.ensureUserExists(firebaseUid); var rows = await _listEnabledUnansweredOrdered(firebaseUid); for (var attempt = 0; rows.isEmpty && attempt < _maxQueueAttempts; attempt++) { await _runQueueMaintenance(firebaseUid); rows = await _listEnabledUnansweredOrdered(firebaseUid); } return rows .map((Map row) => _toClientPayload(row, rows.length)) .toList(growable: false); } /// Counts the number of unanswered questions that belong to enabled sources. Future countEnabledUnansweredQuestions(String firebaseUid) async { final List prefs = await _preferencesDb.getResolved(firebaseUid); final Map mix = QuestionSourceMix.effectiveMixFromResolved(prefs); final Set allowed = _enabledSourceIds(prefs) .where((String id) => (mix[id] ?? 0) > 0) .toSet(); return _questionsDb.countUnansweredForAllowedSources(firebaseUid, allowed); } /// Next unanswered enabled-source question after an answer. Future?> nextQuestionAfterAnswer({ required String firebaseUid, required Map answeredQuestion, }) async { _log('nextQuestionAfterAnswer($firebaseUid)'); var rows = await _listEnabledUnansweredOrdered(firebaseUid); if (rows.isEmpty) { _log('Queue empty, falling back to dispatchNextQuestion'); return dispatchNextQuestion(firebaseUid); } final List prefs = await _preferencesDb.getResolved(firebaseUid); final Map mix = QuestionSourceMix.effectiveMixFromResolved(prefs); final Map delivered = await _questionsDb.countDeliveredBySourceId(firebaseUid); final Set allowed = _enabledSourceIds(prefs) .where((String id) => (mix[id] ?? 0) > 0) .toSet(); _log('nextQuestionAfterAnswer: mix=$mix, allowed=$allowed, delivered=$delivered'); final Map? row = await _questionsDb.findHeadUnansweredForMix( firebaseUid, effectiveMixPercent: mix, deliveredCounts: delivered, allowedSourceIds: allowed, random: _random, ); if (row == null) { return dispatchNextQuestion(firebaseUid); } return _toClientPayload(row, rows.length); } /// Next unanswered enabled-source question after deferring a question. Future?> nextQuestionAfterDefer({ required String firebaseUid, required String deferredQuestionId, }) async { final List prefs = await _preferencesDb.getResolved(firebaseUid); final Map mix = QuestionSourceMix.effectiveMixFromResolved(prefs); final Map delivered = await _questionsDb.countDeliveredBySourceId(firebaseUid); final String preferred = QuestionSourceMix.pickNextSourceId( effectiveMixPercent: mix, deliveredCounts: delivered, random: _random, ); // If spelling was rolled/preferred, but the only spelling question is the deferred one, // force enqueue a new spelling question (so the user gets a different word). if (preferred == 'spelling') { final List> currentRows = await _listEnabledUnansweredOrdered(firebaseUid); final bool hasOtherSpelling = currentRows.any((Map row) => QuestionsDb.resolveSourceId(row) == 'spelling' && row['id'] != deferredQuestionId); if (!hasOtherSpelling && QuestionSourcePreferences.isEnabled(prefs, 'spelling')) { await _questionPipeline.forceEnqueueSpellingQuestion(firebaseUid); } } final List> rows = await _listEnabledUnansweredOrdered(firebaseUid); if (rows.isEmpty) { return dispatchNextQuestion(firebaseUid); } final List> ordered = QuestionSourceMix.orderUnansweredByPreferredSource( rows, preferredSourceId: preferred, ); for (final Map row in ordered) { if (row['id'] != deferredQuestionId) { return _toClientPayload(row, rows.length); } } return _toClientPayload(ordered.first, rows.length); } /// Fills the queue up to [maxQueuedQuestions] using mix-sequential source picks. Future topUpQueue(String firebaseUid) async { await _questionsDb.ensureUserExists(firebaseUid); while (true) { final int enabledQueued = (await _listEnabledUnansweredOrdered(firebaseUid)).length; if (enabledQueued >= maxQueuedQuestions) { return; } if (!await _enqueueOneFromMix(firebaseUid)) { return; } } } /// Runs both mix-based enqueue and spelling-specific maintenance in one pass. /// /// Both helpers are idempotent (spelling checks for an existing unanswered /// word-quiz before creating a new one), so calling this in a loop is safe. Future _runQueueMaintenance(String firebaseUid) async { if (!await _enqueueOneFromMix(firebaseUid)) { await _ensureSpellingQuestionWhenNeeded(firebaseUid); } } Future _enqueueOneFromMix(String firebaseUid) async { _log('_enqueueOneFromMix($firebaseUid)'); final List prefs = await _preferencesDb.getResolved(firebaseUid); final Map mix = QuestionSourceMix.effectiveMixFromResolved(prefs); if (mix.isEmpty) { _log('_enqueueOneFromMix: mix is empty'); return false; } final Map delivered = await _questionsDb.countDeliveredBySourceId(firebaseUid); final String primary = QuestionSourceMix.pickNextSourceId( effectiveMixPercent: mix, deliveredCounts: delivered, random: _random, ); _log('_enqueueOneFromMix: picked primary=$primary (mix=$mix, delivered=$delivered)'); final List order = _sourceTryOrderForEnqueue( prefs: prefs, primarySourceId: primary, ).where((String id) => (mix[id] ?? 0) > 0).toList(growable: false); final int? marketBatchCap = _marketBatchCapForMix(prefs); for (final String sourceId in order) { final bool added = await _produceFromSource( firebaseUid, sourceId, marketBatchCap: marketBatchCap, ); if (added) { return true; } } return false; } Future>> _listEnabledUnansweredOrdered( String firebaseUid, ) async { final List prefs = await _preferencesDb.getResolved(firebaseUid); final Map mix = QuestionSourceMix.effectiveMixFromResolved(prefs); // Only include sources that are both enabled and have a non-zero mix share. // A source with enabled=true but ratio=0 should not serve questions. final Set allowed = _enabledSourceIds(prefs) .where((String id) => (mix[id] ?? 0) > 0) .toSet(); if (allowed.isEmpty) { _log('_listEnabledUnansweredOrdered: allowed is empty'); return >[]; } _log('_listEnabledUnansweredOrdered: fetching for allowed=$allowed'); return _questionsDb.listUnansweredOrderedForMix( firebaseUid, effectiveMixPercent: mix, allowedSourceIds: allowed, random: _random, ); } Set _enabledSourceIds( List prefs, ) { return prefs .where( (ResolvedQuestionSourcePreference s) => s.enabled && !s.comingSoon, ) .map((ResolvedQuestionSourcePreference s) => s.id) .toSet(); } List _sourceTryOrderForEnqueue({ required List prefs, required String primarySourceId, }) { final List enabled = prefs .where( (ResolvedQuestionSourcePreference s) => s.enabled && !s.comingSoon, ) .toList(growable: false); final List rest = enabled .map((ResolvedQuestionSourcePreference s) => s.id) .where((String id) => id != primarySourceId) .toList() ..sort( ( String a, String b, ) { final int ratioA = enabled .firstWhere((ResolvedQuestionSourcePreference s) => s.id == a) .ratio; final int ratioB = enabled .firstWhere((ResolvedQuestionSourcePreference s) => s.id == b) .ratio; return ratioB.compareTo(ratioA); }, ); return [primarySourceId, ...rest]; } /// Last-resort spelling enqueue when mix dispatch did not produce a row. Future _ensureSpellingQuestionWhenNeeded(String firebaseUid) async { final List prefs = await _preferencesDb.getResolved(firebaseUid); if (!QuestionSourcePreferences.isEnabled(prefs, 'spelling')) { return; } await _questionPipeline.maintainSpellingForUser(firebaseUid); } Future _produceFromSource( String firebaseUid, String sourceId, { int? marketBatchCap, }) async { if (!await _isSourceEnabled(firebaseUid, sourceId)) { return false; } final int before = (await _listEnabledUnansweredOrdered(firebaseUid)).length; switch (sourceId) { case 'market_history': await _questionService.tryEnqueueMarketQuestions( firebaseUid, maxCreates: marketBatchCap, ); case 'spelling': await _questionPipeline.maintainSpellingForUser(firebaseUid); default: return false; } final int after = (await _listEnabledUnansweredOrdered(firebaseUid)).length; return after > before; } Future _isSourceEnabled(String firebaseUid, String sourceId) async { final List prefs = await _preferencesDb.getResolved(firebaseUid); return QuestionSourcePreferences.isEnabled(prefs, sourceId); } int? _marketBatchCapForMix(List prefs) { if (!QuestionSourcePreferences.isEnabled(prefs, 'market_history') || !QuestionSourcePreferences.isEnabled(prefs, 'spelling')) { return null; } final Map mix = QuestionSourcePreferences.effectiveMix(prefs); final int spellingShare = mix['spelling'] ?? 0; if (spellingShare <= 0) { return null; } return 1; } Map _toClientPayload( Map row, int unansweredCount, ) { return _questionsDb.toClientPayload( row, unansweredCount: unansweredCount, ); } }