using System.Collections.Generic; using System.Linq; using GB5Shared.DTO.Framework.Login; using GB5Shared.Validation; using Newtonsoft.Json; using TMSDAL.CustomCode.Quiz; using TMSDAL.DTO.Assessment; using TMSDAL.DTO.Quiz; namespace TMSBLL.Quiz { /// /// Mirrors TMSBLL.AssessmentAttempt.AssessmentAttemptBLL's scoring shape (server-computed /// MarksObtained/ScorePct/AttemptIsPassed from actual responses — caller-supplied scores are /// never trusted), adapted for the standalone Quiz engine (Instant vs. Structured question /// selection instead of TMS's Simple/Structured assessment-paper indirection). /// public class QuizAttemptBLL : IQuizAttemptBLL { private const byte QUIZMODE_INSTANT = 0; private const byte QUIZMODE_STRUCTURED = 1; private readonly IQuizAttemptDAL _QuizAttemptDAL; private readonly IQuizDAL _QuizDAL; private readonly IQuizSectionDAL _QuizSectionDAL; private readonly GB5Shared.GenerateAutoNumber.AutoNumber _AutoNumber; private readonly IValidation _Validation; public QuizAttemptBLL( IQuizAttemptDAL quizAttemptDAL, IQuizDAL quizDAL, IQuizSectionDAL quizSectionDAL, GB5Shared.GenerateAutoNumber.AutoNumber autoNumber, IValidation validation) { _QuizAttemptDAL = quizAttemptDAL; _QuizDAL = quizDAL; _QuizSectionDAL = quizSectionDAL; _AutoNumber = autoNumber; _Validation = validation; } public async Task GetQuizAttempt(int quizAttemptId, LoginDTO loginDTO) { try { var headerJson = await _QuizAttemptDAL.GetQuizAttempt(quizAttemptId, loginDTO); var attempt = JsonConvert.DeserializeObject(headerJson); if (attempt == null) return headerJson; var responses = await _QuizAttemptDAL.GetResponsesByAttempt(quizAttemptId, loginDTO); // Never send the answer key to the client while the attempt is still in progress — // CorrectAnswer is only needed server-side (SubmitQuizAttempt's grading), not for // rendering the question. Once completed, it's safe (the learner has already // answered) and useful for an "explanation"/review screen. if (attempt.CompletedOn == null) foreach (var r in responses) r.CorrectAnswer = null; attempt.Responses = responses; return JsonConvert.SerializeObject(attempt); } catch (Exception) { throw; } } public async Task> GetQuizAttemptFullList(LoginDTO loginDTO) { try { return await _QuizAttemptDAL.GetQuizAttemptFullList(loginDTO); } catch (Exception) { throw; } } public async Task> GetQuizAttemptListByEmployee(int employeeId, LoginDTO loginDTO) { try { return await _QuizAttemptDAL.GetQuizAttemptListByEmployee(employeeId, loginDTO); } catch (Exception) { throw; } } public async Task StartQuizAttempt(QuizAttemptDTO dto, LoginDTO loginDTO) { if (dto == null) throw new ArgumentNullException(nameof(dto)); if (dto.QuizId <= 0) throw new ArgumentException("QuizId must be selected."); if (dto.EmployeeId <= 0) throw new ArgumentException("EmployeeId must be selected."); GB5Shared.DTO.Framework.AutoNumber.AutoNumberDTO? autoNumberDTO = null; try { var quizJson = await _QuizDAL.GetQuiz(dto.QuizId, loginDTO); var quiz = JsonConvert.DeserializeObject(quizJson) ?? throw new Exception($"Quiz {dto.QuizId} not found."); int attemptCount = await _QuizAttemptDAL.GetQuizAttemptCount(dto.QuizId, dto.EmployeeId, loginDTO); if (quiz.MaxAttempts > 0 && attemptCount >= quiz.MaxAttempts) throw new Exception("Maximum attempts reached for this quiz."); List questions = quiz.QuizMode == QUIZMODE_STRUCTURED ? await CompileStructuredQuestions(quiz.QuizId, loginDTO) : await _QuizAttemptDAL.GetRandomQuestionsByTopic(quiz.TopicId, quiz.TotalQuestions, loginDTO); if (questions.Count == 0) throw new Exception("No questions available for this quiz."); dto.AttemptNo = (short)(attemptCount + 1); dto.StartedOn = DateTime.UtcNow; dto.CompletedOn = null; dto.TotalMarks = questions.Sum(q => (int)q.Marks); dto.MarksObtained = 0; dto.ScorePct = 0; dto.AttemptIsPassed = 0; dto.EvaluatedById = -1; dto.Status = 1; dto.SourceType = loginDTO.SourceType; dto.TenantId = loginDTO.ClientId; dto.CreatedById = loginDTO.UserId; dto.CreatedOn = DateTime.UtcNow; dto.ModifiedById = loginDTO.UserId; dto.ModifiedOn = DateTime.UtcNow; autoNumberDTO = await _AutoNumber.GetAutoNumber(1, "QUIZATTEMPT", loginDTO); dto.QuizAttemptId = autoNumberDTO.StartNumber; await _QuizAttemptDAL.SaveQuizAttempt(dto, loginDTO); short slNo = 1; foreach (var q in questions) { var responseAutoNumber = await _AutoNumber.GetAutoNumber(1, "QUIZATTEMPTRESPONSE", loginDTO); var response = new QuizAttemptResponseDTO { QuizAttemptResponseId = responseAutoNumber.StartNumber, QuizAttemptId = dto.QuizAttemptId, QuestionBankId = q.QuestionBankId, SlNo = slNo++, GivenAnswer = null, IsCorrect = -1, MarksAwarded = 0 }; await _QuizAttemptDAL.SaveQuizAttemptResponse(response, loginDTO); } // Self (not the DAL directly) — GetQuizAttempt embeds Responses[] with the // joined question detail (QuestionTitle/QuestionText/OptionsJson/Marks) and strips // CorrectAnswer while the attempt is in progress. Calling the DAL directly here // would hand the FE player a header with no Responses[] to render at all. return await GetQuizAttempt(dto.QuizAttemptId, loginDTO); } catch (Exception) { if (autoNumberDTO != null && dto.QuizAttemptId > 0) await _AutoNumber.RollbackAutoNumber("QUIZATTEMPT", dto.QuizAttemptId, loginDTO); throw; } } public async Task SubmitQuizAttempt(QuizAttemptDTO dto, LoginDTO loginDTO) { if (dto == null) throw new ArgumentNullException(nameof(dto)); if (dto.QuizAttemptId <= 0) throw new ArgumentException("QuizAttemptId must be selected."); try { var attemptJson = await _QuizAttemptDAL.GetQuizAttempt(dto.QuizAttemptId, loginDTO); var attempt = JsonConvert.DeserializeObject(attemptJson) ?? throw new Exception($"QuizAttempt {dto.QuizAttemptId} not found."); var quizJson = await _QuizDAL.GetQuiz(attempt.QuizId, loginDTO); var quiz = JsonConvert.DeserializeObject(quizJson) ?? throw new Exception($"Quiz {attempt.QuizId} not found."); var responses = await _QuizAttemptDAL.GetResponsesByAttempt(dto.QuizAttemptId, loginDTO); var givenByQuestion = (dto.Responses ?? new List()) .Where(r => r.QuestionBankId > 0) .ToDictionary(r => r.QuestionBankId, r => r.GivenAnswer); decimal marksObtained = 0; foreach (var response in responses) { if (givenByQuestion.TryGetValue(response.QuestionBankId, out var given)) response.GivenAnswer = given; if (!string.IsNullOrWhiteSpace(response.CorrectAnswer)) { bool isCorrect = IsAnswerCorrect(response.QuestionType ?? 0, response.GivenAnswer, response.CorrectAnswer); response.IsCorrect = isCorrect ? (short)1 : (short)0; response.MarksAwarded = isCorrect ? (response.Marks ?? 0) : 0; } else { // No stored correct answer to grade against — leave pending manual/AI evaluation. response.IsCorrect = -1; response.MarksAwarded = 0; } await _QuizAttemptDAL.UpdateQuizAttemptResponse(response, loginDTO); marksObtained += response.MarksAwarded; } attempt.TotalMarks = attempt.TotalMarks > 0 ? attempt.TotalMarks : responses.Sum(r => (int)(r.Marks ?? 0)); attempt.MarksObtained = marksObtained; attempt.ScorePct = attempt.TotalMarks > 0 ? Math.Round(marksObtained / attempt.TotalMarks * 100, 4) : 0; attempt.AttemptIsPassed = (byte)(attempt.ScorePct >= quiz.PassThresholdPct ? 1 : 0); attempt.CompletedOn = DateTime.UtcNow; attempt.EvaluatedById = loginDTO.UserId; attempt.Remarks = dto.Remarks; attempt.ModifiedById = loginDTO.UserId; attempt.ModifiedOn = DateTime.UtcNow; attempt.TenantId = loginDTO.ClientId; await _QuizAttemptDAL.UpdateQuizAttemptScore(attempt, loginDTO); // Self — see StartQuizAttempt's comment; the results screen needs Responses[] // (with the now-graded IsCorrect/MarksAwarded) and CorrectAnswer is safe to // expose here since CompletedOn is now set. return await GetQuizAttempt(dto.QuizAttemptId, loginDTO); } catch (Exception) { throw; } } public async Task DeleteQuizAttempt(int quizAttemptId, LoginDTO loginDTO) { try { return await _QuizAttemptDAL.DeleteQuizAttempt(quizAttemptId, loginDTO); } catch (Exception) { throw; } } public async Task GetLatestPassingAttempt(int quizId, int employeeId, LoginDTO loginDTO) { try { return await _QuizAttemptDAL.GetLatestPassingAttempt(quizId, employeeId, loginDTO); } catch (Exception) { throw; } } private async Task> CompileStructuredQuestions(int quizId, LoginDTO loginDTO) { var sectionsJson = await _QuizSectionDAL.GetQuizSectionList(quizId, loginDTO); var sections = JsonConvert.DeserializeObject>(sectionsJson) ?? new List(); var questions = new List(); foreach (var section in sections) { var qJson = await _QuizSectionDAL.GetQuestionsForQuizSection(section.QuizSectionId, loginDTO); var qs = JsonConvert.DeserializeObject>(qJson) ?? new List(); questions.AddRange(qs); } return questions; } /// Type-aware answer comparison, mirroring AssessmentAttemptBLL.IsAnswerCorrect exactly: /// MCQ(0)/TrueFalse(1)/ShortAnswer(2) case-insensitive exact match; MultiSelect(3) comma-split /// order-independent compare; Matching(4)/Ordering(5) whitespace-stripped JSON string compare. private static bool IsAnswerCorrect(byte questionType, string? given, string correct) { if (string.IsNullOrWhiteSpace(given)) return false; switch (questionType) { case 0: // MCQ case 1: // TrueFalse case 2: // ShortAnswer return string.Equals(given.Trim(), correct.Trim(), StringComparison.OrdinalIgnoreCase); case 3: // MultiSelect var givenSet = given.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).OrderBy(s => s, StringComparer.OrdinalIgnoreCase); var correctSet = correct.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).OrderBy(s => s, StringComparer.OrdinalIgnoreCase); return givenSet.SequenceEqual(correctSet, StringComparer.OrdinalIgnoreCase); case 4: // Matching case 5: // Ordering return string.Equals( new string(given.Where(c => !char.IsWhiteSpace(c)).ToArray()), new string(correct.Where(c => !char.IsWhiteSpace(c)).ToArray()), StringComparison.OrdinalIgnoreCase); default: return string.Equals(given.Trim(), correct.Trim(), StringComparison.OrdinalIgnoreCase); } } } }