from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from generic.models import CidUser, ExamUserStatus, CidUserExam, Flag
from physics.decorators import user_is_author_or_physics_checker
from physics.filters import QuestionFilter, UserAnswerFilter
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
from physics.tables import QuestionTable, UserAnswerTable
from django.shortcuts import render, get_object_or_404, redirect
from django.views.decorators.csrf import csrf_exempt
from django import forms

# from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required, user_passes_test
from django.contrib.auth.models import User

from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.detail import DetailView

from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.views.generic import ListView

from django.db.models.functions import Lower

from django.core.cache import cache

from django.urls import reverse_lazy, reverse

from django.http import Http404, HttpResponseBadRequest, JsonResponse

from django.http import HttpResponseRedirect, HttpResponse
from django_filters.views import FilterView
from django_tables2.views import SingleTableMixin
from reversion.views import RevisionMixin

from .models import Question, Category, Exam, UserAnswer

from collections import defaultdict
import os
import base64
import mimetypes
import json
import statistics
import plotly.express as px

from generic.views import (
    AuthorRequiredMixin,
    ExamCloneMixin,
    ExamCreateBase,
    ExamDeleteBase,
    ExamGroupsUpdateBase,
    ExamUpdateBase,
    ExamViews,
    GenericViewBase,
    exam_inactive,
)

from django.core.exceptions import PermissionDenied

from .forms import (
    ExamGroupsForm,
    ExamMarkerForm,
    UserAnswerForm,
    ExamAuthorForm,
    ExamForm,
    QuestionForm,
)
from .decorators import (
    user_is_author_or_physics_checker,
    user_is_exam_author_or_physics_checker,
)

from loguru import logger


class AuthorOrCheckerRequiredMixin(object):
    def get_object(self, *args, **kwargs):
        obj = super().get_object(*args, **kwargs)
        if self.request.user.is_superuser:
            return obj
        if self.request.user.groups.filter(name="physics_checker").exists():
            return obj
        if hasattr(obj, "author"):
            if self.request.user not in obj.author.all():
                raise PermissionDenied()
        elif hasattr(obj, "user"):
            if self.request.user != obj.user:
                raise PermissionDenied()
        else:
            raise PermissionDenied()
        return obj


def question_list(request):
    questions = Question.objects.all()
    return render(request, "physics/question_list.html", {"questions": questions})


@login_required
@user_is_author_or_physics_checker
def question_detail(request, pk):
    question = get_object_or_404(Question, pk=pk)
    return render(request, "physics/question_detail.html", {"question": question})


@login_required
def active_exams(request):
    exams = Exam.objects.all()

    active_exams = []

    for exam in exams:
        if exam.active:
            active_exams.append(exam)
    return render(request, "physics/available_exam_list.html", {"exams": active_exams})


def exam_take_old(request, pk):
    exam = get_object_or_404(Exam, pk=pk)

    if not exam.active:
        raise exam_inactive(request)

    questions = exam.get_questions()

    return render(
        request,
        "physics/exam_take_old.html",
        {
            "exam": exam,
            "questions": questions,
        },
    )


def exam_start(request, pk):
    exam = get_object_or_404(Exam, pk=pk)

    if not exam.active:
        return exam_inactive(request, context={"exam": exam})

    # Check if pre-survey is required and not yet taken
    from survey.views import check_survey_interception
    response = check_survey_interception(request, exam)
    if response:
        return response

    return render(
        request,
        "physics/exam_start.html",
        {
            "exam": exam,
            # This may be overly comlicated but if the logged in user
            # does not have access we give the option to take it as a cid user
            "valid_user": exam.check_logged_in_user(request),
        },
    )

def exam_complete(request, pk, cid=None, passcode=None):
    exam = get_object_or_404(Exam, pk=pk)

    exam.check_user_can_take(cid, passcode, request.user)

    cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)

    cid_user_exam.complete_exam()
    try:
        ct = ContentType.objects.get_for_model(exam)
        ExamUserStatus.objects.create(
            content_type=ct,
            object_id=exam.pk,
            cid_user_exam=cid_user_exam,
            status="completed",
            extra="physics",
        )
    except Exception:
        logger.error("Failed to log exam completion status")
        # avoid crashing the user flow if logging fails
        pass

    return HttpResponse("<div role='alert' class='alert alert-info'>Exam completed</div>")


def exam_take_overview(request, pk, cid=None, passcode=None):
    exam = get_object_or_404(Exam, pk=pk)

    exam.check_user_can_take(cid, passcode, request.user)

    # Check if pre-survey is required and not yet taken
    from survey.views import check_survey_interception
    response = check_survey_interception(request, exam, cid, passcode)
    if response:
        return response

    questions = exam.get_questions()

    if cid is not None:
        answers = UserAnswer.objects.filter(cid=cid, exam=exam)
    else:
        answers = UserAnswer.objects.filter(user=request.user, exam=exam)

    # Map answers by question id for quick lookup
    answer_question_map = {ans.question_id: ans for ans in answers}

    # Prepare bulk flagged set to avoid per-question queries
    q_ids = [q.pk for q in questions]
    try:
        ct = ContentType.objects.get_for_model(Question)
        if cid is not None:
            flags_qs = Flag.objects.filter(content_type=ct, object_id__in=q_ids, cid_user__cid=cid)
        else:
            flags_qs = Flag.objects.filter(content_type=ct, object_id__in=q_ids, user=request.user)
        flagged_set = set(flags_qs.values_list('object_id', flat=True))
    except Exception:
        flagged_set = set()

    question_answer_tuples = []
    answer_count = 0
    for q in questions:
        ans = answer_question_map.get(q.pk)
        if ans is not None:
            answer_count += 1
        flagged = q.pk in flagged_set
        question_answer_tuples.append((q, ans, flagged))

    cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)

    # Check if post-survey is mandatory and not yet taken
    if exam.post_survey and getattr(exam, "post_survey_mandatory", False) and cid_user_exam.completed:
        from survey.models import SurveyResponse
        user_param = request.user if request.user.is_authenticated else None
        cid_param = int(cid) if cid and str(cid).isdigit() else None
        ct = ContentType.objects.get_for_model(exam)
        has_responded = SurveyResponse.objects.filter(
            survey=exam.post_survey,
            user=user_param,
            cid=cid_param,
            content_type=ct,
            object_id=exam.pk,
            pre_or_post=SurveyResponse.SurveyContext.POST
        ).exists()
        if not has_responded:
            from django.urls import reverse
            survey_url = reverse("survey:survey_take", kwargs={"pk": exam.post_survey.pk})
            params = f"?pre_or_post=POST&content_type_id={ct.id}&object_id={exam.pk}&next={request.get_full_path()}&mandatory=true"
            if cid and passcode:
                params += f"&cid={cid}&passcode={passcode}"
            return redirect(f"{survey_url}{params}")

    post_survey_url = None
    if exam.post_survey and not getattr(exam, "post_survey_mandatory", False):
        from survey.models import SurveyResponse
        user_param = request.user if request.user.is_authenticated else None
        cid_param = int(cid) if cid and str(cid).isdigit() else None
        ct = ContentType.objects.get_for_model(exam)
        has_responded = SurveyResponse.objects.filter(
            survey=exam.post_survey,
            user=user_param,
            cid=cid_param,
            content_type=ct,
            object_id=exam.pk,
            pre_or_post=SurveyResponse.SurveyContext.POST
        ).exists()
        if not has_responded:
            session_key = f"skipped_post_survey_{exam.pk}"
            if not request.session.get(session_key):
                from django.urls import reverse
                survey_url = reverse("survey:survey_take", kwargs={"pk": exam.post_survey.pk})
                params = f"?pre_or_post=POST&content_type_id={ct.id}&object_id={exam.pk}&next={request.get_full_path()}&mandatory=false&skip_key={session_key}"
                if cid and passcode:
                    params += f"&cid={cid}&passcode={passcode}"
                post_survey_url = f"{survey_url}{params}"

    return render(
        request,
        "physics/exam_take_overview.html",
        {
            "exam": exam,
            "cid": cid,
            "question_answer_tuples": question_answer_tuples,
            "answer_count": answer_count,
            "exam_length": len(questions),
            "passcode": passcode,
            "cid_user_exam": cid_user_exam,
            "post_survey_url": post_survey_url,
        },
    )


def exam_take(request, pk: int, sk: int, cid: str | None = None, passcode: str | None = None):
    """Display and handle answers for a single question in a physics exam.

    Behavior and invariants:
    - Uses a single canonical list of questions (from `exam.get_questions()`) for
      selection, navigation and sizing so indexes cannot drift.
    - Validates the incoming question index and raises 404 for invalid values.
    - Supports candidate (cid) and logged-in user answers.
    """

    exam = get_object_or_404(Exam, pk=pk)

    if not exam.active:
        return exam_inactive(request, context={"exam": exam})

    exam.check_user_can_take(cid, passcode, request.user)

    # Check if pre-survey is required and not yet taken
    from survey.views import check_survey_interception
    response = check_survey_interception(request, exam, cid, passcode)
    if response:
        return response

    cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)

    # Canonical questions list for consistent indexing/navigation
    questions = list(exam.get_questions())

    # Normalize and validate provided index
    try:
        index = int(sk)
    except Exception:
        raise Http404("Invalid question index")

    if index < 0 or index >= len(questions):
        raise Http404("Question not found in exam")

    question = questions[index]
    exam_length = len(questions)

    # local numeric position equals the validated index
    pos = index

    if cid is not None:
        answer = question.cid_user_answers.filter(cid=cid, exam=exam).first()
    else:
        answer = question.cid_user_answers.filter(user=request.user, exam=exam).first()

    previous = -1
    if sk > 0:
        previous = sk - 1
    next = sk + 1
    if sk == exam_length - 1:
        next = False

    if request.method == "POST":
        if answer:
            form = UserAnswerForm(request.POST, instance=answer)
        else:
            form = UserAnswerForm(request.POST)
        if form.is_valid() and not exam.publish_results and not cid_user_exam.completed:
            answer = form.save(commit=False)

            if cid is not None:
                answer.cid = cid
            else:
                answer.user = request.user
            answer.question = question
            answer.exam = exam
            # answer.published_date = timezone.now()
            answer.save()

            cid_user_exam.end_time = timezone.now()
            cid_user_exam.save()

        if cid is not None:
            kwargs = {"pk": pk, "cid": cid, "passcode": passcode}
            take_url = "physics:exam_take"
            finish_url = "physics:exam_take_overview"
        else:
            kwargs = {"pk": pk}
            take_url = "physics:exam_take_user"
            finish_url = "physics:exam_take_overview_user"

        # Support HTMX: if this is an HX request, return the fragment HTML for the
        # destination question instead of issuing a full-page redirect. This lets
        # the client swap only the question fragment.
        is_htmx = request.headers.get("HX-Request") == "true" or request.META.get("HTTP_HX_REQUEST")

        if "next" in request.POST:
            if not next:
                return HttpResponseBadRequest("Invalid request")
            if is_htmx:
                return exam_take_fragment(request, pk=pk, sk=pos + 1, cid=cid, passcode=passcode)
            return redirect(take_url, sk=pos + 1, **kwargs)
        elif "previous" in request.POST:
            if is_htmx:
                return exam_take_fragment(request, pk=pk, sk=pos - 1, cid=cid, passcode=passcode)
            return redirect(take_url, sk=pos - 1, **kwargs)
        elif "finish" in request.POST:
            # The overview is a full page. For HTMX clients, instruct the
            # browser to perform a full navigation by returning an HX-Redirect
            # header (htmx will set window.location). For non-HTMX clients,
            # perform a normal redirect.
            if is_htmx:
                try:
                    url = reverse(finish_url, kwargs=kwargs)
                except Exception:
                    # Fallback: build a simple redirect response
                    resp = HttpResponse()
                    resp["HX-Redirect"] = ""
                    return resp
                resp = HttpResponse()
                resp["HX-Redirect"] = url
                return resp
            return redirect(finish_url, **kwargs)
        elif "save" in request.POST:
            # Save action should not trigger a full-page load for HTMX clients.
            if is_htmx:
                # Re-render the same fragment so the client can swap it in-place
                # and set an HX-Trigger header so the client can show a confirmation
                resp = exam_take_fragment(request, pk=pk, sk=pos, cid=cid, passcode=passcode)
                try:
                    # Use a small JSON payload so client listeners can access details if needed
                    resp['HX-Trigger'] = json.dumps({'saved': True})
                except Exception:
                    # Fallback to a simple event name
                    resp['HX-Trigger'] = 'saved'
                return resp
            # Non-HTMX clients get redirected back to the same question page
            return redirect(take_url, sk=pos, **kwargs)
        elif "goto" in request.POST:
            dest = int(request.POST.get("goto"))
            if is_htmx:
                return exam_take_fragment(request, pk=pk, sk=dest, cid=cid, passcode=passcode)
            return redirect(take_url, sk=dest, **kwargs)
        # For HTMX POSTs that don't match a navigation action (eg validation errors),
        # return the fragment so the client receives only the partial HTML.
        if is_htmx:
            return exam_take_fragment(request, pk=pk, sk=pos, cid=cid, passcode=passcode)
    else:
        form = UserAnswerForm(instance=answer)

    saved_answer = False
    if answer is not None:
        saved_answer = [answer.a, answer.b, answer.c, answer.d, answer.e]

    # compute flagged state for this question+actor
    try:
        ct = ContentType.objects.get_for_model(question)
        flags_qs = Flag.objects.filter(content_type=ct, object_id=question.pk)
        if cid is not None:
            flags_qs = flags_qs.filter(cid_user__cid=cid)
        else:
            flags_qs = flags_qs.filter(user=request.user)
        flagged = flags_qs.exists()
    except Exception:
        flagged = False

    # Precompute answered and flagged sets in bulk to avoid repeated queries
    q_ids = [q.pk for q in questions]
    if cid is not None:
        answered_set = set(UserAnswer.objects.filter(cid=cid, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
        flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, cid_user__cid=cid).values_list('object_id', flat=True))
    else:
        answered_set = set(UserAnswer.objects.filter(user=request.user, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
        flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, user=request.user).values_list('object_id', flat=True))

    return render(
        request,
        "physics/exam_take.html",
        {
            "form": form,
            "cid": cid,
            "exam": exam,
            "question": question,
            "next": next,
            "previous": previous,
            "exam_length": exam_length,
            "pos": pos,
            "saved_answer": saved_answer,
            "passcode": passcode,
            "cid_user_exam": cid_user_exam,
            "flagged": flagged,
            # minimal per-question status for client-side menu: answered/flagged lists
            # compute answered/flagged sets in bulk to avoid N+1 queries
            "answered_json": json.dumps([q.pk in answered_set for q in questions]),
            "flagged_json": json.dumps([q.pk in flagged_set for q in questions]),
        },
    )


def exam_take_fragment(request, pk: int, sk: int, cid: str | None = None, passcode: str | None = None):
    """Return an HTMX partial containing the question form and navigation controls.

    This view mirrors the GET rendering logic of `exam_take` but returns a fragment
    suitable for being loaded via HTMX into the page. It intentionally does not
    process POSTs (the main `exam_take` endpoint handles form submissions and
    redirects) to keep fragment logic focused on rendering.
    """

    exam = get_object_or_404(Exam, pk=pk)

    if not exam.active:
        return exam_inactive(request, context={"exam": exam})

    exam.check_user_can_take(cid, passcode, request.user)

    cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)

    # Canonical questions list for consistent indexing/navigation
    questions = list(exam.get_questions())

    try:
        index = int(sk)
    except Exception:
        raise Http404("Invalid question index")

    if index < 0 or index >= len(questions):
        raise Http404("Question not found in exam")

    question = questions[index]
    exam_length = len(questions)
    pos = index

    if cid is not None:
        answer = question.cid_user_answers.filter(cid=cid, exam=exam).first()
    else:
        answer = question.cid_user_answers.filter(user=request.user, exam=exam).first()

    if answer is not None:
        form = UserAnswerForm(instance=answer)
        saved_answer = [answer.a, answer.b, answer.c, answer.d, answer.e]
    else:
        form = UserAnswerForm()
        saved_answer = False

    # compute flagged state for this question+actor so fragment renders correctly
    try:
        ct = ContentType.objects.get_for_model(question)
        flags_qs = Flag.objects.filter(content_type=ct, object_id=question.pk)
        if cid is not None:
            flags_qs = flags_qs.filter(cid_user__cid=cid)
        else:
            flags_qs = flags_qs.filter(user=request.user)
        flagged = flags_qs.exists()
    except Exception:
        flagged = False

    previous = -1
    if sk > 0:
        previous = sk - 1
    next = sk + 1
    if sk == exam_length - 1:
        next = False

    # Bulk compute answered and flagged sets for the fragment
    q_ids = [q.pk for q in questions]
    if cid is not None:
        frag_answered_set = set(UserAnswer.objects.filter(cid=cid, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
        frag_flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, cid_user__cid=cid).values_list('object_id', flat=True))
    else:
        frag_answered_set = set(UserAnswer.objects.filter(user=request.user, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
        frag_flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, user=request.user).values_list('object_id', flat=True))

    return render(
        request,
        "physics/partials/exam_take_fragment.html",
        {
            "form": form,
            "cid": cid,
            "exam": exam,
            "question": question,
            "next": next,
            "previous": previous,
            "exam_length": exam_length,
            "pos": pos,
                "saved_answer": saved_answer,
                "answer": answer,
                "flagged": flagged,
                "answered_json": json.dumps([q.pk in frag_answered_set for q in questions]),
                "flagged_json": json.dumps([q.pk in frag_flagged_set for q in questions]),
            "passcode": passcode,
            "cid_user_exam": cid_user_exam,
        },
    )


#@login_required
def exam_toggle_flag(request, pk: int, sk: int, cid: str | None = None, passcode: str | None = None):
    """Toggle the flagged state for the current user's answer to a question.

    Returns the small flag-button partial so HTMX clients can swap it in-place.
    """
    exam = get_object_or_404(Exam, pk=pk)

    if not exam.active:
        return exam_inactive(request, context={"exam": exam})

    exam.check_user_can_take(cid, passcode, request.user)

    # canonical questions list
    questions = list(exam.get_questions())
    try:
        index = int(sk)
    except Exception:
        raise Http404("Invalid question index")

    if index < 0 or index >= len(questions):
        raise Http404("Question not found in exam")

    question = questions[index]

    # Determine actor: either a CidUser (for cid flows) or the logged-in user
    cid_user_obj = None
    if cid is not None:
        try:
            cid_user_obj = CidUser.objects.filter(cid=cid).first()
        except Exception:
            cid_user_obj = None

    # Find existing flag for this question+actor
    ct = ContentType.objects.get_for_model(question)
    flags_qs = Flag.objects.filter(content_type=ct, object_id=question.pk)
    if cid_user_obj is not None:
        flags_qs = flags_qs.filter(cid_user=cid_user_obj)
    else:
        flags_qs = flags_qs.filter(user=request.user)

    flagged = flags_qs.exists()

    if request.method == "POST":
        # set param explicitly controls state; otherwise toggle
        set_val = request.POST.get("set")
        if set_val is None:
            # toggle
            if flagged:
                flags_qs.delete()
                flagged = False
            else:
                Flag.objects.create(content_type=ct, object_id=question.pk, user=(None if cid_user_obj else request.user), cid_user=cid_user_obj)
                flagged = True
        else:
            desired = str(set_val).lower() in ("1", "true", "yes")
            if desired and not flagged:
                Flag.objects.create(content_type=ct, object_id=question.pk, user=(None if cid_user_obj else request.user), cid_user=cid_user_obj)
                flagged = True
            elif not desired and flagged:
                flags_qs.delete()
                flagged = False

    # Render the partial button for the current state
    return render(request, "physics/partials/exam_flag_button.html", {"flagged": flagged, "exam": exam, "pos": index, "cid": cid, "passcode": passcode})


# def loadJsonAnswer(answer):
#    # As access is not restricted make sure the data appears valid
#    if (not isinstance(answer["cid"], int)) or (not isinstance(answer["eid"], int)):
#        return False, JsonResponse({"success": False, "error": "invalid"})
#
#    # The model should catch invalid data but this should be less intensive
#    max_int = 2147483647
#    if answer["cid"] > max_int or answer["eid"] > max_int:
#        return False, JsonResponse({"success": False, "error": "invalid candidate id"})
#
#    exam = get_object_or_404(Exam, pk=answer["eid"])
#
#    # if not exam.active:
#    #    return False, JsonResponse(
#    #        {"success": False, "error": "No active exam: {}".format(answer["eid"])}
#    #    )
#
#    questions = defaultdict(dict)
#
#    for q, n, a in answer["ans"]:
#        questions[q][n] = a
#        pass
#
#    for qid in questions:
#        exiting_answers = UserAnswer.objects.filter(
#            question__id=qid, exam__id=answer["eid"], cid=answer["cid"]
#        )
#
#        a = questions[qid]["a"]
#        b = questions[qid]["b"]
#        c = questions[qid]["c"]
#        d = questions[qid]["d"]
#        e = questions[qid]["e"]
#
#        if not exiting_answers:
#            ans = UserAnswer(a=a, b=b, c=c, d=d, e=e, cid=answer["cid"])
#            ans.question_id = qid
#            ans.exam_id = answer["eid"]
#
#            ans.full_clean()
#
#            ans.save()
#        else:
#            # Update an existing answer
#            # should never be more than one (famous last words)
#            ans = exiting_answers[0]
#            ans.a = a
#            ans.b = b
#            ans.c = c
#            ans.d = d
#            ans.e = e
#            ans.full_clean()
#            ans.save()
#
#    return True, None


GenericExamViews = ExamViews(Exam, Question, None, UserAnswer, "physics", "physics")

GenericViews = GenericViewBase("physics", Question, UserAnswer, Exam)


class ExamCreate(ExamCreateBase):
    model = Exam
    form_class = ExamForm


class ExamUpdate(ExamUpdateBase, AuthorOrCheckerRequiredMixin):
    model = Exam
    form_class = ExamForm


class ExamDelete(AuthorOrCheckerRequiredMixin, ExamDeleteBase):
    model = Exam


class ExamClone(AuthorOrCheckerRequiredMixin, ExamCloneMixin, ExamCreate):
    """Clone exam view"""


class ExamAuthorUpdate(
    RevisionMixin, CheckCanEditMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView
):
    model = Exam
    form_class = ExamAuthorForm
    template_name = "author_form.html"

class ExamMarkersUpdate(
    RevisionMixin, CheckCanEditMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView
):
    model = Exam
    form_class = ExamMarkerForm

    template_name = "markers_form.html"


class ExamGroupsUpdate(ExamGroupsUpdateBase):
    model = Exam
    form_class = ExamGroupsForm

class QuestionView(
    LoginRequiredMixin, SingleTableMixin, FilterView, AuthorOrCheckerRequiredMixin
):
    model = Question
    table_class = QuestionTable
    template_name = "question_table_view.html"

    filterset_class = QuestionFilter

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["app_name"] = "physics"
        return context


class QuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
    model = Question
    form_class = QuestionForm

    def get_form_kwargs(self):
        kwargs = super(QuestionCreateBase, self).get_form_kwargs()
        kwargs.update({"user": self.request.user})
        return kwargs

    def form_valid(self, form):
        self.object = form.save(commit=False)
        self.object.save()

        # add the current user as an author
        form.instance.author.add(self.request.user.id)

        response = super().form_valid(form)
        return response


class QuestionCreate(QuestionCreateBase):
    def get_initial(self):
        if "pk" in self.kwargs:
            initial = super().get_initial()
            exam = get_object_or_404(Exam, pk=self.kwargs["pk"])
            # When creating a question from an exam context we don't have an
            # 'exams' field on the ModelForm (exams is a reverse relation).
            # Store nothing in the initial data here; attachment is handled
            # in form_valid below.
            return initial

    def form_valid(self, form):
        # Let the base class save the object and set the author
        response = super().form_valid(form)

        # If the create URL included an exam pk, add the new question to that exam
        if "pk" in self.kwargs:
            try:
                exam = get_object_or_404(Exam, pk=self.kwargs["pk"])
                # exam.exam_questions is a ManyToMany through relation; add is fine
                exam.exam_questions.add(self.object)
            except Exception:
                # Don't fail the whole request if attaching to the exam fails;
                # the question has already been created and author set.
                pass

        return response


class UserAnswerView(AuthorOrCheckerRequiredMixin, LoginRequiredMixin, DetailView):
    model = UserAnswer


class UserAnswerTableView(
    AuthorOrCheckerRequiredMixin, LoginRequiredMixin, SingleTableMixin, FilterView
):
    model = UserAnswer
    table_class = UserAnswerTable
    template_name = "physics/user_answer_question_view.html"

    filterset_class = UserAnswerFilter


class UserAnswerDelete(SuperuserRequiredMixin, DeleteView):
    model = UserAnswer
    template_name = "user_answer_delete.html"
    success_url = reverse_lazy("physics:user_answer_table_view")


@login_required
def exam_review_question_responses(request, pk: int, q_index: int):
    """Return the responses partial for a given exam question (HTMX-friendly)."""
    exam = get_object_or_404(Exam, pk=pk)

    questions = exam.get_questions()
    try:
        question = questions[q_index]
    except Exception:
        raise Http404("Question not found in exam")

    context = {
        "exam": exam,
        "question": question,
        "q_index": q_index,
        "app_name": "physics",
    }

    # Prefilter user answers for this exam to avoid template-side filtering
    try:
        exam_user_answers = question.cid_user_answers.filter(exam=exam).select_related("user")
    except Exception:
        exam_user_answers = question.cid_user_answers.all()

    # Default: anonymise unless reveal=1 is provided
    reveal_flag = str(request.GET.get("reveal", "")).lower() in ("1", "true", "on")
    context["reveal_respondents"] = reveal_flag
    context["exam_user_answers"] = exam_user_answers

    return render(request, "physics/partials/exam_review_question_responses_fragment.html", context)


@login_required
def exam_review_question_summary(request, pk: int, q_index: int):
    """Return the aggregated response summary partial for a given exam question (HTMX-friendly)."""
    exam = get_object_or_404(Exam, pk=pk)

    questions = exam.get_questions()
    try:
        question = questions[q_index]
    except Exception:
        raise Http404("Question not found in exam")

    # Aggregate user answers for this question & exam
    ua_qs = question.cid_user_answers.filter(exam=exam)
    total_responses = ua_qs.count()

    # Physics answers may be stored as tuples/lists or named fields; attempt to aggregate
    counts = defaultdict(int)
    other_count = 0
    # Try simple 'answer' field aggregation first; if not present, fall
    # back to iterating objects and using get_answer() or composing parts.
    try:
        for ans in ua_qs.values_list("answer", flat=True):
            if not ans:
                other_count += 1
                continue
            if isinstance(ans, (list, tuple)):
                key = ", ".join([str(x) for x in ans])
            else:
                key = str(ans)
            counts[key] += 1
    except Exception:
        for ua in ua_qs:
            ans_val = None
            try:
                if hasattr(ua, "get_answer"):
                    ans_val = ua.get_answer()
            except Exception:
                ans_val = None

            if not ans_val:
                parts = []
                for fld in ("a", "b", "c", "d", "e"):
                    if hasattr(ua, fld):
                        v = getattr(ua, fld)
                        if v is None:
                            continue
                        if isinstance(v, (list, tuple)):
                            parts.extend([str(x) for x in v])
                        else:
                            parts.append(str(v))

                if parts:
                    ans_val = ", ".join(parts)

            if not ans_val:
                other_count += 1
                continue

            counts[ans_val] += 1

    counts["unanswered"] = other_count

    pcts = {}
    if total_responses:
        for k, v in counts.items():
            pcts[k] = round(100.0 * v / total_responses, 1)
    else:
        for k in counts.keys():
            pcts[k] = 0.0

    # Provide an explicit iterable of structured items for templates to consume.
    # Each item includes the original key, count, pct and optional per-part
    # breakdown (list of {value, is_correct}) for physics multi-part answers.
    exam_response_items = []
    parts_names = ["a", "b", "c", "d", "e"]
    for k, v in sorted(counts.items(), key=lambda x: x[1], reverse=True):
        item = {"key": k, "count": v, "pct": pcts.get(k, 0.0), "parts": None}
        # Attempt to parse comma-separated boolean-like keys into parts
        try:
            if isinstance(k, str) and "," in k:
                parts_raw = [p.strip() for p in k.split(",")]
                if len(parts_raw) == 5:
                    parts_details = []
                    for idx, sval in enumerate(parts_raw):
                        sval_l = sval.lower()
                        if sval_l in ("true", "t", "1", "yes"):
                            val = True
                        elif sval_l in ("false", "f", "0", "no"):
                            val = False
                        elif sval_l in ("none", "", "not answered", "not_answered"):
                            val = None
                        else:
                            # leave as string fallback
                            val = sval

                        # determine correctness where possible
                        is_correct = None
                        try:
                            correct_val = getattr(question, f"{parts_names[idx]}_answer")
                            if isinstance(val, bool) and isinstance(correct_val, bool):
                                is_correct = val == correct_val
                        except Exception:
                            is_correct = None

                        parts_details.append({"value": val, "is_correct": is_correct})

                    item["parts"] = parts_details
        except Exception:
            item["parts"] = None

        exam_response_items.append(item)

    # Compute percent correct using per-answer scoring
    correct_count = None
    correct_pct = None
    try:
        # For physics, count true/false sub-questions individually rather than
        # requiring a full-match across all parts.
        if total_responses:
            correct_subitems = 0
            total_subitems = 0
            for ua in ua_qs:
                try:
                    score = ua.get_answer_score()
                except Exception:
                    continue
                if isinstance(score, (list, tuple)) and score:
                    for s in score:
                        if isinstance(s, (int, float)) and s > 0:
                            correct_subitems += 1
                    total_subitems += len(score)
                else:
                    if isinstance(score, (int, float)):
                        if score > 0:
                            correct_subitems += 1
                        total_subitems += 1

            correct_count = correct_subitems
            if total_subitems:
                correct_pct = round(100.0 * correct_subitems / total_subitems, 1)
            else:
                correct_pct = 0.0
        else:
            correct_count = 0
            correct_pct = 0.0
    except Exception:
        correct_count = None
        correct_pct = None

    # Per-part (a-e) true/false aggregation. Each physics question has 5 parts
    # stored as boolean fields on UserAnswer (a,b,c,d,e). Build counts and
    # percentages for each part so templates can render them as separate TF
    # sub-questions.
    per_part_stats = []
    try:
        parts = ["a", "b", "c", "d", "e"]
        # initialize counters
        for idx, part in enumerate(parts):
            per_part_stats.append({
                "part": part,
                "label": getattr(question, part).strip() if getattr(question, part, None) else f"Part {idx+1}",
                "true_count": 0,
                "false_count": 0,
                "total": 0,
                "correct_count": 0,
                "correct_pct": 0.0,
                "pct_true": 0.0,
            })

        for ua in ua_qs:
            for idx, part in enumerate(parts):
                try:
                    val = getattr(ua, part)
                except Exception:
                    continue
                if val is True:
                    per_part_stats[idx]["true_count"] += 1
                elif val is False:
                    per_part_stats[idx]["false_count"] += 1
                # count only explicit booleans as total
                if isinstance(val, bool):
                    per_part_stats[idx]["total"] += 1

                # correct counting
                try:
                    correct_val = getattr(question, f"{part}_answer")
                except Exception:
                    correct_val = None
                if isinstance(val, bool) and isinstance(correct_val, bool):
                    if val == correct_val:
                        per_part_stats[idx]["correct_count"] += 1

        # compute percentages
        for stats in per_part_stats:
            if stats["total"]:
                stats["pct_true"] = round(100.0 * stats["true_count"] / stats["total"], 1)
                stats["correct_pct"] = round(100.0 * stats["correct_count"] / stats["total"], 1)
            else:
                stats["pct_true"] = 0.0
                stats["correct_pct"] = 0.0
    except Exception:
        per_part_stats = []

    # Provide a best-effort correct answer display
    correct_answer = None
    try:
        correct_answer = question.get_answers()
        if isinstance(correct_answer, (list, tuple)):
            correct_answer = ", ".join([str(x) for x in correct_answer])
        else:
            correct_answer = str(correct_answer)
    except Exception:
        correct_answer = None

    context = {
        "exam": exam,
        "question": question,
        "q_index": q_index,
        "app_name": "physics",
    "exam_response_counts": counts,
    "exam_response_items": exam_response_items,
    "exam_response_pcts": pcts,
        "exam_response_total": total_responses,
        "exam_response_correct_count": correct_count,
        "exam_response_correct_pct": correct_pct,
        "exam_response_texts": {},
        "correct_answer": correct_answer,
        "per_part_stats": per_part_stats,
    }

    return render(request, "physics/partials/exam_review_question_summary_fragment.html", context)
