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.maxQueuedQuestions = 3, }) : _questionsDb = questionsDb, _questionService = questionService, _questionPipeline = questionPipeline, _preferencesDb = preferencesDb; final QuestionsDb _questionsDb; final QuestionService _questionService; final QuestionPipeline _questionPipeline; final QuestionSourcePreferencesDb _preferencesDb; final int maxQueuedQuestions; /// 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 { 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); } if (rows.isEmpty) { return null; } 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 { var rows = await _listEnabledUnansweredOrdered(firebaseUid); if (rows.isEmpty) { 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(); final Map? row = await _questionsDb.findHeadUnansweredForMix( firebaseUid, effectiveMixPercent: mix, deliveredCounts: delivered, afterAnsweringSourceId: QuestionsDb.resolveSourceId(answeredQuestion), allowedSourceIds: allowed, ); 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> rows = await _listEnabledUnansweredOrdered(firebaseUid); if (rows.isEmpty) { return dispatchNextQuestion(firebaseUid); } for (final Map row in rows) { if (row['id'] != deferredQuestionId) { return _toClientPayload(row, rows.length); } } return _toClientPayload(rows.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 { final List prefs = await _preferencesDb.getResolved(firebaseUid); final Map mix = QuestionSourceMix.effectiveMixFromResolved(prefs); if (mix.isEmpty) { return false; } final Map delivered = await _questionsDb.countDeliveredBySourceId(firebaseUid); final String primary = QuestionSourceMix.pickNextSourceId( effectiveMixPercent: mix, deliveredCounts: 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) { return >[]; } return _questionsDb.listUnansweredOrderedForMix( firebaseUid, effectiveMixPercent: mix, allowedSourceIds: allowed, ); } 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, ); } }