import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useApiMutation, useApiQuery, extractErrorMessage } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import type {
  AttemptSession,
  CandidateAnswer,
  ExamAttempt,
  RegistrationWithExam,
  SaveState,
} from '../types/exam-attempt';

/** Mirrors the server's allow-list (ExamAttemptService.MAY_SIT). */
const MAY_SIT = ['PRESENT', 'LATE'];

const ESSAY_DEBOUNCE_MS = 1500;

type ViewState = 'loading' | 'not-started' | 'taking' | 'completed' | 'error';

type LocalAnswer = { selectedOptionId?: string | null; answerText?: string | null };

/**
 * All state and API orchestration for taking one exam. Kept out of the page
 * component so the component tree stays about rendering, not about save
 * timers and expiry races.
 *
 * Nothing here is a security boundary — every write still goes through the
 * backend's own ownership + `applyExpiry()` checks on every call. This hook
 * only decides what to show; a client that skipped straight to calling the
 * API directly would hit exactly the same server-side rules.
 */
export function useExamAttempt(examId: string | undefined) {
  const [session, setSession] = useState<AttemptSession | null>(null);
  const [viewState, setViewState] = useState<ViewState>('loading');
  const [errorMessage, setErrorMessage] = useState('');
  const [currentIndex, setCurrentIndex] = useState(0);
  const [answers, setAnswers] = useState<Record<string, LocalAnswer>>({});
  const [saveStates, setSaveStates] = useState<Record<string, SaveState>>({});
  const [remainingSeconds, setRemainingSeconds] = useState(0);

  const answersRef = useRef(answers);
  answersRef.current = answers;
  const debounceTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
  const seeded = useRef(false);

  const {
    data: registrations,
    isLoading: loadingRegistrations,
  } = useApiQuery<RegistrationWithExam[]>({ url: '/exams/registrations/mine' });
  const registration = registrations?.find((r) => r.exam?.id === examId);

  const {
    data: mineData,
    isLoading: loadingMine,
    isError: mineIsError,
    error: mineError,
    refetch: refetchMine,
  } = useApiQuery<AttemptSession>(
    { url: `/exam-attempts/mine/${examId}` },
    { skip: !examId },
  );

  const [startTrigger, { isLoading: starting }] = useApiMutation<AttemptSession>();
  const [answerTrigger] = useApiMutation<CandidateAnswer>();
  const [submitTrigger, { isLoading: submitting }] = useApiMutation<ExamAttempt>();

  const seedFrom = useCallback((data: AttemptSession) => {
    setSession(data);
    const map: Record<string, LocalAnswer> = {};
    for (const a of data.answers) {
      map[a.questionId] = { selectedOptionId: a.selectedOptionId, answerText: a.answerText };
    }
    setAnswers(map);
    setRemainingSeconds(data.remainingSeconds);
    setViewState(data.attempt.status === 'IN_PROGRESS' ? 'taking' : 'completed');
  }, []);

  // Seed once from the initial load — after that, local state (ticking
  // timer, in-flight edits) is the source of truth, not this query.
  useEffect(() => {
    if (seeded.current) return;
    if (loadingRegistrations || loadingMine) return;
    seeded.current = true;

    if (!registration) {
      setViewState('error');
      setErrorMessage('You are not registered for this examination.');
      return;
    }
    // Attendance gates the sitting, and the exam list already hides "Take
    // exam" for these — this is the direct-link path. The server refuses
    // either way (ExamAttemptService.MAY_SIT); this only makes the refusal
    // legible instead of a raw error key on a Start button that never works.
    if (!MAY_SIT.includes(registration.attendanceStatus)) {
      setViewState('error');
      setErrorMessage(
        registration.attendanceStatus === 'REGISTERED'
          ? 'An invigilator must confirm you are present before this exam opens.'
          : 'Your attendance record does not permit sitting this examination.',
      );
      return;
    }
    if (mineData) {
      seedFrom(mineData);
      return;
    }
    if (mineIsError) {
      const key = extractErrorMessage(mineError, '');
      if (key === 'attempt_not_found') {
        setViewState('not-started');
        return;
      }
      setViewState('error');
      setErrorMessage(extractErrorMessage(mineError, 'Could not load the exam.'));
    }
  }, [loadingRegistrations, loadingMine, registration, mineData, mineIsError, mineError, seedFrom]);

  /** Authoritative resync — used after any write is refused as expired/submitted. */
  const syncFromServer = useCallback(async () => {
    const result = await refetchMine();
    if (result.data) {
      seedFrom(result.data as AttemptSession);
    } else {
      setViewState('error');
      setErrorMessage(extractErrorMessage(result.error, 'The exam session ended.'));
    }
  }, [refetchMine, seedFrom]);

  const persistAnswer = useCallback(
    async (questionId: string, payload: LocalAnswer) => {
      if (!session) return;
      setSaveStates((s) => ({ ...s, [questionId]: 'saving' }));
      try {
        const saved = await answerTrigger({
          url: `/exam-attempts/${session.attempt.id}/answers`,
          method: 'POST',
          body: { questionId, ...payload },
        }).unwrap();
        setAnswers((a) => ({
          ...a,
          [questionId]: { selectedOptionId: saved.selectedOptionId, answerText: saved.answerText },
        }));
        setSaveStates((s) => ({ ...s, [questionId]: 'saved' }));
      } catch (error) {
        setSaveStates((s) => ({ ...s, [questionId]: 'error' }));
        const key = extractErrorMessage(error, '');
        if (key === 'attempt_expired' || key === 'attempt_already_submitted') {
          notify.error(
            key === 'attempt_expired'
              ? 'Time is up — this answer was not saved.'
              : 'This attempt has already been submitted.',
          );
          syncFromServer();
        }
      }
    },
    [session, answerTrigger, syncFromServer],
  );

  const flush = useCallback(
    (questionId: string) => {
      const timer = debounceTimers.current[questionId];
      if (!timer) return;
      clearTimeout(timer);
      delete debounceTimers.current[questionId];
      const current = answersRef.current[questionId];
      if (current) persistAnswer(questionId, current);
    },
    [persistAnswer],
  );

  const selectOption = useCallback(
    (questionId: string, optionId: string) => {
      setAnswers((a) => ({ ...a, [questionId]: { ...a[questionId], selectedOptionId: optionId } }));
      persistAnswer(questionId, { selectedOptionId: optionId });
    },
    [persistAnswer],
  );

  const changeText = useCallback(
    (questionId: string, text: string) => {
      setAnswers((a) => ({ ...a, [questionId]: { ...a[questionId], answerText: text } }));
      setSaveStates((s) => ({ ...s, [questionId]: 'idle' }));
      clearTimeout(debounceTimers.current[questionId]);
      debounceTimers.current[questionId] = setTimeout(() => {
        delete debounceTimers.current[questionId];
        persistAnswer(questionId, { answerText: text });
      }, ESSAY_DEBOUNCE_MS);
    },
    [persistAnswer],
  );

  const goTo = useCallback(
    (index: number) => {
      const current = session?.questions[currentIndex];
      if (current) flush(current.id);
      setCurrentIndex(index);
    },
    [session, currentIndex, flush],
  );

  const retry = useCallback(
    (questionId: string) => {
      const current = answersRef.current[questionId];
      if (current) persistAnswer(questionId, current);
    },
    [persistAnswer],
  );

  const start = useCallback(async () => {
    if (!examId) return;
    try {
      const result = await startTrigger({
        url: '/exam-attempts/start',
        method: 'POST',
        body: { examId },
      }).unwrap();
      seedFrom(result);
    } catch (error) {
      const key = extractErrorMessage(error, 'Could not start the exam.');
      notify.error(
        key === 'attendance_not_confirmed'
          ? 'An invigilator must confirm you are present before this exam opens.'
          : key === 'candidate_not_present'
            ? 'Your attendance record does not permit sitting this examination.'
            : key,
      );
    }
  }, [examId, startTrigger, seedFrom]);

  const submit = useCallback(async () => {
    if (!session) return;
    const current = session.questions[currentIndex];
    if (current) flush(current.id);
    try {
      const attempt = await submitTrigger({
        url: `/exam-attempts/${session.attempt.id}/submit`,
        method: 'POST',
      }).unwrap();
      setSession((s) => (s ? { ...s, attempt } : s));
      setViewState('completed');
    } catch (error) {
      const key = extractErrorMessage(error, '');
      if (key === 'attempt_expired' || key === 'attempt_already_submitted') {
        syncFromServer();
      } else {
        notify.error(extractErrorMessage(error, 'Could not submit the exam.'));
      }
    }
  }, [session, currentIndex, flush, submitTrigger, syncFromServer]);

  /** Local countdown only — every write is still checked server-side regardless. */
  useEffect(() => {
    if (viewState !== 'taking') return;
    const id = setInterval(() => {
      setRemainingSeconds((s) => {
        if (s <= 1) {
          clearInterval(id);
          return 0;
        }
        return s - 1;
      });
    }, 1000);
    return () => clearInterval(id);
  }, [viewState]);

  // Time reaching zero locally: stop taking input, tell the server, then
  // trust whatever it reports back over anything computed in the browser.
  const timedOutRef = useRef(false);
  useEffect(() => {
    if (viewState !== 'taking' || remainingSeconds > 0 || timedOutRef.current) return;
    timedOutRef.current = true;
    notify.error("Time's up.");
    // submit() itself resyncs from the server if this loses the race against
    // applyExpiry() — either way the final state comes from the backend, not
    // from this timer having reached zero.
    submit();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [remainingSeconds, viewState]);

  const answeredIds = useMemo(
    () =>
      new Set(
        Object.entries(answers)
          .filter(([, v]) => v.selectedOptionId || v.answerText?.trim())
          .map(([id]) => id),
      ),
    [answers],
  );

  return {
    viewState,
    errorMessage,
    registration,
    session,
    currentIndex,
    answers,
    saveStates,
    answeredIds,
    remainingSeconds,
    starting,
    submitting,
    start,
    selectOption,
    changeText,
    goTo,
    retry,
    submit,
  };
}
