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);
}
}
}
}