import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useParams, useSearchParams } from 'react-router-dom' import toast from 'react-hot-toast' import { adminAPI, testAPI } from '../api/api' import { getEndTimeMs } from '../utils/testEngineUtils' import useAntiCheat from './useAntiCheat' export default function useTestEngine() { const { testId } = useParams() const navigate = useNavigate() const [searchParams] = useSearchParams() const isPreview = searchParams.get('preview') === 'true' const [test, setTest] = useState(null) const [attempt, setAttempt] = useState(null) const [questions, setQuestions] = useState([]) const [answers, setAnswers] = useState({}) const [timings, setTimings] = useState({}) const [marked, setMarked] = useState(new Set()) const [visited, setVisited] = useState(new Set()) const [current, setCurrent] = useState(0) const [loading, setLoading] = useState(true) const [submitting, setSubmitting] = useState(false) const [showConfirm, setShowConfirm] = useState(false) const [showCalc, setShowCalc] = useState(false) const [natInput, setNatInput] = useState('') const [tabViolations, setTabViolations] = useState(0) const [fsViolations, setFsViolations] = useState(0) const [showFsWarning, setShowFsWarning] = useState(false) const [attemptNumber, setAttemptNumber] = useState(null) const [maxAttempts, setMaxAttempts] = useState(6) const [started, setStarted] = useState(false) const [starting, setStarting] = useState(false) const [accepted, setAccepted] = useState(false) const [showPalette, setShowPalette] = useState(false) const submitLockRef = useRef(false) const autoSaveRef = useRef(null) const tabViolRef = useRef(0) const fsViolRef = useRef(0) const attemptRef = useRef(null) const answersRef = useRef({}) const questionsRef = useRef([]) const timingsRef = useRef({}) const testIdRef = useRef(testId) const questionStartRef = useRef(Date.now()) const loadedRef = useRef(false) useEffect(() => { testIdRef.current = testId }, [testId]) useEffect(() => { attemptRef.current = attempt }, [attempt]) useEffect(() => { answersRef.current = answers }, [answers]) useEffect(() => { questionsRef.current = questions }, [questions]) useEffect(() => { timingsRef.current = timings }, [timings]) useEffect(() => { let cancelled = false ;(async () => { setLoading(true) setStarted(false) setAccepted(false) setAttempt(null) setQuestions([]) setAnswers({}) setTimings({}) setMarked(new Set()) setVisited(new Set()) setCurrent(0) attemptRef.current = null questionsRef.current = [] answersRef.current = {} timingsRef.current = {} loadedRef.current = false try { const testRes = await testAPI.getTest(testId) if (cancelled) return setTest(testRes.data) } catch (err) { toast.error(err.response?.data?.detail || 'Failed to load test') navigate('/tests') } finally { if (!cancelled) setLoading(false) } })() return () => { cancelled = true clearInterval(autoSaveRef.current) } }, [testId, navigate]) const beginTest = useCallback(async () => { if (!accepted || starting || started) return setStarting(true) loadedRef.current = false try { try { if (!document.fullscreenElement) { await document.documentElement.requestFullscreen?.() } } catch { // Browsers may reject fullscreen in some environments; show recovery after load. } let attemptData let qData if (isPreview) { attemptData = { id: 'preview', attempt_number: 'Preview', max_attempts: '∞', started_at: new Date().toISOString(), } const qRes = await adminAPI.getQuestions(testId) qData = qRes.data } else { const attemptRes = await testAPI.startTest(testId) attemptData = attemptRes.data const qRes = await testAPI.getQuestions(testId, attemptData.id) qData = qRes.data } setAttempt(attemptData) attemptRef.current = attemptData setAttemptNumber(attemptData.attempt_number || 1) setMaxAttempts(attemptData.max_attempts || 6) setQuestions(qData) questionsRef.current = qData setVisited(qData[0] ? new Set([qData[0].id]) : new Set()) setAnswers({}) setTimings({}) setMarked(new Set()) setCurrent(0) setNatInput('') setTabViolations(0) setFsViolations(0) setShowFsWarning(false) tabViolRef.current = 0 fsViolRef.current = 0 answersRef.current = {} timingsRef.current = {} setStarted(true) setTimeout(() => { loadedRef.current = true if (!document.fullscreenElement) { setShowFsWarning(true) } }, 2000) } catch (err) { console.error('TestEngine beginTest error:', err) toast.error(err.response?.data?.detail || 'Failed to start test') navigate('/tests') } finally { setStarting(false) } }, [accepted, navigate, starting, started, testId, isPreview]) const doSubmitCore = useCallback(async (auto = false) => { if (submitLockRef.current) return submitLockRef.current = true setSubmitting(true) clearInterval(autoSaveRef.current) try { if (document.fullscreenElement) await document.exitFullscreen() } catch {} if (isPreview) { if (auto) toast('Time up! Auto-submitted preview.', { duration: 5000 }) else toast.success('Preview submitted successfully! (No data saved)') submitLockRef.current = false setSubmitting(false) navigate(`/admin/tests/${testIdRef.current}`) return } const currentAttempt = attemptRef.current const currentAnswers = answersRef.current const currentQuestions = questionsRef.current const currentTimings = timingsRef.current if (!currentAttempt) { submitLockRef.current = false setSubmitting(false) return } try { const ans = currentQuestions.map(q => ({ question_id: q.id, selected_answer: currentAnswers[q.id] || null, time_spent_seconds: currentTimings[q.id] || 0, })) const res = await testAPI.submitTest(testIdRef.current, currentAttempt.id, ans) if (auto) toast('Time up! Auto-submitted.', { duration: 5000 }) else toast.success('Test submitted successfully!') if (res.data?.persisted === false && res.data?.result) { const result = res.data.result const resultId = result.client_result_id || result.attempt_id || res.data.id sessionStorage.setItem(`practice-result:${resultId}`, JSON.stringify(result)) navigate(`/results/${resultId}`, { state: { result } }) return } navigate(`/results/${res.data.id}`) } catch (err) { toast.error(err.response?.data?.detail || 'Submit failed. Please try again.') submitLockRef.current = false setSubmitting(false) } }, [isPreview, navigate]) const doSubmitRef = useRef(doSubmitCore) useEffect(() => { doSubmitRef.current = doSubmitCore }, [doSubmitCore]) const doSubmit = useCallback((auto = false) => doSubmitRef.current(auto), []) const handleTimerExpire = useCallback(() => { doSubmitRef.current(true) }, []) useAntiCheat({ attemptRef, doSubmitRef, fsViolRef, isPreview, loadedRef, setFsViolations, setShowFsWarning, setTabViolations, tabViolRef, testIdRef, }) useEffect(() => { const q = questions[current] if (!q) return questionStartRef.current = Date.now() return () => { const elapsed = Math.floor((Date.now() - questionStartRef.current) / 1000) if (elapsed > 0) { setTimings(t => { const updated = { ...t, [q.id]: (t[q.id] || 0) + elapsed } timingsRef.current = updated return updated }) } } }, [current, questions]) useEffect(() => { const q = questions[current] if (q) setVisited(v => new Set([...v, q.id])) }, [current, questions]) useEffect(() => { const q = questions[current] if (q?.question_type === 'nat') setNatInput(answers[q.id] || '') }, [current, questions, answers]) useEffect(() => { if (!attempt || isPreview) return autoSaveRef.current = setInterval(() => { const ans = Object.entries(answersRef.current).map(([qid, sel]) => ({ question_id: +qid, selected_answer: sel, time_spent_seconds: timingsRef.current[+qid] || 0, })) if (ans.length > 0) { testAPI.saveAnswers(testIdRef.current, attemptRef.current?.id, ans).catch(() => {}) } }, 30000) return () => clearInterval(autoSaveRef.current) }, [attempt, isPreview]) const currentQuestion = questions[current] const setMCQ = useCallback((letter) => { if (!currentQuestion) return setAnswers(a => { const updated = { ...a, [currentQuestion.id]: a[currentQuestion.id] === letter ? undefined : letter } answersRef.current = updated return updated }) }, [currentQuestion]) const toggleMSQ = useCallback((letter) => { if (!currentQuestion) return setAnswers(a => { const cur = (a[currentQuestion.id] || '').split(',').filter(Boolean) const next = cur.includes(letter) ? cur.filter(l => l !== letter) : [...cur, letter].sort() const updated = { ...a, [currentQuestion.id]: next.join(',') || undefined } answersRef.current = updated return updated }) }, [currentQuestion]) const commitNAT = useCallback(() => { if (!currentQuestion) return setAnswers(a => { const updated = { ...a, [currentQuestion.id]: natInput.trim() || undefined } answersRef.current = updated return updated }) }, [currentQuestion, natInput]) const saveAndNext = useCallback(() => { if (currentQuestion?.question_type === 'nat') commitNAT() if (current < questions.length - 1) setCurrent(c => c + 1) }, [commitNAT, current, currentQuestion, questions.length]) const markAndNext = useCallback(() => { if (!currentQuestion) return setMarked(m => { const n = new Set(m) n.has(currentQuestion.id) ? n.delete(currentQuestion.id) : n.add(currentQuestion.id) return n }) if (current < questions.length - 1) setCurrent(c => c + 1) }, [current, currentQuestion, questions.length]) const clearResponse = useCallback(() => { if (!currentQuestion) return setAnswers(a => { const n = { ...a } delete n[currentQuestion.id] answersRef.current = n return n }) setNatInput('') }, [currentQuestion]) const subjects = useMemo(() => { return [...new Set(questions.map(q => q.subject || 'General'))] }, [questions]) const answered = useMemo(() => { return Object.values(answers).filter(Boolean).length }, [answers]) const notAnswered = questions.length - answered const totalViolations = tabViolations + fsViolations const endTimeMs = useMemo( () => getEndTimeMs(attempt?.started_at, test?.duration_minutes), [attempt?.started_at, test?.duration_minutes] ) return { accepted, answered, answers, attemptNumber, beginTest, clearResponse, commitNAT, current, currentQuestion, doSubmit, endTimeMs, fsViolations, handleTimerExpire, loading, markAndNext, marked, maxAttempts, natInput, navigate, notAnswered, questions, saveAndNext, setAccepted, setCurrent, setNatInput, setShowCalc, setShowConfirm, setShowFsWarning, setShowPalette, setMCQ, showCalc, showConfirm, showFsWarning, showPalette, started, starting, subjects, submitting, tabViolations, test, toggleMSQ, totalViolations, visited, } }